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`
|
- [ ] `aw-worktime-influx-exporter.service` and `aw-dlp-influx-exporter.service`
|
||||||
complete once and write points;
|
complete once and write points;
|
||||||
- [ ] `detmir-readiness --json` returns `status=OK`;
|
- [ ] `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;
|
- [ ] `detmir-check --json`, `detmir-status --json` and Grafana check are green;
|
||||||
- [ ] rollback path for changed binaries/env files is known.
|
- [ ] rollback path for changed binaries/env files is known.
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,18 @@ struct Cli {
|
|||||||
|
|
||||||
#[arg(long, default_value = DEFAULT_GRAFANA_DATASOURCE_UID)]
|
#[arg(long, default_value = DEFAULT_GRAFANA_DATASOURCE_UID)]
|
||||||
grafana_datasource_uid: String,
|
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)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -104,6 +116,10 @@ fn main() {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let code = match run(&cli) {
|
let code = match run(&cli) {
|
||||||
Ok(report) => {
|
Ok(report) => {
|
||||||
|
if let Err(err) = write_outputs(&cli, &report) {
|
||||||
|
eprintln!("{err:#}");
|
||||||
|
exit_codes::ERROR
|
||||||
|
} else {
|
||||||
if cli.json {
|
if cli.json {
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
@@ -112,7 +128,8 @@ fn main() {
|
|||||||
} else {
|
} else {
|
||||||
print_text(&report);
|
print_text(&report);
|
||||||
}
|
}
|
||||||
report.status.exit_code()
|
readiness_exit_code(report.status)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
eprintln!("{err:#}");
|
eprintln!("{err:#}");
|
||||||
@@ -160,11 +177,12 @@ fn run(cli: &Cli) -> Result<Report> {
|
|||||||
|
|
||||||
if cli.skip_grafana {
|
if cli.skip_grafana {
|
||||||
checks.push(warn(
|
checks.push(warn(
|
||||||
"grafana:datasource",
|
"grafana",
|
||||||
"Grafana datasource check skipped",
|
"Grafana API and datasource checks skipped",
|
||||||
json!({}),
|
json!({}),
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
|
checks.push(check_grafana_api(&client, cli, &grafana_env));
|
||||||
checks.push(check_grafana_datasource(
|
checks.push(check_grafana_datasource(
|
||||||
&client,
|
&client,
|
||||||
cli,
|
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 {
|
fn escape_tag(value: &str) -> String {
|
||||||
value
|
value
|
||||||
.replace('\\', "\\\\")
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -578,4 +881,28 @@ mod tests {
|
|||||||
assert_eq!(counts.warn, 1);
|
assert_eq!(counts.warn, 1);
|
||||||
assert_eq!(counts.fail, 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ detmir-readiness --json
|
|||||||
Коды возврата:
|
Коды возврата:
|
||||||
|
|
||||||
- `0` - готово к промышленной эксплуатации;
|
- `0` - готово к промышленной эксплуатации;
|
||||||
- `2` - readiness check нашел `WARN` или `FAIL`;
|
- `2` - readiness check нашел `WARN`;
|
||||||
|
- `3` - readiness check нашел `FAIL`;
|
||||||
- `1` - сама команда не смогла выполниться.
|
- `1` - сама команда не смогла выполниться.
|
||||||
|
|
||||||
## Private production inventory
|
## Private production inventory
|
||||||
@@ -56,6 +57,22 @@ tracked defaults и `.example` файлы могут содержать `HOST-EX
|
|||||||
- Influx write-probe не смог записать heartbeat;
|
- Influx write-probe не смог записать heartbeat;
|
||||||
- Grafana datasource health не `OK`.
|
- 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
|
```bash
|
||||||
|
|||||||
Reference in New Issue
Block a user