feat(cli): add transactional installation backups

This commit is contained in:
2026-08-16 00:55:22 +02:00
parent 3e0c864c85
commit 11fbf0a138
8 changed files with 2088 additions and 0 deletions
+105
View File
@@ -14,9 +14,11 @@ import (
"strconv"
"strings"
"github.com/aritmolab/thothii/tools/tht/internal/backup"
"github.com/aritmolab/thothii/tools/tht/internal/compose"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/doctor"
"github.com/aritmolab/thothii/tools/tht/internal/lifecycle"
"github.com/aritmolab/thothii/tools/tht/internal/output"
"github.com/aritmolab/thothii/tools/tht/internal/pi"
"github.com/aritmolab/thothii/tools/tht/internal/project"
@@ -42,6 +44,8 @@ Commands:
start [--build] Start the installation; --build builds current-checkout images first.
stop Stop the installation.
update --check-only Validate the current installation without changing containers.
backup [--output PATH] [--include-secrets --yes] [--drain]
Create one transactional installation backup.
sessions migrate --yes
Run only the server session migrator and verify pending=[] and drifted=[].
remove Display exact stopped app container IDs without mutation.
@@ -169,6 +173,8 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return commandUsageError(stderr, "update currently requires --check-only")
}
result, err = runner.Run(ctx, installation.ComposeArgs("config", "--quiet"), nil)
case "backup":
return backupCommand(ctx, installation, commandArgs, stdout, stderr)
case "doctor":
return doctorCommand(ctx, installation, runner, commandArgs, stdout, stderr)
case "pi":
@@ -419,6 +425,13 @@ func piCommand(ctx context.Context, installation config.Installation, runner com
if len(args) == 0 {
return commandUsageError(stderr, "pi requires a subcommand")
}
if piMutationRequiresLifecycleLock(args) {
lock, err := lifecycle.Acquire(installation)
if err != nil {
return lifecycleFailure(stderr, err, secretValues)
}
defer func() { _ = lock.Release() }()
}
controlled := compose.InstallationRunner{Installation: installation, Runner: runner}
switch args[0] {
case "status":
@@ -790,6 +803,98 @@ func logsArgs(args []string) ([]string, error) {
return nil, errors.New("logs does not accept arguments; use bounded snapshots")
}
type backupExecutor func(context.Context, config.Installation, backup.CreateRequest) (backup.Result, error)
func backupCommand(ctx context.Context, installation config.Installation, args []string, stdout, stderr io.Writer) int {
return backupCommandWith(ctx, installation, args, backup.Create, stdout, stderr)
}
func backupCommandWith(
ctx context.Context,
installation config.Installation,
args []string,
execute backupExecutor,
stdout, stderr io.Writer,
) int {
request, err := parseBackupArgs(args)
if err != nil {
return commandUsageError(stderr, err.Error())
}
if execute == nil {
return commandUsageError(stderr, "backup executor is unavailable")
}
result, err := execute(ctx, installation, request)
if err != nil {
message := output.Sanitize(err.Error(), nil)
fmt.Fprintf(stderr, "tht: %s\n", message)
if errors.Is(err, backup.ErrActiveSessions) || errors.Is(err, backup.ErrConfirmationRequired) || errors.Is(err, lifecycle.ErrLocked) {
return 2
}
return 1
}
fmt.Fprintf(stdout, "Backup created: %s\n", result.Path)
if result.Warning != "" {
fmt.Fprintln(stderr, output.Sanitize(result.Warning, nil))
}
return 0
}
func parseBackupArgs(args []string) (backup.CreateRequest, error) {
request := backup.CreateRequest{}
for len(args) > 0 {
option := args[0]
args = args[1:]
switch option {
case "--output":
if request.Output != "" {
return backup.CreateRequest{}, errors.New("--output may be supplied once")
}
if len(args) == 0 || args[0] == "" {
return backup.CreateRequest{}, errors.New("--output requires a path")
}
request.Output, args = args[0], args[1:]
case "--include-secrets":
if request.IncludeSecrets {
return backup.CreateRequest{}, errors.New("--include-secrets may be supplied once")
}
request.IncludeSecrets = true
case "--yes":
if request.Confirm {
return backup.CreateRequest{}, errors.New("--yes may be supplied once")
}
request.Confirm = true
case "--drain":
if request.Drain {
return backup.CreateRequest{}, errors.New("--drain may be supplied once")
}
request.Drain = true
default:
return backup.CreateRequest{}, fmt.Errorf("unknown backup option %q", option)
}
}
if request.IncludeSecrets && !request.Confirm {
return backup.CreateRequest{}, errors.New("--include-secrets requires --yes")
}
if request.Confirm && !request.IncludeSecrets {
return backup.CreateRequest{}, errors.New("--yes is only valid with --include-secrets")
}
return request, nil
}
func piMutationRequiresLifecycleLock(args []string) bool {
if len(args) == 0 {
return false
}
switch args[0] {
case "configure", "restart", "update", "rollback":
return true
case "maintenance":
return len(args) > 1 && args[1] == "recover"
default:
return false
}
}
func commandUsageError(stderr io.Writer, message string) int {
fmt.Fprintf(stderr, "tht: %s\n", message)
return 2