feat(ops): add signed readiness bundle
This commit is contained in:
Generated
+1
@@ -601,6 +601,7 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
+10
-4
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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=<uid>`;
|
||||
- `DETMIR_GIT_COMMIT=<commit>`.
|
||||
|
||||
## Полезные параметры
|
||||
|
||||
```bash
|
||||
|
||||
Reference in New Issue
Block a user