Merge idunn/runner-failure-evidence: a failed runner keeps its evidence
Cut idunn-deploy-provenance:cut_spec:cut-runner-failure-evidence.r1, reports h1-h3, Soul s1-s3. The docker runner kept only stderr, so a step reporting on stdout failed with an empty message (Vili's node --test). A failed step's name and bounded stdout/stderr tails go to a root-only typed store read by idunn status; errors and the journal carry no output; one allowlist escape at the status sink. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
8400d0712f
4 changed files with 1774 additions and 14 deletions
|
|
@ -42,11 +42,14 @@ use crate::drivers::{
|
|||
ChallengeFailure, CultCacheTopologyDriver, CultCacheWriteLeaseDriver, DockerRunnerDriver, FrozenSourceReceipt,
|
||||
GitSourceDriver, InstalledReleaseObservation, IsolationEvidence, NginxRouteDriver,
|
||||
LockContended, ProcessIdentity, RouteActuation, RouteActuationGate, RouteActuationRefused, RouteActuators,
|
||||
RouteObservation, RoutePreflightReceipt, RunnerPort, SourcePort,
|
||||
RouteObservation, RoutePreflightReceipt, RunnerPort, RunnerStepFailure, SourcePort,
|
||||
SystemdTransientWorkloadDriver, TopologyPort, WorkloadObservation, WorkloadPort,
|
||||
WriteLeasePort,
|
||||
};
|
||||
use crate::incident::{CloseReason, IncidentCondition, IncidentStore, render_incidents};
|
||||
use crate::runner_evidence::{
|
||||
self, Kept, RunnerFailureRecord, runner_evidence_store_path, unreadable_store_path,
|
||||
};
|
||||
use crate::host_actuator::{
|
||||
HostActuatorAccess, HostActuatorHub, HostActuatorRunnerDriver, HostActuatorWorkloadDriver,
|
||||
HostUnobservable, IdunnHostActuatorIdentity, SharedHostActuatorHub, spawn_hub_service,
|
||||
|
|
@ -4877,6 +4880,13 @@ fn status(
|
|||
}
|
||||
if let Some(abort) = &transaction.pre_fencing_abort {
|
||||
writeln!(out, " pre-fencing abort {abort:?}")?;
|
||||
// The step that failed and its output tail live in their
|
||||
// own private store. An unreadable store costs its lines.
|
||||
runner_evidence::show_status(
|
||||
out,
|
||||
&runner_evidence_store_path(store_path),
|
||||
&transaction.transaction_id,
|
||||
)?;
|
||||
}
|
||||
if let Some(abort) = &transaction.post_fencing_abort {
|
||||
writeln!(out, " post-fencing abort {abort:?}")?;
|
||||
|
|
@ -5374,6 +5384,32 @@ impl Engine {
|
|||
IncidentStore::new(&self.options.incident_store)
|
||||
}
|
||||
|
||||
/// A failed runner step keeps its output tail in the runner evidence
|
||||
/// store and says so on the journal, naming no output. The store write is
|
||||
/// best effort: its failure is named in the line and never stops the
|
||||
/// abort that follows.
|
||||
fn keep_runner_failure(
|
||||
&self,
|
||||
transaction: &DeploymentTransaction,
|
||||
error: &anyhow::Error,
|
||||
) -> Option<String> {
|
||||
let failure = error.downcast_ref::<RunnerStepFailure>()?;
|
||||
let path = runner_evidence_store_path(&self.options.state_store);
|
||||
let recorded = now_millis().and_then(|now| {
|
||||
runner_evidence::record(
|
||||
&path,
|
||||
&RunnerFailureRecord::from_failure(
|
||||
&transaction.transaction_id,
|
||||
&transaction.command_id,
|
||||
&transaction.target,
|
||||
failure,
|
||||
now,
|
||||
),
|
||||
)
|
||||
});
|
||||
Some(runner_failure_journal_line(transaction, failure, &path, &recorded))
|
||||
}
|
||||
|
||||
/// Settle the outcome of one incident operation. A failure never leaves
|
||||
/// this function: it is traced once per episode, under the operation and
|
||||
/// the subject it was decided for (`*` for a pass over the whole store),
|
||||
|
|
@ -6315,6 +6351,9 @@ impl Engine {
|
|||
if latest.value.pre_fencing_abort.is_none()
|
||||
&& error.downcast_ref::<LockContended>().is_none()
|
||||
{
|
||||
if let Some(line) = self.keep_runner_failure(&latest.value, &error) {
|
||||
eprintln!("{line}");
|
||||
}
|
||||
self.begin_pre_fencing_abort(latest, error)?;
|
||||
} else {
|
||||
self.record_resumable_error(latest, &error)?;
|
||||
|
|
@ -10641,6 +10680,43 @@ const DETAIL_EMPTY: &str = "(no detail)";
|
|||
/// only judge, and this output always satisfies it. NUL is dropped, empty
|
||||
/// input gets a fixed placeholder, and an over-long value keeps its head and
|
||||
/// its tail (where a build log's error is) cut on char boundaries.
|
||||
/// The journal line for a failed runner step: the transaction, the subject and
|
||||
/// status (the failure's `Display`), and where the evidence is. Never output.
|
||||
fn runner_failure_journal_line(
|
||||
transaction: &DeploymentTransaction,
|
||||
failure: &RunnerStepFailure,
|
||||
store_path: &Path,
|
||||
recorded: &Result<Kept>,
|
||||
) -> String {
|
||||
let head = format!(
|
||||
"Idunn transaction {} (command {}, target {}) {failure}",
|
||||
transaction.transaction_id, transaction.command_id, transaction.target
|
||||
);
|
||||
match recorded {
|
||||
Ok(kept) => {
|
||||
let aside = match kept {
|
||||
Kept::Stored => String::new(),
|
||||
Kept::SetAside { replaced } => format!(
|
||||
"; an undecodable evidence store was set aside as {}{}",
|
||||
unreadable_store_path(store_path).display(),
|
||||
match replaced {
|
||||
Some(last_written) => format!(
|
||||
", replacing an earlier set-aside store last written at unix millis {last_written}"
|
||||
),
|
||||
None => String::new(),
|
||||
}
|
||||
),
|
||||
};
|
||||
format!(
|
||||
"{head}; evidence in {}{aside}; read it with idunn status --command {}",
|
||||
store_path.display(),
|
||||
transaction.command_id
|
||||
)
|
||||
}
|
||||
Err(error) => format!("{head}; evidence was not kept: {error:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded_detail(value: &str) -> String {
|
||||
let value = value.replace(' | ||||