diff --git a/api/exec/compose_stack.go b/api/exec/compose_stack.go index 386cc8c6e..c29b625da 100644 --- a/api/exec/compose_stack.go +++ b/api/exec/compose_stack.go @@ -85,6 +85,27 @@ func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.S return errors.Wrap(err, "failed to remove a stack") } +// Pull an image associated with a service defined in a docker-compose.yml or docker-stack.yml file, +// but does not start containers based on those images. +func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { + url, proxy, err := manager.fetchEndpointProxy(endpoint) + if err != nil { + return err + } + if proxy != nil { + defer proxy.Close() + } + + envFile, err := createEnvFile(stack) + if err != nil { + return errors.Wrap(err, "failed to create env file") + } + + filePaths := stackutils.GetStackFilePaths(stack) + err = manager.deployer.Pull(ctx, stack.ProjectPath, url, stack.Name, filePaths, envFile) + return errors.Wrap(err, "failed to pull images of the stack") +} + // NormalizeStackName returns a new stack name with unsupported characters replaced func (manager *ComposeStackManager) NormalizeStackName(name string) string { return stackNameNormalizeRegex.ReplaceAllString(strings.ToLower(name), "") diff --git a/api/exec/swarm_stack.go b/api/exec/swarm_stack.go index 1c273135b..07818145d 100644 --- a/api/exec/swarm_stack.go +++ b/api/exec/swarm_stack.go @@ -89,7 +89,7 @@ func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error { } // Deploy executes the docker stack deploy command. -func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, endpoint *portainer.Endpoint) error { +func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, pullImage bool, endpoint *portainer.Endpoint) error { filePaths := stackutils.GetStackFilePaths(stack) command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint) if err != nil { @@ -101,6 +101,9 @@ func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, end } else { args = append(args, "stack", "deploy", "--with-registry-auth") } + if !pullImage { + args = append(args, "--resolve-image=never") + } args = configureFilePaths(args, filePaths) args = append(args, stack.Name) diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index 891b551c2..728acf739 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -124,7 +124,7 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, doCleanUp := true defer handler.cleanUp(stack, &doCleanUp) - config, configErr := handler.createComposeDeployConfig(r, stack, endpoint) + config, configErr := handler.createComposeDeployConfig(r, stack, endpoint, false) if configErr != nil { return configErr } @@ -275,7 +275,7 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite } stack.GitConfig.ConfigHash = commitID - config, configErr := handler.createComposeDeployConfig(r, stack, endpoint) + config, configErr := handler.createComposeDeployConfig(r, stack, endpoint, false) if configErr != nil { return configErr } @@ -386,7 +386,7 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, doCleanUp := true defer handler.cleanUp(stack, &doCleanUp) - config, configErr := handler.createComposeDeployConfig(r, stack, endpoint) + config, configErr := handler.createComposeDeployConfig(r, stack, endpoint, false) if configErr != nil { return configErr } @@ -408,14 +408,15 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, } type composeStackDeploymentConfig struct { - stack *portainer.Stack - endpoint *portainer.Endpoint - registries []portainer.Registry - isAdmin bool - user *portainer.User + stack *portainer.Stack + endpoint *portainer.Endpoint + registries []portainer.Registry + isAdmin bool + user *portainer.User + forcePullImage bool } -func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) (*composeStackDeploymentConfig, *httperror.HandlerError) { +func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, forcePullImage bool) (*composeStackDeploymentConfig, *httperror.HandlerError) { securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { return nil, httperror.InternalServerError("Unable to retrieve info from request context", err) @@ -433,11 +434,12 @@ func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portai filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID) config := &composeStackDeploymentConfig{ - stack: stack, - endpoint: endpoint, - registries: filteredRegistries, - isAdmin: securityContext.IsAdmin, - user: user, + stack: stack, + endpoint: endpoint, + registries: filteredRegistries, + isAdmin: securityContext.IsAdmin, + user: user, + forcePullImage: forcePullImage, } return config, nil @@ -477,5 +479,5 @@ func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig, } } - return handler.StackDeployer.DeployComposeStack(config.stack, config.endpoint, config.registries, forceCreate) + return handler.StackDeployer.DeployComposeStack(config.stack, config.endpoint, config.registries, config.forcePullImage, forceCreate) } diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index 1b024fcc0..78e5808a8 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -85,7 +85,7 @@ func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r doCleanUp := true defer handler.cleanUp(stack, &doCleanUp) - config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false) + config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false, true) if configErr != nil { return configErr } @@ -226,7 +226,7 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, } stack.GitConfig.ConfigHash = commitID - config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false) + config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false, true) if configErr != nil { return configErr } @@ -332,7 +332,7 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r doCleanUp := true defer handler.cleanUp(stack, &doCleanUp) - config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false) + config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false, true) if configErr != nil { return configErr } @@ -360,9 +360,10 @@ type swarmStackDeploymentConfig struct { prune bool isAdmin bool user *portainer.User + pullImage bool } -func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, prune bool) (*swarmStackDeploymentConfig, *httperror.HandlerError) { +func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, prune bool, pullImage bool) (*swarmStackDeploymentConfig, *httperror.HandlerError) { securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { return nil, httperror.InternalServerError("Unable to retrieve info from request context", err) @@ -386,6 +387,7 @@ func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portaine prune: prune, isAdmin: securityContext.IsAdmin, user: user, + pullImage: pullImage, } return config, nil @@ -413,5 +415,5 @@ func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) err } } - return handler.StackDeployer.DeploySwarmStack(config.stack, config.endpoint, config.registries, config.prune) + return handler.StackDeployer.DeploySwarmStack(config.stack, config.endpoint, config.registries, config.prune, config.pullImage) } diff --git a/api/http/handler/stacks/stack_migrate.go b/api/http/handler/stacks/stack_migrate.go index 379fe97f5..be2dc309e 100644 --- a/api/http/handler/stacks/stack_migrate.go +++ b/api/http/handler/stacks/stack_migrate.go @@ -189,7 +189,7 @@ func (handler *Handler) migrateStack(r *http.Request, stack *portainer.Stack, ne } func (handler *Handler) migrateComposeStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError { - config, configErr := handler.createComposeDeployConfig(r, stack, next) + config, configErr := handler.createComposeDeployConfig(r, stack, next, false) if configErr != nil { return configErr } @@ -203,7 +203,7 @@ func (handler *Handler) migrateComposeStack(r *http.Request, stack *portainer.St } func (handler *Handler) migrateSwarmStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError { - config, configErr := handler.createSwarmDeployConfig(r, stack, next, true) + config, configErr := handler.createSwarmDeployConfig(r, stack, next, true, true) if configErr != nil { return configErr } diff --git a/api/http/handler/stacks/stack_start.go b/api/http/handler/stacks/stack_start.go index f93e1dce6..31834b008 100644 --- a/api/http/handler/stacks/stack_start.go +++ b/api/http/handler/stacks/stack_start.go @@ -134,7 +134,7 @@ func (handler *Handler) startStack(stack *portainer.Stack, endpoint *portainer.E case portainer.DockerComposeStack: return handler.ComposeStackManager.Up(context.TODO(), stack, endpoint, false) case portainer.DockerSwarmStack: - return handler.SwarmStackManager.Deploy(stack, true, endpoint) + return handler.SwarmStackManager.Deploy(stack, true, true, endpoint) } return nil } diff --git a/api/http/handler/stacks/stack_update.go b/api/http/handler/stacks/stack_update.go index bef1115c5..e2fc7eb48 100644 --- a/api/http/handler/stacks/stack_update.go +++ b/api/http/handler/stacks/stack_update.go @@ -23,6 +23,8 @@ type updateComposeStackPayload 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 + // Force a pulling to current image with the original tag though the image is already the latest + PullImage bool `example:"false"` } func (payload *updateComposeStackPayload) Validate(r *http.Request) error { @@ -39,6 +41,8 @@ type updateSwarmStackPayload struct { Env []portainer.Pair // Prune services that are no longer referenced (only available for Swarm stacks) Prune bool `example:"true"` + // Force a pulling to current image with the original tag though the image is already the latest + PullImage bool `example:"false"` } func (payload *updateSwarmStackPayload) Validate(r *http.Request) error { @@ -199,7 +203,7 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta return httperror.InternalServerError("Unable to persist updated Compose file on disk", err) } - config, configErr := handler.createComposeDeployConfig(r, stack, endpoint) + config, configErr := handler.createComposeDeployConfig(r, stack, endpoint, payload.PullImage) if configErr != nil { if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil { log.Printf("[WARN] [stack,update] [message: rollback stack file error] [err: %s]", rollbackErr) @@ -250,7 +254,7 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack return httperror.InternalServerError("Unable to persist updated Compose file on disk", err) } - config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, payload.Prune) + config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, payload.Prune, payload.PullImage) if configErr != nil { if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil { log.Printf("[WARN] [swarm,stack,update] [message: rollback stack file error] [err: %s]", rollbackErr) diff --git a/api/http/handler/stacks/stack_update_git_redeploy.go b/api/http/handler/stacks/stack_update_git_redeploy.go index f1f8badfc..cb227115d 100644 --- a/api/http/handler/stacks/stack_update_git_redeploy.go +++ b/api/http/handler/stacks/stack_update_git_redeploy.go @@ -25,6 +25,8 @@ type stackGitRedployPayload struct { RepositoryPassword string Env []portainer.Pair Prune bool + // Force a pulling to current image with the original tag though the image is already the latest + PullImage bool `example:"false"` } func (payload *stackGitRedployPayload) Validate(r *http.Request) error { @@ -167,7 +169,7 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) } }() - httpErr := handler.deployStack(r, stack, endpoint) + httpErr := handler.deployStack(r, stack, payload.PullImage, endpoint) if httpErr != nil { return httpErr } @@ -199,14 +201,14 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) return response.JSON(w, stack) } -func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { +func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, pullImage bool, endpoint *portainer.Endpoint) *httperror.HandlerError { switch stack.Type { case portainer.DockerSwarmStack: prune := false if stack.Option != nil { prune = stack.Option.Prune } - config, httpErr := handler.createSwarmDeployConfig(r, stack, endpoint, prune) + config, httpErr := handler.createSwarmDeployConfig(r, stack, endpoint, prune, pullImage) if httpErr != nil { return httpErr } @@ -216,7 +218,7 @@ func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, end } case portainer.DockerComposeStack: - config, httpErr := handler.createComposeDeployConfig(r, stack, endpoint) + config, httpErr := handler.createComposeDeployConfig(r, stack, endpoint, pullImage) if httpErr != nil { return httpErr } diff --git a/api/portainer.go b/api/portainer.go index 10459e5c6..4b237282d 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1244,6 +1244,7 @@ type ( NormalizeStackName(name string) string Up(ctx context.Context, stack *Stack, endpoint *Endpoint, forceRereate bool) error Down(ctx context.Context, stack *Stack, endpoint *Endpoint) error + Pull(ctx context.Context, stack *Stack, endpoint *Endpoint) error } // CryptoService represents a service for encrypting/hashing data @@ -1396,7 +1397,7 @@ type ( SwarmStackManager interface { Login(registries []Registry, endpoint *Endpoint) error Logout(endpoint *Endpoint) error - Deploy(stack *Stack, prune bool, endpoint *Endpoint) error + Deploy(stack *Stack, prune bool, pullImage bool, endpoint *Endpoint) error Remove(stack *Stack, endpoint *Endpoint) error NormalizeStackName(name string) string } diff --git a/api/stacks/deploy.go b/api/stacks/deploy.go index 3e7180687..fca79c4ef 100644 --- a/api/stacks/deploy.go +++ b/api/stacks/deploy.go @@ -89,12 +89,12 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data switch stack.Type { case portainer.DockerComposeStack: - err := deployer.DeployComposeStack(stack, endpoint, registries, false) + err := deployer.DeployComposeStack(stack, endpoint, registries, true, false) if err != nil { return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID) } case portainer.DockerSwarmStack: - err := deployer.DeploySwarmStack(stack, endpoint, registries, true) + err := deployer.DeploySwarmStack(stack, endpoint, registries, true, true) if err != nil { return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID) } diff --git a/api/stacks/deploy_test.go b/api/stacks/deploy_test.go index 37f28d6de..4a45a4b18 100644 --- a/api/stacks/deploy_test.go +++ b/api/stacks/deploy_test.go @@ -27,11 +27,11 @@ func (g *gitService) LatestCommitID(repositoryURL, referenceName, username, pass type noopDeployer struct{} -func (s *noopDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool) error { +func (s *noopDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool, pullImage bool) error { return nil } -func (s *noopDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forceRereate bool) error { +func (s *noopDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage bool, forceRereate bool) error { return nil } diff --git a/api/stacks/deployer.go b/api/stacks/deployer.go index ccd13fd0b..3d15f9ee3 100644 --- a/api/stacks/deployer.go +++ b/api/stacks/deployer.go @@ -13,8 +13,8 @@ import ( ) type StackDeployer interface { - DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool) error - DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forceRereate bool) error + DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool, pullImage bool) error + DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage bool, forceRereate bool) error DeployKubernetesStack(stack *portainer.Stack, endpoint *portainer.Endpoint, user *portainer.User) error } @@ -35,23 +35,31 @@ func NewStackDeployer(swarmStackManager portainer.SwarmStackManager, composeStac } } -func (d *stackDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool) error { +func (d *stackDeployer) DeploySwarmStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune bool, pullImage bool) error { d.lock.Lock() defer d.lock.Unlock() d.swarmStackManager.Login(registries, endpoint) defer d.swarmStackManager.Logout(endpoint) - return d.swarmStackManager.Deploy(stack, prune, endpoint) + return d.swarmStackManager.Deploy(stack, prune, pullImage, endpoint) } -func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forceRereate bool) error { +func (d *stackDeployer) DeployComposeStack(stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, forcePullImage bool, forceRereate bool) error { d.lock.Lock() defer d.lock.Unlock() d.swarmStackManager.Login(registries, endpoint) defer d.swarmStackManager.Logout(endpoint) + // --force-recreate doesn't pull updated images + if forcePullImage { + err := d.composeStackManager.Pull(context.TODO(), stack, endpoint) + if err != nil { + return err + } + } + err := d.composeStackManager.Up(context.TODO(), stack, endpoint, forceRereate) if err != nil { d.composeStackManager.Down(context.TODO(), stack, endpoint) diff --git a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js index 2fe077700..6401b2516 100644 --- a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js +++ b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js @@ -28,6 +28,7 @@ class StackRedeployGitFormController { RepositoryUsername: '', RepositoryPassword: '', Env: [], + PullImage: false, Option: { Prune: false, },