1use 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
24pub(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#[derive(Debug, Serialize)]
40pub struct DoctorReport {
41 pub database_reachable: bool,
43 pub migrations_current: bool,
45 pub services_seeded: bool,
47 pub admin_token_configured: bool,
52 pub ok: bool,
54}
55
56impl DoctorReport {
57 #[must_use]
62 pub fn remediations(&self) -> Vec<String> {
63 let mut hints = Vec::new();
64 if !self.database_reachable {
65 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 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#[derive(Debug, Serialize)]
102pub struct InitOutcome {
103 pub migrations_applied: Vec<String>,
105 pub config_created: bool,
107}
108
109pub fn doctor(database_url: &str) -> DoctorReport {
111 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
139const MIN_PG_VERSION_NUM: i32 = 180_000;
145
146fn 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
157pub 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 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 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#[derive(Debug, Serialize)]
202pub struct SetTokenOutcome {
203 pub owner: String,
205 pub replaced: bool,
207 pub token_count: usize,
209}
210
211pub fn generate_token() -> String {
216 rand::rng()
217 .sample_iter(&Alphanumeric)
218 .take(32)
219 .map(char::from)
220 .collect()
221}
222
223pub 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 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#[derive(Debug, Serialize)]
262pub struct RevokeTokenOutcome {
263 pub revoked: usize,
265 pub token_count: usize,
267}
268
269pub 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
318pub 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
340fn 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
355fn 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
364fn 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}