Merge pull request 'Static release target: StaticRelease binding, carry, atomic flip, control record v5' (#2) from eureka-self/static-release into main
This commit is contained in:
commit
9f15cee11f
7 changed files with 2096 additions and 223 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1388
src/control_plane.rs
1388
src/control_plane.rs
File diff suppressed because it is too large
Load diff
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
416
src/drivers.rs
416
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<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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue