feat(ops): export DetMir readiness act
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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('&', "&")
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user