diff --git a/tools/thothctl/cmd/thothctl/main.go b/tools/thothctl/cmd/thothctl/main.go index 509c0eb6..a9f3d415 100644 --- a/tools/thothctl/cmd/thothctl/main.go +++ b/tools/thothctl/cmd/thothctl/main.go @@ -41,6 +41,8 @@ Commands: pi check Alias for pi test. pi configure [--provider P --model M --thinking low|medium|high] Select closed backend defaults interactively on a TTY; all flags are required otherwise. + pi restart --yes [--drain] + Recreate only core with the currently selected Pi image and verify readiness. pi update --version V --source build --yes [--drain] Rebuild a pinned Pi version and recreate only core. pi update --version V --source pull --image IMAGE@sha256:DIGEST --yes [--drain] @@ -315,6 +317,21 @@ func piCommand(ctx context.Context, installation config.Installation, runner com } fmt.Fprintf(stdout, "Pi defaults applied and read back. Provider credentials remain only in the host file %s (mode 0600). Never pass credentials to thothctl.\n", authFile) return 0 + case "restart": + request, err := parsePiRestartArgs( + args[1:], + installation.RestartStatePath(), + installation.UpdateStatePath(), + ) + if err != nil { + return commandUsageError(stderr, err.Error()) + } + result, err := pi.Restart(ctx, controlled, request) + if err != nil { + return piFailure(stderr, err, secretValues) + } + fmt.Fprintf(stdout, "Pi core restarted with the existing image; version %s readiness and smoke checks passed.\n", output.Sanitize(result.Version, secretValues)) + return 0 case "update": request, err := parsePiUpdateArgs(args[1:], installation.UpdateStatePath()) if err != nil { @@ -350,8 +367,13 @@ func piCommand(ctx context.Context, installation config.Installation, runner com return 0 } if len(args) == 3 && args[1] == "recover" && args[2] == "--yes" { - statePath := installation.UpdateStatePath() - if err := pi.RecoverMaintenance(ctx, controlled, statePath, true); err != nil { + if err := pi.RecoverLifecycleMaintenance( + ctx, + controlled, + installation.UpdateStatePath(), + installation.RestartStatePath(), + true, + ); err != nil { return piFailure(stderr, err, secretValues) } fmt.Fprintln(stdout, "Pi maintenance recovery verified; stale lifecycle files were removed and admissions are open.") @@ -518,6 +540,30 @@ func parsePiUpdateArgs(args []string, statePath string) (pi.Request, error) { return request, nil } +func parsePiRestartArgs(args []string, restartStatePath, updateStatePath string) (pi.RestartRequest, error) { + request := pi.RestartRequest{StatePath: restartStatePath, UpdateStatePath: updateStatePath} + for len(args) > 0 { + switch args[0] { + case "--yes": + if request.Confirm { + return pi.RestartRequest{}, errors.New("--yes may be supplied once") + } + request.Confirm, args = true, args[1:] + case "--drain": + if request.Drain { + return pi.RestartRequest{}, errors.New("--drain may be supplied once") + } + request.Drain, args = true, args[1:] + default: + return pi.RestartRequest{}, errors.New("unknown pi restart option") + } + } + if !request.Confirm { + return pi.RestartRequest{}, errors.New("pi restart requires --yes") + } + return request, nil +} + func piFailure(stderr io.Writer, err error, secretValues []string) int { code := 1 if errors.Is(err, pi.ErrConfirmationRequired) || errors.Is(err, pi.ErrInvalidRequest) || errors.Is(err, pi.ErrActiveSessions) || errors.Is(err, pi.ErrInterruptedUpdate) { diff --git a/tools/thothctl/cmd/thothctl/main_test.go b/tools/thothctl/cmd/thothctl/main_test.go index a956c662..aa36a43f 100644 --- a/tools/thothctl/cmd/thothctl/main_test.go +++ b/tools/thothctl/cmd/thothctl/main_test.go @@ -84,7 +84,7 @@ func TestLogsRejectsFollowAndOtherArguments(t *testing.T) { } } -func TestUsageDocumentsClosedConfigureUpdateSourcesAndMaintenanceRecovery(t *testing.T) { +func TestUsageDocumentsClosedConfigureUpdateSourcesRestartAndMaintenanceRecovery(t *testing.T) { if strings.Contains(usage, "--follow") { t.Fatal("usage still advertises unbounded log following") } @@ -92,6 +92,8 @@ func TestUsageDocumentsClosedConfigureUpdateSourcesAndMaintenanceRecovery(t *tes "--provider P --model M --thinking low|medium|high", "--source build", "--source pull --image IMAGE@sha256:DIGEST", + "pi restart --yes [--drain]", + "Recreate only core with the currently selected Pi image", "pi maintenance status", "pi maintenance recover --yes", "sessions migrate --yes", @@ -103,6 +105,47 @@ func TestUsageDocumentsClosedConfigureUpdateSourcesAndMaintenanceRecovery(t *tes } } +func TestParsePiRestartArgs(t *testing.T) { + restartPath := "/var/lib/thothctl/restart-state.json" + updatePath := "/var/lib/thothctl/update-state.json" + for _, test := range []struct { + name string + args []string + want pi.RestartRequest + wantErr string + }{ + { + name: "confirmed", + args: []string{"--yes"}, + want: pi.RestartRequest{StatePath: restartPath, UpdateStatePath: updatePath, Confirm: true}, + }, + { + name: "drain", + args: []string{"--yes", "--drain"}, + want: pi.RestartRequest{StatePath: restartPath, UpdateStatePath: updatePath, Confirm: true, Drain: true}, + }, + {name: "duplicate yes", args: []string{"--yes", "--yes"}, wantErr: "--yes may be supplied once"}, + {name: "duplicate drain", args: []string{"--drain", "--drain"}, wantErr: "--drain may be supplied once"}, + {name: "unknown", args: []string{"--force"}, wantErr: "unknown pi restart option"}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := parsePiRestartArgs(test.args, restartPath, updatePath) + if test.wantErr != "" { + if err == nil || err.Error() != test.wantErr { + t.Fatalf("parsePiRestartArgs(%v) error = %v, want %q", test.args, err, test.wantErr) + } + return + } + if err != nil { + t.Fatalf("parsePiRestartArgs(%v) error = %v", test.args, err) + } + if got != test.want { + t.Fatalf("parsePiRestartArgs(%v) = %#v, want %#v", test.args, got, test.want) + } + }) + } +} + func TestRunSessionsMigrateRequiresExplicitConfirmationBeforeDocker(t *testing.T) { fixture := newCLIFixture(t, "") fixture.setProfile(t, "server") @@ -596,6 +639,49 @@ func TestRunPiUpdateRequiresExplicitConfirmationWithoutInvokingDocker(t *testing assertDockerNotInvoked(t, fixture) } +func TestRunPiRestartRequiresExplicitConfirmationWithoutInvokingDocker(t *testing.T) { + fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n") + fixture.setEnvironment(t) + + var stdout, stderr bytes.Buffer + exitCode := run(context.Background(), []string{ + "--installation", fixture.installationPath, "pi", "restart", + }, &stdout, &stderr) + + if exitCode != 2 { + t.Errorf("run() exit code = %d, want 2", exitCode) + } + if !strings.Contains(stderr.String(), "pi restart requires --yes") { + t.Errorf("stderr = %q, want explicit confirmation guidance", stderr.String()) + } + assertDockerNotInvoked(t, fixture) +} + +func TestRunPiRestartSanitizesSuccessOutput(t *testing.T) { + fixture := newCLIFixture(t, "PI_TOKEN_FILE=%s\nTHT_LLM_URL=https://llm.example.invalid\n") + secretPath := filepath.Join(fixture.root, "pi-restart-secret") + if err := os.WriteFile(secretPath, []byte("pi-restart-secret"), 0o600); err != nil { + t.Fatal(err) + } + fixture.setEnvironment(t, secretPath) + t.Setenv("THOTHCTL_FAKE_PI_VERSION", "pi-restart-secret") + + var stdout, stderr bytes.Buffer + exitCode := run(context.Background(), []string{ + "--installation", fixture.installationPath, "pi", "restart", "--yes", + }, &stdout, &stderr) + + if exitCode != 0 { + t.Fatalf("run() exit code = %d, stderr = %s", exitCode, stderr.String()) + } + if !strings.Contains(stdout.String(), "Pi core restarted with the existing image;") { + t.Fatalf("stdout = %q, want restart success", stdout.String()) + } + if strings.Contains(stdout.String()+stderr.String(), "pi-restart-secret") { + t.Fatalf("Pi restart exposed a secret: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } +} + func TestRunPiUpdateRequiresExplicitSourceWithoutInvokingDocker(t *testing.T) { fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n") fixture.setEnvironment(t) @@ -664,6 +750,46 @@ func TestRunPiMaintenanceStatusAndRecoverConfirmationContract(t *testing.T) { assertDockerNotInvoked(t, second) } +func TestRunPiMaintenanceRecoverClearsRestartLifecycleState(t *testing.T) { + fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n") + fixture.setEnvironment(t) + installation, err := config.Load(fixture.installationPath) + if err != nil { + t.Fatal(err) + } + const restartState = `{ + "version": 4, + "transaction": "restart-recovery", + "phase": "preflight", + "target": {"version": "0.80.3", "source": "restart"}, + "previous": { + "id": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "reference": "thothii-core:local", + "mounts": [], + "mount_fingerprint": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "configuration_sha256": "config" + } +}` + if err := os.MkdirAll(filepath.Dir(installation.RestartStatePath()), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(installation.RestartStatePath(), []byte(restartState), 0o600); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + exitCode := run(context.Background(), []string{ + "--installation", fixture.installationPath, "pi", "maintenance", "recover", "--yes", + }, &stdout, &stderr) + + if exitCode != 0 { + t.Fatalf("run() exit code = %d, stderr = %s", exitCode, stderr.String()) + } + if _, err := os.Stat(installation.RestartStatePath()); !os.IsNotExist(err) { + t.Fatalf("restart state still exists: %v", err) + } +} + func TestRunWorkspaceInspectDispatchesThroughTheMaintenanceService(t *testing.T) { fixture := newCLIFixture(t, "SAFE_VALUE=1\n") fixture.setEnvironment(t) @@ -866,13 +992,20 @@ case " $* " in fi printf '%s\n' '{"applied":[],"drifted":[],"pending":[]}' ;; *" ps --format json "*) printf '%s\n' '[{"Service":"core","State":"running","Health":"healthy"},{"Service":"frontend","State":"running","Health":"healthy"}]' ;; + *" ps -q core "*) printf '%s\n' 'core-id' ;; *" image inspect --format {{.Id}} "*) printf '%s\n' "${THOTHCTL_FAKE_IMAGE_ID:-sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb}" ;; - *"io.thothii.pi.version"*) printf '%s\n' '0.80.3' ;; - *"PI_VERSION"*) printf '%s\n' '0.80.3' ;; - *" pi --version "*) printf '%s\n' '0.80.3' ;; + *"inspect --format {{.Image}} core-id"*) printf '%s\n' "${THOTHCTL_FAKE_IMAGE_ID:-sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb}" ;; + *"inspect --format {{json .Mounts}} core-id"*) printf '%s\n' '[{"Type":"volume","Name":"settings","Source":"settings","Destination":"/home/thoth/.pi","RW":true}]' ;; + *"io.thothii.pi.version"*) printf '%s\n' "${THOTHCTL_FAKE_PI_VERSION:-0.80.3}" ;; + *"PI_VERSION"*) printf '%s\n' "${THOTHCTL_FAKE_PI_VERSION:-0.80.3}" ;; + *" pi --version "*) printf '%s\n' "${THOTHCTL_FAKE_PI_VERSION:-0.80.3}" ;; *"/pi-management/options "*) printf '%s\n' '{"providers":["provider"],"models":[{"provider":"provider","id":"model"}],"reasoning":["low","medium","high"]}' ;; *"settings-cli.js --snapshot"*) printf '%s\n' '{"exists":false,"rawBase64":""}' ;; *"/settings "*) printf '%s\n' '{"provider":"provider","model":"model","thinking":"medium"}' ;; + *"/pi-management/test "*) printf '%s\n' '{"ready":true}' ;; + *"/sessions?scope="*) printf '%s\n' '[]' ;; + *"/internal/maintenance/activate "*) printf '%s\n' '{"active":true,"admissions":0}' ;; + *"/internal/maintenance/deactivate "*) printf '%s\n' '{"active":false,"admissions":0}' ;; *"/internal/maintenance/status "*) printf '%s\n' '{"active":true,"admissions":0}' ;; *" logs "*) printf '%s\n' "$THOTHCTL_FAKE_LOG" ;; *" run --rm --no-deps --no-TTY "*" workspace-maintenance "*) printf '%s\n' "$THOTHCTL_FAKE_WORKSPACE_RESULT" ;; @@ -918,6 +1051,7 @@ func (f cliFixture) setEnvContents(t *testing.T, env string) { t.Setenv("THOTHCTL_FAKE_MIGRATION_EXIT", "0") t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", "") t.Setenv("THOTHCTL_FAKE_IMAGE_ID", "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + t.Setenv("THOTHCTL_FAKE_PI_VERSION", "0.80.3") } func (f cliFixture) setProfile(t *testing.T, profile string) {