Skip to content

Commit 84f983b

Browse files
committed
feat: configurable options for the project up command
1 parent 90d1998 commit 84f983b

39 files changed

+1562
-528
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
@@ -101,10 +101,12 @@ type Settings struct {
101101
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"`
102102

103103
// Appearance category
104-
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"`
105-
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"`
106-
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"`
107-
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"`
104+
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"`
105+
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"`
106+
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"`
107+
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"`
108+
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)"`
109+
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"`
108110

109111
// Notifications category (placeholder for category metadata only - actual settings managed via notification service)
110112
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: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -698,7 +698,73 @@ func (s *ProjectService) GetProjectStatusCounts(ctx context.Context) (folderCoun
698698

699699
// Project Actions
700700

701+
type deployPullPolicy string
702+
703+
const (
704+
deployPullPolicyMissing deployPullPolicy = "missing"
705+
deployPullPolicyAlways deployPullPolicy = "always"
706+
)
707+
708+
type deployOptions struct {
709+
pullPolicy deployPullPolicy
710+
forceRecreate bool
711+
}
712+
713+
func defaultDeployOptions() deployOptions {
714+
return deployOptions{
715+
pullPolicy: deployPullPolicyMissing,
716+
forceRecreate: false,
717+
}
718+
}
719+
720+
func normalizeDeployOptions(options *project.UpRequest) (deployOptions, error) {
721+
normalized := defaultDeployOptions()
722+
if options == nil {
723+
return normalized, nil
724+
}
725+
726+
switch options.PullPolicy {
727+
case "", project.UpPullPolicyMissing:
728+
normalized.pullPolicy = deployPullPolicyMissing
729+
case project.UpPullPolicyAlways:
730+
normalized.pullPolicy = deployPullPolicyAlways
731+
default:
732+
return deployOptions{}, fmt.Errorf("invalid pull policy: %q", options.PullPolicy)
733+
}
734+
735+
normalized.forceRecreate = options.ForceRecreate
736+
737+
return normalized, nil
738+
}
739+
740+
func hasComposePullPolicyOverride(composeProject *composetypes.Project) bool {
741+
if composeProject == nil {
742+
return false
743+
}
744+
745+
for _, service := range composeProject.Services {
746+
if strings.TrimSpace(string(service.PullPolicy)) != "" {
747+
return true
748+
}
749+
}
750+
751+
return false
752+
}
753+
701754
func (s *ProjectService) DeployProject(ctx context.Context, projectID string, user models.User) error {
755+
return s.DeployProjectWithOptions(ctx, projectID, user, nil)
756+
}
757+
758+
func (s *ProjectService) DeployProjectWithOptions(ctx context.Context, projectID string, user models.User, options *project.UpRequest) error {
759+
normalizedOptions, err := normalizeDeployOptions(options)
760+
if err != nil {
761+
return err
762+
}
763+
764+
return s.deployProject(ctx, projectID, user, normalizedOptions)
765+
}
766+
767+
func (s *ProjectService) deployProject(ctx context.Context, projectID string, user models.User, options deployOptions) error {
702768
projectFromDb, err := s.GetProjectFromDatabaseByID(ctx, projectID)
703769
if err != nil {
704770
return fmt.Errorf("failed to get project: %w", err)
@@ -732,15 +798,35 @@ func (s *ProjectService) DeployProject(ctx context.Context, projectID string, us
732798
return fmt.Errorf("failed to update project status to deploying: %w", err)
733799
}
734800

735-
if perr := s.EnsureProjectImagesPresent(ctx, projectID, io.Discard, nil); perr != nil {
736-
slog.Warn("ensure images present failed (continuing to compose up)", "projectID", projectID, "error", perr)
801+
progressWriter := io.Writer(io.Discard)
802+
if writer, ok := ctx.Value(projects.ProgressWriterKey{}).(io.Writer); ok && writer != nil {
803+
progressWriter = writer
804+
}
805+
806+
if hasComposePullPolicyOverride(project) {
807+
slog.Info("compose pull_policy detected; skipping Arcane pre-pull behavior", "projectID", projectID, "projectName", project.Name)
808+
} else {
809+
switch options.pullPolicy {
810+
case deployPullPolicyAlways:
811+
if perr := s.PullProjectImages(ctx, projectID, progressWriter, nil); perr != nil {
812+
slog.Warn("pull project images failed (continuing to compose up)", "projectID", projectID, "error", perr)
813+
}
814+
default:
815+
if perr := s.EnsureProjectImagesPresent(ctx, projectID, progressWriter, nil); perr != nil {
816+
slog.Warn("ensure images present failed (continuing to compose up)", "projectID", projectID, "error", perr)
817+
}
818+
}
737819
}
738820

739821
removeOrphans := projectFromDb.GitOpsManagedBy != nil && *projectFromDb.GitOpsManagedBy != ""
822+
composeUpOptions := projects.ComposeUpOptions{
823+
RemoveOrphans: removeOrphans,
824+
ForceRecreate: options.forceRecreate,
825+
}
740826

741-
slog.Info("starting compose up with health check support", "projectID", projectID, "projectName", project.Name, "services", len(project.Services), "removeOrphans", removeOrphans)
827+
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)
742828
// Health/progress streaming (if any) is handled inside projects.ComposeUp via ctx.
743-
if err := projects.ComposeUp(ctx, project, nil, removeOrphans); err != nil {
829+
if err := projects.ComposeUp(ctx, project, nil, composeUpOptions); err != nil {
744830
slog.Error("compose up failed", "projectName", project.Name, "projectID", projectID, "error", err)
745831
if containers, psErr := s.GetProjectServices(ctx, projectID); psErr == nil {
746832
slog.Info("containers after failed deploy", "projectID", projectID, "containers", containers)

backend/internal/services/project_service_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"testing"
66
"time"
77

8+
composetypes "github.com/compose-spec/compose-go/v2/types"
89
"github.com/docker/docker/api/types/container"
910
glsqlite "github.com/glebarez/sqlite"
1011
"github.com/stretchr/testify/assert"
@@ -13,6 +14,7 @@ import (
1314

1415
"github.com/getarcaneapp/arcane/backend/internal/database"
1516
"github.com/getarcaneapp/arcane/backend/internal/models"
17+
projecttypes "github.com/getarcaneapp/arcane/types/project"
1618
)
1719

1820
func setupProjectTestDB(t *testing.T) *database.DB {
@@ -252,3 +254,126 @@ func TestProjectService_NormalizeComposeProjectName(t *testing.T) {
252254
})
253255
}
254256
}
257+
258+
func TestNormalizeDeployOptions(t *testing.T) {
259+
tests := []struct {
260+
name string
261+
input *projecttypes.UpRequest
262+
wantPullPolicy deployPullPolicy
263+
wantForceCreate bool
264+
wantError bool
265+
wantErrorContain string
266+
}{
267+
{
268+
name: "nil uses defaults",
269+
input: nil,
270+
wantPullPolicy: deployPullPolicyMissing,
271+
wantForceCreate: false,
272+
},
273+
{
274+
name: "empty pull policy uses default",
275+
input: &projecttypes.UpRequest{
276+
PullPolicy: "",
277+
},
278+
wantPullPolicy: deployPullPolicyMissing,
279+
wantForceCreate: false,
280+
},
281+
{
282+
name: "missing pull policy",
283+
input: &projecttypes.UpRequest{
284+
PullPolicy: projecttypes.UpPullPolicyMissing,
285+
},
286+
wantPullPolicy: deployPullPolicyMissing,
287+
wantForceCreate: false,
288+
},
289+
{
290+
name: "always with force recreate",
291+
input: &projecttypes.UpRequest{
292+
PullPolicy: projecttypes.UpPullPolicyAlways,
293+
ForceRecreate: true,
294+
},
295+
wantPullPolicy: deployPullPolicyAlways,
296+
wantForceCreate: true,
297+
},
298+
{
299+
name: "invalid pull policy",
300+
input: &projecttypes.UpRequest{
301+
PullPolicy: "latest",
302+
},
303+
wantError: true,
304+
wantErrorContain: "invalid pull policy",
305+
},
306+
}
307+
308+
for _, tt := range tests {
309+
t.Run(tt.name, func(t *testing.T) {
310+
got, err := normalizeDeployOptions(tt.input)
311+
if tt.wantError {
312+
require.Error(t, err)
313+
if tt.wantErrorContain != "" {
314+
assert.Contains(t, err.Error(), tt.wantErrorContain)
315+
}
316+
return
317+
}
318+
319+
require.NoError(t, err)
320+
assert.Equal(t, tt.wantPullPolicy, got.pullPolicy)
321+
assert.Equal(t, tt.wantForceCreate, got.forceRecreate)
322+
})
323+
}
324+
}
325+
326+
func TestHasComposePullPolicyOverride(t *testing.T) {
327+
tests := []struct {
328+
name string
329+
proj *composetypes.Project
330+
want bool
331+
}{
332+
{
333+
name: "nil project",
334+
proj: nil,
335+
want: false,
336+
},
337+
{
338+
name: "no services",
339+
proj: &composetypes.Project{},
340+
want: false,
341+
},
342+
{
343+
name: "services without pull policy",
344+
proj: &composetypes.Project{
345+
Services: composetypes.Services{
346+
"web": {Name: "web", Image: "nginx:latest"},
347+
"db": {Name: "db", Image: "postgres:16"},
348+
},
349+
},
350+
want: false,
351+
},
352+
{
353+
name: "single service with pull policy",
354+
proj: &composetypes.Project{
355+
Services: composetypes.Services{
356+
"web": {Name: "web", Image: "nginx:latest", PullPolicy: "always"},
357+
},
358+
},
359+
want: true,
360+
},
361+
{
362+
name: "mixed services where one has pull policy",
363+
proj: &composetypes.Project{
364+
Services: composetypes.Services{
365+
"web": {Name: "web", Image: "nginx:latest"},
366+
"api": {Name: "api", Image: "ghcr.io/acme/api:latest", PullPolicy: "missing"},
367+
},
368+
},
369+
want: true,
370+
},
371+
}
372+
373+
for _, tt := range tests {
374+
t.Run(tt.name, func(t *testing.T) {
375+
got := hasComposePullPolicyOverride(tt.proj)
376+
assert.Equal(t, tt.want, got)
377+
})
378+
}
379+
}

backend/internal/services/settings_service.go

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -111,30 +111,32 @@ func (s *SettingsService) getDefaultSettings() *models.Settings {
111111
AuthPasswordPolicy: models.SettingVariable{Value: "strong"},
112112
TrivyImage: models.SettingVariable{Value: "ghcr.io/aquasecurity/trivy:latest"},
113113
// AuthOidcConfig DEPRECATED will be removed in a future release
114-
AuthOidcConfig: models.SettingVariable{Value: "{}"},
115-
OidcEnabled: models.SettingVariable{Value: "false"},
116-
OidcClientId: models.SettingVariable{Value: ""},
117-
OidcClientSecret: models.SettingVariable{Value: ""},
118-
OidcIssuerUrl: models.SettingVariable{Value: ""},
119-
OidcAuthorizationEndpoint: models.SettingVariable{Value: ""},
120-
OidcTokenEndpoint: models.SettingVariable{Value: ""},
121-
OidcUserinfoEndpoint: models.SettingVariable{Value: ""},
122-
OidcJwksEndpoint: models.SettingVariable{Value: ""},
123-
OidcScopes: models.SettingVariable{Value: "openid email profile"},
124-
OidcAdminClaim: models.SettingVariable{Value: ""},
125-
OidcAdminValue: models.SettingVariable{Value: ""},
126-
OidcSkipTlsVerify: models.SettingVariable{Value: "false"},
127-
OidcAutoRedirectToProvider: models.SettingVariable{Value: "false"},
128-
OidcMergeAccounts: models.SettingVariable{Value: "false"},
129-
OidcProviderName: models.SettingVariable{Value: ""},
130-
OidcProviderLogoUrl: models.SettingVariable{Value: ""},
131-
MobileNavigationMode: models.SettingVariable{Value: "floating"},
132-
MobileNavigationShowLabels: models.SettingVariable{Value: "true"},
133-
SidebarHoverExpansion: models.SettingVariable{Value: "true"},
134-
KeyboardShortcutsEnabled: models.SettingVariable{Value: "true"},
135-
AccentColor: models.SettingVariable{Value: "oklch(0.606 0.25 292.717)"},
136-
MaxImageUploadSize: models.SettingVariable{Value: "500"},
137-
EnvironmentHealthInterval: models.SettingVariable{Value: "0 */2 * * * *"},
114+
AuthOidcConfig: models.SettingVariable{Value: "{}"},
115+
OidcEnabled: models.SettingVariable{Value: "false"},
116+
OidcClientId: models.SettingVariable{Value: ""},
117+
OidcClientSecret: models.SettingVariable{Value: ""},
118+
OidcIssuerUrl: models.SettingVariable{Value: ""},
119+
OidcAuthorizationEndpoint: models.SettingVariable{Value: ""},
120+
OidcTokenEndpoint: models.SettingVariable{Value: ""},
121+
OidcUserinfoEndpoint: models.SettingVariable{Value: ""},
122+
OidcJwksEndpoint: models.SettingVariable{Value: ""},
123+
OidcScopes: models.SettingVariable{Value: "openid email profile"},
124+
OidcAdminClaim: models.SettingVariable{Value: ""},
125+
OidcAdminValue: models.SettingVariable{Value: ""},
126+
OidcSkipTlsVerify: models.SettingVariable{Value: "false"},
127+
OidcAutoRedirectToProvider: models.SettingVariable{Value: "false"},
128+
OidcMergeAccounts: models.SettingVariable{Value: "false"},
129+
OidcProviderName: models.SettingVariable{Value: ""},
130+
OidcProviderLogoUrl: models.SettingVariable{Value: ""},
131+
MobileNavigationMode: models.SettingVariable{Value: "floating"},
132+
MobileNavigationShowLabels: models.SettingVariable{Value: "true"},
133+
SidebarHoverExpansion: models.SettingVariable{Value: "true"},
134+
KeyboardShortcutsEnabled: models.SettingVariable{Value: "true"},
135+
ProjectUpDefaultPullPolicy: models.SettingVariable{Value: "missing"},
136+
ProjectUpDefaultForceRecreate: models.SettingVariable{Value: "false"},
137+
AccentColor: models.SettingVariable{Value: "oklch(0.606 0.25 292.717)"},
138+
MaxImageUploadSize: models.SettingVariable{Value: "500"},
139+
EnvironmentHealthInterval: models.SettingVariable{Value: "0 */2 * * * *"},
138140

139141
DockerAPITimeout: models.SettingVariable{Value: "30"},
140142
DockerImagePullTimeout: models.SettingVariable{Value: "600"},
@@ -540,6 +542,12 @@ func (s *SettingsService) prepareUpdateValues(updates settings.Update, cfg, defa
540542
value = fieldValue.Elem().String()
541543
}
542544

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

0 commit comments

Comments
 (0)