Files
ThothII/tools/tht/internal/doctor/report.go
T

257 lines
9.4 KiB
Go

// 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)
}