Skip to content

Commit 5142da0

Browse files
committed
feat: configurable options for the project up command
1 parent d89541c commit 5142da0

39 files changed

+1523
-522
lines changed

backend/internal/huma/handlers/projects.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ type GetProjectStatusCountsOutput struct {
5959
type DeployProjectInput struct {
6060
EnvironmentID string `path:"id" doc:"Environment ID"`
6161
ProjectID string `path:"projectId" doc:"Project ID"`
62+
Body *project.UpRequest
6263
}
6364

6465
type DeployProjectOutput struct {
@@ -404,6 +405,24 @@ func (h *ProjectHandler) DeployProject(ctx context.Context, input *DeployProject
404405
return nil, huma.Error400BadRequest((&common.ProjectIDRequiredError{}).Error())
405406
}
406407

408+
deployOptions := &project.UpRequest{
409+
PullPolicy: project.UpPullPolicyMissing,
410+
ForceRecreate: false,
411+
}
412+
413+
if input.Body != nil {
414+
deployOptions.ForceRecreate = input.Body.ForceRecreate
415+
416+
switch input.Body.PullPolicy {
417+
case "", project.UpPullPolicyMissing:
418+
deployOptions.PullPolicy = project.UpPullPolicyMissing
419+
case project.UpPullPolicyAlways:
420+
deployOptions.PullPolicy = project.UpPullPolicyAlways
421+
default:
422+
return nil, huma.Error400BadRequest("invalid pullPolicy, expected one of: missing, always")
423+
}
424+
}
425+
407426
user, exists := humamw.GetCurrentUserFromContext(ctx)
408427
if !exists {
409428
return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error())
@@ -424,7 +443,7 @@ func (h *ProjectHandler) DeployProject(ctx context.Context, input *DeployProject
424443
}
425444

426445
deployCtx := context.WithValue(humaCtx.Context(), projects.ProgressWriterKey{}, writer)
427-
if err := h.projectService.DeployProject(deployCtx, input.ProjectID, *user); err != nil {
446+
if err := h.projectService.DeployProjectWithOptions(deployCtx, input.ProjectID, *user, deployOptions); err != nil {
428447
_, _ = fmt.Fprintf(writer, `{"error":%q}`+"\n", err.Error())
429448
if f, ok := writer.(http.Flusher); ok {
430449
f.Flush()

backend/internal/models/settings.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,12 @@ type Settings struct {
112112
OidcProviderLogoUrl SettingVariable `key:"oidcProviderLogoUrl,public,envOverride" meta:"label=OIDC Provider Logo URL;type=text;keywords=oidc,provider,logo,url,image,icon,sso;category=security;description=Custom logo URL for the OIDC provider"`
113113

114114
// Appearance category
115-
MobileNavigationMode SettingVariable `key:"mobileNavigationMode,public,local" meta:"label=Mobile Navigation Mode;type=select;keywords=mode,style,type,floating,docked,position,layout,design,appearance,bottom;category=appearance;description=Choose between floating or docked navigation on mobile" catmeta:"id=appearance;title=Appearance;icon=appearance;url=/settings/appearance;description=Customize navigation, theme, and interface behavior"`
116-
MobileNavigationShowLabels SettingVariable `key:"mobileNavigationShowLabels,public,local" meta:"label=Show Navigation Labels;type=boolean;keywords=labels,text,icons,display,show,hide,names,captions,titles,visible,toggle;category=appearance;description=Display text labels alongside navigation icons"`
117-
SidebarHoverExpansion SettingVariable `key:"sidebarHoverExpansion,public,local" meta:"label=Sidebar Hover Expansion;type=boolean;keywords=sidebar,hover,expansion,expand,desktop,mouse,over,collapsed,collapsible,icon,labels,text,preview,peek,tooltip,overlay,temporary,quick,access,navigation,menu,items,submenu,nested;category=appearance;description=Expand sidebar on hover in desktop mode"`
118-
KeyboardShortcutsEnabled SettingVariable `key:"keyboardShortcutsEnabled,public,local" meta:"label=Keyboard Shortcuts;type=boolean;keywords=keyboard,shortcuts,hotkeys,keybindings,navigation,tooltips,disable;category=appearance;description=Enable keyboard shortcuts for navigation and show shortcut hints in tooltips"`
115+
MobileNavigationMode SettingVariable `key:"mobileNavigationMode,public,local" meta:"label=Mobile Navigation Mode;type=select;keywords=mode,style,type,floating,docked,position,layout,design,appearance,bottom;category=appearance;description=Choose between floating or docked navigation on mobile" catmeta:"id=appearance;title=Appearance;icon=appearance;url=/settings/appearance;description=Customize navigation, theme, and interface behavior"`
116+
MobileNavigationShowLabels SettingVariable `key:"mobileNavigationShowLabels,public,local" meta:"label=Show Navigation Labels;type=boolean;keywords=labels,text,icons,display,show,hide,names,captions,titles,visible,toggle;category=appearance;description=Display text labels alongside navigation icons"`
117+
SidebarHoverExpansion SettingVariable `key:"sidebarHoverExpansion,public,local" meta:"label=Sidebar Hover Expansion;type=boolean;keywords=sidebar,hover,expansion,expand,desktop,mouse,over,collapsed,collapsible,icon,labels,text,preview,peek,tooltip,overlay,temporary,quick,access,navigation,menu,items,submenu,nested;category=appearance;description=Expand sidebar on hover in desktop mode"`
118+
KeyboardShortcutsEnabled SettingVariable `key:"keyboardShortcutsEnabled,public,local" meta:"label=Keyboard Shortcuts;type=boolean;keywords=keyboard,shortcuts,hotkeys,keybindings,navigation,tooltips,disable;category=appearance;description=Enable keyboard shortcuts for navigation and show shortcut hints in tooltips"`
119+
ProjectUpDefaultPullPolicy SettingVariable `key:"projectUpDefaultPullPolicy,public" meta:"label=Project Up Default Pull Policy;type=select;keywords=project,compose,up,pull,policy,default,images;category=appearance;description=Default image pull policy for project Up actions (missing or always)"`
120+
ProjectUpDefaultForceRecreate SettingVariable `key:"projectUpDefaultForceRecreate,public" meta:"label=Project Up Default Force Recreate;type=boolean;keywords=project,compose,up,force,recreate,default;category=appearance;description=Default force-recreate behavior for project Up actions"`
119121

120122
// Notifications category (placeholder for category metadata only - actual settings managed via notification service)
121123
NotificationsCategoryPlaceholder SettingVariable `key:"notificationsCategory,internal" meta:"label=Notifications;type=internal;keywords=notifications,alerts,email,discord,webhooks,events,messages;category=notifications;description=Configure notification providers and alerts" catmeta:"id=notifications;title=Notifications;icon=bell;url=/settings/notifications;description=Configure email and Discord notifications for container and image updates"`

backend/internal/services/project_service.go

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -757,7 +757,73 @@ func (s *ProjectService) GetProjectStatusCounts(ctx context.Context) (folderCoun
757757

758758
// Project Actions
759759

760+
type deployPullPolicy string
761+
762+
const (
763+
deployPullPolicyMissing deployPullPolicy = "missing"
764+
deployPullPolicyAlways deployPullPolicy = "always"
765+
)
766+
767+
type deployOptions struct {
768+
pullPolicy deployPullPolicy
769+
forceRecreate bool
770+
}
771+
772+
func defaultDeployOptions() deployOptions {
773+
return deployOptions{
774+
pullPolicy: deployPullPolicyMissing,
775+
forceRecreate: false,
776+
}
777+
}
778+
779+
func normalizeDeployOptions(options *project.UpRequest) (deployOptions, error) {
780+
normalized := defaultDeployOptions()
781+
if options == nil {
782+
return normalized, nil
783+
}
784+
785+
switch options.PullPolicy {
786+
case "", project.UpPullPolicyMissing:
787+
normalized.pullPolicy = deployPullPolicyMissing
788+
case project.UpPullPolicyAlways:
789+
normalized.pullPolicy = deployPullPolicyAlways
790+
default:
791+
return deployOptions{}, fmt.Errorf("invalid pull policy: %q", options.PullPolicy)
792+
}
793+
794+
normalized.forceRecreate = options.ForceRecreate
795+
796+
return normalized, nil
797+
}
798+
799+
func hasComposePullPolicyOverride(composeProject *composetypes.Project) bool {
800+
if composeProject == nil {
801+
return false
802+
}
803+
804+
for _, service := range composeProject.Services {
805+
if strings.TrimSpace(string(service.PullPolicy)) != "" {
806+
return true
807+
}
808+
}
809+
810+
return false
811+
}
812+
760813
func (s *ProjectService) DeployProject(ctx context.Context, projectID string, user models.User) error {
814+
return s.DeployProjectWithOptions(ctx, projectID, user, nil)
815+
}
816+
817+
func (s *ProjectService) DeployProjectWithOptions(ctx context.Context, projectID string, user models.User, options *project.UpRequest) error {
818+
normalizedOptions, err := normalizeDeployOptions(options)
819+
if err != nil {
820+
return err
821+
}
822+
823+
return s.deployProject(ctx, projectID, user, normalizedOptions)
824+
}
825+
826+
func (s *ProjectService) deployProject(ctx context.Context, projectID string, user models.User, options deployOptions) error {
761827
projectFromDb, err := s.GetProjectFromDatabaseByID(ctx, projectID)
762828
if err != nil {
763829
return fmt.Errorf("failed to get project: %w", err)
@@ -796,15 +862,30 @@ func (s *ProjectService) DeployProject(ctx context.Context, projectID string, us
796862
progressWriter = io.Discard
797863
}
798864

799-
if perr := s.EnsureProjectImagesPresent(ctx, projectID, progressWriter, user, nil); perr != nil {
800-
slog.Warn("ensure images present failed (continuing to compose up)", "projectID", projectID, "error", perr)
865+
if hasComposePullPolicyOverride(project) {
866+
slog.Info("compose pull_policy detected; skipping Arcane pre-pull behavior", "projectID", projectID, "projectName", project.Name)
867+
} else {
868+
switch options.pullPolicy {
869+
case deployPullPolicyAlways:
870+
if perr := s.PullProjectImages(ctx, projectID, progressWriter, user, nil); perr != nil {
871+
slog.Warn("pull project images failed (continuing to compose up)", "projectID", projectID, "error", perr)
872+
}
873+
default:
874+
if perr := s.EnsureProjectImagesPresent(ctx, projectID, progressWriter, user, nil); perr != nil {
875+
slog.Warn("ensure images present failed (continuing to compose up)", "projectID", projectID, "error", perr)
876+
}
877+
}
801878
}
802879

803880
removeOrphans := projectFromDb.GitOpsManagedBy != nil && *projectFromDb.GitOpsManagedBy != ""
881+
composeUpOptions := projects.ComposeUpOptions{
882+
RemoveOrphans: removeOrphans,
883+
ForceRecreate: options.forceRecreate,
884+
}
804885

805-
slog.Info("starting compose up with health check support", "projectID", projectID, "projectName", project.Name, "services", len(project.Services), "removeOrphans", removeOrphans)
886+
slog.Info("starting compose up with health check support", "projectID", projectID, "projectName", project.Name, "services", len(project.Services), "removeOrphans", removeOrphans, "pullPolicy", options.pullPolicy, "forceRecreate", options.forceRecreate)
806887
// Health/progress streaming (if any) is handled inside projects.ComposeUp via ctx.
807-
if err := projects.ComposeUp(ctx, project, nil, removeOrphans); err != nil {
888+
if err := projects.ComposeUp(ctx, project, nil, composeUpOptions); err != nil {
808889
slog.Error("compose up failed", "projectName", project.Name, "projectID", projectID, "error", err)
809890
if containers, psErr := s.GetProjectServices(ctx, projectID); psErr == nil {
810891
slog.Info("containers after failed deploy", "projectID", projectID, "containers", containers)

backend/internal/services/project_service_test.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616

1717
"github.com/getarcaneapp/arcane/backend/internal/database"
1818
"github.com/getarcaneapp/arcane/backend/internal/models"
19+
projecttypes "github.com/getarcaneapp/arcane/types/project"
1920
)
2021

2122
func setupProjectTestDB(t *testing.T) *database.DB {
@@ -476,3 +477,126 @@ func TestProjectService_UpdateProject_RenameFailsWhenProjectRunning(t *testing.T
476477
require.NotNil(t, fromDB.DirName)
477478
assert.Equal(t, "Foo", *fromDB.DirName)
478479
}
480+
481+
func TestNormalizeDeployOptions(t *testing.T) {
482+
tests := []struct {
483+
name string
484+
input *projecttypes.UpRequest
485+
wantPullPolicy deployPullPolicy
486+
wantForceCreate bool
487+
wantError bool
488+
wantErrorContain string
489+
}{
490+
{
491+
name: "nil uses defaults",
492+
input: nil,
493+
wantPullPolicy: deployPullPolicyMissing,
494+
wantForceCreate: false,
495+
},
496+
{
497+
name: "empty pull policy uses default",
498+
input: &projecttypes.UpRequest{
499+
PullPolicy: "",
500+
},
501+
wantPullPolicy: deployPullPolicyMissing,
502+
wantForceCreate: false,
503+
},
504+
{
505+
name: "missing pull policy",
506+
input: &projecttypes.UpRequest{
507+
PullPolicy: projecttypes.UpPullPolicyMissing,
508+
},
509+
wantPullPolicy: deployPullPolicyMissing,
510+
wantForceCreate: false,
511+
},
512+
{
513+
name: "always with force recreate",
514+
input: &projecttypes.UpRequest{
515+
PullPolicy: projecttypes.UpPullPolicyAlways,
516+
ForceRecreate: true,
517+
},
518+
wantPullPolicy: deployPullPolicyAlways,
519+
wantForceCreate: true,
520+
},
521+
{
522+
name: "invalid pull policy",
523+
input: &projecttypes.UpRequest{
524+
PullPolicy: "latest",
525+
},
526+
wantError: true,
527+
wantErrorContain: "invalid pull policy",
528+
},
529+
}
530+
531+
for _, tt := range tests {
532+
t.Run(tt.name, func(t *testing.T) {
533+
got, err := normalizeDeployOptions(tt.input)
534+
if tt.wantError {
535+
require.Error(t, err)
536+
if tt.wantErrorContain != "" {
537+
assert.Contains(t, err.Error(), tt.wantErrorContain)
538+
}
539+
return
540+
}
541+
542+
require.NoError(t, err)
543+
assert.Equal(t, tt.wantPullPolicy, got.pullPolicy)
544+
assert.Equal(t, tt.wantForceCreate, got.forceRecreate)
545+
})
546+
}
547+
}
548+
549+
func TestHasComposePullPolicyOverride(t *testing.T) {
550+
tests := []struct {
551+
name string
552+
proj *composetypes.Project
553+
want bool
554+
}{
555+
{
556+
name: "nil project",
557+
proj: nil,
558+
want: false,
559+
},
560+
{
561+
name: "no services",
562+
proj: &composetypes.Project{},
563+
want: false,
564+
},
565+
{
566+
name: "services without pull policy",
567+
proj: &composetypes.Project{
568+
Services: composetypes.Services{
569+
"web": {Name: "web", Image: "nginx:latest"},
570+
"db": {Name: "db", Image: "postgres:16"},
571+
},
572+
},
573+
want: false,
574+
},
575+
{
576+
name: "single service with pull policy",
577+
proj: &composetypes.Project{
578+
Services: composetypes.Services{
579+
"web": {Name: "web", Image: "nginx:latest", PullPolicy: "always"},
580+
},
581+
},
582+
want: true,
583+
},
584+
{
585+
name: "mixed services where one has pull policy",
586+
proj: &composetypes.Project{
587+
Services: composetypes.Services{
588+
"web": {Name: "web", Image: "nginx:latest"},
589+
"api": {Name: "api", Image: "ghcr.io/acme/api:latest", PullPolicy: "missing"},
590+
},
591+
},
592+
want: true,
593+
},
594+
}
595+
596+
for _, tt := range tests {
597+
t.Run(tt.name, func(t *testing.T) {
598+
got := hasComposePullPolicyOverride(tt.proj)
599+
assert.Equal(t, tt.want, got)
600+
})
601+
}
602+
}

backend/internal/services/settings_service.go

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -126,30 +126,32 @@ func (s *SettingsService) getDefaultSettings() *models.Settings {
126126
TrivyMemoryLimitMb: models.SettingVariable{Value: "0"},
127127
TrivyConcurrentScanContainers: models.SettingVariable{Value: "1"},
128128
// AuthOidcConfig DEPRECATED will be removed in a future release
129-
AuthOidcConfig: models.SettingVariable{Value: "{}"},
130-
OidcEnabled: models.SettingVariable{Value: "false"},
131-
OidcClientId: models.SettingVariable{Value: ""},
132-
OidcClientSecret: models.SettingVariable{Value: ""},
133-
OidcIssuerUrl: models.SettingVariable{Value: ""},
134-
OidcAuthorizationEndpoint: models.SettingVariable{Value: ""},
135-
OidcTokenEndpoint: models.SettingVariable{Value: ""},
136-
OidcUserinfoEndpoint: models.SettingVariable{Value: ""},
137-
OidcJwksEndpoint: models.SettingVariable{Value: ""},
138-
OidcScopes: models.SettingVariable{Value: "openid email profile"},
139-
OidcAdminClaim: models.SettingVariable{Value: ""},
140-
OidcAdminValue: models.SettingVariable{Value: ""},
141-
OidcSkipTlsVerify: models.SettingVariable{Value: "false"},
142-
OidcAutoRedirectToProvider: models.SettingVariable{Value: "false"},
143-
OidcMergeAccounts: models.SettingVariable{Value: "false"},
144-
OidcProviderName: models.SettingVariable{Value: ""},
145-
OidcProviderLogoUrl: models.SettingVariable{Value: ""},
146-
MobileNavigationMode: models.SettingVariable{Value: "floating"},
147-
MobileNavigationShowLabels: models.SettingVariable{Value: "true"},
148-
SidebarHoverExpansion: models.SettingVariable{Value: "true"},
149-
KeyboardShortcutsEnabled: models.SettingVariable{Value: "true"},
150-
AccentColor: models.SettingVariable{Value: "oklch(0.606 0.25 292.717)"},
151-
MaxImageUploadSize: models.SettingVariable{Value: "500"},
152-
EnvironmentHealthInterval: models.SettingVariable{Value: "0 */2 * * * *"},
129+
AuthOidcConfig: models.SettingVariable{Value: "{}"},
130+
OidcEnabled: models.SettingVariable{Value: "false"},
131+
OidcClientId: models.SettingVariable{Value: ""},
132+
OidcClientSecret: models.SettingVariable{Value: ""},
133+
OidcIssuerUrl: models.SettingVariable{Value: ""},
134+
OidcAuthorizationEndpoint: models.SettingVariable{Value: ""},
135+
OidcTokenEndpoint: models.SettingVariable{Value: ""},
136+
OidcUserinfoEndpoint: models.SettingVariable{Value: ""},
137+
OidcJwksEndpoint: models.SettingVariable{Value: ""},
138+
OidcScopes: models.SettingVariable{Value: "openid email profile"},
139+
OidcAdminClaim: models.SettingVariable{Value: ""},
140+
OidcAdminValue: models.SettingVariable{Value: ""},
141+
OidcSkipTlsVerify: models.SettingVariable{Value: "false"},
142+
OidcAutoRedirectToProvider: models.SettingVariable{Value: "false"},
143+
OidcMergeAccounts: models.SettingVariable{Value: "false"},
144+
OidcProviderName: models.SettingVariable{Value: ""},
145+
OidcProviderLogoUrl: models.SettingVariable{Value: ""},
146+
MobileNavigationMode: models.SettingVariable{Value: "floating"},
147+
MobileNavigationShowLabels: models.SettingVariable{Value: "true"},
148+
SidebarHoverExpansion: models.SettingVariable{Value: "true"},
149+
KeyboardShortcutsEnabled: models.SettingVariable{Value: "true"},
150+
ProjectUpDefaultPullPolicy: models.SettingVariable{Value: "missing"},
151+
ProjectUpDefaultForceRecreate: models.SettingVariable{Value: "false"},
152+
AccentColor: models.SettingVariable{Value: "oklch(0.606 0.25 292.717)"},
153+
MaxImageUploadSize: models.SettingVariable{Value: "500"},
154+
EnvironmentHealthInterval: models.SettingVariable{Value: "0 */2 * * * *"},
153155

154156
DockerAPITimeout: models.SettingVariable{Value: "30"},
155157
DockerImagePullTimeout: models.SettingVariable{Value: "600"},
@@ -553,6 +555,12 @@ func (s *SettingsService) prepareUpdateValues(updates settings.Update, cfg, defa
553555
value = fieldValue.Elem().String()
554556
}
555557

558+
if key == "projectUpDefaultPullPolicy" && value != "" {
559+
if value != "missing" && value != "always" {
560+
return nil, false, false, false, false, nil, fmt.Errorf("invalid value for %s: %s", key, value)
561+
}
562+
}
563+
556564
// Validate cron settings
557565
cronFields := []string{"scheduledPruneInterval", "autoUpdateInterval", "pollingInterval", "environmentHealthInterval", "eventCleanupInterval", "analyticsHeartbeatInterval", "vulnerabilityScanInterval", "gitopsSyncInterval", "autoHealInterval"}
558566
if slices.Contains(cronFields, key) && value != "" {

0 commit comments

Comments
 (0)