feat(ops): export DetMir readiness act

This commit is contained in:
igor04091968
2026-06-03 20:11:30 +03:00
parent f5f0cebfc4
commit ea5bedc3c3
3 changed files with 358 additions and 10 deletions
+4
View File
@@ -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.
+336 -9
View File
@@ -62,6 +62,18 @@ struct Cli {
#[arg(long, default_value = DEFAULT_GRAFANA_DATASOURCE_UID)]
grafana_datasource_uid: String,
#[arg(long)]
output_json: Option<PathBuf>,
#[arg(long)]
output_markdown: Option<PathBuf>,
#[arg(long)]
output_html: Option<PathBuf>,
#[arg(long)]
output_pdf: Option<PathBuf>,
}
#[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<Report> {
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<String, String>) -> 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::<Value>().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, String>,
) -> (String, Option<String>, Option<String>) {
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!(
"<tr><td class=\"{}\">{}</td><td>{}</td><td>{}</td></tr>",
html_escape(&check.status.to_string().to_ascii_lowercase()),
html_escape(&check.status.to_string()),
html_escape(&check.name),
html_escape(&check.summary)
)
})
.collect::<Vec<_>>()
.join("\n");
format!(
r#"<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Акт готовности стенда DetMir</title>
<style>
body {{ font-family: sans-serif; margin: 32px; color: #1f2933; }}
h1 {{ font-size: 24px; }}
.status {{ font-size: 20px; font-weight: 700; }}
.ok {{ color: #166534; font-weight: 700; }}
.warn {{ color: #92400e; font-weight: 700; }}
.fail,.unknown {{ color: #991b1b; font-weight: 700; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 16px; }}
th,td {{ border: 1px solid #cbd5e1; padding: 8px; text-align: left; vertical-align: top; }}
th {{ background: #f1f5f9; }}
</style>
</head>
<body>
<h1>Акт готовности стенда DetMir</h1>
<p class="status">Статус: <span class="{status_class}">{status}</span></p>
<p>Готовность: <strong>{ready}</strong></p>
<p>Сформировано UTC: <code>{generated}</code></p>
<p>Проверки: OK <strong>{ok}</strong>, WARN <strong>{warn}</strong>, FAIL <strong>{fail}</strong></p>
<h2>Результаты проверок</h2>
<table>
<thead><tr><th>Статус</th><th>Проверка</th><th>Результат</th></tr></thead>
<tbody>
{rows}
</tbody>
</table>
<h2>Решение</h2>
<p>{decision}</p>
</body>
</html>"#,
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
#[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);
}
}