Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Meta Vens
1786ddf981 Merge main (B1) into route continuity B2
B1's supervision now defers a restart whose projection cannot be demoted
by writing ContinuityBackoff.next_restart_at. B2's ceiling owns the
restart count and window on the same ContinuityBackoff; the deferral
writes only the wait and its reason, and B2's is_waiting is the single
gate on both. B1's own next_restart_at check is deleted.

Crash recovery no longer reads history: the restart ceiling is the
target's ContinuityBackoff alone, so an unreadable history.cc neither
stops a restart nor frees an exhausted target. Command freezing stays
fail-closed on history. B1's test that pinned a history-counted ceiling
is replaced by tests of the new owner.

Status renders each admitted target's continuity restarts (count, window,
next restart), the last deferral reason, and the route's degradation,
challenge backoff and actuation window. The deferral reason is stored on
ContinuityBackoff; a restart clears it.

The routed test fixture is rebuilt on B1's seeded_transaction, which now
takes an optional route.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 12:10:16 +02:00
Meta Vens
97f1ad26a7 Bound and back off route and unit repair (B2)
Every route actuation now goes through one per-target rolling ceiling
kept in the admitted generation's RouteSupervisionState, so it survives
a restart and a new generation. The route driver takes a
RouteActuationGate and asks it before each group of host mutations in
install, restore (rollback and post-fence withdrawal) and
restore_admitted_membership; the Engine's gate charges the generation
before the driver acts. 12 actuations per rolling hour.

A failed route proof, or a failed repair of the fragment it needs,
marks the route degraded and backs its next challenge off (max age,
doubling, capped at 10 minutes). It changes observation state only.

Continuity restarts are counted per target in ContinuityBackoff, in the
same CAS that mints the transaction: a doubling wait from 5 s, 6
restarts per rolling hour, inherited by each new generation. This
replaces counting failed transactions per generation id, which a
successful restart reset (F9).

The actuator program paths and the preflight root are RouteActuators on
RuntimeOptions, defaulting to today's paths; every route driver is built
through Engine::route_driver. The test module gains a routed
Stored<AdmittedGeneration> fixture and stub actuator programs beside
EngineFixture, and timeline tests count reloads for both storm paths:
a failing reload that deletes the fragment, and a candidate whose proof
fails and resumes.

A target with no routed admitted generation has no window yet, so a
first routed deployment is not metered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 11:04:07 +02:00
2 changed files with 1430 additions and 231 deletions

File diff suppressed because it is too large Load diff

View file

