max / makenotwork
1 file changed,
+106 insertions,
-43 deletions
| @@ -85,6 +85,47 @@ async fn main() { | |||
| 85 | 85 | } | |
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | + | // Docs integrity check mode. Loads the doc corpus with the exact production | |
| 89 | + | // config and reports broken internal links, then exits — no DB, no socket, | |
| 90 | + | // so it is cheap. Sando's `code_smoke` gate runs `MNW_CHECK_DOCS=1 <binary>` | |
| 91 | + | // as its first step, before the throwaway-DB boot, so a rotted internal link | |
| 92 | + | // fails the pipeline early rather than serving a live 404 in prod. Broken | |
| 93 | + | // links fail the check; slug collisions are reported but do not fail it | |
| 94 | + | // (pre-existing collisions shouldn't red the pipeline). Prints a stable | |
| 95 | + | // sentinel line, like MNW_CHECK_CONFIG. | |
| 96 | + | if std::env::var("MNW_CHECK_DOCS").is_ok() { | |
| 97 | + | let assumptions = match load_assumptions() { | |
| 98 | + | Ok(a) => a, | |
| 99 | + | Err(e) => { | |
| 100 | + | eprintln!("MNW_CHECK_DOCS: error: {e}"); | |
| 101 | + | std::process::exit(1); | |
| 102 | + | } | |
| 103 | + | }; | |
| 104 | + | let docs = build_doc_loader(assumptions); | |
| 105 | + | let broken = docs.broken_links(); | |
| 106 | + | for b in broken { | |
| 107 | + | eprintln!( | |
| 108 | + | " broken link: {} -> {} (no page serves this slug)", | |
| 109 | + | b.source_slug, b.target_slug | |
| 110 | + | ); | |
| 111 | + | } | |
| 112 | + | for c in docs.collisions() { | |
| 113 | + | eprintln!( | |
| 114 | + | " slug collision: {} ({} displaced by {})", | |
| 115 | + | c.slug, c.displaced_section, c.winning_section | |
| 116 | + | ); | |
| 117 | + | } | |
| 118 | + | if broken.is_empty() { | |
| 119 | + | println!( | |
| 120 | + | "MNW_CHECK_DOCS: ok ({} collision(s) reported)", | |
| 121 | + | docs.collisions().len() | |
| 122 | + | ); | |
| 123 | + | std::process::exit(0); | |
| 124 | + | } | |
| 125 | + | println!("MNW_CHECK_DOCS: {} broken link(s)", broken.len()); | |
| 126 | + | std::process::exit(1); | |
| 127 | + | } | |
| 128 | + | ||
| 88 | 129 | let config = Config::from_env().expect("Failed to load configuration"); | |
| 89 | 130 | tracing::info!("Configuration loaded"); | |
| 90 | 131 | ||
| @@ -142,7 +183,7 @@ async fn main() { | |||
| 142 | 183 | // Best-effort WAM alert (DB is up, WAM may be reachable) | |
| 143 | 184 | if let Ok(wam_url) = std::env::var("WAM_URL") { | |
| 144 | 185 | let body = format!("Migration error on startup:\n{e}"); | |
| 145 | - | let _ = reqwest::Client::new() | |
| 186 | + | let mut req = reqwest::Client::new() | |
| 146 | 187 | .post(format!("{wam_url}/tickets")) | |
| 147 | 188 | .json(&serde_json::json!({ | |
| 148 | 189 | "title": "Migration failure — server not starting", | |
| @@ -150,9 +191,13 @@ async fn main() { | |||
| 150 | 191 | "priority": "critical", | |
| 151 | 192 | "source": "migration-failure", | |
| 152 | 193 | })) | |
| 153 | - | .timeout(std::time::Duration::from_secs(5)) | |
| 154 | - | .send() | |
| 155 | - | .await; | |
| 194 | + | .timeout(std::time::Duration::from_secs(5)); | |
| 195 | + | if let Ok(token) = std::env::var("WAM_TOKEN") { | |
| 196 | + | if !token.trim().is_empty() { | |
| 197 | + | req = req.bearer_auth(token.trim()); | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | let _ = req.send().await; | |
| 156 | 201 | } | |
| 157 | 202 | ||
| 158 | 203 | // Exit code 2 — systemd configured not to restart on this code | |
| @@ -340,43 +385,12 @@ async fn main() { | |||
| 340 | 385 | ||
| 341 | 386 | // Load business assumptions (single source of truth for figures in the docs). | |
| 342 | 387 | // Validation failure aborts startup — we don't want to serve stale numbers. | |
| 343 | - | let assumptions_path = std::env::var("ASSUMPTIONS_PATH") | |
| 344 | - | .unwrap_or_else(|_| "docs/business/assumptions.toml".to_string()); | |
| 345 | - | let assumptions = std::sync::Arc::new(match Assumptions::load(&assumptions_path) { | |
| 346 | - | Ok(a) => { | |
| 347 | - | if let Err(e) = a.validate() { | |
| 348 | - | panic!("assumptions validation failed:\n{e}"); | |
| 349 | - | } | |
| 350 | - | tracing::info!(path = %assumptions_path, "assumptions loaded and validated"); | |
| 351 | - | a | |
| 352 | - | } | |
| 353 | - | Err(e) => panic!("failed to load assumptions from {assumptions_path}: {e}"), | |
| 354 | - | }); | |
| 388 | + | let assumptions = load_assumptions().unwrap_or_else(|e| panic!("{e}")); | |
| 355 | 389 | ||
| 356 | - | // Load documentation pages from disk | |
| 357 | - | let docs_path = std::env::var("DOCS_PATH").unwrap_or_else(|_| "site-docs/public".to_string()); | |
| 358 | - | let docs = std::sync::Arc::new(DocLoader::load( | |
| 359 | - | std::path::Path::new(&docs_path), | |
| 360 | - | &DocLoaderConfig { | |
| 361 | - | sections: vec![ | |
| 362 | - | ("about".to_string(), "About".to_string()), | |
| 363 | - | ("guide".to_string(), "Guide".to_string()), | |
| 364 | - | ("developer".to_string(), "Developer".to_string()), | |
| 365 | - | ("legal".to_string(), "Legal".to_string()), | |
| 366 | - | ("support".to_string(), "Support".to_string()), | |
| 367 | - | ("tech".to_string(), "Tech".to_string()), | |
| 368 | - | ], | |
| 369 | - | link_prefix: "/docs".to_string(), | |
| 370 | - | unpublished_pattern: Some("unpublished/".to_string()), | |
| 371 | - | examples_path: Some(std::path::Path::new(&docs_path).join("../examples")), | |
| 372 | - | pre_process: { | |
| 373 | - | let assumptions = assumptions.clone(); | |
| 374 | - | Some(Box::new(move |md: &str| { | |
| 375 | - | assumptions.substitute(md).map_err(|e| e.to_string()) | |
| 376 | - | })) | |
| 377 | - | }, | |
| 378 | - | }, | |
| 379 | - | )); | |
| 390 | + | // Load documentation pages from disk. The same builder backs the DB-free | |
| 391 | + | // MNW_CHECK_DOCS integrity check above, so what boots and what is checked | |
| 392 | + | // are the same corpus under the same config. | |
| 393 | + | let docs = std::sync::Arc::new(build_doc_loader(assumptions.clone())); | |
| 380 | 394 | ||
| 381 | 395 | // Initialize file scanning pipeline (optional). If scanning is configured, | |
| 382 | 396 | // assert at least one AV layer is actually live — otherwise the FailOpen | |
| @@ -447,10 +461,11 @@ async fn main() { | |||
| 447 | 461 | } | |
| 448 | 462 | }; | |
| 449 | 463 | ||
| 450 | - | // WAM ticket manager client (tailnet-only, for operational alerts) | |
| 464 | + | // WAM ticket manager client (tailnet-only, for operational alerts). The | |
| 465 | + | // optional WAM_TOKEN is the shared secret WAM enforces when set. | |
| 451 | 466 | let wam = config.integrations.wam_url.as_ref().map(|url| { | |
| 452 | 467 | tracing::info!(url = %url, "WAM integration enabled"); | |
| 453 | - | makenotwork::wam_client::WamClient::new(url.clone()) | |
| 468 | + | makenotwork::wam_client::WamClient::new(url.clone(), std::env::var("WAM_TOKEN").ok()) | |
| 454 | 469 | }); | |
| 455 | 470 | ||
| 456 | 471 | // Warm custom domain cache | |
| @@ -484,7 +499,7 @@ async fn main() { | |||
| 484 | 499 | tier_prices.clone().install_global(); | |
| 485 | 500 | let runway_config = makenotwork::tier_prices::RunwayConfig::from_assumptions(&assumptions); | |
| 486 | 501 | let pricing_comparison = | |
| 487 | - | makenotwork::pricing_comparison::PricingComparison::load(&assumptions_path); | |
| 502 | + | makenotwork::pricing_comparison::PricingComparison::load(assumptions_path()); | |
| 488 | 503 | ||
| 489 | 504 | let state = AppState::build(makenotwork::AppStateParts { | |
| 490 | 505 | db, | |
| @@ -699,6 +714,54 @@ async fn main() { | |||
| 699 | 714 | tracing::info!("Server shut down gracefully"); | |
| 700 | 715 | } | |
| 701 | 716 | ||
| 717 | + | /// Filesystem path to the business-assumptions TOML, `ASSUMPTIONS_PATH` or its | |
| 718 | + | /// default. Single source for the default so every reader agrees. | |
| 719 | + | fn assumptions_path() -> String { | |
| 720 | + | std::env::var("ASSUMPTIONS_PATH") | |
| 721 | + | .unwrap_or_else(|_| "docs/business/assumptions.toml".to_string()) | |
| 722 | + | } | |
| 723 | + | ||
| 724 | + | /// Load and validate business assumptions. `Err` carries a human-readable | |
| 725 | + | /// message; normal startup turns it into a panic, the check modes into a | |
| 726 | + | /// sentinel line. Shared so the two paths validate identically. | |
| 727 | + | fn load_assumptions() -> Result<std::sync::Arc<Assumptions>, String> { | |
| 728 | + | let assumptions_path = assumptions_path(); | |
| 729 | + | let a = Assumptions::load(&assumptions_path) | |
| 730 | + | .map_err(|e| format!("failed to load assumptions from {assumptions_path}: {e}"))?; | |
| 731 | + | a.validate() | |
| 732 | + | .map_err(|e| format!("assumptions validation failed:\n{e}"))?; | |
| 733 | + | tracing::info!(path = %assumptions_path, "assumptions loaded and validated"); | |
| 734 | + | Ok(std::sync::Arc::new(a)) | |
| 735 | + | } | |
| 736 | + | ||
| 737 | + | /// Build the documentation loader from disk with the production config. | |
| 738 | + | /// | |
| 739 | + | /// Shared by normal startup and the `MNW_CHECK_DOCS` integrity check so both | |
| 740 | + | /// see the exact same sections, link prefix, examples path, and assumption | |
| 741 | + | /// substitution — the broken-link report can't drift from what is served. | |
| 742 | + | fn build_doc_loader(assumptions: std::sync::Arc<Assumptions>) -> DocLoader { | |
| 743 | + | let docs_path = std::env::var("DOCS_PATH").unwrap_or_else(|_| "site-docs/public".to_string()); | |
| 744 | + | DocLoader::load( | |
| 745 | + | std::path::Path::new(&docs_path), | |
| 746 | + | &DocLoaderConfig { | |
| 747 | + | sections: vec![ | |
| 748 | + | ("about".to_string(), "About".to_string()), | |
| 749 | + | ("guide".to_string(), "Guide".to_string()), | |
| 750 | + | ("developer".to_string(), "Developer".to_string()), | |
| 751 | + | ("legal".to_string(), "Legal".to_string()), | |
| 752 | + | ("support".to_string(), "Support".to_string()), | |
| 753 | + | ("tech".to_string(), "Tech".to_string()), | |
| 754 | + | ], | |
| 755 | + | link_prefix: "/docs".to_string(), | |
| 756 | + | unpublished_pattern: Some("unpublished/".to_string()), | |
| 757 | + | examples_path: Some(std::path::Path::new(&docs_path).join("../examples")), | |
| 758 | + | pre_process: Some(Box::new(move |md: &str| { | |
| 759 | + | assumptions.substitute(md).map_err(|e| e.to_string()) | |
| 760 | + | })), | |
| 761 | + | }, | |
| 762 | + | ) | |
| 763 | + | } | |
| 764 | + | ||
| 702 | 765 | /// Shutdown budget, reconciled so the background-pool drain can't be truncated | |
| 703 | 766 | /// by the hard-exit deadline (audit Run 23 F4). The hard-exit timer arms at | |
| 704 | 767 | /// signal receipt and must outlast the in-flight request drain PLUS the |