fix: harden Pi lifecycle recovery

This commit is contained in:
2026-08-04 20:28:09 +02:00
parent 5b3ce93e31
commit 5ba2821a1b
24 changed files with 1764 additions and 384 deletions
+147
View File
@@ -4,15 +4,91 @@ import (
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/aritmolab/thothii/tools/thothctl/internal/compose"
"github.com/aritmolab/thothii/tools/thothctl/internal/pi"
"github.com/aritmolab/thothii/tools/thothctl/internal/testsupport"
)
func TestResolvePiConfigureUsesNumberedClosedChoicesOnlyForTTY(t *testing.T) {
runner := &wizardRunner{}
var prompt bytes.Buffer
defaults, err := resolvePiConfigure(
context.Background(), runner, nil, strings.NewReader("2\n1\n3\n"), &prompt, true,
)
if err != nil {
t.Fatal(err)
}
want := pi.Defaults{Provider: "zai", Model: "glm-5.2", Thinking: "high"}
if defaults != want {
t.Fatalf("defaults = %#v", defaults)
}
for _, expected := range []string{"1) deepseek", "2) zai", "1) glm-5.2", "3) high"} {
if !strings.Contains(prompt.String(), expected) {
t.Errorf("prompt %q missing %q", prompt.String(), expected)
}
}
}
func TestResolvePiConfigureRequiresExplicitFlagsWithoutTTY(t *testing.T) {
runner := &wizardRunner{}
_, err := resolvePiConfigure(context.Background(), runner, nil, strings.NewReader("1\n1\n1\n"), io.Discard, false)
if err == nil || !strings.Contains(err.Error(), "non-interactive") {
t.Fatalf("resolvePiConfigure() error = %v, want explicit non-interactive guidance", err)
}
if len(runner.calls) != 0 {
t.Fatalf("Docker calls = %v, want none", runner.calls)
}
}
func TestPiLifecycleContractErrorsExitTwo(t *testing.T) {
for _, lifecycleErr := range []error{pi.ErrActiveSessions, pi.ErrInterruptedUpdate} {
var stderr bytes.Buffer
if code := piFailure(&stderr, lifecycleErr, nil); code != 2 {
t.Errorf("piFailure(%v) = %d, want 2", lifecycleErr, code)
}
}
}
func TestLogsRejectsFollowAndOtherArguments(t *testing.T) {
if _, err := logsArgs([]string{"--follow"}); err == nil {
t.Fatal("logsArgs(--follow) error = nil, want bounded-log rejection")
}
if got, err := logsArgs(nil); err != nil || strings.Join(got, " ") != "logs --tail 200" {
t.Fatalf("logsArgs(nil) = %v, %v", got, err)
}
}
func TestUsageDocumentsClosedConfigureUpdateSourcesAndMaintenanceRecovery(t *testing.T) {
if strings.Contains(usage, "--follow") {
t.Fatal("usage still advertises unbounded log following")
}
for _, required := range []string{
"--provider P --model M --thinking low|medium|high",
"--source build",
"--source pull --image IMAGE@sha256:DIGEST",
"pi maintenance status",
"pi maintenance recover --yes",
} {
if !strings.Contains(usage, required) {
t.Errorf("usage missing %q", required)
}
}
}
type wizardRunner struct{ calls []string }
func (r *wizardRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
r.calls = append(r.calls, strings.Join(args, " "))
return compose.Result{Stdout: `{"models":[{"provider":"deepseek","id":"deepseek-v4"},{"provider":"zai","id":"glm-5.2"}]}`}, nil
}
func TestRunLogsRedactsAnUnlabelledDeclaredSecret(t *testing.T) {
fixture := newCLIFixture(t, "UNLABELLED_SECRET_FILE=%s\n")
secretPath := filepath.Join(fixture.root, "operator-secret")
@@ -379,6 +455,74 @@ func TestRunPiUpdateRequiresExplicitConfirmationWithoutInvokingDocker(t *testing
assertDockerNotInvoked(t, fixture)
}
func TestRunPiUpdateRequiresExplicitSourceWithoutInvokingDocker(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", "update", "--version", "0.81.0", "--yes",
}, &stdout, &stderr)
if exitCode != 2 {
t.Errorf("run() exit code = %d, want 2", exitCode)
}
if !strings.Contains(stderr.String(), "requires explicit --source build or pull") {
t.Errorf("stderr = %q, want source guidance", stderr.String())
}
assertDockerNotInvoked(t, fixture)
}
func TestRunPiConfigureReportsTheActualHostAuthFile(t *testing.T) {
fixture := newCLIFixture(t, "")
authFile := filepath.Join(fixture.root, "pi-auth.json")
if err := os.WriteFile(authFile, []byte(`{"provider":"credential"}`), 0o600); err != nil {
t.Fatal(err)
}
fixture.setEnvContents(t, "THT_LLM_URL=https://llm.example.invalid\nPI_AUTH_FILE="+authFile+"\n")
var stdout, stderr bytes.Buffer
exitCode := run(context.Background(), []string{
"--installation", fixture.installationPath, "pi", "configure",
"--provider", "provider", "--model", "model", "--thinking", "medium",
}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("run() exit = %d, stderr=%s", exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), authFile) {
t.Fatalf("stdout = %q, want host auth path", stdout.String())
}
if strings.Contains(stdout.String(), "/home/thoth/.pi") {
t.Fatalf("stdout exposed container-only auth path: %q", stdout.String())
}
}
func TestRunPiMaintenanceStatusAndRecoverConfirmationContract(t *testing.T) {
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
fixture.setEnvironment(t)
var stdout, stderr bytes.Buffer
if code := run(context.Background(), []string{
"--installation", fixture.installationPath, "pi", "maintenance", "status",
}, &stdout, &stderr); code != 0 {
t.Fatalf("maintenance status exit = %d, stderr = %s", code, stderr.String())
}
if stdout.String() != "Pi maintenance active: true (admissions: 0)\n" {
t.Fatalf("maintenance status output = %q", stdout.String())
}
second := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
second.setEnvironment(t)
stdout.Reset()
stderr.Reset()
if code := run(context.Background(), []string{
"--installation", second.installationPath, "pi", "maintenance", "recover",
}, &stdout, &stderr); code != 2 {
t.Fatalf("maintenance recover without --yes exit = %d, want 2", code)
}
assertDockerNotInvoked(t, second)
}
func TestRunPiStatusPreservesDockerExitCodeAndRedactsDiagnostics(t *testing.T) {
fixture := newCLIFixture(t, "PI_TOKEN_FILE=%s\n")
secretPath := filepath.Join(fixture.root, "pi-secret")
@@ -448,6 +592,9 @@ case " $* " in
*" config --format json "*) printf '%s\n' '{"volumes":{"settings":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}' ;;
*" ps --format json "*) printf '%s\n' '[{"Service":"core","State":"running","Health":"healthy"},{"Service":"frontend","State":"running","Health":"healthy"}]' ;;
*" pi --version "*) printf '%s\n' '0.80.3' ;;
*"/models "*) printf '%s\n' '{"models":[{"provider":"provider","id":"model"}]}' ;;
*"/settings "*) printf '%s\n' '{"provider":"provider","model":"model","thinking":"medium"}' ;;
*"/internal/maintenance/status "*) printf '%s\n' '{"active":true,"admissions":0}' ;;
*" logs "*) printf '%s\n' "$THOTHCTL_FAKE_LOG" ;;
esac
if [ "${THOTHCTL_FAKE_FAIL_ON:-}" = "version" ]; then