// Package doctor aggregates non-mutating host and container diagnostics for one installation. package doctor import ( "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strings" "time" "github.com/aritmolab/thothii/tools/tht/internal/authconfig" "github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/output" "github.com/aritmolab/thothii/tools/tht/internal/pi" "github.com/aritmolab/thothii/tools/tht/internal/service" ) const ( StatusPassed = "passed" StatusFailed = "failed" StatusSkipped = "skipped" ) const probeTimeout = 5 * time.Second const registryValidationProgram = `const fs=require("node:fs");const path="/data/workspace-registry/state/active.json";const s=JSON.parse(fs.readFileSync(path,"utf8"));const hex=/^[0-9a-f]{40}$/;if(!hex.test(s.head)||!Array.isArray(s.revisions)||s.revisions.some((r)=>!r||typeof r.id!=="string"||!r.id||!hex.test(r.commit)||!hex.test(r.blob))){process.exit(1)}for(const r of s.revisions){fs.accessSync("/data/workspace-registry/snapshots/"+r.commit+"/"+r.id+".yaml",fs.constants.R_OK)}` // Check is one named, redacted diagnostic outcome. type Check struct { Name string `json:"name"` Status string `json:"status"` Detail string `json:"detail"` } // Report is the typed, machine-readable diagnostic result. type Report struct { OK bool `json:"ok"` Checks []Check `json:"checks"` } // Runner is the shell-free Docker boundary used for all host and in-container checks. type Runner interface { Run(context.Context, []string, io.Reader) (compose.Result, error) } // HTTPProbeTarget identifies one local service endpoint that must answer a bounded request. type HTTPProbeTarget struct { Name string Service string URL string } // HTTPProbe is injectable so reachability failures remain independently testable. type HTTPProbe interface { Probe(context.Context, HTTPProbeTarget) error } type composeHTTPProbe struct { installation config.Installation runner Runner } func (p composeHTTPProbe) Probe(ctx context.Context, target HTTPProbeTarget) error { probeContext, cancel := context.WithTimeout(ctx, probeTimeout) defer cancel() var command []string switch target.Name { case "core": command = []string{"exec", "-T", target.Service, "curl", "-fsS", "--max-time", "5", target.URL} case "frontend": command = []string{"exec", "-T", target.Service, "wget", "-q", "-T", "5", "-O", "/dev/null", target.URL} default: return errors.New("unknown HTTP probe target") } result, err := p.runner.Run(probeContext, p.installation.ComposeArgs(command...), nil) if err != nil { return errors.New(commandDetail(target.Name+" HTTP probe", result, err, nil)) } return nil } // Run performs diagnostics only. Expected environmental failures become failed checks so that // callers can always render a complete report; unexpected local read errors are also reported. func Run(ctx context.Context, installation config.Installation, runner Runner) (Report, error) { return RunWithProbe(ctx, installation, runner, composeHTTPProbe{installation: installation, runner: runner}) } // RunWithProbe performs diagnostics only, with an injectable bounded HTTP probe. func RunWithProbe(ctx context.Context, installation config.Installation, runner Runner, probe HTTPProbe) (Report, error) { if runner == nil { return Report{}, errors.New("doctor requires a Docker command runner") } if probe == nil { return Report{}, errors.New("doctor requires an HTTP probe") } secretValues := secretValues(installation) report := Report{Checks: make([]Check, 0, 12)} add := func(name, status, detail string) { report.Checks = append(report.Checks, Check{Name: name, Status: status, Detail: output.SanitizeDetail(detail, secretValues)}) } if err := validateInstallation(installation); err != nil { add("descriptor", StatusFailed, err.Error()) } else { add("descriptor", StatusPassed, "installation descriptor is loaded") } if err := filePermissions(installation); err != nil { add("files", StatusFailed, err.Error()) } else { add("files", StatusPassed, "declared host files have safe permissions") } if !commandCheck(ctx, runner, []string{"version", "--format", "{{.Client.Version}}"}, secretValues, add, "docker", "Docker Engine") { add("compose", StatusSkipped, "Docker Engine is unavailable") add("configuration", StatusSkipped, "Docker Engine is unavailable") add("authentication", StatusSkipped, "core is unavailable") add("services", StatusSkipped, "Docker Engine is unavailable") add("core-http", StatusSkipped, "core is unavailable") add("frontend-http", StatusSkipped, "frontend is unavailable") add("workspace-registry", StatusSkipped, "core is unavailable") add("workflow", StatusSkipped, "core is unavailable") add("pi", StatusSkipped, "core is unavailable") return finalize(report), nil } if !commandCheck(ctx, runner, []string{"compose", "version", "--short"}, secretValues, add, "compose", "Docker Compose") { add("configuration", StatusSkipped, "Docker Compose is unavailable") add("authentication", StatusSkipped, "core is unavailable") add("services", StatusSkipped, "Docker Compose is unavailable") add("core-http", StatusSkipped, "core is unavailable") add("frontend-http", StatusSkipped, "frontend is unavailable") add("workspace-registry", StatusSkipped, "core is unavailable") add("workflow", StatusSkipped, "core is unavailable") add("pi", StatusSkipped, "core is unavailable") return finalize(report), nil } configReady := false rendered := "" if result, err := runner.Run(ctx, installation.ComposeArgs("config", "--quiet"), nil); err != nil { add("configuration", StatusFailed, commandDetail("Compose configuration", result, err, secretValues)) } else if result, err := runner.Run(ctx, installation.ComposeArgs("config", "--format", "json"), nil); err != nil { add("configuration", StatusFailed, commandDetail("Compose rendering", result, err, secretValues)) } else if err := ValidateVolumes(result.Stdout); err != nil { add("configuration", StatusFailed, err.Error()) } else { rendered = result.Stdout configReady = true add("configuration", StatusPassed, "Compose configuration and required volumes are valid") } status, statusAvailable, servicesCheck := serviceStatus(ctx, installation, runner, secretValues) coreRunning := false if statusAvailable { var err error coreRunning, err = service.CoreRunning(status) if err != nil { coreRunning = false } } if !configReady || !coreRunning { add("authentication", StatusSkipped, "core is unavailable") } else if authenticationCheck(ctx, installation, runner, secretValues) { add("authentication", StatusPassed, "container-local authentication diagnostics passed") } else { add("authentication", StatusFailed, "container-local authentication diagnostics failed") } add(servicesCheck.Name, servicesCheck.Status, servicesCheck.Detail) if !coreRunning { add("core-http", StatusSkipped, "core is not running") add("frontend-http", StatusSkipped, "core is not running") add("workspace-registry", StatusSkipped, "core is not running") add("workflow", StatusSkipped, "core is not running") add("pi", StatusSkipped, "core is not running") return finalize(report), nil } reachabilityChecks(ctx, probe, secretValues, add) registryCheck(ctx, installation, runner, secretValues, add, configReady, rendered) workflowCheck(ctx, installation, runner, secretValues, add) piCheck(ctx, installation, runner, secretValues, add) return finalize(report), nil } func reachabilityChecks(ctx context.Context, probe HTTPProbe, secrets []string, add func(string, string, string)) { for _, target := range []HTTPProbeTarget{ {Name: "core", Service: "core", URL: "http://127.0.0.1:8787/health"}, {Name: "frontend", Service: "frontend", URL: "http://127.0.0.1:8080/"}, } { if err := probe.Probe(ctx, target); err != nil { add(target.Name+"-http", StatusFailed, output.SanitizeDetail(err.Error(), secrets)) continue } add(target.Name+"-http", StatusPassed, target.Name+" answered a bounded HTTP probe") } } func registryCheck(ctx context.Context, installation config.Installation, runner Runner, secrets []string, add func(string, string, string), configReady bool, rendered string) { if !configReady { add("workspace-registry", StatusFailed, "workspace-registry cannot be checked because Compose configuration is invalid") return } if !workspaceRegistryDeclared(rendered) { add("workspace-registry", StatusFailed, "workspace-registry volume is not configured") return } result, err := runner.Run(ctx, installation.ComposeArgs("exec", "-T", "core", "node", "-e", registryValidationProgram), nil) if err != nil { add("workspace-registry", StatusFailed, commandDetail("container-local workspace registry", result, err, secrets)) return } add("workspace-registry", StatusPassed, "container-local active registry state and snapshots are valid") } func finalize(report Report) Report { report.OK = len(report.Checks) > 0 for _, check := range report.Checks { if check.Status != StatusPassed { report.OK = false break } } return report } func commandCheck(ctx context.Context, runner Runner, args []string, secrets []string, add func(string, string, string), name, label string) bool { result, err := runner.Run(ctx, args, nil) if err != nil || strings.TrimSpace(result.Stdout) == "" { add(name, StatusFailed, commandDetail(label, result, err, secrets)) return false } add(name, StatusPassed, strings.TrimSpace(result.Stdout)) return true } func serviceStatus(ctx context.Context, installation config.Installation, runner Runner, secrets []string) (string, bool, Check) { result, err := runner.Run(ctx, installation.ComposeArgs("ps", "--all", "--format", "json"), nil) if err != nil { return "", false, Check{Name: "services", Status: StatusFailed, Detail: commandDetail("Compose service status", result, err, secrets)} } if err := service.Healthy(result.Stdout); err != nil { return result.Stdout, true, Check{Name: "services", Status: StatusFailed, Detail: err.Error()} } return result.Stdout, true, Check{Name: "services", Status: StatusPassed, Detail: "required services are running and reachable through Docker health checks"} } func authenticationCheck(ctx context.Context, installation config.Installation, runner Runner, secrets []string) bool { report, err := authconfig.Check(ctx, installation, runner, false, true) if err != nil { return false } if !report.Ready { return false } // Check performs stream redaction before decoding; retain this sanitization call as a boundary // if future report fields are added to the backend machine contract. for _, check := range report.Checks { if output.Sanitize(check.Message, secrets) != check.Message { return false } } return true } func workflowCheck(ctx context.Context, installation config.Installation, runner Runner, secrets []string, add func(string, string, string)) { result, err := runner.Run(ctx, installation.ComposeArgs("exec", "-T", "core", "tht", "doctor", "--json"), nil) if err != nil { add("workflow", StatusFailed, commandDetail("container-local workflow doctor", result, err, secrets)) return } var payload struct { OK bool `json:"ok"` } if json.Unmarshal([]byte(result.Stdout), &payload) != nil || !payload.OK { add("workflow", StatusFailed, "container-local workflow doctor returned an invalid or failing report") return } add("workflow", StatusPassed, "container-local workflow doctor passed") } func piCheck(ctx context.Context, installation config.Installation, runner Runner, secrets []string, add func(string, string, string)) { controlled := compose.InstallationRunner{Installation: installation, Runner: runner} if err := pi.Doctor(ctx, controlled); err != nil { add("pi", StatusFailed, output.SanitizeDetail(err.Error(), secrets)) return } add("pi", StatusPassed, "Pi doctor passed") } func validateInstallation(installation config.Installation) error { if installation.Path == "" || installation.ProjectDirectory == "" || installation.EnvFile == "" { return errors.New("installation descriptor is incomplete") } return nil } func secretValues(installation config.Installation) []string { files, err := installation.SecretFiles() if err != nil { return nil } values, err := output.SecretValuesFromFiles(files) if err != nil { return nil } return values } func filePermissions(installation config.Installation) error { files, err := installation.SecretFiles() if err != nil { return errors.New("declared secret files could not be read") } for _, path := range append([]string{installation.EnvFile}, files...) { info, err := os.Stat(path) if err != nil || !info.Mode().IsRegular() { return fmt.Errorf("required host file is unavailable: %s", filepath.Base(path)) } if info.Mode().Perm()&0o077 != 0 { return fmt.Errorf("required host file has unsafe permissions: %s", filepath.Base(path)) } } return nil } // ValidateVolumes checks the seven persistent volumes required by a ThothII installation. func ValidateVolumes(rendered string) error { var document struct { Volumes map[string]json.RawMessage `json:"volumes"` } if err := json.Unmarshal([]byte(rendered), &document); err != nil { return errors.New("Compose returned invalid rendered configuration") } for _, name := range []string{"settings", "pi-state", "workspace-registry", "workspace-secrets", "sessions", "qdrant-data", "embedding-models"} { if _, exists := document.Volumes[name]; !exists { return fmt.Errorf("rendered Compose configuration is missing required volume %s", name) } } return nil } func workspaceRegistryDeclared(rendered string) bool { var document struct { Volumes map[string]json.RawMessage `json:"volumes"` } return json.Unmarshal([]byte(rendered), &document) == nil && document.Volumes["workspace-registry"] != nil } func commandDetail(label string, result compose.Result, err error, secrets []string) string { if result.ExitCode != 0 { return output.SanitizeDetail(fmt.Sprintf("%s failed (exit %d): %s", label, result.ExitCode, result.Stderr), secrets) } if err != nil { return output.SanitizeDetail(fmt.Sprintf("%s failed: %s", label, result.Stderr), secrets) } return output.SanitizeDetail(label+" returned no output", secrets) }