diff --git a/api/datastore/migrator/migrate_2_44_0.go b/api/datastore/migrator/migrate_2_44_0.go new file mode 100644 index 000000000..246e46976 --- /dev/null +++ b/api/datastore/migrator/migrate_2_44_0.go @@ -0,0 +1,158 @@ +package migrator + +import ( + "os" + "path/filepath" + "strconv" + + portainer "github.com/portainer/portainer/api" + + "github.com/rs/zerolog/log" +) + +// migrateStackFileVersions_2_44_0 introduces the on-disk file version history for existing +// file-based (non-git) Compose/Swarm stacks. For each such stack it moves the entrypoint and +// every additional file from compose/{id}/ into compose/{id}/v1/, 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", + }} +} diff --git a/api/datastore/migrator/migrate_2_44_0_test.go b/api/datastore/migrator/migrate_2_44_0_test.go new file mode 100644 index 000000000..c8a44a097 --- /dev/null +++ b/api/datastore/migrator/migrate_2_44_0_test.go @@ -0,0 +1,223 @@ +package migrator + +import ( + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/database/boltdb" + "github.com/portainer/portainer/api/dataservices/stack" + "github.com/portainer/portainer/api/filesystem" + "github.com/portainer/portainer/api/logs" + + "github.com/stretchr/testify/require" +) + +func newStackVersionTestMigrator(t *testing.T) (*Migrator, *stack.Service, portainer.FileService) { + t.Helper() + + conn := &boltdb.DbConnection{Path: t.TempDir()} + require.NoError(t, conn.Open()) + t.Cleanup(func() { logs.CloseAndLogErr(conn) }) + + stackSvc, err := stack.NewService(conn) + require.NoError(t, err) + + fileSvc, err := filesystem.NewService(t.TempDir(), "") + require.NoError(t, err) + + m := NewMigrator(&MigratorParameters{ + StackService: stackSvc, + FileService: fileSvc, + }) + + return m, stackSvc, fileSvc +} + +func TestMigrateStackFileVersions_2_44_0_MultiFileMove(t *testing.T) { + t.Parallel() + + m, stackSvc, fileSvc := newStackVersionTestMigrator(t) + + stackFolder := "1" + // Seed the legacy on-disk layout: files live directly under compose/{id}/. + _, err := fileSvc.StoreStackFileFromBytes(stackFolder, "docker-compose.yml", []byte("main-content")) + require.NoError(t, err) + _, err = fileSvc.StoreStackFileFromBytes(stackFolder, "override.yml", []byte("override-content")) + require.NoError(t, err) + + fileStack := &portainer.Stack{ + ID: 1, + Name: "file-stack", + Type: portainer.DockerComposeStack, + EntryPoint: "docker-compose.yml", + AdditionalFiles: []string{"override.yml"}, + ProjectPath: fileSvc.GetStackProjectPath(stackFolder), + CreationDate: 1234, + CreatedBy: "admin", + } + require.NoError(t, stackSvc.Create(fileStack)) + + require.NoError(t, m.migrateStackFileVersions_2_44_0()) + + migrated, err := stackSvc.Read(1) + require.NoError(t, err) + require.Equal(t, 1, migrated.StackFileVersion) + require.Equal(t, fileSvc.GetStackProjectPathByVersion(stackFolder, 1, ""), migrated.ProjectPath) + require.Len(t, migrated.Versions, 1) + require.Equal(t, 1, migrated.Versions[0].Version) + require.Equal(t, int64(1234), migrated.Versions[0].CreatedAt) + require.Equal(t, "admin", migrated.Versions[0].CreatedBy) + require.Equal(t, "migrated", migrated.Versions[0].Note) + + // Files must have been moved into v1 (content preserved) and removed from the base folder. + v1Path := fileSvc.GetStackProjectPathByVersion(stackFolder, 1, "") + got, err := fileSvc.GetFileContent(v1Path, "docker-compose.yml") + require.NoError(t, err) + require.Equal(t, []byte("main-content"), got) + got, err = fileSvc.GetFileContent(v1Path, "override.yml") + require.NoError(t, err) + require.Equal(t, []byte("override-content"), got) + + basePath := fileSvc.GetStackProjectPath(stackFolder) + exists, err := fileSvc.FileExists(basePath + "/docker-compose.yml") + require.NoError(t, err) + require.False(t, exists, "old base entrypoint should be removed") + + // Idempotency: a second run must not change anything and must not error. + require.NoError(t, m.migrateStackFileVersions_2_44_0()) + again, err := stackSvc.Read(1) + require.NoError(t, err) + require.Equal(t, 1, again.StackFileVersion) + require.Len(t, again.Versions, 1) +} + +func TestMigrateStackFileVersions_2_44_0_SkipsGitAndOrphan(t *testing.T) { + t.Parallel() + + m, stackSvc, fileSvc := newStackVersionTestMigrator(t) + + // Git stack: must be left untouched. + gitStack := &portainer.Stack{ + ID: 1, + Name: "git-stack", + Type: portainer.DockerComposeStack, + EntryPoint: "docker-compose.yml", + ProjectPath: fileSvc.GetStackProjectPath("1"), + WorkflowID: 99, + } + require.NoError(t, stackSvc.Create(gitStack)) + + // Orphan file-based stack: metadata present but no files on disk. + orphanStack := &portainer.Stack{ + ID: 2, + Name: "orphan-stack", + Type: portainer.DockerSwarmStack, + EntryPoint: "docker-compose.yml", + ProjectPath: fileSvc.GetStackProjectPath("2"), + } + require.NoError(t, stackSvc.Create(orphanStack)) + + require.NoError(t, m.migrateStackFileVersions_2_44_0()) + + git, err := stackSvc.Read(1) + require.NoError(t, err) + require.Equal(t, 0, git.StackFileVersion) + require.Empty(t, git.Versions) + + orphan, err := stackSvc.Read(2) + require.NoError(t, err) + require.Equal(t, 0, orphan.StackFileVersion) + require.Empty(t, orphan.Versions) +} + +// TestMigrateStackFileVersions_2_44_0_MissingAdditionalFile verifies that a stack whose +// entrypoint exists but whose additional file is missing is skipped entirely: it is left +// un-migrated, the base entrypoint stays intact, and NO partial v1 directory is created. +// A re-run must not accept the (absent) v1 either. +func TestMigrateStackFileVersions_2_44_0_MissingAdditionalFile(t *testing.T) { + t.Parallel() + + m, stackSvc, fileSvc := newStackVersionTestMigrator(t) + + stackFolder := "1" + // Only the entrypoint is on disk; the declared additional file is missing. + _, err := fileSvc.StoreStackFileFromBytes(stackFolder, "docker-compose.yml", []byte("main-content")) + require.NoError(t, err) + + fileStack := &portainer.Stack{ + ID: 1, + Name: "partial-stack", + Type: portainer.DockerComposeStack, + EntryPoint: "docker-compose.yml", + AdditionalFiles: []string{"override.yml"}, + ProjectPath: fileSvc.GetStackProjectPath(stackFolder), + CreationDate: 1234, + CreatedBy: "admin", + } + require.NoError(t, stackSvc.Create(fileStack)) + + require.NoError(t, m.migrateStackFileVersions_2_44_0()) + + // The stack must be left un-migrated and consistent. + skipped, err := stackSvc.Read(1) + require.NoError(t, err) + require.Equal(t, 0, skipped.StackFileVersion) + require.Empty(t, skipped.Versions) + require.Equal(t, fileSvc.GetStackProjectPath(stackFolder), skipped.ProjectPath) + + // No partial v1 directory may have been created, and base files stay intact. + v1Path := fileSvc.GetStackProjectPathByVersion(stackFolder, 1, "") + exists, err := fileSvc.FileExists(v1Path) + require.NoError(t, err) + require.False(t, exists, "no partial v1 directory should be created when a file is missing") + + basePath := fileSvc.GetStackProjectPath(stackFolder) + exists, err = fileSvc.FileExists(basePath + "/docker-compose.yml") + require.NoError(t, err) + require.True(t, exists, "base entrypoint must be left intact") + + // A re-run must remain a no-op (still un-migrated, still no v1 adopted). + require.NoError(t, m.migrateStackFileVersions_2_44_0()) + again, err := stackSvc.Read(1) + require.NoError(t, err) + require.Equal(t, 0, again.StackFileVersion) + require.Empty(t, again.Versions) +} + +// TestMigrateStackFileVersions_2_44_0_IncompleteExistingV1 verifies that a pre-existing but +// INCOMPLETE v1 directory (e.g. left by an interrupted earlier migration) is not adopted as +// authoritative: the stack stays un-migrated rather than being repointed to a partial v1. +func TestMigrateStackFileVersions_2_44_0_IncompleteExistingV1(t *testing.T) { + t.Parallel() + + m, stackSvc, fileSvc := newStackVersionTestMigrator(t) + + stackFolder := "1" + // Base files present. + _, err := fileSvc.StoreStackFileFromBytes(stackFolder, "docker-compose.yml", []byte("main-content")) + require.NoError(t, err) + _, err = fileSvc.StoreStackFileFromBytes(stackFolder, "override.yml", []byte("override-content")) + require.NoError(t, err) + // A partial v1 exists: only the entrypoint was copied, the additional file is missing. + _, err = fileSvc.StoreStackFileFromBytesByVersion(stackFolder, "docker-compose.yml", 1, []byte("main-content")) + require.NoError(t, err) + + fileStack := &portainer.Stack{ + ID: 1, + Name: "interrupted-stack", + Type: portainer.DockerComposeStack, + EntryPoint: "docker-compose.yml", + AdditionalFiles: []string{"override.yml"}, + ProjectPath: fileSvc.GetStackProjectPath(stackFolder), + } + require.NoError(t, stackSvc.Create(fileStack)) + + require.NoError(t, m.migrateStackFileVersions_2_44_0()) + + // The incomplete v1 must NOT have been adopted; metadata stays un-migrated. + skipped, err := stackSvc.Read(1) + require.NoError(t, err) + require.Equal(t, 0, skipped.StackFileVersion) + require.Empty(t, skipped.Versions) + require.Equal(t, fileSvc.GetStackProjectPath(stackFolder), skipped.ProjectPath) +} diff --git a/api/datastore/migrator/migrator.go b/api/datastore/migrator/migrator.go index 385778809..cc49d00e0 100644 --- a/api/datastore/migrator/migrator.go +++ b/api/datastore/migrator/migrator.go @@ -278,6 +278,8 @@ func (m *Migrator) initMigrations() { m.migrateContainerAutomationSettings_2_43_0, ) + m.addMigrations("2.44.0", m.migrateStackFileVersions_2_44_0) + // WARNING: do not change migrations that have already been released! // Add new migrations above... diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json index 7fdf150a0..d67268041 100644 --- a/api/datastore/test_data/output_24_to_latest.json +++ b/api/datastore/test_data/output_24_to_latest.json @@ -623,7 +623,7 @@ "RequiredPasswordLength": 12 }, "KubeconfigExpiry": "0", - "KubectlShellImage": "portainer/kubectl-shell:2.43.0", + "KubectlShellImage": "portainer/kubectl-shell:2.44.0", "LDAPSettings": { "AnonymousMode": true, "AutoCreateUsers": true, @@ -940,7 +940,7 @@ } ], "version": { - "VERSION": "{\"SchemaVersion\":\"2.43.0\",\"MigratorCount\":3,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}" + "VERSION": "{\"SchemaVersion\":\"2.44.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}" }, "webhooks": null, "workflows": null diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go index 352f64090..cada2bd0f 100644 --- a/api/http/handler/stacks/handler.go +++ b/api/http/handler/stacks/handler.go @@ -80,6 +80,8 @@ func NewHandler(bouncer security.BouncerService) *Handler { bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackGitRedeploy))).Methods(http.MethodPut) h.Handle("/stacks/{id}/file", bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet) + h.Handle("/stacks/{id}/versions", + bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackVersions))).Methods(http.MethodGet) h.Handle("/stacks/{id}/migrate", bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackMigrate))).Methods(http.MethodPost) h.Handle("/stacks/{id}/start", diff --git a/api/http/handler/stacks/stack_file.go b/api/http/handler/stacks/stack_file.go index 19519b993..8302713d3 100644 --- a/api/http/handler/stacks/stack_file.go +++ b/api/http/handler/stacks/stack_file.go @@ -2,6 +2,7 @@ package stacks import ( "net/http" + "strconv" portainer "github.com/portainer/portainer/api" gittypes "github.com/portainer/portainer/api/git/types" @@ -107,7 +108,28 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe return httperror.Conflict("Stack git settings have changed. Redeploy the stack to apply the new configuration.", errors.New("git settings updated without redeploy")) } - stackFileContent, err := handler.FileService.GetFileContent(stack.ProjectPath, stack.EntryPoint) + projectPath := stack.ProjectPath + + // Optional ?version= selects a specific past file version of a file-based (non-git) stack. + version, err := request.RetrieveNumericQueryParameter(r, "version", true) + if err != nil { + return httperror.BadRequest("Invalid query parameter: version", err) + } + // A negative version is never valid (0/absent means "current"); reject it explicitly + // rather than silently falling through to the current version. + if version < 0 { + return httperror.BadRequest("Invalid query parameter: version", errors.New("version must be a positive integer")) + } + + if version > 0 && stack.WorkflowID == 0 { + if !stackFileVersionExists(stack, version) { + return httperror.BadRequest("Invalid stack file version", errors.Errorf("version %d not found in stack history", version)) + } + + projectPath = handler.FileService.GetStackProjectPathByVersion(strconv.Itoa(int(stack.ID)), version, "") + } + + stackFileContent, err := handler.FileService.GetFileContent(projectPath, stack.EntryPoint) if err != nil { return httperror.InternalServerError("Unable to retrieve Compose file from disk", err) } @@ -115,6 +137,18 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe return response.JSON(w, &stackFileResponse{StackFileContent: string(stackFileContent)}) } +// stackFileVersionExists reports whether the given version is present in the stack's file +// version history (or, for stacks predating the history seed, is within the current range). +func stackFileVersionExists(stack *portainer.Stack, version int) bool { + for _, v := range stack.Versions { + if v.Version == version { + return true + } + } + + return len(stack.Versions) == 0 && version >= 1 && version <= stack.StackFileVersion +} + // gitStackPendingRedeploy returns true when the stack's git settings (URL or config file path) // have been updated via "save settings" but the stack has not yet been redeployed to apply them. // In that state the local clone is stale and the stack file cannot be read from disk. diff --git a/api/http/handler/stacks/stack_update.go b/api/http/handler/stacks/stack_update.go index 5f58062cd..02334ecfa 100644 --- a/api/http/handler/stacks/stack_update.go +++ b/api/http/handler/stacks/stack_update.go @@ -2,6 +2,7 @@ package stacks import ( "context" + "fmt" "net/http" "strconv" "time" @@ -35,10 +36,15 @@ type updateComposeStackPayload struct { // Deprecated(2.36): use RepullImageAndRedeploy instead for cleaner responsibility // Force a pulling to current image with the original tag though the image is already the latest PullImage bool `example:"false"` + + // RollbackTo, when set, redeploys the content of a past file version as a new version. + // The server reads the target version's content from disk; StackFileContent may be empty. + RollbackTo *int `example:"3"` } func (payload *updateComposeStackPayload) Validate(r *http.Request) error { - if len(payload.StackFileContent) == 0 { + // For a rollback the content is read from disk on the server, so an empty payload is allowed. + if payload.RollbackTo == nil && len(payload.StackFileContent) == 0 { return errors.New("Invalid stack file content") } @@ -58,10 +64,15 @@ type updateSwarmStackPayload struct { // Deprecated(2.36): use RepullImageAndRedeploy instead for cleaner responsibility // Force a pulling to current image with the original tag though the image is already the latest PullImage bool `example:"false"` + + // RollbackTo, when set, redeploys the content of a past file version as a new version. + // The server reads the target version's content from disk; StackFileContent may be empty. + RollbackTo *int `example:"3"` } func (payload *updateSwarmStackPayload) Validate(r *http.Request) error { - if len(payload.StackFileContent) == 0 { + // For a rollback the content is read from disk on the server, so an empty payload is allowed. + if payload.RollbackTo == nil && len(payload.StackFileContent) == 0 { return errors.New("Invalid stack file content") } @@ -102,27 +113,39 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt } var stack *portainer.Stack + var pruneVersions []int err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error { var httpErr *httperror.HandlerError - stack, httpErr = handler.updateStackInTx(tx, r, portainer.StackID(stackID), portainer.EndpointID(endpointID)) + stack, pruneVersions, httpErr = handler.updateStackInTx(tx, r, portainer.StackID(stackID), portainer.EndpointID(endpointID)) if httpErr != nil { return httpErr } return nil }) + + // Physically delete the file-version directories pruned by retention only after the + // transaction has committed successfully. If the tx failed the trimmed Versions[] was + // never persisted, so the old directories must stay to match the DB (harmless orphans). + if err == nil && len(pruneVersions) > 0 { + handler.pruneStackFileVersionDirs(stack.ID, pruneVersions) + } + return response.TxResponse(w, stack, err) } -func (handler *Handler) updateStackInTx(tx dataservices.DataStoreTx, r *http.Request, stackID portainer.StackID, endpointID portainer.EndpointID) (*portainer.Stack, *httperror.HandlerError) { +// updateStackInTx returns the updated stack and the file-version numbers pruned by retention. +// The pruned directories must be physically deleted by the caller only after the transaction +// has committed successfully (see stackUpdate). +func (handler *Handler) updateStackInTx(tx dataservices.DataStoreTx, r *http.Request, stackID portainer.StackID, endpointID portainer.EndpointID) (*portainer.Stack, []int, *httperror.HandlerError) { stack, err := tx.Stack().Read(stackID) if tx.IsErrObjectNotFound(err) { - return nil, httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err) + return nil, nil, httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err) } else if err != nil { - return nil, httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err) + return nil, nil, httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err) } if stack.Status == portainer.StackStatusDeploying { - return nil, httperror.Conflict("Unable to update stack", errors.New("Stack deployment is already in progress")) + return nil, nil, httperror.Conflict("Unable to update stack", errors.New("Stack deployment is already in progress")) } if endpointID != 0 && endpointID != stack.EndpointID { @@ -131,50 +154,51 @@ func (handler *Handler) updateStackInTx(tx dataservices.DataStoreTx, r *http.Req endpoint, err := tx.Endpoint().Endpoint(stack.EndpointID) if tx.IsErrObjectNotFound(err) { - return nil, httperror.NotFound("Unable to find the environment associated to the stack inside the database", err) + return nil, nil, httperror.NotFound("Unable to find the environment associated to the stack inside the database", err) } else if err != nil { - return nil, httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err) + return nil, nil, httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err) } if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil { - return nil, httperror.Forbidden("Permission denied to access environment", err) + return nil, nil, httperror.Forbidden("Permission denied to access environment", err) } securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { - return nil, httperror.InternalServerError("Unable to retrieve info from request context", err) + return nil, nil, httperror.InternalServerError("Unable to retrieve info from request context", err) } //only check resource control when it is a DockerSwarmStack or a DockerComposeStack if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { resourceControl, err := tx.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) if err != nil { - return nil, httperror.InternalServerError("Unable to retrieve a resource control associated to the stack", err) + return nil, nil, httperror.InternalServerError("Unable to retrieve a resource control associated to the stack", err) } if access, err := handler.userCanAccessStack(securityContext, resourceControl); err != nil { - return nil, httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err) + return nil, nil, httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err) } else if !access { - return nil, httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied) + return nil, nil, httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied) } } if canManage, err := handler.userCanManageStacks(securityContext, endpoint); err != nil { - return nil, httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err) + return nil, nil, httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err) } else if !canManage { errMsg := "Stack editing is disabled for non-admin users" - return nil, httperror.Forbidden(errMsg, errors.New(errMsg)) + return nil, nil, httperror.Forbidden(errMsg, errors.New(errMsg)) } deployGate := newDeployGate() - if err := handler.updateAndDeployStack(tx, r, stack, endpoint, deployGate); err != nil { - return nil, err + pruneVersions, httpErr := handler.updateAndDeployStack(tx, r, stack, endpoint, deployGate) + if httpErr != nil { + return nil, nil, httpErr } user, err := tx.User().Read(securityContext.UserID) if err != nil { - return nil, httperror.BadRequest("Cannot find context user", errors.Wrap(err, "failed to fetch the user")) + return nil, nil, httperror.BadRequest("Cannot find context user", errors.Wrap(err, "failed to fetch the user")) } stack.UpdatedBy = user.Username @@ -183,19 +207,21 @@ func (handler *Handler) updateStackInTx(tx dataservices.DataStoreTx, r *http.Req if err := tx.Stack().Update(stack.ID, stack); err != nil { deployGate.abortDeploy() - return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err) + return nil, nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err) } deployGate.startDeploy() if err := fillStackGitConfig(tx, stack); err != nil { - return nil, httperror.InternalServerError("Unable to load git config for stack", err) + return nil, nil, httperror.InternalServerError("Unable to load git config for stack", err) } - return stack, nil + return stack, pruneVersions, nil } -func (handler *Handler) updateAndDeployStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) *httperror.HandlerError { +// updateAndDeployStack returns the file-version numbers pruned by retention (to be deleted +// post-commit by the caller) alongside any handler error. +func (handler *Handler) updateAndDeployStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) ([]int, *httperror.HandlerError) { switch stack.Type { case portainer.DockerSwarmStack: stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name) @@ -206,13 +232,105 @@ func (handler *Handler) updateAndDeployStack(tx dataservices.DataStoreTx, r *htt return handler.updateComposeStack(tx, r, stack, endpoint, gate) case portainer.KubernetesStack: - return handler.updateKubernetesStack(tx, r, stack, endpoint, gate) + return nil, handler.updateKubernetesStack(tx, r, stack, endpoint, gate) } - return httperror.InternalServerError("Unsupported stack", errors.Errorf("unsupported stack type: %v", stack.Type)) + return nil, httperror.InternalServerError("Unsupported stack", errors.Errorf("unsupported stack type: %v", stack.Type)) } -func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) *httperror.HandlerError { +// validateRollbackTarget ensures the requested rollback version is within range and present +// in the stack's append-only version history. +func validateRollbackTarget(stack *portainer.Stack, target int) error { + if target < 1 || target > stack.StackFileVersion { + return errors.Errorf("rollback version %d is out of range (1..%d)", target, stack.StackFileVersion) + } + + for _, v := range stack.Versions { + if v.Version == target { + return nil + } + } + + return errors.Errorf("rollback version %d not found in stack history", target) +} + +// snapshotFileBasedStackVersion writes the new stack content into a fresh v{N} version folder +// for a file-based (non-git) Compose/Swarm stack and updates the stack's version bookkeeping +// (ProjectPath, StackFileVersion, PreviousDeploymentInfo, Versions) plus applies retention. +// payloadContent is the entrypoint content from the request; when rollbackTo is non-nil the +// target version's content is read from disk on the server (client content is ignored) and a +// multi-file copy of that version is snapshotted. +// The returned slice holds the version numbers whose on-disk directories were pruned by +// retention and must be physically deleted by the caller AFTER the transaction commits. +func (handler *Handler) snapshotFileBasedStackVersion(tx dataservices.DataStoreTx, stack *portainer.Stack, payloadContent []byte, rollbackTo *int, userID portainer.UserID) ([]int, *httperror.HandlerError) { + stackFolder := strconv.Itoa(int(stack.ID)) + + note := "" + srcProjectPath := stack.ProjectPath + entryPointContent := payloadContent + + if rollbackTo != nil { + target := *rollbackTo + if err := validateRollbackTarget(stack, target); err != nil { + return nil, httperror.BadRequest("Invalid rollback version", err) + } + + srcProjectPath = handler.FileService.GetStackProjectPathByVersion(stackFolder, target, "") + content, err := handler.FileService.GetFileContent(srcProjectPath, stack.EntryPoint) + if err != nil { + return nil, httperror.InternalServerError("Unable to read rollback version content from disk", err) + } + + entryPointContent = content + note = fmt.Sprintf("rollback from v%d", target) + } + + filesContent, err := collectStackFilesContent(handler.FileService, stack, srcProjectPath, entryPointContent) + if err != nil { + return nil, httperror.InternalServerError("Unable to read stack files from disk", err) + } + + newVersion := stack.StackFileVersion + 1 + if newVersion < 1 { + newVersion = 1 + } + + projectPath, err := snapshotStackFilesToVersion(handler.FileService, stackFolder, newVersion, filesContent) + if err != nil { + return nil, httperror.InternalServerError("Unable to persist stack file version on disk", err) + } + + deployedBy := "" + if user, err := tx.User().Read(userID); err == nil { + deployedBy = user.Username + } + + // Record the prior file version so the frontend can display where the stack came from. + stack.PreviousDeploymentInfo = &portainer.StackDeploymentInfo{ + FileVersion: stack.StackFileVersion, + } + + stack.ProjectPath = projectPath + stack.StackFileVersion = newVersion + stack.Versions = append(stack.Versions, portainer.StackFileVersionInfo{ + Version: newVersion, + CreatedAt: time.Now().Unix(), + CreatedBy: deployedBy, + Note: note, + }) + + // Trim history in memory only; the pruned directories are deleted post-commit. + pruneVersions := applyRetention(stack, maxStackFileVersions) + + return pruneVersions, nil +} + +func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) ([]int, *httperror.HandlerError) { + // Whether the stack is file-based (non-git) before any git-detach conversion below. + // Only file-based stacks get an on-disk file version history; git stacks keep their + // existing (commit-based) behavior. + wasFileBased := stack.WorkflowID == 0 + // Must not be git based stack. stop the auto update job if there is any if stack.AutoUpdate != nil { deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) @@ -224,7 +342,7 @@ func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http. var payload updateComposeStackPayload if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { - return httperror.BadRequest("Invalid request payload", err) + return nil, httperror.BadRequest("Invalid request payload", err) } payload.RepullImageAndRedeploy = payload.RepullImageAndRedeploy || payload.PullImage @@ -234,23 +352,32 @@ func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http. oldWorkflowID := stack.WorkflowID stack.WorkflowID = 0 if err := tx.Workflow().Delete(oldWorkflowID); err != nil { - return httperror.InternalServerError("Unable to remove git workflow records from database", err) + return nil, httperror.InternalServerError("Unable to remove git workflow records from database", err) } } - stackFolder := strconv.Itoa(int(stack.ID)) - if _, err := handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)); err != nil { - if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil { - log.Warn().Err(rollbackErr).Msg("rollback stack file error") - } - - return httperror.InternalServerError("Unable to persist updated Compose file on disk", err) - } - // Create compose deployment config securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { - return httperror.InternalServerError("Unable to retrieve info from request context", err) + return nil, httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + var pruneVersions []int + stackFolder := strconv.Itoa(int(stack.ID)) + if wasFileBased { + var httpErr *httperror.HandlerError + pruneVersions, httpErr = handler.snapshotFileBasedStackVersion(tx, stack, []byte(payload.StackFileContent), payload.RollbackTo, securityContext.UserID) + if httpErr != nil { + return nil, httpErr + } + } else { + if _, err := handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)); err != nil { + if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil { + log.Warn().Err(rollbackErr).Msg("rollback stack file error") + } + + return nil, httperror.InternalServerError("Unable to persist updated Compose file on disk", err) + } } composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfigTx(tx, securityContext, @@ -262,11 +389,14 @@ func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http. payload.RepullImageAndRedeploy, payload.RepullImageAndRedeploy) if err != nil { + // For a versioned (file-based) stack no {entryPoint}.bak backup exists — the version + // directory is the durable record — so RollbackStackFile is a deliberate no-op here; + // it only performs a real rollback on the legacy git-detach path above. if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil { log.Warn().Err(rollbackErr).Msg("rollback stack file error") } - return httperror.InternalServerError(err.Error(), err) + return nil, httperror.InternalServerError(err.Error(), err) } if stack.Option != nil { @@ -278,6 +408,9 @@ func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http. } postDeploy := func(ctx context.Context, deployErr error) { + // For a versioned (file-based) stack these backup ops are deliberate no-ops: no + // {entryPoint}.bak was ever written, so a failed deploy simply leaves an unused + // version directory behind (the durable record). They only act on the git-detach path. if deployErr != nil { if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil { log.Warn().Err(rollbackErr).Msg("rollback stack file error") @@ -292,10 +425,15 @@ func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http. go stackDeploy(handler.DataStore, stack.ID, composeDeploymentConfig, gate, postDeploy) - return nil + return pruneVersions, nil } -func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) *httperror.HandlerError { +func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) ([]int, *httperror.HandlerError) { + // Whether the stack is file-based (non-git) before any git-detach conversion below. + // Only file-based stacks get an on-disk file version history; git stacks keep their + // existing (commit-based) behavior. + wasFileBased := stack.WorkflowID == 0 + // Must not be git based stack. stop the auto update job if there is any if stack.AutoUpdate != nil { deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) @@ -307,7 +445,7 @@ func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Re var payload updateSwarmStackPayload if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { - return httperror.BadRequest("Invalid request payload", err) + return nil, httperror.BadRequest("Invalid request payload", err) } payload.RepullImageAndRedeploy = payload.RepullImageAndRedeploy || payload.PullImage stack.Env = payload.Env @@ -316,23 +454,32 @@ func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Re oldWorkflowID := stack.WorkflowID stack.WorkflowID = 0 if err := tx.Workflow().Delete(oldWorkflowID); err != nil { - return httperror.InternalServerError("Unable to remove git workflow records from database", err) + return nil, httperror.InternalServerError("Unable to remove git workflow records from database", err) } } - stackFolder := strconv.Itoa(int(stack.ID)) - if _, err := handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)); err != nil { - if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil { - log.Warn().Err(rollbackErr).Msg("rollback stack file error") - } - - return httperror.InternalServerError("Unable to persist updated Compose file on disk", err) - } - // Create swarm deployment config securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { - return httperror.InternalServerError("Unable to retrieve info from request context", err) + return nil, httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + var pruneVersions []int + stackFolder := strconv.Itoa(int(stack.ID)) + if wasFileBased { + var httpErr *httperror.HandlerError + pruneVersions, httpErr = handler.snapshotFileBasedStackVersion(tx, stack, []byte(payload.StackFileContent), payload.RollbackTo, securityContext.UserID) + if httpErr != nil { + return nil, httpErr + } + } else { + if _, err := handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)); err != nil { + if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil { + log.Warn().Err(rollbackErr).Msg("rollback stack file error") + } + + return nil, httperror.InternalServerError("Unable to persist updated Compose file on disk", err) + } } swarmDeploymentConfig, err := deployments.CreateSwarmStackDeploymentConfigTx(tx, securityContext, @@ -343,11 +490,14 @@ func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Re payload.Prune, payload.RepullImageAndRedeploy) if err != nil { + // For a versioned (file-based) stack no {entryPoint}.bak backup exists — the version + // directory is the durable record — so RollbackStackFile is a deliberate no-op here; + // it only performs a real rollback on the legacy git-detach path above. if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil { log.Warn().Err(rollbackErr).Msg("rollback stack file error") } - return httperror.InternalServerError(err.Error(), err) + return nil, httperror.InternalServerError(err.Error(), err) } if stack.Option != nil { @@ -359,6 +509,9 @@ func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Re } postDeploy := func(ctx context.Context, deployErr error) { + // For a versioned (file-based) stack these backup ops are deliberate no-ops: no + // {entryPoint}.bak was ever written, so a failed deploy simply leaves an unused + // version directory behind (the durable record). They only act on the git-detach path. if deployErr != nil { if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil { log.Warn().Err(rollbackErr).Msg("rollback stack file error") @@ -373,7 +526,7 @@ func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Re go stackDeploy(handler.DataStore, stack.ID, swarmDeploymentConfig, gate, postDeploy) - return nil + return pruneVersions, nil } func stackDeploy(dataStore dataservices.DataStore, stackID portainer.StackID, stackDeploymentConfig deployments.StackDeploymentConfiger, gate *deployGate, postDeploy postDeployFunc) { diff --git a/api/http/handler/stacks/stack_update_test.go b/api/http/handler/stacks/stack_update_test.go index 15e8f04e9..9635e3dbb 100644 --- a/api/http/handler/stacks/stack_update_test.go +++ b/api/http/handler/stacks/stack_update_test.go @@ -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 } diff --git a/api/http/handler/stacks/stack_versioning.go b/api/http/handler/stacks/stack_versioning.go new file mode 100644 index 000000000..6903e067b --- /dev/null +++ b/api/http/handler/stacks/stack_versioning.go @@ -0,0 +1,97 @@ +package stacks + +import ( + "strconv" + + portainer "github.com/portainer/portainer/api" + + "github.com/rs/zerolog/log" +) + +// maxStackFileVersions caps the number of on-disk file versions kept per file-based stack. +// Once the history exceeds this cap, the oldest versions are pruned from disk and history. +const maxStackFileVersions = 20 + +// snapshotStackFilesToVersion writes every stack file into the v{version} folder of the given +// stack. filesContent maps a file name (relative to the stack root) to its content and must +// include the entrypoint (multi-file: all AdditionalFiles are copied too). It returns the +// versioned project path, which the caller must assign to stack.ProjectPath (the underlying +// StoreStackFileFromBytesByVersion returns the base path, not the version path). +func snapshotStackFilesToVersion(fileService portainer.FileService, stackID string, version int, filesContent map[string][]byte) (string, error) { + for fileName, content := range filesContent { + if _, err := fileService.StoreStackFileFromBytesByVersion(stackID, fileName, version, content); err != nil { + return "", err + } + } + + return fileService.GetStackProjectPathByVersion(stackID, version, ""), nil +} + +// applyRetention trims the oldest entries from stack.Versions once the history exceeds +// maxVersions and returns the version numbers whose on-disk v{N} directories should be +// deleted. It never selects the currently deployed version for deletion, and the in-memory +// trim is kept consistent with what is actually pruned (an entry whose directory is +// intentionally kept stays in stack.Versions). +// +// The physical directory deletion is deliberately NOT performed here: the caller must run +// it (see (*Handler).pruneStackFileVersionDirs) only AFTER the enclosing DB transaction has +// committed. Deleting inside the transaction would leave dangling history if the tx later +// rolled back (the persisted Versions[] would still reference now-deleted directories). +func applyRetention(stack *portainer.Stack, maxVersions int) []int { + if maxVersions <= 0 || len(stack.Versions) <= maxVersions { + return nil + } + + removeCount := len(stack.Versions) - maxVersions + + pruned := make([]int, 0, removeCount) + kept := make([]portainer.StackFileVersionInfo, 0, len(stack.Versions)) + for i, info := range stack.Versions { + // Only the oldest removeCount entries are eligible for pruning, and never the + // currently deployed version (safety guard). Everything else is kept. + if i < removeCount && info.Version != stack.StackFileVersion { + pruned = append(pruned, info.Version) + continue + } + + kept = append(kept, info) + } + + stack.Versions = kept + + return pruned +} + +// pruneStackFileVersionDirs physically deletes the given file-version directories for a stack. +// It is best-effort: a failed deletion is logged and does not fail the request. This MUST be +// called only after the DB transaction that trimmed stack.Versions has committed successfully, +// so a rolled-back transaction never leaves history referencing deleted directories. +func (handler *Handler) pruneStackFileVersionDirs(stackID portainer.StackID, versions []int) { + stackFolder := strconv.Itoa(int(stackID)) + for _, v := range versions { + dir := handler.FileService.GetStackProjectPathByVersion(stackFolder, v, "") + if err := handler.FileService.RemoveDirectory(dir); err != nil { + log.Warn().Err(err).Int("version", v).Int("stack_id", int(stackID)).Msg("unable to remove old stack file version directory") + } + } +} + +// collectStackFilesContent builds the file-name -> content map for a version snapshot: the +// entrypoint is taken from entryPointContent (the new/target content), while every additional +// file is read from srcProjectPath so the whole file set is carried into the new version. +func collectStackFilesContent(fileService portainer.FileService, stack *portainer.Stack, srcProjectPath string, entryPointContent []byte) (map[string][]byte, error) { + filesContent := map[string][]byte{ + stack.EntryPoint: entryPointContent, + } + + for _, additionalFile := range stack.AdditionalFiles { + content, err := fileService.GetFileContent(srcProjectPath, additionalFile) + if err != nil { + return nil, err + } + + filesContent[additionalFile] = content + } + + return filesContent, nil +} diff --git a/api/http/handler/stacks/stack_versioning_test.go b/api/http/handler/stacks/stack_versioning_test.go new file mode 100644 index 000000000..d87f62bc0 --- /dev/null +++ b/api/http/handler/stacks/stack_versioning_test.go @@ -0,0 +1,118 @@ +package stacks + +import ( + "strconv" + "testing" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" + + "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 +} + +// 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) + } +} diff --git a/api/http/handler/stacks/stack_versions.go b/api/http/handler/stacks/stack_versions.go new file mode 100644 index 000000000..f30d85db7 --- /dev/null +++ b/api/http/handler/stacks/stack_versions.go @@ -0,0 +1,96 @@ +package stacks + +import ( + "net/http" + + portainer "github.com/portainer/portainer/api" + httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/stacks/stackutils" + httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/libhttp/response" + + "github.com/pkg/errors" +) + +// @id StackVersions +// @summary List the file version history of a file-based stack +// @description Get the append-only file version history for a file-based (non-git) Compose/Swarm stack. +// @description **Access policy**: restricted +// @tags stacks +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param id path int true "Stack identifier" +// @success 200 {array} portainer.StackFileVersionInfo "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @failure 404 "Stack not found" +// @failure 500 "Server error" +// @router /stacks/{id}/versions [get] +func (handler *Handler) stackVersions(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest("Invalid stack identifier route variable", err) + } + + stack, err := handler.DataStore.Stack().Read(portainer.StackID(stackID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err) + } + + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) + if handler.DataStore.IsErrObjectNotFound(err) { + if !securityContext.IsAdmin { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + canManage, err := handler.userCanManageStacks(securityContext, endpoint) + if err != nil { + return httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err) + } + if !canManage { + errMsg := "Stack management is disabled for non-admin users" + return httperror.Forbidden(errMsg, errors.New(errMsg)) + } + + if endpoint != nil { + if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil { + return httperror.Forbidden("Permission denied to access environment", err) + } + + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) + if err != nil { + return httperror.InternalServerError("Unable to retrieve a resource control associated to the stack", err) + } + + access, err := handler.userCanAccessStack(securityContext, resourceControl) + if err != nil { + return httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err) + } + if !access { + return httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied) + } + } + } + + // Only file-based (non-git) Compose/Swarm stacks carry a file version history. + versions := stack.Versions + if versions == nil || stack.WorkflowID != 0 { + versions = []portainer.StackFileVersionInfo{} + } + + return response.JSON(w, versions) +} diff --git a/api/portainer.go b/api/portainer.go index 893eaaf83..653780aa8 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -324,6 +324,19 @@ type ( SourceID SourceID `json:"SourceID,omitempty"` } + // StackFileVersionInfo records one entry in the append-only file version history + // of a file-based (non-git) Compose/Swarm stack. + StackFileVersionInfo struct { + // Version is the v{N} folder number holding this version's files + Version int `json:"Version"` + // CreatedAt is the unix time (seconds) when this version was deployed + CreatedAt int64 `json:"CreatedAt"` + // CreatedBy is the username/id that deployed this version + CreatedBy string `json:"CreatedBy,omitempty"` + // Note is an optional description (e.g. "rollback from v5", "migrated") + Note string `json:"Note,omitempty"` + } + // EdgeStack represents an edge stack EdgeStack struct { // EdgeStack Identifier @@ -1355,6 +1368,14 @@ type ( // DeploymentStatus records the status progression of the current deployment. // Cleared when a new deployment starts. DeploymentStatus []StackDeploymentStatus `json:"DeploymentStatus,omitempty"` + // StackFileVersion is the current (monotonic) file version number for file-based + // (non-git) Compose/Swarm stacks. Zero for git/kubernetes/edge stacks. + StackFileVersion int `json:"StackFileVersion,omitempty"` + // PreviousDeploymentInfo records the deployment info captured before the last update, + // used by the frontend to display the prior file version. + PreviousDeploymentInfo *StackDeploymentInfo `json:"PreviousDeploymentInfo,omitempty"` + // Versions is the append-only file version history (source of truth) for file-based stacks. + Versions []StackFileVersionInfo `json:"Versions,omitempty"` } // StackOption represents the options for stack deployment @@ -2105,7 +2126,7 @@ type ( const ( // APIVersion is the version number of the Portainer API - APIVersion = "2.43.0" + APIVersion = "2.44.0" // Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support) APIVersionSupport = "STS" // Edition is what this edition of Portainer is called diff --git a/api/stacks/stackbuilders/compose_file_builder.go b/api/stacks/stackbuilders/compose_file_builder.go index a2f7bd561..cfcf63f3e 100644 --- a/api/stacks/stackbuilders/compose_file_builder.go +++ b/api/stacks/stackbuilders/compose_file_builder.go @@ -38,7 +38,7 @@ func (b *ComposeStackFileBuilder) prepare(_ context.Context, payload *StackPaylo return err } - return b.storeStackFile(payload.StackFileContent) + return b.storeStackFileVersioned(payload.StackFileContent) } func (b *ComposeStackFileBuilder) deploy(ctx context.Context, endpoint *portainer.Endpoint) error { diff --git a/api/stacks/stackbuilders/stack_builder.go b/api/stacks/stackbuilders/stack_builder.go index 0181c4074..7471b7411 100644 --- a/api/stacks/stackbuilders/stack_builder.go +++ b/api/stacks/stackbuilders/stack_builder.go @@ -117,6 +117,27 @@ func (b *StackBuilder) storeStackFile(content []byte) error { return nil } +// storeStackFileVersioned stores the initial file of a file-based (non-git) Compose/Swarm stack +// into the v1 version folder and seeds the append-only version history. Note that +// StoreStackFileFromBytesByVersion returns the base path (not the version path), so ProjectPath +// is set explicitly via GetStackProjectPathByVersion. +func (b *StackBuilder) storeStackFileVersioned(content []byte) error { + stackFolder := strconv.Itoa(int(b.stack.ID)) + if _, err := b.fileService.StoreStackFileFromBytesByVersion(stackFolder, b.stack.EntryPoint, 1, content); err != nil { + return err + } + + b.stack.ProjectPath = b.fileService.GetStackProjectPathByVersion(stackFolder, 1, "") + b.stack.StackFileVersion = 1 + b.stack.Versions = []portainer.StackFileVersionInfo{{ + Version: 1, + CreatedAt: time.Now().Unix(), + CreatedBy: b.stack.CreatedBy, + }} + + return nil +} + func (b *StackBuilder) initComposeDeployment(secCtx *security.RestrictedRequestContext, endpoint *portainer.Endpoint) error { config, err := deployments.CreateComposeStackDeploymentConfigTx(b.dataStore, secCtx, b.stack, endpoint, b.fileService, b.stackDeployer, false, false, false) if err != nil { diff --git a/api/stacks/stackbuilders/swarm_file_builder.go b/api/stacks/stackbuilders/swarm_file_builder.go index 13720b91c..dfbec21cf 100644 --- a/api/stacks/stackbuilders/swarm_file_builder.go +++ b/api/stacks/stackbuilders/swarm_file_builder.go @@ -39,7 +39,7 @@ func (b *SwarmStackFileBuilder) prepare(_ context.Context, payload *StackPayload return err } - return b.storeStackFile(payload.StackFileContent) + return b.storeStackFileVersioned(payload.StackFileContent) } func (b *SwarmStackFileBuilder) deploy(ctx context.Context, endpoint *portainer.Endpoint) error { diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts index 561598f65..eb270b04d 100644 --- a/app/portainer/react/components/index.ts +++ b/app/portainer/react/components/index.ts @@ -224,6 +224,7 @@ export const ngModule = angular 'height', 'data-cy', 'versions', + 'versionsInfo', 'onVersionChange', 'schema', 'fileName', diff --git a/app/react/common/stacks/queries/query-keys.ts b/app/react/common/stacks/queries/query-keys.ts index 4ccdd662a..9a2bed8a1 100644 --- a/app/react/common/stacks/queries/query-keys.ts +++ b/app/react/common/stacks/queries/query-keys.ts @@ -5,4 +5,6 @@ export const queryKeys = { stack: (stackId?: StackId) => [...queryKeys.base(), stackId] as const, stackFile: (stackId?: StackId, params?: unknown) => [...queryKeys.stack(stackId), 'file', params] as const, + stackVersions: (stackId?: StackId) => + [...queryKeys.stack(stackId), 'versions'] as const, }; diff --git a/app/react/common/stacks/queries/useStackVersions.ts b/app/react/common/stacks/queries/useStackVersions.ts new file mode 100644 index 000000000..3f0779934 --- /dev/null +++ b/app/react/common/stacks/queries/useStackVersions.ts @@ -0,0 +1,51 @@ +import { useQuery } from '@tanstack/react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { withError } from '@/react-tools/react-query'; + +import { StackFileVersionInfo, StackId } from '../types'; + +import { queryKeys } from './query-keys'; + +export function useStackVersions( + stackId?: StackId, + environmentId?: EnvironmentId, + { enabled }: { enabled?: boolean } = {} +) { + return useQuery({ + queryKey: queryKeys.stackVersions(stackId), + queryFn: ({ signal }) => + getStackVersions({ + stackId: stackId!, + environmentId, + options: { signal }, + }), + + ...withError('Unable to retrieve stack versions'), + enabled: !!stackId && enabled, + }); +} + +export async function getStackVersions({ + stackId, + environmentId, + options = {}, +}: { + stackId: StackId; + environmentId?: EnvironmentId; + options?: { signal?: AbortSignal }; +}) { + try { + const { data } = await axios.get( + `/stacks/${stackId}/versions`, + { + params: { endpointId: environmentId }, + signal: options.signal, + } + ); + return data; + } catch (e) { + throw parseAxiosError(e, 'Unable to retrieve stack versions'); + } +} diff --git a/app/react/common/stacks/types.ts b/app/react/common/stacks/types.ts index f48d96b54..e7ca2e688 100644 --- a/app/react/common/stacks/types.ts +++ b/app/react/common/stacks/types.ts @@ -113,6 +113,20 @@ export type StackFile = { StackFileContent: string; }; +/** + * Metadata describing a single stored version of a stack file, as returned by + * `GET /stacks/{id}/versions`. Field casing mirrors the backend JSON. + */ +export interface StackFileVersionInfo { + Version: number; + /** + * Creation time of the version, as a Unix timestamp in seconds. + */ + CreatedAt: number; + CreatedBy: string; + Note: string; +} + export interface GitStackPayload { env: Array; prune?: boolean; diff --git a/app/react/components/CodeEditor/CodeEditor.tsx b/app/react/components/CodeEditor/CodeEditor.tsx index 1bf4bc0b8..80bfd76b4 100644 --- a/app/react/components/CodeEditor/CodeEditor.tsx +++ b/app/react/components/CodeEditor/CodeEditor.tsx @@ -6,6 +6,7 @@ import type { JSONSchema7 } from 'json-schema'; import clsx from 'clsx'; import { AutomationTestingProps } from '@/types'; +import { StackFileVersionInfo } from '@/react/common/stacks/types'; import { CopyButton } from '@@/buttons/CopyButton'; @@ -29,6 +30,7 @@ interface Props extends AutomationTestingProps { value: string; height?: string; versions?: number[]; + versionsInfo?: StackFileVersionInfo[]; onVersionChange?: (version: number) => void; schema?: JSONSchema7; fileName?: string; @@ -73,6 +75,7 @@ export function CodeEditor({ readonly, value, versions, + versionsInfo, onVersionChange, height = '500px', type, @@ -128,6 +131,7 @@ export function CodeEditor({
diff --git a/app/react/components/StackVersionSelector/StackVersionSelector.test.tsx b/app/react/components/StackVersionSelector/StackVersionSelector.test.tsx new file mode 100644 index 000000000..6ce92863d --- /dev/null +++ b/app/react/components/StackVersionSelector/StackVersionSelector.test.tsx @@ -0,0 +1,99 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { isoDateFromTimestamp } from '@/portainer/filters/filters'; +import { StackFileVersionInfo } from '@/react/common/stacks/types'; + +import { StackVersionSelector } from './StackVersionSelector'; + +function createInfo( + overrides: Partial = {} +): StackFileVersionInfo { + 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( + + ); + + 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( + + ); + + 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(); + + 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( + + ); + + 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( + + ); + + await user.selectOptions( + screen.getByRole('combobox', { name: /version/i }), + '2' + ); + + expect(onChange).toHaveBeenCalledWith(2); +}); diff --git a/app/react/components/StackVersionSelector/StackVersionSelector.tsx b/app/react/components/StackVersionSelector/StackVersionSelector.tsx index 093ae4956..6af90865b 100644 --- a/app/react/components/StackVersionSelector/StackVersionSelector.tsx +++ b/app/react/components/StackVersionSelector/StackVersionSelector.tsx @@ -1,9 +1,37 @@ +import { isoDateFromTimestamp } from '@/portainer/filters/filters'; +import { StackFileVersionInfo } from '@/react/common/stacks/types'; + interface Props { versions?: number[]; + /** + * Optional richer metadata (date/author/note) used to build the option + * labels. Looked up by version number; falls back to the bare version when a + * given version has no metadata. + */ + versionsInfo?: StackFileVersionInfo[]; onChange(value: number): void; } -export function StackVersionSelector({ versions, onChange }: Props) { +/** + * Build a human-readable label for a version, e.g. `v3 · 2026-07-02 14:12 · admin`. + * Falls back to just the version number when no metadata is available. + */ +function buildVersionLabel(version: number, info?: StackFileVersionInfo) { + const parts = [`v${version}`]; + if (info?.CreatedAt) { + parts.push(isoDateFromTimestamp(info.CreatedAt)); + } + if (info?.CreatedBy) { + parts.push(info.CreatedBy); + } + return parts.join(' · '); +} + +export function StackVersionSelector({ + versions, + versionsInfo, + onChange, +}: Props) { if (!versions || versions.length === 0) { return null; } @@ -12,7 +40,10 @@ export function StackVersionSelector({ versions, onChange }: Props) { const versionOptions = versions.map((version) => ({ value: version, - label: version.toString(), + label: buildVersionLabel( + version, + versionsInfo?.find((info) => info.Version === version) + ), })); return ( @@ -23,7 +54,7 @@ export function StackVersionSelector({ versions, onChange }: Props) { Version: - {versions[0]} + {versionOptions[0].label} )} @@ -38,7 +69,6 @@ export function StackVersionSelector({ versions, onChange }: Props) { data-cy="version-selector" id="version_id" style={{ - width: '60px', height: '24px', borderRadius: '4px', borderColor: 'hsl(0, 0%, 80%)', @@ -48,7 +78,7 @@ export function StackVersionSelector({ versions, onChange }: Props) { > {versionOptions.map((option) => ( ))} diff --git a/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.test.tsx b/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.test.tsx index ebcc9a367..002709b8c 100644 --- a/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.test.tsx +++ b/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.test.tsx @@ -434,6 +434,7 @@ function setupMswHandlers({ }, }) ), + http.get('/api/stacks/:id/versions', () => HttpResponse.json([])), http.put('/api/stacks/:id', async ({ request, params }) => { const body = await request.json(); diff --git a/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.tsx b/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.tsx index db6239b85..c896c0f9a 100644 --- a/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.tsx +++ b/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTab.tsx @@ -1,10 +1,13 @@ import { Formik } from 'formik'; import { useRouter } from '@uirouter/react'; +import { useQueryClient } from '@tanstack/react-query'; import _ from 'lodash'; import { useState } from 'react'; import uuidv4 from 'uuid/v4'; import { Stack, StackType } from '@/react/common/stacks/types'; +import { useStackVersions } from '@/react/common/stacks/queries/useStackVersions'; +import { queryKeys } from '@/react/common/stacks/queries/query-keys'; import { useDockerComposeSchema } from '@/react/hooks/useDockerComposeSchema/useDockerComposeSchema'; import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment'; import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update'; @@ -35,20 +38,32 @@ export function StackEditorTab({ onSubmitSuccess = () => {}, stack, }: StackEditorTabProps) { - const versions = _.compact([ - stack.StackFileVersion, - stack.PreviousDeploymentInfo?.FileVersion, - ]); const router = useRouter(); + const queryClient = useQueryClient(); const mutation = useUpdateStackMutation(); const envQuery = useCurrentEnvironment(); const schemaQuery = useDockerComposeSchema(); + const versionsQuery = useStackVersions(stack.Id, envQuery.data?.Id, { + enabled: !!envQuery.data, + }); const [webhookId] = useState(() => stack.Webhook || uuidv4()); if (!envQuery.data || !schemaQuery.data) { return null; } + const versionsInfo = versionsQuery.data; + // Build the full descending version list from the fetched history; fall back + // to the current + previous deployment versions if the history is unavailable + // so the editor keeps working (e.g. while loading or on error). + const versions = + versionsInfo && versionsInfo.length > 0 + ? versionsInfo.map((v) => v.Version).sort((a, b) => b - a) + : _.compact([ + stack.StackFileVersion, + stack.PreviousDeploymentInfo?.FileVersion, + ]); + const envType = envQuery.data?.Type; const composeSyntaxMaxVersion = parseFloat( envQuery.data?.ComposeSyntaxMaxVersion @@ -94,6 +109,14 @@ export function StackEditorTab({ { onSuccess() { notifySuccess('Success', 'Stack successfully deployed'); + // Refresh the version history and cached file so the selector + // reflects the new deployment / rollback. Invalidate the file by + // its 3-element prefix (['stacks', id, 'file']) so it matches + // EVERY versioned file query — the real keys carry a params object + // ({version, commitHash}) in the 4th slot, which stackFile(id) + // with no params (undefined) would not partial-match. + queryClient.invalidateQueries(queryKeys.stackVersions(stack.Id)); + queryClient.invalidateQueries([...queryKeys.stack(stack.Id), 'file']); router.stateService.reload(); onSubmitSuccess(); }, @@ -114,6 +137,7 @@ export function StackEditorTab({ envType={envType} schema={schemaQuery.data} versions={versions} + versionsInfo={versionsInfo} isSubmitting={mutation.isLoading} isSaved={mutation.isSuccess} webhookId={webhookId} diff --git a/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTabInner.tsx b/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTabInner.tsx index ec92c3cf0..d406b3a28 100644 --- a/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTabInner.tsx +++ b/app/react/docker/stacks/ItemView/StackEditorTab/StackEditorTabInner.tsx @@ -2,7 +2,11 @@ import { Form, useFormikContext } from 'formik'; import { JSONSchema7 } from 'json-schema'; import { useCallback } from 'react'; -import { Stack, StackType } from '@/react/common/stacks/types'; +import { + Stack, + StackFileVersionInfo, + StackType, +} from '@/react/common/stacks/types'; import { PruneField } from '@/react/common/stacks/PruneField'; import { EnvironmentType } from '@/react/portainer/environments/types'; import { Authorized, useAuthorizations } from '@/react/hooks/useUser'; @@ -25,6 +29,7 @@ interface StackEditorTabInnerProps { schema: JSONSchema7; isOrphaned: boolean; versions?: Array; + versionsInfo?: StackFileVersionInfo[]; stackId: Stack['Id']; isSaved: boolean; isSubmitting: boolean; @@ -38,6 +43,7 @@ export function StackEditorTabInner({ schema, isOrphaned, versions, + versionsInfo, stackId, isSaved, isSubmitting, @@ -120,6 +126,7 @@ export function StackEditorTabInner({ data-cy="stack-editor" onVersionChange={handleVersionChange} versions={versions} + versionsInfo={versionsInfo} />