use std::path::Path;
use clap::Args;
use cortex_ledger::audit::verify_schema_migration_v1_to_v2_boundary;
use serde::Serialize;
use crate::cmd::open_default_store;
use crate::cmd::restore::production::deployment_id_for;
use crate::exit::Exit;
use crate::output::{self, Envelope, Outcome};
use crate::paths::DataLayout;
#[derive(Debug, Args)]
pub struct DoctorArgs {
#[arg(long)]
pub strict: bool,
#[arg(long, conflicts_with = "strict")]
pub print_deployment_id: bool,
#[arg(
long,
conflicts_with = "strict",
conflicts_with = "print_deployment_id"
)]
pub repair: bool,
#[arg(long, requires = "repair")]
pub dry_run: bool,
}
pub fn run(args: DoctorArgs) -> Exit {
let json = output::json_enabled();
if args.print_deployment_id {
return run_print_deployment_id(json);
}
if args.repair {
return run_repair(json, args.dry_run);
}
if !args.strict {
if json {
let envelope = Envelope::new(
"cortex.doctor",
Exit::Usage,
DoctorReport::usage(
"doctor requires --strict to run schema-version gates (or --print-deployment-id for the deployment binding)",
),
);
return output::emit(&envelope, Exit::Usage);
}
eprintln!(
"cortex doctor: pass --strict to run schema-version gates, or --print-deployment-id to print the deployment binding."
);
return Exit::Usage;
}
let pool = match open_default_store("doctor") {
Ok(pool) => pool,
Err(exit) => {
return finish(
exit,
DoctorReport::precondition("failed to open store"),
None,
)
}
};
let layout = match DataLayout::resolve(None, None) {
Ok(layout) => layout,
Err(exit) => {
return finish(
exit,
DoctorReport::precondition("failed to resolve layout"),
None,
)
}
};
match cortex_store::verify::verify_schema_version(&pool, cortex_core::SCHEMA_VERSION) {
Ok(report) if report.is_ok() => {
let needs_boundary = match (cortex_core::SCHEMA_VERSION >= 2)
.then(|| contains_pre_cutover_v1_rows(&layout.event_log_path))
{
Some(Ok(value)) => value,
Some(Err(err)) => {
let detail =
format!("failed to inspect event log for pre-cutover v1 rows: {err}");
if !json {
eprintln!("cortex doctor: {detail}");
}
return finish(
Exit::PreconditionUnmet,
DoctorReport::precondition(detail),
None,
);
}
None => false,
};
match verify_schema_migration_v1_to_v2_boundary(&layout.event_log_path, needs_boundary)
{
Ok(boundary_report) if boundary_report.ok() => {}
Ok(boundary_report) => {
let mut details = Vec::new();
for failure in &boundary_report.failures {
let line = format!("{}: {:?}", failure.invariant, failure.detail);
if !json {
eprintln!("cortex doctor: {line}");
}
details.push(line);
}
if !json {
eprintln!(
"cortex doctor: hint: if this is a fresh v2 store, the boundary check \
may be a false positive — run `cortex doctor --repair` to confirm; \
for a migrated store run `cortex migrate v2 --backup-manifest <path>` \
to complete the upgrade"
);
}
return finish(
Exit::SchemaMismatch,
DoctorReport::schema_mismatch(
"boundary verification failed — hint: if this is a fresh v2 store run \
`cortex doctor --repair` to confirm; for a migrated store run \
`cortex migrate v2 --backup-manifest <path>` to complete the upgrade",
details,
),
None,
);
}
Err(err) => {
let detail = format!("failed to verify schema boundary events: {err}");
if !json {
eprintln!("cortex doctor: {detail}");
}
return finish(
Exit::PreconditionUnmet,
DoctorReport::precondition(detail),
None,
);
}
}
let checked_tables: Vec<String> = report
.checked_tables
.iter()
.map(|s| s.to_string())
.collect();
if !json {
println!(
"cortex doctor: ok: schema shape is present and schema_version matches code version {} across {} tables",
report.expected,
checked_tables.len()
);
}
finish(
Exit::Ok,
DoctorReport::ok(report.expected, checked_tables),
None,
)
}
Ok(report) => {
let mut details = Vec::new();
for failure in &report.failures {
let line = format!("{}: {}", failure.invariant(), failure.detail());
if !json {
eprintln!("cortex doctor: {line}");
}
details.push(line);
}
if !json {
eprintln!(
"cortex doctor: hint: run `cortex doctor --repair` to apply pending \
migrations, or `cortex migrate v2 --backup-manifest <path>` for a major \
version upgrade"
);
}
finish(
Exit::SchemaMismatch,
DoctorReport::schema_mismatch(
"schema_version mismatch — hint: run `cortex doctor --repair` to apply \
pending migrations, or `cortex migrate v2 --backup-manifest <path>` for a \
major version upgrade",
details,
),
None,
)
}
Err(err) => {
let detail = format!("failed to verify schema version: {err}");
if !json {
eprintln!("cortex doctor: {detail}");
}
finish(
Exit::PreconditionUnmet,
DoctorReport::precondition(detail),
None,
)
}
}
}
#[derive(Debug, Serialize)]
struct DoctorReport {
status: &'static str,
detail: String,
#[serde(skip_serializing_if = "Option::is_none")]
schema_version: Option<u16>,
#[serde(skip_serializing_if = "Vec::is_empty")]
checked_tables: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
failures: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
migrations_applied: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
migrations_applied_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
schema_version_before: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
schema_version_after: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
dry_run: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
invariant: Option<&'static str>,
}
pub const INVARIANT_NO_MIGRATIONS_PENDING: &str = "doctor.repair.no_migrations_pending";
impl DoctorReport {
fn ok(schema_version: u16, checked_tables: Vec<String>) -> Self {
Self {
status: "ok",
detail: "schema shape is present and schema_version matches the running binary"
.to_string(),
schema_version: Some(schema_version),
checked_tables,
failures: Vec::new(),
migrations_applied: Vec::new(),
migrations_applied_count: None,
schema_version_before: None,
schema_version_after: None,
dry_run: None,
invariant: None,
}
}
fn repair_ok(
detail: impl Into<String>,
applied_names: Vec<String>,
schema_version_before: usize,
schema_version_after: usize,
dry_run: bool,
) -> Self {
let count = applied_names.len();
let invariant = if count == 0 {
Some(INVARIANT_NO_MIGRATIONS_PENDING)
} else {
None
};
Self {
status: "ok",
detail: detail.into(),
schema_version: None,
checked_tables: Vec::new(),
failures: Vec::new(),
migrations_applied: applied_names,
migrations_applied_count: Some(count),
schema_version_before: Some(schema_version_before),
schema_version_after: Some(schema_version_after),
dry_run: Some(dry_run),
invariant,
}
}
fn schema_mismatch(detail: impl Into<String>, failures: Vec<String>) -> Self {
Self {
status: "schema_mismatch",
detail: detail.into(),
schema_version: None,
checked_tables: Vec::new(),
failures,
migrations_applied: Vec::new(),
migrations_applied_count: None,
schema_version_before: None,
schema_version_after: None,
dry_run: None,
invariant: None,
}
}
fn precondition(detail: impl Into<String>) -> Self {
Self {
status: "precondition_unmet",
detail: detail.into(),
schema_version: None,
checked_tables: Vec::new(),
failures: Vec::new(),
migrations_applied: Vec::new(),
migrations_applied_count: None,
schema_version_before: None,
schema_version_after: None,
dry_run: None,
invariant: None,
}
}
fn usage(detail: impl Into<String>) -> Self {
Self {
status: "usage",
detail: detail.into(),
schema_version: None,
checked_tables: Vec::new(),
failures: Vec::new(),
migrations_applied: Vec::new(),
migrations_applied_count: None,
schema_version_before: None,
schema_version_after: None,
dry_run: None,
invariant: None,
}
}
}
fn finish(exit: Exit, report: DoctorReport, outcome_override: Option<Outcome>) -> Exit {
if !output::json_enabled() {
return exit;
}
let mut envelope = Envelope::new("cortex.doctor", exit, report);
if let Some(outcome) = outcome_override {
envelope = envelope.with_outcome(outcome);
}
output::emit(&envelope, exit)
}
fn contains_pre_cutover_v1_rows(event_log_path: &Path) -> Result<bool, cortex_ledger::JsonlError> {
let report = verify_schema_migration_v1_to_v2_boundary(event_log_path, false)?;
Ok(!report.boundary_rows.is_empty())
}
fn run_repair(json: bool, dry_run: bool) -> Exit {
let flag = if dry_run {
"cortex doctor --repair --dry-run"
} else {
"cortex doctor --repair"
};
let layout = match DataLayout::resolve(None, None) {
Ok(layout) => layout,
Err(exit) => {
let detail = "failed to resolve data layout";
if !json {
eprintln!("{flag}: {detail}");
}
return finish(exit, DoctorReport::precondition(detail), None);
}
};
if !layout.db_path.exists() {
let detail = format!(
"database {} does not exist; run `cortex init` first",
layout.db_path.display()
);
if !json {
eprintln!("{flag}: {detail}");
}
return finish(
Exit::PreconditionUnmet,
DoctorReport::precondition(detail),
None,
);
}
let pool = match rusqlite::Connection::open(&layout.db_path) {
Ok(pool) => pool,
Err(err) => {
let detail = format!("failed to open database: {err}");
if !json {
eprintln!("{flag}: {detail}");
}
return finish(
Exit::PreconditionUnmet,
DoctorReport::precondition(detail),
None,
);
}
};
let known_names: Vec<String> = cortex_store::migrate::known_migration_names()
.iter()
.map(|s| s.to_string())
.collect();
if dry_run {
let detail = format!(
"dry-run: {} known migration(s) defined; run without --dry-run to apply any pending",
known_names.len()
);
if !json {
println!("{flag}: ok: {detail}");
for name in &known_names {
println!(" - {name}");
}
}
return finish(
Exit::Ok,
DoctorReport::repair_ok(detail, known_names, 0, 0, true),
None,
);
}
match cortex_store::migrate::apply_pending(&pool) {
Ok(applied) => {
let detail = if applied == 0 {
"store is already fully migrated; no changes needed".to_string()
} else {
format!("applied {applied} pending migration(s); store is now up to date")
};
if !json {
if applied == 0 {
println!("{flag}: ok: {detail} [invariant: {INVARIANT_NO_MIGRATIONS_PENDING}]");
} else {
println!("{flag}: ok: {detail}");
}
}
let applied_names: Vec<String> = known_names.into_iter().take(applied).collect();
finish(
Exit::Ok,
DoctorReport::repair_ok(
detail,
applied_names,
0,
applied,
false,
),
None,
)
}
Err(err) => {
let detail = format!(
"migration failed: {err}\n\
If you see 'duplicate column name: source_attestation_json', \
upgrade to a cortex binary >= this release — the migration is \
now idempotent."
);
if !json {
eprintln!("{flag}: {detail}");
}
finish(Exit::Internal, DoctorReport::precondition(detail), None)
}
}
}
fn run_print_deployment_id(json: bool) -> Exit {
let layout = match DataLayout::resolve(None, None) {
Ok(layout) => layout,
Err(exit) => {
return finish(
exit,
DoctorReport::precondition("failed to resolve data layout"),
None,
);
}
};
let deployment_id = deployment_id_for(&layout);
let data_dir = layout.data_dir.display().to_string();
if !json {
println!("{deployment_id}");
return Exit::Ok;
}
let payload = serde_json::json!({
"status": "ok",
"deployment_id": deployment_id,
"data_dir": data_dir,
"detail": "deterministic BLAKE3 of canonicalize(data_dir).to_string_lossy(); mint this into RESTORE_INTENT.deployment_id verbatim.",
});
let envelope = Envelope::new("cortex.doctor", Exit::Ok, payload);
output::emit(&envelope, Exit::Ok)
}