feat(tht): wire restore command
This commit is contained in:
@@ -46,6 +46,8 @@ Commands:
|
|||||||
update --check-only Validate the current installation without changing containers.
|
update --check-only Validate the current installation without changing containers.
|
||||||
backup [--output PATH] [--include-secrets --yes] [--drain]
|
backup [--output PATH] [--include-secrets --yes] [--drain]
|
||||||
Create one transactional installation backup.
|
Create one transactional installation backup.
|
||||||
|
restore ARCHIVE --yes [--drain]
|
||||||
|
Restore one validated installation backup transactionally.
|
||||||
sessions migrate --yes
|
sessions migrate --yes
|
||||||
Run only the server session migrator and verify pending=[] and drifted=[].
|
Run only the server session migrator and verify pending=[] and drifted=[].
|
||||||
remove Display exact stopped app container IDs without mutation.
|
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)
|
result, err = runner.Run(ctx, installation.ComposeArgs("config", "--quiet"), nil)
|
||||||
case "backup":
|
case "backup":
|
||||||
return backupCommand(ctx, installation, commandArgs, stdout, stderr)
|
return backupCommand(ctx, installation, commandArgs, stdout, stderr)
|
||||||
|
case "restore":
|
||||||
|
return restoreCommand(ctx, installation, commandArgs, stdout, stderr)
|
||||||
case "doctor":
|
case "doctor":
|
||||||
return doctorCommand(ctx, installation, runner, commandArgs, stdout, stderr)
|
return doctorCommand(ctx, installation, runner, commandArgs, stdout, stderr)
|
||||||
case "pi":
|
case "pi":
|
||||||
@@ -881,6 +885,81 @@ func parseBackupArgs(args []string) (backup.CreateRequest, error) {
|
|||||||
return request, nil
|
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 {
|
func piMutationRequiresLifecycleLock(args []string) bool {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -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) {
|
func TestPiMutationsUseTheSharedInstallationLifecycleLock(t *testing.T) {
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
args []string
|
args []string
|
||||||
|
|||||||
Reference in New Issue
Block a user