feat(ops): add signed readiness bundle

This commit is contained in:
igor04091968
2026-06-03 20:30:05 +03:00
parent ea5bedc3c3
commit 3dc7214b58
8 changed files with 293 additions and 17 deletions
@@ -14,5 +14,6 @@ detmir-core.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
sha2.workspace = true
tempfile.workspace = true
urlencoding.workspace = true
+173 -13
View File
@@ -12,6 +12,7 @@ use detmir_core::{StatusLevel, exit_codes, now_utc_rfc3339};
use reqwest::blocking::Client;
use serde::Serialize;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
const DEFAULT_AW_ENV_FILE: &str = "/etc/activitywatch/aw-server.env";
const DEFAULT_GRAFANA_ENV_FILE: &str = "/etc/detmir-grafana-check.env";
@@ -39,16 +40,16 @@ struct Cli {
#[arg(long, default_value_t = 15)]
timeout_seconds: u64,
#[arg(long)]
#[arg(long, env = "DETMIR_READINESS_SKIP_SYSTEMD")]
skip_systemd: bool,
#[arg(long)]
#[arg(long, env = "DETMIR_READINESS_SKIP_INFLUX_WRITE")]
skip_influx_write: bool,
#[arg(long)]
#[arg(long, env = "DETMIR_READINESS_ALLOW_DISABLED_INFLUX")]
allow_disabled_influx: bool,
#[arg(long)]
#[arg(long, env = "DETMIR_READINESS_SKIP_GRAFANA")]
skip_grafana: bool,
#[arg(long)]
@@ -60,7 +61,11 @@ struct Cli {
#[arg(long)]
grafana_password: Option<String>,
#[arg(long, default_value = DEFAULT_GRAFANA_DATASOURCE_UID)]
#[arg(
long,
env = "DETMIR_GRAFANA_DATASOURCE_UID",
default_value = DEFAULT_GRAFANA_DATASOURCE_UID
)]
grafana_datasource_uid: String,
#[arg(long)]
@@ -74,6 +79,12 @@ struct Cli {
#[arg(long)]
output_pdf: Option<PathBuf>,
#[arg(long)]
output_dir: Option<PathBuf>,
#[arg(long, env = "DETMIR_GIT_COMMIT", default_value = "unknown")]
git_commit: String,
}
#[derive(Debug, Serialize)]
@@ -81,8 +92,19 @@ struct Report {
ok: bool,
status: StatusLevel,
generated_at_utc: String,
generated_by: GeneratedBy,
host: String,
version: String,
git_commit: String,
counts: Counts,
checks: Vec<Check>,
limitations: Vec<String>,
}
#[derive(Debug, Serialize)]
struct GeneratedBy {
name: String,
version: String,
}
#[derive(Debug, Default, Serialize)]
@@ -192,12 +214,21 @@ fn run(cli: &Cli) -> Result<Report> {
}
let (status, counts) = summarize(&checks);
let version = env!("CARGO_PKG_VERSION").to_string();
Ok(Report {
ok: status == StatusLevel::Ok,
status,
generated_at_utc: now_utc_rfc3339(),
generated_by: GeneratedBy {
name: "detmir-readiness".to_string(),
version: version.clone(),
},
host: hostname(),
version,
git_commit: cli.git_commit.clone(),
counts,
checks,
limitations: build_limitations(cli),
})
}
@@ -265,6 +296,48 @@ fn split_csv(value: &str) -> Vec<String> {
.collect()
}
fn hostname() -> String {
Command::new("hostname")
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "unknown".to_string())
}
fn build_limitations(cli: &Cli) -> Vec<String> {
let mut limitations = Vec::new();
limitations.push(
"Проверка подтверждает состояние runtime на момент формирования акта и не заменяет аудит конфигурации, нагрузочное тестирование или приемочные испытания заказчика.".to_string(),
);
limitations.push(
"Секреты, токены и пароли не включаются в JSON, Markdown, HTML, PDF и checksum bundle."
.to_string(),
);
if cli.skip_systemd {
limitations.push("Проверки systemd были пропущены параметром --skip-systemd.".to_string());
}
if cli.skip_influx_write {
limitations.push(
"Реальная запись тестовой точки в InfluxDB была пропущена параметром --skip-influx-write."
.to_string(),
);
}
if cli.skip_grafana {
limitations.push(
"Проверки Grafana API/datasource были пропущены параметром --skip-grafana.".to_string(),
);
}
if cli.allow_disabled_influx {
limitations.push(
"Отключенный Influx тракт допускается как WARN из-за параметра --allow-disabled-influx."
.to_string(),
);
}
limitations
}
fn influx_config(env: &BTreeMap<String, String>, prefix: &'static str) -> InfluxConfig {
let enabled_key = match prefix {
"AW_WORKTIME_INFLUX" => "AW_WORKTIME_INFLUX_ENABLED",
@@ -609,6 +682,9 @@ fn readiness_exit_code(status: StatusLevel) -> i32 {
}
fn write_outputs(cli: &Cli, report: &Report) -> Result<()> {
if let Some(dir) = &cli.output_dir {
write_bundle(dir, report)?;
}
if let Some(path) = &cli.output_json {
write_text(path, &serde_json::to_string_pretty(report)?)?;
}
@@ -624,6 +700,38 @@ fn write_outputs(cli: &Cli, report: &Report) -> Result<()> {
Ok(())
}
fn write_bundle(dir: &Path, report: &Report) -> Result<()> {
fs::create_dir_all(dir).with_context(|| format!("create output dir: {}", dir.display()))?;
let files = [
(
"detmir-readiness-latest.json",
serde_json::to_string_pretty(report)?,
),
("detmir-readiness-act.md", render_markdown(report)),
("detmir-readiness-act.html", render_html(report)),
];
let mut sums = Vec::new();
for (name, content) in files {
let path = dir.join(name);
write_text(&path, &content)?;
sums.push((name.to_string(), sha256_file(&path)?));
}
let sums_text = sums
.into_iter()
.map(|(name, sum)| format!("{sum} {name}\n"))
.collect::<String>();
write_text(&dir.join("sha256sums.txt"), &sums_text)?;
Ok(())
}
fn sha256_file(path: &Path) -> Result<String> {
let bytes =
fs::read(path).with_context(|| format!("read file for sha256: {}", path.display()))?;
let mut hasher = Sha256::new();
hasher.update(bytes);
Ok(format!("{:x}", hasher.finalize()))
}
fn write_text(path: &Path, text: &str) -> Result<()> {
if let Some(parent) = path
.parent()
@@ -712,6 +820,10 @@ fn render_markdown(report: &Report) -> String {
"- Сформировано UTC: `{}`\n",
report.generated_at_utc
));
out.push_str(&format!("- Узел: `{}`\n", report.host));
out.push_str(&format!("- Утилита: `{}`\n", report.generated_by.name));
out.push_str(&format!("- Версия: `{}`\n", report.version));
out.push_str(&format!("- Git commit: `{}`\n", report.git_commit));
out.push_str(&format!(
"- Проверки: OK `{}`, WARN `{}`, FAIL `{}`\n\n",
report.counts.ok, report.counts.warn, report.counts.fail
@@ -739,6 +851,10 @@ fn render_markdown(report: &Report) -> String {
out.push_str("Стенд не готов к промышленной эксплуатации до устранения отказов.\n")
}
}
out.push_str("\n## Ограничения проверки\n\n");
for item in &report.limitations {
out.push_str(&format!("- {}\n", item));
}
out
}
@@ -780,6 +896,8 @@ th {{ background: #f1f5f9; }}
<p class="status">Статус: <span class="{status_class}">{status}</span></p>
<p>Готовность: <strong>{ready}</strong></p>
<p>Сформировано UTC: <code>{generated}</code></p>
<p>Узел: <code>{host}</code></p>
<p>Утилита: <code>{generated_by}</code>, версия <code>{version}</code>, git commit <code>{git_commit}</code></p>
<p>Проверки: OK <strong>{ok}</strong>, WARN <strong>{warn}</strong>, FAIL <strong>{fail}</strong></p>
<h2>Результаты проверок</h2>
<table>
@@ -790,16 +908,30 @@ th {{ background: #f1f5f9; }}
</table>
<h2>Решение</h2>
<p>{decision}</p>
<h2>Ограничения проверки</h2>
<ul>
{limitations}
</ul>
</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),
host = html_escape(&report.host),
generated_by = html_escape(&report.generated_by.name),
version = html_escape(&report.version),
git_commit = html_escape(&report.git_commit),
ok = report.counts.ok,
warn = report.counts.warn,
fail = report.counts.fail,
rows = rows,
limitations = report
.limitations
.iter()
.map(|item| format!("<li>{}</li>", html_escape(item)))
.collect::<Vec<_>>()
.join("\n"),
decision = html_escape(match report.status {
StatusLevel::Ok => "Стенд готов к промышленной эксплуатации по проверенным критериям.",
StatusLevel::Warn =>
@@ -826,6 +958,25 @@ fn html_escape(value: &str) -> String {
mod tests {
use super::*;
fn test_report(checks: Vec<Check>) -> Report {
let (status, counts) = summarize(&checks);
Report {
ok: status == StatusLevel::Ok,
status,
generated_at_utc: "2026-06-03T12:00:00Z".to_string(),
generated_by: GeneratedBy {
name: "detmir-readiness".to_string(),
version: "0.1.0".to_string(),
},
host: "HOST-TEST".to_string(),
version: "0.1.0".to_string(),
git_commit: "abc123".to_string(),
counts,
checks,
limitations: vec!["Test limitation".to_string()],
}
}
#[test]
fn parses_env_file_without_quotes() {
let dir = tempfile::tempdir().unwrap();
@@ -889,20 +1040,29 @@ mod tests {
"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 report = test_report(checks);
let markdown = render_markdown(&report);
assert!(markdown.contains("Акт готовности стенда DetMir"));
assert!(markdown.contains("env:AW_WORKTIME_INFLUX"));
assert!(markdown.contains("Ограничения проверки"));
assert!(markdown.contains("abc123"));
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);
}
#[test]
fn writes_bundle_with_sha256sums() {
let dir = tempfile::tempdir().unwrap();
let report = test_report(vec![ok("env:test", "ok", json!({}))]);
write_bundle(dir.path(), &report).unwrap();
assert!(dir.path().join("detmir-readiness-latest.json").is_file());
assert!(dir.path().join("detmir-readiness-act.md").is_file());
assert!(dir.path().join("detmir-readiness-act.html").is_file());
let sums = fs::read_to_string(dir.path().join("sha256sums.txt")).unwrap();
assert!(sums.contains("detmir-readiness-latest.json"));
assert!(sums.contains("detmir-readiness-act.md"));
assert!(sums.contains("detmir-readiness-act.html"));
}
}