@ -5043,15 +5043,51 @@ pub struct NginxRouteDriver {
pub preflight_root: PathBuf,
}
/// The host programs and scratch directory the route driver actuates through.
/// The daemon owns one value and every driver is built from it, so the paths
/// are chosen in one place and a test can point them at stubs.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RouteActuators {
pub nginx: PathBuf,
pub systemd_run: PathBuf,
pub systemctl: PathBuf,
pub ufw: PathBuf,
pub preflight_root: PathBuf,
}
impl Default for RouteActuators {
fn default() -> Self {
Self {
nginx: PathBuf::from("/usr/sbin/nginx"),
systemd_run: PathBuf::from("/usr/bin/systemd-run"),
systemctl: PathBuf::from("/usr/bin/systemctl"),
ufw: PathBuf::from("/usr/sbin/ufw"),
preflight_root: PathBuf::from("/run/idunn/route-preflight"),
}
}
}
/// The one rate authority over route actuation. The driver asks it before
/// every group of host mutations (fragment write, firewall, `nginx -t`,
/// reload, private-mount validation) and does none of them when it refuses.
/// Its owner keeps the count durably, so the ceiling survives a restart.
pub trait RouteActuationGate {
fn admit(&self) -> Result<()>;
}
impl NginxRouteDriver {
pub fn new(binding: RouteBinding) -> Self {
Self::with_actuators(binding, &RouteActuators::default())
}
pub fn with_actuators(binding: RouteBinding, actuators: &RouteActuators) -> Self {
Self {
binding,
nginx_program: PathBuf::from("/usr/sbin/nginx"),
systemd_run_program: PathBuf::from("/usr/bin/systemd-run"),
systemctl_program: PathBuf::from("/usr/bin/systemctl"),
ufw_program: PathBuf::from("/usr/sbin/ufw"),
preflight_root: PathBuf::from("/run/idunn/route-preflight"),
nginx_program: actuators.nginx.clone(),
systemd_run_program: actuators.systemd_run.clone(),
systemctl_program: actuators.systemctl.clone(),
ufw_program: actuators.ufw.clone(),
preflight_root: actuators.preflight_root.clone(),
}
}
@ -5128,7 +5164,7 @@ impl NginxRouteDriver {
Ok(output)
}
fn render(&self, expected: &IdunnExpectedIncarnationRecord) -> Result<Vec<u8>> {
pub(crate) fn render(&self, expected: &IdunnExpectedIncarnationRecord) -> Result<Vec<u8>> {
expected.validate()?;
let expected_projection_sha256 = expected.canonical_sha256()?;
let route = expected
@ -5253,7 +5289,8 @@ impl NginxRouteDriver {
}
}
fn restore(&self, prior: Option<&[u8]>) -> Result<()> {
fn restore(&self, prior: Option<&[u8]>, gate: &dyn RouteActuationGate) -> Result<()> {
gate.admit()?;
self.write_fragment(prior)?;
self.reload()?;
// No prior membership means the stable endpoint no longer routes to
@ -5269,8 +5306,9 @@ impl NginxRouteDriver {
prior: Option<&[u8]>,
failure: anyhow::Error,
context: &str,
gate: &dyn RouteActuationGate,
) -> Result<T> {
match self.restore(prior) {
match self.restore(prior, gate) {
Ok(()) => Err(failure).context(context.to_owned()),
Err(rollback) => Err(failure).context(format!(
"{context}; route rollback also failed: {rollback:#}"
@ -5329,6 +5367,7 @@ impl NginxRouteDriver {
runtime_instance_id: &str,
preflight: &RoutePreflightReceipt,
rollback_allowed: bool,
gate: &dyn RouteActuationGate,
) -> Result<String> {
preflight.validate()?;
let rendered = self.render(expected)?;
@ -5347,6 +5386,7 @@ impl NginxRouteDriver {
&& prior_sha256 == preflight.incumbent_membership_sha256),
"route baseline changed after preflight"
);
gate.admit()?;
if !candidate_already_written {
atomic_replace(&self.binding.config_path, &rendered)?;
}
@ -5356,6 +5396,7 @@ impl NginxRouteDriver {
preflight.incumbent_configuration.as_deref(),
error,
"candidate route validation or reload failed",
gate,
);
}
return Err(error).context(
@ -5394,13 +5435,17 @@ impl NginxRouteDriver {
/// candidate's membership was installed, so restoring it is precise whether
/// there was an incumbent (its bytes) or none (removal). Used when a
/// transaction is abandoned after the fence.
pub fn withdraw_candidate_membership(&self, preflight: &RoutePreflightReceipt) -> Result<()> {
pub fn withdraw_candidate_membership(
&self,
preflight: &RoutePreflightReceipt,
gate: &dyn RouteActuationGate,
) -> Result<()> {
preflight.validate()?;
ensure!(
preflight.route_id == self.binding.route_id,
"route preflight receipt describes another route"
);
self.restore(preflight.incumbent_configuration.as_deref())
self.restore(preflight.incumbent_configuration.as_deref(), gate)
}
/// A fragment already equal to the admitted membership is left alone: no
@ -5409,6 +5454,7 @@ impl NginxRouteDriver {
&self,
expected: &IdunnExpectedIncarnationRecord,
membership_sha256: &str,
gate: &dyn RouteActuationGate,
) -> Result<()> {
let rendered = self.render(expected)?;
ensure!(
@ -5418,6 +5464,7 @@ impl NginxRouteDriver {
if self.current_configuration()?.as_deref() == Some(rendered.as_slice()) {
return Ok(());
}
gate.admit()?;
self.validate_candidate_in_private_mount(&rendered)?;
atomic_replace(&self.binding.config_path, &rendered)?;
if let Err(reload) = self.admit_endpoint().and_then(|()| self.reload()) {
@ -5538,6 +5585,7 @@ impl NginxRouteDriver {
expected: &IdunnExpectedIncarnationRecord,
runtime_instance_id: &str,
preflight: &RoutePreflightReceipt,
gate: &dyn RouteActuationGate,
) -> Result<()> {
preflight.validate()?;
let rendered = self.render(expected)?;
@ -5551,7 +5599,7 @@ impl NginxRouteDriver {
self.current_configuration()?.as_deref() == Some(rendered.as_slice()),
"candidate route changed before rollback"
);
self.restore(preflight.incumbent_configuration.as_deref())
self.restore(preflight.incumbent_configuration.as_deref(), gate)
}
}
@ -8034,6 +8082,15 @@ fn apply_identity(command: &mut Command, identity: Option<ProcessIdentity>) -> R
mod tests {
use super::*;
/// A gate that never refuses, for driver tests that are not about the ceiling.
struct Unmetered;
impl RouteActuationGate for Unmetered {
fn admit(&self) -> Result<()> {
Ok(())
}
}
#[test]
fn route_connect_retries_connection_refused_until_listener_is_ready() -> Result<()> {
let reservation = std::net::TcpListener::bind("127.0.0.1:0")?;
@ -10294,13 +10351,100 @@ mod tests {
"allow in to 10.77.0.1 port 17971 proto udp"
);
driver.restore(None)?;
driver.restore(None, &Unmetered)?;
assert_eq!(
fs::read_to_string(ufw.with_extension("calls"))?,
"delete allow in to 10.77.0.1 port 17971 proto udp\n"
);
fs::write(ufw.with_extension("absent"), b"gone\n")?;
driver.restore(None)?;
driver.restore(None, &Unmetered)?;
Ok(())
}
/// A gate that refuses stops every route actuation before it starts: no
/// program runs and the fragment on disk is untouched.
#[cfg(unix)]
#[test]
fn a_refusing_gate_stops_every_route_actuation_before_it_starts() -> Result<()> {
use std::os::unix::fs::PermissionsExt;
struct Refusing;
impl RouteActuationGate for Refusing {
fn admit(&self) -> Result<()> {
bail!("ceiling reached")
}
}
let temp = tempfile::tempdir()?;
let calls = temp.path().join("calls");
let program = |name: &str| -> Result<PathBuf> {
let path = temp.path().join(name);
fs::write(
&path,
format!("#!/bin/sh\necho \"{name} $*\" >> '{}'\nexit 0\n", calls.display()),
)?;
fs::set_permissions(&path, fs::Permissions::from_mode(0o755))?;
Ok(path)
};
let config = temp.path().join("service.conf");
let driver = NginxRouteDriver::with_actuators(
RouteBinding {
driver: RouteDriver::NginxStreamTcp,
route_id: "service-route".into(),
stable_endpoint: "http://127.0.0.1:4103".into(),
private_host: "127.0.0.1".into(),
private_port_start: 4104,
private_port_end: 4109,
config_path: config.clone(),
reload_unit: "nginx.service".into(),
},
&RouteActuators {
nginx: program("nginx")?,
systemd_run: program("systemd-run")?,
systemctl: program("systemctl")?,
ufw: program("ufw")?,
preflight_root: temp.path().join("preflight"),
},
);
let mut candidate = expected();
candidate.route = Some(cultnet_rs::IdunnExpectedRoute {
route_id: "service-route".into(),
transport: "http".into(),
stable_endpoint: "http://127.0.0.1:4103".into(),
candidate_endpoint: "http://127.0.0.1:4104".into(),
});
let rendered = driver.render(&candidate)?;
let membership_sha256 = sha256_id(&rendered);
let receipt = RoutePreflightReceipt {
route_id: "service-route".into(),
candidate_runtime_instance_id: digest('a'),
candidate_membership_sha256: membership_sha256.clone(),
incumbent_runtime_instance_id: None,
incumbent_membership_sha256: None,
incumbent_configuration: None,
};
fs::write(&config, b"drifted bytes\n")?;
assert!(
driver
.restore_admitted_membership(&candidate, &membership_sha256, &Refusing)
.is_err()
);
assert!(driver.withdraw_candidate_membership(&receipt, &Refusing).is_err());
assert!(
driver
.rollback(&candidate, &digest('a'), &receipt, &Refusing)
.is_err()
);
assert_eq!(fs::read(&config)?, b"drifted bytes\n");
fs::write(&config, &rendered)?;
assert!(
driver
.install(&candidate, &digest('a'), &receipt, true, &Refusing)
.is_err()
);
assert_eq!(fs::read(&config)?, rendered);
assert!(!calls.exists(), "a refused actuation must run no program");
Ok(())
}
@ -10360,22 +10504,22 @@ mod tests {
fs::write(&config, &rendered)?;
let modified = fs::metadata(&config)?.modified()?;
driver.restore_admitted_membership(&candidate, &membership_sha256)?;
driver.restore_admitted_membership(&candidate, &membership_sha256)?;
driver.restore_admitted_membership(&candidate, &membership_sha256, &Unmetered)?;
driver.restore_admitted_membership(&candidate, &membership_sha256, &Unmetered)?;
assert!(!calls.exists(), "an exact fragment must run no program");
assert!(!driver.preflight_root.exists());
assert_eq!(fs::read(&config)?, rendered);
assert_eq!(fs::metadata(&config)?.modified()?, modified);
fs::write(&config, b"drifted bytes\n")?;
driver.restore_admitted_membership(&candidate, &membership_sha256)?;
driver.restore_admitted_membership(&candidate, &membership_sha256, &Unmetered)?;
assert_eq!(fs::read(&config)?, rendered);
assert_eq!(count("systemctl reload")?, 1);
assert_eq!(count("systemd-run")?, 1);
assert_eq!(count("ufw allow")?, 1);
assert_eq!(count("nginx -t")?, 1);
driver.restore_admitted_membership(&candidate, &membership_sha256)?;
driver.restore_admitted_membership(&candidate, &membership_sha256, &Unmetered)?;
assert_eq!(count("systemctl reload")?, 1, "restored bytes never reload again");
assert_eq!(count("ufw")?, 1);
Ok(())
@ -10503,7 +10647,7 @@ mod tests {
let candidate_bytes = driver.render(&candidate)?;
fs::write(&config, &candidate_bytes)?;
let membership_sha256 = driver.install(&candidate, &digest('a'), &preflight, true)?;
let membership_sha256 = driver.install(&candidate, &digest('a'), &preflight, true, &Unmetered)?;
assert_eq!(
fs::read_to_string(systemctl.with_extension("calls"))?,
"reload\n"
@ -10530,19 +10674,19 @@ mod tests {
fs::write(systemctl.with_extension("fail"), b"fail\n")?;
assert!(
driver
.install(&candidate, &digest('b'), &next_preflight, false)
.install(&candidate, &digest('b'), &next_preflight, false, &Unmetered)
.is_err()
);
assert_eq!(fs::read(&config)?, admitted_bytes);
fs::remove_file(systemctl.with_extension("fail"))?;
driver.install(&candidate, &digest('b'), &next_preflight, false)?;
driver.install(&candidate, &digest('b'), &next_preflight, false, &Unmetered)?;
fs::write(&config, b"foreign route\n")?;
assert!(
driver
.install(&candidate, &digest('b'), &next_preflight, true)
.install(&candidate, &digest('b'), &next_preflight, true, &Unmetered)
.is_err()
);
driver.restore_admitted_membership(&candidate, &admitted.membership_sha256)?;
driver.restore_admitted_membership(&candidate, &admitted.membership_sha256, &Unmetered)?;
assert_eq!(fs::read(&config)?, admitted_bytes);
assert!(driver.observe_membership(&candidate, &admitted.membership_sha256)?);
@ -10550,12 +10694,12 @@ mod tests {
fs::write(systemctl.with_extension("fail"), b"fail\n")?;
assert!(
driver
.restore_admitted_membership(&candidate, &admitted.membership_sha256)
.restore_admitted_membership(&candidate, &admitted.membership_sha256, &Unmetered)
.is_err()
);
assert!(!config.exists());
fs::remove_file(systemctl.with_extension("fail"))?;
driver.restore_admitted_membership(&candidate, &admitted.membership_sha256)?;
driver.restore_admitted_membership(&candidate, &admitted.membership_sha256, &Unmetered)?;
assert_eq!(fs::read(&config)?, admitted_bytes);
Ok(())
}