Compare commits

..

2 Commits

Author SHA1 Message Date
agent_coder bb68acfbf6 fix(#29 review r1): stack-delete leak, rollback/retention/version tests, UX trap
F3: deleting a file-based stack now removes the stack ROOT (compose/{id}) via a
new removeStackProjectDir helper, not stack.ProjectPath (which the PR repointed to
compose/{id}/v{N}) — old version dirs + parent no longer leak. Git stacks unchanged.
F1: tests for validateRollbackTarget (rejects 0/neg/>current/hole) and the rollback
snapshot (client content ignored, target read from disk, monotonic new version, note).
F2: tests for pruneStackFileVersionDirs (deletes given dirs, swallows errors) + the
post-commit gate contract + a monotonic-version regression guard.
F4: handler tests for ?version= (negative/out-of-range -> 400, valid version served,
legacy fallback).
F5: swagger @param version on GET file; @version 2.44.0 (handler.go) + package.json
2.44.0, matching APIVersion.
F6: the version selector no longer sets rollbackTo for the current/top version and
clears it on a manual buffer edit (so edits are honored, not silently discarded);
returning to the current version restores the current content. Distinguishes real
user edits from the programmatic version-load (CodeMirror ExternalChange).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 17:28:52 +03:00
agent_coder d0d3c068ba feat(stacks): file-based stack versioning with full history + rollback (#27)
Adds append-only version history on disk (compose/{id}/v{N}/<files>) for
file-based (WorkflowID==0) Compose/Swarm stacks, with rollback to any past
version. Git stacks (versioned by commit) and Kubernetes are untouched.

Backend:
- Stack model: StackFileVersion, PreviousDeploymentInfo, Versions[]; new
  StackFileVersionInfo type. APIVersion 2.43.0 -> 2.44.0.
- Versioned multi-file snapshot (entrypoint + AdditionalFiles) into v{N}/;
  ProjectPath repointed via GetStackProjectPathByVersion each deploy. Retention
  cap (20): Versions[] trimmed in-tx, old dirs deleted only AFTER the tx commits.
- Update handlers: RollbackTo (content read server-side from the target version,
  never trusted from the client; validated 1..current & present in Versions).
- Create paths seed v1. stackFile reads ?version= (validated; negative -> 400).
- New GET /stacks/{id}/versions endpoint.
- Migration 2.44.0: move existing file-based stacks' files into v1/ (idempotent,
  atomic pre-read of the full file set, skips git/kube/orphans).

Frontend:
- useStackVersions query + stackVersions key; StackEditorTab builds the full
  history list; StackVersionSelector shows 'v{N} · date · author'; file/versions
  caches invalidated (by prefix) after deploy/rollback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 16:07:26 +03:00
51 changed files with 3160 additions and 330 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)
containerAutomationService := containerautomation.NewService(shutdownCtx, scheduler, dataStore, dockerClientFactory, containerService, stackDeployer)
containerAutomationService.Start()
sslDBSettings, err := dataStore.SSLSettings().Settings()
+157 -14
View File
@@ -9,6 +9,8 @@ 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"
@@ -22,6 +24,8 @@ 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.
@@ -108,15 +112,15 @@ func parseRollbackTimeout(raw string) time.Duration {
}
// updateEndpoint applies image updates to the in-scope, outdated containers of a
// 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.
// 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.
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.
// are not updated here; stacks are redeployed cluster-wide by the swarm engine.
clientTimeout := endpointTimeout
cli, err := s.clientFactory.CreateClient(endpoint, "", &clientTimeout)
if err != nil {
@@ -175,15 +179,51 @@ 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})
}
// 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 {
// 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 {
s.updateStandalone(cli, endpoint, c, opts)
}
for _, st := range grouped.Stacks {
s.updateStack(cli, endpoint, st)
}
}
// updateStandalone recreates a container with a re-pull of its image,
// 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,
// 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
@@ -205,7 +245,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 container, rollback is enabled but there is no stable name to key the loop guard")
Msg("auto-update: skipping unnamed standalone container, rollback is enabled but there is no stable name to key the loop guard")
return
}
@@ -273,16 +313,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 container")
Msg("auto-update: failed to recreate standalone container")
s.notifier.Notify(Event{
Kind: EventUpdateFailed, EndpointID: endpointID, ContainerID: c.ID, ContainerName: c.Name,
Message: "failed to recreate container", Err: err,
Message: "failed to recreate standalone container", Err: err,
})
return
}
log.Info().Str("container_id", c.ID).Int("endpoint_id", endpointID).
Msg("auto-update: recreated container with updated image")
Msg("auto-update: recreated standalone container with updated image")
newImage := ""
if newContainer != nil {
newImage = newContainer.Config.Image
@@ -313,7 +353,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 container",
Message: "updated standalone container",
})
if opts.cleanup && newContainer != nil && newContainer.Image != oldImageID {
@@ -452,3 +492,106 @@ 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
}
+112 -103
View File
@@ -10,130 +10,139 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/docker"
dockerclient "github.com/portainer/portainer/api/docker/client"
"github.com/portainer/portainer/api/docker/images"
"github.com/portainer/portainer/api/internal/testhelpers"
"github.com/docker/docker/api/types/container"
dockerclient "github.com/docker/docker/client"
"github.com/stretchr/testify/require"
)
// 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"
)
// 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()
// 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) {
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)
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)
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
}
}
http.Error(w, "not found", http.StatusNotFound)
}))
t.Cleanup(srv.Close)
// 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) })
cli, err := dockerclient.NewClientWithOpts(
dockerclient.WithHost(srv.URL),
dockerclient.WithHTTPClient(http.DefaultClient),
)
require.NoError(t, err)
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)
// 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.
// 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))
require.NoError(t, store.Stack().Create(&portainer.Stack{
ID: 1, Name: "regression-stack", Type: portainer.DockerComposeStack, EndpointID: 1,
ID: 7, EndpointID: 1, Name: "cache-demo", Type: portainer.DockerComposeStack, CreatedBy: "auto",
}))
// 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)
const (
oldEsphome = "sha256:59b94983c73a000000000000000000000000000000000000000000000000aaaa"
newEsphome = "sha256:2231ca5d676d000000000000000000000000000000000000000000000000bbbb"
oldOther = "sha256:1111111111110000000000000000000000000000000000000000000000000000"
newOther = "sha256:2222222222220000000000000000000000000000000000000000000000000000"
)
cli := newStackInspectClient(t, map[string]string{
"esphome": newEsphome,
"other": newOther,
})
rec := &recordingNotifier{}
s := &Service{
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),
baseCtx: context.Background(),
dataStore: store,
stackDeployer: testhelpers.NewTestStackDeployer(),
notifier: rec,
}
endpoint := &portainer.Endpoint{ID: 1, Name: "nebula.lc", URL: srv.URL, Type: portainer.DockerEnvironment}
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"}},
},
}
s.updateEndpoint(endpoint, ScopeAll, updateOptions{})
s.updateStack(cli, endpoint, st)
// 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")
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")
}
+110
View File
@@ -20,6 +20,9 @@ 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
@@ -99,6 +102,60 @@ 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
@@ -114,6 +171,59 @@ 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,3 +132,117 @@ 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)
}
}
+7 -2
View File
@@ -18,6 +18,7 @@ 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"
)
@@ -49,6 +50,7 @@ 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.
@@ -85,14 +87,16 @@ 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. containerService is used by the auto-update job; it
// may be nil only in tests that do not exercise auto-update.
// 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.
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()
@@ -105,6 +109,7 @@ 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
+158
View File
@@ -0,0 +1,158 @@
package migrator
import (
"os"
"path/filepath"
"strconv"
portainer "github.com/portainer/portainer/api"
"github.com/rs/zerolog/log"
)
// migrateStackFileVersions_2_44_0 introduces the on-disk file version history for existing
// file-based (non-git) Compose/Swarm stacks. For each such stack it moves the entrypoint and
// every additional file from compose/{id}/<files> into compose/{id}/v1/<files>, repoints
// ProjectPath to the v1 folder, sets StackFileVersion=1 and seeds the append-only Versions
// history with a single "migrated" entry.
//
// The migration is idempotent (it skips stacks that already have StackFileVersion>0 or a
// COMPLETE v1 folder on disk) and resilient: git/kubernetes stacks and stacks whose files are
// missing on disk are logged and skipped without failing the whole migration. It never writes
// a partial v1 (all files are read up-front) and never adopts an incomplete pre-existing v1.
func (m *Migrator) migrateStackFileVersions_2_44_0() error {
log.Info().Msg("migrating file-based stacks to versioned file storage")
stacks, err := m.stackService.ReadAll()
if err != nil {
return err
}
for i := range stacks {
stack := stacks[i]
// Only file-based (non-git) Compose/Swarm stacks get a file version history.
if stack.WorkflowID != 0 {
continue
}
if stack.Type != portainer.DockerComposeStack && stack.Type != portainer.DockerSwarmStack {
continue
}
if stack.ProjectPath == "" || stack.EntryPoint == "" {
continue
}
// Idempotency: already migrated.
if stack.StackFileVersion > 0 {
continue
}
stackID := strconv.Itoa(int(stack.ID))
v1Dir := m.fileService.GetStackProjectPathByVersion(stackID, 1, "")
// The complete expected file set for this stack: entrypoint + every additional file.
fileNames := append([]string{stack.EntryPoint}, stack.AdditionalFiles...)
if exists, err := m.fileService.FileExists(v1Dir); err != nil {
log.Warn().Err(err).Int("stack_id", int(stack.ID)).Msg("unable to check stack v1 directory; skipping")
continue
} else if exists {
// v1 folder already present (e.g. from an interrupted earlier migration). Only
// treat it as authoritative if it holds the FULL expected file set; a partial v1
// must not be adopted, or we'd repoint the stack to an incomplete directory.
complete, err := m.stackVersionDirComplete(v1Dir, fileNames)
if err != nil {
log.Warn().Err(err).Int("stack_id", int(stack.ID)).Msg("unable to verify existing stack v1 directory; skipping")
continue
}
if !complete {
log.Warn().Int("stack_id", int(stack.ID)).Str("v1_dir", v1Dir).Msg("existing stack v1 directory is incomplete; skipping version migration")
continue
}
// Complete v1: repoint metadata if needed but don't move files.
m.seedStackVersionMetadata(&stack, v1Dir)
if err := m.stackService.Update(stack.ID, &stack); err != nil {
return err
}
continue
}
// Pre-flight: read the entrypoint and every additional file into memory BEFORE writing
// anything to v1. If any file is missing/unreadable we skip the whole stack (leaving the
// base files intact) rather than create a partial v1 that a re-run could later adopt.
contents := make(map[string][]byte, len(fileNames))
missing := false
for _, fileName := range fileNames {
content, err := m.fileService.GetFileContent(stack.ProjectPath, fileName)
if err != nil {
log.Warn().Err(err).Int("stack_id", int(stack.ID)).Str("file", fileName).Str("project_path", stack.ProjectPath).Msg("stack file missing or unreadable on disk; skipping version migration")
missing = true
break
}
contents[fileName] = content
}
if missing {
continue
}
// All files read successfully; now move them (all-or-nothing) into the v1 folder.
for _, fileName := range fileNames {
if _, err := m.fileService.StoreStackFileFromBytesByVersion(stackID, fileName, 1, contents[fileName]); err != nil {
return err
}
}
// Remove the old base-level copies now that they live under v1.
for _, fileName := range fileNames {
oldPath := filepath.Join(stack.ProjectPath, fileName)
if err := os.Remove(oldPath); err != nil && !os.IsNotExist(err) {
log.Warn().Err(err).Int("stack_id", int(stack.ID)).Str("file", fileName).Msg("unable to remove old stack file after version migration")
}
}
m.seedStackVersionMetadata(&stack, v1Dir)
if err := m.stackService.Update(stack.ID, &stack); err != nil {
return err
}
}
return nil
}
// stackVersionDirComplete reports whether dir holds the stack's full expected file set
// (entrypoint + every additional file). Used to reject an incomplete v1 left behind by an
// interrupted migration so it is never adopted as authoritative.
func (m *Migrator) stackVersionDirComplete(dir string, fileNames []string) (bool, error) {
for _, fileName := range fileNames {
exists, err := m.fileService.FileExists(filepath.Join(dir, fileName))
if err != nil {
return false, err
}
if !exists {
return false, nil
}
}
return true, nil
}
// seedStackVersionMetadata repoints a stack to its v1 folder and seeds the version history.
func (m *Migrator) seedStackVersionMetadata(stack *portainer.Stack, v1Dir string) {
createdAt := stack.CreationDate
if createdAt == 0 {
createdAt = stack.UpdateDate
}
stack.ProjectPath = v1Dir
stack.StackFileVersion = 1
stack.Versions = []portainer.StackFileVersionInfo{{
Version: 1,
CreatedAt: createdAt,
CreatedBy: stack.CreatedBy,
Note: "migrated",
}}
}
@@ -0,0 +1,223 @@
package migrator
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/boltdb"
"github.com/portainer/portainer/api/dataservices/stack"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/logs"
"github.com/stretchr/testify/require"
)
func newStackVersionTestMigrator(t *testing.T) (*Migrator, *stack.Service, portainer.FileService) {
t.Helper()
conn := &boltdb.DbConnection{Path: t.TempDir()}
require.NoError(t, conn.Open())
t.Cleanup(func() { logs.CloseAndLogErr(conn) })
stackSvc, err := stack.NewService(conn)
require.NoError(t, err)
fileSvc, err := filesystem.NewService(t.TempDir(), "")
require.NoError(t, err)
m := NewMigrator(&MigratorParameters{
StackService: stackSvc,
FileService: fileSvc,
})
return m, stackSvc, fileSvc
}
func TestMigrateStackFileVersions_2_44_0_MultiFileMove(t *testing.T) {
t.Parallel()
m, stackSvc, fileSvc := newStackVersionTestMigrator(t)
stackFolder := "1"
// Seed the legacy on-disk layout: files live directly under compose/{id}/.
_, err := fileSvc.StoreStackFileFromBytes(stackFolder, "docker-compose.yml", []byte("main-content"))
require.NoError(t, err)
_, err = fileSvc.StoreStackFileFromBytes(stackFolder, "override.yml", []byte("override-content"))
require.NoError(t, err)
fileStack := &portainer.Stack{
ID: 1,
Name: "file-stack",
Type: portainer.DockerComposeStack,
EntryPoint: "docker-compose.yml",
AdditionalFiles: []string{"override.yml"},
ProjectPath: fileSvc.GetStackProjectPath(stackFolder),
CreationDate: 1234,
CreatedBy: "admin",
}
require.NoError(t, stackSvc.Create(fileStack))
require.NoError(t, m.migrateStackFileVersions_2_44_0())
migrated, err := stackSvc.Read(1)
require.NoError(t, err)
require.Equal(t, 1, migrated.StackFileVersion)
require.Equal(t, fileSvc.GetStackProjectPathByVersion(stackFolder, 1, ""), migrated.ProjectPath)
require.Len(t, migrated.Versions, 1)
require.Equal(t, 1, migrated.Versions[0].Version)
require.Equal(t, int64(1234), migrated.Versions[0].CreatedAt)
require.Equal(t, "admin", migrated.Versions[0].CreatedBy)
require.Equal(t, "migrated", migrated.Versions[0].Note)
// Files must have been moved into v1 (content preserved) and removed from the base folder.
v1Path := fileSvc.GetStackProjectPathByVersion(stackFolder, 1, "")
got, err := fileSvc.GetFileContent(v1Path, "docker-compose.yml")
require.NoError(t, err)
require.Equal(t, []byte("main-content"), got)
got, err = fileSvc.GetFileContent(v1Path, "override.yml")
require.NoError(t, err)
require.Equal(t, []byte("override-content"), got)
basePath := fileSvc.GetStackProjectPath(stackFolder)
exists, err := fileSvc.FileExists(basePath + "/docker-compose.yml")
require.NoError(t, err)
require.False(t, exists, "old base entrypoint should be removed")
// Idempotency: a second run must not change anything and must not error.
require.NoError(t, m.migrateStackFileVersions_2_44_0())
again, err := stackSvc.Read(1)
require.NoError(t, err)
require.Equal(t, 1, again.StackFileVersion)
require.Len(t, again.Versions, 1)
}
func TestMigrateStackFileVersions_2_44_0_SkipsGitAndOrphan(t *testing.T) {
t.Parallel()
m, stackSvc, fileSvc := newStackVersionTestMigrator(t)
// Git stack: must be left untouched.
gitStack := &portainer.Stack{
ID: 1,
Name: "git-stack",
Type: portainer.DockerComposeStack,
EntryPoint: "docker-compose.yml",
ProjectPath: fileSvc.GetStackProjectPath("1"),
WorkflowID: 99,
}
require.NoError(t, stackSvc.Create(gitStack))
// Orphan file-based stack: metadata present but no files on disk.
orphanStack := &portainer.Stack{
ID: 2,
Name: "orphan-stack",
Type: portainer.DockerSwarmStack,
EntryPoint: "docker-compose.yml",
ProjectPath: fileSvc.GetStackProjectPath("2"),
}
require.NoError(t, stackSvc.Create(orphanStack))
require.NoError(t, m.migrateStackFileVersions_2_44_0())
git, err := stackSvc.Read(1)
require.NoError(t, err)
require.Equal(t, 0, git.StackFileVersion)
require.Empty(t, git.Versions)
orphan, err := stackSvc.Read(2)
require.NoError(t, err)
require.Equal(t, 0, orphan.StackFileVersion)
require.Empty(t, orphan.Versions)
}
// TestMigrateStackFileVersions_2_44_0_MissingAdditionalFile verifies that a stack whose
// entrypoint exists but whose additional file is missing is skipped entirely: it is left
// un-migrated, the base entrypoint stays intact, and NO partial v1 directory is created.
// A re-run must not accept the (absent) v1 either.
func TestMigrateStackFileVersions_2_44_0_MissingAdditionalFile(t *testing.T) {
t.Parallel()
m, stackSvc, fileSvc := newStackVersionTestMigrator(t)
stackFolder := "1"
// Only the entrypoint is on disk; the declared additional file is missing.
_, err := fileSvc.StoreStackFileFromBytes(stackFolder, "docker-compose.yml", []byte("main-content"))
require.NoError(t, err)
fileStack := &portainer.Stack{
ID: 1,
Name: "partial-stack",
Type: portainer.DockerComposeStack,
EntryPoint: "docker-compose.yml",
AdditionalFiles: []string{"override.yml"},
ProjectPath: fileSvc.GetStackProjectPath(stackFolder),
CreationDate: 1234,
CreatedBy: "admin",
}
require.NoError(t, stackSvc.Create(fileStack))
require.NoError(t, m.migrateStackFileVersions_2_44_0())
// The stack must be left un-migrated and consistent.
skipped, err := stackSvc.Read(1)
require.NoError(t, err)
require.Equal(t, 0, skipped.StackFileVersion)
require.Empty(t, skipped.Versions)
require.Equal(t, fileSvc.GetStackProjectPath(stackFolder), skipped.ProjectPath)
// No partial v1 directory may have been created, and base files stay intact.
v1Path := fileSvc.GetStackProjectPathByVersion(stackFolder, 1, "")
exists, err := fileSvc.FileExists(v1Path)
require.NoError(t, err)
require.False(t, exists, "no partial v1 directory should be created when a file is missing")
basePath := fileSvc.GetStackProjectPath(stackFolder)
exists, err = fileSvc.FileExists(basePath + "/docker-compose.yml")
require.NoError(t, err)
require.True(t, exists, "base entrypoint must be left intact")
// A re-run must remain a no-op (still un-migrated, still no v1 adopted).
require.NoError(t, m.migrateStackFileVersions_2_44_0())
again, err := stackSvc.Read(1)
require.NoError(t, err)
require.Equal(t, 0, again.StackFileVersion)
require.Empty(t, again.Versions)
}
// TestMigrateStackFileVersions_2_44_0_IncompleteExistingV1 verifies that a pre-existing but
// INCOMPLETE v1 directory (e.g. left by an interrupted earlier migration) is not adopted as
// authoritative: the stack stays un-migrated rather than being repointed to a partial v1.
func TestMigrateStackFileVersions_2_44_0_IncompleteExistingV1(t *testing.T) {
t.Parallel()
m, stackSvc, fileSvc := newStackVersionTestMigrator(t)
stackFolder := "1"
// Base files present.
_, err := fileSvc.StoreStackFileFromBytes(stackFolder, "docker-compose.yml", []byte("main-content"))
require.NoError(t, err)
_, err = fileSvc.StoreStackFileFromBytes(stackFolder, "override.yml", []byte("override-content"))
require.NoError(t, err)
// A partial v1 exists: only the entrypoint was copied, the additional file is missing.
_, err = fileSvc.StoreStackFileFromBytesByVersion(stackFolder, "docker-compose.yml", 1, []byte("main-content"))
require.NoError(t, err)
fileStack := &portainer.Stack{
ID: 1,
Name: "interrupted-stack",
Type: portainer.DockerComposeStack,
EntryPoint: "docker-compose.yml",
AdditionalFiles: []string{"override.yml"},
ProjectPath: fileSvc.GetStackProjectPath(stackFolder),
}
require.NoError(t, stackSvc.Create(fileStack))
require.NoError(t, m.migrateStackFileVersions_2_44_0())
// The incomplete v1 must NOT have been adopted; metadata stays un-migrated.
skipped, err := stackSvc.Read(1)
require.NoError(t, err)
require.Equal(t, 0, skipped.StackFileVersion)
require.Empty(t, skipped.Versions)
require.Equal(t, fileSvc.GetStackProjectPath(stackFolder), skipped.ProjectPath)
}
+2
View File
@@ -278,6 +278,8 @@ func (m *Migrator) initMigrations() {
m.migrateContainerAutomationSettings_2_43_0,
)
m.addMigrations("2.44.0", m.migrateStackFileVersions_2_44_0)
// WARNING: do not change migrations that have already been released!
// Add new migrations above...
@@ -623,7 +623,7 @@
"RequiredPasswordLength": 12
},
"KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.43.0",
"KubectlShellImage": "portainer/kubectl-shell:2.44.0",
"LDAPSettings": {
"AnonymousMode": true,
"AutoCreateUsers": true,
@@ -940,7 +940,7 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.43.0\",\"MigratorCount\":3,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.44.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
},
"webhooks": null,
"workflows": null
+1 -1
View File
@@ -79,7 +79,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.43.0
// @version 2.44.0
// @description.markdown
// @x-tagGroups [{"name":"Access Control","tags":["auth","roles","team_memberships","teams","users"]},{"name":"Administration","tags":["backup","ldap","motd","settings","status","system","ssl","upload"]},{"name":"Docker","tags":["templates","custom_templates","docker","registries","resource_controls","stacks","webhooks","websocket"]},{"name":"Edge Compute","tags":["edge_agent","edge_groups","edge_jobs","edge","edge_stacks"]},{"name":"Environment Management","tags":["endpoint_groups","endpoints","tags"]},{"name":"GitOps","tags":["gitops"]},{"name":"Kubernetes","tags":["helm","kubernetes"]}]
// @termsOfService
+2
View File
@@ -80,6 +80,8 @@ func NewHandler(bouncer security.BouncerService) *Handler {
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackGitRedeploy))).Methods(http.MethodPut)
h.Handle("/stacks/{id}/file",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet)
h.Handle("/stacks/{id}/versions",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackVersions))).Methods(http.MethodGet)
h.Handle("/stacks/{id}/migrate",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackMigrate))).Methods(http.MethodPost)
h.Handle("/stacks/{id}/start",
+14 -2
View File
@@ -139,13 +139,25 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt
}
}
if err := handler.FileService.RemoveDirectory(stack.ProjectPath); err != nil {
if err := handler.removeStackProjectDir(stack); err != nil {
log.Warn().Err(err).Msg("Unable to remove stack files from disk")
}
return response.Empty(w)
}
// removeStackProjectDir deletes a stack's files from disk. For file-based (non-git) stacks the
// whole stack root (compose/{id}) is removed so every versioned subfolder (v1..vN) is cleaned up,
// because stack.ProjectPath only points at the current version directory (compose/{id}/v{N}).
// Git stacks keep a ProjectPath of compose/{id}, so removing it directly preserves their behavior.
func (handler *Handler) removeStackProjectDir(stack *portainer.Stack) error {
if stack.WorkflowID == 0 {
return handler.FileService.RemoveDirectory(handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID))))
}
return handler.FileService.RemoveDirectory(stack.ProjectPath)
}
func (handler *Handler) deleteExternalStack(r *http.Request, w http.ResponseWriter, stackName string, securityContext *security.RestrictedRequestContext) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", false)
if err != nil {
@@ -355,7 +367,7 @@ func (handler *Handler) stackDeleteKubernetesByName(w http.ResponseWriter, r *ht
continue
}
if err := handler.FileService.RemoveDirectory(stack.ProjectPath); err != nil {
if err := handler.removeStackProjectDir(&stack); err != nil {
errs = errors.Join(errs, err)
log.Warn().Err(err).Msg("Unable to remove stack files from disk")
}
+36 -1
View File
@@ -2,6 +2,7 @@ package stacks
import (
"net/http"
"strconv"
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
@@ -29,6 +30,7 @@ type stackFileResponse struct {
// @security jwt
// @produce json
// @param id path int true "Stack identifier"
// @param version query int false "return this file version (file-based stacks)"
// @success 200 {object} stackFileResponse "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
@@ -107,7 +109,28 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe
return httperror.Conflict("Stack git settings have changed. Redeploy the stack to apply the new configuration.", errors.New("git settings updated without redeploy"))
}
stackFileContent, err := handler.FileService.GetFileContent(stack.ProjectPath, stack.EntryPoint)
projectPath := stack.ProjectPath
// Optional ?version= selects a specific past file version of a file-based (non-git) stack.
version, err := request.RetrieveNumericQueryParameter(r, "version", true)
if err != nil {
return httperror.BadRequest("Invalid query parameter: version", err)
}
// A negative version is never valid (0/absent means "current"); reject it explicitly
// rather than silently falling through to the current version.
if version < 0 {
return httperror.BadRequest("Invalid query parameter: version", errors.New("version must be a positive integer"))
}
if version > 0 && stack.WorkflowID == 0 {
if !stackFileVersionExists(stack, version) {
return httperror.BadRequest("Invalid stack file version", errors.Errorf("version %d not found in stack history", version))
}
projectPath = handler.FileService.GetStackProjectPathByVersion(strconv.Itoa(int(stack.ID)), version, "")
}
stackFileContent, err := handler.FileService.GetFileContent(projectPath, stack.EntryPoint)
if err != nil {
return httperror.InternalServerError("Unable to retrieve Compose file from disk", err)
}
@@ -115,6 +138,18 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe
return response.JSON(w, &stackFileResponse{StackFileContent: string(stackFileContent)})
}
// stackFileVersionExists reports whether the given version is present in the stack's file
// version history (or, for stacks predating the history seed, is within the current range).
func stackFileVersionExists(stack *portainer.Stack, version int) bool {
for _, v := range stack.Versions {
if v.Version == version {
return true
}
}
return len(stack.Versions) == 0 && version >= 1 && version <= stack.StackFileVersion
}
// gitStackPendingRedeploy returns true when the stack's git settings (URL or config file path)
// have been updated via "save settings" but the stack has not yet been redeployed to apply them.
// In that state the local clone is stale and the stack file cannot be read from disk.
+124
View File
@@ -76,6 +76,130 @@ func TestStackFile_GitPendingRedeploy_Returns409(t *testing.T) {
require.Equal(t, http.StatusConflict, rr.Code)
}
// setupFileVersionStackTest wires a handler over a real datastore + filesystem and creates a
// file-based (non-git) stack together with the given on-disk file versions. It returns the handler
// and the created stack so version-selection cases can be exercised through the HTTP handler.
func setupFileVersionStackTest(t *testing.T, stack *portainer.Stack, versionContent map[int]string) *Handler {
t.Helper()
_, store := datastore.MustNewTestStore(t, false, true)
_, err := mockCreateUser(store)
require.NoError(t, err)
endpoint, err := mockCreateEndpoint(store)
require.NoError(t, err)
fileService, err := filesystem.NewService(t.TempDir(), "")
require.NoError(t, err)
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.FileService = fileService
handler.DataStore = store
stackFolder := strconv.Itoa(int(stack.ID))
for v, content := range versionContent {
_, err := fileService.StoreStackFileFromBytesByVersion(stackFolder, stack.EntryPoint, v, []byte(content))
require.NoError(t, err)
}
stack.EndpointID = endpoint.ID
require.NoError(t, store.Stack().Create(stack))
return handler
}
// requestStackFile performs a GET /stacks/{id}/file request (optionally with a raw query string).
func requestStackFile(t *testing.T, handler *Handler, stackID portainer.StackID, rawQuery string) *httptest.ResponseRecorder {
t.Helper()
target := "/stacks/" + strconv.Itoa(int(stackID)) + "/file"
if rawQuery != "" {
target += "?" + rawQuery
}
req := mockCreateStackRequestWithSecurityContext(http.MethodGet, target, nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
return rr
}
// TestStackFile_VersionParam exercises the ?version= selector on a file-based stack: rejects a
// negative version, rejects an out-of-range version, and returns the selected version's content.
func TestStackFile_VersionParam(t *testing.T) {
t.Parallel()
newHandlerAndStack := func(t *testing.T) (*Handler, portainer.StackID) {
stack := &portainer.Stack{
ID: 10,
Type: portainer.DockerComposeStack,
EntryPoint: "docker-compose.yml",
StackFileVersion: 3,
Versions: []portainer.StackFileVersionInfo{
{Version: 1}, {Version: 2}, {Version: 3},
},
}
handler := setupFileVersionStackTest(t, stack, map[int]string{
1: "V1-CONTENT", 2: "V2-CONTENT", 3: "V3-CONTENT",
})
// Point ProjectPath at the current version directory (as the versioning code does).
stack.ProjectPath = handler.FileService.GetStackProjectPathByVersion("10", 3, "")
require.NoError(t, handler.DataStore.Stack().Update(stack.ID, stack))
return handler, stack.ID
}
t.Run("negative version returns 400", func(t *testing.T) {
handler, id := newHandlerAndStack(t)
rr := requestStackFile(t, handler, id, "version=-1")
require.Equal(t, http.StatusBadRequest, rr.Code)
})
t.Run("out-of-range version returns 400", func(t *testing.T) {
handler, id := newHandlerAndStack(t)
rr := requestStackFile(t, handler, id, "version=99")
require.Equal(t, http.StatusBadRequest, rr.Code)
})
t.Run("valid version returns that version content", func(t *testing.T) {
handler, id := newHandlerAndStack(t)
rr := requestStackFile(t, handler, id, "version=2")
require.Equal(t, http.StatusOK, rr.Code)
var resp stackFileResponse
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
require.Equal(t, "V2-CONTENT", resp.StackFileContent)
})
}
// TestStackFile_VersionParam_LegacyFallback covers the fallback branch for stacks predating the
// version history seed: when Versions[] is empty, an in-range version (1..StackFileVersion) is
// accepted and served from its v{N} directory.
func TestStackFile_VersionParam_LegacyFallback(t *testing.T) {
t.Parallel()
stack := &portainer.Stack{
ID: 11,
Type: portainer.DockerComposeStack,
EntryPoint: "docker-compose.yml",
StackFileVersion: 2,
// Versions intentionally empty: legacy stack without a recorded history.
}
handler := setupFileVersionStackTest(t, stack, map[int]string{
1: "LEGACY-V1", 2: "LEGACY-V2",
})
stack.ProjectPath = handler.FileService.GetStackProjectPathByVersion("11", 2, "")
require.NoError(t, handler.DataStore.Stack().Update(stack.ID, stack))
rr := requestStackFile(t, handler, stack.ID, "version=1")
require.Equal(t, http.StatusOK, rr.Code)
var resp stackFileResponse
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp))
require.Equal(t, "LEGACY-V1", resp.StackFileContent)
}
func TestStackFile_MatchingGitSettings_ReturnsFileContent(t *testing.T) {
t.Parallel()
_, store := datastore.MustNewTestStore(t, false, true)
+208 -55
View File
@@ -2,6 +2,7 @@ package stacks
import (
"context"
"fmt"
"net/http"
"strconv"
"time"
@@ -35,10 +36,15 @@ type updateComposeStackPayload struct {
// Deprecated(2.36): use RepullImageAndRedeploy instead for cleaner responsibility
// Force a pulling to current image with the original tag though the image is already the latest
PullImage bool `example:"false"`
// RollbackTo, when set, redeploys the content of a past file version as a new version.
// The server reads the target version's content from disk; StackFileContent may be empty.
RollbackTo *int `example:"3"`
}
func (payload *updateComposeStackPayload) Validate(r *http.Request) error {
if len(payload.StackFileContent) == 0 {
// For a rollback the content is read from disk on the server, so an empty payload is allowed.
if payload.RollbackTo == nil && len(payload.StackFileContent) == 0 {
return errors.New("Invalid stack file content")
}
@@ -58,10 +64,15 @@ type updateSwarmStackPayload struct {
// Deprecated(2.36): use RepullImageAndRedeploy instead for cleaner responsibility
// Force a pulling to current image with the original tag though the image is already the latest
PullImage bool `example:"false"`
// RollbackTo, when set, redeploys the content of a past file version as a new version.
// The server reads the target version's content from disk; StackFileContent may be empty.
RollbackTo *int `example:"3"`
}
func (payload *updateSwarmStackPayload) Validate(r *http.Request) error {
if len(payload.StackFileContent) == 0 {
// For a rollback the content is read from disk on the server, so an empty payload is allowed.
if payload.RollbackTo == nil && len(payload.StackFileContent) == 0 {
return errors.New("Invalid stack file content")
}
@@ -102,27 +113,39 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt
}
var stack *portainer.Stack
var pruneVersions []int
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
var httpErr *httperror.HandlerError
stack, httpErr = handler.updateStackInTx(tx, r, portainer.StackID(stackID), portainer.EndpointID(endpointID))
stack, pruneVersions, httpErr = handler.updateStackInTx(tx, r, portainer.StackID(stackID), portainer.EndpointID(endpointID))
if httpErr != nil {
return httpErr
}
return nil
})
// Physically delete the file-version directories pruned by retention only after the
// transaction has committed successfully. If the tx failed the trimmed Versions[] was
// never persisted, so the old directories must stay to match the DB (harmless orphans).
if err == nil && len(pruneVersions) > 0 {
handler.pruneStackFileVersionDirs(stack.ID, pruneVersions)
}
return response.TxResponse(w, stack, err)
}
func (handler *Handler) updateStackInTx(tx dataservices.DataStoreTx, r *http.Request, stackID portainer.StackID, endpointID portainer.EndpointID) (*portainer.Stack, *httperror.HandlerError) {
// updateStackInTx returns the updated stack and the file-version numbers pruned by retention.
// The pruned directories must be physically deleted by the caller only after the transaction
// has committed successfully (see stackUpdate).
func (handler *Handler) updateStackInTx(tx dataservices.DataStoreTx, r *http.Request, stackID portainer.StackID, endpointID portainer.EndpointID) (*portainer.Stack, []int, *httperror.HandlerError) {
stack, err := tx.Stack().Read(stackID)
if tx.IsErrObjectNotFound(err) {
return nil, httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err)
return nil, nil, httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err)
} else if err != nil {
return nil, httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err)
return nil, nil, httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err)
}
if stack.Status == portainer.StackStatusDeploying {
return nil, httperror.Conflict("Unable to update stack", errors.New("Stack deployment is already in progress"))
return nil, nil, httperror.Conflict("Unable to update stack", errors.New("Stack deployment is already in progress"))
}
if endpointID != 0 && endpointID != stack.EndpointID {
@@ -131,50 +154,51 @@ func (handler *Handler) updateStackInTx(tx dataservices.DataStoreTx, r *http.Req
endpoint, err := tx.Endpoint().Endpoint(stack.EndpointID)
if tx.IsErrObjectNotFound(err) {
return nil, httperror.NotFound("Unable to find the environment associated to the stack inside the database", err)
return nil, nil, httperror.NotFound("Unable to find the environment associated to the stack inside the database", err)
} else if err != nil {
return nil, httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err)
return nil, nil, httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err)
}
if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil {
return nil, httperror.Forbidden("Permission denied to access environment", err)
return nil, nil, httperror.Forbidden("Permission denied to access environment", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve info from request context", err)
return nil, nil, httperror.InternalServerError("Unable to retrieve info from request context", err)
}
//only check resource control when it is a DockerSwarmStack or a DockerComposeStack
if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
resourceControl, err := tx.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve a resource control associated to the stack", err)
return nil, nil, httperror.InternalServerError("Unable to retrieve a resource control associated to the stack", err)
}
if access, err := handler.userCanAccessStack(securityContext, resourceControl); err != nil {
return nil, httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err)
return nil, nil, httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err)
} else if !access {
return nil, httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
return nil, nil, httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
}
}
if canManage, err := handler.userCanManageStacks(securityContext, endpoint); err != nil {
return nil, httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err)
return nil, nil, httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err)
} else if !canManage {
errMsg := "Stack editing is disabled for non-admin users"
return nil, httperror.Forbidden(errMsg, errors.New(errMsg))
return nil, nil, httperror.Forbidden(errMsg, errors.New(errMsg))
}
deployGate := newDeployGate()
if err := handler.updateAndDeployStack(tx, r, stack, endpoint, deployGate); err != nil {
return nil, err
pruneVersions, httpErr := handler.updateAndDeployStack(tx, r, stack, endpoint, deployGate)
if httpErr != nil {
return nil, nil, httpErr
}
user, err := tx.User().Read(securityContext.UserID)
if err != nil {
return nil, httperror.BadRequest("Cannot find context user", errors.Wrap(err, "failed to fetch the user"))
return nil, nil, httperror.BadRequest("Cannot find context user", errors.Wrap(err, "failed to fetch the user"))
}
stack.UpdatedBy = user.Username
@@ -183,19 +207,21 @@ func (handler *Handler) updateStackInTx(tx dataservices.DataStoreTx, r *http.Req
if err := tx.Stack().Update(stack.ID, stack); err != nil {
deployGate.abortDeploy()
return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
return nil, nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
}
deployGate.startDeploy()
if err := fillStackGitConfig(tx, stack); err != nil {
return nil, httperror.InternalServerError("Unable to load git config for stack", err)
return nil, nil, httperror.InternalServerError("Unable to load git config for stack", err)
}
return stack, nil
return stack, pruneVersions, nil
}
func (handler *Handler) updateAndDeployStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) *httperror.HandlerError {
// updateAndDeployStack returns the file-version numbers pruned by retention (to be deleted
// post-commit by the caller) alongside any handler error.
func (handler *Handler) updateAndDeployStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) ([]int, *httperror.HandlerError) {
switch stack.Type {
case portainer.DockerSwarmStack:
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
@@ -206,13 +232,105 @@ func (handler *Handler) updateAndDeployStack(tx dataservices.DataStoreTx, r *htt
return handler.updateComposeStack(tx, r, stack, endpoint, gate)
case portainer.KubernetesStack:
return handler.updateKubernetesStack(tx, r, stack, endpoint, gate)
return nil, handler.updateKubernetesStack(tx, r, stack, endpoint, gate)
}
return httperror.InternalServerError("Unsupported stack", errors.Errorf("unsupported stack type: %v", stack.Type))
return nil, httperror.InternalServerError("Unsupported stack", errors.Errorf("unsupported stack type: %v", stack.Type))
}
func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) *httperror.HandlerError {
// validateRollbackTarget ensures the requested rollback version is within range and present
// in the stack's append-only version history.
func validateRollbackTarget(stack *portainer.Stack, target int) error {
if target < 1 || target > stack.StackFileVersion {
return errors.Errorf("rollback version %d is out of range (1..%d)", target, stack.StackFileVersion)
}
for _, v := range stack.Versions {
if v.Version == target {
return nil
}
}
return errors.Errorf("rollback version %d not found in stack history", target)
}
// snapshotFileBasedStackVersion writes the new stack content into a fresh v{N} version folder
// for a file-based (non-git) Compose/Swarm stack and updates the stack's version bookkeeping
// (ProjectPath, StackFileVersion, PreviousDeploymentInfo, Versions) plus applies retention.
// payloadContent is the entrypoint content from the request; when rollbackTo is non-nil the
// target version's content is read from disk on the server (client content is ignored) and a
// multi-file copy of that version is snapshotted.
// The returned slice holds the version numbers whose on-disk directories were pruned by
// retention and must be physically deleted by the caller AFTER the transaction commits.
func (handler *Handler) snapshotFileBasedStackVersion(tx dataservices.DataStoreTx, stack *portainer.Stack, payloadContent []byte, rollbackTo *int, userID portainer.UserID) ([]int, *httperror.HandlerError) {
stackFolder := strconv.Itoa(int(stack.ID))
note := ""
srcProjectPath := stack.ProjectPath
entryPointContent := payloadContent
if rollbackTo != nil {
target := *rollbackTo
if err := validateRollbackTarget(stack, target); err != nil {
return nil, httperror.BadRequest("Invalid rollback version", err)
}
srcProjectPath = handler.FileService.GetStackProjectPathByVersion(stackFolder, target, "")
content, err := handler.FileService.GetFileContent(srcProjectPath, stack.EntryPoint)
if err != nil {
return nil, httperror.InternalServerError("Unable to read rollback version content from disk", err)
}
entryPointContent = content
note = fmt.Sprintf("rollback from v%d", target)
}
filesContent, err := collectStackFilesContent(handler.FileService, stack, srcProjectPath, entryPointContent)
if err != nil {
return nil, httperror.InternalServerError("Unable to read stack files from disk", err)
}
newVersion := stack.StackFileVersion + 1
if newVersion < 1 {
newVersion = 1
}
projectPath, err := snapshotStackFilesToVersion(handler.FileService, stackFolder, newVersion, filesContent)
if err != nil {
return nil, httperror.InternalServerError("Unable to persist stack file version on disk", err)
}
deployedBy := ""
if user, err := tx.User().Read(userID); err == nil {
deployedBy = user.Username
}
// Record the prior file version so the frontend can display where the stack came from.
stack.PreviousDeploymentInfo = &portainer.StackDeploymentInfo{
FileVersion: stack.StackFileVersion,
}
stack.ProjectPath = projectPath
stack.StackFileVersion = newVersion
stack.Versions = append(stack.Versions, portainer.StackFileVersionInfo{
Version: newVersion,
CreatedAt: time.Now().Unix(),
CreatedBy: deployedBy,
Note: note,
})
// Trim history in memory only; the pruned directories are deleted post-commit.
pruneVersions := applyRetention(stack, maxStackFileVersions)
return pruneVersions, nil
}
func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) ([]int, *httperror.HandlerError) {
// Whether the stack is file-based (non-git) before any git-detach conversion below.
// Only file-based stacks get an on-disk file version history; git stacks keep their
// existing (commit-based) behavior.
wasFileBased := stack.WorkflowID == 0
// Must not be git based stack. stop the auto update job if there is any
if stack.AutoUpdate != nil {
deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler)
@@ -224,7 +342,7 @@ func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.
var payload updateComposeStackPayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
return nil, httperror.BadRequest("Invalid request payload", err)
}
payload.RepullImageAndRedeploy = payload.RepullImageAndRedeploy || payload.PullImage
@@ -234,23 +352,32 @@ func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.
oldWorkflowID := stack.WorkflowID
stack.WorkflowID = 0
if err := tx.Workflow().Delete(oldWorkflowID); err != nil {
return httperror.InternalServerError("Unable to remove git workflow records from database", err)
return nil, httperror.InternalServerError("Unable to remove git workflow records from database", err)
}
}
stackFolder := strconv.Itoa(int(stack.ID))
if _, err := handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)); err != nil {
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
log.Warn().Err(rollbackErr).Msg("rollback stack file error")
}
return httperror.InternalServerError("Unable to persist updated Compose file on disk", err)
}
// Create compose deployment config
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
return nil, httperror.InternalServerError("Unable to retrieve info from request context", err)
}
var pruneVersions []int
stackFolder := strconv.Itoa(int(stack.ID))
if wasFileBased {
var httpErr *httperror.HandlerError
pruneVersions, httpErr = handler.snapshotFileBasedStackVersion(tx, stack, []byte(payload.StackFileContent), payload.RollbackTo, securityContext.UserID)
if httpErr != nil {
return nil, httpErr
}
} else {
if _, err := handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)); err != nil {
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
log.Warn().Err(rollbackErr).Msg("rollback stack file error")
}
return nil, httperror.InternalServerError("Unable to persist updated Compose file on disk", err)
}
}
composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfigTx(tx, securityContext,
@@ -262,11 +389,14 @@ func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.
payload.RepullImageAndRedeploy,
payload.RepullImageAndRedeploy)
if err != nil {
// For a versioned (file-based) stack no {entryPoint}.bak backup exists — the version
// directory is the durable record — so RollbackStackFile is a deliberate no-op here;
// it only performs a real rollback on the legacy git-detach path above.
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
log.Warn().Err(rollbackErr).Msg("rollback stack file error")
}
return httperror.InternalServerError(err.Error(), err)
return nil, httperror.InternalServerError(err.Error(), err)
}
if stack.Option != nil {
@@ -278,6 +408,9 @@ func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.
}
postDeploy := func(ctx context.Context, deployErr error) {
// For a versioned (file-based) stack these backup ops are deliberate no-ops: no
// {entryPoint}.bak was ever written, so a failed deploy simply leaves an unused
// version directory behind (the durable record). They only act on the git-detach path.
if deployErr != nil {
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
log.Warn().Err(rollbackErr).Msg("rollback stack file error")
@@ -292,10 +425,15 @@ func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.
go stackDeploy(handler.DataStore, stack.ID, composeDeploymentConfig, gate, postDeploy)
return nil
return pruneVersions, nil
}
func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) *httperror.HandlerError {
func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) ([]int, *httperror.HandlerError) {
// Whether the stack is file-based (non-git) before any git-detach conversion below.
// Only file-based stacks get an on-disk file version history; git stacks keep their
// existing (commit-based) behavior.
wasFileBased := stack.WorkflowID == 0
// Must not be git based stack. stop the auto update job if there is any
if stack.AutoUpdate != nil {
deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler)
@@ -307,7 +445,7 @@ func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Re
var payload updateSwarmStackPayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
return nil, httperror.BadRequest("Invalid request payload", err)
}
payload.RepullImageAndRedeploy = payload.RepullImageAndRedeploy || payload.PullImage
stack.Env = payload.Env
@@ -316,23 +454,32 @@ func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Re
oldWorkflowID := stack.WorkflowID
stack.WorkflowID = 0
if err := tx.Workflow().Delete(oldWorkflowID); err != nil {
return httperror.InternalServerError("Unable to remove git workflow records from database", err)
return nil, httperror.InternalServerError("Unable to remove git workflow records from database", err)
}
}
stackFolder := strconv.Itoa(int(stack.ID))
if _, err := handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)); err != nil {
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
log.Warn().Err(rollbackErr).Msg("rollback stack file error")
}
return httperror.InternalServerError("Unable to persist updated Compose file on disk", err)
}
// Create swarm deployment config
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
return nil, httperror.InternalServerError("Unable to retrieve info from request context", err)
}
var pruneVersions []int
stackFolder := strconv.Itoa(int(stack.ID))
if wasFileBased {
var httpErr *httperror.HandlerError
pruneVersions, httpErr = handler.snapshotFileBasedStackVersion(tx, stack, []byte(payload.StackFileContent), payload.RollbackTo, securityContext.UserID)
if httpErr != nil {
return nil, httpErr
}
} else {
if _, err := handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)); err != nil {
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
log.Warn().Err(rollbackErr).Msg("rollback stack file error")
}
return nil, httperror.InternalServerError("Unable to persist updated Compose file on disk", err)
}
}
swarmDeploymentConfig, err := deployments.CreateSwarmStackDeploymentConfigTx(tx, securityContext,
@@ -343,11 +490,14 @@ func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Re
payload.Prune,
payload.RepullImageAndRedeploy)
if err != nil {
// For a versioned (file-based) stack no {entryPoint}.bak backup exists — the version
// directory is the durable record — so RollbackStackFile is a deliberate no-op here;
// it only performs a real rollback on the legacy git-detach path above.
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
log.Warn().Err(rollbackErr).Msg("rollback stack file error")
}
return httperror.InternalServerError(err.Error(), err)
return nil, httperror.InternalServerError(err.Error(), err)
}
if stack.Option != nil {
@@ -359,6 +509,9 @@ func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Re
}
postDeploy := func(ctx context.Context, deployErr error) {
// For a versioned (file-based) stack these backup ops are deliberate no-ops: no
// {entryPoint}.bak was ever written, so a failed deploy simply leaves an unused
// version directory behind (the durable record). They only act on the git-detach path.
if deployErr != nil {
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
log.Warn().Err(rollbackErr).Msg("rollback stack file error")
@@ -373,7 +526,7 @@ func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Re
go stackDeploy(handler.DataStore, stack.ID, swarmDeploymentConfig, gate, postDeploy)
return nil
return pruneVersions, nil
}
func stackDeploy(dataStore dataservices.DataStore, stackID portainer.StackID, stackDeploymentConfig deployments.StackDeploymentConfiger, gate *deployGate, postDeploy postDeployFunc) {
+8 -8
View File
@@ -40,7 +40,7 @@ func Test_updateStackInTx(t *testing.T) {
// Execute updateStackInTx within a successful transaction
err := setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID)
_, _, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID)
if handlerErr != nil {
return handlerErr
}
@@ -70,7 +70,7 @@ func Test_updateStackInTx(t *testing.T) {
// Execute updateStackInTx within a transaction that we force to fail
err := setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
updatedStack, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID)
updatedStack, _, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID)
if handlerErr != nil {
return handlerErr
}
@@ -109,7 +109,7 @@ func Test_updateStackInTx(t *testing.T) {
var handlerErr *httperror.HandlerError
_ = setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, handlerErr = setup.handler.updateStackInTx(tx, setup.req, 9999, setup.endpoint.ID)
_, _, handlerErr = setup.handler.updateStackInTx(tx, setup.req, 9999, setup.endpoint.ID)
return handlerErr
})
@@ -132,7 +132,7 @@ func Test_updateStackInTx(t *testing.T) {
var handlerErr *httperror.HandlerError
_ = setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, handlerErr = setup.handler.updateStackInTx(tx, setup.req, stack.ID, 2999) // Non-existent endpoint ID
_, _, handlerErr = setup.handler.updateStackInTx(tx, setup.req, stack.ID, 2999) // Non-existent endpoint ID
return nil
})
@@ -162,7 +162,7 @@ func Test_updateStackInTx(t *testing.T) {
var handlerErr *httperror.HandlerError
_ = setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, handlerErr = setup.handler.updateStackInTx(tx, setup.req, stack.ID, stack.EndpointID)
_, _, handlerErr = setup.handler.updateStackInTx(tx, setup.req, stack.ID, stack.EndpointID)
return nil
})
@@ -187,7 +187,7 @@ func Test_updateStackInTx(t *testing.T) {
var handlerErr *httperror.HandlerError
_ = setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, handlerErr = setup.handler.updateStackInTx(tx, setup.req, stack.ID, stack.EndpointID)
_, _, handlerErr = setup.handler.updateStackInTx(tx, setup.req, stack.ID, stack.EndpointID)
return nil
})
@@ -423,7 +423,7 @@ func Test_updateSwarmStack_Prune(t *testing.T) {
setup.handler.StackDeployer = deployer
err := setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID)
_, _, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID)
if handlerErr != nil {
return handlerErr
}
@@ -462,7 +462,7 @@ func Test_updateComposeStack_Prune(t *testing.T) {
setup.handler.StackDeployer = deployer
err := setup.store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID)
_, _, handlerErr := setup.handler.updateStackInTx(tx, setup.req, setup.stack.ID, setup.endpoint.ID)
if handlerErr != nil {
return handlerErr
}
@@ -0,0 +1,97 @@
package stacks
import (
"strconv"
portainer "github.com/portainer/portainer/api"
"github.com/rs/zerolog/log"
)
// maxStackFileVersions caps the number of on-disk file versions kept per file-based stack.
// Once the history exceeds this cap, the oldest versions are pruned from disk and history.
const maxStackFileVersions = 20
// snapshotStackFilesToVersion writes every stack file into the v{version} folder of the given
// stack. filesContent maps a file name (relative to the stack root) to its content and must
// include the entrypoint (multi-file: all AdditionalFiles are copied too). It returns the
// versioned project path, which the caller must assign to stack.ProjectPath (the underlying
// StoreStackFileFromBytesByVersion returns the base path, not the version path).
func snapshotStackFilesToVersion(fileService portainer.FileService, stackID string, version int, filesContent map[string][]byte) (string, error) {
for fileName, content := range filesContent {
if _, err := fileService.StoreStackFileFromBytesByVersion(stackID, fileName, version, content); err != nil {
return "", err
}
}
return fileService.GetStackProjectPathByVersion(stackID, version, ""), nil
}
// applyRetention trims the oldest entries from stack.Versions once the history exceeds
// maxVersions and returns the version numbers whose on-disk v{N} directories should be
// deleted. It never selects the currently deployed version for deletion, and the in-memory
// trim is kept consistent with what is actually pruned (an entry whose directory is
// intentionally kept stays in stack.Versions).
//
// The physical directory deletion is deliberately NOT performed here: the caller must run
// it (see (*Handler).pruneStackFileVersionDirs) only AFTER the enclosing DB transaction has
// committed. Deleting inside the transaction would leave dangling history if the tx later
// rolled back (the persisted Versions[] would still reference now-deleted directories).
func applyRetention(stack *portainer.Stack, maxVersions int) []int {
if maxVersions <= 0 || len(stack.Versions) <= maxVersions {
return nil
}
removeCount := len(stack.Versions) - maxVersions
pruned := make([]int, 0, removeCount)
kept := make([]portainer.StackFileVersionInfo, 0, len(stack.Versions))
for i, info := range stack.Versions {
// Only the oldest removeCount entries are eligible for pruning, and never the
// currently deployed version (safety guard). Everything else is kept.
if i < removeCount && info.Version != stack.StackFileVersion {
pruned = append(pruned, info.Version)
continue
}
kept = append(kept, info)
}
stack.Versions = kept
return pruned
}
// pruneStackFileVersionDirs physically deletes the given file-version directories for a stack.
// It is best-effort: a failed deletion is logged and does not fail the request. This MUST be
// called only after the DB transaction that trimmed stack.Versions has committed successfully,
// so a rolled-back transaction never leaves history referencing deleted directories.
func (handler *Handler) pruneStackFileVersionDirs(stackID portainer.StackID, versions []int) {
stackFolder := strconv.Itoa(int(stackID))
for _, v := range versions {
dir := handler.FileService.GetStackProjectPathByVersion(stackFolder, v, "")
if err := handler.FileService.RemoveDirectory(dir); err != nil {
log.Warn().Err(err).Int("version", v).Int("stack_id", int(stackID)).Msg("unable to remove old stack file version directory")
}
}
}
// collectStackFilesContent builds the file-name -> content map for a version snapshot: the
// entrypoint is taken from entryPointContent (the new/target content), while every additional
// file is read from srcProjectPath so the whole file set is carried into the new version.
func collectStackFilesContent(fileService portainer.FileService, stack *portainer.Stack, srcProjectPath string, entryPointContent []byte) (map[string][]byte, error) {
filesContent := map[string][]byte{
stack.EntryPoint: entryPointContent,
}
for _, additionalFile := range stack.AdditionalFiles {
content, err := fileService.GetFileContent(srcProjectPath, additionalFile)
if err != nil {
return nil, err
}
filesContent[additionalFile] = content
}
return filesContent, nil
}
@@ -0,0 +1,367 @@
package stacks
import (
"errors"
"strconv"
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/api/internal/testhelpers"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/stretchr/testify/require"
)
// TestApplyRetention verifies that once the version history exceeds the cap, the oldest
// entries are trimmed from Versions in memory and their version numbers are returned for
// post-commit deletion. Retention itself must NOT touch the disk.
func TestApplyRetention(t *testing.T) {
t.Parallel()
fs, err := filesystem.NewService(t.TempDir(), "")
require.NoError(t, err)
stackID := 7
stackFolder := strconv.Itoa(stackID)
const total = maxStackFileVersions + 2
stack := &portainer.Stack{
ID: portainer.StackID(stackID),
EntryPoint: "docker-compose.yml",
StackFileVersion: total,
}
for v := 1; v <= total; v++ {
_, err := fs.StoreStackFileFromBytesByVersion(stackFolder, stack.EntryPoint, v, []byte("v"+strconv.Itoa(v)))
require.NoError(t, err)
stack.Versions = append(stack.Versions, portainer.StackFileVersionInfo{Version: v})
}
pruned := applyRetention(stack, maxStackFileVersions)
require.Len(t, stack.Versions, maxStackFileVersions)
// The two oldest (v1, v2) must be dropped; the window now starts at v3.
require.Equal(t, 3, stack.Versions[0].Version)
require.Equal(t, total, stack.Versions[len(stack.Versions)-1].Version)
// The two oldest versions are returned for deletion, in order.
require.Equal(t, []int{1, 2}, pruned)
// applyRetention must NOT delete anything from disk; all directories still exist.
for _, v := range []int{1, 2, 3, total} {
exists, err := fs.FileExists(fs.GetStackProjectPathByVersion(stackFolder, v, ""))
require.NoError(t, err)
require.True(t, exists, "version %d directory must still exist (retention is in-memory only)", v)
}
}
// TestApplyRetentionKeepsCurrentVersion verifies the safety guard: a to-be-pruned entry that
// happens to be the currently deployed version is neither trimmed from Versions nor returned
// for deletion, keeping the slice trim consistent with what is actually pruned.
func TestApplyRetentionKeepsCurrentVersion(t *testing.T) {
t.Parallel()
// 4 versions, cap 2 -> the 2 oldest (v1, v2) are eligible; but mark v1 as current.
stack := &portainer.Stack{ID: 1, StackFileVersion: 1}
stack.Versions = []portainer.StackFileVersionInfo{{Version: 1}, {Version: 2}, {Version: 3}, {Version: 4}}
pruned := applyRetention(stack, 2)
// Only v2 is pruned; v1 (current) is kept even though it is in the oldest window.
require.Equal(t, []int{2}, pruned)
// The kept slice retains v1 and every non-pruned entry, in order.
require.Equal(t, []int{1, 3, 4}, versionNumbers(stack.Versions))
}
// TestApplyRetentionNoOp verifies retention leaves history untouched when under the cap.
func TestApplyRetentionNoOp(t *testing.T) {
t.Parallel()
stack := &portainer.Stack{ID: 1, StackFileVersion: 3}
stack.Versions = []portainer.StackFileVersionInfo{{Version: 1}, {Version: 2}, {Version: 3}}
pruned := applyRetention(stack, maxStackFileVersions)
require.Nil(t, pruned)
require.Len(t, stack.Versions, 3)
}
func versionNumbers(versions []portainer.StackFileVersionInfo) []int {
out := make([]int, len(versions))
for i, v := range versions {
out[i] = v.Version
}
return out
}
// TestValidateRollbackTarget checks the rollback-version guard: it rejects non-positive versions,
// versions above the current file version, and versions that are not present in the append-only
// history (a "hole"), while accepting an in-range version that exists in Versions[].
func TestValidateRollbackTarget(t *testing.T) {
t.Parallel()
// StackFileVersion is 5, but v4 was never recorded (a hole in the history).
stack := &portainer.Stack{
StackFileVersion: 5,
Versions: []portainer.StackFileVersionInfo{
{Version: 1}, {Version: 2}, {Version: 3}, {Version: 5},
},
}
require.Error(t, validateRollbackTarget(stack, 0), "zero is not a valid version")
require.Error(t, validateRollbackTarget(stack, -1), "negative is not a valid version")
require.Error(t, validateRollbackTarget(stack, 6), "version above StackFileVersion is out of range")
require.Error(t, validateRollbackTarget(stack, 4), "a version missing from history (hole) is rejected")
require.NoError(t, validateRollbackTarget(stack, 3), "in-range version present in history is accepted")
require.NoError(t, validateRollbackTarget(stack, 5), "current version present in history is accepted")
}
// newVersioningTestHandler builds a Handler wired with a real filesystem service and datastore,
// plus a test user, for exercising the version snapshot/prune helpers.
func newVersioningTestHandler(t *testing.T) (*Handler, *datastore.Store, portainer.FileService, *portainer.User) {
t.Helper()
_, store := datastore.MustNewTestStore(t, false, true)
fs, err := filesystem.NewService(t.TempDir(), "")
require.NoError(t, err)
user, err := mockCreateUser(store)
require.NoError(t, err)
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.DataStore = store
handler.FileService = fs
return handler, store, fs, user
}
// TestSnapshotFileBasedStackVersion_Rollback verifies the rollback branch: it reads the TARGET
// version's content from disk (ignoring the client-supplied content), writes a NEW monotonic
// version whose note is "rollback from v{N}", and repoints ProjectPath to the new version dir.
func TestSnapshotFileBasedStackVersion_Rollback(t *testing.T) {
t.Parallel()
handler, store, fs, user := newVersioningTestHandler(t)
stackFolder := "1"
entryPoint := "docker-compose.yml"
// Seed three on-disk versions with distinct content.
for v, content := range map[int]string{1: "V1-CONTENT", 2: "V2-CONTENT", 3: "V3-CONTENT"} {
_, err := fs.StoreStackFileFromBytesByVersion(stackFolder, entryPoint, v, []byte(content))
require.NoError(t, err)
}
stack := &portainer.Stack{
ID: 1,
EntryPoint: entryPoint,
StackFileVersion: 3,
ProjectPath: fs.GetStackProjectPathByVersion(stackFolder, 3, ""),
Versions: []portainer.StackFileVersionInfo{
{Version: 1}, {Version: 2}, {Version: 3},
},
}
target := 1
var (
pruned []int
httpErr *httperror.HandlerError
)
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
// Client content is deliberately non-empty to prove it is IGNORED for a rollback.
pruned, httpErr = handler.snapshotFileBasedStackVersion(tx, stack, []byte("CLIENT-CONTENT-IGNORED"), &target, user.ID)
return nil
})
require.NoError(t, err)
require.Nil(t, httpErr)
require.Empty(t, pruned, "no retention pruning expected below the cap")
// A new monotonic version (v4) was created.
require.Equal(t, 4, stack.StackFileVersion)
require.Equal(t, fs.GetStackProjectPathByVersion(stackFolder, 4, ""), stack.ProjectPath)
last := stack.Versions[len(stack.Versions)-1]
require.Equal(t, 4, last.Version)
require.Equal(t, "rollback from v1", last.Note)
// The new version's content is the TARGET (v1) content read from disk, not the client payload.
got, err := fs.GetFileContent(stack.ProjectPath, entryPoint)
require.NoError(t, err)
require.Equal(t, "V1-CONTENT", string(got))
}
// TestSnapshotFileBasedStackVersion_MonotonicVersion guards against a len-based next-version bug:
// the new version must be StackFileVersion+1, strictly greater than any previously trimmed version,
// even when the history slice is shorter than StackFileVersion (older entries already pruned).
func TestSnapshotFileBasedStackVersion_MonotonicVersion(t *testing.T) {
t.Parallel()
handler, store, fs, user := newVersioningTestHandler(t)
stackFolder := "1"
entryPoint := "docker-compose.yml"
// StackFileVersion is 24 but only two history entries remain (older ones were pruned):
// a len-based scheme would wrongly compute the next version as len+1 = 3.
stack := &portainer.Stack{
ID: 1,
EntryPoint: entryPoint,
StackFileVersion: 24,
ProjectPath: fs.GetStackProjectPathByVersion(stackFolder, 24, ""),
Versions: []portainer.StackFileVersionInfo{
{Version: 23}, {Version: 24},
},
}
var httpErr *httperror.HandlerError
err := store.UpdateTx(func(tx dataservices.DataStoreTx) error {
_, httpErr = handler.snapshotFileBasedStackVersion(tx, stack, []byte("NEW-CONTENT"), nil, user.ID)
return nil
})
require.NoError(t, err)
require.Nil(t, httpErr)
require.Equal(t, 25, stack.StackFileVersion, "next version must be StackFileVersion+1, not len-based")
last := stack.Versions[len(stack.Versions)-1]
require.Equal(t, 25, last.Version)
require.Greater(t, last.Version, 24, "new version must be strictly greater than any prior version")
got, err := fs.GetFileContent(stack.ProjectPath, entryPoint)
require.NoError(t, err)
require.Equal(t, "NEW-CONTENT", string(got))
}
// countingFileService wraps a FileService to count and optionally fail RemoveDirectory calls,
// so tests can assert prune ordering and error-swallowing without touching real disk.
type countingFileService struct {
portainer.FileService
removeDirErr error
removeDirCalls int
removedDirs []string
}
func (s *countingFileService) GetStackProjectPathByVersion(stackID string, version int, commitHash string) string {
return "compose/" + stackID + "/v" + strconv.Itoa(version)
}
func (s *countingFileService) RemoveDirectory(dir string) error {
s.removeDirCalls++
s.removedDirs = append(s.removedDirs, dir)
return s.removeDirErr
}
// TestPruneStackFileVersionDirs_RemovesGivenDirs verifies the prune helper physically deletes
// exactly the requested version directories and leaves the others untouched.
func TestPruneStackFileVersionDirs_RemovesGivenDirs(t *testing.T) {
t.Parallel()
fs, err := filesystem.NewService(t.TempDir(), "")
require.NoError(t, err)
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.FileService = fs
const stackID = portainer.StackID(9)
stackFolder := strconv.Itoa(int(stackID))
for v := 1; v <= 3; v++ {
_, err := fs.StoreStackFileFromBytesByVersion(stackFolder, "docker-compose.yml", v, []byte("v"+strconv.Itoa(v)))
require.NoError(t, err)
}
handler.pruneStackFileVersionDirs(stackID, []int{1, 3})
for v, wantExists := range map[int]bool{1: false, 2: true, 3: false} {
exists, err := fs.FileExists(fs.GetStackProjectPathByVersion(stackFolder, v, ""))
require.NoError(t, err)
require.Equal(t, wantExists, exists, "version %d directory existence mismatch", v)
}
}
// TestPruneStackFileVersionDirs_SwallowsRemoveError verifies a RemoveDirectory failure is
// best-effort: it is logged and swallowed (no panic), and every requested version is attempted.
func TestPruneStackFileVersionDirs_SwallowsRemoveError(t *testing.T) {
t.Parallel()
fs := &countingFileService{removeDirErr: errors.New("disk error")}
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.FileService = fs
require.NotPanics(t, func() {
handler.pruneStackFileVersionDirs(7, []int{1, 2})
})
require.Equal(t, 2, fs.removeDirCalls, "every requested version directory must be attempted")
}
// TestPruneGateContract_Illustrative documents the post-commit gate shape used by stackUpdate:
// the pruned version directories are physically removed only when the transaction committed
// (err == nil); on a failed transaction the trimmed Versions[] was never persisted, so the
// directories must be kept on disk to stay consistent with the database.
//
// NOTE: this reproduces the gate condition inline — it illustrates the intended contract rather
// than exercising the real handler wiring (forcing a mid-commit UpdateTx failure while
// pruneVersions is already populated is not injectable in a unit test). The actual gate lives in
// stackUpdate (`if err == nil && len(pruneVersions) > 0`); pruneStackFileVersionDirs's real
// behaviour (deletes the given dirs, swallows RemoveDirectory errors) is covered non-vacuously by
// TestPruneStackFileVersionDirs_RemovesGivenDirs and _SwallowsRemoveError.
func TestPruneGateContract_Illustrative(t *testing.T) {
t.Parallel()
pruneVersions := []int{1, 2}
t.Run("transaction failed - prune skipped", func(t *testing.T) {
fs := &countingFileService{}
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.FileService = fs
var err error = errors.New("commit failed")
if err == nil && len(pruneVersions) > 0 {
handler.pruneStackFileVersionDirs(7, pruneVersions)
}
require.Zero(t, fs.removeDirCalls, "no directory may be deleted when the transaction failed")
})
t.Run("transaction committed - prune runs", func(t *testing.T) {
fs := &countingFileService{}
handler := NewHandler(testhelpers.NewTestRequestBouncer())
handler.FileService = fs
var err error
if err == nil && len(pruneVersions) > 0 {
handler.pruneStackFileVersionDirs(7, pruneVersions)
}
require.Equal(t, len(pruneVersions), fs.removeDirCalls, "all pruned directories deleted after commit")
})
}
// TestSnapshotStackFilesToVersion verifies a multi-file snapshot writes every file into the
// v{N} folder and returns the version project path (not the base path).
func TestSnapshotStackFilesToVersion(t *testing.T) {
t.Parallel()
fs, err := filesystem.NewService(t.TempDir(), "")
require.NoError(t, err)
stackFolder := "42"
filesContent := map[string][]byte{
"docker-compose.yml": []byte("main"),
"override.yml": []byte("extra"),
}
projectPath, err := snapshotStackFilesToVersion(fs, stackFolder, 5, filesContent)
require.NoError(t, err)
require.Equal(t, fs.GetStackProjectPathByVersion(stackFolder, 5, ""), projectPath)
for name, want := range filesContent {
got, err := fs.GetFileContent(projectPath, name)
require.NoError(t, err)
require.Equal(t, want, got)
}
}
+96
View File
@@ -0,0 +1,96 @@
package stacks
import (
"net/http"
portainer "github.com/portainer/portainer/api"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/stacks/stackutils"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/pkg/errors"
)
// @id StackVersions
// @summary List the file version history of a file-based stack
// @description Get the append-only file version history for a file-based (non-git) Compose/Swarm stack.
// @description **Access policy**: restricted
// @tags stacks
// @security ApiKeyAuth
// @security jwt
// @produce json
// @param id path int true "Stack identifier"
// @success 200 {array} portainer.StackFileVersionInfo "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 404 "Stack not found"
// @failure 500 "Server error"
// @router /stacks/{id}/versions [get]
func (handler *Handler) stackVersions(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid stack identifier route variable", err)
}
stack, err := handler.DataStore.Stack().Read(portainer.StackID(stackID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
if handler.DataStore.IsErrObjectNotFound(err) {
if !securityContext.IsAdmin {
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
}
} else if err != nil {
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
}
canManage, err := handler.userCanManageStacks(securityContext, endpoint)
if err != nil {
return httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err)
}
if !canManage {
errMsg := "Stack management is disabled for non-admin users"
return httperror.Forbidden(errMsg, errors.New(errMsg))
}
if endpoint != nil {
if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil {
return httperror.Forbidden("Permission denied to access environment", err)
}
if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil {
return httperror.InternalServerError("Unable to retrieve a resource control associated to the stack", err)
}
access, err := handler.userCanAccessStack(securityContext, resourceControl)
if err != nil {
return httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err)
}
if !access {
return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
}
}
}
// Only file-based (non-git) Compose/Swarm stacks carry a file version history.
versions := stack.Versions
if versions == nil || stack.WorkflowID != 0 {
versions = []portainer.StackFileVersionInfo{}
}
return response.JSON(w, versions)
}
+22 -1
View File
@@ -324,6 +324,19 @@ type (
SourceID SourceID `json:"SourceID,omitempty"`
}
// StackFileVersionInfo records one entry in the append-only file version history
// of a file-based (non-git) Compose/Swarm stack.
StackFileVersionInfo struct {
// Version is the v{N} folder number holding this version's files
Version int `json:"Version"`
// CreatedAt is the unix time (seconds) when this version was deployed
CreatedAt int64 `json:"CreatedAt"`
// CreatedBy is the username/id that deployed this version
CreatedBy string `json:"CreatedBy,omitempty"`
// Note is an optional description (e.g. "rollback from v5", "migrated")
Note string `json:"Note,omitempty"`
}
// EdgeStack represents an edge stack
EdgeStack struct {
// EdgeStack Identifier
@@ -1355,6 +1368,14 @@ type (
// DeploymentStatus records the status progression of the current deployment.
// Cleared when a new deployment starts.
DeploymentStatus []StackDeploymentStatus `json:"DeploymentStatus,omitempty"`
// StackFileVersion is the current (monotonic) file version number for file-based
// (non-git) Compose/Swarm stacks. Zero for git/kubernetes/edge stacks.
StackFileVersion int `json:"StackFileVersion,omitempty"`
// PreviousDeploymentInfo records the deployment info captured before the last update,
// used by the frontend to display the prior file version.
PreviousDeploymentInfo *StackDeploymentInfo `json:"PreviousDeploymentInfo,omitempty"`
// Versions is the append-only file version history (source of truth) for file-based stacks.
Versions []StackFileVersionInfo `json:"Versions,omitempty"`
}
// StackOption represents the options for stack deployment
@@ -2105,7 +2126,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.43.0"
APIVersion = "2.44.0"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "STS"
// Edition is what this edition of Portainer is called
@@ -38,7 +38,7 @@ func (b *ComposeStackFileBuilder) prepare(_ context.Context, payload *StackPaylo
return err
}
return b.storeStackFile(payload.StackFileContent)
return b.storeStackFileVersioned(payload.StackFileContent)
}
func (b *ComposeStackFileBuilder) deploy(ctx context.Context, endpoint *portainer.Endpoint) error {
+21
View File
@@ -117,6 +117,27 @@ func (b *StackBuilder) storeStackFile(content []byte) error {
return nil
}
// storeStackFileVersioned stores the initial file of a file-based (non-git) Compose/Swarm stack
// into the v1 version folder and seeds the append-only version history. Note that
// StoreStackFileFromBytesByVersion returns the base path (not the version path), so ProjectPath
// is set explicitly via GetStackProjectPathByVersion.
func (b *StackBuilder) storeStackFileVersioned(content []byte) error {
stackFolder := strconv.Itoa(int(b.stack.ID))
if _, err := b.fileService.StoreStackFileFromBytesByVersion(stackFolder, b.stack.EntryPoint, 1, content); err != nil {
return err
}
b.stack.ProjectPath = b.fileService.GetStackProjectPathByVersion(stackFolder, 1, "")
b.stack.StackFileVersion = 1
b.stack.Versions = []portainer.StackFileVersionInfo{{
Version: 1,
CreatedAt: time.Now().Unix(),
CreatedBy: b.stack.CreatedBy,
}}
return nil
}
func (b *StackBuilder) initComposeDeployment(secCtx *security.RestrictedRequestContext, endpoint *portainer.Endpoint) error {
config, err := deployments.CreateComposeStackDeploymentConfigTx(b.dataStore, secCtx, b.stack, endpoint, b.fileService, b.stackDeployer, false, false, false)
if err != nil {
@@ -39,7 +39,7 @@ func (b *SwarmStackFileBuilder) prepare(_ context.Context, payload *StackPayload
return err
}
return b.storeStackFile(payload.StackFileContent)
return b.storeStackFileVersioned(payload.StackFileContent)
}
func (b *SwarmStackFileBuilder) deploy(ctx context.Context, endpoint *portainer.Endpoint) error {
+1
View File
@@ -224,6 +224,7 @@ export const ngModule = angular
'height',
'data-cy',
'versions',
'versionsInfo',
'onVersionChange',
'schema',
'fileName',
@@ -5,4 +5,6 @@ export const queryKeys = {
stack: (stackId?: StackId) => [...queryKeys.base(), stackId] as const,
stackFile: (stackId?: StackId, params?: unknown) =>
[...queryKeys.stack(stackId), 'file', params] as const,
stackVersions: (stackId?: StackId) =>
[...queryKeys.stack(stackId), 'versions'] as const,
};
@@ -0,0 +1,51 @@
import { useQuery } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { withError } from '@/react-tools/react-query';
import { StackFileVersionInfo, StackId } from '../types';
import { queryKeys } from './query-keys';
export function useStackVersions(
stackId?: StackId,
environmentId?: EnvironmentId,
{ enabled }: { enabled?: boolean } = {}
) {
return useQuery({
queryKey: queryKeys.stackVersions(stackId),
queryFn: ({ signal }) =>
getStackVersions({
stackId: stackId!,
environmentId,
options: { signal },
}),
...withError('Unable to retrieve stack versions'),
enabled: !!stackId && enabled,
});
}
export async function getStackVersions({
stackId,
environmentId,
options = {},
}: {
stackId: StackId;
environmentId?: EnvironmentId;
options?: { signal?: AbortSignal };
}) {
try {
const { data } = await axios.get<StackFileVersionInfo[]>(
`/stacks/${stackId}/versions`,
{
params: { endpointId: environmentId },
signal: options.signal,
}
);
return data;
} catch (e) {
throw parseAxiosError(e, 'Unable to retrieve stack versions');
}
}
+14
View File
@@ -113,6 +113,20 @@ export type StackFile = {
StackFileContent: string;
};
/**
* Metadata describing a single stored version of a stack file, as returned by
* `GET /stacks/{id}/versions`. Field casing mirrors the backend JSON.
*/
export interface StackFileVersionInfo {
Version: number;
/**
* Creation time of the version, as a Unix timestamp in seconds.
*/
CreatedAt: number;
CreatedBy: string;
Note: string;
}
export interface GitStackPayload {
env: Array<EnvVar>;
prune?: boolean;
@@ -6,6 +6,7 @@ import type { JSONSchema7 } from 'json-schema';
import clsx from 'clsx';
import { AutomationTestingProps } from '@/types';
import { StackFileVersionInfo } from '@/react/common/stacks/types';
import { CopyButton } from '@@/buttons/CopyButton';
@@ -29,6 +30,7 @@ interface Props extends AutomationTestingProps {
value: string;
height?: string;
versions?: number[];
versionsInfo?: StackFileVersionInfo[];
onVersionChange?: (version: number) => void;
schema?: JSONSchema7;
fileName?: string;
@@ -73,6 +75,7 @@ export function CodeEditor({
readonly,
value,
versions,
versionsInfo,
onVersionChange,
height = '500px',
type,
@@ -128,6 +131,7 @@ export function CodeEditor({
<div className="ml-auto mr-2">
<StackVersionSelector
versions={versions}
versionsInfo={versionsInfo}
onChange={handleVersionChange}
/>
</div>
@@ -0,0 +1,99 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import { StackFileVersionInfo } from '@/react/common/stacks/types';
import { StackVersionSelector } from './StackVersionSelector';
function createInfo(
overrides: Partial<StackFileVersionInfo> = {}
): StackFileVersionInfo {
return {
Version: 1,
CreatedAt: 1751464320, // fixed unix timestamp (seconds)
CreatedBy: 'admin',
Note: '',
...overrides,
};
}
it('should render nothing when there are no versions', () => {
const { container } = render(
<StackVersionSelector versions={[]} onChange={vi.fn()} />
);
expect(container).toBeEmptyDOMElement();
});
it('should render a rich label with version, date and author for a single version', () => {
const info = createInfo({ Version: 3, CreatedBy: 'alice' });
render(
<StackVersionSelector
versions={[3]}
versionsInfo={[info]}
onChange={vi.fn()}
/>
);
const expected = `v3 · ${isoDateFromTimestamp(info.CreatedAt)} · alice`;
expect(screen.getByText(expected)).toBeInTheDocument();
});
it('should fall back to the bare version number when no metadata is available', () => {
render(<StackVersionSelector versions={[7]} onChange={vi.fn()} />);
expect(screen.getByText('v7')).toBeInTheDocument();
});
it('should render a select with rich labels when multiple versions exist', () => {
const versionsInfo = [
createInfo({ Version: 2, CreatedBy: 'bob' }),
createInfo({ Version: 1, CreatedBy: 'alice' }),
];
render(
<StackVersionSelector
versions={[2, 1]}
versionsInfo={versionsInfo}
onChange={vi.fn()}
/>
);
const select = screen.getByRole('combobox', { name: /version/i });
expect(select).toBeInTheDocument();
const options = screen.getAllByRole('option');
expect(options).toHaveLength(2);
expect(options[0]).toHaveTextContent(
`v2 · ${isoDateFromTimestamp(versionsInfo[0].CreatedAt)} · bob`
);
expect(options[1]).toHaveTextContent(
`v1 · ${isoDateFromTimestamp(versionsInfo[1].CreatedAt)} · alice`
);
});
it('should call onChange with the selected version number', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
<StackVersionSelector
versions={[3, 2, 1]}
versionsInfo={[
createInfo({ Version: 3 }),
createInfo({ Version: 2 }),
createInfo({ Version: 1 }),
]}
onChange={onChange}
/>
);
await user.selectOptions(
screen.getByRole('combobox', { name: /version/i }),
'2'
);
expect(onChange).toHaveBeenCalledWith(2);
});
@@ -1,9 +1,37 @@
import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import { StackFileVersionInfo } from '@/react/common/stacks/types';
interface Props {
versions?: number[];
/**
* Optional richer metadata (date/author/note) used to build the option
* labels. Looked up by version number; falls back to the bare version when a
* given version has no metadata.
*/
versionsInfo?: StackFileVersionInfo[];
onChange(value: number): void;
}
export function StackVersionSelector({ versions, onChange }: Props) {
/**
* Build a human-readable label for a version, e.g. `v3 · 2026-07-02 14:12 · admin`.
* Falls back to just the version number when no metadata is available.
*/
function buildVersionLabel(version: number, info?: StackFileVersionInfo) {
const parts = [`v${version}`];
if (info?.CreatedAt) {
parts.push(isoDateFromTimestamp(info.CreatedAt));
}
if (info?.CreatedBy) {
parts.push(info.CreatedBy);
}
return parts.join(' · ');
}
export function StackVersionSelector({
versions,
versionsInfo,
onChange,
}: Props) {
if (!versions || versions.length === 0) {
return null;
}
@@ -12,7 +40,10 @@ export function StackVersionSelector({ versions, onChange }: Props) {
const versionOptions = versions.map((version) => ({
value: version,
label: version.toString(),
label: buildVersionLabel(
version,
versionsInfo?.find((info) => info.Version === version)
),
}));
return (
@@ -23,7 +54,7 @@ export function StackVersionSelector({ versions, onChange }: Props) {
<span>Version:</span>
</label>
<span className="text-muted" id="version_id">
{versions[0]}
{versionOptions[0].label}
</span>
</>
)}
@@ -38,7 +69,6 @@ export function StackVersionSelector({ versions, onChange }: Props) {
data-cy="version-selector"
id="version_id"
style={{
width: '60px',
height: '24px',
borderRadius: '4px',
borderColor: 'hsl(0, 0%, 80%)',
@@ -48,7 +78,7 @@ export function StackVersionSelector({ versions, onChange }: Props) {
>
{versionOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.value}
{option.label}
</option>
))}
</select>
@@ -2,25 +2,33 @@ 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 recreate confirm dialog is the async gate before the mutation dispatches;
// stub it so a test can drive the resolved (confirmed) branch.
// The two confirm dialogs are the async gate before the mutation dispatches;
// stub them 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(),
}));
@@ -29,6 +37,21 @@ 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.
@@ -61,6 +84,8 @@ function renderButton(
describe('UpdateNowButton', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseStacks.mockReturnValue({ data: [], isLoading: false });
mockUseAuthorizations.mockReturnValue({ authorized: true });
});
it('renders when the image is outdated', () => {
@@ -81,50 +106,72 @@ describe('UpdateNowButton', () => {
expect(screen.queryByTestId('update-now-button')).not.toBeInTheDocument();
});
it('disables the action for Portainer\'s own container', () => {
it('disables the action for externally-managed compose containers', () => {
setStatus('outdated');
renderButton({ isPortainer: true });
renderButton({
labels: { [COMPOSE_STACK_NAME_LABEL]: 'not-in-portainer' },
});
expect(screen.getByTestId('update-now-button')).toBeDisabled();
});
it('enables a compose-managed container (recreate keeps it in its project)', () => {
it('enables a stack container when the user can update stacks', () => {
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();
});
// 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 () => {
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 () => {
setStatus('outdated');
mockConfirmContainerRecreation.mockResolvedValue({ pullLatest: true });
renderButton();
renderButton(); // no compose label -> standalone
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('recreates a compose-managed container the same way (never redeploys a stack)', async () => {
it('applies a stack update via the stack confirm, mutating once with pullImage', async () => {
setStatus('outdated');
mockConfirmContainerRecreation.mockResolvedValue({ pullLatest: false });
mockUseStacks.mockReturnValue({ data: [composeStack], isLoading: false });
mockUseAuthorizations.mockReturnValue({ authorized: true });
mockConfirmStackUpdate.mockResolvedValue({ repullImageAndRedeploy: 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(mockConfirmContainerRecreation).toHaveBeenCalledTimes(1);
expect(mockConfirmStackUpdate).toHaveBeenCalledTimes(1);
expect(mockConfirmContainerRecreation).not.toHaveBeenCalled();
expect(mockMutate).toHaveBeenCalledWith(
expect.objectContaining({ pullImage: false }),
expect.anything()
@@ -6,6 +6,7 @@ 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';
@@ -21,11 +22,14 @@ interface UpdateNowButtonProps {
/**
* "Update now" surfaces a discoverable per-container apply action ONLY when the
* 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.
* 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.
*/
export function UpdateNowButton({
environmentId,
@@ -42,18 +46,19 @@ export function UpdateNowButton({
containerId,
nodeName
);
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 }),
});
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 }),
});
// Only meaningful when a newer image is actually available.
if (statusQuery.data?.Status !== 'outdated') {
@@ -75,5 +80,25 @@ 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,6 +29,7 @@ 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,
@@ -83,7 +84,14 @@ 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) {
@@ -182,7 +190,7 @@ export function ContainersDatatableActions({
color="light"
data-cy="update-selected-docker-container-button"
onClick={() => onUpdateClick(selectedItems)}
disabled={selectedItemCount === 0}
disabled={selectedItemCount === 0 || stacksQuery.isLoading}
isLoading={bulkUpdateMutation.isLoading}
loadingText="Updating..."
icon={Download}
@@ -270,8 +278,8 @@ export function ContainersDatatableActions({
}
function onUpdateClick(selectedItems: ContainerListViewModel[]) {
// Recreate every outdated container individually (Watchtower-style),
// skipping up-to-date/unknown ones.
// Apply the shared image-update primitive to every outdated container,
// skipping up-to-date/unknown ones and redeploying each owning stack once.
const contexts: ContainerUpdateContext[] = selectedItems.map(
(container) => ({
id: container.Id,
@@ -285,7 +293,7 @@ export function ContainersDatatableActions({
);
bulkUpdateMutation.mutate(
{ contexts },
{ contexts, stacks: stacksQuery.data ?? [], canUpdateStack },
{
onSettled: () => {
router.stateService.reload();
@@ -8,6 +8,8 @@ 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';
@@ -43,11 +45,13 @@ function UpdateAvailableCell({
);
// Same shared apply flow as the details-view "Update now" button (confirm +
// single-container recreate). No `onSuccess`: the mutation's query
// invalidation refreshes this row's badge in place.
// standalone/stack/external routing + permission gating). No `onSuccess`: the
// mutation's query invalidation refreshes this row's badge in place.
const {
apply,
isLoading: isUpdating,
isExternal,
stackUpdateForbidden,
canApply,
} = useApplyContainerImageUpdate({
environmentId,
@@ -65,8 +69,9 @@ function UpdateAvailableCell({
<UpdateStatusBadge
status={status}
isLoading={statusQuery.isLoading}
// Clicking recreates just this one container (Watchtower-style). Disabled
// only for Portainer's own container, which can't recreate itself.
// 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).
onUpdateClick={
status === 'outdated' && canApply ? () => apply() : undefined
}
@@ -78,5 +83,23 @@ 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,17 +1,41 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { COMPOSE_STACK_NAME_LABEL } from '@/react/constants';
import { Stack, StackType } from '@/react/common/stacks/types';
import { applyContainerUpdate } from './applyContainerUpdate';
import {
applyContainerUpdate,
EXTERNAL_STACK_UPDATE_ERROR,
} from './applyContainerUpdate';
import { ContainerUpdateContext } from './types';
import { resolveContainerUpdatePath } from './resolveContainerUpdatePath';
// Mock the recreate mutation so we assert the dispatch and payload mapping
// without touching the network.
// Mock the side-effecting mutations and the file fetch 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> = {}
@@ -26,39 +50,116 @@ 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 plain container with pull and node', async () => {
await applyContainerUpdate(buildContext());
it('recreates a standalone container with pull and node, returning "standalone"', async () => {
resolveMock.mockReturnValue({ kind: 'standalone' });
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('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' },
})
);
it('passes pullImage=false through to the standalone recreate', async () => {
resolveMock.mockReturnValue({ kind: 'standalone' });
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 });
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,23 +1,86 @@
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 { ContainerUpdateContext } from './types';
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.';
/**
* 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.
* 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.
*
* 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.
* 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).
*/
export async function applyContainerUpdate(
context: ContainerUpdateContext,
stacks: Stack[],
{ pullImage = true }: { pullImage?: boolean } = {}
): Promise<void> {
await recreateContainer(context.environmentId, context.id, pullImage, {
nodeName: context.nodeName,
});
): 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);
}
}
@@ -0,0 +1,66 @@
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']);
});
});
@@ -0,0 +1,50 @@
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()) };
}
+11 -2
View File
@@ -1,8 +1,17 @@
export { applyContainerUpdate } from './applyContainerUpdate';
export { resolveContainerUpdatePath } from './resolveContainerUpdatePath';
export { groupContainersForUpdate } from './groupContainersForUpdate';
export {
applyContainerUpdate,
EXTERNAL_STACK_UPDATE_ERROR,
} from './applyContainerUpdate';
export {
useUpdateContainerImage,
invalidateContainerUpdateQueries,
} from './useUpdateContainerImage';
export { useApplyContainerImageUpdate } from './useApplyContainerImageUpdate';
export { useBulkUpdateContainerImages } from './useBulkUpdateContainerImages';
export type { ContainerUpdateContext } from './types';
export type {
ContainerUpdateContext,
ContainerUpdateKind,
ContainerUpdatePath,
} from './types';
@@ -0,0 +1,120 @@
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' });
});
});
@@ -0,0 +1,52 @@
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,
};
}
+21 -1
View File
@@ -1,3 +1,4 @@
import { StackId } from '@/react/common/stacks/types';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { ContainerId } from '../types';
@@ -5,6 +6,7 @@ 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;
@@ -12,9 +14,27 @@ export interface ContainerUpdateContext {
name: string;
/** Current image reference, used to detect un-pullable (sha256) images. */
image: string;
/** Docker labels, preserved by the recreate endpoint (kept for callers). */
/** Docker labels, used to detect compose/stack membership. */
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,9 +1,13 @@
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';
@@ -27,12 +31,14 @@ 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
* dialog lives in ONE place.
* dialogs, standalone/stack/external routing and permission gating live in ONE
* place.
*
* 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.
* 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).
*/
export function useApplyContainerImageUpdate({
environmentId,
@@ -44,13 +50,30 @@ 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 canApply = !isPortainer && !updateMutation.isLoading;
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;
return {
apply,
isLoading: updateMutation.isLoading,
isExternal,
stackUpdateForbidden,
canApply,
};
@@ -64,16 +87,29 @@ export function useApplyContainerImageUpdate({
nodeName,
};
const cannotPullImage =
!containerImage || containerImage.toLowerCase().startsWith('sha256');
const result = await confirmContainerRecreation(cannotPullImage);
if (!result) {
return;
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 pullImage = result.pullLatest;
updateMutation.mutate(
{ context, pullImage },
{ context, stacks, pullImage },
{
onSuccess: () => {
notifySuccess('Success', 'Container image update applied');
@@ -1,5 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Stack } from '@/react/common/stacks/types';
import {
notifyError,
notifySuccess,
@@ -13,30 +14,40 @@ 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": 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.
* 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.
*/
export function useBulkUpdateContainerImages() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contexts }: BulkUpdateParams) =>
bulkUpdate(queryClient, contexts),
mutationFn: ({ contexts, stacks, canUpdateStack }: BulkUpdateParams) =>
bulkUpdate(queryClient, contexts, stacks, canUpdateStack),
});
}
async function bulkUpdate(
queryClient: ReturnType<typeof useQueryClient>,
contexts: ContainerUpdateContext[]
contexts: ContainerUpdateContext[],
stacks: Stack[],
canUpdateStack: boolean
) {
// Resolve each container's status (cached where the badge already loaded it).
const statuses = await Promise.all(
@@ -70,16 +81,24 @@ async function bulkUpdate(
'Nothing to update',
'None of the selected containers have updates available.'
);
return { containersUpdated: 0, failures: [], skipped: 0 };
return { containersUpdated: 0, stacksUpdated: 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[] = [];
// Recreate each outdated container individually (Watchtower-style).
await runSequential(outdated, async (context) => {
// Standalone containers: recreate-with-pull, one per container.
await runSequential(standalone, async (context) => {
try {
await applyContainerUpdate(context);
await applyContainerUpdate(context, stacks);
invalidateContainerUpdateQueries(queryClient, context);
containersUpdated += 1;
} catch (err) {
@@ -88,27 +107,64 @@ async function bulkUpdate(
}
});
if (containersUpdated > 0) {
notifySuccess(
'Success',
`${containersUpdated} ${pluralize(
containersUpdated,
'container'
)} updated`
);
// 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 (skippedNotOutdated > 0) {
notifyWarning(
'Some containers were skipped',
`${skippedNotOutdated} not outdated`
);
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(', '));
}
return {
containersUpdated,
stacksUpdated,
failures,
skipped: skippedNotOutdated,
skipped: skippedNotOutdated + skippedExternal + skippedUnauthorizedStacks,
};
}
@@ -116,7 +172,7 @@ async function runSequential<T>(
items: T[],
fn: (item: T) => Promise<void>
): Promise<void> {
// Sequential to avoid hammering registries with parallel pulls.
// Sequential to avoid hammering registries / overlapping stack redeploys.
await items.reduce(
(chain, item) => chain.then(() => fn(item)),
Promise.resolve()
@@ -1,6 +1,8 @@
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';
@@ -8,10 +10,16 @@ import { applyContainerUpdate } from './applyContainerUpdate';
import { ContainerUpdateContext } from './types';
/**
* 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.
* 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.
*/
export function invalidateContainerUpdateQueries(
queryClient: QueryClient,
@@ -27,10 +35,12 @@ export function invalidateContainerUpdateQueries(
context.nodeName
)
);
queryClient.invalidateQueries(stacksQueryKeys.base());
}
interface UpdateContainerImageParams {
context: ContainerUpdateContext;
stacks: Stack[];
pullImage?: boolean;
}
@@ -43,9 +53,9 @@ export function useUpdateContainerImage() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ context, pullImage }: UpdateContainerImageParams) =>
applyContainerUpdate(context, { pullImage }),
onSuccess: (_result, { context }) => {
mutationFn: ({ context, stacks, pullImage }: UpdateContainerImageParams) =>
applyContainerUpdate(context, stacks, { pullImage }),
onSuccess: (_kind, { context }) => {
invalidateContainerUpdateQueries(queryClient, context);
},
...withError('Unable to update container image'),
@@ -434,6 +434,7 @@ function setupMswHandlers({
},
})
),
http.get('/api/stacks/:id/versions', () => HttpResponse.json([])),
http.put('/api/stacks/:id', async ({ request, params }) => {
const body = await request.json();
@@ -1,10 +1,13 @@
import { Formik } from 'formik';
import { useRouter } from '@uirouter/react';
import { useQueryClient } from '@tanstack/react-query';
import _ from 'lodash';
import { useState } from 'react';
import uuidv4 from 'uuid/v4';
import { Stack, StackType } from '@/react/common/stacks/types';
import { useStackVersions } from '@/react/common/stacks/queries/useStackVersions';
import { queryKeys } from '@/react/common/stacks/queries/query-keys';
import { useDockerComposeSchema } from '@/react/hooks/useDockerComposeSchema/useDockerComposeSchema';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update';
@@ -35,20 +38,32 @@ export function StackEditorTab({
onSubmitSuccess = () => {},
stack,
}: StackEditorTabProps) {
const versions = _.compact([
stack.StackFileVersion,
stack.PreviousDeploymentInfo?.FileVersion,
]);
const router = useRouter();
const queryClient = useQueryClient();
const mutation = useUpdateStackMutation();
const envQuery = useCurrentEnvironment();
const schemaQuery = useDockerComposeSchema();
const versionsQuery = useStackVersions(stack.Id, envQuery.data?.Id, {
enabled: !!envQuery.data,
});
const [webhookId] = useState(() => stack.Webhook || uuidv4());
if (!envQuery.data || !schemaQuery.data) {
return null;
}
const versionsInfo = versionsQuery.data;
// Build the full descending version list from the fetched history; fall back
// to the current + previous deployment versions if the history is unavailable
// so the editor keeps working (e.g. while loading or on error).
const versions =
versionsInfo && versionsInfo.length > 0
? versionsInfo.map((v) => v.Version).sort((a, b) => b - a)
: _.compact([
stack.StackFileVersion,
stack.PreviousDeploymentInfo?.FileVersion,
]);
const envType = envQuery.data?.Type;
const composeSyntaxMaxVersion = parseFloat(
envQuery.data?.ComposeSyntaxMaxVersion
@@ -94,6 +109,14 @@ export function StackEditorTab({
{
onSuccess() {
notifySuccess('Success', 'Stack successfully deployed');
// Refresh the version history and cached file so the selector
// reflects the new deployment / rollback. Invalidate the file by
// its 3-element prefix (['stacks', id, 'file']) so it matches
// EVERY versioned file query — the real keys carry a params object
// ({version, commitHash}) in the 4th slot, which stackFile(id)
// with no params (undefined) would not partial-match.
queryClient.invalidateQueries(queryKeys.stackVersions(stack.Id));
queryClient.invalidateQueries([...queryKeys.stack(stack.Id), 'file']);
router.stateService.reload();
onSubmitSuccess();
},
@@ -114,6 +137,7 @@ export function StackEditorTab({
envType={envType}
schema={schemaQuery.data}
versions={versions}
versionsInfo={versionsInfo}
isSubmitting={mutation.isLoading}
isSaved={mutation.isSuccess}
webhookId={webhookId}
@@ -1,4 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Formik } from 'formik';
import { vi } from 'vitest';
@@ -17,7 +17,10 @@ import { server } from '@/setup-tests/server';
import { usePreventExit } from '@@/WebEditorForm';
import { StackEditorTabInner } from './StackEditorTabInner';
import {
StackEditorTabInner,
resolveRollbackTarget,
} from './StackEditorTabInner';
import { StackEditorFormValues } from './StackEditorTab.types';
import { useVersionedStackFile } from './useVersionedStackFile';
@@ -383,6 +386,178 @@ describe('version rollback', () => {
});
});
// F6: the version selector must not silently discard manual edits. The backend
// ignores the client buffer whenever rollbackTo is set, so rollbackTo must only
// be set for a genuinely older version and must be cleared once the user either
// re-selects the current version or edits the buffer by hand.
describe('resolveRollbackTarget (F6 decision)', () => {
it('returns undefined when the current/top version is picked', () => {
// versions[0] is the latest -> picking it is not a rollback
expect(resolveRollbackTarget(3, [3, 2, 1])).toBeUndefined();
});
it('returns the version when a genuinely older version is picked', () => {
expect(resolveRollbackTarget(2, [3, 2, 1])).toBe(2);
expect(resolveRollbackTarget(1, [3, 2, 1])).toBe(1);
});
it('returns undefined when only a single version exists', () => {
expect(resolveRollbackTarget(1, [1])).toBeUndefined();
});
it('returns undefined when versions are unavailable', () => {
expect(resolveRollbackTarget(1, undefined)).toBeUndefined();
expect(resolveRollbackTarget(1, [])).toBeUndefined();
});
});
describe('version selector edit trap (F6)', () => {
// The `version` passed to useVersionedStackFile mirrors values.rollbackTo, so
// we assert on the latest call to observe how rollbackTo evolves.
function lastRollbackTo() {
const { calls } = vi.mocked(useVersionedStackFile).mock;
return calls[calls.length - 1][0].version;
}
beforeEach(() => {
vi.mocked(useVersionedStackFile).mockReturnValue({
content: '',
isLoading: false,
});
});
it('should set rollbackTo when an older version is selected', async () => {
renderComponent({ versions: [3, 2, 1] });
const user = userEvent.setup();
const versionSelect = screen.getByRole('combobox', { name: /version/i });
await user.selectOptions(versionSelect, '2');
await waitFor(() => {
expect(lastRollbackTo()).toBe(2);
});
});
it('should clear rollbackTo when the current/top version is re-selected', async () => {
renderComponent({ versions: [3, 2, 1] });
const user = userEvent.setup();
const versionSelect = screen.getByRole('combobox', { name: /version/i });
// First roll back to an older version...
await user.selectOptions(versionSelect, '2');
await waitFor(() => {
expect(lastRollbackTo()).toBe(2);
});
// ...then return to the current version: this is not a rollback, so
// rollbackTo must be cleared to restore normal edit-and-deploy.
await user.selectOptions(versionSelect, '3');
await waitFor(() => {
expect(lastRollbackTo()).toBeUndefined();
});
});
it('should clear rollbackTo when the user edits the buffer', async () => {
renderComponent(
{ versions: [3, 2, 1] },
{ initialValues: { ...defaultInitialValues, rollbackTo: 2 } }
);
const user = userEvent.setup();
// rollbackTo starts at 2 (an older version pre-selected)
expect(lastRollbackTo()).toBe(2);
const editor = screen.getByTestId('stack-editor');
await waitFor(() => {
expect(editor).not.toHaveAttribute('readonly');
});
// A genuine user edit should reset rollbackTo so the edits are honored.
await user.type(editor, ' # manual edit');
await waitFor(() => {
expect(lastRollbackTo()).toBeUndefined();
});
});
it('should NOT clear rollbackTo when a version is loaded programmatically', async () => {
let capturedOnLoad: ((content: string) => void) | undefined;
vi.mocked(useVersionedStackFile).mockImplementation(({ onLoad }) => {
capturedOnLoad = onLoad;
return { content: '', isLoading: false };
});
renderComponent(
{ versions: [3, 2, 1] },
{ initialValues: { ...defaultInitialValues, rollbackTo: 2 } }
);
expect(capturedOnLoad).toBeDefined();
// The programmatic version-load goes through handleLoadFile (setFieldValue
// on stackFileContent), NOT the CodeEditor onChange, so rollbackTo must
// stay set — otherwise the rollback would immediately cancel itself.
act(() => {
capturedOnLoad?.('version: "2"\nservices:\n db:\n image: postgres');
});
// Wait past the CodeEditor's 300ms onChange debounce: if the programmatic
// load ever leaked into handleContentChange it would clear rollbackTo only
// after the debounce fires, so asserting at t≈0 would pass vacuously. By
// waiting beyond the window we ensure the assertion fails if a load ever
// clears rollbackTo.
await act(async () => {
await new Promise((resolve) => {
setTimeout(resolve, 350);
});
});
expect(lastRollbackTo()).toBe(2);
});
it('should restore the current content when returning to the current/top version', async () => {
let capturedOnLoad: ((content: string) => void) | undefined;
vi.mocked(useVersionedStackFile).mockImplementation(({ onLoad }) => {
capturedOnLoad = onLoad;
return { content: '', isLoading: false };
});
renderComponent({ versions: [3, 2, 1] });
const user = userEvent.setup();
const editor = screen.getByTestId('stack-editor');
await waitFor(() => {
expect(editor).not.toHaveAttribute('readonly');
});
const versionSelect = screen.getByRole('combobox', { name: /version/i });
// Roll back to an older version: rollbackTo is set and its content is loaded
// into the buffer (simulating useVersionedStackFile's fetch via onLoad).
const olderContent = 'version: "2"\nservices:\n db:\n image: postgres';
await user.selectOptions(versionSelect, '2');
await waitFor(() => {
expect(lastRollbackTo()).toBe(2);
});
act(() => {
capturedOnLoad?.(olderContent);
});
await waitFor(() => {
expect(editor).toHaveValue(olderContent);
});
// Return to the current/top version: rollbackTo must clear AND the editor
// must show the current content again, not the older version's leftover
// content (which would otherwise be deployed as a brand-new version).
await user.selectOptions(versionSelect, '3');
await waitFor(() => {
expect(lastRollbackTo()).toBeUndefined();
});
expect(editor).toHaveValue(defaultInitialValues.stackFileContent);
});
});
describe('form submission', () => {
it('should enable submit button when form is valid', async () => {
renderComponent();
@@ -2,7 +2,11 @@ import { Form, useFormikContext } from 'formik';
import { JSONSchema7 } from 'json-schema';
import { useCallback } from 'react';
import { Stack, StackType } from '@/react/common/stacks/types';
import {
Stack,
StackFileVersionInfo,
StackType,
} from '@/react/common/stacks/types';
import { PruneField } from '@/react/common/stacks/PruneField';
import { EnvironmentType } from '@/react/portainer/environments/types';
import { Authorized, useAuthorizations } from '@/react/hooks/useUser';
@@ -18,6 +22,24 @@ import { WebhookFieldset } from '../../common/WebhookFieldset';
import { StackEditorFormValues } from './StackEditorTab.types';
import { useVersionedStackFile } from './useVersionedStackFile';
/**
* Decide the `rollbackTo` value for a version chosen in the selector.
*
* `versions[0]` is the latest/current version (the list is sorted descending).
* Picking the current version is NOT a rollback, so this returns `undefined`
* to keep the normal edit-and-deploy flow; only a genuinely older version sets
* a rollback target (which the backend reads from disk, ignoring the buffer).
*/
export function resolveRollbackTarget(
newVersion: number,
versions?: Array<number>
): number | undefined {
if (!versions || versions.length <= 1) {
return undefined;
}
return newVersion < versions[0] ? newVersion : undefined;
}
interface StackEditorTabInnerProps {
stackType: StackType | undefined;
composeSyntaxMaxVersion: number;
@@ -25,6 +47,7 @@ interface StackEditorTabInnerProps {
schema: JSONSchema7;
isOrphaned: boolean;
versions?: Array<number>;
versionsInfo?: StackFileVersionInfo[];
stackId: Stack['Id'];
isSaved: boolean;
isSubmitting: boolean;
@@ -38,6 +61,7 @@ export function StackEditorTabInner({
schema,
isOrphaned,
versions,
versionsInfo,
stackId,
isSaved,
isSubmitting,
@@ -63,6 +87,21 @@ export function StackEditorTabInner({
[setFieldValue]
);
const handleContentChange = useCallback(
(value: string) => {
setFieldValue('stackFileContent', value);
// A manual edit means the user wants a normal edit-and-deploy, not a
// rollback, so clear rollbackTo (otherwise the backend would ignore the
// edited buffer and redeploy the selected version from disk). This runs
// only on genuine user input: the programmatic version-load goes through
// handleLoadFile -> setFieldValue, which updates the editor's `value`
// prop and is skipped by CodeMirror's onChange (marked as an external
// change), so it does not clear rollbackTo.
setFieldValue('rollbackTo', undefined);
},
[setFieldValue]
);
useVersionedStackFile({
stackId,
version: values.rollbackTo,
@@ -113,13 +152,14 @@ export function StackEditorTabInner({
id="stack-editor"
textTip="Define or paste the content of your docker compose file here"
type="yaml"
onChange={(value) => setFieldValue('stackFileContent', value)}
onChange={handleContentChange}
value={values.stackFileContent}
readonly={isOrphaned || !isAuthorizedToUpdate}
schema={schema}
data-cy="stack-editor"
onVersionChange={handleVersionChange}
versions={versions}
versionsInfo={versionsInfo}
/>
</div>
</div>
@@ -162,10 +202,21 @@ export function StackEditorTabInner({
async function handleVersionChange(newVersion: number) {
if (versions && versions.length > 1) {
setFieldValue(
'rollbackTo',
newVersion < versions[0] ? newVersion : versions[0]
);
// Picking the current/top version clears rollbackTo (undefined); only a
// genuinely older version sets it. See resolveRollbackTarget.
const rollbackTarget = resolveRollbackTarget(newVersion, versions);
if (rollbackTarget === undefined) {
// Returning to the current version: restore the current file content so
// the editor matches the selector. Otherwise the previously-viewed older
// version's content would remain in the buffer (useVersionedStackFile
// does not fetch when rollbackTo is undefined) and get deployed as a new
// version. initialValues.stackFileContent is the current stack file
// loaded at page init. This flows through the CodeEditor `value` prop as
// an external change, so it does not re-trigger handleContentChange and
// rollbackTo stays undefined.
setFieldValue('stackFileContent', initialValues.stackFileContent);
}
setFieldValue('rollbackTo', rollbackTarget);
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"author": "Portainer.io",
"name": "@portainer/ce",
"homepage": "http://portainer.io",
"version": "2.43.0",
"version": "2.44.0",
"repository": {
"type": "git",
"url": "git@github.com:portainer/portainer.git"