feat(cli): add transactional installation backups
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user