Doctor module
The doctor module is a diagnose-and-prescribe framework. It
runs a registered list of checks, each of which can optionally carry
a repair. The CLI entry point is shc doctor run — inspection-only
by default; repairs run only with an explicit --fix.
Unlike runtime health (shc health, shc hud), doctor is
intended for install-time, post-recovery, and offline
cleanup — the kind of issues that accumulate when
containers/volumes get cleaned up out-of-band or when a crashed
install leaves orphan state behind.
Source: modules/doctor/ — framework.go holds the types
and the verdict tree, register.go seeds the core diagnoses from
diagnoses/, bridge.go adopts the probes sibling modules publish
through the central registry, and router.go exposes the HTTP
endpoints.
Responsibilities
Section titled “Responsibilities”- Framework types —
Diagnosis,Finding,DiagnosisResult, plus theSeverityandVerdictenums that callers render. - Runner —
Runneriterates the registered diagnoses, captures panics/errors intoSKIPPEDresults, and appliesprescribecallbacks only when fix is requested. - Registration — the doctor package’s own
init()seeds the core diagnoses; sibling modules (vault / storage / repo) publish slim(status, hint, err)probes viaregistry.Diagnoses/registry.DiagnosesWithDeps, and the daemon adopts those at wire time viadoctor.RegisterModuleDiagnoses(bridge.go). - Built-in diagnoses — ten checks across five categories covering Docker/Filesystem, DB-backed state, compose-rendered deployment dirs, DB↔container drift, and keycloak realms.
Diagnose / prescribe contract
Section titled “Diagnose / prescribe contract”Every Diagnosis is a record of:
type Diagnosis struct { Name string // dotted, e.g. "database.orphaned-stacks" Description string // one-line human summary Diagnose func(ctx *Ctx) ([]Finding, error) Prescribe func(ctx *Ctx, findings []Finding) ([]Finding, error) Category string // defaults to "general"}A Diagnose function returns Findings — zero means PASS, one or
more means something to report. A Prescribe is optional; when
present, DoctorRunner.runOne runs it with the findings and
expects them back with Fixed=true set on anything it successfully
corrected.
Verdict calculation
Section titled “Verdict calculation”The runner translates (findings, prescribe?, fix) into a verdict:
| Condition | Verdict |
|---|---|
diagnose raised | SKIPPED |
| No findings | PASS |
All findings severity=INFO, no prescribe run | WARN |
prescribe ran and every finding came back fixed=True | FIXED |
| Otherwise | FAIL |
prescribe runs only under --fix (or {"fix": true} on the
API). The default run is the preview: verdicts stay at FAIL /
WARN, results whose skipped prescription could repair them carry
fixable=true, and the CLI hints at --fix.
Exception containment
Section titled “Exception containment”The runner wraps both diagnose and prescribe in broad
except Exception blocks by design — a broken third-party diagnosis
(e.g. an external module) must not abort the whole run and hide
every other subsystem’s health. Exceptions become ERROR-severity
findings with the traceback attached and a SKIPPED or FAIL
verdict on that one check.
Built-in inventory
Section titled “Built-in inventory”Registered from modules/doctor/diagnoses/:
| Category | Diagnosis name | Prescribe? | What it checks |
|---|---|---|---|
system | system.docker-daemon | — | Docker daemon reachable |
system | system.directories | yes | Required SHC directories exist and are writable |
database | database.orphaned-stacks | yes | DB stack rows whose deployment dir is gone |
database | database.orphaned-dirs | — | Deployment dirs with no matching DB row |
database | database.stale-status | yes | status / stopped columns disagree with running state |
database | database.storage | — | Database file present, sized sanely, writable |
state | state.container-mismatch | yes | DB says running but no container (or vice versa) |
deployments | deployments.missing-compose | yes | Stack has a template but no rendered compose.yaml |
keycloak | keycloak.realms | yes | Every tenant has a keycloak realm |
doctor | doctor.self | — | The doctor’s own dependency seams are wired |
On the daemon the table additionally carries the module probes
adopted from the central registry (vault.status, vault.secrets,
vault.crypto_format, storage.local, repo.sync-state) —
inspection-only entries whose category is the publishing module’s
name.
The categories are free-form strings; the CLI’s --category/-c
flag filters by exact match.
Extending doctor from another module
Section titled “Extending doctor from another module”To add a diagnosis without touching the doctor module, publish a
probe through the central registry from the module’s doctor.go and
register it in the module’s init():
package myfeature
import ( "context"
"gitlab.com/bitspur/selfhosted-cloud/ce/core/registry")
func diagnoses() []registry.Diagnosis { return []registry.Diagnosis{ { Name: "myfeature.invariant", Run: func(ctx context.Context) (status, hint string, err error) { if !someInvariantHolds() { return "warning: myfeature invariant broken", "run `shc myfeature resync`", nil } return "ok", "", nil }, }, }}
// in register.go's init():// registry.Diagnoses(ModuleName, diagnoses())The daemon adopts every published probe at wire time
(doctor.RegisterModuleDiagnoses in bridge.go), classifying
"error:" / "warning:" status prefixes into finding severities. A
probe whose check needs a daemon-resolved service registers via
registry.DiagnosesWithDeps and resolves its *Service off the
composition root per run (see vault/doctor.go). Bridged probes are
inspection-only — the probe shape carries no prescribe callback.
Doctor vs. health
Section titled “Doctor vs. health”- Doctor — offline, inspects persisted state, can fix. Runs on demand from the CLI. No background loop.
- Health — runtime, inspects liveness + heartbeats,
reports only. Runs continuously as crons; feeds
shc.status.get.
If you’re asking “is the stack up right now?” use health. If you’re asking “is my state consistent?” use doctor.
CLI + HTTP surface
Section titled “CLI + HTTP surface”shc doctor run [--fix] [--category X]— daemon-first: runs the sweep overPOST /api/doctor/runwhen a daemon is reachable, and degrades to a local diagnose-only pass (with a warning;--fixrefused) when none is. See reference/cli/doctorshc doctor list— the registered diagnosis table, rendered locally.POST /api/doctor/run— body{"category": "...", "fix": bool}, both optional; an empty body diagnoses only.GET /api/doctor/diagnoses— name/description/category of every registered diagnosis.
Error codes live in the DCR band (X400917DCRINP,
X500917DCROPF, X500918DCRNWR, X503919DCRKCD, X503920DCRNAV).
Related
Section titled “Related”- Health module — runtime liveness, not state drift
- App module — owns
DeploymentModelthat several diagnoses reconcile against - Reference: doctor CLI