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