Skip to content

Commit 1d808bb

Browse files
authored
feat: add removeOrphans option to project deploy/redeploy (#2785)
1 parent f9e20ef commit 1d808bb

7 files changed

Lines changed: 69 additions & 7 deletions

File tree

backend/api/handlers/projects.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ type GetProjectFileOutput struct {
108108
type RedeployProjectInput struct {
109109
EnvironmentID string `path:"id" doc:"Environment ID"`
110110
ProjectID string `path:"projectId" doc:"Project ID"`
111+
Body *project.DeployOptions
111112
}
112113

113114
type RedeployProjectOutput struct {
@@ -811,7 +812,7 @@ func (h *ProjectHandler) RedeployProject(ctx context.Context, input *RedeployPro
811812
return nil, huma.Error401Unauthorized((&common.NotAuthenticatedError{}).Error())
812813
}
813814

814-
if err := h.projectService.RedeployProject(ctx, input.ProjectID, *user); err != nil {
815+
if err := h.projectService.RedeployProject(ctx, input.ProjectID, *user, input.Body); err != nil {
815816
var archivedErr *common.ProjectArchivedError
816817
if errors.As(err, &archivedErr) {
817818
return nil, huma.Error400BadRequest(err.Error())

backend/internal/services/gitops_sync_service.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -682,7 +682,7 @@ func (s *GitOpsSyncService) redeployIfRunningAfterSync(ctx context.Context, proj
682682
}
683683

684684
slog.InfoContext(ctx, "Redeploying project due to content change from Git sync", "syncMode", syncMode, "projectName", project.Name, "projectId", project.ID)
685-
if err := s.projectService.RedeployProject(ctx, project.ID, actor); err != nil {
685+
if err := s.projectService.RedeployProject(ctx, project.ID, actor, nil); err != nil {
686686
slog.ErrorContext(ctx, "Failed to redeploy project after Git sync", "syncMode", syncMode, "error", err, "projectId", project.ID)
687687
}
688688
}
@@ -994,7 +994,7 @@ func (s *GitOpsSyncService) updateProjectForSyncInternal(ctx context.Context, sy
994994
details, err := s.projectService.GetProjectDetails(ctx, project.ID, projecttypes.AllDetails())
995995
if err == nil && (details.Status == string(models.ProjectStatusRunning) || details.Status == string(models.ProjectStatusPartiallyRunning)) {
996996
slog.InfoContext(ctx, "Redeploying project due to content change from Git sync", "projectName", project.Name, "projectId", project.ID)
997-
if err := s.projectService.RedeployProject(ctx, project.ID, actor); err != nil {
997+
if err := s.projectService.RedeployProject(ctx, project.ID, actor, nil); err != nil {
998998
slog.ErrorContext(ctx, "Failed to redeploy project after Git sync", "error", err, "projectId", project.ID)
999999
}
10001000
}

backend/internal/services/project_service.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1843,6 +1843,13 @@ func (s *ProjectService) UnarchiveProject(ctx context.Context, projectID string,
18431843
return nil
18441844
}
18451845

1846+
// resolveRemoveOrphansInternal decides whether compose up should remove orphan containers.
1847+
// GitOps-managed projects always remove orphans so the running stack matches the
1848+
// tracked compose file. Non-GitOps callers may opt in per request via DeployOptions.
1849+
func resolveRemoveOrphansInternal(gitOpsManaged bool, options *project.DeployOptions) bool {
1850+
return gitOpsManaged || (options != nil && options.RemoveOrphans)
1851+
}
1852+
18461853
func (s *ProjectService) DeployProject(ctx context.Context, projectID string, user models.User, options *project.DeployOptions) error {
18471854
projectFromDb, err := s.GetProjectFromDatabaseByID(ctx, projectID)
18481855
if err != nil {
@@ -1880,7 +1887,8 @@ func (s *ProjectService) DeployProject(ctx context.Context, projectID string, us
18801887
return fmt.Errorf("failed to prepare project images for deploy: %w", perr)
18811888
}
18821889

1883-
removeOrphans := projectFromDb.GitOpsManagedBy != nil && *projectFromDb.GitOpsManagedBy != ""
1890+
gitOpsManaged := projectFromDb.GitOpsManagedBy != nil && *projectFromDb.GitOpsManagedBy != ""
1891+
removeOrphans := resolveRemoveOrphansInternal(gitOpsManaged, options)
18841892

18851893
slog.Info("starting compose up with health check support", "projectID", projectID, "projectName", project.Name, "services", len(project.Services), "removeOrphans", removeOrphans)
18861894
// Health/progress streaming (if any) is handled inside projects.ComposeUp via ctx.
@@ -2051,7 +2059,7 @@ func (s *ProjectService) DestroyProject(ctx context.Context, projectID string, r
20512059
return nil
20522060
}
20532061

2054-
func (s *ProjectService) RedeployProject(ctx context.Context, projectID string, user models.User) error {
2062+
func (s *ProjectService) RedeployProject(ctx context.Context, projectID string, user models.User, options *project.DeployOptions) error {
20552063
proj, err := s.GetProjectFromDatabaseByID(ctx, projectID)
20562064
if err != nil {
20572065
return err
@@ -2077,7 +2085,7 @@ func (s *ProjectService) RedeployProject(ctx context.Context, projectID string,
20772085
slog.ErrorContext(ctx, "could not log project redeploy action", "error", logErr)
20782086
}
20792087

2080-
return s.DeployProject(ctx, projectID, user, nil)
2088+
return s.DeployProject(ctx, projectID, user, options)
20812089
}
20822090

20832091
func (s *ProjectService) projectRedeployDisabledInternal(ctx context.Context, proj models.Project) (bool, error) {

backend/internal/services/project_service_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3805,3 +3805,25 @@ func dockerHostFromProjectRuntimeServerURLInternal(t *testing.T, serverURL strin
38053805
func ptr(v string) *string {
38063806
return new(v)
38073807
}
3808+
3809+
func TestResolveRemoveOrphans(t *testing.T) {
3810+
tests := []struct {
3811+
name string
3812+
gitOpsManaged bool
3813+
options *projecttypes.DeployOptions
3814+
want bool
3815+
}{
3816+
{"non-gitops, nil options", false, nil, false},
3817+
{"non-gitops, flag false", false, &projecttypes.DeployOptions{RemoveOrphans: false}, false},
3818+
{"non-gitops, flag true opts in", false, &projecttypes.DeployOptions{RemoveOrphans: true}, true},
3819+
{"gitops, nil options stays true", true, nil, true},
3820+
{"gitops, flag false stays true", true, &projecttypes.DeployOptions{RemoveOrphans: false}, true},
3821+
{"gitops, flag true stays true", true, &projecttypes.DeployOptions{RemoveOrphans: true}, true},
3822+
}
3823+
3824+
for _, tt := range tests {
3825+
t.Run(tt.name, func(t *testing.T) {
3826+
require.Equal(t, tt.want, resolveRemoveOrphansInternal(tt.gitOpsManaged, tt.options))
3827+
})
3828+
}
3829+
}

backend/internal/services/webhook_service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -576,7 +576,7 @@ func (s *WebhookService) executeProjectWebhookActionInternal(ctx context.Context
576576
}
577577
return nil, nil
578578
case models.WebhookActionTypeRedeploy:
579-
if err := s.projectService.RedeployProject(ctx, wh.TargetID, systemUser); err != nil {
579+
if err := s.projectService.RedeployProject(ctx, wh.TargetID, systemUser, nil); err != nil {
580580
return nil, s.wrapWebhookActionErrorInternal(ctx, wh, "project", actionType, err)
581581
}
582582
return nil, nil

backend/pkg/projects/cmds_test.go

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

88
composetypes "github.com/compose-spec/compose-go/v2/types"
9+
"github.com/docker/compose/v5/pkg/api"
910
"github.com/stretchr/testify/require"
1011
)
1112

@@ -107,3 +108,28 @@ func TestFilterProjectServicesForPullInternalReturnsDeepCopiedProject(t *testing
107108
require.Contains(t, project.Services["web"].DependsOn, "db")
108109
require.Contains(t, project.Networks, "default")
109110
}
111+
112+
func TestComposeUpOptions_RemoveOrphans(t *testing.T) {
113+
proj := &composetypes.Project{Name: "test"}
114+
115+
t.Run("removeOrphans true propagates to CreateOptions", func(t *testing.T) {
116+
upOptions, _ := composeUpOptions(proj, nil, true, false)
117+
require.True(t, upOptions.RemoveOrphans)
118+
})
119+
120+
t.Run("removeOrphans false leaves CreateOptions disabled", func(t *testing.T) {
121+
upOptions, _ := composeUpOptions(proj, nil, false, false)
122+
require.False(t, upOptions.RemoveOrphans)
123+
})
124+
125+
t.Run("removeOrphans is independent of forceRecreate", func(t *testing.T) {
126+
// forceRecreate drives the Recreate policy, not RemoveOrphans.
127+
upOptions, _ := composeUpOptions(proj, nil, true, true)
128+
require.True(t, upOptions.RemoveOrphans)
129+
require.Equal(t, api.RecreateForce, upOptions.Recreate)
130+
131+
upOptions, _ = composeUpOptions(proj, nil, false, true)
132+
require.False(t, upOptions.RemoveOrphans)
133+
require.Equal(t, api.RecreateForce, upOptions.Recreate)
134+
})
135+
}

types/project/project.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ type DeployOptions struct {
8080
//
8181
// Required: false
8282
ForceRecreate bool `json:"forceRecreate,omitempty"`
83+
84+
// RemoveOrphans removes containers for services not defined in the compose file.
85+
//
86+
// Required: false
87+
RemoveOrphans bool `json:"removeOrphans,omitempty"`
8388
}
8489

8590
// UpdateIncludeFile is used to update an include file within a project.

0 commit comments

Comments
 (0)