diff --git a/adk-rust/RUNBOOK.md b/adk-rust/RUNBOOK.md index 00b99aa..cf432cb 100644 --- a/adk-rust/RUNBOOK.md +++ b/adk-rust/RUNBOOK.md @@ -639,6 +639,10 @@ Production readiness checklist: - [ ] `aw-worktime-influx-exporter.service` and `aw-dlp-influx-exporter.service` complete once and write points; - [ ] `detmir-readiness --json` returns `status=OK`; +- [ ] readiness act is generated with + `--output-markdown /var/lib/activitywatch/health/detmir-readiness-act.md` + and, when PDF renderer exists, + `--output-pdf /var/lib/activitywatch/health/detmir-readiness-act.pdf`; - [ ] `detmir-check --json`, `detmir-status --json` and Grafana check are green; - [ ] rollback path for changed binaries/env files is known. diff --git a/adk-rust/crates/detmir-readiness/src/main.rs b/adk-rust/crates/detmir-readiness/src/main.rs index 1e11df8..cf846b7 100644 --- a/adk-rust/crates/detmir-readiness/src/main.rs +++ b/adk-rust/crates/detmir-readiness/src/main.rs @@ -62,6 +62,18 @@ struct Cli { #[arg(long, default_value = DEFAULT_GRAFANA_DATASOURCE_UID)] grafana_datasource_uid: String, + + #[arg(long)] + output_json: Option, + + #[arg(long)] + output_markdown: Option, + + #[arg(long)] + output_html: Option, + + #[arg(long)] + output_pdf: Option, } #[derive(Debug, Serialize)] @@ -104,15 +116,20 @@ fn main() { let cli = Cli::parse(); let code = match run(&cli) { Ok(report) => { - if cli.json { - println!( - "{}", - serde_json::to_string_pretty(&report).expect("serialize report") - ); + if let Err(err) = write_outputs(&cli, &report) { + eprintln!("{err:#}"); + exit_codes::ERROR } else { - print_text(&report); + if cli.json { + println!( + "{}", + serde_json::to_string_pretty(&report).expect("serialize report") + ); + } else { + print_text(&report); + } + readiness_exit_code(report.status) } - report.status.exit_code() } Err(err) => { eprintln!("{err:#}"); @@ -160,11 +177,12 @@ fn run(cli: &Cli) -> Result { if cli.skip_grafana { checks.push(warn( - "grafana:datasource", - "Grafana datasource check skipped", + "grafana", + "Grafana API and datasource checks skipped", json!({}), )); } else { + checks.push(check_grafana_api(&client, cli, &grafana_env)); checks.push(check_grafana_datasource( &client, cli, @@ -458,6 +476,69 @@ fn check_grafana_datasource( } } +fn check_grafana_api(client: &Client, cli: &Cli, env: &BTreeMap) -> Check { + let (grafana_url, user, password) = grafana_auth(cli, env); + let url = format!("{grafana_url}/api/health"); + let mut request = client.get(url); + if let Some(user) = user.as_deref() { + request = request.basic_auth(user, password.as_deref()); + } + match request.send().and_then(|resp| resp.error_for_status()) { + Ok(resp) => { + let value = resp.json::().unwrap_or_else(|_| json!({})); + let database = value.get("database").and_then(Value::as_str).unwrap_or(""); + if database.eq_ignore_ascii_case("ok") || value.get("version").is_some() { + ok( + "grafana:api", + "Grafana API health is reachable", + json!({ + "grafana_url": grafana_url, + "auth_present": user.is_some(), + "version": value.get("version").and_then(Value::as_str), + "database": database, + }), + ) + } else { + fail( + "grafana:api", + "Grafana API health returned unexpected payload", + json!({ "grafana_url": grafana_url, "auth_present": user.is_some() }), + ) + } + } + Err(err) => fail( + "grafana:api", + format!("Grafana API health request failed: {err}"), + json!({ "grafana_url": grafana_url, "auth_present": user.is_some() }), + ), + } +} + +fn grafana_auth( + cli: &Cli, + env: &BTreeMap, +) -> (String, Option, Option) { + let grafana_url = cli + .grafana_url + .clone() + .or_else(|| env.get("DETMIR_GRAFANA_URL").cloned()) + .or_else(|| env.get("GRAFANA_URL").cloned()) + .unwrap_or_else(|| DEFAULT_GRAFANA_URL.to_string()) + .trim_end_matches('/') + .to_string(); + let user = cli + .grafana_user + .clone() + .or_else(|| env.get("DETMIR_GRAFANA_USER").cloned()) + .or_else(|| env.get("GRAFANA_USER").cloned()); + let password = cli + .grafana_password + .clone() + .or_else(|| env.get("DETMIR_GRAFANA_PASSWORD").cloned()) + .or_else(|| env.get("GRAFANA_PASSWORD").cloned()); + (grafana_url, user, password) +} + fn escape_tag(value: &str) -> String { value .replace('\\', "\\\\") @@ -519,6 +600,228 @@ fn print_text(report: &Report) { } } +fn readiness_exit_code(status: StatusLevel) -> i32 { + match status { + StatusLevel::Ok => exit_codes::OK, + StatusLevel::Warn => exit_codes::CHECK_FAILED, + StatusLevel::Fail | StatusLevel::Unknown => exit_codes::POLICY_DENIED, + } +} + +fn write_outputs(cli: &Cli, report: &Report) -> Result<()> { + if let Some(path) = &cli.output_json { + write_text(path, &serde_json::to_string_pretty(report)?)?; + } + if let Some(path) = &cli.output_markdown { + write_text(path, &render_markdown(report))?; + } + if let Some(path) = &cli.output_html { + write_text(path, &render_html(report))?; + } + if let Some(path) = &cli.output_pdf { + write_pdf(path, report)?; + } + Ok(()) +} + +fn write_text(path: &Path, text: &str) -> Result<()> { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent) + .with_context(|| format!("create output directory: {}", parent.display()))?; + } + fs::write(path, text).with_context(|| format!("write output file: {}", path.display())) +} + +fn write_pdf(path: &Path, report: &Report) -> Result<()> { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + fs::create_dir_all(parent) + .with_context(|| format!("create output directory: {}", parent.display()))?; + } + let dir = tempfile::tempdir().context("create temporary PDF render directory")?; + let html_path = dir.path().join("detmir-readiness.html"); + write_text(&html_path, &render_html(report))?; + + if command_exists("weasyprint") { + run_pdf_command(Command::new("weasyprint").arg(&html_path).arg(path)) + } else if command_exists("chromium") { + run_chromium_pdf("chromium", &html_path, path) + } else if command_exists("chromium-browser") { + run_chromium_pdf("chromium-browser", &html_path, path) + } else if command_exists("google-chrome") { + run_chromium_pdf("google-chrome", &html_path, path) + } else { + anyhow::bail!( + "PDF output requires one of: weasyprint, chromium, chromium-browser, google-chrome" + ); + } +} + +fn command_exists(name: &str) -> bool { + Command::new("sh") + .arg("-c") + .arg(format!("command -v {}", shell_quote(name))) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +fn run_chromium_pdf(binary: &str, html_path: &Path, pdf_path: &Path) -> Result<()> { + let html_uri = format!("file://{}", html_path.display()); + run_pdf_command( + Command::new(binary) + .arg("--headless") + .arg("--disable-gpu") + .arg("--no-sandbox") + .arg(format!("--print-to-pdf={}", pdf_path.display())) + .arg(html_uri), + ) +} + +fn run_pdf_command(command: &mut Command) -> Result<()> { + let output = command.output().context("run PDF renderer")?; + if output.status.success() { + Ok(()) + } else { + anyhow::bail!( + "PDF renderer failed: {}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn render_markdown(report: &Report) -> String { + let mut out = String::new(); + out.push_str("# Акт готовности стенда DetMir\n\n"); + out.push_str(&format!("- Статус: **{}**\n", report.status)); + out.push_str(&format!( + "- Готовность: **{}**\n", + if report.ok { "да" } else { "нет" } + )); + out.push_str(&format!( + "- Сформировано UTC: `{}`\n", + report.generated_at_utc + )); + out.push_str(&format!( + "- Проверки: OK `{}`, WARN `{}`, FAIL `{}`\n\n", + report.counts.ok, report.counts.warn, report.counts.fail + )); + out.push_str("## Результаты проверок\n\n"); + out.push_str("| Статус | Проверка | Результат |\n"); + out.push_str("| --- | --- | --- |\n"); + for check in &report.checks { + out.push_str(&format!( + "| {} | `{}` | {} |\n", + check.status, + escape_markdown_table(&check.name), + escape_markdown_table(&check.summary) + )); + } + out.push_str("\n## Решение\n\n"); + match report.status { + StatusLevel::Ok => { + out.push_str("Стенд готов к промышленной эксплуатации по проверенным критериям.\n") + } + StatusLevel::Warn => out.push_str( + "Стенд имеет предупреждения. Перед промышленной эксплуатацией требуется управленческое принятие риска или устранение предупреждений.\n", + ), + StatusLevel::Fail | StatusLevel::Unknown => { + out.push_str("Стенд не готов к промышленной эксплуатации до устранения отказов.\n") + } + } + out +} + +fn render_html(report: &Report) -> String { + let rows = report + .checks + .iter() + .map(|check| { + format!( + "{}{}{}", + html_escape(&check.status.to_string().to_ascii_lowercase()), + html_escape(&check.status.to_string()), + html_escape(&check.name), + html_escape(&check.summary) + ) + }) + .collect::>() + .join("\n"); + format!( + r#" + + + +Акт готовности стенда DetMir + + + +

Акт готовности стенда DetMir

+

Статус: {status}

+

Готовность: {ready}

+

Сформировано UTC: {generated}

+

Проверки: OK {ok}, WARN {warn}, FAIL {fail}

+

Результаты проверок

+ + + +{rows} + +
СтатусПроверкаРезультат
+

Решение

+

{decision}

+ +"#, + status_class = html_escape(&report.status.to_string().to_ascii_lowercase()), + status = html_escape(&report.status.to_string()), + ready = if report.ok { "да" } else { "нет" }, + generated = html_escape(&report.generated_at_utc), + ok = report.counts.ok, + warn = report.counts.warn, + fail = report.counts.fail, + rows = rows, + decision = html_escape(match report.status { + StatusLevel::Ok => "Стенд готов к промышленной эксплуатации по проверенным критериям.", + StatusLevel::Warn => + "Стенд имеет предупреждения. Требуется принятие риска или устранение предупреждений.", + StatusLevel::Fail | StatusLevel::Unknown => + "Стенд не готов к промышленной эксплуатации до устранения отказов.", + }), + ) +} + +fn escape_markdown_table(value: &str) -> String { + value.replace('|', "\\|").replace('\n', " ") +} + +fn html_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + #[cfg(test)] mod tests { use super::*; @@ -578,4 +881,28 @@ mod tests { assert_eq!(counts.warn, 1); assert_eq!(counts.fail, 1); } + + #[test] + fn renders_readiness_act_without_secrets() { + let checks = vec![ok( + "env:AW_WORKTIME_INFLUX", + "Influx runtime env is production-ready", + json!({ "token_redacted": true }), + )]; + let (status, counts) = summarize(&checks); + let report = Report { + ok: status == StatusLevel::Ok, + status, + generated_at_utc: "2026-06-03T12:00:00Z".to_string(), + counts, + checks, + }; + let markdown = render_markdown(&report); + assert!(markdown.contains("Акт готовности стенда DetMir")); + assert!(markdown.contains("env:AW_WORKTIME_INFLUX")); + assert!(!markdown.contains("prod-write-token-value")); + assert_eq!(readiness_exit_code(StatusLevel::Ok), 0); + assert_eq!(readiness_exit_code(StatusLevel::Warn), 2); + assert_eq!(readiness_exit_code(StatusLevel::Fail), 3); + } } diff --git a/docs/PRODUCTION_READINESS_RU.md b/docs/PRODUCTION_READINESS_RU.md index 94671f7..ac3ae4f 100644 --- a/docs/PRODUCTION_READINESS_RU.md +++ b/docs/PRODUCTION_READINESS_RU.md @@ -30,7 +30,8 @@ detmir-readiness --json Коды возврата: - `0` - готово к промышленной эксплуатации; -- `2` - readiness check нашел `WARN` или `FAIL`; +- `2` - readiness check нашел `WARN`; +- `3` - readiness check нашел `FAIL`; - `1` - сама команда не смогла выполниться. ## Private production inventory @@ -56,6 +57,22 @@ tracked defaults и `.example` файлы могут содержать `HOST-EX - Influx write-probe не смог записать heartbeat; - Grafana datasource health не `OK`. +## Акт готовности стенда + +`detmir-readiness` может сохранить акт готовности в JSON, Markdown, HTML и PDF: + +```bash +detmir-readiness --json \ + --output-json /var/lib/activitywatch/health/detmir-readiness-latest.json \ + --output-markdown /var/lib/activitywatch/health/detmir-readiness-act.md \ + --output-pdf /var/lib/activitywatch/health/detmir-readiness-act.pdf +``` + +PDF-вывод требует один из render tools на хосте: `weasyprint`, `chromium`, +`chromium-browser` или `google-chrome`. Если PDF renderer не установлен, +используйте `--output-markdown` и `--output-html` как обязательный минимальный +артефакт внедрения. + ## Полезные параметры ```bash