388 lines
16 KiB
Go
388 lines
16 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"
|
|
"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, secretErr := 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 || secretErr != nil {
|
|
if secretErr != nil {
|
|
add("files", StatusFailed, "declared secret files could not be read")
|
|
} else {
|
|
add("files", StatusFailed, err.Error())
|
|
}
|
|
} else {
|
|
add("files", StatusPassed, "declared host files have safe permissions")
|
|
}
|
|
if secretErr != nil {
|
|
add("docker", StatusSkipped, "declared secret files are unavailable")
|
|
add("compose", StatusSkipped, "declared secret files are unavailable")
|
|
add("configuration", StatusSkipped, "declared secret files are unavailable")
|
|
add("authentication", StatusSkipped, "declared secret files are unavailable")
|
|
add("services", StatusSkipped, "declared secret files are unavailable")
|
|
add("core-http", StatusSkipped, "declared secret files are unavailable")
|
|
add("frontend-http", StatusSkipped, "declared secret files are unavailable")
|
|
add("workspace-registry", StatusSkipped, "declared secret files are unavailable")
|
|
add("workflow", StatusSkipped, "declared secret files are unavailable")
|
|
add("pi", StatusSkipped, "declared secret files are unavailable")
|
|
return finalize(report), nil
|
|
}
|
|
|
|
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
|
|
coreHealthy := false
|
|
if statusAvailable {
|
|
var runningErr, healthyErr error
|
|
coreRunning, runningErr = service.CoreRunning(status)
|
|
coreHealthy, healthyErr = service.CoreHealthy(status)
|
|
if runningErr != nil || healthyErr != nil {
|
|
coreRunning = false
|
|
coreHealthy = false
|
|
}
|
|
}
|
|
if !configReady || !coreRunning {
|
|
add("authentication", StatusSkipped, "core is unavailable")
|
|
} else if !coreHealthy {
|
|
add("authentication", StatusFailed, "core is running but unhealthy")
|
|
} 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 !coreHealthy || servicesCheck.Status != StatusPassed {
|
|
detail := "required services are not healthy"
|
|
if !coreRunning {
|
|
detail = "core is not running"
|
|
}
|
|
add("core-http", StatusSkipped, detail)
|
|
add("frontend-http", StatusSkipped, detail)
|
|
add("workspace-registry", StatusSkipped, detail)
|
|
add("workflow", StatusSkipped, detail)
|
|
add("pi", StatusSkipped, detail)
|
|
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, error) {
|
|
files, err := installation.SecretFiles()
|
|
if err != nil {
|
|
return nil, errors.New("declared secret files could not be read")
|
|
}
|
|
values, err := output.SecretValuesFromFiles(files)
|
|
if err != nil {
|
|
return nil, errors.New("declared secret files could not be read")
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
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 eight persistent volumes required by a local 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", "auth-state"} {
|
|
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)
|
|
}
|