feat(ops): surface readiness in portal
This commit is contained in:
@@ -650,6 +650,21 @@ Production readiness checklist:
|
|||||||
-signature sha256sums.txt.sig sha256sums.txt
|
-signature sha256sums.txt.sig sha256sums.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- [ ] readiness signing public-key fingerprint for the shipped/customer
|
||||||
|
contour is fixed and documented:
|
||||||
|
|
||||||
|
```text
|
||||||
|
READINESS_PUBLIC_KEY_SHA256=<READINESS_PUBLIC_KEY_SHA256_FINGERPRINT>
|
||||||
|
```
|
||||||
|
|
||||||
|
Verification command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sha256sum /var/lib/activitywatch/health/readiness-bundle/public-key.pem
|
||||||
|
jq -r '.signature.public_key_fingerprint_sha256' \
|
||||||
|
/var/lib/activitywatch/health/readiness-bundle/detmir-readiness-status.json
|
||||||
|
```
|
||||||
|
|
||||||
- [ ] `detmir-readiness-status.json` and `detmir-readiness.prom` expose the
|
- [ ] `detmir-readiness-status.json` and `detmir-readiness.prom` expose the
|
||||||
latest OK/WARN/FAIL state independently from systemd unit result;
|
latest OK/WARN/FAIL state independently from systemd unit result;
|
||||||
- [ ] `detmir-readiness.timer` is enabled for daily bundle generation;
|
- [ ] `detmir-readiness.timer` is enabled for daily bundle generation;
|
||||||
|
|||||||
@@ -130,6 +130,40 @@ h1 {
|
|||||||
.status-warn { background: var(--warn-bg); color: var(--warn); }
|
.status-warn { background: var(--warn-bg); color: var(--warn); }
|
||||||
.status-fail { background: var(--fail-bg); color: var(--fail); }
|
.status-fail { background: var(--fail-bg); color: var(--fail); }
|
||||||
.status-unknown { background: var(--unknown-bg); color: var(--unknown); }
|
.status-unknown { background: var(--unknown-bg); color: var(--unknown); }
|
||||||
|
.text-ok { color: var(--ok); }
|
||||||
|
.text-fail { color: var(--fail); }
|
||||||
|
|
||||||
|
.readiness-card {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readiness-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readiness-head h3 {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readiness-metrics {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readiness-metrics > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.readiness-card code {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.grid-2 {
|
.grid-2 {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -350,6 +384,9 @@ pre {
|
|||||||
.row { grid-template-columns: 1fr; }
|
.row { grid-template-columns: 1fr; }
|
||||||
.incident-row { grid-template-columns: 1fr; }
|
.incident-row { grid-template-columns: 1fr; }
|
||||||
.evidence-row { grid-template-columns: 1fr; }
|
.evidence-row { grid-template-columns: 1fr; }
|
||||||
|
.readiness-card { grid-column: span 1; }
|
||||||
|
.readiness-head { flex-direction: column; }
|
||||||
|
.readiness-metrics { grid-template-columns: 1fr; }
|
||||||
.app-weight-row { grid-template-columns: 1fr; }
|
.app-weight-row { grid-template-columns: 1fr; }
|
||||||
.employee-index-row { grid-template-columns: 1fr; }
|
.employee-index-row { grid-template-columns: 1fr; }
|
||||||
.section-head { flex-direction: column; }
|
.section-head { flex-direction: column; }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const state = { tab: "operator", links: null };
|
const state = { tab: "operator", links: null, readiness: null };
|
||||||
|
|
||||||
function apiBase() {
|
function apiBase() {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
@@ -37,12 +37,13 @@ function escapeHtml(value) {
|
|||||||
.replaceAll('"', """);
|
.replaceAll('"', """);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSummary(summary) {
|
function renderSummary(summary, readiness) {
|
||||||
const global = document.getElementById("globalStatus");
|
const global = document.getElementById("globalStatus");
|
||||||
global.className = `status-pill ${statusClass(summary.severity)}`;
|
global.className = `status-pill ${statusClass(summary.severity)}`;
|
||||||
global.textContent = `${summary.severity} · operator ${summary.operator_ok ? "OK" : "NO"}`;
|
global.textContent = `${summary.severity} · operator ${summary.operator_ok ? "OK" : "NO"}`;
|
||||||
const blocks = Object.entries(summary.blocks || {});
|
const blocks = Object.entries(summary.blocks || {});
|
||||||
document.getElementById("summary").innerHTML = blocks.map(([name, block]) => `
|
const readinessCard = renderReadinessSummaryCard(readiness);
|
||||||
|
document.getElementById("summary").innerHTML = readinessCard + blocks.map(([name, block]) => `
|
||||||
<article class="card">
|
<article class="card">
|
||||||
<span class="badge ${statusClass(block.status)}">${escapeHtml(block.status)}</span>
|
<span class="badge ${statusClass(block.status)}">${escapeHtml(block.status)}</span>
|
||||||
<h3>${escapeHtml(label(name))}</h3>
|
<h3>${escapeHtml(label(name))}</h3>
|
||||||
@@ -51,6 +52,44 @@ function renderSummary(summary) {
|
|||||||
`).join("");
|
`).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderReadinessSummaryCard(readiness) {
|
||||||
|
const bundle = readiness?.bundle || {};
|
||||||
|
const verify = readiness?.verify || {};
|
||||||
|
const status = bundle.status?.status || bundle.status || (verify.ok ? "OK" : "UNKNOWN");
|
||||||
|
const generated = bundle.status?.generated_at_utc || bundle.generated_at_utc || "-";
|
||||||
|
const signature = bundle.status?.signature || {};
|
||||||
|
const signatureOk = Boolean(verify.signature_verified || signature.verified);
|
||||||
|
const checksumOk = Boolean(verify.checksum_verified || bundle.status?.checksum_verified || verify.ok);
|
||||||
|
const fingerprint = signature.public_key_fingerprint_sha256 || "-";
|
||||||
|
const verificationText = verify.generated_at_utc
|
||||||
|
? `Проверено: ${verify.generated_at_utc}`
|
||||||
|
: "Проверка не запускалась";
|
||||||
|
return `
|
||||||
|
<article class="card readiness-card">
|
||||||
|
<div class="readiness-head">
|
||||||
|
<div>
|
||||||
|
<span class="badge ${statusClass(status)}">${escapeHtml(status)}</span>
|
||||||
|
<h3>Готовность системы</h3>
|
||||||
|
</div>
|
||||||
|
<button class="small-button" data-readiness-verify="true">Проверить bundle</button>
|
||||||
|
</div>
|
||||||
|
<div class="readiness-metrics">
|
||||||
|
<div><span class="muted">Дата</span><strong>${escapeHtml(generated)}</strong></div>
|
||||||
|
<div><span class="muted">Подпись</span><strong class="${signatureOk ? "text-ok" : "text-fail"}">${signatureOk ? "OK" : "FAIL"}</strong></div>
|
||||||
|
<div><span class="muted">Checksum</span><strong class="${checksumOk ? "text-ok" : "text-fail"}">${checksumOk ? "OK" : "FAIL"}</strong></div>
|
||||||
|
</div>
|
||||||
|
<p class="muted small">Отпечаток: <code>${escapeHtml(shortFingerprint(fingerprint))}</code></p>
|
||||||
|
<p id="readinessVerifyStatus" class="muted small">${escapeHtml(verificationText)}</p>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortFingerprint(value) {
|
||||||
|
const text = String(value || "-");
|
||||||
|
if (text.length <= 24) return text;
|
||||||
|
return `${text.slice(0, 12)}…${text.slice(-12)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function label(name) {
|
function label(name) {
|
||||||
return {
|
return {
|
||||||
collection: "Сбор данных",
|
collection: "Сбор данных",
|
||||||
@@ -464,7 +503,11 @@ function renderReports(data) {
|
|||||||
async function refresh() {
|
async function refresh() {
|
||||||
if (!state.links) state.links = await loadJson("/links");
|
if (!state.links) state.links = await loadJson("/links");
|
||||||
const summary = await loadJson("/summary");
|
const summary = await loadJson("/summary");
|
||||||
renderSummary(summary);
|
state.readiness = {
|
||||||
|
bundle: await loadJson("/readiness/bundle").catch(error => ({ ok: false, error: error.message })),
|
||||||
|
verify: state.readiness?.verify || null
|
||||||
|
};
|
||||||
|
renderSummary(summary, state.readiness);
|
||||||
const content = document.getElementById("content");
|
const content = document.getElementById("content");
|
||||||
const data = await loadJson(`/${state.tab}`);
|
const data = await loadJson(`/${state.tab}`);
|
||||||
if (state.tab === "operator") content.innerHTML = renderOperator(data);
|
if (state.tab === "operator") content.innerHTML = renderOperator(data);
|
||||||
@@ -515,6 +558,28 @@ document.addEventListener("click", event => {
|
|||||||
anonymizeReport(button).catch(showError);
|
anonymizeReport(button).catch(showError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", event => {
|
||||||
|
const button = event.target.closest("[data-readiness-verify]");
|
||||||
|
if (!button) return;
|
||||||
|
verifyReadinessBundle(button).catch(showError);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function verifyReadinessBundle(button) {
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = "Проверка...";
|
||||||
|
const verify = await loadJson("/readiness/verify");
|
||||||
|
state.readiness = {
|
||||||
|
...(state.readiness || {}),
|
||||||
|
verify
|
||||||
|
};
|
||||||
|
const status = document.getElementById("readinessVerifyStatus");
|
||||||
|
if (status) {
|
||||||
|
status.textContent = `Проверено: checksum ${verify.checksum_verified ? "OK" : "FAIL"} · signature ${verify.signature_verified ? "OK" : "FAIL"}`;
|
||||||
|
}
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = "Проверить bundle";
|
||||||
|
}
|
||||||
|
|
||||||
async function anonymizeReport(button) {
|
async function anonymizeReport(button) {
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
const data = await loadJson("/reports?anonymize=1");
|
const data = await loadJson("/reports?anonymize=1");
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ struct BundleStatus {
|
|||||||
generated_at_utc: String,
|
generated_at_utc: String,
|
||||||
archive_dir: String,
|
archive_dir: String,
|
||||||
latest_dir: String,
|
latest_dir: String,
|
||||||
|
checksum_verified: bool,
|
||||||
signature: SignatureStatus,
|
signature: SignatureStatus,
|
||||||
counts: Counts,
|
counts: Counts,
|
||||||
prometheus_metric_file: String,
|
prometheus_metric_file: String,
|
||||||
@@ -156,6 +157,8 @@ struct SignatureStatus {
|
|||||||
method: String,
|
method: String,
|
||||||
summary: String,
|
summary: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
public_key_fingerprint_sha256: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
signature_file: Option<String>,
|
signature_file: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
public_key_file: Option<String>,
|
public_key_file: Option<String>,
|
||||||
@@ -772,6 +775,7 @@ fn write_bundle(dir: &Path, report: &Report, cli: &Cli) -> Result<()> {
|
|||||||
generated_at_utc: report.generated_at_utc.clone(),
|
generated_at_utc: report.generated_at_utc.clone(),
|
||||||
archive_dir: archive_dir.display().to_string(),
|
archive_dir: archive_dir.display().to_string(),
|
||||||
latest_dir: dir.display().to_string(),
|
latest_dir: dir.display().to_string(),
|
||||||
|
checksum_verified: true,
|
||||||
signature,
|
signature,
|
||||||
counts: report.counts.clone(),
|
counts: report.counts.clone(),
|
||||||
prometheus_metric_file: dir.join("detmir-readiness.prom").display().to_string(),
|
prometheus_metric_file: dir.join("detmir-readiness.prom").display().to_string(),
|
||||||
@@ -840,6 +844,7 @@ fn sign_bundle(archive_dir: &Path, cli: &Cli) -> Result<SignatureStatus> {
|
|||||||
verified: false,
|
verified: false,
|
||||||
method: "openssl dgst -sha256".to_string(),
|
method: "openssl dgst -sha256".to_string(),
|
||||||
summary: "signature not configured".to_string(),
|
summary: "signature not configured".to_string(),
|
||||||
|
public_key_fingerprint_sha256: None,
|
||||||
signature_file: None,
|
signature_file: None,
|
||||||
public_key_file: None,
|
public_key_file: None,
|
||||||
});
|
});
|
||||||
@@ -854,6 +859,7 @@ fn sign_bundle(archive_dir: &Path, cli: &Cli) -> Result<SignatureStatus> {
|
|||||||
verified: false,
|
verified: false,
|
||||||
method: "openssl dgst -sha256".to_string(),
|
method: "openssl dgst -sha256".to_string(),
|
||||||
summary: "signing key not found".to_string(),
|
summary: "signing key not found".to_string(),
|
||||||
|
public_key_fingerprint_sha256: None,
|
||||||
signature_file: None,
|
signature_file: None,
|
||||||
public_key_file: None,
|
public_key_file: None,
|
||||||
});
|
});
|
||||||
@@ -890,12 +896,14 @@ fn sign_bundle(archive_dir: &Path, cli: &Cli) -> Result<SignatureStatus> {
|
|||||||
.arg(&sums_path),
|
.arg(&sums_path),
|
||||||
"verify readiness sha256sums signature",
|
"verify readiness sha256sums signature",
|
||||||
)?;
|
)?;
|
||||||
|
let fingerprint = sha256_file(&public_key_path)?;
|
||||||
Ok(SignatureStatus {
|
Ok(SignatureStatus {
|
||||||
required: cli.require_signature,
|
required: cli.require_signature,
|
||||||
signed: true,
|
signed: true,
|
||||||
verified: true,
|
verified: true,
|
||||||
method: "openssl dgst -sha256".to_string(),
|
method: "openssl dgst -sha256".to_string(),
|
||||||
summary: "sha256sums detached signature verified".to_string(),
|
summary: "sha256sums detached signature verified".to_string(),
|
||||||
|
public_key_fingerprint_sha256: Some(fingerprint),
|
||||||
signature_file: Some(sig_path.display().to_string()),
|
signature_file: Some(sig_path.display().to_string()),
|
||||||
public_key_file: Some(public_key_path.display().to_string()),
|
public_key_file: Some(public_key_path.display().to_string()),
|
||||||
})
|
})
|
||||||
@@ -1337,9 +1345,67 @@ mod tests {
|
|||||||
&fs::read_to_string(dir.path().join("detmir-readiness-status.json")).unwrap(),
|
&fs::read_to_string(dir.path().join("detmir-readiness-status.json")).unwrap(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
assert_eq!(status["checksum_verified"], true);
|
||||||
assert_eq!(status["signature"]["signed"], false);
|
assert_eq!(status["signature"]["signed"], false);
|
||||||
assert_eq!(status["signature"]["verified"], false);
|
assert_eq!(status["signature"]["verified"], false);
|
||||||
let latest_dir = fs::read_to_string(dir.path().join("latest-dir.txt")).unwrap();
|
let latest_dir = fs::read_to_string(dir.path().join("latest-dir.txt")).unwrap();
|
||||||
assert!(latest_dir.contains("2026") || latest_dir.contains("20"));
|
assert!(latest_dir.contains("2026") || latest_dir.contains("20"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writes_signed_bundle_when_openssl_available() {
|
||||||
|
if !command_exists("openssl") {
|
||||||
|
eprintln!("skip signed bundle test: openssl is not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let key_path = dir.path().join("signing-key.pem");
|
||||||
|
run_command(
|
||||||
|
Command::new("openssl")
|
||||||
|
.arg("genpkey")
|
||||||
|
.arg("-algorithm")
|
||||||
|
.arg("RSA")
|
||||||
|
.arg("-pkeyopt")
|
||||||
|
.arg("rsa_keygen_bits:2048")
|
||||||
|
.arg("-out")
|
||||||
|
.arg(&key_path),
|
||||||
|
"generate test signing key",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut cli = test_cli();
|
||||||
|
cli.signing_key = Some(key_path);
|
||||||
|
cli.require_signature = true;
|
||||||
|
let report = test_report(vec![ok("env:test", "ok", json!({}))]);
|
||||||
|
write_bundle(dir.path(), &report, &cli).unwrap();
|
||||||
|
assert!(dir.path().join("sha256sums.txt.sig").is_file());
|
||||||
|
assert!(dir.path().join("public-key.pem").is_file());
|
||||||
|
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"], true);
|
||||||
|
assert_eq!(status["signature"]["verified"], true);
|
||||||
|
assert!(
|
||||||
|
status["signature"]["public_key_fingerprint_sha256"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap_or("")
|
||||||
|
.len()
|
||||||
|
>= 64
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prunes_old_readiness_archives() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let old = dir.path().join("2026-01-01").join("000000Z");
|
||||||
|
let fresh_date = Utc::now().format("%Y-%m-%d").to_string();
|
||||||
|
let fresh = dir.path().join(&fresh_date).join("000000Z");
|
||||||
|
fs::create_dir_all(&old).unwrap();
|
||||||
|
fs::create_dir_all(&fresh).unwrap();
|
||||||
|
fs::write(old.join("marker"), "old").unwrap();
|
||||||
|
fs::write(fresh.join("marker"), "fresh").unwrap();
|
||||||
|
prune_old_archives(dir.path(), 30).unwrap();
|
||||||
|
assert!(!dir.path().join("2026-01-01").exists());
|
||||||
|
assert!(dir.path().join(fresh_date).exists());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1679,6 +1679,22 @@
|
|||||||
group: root
|
group: root
|
||||||
mode: "0644"
|
mode: "0644"
|
||||||
|
|
||||||
|
- name: Создать каталог Prometheus rules для DetMir readiness
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/prometheus/rules
|
||||||
|
state: directory
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
|
- name: Установить Prometheus alerts DetMir readiness
|
||||||
|
ansible.builtin.copy:
|
||||||
|
src: "{{ aw_repo_root }}/aw-server/detmir-readiness-alerts.yml"
|
||||||
|
dest: /etc/prometheus/rules/detmir-readiness-alerts.yml
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
mode: "0644"
|
||||||
|
|
||||||
- name: Перезагрузить systemd после установки AW worktime API
|
- name: Перезагрузить systemd после установки AW worktime API
|
||||||
ansible.builtin.systemd:
|
ansible.builtin.systemd:
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
groups:
|
||||||
|
- name: detmir_readiness
|
||||||
|
rules:
|
||||||
|
- alert: DetMirReadinessNotOk
|
||||||
|
expr: detmir_readiness_ok == 0
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
service: detmir-readiness
|
||||||
|
annotations:
|
||||||
|
summary: "DetMir readiness is not OK"
|
||||||
|
description: "detmir-readiness reported WARN/FAIL or the latest readiness bundle is not acceptable."
|
||||||
|
|
||||||
|
- alert: DetMirReadinessSignatureInvalid
|
||||||
|
expr: detmir_readiness_signature_verified == 0
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
service: detmir-readiness
|
||||||
|
annotations:
|
||||||
|
summary: "DetMir readiness bundle signature is not verified"
|
||||||
|
description: "The latest readiness bundle does not have a valid detached signature."
|
||||||
@@ -130,6 +130,19 @@ Private signing key хранится только на сервере:
|
|||||||
Публичный ключ попадает в bundle как `public-key.pem`. Retention архивов
|
Публичный ключ попадает в bundle как `public-key.pem`. Retention архивов
|
||||||
управляется переменной `DETMIR_READINESS_RETENTION_DAYS`.
|
управляется переменной `DETMIR_READINESS_RETENTION_DAYS`.
|
||||||
|
|
||||||
|
Fingerprint публичного ключа считается как SHA-256 файла `public-key.pem` и
|
||||||
|
дублируется в `detmir-readiness-status.json`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sha256sum /var/lib/activitywatch/health/readiness-bundle/public-key.pem
|
||||||
|
jq -r '.signature.public_key_fingerprint_sha256' \
|
||||||
|
/var/lib/activitywatch/health/readiness-bundle/detmir-readiness-status.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Для публичной поставки вместо live fingerprint используется placeholder
|
||||||
|
`<READINESS_PUBLIC_KEY_SHA256_FINGERPRINT>`; конкретный customer contour
|
||||||
|
фиксирует свой fingerprint в private acceptance package.
|
||||||
|
|
||||||
## Ежедневное формирование
|
## Ежедневное формирование
|
||||||
|
|
||||||
При штатном развертывании Ansible устанавливает:
|
При штатном развертывании Ansible устанавливает:
|
||||||
@@ -172,6 +185,26 @@ systemctl status detmir-readiness.service --no-pager
|
|||||||
- `/api/readiness/bundle` - индекс latest bundle и список артефактов;
|
- `/api/readiness/bundle` - индекс latest bundle и список артефактов;
|
||||||
- `/api/readiness/verify` - проверка `sha256sum -c` и detached signature.
|
- `/api/readiness/verify` - проверка `sha256sum -c` и detached signature.
|
||||||
|
|
||||||
|
В UI портала карточка `Готовность системы` показывает статус `OK/WARN/FAIL`,
|
||||||
|
дату формирования, состояние checksum/signature и fingerprint публичного ключа.
|
||||||
|
Кнопка `Проверить bundle` запускает lightweight verify endpoint без повторного
|
||||||
|
запуска runtime checks.
|
||||||
|
|
||||||
|
## Prometheus alerts
|
||||||
|
|
||||||
|
Поставочный файл правил:
|
||||||
|
|
||||||
|
```text
|
||||||
|
aw-server/detmir-readiness-alerts.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Критичные условия:
|
||||||
|
|
||||||
|
```promql
|
||||||
|
detmir_readiness_ok == 0
|
||||||
|
detmir_readiness_signature_verified == 0
|
||||||
|
```
|
||||||
|
|
||||||
## Полезные параметры
|
## Полезные параметры
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Reference in New Issue
Block a user