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>
This commit is contained in:
agent_coder
2026-07-02 16:07:26 +03:00
parent 549e916c3e
commit d0d3c068ba
25 changed files with 1237 additions and 79 deletions
+208 -55
View File
@@ -2,6 +2,7 @@ package stacks
import (
"context"
"fmt"
"net/http"
"strconv"
"time"
@@ -35,10 +36,15 @@ type updateComposeStackPayload struct {
// Deprecated(2.36): use RepullImageAndRedeploy instead for cleaner responsibility
// Force a pulling to current image with the original tag though the image is already the latest
PullImage bool `example:"false"`
// RollbackTo, when set, redeploys the content of a past file version as a new version.
// The server reads the target version's content from disk; StackFileContent may be empty.
RollbackTo *int `example:"3"`
}
func (payload *updateComposeStackPayload) Validate(r *http.Request) error {
if len(payload.StackFileContent) == 0 {
// For a rollback the content is read from disk on the server, so an empty payload is allowed.
if payload.RollbackTo == nil && len(payload.StackFileContent) == 0 {
return errors.New("Invalid stack file content")
}
@@ -58,10 +64,15 @@ type updateSwarmStackPayload struct {
// Deprecated(2.36): use RepullImageAndRedeploy instead for cleaner responsibility
// Force a pulling to current image with the original tag though the image is already the latest
PullImage bool `example:"false"`
// RollbackTo, when set, redeploys the content of a past file version as a new version.
// The server reads the target version's content from disk; StackFileContent may be empty.
RollbackTo *int `example:"3"`
}
func (payload *updateSwarmStackPayload) Validate(r *http.Request) error {
if len(payload.StackFileContent) == 0 {
// For a rollback the content is read from disk on the server, so an empty payload is allowed.
if payload.RollbackTo == nil && len(payload.StackFileContent) == 0 {
return errors.New("Invalid stack file content")
}
@@ -102,27 +113,39 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt
}
var stack *portainer.Stack
var pruneVersions []int
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
var httpErr *httperror.HandlerError
stack, httpErr = handler.updateStackInTx(tx, r, portainer.StackID(stackID), portainer.EndpointID(endpointID))
stack, pruneVersions, httpErr = handler.updateStackInTx(tx, r, portainer.StackID(stackID), portainer.EndpointID(endpointID))
if httpErr != nil {
return httpErr
}
return nil
})
// Physically delete the file-version directories pruned by retention only after the
// transaction has committed successfully. If the tx failed the trimmed Versions[] was
// never persisted, so the old directories must stay to match the DB (harmless orphans).
if err == nil && len(pruneVersions) > 0 {
handler.pruneStackFileVersionDirs(stack.ID, pruneVersions)
}
return response.TxResponse(w, stack, err)
}
func (handler *Handler) updateStackInTx(tx dataservices.DataStoreTx, r *http.Request, stackID portainer.StackID, endpointID portainer.EndpointID) (*portainer.Stack, *httperror.HandlerError) {
// updateStackInTx returns the updated stack and the file-version numbers pruned by retention.
// The pruned directories must be physically deleted by the caller only after the transaction
// has committed successfully (see stackUpdate).
func (handler *Handler) updateStackInTx(tx dataservices.DataStoreTx, r *http.Request, stackID portainer.StackID, endpointID portainer.EndpointID) (*portainer.Stack, []int, *httperror.HandlerError) {
stack, err := tx.Stack().Read(stackID)
if tx.IsErrObjectNotFound(err) {
return nil, httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err)
return nil, nil, httperror.NotFound("Unable to find a stack with the specified identifier inside the database", err)
} else if err != nil {
return nil, httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err)
return nil, nil, httperror.InternalServerError("Unable to find a stack with the specified identifier inside the database", err)
}
if stack.Status == portainer.StackStatusDeploying {
return nil, httperror.Conflict("Unable to update stack", errors.New("Stack deployment is already in progress"))
return nil, nil, httperror.Conflict("Unable to update stack", errors.New("Stack deployment is already in progress"))
}
if endpointID != 0 && endpointID != stack.EndpointID {
@@ -131,50 +154,51 @@ func (handler *Handler) updateStackInTx(tx dataservices.DataStoreTx, r *http.Req
endpoint, err := tx.Endpoint().Endpoint(stack.EndpointID)
if tx.IsErrObjectNotFound(err) {
return nil, httperror.NotFound("Unable to find the environment associated to the stack inside the database", err)
return nil, nil, httperror.NotFound("Unable to find the environment associated to the stack inside the database", err)
} else if err != nil {
return nil, httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err)
return nil, nil, httperror.InternalServerError("Unable to find the environment associated to the stack inside the database", err)
}
if err := handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint); err != nil {
return nil, httperror.Forbidden("Permission denied to access environment", err)
return nil, nil, httperror.Forbidden("Permission denied to access environment", err)
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve info from request context", err)
return nil, nil, httperror.InternalServerError("Unable to retrieve info from request context", err)
}
//only check resource control when it is a DockerSwarmStack or a DockerComposeStack
if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
resourceControl, err := tx.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil {
return nil, httperror.InternalServerError("Unable to retrieve a resource control associated to the stack", err)
return nil, nil, httperror.InternalServerError("Unable to retrieve a resource control associated to the stack", err)
}
if access, err := handler.userCanAccessStack(securityContext, resourceControl); err != nil {
return nil, httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err)
return nil, nil, httperror.InternalServerError("Unable to verify user authorizations to validate stack access", err)
} else if !access {
return nil, httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
return nil, nil, httperror.Forbidden("Access denied to resource", httperrors.ErrResourceAccessDenied)
}
}
if canManage, err := handler.userCanManageStacks(securityContext, endpoint); err != nil {
return nil, httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err)
return nil, nil, httperror.InternalServerError("Unable to verify user authorizations to validate stack deletion", err)
} else if !canManage {
errMsg := "Stack editing is disabled for non-admin users"
return nil, httperror.Forbidden(errMsg, errors.New(errMsg))
return nil, nil, httperror.Forbidden(errMsg, errors.New(errMsg))
}
deployGate := newDeployGate()
if err := handler.updateAndDeployStack(tx, r, stack, endpoint, deployGate); err != nil {
return nil, err
pruneVersions, httpErr := handler.updateAndDeployStack(tx, r, stack, endpoint, deployGate)
if httpErr != nil {
return nil, nil, httpErr
}
user, err := tx.User().Read(securityContext.UserID)
if err != nil {
return nil, httperror.BadRequest("Cannot find context user", errors.Wrap(err, "failed to fetch the user"))
return nil, nil, httperror.BadRequest("Cannot find context user", errors.Wrap(err, "failed to fetch the user"))
}
stack.UpdatedBy = user.Username
@@ -183,19 +207,21 @@ func (handler *Handler) updateStackInTx(tx dataservices.DataStoreTx, r *http.Req
if err := tx.Stack().Update(stack.ID, stack); err != nil {
deployGate.abortDeploy()
return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
return nil, nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
}
deployGate.startDeploy()
if err := fillStackGitConfig(tx, stack); err != nil {
return nil, httperror.InternalServerError("Unable to load git config for stack", err)
return nil, nil, httperror.InternalServerError("Unable to load git config for stack", err)
}
return stack, nil
return stack, pruneVersions, nil
}
func (handler *Handler) updateAndDeployStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) *httperror.HandlerError {
// updateAndDeployStack returns the file-version numbers pruned by retention (to be deleted
// post-commit by the caller) alongside any handler error.
func (handler *Handler) updateAndDeployStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) ([]int, *httperror.HandlerError) {
switch stack.Type {
case portainer.DockerSwarmStack:
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
@@ -206,13 +232,105 @@ func (handler *Handler) updateAndDeployStack(tx dataservices.DataStoreTx, r *htt
return handler.updateComposeStack(tx, r, stack, endpoint, gate)
case portainer.KubernetesStack:
return handler.updateKubernetesStack(tx, r, stack, endpoint, gate)
return nil, handler.updateKubernetesStack(tx, r, stack, endpoint, gate)
}
return httperror.InternalServerError("Unsupported stack", errors.Errorf("unsupported stack type: %v", stack.Type))
return nil, httperror.InternalServerError("Unsupported stack", errors.Errorf("unsupported stack type: %v", stack.Type))
}
func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) *httperror.HandlerError {
// validateRollbackTarget ensures the requested rollback version is within range and present
// in the stack's append-only version history.
func validateRollbackTarget(stack *portainer.Stack, target int) error {
if target < 1 || target > stack.StackFileVersion {
return errors.Errorf("rollback version %d is out of range (1..%d)", target, stack.StackFileVersion)
}
for _, v := range stack.Versions {
if v.Version == target {
return nil
}
}
return errors.Errorf("rollback version %d not found in stack history", target)
}
// snapshotFileBasedStackVersion writes the new stack content into a fresh v{N} version folder
// for a file-based (non-git) Compose/Swarm stack and updates the stack's version bookkeeping
// (ProjectPath, StackFileVersion, PreviousDeploymentInfo, Versions) plus applies retention.
// payloadContent is the entrypoint content from the request; when rollbackTo is non-nil the
// target version's content is read from disk on the server (client content is ignored) and a
// multi-file copy of that version is snapshotted.
// The returned slice holds the version numbers whose on-disk directories were pruned by
// retention and must be physically deleted by the caller AFTER the transaction commits.
func (handler *Handler) snapshotFileBasedStackVersion(tx dataservices.DataStoreTx, stack *portainer.Stack, payloadContent []byte, rollbackTo *int, userID portainer.UserID) ([]int, *httperror.HandlerError) {
stackFolder := strconv.Itoa(int(stack.ID))
note := ""
srcProjectPath := stack.ProjectPath
entryPointContent := payloadContent
if rollbackTo != nil {
target := *rollbackTo
if err := validateRollbackTarget(stack, target); err != nil {
return nil, httperror.BadRequest("Invalid rollback version", err)
}
srcProjectPath = handler.FileService.GetStackProjectPathByVersion(stackFolder, target, "")
content, err := handler.FileService.GetFileContent(srcProjectPath, stack.EntryPoint)
if err != nil {
return nil, httperror.InternalServerError("Unable to read rollback version content from disk", err)
}
entryPointContent = content
note = fmt.Sprintf("rollback from v%d", target)
}
filesContent, err := collectStackFilesContent(handler.FileService, stack, srcProjectPath, entryPointContent)
if err != nil {
return nil, httperror.InternalServerError("Unable to read stack files from disk", err)
}
newVersion := stack.StackFileVersion + 1
if newVersion < 1 {
newVersion = 1
}
projectPath, err := snapshotStackFilesToVersion(handler.FileService, stackFolder, newVersion, filesContent)
if err != nil {
return nil, httperror.InternalServerError("Unable to persist stack file version on disk", err)
}
deployedBy := ""
if user, err := tx.User().Read(userID); err == nil {
deployedBy = user.Username
}
// Record the prior file version so the frontend can display where the stack came from.
stack.PreviousDeploymentInfo = &portainer.StackDeploymentInfo{
FileVersion: stack.StackFileVersion,
}
stack.ProjectPath = projectPath
stack.StackFileVersion = newVersion
stack.Versions = append(stack.Versions, portainer.StackFileVersionInfo{
Version: newVersion,
CreatedAt: time.Now().Unix(),
CreatedBy: deployedBy,
Note: note,
})
// Trim history in memory only; the pruned directories are deleted post-commit.
pruneVersions := applyRetention(stack, maxStackFileVersions)
return pruneVersions, nil
}
func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) ([]int, *httperror.HandlerError) {
// Whether the stack is file-based (non-git) before any git-detach conversion below.
// Only file-based stacks get an on-disk file version history; git stacks keep their
// existing (commit-based) behavior.
wasFileBased := stack.WorkflowID == 0
// Must not be git based stack. stop the auto update job if there is any
if stack.AutoUpdate != nil {
deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler)
@@ -224,7 +342,7 @@ func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.
var payload updateComposeStackPayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
return nil, httperror.BadRequest("Invalid request payload", err)
}
payload.RepullImageAndRedeploy = payload.RepullImageAndRedeploy || payload.PullImage
@@ -234,23 +352,32 @@ func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.
oldWorkflowID := stack.WorkflowID
stack.WorkflowID = 0
if err := tx.Workflow().Delete(oldWorkflowID); err != nil {
return httperror.InternalServerError("Unable to remove git workflow records from database", err)
return nil, httperror.InternalServerError("Unable to remove git workflow records from database", err)
}
}
stackFolder := strconv.Itoa(int(stack.ID))
if _, err := handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)); err != nil {
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
log.Warn().Err(rollbackErr).Msg("rollback stack file error")
}
return httperror.InternalServerError("Unable to persist updated Compose file on disk", err)
}
// Create compose deployment config
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
return nil, httperror.InternalServerError("Unable to retrieve info from request context", err)
}
var pruneVersions []int
stackFolder := strconv.Itoa(int(stack.ID))
if wasFileBased {
var httpErr *httperror.HandlerError
pruneVersions, httpErr = handler.snapshotFileBasedStackVersion(tx, stack, []byte(payload.StackFileContent), payload.RollbackTo, securityContext.UserID)
if httpErr != nil {
return nil, httpErr
}
} else {
if _, err := handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)); err != nil {
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
log.Warn().Err(rollbackErr).Msg("rollback stack file error")
}
return nil, httperror.InternalServerError("Unable to persist updated Compose file on disk", err)
}
}
composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfigTx(tx, securityContext,
@@ -262,11 +389,14 @@ func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.
payload.RepullImageAndRedeploy,
payload.RepullImageAndRedeploy)
if err != nil {
// For a versioned (file-based) stack no {entryPoint}.bak backup exists — the version
// directory is the durable record — so RollbackStackFile is a deliberate no-op here;
// it only performs a real rollback on the legacy git-detach path above.
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
log.Warn().Err(rollbackErr).Msg("rollback stack file error")
}
return httperror.InternalServerError(err.Error(), err)
return nil, httperror.InternalServerError(err.Error(), err)
}
if stack.Option != nil {
@@ -278,6 +408,9 @@ func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.
}
postDeploy := func(ctx context.Context, deployErr error) {
// For a versioned (file-based) stack these backup ops are deliberate no-ops: no
// {entryPoint}.bak was ever written, so a failed deploy simply leaves an unused
// version directory behind (the durable record). They only act on the git-detach path.
if deployErr != nil {
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
log.Warn().Err(rollbackErr).Msg("rollback stack file error")
@@ -292,10 +425,15 @@ func (handler *Handler) updateComposeStack(tx dataservices.DataStoreTx, r *http.
go stackDeploy(handler.DataStore, stack.ID, composeDeploymentConfig, gate, postDeploy)
return nil
return pruneVersions, nil
}
func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) *httperror.HandlerError {
func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, gate *deployGate) ([]int, *httperror.HandlerError) {
// Whether the stack is file-based (non-git) before any git-detach conversion below.
// Only file-based stacks get an on-disk file version history; git stacks keep their
// existing (commit-based) behavior.
wasFileBased := stack.WorkflowID == 0
// Must not be git based stack. stop the auto update job if there is any
if stack.AutoUpdate != nil {
deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler)
@@ -307,7 +445,7 @@ func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Re
var payload updateSwarmStackPayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return httperror.BadRequest("Invalid request payload", err)
return nil, httperror.BadRequest("Invalid request payload", err)
}
payload.RepullImageAndRedeploy = payload.RepullImageAndRedeploy || payload.PullImage
stack.Env = payload.Env
@@ -316,23 +454,32 @@ func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Re
oldWorkflowID := stack.WorkflowID
stack.WorkflowID = 0
if err := tx.Workflow().Delete(oldWorkflowID); err != nil {
return httperror.InternalServerError("Unable to remove git workflow records from database", err)
return nil, httperror.InternalServerError("Unable to remove git workflow records from database", err)
}
}
stackFolder := strconv.Itoa(int(stack.ID))
if _, err := handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)); err != nil {
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
log.Warn().Err(rollbackErr).Msg("rollback stack file error")
}
return httperror.InternalServerError("Unable to persist updated Compose file on disk", err)
}
// Create swarm deployment config
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve info from request context", err)
return nil, httperror.InternalServerError("Unable to retrieve info from request context", err)
}
var pruneVersions []int
stackFolder := strconv.Itoa(int(stack.ID))
if wasFileBased {
var httpErr *httperror.HandlerError
pruneVersions, httpErr = handler.snapshotFileBasedStackVersion(tx, stack, []byte(payload.StackFileContent), payload.RollbackTo, securityContext.UserID)
if httpErr != nil {
return nil, httpErr
}
} else {
if _, err := handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)); err != nil {
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
log.Warn().Err(rollbackErr).Msg("rollback stack file error")
}
return nil, httperror.InternalServerError("Unable to persist updated Compose file on disk", err)
}
}
swarmDeploymentConfig, err := deployments.CreateSwarmStackDeploymentConfigTx(tx, securityContext,
@@ -343,11 +490,14 @@ func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Re
payload.Prune,
payload.RepullImageAndRedeploy)
if err != nil {
// For a versioned (file-based) stack no {entryPoint}.bak backup exists — the version
// directory is the durable record — so RollbackStackFile is a deliberate no-op here;
// it only performs a real rollback on the legacy git-detach path above.
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
log.Warn().Err(rollbackErr).Msg("rollback stack file error")
}
return httperror.InternalServerError(err.Error(), err)
return nil, httperror.InternalServerError(err.Error(), err)
}
if stack.Option != nil {
@@ -359,6 +509,9 @@ func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Re
}
postDeploy := func(ctx context.Context, deployErr error) {
// For a versioned (file-based) stack these backup ops are deliberate no-ops: no
// {entryPoint}.bak was ever written, so a failed deploy simply leaves an unused
// version directory behind (the durable record). They only act on the git-detach path.
if deployErr != nil {
if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil {
log.Warn().Err(rollbackErr).Msg("rollback stack file error")
@@ -373,7 +526,7 @@ func (handler *Handler) updateSwarmStack(tx dataservices.DataStoreTx, r *http.Re
go stackDeploy(handler.DataStore, stack.ID, swarmDeploymentConfig, gate, postDeploy)
return nil
return pruneVersions, nil
}
func stackDeploy(dataStore dataservices.DataStore, stackID portainer.StackID, stackDeploymentConfig deployments.StackDeploymentConfiger, gate *deployGate, postDeploy postDeployFunc) {