Skip to content

Commit 9b98026

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

38 files changed

+1465
-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: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -698,7 +698,59 @@ 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+
701740
func (s *ProjectService) DeployProject(ctx context.Context, projectID string, user models.User) error {
741+
return s.DeployProjectWithOptions(ctx, projectID, user, nil)
742+
}
743+
744+
func (s *ProjectService) DeployProjectWithOptions(ctx context.Context, projectID string, user models.User, options *project.UpRequest) error {
745+
normalizedOptions, err := normalizeDeployOptions(options)
746+
if err != nil {
747+
return err
748+
}
749+
750+
return s.deployProject(ctx, projectID, user, normalizedOptions)
751+
}
752+
753+
func (s *ProjectService) deployProject(ctx context.Context, projectID string, user models.User, options deployOptions) error {
702754
projectFromDb, err := s.GetProjectFromDatabaseByID(ctx, projectID)
703755
if err != nil {
704756
return fmt.Errorf("failed to get project: %w", err)
@@ -732,15 +784,31 @@ func (s *ProjectService) DeployProject(ctx context.Context, projectID string, us
732784
return fmt.Errorf("failed to update project status to deploying: %w", err)
733785
}
734786

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)
787+
progressWriter := io.Writer(io.Discard)
788+
if writer, ok := ctx.Value(projects.ProgressWriterKey{}).(io.Writer); ok && writer != nil {
789+
progressWriter = writer
790+
}
791+
792+
switch options.pullPolicy {
793+
case deployPullPolicyAlways:
794+
if perr := s.PullProjectImages(ctx, projectID, progressWriter, nil); perr != nil {
795+
slog.Warn("pull project images failed (continuing to compose up)", "projectID", projectID, "error", perr)
796+
}
797+
default:
798+
if perr := s.EnsureProjectImagesPresent(ctx, projectID, progressWriter, nil); perr != nil {
799+
slog.Warn("ensure images present failed (continuing to compose up)", "projectID", projectID, "error", perr)
800+
}
737801
}
738802

739803
removeOrphans := projectFromDb.GitOpsManagedBy != nil && *projectFromDb.GitOpsManagedBy != ""
804+
composeUpOptions := projects.ComposeUpOptions{
805+
RemoveOrphans: removeOrphans,
806+
ForceRecreate: options.forceRecreate,
807+
}
740808

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

backend/internal/services/project_service_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313

1414
"github.com/getarcaneapp/arcane/backend/internal/database"
1515
"github.com/getarcaneapp/arcane/backend/internal/models"
16+
projecttypes "github.com/getarcaneapp/arcane/types/project"
1617
)
1718

1819
func setupProjectTestDB(t *testing.T) *database.DB {
@@ -252,3 +253,71 @@ func TestProjectService_NormalizeComposeProjectName(t *testing.T) {
252253
})
253254
}
254255
}
256+
257+
func TestNormalizeDeployOptions(t *testing.T) {
258+
tests := []struct {
259+
name string
260+
input *projecttypes.UpRequest
261+
wantPullPolicy deployPullPolicy
262+
wantForceCreate bool
263+
wantError bool
264+
wantErrorContain string
265+
}{
266+
{
267+
name: "nil uses defaults",
268+
input: nil,
269+
wantPullPolicy: deployPullPolicyMissing,
270+
wantForceCreate: false,
271+
},
272+
{
273+
name: "empty pull policy uses default",
274+
input: &projecttypes.UpRequest{
275+
PullPolicy: "",
276+
},
277+
wantPullPolicy: deployPullPolicyMissing,
278+
wantForceCreate: false,
279+
},
280+
{
281+
name: "missing pull policy",
282+
input: &projecttypes.UpRequest{
283+
PullPolicy: projecttypes.UpPullPolicyMissing,
284+
},
285+
wantPullPolicy: deployPullPolicyMissing,
286+
wantForceCreate: false,
287+
},
288+
{
289+
name: "always with force recreate",
290+
input: &projecttypes.UpRequest{
291+
PullPolicy: projecttypes.UpPullPolicyAlways,
292+
ForceRecreate: true,
293+
},
294+
wantPullPolicy: deployPullPolicyAlways,
295+
wantForceCreate: true,
296+
},
297+
{
298+
name: "invalid pull policy",
299+
input: &projecttypes.UpRequest{
300+
PullPolicy: "latest",
301+
},
302+
wantError: true,
303+
wantErrorContain: "invalid pull policy",
304+
},
305+
}
306+
307+
for _, tt := range tests {
308+
t.Run(tt.name, func(t *testing.T) {
309+
got, err := normalizeDeployOptions(tt.input)
310+
if tt.wantError {
311+
require.Error(t, err)
312+
if tt.wantErrorContain != "" {
313+
assert.Contains(t, err.Error(), tt.wantErrorContain)
314+
}
315+
return
316+
}
317+
318+
require.NoError(t, err)
319+
assert.Equal(t, tt.wantPullPolicy, got.pullPolicy)
320+
assert.Equal(t, tt.wantForceCreate, got.forceRecreate)
321+
})
322+
}
323+
}

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 != "" {

backend/pkg/projects/cmds.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,12 @@ func ComposeRestart(ctx context.Context, proj *types.Project, services []string)
4242
return c.svc.Restart(ctx, proj.Name, api.RestartOptions{Services: services})
4343
}
4444

