fix: harden Pi lifecycle recovery
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user