d0d3c068ba
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>
119 lines
4.0 KiB
Go
119 lines
4.0 KiB
Go
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)
|
|
}
|
|
}
|