feat(ops): sign readiness bundle

This commit is contained in:
igor04091968
2026-06-03 20:56:35 +03:00
parent 3dc7214b58
commit 59d5e1f1fc
8 changed files with 532 additions and 8 deletions
+5
View File
@@ -646,9 +646,14 @@ Production readiness checklist:
```bash
cd /var/lib/activitywatch/health/readiness-bundle
sha256sum -c sha256sums.txt
openssl dgst -sha256 -verify public-key.pem \
-signature sha256sums.txt.sig sha256sums.txt
```
- [ ] `detmir-readiness-status.json` and `detmir-readiness.prom` expose the
latest OK/WARN/FAIL state independently from systemd unit result;
- [ ] `detmir-readiness.timer` is enabled for daily bundle generation;
- [ ] DetMir portal readiness endpoints return latest/bundle/verify data;
- [ ] `detmir-check --json`, `detmir-status --json` and Grafana check are green;
- [ ] rollback path for changed binaries/env files is known.
+135
View File
@@ -103,6 +103,13 @@ struct Cli {
)]
evidence_root: PathBuf,
#[arg(
long,
default_value = "/var/lib/activitywatch/health/readiness-bundle",
env = "DETMIR_PORTAL_READINESS_BUNDLE_DIR"
)]
readiness_bundle_dir: PathBuf,
#[arg(long, default_value_t = 30, env = "DETMIR_PORTAL_EVIDENCE_LIMIT")]
evidence_limit: u32,
@@ -610,6 +617,9 @@ fn handle_request(request: Request, args: &Cli) -> Result<()> {
),
"/favicon.ico" => respond_text(request, StatusCode(204), "", "image/x-icon"),
"/api/health" => respond_json(request, &build_health(&build_snapshot(args))),
"/api/readiness/latest" => respond_json(request, &readiness_latest(args)),
"/api/readiness/bundle" => respond_json(request, &readiness_bundle(args)),
"/api/readiness/verify" => respond_json(request, &readiness_verify(args)),
"/api/summary" => respond_json(request, &build_summary(&build_snapshot(args))),
"/api/operator" => {
let snapshot = build_snapshot(args);
@@ -686,6 +696,15 @@ fn handle_evidence_only_request(request: Request, args: &Cli) -> Result<()> {
}),
);
}
if path == "/api/readiness/latest" {
return respond_json(request, &readiness_latest(args));
}
if path == "/api/readiness/bundle" {
return respond_json(request, &readiness_bundle(args));
}
if path == "/api/readiness/verify" {
return respond_json(request, &readiness_verify(args));
}
if path == "/api/dlp/evidence" {
return respond_json(request, &build_dlp_evidence_response(args));
}
@@ -710,6 +729,121 @@ fn normalize_path(url: &str) -> String {
}
}
fn readiness_latest(args: &Cli) -> Value {
read_json_file(
&args
.readiness_bundle_dir
.join("detmir-readiness-latest.json"),
)
.unwrap_or_else(|err| {
json!({
"ok": false,
"generated_at_utc": now(),
"error": err.to_string(),
})
})
}
fn readiness_bundle(args: &Cli) -> Value {
let dir = &args.readiness_bundle_dir;
let status = read_json_file(&dir.join("detmir-readiness-status.json")).unwrap_or_else(|err| {
json!({
"ok": false,
"error": err.to_string(),
})
});
let latest_dir = fs::read_to_string(dir.join("latest-dir.txt"))
.unwrap_or_default()
.trim()
.to_string();
let artifacts = [
"detmir-readiness-latest.json",
"detmir-readiness-act.md",
"detmir-readiness-act.html",
"sha256sums.txt",
"sha256sums.txt.sig",
"public-key.pem",
"detmir-readiness-status.json",
"detmir-readiness.prom",
]
.into_iter()
.filter_map(|name| {
let path = dir.join(name);
path.metadata().ok().map(|meta| {
json!({
"name": name,
"bytes": meta.len(),
"available": true,
})
})
})
.collect::<Vec<_>>();
json!({
"ok": status.get("ok").and_then(Value::as_bool).unwrap_or(false),
"generated_at_utc": now(),
"bundle_dir": dir.display().to_string(),
"latest_archive_dir": latest_dir,
"status": status,
"artifacts": artifacts,
})
}
fn readiness_verify(args: &Cli) -> Value {
let dir = &args.readiness_bundle_dir;
let checksum = run_in_dir(
dir,
Command::new("sha256sum").arg("-c").arg("sha256sums.txt"),
);
let sig_path = dir.join("sha256sums.txt.sig");
let pub_path = dir.join("public-key.pem");
let signature = if sig_path.is_file() && pub_path.is_file() {
run_in_dir(
dir,
Command::new("openssl")
.arg("dgst")
.arg("-sha256")
.arg("-verify")
.arg("public-key.pem")
.arg("-signature")
.arg("sha256sums.txt.sig")
.arg("sha256sums.txt"),
)
} else {
Err("signature files are not available".to_string())
};
json!({
"ok": checksum.is_ok() && signature.is_ok(),
"generated_at_utc": now(),
"checksum_verified": checksum.is_ok(),
"signature_verified": signature.is_ok(),
"checksum_error": checksum.err(),
"signature_error": signature.err(),
})
}
fn read_json_file(path: &Path) -> Result<Value> {
let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))
}
fn run_in_dir(dir: &Path, command: &mut Command) -> std::result::Result<(), String> {
let output = command
.current_dir(dir)
.output()
.map_err(|err| format!("run command in {}: {err}", dir.display()))?;
if output.status.success() {
Ok(())
} else {
Err(format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
.trim()
.to_string())
}
}
fn query_flag(url: &str, key: &str) -> bool {
let Some(query) = url.split_once('?').map(|(_, query)| query) else {
return false;
@@ -4434,6 +4568,7 @@ mod tests {
state_dir: dir.path().join("state"),
dlp_db_path: dir.path().join("dlp.sqlite"),
evidence_root: dir.path().to_path_buf(),
readiness_bundle_dir: dir.path().join("readiness-bundle"),
evidence_limit: 10,
evidence_max_bytes: 1024,
json_smoke: false,
+283 -6
View File
@@ -19,6 +19,7 @@ const DEFAULT_GRAFANA_ENV_FILE: &str = "/etc/detmir-grafana-check.env";
const DEFAULT_GRAFANA_URL: &str = "http://127.0.0.1:3000";
const DEFAULT_GRAFANA_DATASOURCE_UID: &str = "influxdb_aw";
const DEFAULT_SYSTEMD_SERVICES: &str = "activitywatch-server,aw-worktime-api,aw-worktime-influx-exporter.timer,aw-dlp-influx-exporter.timer";
const DEFAULT_RETENTION_DAYS: i64 = 30;
#[derive(Debug, Parser)]
#[command(
@@ -83,6 +84,19 @@ struct Cli {
#[arg(long)]
output_dir: Option<PathBuf>,
#[arg(long, env = "DETMIR_READINESS_SIGNING_KEY")]
signing_key: Option<PathBuf>,
#[arg(long, env = "DETMIR_READINESS_REQUIRE_SIGNATURE")]
require_signature: bool,
#[arg(
long,
env = "DETMIR_READINESS_RETENTION_DAYS",
default_value_t = DEFAULT_RETENTION_DAYS
)]
retention_days: i64,
#[arg(long, env = "DETMIR_GIT_COMMIT", default_value = "unknown")]
git_commit: String,
}
@@ -107,7 +121,7 @@ struct GeneratedBy {
version: String,
}
#[derive(Debug, Default, Serialize)]
#[derive(Debug, Clone, Default, Serialize)]
struct Counts {
ok: usize,
warn: usize,
@@ -122,6 +136,31 @@ struct Check {
details: Value,
}
#[derive(Debug, Serialize)]
struct BundleStatus {
ok: bool,
status: StatusLevel,
generated_at_utc: String,
archive_dir: String,
latest_dir: String,
signature: SignatureStatus,
counts: Counts,
prometheus_metric_file: String,
}
#[derive(Debug, Clone, Serialize)]
struct SignatureStatus {
required: bool,
signed: bool,
verified: bool,
method: String,
summary: String,
#[serde(skip_serializing_if = "Option::is_none")]
signature_file: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
public_key_file: Option<String>,
}
#[derive(Debug, Clone)]
struct InfluxConfig {
prefix: &'static str,
@@ -683,7 +722,7 @@ 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)?;
write_bundle(dir, report, cli)?;
}
if let Some(path) = &cli.output_json {
write_text(path, &serde_json::to_string_pretty(report)?)?;
@@ -700,8 +739,12 @@ fn write_outputs(cli: &Cli, report: &Report) -> Result<()> {
Ok(())
}
fn write_bundle(dir: &Path, report: &Report) -> Result<()> {
fn write_bundle(dir: &Path, report: &Report, cli: &Cli) -> Result<()> {
fs::create_dir_all(dir).with_context(|| format!("create output dir: {}", dir.display()))?;
let archive_dir = archive_dir_for(dir);
fs::create_dir_all(&archive_dir)
.with_context(|| format!("create archive dir: {}", archive_dir.display()))?;
let files = [
(
"detmir-readiness-latest.json",
@@ -712,7 +755,7 @@ fn write_bundle(dir: &Path, report: &Report) -> Result<()> {
];
let mut sums = Vec::new();
for (name, content) in files {
let path = dir.join(name);
let path = archive_dir.join(name);
write_text(&path, &content)?;
sums.push((name.to_string(), sha256_file(&path)?));
}
@@ -720,7 +763,203 @@ fn write_bundle(dir: &Path, report: &Report) -> Result<()> {
.into_iter()
.map(|(name, sum)| format!("{sum} {name}\n"))
.collect::<String>();
write_text(&dir.join("sha256sums.txt"), &sums_text)?;
write_text(&archive_dir.join("sha256sums.txt"), &sums_text)?;
let signature = sign_bundle(&archive_dir, cli)?;
let status = BundleStatus {
ok: report.ok && (!cli.require_signature || signature.verified),
status: report.status,
generated_at_utc: report.generated_at_utc.clone(),
archive_dir: archive_dir.display().to_string(),
latest_dir: dir.display().to_string(),
signature,
counts: report.counts.clone(),
prometheus_metric_file: dir.join("detmir-readiness.prom").display().to_string(),
};
write_text(
&archive_dir.join("detmir-readiness-status.json"),
&serde_json::to_string_pretty(&status)?,
)?;
write_text(
&archive_dir.join("detmir-readiness.prom"),
&render_prometheus_metrics(&status),
)?;
copy_latest_bundle(dir, &archive_dir)?;
prune_old_archives(dir, cli.retention_days)?;
Ok(())
}
fn archive_dir_for(root: &Path) -> PathBuf {
let now = Utc::now();
root.join(now.format("%Y-%m-%d").to_string())
.join(now.format("%H%M%SZ").to_string())
}
fn copy_latest_bundle(root: &Path, archive_dir: &Path) -> Result<()> {
for name in [
"detmir-readiness-latest.json",
"detmir-readiness-act.md",
"detmir-readiness-act.html",
"sha256sums.txt",
"sha256sums.txt.sig",
"public-key.pem",
"detmir-readiness-status.json",
"detmir-readiness.prom",
] {
let src = archive_dir.join(name);
if src.is_file() {
fs::copy(&src, root.join(name))
.with_context(|| format!("copy latest bundle file: {}", src.display()))?;
} else {
let latest = root.join(name);
if latest.exists() {
fs::remove_file(&latest)
.with_context(|| format!("remove stale latest file: {}", latest.display()))?;
}
}
}
write_text(
&root.join("latest-dir.txt"),
&format!("{}\n", archive_dir.display()),
)
}
fn sign_bundle(archive_dir: &Path, cli: &Cli) -> Result<SignatureStatus> {
let sums_path = archive_dir.join("sha256sums.txt");
let sig_path = archive_dir.join("sha256sums.txt.sig");
let public_key_path = archive_dir.join("public-key.pem");
let Some(key_path) = cli.signing_key.as_deref() else {
if cli.require_signature {
anyhow::bail!(
"readiness bundle signature is required but signing key is not configured"
);
}
return Ok(SignatureStatus {
required: false,
signed: false,
verified: false,
method: "openssl dgst -sha256".to_string(),
summary: "signature not configured".to_string(),
signature_file: None,
public_key_file: None,
});
};
if !key_path.is_file() {
if cli.require_signature {
anyhow::bail!("readiness signing key not found: {}", key_path.display());
}
return Ok(SignatureStatus {
required: false,
signed: false,
verified: false,
method: "openssl dgst -sha256".to_string(),
summary: "signing key not found".to_string(),
signature_file: None,
public_key_file: None,
});
}
run_command(
Command::new("openssl")
.arg("pkey")
.arg("-in")
.arg(key_path)
.arg("-pubout")
.arg("-out")
.arg(&public_key_path),
"extract readiness public key",
)?;
run_command(
Command::new("openssl")
.arg("dgst")
.arg("-sha256")
.arg("-sign")
.arg(key_path)
.arg("-out")
.arg(&sig_path)
.arg(&sums_path),
"sign readiness sha256sums",
)?;
run_command(
Command::new("openssl")
.arg("dgst")
.arg("-sha256")
.arg("-verify")
.arg(&public_key_path)
.arg("-signature")
.arg(&sig_path)
.arg(&sums_path),
"verify readiness sha256sums signature",
)?;
Ok(SignatureStatus {
required: cli.require_signature,
signed: true,
verified: true,
method: "openssl dgst -sha256".to_string(),
summary: "sha256sums detached signature verified".to_string(),
signature_file: Some(sig_path.display().to_string()),
public_key_file: Some(public_key_path.display().to_string()),
})
}
fn run_command(command: &mut Command, context: &str) -> Result<()> {
let output = command.output().with_context(|| format!("run {context}"))?;
if output.status.success() {
Ok(())
} else {
anyhow::bail!(
"{context} failed: {}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
}
fn render_prometheus_metrics(status: &BundleStatus) -> String {
let ready = if status.ok { 1 } else { 0 };
let signed = if status.signature.verified { 1 } else { 0 };
let status_value = match status.status {
StatusLevel::Ok => 0,
StatusLevel::Warn => 1,
StatusLevel::Fail | StatusLevel::Unknown => 2,
};
format!(
"# HELP detmir_readiness_ok DetMir readiness result, 1 means OK.\n\
# TYPE detmir_readiness_ok gauge\n\
detmir_readiness_ok {ready}\n\
# HELP detmir_readiness_status DetMir readiness status: 0 OK, 1 WARN, 2 FAIL.\n\
# TYPE detmir_readiness_status gauge\n\
detmir_readiness_status {status_value}\n\
# HELP detmir_readiness_signature_verified DetMir readiness detached signature verification result.\n\
# TYPE detmir_readiness_signature_verified gauge\n\
detmir_readiness_signature_verified {signed}\n\
detmir_readiness_checks_ok {}\n\
detmir_readiness_checks_warn {}\n\
detmir_readiness_checks_fail {}\n",
status.counts.ok, status.counts.warn, status.counts.fail
)
}
fn prune_old_archives(root: &Path, retention_days: i64) -> Result<()> {
if retention_days <= 0 {
return Ok(());
}
let cutoff = Utc::now().date_naive() - chrono::Duration::days(retention_days);
for entry in
fs::read_dir(root).with_context(|| format!("read output dir: {}", root.display()))?
{
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
let Ok(date) = chrono::NaiveDate::parse_from_str(&name, "%Y-%m-%d") else {
continue;
};
if date < cutoff {
fs::remove_dir_all(entry.path())
.with_context(|| format!("prune readiness archive: {}", entry.path().display()))?;
}
}
Ok(())
}
@@ -977,6 +1216,33 @@ mod tests {
}
}
fn test_cli() -> Cli {
Cli {
json: false,
aw_env_file: PathBuf::from("/nonexistent/aw.env"),
grafana_env_file: PathBuf::from("/nonexistent/grafana.env"),
systemd_services: DEFAULT_SYSTEMD_SERVICES.to_string(),
timeout_seconds: 1,
skip_systemd: true,
skip_influx_write: true,
allow_disabled_influx: true,
skip_grafana: true,
grafana_url: None,
grafana_user: None,
grafana_password: None,
grafana_datasource_uid: DEFAULT_GRAFANA_DATASOURCE_UID.to_string(),
output_json: None,
output_markdown: None,
output_html: None,
output_pdf: None,
output_dir: None,
signing_key: None,
require_signature: false,
retention_days: DEFAULT_RETENTION_DAYS,
git_commit: "abc123".to_string(),
}
}
#[test]
fn parses_env_file_without_quotes() {
let dir = tempfile::tempdir().unwrap();
@@ -1056,13 +1322,24 @@ mod tests {
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();
let cli = test_cli();
write_bundle(dir.path(), &report, &cli).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());
assert!(dir.path().join("detmir-readiness-status.json").is_file());
assert!(dir.path().join("detmir-readiness.prom").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"));
let status: Value = serde_json::from_str(
&fs::read_to_string(dir.path().join("detmir-readiness-status.json")).unwrap(),
)
.unwrap();
assert_eq!(status["signature"]["signed"], false);
assert_eq!(status["signature"]["verified"], false);
let latest_dir = fs::read_to_string(dir.path().join("latest-dir.txt")).unwrap();
assert!(latest_dir.contains("2026") || latest_dir.contains("20"));
}
}
+55
View File
@@ -1608,6 +1608,61 @@
mode: "0755"
when: detmir_readiness_rust_binary.stat.exists | default(false)
- name: Создать private каталог DetMir readiness signing
ansible.builtin.file:
path: /etc/detmir-readiness
state: directory
owner: root
group: root
mode: "0700"
- name: Проверить private key DetMir readiness signing
ansible.builtin.stat:
path: /etc/detmir-readiness/signing-key.pem
register: detmir_readiness_signing_key
- name: Сгенерировать private key DetMir readiness signing
ansible.builtin.command:
argv:
- openssl
- genpkey
- -algorithm
- RSA
- -pkeyopt
- rsa_keygen_bits:3072
- -out
- /etc/detmir-readiness/signing-key.pem
when: not (detmir_readiness_signing_key.stat.exists | default(false))
no_log: true
- name: Зафиксировать права private key DetMir readiness signing
ansible.builtin.file:
path: /etc/detmir-readiness/signing-key.pem
owner: root
group: root
mode: "0600"
- name: Создать private env DetMir readiness
ansible.builtin.copy:
dest: /etc/detmir-readiness.env
content: ""
owner: root
group: root
mode: "0600"
force: false
- name: Настроить private env DetMir readiness signing
ansible.builtin.blockinfile:
path: /etc/detmir-readiness.env
marker: "# {mark} ANSIBLE MANAGED DETMIR READINESS"
block: |
DETMIR_READINESS_SIGNING_KEY=/etc/detmir-readiness/signing-key.pem
DETMIR_READINESS_REQUIRE_SIGNATURE=true
DETMIR_READINESS_RETENTION_DAYS={{ detmir_readiness_retention_days | default(30) }}
owner: root
group: root
mode: "0600"
- name: Установить systemd service DetMir readiness bundle
ansible.builtin.copy:
src: "{{ aw_repo_root }}/aw-server/detmir-readiness.service"
+3
View File
@@ -14,6 +14,7 @@
detmir_portal_one_c_url: "{{ detmir_portal_one_c_url_override | default('http://' + detmir_portal_one_c_host + ':8710', true) }}"
detmir_portal_workforce_policy_path: "/etc/detmir-portal-workforce-policy.json"
detmir_portal_ueba_policy_path: "/etc/detmir-portal-ueba-policy.yaml"
detmir_portal_readiness_bundle_dir: "{{ detmir_portal_readiness_bundle_dir_override | default('/var/lib/activitywatch/health/readiness-bundle', true) }}"
tasks:
- name: Check local detmir-portal binary
@@ -55,6 +56,7 @@
DETMIR_PORTAL_STATE_DIR=/var/lib/detmir-portal
DETMIR_PORTAL_DLP_DB_PATH=/var/lib/activitywatch/dlp_warehouse.sqlite
DETMIR_PORTAL_EVIDENCE_ROOT=/var/lib/detmir-portal/evidence
DETMIR_PORTAL_READINESS_BUNDLE_DIR={{ detmir_portal_readiness_bundle_dir }}
DETMIR_PORTAL_EVIDENCE_LIMIT=30
DETMIR_PORTAL_EVIDENCE_MAX_BYTES=8388608
@@ -235,6 +237,7 @@
DETMIR_PORTAL_STATE_DIR=/var/lib/activitywatch/dlp-evidence
DETMIR_PORTAL_DLP_DB_PATH=/var/lib/activitywatch/dlp_warehouse.sqlite
DETMIR_PORTAL_EVIDENCE_ROOT=/var/lib/activitywatch/dlp-evidence
DETMIR_PORTAL_READINESS_BUNDLE_DIR=/var/lib/activitywatch/health/readiness-bundle
DETMIR_PORTAL_EVIDENCE_LIMIT=30
DETMIR_PORTAL_EVIDENCE_MAX_BYTES=8388608
DETMIR_PORTAL_EVIDENCE_UPLOAD_TOKEN={{ detmir_evidence_upload_token_slurp.content | b64decode | trim }}
@@ -268,6 +268,13 @@ server {
proxy_redirect off;
}
location ^~ /portal/api/readiness {
proxy_set_header Authorization "";
proxy_set_header X-Remote-User $remote_user;
proxy_pass http://192.0.2.13:8721/api/readiness;
proxy_redirect off;
}
location /portal/ {
proxy_set_header Authorization "";
proxy_set_header X-Remote-User $remote_user;
+1 -1
View File
@@ -11,7 +11,7 @@ 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
SuccessExitStatus=0 2
StandardOutput=journal
StandardError=journal
SyslogIdentifier=detmir-readiness
+43 -1
View File
@@ -86,19 +86,50 @@ 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-файлов.
- `sha256sums.txt` - контрольные суммы bundle-файлов;
- `sha256sums.txt.sig` - detached signature для `sha256sums.txt`;
- `public-key.pem` - публичный ключ проверки подписи;
- `detmir-readiness-status.json` - короткий machine-readable статус bundle;
- `detmir-readiness.prom` - Prometheus textfile metric.
Архив хранится по датам:
```text
/var/lib/activitywatch/health/readiness-bundle/
2026-06-03/
062000Z/
detmir-readiness-latest.json
detmir-readiness-act.md
detmir-readiness-act.html
sha256sums.txt
sha256sums.txt.sig
public-key.pem
```
Файлы в корне `readiness-bundle/` являются latest-копией последнего архива.
Проверка целостности:
```bash
cd /var/lib/activitywatch/health/readiness-bundle
sha256sum -c sha256sums.txt
openssl dgst -sha256 -verify public-key.pem \
-signature sha256sums.txt.sig sha256sums.txt
```
В JSON и акт добавляются технические поля `generated_by`, `host`, `version`,
`git_commit`, а также раздел `Ограничения проверки`. Секреты, токены и пароли
в артефакты не включаются.
Private signing key хранится только на сервере:
```text
/etc/detmir-readiness/signing-key.pem
```
Публичный ключ попадает в bundle как `public-key.pem`. Retention архивов
управляется переменной `DETMIR_READINESS_RETENTION_DAYS`.
## Ежедневное формирование
При штатном развертывании Ansible устанавливает:
@@ -123,6 +154,9 @@ systemctl status detmir-readiness.service --no-pager
Поддерживаемые private env-переключатели:
- `DETMIR_READINESS_SIGNING_KEY=/etc/detmir-readiness/signing-key.pem`;
- `DETMIR_READINESS_REQUIRE_SIGNATURE=true`;
- `DETMIR_READINESS_RETENTION_DAYS=30`;
- `DETMIR_READINESS_SKIP_SYSTEMD=true`;
- `DETMIR_READINESS_SKIP_INFLUX_WRITE=true`;
- `DETMIR_READINESS_ALLOW_DISABLED_INFLUX=true`;
@@ -130,6 +164,14 @@ systemctl status detmir-readiness.service --no-pager
- `DETMIR_GRAFANA_DATASOURCE_UID=<uid>`;
- `DETMIR_GIT_COMMIT=<commit>`.
## Portal endpoints
`detmir-portal` публикует read-only endpoints:
- `/api/readiness/latest` - последний readiness JSON;
- `/api/readiness/bundle` - индекс latest bundle и список артефактов;
- `/api/readiness/verify` - проверка `sha256sum -c` и detached signature.
## Полезные параметры
```bash