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
+75
View File
@@ -12,6 +12,7 @@ import (
"strings"
"testing"
"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"
@@ -20,6 +21,80 @@ import (
"github.com/aritmolab/thothii/tools/tht/internal/testsupport"
)
func TestBackupCommandParsesSafeTransactionalOptions(t *testing.T) {
request, err := parseBackupArgs([]string{"--output", "backup.zip", "--include-secrets", "--yes", "--drain"})
if err != nil {
t.Fatal(err)
}
if request.Output != "backup.zip" || !request.IncludeSecrets || !request.Confirm || !request.Drain {
t.Fatalf("backup request = %#v", request)
}
for _, args := range [][]string{
{"--include-secrets"},
{"--yes"},
{"--output"},
{"--drain", "--drain"},
{"--unknown"},
} {
if _, err := parseBackupArgs(args); err == nil {
t.Errorf("parseBackupArgs(%v) succeeded", args)
}
}
}
func TestBackupCommandDispatchesWithoutDockerAndPrintsCustodyWarning(t *testing.T) {
installation := config.Installation{Path: "/tmp/thothii-installation.yaml"}
var received backup.CreateRequest
executor := func(_ context.Context, got config.Installation, request backup.CreateRequest) (backup.Result, error) {
if got.Path != installation.Path {
t.Fatalf("installation = %#v", got)
}
received = request
return backup.Result{Path: "/tmp/backup.zip", Warning: "archive contains external secret files; protect its custody"}, nil
}
var stdout, stderr bytes.Buffer
code := backupCommandWith(context.Background(), installation, []string{"--include-secrets", "--yes"}, executor, &stdout, &stderr)
if code != 0 {
t.Fatalf("backup exit = %d, stderr = %s", code, stderr.String())
}
if !received.IncludeSecrets || !received.Confirm || !strings.Contains(stdout.String(), "/tmp/backup.zip") || !strings.Contains(stderr.String(), "protect its custody") {
t.Fatalf("request=%#v stdout=%q stderr=%q", received, stdout.String(), stderr.String())
}
}
func TestBackupCommandMapsSafetyRefusalsToUsageExit(t *testing.T) {
executor := func(context.Context, config.Installation, backup.CreateRequest) (backup.Result, error) {
return backup.Result{}, backup.ErrActiveSessions
}
var stdout, stderr bytes.Buffer
if code := backupCommandWith(context.Background(), config.Installation{}, nil, executor, &stdout, &stderr); code != 2 {
t.Fatalf("backup safety refusal exit = %d, stderr = %q", code, stderr.String())
}
}
func TestPiMutationsUseTheSharedInstallationLifecycleLock(t *testing.T) {
for _, test := range []struct {
args []string
want bool
}{
{args: []string{"status"}, want: false},
{args: []string{"doctor"}, want: false},
{args: []string{"test"}, want: false},
{args: []string{"logs"}, want: false},
{args: []string{"configure"}, want: true},
{args: []string{"restart"}, want: true},
{args: []string{"update"}, want: true},
{args: []string{"rollback"}, want: true},
{args: []string{"maintenance", "recover"}, want: true},
{args: []string{"maintenance", "status"}, want: false},
} {
if got := piMutationRequiresLifecycleLock(test.args); got != test.want {
t.Errorf("piMutationRequiresLifecycleLock(%v) = %v, want %v", test.args, got, test.want)
}
}
}
func TestInstallationRunnerMapsProfileToSessionInventoryScope(t *testing.T) {
for _, test := range []struct {
profile string