feat(thothctl): expose Pi restart command
This commit is contained in:
@@ -41,6 +41,8 @@ Commands:
|
|||||||
pi check Alias for pi test.
|
pi check Alias for pi test.
|
||||||
pi configure [--provider P --model M --thinking low|medium|high]
|
pi configure [--provider P --model M --thinking low|medium|high]
|
||||||
Select closed backend defaults interactively on a TTY; all flags are required otherwise.
|
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]
|
pi update --version V --source build --yes [--drain]
|
||||||
Rebuild a pinned Pi version and recreate only core.
|
Rebuild a pinned Pi version and recreate only core.
|
||||||
pi update --version V --source pull --image IMAGE@sha256:DIGEST --yes [--drain]
|
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)
|
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
|
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":
|
case "update":
|
||||||
request, err := parsePiUpdateArgs(args[1:], installation.UpdateStatePath())
|
request, err := parsePiUpdateArgs(args[1:], installation.UpdateStatePath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -350,8 +367,13 @@ func piCommand(ctx context.Context, installation config.Installation, runner com
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
if len(args) == 3 && args[1] == "recover" && args[2] == "--yes" {
|
if len(args) == 3 && args[1] == "recover" && args[2] == "--yes" {
|
||||||
statePath := installation.UpdateStatePath()
|
if err := pi.RecoverLifecycleMaintenance(
|
||||||
if err := pi.RecoverMaintenance(ctx, controlled, statePath, true); err != nil {
|
ctx,
|
||||||
|
controlled,
|
||||||
|
installation.UpdateStatePath(),
|
||||||
|
installation.RestartStatePath(),
|
||||||
|
true,
|
||||||
|
); err != nil {
|
||||||
return piFailure(stderr, err, secretValues)
|
return piFailure(stderr, err, secretValues)
|
||||||
}
|
}
|
||||||
fmt.Fprintln(stdout, "Pi maintenance recovery verified; stale lifecycle files were removed and admissions are open.")
|
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
|
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 {
|
func piFailure(stderr io.Writer, err error, secretValues []string) int {
|
||||||
code := 1
|
code := 1
|
||||||
if errors.Is(err, pi.ErrConfirmationRequired) || errors.Is(err, pi.ErrInvalidRequest) || errors.Is(err, pi.ErrActiveSessions) || errors.Is(err, pi.ErrInterruptedUpdate) {
|
if errors.Is(err, pi.ErrConfirmationRequired) || errors.Is(err, pi.ErrInvalidRequest) || errors.Is(err, pi.ErrActiveSessions) || errors.Is(err, pi.ErrInterruptedUpdate) {
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ func TestLogsRejectsFollowAndOtherArguments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUsageDocumentsClosedConfigureUpdateSourcesAndMaintenanceRecovery(t *testing.T) {
|
func TestUsageDocumentsClosedConfigureUpdateSourcesRestartAndMaintenanceRecovery(t *testing.T) {
|
||||||
if strings.Contains(usage, "--follow") {
|
if strings.Contains(usage, "--follow") {
|
||||||
t.Fatal("usage still advertises unbounded log following")
|
t.Fatal("usage still advertises unbounded log following")
|
||||||
}
|
}
|
||||||
@@ -92,6 +92,8 @@ func TestUsageDocumentsClosedConfigureUpdateSourcesAndMaintenanceRecovery(t *tes
|
|||||||
"--provider P --model M --thinking low|medium|high",
|
"--provider P --model M --thinking low|medium|high",
|
||||||
"--source build",
|
"--source build",
|
||||||
"--source pull --image IMAGE@sha256:DIGEST",
|
"--source pull --image IMAGE@sha256:DIGEST",
|
||||||
|
"pi restart --yes [--drain]",
|
||||||
|
"Recreate only core with the currently selected Pi image",
|
||||||
"pi maintenance status",
|
"pi maintenance status",
|
||||||
"pi maintenance recover --yes",
|
"pi maintenance recover --yes",
|
||||||
"sessions migrate --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) {
|
func TestRunSessionsMigrateRequiresExplicitConfirmationBeforeDocker(t *testing.T) {
|
||||||
fixture := newCLIFixture(t, "")
|
fixture := newCLIFixture(t, "")
|
||||||
fixture.setProfile(t, "server")
|
fixture.setProfile(t, "server")
|
||||||
@@ -596,6 +639,49 @@ func TestRunPiUpdateRequiresExplicitConfirmationWithoutInvokingDocker(t *testing
|
|||||||
assertDockerNotInvoked(t, fixture)
|
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) {
|
func TestRunPiUpdateRequiresExplicitSourceWithoutInvokingDocker(t *testing.T) {
|
||||||
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
|
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
|
||||||
fixture.setEnvironment(t)
|
fixture.setEnvironment(t)
|
||||||
@@ -664,6 +750,46 @@ func TestRunPiMaintenanceStatusAndRecoverConfirmationContract(t *testing.T) {
|
|||||||
assertDockerNotInvoked(t, second)
|
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) {
|
func TestRunWorkspaceInspectDispatchesThroughTheMaintenanceService(t *testing.T) {
|
||||||
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
||||||
fixture.setEnvironment(t)
|
fixture.setEnvironment(t)
|
||||||
@@ -866,13 +992,20 @@ case " $* " in
|
|||||||
fi
|
fi
|
||||||
printf '%s\n' '{"applied":[],"drifted":[],"pending":[]}' ;;
|
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 --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}" ;;
|
*" image inspect --format {{.Id}} "*) printf '%s\n' "${THOTHCTL_FAKE_IMAGE_ID:-sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb}" ;;
|
||||||
*"io.thothii.pi.version"*) printf '%s\n' '0.80.3' ;;
|
*"inspect --format {{.Image}} core-id"*) printf '%s\n' "${THOTHCTL_FAKE_IMAGE_ID:-sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb}" ;;
|
||||||
*"PI_VERSION"*) printf '%s\n' '0.80.3' ;;
|
*"inspect --format {{json .Mounts}} core-id"*) printf '%s\n' '[{"Type":"volume","Name":"settings","Source":"settings","Destination":"/home/thoth/.pi","RW":true}]' ;;
|
||||||
*" pi --version "*) printf '%s\n' '0.80.3' ;;
|
*"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"]}' ;;
|
*"/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-cli.js --snapshot"*) printf '%s\n' '{"exists":false,"rawBase64":""}' ;;
|
||||||
*"/settings "*) printf '%s\n' '{"provider":"provider","model":"model","thinking":"medium"}' ;;
|
*"/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}' ;;
|
*"/internal/maintenance/status "*) printf '%s\n' '{"active":true,"admissions":0}' ;;
|
||||||
*" logs "*) printf '%s\n' "$THOTHCTL_FAKE_LOG" ;;
|
*" logs "*) printf '%s\n' "$THOTHCTL_FAKE_LOG" ;;
|
||||||
*" run --rm --no-deps --no-TTY "*" workspace-maintenance "*) printf '%s\n' "$THOTHCTL_FAKE_WORKSPACE_RESULT" ;;
|
*" 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_MIGRATION_EXIT", "0")
|
||||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", "")
|
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", "")
|
||||||
t.Setenv("THOTHCTL_FAKE_IMAGE_ID", "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
|
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) {
|
func (f cliFixture) setProfile(t *testing.T, profile string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user