Static release target: StaticRelease binding, carry, atomic flip, control record v5 #2

Merged
eureka merged 6 commits from eureka-self/static-release into main 2026-11-13 16:50:20 +00:00
7 changed files with 2096 additions and 223 deletions

View file

@ -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

View file

@ -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 = "<id>"` 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
`<release_root>/<sealed release id>/<carry.destination>` (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/<artifact destination>` for the tree and `current_link/<carry
destination>` 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 <id>` 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

File diff suppressed because it is too large Load diff

View file

@ -45,7 +45,12 @@ pub struct TargetDeclaration {
#[serde(default)]
pub external_inputs: Vec<ExternalInput>,
pub artifacts: Vec<ArtifactOutput>,
pub service: ServiceDeclaration,
/// A running workload. Exactly one of `service` and `static_release`.
#[serde(default)]
pub service: Option<ServiceDeclaration>,
/// A sealed tree that is published by repointing a link, with no process.
#[serde(default)]
pub static_release: Option<StaticReleaseDeclaration>,
#[serde(default)]
pub state: Option<StateDeclaration>,
#[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<String, String> = 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}");
}
}
}

View file

@ -854,14 +854,15 @@ impl SealedRelease {
) -> Result<ExpectedIncarnation> {
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

View file

@ -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<bool>;
}
/// 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<Option<String>>;
/// 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<StaticFlip>;
/// 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<String>;
}
#[derive(Clone, Copy, Debug, Default)]
pub struct FilesystemStaticRelease;
#[cfg(unix)]
impl StaticReleasePort for FilesystemStaticRelease {
fn observe_current(&self, binding: &StaticReleaseBinding) -> Result<Option<String>> {
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<String> {
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<StaticFlip> {
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<Option<String>> {
bail!("a static release flip requires Unix symlinks")
}
fn carry_and_flip(&self, _: &StaticReleaseBinding, _: &str) -> Result<StaticFlip> {
bail!("a static release flip requires Unix symlinks")
}
fn carried_sha256(&self, _: &StaticReleaseBinding, _: &str) -> Result<String> {
bail!("a static release flip requires Unix symlinks")
}
}
pub trait TopologyPort {
fn publish_expected(
&self,
@ -2903,7 +3137,7 @@ impl SystemdTransientWorkloadDriver {
) -> Result<InstalledReleaseObservation> {
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<Vec<OsString>> {
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<StandInSite> {
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!("<html>{release}</html>"))?;
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::<FlipRefused>().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::<FlipRefused>().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(())
}
}

View file

@ -631,11 +631,11 @@ impl Actuator {
workload: &HostWorkloadBinding,
installed: &Path,
) -> Result<(PathBuf, Vec<String>)> {
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