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..1f39f5d 100644 --- a/docs/deployment-authority.md +++ b/docs/deployment-authority.md @@ -171,6 +171,57 @@ 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. 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. + ## 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 cd49073..8556deb 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, + FlipRefused, FrozenSourceReceipt, GitSourceDriver, InstalledReleaseObservation, IsolationEvidence, NginxRouteDriver, LockContended, ProcessIdentity, RouteActuation, RouteActuationGate, RouteActuationRefused, RouteActuators, RouteObservation, RoutePreflightReceipt, RunnerPort, RunnerStepFailure, SourcePort, - SystemdTransientWorkloadDriver, TopologyPort, WorkloadObservation, WorkloadPort, - WriteLeasePort, + StaticFlip, StaticReleasePort, SystemdTransientWorkloadDriver, TopologyPort, WorkloadObservation, WorkloadPort, + WriteLeasePort, require_sha256_id, }; use crate::incident::{CloseReason, IncidentCondition, IncidentStore, render_incidents}; use crate::runner_evidence::{ @@ -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"; @@ -635,6 +636,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 @@ -1626,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)] @@ -1708,6 +1753,148 @@ 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 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. @@ -1939,6 +2126,36 @@ 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(_))) + } + + /// 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. + 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 +2211,7 @@ impl DeploymentTransaction { post_commit_cleanup: None, phase_deadline: None, lease_adoption: None, + flip: None, }; transaction.validate()?; Ok(transaction) @@ -2160,6 +2378,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,8 +2639,24 @@ 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!( + !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" @@ -2464,111 +2773,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 +4196,7 @@ impl LegacyDeploymentTransaction { post_fencing_abort: self.post_fencing_abort, phase_deadline: None, lease_adoption: None, + flip: None, } } } @@ -4090,10 +4405,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 } @@ -4112,7 +4436,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!( @@ -4545,7 +4873,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) @@ -4891,9 +5223,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 +5496,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 +5630,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,8 +5886,26 @@ 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(_) => 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 +5916,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() { @@ -5653,13 +6016,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" ); } @@ -6389,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}"); @@ -7352,8 +7715,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; @@ -7376,9 +7743,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, @@ -7395,11 +7763,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(()) @@ -7443,6 +7813,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")?; @@ -7528,6 +7901,78 @@ 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() { + // 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); + 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")?; @@ -8639,7 +9084,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)) = @@ -8653,8 +9098,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, ) @@ -13390,6 +13835,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, @@ -13411,7 +13868,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(), @@ -13419,7 +13876,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), @@ -20204,7 +20661,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")?; @@ -20215,15 +20671,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(), @@ -20250,7 +20723,7 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide ], )? ); - Ok(world) + Ok(()) } fn unrouted_recipe() -> String { @@ -25207,7 +25680,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")); } } } @@ -25215,11 +25688,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(), @@ -25246,6 +25718,638 @@ 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 + // 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())); + + // 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)?; + // 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())); + 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(()) + } + + #[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(()) + } + + + /// 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(()) + } + + #[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 } 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..605ccc8 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,239 @@ 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, +} + +/// 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. +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. + /// + /// 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)] +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 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, + release_id: &str, + ) -> Result { + use std::os::unix::fs::{OpenOptionsExt, symlink}; + + // 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 + .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(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()); + } + 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") + } + + fn carried_sha256(&self, _: &StaticReleaseBinding, _: &str) -> Result { + bail!("a static release flip requires Unix symlinks") + } +} + pub trait TopologyPort { fn publish_expected( &self, @@ -2903,7 +3137,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 +3189,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" ); @@ -3435,11 +3668,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 +3967,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 +3990,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 +4200,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, @@ -8524,7 +8757,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 +16931,167 @@ 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!(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(), + "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)?; + 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 + // 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(()) + } } 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