Add a health check or an initialiser¶
Two link-time registries, two commands, two different jobs. Pick by the question you are answering.
| Question | Trait | Command |
|---|---|---|
| Is this working right now? | HealthCheck |
doctor |
| Is this set up, and if not, set it up | Initialiser |
init |
Add a check to doctor¶
use rtb_app::app::App;
use rtb_cli::health::{HealthCheck, HealthStatus, HEALTH_CHECKS};
use linkme::distributed_slice;
struct ApiTokenCheck;
#[async_trait::async_trait]
impl HealthCheck for ApiTokenCheck {
fn name(&self) -> &'static str {
"api-token"
}
async fn check(&self, app: &App) -> HealthStatus {
if app.credentials().iter().any(|(n, _)| n == "api-token") {
HealthStatus::ok("declared in config")
} else {
HealthStatus::fail("no api-token credential declared")
}
}
}
#[distributed_slice(HEALTH_CHECKS)]
fn register() -> Box<dyn HealthCheck> {
Box::new(ApiTokenCheck)
}
linkme has to be a direct dependency of your crate — see
add a command.
Choose the right verdict¶
| Constructor | Renders as | Effect |
|---|---|---|
HealthStatus::ok(msg) |
[OK ] |
none |
HealthStatus::warn(msg) |
[WARN] |
none — the run still succeeds |
HealthStatus::fail(msg) |
[FAIL] |
doctor exits non-zero |
Warn is a real outcome, not a soft failure. Use it for "working, but you should
know" — a fallback in use, a deprecated setting, an expiry approaching. Reserve
Fail for something an operator has to act on, because that is what turns a
doctor run red in CI.
The summary is shown verbatim on one line. Name the thing and the state:
keychain locked, falling back to env beats check failed.
Keep checks read-only and independent¶
doctor runs every registered check, in link order, regardless of what the others
reported. A check that mutates state changes what the checks after it see, and link
order is not stable across builds — so the outcome would vary between compilations.
A check that needs network or disk access should be quick and should degrade to
Warn rather than hanging. There is no timeout around a check.
Add a step to init¶
use rtb_app::app::App;
use rtb_cli::init::{Initialiser, INITIALISERS};
use linkme::distributed_slice;
struct ConfigFileInit;
#[async_trait::async_trait]
impl Initialiser for ConfigFileInit {
fn name(&self) -> &'static str {
"config-file"
}
async fn is_configured(&self, _app: &App) -> bool {
config_path().exists()
}
async fn configure(&self, _app: &App) -> miette::Result<()> {
write_default_config()
}
}
#[distributed_slice(INITIALISERS)]
fn register() -> Box<dyn Initialiser> {
Box::new(ConfigFileInit)
}
Make configure idempotent¶
init stops at the first configure that returns an error. Anything that already
ran stays as it left things, and nothing is rolled back. The user's next move is to
fix the problem and re-run init, which re-runs every step that still reports
is_configured() == false.
So write configure to be safe to run twice, and make is_configured cheap and
side-effect-free — it is called for every step on every init.
Do not prompt without checking¶
configure is free to prompt interactively, and init may run in CI. Guard any
prompt, or provide a non-interactive path, or the pipeline hangs waiting for input
nobody will type.
Check your work¶
$ mytool doctor
[OK ] api-token: declared in config
[WARN] keychain: locked, falling back to env
$ mytool init
[SKIP] config-file — already configured
[RUN] api-token
A tool with nothing registered prints nothing from doctor, and
no initialisers registered — nothing to do from init.