feat(rotate-key): add report + key fingerprint in hexdigit fmt

This commit is contained in:
Edouard Vanbelle
2026-08-02 01:05:02 +02:00
parent ddede168f9
commit 0a41f561d0
11 changed files with 210 additions and 23 deletions
@@ -229,6 +229,35 @@ impl JobStore for PgJobStore {
Ok(row.and_then(|(v,)| v))
}
async fn merge_stats(
&self,
extras: &serde_json::Map<String, serde_json::Value>,
) -> Result<(), DomainError> {
if extras.is_empty() {
return Ok(());
}
// JSONB concat (`||`) is a shallow merge — right side wins on
// key conflict, which matches the "last-write-wins" semantic
// in the trait doc. Existing keys from the engine's own
// `scanned_count` / `finding_count` (written via `checkpoint`
// / `record_finding`) are preserved because handlers never
// emit those keys in their extras map.
sqlx::query(
r#"
UPDATE jobs.recoverable_runs
SET stats = COALESCE(stats, '{}'::jsonb) || $1::jsonb,
last_progress_at = NOW()
WHERE id = $2
"#,
)
.bind(serde_json::Value::Object(extras.clone()))
.bind(self.run_id)
.execute(self.pool.as_ref())
.await
.map_err(|e| map_sqlx_err("merge_stats", e))?;
Ok(())
}
async fn mark_completed(&self) -> Result<(), DomainError> {
sqlx::query(
r#"
+111 -6
View File
@@ -119,9 +119,65 @@ impl RunStatus {
/// writes `status = Failed` with the message.
#[derive(Debug, Clone)]
pub enum RunOutcome {
Completed,
Paused { cursor: Vec<u8> },
Failed { message: String },
/// The run walked the whole subject space.
///
/// `extra_stats` is merged into the run row's `stats` JSONB
/// alongside the engine-owned `scanned_count` + `finding_count`
/// / `severity_counts`. Handlers use it to surface per-run
/// summary counters (e.g. `storage_rotate` reports
/// `{"rewritten": N, "skipped": M, "failed": K}`) — the outcome
/// message in `JobOutcome.extra` and every downstream reader
/// of `RunSummary.stats` see the merged fields.
///
/// Empty map = "no tenant-specific extras" — same shape as the
/// pre-K3 bare `Completed` variant. Handlers that don't
/// summarise their work call [`Self::completed`].
Completed {
extra_stats: serde_json::Map<String, serde_json::Value>,
},
Paused {
cursor: Vec<u8>,
},
Failed {
message: String,
},
}
impl RunOutcome {
/// Convenience for the common case: handler has nothing to add
/// to `stats` beyond what the engine already tracks (finding /
/// scanned counters). Equivalent to
/// `Completed { extra_stats: Map::new() }`.
pub fn completed() -> Self {
RunOutcome::Completed {
extra_stats: serde_json::Map::new(),
}
}
/// Convenience for handlers that want to surface per-run
/// summary counters. Takes any JSON object literal produced by
/// `serde_json::json!({...})`; panics if the top-level value
/// isn't an Object (programmer bug — the contract is
/// object-shaped).
///
/// Example — a rotate handler at run-complete:
///
/// ```ignore
/// return RunOutcome::completed_with(serde_json::json!({
/// "rewritten": rewritten_count,
/// "skipped": skipped_count,
/// "failed": failed_count,
/// }));
/// ```
pub fn completed_with(extras: serde_json::Value) -> Self {
match extras {
serde_json::Value::Object(map) => RunOutcome::Completed { extra_stats: map },
other => panic!(
"RunOutcome::completed_with expected a JSON object, got {}",
other
),
}
}
}
// ─── Traits — implementor + port ────────────────────────────────────────────
@@ -295,6 +351,24 @@ pub trait JobStore: Send + Sync {
detail: serde_json::Value,
) -> Result<(), DomainError>;
/// **Engine-only.** Merge `extras` into the run row's `stats`
/// JSONB (SQL `stats = stats || $1`). Called by [`run_or_resume`]
/// on [`RunOutcome::Completed`] to persist the handler's
/// per-run summary counters alongside the engine-owned
/// `scanned_count` / `finding_count`. Handler code MUST NOT
/// call this directly — return an `extra_stats` map on
/// `Completed` and the engine handles the write.
///
/// Idempotent: merging the same map twice yields the same row.
/// A stats key that already exists is OVERWRITTEN by the
/// merge (last-write-wins) — a handler that emits e.g.
/// `"rewritten": 300` at run end always displaces any prior
/// per-batch write of the same key.
async fn merge_stats(
&self,
extras: &serde_json::Map<String, serde_json::Value>,
) -> Result<(), DomainError>;
// ─── Terminal writes — engine-only. Do not call from handler code.
/// Engine-only. Called by [`run_or_resume`] on
@@ -630,8 +704,22 @@ pub async fn run_or_resume(
let stats = fetch_outcome_stats(&*provider, run_id).await;
match outcome {
RunOutcome::Completed => {
RunOutcome::Completed { extra_stats } => {
// Merge tenant-supplied extras into the run's stats
// JSONB BEFORE the terminal mark, so downstream readers
// see the merged view atomically. `fetch_outcome_stats`
// (a few lines up) already ran and reflects the state
// WITHOUT the merge — re-fetch so the outer JobOutcome
// includes the tenant counters too.
if !extra_stats.is_empty() {
log_terminal_write_err(
"merge_stats",
run_id,
store.merge_stats(&extra_stats).await,
);
}
log_terminal_write_err("mark_completed", run_id, store.mark_completed().await);
let stats = fetch_outcome_stats(&*provider, run_id).await;
JobOutcome::ok_with(
stats.finding_count,
serde_json::json!({
@@ -640,6 +728,7 @@ pub async fn run_or_resume(
"finding_count": stats.finding_count,
"scanned_count": stats.scanned_count,
"severity_counts": stats.by_severity,
"extra_stats": serde_json::Value::Object(extra_stats),
}),
)
}
@@ -865,6 +954,10 @@ mod tests {
progress_total: Option<u64>,
progress_kind: Option<ProgressKind>,
string_params: std::collections::HashMap<String, String>,
/// K3+: extras merged into the run's stats JSONB via
/// `merge_stats` at Completed time. Tests observe the merged
/// view by reading this map alongside `scanned_count`.
extra_stats: serde_json::Map<String, serde_json::Value>,
}
#[async_trait]
@@ -924,6 +1017,16 @@ mod tests {
async fn get_string_param(&self, key: &str) -> Result<Option<String>, DomainError> {
Ok(self.state.lock().unwrap().string_params.get(key).cloned())
}
async fn merge_stats(
&self,
extras: &serde_json::Map<String, serde_json::Value>,
) -> Result<(), DomainError> {
let mut s = self.state.lock().unwrap();
for (k, v) in extras {
s.extra_stats.insert(k.clone(), v.clone());
}
Ok(())
}
async fn mark_completed(&self) -> Result<(), DomainError> {
self.state.lock().unwrap().status = RunStatus::Completed;
Ok(())
@@ -976,6 +1079,7 @@ mod tests {
progress_total: None,
progress_kind: None,
string_params: std::collections::HashMap::new(),
extra_stats: serde_json::Map::new(),
}),
});
let id = store.run_id;
@@ -1035,6 +1139,7 @@ mod tests {
progress_total: None,
progress_kind: None,
string_params: std::collections::HashMap::new(),
extra_stats: serde_json::Map::new(),
}),
});
stores.push(store.clone());
@@ -1204,7 +1309,7 @@ mod tests {
_resume_cursor: Option<Vec<u8>>,
) -> RunOutcome {
store.checkpoint(vec![1, 2, 3], 5).await.unwrap();
RunOutcome::Completed
RunOutcome::completed()
}
}
@@ -1259,7 +1364,7 @@ mod tests {
resume_cursor: Option<Vec<u8>>,
) -> RunOutcome {
*self.saw_cursor.lock().unwrap() = resume_cursor;
RunOutcome::Completed
RunOutcome::completed()
}
}