Files
portainer/api/http/handler/stacks/stack_versioning.go
T
agent_coder d0d3c068ba feat(stacks): file-based stack versioning with full history + rollback (#27)
Adds append-only version history on disk (compose/{id}/v{N}/<files>) for
file-based (WorkflowID==0) Compose/Swarm stacks, with rollback to any past
version. Git stacks (versioned by commit) and Kubernetes are untouched.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 16:07:26 +03:00

98 lines
4.2 KiB
Go

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
}