Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e63d2ffe9b |
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
-59
@@ -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()
|
||||
|
||||
+17
-42
@@ -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>;
|
||||
}
|
||||
|
||||
+4
-12
@@ -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();
|
||||
|
||||
+4
-27
@@ -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()) };
|
||||
}
|
||||
@@ -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,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'),
|
||||
|
||||
Reference in New Issue
Block a user