feat(ops): surface readiness in portal
This commit is contained in:
@@ -650,6 +650,21 @@ Production readiness checklist:
|
||||
-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
|
||||
latest OK/WARN/FAIL state independently from systemd unit result;
|
||||
- [ ] `detmir-readiness.timer` is enabled for daily bundle generation;
|
||||
|
||||
@@ -130,6 +130,40 @@ h1 {
|
||||
.status-warn { background: var(--warn-bg); color: var(--warn); }
|
||||
.status-fail { background: var(--fail-bg); color: var(--fail); }
|
||||
.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 {
|
||||
display: grid;
|
||||
@@ -350,6 +384,9 @@ pre {
|
||||
.row { grid-template-columns: 1fr; }
|
||||
.incident-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; }
|
||||
.employee-index-row { grid-template-columns: 1fr; }
|
||||
.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() {
|
||||
const path = window.location.pathname;
|
||||
@@ -37,12 +37,13 @@ function escapeHtml(value) {
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function renderSummary(summary) {
|
||||
function renderSummary(summary, readiness) {
|
||||
const global = document.getElementById("globalStatus");
|
||||
global.className = `status-pill ${statusClass(summary.severity)}`;
|
||||
global.textContent = `${summary.severity} · operator ${summary.operator_ok ? "OK" : "NO"}`;
|
||||
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">
|
||||
<span class="badge ${statusClass(block.status)}">${escapeHtml(block.status)}</span>
|
||||
<h3>${escapeHtml(label(name))}</h3>
|
||||
@@ -51,6 +52,44 @@ function renderSummary(summary) {
|
||||
`).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) {
|
||||
return {
|
||||
collection: "Сбор данных",
|
||||
@@ -464,7 +503,11 @@ function renderReports(data) {
|
||||
async function refresh() {
|
||||
if (!state.links) state.links = await loadJson("/links");
|
||||
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 data = await loadJson(`/${state.tab}`);
|
||||
if (state.tab === "operator") content.innerHTML = renderOperator(data);
|
||||
@@ -515,6 +558,28 @@ document.addEventListener("click", event => {
|
||||
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) {
|
||||
button.disabled = true;
|
||||
const data = await loadJson("/reports?anonymize=1");
|
||||
|
||||
@@ -143,6 +143,7 @@ struct BundleStatus {
|
||||
generated_at_utc: String,
|
||||
archive_dir: String,
|
||||
latest_dir: String,
|
||||
checksum_verified: bool,
|
||||
signature: SignatureStatus,
|
||||
counts: Counts,
|
||||
prometheus_metric_file: String,
|
||||
@@ -156,6 +157,8 @@ struct SignatureStatus {
|
||||
method: String,
|
||||
summary: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
public_key_fingerprint_sha256: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
signature_file: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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(),
|
||||
archive_dir: archive_dir.display().to_string(),
|
||||
latest_dir: dir.display().to_string(),
|
||||
checksum_verified: true,
|
||||
signature,
|
||||
counts: report.counts.clone(),
|
||||
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,
|
||||
method: "openssl dgst -sha256".to_string(),
|
||||
summary: "signature not configured".to_string(),
|
||||
public_key_fingerprint_sha256: None,
|
||||
signature_file: None,
|
||||
public_key_file: None,
|
||||
});
|
||||
@@ -854,6 +859,7 @@ fn sign_bundle(archive_dir: &Path, cli: &Cli) -> Result<SignatureStatus> {
|
||||
verified: false,
|
||||
method: "openssl dgst -sha256".to_string(),
|
||||
summary: "signing key not found".to_string(),
|
||||
public_key_fingerprint_sha256: None,
|
||||
signature_file: None,
|
||||
public_key_file: None,
|
||||
});
|
||||
@@ -890,12 +896,14 @@ fn sign_bundle(archive_dir: &Path, cli: &Cli) -> Result<SignatureStatus> {
|
||||
.arg(&sums_path),
|
||||
"verify readiness sha256sums signature",
|
||||
)?;
|
||||
let fingerprint = sha256_file(&public_key_path)?;
|
||||
Ok(SignatureStatus {
|
||||
required: cli.require_signature,
|
||||
signed: true,
|
||||
verified: true,
|
||||
method: "openssl dgst -sha256".to_string(),
|
||||
summary: "sha256sums detached signature verified".to_string(),
|
||||
public_key_fingerprint_sha256: Some(fingerprint),
|
||||
signature_file: Some(sig_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(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(status["checksum_verified"], true);
|
||||
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"));
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user