diff --git a/adk-rust/crates/detmir-portal/src/main.rs b/adk-rust/crates/detmir-portal/src/main.rs index 122b39c..5bd96d0 100644 --- a/adk-rust/crates/detmir-portal/src/main.rs +++ b/adk-rust/crates/detmir-portal/src/main.rs @@ -29,11 +29,11 @@ mod executive_actions; mod path_query; mod portal_roles; mod production; +mod readiness_api; mod risk_narrative; mod workforce_kpi_explain; use api_contracts::api_contract_summary; -use command_runner::run_in_dir; use executive_actions::{ actions_from_center, build_action_center_from_report, filter_actions_for_role, }; @@ -48,6 +48,7 @@ use production::{ record_ingestion_rejected, record_report_generated, render_prometheus_metrics, validate_api_query_limits, validate_portal_config, }; +use readiness_api::{readiness_bundle, readiness_latest, readiness_verify}; use risk_narrative::{ RiskNarrativeInputs, RiskNarrativeQuery, build_risk_narrative, build_risk_narrative_from_report, }; @@ -1662,103 +1663,6 @@ fn handle_evidence_only_request(request: Request, args: &Cli) -> Result<()> { ) } -fn readiness_latest(args: &Cli) -> Value { - read_json_file( - &args - .readiness_bundle_dir - .join("detmir-readiness-latest.json"), - ) - .unwrap_or_else(|err| { - json!({ - "ok": false, - "generated_at_utc": now(), - "error": err.to_string(), - }) - }) -} - -fn readiness_bundle(args: &Cli) -> Value { - let dir = &args.readiness_bundle_dir; - let status = read_json_file(&dir.join("detmir-readiness-status.json")).unwrap_or_else(|err| { - json!({ - "ok": false, - "error": err.to_string(), - }) - }); - let latest_dir = fs::read_to_string(dir.join("latest-dir.txt")) - .unwrap_or_default() - .trim() - .to_string(); - let artifacts = [ - "detmir-readiness-latest.json", - "detmir-readiness-act.md", - "detmir-readiness-act.html", - "sha256sums.txt", - "sha256sums.txt.sig", - "public-key.pem", - "detmir-readiness-status.json", - "detmir-readiness.prom", - ] - .into_iter() - .filter_map(|name| { - let path = dir.join(name); - path.metadata().ok().map(|meta| { - json!({ - "name": name, - "bytes": meta.len(), - "available": true, - }) - }) - }) - .collect::>(); - json!({ - "ok": status.get("ok").and_then(Value::as_bool).unwrap_or(false), - "generated_at_utc": now(), - "bundle_dir": dir.display().to_string(), - "latest_archive_dir": latest_dir, - "status": status, - "artifacts": artifacts, - }) -} - -fn readiness_verify(args: &Cli) -> Value { - let dir = &args.readiness_bundle_dir; - let checksum = run_in_dir( - dir, - Command::new("sha256sum").arg("-c").arg("sha256sums.txt"), - ); - let sig_path = dir.join("sha256sums.txt.sig"); - let pub_path = dir.join("public-key.pem"); - let signature = if sig_path.is_file() && pub_path.is_file() { - run_in_dir( - dir, - Command::new("openssl") - .arg("dgst") - .arg("-sha256") - .arg("-verify") - .arg("public-key.pem") - .arg("-signature") - .arg("sha256sums.txt.sig") - .arg("sha256sums.txt"), - ) - } else { - Err("signature files are not available".to_string()) - }; - json!({ - "ok": checksum.is_ok() && signature.is_ok(), - "generated_at_utc": now(), - "checksum_verified": checksum.is_ok(), - "signature_verified": signature.is_ok(), - "checksum_error": checksum.err(), - "signature_error": signature.err(), - }) -} - -fn read_json_file(path: &Path) -> Result { - let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; - serde_json::from_str(&text).with_context(|| format!("parse {}", path.display())) -} - fn portal_role_from_request(request: &Request, url: &str) -> PortalRole { query_param(url, "role") .as_deref() @@ -10714,7 +10618,7 @@ fn header(name: &str, value: &str) -> Result
{ .map_err(|_| anyhow!("invalid header {name}: {value}")) } -fn now() -> String { +pub(crate) fn now() -> String { Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true) } diff --git a/adk-rust/crates/detmir-portal/src/readiness_api.rs b/adk-rust/crates/detmir-portal/src/readiness_api.rs new file mode 100644 index 0000000..cf9f237 --- /dev/null +++ b/adk-rust/crates/detmir-portal/src/readiness_api.rs @@ -0,0 +1,112 @@ +//! Readiness API payload helpers for the portal. +//! +//! CONTRACT: these helpers expose existing readiness bundle/status/verify +//! payloads. Keep file names, JSON fields and verification commands stable +//! unless the customer readiness contract is updated in the same PR. + +use std::fs; +use std::path::Path; +use std::process::Command; + +use anyhow::{Context, Result}; +use serde_json::{Value, json}; + +use crate::command_runner::run_in_dir; +use crate::{Cli, now}; + +pub(crate) fn readiness_latest(args: &Cli) -> Value { + read_json_file( + &args + .readiness_bundle_dir + .join("detmir-readiness-latest.json"), + ) + .unwrap_or_else(|err| { + json!({ + "ok": false, + "generated_at_utc": now(), + "error": err.to_string(), + }) + }) +} + +pub(crate) fn readiness_bundle(args: &Cli) -> Value { + let dir = &args.readiness_bundle_dir; + let status = read_json_file(&dir.join("detmir-readiness-status.json")).unwrap_or_else(|err| { + json!({ + "ok": false, + "error": err.to_string(), + }) + }); + let latest_dir = fs::read_to_string(dir.join("latest-dir.txt")) + .unwrap_or_default() + .trim() + .to_string(); + let artifacts = [ + "detmir-readiness-latest.json", + "detmir-readiness-act.md", + "detmir-readiness-act.html", + "sha256sums.txt", + "sha256sums.txt.sig", + "public-key.pem", + "detmir-readiness-status.json", + "detmir-readiness.prom", + ] + .into_iter() + .filter_map(|name| { + let path = dir.join(name); + path.metadata().ok().map(|meta| { + json!({ + "name": name, + "bytes": meta.len(), + "available": true, + }) + }) + }) + .collect::>(); + json!({ + "ok": status.get("ok").and_then(Value::as_bool).unwrap_or(false), + "generated_at_utc": now(), + "bundle_dir": dir.display().to_string(), + "latest_archive_dir": latest_dir, + "status": status, + "artifacts": artifacts, + }) +} + +pub(crate) fn readiness_verify(args: &Cli) -> Value { + let dir = &args.readiness_bundle_dir; + let checksum = run_in_dir( + dir, + Command::new("sha256sum").arg("-c").arg("sha256sums.txt"), + ); + let sig_path = dir.join("sha256sums.txt.sig"); + let pub_path = dir.join("public-key.pem"); + let signature = if sig_path.is_file() && pub_path.is_file() { + run_in_dir( + dir, + Command::new("openssl") + .arg("dgst") + .arg("-sha256") + .arg("-verify") + .arg("public-key.pem") + .arg("-signature") + .arg("sha256sums.txt.sig") + .arg("sha256sums.txt"), + ) + } else { + Err("signature files are not available".to_string()) + }; + json!({ + "ok": checksum.is_ok() && signature.is_ok(), + "generated_at_utc": now(), + "checksum_verified": checksum.is_ok(), + "signature_verified": signature.is_ok(), + "checksum_error": checksum.err(), + "signature_error": signature.err(), + }) +} + +fn read_json_file(path: &Path) -> Result { + let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; + serde_json::from_str(&text).with_context(|| format!("parse {}", path.display())) +}