Skip to content
SHC Docs

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.

  1. Framework typesDiagnosis, Finding, DiagnosisResult, plus the Severity and Verdict enums that callers render.
  2. RunnerRunner iterates the registered diagnoses, captures panics/errors into SKIPPED results, and applies prescribe callbacks only when fix is requested.
  3. Registration — the doctor package’s own init() seeds the core diagnoses; sibling modules (vault / storage / repo) publish slim (status, hint, err) probes via registry.Diagnoses / registry.DiagnosesWithDeps, and the daemon adopts those at wire time via doctor.RegisterModuleDiagnoses (bridge.go).
  4. Built-in diagnoses — ten checks across five categories covering Docker/Filesystem, DB-backed state, compose-rendered deployment dirs, DB↔container drift, and keycloak realms.

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.

The runner translates (findings, prescribe?, fix) into a verdict:

ConditionVerdict
diagnose raisedSKIPPED
No findingsPASS
All findings severity=INFO, no prescribe runWARN
prescribe ran and every finding came back fixed=TrueFIXED
OtherwiseFAIL

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.

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.

Registered from modules/doctor/diagnoses/:

CategoryDiagnosis namePrescribe?What it checks
systemsystem.docker-daemonDocker daemon reachable
systemsystem.directoriesyesRequired SHC directories exist and are writable
databasedatabase.orphaned-stacksyesDB stack rows whose deployment dir is gone
databasedatabase.orphaned-dirsDeployment dirs with no matching DB row
databasedatabase.stale-statusyesstatus / stopped columns disagree with running state
databasedatabase.storageDatabase file present, sized sanely, writable
statestate.container-mismatchyesDB says running but no container (or vice versa)
deploymentsdeployments.missing-composeyesStack has a template but no rendered compose.yaml
keycloakkeycloak.realmsyesEvery tenant has a keycloak realm
doctordoctor.selfThe 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.

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():

modules/myfeature/doctor.go
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.

  • Doctoroffline, inspects persisted state, can fix. Runs on demand from the CLI. No background loop.
  • Healthruntime, 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.

  • shc doctor run [--fix] [--category X] — daemon-first: runs the sweep over POST /api/doctor/run when a daemon is reachable, and degrades to a local diagnose-only pass (with a warning; --fix refused) when none is. See reference/cli/doctor
  • shc 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).