Skip to main content

cortex/
bootstrap.rs

1// Copyright 2015-2025 Deyan Ginev. See the LICENSE
2// file at the top-level directory of this distribution.
3//
4// Licensed under the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT>.
5// This file may not be copied, modified, or distributed
6// except according to those terms.
7
8//! Self-install and diagnostics: the library logic behind `cortex init` and `cortex doctor`.
9//!
10//! Kept in the library (not the binary) so the contracts are testable; the `cortex` binary is a
11//! thin renderer over these functions.
12
13use std::path::Path;
14
15use diesel::pg::PgConnection;
16use diesel::prelude::*;
17use rand::RngExt;
18use rand::distr::Alphanumeric;
19use serde::Serialize;
20
21use crate::config::{CortexConfig, TokenFile, to_persisted_toml};
22use crate::migrations;
23
24/// Write `cortex.toml` **atomically**: serialize to a sibling temp file, then `rename` it over the
25/// target. `rename` is atomic on a POSIX filesystem, so a crash mid-write leaves the **previous**
26/// `cortex.toml` (or none) intact rather than a half-written file the app can't parse — and that
27/// `cortex init` would then skip as "already present". `fs::write` truncates any temp orphaned by a
28/// prior crash, so the next write starts clean. (Process-crash atomic; power-loss durability would
29/// additionally need an `fsync` of the temp + dir, overkill for a setup-time config write.)
30pub(crate) fn write_config_atomically(path: impl AsRef<Path>, content: &str) -> Result<(), String> {
31  let path = path.as_ref();
32  let tmp = path.with_extension("toml.tmp");
33  std::fs::write(&tmp, content).map_err(|e| format!("cannot write {}: {e}", tmp.display()))?;
34  std::fs::rename(&tmp, path).map_err(|e| format!("cannot publish {}: {e}", path.display()))?;
35  Ok(())
36}
37
38/// Structured diagnostics — the data contract shared by `cortex doctor` and its agent twin.
39#[derive(Debug, Serialize)]
40pub struct DoctorReport {
41  /// The database accepts a connection and a trivial query.
42  pub database_reachable: bool,
43  /// The schema is at the latest embedded migration.
44  pub migrations_current: bool,
45  /// The built-in `init` and `import` services are seeded.
46  pub services_seeded: bool,
47  /// At least one admin/API token is configured (`auth.rerun_tokens`) — i.e. the deployment is
48  /// sign-in-able. **Informational, not part of `ok`**: a freshly-`init`ed box legitimately has
49  /// none until `cortex set-admin-token` runs, so this must not make `cortex init` exit
50  /// non-zero.
51  pub admin_token_configured: bool,
52  /// Whether every *blocking* check passed (database + migrations + seeded services).
53  pub ok: bool,
54}
55
56impl DoctorReport {
57  /// Actionable next-step hints for any failing or unconfigured check, in fix-this-first order, so
58  /// a stuck operator is told *how* to fix a red check, not merely that it is red. Empty when the
59  /// box is healthy and configured. Shared by the `cortex doctor` text output and its JSON twin
60  /// (the agent gets the same guidance).
61  #[must_use]
62  pub fn remediations(&self) -> Vec<String> {
63    let mut hints = Vec::new();
64    if !self.database_reachable {
65      // Until the database is back, the migration / service checks are unknowable — fix this first
66      // and re-run, rather than chasing the cascade of `false`s it produces.
67      hints.push(
68        "database unreachable — check the database URL (`cortex.toml` [database].url, or \
69         DATABASE_URL) and that PostgreSQL is running"
70          .to_string(),
71      );
72      return hints;
73    }
74    if !self.migrations_current {
75      hints
76        .push("schema out of date — run `cortex init` to apply the pending migrations".to_string());
77    }
78    // The built-in services are seeded by a migration, so a missing pair *while migrations are
79    // current* means they were removed out of band; otherwise `cortex init` (above) restores them,
80    // and a separate hint here would be redundant noise.
81    if !self.services_seeded && self.migrations_current {
82      hints.push(
83        "the built-in `init`/`import` services are missing despite current migrations — they were \
84         likely deleted; re-create them (originally seeded by migration \
85         2017-10-01-204801_services)"
86          .to_string(),
87      );
88    }
89    if !self.admin_token_configured {
90      hints.push(
91        "no admin token configured — run `cortex set-admin-token --generate --owner <you>` to \
92         enable sign-in and write actions"
93          .to_string(),
94      );
95    }
96    hints
97  }
98}
99
100/// The outcome of `cortex init`.
101#[derive(Debug, Serialize)]
102pub struct InitOutcome {
103  /// Migration versions applied (empty when the database was already current).
104  pub migrations_applied: Vec<String>,
105  /// Whether a configuration file was scaffolded (because it was missing).
106  pub config_created: bool,
107}
108
109/// Runs the install diagnostics against the given database URL.
110pub fn doctor(database_url: &str) -> DoctorReport {
111  // Auth readiness is independent of the database — a token may be configured even if the DB is
112  // down.
113  let admin_token_configured = !crate::config::config().auth.rerun_tokens.is_empty();
114  match PgConnection::establish(database_url) {
115    Ok(mut connection) => {
116      let database_reachable = diesel::sql_query("SELECT 1")
117        .execute(&mut connection)
118        .is_ok();
119      let migrations_current = !migrations::has_pending_migrations(&mut connection);
120      let services_seeded = builtin_services_present(&mut connection);
121      DoctorReport {
122        database_reachable,
123        migrations_current,
124        services_seeded,
125        admin_token_configured,
126        ok: database_reachable && migrations_current && services_seeded,
127      }
128    },
129    Err(_) => DoctorReport {
130      database_reachable: false,
131      migrations_current: false,
132      services_seeded: false,
133      admin_token_configured,
134      ok: false,
135    },
136  }
137}
138
139/// Minimum PostgreSQL server version CorTeX requires (`180000` = 18.0). The migrations use
140/// **PG18-only features** — `uuidv7()` defaults on the external-handle columns and the
141/// report-summary materialized-view rollups — so an older server cannot run them. `init`
142/// pre-flights this and fails with a clear message instead of a cryptic mid-migration `function
143/// "uuidv7" does not exist`.
144const MIN_PG_VERSION_NUM: i32 = 180_000;
145
146/// Reads PostgreSQL's `server_version_num` (e.g. `180004` for 18.4); `None` if the probe itself
147/// fails (then we let the migration proceed rather than block on a probe error — the migration is
148/// the backstop).
149fn server_version_num(connection: &mut PgConnection) -> Option<i32> {
150  use diesel::dsl::sql;
151  use diesel::sql_types::Integer;
152  diesel::select(sql::<Integer>("current_setting('server_version_num')::int"))
153    .get_result::<i32>(connection)
154    .ok()
155}
156
157/// Self-migrates the database (embedded migrations) and scaffolds a config file if one is missing.
158pub fn init(database_url: &str, config_path: &Path) -> Result<InitOutcome, String> {
159  let mut connection = PgConnection::establish(database_url)
160    .map_err(|e| format!("cannot connect to database: {e}"))?;
161  // Pre-flight the server version: fail fast with a clear message on PostgreSQL < 18 instead of a
162  // cryptic mid-migration error. A probe failure (`None`) doesn't block — the migration still runs.
163  if let Some(version) = server_version_num(&mut connection)
164    && version < MIN_PG_VERSION_NUM
165  {
166    return Err(format!(
167      "CorTeX requires PostgreSQL 18 or newer (the migrations use PG18-only features — `uuidv7()` \
168         handle defaults and the report-summary materialized views); this server is {}.{}. Upgrade \
169         PostgreSQL and re-run `cortex init`.",
170      version / 10000,
171      version % 10000
172    ));
173  }
174  let migrations_applied = migrations::run_pending_migrations(&mut connection)
175    .map_err(|e| format!("migration failed: {e}"))?;
176  let config_created = if config_path.exists() {
177    false
178  } else {
179    let toml = to_persisted_toml(&CortexConfig::default())
180      .map_err(|e| format!("cannot serialize config: {e}"))?;
181    write_config_atomically(config_path, &toml)?;
182    true
183  };
184  // Scaffold an **empty** JSON token file (`config.json` / `CORTEX_AUTH_FILE`) — the single source
185  // of truth for admin/API tokens — if one is missing, so the operator has a discoverable place for
186  // `cortex set-admin-token` to fill in. Seeded empty on purpose: never copy the demo
187  // `config.example.json` tokens into a live deployment (they are public in the repo).
188  let auth_path = crate::config::auth_file_path();
189  if !auth_path.exists() {
190    let empty = serde_json::to_string_pretty(&TokenFile::default())
191      .map_err(|e| format!("cannot serialize token file: {e}"))?;
192    write_config_atomically(&auth_path, &empty)?;
193  }
194  Ok(InitOutcome {
195    migrations_applied,
196    config_created,
197  })
198}
199
200/// The outcome of `cortex set-admin-token`.
201#[derive(Debug, Serialize)]
202pub struct SetTokenOutcome {
203  /// The owner the token now maps to.
204  pub owner: String,
205  /// `true` if the token already existed (its owner was updated) rather than added.
206  pub replaced: bool,
207  /// How many tokens are configured after the write.
208  pub token_count: usize,
209}
210
211/// Generates a fresh random admin/API token: 32 URL-safe alphanumeric characters (~190 bits). Used
212/// by `cortex set-admin-token --generate`. The token is a plaintext bearer credential (the
213/// lightweight scheme — see `docs/archive/AAA_DESIGN.md`); hashing-at-rest is a documented later
214/// step.
215pub fn generate_token() -> String {
216  rand::rng()
217    .sample_iter(&Alphanumeric)
218    .take(32)
219    .map(char::from)
220    .collect()
221}
222
223/// Sets (or updates) an admin/API token in the JSON token file at `auth_path` (`config.json` /
224/// `CORTEX_AUTH_FILE`) — the **single source of truth** for `rerun_tokens` — **merging** into the
225/// existing file so other tokens are preserved. The library logic behind `cortex set-admin-token`.
226///
227/// If the file is missing it is created with just this token (an empty `rerun_tokens` map
228/// otherwise). There is no `cortex.toml [auth]` layering anymore, so a written token takes effect
229/// immediately (the frontend re-reads this file on every gated request). Mapping the token to a
230/// per-person `owner` is what gives the audit log its actor (`docs/archive/AAA_DESIGN.md`).
231pub fn set_admin_token(
232  auth_path: &Path,
233  token: &str,
234  owner: &str,
235) -> Result<SetTokenOutcome, String> {
236  if token.is_empty() {
237    return Err("refusing to set an empty token".to_string());
238  }
239  // Read the existing token file (empty map when none exists yet), merge, write back as JSON.
240  let mut file: TokenFile = match std::fs::read_to_string(auth_path) {
241    Ok(text) => serde_json::from_str(&text)
242      .map_err(|e| format!("cannot parse {}: {e}", auth_path.display()))?,
243    Err(_) => TokenFile::default(),
244  };
245  let replaced = file
246    .rerun_tokens
247    .insert(token.to_string(), owner.to_string())
248    .is_some();
249  let token_count = file.rerun_tokens.len();
250  let serialized =
251    serde_json::to_string_pretty(&file).map_err(|e| format!("cannot serialize token file: {e}"))?;
252  write_config_atomically(auth_path, &serialized)?;
253  Ok(SetTokenOutcome {
254    owner: owner.to_string(),
255    replaced,
256    token_count,
257  })
258}
259
260/// The outcome of `cortex revoke-token`.
261#[derive(Debug, Serialize)]
262pub struct RevokeTokenOutcome {
263  /// How many tokens this call removed (`0` if none matched).
264  pub revoked: usize,
265  /// How many tokens remain configured afterward.
266  pub token_count: usize,
267}
268
269/// Revokes admin/API token(s) from the JSON token file at `auth_path` (`config.json` /
270/// `CORTEX_AUTH_FILE`) — the inverse of [`set_admin_token`]. Removes either a specific `token` or
271/// **every** token mapped to `owner` (the caller supplies exactly one). Merges into the existing
272/// file (other tokens are preserved) and writes atomically; rewrites only when something actually
273/// changed. The library logic behind `cortex revoke-token`. A revoked token stops working
274/// immediately — the frontend re-reads this file on every gated request.
275pub fn revoke_admin_token(
276  auth_path: &Path,
277  token: Option<&str>,
278  owner: Option<&str>,
279) -> Result<RevokeTokenOutcome, String> {
280  let mut file: TokenFile = match std::fs::read_to_string(auth_path) {
281    Ok(text) => serde_json::from_str(&text)
282      .map_err(|e| format!("cannot parse {}: {e}", auth_path.display()))?,
283    Err(_) => {
284      return Err(format!(
285        "no token file at {} — nothing to revoke",
286        auth_path.display()
287      ));
288    },
289  };
290  let revoked = match (token, owner) {
291    (Some(tok), _) => usize::from(file.rerun_tokens.remove(tok).is_some()),
292    (None, Some(own)) => {
293      let matching: Vec<String> = file
294        .rerun_tokens
295        .iter()
296        .filter(|(_, value)| value.as_str() == own)
297        .map(|(key, _)| key.clone())
298        .collect();
299      for key in &matching {
300        file.rerun_tokens.remove(key);
301      }
302      matching.len()
303    },
304    (None, None) => return Err("specify a token to revoke, or --owner <name>".to_string()),
305  };
306  let token_count = file.rerun_tokens.len();
307  if revoked > 0 {
308    let serialized = serde_json::to_string_pretty(&file)
309      .map_err(|e| format!("cannot serialize token file: {e}"))?;
310    write_config_atomically(auth_path, &serialized)?;
311  }
312  Ok(RevokeTokenOutcome {
313    revoked,
314    token_count,
315  })
316}
317
318/// Operator guidance for tuning the PostgreSQL **server** (`shared_buffers`, `work_mem`, …),
319/// printed by `cortex tune-db` and at the end of `cortex init`. Per the owner decision (see
320/// `docs/DB_TUNING.md`), `cortex` does **not** reimplement the pgtune heuristic — it points at the
321/// upstream service and pre-fills the host's RAM / cores so the operator's inputs are ready. The
322/// per-table autovacuum is
323/// already automatic (a migration); this is the host-sized server config (see `docs/DB_TUNING.md`).
324pub fn db_tuning_guidance() -> String {
325  format!(
326    "PostgreSQL server tuning is recommended but not automated.\n\
327     CorTeX is a \"Mixed\" workload (OLTP task/log writes + DW bulk-loads + reporting); the stock\n\
328     defaults (shared_buffers=128MB, work_mem=4MB) are far too small for a real corpus.\n\n\
329     1. Generate a config at https://pgtune.leopard.in.ua/ with these inputs:\n\
330     \x20    DB Type = mixed   OS = linux   DB Version = <your PG major>\n\
331     \x20    Total RAM = {ram}   CPUs = {cores}   Connections = 300   Storage = nvme (or ssd)\n\
332     2. Apply the ALTER SYSTEM block it prints, then restart PostgreSQL.\n\
333     \x20  (build note: keep wal_compression=lz4 / io_method=io_uring only if your build supports them.)\n\n\
334     A verified example block (256 GB / 64 cores / nvme) is in docs/DB_TUNING.md.",
335    ram = total_ram_hint(),
336    cores = core_hint(),
337  )
338}
339
340/// Best-effort host RAM for the tuning guidance (Linux `/proc/meminfo`), or a placeholder.
341fn total_ram_hint() -> String {
342  std::fs::read_to_string("/proc/meminfo")
343    .ok()
344    .and_then(|meminfo| {
345      meminfo
346        .lines()
347        .find(|line| line.starts_with("MemTotal"))
348        .and_then(|line| line.split_whitespace().nth(1))
349        .and_then(|kb| kb.parse::<u64>().ok())
350    })
351    .map(|kb| format!("{} GB", kb / 1024 / 1024))
352    .unwrap_or_else(|| "<your host RAM>".to_string())
353}
354
355/// Best-effort CPU hint for the tuning guidance (logical count, with a physical-cores reminder).
356fn core_hint() -> String {
357  std::thread::available_parallelism()
358    .map(|logical| {
359      format!("{logical} (use *physical* cores — often half this on hyperthreaded CPUs)")
360    })
361    .unwrap_or_else(|_| "<physical cores>".to_string())
362}
363
364/// Whether the built-in `init` and `import` services are present in the database.
365fn builtin_services_present(connection: &mut PgConnection) -> bool {
366  use crate::schema::services::dsl::{name, services};
367  let count: i64 = services
368    .filter(name.eq_any(["init", "import"]))
369    .count()
370    .get_result(connection)
371    .unwrap_or(0);
372  count >= 2
373}