From 46ec8241a28294fe289d28bfff63df629ce3a5de Mon Sep 17 00:00:00 2001 From: Eureka Date: Fri, 13 Nov 2026 16:27:56 +0000 Subject: [PATCH 1/6] Idunn: StaticRelease target declaration and workload binding A target may declare [static_release] artifact = "" instead of [service], and bind driver = "static-release" (release_root, current_link, one carry source/destination). TargetDeclaration.service becomes Option with a service() accessor; admission refuses both-or-neither, state/provides/ dependencies/conflicts/route/write lease on a static target, stop-then-start, retain_releases < 2, a carry destination that is not a single leaf or collides with an artifact destination, a carry source inside the release root, and a current link overlapping the release root or an authority path. Read-only audit of TransactionCompletion::Admitted / admitted_for readers (cut spec step 2); a static Admitted transaction has no AdmittedGeneration: - control_plane.rs:1781 (deadline_for_current_activity, Complete): reads the variant and drains_incumbent only. Tolerates. - :1828 (carries_a_deadline): variant only. Tolerates. - :2086 (is_terminal): variant plus post_commit_cleanup. Tolerates. - :2550 (validate, Complete): requires Admitted and generation-; the generation record is never read. Tolerates; static gets its own branch. - :5085 (derived_command_status): variant only. Tolerates. - :6567 region (command admission; the admitted_for read is at :6587): passes an Option incumbent, None for a static target. Tolerates. Anchor drifted by ~20 lines; the code matches. - :8371 (advance_committing): the only writer of Admitted with a generation; a static transaction never enters Committing. Unchanged. Other admitted_for readers (3517 fenced-incumbent check, 4963 render, 5482 incident close, 9571 provider rehydrate, 9654/9712/9777 rebase, continuity, take_target): all handle None; a static target has no fencing, no incident, no capability to be a provider. Fallout: runner_for/workload_for gain a StaticRelease arm (docker runner; the systemd driver serves install only), and the service-only readers in host.rs, drivers.rs and deployment_plan.rs go through service()?. Tests: cargo test -p idunn --lib deployment::tests::static_ (6 passed). Co-Authored-By: Claude Sonnet 5.5 --- src/control_plane.rs | 10 +- src/deployment.rs | 436 +++++++++++++++++++++++++++++++++++------ src/deployment_plan.rs | 9 +- src/drivers.rs | 10 +- src/host.rs | 4 +- 5 files changed, 396 insertions(+), 73 deletions(-) diff --git a/src/control_plane.rs b/src/control_plane.rs index cd49073..1dc0631 100644 --- a/src/control_plane.rs +++ b/src/control_plane.rs @@ -5546,7 +5546,9 @@ impl Engine { fn runner_for(&self, plan: &CompiledDeploymentPlan) -> Result> { let (_, binding) = plan.parsed_inputs()?; Ok(match &binding.workload { - WorkloadBinding::SystemdTransient(_) => Box::new(self.docker_runner.clone()), + WorkloadBinding::SystemdTransient(_) | WorkloadBinding::StaticRelease(_) => { + Box::new(self.docker_runner.clone()) + } WorkloadBinding::HostActuator(_) => Box::new(HostActuatorRunnerDriver { access: self.host_access()?, }), @@ -5557,7 +5559,11 @@ impl Engine { fn workload_for(&self, plan: &CompiledDeploymentPlan) -> Result> { let (_, binding) = plan.parsed_inputs()?; Ok(match &binding.workload { - WorkloadBinding::SystemdTransient(_) => Arc::clone(&self.systemd_workload), + // A static release has no workload to run: the systemd driver + // serves its install, and nothing else is ever asked of it. + WorkloadBinding::SystemdTransient(_) | WorkloadBinding::StaticRelease(_) => { + Arc::clone(&self.systemd_workload) + } WorkloadBinding::HostActuator(_) => { #[cfg(test)] if let Some(port) = self.host_workload.lock().expect("host workload mutex").clone() { diff --git a/src/deployment.rs b/src/deployment.rs index b7cbdc8..21cf234 100644 --- a/src/deployment.rs +++ b/src/deployment.rs @@ -45,7 +45,12 @@ pub struct TargetDeclaration { #[serde(default)] pub external_inputs: Vec, pub artifacts: Vec, - pub service: ServiceDeclaration, + /// A running workload. Exactly one of `service` and `static_release`. + #[serde(default)] + pub service: Option, + /// A sealed tree that is published by repointing a link, with no process. + #[serde(default)] + pub static_release: Option, #[serde(default)] pub state: Option, #[serde(default)] @@ -126,6 +131,15 @@ pub enum ArtifactSource { WorktreeTree, } +/// A target that is a sealed tree and nothing else: Idunn installs the release +/// and repoints the binding's current link at it. `artifact` names the tree a +/// web server reads through that link. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StaticReleaseDeclaration { + pub artifact: String, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ServiceDeclaration { @@ -475,20 +489,42 @@ pub struct DockerRunnerBinding { pub enum WorkloadBinding { SystemdTransient(SystemdWorkloadBinding), HostActuator(HostWorkloadBinding), + /// No process: a release directory and the link that names the current one. + StaticRelease(StaticReleaseBinding), } +static NO_ENVIRONMENT: BTreeMap = BTreeMap::new(); + impl WorkloadBinding { pub fn systemd(&self) -> Result<&SystemdWorkloadBinding> { match self { Self::SystemdTransient(binding) => Ok(binding), Self::HostActuator(_) => bail!("workload is host-actuator, not systemd-transient"), + Self::StaticRelease(_) => bail!("workload is a static release, not systemd-transient"), + } + } + + pub fn static_release(&self) -> Result<&StaticReleaseBinding> { + match self { + Self::StaticRelease(binding) => Ok(binding), + _ => bail!("workload is not a static release"), + } + } + + /// Where Idunn installs sealed releases on its own host: the one thing a + /// systemd workload and a static release share. + pub fn release_root(&self) -> Result<&Path> { + match self { + Self::SystemdTransient(binding) => Ok(&binding.release_root), + Self::StaticRelease(binding) => Ok(&binding.release_root), + Self::HostActuator(_) => bail!("a host-actuator workload installs on its own host"), } } pub fn host(&self) -> Result<&HostWorkloadBinding> { match self { Self::HostActuator(binding) => Ok(binding), - Self::SystemdTransient(_) => bail!("workload is systemd-transient, not host-actuator"), + _ => bail!("workload is not host-actuator"), } } @@ -496,6 +532,7 @@ impl WorkloadBinding { match self { Self::SystemdTransient(binding) => binding.state_root.is_some(), Self::HostActuator(binding) => binding.state_root.is_some(), + Self::StaticRelease(_) => false, } } @@ -503,6 +540,7 @@ impl WorkloadBinding { match self { Self::SystemdTransient(binding) => &binding.environment, Self::HostActuator(binding) => &binding.environment, + Self::StaticRelease(_) => &NO_ENVIRONMENT, } } @@ -510,6 +548,7 @@ impl WorkloadBinding { match self { Self::SystemdTransient(binding) => binding.secret_files.keys().collect(), Self::HostActuator(binding) => binding.secret_files.keys().collect(), + Self::StaticRelease(_) => Vec::new(), } } @@ -517,6 +556,7 @@ impl WorkloadBinding { match self { Self::SystemdTransient(binding) => &binding.argument_bindings, Self::HostActuator(binding) => &binding.argument_bindings, + Self::StaticRelease(_) => &NO_ENVIRONMENT, } } @@ -551,6 +591,49 @@ impl WorkloadBinding { } } +/// A static release on the Idunn host. `current_link` is the symlink a web +/// server's root reads through; Idunn repoints it at one sealed release. +/// `carry` names one operator-owned file (a per-site config) that Idunn copies +/// into each release as `destination` when it flips: the copy sits beside the +/// artifacts, outside every sealed digest. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StaticReleaseBinding { + pub release_root: PathBuf, + pub current_link: PathBuf, + pub carry: StaticCarryBinding, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StaticCarryBinding { + pub source: PathBuf, + pub destination: PathBuf, +} + +impl StaticReleaseBinding { + fn validate(&self) -> Result<()> { + require_absolute_path(&self.release_root, "static release root")?; + require_absolute_path(&self.current_link, "static current link")?; + require_absolute_path(&self.carry.source, "static carry source")?; + require_leaf_path(&self.carry.destination, "static carry destination")?; + ensure!( + !paths_overlap(&self.current_link, &self.release_root), + "static current link and release root overlap" + ); + ensure!( + !self.carry.source.starts_with(&self.release_root), + "static carry source is inside the release root" + ); + ensure!( + self.carry.source != self.current_link + && !self.carry.source.starts_with(&self.current_link), + "static carry source is the current link" + ); + Ok(()) + } +} + /// A workload on a managed host. Every path is the host's and is validated by /// the actuator that owns the host; here they are opaque non-empty values. /// The host's actuator is authenticated against `host_trust_anchor_store`, @@ -977,57 +1060,76 @@ impl TargetDeclaration { "target declaration uses no runner classes" ); - ensure!( - artifact_ids.contains(self.service.executable_artifact.as_str()), - "service executable artifact is not declared" - ); - for artifact in &self.service.required_adjacent_artifacts { + match (&self.service, &self.static_release) { + (Some(service), None) => { + ensure!( + artifact_ids.contains(service.executable_artifact.as_str()), + "service executable artifact is not declared" + ); + for artifact in &service.required_adjacent_artifacts { + ensure!( + artifact_ids.contains(artifact.as_str()), + "adjacent service artifact {artifact} is not declared" + ); + } + for argument in &service.arguments { + validate_launch_argument(argument)?; + } + require_capability(&service.health.contract, "health contract")?; + for name in &service.required_environment { + require_environment_name(name)?; + } ensure!( - artifact_ids.contains(artifact.as_str()), - "adjacent service artifact {artifact} is not declared" + service + .required_environment + .contains(IDUNN_RUNTIME_BUNDLE_ENVIRONMENT), + "service does not require the standard Idunn runtime bundle" ); - } - for argument in &self.service.arguments { - validate_launch_argument(argument)?; - } - require_capability(&self.service.health.contract, "health contract")?; - for name in &self.service.required_environment { - require_environment_name(name)?; - } - ensure!( - self.service - .required_environment - .contains(IDUNN_RUNTIME_BUNDLE_ENVIRONMENT), - "service does not require the standard Idunn runtime bundle" - ); - ensure!( - self.service - .required_environment - .contains(IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT) - == self.service.route_required, - "routed services must require exactly one Idunn-owned candidate bind" - ); - ensure!( - !self - .service - .required_environment - .contains(IDUNN_RUNTIME_MEMBER_BIND_ENVIRONMENT) - || self.service.route_required, - "only routed services may require the Idunn-owned member bind" - ); - ensure!( - self.service - .required_environment - .contains(IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT) - == self.write_lease_required(), - "state-writing services must require exactly one Idunn-owned process write lease" - ); - for name in &self.service.optional_environment { - require_environment_name(name)?; ensure!( - !self.service.required_environment.contains(name), - "service environment {name} is both required and optional" + service + .required_environment + .contains(IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT) + == service.route_required, + "routed services must require exactly one Idunn-owned candidate bind" ); + ensure!( + !service + .required_environment + .contains(IDUNN_RUNTIME_MEMBER_BIND_ENVIRONMENT) + || service.route_required, + "only routed services may require the Idunn-owned member bind" + ); + ensure!( + service + .required_environment + .contains(IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT) + == self.write_lease_required(), + "state-writing services must require exactly one Idunn-owned process write lease" + ); + for name in &service.optional_environment { + require_environment_name(name)?; + ensure!( + !service.required_environment.contains(name), + "service environment {name} is both required and optional" + ); + } + + } + (None, Some(static_release)) => { + ensure!( + artifact_ids.contains(static_release.artifact.as_str()), + "static release artifact is not declared" + ); + ensure!( + self.state.is_none() + && self.provides.is_empty() + && self.dependencies.is_empty() + && self.conflicts.is_empty(), + "a static release declares no state, capability, dependency or conflict" + ); + } + (Some(_), Some(_)) => bail!("target declares both a service and a static release"), + (None, None) => bail!("target declares neither a service nor a static release"), } if let Some(state) = &self.state { @@ -1144,6 +1246,14 @@ impl TargetDeclaration { Ok(()) } + /// The service of a service target. A static release has none, and a code + /// path that asks for one has been reached by the wrong kind of target. + pub fn service(&self) -> Result<&ServiceDeclaration> { + self.service + .as_ref() + .context("target declares no service") + } + pub fn write_lease_required(&self) -> bool { self.state.as_ref().is_some_and(|state| { state @@ -1338,6 +1448,29 @@ impl OperatorBinding { ); return self.validate_shared_tail(); } + WorkloadBinding::StaticRelease(workload) => { + workload.validate()?; + ensure!( + self.process_write_lease.is_none() && self.route.is_none(), + "a static release has no write lease or route" + ); + ensure!( + self.rollout.strategy == RolloutStrategy::CandidateThenPromote, + "a static release rolls out candidate-then-promote: it never stops an incumbent" + ); + for authority in [ + &self.brakes.deployment_store, + &self.brakes.lifecycle_store, + &self.runtime_identity.trust_anchor_store, + ] { + ensure!( + !paths_overlap(authority, &workload.release_root) + && !paths_overlap(authority, &workload.current_link), + "static release path overlaps an Idunn authority path" + ); + } + return self.validate_shared_tail(); + } WorkloadBinding::SystemdTransient(workload) => { ensure!( self.rollout.strategy == RolloutStrategy::CandidateThenPromote, @@ -1565,6 +1698,11 @@ impl OperatorBinding { self.target == declaration.target, "binding target does not match declaration" ); + ensure!( + declaration.static_release.is_some() + == matches!(self.workload, WorkloadBinding::StaticRelease(_)), + "a static release declaration needs a static-release workload, and only it" + ); let bound_gitlinks: BTreeSet<_> = self.repository.gitlinks.keys().cloned().collect(); ensure!( bound_gitlinks == declaration.required_gitlinks, @@ -1676,6 +1814,19 @@ impl OperatorBinding { input.id ); } + let Some(service) = declaration.service.as_ref() else { + let WorkloadBinding::StaticRelease(workload) = &self.workload else { + bail!("a static release declaration needs a static-release workload"); + }; + ensure!( + declaration + .artifacts + .iter() + .all(|artifact| artifact.destination != workload.carry.destination), + "static carry destination collides with an artifact destination" + ); + return Ok(()); + }; let workload_names: BTreeSet<&String> = self .workload .environment() @@ -1702,7 +1853,7 @@ impl OperatorBinding { if host_workload { available_environment.insert(IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT.into()); } - if declaration.service.route_required { + if service.route_required { available_environment.insert(IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT.into()); if self.route.as_ref().is_some_and(|route| route.member.is_some()) { available_environment.insert(IDUNN_RUNTIME_MEMBER_BIND_ENVIRONMENT.into()); @@ -1712,16 +1863,14 @@ impl OperatorBinding { available_environment.insert(IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT.into()); } ensure!( - declaration - .service + service .required_environment .is_subset(&available_environment), "operator service environment omits a required launch input" ); - let declared_environment: BTreeSet<_> = declaration - .service + let declared_environment: BTreeSet<_> = service .required_environment - .union(&declaration.service.optional_environment) + .union(&service.optional_environment) .cloned() .collect(); // On the Idunn host the presence identity reaches the workload as a @@ -1741,8 +1890,7 @@ impl OperatorBinding { available_environment.is_subset(&declared_environment), "operator service environment contains an undeclared launch input" ); - let required_arguments: BTreeSet<_> = declaration - .service + let required_arguments: BTreeSet<_> = service .arguments .iter() .filter_map(|argument| match argument { @@ -1758,6 +1906,7 @@ impl OperatorBinding { .as_ref() .map(|path| path.display().to_string()), WorkloadBinding::HostActuator(workload) => workload.state_root.clone(), + WorkloadBinding::StaticRelease(_) => None, }; match ( state_root.as_deref(), @@ -1780,8 +1929,8 @@ impl OperatorBinding { "operator argument bindings do not exactly match the launch contract" ); match ( - declaration.service.route_required, - declaration.service.transport, + service.route_required, + service.transport, self.route.as_ref(), ) { (true, ServiceTransport::Http, Some(route)) @@ -2344,7 +2493,7 @@ nodes = ["yggdrasil"] ) .is_err() ); - assert!(recipe.service.route_required); + assert!(recipe.service().unwrap().route_required); } #[test] @@ -2832,4 +2981,171 @@ max_connections = {value}"), "v2", )); } + + /// A static release: a vite-style tree published by repointing a link. + pub(crate) const STATIC_RECIPE: &str = r#" +schema = "gamecult.idunn.target_declaration.v1" +target = "service" +source_stamp_environment = "SERVICE_BUILD_COMMIT" + +[[steps]] +id = "build" +phase = "build" +runner = "rust" +argv = ["true"] + +[[artifacts]] +id = "dist" +source_kind = "worktree-tree" +source = "dist" +destination = "dist" + +[static_release] +artifact = "dist" +"#; + + /// `@SITE@` stands for the stand-in site tree a test lays out. + pub(crate) const STATIC_BINDING: &str = r#" +schema = "gamecult.idunn.operator_binding.v2" +target = "service" + +[repository] +origin = "https://github.com/GameCult/Service.git" +admitted_ref = "refs/heads/main" +minimum_revision = "1111111111111111111111111111111111111111" +selection = "ref-head" +checkout = "/srv/build/Service" +recipe_path = "deployment/idunn/recipe.toml" + +[runners.rust] +driver = "docker" +image = "rust@sha256:4c2fd73ef19c5ef9d54bee03b06b2839a392604fbfcd578ed948b71b37c1d7fb" +user = "1000:1000" +affordances = ["source-read", "artifact-write"] +allowed_programs = ["true"] +memory_mebibytes = 1024 +cpu_quota_percent = 100 +pids_limit = 128 +tmpfs_mebibytes = 64 + +[workload] +driver = "static-release" +release_root = "@SITE@/releases" +current_link = "@SITE@/www/current" + +[workload.carry] +source = "@SITE@/config/site.json" +destination = "config.json" + +[runtime_identity] +runtime_id = "site-yggdrasil" +expected_signer_identity_id = "site-runtime-signer" +trust_anchor_store = "@SITE@/trust/site.cc" + +[brakes] +deployment_store = "@SITE@/authority/deployment-brake.cc" +lifecycle_store = "@SITE@/authority/lifecycle-brake.cc" + +[rollout] +strategy = "candidate-then-promote" +drain_seconds = 1 +retain_releases = 2 + +[placement] +desired_replicas = 1 +nodes = ["yggdrasil"] +"#; + + fn static_binding(replace: &[(&str, &str)]) -> String { + let mut text = STATIC_BINDING.replace("@SITE@", "/srv/site"); + for (from, to) in replace { + assert!(text.contains(from), "the fixture lost {from}"); + text = text.replace(from, to); + } + text + } + + #[test] + fn static_recipe_and_binding_admit() { + let recipe = TargetDeclaration::parse(STATIC_RECIPE).unwrap(); + let binding = OperatorBinding::parse(&static_binding(&[])).unwrap(); + binding.admit(&recipe).unwrap(); + assert!(recipe.service().is_err()); + assert_eq!(binding.workload.release_root().unwrap(), Path::new("/srv/site/releases")); + } + + #[test] + fn static_recipe_declares_exactly_one_of_service_and_static_release() { + let both = format!("{RECIPE}\n[static_release]\nartifact = \"daemon\"\n"); + let error = TargetDeclaration::parse(&both).unwrap_err(); + assert!(format!("{error:#}").contains("both a service and a static release")); + let neither = STATIC_RECIPE.replace("[static_release]\nartifact = \"dist\"\n", ""); + let error = TargetDeclaration::parse(&neither).unwrap_err(); + assert!(format!("{error:#}").contains("neither a service nor a static release")); + let unknown = STATIC_RECIPE.replace("artifact = \"dist\"", "artifact = \"nope\""); + assert!(TargetDeclaration::parse(&unknown).is_err()); + } + + #[test] + fn static_release_refuses_a_workload_contract() { + for extra in [ + "\n[[provides]]\ncapability = \"site.runtime\"\nschema = \"site.v1\"\ncompatibility = \"v1\"\n", + "\n[[conflicts]]\ncapability = \"site.old\"\nreason = \"replaced\"\n", + "\n[state]\nschema_generation = \"v1\"\n\n[[state.slots]]\nid = \"world\"\nrelative_path = \"world.cc\"\nkind = \"cultcache-file\"\nschema = \"site.v1\"\nwriter = \"none\"\nrecovery = \"preserve\"\nstartup = \"open-at-start\"\n", + ] { + let error = TargetDeclaration::parse(&format!("{STATIC_RECIPE}{extra}")).unwrap_err(); + assert!( + format!("{error:#}").contains("a static release declares no state"), + "{extra}: {error:#}" + ); + } + for (from, to, refusal) in [ + ("[rollout]", "[route]\ndriver = \"nginx-stream-tcp\"\nroute_id = \"r\"\nstable_endpoint = \"http://127.0.0.1:18830\"\nprivate_host = \"127.0.0.1\"\nprivate_port_start = 18831\nprivate_port_end = 18839\nconfig_path = \"/etc/nginx/r.conf\"\nreload_unit = \"nginx.service\"\n\n[rollout]", "no write lease or route"), + ("[rollout]", "[process_write_lease]\nrecord_path = \"/srv/site/lease.cc\"\n\n[rollout]", "no write lease or route"), + ("retain_releases = 2", "retain_releases = 1", "retain at least current and prior"), + ("candidate-then-promote", "stop-then-start", "never stops an incumbent"), + ] { + let error = OperatorBinding::parse(&static_binding(&[(from, to)])).unwrap_err(); + assert!(format!("{error:#}").contains(refusal), "{to}: {error:#}"); + } + } + + #[test] + fn static_release_and_service_recipes_need_their_own_binding_kind() { + let static_recipe = TargetDeclaration::parse(STATIC_RECIPE).unwrap(); + let service_recipe = TargetDeclaration::parse(RECIPE).unwrap(); + let static_binding = OperatorBinding::parse(&static_binding(&[])).unwrap(); + let service_binding = OperatorBinding::parse(BINDING).unwrap(); + assert!(static_binding.admit(&service_recipe).is_err()); + assert!(service_binding.admit(&static_recipe).is_err()); + } + + #[test] + fn static_carry_destination_must_not_be_an_artifact_destination() { + let recipe = TargetDeclaration::parse(STATIC_RECIPE).unwrap(); + let binding = + OperatorBinding::parse(&static_binding(&[("destination = \"config.json\"", "destination = \"dist\"")])) + .unwrap(); + let error = binding.admit(&recipe).unwrap_err(); + assert!(format!("{error:#}").contains("collides with an artifact destination")); + for destination in ["a/b", "../b", "/b", ""] { + let line = format!("destination = \"{destination}\""); + assert!( + OperatorBinding::parse(&static_binding(&[("destination = \"config.json\"", &line)])).is_err(), + "{destination}" + ); + } + } + + #[test] + fn static_carry_source_and_link_stay_out_of_the_release_root() { + for (from, to) in [ + ("source = \"/srv/site/config/site.json\"", "source = \"/srv/site/releases/site.json\""), + ("current_link = \"/srv/site/www/current\"", "current_link = \"/srv/site/releases/current\""), + ("source = \"/srv/site/config/site.json\"", "source = \"site.json\""), + ("release_root = \"/srv/site/releases\"", "release_root = \"/srv/site/www\""), + ] { + assert!(OperatorBinding::parse(&static_binding(&[(from, to)])).is_err(), "{to}"); + } + } } diff --git a/src/deployment_plan.rs b/src/deployment_plan.rs index eceb513..da790c7 100644 --- a/src/deployment_plan.rs +++ b/src/deployment_plan.rs @@ -854,14 +854,15 @@ impl SealedRelease { ) -> Result { self.validate_against(plan)?; let (declaration, binding) = plan.parsed_inputs()?; + let service = declaration.service()?; let route = match (&binding.route, plan.candidate_port) { (Some(bound), Some(port)) => Some(IdunnExpectedRoute { route_id: bound.route_id.clone(), - transport: endpoint_scheme(declaration.service.transport)?.into(), + transport: endpoint_scheme(service.transport)?.into(), stable_endpoint: bound.stable_endpoint.clone(), candidate_endpoint: format!( "{}://{}:{port}", - endpoint_scheme(declaration.service.transport)?, + endpoint_scheme(service.transport)?, bound.private_host ), }), @@ -871,7 +872,7 @@ impl SealedRelease { let artifact_sha256 = self .artifacts .iter() - .find(|artifact| artifact.artifact_id == declaration.service.executable_artifact) + .find(|artifact| artifact.artifact_id == service.executable_artifact) .context("sealed release has no executable service artifact")? .sha256 .clone(); @@ -918,7 +919,7 @@ impl SealedRelease { .runtime_identity .expected_signer_identity_id .clone(), - health_contract: declaration.service.health.contract.clone(), + health_contract: service.health.contract.clone(), artifact_sha256, state_schema_generation: declaration .state diff --git a/src/drivers.rs b/src/drivers.rs index 3ea1145..4a03b5e 100644 --- a/src/drivers.rs +++ b/src/drivers.rs @@ -3435,11 +3435,11 @@ impl SystemdTransientWorkloadDriver { installed: &Path, ) -> Result> { let executable_artifact = - release_artifact(declaration, &declaration.service.executable_artifact)?; + release_artifact(declaration, &declaration.service()?.executable_artifact)?; let executable = installed.join(&executable_artifact.destination); ensure!(executable.is_file(), "sealed service executable is absent"); let mut command = vec![executable.into_os_string()]; - for argument in &declaration.service.arguments { + for argument in &declaration.service()?.arguments { command.push(match argument { LaunchArgument::Literal { value } => value.into(), LaunchArgument::Binding { name } => binding.workload.systemd()?.argument_bindings @@ -3734,7 +3734,7 @@ impl SystemdTransientWorkloadDriver { ) -> Result<()> { ensure!( declaration - .service + .service()? .required_environment .contains(IDUNN_RUNTIME_BUNDLE_ENVIRONMENT), "target does not declare the standard Idunn runtime bundle" @@ -3757,7 +3757,7 @@ impl SystemdTransientWorkloadDriver { parent_only_open_file_properties(parent_only_file_descriptors)?; let environment = self.launch_environment(binding, bundle, expected, unit)?; let executable_artifact = - release_artifact(declaration, &declaration.service.executable_artifact)?; + release_artifact(declaration, &declaration.service()?.executable_artifact)?; let executable = installed.join(&executable_artifact.destination); ensure!(executable.is_file(), "sealed service executable is absent"); let unit_description = format!( @@ -3967,7 +3967,7 @@ impl WorkloadPort for SystemdTransientWorkloadDriver { let (declaration, binding) = plan.parsed_inputs()?; self.validate_writable_bindings(&binding)?; let executable_artifact = - release_artifact(&declaration, &declaration.service.executable_artifact)?; + release_artifact(&declaration, &declaration.service()?.executable_artifact)?; let executable = installed.root.join(&executable_artifact.destination); let unit = self.unit_name( &binding.workload.systemd()?.unit_prefix, diff --git a/src/host.rs b/src/host.rs index 4c6cbec..5605a8d 100644 --- a/src/host.rs +++ b/src/host.rs @@ -631,11 +631,11 @@ impl Actuator { workload: &HostWorkloadBinding, installed: &Path, ) -> Result<(PathBuf, Vec)> { - let artifact = release_artifact(declaration, &declaration.service.executable_artifact)?; + let artifact = release_artifact(declaration, &declaration.service()?.executable_artifact)?; let executable = installed.join(&artifact.destination); ensure!(executable.is_file(), "sealed service executable is absent"); let mut arguments = Vec::new(); - for argument in &declaration.service.arguments { + for argument in &declaration.service()?.arguments { arguments.push(match argument { LaunchArgument::Literal { value } => value.clone(), LaunchArgument::Binding { name } => workload From 6c8fd12335a9aad8d70cefab48ddddeab5b960a9 Mon Sep 17 00:00:00 2001 From: Eureka Date: Fri, 13 Nov 2026 16:28:07 +0000 Subject: [PATCH 2/6] Idunn: StaticReleasePort and the filesystem flip driver install_release and validate_installed_release read the release root through WorkloadBinding::release_root(), so a static release installs through the same copy, digest and harden path as a systemd one. StaticReleasePort { observe_current, carry_and_flip } with FilesystemStaticRelease: reads the operator's site file (regular file, not a link, 1 MiB cap) and hashes it before anything moves; writes / by temp + rename at mode 0444 outside every artifact digest; then renames a fresh symlink over current_link (a stale flip link a crash left is replaced; a real directory in the link's place is never replaced). observe_current refuses a link that names anything but a release under the release root. Tests on a tempdir stand-in site (fake vite dist with .br/.gz twins, fake site config): cargo test -p idunn --lib drivers::tests::static_release_ (5 passed). Co-Authored-By: Claude Sonnet 5.5 --- src/drivers.rs | 346 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 341 insertions(+), 5 deletions(-) diff --git a/src/drivers.rs b/src/drivers.rs index 4a03b5e..67068f2 100644 --- a/src/drivers.rs +++ b/src/drivers.rs @@ -25,7 +25,8 @@ use crate::deployment::{ IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, IDUNN_RUNTIME_MEMBER_BIND_ENVIRONMENT, LaunchArgument, OperatorBinding, RUNTIME_PRESENCE_IDENTITY_BINDING, RUNTIME_PRESENCE_IDENTITY_FD_NAME, RouteBinding, - RouteDriver, SourceSelectionPolicy, TargetDeclaration, WorkloadNetwork, + RouteDriver, SourceSelectionPolicy, StaticReleaseBinding, TargetDeclaration, + WorkloadNetwork, }; use crate::deployment_plan::{ ArtifactReceipt, CompiledDeploymentPlan, ExternalInputMaterializationReceipt, GitlinkTreeFact, @@ -624,6 +625,185 @@ pub trait WorkloadPort { ) -> Result; } +/// The largest per-site file a static release will carry. +const STATIC_CARRY_LIMIT_BYTES: u64 = 1024 * 1024; + +/// What the flip landed: the digest of the exact bytes now inside the release. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StaticFlip { + pub carried_sha256: String, +} + +/// The one external change a static release makes: copy the operator's site +/// file into an installed release and repoint the current link at it. The port +/// decides nothing; the engine decides when, and records what it reports. +pub trait StaticReleasePort { + /// The sealed release id the current link names: `None` when the link does + /// not exist yet, an error when it names anything but a release under the + /// binding's release root. + fn observe_current(&self, binding: &StaticReleaseBinding) -> Result>; + + /// Copy the carry into `release_id` and repoint the link at it. Nothing + /// moves unless the carry source was read, so a missing site file leaves + /// the link where it was. Repeating it is harmless. + fn carry_and_flip(&self, binding: &StaticReleaseBinding, release_id: &str) + -> Result; +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct FilesystemStaticRelease; + +#[cfg(unix)] +impl StaticReleasePort for FilesystemStaticRelease { + fn observe_current(&self, binding: &StaticReleaseBinding) -> Result> { + let metadata = match fs::symlink_metadata(&binding.current_link) { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(error).with_context(|| { + format!("reading current link {}", binding.current_link.display()) + }); + } + }; + ensure!( + metadata.file_type().is_symlink(), + "current {} is not a symlink", + binding.current_link.display() + ); + let target = fs::read_link(&binding.current_link)?; + let target = if target.is_absolute() { + target + } else { + binding + .current_link + .parent() + .context("current link has no parent")? + .join(target) + }; + ensure!( + target.parent() == Some(binding.release_root.as_path()), + "current link names {}, which is not a release under {}", + target.display(), + binding.release_root.display() + ); + let id = target + .file_name() + .and_then(OsStr::to_str) + .context("current link names no release")?; + require_sha256_id(id, "release named by the current link")?; + Ok(Some(id.to_owned())) + } + + fn carry_and_flip( + &self, + binding: &StaticReleaseBinding, + release_id: &str, + ) -> Result { + use std::os::unix::fs::{OpenOptionsExt, symlink}; + + require_sha256_id(release_id, "release to flip to")?; + let release = binding.release_root.join(release_id); + let release_metadata = fs::symlink_metadata(&release) + .with_context(|| format!("installed release {}", release.display()))?; + ensure!( + release_metadata.is_dir(), + "installed release {} is not a directory", + release.display() + ); + let link_parent = binding + .current_link + .parent() + .context("current link has no parent")?; + match fs::symlink_metadata(&binding.current_link) { + Ok(metadata) => ensure!( + metadata.file_type().is_symlink(), + "current {} is not a symlink and will not be replaced", + binding.current_link.display() + ), + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(error).context("reading current link"), + } + + // Everything that can fail on the operator's side fails here, before + // the link moves. + let source = &binding.carry.source; + let source_metadata = fs::symlink_metadata(source) + .with_context(|| format!("reading carry source {}", source.display()))?; + ensure!( + source_metadata.is_file(), + "carry source {} is not a regular file", + source.display() + ); + ensure!( + source_metadata.len() <= STATIC_CARRY_LIMIT_BYTES, + "carry source {} is larger than {STATIC_CARRY_LIMIT_BYTES} bytes", + source.display() + ); + let mut bytes = Vec::new(); + fs::File::open(source)? + .take(STATIC_CARRY_LIMIT_BYTES + 1) + .read_to_end(&mut bytes)?; + ensure!( + bytes.len() as u64 <= STATIC_CARRY_LIMIT_BYTES, + "carry source {} grew past {STATIC_CARRY_LIMIT_BYTES} bytes", + source.display() + ); + let carried_sha256 = format!("sha256-{:x}", Sha256::digest(&bytes)); + + let carried = release.join(&binding.carry.destination); + let temporary_carry = release.join(format!(".carry-{}", Uuid::new_v4())); + let written = (|| -> Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o444) + .open(&temporary_carry)?; + file.write_all(&bytes)?; + file.sync_all()?; + fs::rename(&temporary_carry, &carried)?; + fs::File::open(&release)?.sync_all()?; + Ok(()) + })(); + if let Err(error) = written { + let _ = fs::remove_file(&temporary_carry); + return Err(error) + .with_context(|| format!("carrying the site file into {}", release.display())); + } + + // A link is renamed over, never edited: a reader sees the old release + // or the new one. A temporary link a crashed flip left is replaced. + let temporary_link = link_parent.join(format!( + ".{}.flip", + binding + .current_link + .file_name() + .and_then(OsStr::to_str) + .context("current link has no name")? + )); + if fs::symlink_metadata(&temporary_link).is_ok() { + fs::remove_file(&temporary_link).context("removing a stale flip link")?; + } + symlink(&release, &temporary_link).context("creating the flip link")?; + if let Err(error) = fs::rename(&temporary_link, &binding.current_link) { + let _ = fs::remove_file(&temporary_link); + return Err(error).context("repointing the current link"); + } + fs::File::open(link_parent)?.sync_all()?; + Ok(StaticFlip { carried_sha256 }) + } +} + +#[cfg(not(unix))] +impl StaticReleasePort for FilesystemStaticRelease { + fn observe_current(&self, _: &StaticReleaseBinding) -> Result> { + bail!("a static release flip requires Unix symlinks") + } + + fn carry_and_flip(&self, _: &StaticReleaseBinding, _: &str) -> Result { + bail!("a static release flip requires Unix symlinks") + } +} + pub trait TopologyPort { fn publish_expected( &self, @@ -2903,7 +3083,7 @@ impl SystemdTransientWorkloadDriver { ) -> Result { release.release.validate_against(plan)?; let (_, binding) = plan.parsed_inputs()?; - let release_root = &binding.workload.systemd()?.release_root; + let release_root = binding.workload.release_root()?; fs::create_dir_all(release_root) .with_context(|| format!("creating release root {}", release_root.display()))?; let installed = release_root.join(&release.release.sealed_release_id); @@ -2955,8 +3135,7 @@ impl SystemdTransientWorkloadDriver { && installed.root == binding .workload - .systemd()? - .release_root + .release_root()? .join(&release.sealed_release_id), "installed release observation belongs to another sealed release" ); @@ -8524,7 +8703,7 @@ fn require_git_sha(value: &str, label: &str) -> Result<()> { Ok(()) } -fn require_sha256_id(value: &str, label: &str) -> Result<()> { +pub(crate) fn require_sha256_id(value: &str, label: &str) -> Result<()> { let digest = value .strip_prefix("sha256-") .with_context(|| format!("{label} has no sha256 prefix"))?; @@ -16698,4 +16877,161 @@ processes 9 Ok(()) } // END runner-failure-evidence tests + + // --------------------------------------------------------------------- + // A static release flips on a stand-in site tree: releases/, a www/ that + // holds the current link, and the operator's per-site config. Nothing here + // is a real site. + // --------------------------------------------------------------------- + + #[cfg(unix)] + struct StandInSite { + _temp: tempfile::TempDir, + binding: StaticReleaseBinding, + } + + #[cfg(unix)] + const RELEASE_A: &str = "sha256-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + #[cfg(unix)] + const RELEASE_B: &str = "sha256-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + /// Two installed releases, each a fake vite build with compressed twins, + /// and a site config the operator owns. + #[cfg(unix)] + fn stand_in_site() -> Result { + let temp = tempfile::tempdir()?; + let root = temp.path(); + for release in [RELEASE_A, RELEASE_B] { + let dist = root.join("releases").join(release).join("dist"); + fs::create_dir_all(dist.join("assets"))?; + fs::write(dist.join("index.html"), format!("{release}"))?; + fs::write(dist.join("index.html.br"), b"br")?; + fs::write(dist.join("index.html.gz"), b"gz")?; + fs::write(dist.join("assets/app.js"), b"app")?; + } + fs::create_dir_all(root.join("www"))?; + fs::create_dir_all(root.join("config"))?; + fs::write(root.join("config/site.json"), br#"{"api":"https://example.test"}"#)?; + Ok(StandInSite { + binding: StaticReleaseBinding { + release_root: root.join("releases"), + current_link: root.join("www/current"), + carry: crate::deployment::StaticCarryBinding { + source: root.join("config/site.json"), + destination: "config.json".into(), + }, + }, + _temp: temp, + }) + } + + #[cfg(unix)] + #[test] + fn static_release_flip_carries_the_site_file_and_names_the_release() -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let site = stand_in_site()?; + let port = FilesystemStaticRelease; + assert_eq!(port.observe_current(&site.binding)?, None, "no link before the first flip"); + + let flip = port.carry_and_flip(&site.binding, RELEASE_A)?; + assert_eq!(port.observe_current(&site.binding)?.as_deref(), Some(RELEASE_A)); + let carried = site.binding.current_link.join("config.json"); + let bytes = fs::read(&carried)?; + assert_eq!(bytes, fs::read(&site.binding.carry.source)?); + assert_eq!(flip.carried_sha256, format!("sha256-{:x}", Sha256::digest(&bytes))); + assert_eq!(fs::metadata(&carried)?.permissions().mode() & 0o777, 0o444); + assert!(fs::symlink_metadata(&carried)?.is_file(), "the carry is a copy, never a link"); + // The web root reads the release's own tree through the link. + assert_eq!(fs::read(site.binding.current_link.join("dist/index.html.br"))?, b"br"); + + // The site file changes between releases: the second release carries + // the new bytes and the first keeps the ones it was flipped with. + fs::write(&site.binding.carry.source, br#"{"api":"https://other.test"}"#)?; + let second = port.carry_and_flip(&site.binding, RELEASE_B)?; + assert_ne!(second.carried_sha256, flip.carried_sha256); + assert_eq!(port.observe_current(&site.binding)?.as_deref(), Some(RELEASE_B)); + assert_eq!(fs::read(&carried)?, br#"{"api":"https://other.test"}"#); + assert_eq!( + fs::read(site.binding.release_root.join(RELEASE_A).join("config.json"))?, + br#"{"api":"https://example.test"}"# + ); + // Rolling back is the same flip to the release the link named before. + port.carry_and_flip(&site.binding, RELEASE_A)?; + assert_eq!(port.observe_current(&site.binding)?.as_deref(), Some(RELEASE_A)); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn static_release_missing_site_config_fails_before_the_link_moves() -> Result<()> { + let site = stand_in_site()?; + let port = FilesystemStaticRelease; + port.carry_and_flip(&site.binding, RELEASE_A)?; + fs::remove_file(&site.binding.carry.source)?; + let error = port.carry_and_flip(&site.binding, RELEASE_B).unwrap_err(); + assert!(format!("{error:#}").contains("carry source"), "{error:#}"); + assert_eq!(port.observe_current(&site.binding)?.as_deref(), Some(RELEASE_A)); + assert!( + !site.binding.release_root.join(RELEASE_B).join("config.json").exists(), + "a failed flip wrote its carry" + ); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn static_release_refuses_a_site_file_that_is_not_a_plain_small_file() -> Result<()> { + let site = stand_in_site()?; + let port = FilesystemStaticRelease; + let source = site.binding.carry.source.clone(); + fs::remove_file(&source)?; + fs::write(source.with_extension("real"), b"{}")?; + std::os::unix::fs::symlink(source.with_extension("real"), &source)?; + assert!(port.carry_and_flip(&site.binding, RELEASE_A).is_err(), "a linked site file was carried"); + fs::remove_file(&source)?; + fs::write(&source, vec![b'x'; STATIC_CARRY_LIMIT_BYTES as usize + 1])?; + assert!(port.carry_and_flip(&site.binding, RELEASE_A).is_err(), "an oversize site file was carried"); + assert_eq!(port.observe_current(&site.binding)?, None); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn static_release_replaces_a_stale_flip_link_a_crash_left() -> Result<()> { + let site = stand_in_site()?; + let port = FilesystemStaticRelease; + let stale = site.binding.current_link.with_file_name(".current.flip"); + std::os::unix::fs::symlink("/nowhere", &stale)?; + port.carry_and_flip(&site.binding, RELEASE_A)?; + assert_eq!(port.observe_current(&site.binding)?.as_deref(), Some(RELEASE_A)); + assert!(fs::symlink_metadata(&stale).is_err(), "the stale flip link survived"); + // Repeating the flip, as a resumed transaction does, changes nothing. + port.carry_and_flip(&site.binding, RELEASE_A)?; + assert_eq!(port.observe_current(&site.binding)?.as_deref(), Some(RELEASE_A)); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn static_release_will_not_replace_or_trust_what_is_not_its_link() -> Result<()> { + let site = stand_in_site()?; + let port = FilesystemStaticRelease; + // A real directory where the link belongs is left alone. + fs::create_dir(&site.binding.current_link)?; + assert!(port.carry_and_flip(&site.binding, RELEASE_A).is_err()); + assert!(site.binding.current_link.is_dir()); + assert!(port.observe_current(&site.binding).is_err()); + fs::remove_dir(&site.binding.current_link)?; + // A link that names something outside the release root has no release + // to roll back to, and Idunn says so instead of guessing. + std::os::unix::fs::symlink("/usr", &site.binding.current_link)?; + let error = port.observe_current(&site.binding).unwrap_err(); + assert!(format!("{error:#}").contains("not a release under"), "{error:#}"); + // A release that was never installed is not flipped to. + fs::remove_file(&site.binding.current_link)?; + let missing = format!("sha256-{}", "c".repeat(64)); + assert!(port.carry_and_flip(&site.binding, &missing).is_err()); + assert_eq!(port.observe_current(&site.binding)?, None); + Ok(()) + } } From d5ea7790964b2165cf84a19228085df967d6896b Mon Sep 17 00:00:00 2001 From: Eureka Date: Fri, 13 Nov 2026 16:32:06 +0000 Subject: [PATCH 3/6] Idunn: static release transaction (flip in Starting) and the v5 control record A static release is the same DeploymentTransaction in three phases: Sealing, Starting (carry and flip, behind the deployment brake) and Complete. It has no Expected, activation, workload, warming, isolation, fencing, leasing, ready or routing, and creates no AdmittedGeneration. DeploymentTransaction gains key 37 `flip` (FlipEvidence: release, previous release, carry source, carried sha256, time). Adding a key changes the bytes of every record, and the control store refuses a record that does not re-encode to what is stored, so this is schema idunn.deployment_transaction.v5. The v4 layout is kept as LegacyDeploymentTransactionV4 and lifted by the existing mechanism (read_transaction_record, history decode, and the boot migration, which rewrites v4 records in control.cc to v5). Migration is forward-only: an Idunn that predates v5 cannot read a migrated control.cc. Also: is_static() and brake_subject() (the brake names the binding's runtime id and the sealed release, read by deployment_authorization, durable-authority validation and status); validate_static; advance_sealing skips readiness_class and expected_projection for static; advance_starting delegates to advance_static (intent with the previous release read from current_link, carry_and_flip then record, observe link == release, then Complete with Admitted{generation-} and post_commit_cleanup(SkippedNoIncumbent, source Pending)); a failure in Starting takes the existing pre-fencing abort; Engine.static_release Arc. Docs: a Static release section in deployment-authority.md and a scope line in deploy-provenance-target.md. Tests: control_plane::tests::static_release_ (3), a pinned v4 record lifts and re-encodes as the pinned v5, a v4 control store copy in a temp dir migrates to v5 and an already-v5 store is untouched byte for byte, and the pinned-bytes test now pins v5 with the v4 bytes kept as a legacy fixture. The test runner is a stand-in over the real seal_artifact behind a cfg(test) Engine seam, because DockerRunnerDriver workspace ownership requires root. Co-Authored-By: Claude Sonnet 5.5 --- docs/deploy-provenance-target.md | 5 + docs/deployment-authority.md | 43 ++ src/control_plane.rs | 918 ++++++++++++++++++++++++++----- 3 files changed, 837 insertions(+), 129 deletions(-) diff --git a/docs/deploy-provenance-target.md b/docs/deploy-provenance-target.md index 74015c7..6b6293d 100644 --- a/docs/deploy-provenance-target.md +++ b/docs/deploy-provenance-target.md @@ -53,6 +53,11 @@ In: (`heimdall-discord-launch.md`, `idunn-host-raven.md`, the StreamPixels runbook). +- A static release (a sealed tree published by repointing a link, with no + workload): its transaction records `flip` (release, previous release, the + sha256 of the carried per-site file) beside the brake bytes. See + `deployment-authority.md`, "Static release". + Out: - `idunn-reincarnation`. This campaign is mapped after that campaign's cut 2 diff --git a/docs/deployment-authority.md b/docs/deployment-authority.md index bdc7efc..9b72032 100644 --- a/docs/deployment-authority.md +++ b/docs/deployment-authority.md @@ -171,6 +171,49 @@ different semantic capability. `gamecult-ops` publishes these fleet bindings and provisions host substrate. It does not own application build or deployment programs. +## Static release + +A target whose product is a sealed tree, with no process to run (a built web +front end), declares `[static_release] artifact = ""` in place of +`[service]`, and its binding declares `driver = "static-release"` with a +`release_root`, a `current_link` and one `[workload.carry]`. It is the same +transaction as any deployment, in three phases: Sealing (source, build, seal, +install: unchanged), Starting (the one external change, behind the deployment +brake) and Complete. It never enters Warming through Committing, has no +Expected projection, no activation and no workload, publishes nothing to +Odin, and its Complete creates no admitted generation, so continuity never +supervises it. It declares no state, capabilities, dependencies, conflicts, +route or write lease, and its rollout is `candidate-then-promote` with at least +two retained releases. + +What Starting does, in three durable steps recorded as `flip` on the +transaction: the release to name and the release the link names now are +written first; then Idunn copies the operator's carry source into +`//` (mode 0444) and +renames a fresh symlink over `current_link`; then it observes the link naming +the release and completes. A failure before the rename ends the transaction +through the ordinary pre-fencing abort with `current_link` untouched. A crash +resumes at the recorded intent and repeats the flip, which is harmless. + +The carry is a copy of one per-site file (for example `config.json`) taken at +flip time. It sits beside the artifacts, never inside one, so it is outside +every sealed digest: changing the site file changes no release id, and the +sha256 of the bytes carried is provenance on the transaction +(`flip.carried_sha256`), beside the brake bytes and the requester. The +destination is a single leaf name that no artifact uses. The web server reads +`current_link/` for the tree and `current_link/` for the carried file (a one-time root edit of the vhost, an +`alias` for the carried file beside the root for the tree). A missing or +non-regular carry source fails the deployment before anything moves. + +What `current_link` names is observed from the symlink and confirmed by the +latest Admitted static transaction's `flip.release_id`; it is stored nowhere +else. A rollback is a flip back to `flip.previous_release_id`: release +directories stay, and Idunn prunes none. The brake is read exactly as for a +service, naming the binding's runtime id and the sealed release id. A new +upstream tarball enters as a recipe `external_input` with a pinned url and +sha256, so each release is a recipe commit whose pin is its provenance. + ## Typed deployment state Mutable control-plane state will be CultCache. Runtime integration must use diff --git a/src/control_plane.rs b/src/control_plane.rs index 1dc0631..2250331 100644 --- a/src/control_plane.rs +++ b/src/control_plane.rs @@ -39,12 +39,12 @@ use crate::deployment_plan::{ SealedRelease, WARMING_DEADLINE_SECONDS, compile_deployment_plan, }; use crate::drivers::{ - ChallengeFailure, CultCacheTopologyDriver, CultCacheWriteLeaseDriver, DockerRunnerDriver, FrozenSourceReceipt, - GitSourceDriver, InstalledReleaseObservation, IsolationEvidence, NginxRouteDriver, + ChallengeFailure, CultCacheTopologyDriver, CultCacheWriteLeaseDriver, DockerRunnerDriver, FilesystemStaticRelease, + FrozenSourceReceipt, GitSourceDriver, InstalledReleaseObservation, IsolationEvidence, NginxRouteDriver, LockContended, ProcessIdentity, RouteActuation, RouteActuationGate, RouteActuationRefused, RouteActuators, RouteObservation, RoutePreflightReceipt, RunnerPort, RunnerStepFailure, SourcePort, - SystemdTransientWorkloadDriver, TopologyPort, WorkloadObservation, WorkloadPort, - WriteLeasePort, + StaticReleasePort, SystemdTransientWorkloadDriver, TopologyPort, WorkloadObservation, WorkloadPort, + WriteLeasePort, require_sha256_id, }; use crate::incident::{CloseReason, IncidentCondition, IncidentStore, render_incidents}; use crate::runner_evidence::{ @@ -635,6 +635,50 @@ impl RoutingEvidence { } } +/// The one external change a static release makes, recorded before it runs and +/// completed after it lands. `release_id` is the sealed release the current link +/// is to name and `previous_release_id` the one it named before (the release a +/// rollback deploys again). Both land in the record before the link moves, so a +/// crash between the rename and the Complete write resumes at this record, +/// repeats the harmless flip and observes the link. `carried_sha256` and +/// `flipped_at_unix_millis` are set together, once the carry and the link landed. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct FlipEvidence { + release_id: String, + previous_release_id: Option, + carry_source: PathBuf, + carried_sha256: Option, + flipped_at_unix_millis: Option, +} + +impl FlipEvidence { + fn landed(&self) -> bool { + self.carried_sha256.is_some() + } + + fn validate_shape(&self) -> Result<()> { + require_sha256_id(&self.release_id, "flip release")?; + if let Some(previous) = &self.previous_release_id { + require_sha256_id(previous, "flip previous release")?; + ensure!(previous != &self.release_id, "flip names its own release as previous"); + } + ensure!( + self.carry_source.is_absolute(), + "flip carry source is not absolute" + ); + match (&self.carried_sha256, self.flipped_at_unix_millis) { + (None, None) => {} + (Some(sha256), Some(at)) => { + require_sha256_id(sha256, "flip carried digest")?; + ensure!(at > 0, "flip has no time"); + } + _ => bail!("flip carries a digest or a time but not both"), + } + Ok(()) + } +} + /// The states a route-proof candidate may answer its Warming challenge in. A /// stateful one is warming until it holds a lease it cannot hold yet, and the /// lease binds that warming presence. A stateless one may have finished @@ -1708,6 +1752,10 @@ struct DeploymentTransaction { phase_deadline: Option, #[cultcache(key = 36)] lease_adoption: Option, + /// A static release's carry and flip. Absent on every record that + /// predates it, which decodes as None: no schema bump (precedent key 34). + #[cultcache(key = 37, default)] + flip: Option, } /// The note a deployment keeps while another transaction owns its target. @@ -1939,6 +1987,28 @@ impl DeploymentTransaction { } } + /// Whether this transaction publishes a static release: it has no workload, + /// no Expected and no admitted generation, and its one external change is + /// the flip. Derived from the plan's binding, never stored. + fn is_static(&self) -> bool { + self.plan + .as_ref() + .and_then(|plan| plan.parsed_inputs().ok()) + .is_some_and(|(_, binding)| matches!(binding.workload, WorkloadBinding::StaticRelease(_))) + } + + /// The runtime and the sealed release a deployment brake release must name. + /// One reading for both kinds of target: the binding's runtime identity and + /// the sealed release, which a service's Expected projection copies. + fn brake_subject(&self) -> Result<(String, String)> { + let plan = required(&self.plan, "transaction plan")?; + let release = required(&self.sealed_release, "sealed release")?; + Ok(( + plan.parsed_inputs()?.1.runtime_identity.runtime_id, + release.sealed_release_id.clone(), + )) + } + /// Whether this transaction's binding declares stop-then-start. A /// transaction without a plan yet cannot, so it reads as false. fn rollout_stops_incumbent_first(&self) -> bool { @@ -1994,6 +2064,7 @@ impl DeploymentTransaction { post_commit_cleanup: None, phase_deadline: None, lease_adoption: None, + flip: None, }; transaction.validate()?; Ok(transaction) @@ -2160,6 +2231,81 @@ impl DeploymentTransaction { self.readiness_disagreement().filter(|_| !finishing) } + /// What a static release transaction may hold. It never runs a workload, so + /// every piece of evidence of one is absent, and it uses three phases: + /// Sealing, Starting (the flip, behind the brake) and Complete. + fn validate_static(&self) -> Result<()> { + ensure!( + self.command_kind == CommandKind::Deploy, + "a static release has no continuity" + ); + ensure!( + self.expected.is_none() + && self.expected_publication_sha256.is_none() + && self.lifecycle_authorized_at_unix_millis.is_none() + && self.activation.is_none() + && self.workload.is_none() + && self.activation_publication_sha256.is_none() + && self.latest_odin_observation.is_none() + && self.warming.is_none() + && self.route_preflight.is_none() + && self.isolation.is_none() + && self.fencing.is_none() + && self.leasing.is_none() + && self.ready.is_none() + && self.routing.is_none() + && self.lease_adoption.is_none(), + "a static release transaction carries evidence of a running workload" + ); + ensure!( + matches!( + self.phase, + DeploymentPhase::Sealing | DeploymentPhase::Starting | DeploymentPhase::Complete + ), + "a static release never enters {:?}", + self.phase + ); + if self.phase >= DeploymentPhase::Starting { + ensure!( + self.plan.is_some() + && self.sealed_release.is_some() + && self.installed_release.is_some() + && self.frozen_source.is_some() + && self.deployment_authorization.is_some(), + "static release entered Starting without sealed release evidence and brake consumption" + ); + } else { + ensure!( + self.flip.is_none() && self.deployment_authorization.is_none(), + "static release flipped or consumed the brake before Starting" + ); + } + if self.phase == DeploymentPhase::Complete { + ensure!( + self.flip.as_ref().is_some_and(FlipEvidence::landed), + "completed static release has no landed flip" + ); + ensure!( + self.pre_fencing_abort.is_none() && self.post_commit_cleanup.is_some(), + "admitted transaction lacks exclusive post-commit cleanup evidence" + ); + ensure!( + matches!( + &self.completion, + Some(TransactionCompletion::Admitted { generation_id }) + if generation_id == &format!("generation-{}", self.transaction_id) + ), + "successful Complete transaction lacks its admission receipt" + ); + } else { + ensure!( + self.completion.is_none() && self.post_commit_cleanup.is_none(), + "non-Complete transaction has completion or post-commit state" + ); + } + Ok(()) + } + fn validate(&self) -> Result<()> { ensure!( self.schema_version == DEPLOYMENT_TRANSACTION_SCHEMA, @@ -2346,6 +2492,17 @@ impl DeploymentTransaction { if let Some(authorization) = &self.deployment_authorization { authorization.validate_shape()?; } + let is_static = self.is_static(); + if let Some(flip) = &self.flip { + ensure!(is_static, "a flip belongs to a static release transaction"); + flip.validate_shape()?; + if let Some(release) = &self.sealed_release { + ensure!( + flip.release_id == release.sealed_release_id, + "flip names another release than the sealed one" + ); + } + } if let Some(abort) = &self.pre_fencing_abort { require_detail(&abort.error, "pre-fencing abort error")?; ensure!( @@ -2464,111 +2621,116 @@ impl DeploymentTransaction { ), "non-failure branch carries failure completion" ); - if self.phase >= DeploymentPhase::Starting { - ensure!( - self.plan.is_some() - && self.sealed_release.is_some() - && self.installed_release.is_some() - && self.expected.is_some(), - "Starting transaction lacks sealed release evidence" - ); - // Expected is published as Starting's first step, after the - // brake; from the activation onward it must be there. - ensure!( - self.expected_publication_sha256.is_some() - || (self.activation.is_none() && self.phase == DeploymentPhase::Starting), - "started transaction has not published its Expected" - ); - match self.command_kind { - CommandKind::Deploy => ensure!( - self.frozen_source.is_some() && self.deployment_authorization.is_some(), - "deployment entered Starting without frozen source and brake consumption" - ), - CommandKind::Continuity => ensure!( - self.lifecycle_authorized_at_unix_millis.is_some() - && self.deployment_authorization.is_none(), - "continuity entered Starting without its lifecycle gate" - ), - } - } - if self.phase >= DeploymentPhase::Warming { - ensure!( - self.activation.is_some() - && self.workload.is_some() - && self.activation_publication_sha256.is_some(), - "Warming transaction lacks observed activation" - ); - } - if self.phase >= DeploymentPhase::Fencing { - ensure!( - self.warming.is_some() && self.isolation.is_some(), - "Fencing transaction lacks warming or isolation evidence" - ); - ensure!( - self.expected - .as_ref() - .and_then(|expected| expected.route.as_ref()) - .is_some() - == self.route_preflight.is_some(), - "route preflight does not match routed Expected" - ); - if let Some(preflight) = &self.route_preflight { - preflight.validate()?; - } - } - if self.phase >= DeploymentPhase::Leasing { - ensure!(self.fencing.is_some(), "Leasing lacks fencing evidence"); - } - if self.phase >= DeploymentPhase::AwaitingReady { - ensure!( - matches!( - self.leasing.as_ref(), - Some(LeasingEvidence::SkippedStateless) - | Some(LeasingEvidence::Granted { .. }) - ), - "AwaitingReady lacks finalized lease evidence" - ); - } - if self.phase >= DeploymentPhase::Routing { - ensure!(self.ready.is_some(), "Routing lacks Ready evidence"); - } - if self.phase >= DeploymentPhase::Committing { - ensure!(self.routing.is_some(), "Committing lacks routing evidence"); - } - if let Some(RoutingEvidence::Promoted { - observation, - promoted_at_unix_millis, - }) = &self.routing - { - observation.validate()?; - ensure!(*promoted_at_unix_millis > 0, "route promotion has no time"); - } - if self.phase == DeploymentPhase::Complete { - ensure!( - matches!( - self.completion, - Some(TransactionCompletion::Admitted { .. }) - ), - "successful Complete transaction lacks admission receipt" - ); - ensure!( - self.pre_fencing_abort.is_none() && self.post_commit_cleanup.is_some(), - "admitted transaction lacks exclusive post-commit cleanup evidence" - ); - let TransactionCompletion::Admitted { generation_id } = - self.completion.as_ref().unwrap() - else { - unreachable!() - }; - ensure!( - generation_id == &format!("generation-{}", self.transaction_id), - "admission completion names another transaction generation" - ); + if is_static { + self.validate_static()?; } else { - ensure!( - self.completion.is_none() && self.post_commit_cleanup.is_none(), - "non-Complete transaction has completion or post-commit state" - ); + if self.phase >= DeploymentPhase::Starting { + ensure!( + self.plan.is_some() + && self.sealed_release.is_some() + && self.installed_release.is_some() + && self.expected.is_some(), + "Starting transaction lacks sealed release evidence" + ); + // Expected is published as Starting's first step, after the + // brake; from the activation onward it must be there. + ensure!( + self.expected_publication_sha256.is_some() + || (self.activation.is_none() && self.phase == DeploymentPhase::Starting), + "started transaction has not published its Expected" + ); + match self.command_kind { + CommandKind::Deploy => ensure!( + self.frozen_source.is_some() && self.deployment_authorization.is_some(), + "deployment entered Starting without frozen source and brake consumption" + ), + CommandKind::Continuity => ensure!( + self.lifecycle_authorized_at_unix_millis.is_some() + && self.deployment_authorization.is_none(), + "continuity entered Starting without its lifecycle gate" + ), + } + } + if self.phase >= DeploymentPhase::Warming { + ensure!( + self.activation.is_some() + && self.workload.is_some() + && self.activation_publication_sha256.is_some(), + "Warming transaction lacks observed activation" + ); + } + if self.phase >= DeploymentPhase::Fencing { + ensure!( + self.warming.is_some() && self.isolation.is_some(), + "Fencing transaction lacks warming or isolation evidence" + ); + ensure!( + self.expected + .as_ref() + .and_then(|expected| expected.route.as_ref()) + .is_some() + == self.route_preflight.is_some(), + "route preflight does not match routed Expected" + ); + if let Some(preflight) = &self.route_preflight { + preflight.validate()?; + } + } + if self.phase >= DeploymentPhase::Leasing { + ensure!(self.fencing.is_some(), "Leasing lacks fencing evidence"); + } + if self.phase >= DeploymentPhase::AwaitingReady { + ensure!( + matches!( + self.leasing.as_ref(), + Some(LeasingEvidence::SkippedStateless) + | Some(LeasingEvidence::Granted { .. }) + ), + "AwaitingReady lacks finalized lease evidence" + ); + } + if self.phase >= DeploymentPhase::Routing { + ensure!(self.ready.is_some(), "Routing lacks Ready evidence"); + } + if self.phase >= DeploymentPhase::Committing { + ensure!(self.routing.is_some(), "Committing lacks routing evidence"); + } + if let Some(RoutingEvidence::Promoted { + observation, + promoted_at_unix_millis, + }) = &self.routing + { + observation.validate()?; + ensure!(*promoted_at_unix_millis > 0, "route promotion has no time"); + } + if self.phase == DeploymentPhase::Complete { + ensure!( + matches!( + self.completion, + Some(TransactionCompletion::Admitted { .. }) + ), + "successful Complete transaction lacks admission receipt" + ); + ensure!( + self.pre_fencing_abort.is_none() && self.post_commit_cleanup.is_some(), + "admitted transaction lacks exclusive post-commit cleanup evidence" + ); + let TransactionCompletion::Admitted { generation_id } = + self.completion.as_ref().unwrap() + else { + unreachable!() + }; + ensure!( + generation_id == &format!("generation-{}", self.transaction_id), + "admission completion names another transaction generation" + ); + } else { + ensure!( + self.completion.is_none() && self.post_commit_cleanup.is_none(), + "non-Complete transaction has completion or post-commit state" + ); + } + } } if let Some(cleanup) = &self.post_commit_cleanup { @@ -3882,6 +4044,7 @@ impl LegacyDeploymentTransaction { post_fencing_abort: self.post_fencing_abort, phase_deadline: None, lease_adoption: None, + flip: None, } } } @@ -4891,9 +5054,9 @@ pub(crate) fn status( " target {} phase {:?}", transaction.target, transaction.phase )?; - if let Some(expected) = &transaction.expected { - writeln!(out, " runtime {}", expected.runtime_id)?; - writeln!(out, " release {}", expected.sealed_release_id)?; + if let Ok((runtime_id, sealed_release_id)) = transaction.brake_subject() { + writeln!(out, " runtime {runtime_id}")?; + writeln!(out, " release {sealed_release_id}")?; } writeln!( out, @@ -5164,11 +5327,17 @@ struct Engine { source: Arc, docker_runner: DockerRunnerDriver, systemd_workload: Arc, + /// The one external change a static release makes. + static_release: Arc, host_actuators: Option, /// A test world that serves no host actuator runs a host-actuator /// binding's workload through this port instead. #[cfg(test)] host_workload: Mutex>>, + /// A test world that cannot assign runner workspaces (that wants root) + /// seals through this runner instead of the Docker one. + #[cfg(test)] + runner_stand_in: Mutex>>, history_report: ReportOnce, /// One `ReportOnce` per faulting record, so one wedged transaction says /// its fault once and never drowns another's. @@ -5292,9 +5461,12 @@ impl Engine { source, docker_runner: DockerRunnerDriver::default(), systemd_workload, + static_release: Arc::new(FilesystemStaticRelease), host_actuators, #[cfg(test)] host_workload: Mutex::default(), + #[cfg(test)] + runner_stand_in: Mutex::default(), history_report: ReportOnce::default(), fault_reports: Mutex::default(), resume_backoff: Mutex::default(), @@ -5545,6 +5717,22 @@ impl Engine { /// is of one kind; the binding validated that. fn runner_for(&self, plan: &CompiledDeploymentPlan) -> Result> { let (_, binding) = plan.parsed_inputs()?; + #[cfg(test)] + if let Some(runner) = self.runner_stand_in.lock().expect("runner mutex").clone() { + struct Shared(Arc); + impl RunnerPort for Shared { + fn materialize( + &self, + source: &crate::drivers::FrozenSource, + plan: &CompiledDeploymentPlan, + staging_root: &Path, + sealed_at_unix_millis: u64, + ) -> Result { + self.0.materialize(source, plan, staging_root, sealed_at_unix_millis) + } + } + return Ok(Box::new(Shared(runner))); + } Ok(match &binding.workload { WorkloadBinding::SystemdTransient(_) | WorkloadBinding::StaticRelease(_) => { Box::new(self.docker_runner.clone()) @@ -5659,13 +5847,12 @@ impl Engine { &record, operator_anchor.as_ref().expect("read directly above"), )?; - let expected = required(&transaction.expected, "authorized Expected projection")?; + let (runtime_id, sealed_release_id) = transaction.brake_subject()?; ensure!( - record.authorized_release_id.as_deref() - == Some(expected.sealed_release_id.as_str()) + record.authorized_release_id.as_deref() == Some(sealed_release_id.as_str()) && record.authorized_deployment_id.as_deref() == Some(transaction.transaction_id.as_str()) - && record.runtime_id == expected.runtime_id, + && record.runtime_id == runtime_id, "durable deployment authorization names another release or transaction" ); } @@ -7358,8 +7545,12 @@ impl Engine { &providers, )?; // Idunn infers no way to prove readiness. A recipe that declares - // none is refused here, before anything is frozen, built or installed. - plan.readiness_class()?; + // none is refused here, before anything is frozen, built or + // installed. A static release proves nothing to anyone: it runs + // nothing, and its flip is observed on disk. + if !matches!(loaded.binding.workload, WorkloadBinding::StaticRelease(_)) { + plan.readiness_class()?; + } return self.persist_same_phase(current, |next| { next.plan = Some(plan); next.updated_at_unix_millis = now; @@ -7382,9 +7573,10 @@ impl Engine { }); } + let is_static = current.value.is_static(); if current.value.sealed_release.is_none() || current.value.installed_release.is_none() - || current.value.expected.is_none() + || (current.value.expected.is_none() && !is_static) { ensure!( current.value.command_kind == CommandKind::Deploy, @@ -7401,11 +7593,13 @@ impl Engine { now, )?; let installed = self.workload_for(plan)?.install(plan, &materialized)?; - let expected = materialized.release.expected_projection(plan)?; + let expected = (!is_static) + .then(|| materialized.release.expected_projection(plan)) + .transpose()?; return self.persist_same_phase(current, |next| { next.sealed_release = Some(materialized.release); next.installed_release = Some(installed); - next.expected = Some(expected); + next.expected = expected; next.updated_at_unix_millis = now; next.last_error = None; Ok(()) @@ -7449,6 +7643,9 @@ impl Engine { } fn advance_starting(&self, current: &Stored) -> Result<()> { + if current.value.is_static() { + return self.advance_static(current); + } let expected = required(¤t.value.expected, "Expected projection")?; let plan = required(¤t.value.plan, "transaction plan")?; let release = required(¤t.value.sealed_release, "sealed release")?; @@ -7534,6 +7731,66 @@ impl Engine { replace_transaction(&self.options.state_store, current, &next) } + /// A static release's Starting: its one external change, in three durable + /// steps. The intent (the release to name and the one named now) lands + /// first; then the carry and the flip, whose facts land second; then the + /// link is observed naming the release and the transaction completes. The + /// flip is one rename and repeating it is harmless, so a crash anywhere + /// resumes at the record, and a failure before the rename leaves the + /// current link exactly as it was, which is what the pre-fencing abort + /// this error takes says. + fn advance_static(&self, current: &Stored) -> Result<()> { + let plan = required(¤t.value.plan, "transaction plan")?; + let release = required(¤t.value.sealed_release, "sealed release")?; + let binding = plan.parsed_inputs()?.1; + let workload = binding.workload.static_release()?; + let now = now_millis()?; + let Some(flip) = ¤t.value.flip else { + let previous_release_id = self.static_release.observe_current(workload)?; + let release_id = release.sealed_release_id.clone(); + return self.persist_same_phase(current, |next| { + next.flip = Some(FlipEvidence { + release_id, + previous_release_id, + carry_source: workload.carry.source.clone(), + carried_sha256: None, + flipped_at_unix_millis: None, + }); + next.updated_at_unix_millis = now; + Ok(()) + }); + }; + if !flip.landed() { + let landed = self + .static_release + .carry_and_flip(workload, &flip.release_id)?; + return self.persist_same_phase(current, |next| { + let flip = next.flip.as_mut().expect("flip intent read above"); + flip.carried_sha256 = Some(landed.carried_sha256); + flip.flipped_at_unix_millis = Some(now); + next.updated_at_unix_millis = now; + Ok(()) + }); + } + ensure!( + self.static_release.observe_current(workload)?.as_deref() == Some(flip.release_id.as_str()), + "the current link does not name the release this transaction flipped to" + ); + let mut complete = current.value.clone(); + complete.enter_phase(DeploymentPhase::Complete, now); + complete.last_error = None; + complete.completion = Some(TransactionCompletion::Admitted { + generation_id: format!("generation-{}", current.value.transaction_id), + }); + complete.post_commit_cleanup = Some(PostCommitCleanup { + incumbent: IncumbentCleanupEvidence::SkippedNoIncumbent, + source: SourceCleanupEvidence::Pending, + }); + complete.retime(now); + complete.validate()?; + replace_transaction(&self.options.state_store, current, &complete) + } + fn advance_warming(&self, current: &Stored) -> Result<()> { let expected = required(¤t.value.expected, "Expected projection")?; let activation = required(¤t.value.activation, "activation")?; @@ -8645,7 +8902,7 @@ impl Engine { current: &Stored, now: u64, ) -> Result> { - let expected = required(¤t.value.expected, "Expected projection")?; + let (runtime_id, sealed_release_id) = current.value.brake_subject()?; let plan = required(¤t.value.plan, "transaction plan")?; let binding = plan.parsed_inputs()?.1; let Some((record, canonical_bytes)) = @@ -8659,8 +8916,8 @@ impl Engine { if evaluate_idunn_deployment_brake( IdunnDeploymentBrakeObservation::Present(&record), &anchor, - &expected.runtime_id, - &expected.sealed_release_id, + &runtime_id, + &sealed_release_id, ¤t.value.transaction_id, now, ) @@ -13396,6 +13653,18 @@ mod tests { expected: &IdunnExpectedIncarnationRecord, transaction_id: &str, now: u64, + ) -> Result { + released_brake(world, &expected.runtime_id, &expected.sealed_release_id, transaction_id, now) + } + + /// The release receipt for one runtime, release and transaction: all a + /// brake release names, so a target with no Expected can be released too. + fn released_brake( + world: &EngineFixture, + runtime_id: &str, + sealed_release_id: &str, + transaction_id: &str, + now: u64, ) -> Result { use cultnet_rs::{ IDUNN_DEPLOYMENT_BRAKE_AUTHORITY, IDUNN_DEPLOYMENT_BRAKE_ID, @@ -13417,7 +13686,7 @@ mod tests { schema_version: IDUNN_DEPLOYMENT_BRAKE_SCHEMA.into(), brake_id: IDUNN_DEPLOYMENT_BRAKE_ID.into(), authority: IDUNN_DEPLOYMENT_BRAKE_AUTHORITY.into(), - runtime_id: expected.runtime_id.clone(), + runtime_id: runtime_id.to_owned(), status: "released".into(), scope: IDUNN_DEPLOYMENT_BRAKE_SCOPE.into(), reason: "test rollout".into(), @@ -13425,7 +13694,7 @@ mod tests { expires_at_unix_millis: Some(now + 600_000), authorization_id: Some(authorization_id.clone()), authorization_purpose: Some(IDUNN_DEPLOYMENT_RELEASE_PURPOSE.into()), - authorized_release_id: Some(expected.sealed_release_id.clone()), + authorized_release_id: Some(sealed_release_id.to_owned()), authorized_deployment_id: Some(transaction_id.to_owned()), authorized_by: Some(signer.trust_anchor()?.identity_id), authorization_issued_at_unix_millis: Some(now), @@ -20210,7 +20479,6 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide /// source resolves `recipe`. One scheduler tick admits its plan or refuses it. fn sealing_world(recipe: &str) -> Result { use crate::deployment_plan::tests::BINDING; - let mut world = EngineFixture::with_workload(Arc::new(StillWorkload))?; let (binding_head, binding_tail) = BINDING .split_once("[route]") .context("binding has no route table")?; @@ -20221,15 +20489,32 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide .context("binding has no brakes table")? .1 ); + sealing_world_with(recipe, |_| binding, Arc::new(StillWorkload)) + } + + /// `sealing_world` over any binding (given the world's root, which a + /// binding that names paths needs) and any workload port. + fn sealing_world_with( + recipe: &str, + binding: impl FnOnce(&Path) -> String, + workload: Arc, + ) -> Result { + let mut world = EngineFixture::with_workload(workload)?; std::fs::create_dir_all(world.root.join("bindings"))?; - std::fs::write(world.root.join("bindings/service.toml"), binding)?; + std::fs::write(world.root.join("bindings/service.toml"), binding(&world.root))?; world.engine.source = Arc::new(FixedSource { recipe: recipe.to_owned(), }); + enqueue_deploy(&world, "up-service")?; + Ok(world) + } + + /// A Deploy command for target `service` and its transaction, still Sealing. + fn enqueue_deploy(world: &EngineFixture, command_id: &str) -> Result<()> { let now = now_millis()?; let command = DeploymentCommand { schema_version: DEPLOYMENT_COMMAND_SCHEMA.into(), - command_id: "up-service".into(), + command_id: command_id.into(), kind: CommandKind::Deploy, selector: "service".into(), requested_by: "test".into(), @@ -20256,7 +20541,7 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide ], )? ); - Ok(world) + Ok(()) } fn unrouted_recipe() -> String { @@ -25254,4 +25539,379 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide const PINNED_COMMAND_HEX: &str = "96bb6964756e6e2e6465706c6f796d656e745f636f6d6d616e642e7632aa75702d73657276696365a66465706c6f79a773657276696365a47465737464"; const PINNED_TRANSACTION_HEX: &str = "dc0025bf6964756e6e2e6465706c6f796d656e745f7472616e73616374696f6e2e7634a974782d70696e6e6564aa75702d73657276696365a66465706c6f79a77365727669636500a77365616c696e676464c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c000d94072756e6e6572207374657020746573742d6461656d6f6e206f6e2072756e6e657220727573742065786974656420776974682065786974207374617475732033c094d94072756e6e6572207374657020746573742d6461656d6f6e206f6e2072756e6e657220727573742065786974656420776974682065786974207374617475732033a7736b6970706564a7736b6970706564a8636f6d706c657465c0c0c0c0"; // END runner-failure-evidence tests + + // BEGIN static-release tests + // A static release over a stand-in site tree: the real engine, the real + // Sealing path over a fake docker, the real filesystem flip, and an install + // that copies instead of hardening (hardening wants root). Nothing here + // is a real site. + + /// Installs the sealed release by copying it where the binding says. + struct InstallingWorkload; + + impl WorkloadPort for InstallingWorkload { + fn install( + &self, + plan: &CompiledDeploymentPlan, + release: &crate::drivers::MaterializedRelease, + ) -> Result { + let (_, binding) = plan.parsed_inputs()?; + let release_root = binding.workload.release_root()?; + std::fs::create_dir_all(release_root)?; + let root = release_root.join(&release.release.sealed_release_id); + crate::drivers::copy_tree(&release.root, &root)?; + Ok(crate::drivers::InstalledReleaseObservation { + sealed_release_id: release.release.sealed_release_id.clone(), + root, + }) + } + fn prepare_activation( + &self, + _: &CompiledDeploymentPlan, + _: &IdunnExpectedIncarnationRecord, + _: IdunnRuntimeActivationLaunch, + ) -> Result { + bail!("a static release prepares no activation") + } + fn start_prepared( + &self, + _: &CompiledDeploymentPlan, + _: &SealedRelease, + _: &crate::drivers::InstalledReleaseObservation, + _: &IdunnExpectedIncarnationRecord, + _: &IdunnRuntimeActivationRecord, + ) -> Result { + bail!("a static release starts nothing") + } + fn discard_prepared( + &self, + _: &CompiledDeploymentPlan, + _: &IdunnExpectedIncarnationRecord, + _: &IdunnRuntimeActivationRecord, + ) -> Result<()> { + bail!("a static release discards nothing") + } + fn observe( + &self, + _: &IdunnExpectedIncarnationRecord, + _: &IdunnRuntimeActivationRecord, + _: &WorkloadObservation, + ) -> Result { + bail!("a static release observes no workload") + } + fn stop(&self, _: &WorkloadObservation) -> Result<()> { + bail!("a static release stops nothing") + } + fn is_permanently_stopped(&self, _: &WorkloadObservation) -> Result { + bail!("a static release has no workload to be dead") + } + fn lost_to_host_reboot(&self, _: &WorkloadObservation, _: u64) -> Result { + bail!("a static release has no workload to lose") + } + } + + /// The real artifact sealing over the frozen tree, without the Docker + /// step and the root-only workspace ownership that go with it. + struct SealingRunner { + tree: PathBuf, + } + + impl RunnerPort for SealingRunner { + fn materialize( + &self, + _: &crate::drivers::FrozenSource, + plan: &CompiledDeploymentPlan, + staging_parent: &Path, + sealed_at_unix_millis: u64, + ) -> Result { + let (declaration, _) = plan.parsed_inputs()?; + let staging_root = staging_parent.join(format!("stand-in-{}", Uuid::new_v4())); + std::fs::create_dir_all(&staging_root)?; + let mut artifacts = Vec::new(); + for artifact in &declaration.artifacts { + artifacts.push(crate::drivers::seal_artifact( + artifact, + &self.tree, + &self.tree.join(&artifact.source), + &staging_root, + )?); + } + Ok(crate::drivers::MaterializedRelease { + release: SealedRelease::new(plan, artifacts, Vec::new(), sealed_at_unix_millis)?, + root: staging_root, + }) + } + } + + const SITE_CONFIG_ONE: &[u8] = br#"{"api":"https://one.test"}"#; + const SITE_CONFIG_TWO: &[u8] = br#"{"api":"https://two.test"}"#; + + struct StaticWorld { + world: EngineFixture, + _tools: TempDir, + site: PathBuf, + } + + impl StaticWorld { + fn site_config(&self) -> PathBuf { + self.site.join("config/site.json") + } + + fn current_link(&self) -> PathBuf { + self.site.join("www/current") + } + + fn current_release(&self) -> Option { + std::fs::read_link(self.current_link()) + .ok() + .and_then(|target| target.file_name().map(|name| name.to_string_lossy().into_owned())) + } + + /// Scheduler ticks until `done`, or fails when the budget is spent. + fn tick_until( + &self, + id: &str, + what: &str, + done: impl Fn(&DeploymentTransaction) -> bool, + ) -> Result<()> { + for _ in 0..24 { + self.world.engine.resume_backoff.lock().unwrap().clear(); + let _ = self.world.engine.run_scheduler_tick(); + if done(&latest_id(&self.world, id)?) { + return Ok(()); + } + } + let last = latest_id(&self.world, id)?; + bail!("never reached {what}: phase {:?} completion {:?} error {:?}", last.phase, last.completion, last.last_error) + } + + /// The Deploy command's transaction, by command id. + fn transaction_of(&self, command_id: &str) -> Result { + Ok(ControlSnapshot::read(&self.world.state_store)? + .transactions + .into_iter() + .find(|stored| stored.value.command_id == command_id) + .context("no transaction for the command")? + .value + .transaction_id) + } + + /// The operator releases the brake for this exact transaction. + fn release_brake(&self, transaction: &DeploymentTransaction) -> Result<()> { + let (runtime_id, release_id) = transaction.brake_subject()?; + let authorization = released_brake( + &self.world, + &runtime_id, + &release_id, + &transaction.transaction_id, + now_millis()?, + )?; + let store = self.site.join("authority/deployment-brake.cc"); + let record: IdunnDeploymentBrakeRecord = + rmp_serde::from_slice(&authorization.canonical_brake_bytes)?; + let backing = SingleFileMessagePackBackingStore::new(&store); + let current = backing + .pull_all_read_only_snapshot() + .ok() + .and_then(|entries| entries.into_iter().next()); + ensure!(backing.compare_exchange( + &[CultCacheExpectedEnvelope { + r#type: IdunnDeploymentBrakeRecord::TYPE.into(), + key: record.brake_id.clone(), + current, + }], + &[CultCacheEnvelope { + key: record.brake_id.clone(), + r#type: IdunnDeploymentBrakeRecord::TYPE.into(), + payload: authorization.canonical_brake_bytes.clone(), + stored_at: rfc3339_millis(now_millis()?)?, + schema_id: Some(IDUNN_DEPLOYMENT_BRAKE_SCHEMA.into()), + }], + )?); + Ok(()) + } + + /// Run a Deploy up to the brake and say where it waits. + fn run_to_brake(&self, id: &str) -> Result { + self.tick_until(id, "the brake", |t| t.installed_release.is_some())?; + for _ in 0..2 { + self.world.engine.resume_backoff.lock().unwrap().clear(); + let _ = self.world.engine.run_scheduler_tick(); + } + let waiting = latest_id(&self.world, id)?; + assert_eq!(waiting.phase, DeploymentPhase::Sealing, "{waiting:?}"); + assert!(waiting.deployment_authorization.is_none()); + Ok(waiting) + } + } + + /// A target world: the static recipe over a fixed vite-like tree, a runner + /// that seals it, and a stand-in site beside it. + #[cfg(unix)] + fn static_world() -> Result { + use crate::deployment::tests::{STATIC_BINDING, STATIC_RECIPE}; + let tools = TempDir::new()?; + let tree = tools.path().join("tree"); + std::fs::create_dir_all(tree.join("dist/assets"))?; + std::fs::write(tree.join("dist/index.html"), b"site")?; + std::fs::write(tree.join("dist/index.html.br"), b"br")?; + std::fs::write(tree.join("dist/index.html.gz"), b"gz")?; + std::fs::write(tree.join("dist/assets/app.js"), b"app")?; + let mut site = PathBuf::new(); + let mut world = sealing_world_with( + STATIC_RECIPE, + |root| { + site = root.join("site"); + STATIC_BINDING.replace("@SITE@", &site.display().to_string()) + }, + Arc::new(InstallingWorkload), + )?; + std::fs::create_dir_all(site.join("www"))?; + std::fs::create_dir_all(site.join("config"))?; + std::fs::write(site.join("config/site.json"), SITE_CONFIG_ONE)?; + *world.engine.runner_stand_in.lock().unwrap() = + Some(Arc::new(SealingRunner { tree: tree.clone() })); + world.engine.source = Arc::new(TreeSource { recipe: STATIC_RECIPE.into(), root: tree }); + Ok(StaticWorld { world, _tools: tools, site }) + } + + fn sha256_of(bytes: &[u8]) -> String { + sha256_id(bytes) + } + + #[cfg(unix)] + #[test] + fn static_release_waits_at_the_brake_then_flips_and_completes_without_a_workload() -> Result<()> { + let w = static_world()?; + let id = w.transaction_of("up-service")?; + let waiting = w.run_to_brake(&id)?; + let sealed = waiting.sealed_release.clone().context("no sealed release")?; + let release_dir = w.site.join("releases").join(&sealed.sealed_release_id); + assert!(release_dir.join("dist/index.html.br").is_file(), "the release is installed"); + // At the brake nothing external has changed: no link, no carry. + assert_eq!(w.current_release(), None); + assert!(!release_dir.join("config.json").exists()); + assert!(waiting.expected.is_none() && waiting.flip.is_none()); + // Status names what a brake release must name, though no Expected exists. + let printed = printed_status(&w.world)?; + assert!(printed.contains("runtime site-yggdrasil"), "{printed}"); + assert!(printed.contains(&format!("release {}", sealed.sealed_release_id)), "{printed}"); + + // The site file changes while the deployment waits: the sealed release + // does not, because the carry lives outside every artifact digest. + std::fs::write(w.site_config(), SITE_CONFIG_TWO)?; + w.release_brake(&waiting)?; + w.tick_until(&id, "completion", |t| t.is_terminal())?; + + let done = latest_id(&w.world, &id)?; + assert_eq!( + done.sealed_release.as_ref().map(|release| &release.sealed_release_id), + Some(&sealed.sealed_release_id), + "a changed site file changed the sealed release" + ); + let (installed_sha, _) = crate::drivers::digest_artifact(&release_dir.join("dist"))?; + assert_eq!(format!("sha256-{installed_sha}"), sealed.artifacts[0].sha256); + assert_eq!( + done.completion, + Some(TransactionCompletion::Admitted { + generation_id: format!("generation-{}", done.transaction_id) + }) + ); + let flip = done.flip.as_ref().context("no flip evidence")?; + assert_eq!(flip.release_id, sealed.sealed_release_id); + assert_eq!(flip.previous_release_id, None); + assert_eq!(flip.carried_sha256.as_deref(), Some(sha256_of(SITE_CONFIG_TWO).as_str())); + assert_eq!(flip.carry_source, w.site_config()); + assert!(done.deployment_authorization.is_some(), "the brake was not consumed"); + assert_eq!(w.current_release().as_deref(), Some(sealed.sealed_release_id.as_str())); + let carried = w.current_link().join("config.json"); + assert_eq!(std::fs::read(&carried)?, SITE_CONFIG_TWO); + assert!(std::fs::symlink_metadata(&carried)?.is_file(), "the carry must be a copy, not a link to the site file"); + + // A static release runs nothing: no workload evidence, no admitted + // generation, no route program was touched. + assert!( + done.expected.is_none() + && done.activation.is_none() + && done.workload.is_none() + && done.warming.is_none() + && done.isolation.is_none() + && done.fencing.is_none() + && done.leasing.is_none() + && done.ready.is_none() + && done.routing.is_none() + && done.expected_publication_sha256.is_none() + ); + assert!(ControlSnapshot::read(&w.world.state_store)?.admitted_for("service").is_none()); + assert_eq!(w.world.route_stubs.count(""), 0, "the static path ran a host program"); + assert!(done.is_terminal(), "the post-commit cleanup did not finish: {done:?}"); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn static_release_second_deploy_records_the_release_it_replaced() -> Result<()> { + let w = static_world()?; + let first = w.run_to_brake(&w.transaction_of("up-service")?)?; + w.release_brake(&first)?; + w.tick_until(&first.transaction_id, "the first completion", |t| t.is_terminal())?; + let first_done = latest_id(&w.world, &first.transaction_id)?; + let first_release = first_done.flip.context("no first flip")?.release_id; + assert_eq!(w.current_release().as_deref(), Some(first_release.as_str())); + + enqueue_deploy(&w.world, "up-service-2")?; + let second_id = w.transaction_of("up-service-2")?; + let second = w.run_to_brake(&second_id)?; + // The first deployment's brake release names the first transaction + // and cannot release the second. + assert_eq!(w.current_release().as_deref(), Some(first_release.as_str())); + w.release_brake(&second)?; + w.tick_until(&second_id, "the second completion", |t| t.is_terminal())?; + let second_done = latest_id(&w.world, &second_id)?; + let flip = second_done.flip.context("no second flip")?; + assert_eq!(flip.previous_release_id.as_deref(), Some(first_release.as_str())); + assert_ne!(flip.release_id, first_release); + assert_eq!(w.current_release().as_deref(), Some(flip.release_id.as_str())); + // The first release is still on disk: rollback is a flip back to it. + assert!(w.site.join("releases").join(&first_release).join("dist/index.html").is_file()); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn static_release_failed_flip_leaves_current_and_ends_before_fencing() -> Result<()> { + let w = static_world()?; + let first = w.run_to_brake(&w.transaction_of("up-service")?)?; + w.release_brake(&first)?; + w.tick_until(&first.transaction_id, "the first completion", |t| t.is_terminal())?; + let incumbent = w.current_release().context("no incumbent release")?; + + enqueue_deploy(&w.world, "up-service-2")?; + let second_id = w.transaction_of("up-service-2")?; + let second = w.run_to_brake(&second_id)?; + // The operator's site file vanishes before the flip. + std::fs::remove_file(w.site_config())?; + w.release_brake(&second)?; + w.tick_until(&second_id, "the second end", |t| t.is_terminal())?; + + let ended = latest_id(&w.world, &second_id)?; + let Some(TransactionCompletion::FailedBeforeFencing { error }) = &ended.completion else { + bail!("the flip did not end before fencing: {ended:?}"); + }; + assert!(error.contains("carry source"), "{error}"); + assert_eq!(w.current_release().as_deref(), Some(incumbent.as_str()), "a failed flip moved current"); + assert!(ended.is_terminal(), "the abort cleanup did not finish"); + let abort = ended.pre_fencing_abort.as_ref().context("no pre-fencing abort")?; + assert!(abort.is_complete()); + let flip = ended.flip.as_ref().context("the intent was not recorded before the act")?; + assert_eq!(flip.previous_release_id.as_deref(), Some(incumbent.as_str())); + assert!(!flip.landed(), "a failed flip claims it landed"); + assert!( + !w.site.join("releases").join(&flip.release_id).join("config.json").exists(), + "a failed flip left a carry" + ); + Ok(()) + } + + // END static-release tests } From 74d6ef31e1ba9accaf693c2c66ba8940bfd62174 Mon Sep 17 00:00:00 2001 From: Eureka Date: Fri, 13 Nov 2026 16:41:25 +0000 Subject: [PATCH 4/6] Idunn: v5 control record, v4 legacy layout, lift and boot migration d5ea779 named this work but did not contain it: the working-tree edits were never staged. LegacyDeploymentTransactionV4, the v5 schema constant, the v4 lift in read_transaction_record and history decode, the boot-migration case, and the pinned v5 / legacy v4 tests. Co-Authored-By: Claude Sonnet 5.5 --- src/control_plane.rs | 239 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 228 insertions(+), 11 deletions(-) diff --git a/src/control_plane.rs b/src/control_plane.rs index 2250331..b52cbc6 100644 --- a/src/control_plane.rs +++ b/src/control_plane.rs @@ -56,7 +56,8 @@ use crate::host_actuator::{ }; const DEPLOYMENT_COMMAND_SCHEMA: &str = "idunn.deployment_command.v2"; -const DEPLOYMENT_TRANSACTION_SCHEMA: &str = "idunn.deployment_transaction.v4"; +const DEPLOYMENT_TRANSACTION_SCHEMA: &str = "idunn.deployment_transaction.v5"; +const DEPLOYMENT_TRANSACTION_SCHEMA_V4: &str = "idunn.deployment_transaction.v4"; const DEPLOYMENT_TRANSACTION_SCHEMA_V3: &str = "idunn.deployment_transaction.v3"; const DEPLOYMENT_TRANSACTION_SCHEMA_V2: &str = "idunn.deployment_transaction.v2"; const ADMITTED_GENERATION_SCHEMA: &str = "idunn.admitted_generation.v4"; @@ -1670,7 +1671,7 @@ impl PostCommitCleanup { #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] #[cultcache( type = "idunn.deployment_transaction", - schema = "idunn.deployment_transaction.v4" + schema = "idunn.deployment_transaction.v5" )] struct DeploymentTransaction { #[cultcache(key = 0)] @@ -1758,6 +1759,144 @@ struct DeploymentTransaction { flip: Option, } +/// The v4 transaction layout: the current one before a static release's +/// `flip` (key 37). Records are decoded by it and lifted, because the control +/// store refuses a record whose bytes differ from its re-encoding. Migration +/// to v5 is forward-only: an Idunn that predates v5 cannot read a migrated +/// store. `history.cc` is never migrated, so this struct lives until history +/// is pruned. +#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] +#[cultcache( + type = "idunn.deployment_transaction", + schema = "idunn.deployment_transaction.v4" +)] +struct LegacyDeploymentTransactionV4 { + #[cultcache(key = 0)] + schema_version: String, + #[cultcache(key = 1)] + transaction_id: String, + #[cultcache(key = 2)] + command_id: String, + #[cultcache(key = 3)] + command_kind: CommandKind, + #[cultcache(key = 4)] + target: String, + #[cultcache(key = 5)] + ordinal: u32, + #[cultcache(key = 6)] + phase: DeploymentPhase, + #[cultcache(key = 7)] + created_at_unix_millis: u64, + #[cultcache(key = 8)] + updated_at_unix_millis: u64, + #[cultcache(key = 9)] + incumbent_generation_id: Option, + #[cultcache(key = 10)] + plan: Option, + #[cultcache(key = 11)] + frozen_source: Option, + #[cultcache(key = 12)] + sealed_release: Option, + #[cultcache(key = 13)] + installed_release: Option, + #[cultcache(key = 14)] + expected: Option, + #[cultcache(key = 15)] + expected_publication_sha256: Option, + #[cultcache(key = 16)] + deployment_authorization: Option, + #[cultcache(key = 17)] + lifecycle_authorized_at_unix_millis: Option, + #[cultcache(key = 18)] + activation: Option, + #[cultcache(key = 19)] + workload: Option, + #[cultcache(key = 20)] + activation_publication_sha256: Option, + #[cultcache(key = 21)] + latest_odin_observation: Option, + #[cultcache(key = 22)] + warming: Option, + #[cultcache(key = 23)] + route_preflight: Option, + #[cultcache(key = 24)] + isolation: Option, + #[cultcache(key = 25)] + fencing: Option, + #[cultcache(key = 26)] + leasing: Option, + #[cultcache(key = 27)] + ready: Option, + #[cultcache(key = 28)] + routing: Option, + #[cultcache(key = 29)] + odin_publisher_sequence_cursor: u64, + #[cultcache(key = 30)] + last_error: Option, + #[cultcache(key = 31)] + completion: Option, + #[cultcache(key = 32)] + pre_fencing_abort: Option, + #[cultcache(key = 33)] + post_commit_cleanup: Option, + // because every transaction already durable predates this field: + // an absent key 34 decodes as None, which is exactly "this transaction has + // no post-fencing abort". Without it the daemon refuses to read its own + // control store and crashloops. + #[cultcache(key = 34, default)] + post_fencing_abort: Option, + /// Set by `transition` on entry to each post-fencing phase. + #[cultcache(key = 35)] + phase_deadline: Option, + #[cultcache(key = 36)] + lease_adoption: Option, +} + +impl LegacyDeploymentTransactionV4 { + fn into_current(self) -> DeploymentTransaction { + DeploymentTransaction { + schema_version: DEPLOYMENT_TRANSACTION_SCHEMA.into(), + transaction_id: self.transaction_id, + command_id: self.command_id, + command_kind: self.command_kind, + target: self.target, + ordinal: self.ordinal, + phase: self.phase, + created_at_unix_millis: self.created_at_unix_millis, + updated_at_unix_millis: self.updated_at_unix_millis, + incumbent_generation_id: self.incumbent_generation_id, + plan: self.plan, + frozen_source: self.frozen_source, + sealed_release: self.sealed_release, + installed_release: self.installed_release, + expected: self.expected, + expected_publication_sha256: self.expected_publication_sha256, + deployment_authorization: self.deployment_authorization, + lifecycle_authorized_at_unix_millis: self.lifecycle_authorized_at_unix_millis, + activation: self.activation, + workload: self.workload, + activation_publication_sha256: self.activation_publication_sha256, + latest_odin_observation: self.latest_odin_observation, + warming: self.warming, + route_preflight: self.route_preflight, + isolation: self.isolation, + fencing: self.fencing, + leasing: self.leasing, + ready: self.ready, + routing: self.routing, + odin_publisher_sequence_cursor: self.odin_publisher_sequence_cursor, + last_error: self.last_error, + completion: self.completion, + pre_fencing_abort: self.pre_fencing_abort, + post_commit_cleanup: self.post_commit_cleanup, + post_fencing_abort: self.post_fencing_abort, + phase_deadline: self.phase_deadline, + lease_adoption: self.lease_adoption, + flip: None, + } + } +} + /// The note a deployment keeps while another transaction owns its target. fn waiting_for_owner(target: &str, owner: &str) -> String { format!("target {target} is owned by {owner}; this deployment fences what is admitted when it completes") @@ -4253,10 +4392,19 @@ impl LegacyAdmittedGenerationV3 { } } -/// Decode a v2 or v3 transaction and lift it to the current shape, without +/// Decode a v2, v3 or v4 transaction and lift it to the current shape, without /// validating it. History reads stop here; the control store validates. fn lift_legacy_transaction(envelope: &CultCacheEnvelope) -> Result { let schema = match envelope.schema_id.as_deref() { + Some(schema @ DEPLOYMENT_TRANSACTION_SCHEMA_V4) => { + let value: LegacyDeploymentTransactionV4 = rmp_serde::from_slice(&envelope.payload) + .with_context(|| format!("decoding a {schema} deployment transaction"))?; + ensure!( + value.schema_version == schema, + "stored transaction schema differs from its envelope" + ); + return Ok(value.into_current()); + } Some(schema @ (DEPLOYMENT_TRANSACTION_SCHEMA_V2 | DEPLOYMENT_TRANSACTION_SCHEMA_V3)) => { schema } @@ -4275,7 +4423,7 @@ fn lift_legacy_transaction(envelope: &CultCacheEnvelope) -> Result Result { (envelope.r#type == DeploymentTransaction::TYPE && matches!( schema, - Some(DEPLOYMENT_TRANSACTION_SCHEMA_V2 | DEPLOYMENT_TRANSACTION_SCHEMA_V3) + Some( + DEPLOYMENT_TRANSACTION_SCHEMA_V2 + | DEPLOYMENT_TRANSACTION_SCHEMA_V3 + | DEPLOYMENT_TRANSACTION_SCHEMA_V4 + ) )) || (envelope.r#type == AdmittedGeneration::TYPE && matches!( @@ -4708,7 +4860,11 @@ fn decode_history_transactions( .filter(|envelope| envelope.r#type == DeploymentTransaction::TYPE) { let decoded = match envelope.schema_id.as_deref() { - Some(DEPLOYMENT_TRANSACTION_SCHEMA_V2 | DEPLOYMENT_TRANSACTION_SCHEMA_V3) => { + Some( + DEPLOYMENT_TRANSACTION_SCHEMA_V2 + | DEPLOYMENT_TRANSACTION_SCHEMA_V3 + | DEPLOYMENT_TRANSACTION_SCHEMA_V4 + ) => { lift_legacy_transaction(&envelope) } _ => rmp_serde::from_slice::(&envelope.payload) @@ -25498,7 +25654,7 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide assert!(!envelope.r#type.contains("runner_failure"), "{}", envelope.r#type); if envelope.r#type == DeploymentTransaction::TYPE { transactions += 1; - assert_eq!(envelope.schema_id.as_deref(), Some("idunn.deployment_transaction.v4")); + assert_eq!(envelope.schema_id.as_deref(), Some("idunn.deployment_transaction.v5")); } } } @@ -25506,11 +25662,10 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide Ok(()) } - /// The records control.cc holds re-encode byte for byte. The hex was - /// captured at base 804ed75, before this cut touched anything: a runner + /// The records control.cc holds re-encode byte for byte at v5. A runner /// failure adds no field, no type and no schema to the control store. #[test] - fn control_records_encode_exactly_as_they_did_at_base() -> Result<()> { + fn control_records_encode_exactly_as_pinned() -> Result<()> { let command = DeploymentCommand { schema_version: DEPLOYMENT_COMMAND_SCHEMA.into(), command_id: "up-service".into(), @@ -25537,7 +25692,69 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide } const PINNED_COMMAND_HEX: &str = "96bb6964756e6e2e6465706c6f796d656e745f636f6d6d616e642e7632aa75702d73657276696365a66465706c6f79a773657276696365a47465737464"; - const PINNED_TRANSACTION_HEX: &str = "dc0025bf6964756e6e2e6465706c6f796d656e745f7472616e73616374696f6e2e7634a974782d70696e6e6564aa75702d73657276696365a66465706c6f79a77365727669636500a77365616c696e676464c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c000d94072756e6e6572207374657020746573742d6461656d6f6e206f6e2072756e6e657220727573742065786974656420776974682065786974207374617475732033c094d94072756e6e6572207374657020746573742d6461656d6f6e206f6e2072756e6e657220727573742065786974656420776974682065786974207374617475732033a7736b6970706564a7736b6970706564a8636f6d706c657465c0c0c0c0"; + /// The v4 bytes of the same record, captured at base 804ed75: a legacy + /// fixture. v5 adds key 37 (`flip`), so it is one element longer and names v5. + const PINNED_TRANSACTION_HEX_V4: &str = "dc0025bf6964756e6e2e6465706c6f796d656e745f7472616e73616374696f6e2e7634a974782d70696e6e6564aa75702d73657276696365a66465706c6f79a77365727669636500a77365616c696e676464c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c000d94072756e6e6572207374657020746573742d6461656d6f6e206f6e2072756e6e657220727573742065786974656420776974682065786974207374617475732033c094d94072756e6e6572207374657020746573742d6461656d6f6e206f6e2072756e6e657220727573742065786974656420776974682065786974207374617475732033a7736b6970706564a7736b6970706564a8636f6d706c657465c0c0c0c0"; + const PINNED_TRANSACTION_HEX: &str = "dc0026bf6964756e6e2e6465706c6f796d656e745f7472616e73616374696f6e2e7635a974782d70696e6e6564aa75702d73657276696365a66465706c6f79a77365727669636500a77365616c696e676464c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c0c000d94072756e6e6572207374657020746573742d6461656d6f6e206f6e2072756e6e657220727573742065786974656420776974682065786974207374617475732033c094d94072756e6e6572207374657020746573742d6461656d6f6e206f6e2072756e6e657220727573742065786974656420776974682065786974207374617475732033a7736b6970706564a7736b6970706564a8636f6d706c657465c0c0c0c0c0"; + + fn pinned_v4_envelope() -> Result { + let payload = (0..PINNED_TRANSACTION_HEX_V4.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&PINNED_TRANSACTION_HEX_V4[i..i + 2], 16)) + .collect::, _>>()?; + Ok(CultCacheEnvelope { + key: "tx-pinned".into(), + r#type: DeploymentTransaction::TYPE.into(), + payload, + stored_at: rfc3339_millis(100)?, + schema_id: Some(DEPLOYMENT_TRANSACTION_SCHEMA_V4.into()), + }) + } + + #[test] + fn a_pinned_v4_transaction_lifts_and_reencodes_as_the_pinned_v5() -> Result<()> { + let lifted = read_transaction_record(&pinned_v4_envelope()?)?; + assert_eq!(lifted.schema_version, DEPLOYMENT_TRANSACTION_SCHEMA); + assert_eq!(lifted.flip, None); + let bytes = encode_transaction(&lifted, 100)?.payload; + let hex: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + assert_eq!(hex, PINNED_TRANSACTION_HEX); + // Read as v5 the v4 bytes are refused, never silently accepted. + let mut mislabeled = pinned_v4_envelope()?; + mislabeled.schema_id = Some(DEPLOYMENT_TRANSACTION_SCHEMA.into()); + assert!(read_transaction_record(&mislabeled).is_err()); + Ok(()) + } + + /// Always a copy in a temp dir: never a live control.cc. + #[test] + fn a_v4_control_store_boots_into_v5_and_a_v5_store_is_left_alone() -> Result<()> { + let temporary = tempfile::tempdir()?; + let path = temporary.path().join("control.cc"); + let store = SingleFileMessagePackBackingStore::new(&path); + let v4 = pinned_v4_envelope()?; + assert!(store.compare_exchange( + &[CultCacheExpectedEnvelope { + r#type: v4.r#type.clone(), + key: v4.key.clone(), + current: None, + }], + std::slice::from_ref(&v4), + )?); + assert_eq!(migrate_control_store_to_current_schema(&path)?, 1); + let after = store.pull_all_read_only_snapshot()?; + assert_eq!(after.len(), 1); + assert_eq!(after[0].schema_id.as_deref(), Some(DEPLOYMENT_TRANSACTION_SCHEMA)); + assert_eq!(after[0].stored_at, v4.stored_at); + let migrated: String = after[0].payload.iter().map(|byte| format!("{byte:02x}")).collect(); + assert_eq!(migrated, PINNED_TRANSACTION_HEX); + // Negative: an already-v5 store is untouched, byte for byte. + let file_before = std::fs::read(&path)?; + assert_eq!(migrate_control_store_to_current_schema(&path)?, 0); + assert_eq!(std::fs::read(&path)?, file_before); + read_transaction_record(&after[0])?; + Ok(()) + } // END runner-failure-evidence tests // BEGIN static-release tests From dc86c6326c97980823d043f352e7a62c590a55d0 Mon Sep 17 00:00:00 2001 From: Eureka Date: Fri, 13 Nov 2026 16:44:04 +0000 Subject: [PATCH 5/6] Idunn: a static flip that moved current can no longer end before fencing carry_and_flip returns FlipRefused only before the rename (the last fallible step); the parent-directory sync after it is best-effort. Once a flip intent is recorded, any error that is not FlipRefused leaves the transaction resumable instead of taking the pre-fencing abort, and a resumed step that finds current already naming the release records the carry already in it (new port method carried_sha256) instead of flipping again. validate refuses a pre-fencing abort beside a landed flip. Tests: post-rename port fault and post-landing observe fault both end Admitted with current on the new release; validation refuses FailedBeforeFencing with a landed flip; a refused flip over a real directory writes no carry; a populated v4 record lifts to the exact v5 field by field. Co-Authored-By: Claude Sonnet 5.5 --- src/control_plane.rs | 195 +++++++++++++++++++++++++++++++++++- src/drivers.rs | 230 +++++++++++++++++++++++++++---------------- 2 files changed, 335 insertions(+), 90 deletions(-) diff --git a/src/control_plane.rs b/src/control_plane.rs index b52cbc6..819195a 100644 --- a/src/control_plane.rs +++ b/src/control_plane.rs @@ -40,10 +40,10 @@ use crate::deployment_plan::{ }; use crate::drivers::{ ChallengeFailure, CultCacheTopologyDriver, CultCacheWriteLeaseDriver, DockerRunnerDriver, FilesystemStaticRelease, - FrozenSourceReceipt, GitSourceDriver, InstalledReleaseObservation, IsolationEvidence, NginxRouteDriver, + FlipRefused, FrozenSourceReceipt, GitSourceDriver, InstalledReleaseObservation, IsolationEvidence, NginxRouteDriver, LockContended, ProcessIdentity, RouteActuation, RouteActuationGate, RouteActuationRefused, RouteActuators, RouteObservation, RoutePreflightReceipt, RunnerPort, RunnerStepFailure, SourcePort, - StaticReleasePort, SystemdTransientWorkloadDriver, TopologyPort, WorkloadObservation, WorkloadPort, + StaticFlip, StaticReleasePort, SystemdTransientWorkloadDriver, TopologyPort, WorkloadObservation, WorkloadPort, WriteLeasePort, require_sha256_id, }; use crate::incident::{CloseReason, IncidentCondition, IncidentStore, render_incidents}; @@ -2136,6 +2136,14 @@ impl DeploymentTransaction { .is_some_and(|(_, binding)| matches!(binding.workload, WorkloadBinding::StaticRelease(_))) } + /// Whether `error` can no longer end this transaction before fencing: a + /// static flip is recorded as intended and only `FlipRefused` (the link did + /// not move) says it did not happen. Any other error after the intent may + /// follow the rename, so the step is retried, and a retry observes the link. + fn flip_may_have_moved_current(&self, error: &anyhow::Error) -> bool { + self.flip.is_some() && error.downcast_ref::().is_none() + } + /// The runtime and the sealed release a deployment brake release must name. /// One reading for both kinds of target: the binding's runtime identity and /// the sealed release, which a service's Expected projection copies. @@ -2644,6 +2652,11 @@ impl DeploymentTransaction { } if let Some(abort) = &self.pre_fencing_abort { require_detail(&abort.error, "pre-fencing abort error")?; + ensure!( + !self.flip.as_ref().is_some_and(FlipEvidence::landed), + "a transaction whose flip landed cannot fail before fencing" + ); + ensure!( self.post_commit_cleanup.is_none(), "aborted transaction also carries post-commit cleanup" @@ -6738,6 +6751,7 @@ impl Engine { // phase deadline, not an abort, bounds the wait. if latest.value.pre_fencing_abort.is_none() && error.downcast_ref::().is_none() + && !latest.value.flip_may_have_moved_current(&error) { if let Some(line) = self.keep_runner_failure(&latest.value, &error) { eprintln!("{line}"); @@ -7917,9 +7931,21 @@ impl Engine { }); }; if !flip.landed() { - let landed = self - .static_release - .carry_and_flip(workload, &flip.release_id)?; + // An earlier attempt may have moved the link and failed to record + // it: then nothing is flipped again, and the carry already inside + // the release is what is recorded. + let landed = if self.static_release.observe_current(workload)?.as_deref() + == Some(flip.release_id.as_str()) + { + StaticFlip { + carried_sha256: self + .static_release + .carried_sha256(workload, &flip.release_id)?, + } + } else { + self.static_release + .carry_and_flip(workload, &flip.release_id)? + }; return self.persist_same_phase(current, |next| { let flip = next.flip.as_mut().expect("flip intent read above"); flip.carried_sha256 = Some(landed.carried_sha256); @@ -26130,5 +26156,164 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide Ok(()) } + + /// The filesystem port, with faults injected where the real one could only + /// fail after the link had moved: `flip_fault_after_rename` fails the flip + /// call after the rename landed, `observe_fault_at` fails the nth observe. + struct FaultyPort { + inner: FilesystemStaticRelease, + flip_fault_after_rename: std::sync::atomic::AtomicBool, + observe_calls: std::sync::atomic::AtomicUsize, + observe_fault_at: usize, + } + + impl FaultyPort { + fn new(flip_fault_after_rename: bool, observe_fault_at: usize) -> Arc { + Arc::new(Self { + inner: FilesystemStaticRelease, + flip_fault_after_rename: flip_fault_after_rename.into(), + observe_calls: 0.into(), + observe_fault_at, + }) + } + } + + impl crate::drivers::StaticReleasePort for FaultyPort { + fn observe_current(&self, binding: &crate::deployment::StaticReleaseBinding) -> Result> { + use std::sync::atomic::Ordering; + let call = self.observe_calls.fetch_add(1, Ordering::SeqCst) + 1; + ensure!(call != self.observe_fault_at, "injected observe fault (call {call})"); + self.inner.observe_current(binding) + } + + fn carry_and_flip( + &self, + binding: &crate::deployment::StaticReleaseBinding, + release_id: &str, + ) -> Result { + let flip = self.inner.carry_and_flip(binding, release_id)?; + if self.flip_fault_after_rename.swap(false, std::sync::atomic::Ordering::SeqCst) { + bail!("injected fault after the rename"); + } + Ok(flip) + } + + fn carried_sha256( + &self, + binding: &crate::deployment::StaticReleaseBinding, + release_id: &str, + ) -> Result { + self.inner.carried_sha256(binding, release_id) + } + } + + /// Drive the first Deploy through the brake to its end. + fn drive_static_to_end(w: &StaticWorld) -> Result { + let id = w.transaction_of("up-service")?; + let waiting = w.run_to_brake(&id)?; + w.release_brake(&waiting)?; + w.tick_until(&id, "the end", |t| t.is_terminal())?; + latest_id(&w.world, &id) + } + + #[cfg(unix)] + #[test] + fn static_release_error_after_the_rename_never_ends_before_fencing() -> Result<()> { + for (flip_fault, observe_fault_at) in [(true, 0), (false, 4)] { + let mut w = static_world()?; + w.world.engine.static_release = FaultyPort::new(flip_fault, observe_fault_at); + let done = drive_static_to_end(&w)?; + assert!( + matches!(done.completion, Some(TransactionCompletion::Admitted { .. })), + "a post-rename fault ended the transaction: {:?} {:?}", + done.completion, + done.last_error + ); + assert!(done.pre_fencing_abort.is_none()); + let flip = done.flip.as_ref().context("no flip")?; + assert!(flip.landed()); + assert_eq!(w.current_release().as_deref(), Some(flip.release_id.as_str())); + assert_eq!( + flip.carried_sha256.as_deref(), + Some(sha256_of(SITE_CONFIG_ONE).as_str()) + ); + } + Ok(()) + } + + #[cfg(unix)] + #[test] + fn static_release_validation_refuses_a_landed_flip_that_failed_before_fencing() -> Result<()> { + let w = static_world()?; + let mut done = drive_static_to_end(&w)?; + assert!(done.flip.as_ref().is_some_and(FlipEvidence::landed)); + done.completion = Some(TransactionCompletion::FailedBeforeFencing { error: "x".into() }); + done.pre_fencing_abort = Some(PreFencingAbort { + error: "x".into(), + candidate_cleanup: CleanupEvidence::Skipped, + topology_reconciliation: CleanupEvidence::Skipped, + source_cleanup: CleanupEvidence::Complete, + }); + done.post_commit_cleanup = None; + done.phase_deadline = None; + let error = done.validate().unwrap_err(); + assert!(format!("{error:#}").contains("flip landed"), "{error:#}"); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn a_populated_v4_transaction_lifts_to_the_exact_v5_field_by_field() -> Result<()> { + let w = static_world()?; + let done = drive_static_to_end(&w)?; + // A populated record a v4 Idunn could have written: a deploy that + // consumed its brake and ended before fencing. + let mut expected = done.clone(); + expected.flip = None; + expected.completion = Some(TransactionCompletion::FailedBeforeFencing { error: "refused".into() }); + expected.pre_fencing_abort = Some(PreFencingAbort { + error: "refused".into(), + candidate_cleanup: CleanupEvidence::Skipped, + topology_reconciliation: CleanupEvidence::Skipped, + source_cleanup: CleanupEvidence::Complete, + }); + expected.post_commit_cleanup = None; + expected.phase_deadline = None; + expected.last_error = Some("refused".into()); + expected.validate()?; + assert_ne!(expected.created_at_unix_millis, expected.updated_at_unix_millis); + assert!(expected.completion.is_some() && expected.deployment_authorization.is_some()); + // The v4 bytes of this record: the v5 encoding with the old schema name, + // one element shorter (key 37 is the last, a nil here). + let mut as_v4 = expected.clone(); + as_v4.schema_version = DEPLOYMENT_TRANSACTION_SCHEMA_V4.into(); + let mut payload = rmp_serde::to_vec(&as_v4)?; + assert_eq!(&payload[..3], &[0xdc, 0x00, 0x26]); + payload[2] = 0x25; + assert_eq!(payload.pop(), Some(0xc0)); + let envelope = CultCacheEnvelope { + key: expected.transaction_id.clone(), + r#type: DeploymentTransaction::TYPE.into(), + payload, + stored_at: rfc3339_millis(100)?, + schema_id: Some(DEPLOYMENT_TRANSACTION_SCHEMA_V4.into()), + }; + let lifted = read_transaction_record(&envelope)?; + assert_eq!(lifted.schema_version, DEPLOYMENT_TRANSACTION_SCHEMA); + assert_eq!(lifted.transaction_id, expected.transaction_id); + assert_eq!(lifted.created_at_unix_millis, expected.created_at_unix_millis); + assert_eq!(lifted.updated_at_unix_millis, expected.updated_at_unix_millis); + assert_eq!(lifted.completion, expected.completion); + assert_eq!(lifted.plan, expected.plan); + assert_eq!(lifted.sealed_release, expected.sealed_release); + assert_eq!(lifted.installed_release, expected.installed_release); + assert_eq!(lifted.frozen_source, expected.frozen_source); + assert_eq!(lifted.deployment_authorization, expected.deployment_authorization); + assert_eq!(lifted.post_commit_cleanup, expected.post_commit_cleanup); + assert_eq!(lifted.phase_deadline, expected.phase_deadline); + assert_eq!(lifted.flip, None); + assert_eq!(lifted, expected, "some field changed in the lift"); + Ok(()) + } // END static-release tests } diff --git a/src/drivers.rs b/src/drivers.rs index 67068f2..605ccc8 100644 --- a/src/drivers.rs +++ b/src/drivers.rs @@ -634,6 +634,20 @@ pub struct StaticFlip { pub carried_sha256: String, } +/// A flip that was refused before the link moved. It is the only error that may +/// end a static transaction before fencing: once the rename has happened, +/// nothing may say the flip did not. +#[derive(Debug)] +pub(crate) struct FlipRefused(pub(crate) String); + +impl std::fmt::Display for FlipRefused { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +impl std::error::Error for FlipRefused {} + /// The one external change a static release makes: copy the operator's site /// file into an installed release and repoint the current link at it. The port /// decides nothing; the engine decides when, and records what it reports. @@ -646,8 +660,15 @@ pub trait StaticReleasePort { /// Copy the carry into `release_id` and repoint the link at it. Nothing /// moves unless the carry source was read, so a missing site file leaves /// the link where it was. Repeating it is harmless. + /// + /// An error is `FlipRefused` exactly when the link did not move; the rename + /// is the last fallible step, and what follows it cannot fail the flip. fn carry_and_flip(&self, binding: &StaticReleaseBinding, release_id: &str) -> Result; + + /// The digest of the carry already inside an installed release: what a + /// resumed flip records when an earlier attempt landed. + fn carried_sha256(&self, binding: &StaticReleaseBinding, release_id: &str) -> Result; } #[derive(Clone, Copy, Debug, Default)] @@ -694,6 +715,22 @@ impl StaticReleasePort for FilesystemStaticRelease { Ok(Some(id.to_owned())) } + fn carried_sha256(&self, binding: &StaticReleaseBinding, release_id: &str) -> Result { + require_sha256_id(release_id, "release carrying the site file")?; + let carried = binding + .release_root + .join(release_id) + .join(&binding.carry.destination); + ensure!( + fs::symlink_metadata(&carried)?.is_file(), + "carried {} is not a regular file", + carried.display() + ); + let mut hasher = Sha256::new(); + hash_file_exact(&carried, fs::metadata(&carried)?.len(), &mut hasher)?; + Ok(format!("sha256-{:x}", hasher.finalize())) + } + fn carry_and_flip( &self, binding: &StaticReleaseBinding, @@ -701,94 +738,107 @@ impl StaticReleasePort for FilesystemStaticRelease { ) -> Result { use std::os::unix::fs::{OpenOptionsExt, symlink}; - require_sha256_id(release_id, "release to flip to")?; - let release = binding.release_root.join(release_id); - let release_metadata = fs::symlink_metadata(&release) - .with_context(|| format!("installed release {}", release.display()))?; - ensure!( - release_metadata.is_dir(), - "installed release {} is not a directory", - release.display() - ); - let link_parent = binding - .current_link - .parent() - .context("current link has no parent")?; - match fs::symlink_metadata(&binding.current_link) { - Ok(metadata) => ensure!( - metadata.file_type().is_symlink(), - "current {} is not a symlink and will not be replaced", - binding.current_link.display() - ), - Err(error) if error.kind() == ErrorKind::NotFound => {} - Err(error) => return Err(error).context("reading current link"), - } - - // Everything that can fail on the operator's side fails here, before - // the link moves. - let source = &binding.carry.source; - let source_metadata = fs::symlink_metadata(source) - .with_context(|| format!("reading carry source {}", source.display()))?; - ensure!( - source_metadata.is_file(), - "carry source {} is not a regular file", - source.display() - ); - ensure!( - source_metadata.len() <= STATIC_CARRY_LIMIT_BYTES, - "carry source {} is larger than {STATIC_CARRY_LIMIT_BYTES} bytes", - source.display() - ); - let mut bytes = Vec::new(); - fs::File::open(source)? - .take(STATIC_CARRY_LIMIT_BYTES + 1) - .read_to_end(&mut bytes)?; - ensure!( - bytes.len() as u64 <= STATIC_CARRY_LIMIT_BYTES, - "carry source {} grew past {STATIC_CARRY_LIMIT_BYTES} bytes", - source.display() - ); - let carried_sha256 = format!("sha256-{:x}", Sha256::digest(&bytes)); - - let carried = release.join(&binding.carry.destination); - let temporary_carry = release.join(format!(".carry-{}", Uuid::new_v4())); - let written = (|| -> Result<()> { - let mut file = OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o444) - .open(&temporary_carry)?; - file.write_all(&bytes)?; - file.sync_all()?; - fs::rename(&temporary_carry, &carried)?; - fs::File::open(&release)?.sync_all()?; - Ok(()) - })(); - if let Err(error) = written { - let _ = fs::remove_file(&temporary_carry); - return Err(error) - .with_context(|| format!("carrying the site file into {}", release.display())); - } - - // A link is renamed over, never edited: a reader sees the old release - // or the new one. A temporary link a crashed flip left is replaced. - let temporary_link = link_parent.join(format!( - ".{}.flip", - binding + // Everything up to and including the rename can refuse, and only that + // refuses: the link has not moved when `FlipRefused` is returned. + let refuse = |error: anyhow::Error| anyhow::Error::new(FlipRefused(format!("{error:#}"))); + let staged = (|| -> Result<(PathBuf, PathBuf, String)> { + require_sha256_id(release_id, "release to flip to")?; + let release = binding.release_root.join(release_id); + let release_metadata = fs::symlink_metadata(&release) + .with_context(|| format!("installed release {}", release.display()))?; + ensure!( + release_metadata.is_dir(), + "installed release {} is not a directory", + release.display() + ); + let link_parent = binding .current_link - .file_name() - .and_then(OsStr::to_str) - .context("current link has no name")? - )); - if fs::symlink_metadata(&temporary_link).is_ok() { - fs::remove_file(&temporary_link).context("removing a stale flip link")?; - } - symlink(&release, &temporary_link).context("creating the flip link")?; + .parent() + .context("current link has no parent")?; + match fs::symlink_metadata(&binding.current_link) { + Ok(metadata) => ensure!( + metadata.file_type().is_symlink(), + "current {} is not a symlink and will not be replaced", + binding.current_link.display() + ), + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(error).context("reading current link"), + } + + let source = &binding.carry.source; + let source_metadata = fs::symlink_metadata(source) + .with_context(|| format!("reading carry source {}", source.display()))?; + ensure!( + source_metadata.is_file(), + "carry source {} is not a regular file", + source.display() + ); + ensure!( + source_metadata.len() <= STATIC_CARRY_LIMIT_BYTES, + "carry source {} is larger than {STATIC_CARRY_LIMIT_BYTES} bytes", + source.display() + ); + let mut bytes = Vec::new(); + fs::File::open(source)? + .take(STATIC_CARRY_LIMIT_BYTES + 1) + .read_to_end(&mut bytes)?; + ensure!( + bytes.len() as u64 <= STATIC_CARRY_LIMIT_BYTES, + "carry source {} grew past {STATIC_CARRY_LIMIT_BYTES} bytes", + source.display() + ); + let carried_sha256 = format!("sha256-{:x}", Sha256::digest(&bytes)); + + let carried = release.join(&binding.carry.destination); + let temporary_carry = release.join(format!(".carry-{}", Uuid::new_v4())); + let written = (|| -> Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o444) + .open(&temporary_carry)?; + file.write_all(&bytes)?; + file.sync_all()?; + fs::rename(&temporary_carry, &carried)?; + fs::File::open(&release)?.sync_all()?; + Ok(()) + })(); + if let Err(error) = written { + let _ = fs::remove_file(&temporary_carry); + return Err(error) + .with_context(|| format!("carrying the site file into {}", release.display())); + } + + // A link is renamed over, never edited: a reader sees the old + // release or the new one. A temporary link a crashed flip left is + // replaced. + let temporary_link = link_parent.join(format!( + ".{}.flip", + binding + .current_link + .file_name() + .and_then(OsStr::to_str) + .context("current link has no name")? + )); + if fs::symlink_metadata(&temporary_link).is_ok() { + fs::remove_file(&temporary_link).context("removing a stale flip link")?; + } + symlink(&release, &temporary_link).context("creating the flip link")?; + Ok((temporary_link, release, carried_sha256)) + })() + .map_err(refuse)?; + let (temporary_link, _, carried_sha256) = staged; if let Err(error) = fs::rename(&temporary_link, &binding.current_link) { let _ = fs::remove_file(&temporary_link); - return Err(error).context("repointing the current link"); + return Err(refuse( + anyhow::Error::new(error).context("repointing the current link"), + )); + } + // The link has moved and nothing below can say it did not. The parent + // directory sync is durability only, so it is best-effort. + if let Some(parent) = binding.current_link.parent() { + let _ = fs::File::open(parent).and_then(|directory| directory.sync_all()); } - fs::File::open(link_parent)?.sync_all()?; Ok(StaticFlip { carried_sha256 }) } } @@ -802,6 +852,10 @@ impl StaticReleasePort for FilesystemStaticRelease { fn carry_and_flip(&self, _: &StaticReleaseBinding, _: &str) -> Result { bail!("a static release flip requires Unix symlinks") } + + fn carried_sha256(&self, _: &StaticReleaseBinding, _: &str) -> Result { + bail!("a static release flip requires Unix symlinks") + } } pub trait TopologyPort { @@ -16970,6 +17024,7 @@ processes 9 fs::remove_file(&site.binding.carry.source)?; let error = port.carry_and_flip(&site.binding, RELEASE_B).unwrap_err(); assert!(format!("{error:#}").contains("carry source"), "{error:#}"); + assert!(error.downcast_ref::().is_some(), "a pre-rename refusal is typed"); assert_eq!(port.observe_current(&site.binding)?.as_deref(), Some(RELEASE_A)); assert!( !site.binding.release_root.join(RELEASE_B).join("config.json").exists(), @@ -17018,8 +17073,13 @@ processes 9 let port = FilesystemStaticRelease; // A real directory where the link belongs is left alone. fs::create_dir(&site.binding.current_link)?; - assert!(port.carry_and_flip(&site.binding, RELEASE_A).is_err()); + let error = port.carry_and_flip(&site.binding, RELEASE_A).unwrap_err(); + assert!(error.downcast_ref::().is_some(), "{error:#}"); assert!(site.binding.current_link.is_dir()); + assert!( + !site.binding.release_root.join(RELEASE_A).join("config.json").exists(), + "a refused flip wrote its carry" + ); assert!(port.observe_current(&site.binding).is_err()); fs::remove_dir(&site.binding.current_link)?; // A link that names something outside the release root has no release From be98521120d5259e9f33bbdf6638657f5c01289e Mon Sep 17 00:00:00 2001 From: Eureka Date: Fri, 13 Nov 2026 16:48:16 +0000 Subject: [PATCH 6/6] Idunn: pin static redeploy and stuck-flip behavior; document both Release ids are per transaction, so redeploying an unchanged tree with a changed site file yields a new release (the test asserts distinct ids, the first release keeping its carry, current carrying the new file); previous_release_id can never equal release_id and no refusal is needed. A flip whose current link was replaced by a directory after the intent retries visibly: last_error is shown by idunn status as 'waiting on', cancel is refused, and the docs name the by-hand fix. Co-Authored-By: Claude Sonnet 5.5 --- docs/deployment-authority.md | 10 +++++++++- src/control_plane.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/docs/deployment-authority.md b/docs/deployment-authority.md index 9b72032..1f39f5d 100644 --- a/docs/deployment-authority.md +++ b/docs/deployment-authority.md @@ -210,7 +210,15 @@ What `current_link` names is observed from the symlink and confirmed by the latest Admitted static transaction's `flip.release_id`; it is stored nowhere else. A rollback is a flip back to `flip.previous_release_id`: release directories stay, and Idunn prunes none. The brake is read exactly as for a -service, naming the binding's runtime id and the sealed release id. A new +service, naming the binding's runtime id and the sealed release id. Release ids are per transaction, so +redeploying an unchanged tarball with a changed site file is a new release that +carries the new file, never an in-place re-carry of an existing one: the carry +is part of a release. If `current_link` is replaced by a directory or by a link +that names nothing under the release root after the flip was recorded, the +transaction keeps retrying with backoff: `idunn status --command ` shows the +error as "waiting on ...", operator cancel is refused (Starting, not held), and +the fix is by hand (restore the link or remove the directory); it then +completes. A bounded retry is follow-up work. A new upstream tarball enters as a recipe `external_input` with a pinned url and sha256, so each release is a recipe commit whose pin is its provenance. diff --git a/src/control_plane.rs b/src/control_plane.rs index 819195a..8556deb 100644 --- a/src/control_plane.rs +++ b/src/control_plane.rs @@ -26102,6 +26102,11 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide let first_release = first_done.flip.context("no first flip")?.release_id; assert_eq!(w.current_release().as_deref(), Some(first_release.as_str())); + // The same tree is deployed again with a changed site config. Release + // ids are per transaction (the plan names its own incarnation), so the + // redeploy is a new release that carries the new file; the first keeps + // the bytes it was flipped with. No redeploy can name its own release. + std::fs::write(w.site_config(), SITE_CONFIG_TWO)?; enqueue_deploy(&w.world, "up-service-2")?; let second_id = w.transaction_of("up-service-2")?; let second = w.run_to_brake(&second_id)?; @@ -26115,6 +26120,11 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide assert_eq!(flip.previous_release_id.as_deref(), Some(first_release.as_str())); assert_ne!(flip.release_id, first_release); assert_eq!(w.current_release().as_deref(), Some(flip.release_id.as_str())); + assert_eq!(std::fs::read(w.current_link().join("config.json"))?, SITE_CONFIG_TWO); + assert_eq!( + std::fs::read(w.site.join("releases").join(&first_release).join("config.json"))?, + SITE_CONFIG_ONE + ); // The first release is still on disk: rollback is a flip back to it. assert!(w.site.join("releases").join(&first_release).join("dist/index.html").is_file()); Ok(()) @@ -26315,5 +26325,31 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide assert_eq!(lifted, expected, "some field changed in the lift"); Ok(()) } + + #[cfg(unix)] + #[test] + fn static_release_with_a_foreign_current_retries_visibly_and_cannot_be_cancelled() -> Result<()> { + let w = static_world()?; + let id = w.transaction_of("up-service")?; + let waiting = w.run_to_brake(&id)?; + w.release_brake(&waiting)?; + w.tick_until(&id, "the flip intent", |t| t.flip.is_some())?; + // The operator replaces the link's place with a directory. + std::fs::create_dir(w.current_link())?; + for _ in 0..4 { + w.world.engine.resume_backoff.lock().unwrap().clear(); + let _ = w.world.engine.run_scheduler_tick(); + } + let stuck = latest_id(&w.world, &id)?; + assert!(!stuck.is_terminal() && stuck.phase == DeploymentPhase::Starting, "{stuck:?}"); + assert!(stuck.pre_fencing_abort.is_none(), "an error after the intent ended the flip"); + let reason = stuck.last_error.clone().context("the retry kept no error")?; + assert!(reason.contains("not a symlink"), "{reason}"); + let printed = printed_status(&w.world)?; + assert!(printed.contains(&format!("waiting on {reason}")), "{printed}"); + let refused = cancel(&w.world.state_store, "up-service", "operator").unwrap_err(); + assert!(format!("{refused:#}").contains("can only be cancelled"), "{refused:#}"); + Ok(()) + } // END static-release tests }