Skip to content

Commit 5aa2589

Browse files
committed
feat: configurable options for the project up command
1 parent 1967bc2 commit 5aa2589

39 files changed

+1514
-513
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
@@ -106,10 +106,12 @@ type Settings struct {
106106
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"`
107107

108108
// Appearance category
109-
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"`
110-
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"`
111-
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"`
112-
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"`
109+
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"`
110+
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"`
111+
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"`
112+
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"`
113+
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)"`
114+
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"`
113115

114116
// Notifications category (placeholder for category metadata only - actual settings managed via notification service)
115117
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: 88 additions & 7 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)
@@ -791,20 +857,35 @@ func (s *ProjectService) DeployProject(ctx context.Context, projectID string, us
791857
return fmt.Errorf("failed to update project status to deploying: %w", err)
792858
}
793859

794-
progressWriter, _ := ctx.Value(projects.ProgressWriterKey{}).(io.Writer)
795-
if progressWriter == nil {
796-
progressWriter = io.Discard
860+
progressWriter := io.Writer(io.Discard)
861+
if writer, ok := ctx.Value(projects.ProgressWriterKey{}).(io.Writer); ok && writer != nil {
862+
progressWriter = writer
797863
}
798864

799-
if perr := s.EnsureProjectImagesPresent(ctx, projectID, progressWriter, 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, 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, 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
@@ -14,6 +14,7 @@ import (
1414

1515
"github.com/getarcaneapp/arcane/backend/internal/database"
1616
"github.com/getarcaneapp/arcane/backend/internal/models"
17+
projecttypes "github.com/getarcaneapp/arcane/types/project"
1718
)
1819

1920
func setupProjectTestDB(t *testing.T) *database.DB {
@@ -334,3 +335,126 @@ func TestBuildProjectImagePullPlan(t *testing.T) {
334335
assert.Equal(t, imagePullModeAlways, plan["redis:latest"])
335336
assert.Equal(t, imagePullModeNever, plan["nginx:latest"])
336337
}
338+
339+
func TestNormalizeDeployOptions(t *testing.T) {
340+
tests := []struct {
341+
name string
342+
input *projecttypes.UpRequest
343+
wantPullPolicy deployPullPolicy
344+
wantForceCreate bool
345+
wantError bool
346+
wantErrorContain string
347+
}{
348+
{
349+
name: "nil uses defaults",
350+
input: nil,
351+
wantPullPolicy: deployPullPolicyMissing,
352+
wantForceCreate: false,
353+
},
354+
{
355+
name: "empty pull policy uses default",
356+
input: &projecttypes.UpRequest{
357+
PullPolicy: "",
358+
},
359+
wantPullPolicy: deployPullPolicyMissing,
360+
wantForceCreate: false,
361+
},
362+
{
363+
name: "missing pull policy",
364+
input: &projecttypes.UpRequest{
365+
PullPolicy: projecttypes.UpPullPolicyMissing,
366+
},
367+
wantPullPolicy: deployPullPolicyMissing,
368+
wantForceCreate: false,
369+
},
370+
{
371+
name: "always with force recreate",
372+
input: &projecttypes.UpRequest{
373+
PullPolicy: projecttypes.UpPullPolicyAlways,
374+
ForceRecreate: true,
375+
},
376+
wantPullPolicy: deployPullPolicyAlways,
377+
wantForceCreate: true,
378+
},
379+
{
380+
name: "invalid pull policy",
381+
input: &projecttypes.UpRequest{
382+
PullPolicy: "latest",
383+
},
384+
wantError: true,
385+
wantErrorContain: "invalid pull policy",
386+
},
387+
}
388+
389+
for _, tt := range tests {
390+
t.Run(tt.name, func(t *testing.T) {
391+
got, err := normalizeDeployOptions(tt.input)
392+
if tt.wantError {
393+
require.Error(t, err)
394+
if tt.wantErrorContain != "" {
395+
assert.Contains(t, err.Error(), tt.wantErrorContain)
396+
}
397+
return
398+
}
399+
400+
require.NoError(t, err)
401+
assert.Equal(t, tt.wantPullPolicy, got.pullPolicy)
402+
assert.Equal(t, tt.wantForceCreate, got.forceRecreate)
403+
})
404+
}
405+
}
406+
407+
func TestHasComposePullPolicyOverride(t *testing.T) {
408+
tests := []struct {
409+
name string
410+
proj *composetypes.Project
411+
want bool
412+
}{
413+
{
414+
name: "nil project",
415+
proj: nil,
416+
want: false,
417+
},
418+
{
419+
name: "no services",
420+
proj: &composetypes.Project{},
421+
want: false,
422+
},
423+
{
424+
name: "services without pull policy",
425+
proj: &composetypes.Project{
426+
Services: composetypes.Services{
427+
"web": {Name: "web", Image: "nginx:latest"},
428+
"db": {Name: "db", Image: "postgres:16"},
429+
},
430+
},
431+
want: false,
432+
},
433+
{
434+
name: "single service with pull policy",
435+
proj: &composetypes.Project{
436+
Services: composetypes.Services{
437+
"web": {Name: "web", Image: "nginx:latest", PullPolicy: "always"},
438+
},
439+
},
440+
want: true,
441+
},
442+
{
443+
name: "mixed services where one has pull policy",
444+
proj: &composetypes.Project{
445+
Services: composetypes.Services{
446+
"web": {Name: "web", Image: "nginx:latest"},
447+
"api": {Name: "api", Image: "ghcr.io/acme/api:latest", PullPolicy: "missing"},
448+
},
449+
},
450+
want: true,
451+
},
452+
}
453+
454+
for _, tt := range tests {
455+
t.Run(tt.name, func(t *testing.T) {
456+
got := hasComposePullPolicyOverride(tt.proj)
457+
assert.Equal(t, tt.want, got)
458+
})
459+
}
460+
}

backend/internal/services/settings_service.go

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

147149
DockerAPITimeout: models.SettingVariable{Value: "30"},
148150
DockerImagePullTimeout: models.SettingVariable{Value: "600"},
@@ -542,6 +544,12 @@ func (s *SettingsService) prepareUpdateValues(updates settings.Update, cfg, defa
542544
value = fieldValue.Elem().String()
543545
}
544546

547+
if key == "projectUpDefaultPullPolicy" && value != "" {
548+
if value != "missing" && value != "always" {
549+
return nil, false, false, false, false, nil, fmt.Errorf("invalid value for %s: %s", key, value)
550+
}
551+
}
552+
545553
// Validate cron settings
546554
cronFields := []string{"scheduledPruneInterval", "autoUpdateInterval", "pollingInterval", "environmentHealthInterval", "eventCleanupInterval", "analyticsHeartbeatInterval", "vulnerabilityScanInterval", "gitopsSyncInterval"}
547555
if slices.Contains(cronFields, key) && value != "" {

0 commit comments

Comments
 (0)