diff --git a/tools/tht/cmd/tht/main.go b/tools/tht/cmd/tht/main.go index de80d5ca..8ccd5741 100644 --- a/tools/tht/cmd/tht/main.go +++ b/tools/tht/cmd/tht/main.go @@ -46,6 +46,8 @@ Commands: update --check-only Validate the current installation without changing containers. backup [--output PATH] [--include-secrets --yes] [--drain] Create one transactional installation backup. + restore ARCHIVE --yes [--drain] + Restore one validated installation backup transactionally. sessions migrate --yes Run only the server session migrator and verify pending=[] and drifted=[]. remove Display exact stopped app container IDs without mutation. @@ -175,6 +177,8 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { result, err = runner.Run(ctx, installation.ComposeArgs("config", "--quiet"), nil) case "backup": return backupCommand(ctx, installation, commandArgs, stdout, stderr) + case "restore": + return restoreCommand(ctx, installation, commandArgs, stdout, stderr) case "doctor": return doctorCommand(ctx, installation, runner, commandArgs, stdout, stderr) case "pi": @@ -881,6 +885,81 @@ func parseBackupArgs(args []string) (backup.CreateRequest, error) { return request, nil } +type restoreExecutor func(context.Context, config.Installation, backup.RestoreRequest) (backup.RestoreResult, error) + +func restoreCommand(ctx context.Context, installation config.Installation, args []string, stdout, stderr io.Writer) int { + return restoreCommandWith(ctx, installation, args, backup.Restore, stdout, stderr) +} + +func restoreCommandWith( + ctx context.Context, + installation config.Installation, + args []string, + execute restoreExecutor, + stdout, stderr io.Writer, +) int { + request, err := parseRestoreArgs(args) + if err != nil { + return commandUsageError(stderr, err.Error()) + } + if execute == nil { + return commandUsageError(stderr, "restore executor is unavailable") + } + result, err := execute(ctx, installation, request) + if result.Checkpoint != "" { + checkpoint := output.Sanitize(result.Checkpoint, nil) + if err != nil { + fmt.Fprintf(stderr, "Recovery checkpoint: %s\n", checkpoint) + } else { + fmt.Fprintf(stdout, "Recovery checkpoint: %s\n", checkpoint) + } + } + if err != nil { + fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), nil)) + if errors.Is(err, backup.ErrRestoreConfirmationRequired) || errors.Is(err, backup.ErrActiveSessions) { + return 2 + } + return 1 + } + fmt.Fprintln(stdout, "Restore completed and verified.") + return 0 +} + +func parseRestoreArgs(args []string) (backup.RestoreRequest, error) { + request := backup.RestoreRequest{} + for len(args) > 0 { + argument := args[0] + args = args[1:] + switch argument { + case "--yes": + if request.Confirm { + return backup.RestoreRequest{}, errors.New("--yes may be supplied once") + } + request.Confirm = true + case "--drain": + if request.Drain { + return backup.RestoreRequest{}, errors.New("--drain may be supplied once") + } + request.Drain = true + default: + if strings.HasPrefix(argument, "-") { + return backup.RestoreRequest{}, fmt.Errorf("unknown restore option %q", argument) + } + if request.Archive != "" { + return backup.RestoreRequest{}, errors.New("restore accepts exactly one archive") + } + request.Archive = argument + } + } + if request.Archive == "" { + return backup.RestoreRequest{}, errors.New("restore archive is required") + } + if !request.Confirm { + return backup.RestoreRequest{}, errors.New("restore requires --yes") + } + return request, nil +} + func piMutationRequiresLifecycleLock(args []string) bool { if len(args) == 0 { return false diff --git a/tools/tht/cmd/tht/main_test.go b/tools/tht/cmd/tht/main_test.go index ca16fced..ddba657b 100644 --- a/tools/tht/cmd/tht/main_test.go +++ b/tools/tht/cmd/tht/main_test.go @@ -73,6 +73,82 @@ func TestBackupCommandMapsSafetyRefusalsToUsageExit(t *testing.T) { } } +func TestRestoreCommandParsesArchiveConfirmationAndDrain(t *testing.T) { + request, err := parseRestoreArgs([]string{"restore.zip", "--yes", "--drain"}) + if err != nil { + t.Fatal(err) + } + if request.Archive != "restore.zip" || !request.Confirm || !request.Drain { + t.Fatalf("restore request = %#v", request) + } + + for _, args := range [][]string{ + {}, + {"restore.zip"}, + {"--yes"}, + {"restore.zip", "--unknown", "--yes"}, + {"restore.zip", "--yes", "extra"}, + } { + if _, err := parseRestoreArgs(args); err == nil { + t.Errorf("parseRestoreArgs(%v) succeeded", args) + } + } +} + +func TestRestoreCommandDispatchesAndPrintsCheckpointOnlyWhenPresent(t *testing.T) { + installation := config.Installation{Path: "/tmp/thothii-installation.yaml"} + var received backup.RestoreRequest + executor := func(_ context.Context, got config.Installation, request backup.RestoreRequest) (backup.RestoreResult, error) { + if got.Path != installation.Path { + t.Fatalf("installation = %#v", got) + } + received = request + return backup.RestoreResult{Checkpoint: "/tmp/recovery.zip", Restarted: true, Verified: true}, nil + } + var stdout, stderr bytes.Buffer + code := restoreCommandWith(context.Background(), installation, []string{"restore.zip", "--yes", "--drain"}, executor, &stdout, &stderr) + if code != 0 { + t.Fatalf("restore exit = %d, stderr = %s", code, stderr.String()) + } + if received.Archive != "restore.zip" || !received.Confirm || !received.Drain { + t.Fatalf("request = %#v", received) + } + if !strings.Contains(stdout.String(), "Recovery checkpoint: /tmp/recovery.zip") || stderr.Len() != 0 { + t.Fatalf("stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + + stdout.Reset() + code = restoreCommandWith(context.Background(), installation, []string{"restore.zip", "--yes"}, func(context.Context, config.Installation, backup.RestoreRequest) (backup.RestoreResult, error) { + return backup.RestoreResult{}, nil + }, &stdout, &stderr) + if code != 0 || strings.Contains(stdout.String(), "Recovery checkpoint:") { + t.Fatalf("empty checkpoint output: code=%d stdout=%q", code, stdout.String()) + } +} + +func TestRestoreCommandMapsSafetyRefusalsAndRetainsRecoveryText(t *testing.T) { + for _, test := range []struct { + name string + err error + }{ + {name: "confirmation", err: backup.ErrRestoreConfirmationRequired}, + {name: "active sessions", err: backup.ErrActiveSessions}, + } { + t.Run(test.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + code := restoreCommandWith(context.Background(), config.Installation{}, []string{"restore.zip", "--yes"}, func(context.Context, config.Installation, backup.RestoreRequest) (backup.RestoreResult, error) { + return backup.RestoreResult{Checkpoint: "/tmp/recovery.zip"}, test.err + }, &stdout, &stderr) + if code != 2 { + t.Fatalf("restore exit = %d, want usage exit 2", code) + } + if !strings.Contains(stderr.String(), "Recovery checkpoint: /tmp/recovery.zip") { + t.Fatalf("stderr=%q, want checkpoint", stderr.String()) + } + }) + } +} + func TestPiMutationsUseTheSharedInstallationLifecycleLock(t *testing.T) { for _, test := range []struct { args []string