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,
|
ChallengeFailure, CultCacheTopologyDriver, CultCacheWriteLeaseDriver, DockerRunnerDriver, FrozenSourceReceipt,
|
||||||
GitSourceDriver, InstalledReleaseObservation, IsolationEvidence, NginxRouteDriver,
|
GitSourceDriver, InstalledReleaseObservation, IsolationEvidence, NginxRouteDriver,
|
||||||
LockContended, ProcessIdentity, RouteActuation, RouteActuationGate, RouteActuationRefused, RouteActuators,
|
LockContended, ProcessIdentity, RouteActuation, RouteActuationGate, RouteActuationRefused, RouteActuators,
|
||||||
RouteObservation, RoutePreflightReceipt, RunnerPort, SourcePort,
|
RouteObservation, RoutePreflightReceipt, RunnerPort, RunnerStepFailure, SourcePort,
|
||||||
SystemdTransientWorkloadDriver, TopologyPort, WorkloadObservation, WorkloadPort,
|
SystemdTransientWorkloadDriver, TopologyPort, WorkloadObservation, WorkloadPort,
|
||||||
WriteLeasePort,
|
WriteLeasePort,
|
||||||
};
|
};
|
||||||
use crate::incident::{CloseReason, IncidentCondition, IncidentStore, render_incidents};
|
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::{
|
use crate::host_actuator::{
|
||||||
HostActuatorAccess, HostActuatorHub, HostActuatorRunnerDriver, HostActuatorWorkloadDriver,
|
HostActuatorAccess, HostActuatorHub, HostActuatorRunnerDriver, HostActuatorWorkloadDriver,
|
||||||
HostUnobservable, IdunnHostActuatorIdentity, SharedHostActuatorHub, spawn_hub_service,
|
HostUnobservable, IdunnHostActuatorIdentity, SharedHostActuatorHub, spawn_hub_service,
|
||||||
|
|
@ -4877,6 +4880,13 @@ fn status(
|
||||||
}
|
}
|
||||||
if let Some(abort) = &transaction.pre_fencing_abort {
|
if let Some(abort) = &transaction.pre_fencing_abort {
|
||||||
writeln!(out, " pre-fencing abort {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 {
|
if let Some(abort) = &transaction.post_fencing_abort {
|
||||||
writeln!(out, " post-fencing abort {abort:?}")?;
|
writeln!(out, " post-fencing abort {abort:?}")?;
|
||||||
|
|
@ -5374,6 +5384,32 @@ impl Engine {
|
||||||
IncidentStore::new(&self.options.incident_store)
|
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
|
/// Settle the outcome of one incident operation. A failure never leaves
|
||||||
/// this function: it is traced once per episode, under the operation and
|
/// 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),
|
/// 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()
|
if latest.value.pre_fencing_abort.is_none()
|
||||||
&& error.downcast_ref::<LockContended>().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)?;
|
self.begin_pre_fencing_abort(latest, error)?;
|
||||||
} else {
|
} else {
|
||||||
self.record_resumable_error(latest, &error)?;
|
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
|
/// 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
|
/// 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.
|
/// 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 {
|
fn bounded_detail(value: &str) -> String {
|
||||||
let value = value.replace(' | |||||||