Skip to content

Commit bec81dd

Browse files
committed
feat: configurable compose up button
1 parent 6a314a2 commit bec81dd

File tree

18 files changed

+358
-37
lines changed

18 files changed

+358
-37
lines changed

backend/internal/huma/handlers/projects.go

Lines changed: 2 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.DeployOptions
6263
}
6364

6465
type DeployProjectOutput struct {
@@ -448,7 +449,7 @@ func (h *ProjectHandler) DeployProject(ctx context.Context, input *DeployProject
448449
}
449450

450451
deployCtx := context.WithValue(humaCtx.Context(), projects.ProgressWriterKey{}, writer)
451-
if err := h.projectService.DeployProject(deployCtx, input.ProjectID, *user); err != nil {
452+
if err := h.projectService.DeployProject(deployCtx, input.ProjectID, *user, input.Body); err != nil {
452453
_, _ = fmt.Fprintf(writer, `{"error":%q}`+"\n", err.Error())
453454
if f, ok := writer.(http.Flusher); ok {
454455
f.Flush()

backend/internal/models/settings.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ type Settings struct {
6363
AnalyticsHeartbeatInterval SettingVariable `key:"analyticsHeartbeatInterval" meta:"label=Analytics Heartbeat Interval;type=cron;keywords=analytics,heartbeat,interval,frequency,schedule,telemetry,jobs;description=How often to send the anonymous analytics heartbeat (cron expression)"`
6464
AutoInjectEnv SettingVariable `key:"autoInjectEnv" meta:"label=Auto Inject Env Variables;type=boolean;keywords=auto,inject,env,environment,variables,interpolation;category=internal;description=Automatically inject project .env variables into all containers (default: false)"`
6565
PruneMode SettingVariable `key:"dockerPruneMode" meta:"label=Docker Prune Action;type=select;keywords=prune,cleanup,clean,remove,delete,unused,dangling,space,disk;category=internal;description=Configure how unused Docker images are cleaned up"`
66+
DefaultDeployPullPolicy SettingVariable `key:"defaultDeployPullPolicy" meta:"label=Default Deploy Pull Policy;type=select;keywords=deploy,pull,policy,compose,up,missing,always;category=internal;description=Default image pull policy when deploying projects"`
6667
ScheduledPruneEnabled SettingVariable `key:"scheduledPruneEnabled" meta:"label=Scheduled Prune Enabled;type=boolean;keywords=prune,cleanup,maintenance,schedule,automatic;category=internal;description=Enable scheduled pruning of unused Docker resources"`
6768
ScheduledPruneInterval SettingVariable `key:"scheduledPruneInterval" meta:"label=Scheduled Prune Interval;type=cron;keywords=prune,cleanup,interval,minutes,schedule;category=internal;description=How often to run scheduled prunes (cron expression)"`
6869
GitopsSyncInterval SettingVariable `key:"gitopsSyncInterval" meta:"label=GitOps Sync Interval;type=cron;keywords=gitops,sync,interval,frequency,schedule,repository;category=internal;description=How often to run GitOps synchronization checks (cron expression)"`

backend/internal/services/gitops_sync_service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,7 @@ func (s *GitOpsSyncService) createProjectForSyncInternal(ctx context.Context, sy
615615

616616
// Deploy the project immediately after creation
617617
slog.InfoContext(ctx, "Deploying project after initial Git sync", "projectName", project.Name, "projectId", project.ID)
618-
if err := s.projectService.DeployProject(ctx, project.ID, actor); err != nil {
618+
if err := s.projectService.DeployProject(ctx, project.ID, actor, nil); err != nil {
619619
slog.ErrorContext(ctx, "Failed to deploy project after initial Git sync", "error", err, "projectId", project.ID)
620620
}
621621

backend/internal/services/project_service.go

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -790,12 +790,25 @@ func (s *ProjectService) GetProjectStatusCounts(ctx context.Context) (folderCoun
790790

791791
// Project Actions
792792

793-
func (s *ProjectService) DeployProject(ctx context.Context, projectID string, user models.User) error {
793+
func (s *ProjectService) DeployProject(ctx context.Context, projectID string, user models.User, options *project.DeployOptions) error {
794794
projectFromDb, err := s.GetProjectFromDatabaseByID(ctx, projectID)
795795
if err != nil {
796796
return fmt.Errorf("failed to get project: %w", err)
797797
}
798798

799+
resolvedPullPolicy := ""
800+
forceRecreate := false
801+
if options != nil {
802+
resolvedPullPolicy = normalizeDeployPullPolicyInternal(options.PullPolicy)
803+
forceRecreate = options.ForceRecreate
804+
}
805+
if resolvedPullPolicy == "" {
806+
resolvedPullPolicy = normalizeDeployPullPolicyInternal(s.settingsService.GetStringSetting(ctx, "defaultDeployPullPolicy", "missing"))
807+
}
808+
if resolvedPullPolicy == "" {
809+
resolvedPullPolicy = "missing"
810+
}
811+
799812
composeFileFullPath, derr := projects.DetectComposeFile(projectFromDb.Path)
800813
if derr != nil {
801814
return fmt.Errorf("no compose file found in project directory: %s", projectFromDb.Path)
@@ -824,15 +837,15 @@ func (s *ProjectService) DeployProject(ctx context.Context, projectID string, us
824837
return fmt.Errorf("failed to update project status to deploying: %w", err)
825838
}
826839

827-
if perr := s.prepareProjectImagesForDeploy(ctx, projectID, project, io.Discard, nil, &user); perr != nil {
840+
if perr := s.prepareProjectImagesForDeploy(ctx, projectID, project, io.Discard, nil, &user, resolvedPullPolicy); perr != nil {
828841
slog.Warn("prepare images for deploy failed (continuing to compose up)", "projectID", projectID, "error", perr)
829842
}
830843

831844
removeOrphans := projectFromDb.GitOpsManagedBy != nil && *projectFromDb.GitOpsManagedBy != ""
832845

833846
slog.Info("starting compose up with health check support", "projectID", projectID, "projectName", project.Name, "services", len(project.Services), "removeOrphans", removeOrphans)
834847
// Health/progress streaming (if any) is handled inside projects.ComposeUp via ctx.
835-
if err := projects.ComposeUp(ctx, project, nil, removeOrphans); err != nil {
848+
if err := projects.ComposeUp(ctx, project, nil, removeOrphans, forceRecreate); err != nil {
836849
slog.Error("compose up failed", "projectName", project.Name, "projectID", projectID, "error", err)
837850
if containers, psErr := s.GetProjectServices(ctx, projectID); psErr == nil {
838851
slog.Info("containers after failed deploy", "projectID", projectID, "containers", containers)
@@ -1031,7 +1044,7 @@ func (s *ProjectService) RedeployProject(ctx context.Context, projectID string,
10311044
slog.ErrorContext(ctx, "could not log project redeploy action", "error", logErr)
10321045
}
10331046

1034-
return s.DeployProject(ctx, projectID, user)
1047+
return s.DeployProject(ctx, projectID, user, nil)
10351048
}
10361049

10371050
func (s *ProjectService) PullProjectImages(ctx context.Context, projectID string, progressWriter io.Writer, user models.User, credentials []containerregistry.Credential) error {
@@ -1223,6 +1236,7 @@ func (s *ProjectService) prepareProjectImagesForDeploy(
12231236
progressWriter io.Writer,
12241237
credentials []containerregistry.Credential,
12251238
user *models.User,
1239+
pullPolicyOverride string,
12261240
) error {
12271241
if project == nil {
12281242
return nil
@@ -1243,7 +1257,7 @@ func (s *ProjectService) prepareProjectImagesForDeploy(
12431257
continue
12441258
}
12451259

1246-
decision := decideDeployImageAction(svc)
1260+
decision := decideDeployImageAction(svc, pullPolicyOverride)
12471261
if err := s.ensureDeployServiceImageReady(ctx, projectID, project, name, svc, imageName, decision, progressWriter, credentials, user, pathMapper); err != nil {
12481262
return err
12491263
}
@@ -1375,15 +1389,30 @@ func normalizePullPolicy(policy string) string {
13751389
return policy
13761390
}
13771391

1392+
func normalizeDeployPullPolicyInternal(policy string) string {
1393+
normalized := normalizePullPolicy(policy)
1394+
switch normalized {
1395+
case "always", "missing", "never":
1396+
return normalized
1397+
default:
1398+
return ""
1399+
}
1400+
}
1401+
13781402
func isAlwaysPullPolicy(policy string) bool {
13791403
if policy == "always" || policy == "daily" || policy == "weekly" {
13801404
return true
13811405
}
13821406
return strings.HasPrefix(policy, "every_")
13831407
}
13841408

1385-
func decideDeployImageAction(svc composetypes.ServiceConfig) deployImageDecision {
1409+
func decideDeployImageAction(svc composetypes.ServiceConfig, pullPolicyOverride string) deployImageDecision {
13861410
policy := normalizePullPolicy(svc.PullPolicy)
1411+
if policy == "" {
1412+
if override := normalizeDeployPullPolicyInternal(pullPolicyOverride); override != "" {
1413+
policy = override
1414+
}
1415+
}
13871416
buildEnabled := svc.Build != nil
13881417

13891418
if buildEnabled {

backend/internal/services/project_service_test.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -591,25 +591,32 @@ func TestDecideDeployImageAction(t *testing.T) {
591591
Build: &composetypes.BuildConfig{Context: "."},
592592
}
593593

594-
decision := decideDeployImageAction(svc)
594+
decision := decideDeployImageAction(svc, "")
595595
assert.True(t, decision.Build)
596596
assert.False(t, decision.PullAlways)
597597
})
598598

599599
t.Run("build service default policy uses pull then fallback build", func(t *testing.T) {
600600
svc := composetypes.ServiceConfig{Build: &composetypes.BuildConfig{Context: "."}}
601-
decision := decideDeployImageAction(svc)
601+
decision := decideDeployImageAction(svc, "")
602602
assert.True(t, decision.PullIfMissing)
603603
assert.True(t, decision.FallbackBuildOnPullFail)
604604
assert.False(t, decision.Build)
605605
})
606606

607607
t.Run("non-build service never policy requires local only", func(t *testing.T) {
608608
svc := composetypes.ServiceConfig{PullPolicy: "never"}
609-
decision := decideDeployImageAction(svc)
609+
decision := decideDeployImageAction(svc, "")
610610
assert.True(t, decision.RequireLocalOnly)
611611
assert.False(t, decision.PullIfMissing)
612612
})
613+
614+
t.Run("compose pull policy wins over deploy override", func(t *testing.T) {
615+
svc := composetypes.ServiceConfig{PullPolicy: "never"}
616+
decision := decideDeployImageAction(svc, "always")
617+
assert.True(t, decision.RequireLocalOnly)
618+
assert.False(t, decision.PullAlways)
619+
})
613620
}
614621

615622
func TestProjectService_PrepareServiceBuildRequest_GeneratedImageProviderGuardrails(t *testing.T) {

backend/internal/services/settings_service.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ func (s *SettingsService) getDefaultSettings() *models.Settings {
9797
AnalyticsHeartbeatInterval: models.SettingVariable{Value: "0 0 0 * * *"},
9898
AutoInjectEnv: models.SettingVariable{Value: "false"},
9999
PruneMode: models.SettingVariable{Value: "dangling"},
100+
DefaultDeployPullPolicy: models.SettingVariable{Value: "missing"},
100101
ScheduledPruneEnabled: models.SettingVariable{Value: "false"},
101102
ScheduledPruneInterval: models.SettingVariable{Value: "0 0 0 * * *"},
102103
ScheduledPruneContainers: models.SettingVariable{Value: "true"},

backend/pkg/projects/cmds.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ 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+
func ComposeUp(ctx context.Context, proj *types.Project, services []string, removeOrphans bool, forceRecreate bool) error {
4646
c, err := NewClient(ctx)
4747
if err != nil {
4848
return err
@@ -51,7 +51,7 @@ func ComposeUp(ctx context.Context, proj *types.Project, services []string, remo
5151

5252
progressWriter, _ := ctx.Value(ProgressWriterKey{}).(io.Writer)
5353

54-
upOptions, startOptions := composeUpOptions(proj, services, removeOrphans)
54+
upOptions, startOptions := composeUpOptions(proj, services, removeOrphans, forceRecreate)
5555

5656
// If we don't need progress, just run compose up normally.
5757
if progressWriter == nil {
@@ -61,10 +61,15 @@ func ComposeUp(ctx context.Context, proj *types.Project, services []string, remo
6161
return composeUpWithProgress(ctx, c.svc, proj, api.UpOptions{Create: upOptions, Start: startOptions}, progressWriter)
6262
}
6363

64-
func composeUpOptions(proj *types.Project, services []string, removeOrphans bool) (api.CreateOptions, api.StartOptions) {
64+
func composeUpOptions(proj *types.Project, services []string, removeOrphans bool, forceRecreate bool) (api.CreateOptions, api.StartOptions) {
65+
recreatePolicy := api.RecreateDiverged
66+
if forceRecreate {
67+
recreatePolicy = api.RecreateForce
68+
}
69+
6570
upOptions := api.CreateOptions{
6671
Services: services,
67-
Recreate: api.RecreateDiverged,
72+
Recreate: recreatePolicy,
6873
RecreateDependencies: api.RecreateDiverged,
6974
RemoveOrphans: removeOrphans,
7075
}

frontend/messages/en.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2159,5 +2159,11 @@
21592159
"build_settings_depot_token_description": "Personal access token for Depot (leave blank to keep existing).",
21602160
"build_settings_depot_token_placeholder": "******",
21612161
"build_settings_depot_token_help": "Leave blank to preserve the existing token",
2162-
"build_history": "Build History"
2162+
"build_history": "Build History",
2163+
"deploy_pull_policy_missing": "Pull if not present",
2164+
"deploy_pull_policy_always": "Always pull latest",
2165+
"deploy_pull_policy_never": "Never pull",
2166+
"deploy_force_recreate": "Force recreate containers",
2167+
"settings_default_deploy_pull_policy": "Default Deploy Pull Policy",
2168+
"settings_default_deploy_pull_policy_description": "Default image pull policy when deploying projects"
21632169
}

frontend/src/lib/components/action-buttons.svelte

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
import { tryCatch } from '$lib/utils/try-catch';
77
import { handleApiResultWithCallbacks } from '$lib/utils/api.util';
88
import { ArcaneButton } from '$lib/components/arcane-button/index.js';
9+
import DeploySplitButton from '$lib/components/deploy-split-button/deploy-split-button.svelte';
910
import ProgressPopover from '$lib/components/progress-popover.svelte';
1011
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
1112
import { m } from '$lib/paraglide/messages';
1213
import settingsStore from '$lib/stores/config-store';
14+
import { deployOptionsStore } from '$lib/stores/deploy-options.store.svelte';
1315
import { containerService } from '$lib/services/container-service';
14-
import { projectService } from '$lib/services/project-service';
16+
import { projectService, type DeployProjectOptions } from '$lib/services/project-service';
1517
import { isDownloadingLine, calculateOverallProgress, areAllLayersComplete } from '$lib/utils/pull-progress';
1618
import { sanitizeLogText } from '$lib/utils/log-text';
1719
import { EllipsisIcon, DownloadIcon, HammerIcon } from '$lib/icons';
@@ -89,6 +91,12 @@
8991
if (key === 'refresh') refreshLoading = value;
9092
}
9193
94+
function handleDeployPullPolicyChange(value: string) {
95+
if (value === 'missing' || value === 'always' || value === 'never') {
96+
deployOptionsStore.setPullPolicy(value);
97+
}
98+
}
99+
92100
const uiLoading = $derived({
93101
start: !!(isLoading.start || loading?.start || startLoading),
94102
stop: !!(isLoading.stop || loading?.stop || stopLoading),
@@ -103,7 +111,12 @@
103111
104112
const startMutation = createMutation(() => ({
105113
mutationKey: ['action', 'start', type, id],
106-
mutationFn: () => tryCatch(type === 'container' ? containerService.startContainer(id) : projectService.deployProject(id)),
114+
mutationFn: () =>
115+
tryCatch(
116+
type === 'container'
117+
? containerService.startContainer(id)
118+
: projectService.deployProject(id, deployOptionsStore.getRequestOptions())
119+
),
107120
onMutate: () => setLoading('start', true),
108121
onSettled: () => {
109122
if (!deployPulling) {
@@ -367,7 +380,7 @@
367380
});
368381
}
369382
370-
async function handleDeploy() {
383+
async function handleDeploy(options?: DeployProjectOptions) {
371384
resetPullState();
372385
setLoading('start', true);
373386
let openedPopover = false;
@@ -583,7 +596,7 @@
583596
}
584597
};
585598
586-
await projectService.deployProject(id, handleDeployLine);
599+
await projectService.deployProject(id, handleDeployLine, options ?? deployOptionsStore.getRequestOptions());
587600
588601
if (hadError) throw new Error(pullError || m.progress_deploy_failed());
589602
@@ -797,12 +810,11 @@
797810
layers={deployPopoverLayers}
798811
outputLines={deployProgressPhase === 'build' ? buildOutputLines : []}
799812
>
800-
<ArcaneButton
801-
action="deploy"
813+
<DeploySplitButton
802814
size={adaptiveIconOnly ? 'icon' : 'default'}
803815
showLabel={!adaptiveIconOnly}
804816
customLabel={deployButtonLabel}
805-
onclick={() => handleDeploy()}
817+
onDeploy={() => handleDeploy()}
806818
loading={uiLoading.start}
807819
/>
808820
</ProgressPopover>
@@ -927,9 +939,29 @@
927939
{m.common_start()}
928940
</DropdownMenu.Item>
929941
{:else}
930-
<DropdownMenu.Item onclick={handleDeploy} disabled={uiLoading.start}>
942+
<DropdownMenu.Item onclick={() => handleDeploy()} disabled={uiLoading.start}>
931943
{deployButtonLabel}
932944
</DropdownMenu.Item>
945+
{#if type === 'project'}
946+
<DropdownMenu.Separator />
947+
<DropdownMenu.Label>{m.settings_default_deploy_pull_policy()}</DropdownMenu.Label>
948+
<DropdownMenu.RadioGroup value={deployOptionsStore.pullPolicy} onValueChange={handleDeployPullPolicyChange}>
949+
<DropdownMenu.RadioItem value="missing">Missing</DropdownMenu.RadioItem>
950+
<DropdownMenu.RadioItem value="always">
951+
{m.common_always()}
952+
</DropdownMenu.RadioItem>
953+
<DropdownMenu.RadioItem value="never">
954+
{m.common_never()}
955+
</DropdownMenu.RadioItem>
956+
</DropdownMenu.RadioGroup>
957+
<DropdownMenu.Separator />
958+
<DropdownMenu.CheckboxItem
959+
checked={deployOptionsStore.forceRecreate}
960+
onCheckedChange={(checked) => deployOptionsStore.setForceRecreate(checked === true)}
961+
>
962+
{m.deploy_force_recreate()}
963+
</DropdownMenu.CheckboxItem>
964+
{/if}
933965
{/if}
934966
{:else}
935967
<DropdownMenu.Item onclick={handleStop} disabled={uiLoading.stop}>
@@ -1045,12 +1077,7 @@
10451077
layers={deployPopoverLayers}
10461078
outputLines={deployProgressPhase === 'build' ? buildOutputLines : []}
10471079
>
1048-
<ArcaneButton
1049-
action="deploy"
1050-
customLabel={deployButtonLabel}
1051-
onclick={() => handleDeploy()}
1052-
loading={uiLoading.start}
1053-
/>
1080+
<DeploySplitButton customLabel={deployButtonLabel} onDeploy={() => handleDeploy()} loading={uiLoading.start} />
10541081
</ProgressPopover>
10551082
{/if}
10561083
{/if}
@@ -1133,9 +1160,29 @@
11331160
{m.common_start()}
11341161
</DropdownMenu.Item>
11351162
{:else}
1136-
<DropdownMenu.Item onclick={handleDeploy} disabled={uiLoading.start}>
1163+
<DropdownMenu.Item onclick={() => handleDeploy()} disabled={uiLoading.start}>
11371164
{deployButtonLabel}
11381165
</DropdownMenu.Item>
1166+
{#if type === 'project'}
1167+
<DropdownMenu.Separator />
1168+
<DropdownMenu.Label>{m.settings_default_deploy_pull_policy()}</DropdownMenu.Label>
1169+
<DropdownMenu.RadioGroup value={deployOptionsStore.pullPolicy} onValueChange={handleDeployPullPolicyChange}>
1170+
<DropdownMenu.RadioItem value="missing">Missing</DropdownMenu.RadioItem>
1171+
<DropdownMenu.RadioItem value="always">
1172+
{m.common_always()}
1173+
</DropdownMenu.RadioItem>
1174+
<DropdownMenu.RadioItem value="never">
1175+
{m.common_never()}
1176+
</DropdownMenu.RadioItem>
1177+
</DropdownMenu.RadioGroup>
1178+
<DropdownMenu.Separator />
1179+
<DropdownMenu.CheckboxItem
1180+
checked={deployOptionsStore.forceRecreate}
1181+
onCheckedChange={(checked) => deployOptionsStore.setForceRecreate(checked === true)}
1182+
>
1183+
{m.deploy_force_recreate()}
1184+
</DropdownMenu.CheckboxItem>
1185+
{/if}
11391186
{/if}
11401187
{:else}
11411188
<DropdownMenu.Item onclick={handleStop} disabled={uiLoading.stop}>

0 commit comments

Comments
 (0)