diff --git a/adk-rust/Cargo.lock b/adk-rust/Cargo.lock index 0e59f63..54afa94 100644 --- a/adk-rust/Cargo.lock +++ b/adk-rust/Cargo.lock @@ -601,6 +601,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha2", "tempfile", "urlencoding", ] diff --git a/adk-rust/RUNBOOK.md b/adk-rust/RUNBOOK.md index cf432cb..fbd1834 100644 --- a/adk-rust/RUNBOOK.md +++ b/adk-rust/RUNBOOK.md @@ -639,10 +639,16 @@ 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`; +- [ ] readiness bundle is generated with + `--output-dir /var/lib/activitywatch/health/readiness-bundle`; +- [ ] readiness bundle integrity passes: + + ```bash + cd /var/lib/activitywatch/health/readiness-bundle + sha256sum -c sha256sums.txt + ``` + +- [ ] `detmir-readiness.timer` is enabled for daily bundle generation; - [ ] `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/Cargo.toml b/adk-rust/crates/detmir-readiness/Cargo.toml index e8ada52..4f38775 100644 --- a/adk-rust/crates/detmir-readiness/Cargo.toml +++ b/adk-rust/crates/detmir-readiness/Cargo.toml @@ -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 diff --git a/adk-rust/crates/detmir-readiness/src/main.rs b/adk-rust/crates/detmir-readiness/src/main.rs index cf846b7..c817f96 100644 --- a/adk-rust/crates/detmir-readiness/src/main.rs +++ b/adk-rust/crates/detmir-readiness/src/main.rs @@ -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, - #[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, + + #[arg(long)] + output_dir: Option, + + #[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, + limitations: Vec, +} + +#[derive(Debug, Serialize)] +struct GeneratedBy { + name: String, + version: String, } #[derive(Debug, Default, Serialize)] @@ -192,12 +214,21 @@ fn run(cli: &Cli) -> Result { } 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 { .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 { + 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, 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::(); + write_text(&dir.join("sha256sums.txt"), &sums_text)?; + Ok(()) +} + +fn sha256_file(path: &Path) -> Result { + 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; }}

Статус: {status}

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

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

+

Узел: {host}

+

Утилита: {generated_by}, версия {version}, git commit {git_commit}

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

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

@@ -790,16 +908,30 @@ th {{ background: #f1f5f9; }}

Решение

{decision}

+

Ограничения проверки

+
    +{limitations} +
"#, 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!("
  • {}
  • ", html_escape(item))) + .collect::>() + .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) -> 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")); + } } diff --git a/ansible/deploy_aw_server.yml b/ansible/deploy_aw_server.yml index 0c34787..8664fd7 100644 --- a/ansible/deploy_aw_server.yml +++ b/ansible/deploy_aw_server.yml @@ -1608,6 +1608,22 @@ mode: "0755" when: detmir_readiness_rust_binary.stat.exists | default(false) + - name: Установить systemd service DetMir readiness bundle + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/detmir-readiness.service" + dest: /etc/systemd/system/detmir-readiness.service + owner: root + group: root + mode: "0644" + + - name: Установить systemd timer DetMir readiness bundle + ansible.builtin.copy: + src: "{{ aw_repo_root }}/aw-server/detmir-readiness.timer" + dest: /etc/systemd/system/detmir-readiness.timer + owner: root + group: root + mode: "0644" + - name: Перезагрузить systemd после установки AW worktime API ansible.builtin.systemd: daemon_reload: true @@ -1624,6 +1640,12 @@ enabled: true state: started + - name: Включить daily DetMir readiness bundle timer + ansible.builtin.systemd: + name: detmir-readiness.timer + enabled: true + state: started + - name: Включить таймер AW-RUS browser smoke ansible.builtin.systemd: name: aw-browser-smoke.timer diff --git a/aw-server/detmir-readiness.service b/aw-server/detmir-readiness.service new file mode 100644 index 0000000..7c6e3e1 --- /dev/null +++ b/aw-server/detmir-readiness.service @@ -0,0 +1,17 @@ +[Unit] +Description=DetMir production readiness bundle +After=network-online.target activitywatch-server.service aw-worktime-api.service +Wants=network-online.target activitywatch-server.service aw-worktime-api.service + +[Service] +Type=oneshot +EnvironmentFile=/etc/activitywatch/aw-server.env +EnvironmentFile=-/etc/detmir-grafana-check.env +EnvironmentFile=-/etc/detmir-readiness.env +ExecStart=/usr/local/bin/detmir-readiness --output-dir /var/lib/activitywatch/health/readiness-bundle +User=root +Group=root +SuccessExitStatus=0 2 3 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=detmir-readiness diff --git a/aw-server/detmir-readiness.timer b/aw-server/detmir-readiness.timer new file mode 100644 index 0000000..cff2a85 --- /dev/null +++ b/aw-server/detmir-readiness.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Daily DetMir production readiness bundle + +[Timer] +OnCalendar=*-*-* 06:20:00 +AccuracySec=5m +RandomizedDelaySec=10m +Unit=detmir-readiness.service +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/docs/PRODUCTION_READINESS_RU.md b/docs/PRODUCTION_READINESS_RU.md index ac3ae4f..142cbe7 100644 --- a/docs/PRODUCTION_READINESS_RU.md +++ b/docs/PRODUCTION_READINESS_RU.md @@ -73,6 +73,63 @@ PDF-вывод требует один из render tools на хосте: `weasy используйте `--output-markdown` и `--output-html` как обязательный минимальный артефакт внедрения. +## Readiness bundle + +Для промышленного контура предпочтителен единый bundle: + +```bash +detmir-readiness --output-dir /var/lib/activitywatch/health/readiness-bundle +``` + +Команда создает: + +- `detmir-readiness-latest.json` - машинный отчет; +- `detmir-readiness-act.md` - акт готовности для оператора; +- `detmir-readiness-act.html` - HTML-версия акта; +- `sha256sums.txt` - контрольные суммы bundle-файлов. + +Проверка целостности: + +```bash +cd /var/lib/activitywatch/health/readiness-bundle +sha256sum -c sha256sums.txt +``` + +В JSON и акт добавляются технические поля `generated_by`, `host`, `version`, +`git_commit`, а также раздел `Ограничения проверки`. Секреты, токены и пароли +в артефакты не включаются. + +## Ежедневное формирование + +При штатном развертывании Ansible устанавливает: + +- `detmir-readiness.service`; +- `detmir-readiness.timer`. + +Таймер ежедневно формирует readiness bundle в +`/var/lib/activitywatch/health/readiness-bundle`. + +Операторская проверка: + +```bash +systemctl list-timers detmir-readiness.timer +systemctl start detmir-readiness.service +systemctl status detmir-readiness.service --no-pager +``` + +Если Grafana находится на отдельном узле, параметры доступа передаются через +серверный private env-файл `/etc/detmir-grafana-check.env` или +`/etc/detmir-readiness.env`. Эти файлы не входят в публичный репозиторий. + +Поддерживаемые private env-переключатели: + +- `DETMIR_READINESS_SKIP_SYSTEMD=true`; +- `DETMIR_READINESS_SKIP_INFLUX_WRITE=true`; +- `DETMIR_READINESS_ALLOW_DISABLED_INFLUX=true`; +- `DETMIR_READINESS_SKIP_GRAFANA=true`; +- `DETMIR_GRAFANA_DATASOURCE_UID=`; +- `DETMIR_GIT_COMMIT=`. + ## Полезные параметры ```bash