45-
func ComposeUp(ctx context.Context, proj *types.Project, services []string, removeOrphans bool) error {
45+
type ComposeUpOptions struct {
46+
RemoveOrphans bool
47+
ForceRecreate bool
48+
}
49+
50+
func ComposeUp(ctx context.Context, proj *types.Project, services []string, options ComposeUpOptions) error {
4651
c, err := NewClient(ctx)
4752
if err != nil {
4853
return err
@@ -51,7 +56,7 @@ func ComposeUp(ctx context.Context, proj *types.Project, services []string, remo
5156

5257
progressWriter, _ := ctx.Value(ProgressWriterKey{}).(io.Writer)
5358

54-
upOptions, startOptions := composeUpOptions(proj, services, removeOrphans)
59+
upOptions, startOptions := composeUpOptions(proj, services, options)
5560

5661
// If we don't need progress, just run compose up normally.
5762
if progressWriter == nil {
@@ -61,12 +66,17 @@ func ComposeUp(ctx context.Context, proj *types.Project, services []string, remo
6166
return composeUpWithProgress(ctx, c.svc, proj, api.UpOptions{Create: upOptions, Start: startOptions}, progressWriter)
6267
}
6368

64-
func composeUpOptions(proj *types.Project, services []string, removeOrphans bool) (api.CreateOptions, api.StartOptions) {
69+
func composeUpOptions(proj *types.Project, services []string, options ComposeUpOptions) (api.CreateOptions, api.StartOptions) {
70+
recreateMode := api.RecreateDiverged
71+
if options.ForceRecreate {
72+
recreateMode = api.RecreateForce
73+
}
74+
6575
upOptions := api.CreateOptions{
6676
Services: services,
67-
Recreate: api.RecreateDiverged,
68-
RecreateDependencies: api.RecreateDiverged,
69-
RemoveOrphans: removeOrphans,
77+
Recreate: recreateMode,
78+
RecreateDependencies: recreateMode,
79+
RemoveOrphans: options.RemoveOrphans,
7080
}
7181

7282
startOptions := api.StartOptions{

backend/pkg/projects/cmds_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package projects
2+
3+
import (
4+
"testing"
5+
6+
"github.com/compose-spec/compose-go/v2/types"
7+
"github.com/docker/compose/v5/pkg/api"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestComposeUpOptions_DefaultRecreateMode(t *testing.T) {
12+
proj := &types.Project{Name: "demo"}
13+
14+
createOpts, _ := composeUpOptions(proj, nil, ComposeUpOptions{RemoveOrphans: true})
15+
16+
assert.Equal(t, api.RecreateDiverged, createOpts.Recreate)
17+
assert.Equal(t, api.RecreateDiverged, createOpts.RecreateDependencies)
18+
assert.True(t, createOpts.RemoveOrphans)
19+
}
20+
21+
func TestComposeUpOptions_ForceRecreateMode(t *testing.T) {
22+
proj := &types.Project{Name: "demo"}
23+
24+
createOpts, _ := composeUpOptions(proj, nil, ComposeUpOptions{ForceRecreate: true})
25+
26+
assert.Equal(t, api.RecreateForce, createOpts.Recreate)
27+
assert.Equal(t, api.RecreateForce, createOpts.RecreateDependencies)
28+
}

0 commit comments

Comments
 (0)