Compare commits

...

1 Commits

Author SHA1 Message Date
vvzvlad e63d2ffe9b fix(automation): update a single container instead of redeploying its stack
Clicking "Update" on a stack member (and the native auto-update daemon
updating one) redeployed the whole compose stack instead of updating just
that container. Match Watchtower behaviour: always recreate the single
container with a re-pull. The recreate endpoint preserves config + compose
labels, so the container stays part of its project.

Collapse all update surfaces to a single-container recreate and drop the
now-dead stack-aware routing:
- frontend: "Update now" button, list badge and bulk "Update selected" now
  recreate each container individually; remove standalone/stack/external
  routing, the external refusal, the PortainerStackUpdate gate and the
  stack-update confirm dialog.
- daemon: route every outdated candidate through updateStandalone; remove
  updateStack, the stack/external grouping and the stackDeployer dependency.
- add a regression test asserting a Portainer-managed compose-stack member is
  recreated individually, not stack-redeployed.

Behavioural notes: git/external compose containers are now auto-updated too
(were detect-only), and updating a stack member no longer requires
PortainerStackUpdate (same auth as the normal Recreate action).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:21:29 +03:00
21 changed files with 240 additions and 1307 deletions
+1 -1
View File
@@ -579,7 +579,7 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow
}
containerService := docker.NewContainerService(dockerClientFactory, dataStore)
containerAutomationService := containerautomation.NewService(shutdownCtx, scheduler, dataStore, dockerClientFactory, containerService, stackDeployer)
containerAutomationService := containerautomation.NewService(shutdownCtx, scheduler, dataStore, dockerClientFactory, containerService)
containerAutomationService.Start()
sslDBSettings, err := dataStore.SSLSettings().Settings()
+14 -157
View File
@@ -9,8 +9,6 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/docker/images"
"github.com/portainer/portainer/api/internal/endpointutils"
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/api/stacks/stackutils"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
@@ -24,8 +22,6 @@ const (
// recreateTimeout bounds a standalone recreate (pull + stop + create + start).
// Pulls can be slow, so it is generous.
recreateTimeout = 10 * time.Minute
// stackRedeployTimeout bounds a single stack redeploy-with-pull.
stackRedeployTimeout = 15 * time.Minute
)
// update runs a single auto-update pass over every reachable Docker endpoint.
@@ -112,15 +108,15 @@ func parseRollbackTimeout(raw string) time.Duration {
}
// updateEndpoint applies image updates to the in-scope, outdated containers of a
// single endpoint, routing each container to the standalone / stack / external
// apply path. Stack-managed candidates are grouped so each owning stack is
// redeployed at most once per tick.
// single endpoint. Every candidate is recreated individually with a re-pull of
// its image (Watchtower-style), so a compose stack member keeps its labels and
// stays part of its project without redeploying the owning stack.
func (s *Service) updateEndpoint(endpoint *portainer.Endpoint, scope string, opts updateOptions) {
endpointID := int(endpoint.ID)
// Swarm note (M4 limitation, mirrors auto-heal): we connect to the endpoint's
// primary node only (nodeName ""). Containers scheduled on other Swarm nodes
// are not updated here; stacks are redeployed cluster-wide by the swarm engine.
// are not updated here.
clientTimeout := endpointTimeout
cli, err := s.clientFactory.CreateClient(endpoint, "", &clientTimeout)
if err != nil {
@@ -179,51 +175,15 @@ func (s *Service) updateEndpoint(endpoint *portainer.Endpoint, scope string, opt
candidates = append(candidates, UpdateCandidate{ID: c.ID, Name: containerName(c.Names), ImageID: c.ImageID, Image: c.Image, Labels: c.Labels})
}
// Route and de-duplicate: one redeploy per stack per tick.
grouped := groupContainersForUpdate(candidates, s.stackLookupForEndpoint(endpoint.ID))
for _, ext := range grouped.External {
log.Debug().Str("container_id", ext.ID).Int("endpoint_id", endpointID).
Msg("auto-update: outdated externally-managed compose container, detect only")
}
for _, c := range grouped.Standalone {
// Recreate every candidate individually with a re-pull of its image, exactly
// like the standalone path. A stack member keeps its compose labels through the
// recreate, so it stays part of its project without redeploying the stack.
for _, c := range candidates {
s.updateStandalone(cli, endpoint, c, opts)
}
for _, st := range grouped.Stacks {
s.updateStack(cli, endpoint, st)
}
}
// stackLookupForEndpoint builds a compose-project-name -> Portainer compose stack
// resolver for a single endpoint. Only Docker Compose stacks on this endpoint
// match; a same-named swarm/kubernetes stack is treated as external (mirrors
// M3's resolveContainerUpdatePath).
func (s *Service) stackLookupForEndpoint(endpointID portainer.EndpointID) func(project string) *StackMatch {
stacks, err := s.dataStore.Stack().ReadAll()
if err != nil {
log.Warn().Err(err).Int("endpoint_id", int(endpointID)).
Msg("auto-update: unable to read stacks, treating compose containers as external")
return func(string) *StackMatch { return nil }
}
byName := make(map[string]*StackMatch)
for i := range stacks {
st := &stacks[i]
if st.EndpointID != endpointID || st.Type != portainer.DockerComposeStack {
continue
}
byName[st.Name] = &StackMatch{StackID: int(st.ID), IsGit: st.WorkflowID != 0}
}
return func(project string) *StackMatch {
return byName[project]
}
}
// updateStandalone recreates a standalone container with a re-pull of its image,
// updateStandalone recreates a container with a re-pull of its image,
// then (when rollback is enabled and the container has a healthcheck) holds a
// health gate over the new container and rolls back to the previous image if it
// fails to become healthy. The old-image cleanup is deliberately ordered AFTER
@@ -245,7 +205,7 @@ func (s *Service) updateStandalone(cli dockerClient, endpoint *portainer.Endpoin
// unaffected. (With rollback off there is no rollback to loop, so we proceed.)
if skipUnnamedForRollback(opts.rollback, c.Name) {
log.Info().Str("container_id", c.ID).Int("endpoint_id", endpointID).
Msg("auto-update: skipping unnamed standalone container, rollback is enabled but there is no stable name to key the loop guard")
Msg("auto-update: skipping unnamed container, rollback is enabled but there is no stable name to key the loop guard")
return
}
@@ -313,16 +273,16 @@ func (s *Service) updateStandalone(cli dockerClient, endpoint *portainer.Endpoin
// Recreate preserves config and rolls back on a create failure; a pull or
// create failure leaves the original container running.
log.Warn().Err(err).Str("container_id", c.ID).Int("endpoint_id", endpointID).
Msg("auto-update: failed to recreate standalone container")
Msg("auto-update: failed to recreate container")
s.notifier.Notify(Event{
Kind: EventUpdateFailed, EndpointID: endpointID, ContainerID: c.ID, ContainerName: c.Name,
Message: "failed to recreate standalone container", Err: err,
Message: "failed to recreate container", Err: err,
})
return
}
log.Info().Str("container_id", c.ID).Int("endpoint_id", endpointID).
Msg("auto-update: recreated standalone container with updated image")
Msg("auto-update: recreated container with updated image")
newImage := ""
if newContainer != nil {
newImage = newContainer.Config.Image
@@ -353,7 +313,7 @@ func (s *Service) updateStandalone(cli dockerClient, endpoint *portainer.Endpoin
s.notifier.Notify(Event{
Kind: EventUpdated, EndpointID: endpointID, ContainerID: newContainer.ID, ContainerName: c.Name,
Image: newImage, OldDigest: oldImageID, NewDigest: newContainer.Image,
Message: "updated standalone container",
Message: "updated container",
})
if opts.cleanup && newContainer != nil && newContainer.Image != oldImageID {
@@ -492,106 +452,3 @@ func (s *Service) cleanupOldImage(cli dockerClient, endpoint *portainer.Endpoint
log.Info().Str("image_id", oldImageID).Int("endpoint_id", int(endpoint.ID)).
Msg("auto-update: removed dangling old image after update")
}
// updateStack applies an image update to a Portainer-managed compose stack so its
// containers are recreated by the stack engine and stay part of the stack. It is
// called at most once per stack per tick.
//
// - git stacks: detect-only here. A git stack's source of truth is its commit;
// this tick's trigger is an image-only update (same compose manifest, newer
// upstream digest), which the git redeploy path (RedeployWhenChanged) would
// short-circuit without applying — while still doing a real git fetch every
// tick. So we skip git stacks: the image update lands on the stack's next git
// change or via a manual "Update now", and we do not fetch git every tick.
// - file stacks: the deployer is driven directly with forcePullImage=true,
// applying the image update immediately.
//
// On a successful file-stack redeploy it emits one EventUpdated per member
// container that triggered the update (not a single aggregate stack event), each
// carrying the stack name and a best-effort post-redeploy new image id.
func (s *Service) updateStack(cli dockerClient, endpoint *portainer.Endpoint, st StackUpdate) {
if st.IsGit {
// Detect-only: leave git bookkeeping to the git redeploy path. Logged at
// debug so it does not repeat at info on every tick (it would otherwise
// fire for an unchanged git stack indefinitely).
log.Debug().Int("stack_id", st.StackID).Int("endpoint_id", int(endpoint.ID)).
Msg("auto-update: outdated git stack image detected, detect only (applied on next git change or manual update)")
return
}
ctx, cancel := context.WithTimeout(s.baseCtx, stackRedeployTimeout)
defer cancel()
stack, err := s.dataStore.Stack().Read(portainer.StackID(st.StackID))
if err != nil {
log.Warn().Err(err).Int("stack_id", st.StackID).Int("endpoint_id", int(endpoint.ID)).
Msg("auto-update: unable to read stack for redeploy")
return
}
// Resolve registries the same way the established userless/system redeploy does
// (RedeployWhenChanged): scope them to the stack author's access on the endpoint
// and refresh ECR tokens, so an ECR-backed stack authenticates with fresh
// credentials instead of the stale token a raw ReadAll() would pass.
registries, err := deployments.ResolveStackRegistries(s.dataStore, stack, endpoint.ID)
if err != nil {
log.Warn().Err(err).Int("stack_id", st.StackID).Int("endpoint_id", int(endpoint.ID)).
Msg("auto-update: unable to resolve registries for stack redeploy")
return
}
// prune=false (conservative: do not remove resources the user may rely on),
// forcePullImage=true (the whole point), forceRecreate=false.
if stackutils.IsRelativePathStack(stack) {
err = s.stackDeployer.DeployRemoteComposeStack(ctx, stack, endpoint, registries, false, true, false)
} else {
err = s.stackDeployer.DeployComposeStack(ctx, stack, endpoint, registries, false, true, false)
}
if err != nil {
log.Warn().Err(err).Int("stack_id", st.StackID).Int("endpoint_id", int(endpoint.ID)).
Msg("auto-update: failed to redeploy compose stack with re-pull")
return
}
log.Info().Int("stack_id", st.StackID).Int("endpoint_id", int(endpoint.ID)).
Msg("auto-update: redeployed compose stack with updated images")
// One notification PER updated container (the maintainer's requirement), each
// showing the container's stack name. The stack was redeployed as a whole, so the
// per-container new image id is not in hand; re-inspect each container by its
// (compose-stable) name to fill in the "new" digest best-effort. A failed inspect
// leaves NewDigest empty and the message falls back to "image updated" — never a
// blocked delivery.
for _, c := range st.Containers {
s.notifier.Notify(Event{
Kind: EventUpdated, EndpointID: int(endpoint.ID), StackID: st.StackID,
StackName: c.Labels[composeProjectLabel], ContainerName: c.Name,
Image: c.Image, OldDigest: c.ImageID, NewDigest: s.inspectImageID(cli, c.Name),
Message: "updated stack container",
})
}
}
// inspectImageID re-inspects a container by its (compose-stable) name after a stack
// redeploy to recover the new local image id for the update notification. It is
// best-effort: any failure (or an empty name) yields "", and the caller degrades the
// message to "image updated" rather than blocking delivery. The inspect is bounded
// like every other engine call so a hung engine cannot stall the tick.
func (s *Service) inspectImageID(cli dockerClient, containerName string) string {
if containerName == "" {
return ""
}
ctx, cancel := context.WithTimeout(s.baseCtx, endpointTimeout)
defer cancel()
inspect, err := cli.ContainerInspect(ctx, containerName)
if err != nil {
log.Debug().Err(err).Str("container", containerName).
Msg("auto-update: unable to inspect stack container for its new image id, notifying without it")
return ""
}
return inspect.Image
}
+103 -112
View File
@@ -10,139 +10,130 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/docker"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/docker/images"
"github.com/docker/docker/api/types/container"
dockerclient "github.com/docker/docker/client"
"github.com/stretchr/testify/require"
)
// newStackInspectClient builds a Docker client wired to a test server that answers
// ContainerInspect by name, returning the given new image id. It is the seam the
// post-redeploy best-effort "new digest" re-inspect uses.
func newStackInspectClient(t *testing.T, newImageIDByName map[string]string) *dockerclient.Client {
t.Helper()
// TestUpdateEndpointRecreatesComposeStackMemberIndividually is the regression
// guard for the reported bug: a member of a PORTAINER-MANAGED compose stack that
// is in scope and outdated must be recreated INDIVIDUALLY via the standalone
// recreate path (like Watchtower), never redeployed as a whole stack.
//
// The test store is seeded with a real Portainer Docker Compose stack whose name
// matches the container's com.docker.compose.project label and endpoint, so the
// container is a genuine managed stack member — exactly the case that used to
// trigger a whole-stack redeploy under the old routing. Under the fix it flows
// straight to updateStandalone, which calls containerService.Recreate for that
// single container.
//
// It drives the real updateEndpoint against a mock Docker daemon (the production
// ClientFactory reaches loopback because SSRF filtering is inactive when the
// global dialer is unconfigured, as in a plain unit test). The individual recreate
// is deliberately failed at the image pull (the mock daemon rejects
// POST /images/create), which keeps the test fully offline and avoids faking the
// entire successful recreate sequence. That is sufficient to lock in the routing:
// updateStandalone emits EventUpdateFailed ONLY after it has invoked Recreate for
// this container — so the single per-container event proves the managed stack
// member reached the individual recreate path rather than a whole-stack redeploy.
func TestUpdateEndpointRecreatesComposeStackMemberIndividually(t *testing.T) {
const (
containerID = "web-1"
imageID = "sha256:" + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
)
// Mock Docker daemon: enough of the API to let updateEndpoint list one
// compose-labelled container, resolve its status, and begin an individual
// recreate whose image pull is rejected.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for name, imageID := range newImageIDByName {
if strings.HasSuffix(r.URL.Path, "/containers/"+name+"/json") {
_ = json.NewEncoder(w).Encode(container.InspectResponse{
ContainerJSONBase: &container.ContainerJSONBase{ID: name, Image: imageID},
Config: &container.Config{},
})
return
}
}
switch {
case strings.HasSuffix(r.URL.Path, "/_ping"):
// API version negotiation performed by the production client.
w.Header().Set("Api-Version", "1.41")
w.Header().Set("Ostype", "linux")
w.WriteHeader(http.StatusOK)
http.Error(w, "not found", http.StatusNotFound)
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/images/create"):
// The forced re-pull inside Recreate: reject it so the individual
// recreate fails deterministically without contacting a real registry.
http.Error(w, `{"message":"pull rejected in test"}`, http.StatusInternalServerError)
case strings.HasSuffix(r.URL.Path, "/containers/json"):
// ContainerList: a single running, compose-stack-labelled container.
_ = json.NewEncoder(w).Encode([]container.Summary{{
ID: containerID,
Names: []string{"/" + containerID},
Image: "nginx:latest",
ImageID: imageID,
Labels: map[string]string{
"com.docker.compose.project": "regression-stack",
"io.portainer.update.enable": "true",
},
}})
case strings.HasSuffix(r.URL.Path, "/containers/"+containerID+"/json"):
// ContainerInspect (status check) and ContainerInspectWithRaw (recreate).
// The sha256 Image lets the status check resolve the local image id; the
// Config.Image is a parseable reference so Recreate proceeds to the pull.
_ = json.NewEncoder(w).Encode(container.InspectResponse{
ContainerJSONBase: &container.ContainerJSONBase{
ID: containerID,
Name: "/" + containerID,
Image: imageID,
},
Config: &container.Config{Image: "nginx:latest"},
})
default:
http.Error(w, "not found", http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
cli, err := dockerclient.NewClientWithOpts(
dockerclient.WithHost(srv.URL),
dockerclient.WithHTTPClient(http.DefaultClient),
)
require.NoError(t, err)
// Seed the status cache keyed by the local image id so the status check reports
// Outdated without any registry lookup, making the candidate eligible.
images.CacheResourceImageStatus(imageID, images.Outdated)
t.Cleanup(func() { images.EvictImageStatus(imageID) })
return cli
}
// TestUpdateStackEmitsPerContainerEvents proves the maintainer's requirement: a
// (file) stack redeploy emits one EventUpdated PER updated member container, each
// carrying the compose stack name (from the container's label, not a Stack().Read)
// and a best-effort post-redeploy new image id — never a single aggregate stack
// event.
func TestUpdateStackEmitsPerContainerEvents(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, false)
// A stack author must exist for registry resolution; an admin resolves to the
// (empty) registry set without needing endpoint/team wiring.
require.NoError(t, store.User().Create(&portainer.User{ID: 1, Username: "auto", Role: portainer.AdministratorRole}))
endpoint := &portainer.Endpoint{ID: 1, Name: "nebula.lc"}
require.NoError(t, store.Endpoint().Create(endpoint))
// Seed a real Portainer-managed Docker Compose stack whose name matches the
// container's compose project label on this endpoint, so the container is a
// genuine managed stack member (the case that used to trigger a whole-stack
// redeploy), not an externally-managed compose container.
require.NoError(t, store.Stack().Create(&portainer.Stack{
ID: 7, EndpointID: 1, Name: "cache-demo", Type: portainer.DockerComposeStack, CreatedBy: "auto",
ID: 1, Name: "regression-stack", Type: portainer.DockerComposeStack, EndpointID: 1,
}))
const (
oldEsphome = "sha256:59b94983c73a000000000000000000000000000000000000000000000000aaaa"
newEsphome = "sha256:2231ca5d676d000000000000000000000000000000000000000000000000bbbb"
oldOther = "sha256:1111111111110000000000000000000000000000000000000000000000000000"
newOther = "sha256:2222222222220000000000000000000000000000000000000000000000000000"
)
cli := newStackInspectClient(t, map[string]string{
"esphome": newEsphome,
"other": newOther,
})
// One ClientFactory shared by updateEndpoint, the digest client and the
// container service, all pointed at the mock daemon via the endpoint URL.
factory := dockerclient.NewClientFactory(nil, nil)
rec := &recordingNotifier{}
s := &Service{
baseCtx: context.Background(),
dataStore: store,
stackDeployer: testhelpers.NewTestStackDeployer(),
notifier: rec,
baseCtx: context.Background(),
dataStore: store,
clientFactory: factory,
digestClient: images.NewClientWithRegistry(images.NewRegistryClient(store), factory),
containerService: docker.NewContainerService(factory, store),
notifier: rec,
rolledBack: make(map[string]rolledBackTarget),
}
st := StackUpdate{
StackID: 7,
IsGit: false,
Containers: []UpdateCandidate{
{Name: "esphome", ImageID: oldEsphome, Image: "esphome/esphome:latest", Labels: map[string]string{composeProjectLabel: "cache-demo"}},
{Name: "other", ImageID: oldOther, Image: "redis:7", Labels: map[string]string{composeProjectLabel: "cache-demo"}},
},
}
endpoint := &portainer.Endpoint{ID: 1, Name: "nebula.lc", URL: srv.URL, Type: portainer.DockerEnvironment}
s.updateStack(cli, endpoint, st)
s.updateEndpoint(endpoint, ScopeAll, updateOptions{})
require.Len(t, rec.events, 2, "one EventUpdated per updated member container, not one aggregate stack event")
byContainer := map[string]Event{}
for _, e := range rec.events {
require.Equal(t, EventUpdated, e.Kind)
require.Equal(t, "cache-demo", e.StackName, "each per-container event carries the compose stack name")
require.Equal(t, 7, e.StackID)
byContainer[e.ContainerName] = e
}
esphome, ok := byContainer["esphome"]
require.True(t, ok, "expected a per-container event for esphome")
require.Equal(t, oldEsphome, esphome.OldDigest)
require.Equal(t, newEsphome, esphome.NewDigest, "the new image id is recovered by re-inspecting the container after redeploy")
other, ok := byContainer["other"]
require.True(t, ok, "expected a per-container event for other")
require.Equal(t, oldOther, other.OldDigest)
require.Equal(t, newOther, other.NewDigest)
}
// TestUpdateStackGitIsDetectOnly guards that a git stack stays detect-only: it is
// not redeployed and emits no notification (its image update lands on the next git
// change or a manual update).
func TestUpdateStackGitIsDetectOnly(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, false)
endpoint := &portainer.Endpoint{ID: 1, Name: "nebula.lc"}
require.NoError(t, store.Endpoint().Create(endpoint))
deployer := testhelpers.NewTestStackDeployer()
rec := &recordingNotifier{}
s := &Service{
baseCtx: context.Background(),
dataStore: store,
stackDeployer: deployer,
notifier: rec,
}
cli := newStackInspectClient(t, nil)
s.updateStack(cli, endpoint, StackUpdate{
StackID: 9, IsGit: true,
Containers: []UpdateCandidate{{Name: "esphome", Labels: map[string]string{composeProjectLabel: "cache-demo"}}},
})
require.Empty(t, rec.events, "a git stack is detect-only, no per-container notification")
require.Zero(t, deployer.DeployComposeCallCount, "a git stack must not be redeployed here")
// Exactly one per-container event, and it is the standalone recreate outcome for
// this managed stack member. Under the old routing this member would have gone to
// the whole-stack redeploy path (a different flow), so the single per-container
// recreate event is the real discriminator; StackID staying zero is a secondary
// confirmation that no stack-scoped redeploy was taken.
require.Len(t, rec.events, 1, "the managed stack member is recreated individually, emitting one per-container event")
require.Equal(t, EventUpdateFailed, rec.events[0].Kind)
require.Equal(t, containerID, rec.events[0].ContainerName)
require.Contains(t, rec.events[0].Message, "recreate", "the event reflects the individual recreate path")
require.Zero(t, rec.events[0].StackID, "no whole-stack redeploy path is taken for a managed stack member")
}
-110
View File
@@ -20,9 +20,6 @@ const (
labelUpdateMonitorOnly = "io.portainer.update.monitor-only"
labelUpdateMonitorOnlyAlias = "com.centurylinklabs.watchtower.monitor-only"
// composeProjectLabel identifies the compose project a container belongs to.
composeProjectLabel = "com.docker.compose.project"
// Defaults used when a label is missing or holds an invalid value.
defaultStopTimeout = 10
defaultRetries = 3
@@ -102,60 +99,6 @@ func IsMonitorOnly(labels map[string]string) bool {
return present && value
}
// UpdateKind is the apply path resolved for an outdated container.
type UpdateKind string
const (
// UpdateStandalone: recreate-with-pull (no compose project).
UpdateStandalone UpdateKind = "standalone"
// UpdateStack: redeploy the owning Portainer compose stack with re-pull, so
// the container stays part of its stack.
UpdateStack UpdateKind = "stack"
// UpdateExternal: compose-managed but with no matching Portainer compose
// stack record; Portainer must not touch it (would detach it / drift).
UpdateExternal UpdateKind = "external"
)
// StackMatch is the Portainer Docker Compose stack a compose project resolves to.
type StackMatch struct {
StackID int
// IsGit routes file vs git redeploy at apply time.
IsGit bool
}
// UpdateRouting is the decision returned by resolveContainerUpdateRouting.
type UpdateRouting struct {
Kind UpdateKind
StackID int
IsGit bool
}
// resolveContainerUpdateRouting decides how a container's image update must be
// applied, given a lookup that resolves a compose project name to a matching
// Portainer Docker Compose stack (nil when none exists or it is not a compose
// stack). It is the Go equivalent of M3's TS resolveContainerUpdatePath: pure
// and side-effect free so it can be unit-tested without Docker or the datastore.
//
// - No compose project label -> standalone (recreate-with-pull).
// - Compose project matching a Portainer compose stack -> stack
// (redeploy-with-pull, keeps the container in its stack).
// - Compose project with no matching Portainer compose stack -> external
// (managed outside Portainer / a same-named stack of another type), left
// untouched to avoid detaching it or drifting.
func resolveContainerUpdateRouting(labels map[string]string, stackLookup func(project string) *StackMatch) UpdateRouting {
project := labels[composeProjectLabel]
if project == "" {
return UpdateRouting{Kind: UpdateStandalone}
}
match := stackLookup(project)
if match == nil {
return UpdateRouting{Kind: UpdateExternal}
}
return UpdateRouting{Kind: UpdateStack, StackID: match.StackID, IsGit: match.IsGit}
}
// UpdateCandidate is an outdated, in-scope container considered for auto-update.
type UpdateCandidate struct {
ID string
@@ -171,59 +114,6 @@ type UpdateCandidate struct {
Labels map[string]string
}
// StackUpdate identifies a Portainer stack to redeploy once, together with the
// affected member containers so each updated container can emit its own
// notification (with the stack name) after the redeploy.
type StackUpdate struct {
StackID int
IsGit bool
// Containers are the outdated member containers that triggered this stack
// redeploy, threaded through from detection so a per-container notification can
// be emitted for each (name + old image id + image + labels/stack name).
Containers []UpdateCandidate
}
// GroupedUpdates partitions candidates into their apply paths, de-duplicating
// stack containers so each owning stack is redeployed at most once per tick
// (the overlap guard for stack fan-out). Pure and unit-testable, the Go analogue
// of M3's groupContainersForUpdate.
type GroupedUpdates struct {
Standalone []UpdateCandidate
External []UpdateCandidate
Stacks []StackUpdate
}
// groupContainersForUpdate routes each candidate and collapses stack candidates
// so a stack with several outdated containers is redeployed only once.
func groupContainersForUpdate(candidates []UpdateCandidate, stackLookup func(project string) *StackMatch) GroupedUpdates {
grouped := GroupedUpdates{}
// stackIndex maps a stack id to its slot in grouped.Stacks so a stack is
// redeployed once, while every member container is still collected for its own
// notification (rather than discarded at the collapse).
stackIndex := make(map[int]int)
for _, c := range candidates {
routing := resolveContainerUpdateRouting(c.Labels, stackLookup)
switch routing.Kind {
case UpdateStandalone:
grouped.Standalone = append(grouped.Standalone, c)
case UpdateExternal:
grouped.External = append(grouped.External, c)
case UpdateStack:
idx, ok := stackIndex[routing.StackID]
if !ok {
grouped.Stacks = append(grouped.Stacks, StackUpdate{StackID: routing.StackID, IsGit: routing.IsGit})
idx = len(grouped.Stacks) - 1
stackIndex[routing.StackID] = idx
}
grouped.Stacks[idx].Containers = append(grouped.Stacks[idx].Containers, c)
}
}
return grouped
}
// StopTimeout returns the per-container stop timeout (in seconds) from labels,
// falling back to the default when missing or invalid.
func StopTimeout(labels map[string]string) int {
-114
View File
@@ -132,117 +132,3 @@ func TestIsMonitorOnly(t *testing.T) {
})
}
}
func TestResolveContainerUpdateRouting(t *testing.T) {
// stackLookup resolves "my-stack" to compose stack 7 (git) and nothing else,
// mirroring how the job builds a per-endpoint compose-stack index.
stackLookup := func(project string) *StackMatch {
if project == "my-stack" {
return &StackMatch{StackID: 7, IsGit: true}
}
return nil
}
tests := []struct {
name string
labels map[string]string
want UpdateRouting
}{
{
name: "no compose label -> standalone",
labels: map[string]string{"foo": "bar"},
want: UpdateRouting{Kind: UpdateStandalone},
},
{
name: "empty compose label -> standalone",
labels: map[string]string{composeProjectLabel: ""},
want: UpdateRouting{Kind: UpdateStandalone},
},
{
name: "compose project matching a portainer compose stack -> stack",
labels: map[string]string{composeProjectLabel: "my-stack"},
want: UpdateRouting{Kind: UpdateStack, StackID: 7, IsGit: true},
},
{
name: "compose project with no matching stack -> external",
labels: map[string]string{composeProjectLabel: "other"},
want: UpdateRouting{Kind: UpdateExternal},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveContainerUpdateRouting(tt.labels, stackLookup)
if got != tt.want {
t.Errorf("resolveContainerUpdateRouting(%v) = %+v, want %+v", tt.labels, got, tt.want)
}
})
}
}
func TestGroupContainersForUpdate(t *testing.T) {
// stackLookup: "web" -> compose stack 3 (file), "api" -> compose stack 4 (git).
stackLookup := func(project string) *StackMatch {
switch project {
case "web":
return &StackMatch{StackID: 3, IsGit: false}
case "api":
return &StackMatch{StackID: 4, IsGit: true}
default:
return nil
}
}
candidates := []UpdateCandidate{
{ID: "standalone-1"},
{ID: "web-a", Name: "web-a", Labels: map[string]string{composeProjectLabel: "web"}},
{ID: "web-b", Name: "web-b", Labels: map[string]string{composeProjectLabel: "web"}}, // same stack -> deduped redeploy, both kept as members
{ID: "api-a", Name: "api-a", Labels: map[string]string{composeProjectLabel: "api"}},
{ID: "ext-1", Labels: map[string]string{composeProjectLabel: "unknown"}},
}
grouped := groupContainersForUpdate(candidates, stackLookup)
if len(grouped.Standalone) != 1 || grouped.Standalone[0].ID != "standalone-1" {
t.Errorf("Standalone = %+v, want one entry standalone-1", grouped.Standalone)
}
if len(grouped.External) != 1 || grouped.External[0].ID != "ext-1" {
t.Errorf("External = %+v, want one entry ext-1", grouped.External)
}
// One redeploy per stack: web appears twice in input but once in output.
if len(grouped.Stacks) != 2 {
t.Fatalf("Stacks = %+v, want 2 deduped stacks", grouped.Stacks)
}
got := map[int]bool{}
for _, st := range grouped.Stacks {
got[st.StackID] = st.IsGit
}
if isGit, ok := got[3]; !ok || isGit {
t.Errorf("stack 3 = (%v, present=%v), want present file stack", isGit, ok)
}
if isGit, ok := got[4]; !ok || !isGit {
t.Errorf("stack 4 = (%v, present=%v), want present git stack", isGit, ok)
}
// The stack is redeployed once, but every member container is threaded through
// (not discarded at the collapse) so each can emit its own notification.
members := map[int][]string{}
for _, st := range grouped.Stacks {
for _, c := range st.Containers {
members[st.StackID] = append(members[st.StackID], c.Name)
}
}
if got := members[3]; len(got) != 2 || got[0] != "web-a" || got[1] != "web-b" {
t.Errorf("stack 3 members = %v, want [web-a web-b]", got)
}
if got := members[4]; len(got) != 1 || got[0] != "api-a" {
t.Errorf("stack 4 members = %v, want [api-a]", got)
}
}
+2 -7
View File
@@ -18,7 +18,6 @@ import (
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/docker/images"
"github.com/portainer/portainer/api/scheduler"
"github.com/portainer/portainer/api/stacks/deployments"
"github.com/rs/zerolog/log"
)
@@ -50,7 +49,6 @@ type Service struct {
// an interface so the standalone update/rollback recreate step can be faked in
// tests. See containerRecreator.
containerService containerRecreator
stackDeployer deployments.StackDeployer
// notifier receives automation events (update/rollback/failure/heal). The
// default is logNotifier; the field is the seam external senders plug into.
@@ -87,16 +85,14 @@ type Service struct {
// NewService creates a new container automation service. Call Start to schedule
// the jobs according to the persisted settings. baseCtx is the application
// shutdown context: it bounds the job operation contexts so a shutdown cancels
// any in-flight heal/update. The stackDeployer and containerService are used by
// the auto-update job; they may be nil only in tests that do not exercise
// auto-update.
// any in-flight heal/update. containerService is used by the auto-update job; it
// may be nil only in tests that do not exercise auto-update.
func NewService(
baseCtx context.Context,
scheduler *scheduler.Scheduler,
dataStore dataservices.DataStore,
clientFactory *dockerclient.ClientFactory,
containerService *docker.ContainerService,
stackDeployer deployments.StackDeployer,
) *Service {
if baseCtx == nil {
baseCtx = context.Background()
@@ -109,7 +105,6 @@ func NewService(
clientFactory: clientFactory,
digestClient: images.NewClientWithRegistry(images.NewRegistryClient(dataStore), clientFactory),
containerService: containerService,
stackDeployer: stackDeployer,
// Compose the always-on log notifier with the optional webhook notifier.
// The webhook reads the current settings per-event from the datastore, so a
// URL change in the UI takes effect without a restart; logNotifier keeps the
@@ -2,33 +2,25 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { vi } from 'vitest';
import { COMPOSE_STACK_NAME_LABEL } from '@/react/constants';
import { StackType } from '@/react/common/stacks/types';
import { ContainerImageStatusValue } from '@/react/docker/containers/queries/useContainerImageStatus';
import { UpdateNowButton } from './UpdateNowButton';
const mockUseImageStatus = vi.fn();
const mockUseStacks = vi.fn();
const mockUseAuthorizations = vi.fn();
const mockMutate = vi.fn();
const mockConfirmContainerRecreation = vi.fn();
const mockConfirmStackUpdate = vi.fn();
vi.mock('@uirouter/react', () => ({
useRouter: () => ({ stateService: { go: vi.fn() } }),
}));
// The two confirm dialogs are the async gate before the mutation dispatches;
// stub them so a test can drive the resolved (confirmed) branch.
// The recreate confirm dialog is the async gate before the mutation dispatches;
// stub it so a test can drive the resolved (confirmed) branch.
vi.mock('@/react/docker/containers/ItemView/ConfirmRecreationModal', () => ({
confirmContainerRecreation: (...args: unknown[]) =>
mockConfirmContainerRecreation(...args),
}));
vi.mock('@/react/common/stacks/common/confirm-stack-update', () => ({
confirmStackUpdate: (...args: unknown[]) => mockConfirmStackUpdate(...args),
}));
vi.mock('@/portainer/services/notifications', () => ({
notifySuccess: vi.fn(),
}));
@@ -37,21 +29,6 @@ vi.mock('@/react/docker/containers/queries/useContainerImageStatus', () => ({
useContainerImageStatus: () => mockUseImageStatus(),
}));
vi.mock('@/react/common/stacks/queries/useStacks', () => ({
useStacks: () => mockUseStacks(),
}));
vi.mock('@/react/hooks/useUser', () => ({
useAuthorizations: () => mockUseAuthorizations(),
}));
const composeStack = {
Id: 7,
Name: 'my-stack',
EndpointId: 3,
Type: StackType.DockerCompose,
};
// Mock the concrete mutation module so both the `update` index re-export and the
// shared `useApplyContainerImageUpdate` hook (which imports it directly) resolve
// to the stub.
@@ -84,8 +61,6 @@ function renderButton(
describe('UpdateNowButton', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseStacks.mockReturnValue({ data: [], isLoading: false });
mockUseAuthorizations.mockReturnValue({ authorized: true });
});
it('renders when the image is outdated', () => {
@@ -106,72 +81,50 @@ describe('UpdateNowButton', () => {
expect(screen.queryByTestId('update-now-button')).not.toBeInTheDocument();
});
it('disables the action for externally-managed compose containers', () => {
it('disables the action for Portainer\'s own container', () => {
setStatus('outdated');
renderButton({
labels: { [COMPOSE_STACK_NAME_LABEL]: 'not-in-portainer' },
});
renderButton({ isPortainer: true });
expect(screen.getByTestId('update-now-button')).toBeDisabled();
});
it('enables a stack container when the user can update stacks', () => {
it('enables a compose-managed container (recreate keeps it in its project)', () => {
setStatus('outdated');
mockUseStacks.mockReturnValue({ data: [composeStack], isLoading: false });
mockUseAuthorizations.mockReturnValue({ authorized: true });
renderButton({
environmentId: 3,
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
});
expect(screen.getByTestId('update-now-button')).toBeEnabled();
});
it('disables a stack container when the user lacks PortainerStackUpdate', () => {
setStatus('outdated');
mockUseStacks.mockReturnValue({ data: [composeStack], isLoading: false });
mockUseAuthorizations.mockReturnValue({ authorized: false });
renderButton({
environmentId: 3,
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
});
expect(screen.getByTestId('update-now-button')).toBeDisabled();
});
// apply() dispatch: clicking confirms, then routes to the mutation exactly once
// with the resolved pullImage, via the standalone (recreate) vs stack (redeploy)
// confirm dialog.
it('applies a standalone update via the recreate confirm, mutating once with pullImage', async () => {
// apply() dispatch: clicking confirms, then routes to the single-container
// recreate mutation exactly once with the resolved pullImage.
it('applies a single-container recreate via the confirm, mutating once with pullImage', async () => {
setStatus('outdated');
mockConfirmContainerRecreation.mockResolvedValue({ pullLatest: true });
renderButton(); // no compose label -> standalone
renderButton();
fireEvent.click(screen.getByTestId('update-now-button'));
await waitFor(() => expect(mockMutate).toHaveBeenCalledTimes(1));
expect(mockConfirmContainerRecreation).toHaveBeenCalledTimes(1);
expect(mockConfirmStackUpdate).not.toHaveBeenCalled();
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({ pullImage: true }),
expect.anything()
);
});
it('applies a stack update via the stack confirm, mutating once with pullImage', async () => {
it('recreates a compose-managed container the same way (never redeploys a stack)', async () => {
setStatus('outdated');
mockUseStacks.mockReturnValue({ data: [composeStack], isLoading: false });
mockUseAuthorizations.mockReturnValue({ authorized: true });
mockConfirmStackUpdate.mockResolvedValue({ repullImageAndRedeploy: false });
mockConfirmContainerRecreation.mockResolvedValue({ pullLatest: false });
renderButton({
environmentId: 3,
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
});
fireEvent.click(screen.getByTestId('update-now-button'));
await waitFor(() => expect(mockMutate).toHaveBeenCalledTimes(1));
expect(mockConfirmStackUpdate).toHaveBeenCalledTimes(1);
expect(mockConfirmContainerRecreation).not.toHaveBeenCalled();
expect(mockConfirmContainerRecreation).toHaveBeenCalledTimes(1);
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({ pullImage: false }),
expect.anything()
@@ -6,7 +6,6 @@ import { useContainerImageStatus } from '@/react/docker/containers/queries/useCo
import { useApplyContainerImageUpdate } from '@/react/docker/containers/update';
import { ButtonGroup, LoadingButton } from '@@/buttons';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
import { ContainerId } from '../../../types';
@@ -22,14 +21,11 @@ interface UpdateNowButtonProps {
/**
* "Update now" surfaces a discoverable per-container apply action ONLY when the
* image is `outdated`. It routes through the shared update primitive:
* standalone -> recreate-with-pull, stack-managed -> stack redeploy-with-pull
* (container stays in its stack). Externally-managed compose containers are
* shown disabled with an explanatory tooltip, never recreated out-of-band.
*
* A stack redeploy is gated by `PortainerStackUpdate` (as everywhere else in the
* app): a user with container-create but without stack-update rights sees the
* button disabled with a tooltip rather than getting a 403 on click.
* image is `outdated`. It routes through the shared update primitive, which
* always recreates just this one container with a fresh image pull (the recreate
* endpoint preserves config + compose labels, so a stack member stays part of
* its project). The button is disabled only for Portainer's own container, which
* can't recreate itself.
*/
export function UpdateNowButton({
environmentId,
@@ -46,19 +42,18 @@ export function UpdateNowButton({
containerId,
nodeName
);
const { apply, isLoading, isExternal, stackUpdateForbidden, canApply } =
useApplyContainerImageUpdate({
environmentId,
containerId,
nodeName,
containerImage,
containerName,
labels,
isPortainer,
// The details view reloads to reflect the recreated/redeployed container.
onSuccess: () =>
router.stateService.go('docker.containers', {}, { reload: true }),
});
const { apply, isLoading, canApply } = useApplyContainerImageUpdate({
environmentId,
containerId,
nodeName,
containerImage,
containerName,
labels,
isPortainer,
// The details view reloads to reflect the recreated container.
onSuccess: () =>
router.stateService.go('docker.containers', {}, { reload: true }),
});
// Only meaningful when a newer image is actually available.
if (statusQuery.data?.Status !== 'outdated') {
@@ -80,25 +75,5 @@ export function UpdateNowButton({
</LoadingButton>
);
if (isExternal) {
return (
<ButtonGroup>
<TooltipWithChildren message="This container belongs to a compose project that is managed outside Portainer, so it can't be updated from here.">
{button}
</TooltipWithChildren>
</ButtonGroup>
);
}
if (stackUpdateForbidden) {
return (
<ButtonGroup>
<TooltipWithChildren message="Updating this container redeploys its stack, which requires stack update permission you don't have.">
{button}
</TooltipWithChildren>
</ButtonGroup>
);
}
return <ButtonGroup>{button}</ButtonGroup>;
}
@@ -29,7 +29,6 @@ import {
stopContainer,
} from '@/react/docker/containers/containers.service';
import type { EnvironmentId } from '@/react/portainer/environments/types';
import { useStacks } from '@/react/common/stacks/queries/useStacks';
import {
ContainerUpdateContext,
useBulkUpdateContainerImages,
@@ -84,14 +83,7 @@ export function ContainersDatatableActions({
'DockerContainerCreate',
]);
// Stack redeploys triggered by "Update" need stack-update rights, gated
// separately so we never fire a redeploy the user would get a 403 on.
const { authorized: canUpdateStack } = useAuthorizations(
'PortainerStackUpdate'
);
const router = useRouter();
const stacksQuery = useStacks();
const bulkUpdateMutation = useBulkUpdateContainerImages();
if (!authorized) {
@@ -190,7 +182,7 @@ export function ContainersDatatableActions({
color="light"
data-cy="update-selected-docker-container-button"
onClick={() => onUpdateClick(selectedItems)}
disabled={selectedItemCount === 0 || stacksQuery.isLoading}
disabled={selectedItemCount === 0}
isLoading={bulkUpdateMutation.isLoading}
loadingText="Updating..."
icon={Download}
@@ -278,8 +270,8 @@ export function ContainersDatatableActions({
}
function onUpdateClick(selectedItems: ContainerListViewModel[]) {
// Apply the shared image-update primitive to every outdated container,
// skipping up-to-date/unknown ones and redeploying each owning stack once.
// Recreate every outdated container individually (Watchtower-style),
// skipping up-to-date/unknown ones.
const contexts: ContainerUpdateContext[] = selectedItems.map(
(container) => ({
id: container.Id,
@@ -293,7 +285,7 @@ export function ContainersDatatableActions({
);
bulkUpdateMutation.mutate(
{ contexts, stacks: stacksQuery.data ?? [], canUpdateStack },
{ contexts },
{
onSettled: () => {
router.stateService.reload();
@@ -8,8 +8,6 @@ import {
} from '@/react/docker/containers/queries/useContainerImageStatus';
import { useApplyContainerImageUpdate } from '@/react/docker/containers/update';
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
import { UpdateStatusBadge } from '../../../components/UpdateStatusBadge';
import { columnHelper } from './helper';
@@ -45,13 +43,11 @@ function UpdateAvailableCell({
);
// Same shared apply flow as the details-view "Update now" button (confirm +
// standalone/stack/external routing + permission gating). No `onSuccess`: the
// mutation's query invalidation refreshes this row's badge in place.
// single-container recreate). No `onSuccess`: the mutation's query
// invalidation refreshes this row's badge in place.
const {
apply,
isLoading: isUpdating,
isExternal,
stackUpdateForbidden,
canApply,
} = useApplyContainerImageUpdate({
environmentId,
@@ -69,9 +65,8 @@ function UpdateAvailableCell({
<UpdateStatusBadge
status={status}
isLoading={statusQuery.isLoading}
// Externally-managed / permission-gated updates stay non-actionable: no
// click handler, so the badge renders as a plain span (wrapped below in a
// tooltip that explains why).
// Clicking recreates just this one container (Watchtower-style). Disabled
// only for Portainer's own container, which can't recreate itself.
onUpdateClick={
status === 'outdated' && canApply ? () => apply() : undefined
}
@@ -83,23 +78,5 @@ function UpdateAvailableCell({
/>
);
// Mirror the details-view button's explanations for the two gated cases so a
// user understands why the "Update available" badge isn't clickable here.
if (status === 'outdated' && isExternal) {
return (
<TooltipWithChildren message="This container belongs to a compose project that is managed outside Portainer, so it can't be updated from here.">
<span>{badge}</span>
</TooltipWithChildren>
);
}
if (status === 'outdated' && stackUpdateForbidden) {
return (
<TooltipWithChildren message="Updating this container redeploys its stack, which requires stack update permission you don't have.">
<span>{badge}</span>
</TooltipWithChildren>
);
}
return badge;
}
@@ -1,41 +1,17 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { Stack, StackType } from '@/react/common/stacks/types';
import { COMPOSE_STACK_NAME_LABEL } from '@/react/constants';
import {
applyContainerUpdate,
EXTERNAL_STACK_UPDATE_ERROR,
} from './applyContainerUpdate';
import { applyContainerUpdate } from './applyContainerUpdate';
import { ContainerUpdateContext } from './types';
import { resolveContainerUpdatePath } from './resolveContainerUpdatePath';
// Mock the side-effecting mutations and the file fetch so we assert the dispatch
// and payload mapping without touching the network.
// Mock the recreate mutation so we assert the dispatch and payload mapping
// without touching the network.
const recreateContainer = vi.fn();
const updateStack = vi.fn();
const updateGitStack = vi.fn();
const getStackFile = vi.fn();
vi.mock('../containers.service', () => ({
recreateContainer: (...args: unknown[]) => recreateContainer(...args),
}));
vi.mock('@/react/docker/stacks/useUpdateStack', () => ({
updateStack: (...args: unknown[]) => updateStack(...args),
}));
vi.mock('@/react/portainer/gitops/queries/useUpdateGitStack', () => ({
updateGitStack: (...args: unknown[]) => updateGitStack(...args),
}));
vi.mock('@/react/common/stacks/queries/useStackFile', () => ({
getStackFile: (...args: unknown[]) => getStackFile(...args),
}));
// Drive the standalone/stack/external dispatch deterministically; the resolver's
// own routing logic is covered by resolveContainerUpdatePath.test.ts.
vi.mock('./resolveContainerUpdatePath', () => ({
resolveContainerUpdatePath: vi.fn(),
}));
const resolveMock = vi.mocked(resolveContainerUpdatePath);
function buildContext(
overrides: Partial<ContainerUpdateContext> = {}
@@ -50,116 +26,39 @@ function buildContext(
};
}
function buildStack(overrides: Partial<Stack>): Stack {
return {
Id: 7,
Name: 'my-stack',
EndpointId: 3,
Type: StackType.DockerCompose,
Env: [{ name: 'FOO', value: 'bar' }],
Option: { Prune: true },
...overrides,
} as Stack;
}
describe('applyContainerUpdate', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('recreates a standalone container with pull and node, returning "standalone"', async () => {
resolveMock.mockReturnValue({ kind: 'standalone' });
it('recreates a plain container with pull and node', async () => {
await applyContainerUpdate(buildContext());
const context = buildContext();
const result = await applyContainerUpdate(context, []);
expect(result).toBe('standalone');
expect(recreateContainer).toHaveBeenCalledWith(3, 'abc123', true, {
nodeName: 'node-1',
});
expect(updateStack).not.toHaveBeenCalled();
expect(updateGitStack).not.toHaveBeenCalled();
});
it('passes pullImage=false through to the standalone recreate', async () => {
resolveMock.mockReturnValue({ kind: 'standalone' });
it('recreates a compose-managed container the same way (never redeploys a stack)', async () => {
// A container carrying compose labels must ALSO recreate: the recreate
// endpoint preserves those labels, so it stays part of its project.
await applyContainerUpdate(
buildContext({
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
})
);
await applyContainerUpdate(buildContext(), [], { pullImage: false });
expect(recreateContainer).toHaveBeenCalledWith(3, 'abc123', true, {
nodeName: 'node-1',
});
expect(recreateContainer).toHaveBeenCalledTimes(1);
});
it('passes pullImage=false through to the recreate', async () => {
await applyContainerUpdate(buildContext(), { pullImage: false });
expect(recreateContainer).toHaveBeenCalledWith(3, 'abc123', false, {
nodeName: 'node-1',
});
});
it('redeploys a git stack via updateGitStack with RepullImageAndRedeploy', async () => {
const stack = buildStack({
Id: 9,
GitConfig: { URL: 'https://example.com/repo.git' } as Stack['GitConfig'],
});
resolveMock.mockReturnValue({
kind: 'stack',
stackId: 9,
isGitStack: true,
});
const result = await applyContainerUpdate(buildContext(), [stack]);
expect(result).toBe('stack');
expect(updateGitStack).toHaveBeenCalledWith(9, 3, {
RepullImageAndRedeploy: true,
Env: [{ name: 'FOO', value: 'bar' }],
Prune: true,
});
expect(getStackFile).not.toHaveBeenCalled();
expect(updateStack).not.toHaveBeenCalled();
});
it('redeploys a file stack via updateStack, preserving its current file content', async () => {
const stack = buildStack({ Id: 7 });
resolveMock.mockReturnValue({
kind: 'stack',
stackId: 7,
isGitStack: false,
});
getStackFile.mockResolvedValue({ StackFileContent: 'version: "3"\n' });
const result = await applyContainerUpdate(buildContext(), [stack]);
expect(result).toBe('stack');
expect(getStackFile).toHaveBeenCalledWith({ stackId: 7 });
expect(updateStack).toHaveBeenCalledWith({
stackId: 7,
environmentId: 3,
payload: {
stackFileContent: 'version: "3"\n',
env: [{ name: 'FOO', value: 'bar' }],
prune: true,
repullImageAndRedeploy: true,
},
});
expect(updateGitStack).not.toHaveBeenCalled();
});
it('throws for an externally-managed compose container', async () => {
resolveMock.mockReturnValue({ kind: 'external' });
await expect(applyContainerUpdate(buildContext(), [])).rejects.toThrow(
EXTERNAL_STACK_UPDATE_ERROR
);
expect(recreateContainer).not.toHaveBeenCalled();
expect(updateStack).not.toHaveBeenCalled();
expect(updateGitStack).not.toHaveBeenCalled();
});
it('throws rather than bare-recreating when the resolved stack is missing', async () => {
// Guard: resolve routed to a stack, but no stack with that id is present.
resolveMock.mockReturnValue({ kind: 'stack', stackId: 999 });
await expect(applyContainerUpdate(buildContext(), [])).rejects.toThrow(
EXTERNAL_STACK_UPDATE_ERROR
);
expect(recreateContainer).not.toHaveBeenCalled();
expect(updateStack).not.toHaveBeenCalled();
expect(updateGitStack).not.toHaveBeenCalled();
});
});
@@ -1,86 +1,23 @@
import { Stack } from '@/react/common/stacks/types';
import { getStackFile } from '@/react/common/stacks/queries/useStackFile';
import { updateStack } from '@/react/docker/stacks/useUpdateStack';
import { updateGitStack } from '@/react/portainer/gitops/queries/useUpdateGitStack';
import { recreateContainer } from '../containers.service';
import { resolveContainerUpdatePath } from './resolveContainerUpdatePath';
import { ContainerUpdateContext, ContainerUpdateKind } from './types';
/** Thrown when an update is attempted on an externally-managed compose container. */
export const EXTERNAL_STACK_UPDATE_ERROR =
'This container is part of a compose project that is not managed by Portainer, so it cannot be updated from here.';
import { ContainerUpdateContext } from './types';
/**
* Redeploy a Portainer stack, forcing a re-pull, while preserving its current
* file content, environment variables and options. Reuses the exact mutations
* the stack page uses for "pull and redeploy" so we never invent a new path.
*/
async function redeployStackWithPull(stack: Stack, pullImage: boolean) {
if (stack.GitConfig) {
await updateGitStack(stack.Id, stack.EndpointId, {
RepullImageAndRedeploy: pullImage,
Env: stack.Env ?? [],
Prune: stack.Option?.Prune,
});
return;
}
// File/standalone compose stack: redeploy with its current file unchanged.
const file = await getStackFile({ stackId: stack.Id });
await updateStack({
stackId: stack.Id,
environmentId: stack.EndpointId,
payload: {
stackFileContent: file.StackFileContent,
env: stack.Env ?? [],
prune: stack.Option?.Prune,
repullImageAndRedeploy: pullImage,
},
});
}
/**
* Shared "apply an image update" primitive. Decides standalone-vs-stack-vs-external
* and runs the matching mutation. This is the single frontend code path behind the
* "Update now" button and the bulk "Update selected" action, guaranteeing both
* manual flows behave identically. The backend auto-update daemon does NOT call
* this primitive: it is a separate Go implementation
* (resolveContainerUpdateRouting / groupContainersForUpdate in
* api/containerautomation) that mirrors the same routing on the server.
* Shared "apply an image update" primitive. Recreates the single container with
* a fresh image pull via the Portainer `containers/{id}/recreate` endpoint. This
* is the single frontend code path behind the "Update now" button and the bulk
* "Update selected" action, guaranteeing both manual flows behave identically.
*
* A stack-managed container is ALWAYS routed through stack redeploy so it stays
* part of its stack; an externally-managed compose container is refused rather
* than recreated out-of-band (which would detach it).
* The recreate endpoint preserves the container's configuration and its compose
* labels, so a recreated stack member stays part of its project — Watchtower-style:
* only this one container is updated, the owning stack is never redeployed and an
* externally-managed compose container is never refused.
*/
export async function applyContainerUpdate(
context: ContainerUpdateContext,
stacks: Stack[],
{ pullImage = true }: { pullImage?: boolean } = {}
): Promise<ContainerUpdateKind> {
const path = resolveContainerUpdatePath(context, stacks);
switch (path.kind) {
case 'standalone':
await recreateContainer(context.environmentId, context.id, pullImage, {
nodeName: context.nodeName,
});
return 'standalone';
case 'stack': {
const stack = stacks.find((s) => s.Id === path.stackId);
if (!stack) {
// Should not happen (resolve found it), but never fall back to a bare
// recreate: that would orphan the container from its stack.
throw new Error(EXTERNAL_STACK_UPDATE_ERROR);
}
await redeployStackWithPull(stack, pullImage);
return 'stack';
}
case 'external':
default:
throw new Error(EXTERNAL_STACK_UPDATE_ERROR);
}
): Promise<void> {
await recreateContainer(context.environmentId, context.id, pullImage, {
nodeName: context.nodeName,
});
}
@@ -1,66 +0,0 @@
import { Stack, StackType } from '@/react/common/stacks/types';
import { COMPOSE_STACK_NAME_LABEL } from '@/react/constants';
import { groupContainersForUpdate } from './groupContainersForUpdate';
import { ContainerUpdateContext } from './types';
function buildContext(
overrides: Partial<ContainerUpdateContext>
): ContainerUpdateContext {
return {
id: 'c1',
name: 'container',
image: 'nginx:latest',
environmentId: 3,
...overrides,
};
}
const stack = {
Id: 7,
Name: 'my-stack',
EndpointId: 3,
Type: StackType.DockerCompose,
} as Stack;
describe('groupContainersForUpdate', () => {
it('redeploys a stack only once when several of its containers are selected', () => {
const contexts = [
buildContext({
id: 'a',
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
}),
buildContext({
id: 'b',
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
}),
];
const result = groupContainersForUpdate(contexts, [stack]);
expect(result.stacks).toHaveLength(1);
expect(result.stacks[0].stackId).toBe(7);
expect(result.standalone).toHaveLength(0);
expect(result.external).toHaveLength(0);
});
it('partitions standalone, stack and external containers', () => {
const contexts = [
buildContext({ id: 'standalone', labels: {} }),
buildContext({
id: 'stack',
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
}),
buildContext({
id: 'external',
labels: { [COMPOSE_STACK_NAME_LABEL]: 'not-in-portainer' },
}),
];
const result = groupContainersForUpdate(contexts, [stack]);
expect(result.standalone.map((c) => c.id)).toEqual(['standalone']);
expect(result.stacks.map((s) => s.stackId)).toEqual([7]);
expect(result.external.map((c) => c.id)).toEqual(['external']);
});
});
@@ -1,50 +0,0 @@
import { Stack, StackId } from '@/react/common/stacks/types';
import { resolveContainerUpdatePath } from './resolveContainerUpdatePath';
import { ContainerUpdateContext } from './types';
export interface GroupedContainerUpdates {
/** Standalone containers, each recreated individually. */
standalone: ContainerUpdateContext[];
/**
* One entry per owning stack, even if several selected containers belong to
* the same stack: the stack is redeployed ONCE (mirrors M4's "one redeploy
* per stack per tick"). `context` is a representative container of the stack.
*/
stacks: Array<{ stackId: StackId; context: ContainerUpdateContext }>;
/** Externally-managed compose containers, skipped (never recreated). */
external: ContainerUpdateContext[];
}
/**
* Partition a set of containers into the apply paths, de-duplicating stack
* containers so each stack is redeployed only once regardless of how many of
* its containers were selected. Pure and unit-testable.
*/
export function groupContainersForUpdate(
contexts: ContainerUpdateContext[],
stacks: Stack[]
): GroupedContainerUpdates {
const standalone: ContainerUpdateContext[] = [];
const external: ContainerUpdateContext[] = [];
const stackMap = new Map<
StackId,
{ stackId: StackId; context: ContainerUpdateContext }
>();
contexts.forEach((context) => {
const path = resolveContainerUpdatePath(context, stacks);
if (path.kind === 'standalone') {
standalone.push(context);
} else if (path.kind === 'external') {
external.push(context);
} else if (path.kind === 'stack' && path.stackId != null) {
if (!stackMap.has(path.stackId)) {
stackMap.set(path.stackId, { stackId: path.stackId, context });
}
}
});
return { standalone, external, stacks: Array.from(stackMap.values()) };
}
+2 -11
View File
@@ -1,17 +1,8 @@
export { resolveContainerUpdatePath } from './resolveContainerUpdatePath';
export { groupContainersForUpdate } from './groupContainersForUpdate';
export {
applyContainerUpdate,
EXTERNAL_STACK_UPDATE_ERROR,
} from './applyContainerUpdate';
export { applyContainerUpdate } from './applyContainerUpdate';
export {
useUpdateContainerImage,
invalidateContainerUpdateQueries,
} from './useUpdateContainerImage';
export { useApplyContainerImageUpdate } from './useApplyContainerImageUpdate';
export { useBulkUpdateContainerImages } from './useBulkUpdateContainerImages';
export type {
ContainerUpdateContext,
ContainerUpdateKind,
ContainerUpdatePath,
} from './types';
export type { ContainerUpdateContext } from './types';
@@ -1,120 +0,0 @@
import { Stack, StackType } from '@/react/common/stacks/types';
import { COMPOSE_STACK_NAME_LABEL } from '@/react/constants';
import { resolveContainerUpdatePath } from './resolveContainerUpdatePath';
function buildStack(overrides: Partial<Stack>): Stack {
return {
Id: 1,
Name: 'my-stack',
EndpointId: 3,
Type: StackType.DockerCompose,
...overrides,
} as Stack;
}
describe('resolveContainerUpdatePath', () => {
it('returns standalone when there is no compose project label', () => {
expect(
resolveContainerUpdatePath({ labels: {}, environmentId: 3 }, [])
).toEqual({ kind: 'standalone' });
});
it('returns stack when the compose project matches a Portainer stack', () => {
const stack = buildStack({ Id: 7, Name: 'my-stack', EndpointId: 3 });
expect(
resolveContainerUpdatePath(
{
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
environmentId: 3,
},
[stack]
)
).toEqual({ kind: 'stack', stackId: 7, isGitStack: false });
});
it('flags git stacks (non-zero WorkflowID) so they redeploy via the git path', () => {
const stack = buildStack({
Id: 9,
Name: 'git-stack',
EndpointId: 3,
WorkflowID: 42,
});
expect(
resolveContainerUpdatePath(
{
labels: { [COMPOSE_STACK_NAME_LABEL]: 'git-stack' },
environmentId: 3,
},
[stack]
)
).toEqual({ kind: 'stack', stackId: 9, isGitStack: true });
});
it('does not flag a git stack from a deprecated GitConfig alone (no WorkflowID)', () => {
// GitConfig is deprecated and can diverge from the Workflow/Source model;
// only a non-zero WorkflowID marks a stack git-backed (mirrors the Go daemon).
const stack = buildStack({
Id: 11,
Name: 'git-stack',
EndpointId: 3,
GitConfig: { URL: 'https://example.com/repo.git' } as Stack['GitConfig'],
});
expect(
resolveContainerUpdatePath(
{
labels: { [COMPOSE_STACK_NAME_LABEL]: 'git-stack' },
environmentId: 3,
},
[stack]
)
).toEqual({ kind: 'stack', stackId: 11, isGitStack: false });
});
it('returns external when the compose project has no matching stack', () => {
expect(
resolveContainerUpdatePath(
{
labels: { [COMPOSE_STACK_NAME_LABEL]: 'unknown-project' },
environmentId: 3,
},
[buildStack({ Name: 'other' })]
)
).toEqual({ kind: 'external' });
});
it('returns external when a same-named stack is not a compose stack', () => {
const stack = buildStack({
Name: 'my-stack',
EndpointId: 3,
Type: StackType.Kubernetes,
});
expect(
resolveContainerUpdatePath(
{
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
environmentId: 3,
},
[stack]
)
).toEqual({ kind: 'external' });
});
it('does not match a stack from a different endpoint', () => {
const stack = buildStack({ Name: 'my-stack', EndpointId: 99 });
expect(
resolveContainerUpdatePath(
{
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
environmentId: 3,
},
[stack]
)
).toEqual({ kind: 'external' });
});
});
@@ -1,52 +0,0 @@
import { COMPOSE_STACK_NAME_LABEL } from '@/react/constants';
import { Stack, StackType } from '@/react/common/stacks/types';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { ContainerUpdatePath } from './types';
/**
* Decide how a container's image update must be applied, given the list of
* Portainer-managed stacks. Pure and side-effect free so it can be unit-tested
* and reused by the details button and the bulk action. (The backend auto-update
* daemon mirrors the same routing separately in Go.)
*
* - No compose project label -> `standalone` (recreate-with-pull).
* - Compose project that matches a Portainer Docker Compose `Stack` (same name +
* endpoint + compose type) -> `stack` (redeploy-with-pull, keeps the container
* in its stack).
* - Compose project with no matching Portainer compose stack -> `external`:
* managed outside Portainer (or a same-named stack of another type), so we
* must not recreate it (would detach it / drift).
*/
export function resolveContainerUpdatePath(
context: { labels?: Record<string, string>; environmentId: EnvironmentId },
stacks: Stack[]
): ContainerUpdatePath {
const projectName = context.labels?.[COMPOSE_STACK_NAME_LABEL];
if (!projectName) {
return { kind: 'standalone' };
}
// Match by name + endpoint, but only a Docker Compose stack: a same-named
// swarm/kubernetes stack must not be redeployed via the compose update path.
const stack = stacks.find(
(s) =>
s.Name === projectName &&
s.EndpointId === context.environmentId &&
s.Type === StackType.DockerCompose
);
if (!stack) {
return { kind: 'external' };
}
return {
kind: 'stack',
stackId: stack.Id,
// Git-backed iff a Workflow owns the stack's Source (canonical, mirrors the
// Go daemon's `IsGit: st.WorkflowID != 0`). `GitConfig` is deprecated and can
// diverge from the Workflow/Source model, so it must not drive this flag.
isGitStack: (stack.WorkflowID ?? 0) !== 0,
};
}
+1 -21
View File
@@ -1,4 +1,3 @@
import { StackId } from '@/react/common/stacks/types';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { ContainerId } from '../types';
@@ -6,7 +5,6 @@ import { ContainerId } from '../types';
/**
* Minimal context needed to apply an image update to a single container,
* independent of whether it comes from the details view or the bulk action.
* (The backend auto-update daemon mirrors the same routing separately in Go.)
*/
export interface ContainerUpdateContext {
id: ContainerId;
@@ -14,27 +12,9 @@ export interface ContainerUpdateContext {
name: string;
/** Current image reference, used to detect un-pullable (sha256) images. */
image: string;
/** Docker labels, used to detect compose/stack membership. */
/** Docker labels, preserved by the recreate endpoint (kept for callers). */
labels?: Record<string, string>;
environmentId: EnvironmentId;
/** Swarm/agent node hosting the container, threaded through to the API. */
nodeName?: string;
}
/**
* How a container's image update must be applied:
* - `standalone`: recreate-with-pull (no compose project).
* - `stack`: redeploy the owning Portainer stack with re-pull, so the
* container stays part of its stack.
* - `external`: compose-managed but with no matching Portainer stack record;
* Portainer must not touch it (would either detach it or drift).
*/
export type ContainerUpdateKind = 'standalone' | 'stack' | 'external';
export interface ContainerUpdatePath {
kind: ContainerUpdateKind;
/** Set when `kind === 'stack'`. */
stackId?: StackId;
/** Set when `kind === 'stack'`; routes file vs git redeploy. */
isGitStack?: boolean;
}
@@ -1,13 +1,9 @@
import { EnvironmentId } from '@/react/portainer/environments/types';
import { confirmContainerRecreation } from '@/react/docker/containers/ItemView/ConfirmRecreationModal';
import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update';
import { useStacks } from '@/react/common/stacks/queries/useStacks';
import { notifySuccess } from '@/portainer/services/notifications';
import { useAuthorizations } from '@/react/hooks/useUser';
import { ContainerId } from '../types';
import { resolveContainerUpdatePath } from './resolveContainerUpdatePath';
import { useUpdateContainerImage } from './useUpdateContainerImage';
import { ContainerUpdateContext } from './types';
@@ -31,14 +27,12 @@ interface Params {
/**
* Shared "apply an image update to a single container" action, used by both the
* details-view "Update now" button and the interactive list badge so the confirm
* dialogs, standalone/stack/external routing and permission gating live in ONE
* place.
* dialog lives in ONE place.
*
* Routing mirrors the backend: standalone -> recreate-with-pull, stack-managed ->
* stack redeploy-with-pull (container stays in its stack), externally-managed
* compose -> not touched. A stack redeploy is gated by `PortainerStackUpdate`, so
* a user with container-create but without stack-update rights can't apply it
* (surfaced via `stackUpdateForbidden` rather than a 403 on click).
* Watchtower-style: always recreates just this one container with a fresh image
* pull (the recreate endpoint preserves config + compose labels, so a stack
* member stays part of its project). The only container excluded is Portainer's
* own, which can't recreate itself.
*/
export function useApplyContainerImageUpdate({
environmentId,
@@ -50,30 +44,13 @@ export function useApplyContainerImageUpdate({
isPortainer,
onSuccess,
}: Params) {
const stacksQuery = useStacks();
const updateMutation = useUpdateContainerImage();
// A stack redeploy needs stack-update rights, not just container-create.
const { authorized: canUpdateStack } = useAuthorizations(
'PortainerStackUpdate',
environmentId
);
const stacks = stacksQuery.data ?? [];
const path = resolveContainerUpdatePath({ labels, environmentId }, stacks);
const isExternal = path.kind === 'external';
// Stack-managed container the user isn't allowed to redeploy.
const stackUpdateForbidden = path.kind === 'stack' && !canUpdateStack;
const canApply =
!isPortainer &&
!isExternal &&
!stackUpdateForbidden &&
!stacksQuery.isLoading;
const canApply = !isPortainer && !updateMutation.isLoading;
return {
apply,
isLoading: updateMutation.isLoading,
isExternal,
stackUpdateForbidden,
canApply,
};
@@ -87,29 +64,16 @@ export function useApplyContainerImageUpdate({
nodeName,
};
let pullImage: boolean;
if (path.kind === 'stack') {
const result = await confirmStackUpdate(
'This will redeploy the stack pulling the latest images and may cause a service interruption. Do you wish to continue?',
true
);
if (!result) {
return;
}
pullImage = result.repullImageAndRedeploy;
} else {
const cannotPullImage =
!containerImage || containerImage.toLowerCase().startsWith('sha256');
const result = await confirmContainerRecreation(cannotPullImage);
if (!result) {
return;
}
pullImage = result.pullLatest;
const cannotPullImage =
!containerImage || containerImage.toLowerCase().startsWith('sha256');
const result = await confirmContainerRecreation(cannotPullImage);
if (!result) {
return;
}
const pullImage = result.pullLatest;
updateMutation.mutate(
{ context, stacks, pullImage },
{ context, pullImage },
{
onSuccess: () => {
notifySuccess('Success', 'Container image update applied');
@@ -1,6 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Stack } from '@/react/common/stacks/types';
import {
notifyError,
notifySuccess,
@@ -14,40 +13,30 @@ import {
import { queryKeys as containerQueryKeys } from '../queries/query-keys';
import { applyContainerUpdate } from './applyContainerUpdate';
import { groupContainersForUpdate } from './groupContainersForUpdate';
import { invalidateContainerUpdateQueries } from './useUpdateContainerImage';
import { ContainerUpdateContext } from './types';
interface BulkUpdateParams {
contexts: ContainerUpdateContext[];
stacks: Stack[];
/**
* Whether the user holds `PortainerStackUpdate`. Stack redeploys are gated on
* it everywhere else, so without it we skip (never 403) stack-managed ones.
*/
canUpdateStack: boolean;
}
/**
* Bulk "Update selected": applies the shared update primitive to each selected
* container that is `outdated`, skipping up-to-date/unknown ones with a summary,
* and grouping stack containers so each owning stack redeploys ONCE even if
* several of its containers were selected. Reports per-item success/failure.
* Bulk "Update selected": recreates each selected container that is `outdated`
* with a fresh image pull (Watchtower-style, one recreate per container), and
* skips up-to-date/unknown ones with a summary. Reports per-item success/failure.
*/
export function useBulkUpdateContainerImages() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contexts, stacks, canUpdateStack }: BulkUpdateParams) =>
bulkUpdate(queryClient, contexts, stacks, canUpdateStack),
mutationFn: ({ contexts }: BulkUpdateParams) =>
bulkUpdate(queryClient, contexts),
});
}
async function bulkUpdate(
queryClient: ReturnType<typeof useQueryClient>,
contexts: ContainerUpdateContext[],
stacks: Stack[],
canUpdateStack: boolean
contexts: ContainerUpdateContext[]
) {
// Resolve each container's status (cached where the badge already loaded it).
const statuses = await Promise.all(
@@ -81,24 +70,16 @@ async function bulkUpdate(
'Nothing to update',
'None of the selected containers have updates available.'
);
return { containersUpdated: 0, stacksUpdated: 0, failures: [], skipped: 0 };
return { containersUpdated: 0, failures: [], skipped: 0 };
}
const { standalone, stacks: stackGroups, external } =
groupContainersForUpdate(outdated, stacks);
// Without stack-update rights we must not attempt a stack redeploy (403).
const allowedStackGroups = canUpdateStack ? stackGroups : [];
const skippedUnauthorizedStacks = canUpdateStack ? 0 : stackGroups.length;
let containersUpdated = 0;
let stacksUpdated = 0;
const failures: string[] = [];
// Standalone containers: recreate-with-pull, one per container.
await runSequential(standalone, async (context) => {
// Recreate each outdated container individually (Watchtower-style).
await runSequential(outdated, async (context) => {
try {
await applyContainerUpdate(context, stacks);
await applyContainerUpdate(context);
invalidateContainerUpdateQueries(queryClient, context);
containersUpdated += 1;
} catch (err) {
@@ -107,64 +88,27 @@ async function bulkUpdate(
}
});
// Stack-managed containers: redeploy each owning stack exactly once.
await runSequential(allowedStackGroups, async ({ context }) => {
try {
await applyContainerUpdate(context, stacks);
invalidateContainerUpdateQueries(queryClient, context);
stacksUpdated += 1;
} catch (err) {
failures.push(context.name);
notifyError(
'Failure',
err as Error,
`Unable to redeploy stack for ${context.name}`
);
}
});
// A stack redeploy updates every container in the stack, so report containers
// and stacks separately rather than conflating both into one "update" count.
if (containersUpdated > 0 || stacksUpdated > 0) {
const parts: string[] = [];
if (containersUpdated > 0) {
parts.push(`${containersUpdated} ${pluralize(containersUpdated, 'container')}`);
}
if (stacksUpdated > 0) {
parts.push(`${stacksUpdated} ${pluralize(stacksUpdated, 'stack')}`);
}
notifySuccess('Success', `${parts.join(' and ')} updated`);
if (containersUpdated > 0) {
notifySuccess(
'Success',
`${containersUpdated} ${pluralize(
containersUpdated,
'container'
)} updated`
);
}
const skippedExternal = external.length;
if (
skippedNotOutdated > 0 ||
skippedExternal > 0 ||
skippedUnauthorizedStacks > 0
) {
const parts: string[] = [];
if (skippedNotOutdated > 0) {
parts.push(`${skippedNotOutdated} not outdated`);
}
if (skippedExternal > 0) {
parts.push(`${skippedExternal} managed outside Portainer`);
}
if (skippedUnauthorizedStacks > 0) {
parts.push(
`${skippedUnauthorizedStacks} ${pluralize(
skippedUnauthorizedStacks,
'stack'
)} you can't update`
);
}
notifyWarning('Some containers were skipped', parts.join(', '));
if (skippedNotOutdated > 0) {
notifyWarning(
'Some containers were skipped',
`${skippedNotOutdated} not outdated`
);
}
return {
containersUpdated,
stacksUpdated,
failures,
skipped: skippedNotOutdated + skippedExternal + skippedUnauthorizedStacks,
skipped: skippedNotOutdated,
};
}
@@ -172,7 +116,7 @@ async function runSequential<T>(
items: T[],
fn: (item: T) => Promise<void>
): Promise<void> {
// Sequential to avoid hammering registries / overlapping stack redeploys.
// Sequential to avoid hammering registries with parallel pulls.
await items.reduce(
(chain, item) => chain.then(() => fn(item)),
Promise.resolve()
@@ -1,8 +1,6 @@
import { QueryClient, useMutation, useQueryClient } from '@tanstack/react-query';
import { withError } from '@/react-tools/react-query';
import { Stack } from '@/react/common/stacks/types';
import { queryKeys as stacksQueryKeys } from '@/react/common/stacks/queries/query-keys';
import { queryKeys as containerQueryKeys } from '../queries/query-keys';
@@ -10,16 +8,10 @@ import { applyContainerUpdate } from './applyContainerUpdate';
import { ContainerUpdateContext } from './types';
/**
* Refresh the data affected by a container image update: the container itself,
* its image-status badge (so it flips away from "outdated") and the stacks
* list (a stack redeploy bumps its deployment info).
*
* Note: for a stack redeploy this invalidates only the representative container's
* badge, not those of its siblings in the same stack — a stack redeploy updates
* every container, but only `context` is passed here. The sibling badges refresh
* on their next natural refetch (staleTime / window focus) or a manual reload.
* They are deliberately not force-invalidated from this shared helper (also used
* by the single standalone "Update now") to avoid an endpoint-wide badge refetch.
* Refresh the data affected by a single-container image update: the container
* itself and its image-status badge (so it flips away from "outdated"). A
* single-container recreate doesn't change the stacks list, so that isn't
* invalidated here.
*/
export function invalidateContainerUpdateQueries(
queryClient: QueryClient,
@@ -35,12 +27,10 @@ export function invalidateContainerUpdateQueries(
context.nodeName
)
);
queryClient.invalidateQueries(stacksQueryKeys.base());
}
interface UpdateContainerImageParams {
context: ContainerUpdateContext;
stacks: Stack[];
pullImage?: boolean;
}
@@ -53,9 +43,9 @@ export function useUpdateContainerImage() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ context, stacks, pullImage }: UpdateContainerImageParams) =>
applyContainerUpdate(context, stacks, { pullImage }),
onSuccess: (_kind, { context }) => {
mutationFn: ({ context, pullImage }: UpdateContainerImageParams) =>
applyContainerUpdate(context, { pullImage }),
onSuccess: (_result, { context }) => {
invalidateContainerUpdateQueries(queryClient, context);
},
...withError('Unable to update container image'),