197 lines
7.4 KiB
Go
197 lines
7.4 KiB
Go
package doctor
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
|
)
|
|
|
|
// Catches treating an unavailable Docker executable as a successful diagnosis.
|
|
func TestRunReportsUnavailableDockerWithoutReturningAnExecutionError(t *testing.T) {
|
|
installation := doctorInstallation(t, "")
|
|
runner := &doctorRunner{dockerUnavailable: true}
|
|
|
|
report, err := Run(context.Background(), installation, runner)
|
|
if err != nil {
|
|
t.Fatalf("Run() error = %v, want report", err)
|
|
}
|
|
if report.OK || checkStatus(report, "docker") != "failed" {
|
|
t.Fatalf("Run() report = %#v, want failed Docker check", report)
|
|
}
|
|
}
|
|
|
|
// Catches attempts to run in-container diagnostics when core is not running.
|
|
func TestRunSkipsContainerDiagnosticsWhenCoreIsStopped(t *testing.T) {
|
|
installation := doctorInstallation(t, "")
|
|
runner := &doctorRunner{services: stoppedServices}
|
|
|
|
report, err := Run(context.Background(), installation, runner)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if report.OK || checkStatus(report, "workflow") != "skipped" || checkStatus(report, "pi") != "skipped" {
|
|
t.Fatalf("Run() report = %#v, want stopped-core skips", report)
|
|
}
|
|
if strings.Contains(strings.Join(runner.calls, "\n"), " exec -T core ") {
|
|
t.Fatalf("Run() invoked a container diagnostic while core was stopped: %v", runner.calls)
|
|
}
|
|
}
|
|
|
|
// Catches host-Python diagnostics or omission of workflow/Pi checks once core is healthy.
|
|
func TestRunUsesOnlyContainerLocalWorkflowAndPiDiagnosticsWhenCoreRuns(t *testing.T) {
|
|
installation := doctorInstallation(t, "")
|
|
runner := &doctorRunner{services: healthyServices}
|
|
|
|
report, err := Run(context.Background(), installation, runner)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !report.OK || checkStatus(report, "workflow") != "passed" || checkStatus(report, "pi") != "passed" {
|
|
t.Fatalf("Run() report = %#v, want successful container diagnostics", report)
|
|
}
|
|
calls := strings.Join(runner.calls, "\n")
|
|
if !strings.Contains(calls, "exec -T core tht doctor --json") {
|
|
t.Fatalf("Run() calls = %s, want core-local workflow doctor", calls)
|
|
}
|
|
if strings.Contains(calls, ".venv") || strings.Contains(calls, "python") {
|
|
t.Fatalf("Run() calls = %s, must not require host Python", calls)
|
|
}
|
|
}
|
|
|
|
// Catches a workflow failure leaking a credential from a declared secret file into a report.
|
|
func TestRunRedactsWorkflowDiagnosticFailures(t *testing.T) {
|
|
installation := doctorInstallation(t, "WORKFLOW_TOKEN_FILE=%s\n")
|
|
secretPath := filepath.Join(filepath.Dir(installation.EnvFile), "workflow-token")
|
|
if err := os.WriteFile(secretPath, []byte("workflow-secret-value"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
contents := "WORKFLOW_TOKEN_FILE=" + secretPath + "\n"
|
|
if err := os.WriteFile(installation.EnvFile, []byte(contents), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runner := &doctorRunner{services: healthyServices, workflowFailure: "workflow-secret-value"}
|
|
|
|
report, err := Run(context.Background(), installation, runner)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if report.OK || checkStatus(report, "workflow") != "failed" {
|
|
t.Fatalf("Run() report = %#v, want failed workflow check", report)
|
|
}
|
|
if strings.Contains(reportText(report), "workflow-secret-value") {
|
|
t.Fatalf("Run() report exposed a secret: %#v", report)
|
|
}
|
|
}
|
|
|
|
func doctorInstallation(t *testing.T, _ string) config.Installation {
|
|
t.Helper()
|
|
base, err := filepath.EvalSymlinks(os.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
root, err := os.MkdirTemp(base, "tht-doctor-")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.RemoveAll(root) })
|
|
project := filepath.Join(root, "project")
|
|
for _, path := range []string{project, filepath.Join(project, "deploy"), filepath.Join(project, "docker")} {
|
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
for _, path := range []string{filepath.Join(project, "compose.yaml"), filepath.Join(project, "deploy", "compose.local.yaml"), filepath.Join(project, "docker", "core.Dockerfile")} {
|
|
if err := os.WriteFile(path, []byte("services: {}\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
envFile := filepath.Join(root, "operator.env")
|
|
if err := os.WriteFile(envFile, []byte("SAFE_VALUE=1\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return config.Installation{Path: filepath.Join(root, "thothii-installation.yaml"), Profile: "local", ProjectDirectory: project, EnvFile: envFile}
|
|
}
|
|
|
|
type doctorRunner struct {
|
|
calls []string
|
|
dockerUnavailable bool
|
|
services string
|
|
workflowFailure string
|
|
}
|
|
|
|
func (r *doctorRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
|
|
call := strings.Join(args, " ")
|
|
r.calls = append(r.calls, call)
|
|
if r.dockerUnavailable {
|
|
return compose.Result{ExitCode: 127}, errors.New("docker unavailable")
|
|
}
|
|
switch {
|
|
case strings.Contains(call, "version --format {{.Client.Version}}"):
|
|
return compose.Result{Stdout: "26.0.0\n"}, nil
|
|
case strings.Contains(call, "compose version --short"):
|
|
return compose.Result{Stdout: "v2.30.0\n"}, nil
|
|
case strings.Contains(call, "config --quiet"):
|
|
return compose.Result{}, nil
|
|
case strings.Contains(call, "config --format json"):
|
|
return compose.Result{Stdout: renderedConfig}, nil
|
|
case strings.Contains(call, "ps --all --format json"):
|
|
if r.services == "" {
|
|
return compose.Result{Stdout: healthyServices}, nil
|
|
}
|
|
return compose.Result{Stdout: r.services}, nil
|
|
case strings.Contains(call, "tht doctor --json"):
|
|
if r.workflowFailure != "" {
|
|
return compose.Result{Stderr: r.workflowFailure, ExitCode: 23}, errors.New("workflow failed")
|
|
}
|
|
return compose.Result{Stdout: `{"ok":true,"checks":[]}`}, nil
|
|
case strings.Contains(call, "pi --version") || strings.Contains(call, "PI_VERSION") || strings.Contains(call, "io.thothii.pi.version"):
|
|
return compose.Result{Stdout: "0.80.3\n"}, nil
|
|
case strings.Contains(call, "test -w /home/thoth/.pi") || strings.Contains(call, "test -r /home/thoth/.pi/agent/auth.json") || strings.Contains(call, "/health"):
|
|
return compose.Result{Stdout: `{"ready":true}`}, nil
|
|
case strings.Contains(call, "/pi-management/test"):
|
|
return compose.Result{Stdout: `{"ready":true}`}, nil
|
|
case strings.Contains(call, "ps -q core"):
|
|
return compose.Result{Stdout: "core-id\n"}, nil
|
|
}
|
|
return compose.Result{}, nil
|
|
}
|
|
|
|
func checkStatus(report Report, name string) string {
|
|
for _, check := range report.Checks {
|
|
if check.Name == name {
|
|
return check.Status
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func reportText(report Report) string {
|
|
parts := make([]string, 0, len(report.Checks))
|
|
for _, check := range report.Checks {
|
|
parts = append(parts, check.Name+" "+check.Status+" "+check.Detail)
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
|
|
const renderedConfig = `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}`
|
|
|
|
const healthyServices = `[
|
|
{"Service":"core","State":"running","Health":"healthy"},
|
|
{"Service":"frontend","State":"running","Health":"healthy"},
|
|
{"Service":"qdrant","State":"running","Health":"healthy"},
|
|
{"Service":"embedding","State":"running","Health":"healthy"},
|
|
{"Service":"embedding-model-init","State":"exited","ExitCode":0}
|
|
]`
|
|
|
|
const stoppedServices = `[
|
|
{"Service":"core","State":"exited","Health":""},
|
|
{"Service":"frontend","State":"running","Health":"healthy"}
|
|
]`
|