// 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" "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" ) // 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) } // 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) { if runner == nil { return Report{}, errors.New("doctor requires a Docker command runner") } secretValues := secretValues(installation) report := Report{Checks: make([]Check, 0, 10)} 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 !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("files", StatusSkipped, "Docker Engine is unavailable") add("services", StatusSkipped, "Docker Engine 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("files", StatusSkipped, "Docker Compose is unavailable") add("services", StatusSkipped, "Docker Compose 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") } if err := filePermissions(installation); err != nil { add("files", StatusFailed, err.Error()) } else { add("files", StatusPassed, "declared host files have safe permissions") } status, statusAvailable := serviceStatus(ctx, installation, runner, secretValues, add) coreRunning := false if statusAvailable { var err error coreRunning, err = service.CoreRunning(status) if err != nil { coreRunning = false } } if !coreRunning { 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 } if configReady && workspaceRegistryConfigured(rendered) { add("workspace-registry", StatusPassed, "workspace-registry volume is configured") } else { add("workspace-registry", StatusFailed, "workspace-registry volume is not configured") } workflowCheck(ctx, installation, runner, secretValues, add) piCheck(ctx, installation, runner, secretValues, add) return finalize(report), nil } 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, add func(string, string, string)) (string, bool) { result, err := runner.Run(ctx, installation.ComposeArgs("ps", "--all", "--format", "json"), nil) if err != nil { add("services", StatusFailed, commandDetail("Compose service status", result, err, secrets)) return "", false } if err := service.Healthy(result.Stdout); err != nil { add("services", StatusFailed, err.Error()) return result.Stdout, true } add("services", StatusPassed, "required services are running and reachable through Docker health checks") return result.Stdout, 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 workspaceRegistryConfigured(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) }