fix(stacks): pass prune option through the deploy pipeline [BE-12738] (#2098)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chaim Lev-Ari
2026-03-22 12:37:40 +02:00
committed by GitHub
parent 5670216d7e
commit a2fee4fc4c
15 changed files with 180 additions and 37 deletions
+1
View File
@@ -70,6 +70,7 @@ func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Sta
},
ForceRecreate: options.ForceRecreate,
AbortOnContainerExit: options.AbortOnContainerExit,
RemoveOrphans: options.Prune,
})
return errors.Wrap(err, "failed to deploy a stack")
}
+1
View File
@@ -199,6 +199,7 @@ func (handler *Handler) migrateComposeStack(r *http.Request, stack *portainer.St
handler.DataStore,
handler.FileService,
handler.StackDeployer,
true,
false,
false)
if err != nil {
+12 -1
View File
@@ -26,6 +26,8 @@ type updateComposeStackPayload struct {
Env []portainer.Pair
// RepullImageAndRedeploy indicates whether to force repulling images and redeploying the stack
RepullImageAndRedeploy bool
// Prune services that are no longer referenced
Prune bool `example:"true"`
// Deprecated(2.36): use RepullImageAndRedeploy instead for cleaner responsibility
// Force a pulling to current image with the original tag though the image is already the latest
@@ -45,7 +47,7 @@ type updateSwarmStackPayload struct {
StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx"`
// A list of environment(endpoint) variables used during stack deployment
Env []portainer.Pair
// Prune services that are no longer referenced (only available for Swarm stacks)
// Prune services that are no longer referenced
Prune bool `example:"true"`
// RepullImageAndRedeploy indicates whether to force repulling images and redeploying the stack
RepullImageAndRedeploy bool
@@ -242,6 +244,7 @@ func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.
endpoint,
handler.FileService,
handler.StackDeployer,
payload.Prune,
payload.RepullImageAndRedeploy,
payload.RepullImageAndRedeploy)
if err != nil {
@@ -252,6 +255,14 @@ func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.
return httperror.InternalServerError(err.Error(), err)
}
if stack.Option != nil {
stack.Option.Prune = payload.Prune
} else {
stack.Option = &portainer.StackOption{
Prune: payload.Prune,
}
}
// Deploy the stack
if err := composeDeploymentConfig.Deploy(); err != nil {
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
@@ -130,8 +130,11 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
payload.RepullImageAndRedeploy = payload.RepullImageAndRedeploy || payload.PullImage
stack.GitConfig.ReferenceName = payload.RepositoryReferenceName
stack.Env = payload.Env
if stack.Type == portainer.DockerSwarmStack {
stack.Option = &portainer.StackOption{Prune: payload.Prune}
if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
if stack.Option == nil {
stack.Option = &portainer.StackOption{}
}
stack.Option.Prune = payload.Prune
}
if stack.Type == portainer.KubernetesStack {
@@ -233,7 +236,9 @@ func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, pul
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
deploymentConfiger, err = deployments.CreateComposeStackDeploymentConfig(securityContext, stack, endpoint, handler.DataStore, handler.FileService, handler.StackDeployer, pullImage, true)
prune := stack.Option != nil && stack.Option.Prune
deploymentConfiger, err = deployments.CreateComposeStackDeploymentConfig(securityContext, stack, endpoint, handler.DataStore, handler.FileService, handler.StackDeployer, prune, pullImage, true)
if err != nil {
return httperror.InternalServerError(err.Error(), err)
}
+71 -17
View File
@@ -16,7 +16,6 @@ import (
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/portainer/portainer/pkg/fips"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
@@ -248,7 +247,7 @@ func TestStackUpdate(t *testing.T) {
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
handler.FileService = fileService
handler.StackDeployer = testStackDeployer{}
handler.StackDeployer = testhelpers.NewTestStackDeployer()
handler.ComposeStackManager = testhelpers.NewComposeStackManager()
handler.SwarmStackManager = swarmStackManager{}
@@ -318,7 +317,11 @@ type updateStackInTxTestSetup struct {
req *http.Request
}
func setupUpdateStackInTxTest(t *testing.T, stack *portainer.Stack, payload *updateComposeStackPayload) *updateStackInTxTestSetup {
type testUpdateStackPayload interface {
*updateComposeStackPayload | *updateSwarmStackPayload
}
func setupUpdateStackInTxTest[T testUpdateStackPayload](t *testing.T, stack *portainer.Stack, payload T) *updateStackInTxTestSetup {
t.Helper()
_, store := datastore.MustNewTestStore(t, true, true)
@@ -364,7 +367,7 @@ func setupUpdateStackInTxTest(t *testing.T, stack *portainer.Stack, payload *upd
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
handler.FileService = fileService
handler.StackDeployer = testStackDeployer{}
handler.StackDeployer = testhelpers.NewTestStackDeployer()
handler.ComposeStackManager = testhelpers.NewComposeStackManager()
// Create mock request with security context
@@ -398,22 +401,73 @@ func (manager swarmStackManager) NormalizeStackName(name string) string {
return name
}
type testStackDeployer struct {
deployments.StackDeployer
func Test_updateSwarmStack_Prune(t *testing.T) {
fips.InitFIPS(false)
payload := &updateSwarmStackPayload{
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
Prune: true,
}
stack := &portainer.Stack{
ID: 1,
Name: "test-stack-prune",
EntryPoint: "docker-compose.yml",
Type: portainer.DockerSwarmStack,
}
setup := setupUpdateStackInTxTest(t, stack, payload)
setup.handler.SwarmStackManager = swarmStackManager{}
deployer := testhelpers.NewTestStackDeployer()
setup.handler.StackDeployer = deployer
err := setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID)
if handlerErr != nil {
return handlerErr
}
return nil
})
require.NoError(t, err, "handler should accept Prune=true and succeed")
stored, err := setup.store.Stack().Read(setup.stack.ID)
require.NoError(t, err)
require.NotNil(t, stored.Option, "stack.Option should not be nil")
assert.True(t, stored.Option.Prune, "stack.Option.Prune should be persisted as true")
assert.Equal(t, 1, deployer.DeploySwarmCallCount, "DeploySwarmStack should be called exactly once")
assert.True(t, deployer.LastPrune, "deployer should be invoked with prune=true")
}
func (testStackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage, forceRecreate bool) error {
return nil
}
func Test_updateComposeStack_Prune(t *testing.T) {
fips.InitFIPS(false)
func (testStackDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error {
return nil
}
payload := &updateComposeStackPayload{
StackFileContent: "version: '3'\nservices:\n web:\n image: nginx:latest",
Prune: true,
}
stack := &portainer.Stack{
ID: 1,
Name: "test-stack-prune",
EntryPoint: "docker-compose.yml",
Type: portainer.DockerComposeStack,
}
setup := setupUpdateStackInTxTest(t, stack, payload)
deployer := testhelpers.NewTestStackDeployer()
setup.handler.StackDeployer = deployer
func (testStackDeployer) DeployRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage, forceRecreate bool) error {
return nil
}
err := setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID)
if handlerErr != nil {
return handlerErr
}
return nil
})
require.NoError(t, err, "handler should accept Prune=true and succeed")
func (testStackDeployer) DeployRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error {
return nil
stored, err := setup.store.Stack().Read(setup.stack.ID)
require.NoError(t, err)
require.NotNil(t, stored.Option, "stack.Option should not be nil")
assert.True(t, stored.Option.Prune, "stack.Option.Prune should be persisted as true")
assert.Equal(t, 1, deployer.DeployComposeCallCount, "DeployComposeStack should be called exactly once")
assert.True(t, deployer.LastPrune, "deployer should be invoked with prune=true")
}
@@ -0,0 +1,61 @@
package testhelpers
import portainer "github.com/portainer/portainer/api"
type TestStackDeployer struct {
DeployComposeCallCount int
DeploySwarmCallCount int
LastPrune bool
}
func NewTestStackDeployer() *TestStackDeployer {
return &TestStackDeployer{}
}
func (d *TestStackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, forcePullImage, forceRecreate bool) error {
d.DeployComposeCallCount++
d.LastPrune = prune
return nil
}
func (d *TestStackDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error {
d.DeploySwarmCallCount++
d.LastPrune = prune
return nil
}
func (d *TestStackDeployer) DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error {
return nil
}
func (d *TestStackDeployer) DeployRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, forcePullImage, forceRecreate bool) error {
return nil
}
func (d *TestStackDeployer) UndeployRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
return nil
}
func (d *TestStackDeployer) StartRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error {
return nil
}
func (d *TestStackDeployer) StopRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
return nil
}
func (d *TestStackDeployer) DeployRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error {
return nil
}
func (d *TestStackDeployer) UndeployRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
return nil
}
func (d *TestStackDeployer) StartRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error {
return nil
}
func (d *TestStackDeployer) StopRemoteSwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
return nil
}
@@ -62,12 +62,17 @@ func (d *stackDeployer) buildUnpackerCmdForStack(stack *portainer.Stack, operati
return fn(stack, opts, registriesStrings, envStrings), nil
}
// deploy [-u username -p password] [--skip-tls-verify] [--force-recreate] [-k] [--env KEY1=VALUE1 --env KEY2=VALUE2] <git-repo-url> <ref> <project-name> <destination> <compose-file-path> [<more-file-paths>...]
// deploy [-u username -p password] [--skip-tls-verify] [--force-recreate] [-r] [-k] [--env KEY1=VALUE1 --env KEY2=VALUE2] <git-repo-url> <ref> <project-name> <destination> <compose-file-path> [<more-file-paths>...]
func buildDeployCmd(stack *portainer.Stack, opts unpackerCmdBuilderOptions, registries []string, env []string) []string {
cmd := []string{UnpackerCmdDeploy}
cmd = appendGitAuthIfNeeded(cmd, stack)
cmd = appendSkipTLSVerifyIfNeeded(cmd, stack)
cmd = appendForceRecreateIfNeeded(cmd, opts.forceRecreate)
if opts.prune {
cmd = append(cmd, "-r")
}
cmd = append(cmd, env...)
cmd = append(cmd, registries...)
cmd = append(cmd,
+2 -2
View File
@@ -159,9 +159,9 @@ func redeployWhenChangedSecondStage(
switch stack.Type {
case portainer.DockerComposeStack:
if stackutils.IsRelativePathStack(stack) {
err = deployer.DeployRemoteComposeStack(stack, endpoint, registries, true, false)
err = deployer.DeployRemoteComposeStack(stack, endpoint, registries, true, true, false)
} else {
err = deployer.DeployComposeStack(stack, endpoint, registries, true, false)
err = deployer.DeployComposeStack(stack, endpoint, registries, true, true, false)
}
if err != nil {
+2 -2
View File
@@ -82,7 +82,7 @@ func (s noopDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portain
return nil
}
func (s noopDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage, forceRecreate bool) error {
func (s noopDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, forcePullImage, forceRecreate bool) error {
return nil
}
@@ -91,7 +91,7 @@ func (s noopDeployer) DeployKubernetesStack(stack *portainer.Stack, endpoint *po
}
// with unpacker
func (s noopDeployer) DeployRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage, forceRecreate bool) error {
func (s noopDeployer) DeployRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, forcePullImage, forceRecreate bool) error {
return nil
}
func (s noopDeployer) UndeployRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
+3 -2
View File
@@ -15,7 +15,7 @@ import (
type BaseStackDeployer interface {
DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error
DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage, forceRecreate bool) error
DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, forcePullImage, forceRecreate bool) error
DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error
}
@@ -61,7 +61,7 @@ func (d *stackDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *porta
return d.swarmStackManager.Deploy(stack, prune, pullImage, endpoint)
}
func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage, forceRecreate bool) error {
func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, forcePullImage, forceRecreate bool) error {
d.lock.Lock()
defer d.lock.Unlock()
@@ -77,6 +77,7 @@ func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *por
return d.composeStackManager.Up(context.TODO(), stack, endpoint, portainer.ComposeUpOptions{
ComposeOptions: options,
ForceRecreate: forceRecreate,
Prune: prune,
})
}
+3 -1
View File
@@ -34,7 +34,7 @@ const (
type RemoteStackDeployer interface {
// compose
DeployRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage bool, forceRecreate bool) error
DeployRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool, forcePullImage bool, forceRecreate bool) error
UndeployRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error
StartRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry) error
StopRemoteComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error
@@ -50,6 +50,7 @@ func (d *stackDeployer) DeployRemoteComposeStack(
stack *portainer.Stack,
endpoint *portainer.Endpoint,
registries []portainer.Registry,
prune bool,
forcePullImage bool,
forceRecreate bool,
) error {
@@ -72,6 +73,7 @@ func (d *stackDeployer) DeployRemoteComposeStack(
unpackerCmdBuilderOptions{
forceRecreate: forceRecreate,
registries: registries,
prune: prune,
},
)
}
@@ -22,16 +22,17 @@ type ComposeStackDeploymentConfig struct {
ForceCreate bool
FileService portainer.FileService
StackDeployer StackDeployer
prune bool
}
func CreateComposeStackDeploymentConfig(securityContext *security.RestrictedRequestContext, stack *portainer.Stack, endpoint *portainer.Endpoint, dataStore dataservices.DataStore, fileService portainer.FileService, deployer StackDeployer, forcePullImage, forceCreate bool) (*ComposeStackDeploymentConfig, error) {
return CreateComposeStackDeploymentConfigTx(dataStore, securityContext, stack, endpoint, fileService, deployer, forcePullImage, forceCreate)
func CreateComposeStackDeploymentConfig(securityContext *security.RestrictedRequestContext, stack *portainer.Stack, endpoint *portainer.Endpoint, dataStore dataservices.DataStore, fileService portainer.FileService, deployer StackDeployer, prune, forcePullImage, forceCreate bool) (*ComposeStackDeploymentConfig, error) {
return CreateComposeStackDeploymentConfigTx(dataStore, securityContext, stack, endpoint, fileService, deployer, prune, forcePullImage, forceCreate)
}
// Alternate function that works within a transaction
// We didn't update the original function to use a transaction because it would be a breaking change for many other files.
// Let's do this only where necessary for now. This is also planed to be refactored in the future, but not prioritized right now.
func CreateComposeStackDeploymentConfigTx(tx dataservices.DataStoreTx, securityContext *security.RestrictedRequestContext, stack *portainer.Stack, endpoint *portainer.Endpoint, fileService portainer.FileService, deployer StackDeployer, forcePullImage, forceCreate bool) (*ComposeStackDeploymentConfig, error) {
func CreateComposeStackDeploymentConfigTx(tx dataservices.DataStoreTx, securityContext *security.RestrictedRequestContext, stack *portainer.Stack, endpoint *portainer.Endpoint, fileService portainer.FileService, deployer StackDeployer, prune, forcePullImage, forceCreate bool) (*ComposeStackDeploymentConfig, error) {
user, err := tx.User().Read(securityContext.UserID)
if err != nil {
return nil, fmt.Errorf("unable to load user information from the database: %w", err)
@@ -48,6 +49,7 @@ func CreateComposeStackDeploymentConfigTx(tx dataservices.DataStoreTx, securityC
stack: stack,
endpoint: endpoint,
registries: filteredRegistries,
prune: prune,
isAdmin: securityContext.IsAdmin,
user: user,
forcePullImage: forcePullImage,
@@ -86,10 +88,10 @@ func (config *ComposeStackDeploymentConfig) Deploy() error {
}
if stackutils.IsRelativePathStack(config.stack) {
return config.StackDeployer.DeployRemoteComposeStack(config.stack, config.endpoint, config.registries, config.forcePullImage, config.ForceCreate)
return config.StackDeployer.DeployRemoteComposeStack(config.stack, config.endpoint, config.registries, config.prune, config.forcePullImage, config.ForceCreate)
}
return config.StackDeployer.DeployComposeStack(config.stack, config.endpoint, config.registries, config.forcePullImage, config.ForceCreate)
return config.StackDeployer.DeployComposeStack(config.stack, config.endpoint, config.registries, config.prune, config.forcePullImage, config.ForceCreate)
}
func (config *ComposeStackDeploymentConfig) GetResponse() string {
@@ -68,7 +68,7 @@ func (b *ComposeStackFileContentBuilder) Deploy(payload *StackPayload, endpoint
return b
}
composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfig(b.SecurityContext, b.stack, endpoint, b.dataStore, b.fileService, b.stackDeployer, false, false)
composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfig(b.SecurityContext, b.stack, endpoint, b.dataStore, b.fileService, b.stackDeployer, false, false, false)
if err != nil {
b.err = err
return b
@@ -58,7 +58,7 @@ func (b *ComposeStackFileUploadBuilder) Deploy(payload *StackPayload, endpoint *
return b
}
composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfig(b.SecurityContext, b.stack, endpoint, b.dataStore, b.fileService, b.stackDeployer, false, false)
composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfig(b.SecurityContext, b.stack, endpoint, b.dataStore, b.fileService, b.stackDeployer, false, false, false)
if err != nil {
b.err = err
return b
@@ -58,7 +58,7 @@ func (b *ComposeStackGitBuilder) Deploy(payload *StackPayload, endpoint *portain
return b
}
composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfig(b.SecurityContext, b.stack, endpoint, b.dataStore, b.fileService, b.stackDeployer, false, false)
composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfig(b.SecurityContext, b.stack, endpoint, b.dataStore, b.fileService, b.stackDeployer, false, false, false)
if err != nil {
b.err = err
return b