Merge pull request 'idunn rollback: flip a static release back to the previous one' (#3) from eureka-self/static-rollback into main
This commit is contained in:
commit
114803c63b
4 changed files with 549 additions and 36 deletions
|
|
@ -222,6 +222,46 @@ 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.
|
||||
|
||||
### Rollback
|
||||
|
||||
`idunn rollback <target> [--state-store PATH] [--no-wait]` writes a
|
||||
`rollback-` command and nothing else. The daemon freezes it into one new
|
||||
transaction (`CommandKind::Rollback`) that reuses the previous release exactly:
|
||||
the plan, sealed release and installed release are copied from the transaction
|
||||
that flipped it, so there is no source, no build and no source cleanup, and it
|
||||
creates no admitted generation. It starts at Sealing with that evidence and
|
||||
reaches the same deployment brake as a deploy, which the operator releases for
|
||||
the rollback transaction's id and the previous sealed release id; then
|
||||
`advance_static` flips, with the same three durable steps, the same
|
||||
nothing-aborts-after-the-rename rule and the same stuck-flip behavior.
|
||||
|
||||
The previous release is derived, never stored: the newest Admitted static
|
||||
transaction for the target (control.cc and history.cc) names the release
|
||||
`current` must point at (`flip.release_id`) and the one it displaced
|
||||
(`flip.previous_release_id`); the newest transaction that flipped that release
|
||||
supplies the evidence. A rollback of a rollback therefore flips forward again.
|
||||
The command is refused, recorded as a rejected transaction, when the target has
|
||||
no completed static release, when the newest release is the first one, when
|
||||
`current` does not name the newest flip, when the previous release directory is
|
||||
gone, or when an artifact in it no longer digests to its sealed receipt. A
|
||||
failed check at flip time ends the transaction before fencing with `current`
|
||||
untouched.
|
||||
|
||||
The rollback reuses the old plan verbatim, so its binding snapshot, brake path
|
||||
and carry source are the old ones: if the operator moved the site file the
|
||||
rollback fails before the link moves. The carry is re-applied from the site file
|
||||
as it is at rollback time, and its sha256 is recorded in the rollback's own
|
||||
flip evidence (it may differ from the original release's; the copy inside the
|
||||
rolled-back-to release is replaced). Nothing prunes release directories.
|
||||
|
||||
`CommandKind::Rollback` is stored in the existing v5 command and transaction
|
||||
records with no schema change; an Idunn at 9f15cee or earlier cannot decode
|
||||
them, so this is forward-only, like v5 itself. The newest flip is ordered by
|
||||
`flipped_at` wall clock, and the observed-link check makes a clock step refuse
|
||||
the rollback rather than mis-flip. A rollback cannot be cancelled, like a static
|
||||
deploy. A rollback is also refused when the previous release was deployed under
|
||||
a different release root or current link than the newest one.
|
||||
|
||||
## Typed deployment state
|
||||
|
||||
Mutable control-plane state will be CultCache. Runtime integration must use
|
||||
|
|
|
|||
|
|
@ -143,9 +143,11 @@ struct DeploymentCommand {
|
|||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
enum CommandKind {
|
||||
pub(crate) enum CommandKind {
|
||||
Deploy,
|
||||
Continuity,
|
||||
/// Flip a static release's current link back to the release it replaced.
|
||||
Rollback,
|
||||
}
|
||||
|
||||
impl DeploymentCommand {
|
||||
|
|
@ -163,8 +165,13 @@ impl DeploymentCommand {
|
|||
);
|
||||
match self.kind {
|
||||
CommandKind::Deploy => ensure!(
|
||||
!self.command_id.starts_with("continuity-"),
|
||||
"operator deployment uses the continuity command namespace"
|
||||
!self.command_id.starts_with("continuity-")
|
||||
&& !self.command_id.starts_with("rollback-"),
|
||||
"operator deployment uses a reserved command namespace"
|
||||
),
|
||||
CommandKind::Rollback => ensure!(
|
||||
self.command_id.starts_with("rollback-") && !self.selector.starts_with("profile:"),
|
||||
"rollback command identity or selector is invalid"
|
||||
),
|
||||
CommandKind::Continuity => ensure!(
|
||||
self.command_id.starts_with("continuity-")
|
||||
|
|
@ -2108,8 +2115,8 @@ impl DeploymentTransaction {
|
|||
let issued = leasing.and_then(LeasingEvidence::issued_lease).is_some();
|
||||
match self.command_kind {
|
||||
CommandKind::Continuity => PhaseEnd::RetryForward,
|
||||
CommandKind::Deploy if issued => PhaseEnd::OperatorRequired,
|
||||
CommandKind::Deploy => PhaseEnd::Abort {
|
||||
CommandKind::Deploy | CommandKind::Rollback if issued => PhaseEnd::OperatorRequired,
|
||||
CommandKind::Deploy | CommandKind::Rollback => PhaseEnd::Abort {
|
||||
lease_not_adopted: granted && self.lease_adoption.is_none(),
|
||||
},
|
||||
}
|
||||
|
|
@ -2232,13 +2239,25 @@ impl DeploymentTransaction {
|
|||
Ok(transaction)
|
||||
}
|
||||
|
||||
/// A rollback: the release that was flipped before, reused exactly (its
|
||||
/// plan, sealed release and installed release are the prior transaction's),
|
||||
/// with no source to freeze and nothing to build.
|
||||
fn from_rollback(command: &DeploymentCommand, prior: &DeploymentTransaction, now: u64) -> Result<Self> {
|
||||
let mut transaction = Self::new(command, prior.target.clone(), 0, None, now)?;
|
||||
transaction.plan = prior.plan.clone();
|
||||
transaction.sealed_release = prior.sealed_release.clone();
|
||||
transaction.installed_release = prior.installed_release.clone();
|
||||
transaction.validate()?;
|
||||
Ok(transaction)
|
||||
}
|
||||
|
||||
/// Whether an abort owes the projection a reconciliation. A deployment
|
||||
/// published its own Expected key. A continuity shares the admitted key,
|
||||
/// whose Expected is never withdrawn, so it owes only the demotion of the
|
||||
/// activation it issued.
|
||||
fn abort_topology_reconciliation(&self) -> CleanupEvidence {
|
||||
let owes = match self.command_kind {
|
||||
CommandKind::Deploy => self.expected_publication_sha256.is_some(),
|
||||
CommandKind::Deploy | CommandKind::Rollback => self.expected_publication_sha256.is_some(),
|
||||
CommandKind::Continuity => self.activation.is_some(),
|
||||
};
|
||||
if owes {
|
||||
|
|
@ -2383,9 +2402,15 @@ impl DeploymentTransaction {
|
|||
/// Sealing, Starting (the flip, behind the brake) and Complete.
|
||||
fn validate_static(&self) -> Result<()> {
|
||||
ensure!(
|
||||
self.command_kind == CommandKind::Deploy,
|
||||
self.command_kind != CommandKind::Continuity,
|
||||
"a static release has no continuity"
|
||||
);
|
||||
// A deploy sealed its own source; a rollback reuses a release already
|
||||
// sealed and installed, so it never froze one.
|
||||
ensure!(
|
||||
self.command_kind != CommandKind::Rollback || self.frozen_source.is_none(),
|
||||
"a rollback never freezes a source"
|
||||
);
|
||||
ensure!(
|
||||
self.expected.is_none()
|
||||
&& self.expected_publication_sha256.is_none()
|
||||
|
|
@ -2417,7 +2442,7 @@ impl DeploymentTransaction {
|
|||
self.plan.is_some()
|
||||
&& self.sealed_release.is_some()
|
||||
&& self.installed_release.is_some()
|
||||
&& self.frozen_source.is_some()
|
||||
&& (self.command_kind == CommandKind::Rollback || self.frozen_source.is_some())
|
||||
&& self.deployment_authorization.is_some(),
|
||||
"static release entered Starting without sealed release evidence and brake consumption"
|
||||
);
|
||||
|
|
@ -2687,7 +2712,7 @@ impl DeploymentTransaction {
|
|||
(
|
||||
CommandKind::Deploy,
|
||||
CleanupEvidence::Pending | CleanupEvidence::Complete
|
||||
) | (CommandKind::Continuity, CleanupEvidence::Skipped)
|
||||
) | (CommandKind::Continuity | CommandKind::Rollback, CleanupEvidence::Skipped)
|
||||
) || (self.command_kind == CommandKind::Deploy
|
||||
&& abort.source_cleanup == CleanupEvidence::Skipped
|
||||
&& self.plan.is_none()
|
||||
|
|
@ -2796,6 +2821,7 @@ impl DeploymentTransaction {
|
|||
self.frozen_source.is_some() && self.deployment_authorization.is_some(),
|
||||
"deployment entered Starting without frozen source and brake consumption"
|
||||
),
|
||||
CommandKind::Rollback => bail!("a rollback is a static release transaction"),
|
||||
CommandKind::Continuity => ensure!(
|
||||
self.lifecycle_authorized_at_unix_millis.is_some()
|
||||
&& self.deployment_authorization.is_none(),
|
||||
|
|
@ -2909,7 +2935,7 @@ impl DeploymentTransaction {
|
|||
(CommandKind::Deploy, SourceCleanupEvidence::Pending)
|
||||
| (CommandKind::Deploy, SourceCleanupEvidence::Complete)
|
||||
| (
|
||||
CommandKind::Continuity,
|
||||
CommandKind::Continuity | CommandKind::Rollback,
|
||||
SourceCleanupEvidence::SkippedContinuity
|
||||
)
|
||||
),
|
||||
|
|
@ -3223,6 +3249,7 @@ impl Default for RuntimeOptions {
|
|||
enum Command {
|
||||
Serve(RuntimeOptions),
|
||||
Up {
|
||||
kind: CommandKind,
|
||||
selector: String,
|
||||
requested_by: String,
|
||||
state_store: PathBuf,
|
||||
|
|
@ -3258,6 +3285,7 @@ pub fn run(args: impl Iterator<Item = String>) -> Result<()> {
|
|||
match parse(args)? {
|
||||
Command::Serve(options) => serve(options),
|
||||
Command::Up {
|
||||
kind,
|
||||
selector,
|
||||
requested_by,
|
||||
state_store,
|
||||
|
|
@ -3265,6 +3293,7 @@ pub fn run(args: impl Iterator<Item = String>) -> Result<()> {
|
|||
timeout_seconds,
|
||||
} => submit(
|
||||
&state_store,
|
||||
kind,
|
||||
&selector,
|
||||
&requested_by,
|
||||
wait,
|
||||
|
|
@ -3342,8 +3371,9 @@ fn parse(args: impl Iterator<Item = String>) -> Result<Command> {
|
|||
let command = args.next().ok_or_else(|| anyhow!(usage()))?;
|
||||
match command.as_str() {
|
||||
"serve" => parse_serve(args),
|
||||
"up" => parse_up(args),
|
||||
"up" => parse_up(args, CommandKind::Deploy),
|
||||
"status" => parse_status(args),
|
||||
"rollback" => parse_up(args, CommandKind::Rollback),
|
||||
"cancel" => parse_cancel(args),
|
||||
"expire" => parse_expire(args),
|
||||
"validate" => parse_validate(args),
|
||||
|
|
@ -3417,11 +3447,17 @@ fn parse_serve(mut args: impl Iterator<Item = String>) -> Result<Command> {
|
|||
Ok(Command::Serve(options))
|
||||
}
|
||||
|
||||
fn parse_up(mut args: impl Iterator<Item = String>) -> Result<Command> {
|
||||
/// `idunn up <selector>` and `idunn rollback <target>`: both only write a command.
|
||||
fn parse_up(mut args: impl Iterator<Item = String>, kind: CommandKind) -> Result<Command> {
|
||||
let verb = if kind == CommandKind::Rollback { "rollback" } else { "up" };
|
||||
let selector = args
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("idunn up requires a service or profile selector"))?;
|
||||
.ok_or_else(|| anyhow!("idunn {verb} requires a service{} selector", if kind == CommandKind::Rollback { "" } else { " or profile" }))?;
|
||||
require_selector(&selector)?;
|
||||
ensure!(
|
||||
kind != CommandKind::Rollback || !selector.starts_with("profile:"),
|
||||
"a rollback names one target, not a profile"
|
||||
);
|
||||
let mut state_store = RuntimeOptions::default().state_store;
|
||||
let mut requested_by = env::var("SUDO_USER")
|
||||
.or_else(|_| env::var("USER"))
|
||||
|
|
@ -3436,12 +3472,13 @@ fn parse_up(mut args: impl Iterator<Item = String>) -> Result<Command> {
|
|||
"--no-wait" => wait = false,
|
||||
"--timeout-seconds" => timeout_seconds = u64_value(&mut args, &argument)?,
|
||||
"--help" | "-h" => bail!(usage()),
|
||||
_ => bail!("unknown Idunn up option {argument:?}"),
|
||||
_ => bail!("unknown Idunn {verb} option {argument:?}"),
|
||||
}
|
||||
}
|
||||
ensure!(timeout_seconds > 0, "--timeout-seconds must be positive");
|
||||
require_value(&requested_by, "deployment requester")?;
|
||||
Ok(Command::Up {
|
||||
kind,
|
||||
selector,
|
||||
requested_by,
|
||||
state_store,
|
||||
|
|
@ -3818,7 +3855,7 @@ impl ControlSnapshot {
|
|||
"multiple transactions own the incumbent slot of one target"
|
||||
);
|
||||
let (seen, kind) = match transaction.value.command_kind {
|
||||
CommandKind::Deploy => (&mut live_deploys, "deployments"),
|
||||
CommandKind::Deploy | CommandKind::Rollback => (&mut live_deploys, "deployments"),
|
||||
CommandKind::Continuity => (&mut live_continuities, "continuity transactions"),
|
||||
};
|
||||
ensure!(seen.insert(target), "multiple live {kind} for one target");
|
||||
|
|
@ -5078,6 +5115,7 @@ fn resolve_selector(
|
|||
/// deploy order both write theirs here. Returns the commit time.
|
||||
pub(crate) fn commit_deployment_command(
|
||||
store_path: &Path,
|
||||
kind: CommandKind,
|
||||
command_id: &str,
|
||||
selector: &str,
|
||||
requested_by: &str,
|
||||
|
|
@ -5089,7 +5127,7 @@ pub(crate) fn commit_deployment_command(
|
|||
let command = DeploymentCommand {
|
||||
schema_version: DEPLOYMENT_COMMAND_SCHEMA.into(),
|
||||
command_id: command_id.into(),
|
||||
kind: CommandKind::Deploy,
|
||||
kind,
|
||||
selector: selector.into(),
|
||||
requested_by: requested_by.into(),
|
||||
requested_at_unix_millis: now,
|
||||
|
|
@ -5125,13 +5163,15 @@ pub(crate) fn command_state(
|
|||
|
||||
fn submit(
|
||||
store_path: &Path,
|
||||
kind: CommandKind,
|
||||
selector: &str,
|
||||
requested_by: &str,
|
||||
wait: bool,
|
||||
timeout_seconds: u64,
|
||||
) -> Result<()> {
|
||||
let command_id = format!("up-{}", Uuid::new_v4());
|
||||
let now = commit_deployment_command(store_path, &command_id, selector, requested_by)?;
|
||||
let prefix = if kind == CommandKind::Rollback { "rollback" } else { "up" };
|
||||
let command_id = format!("{prefix}-{}", Uuid::new_v4());
|
||||
let now = commit_deployment_command(store_path, kind, &command_id, selector, requested_by)?;
|
||||
println!("{command_id}");
|
||||
if !wait {
|
||||
return Ok(());
|
||||
|
|
@ -5548,7 +5588,7 @@ struct EngineRouteGate<'a> {
|
|||
impl RouteActuationGate for EngineRouteGate<'_> {
|
||||
fn admit(&self, requested: RouteActuation) -> Result<()> {
|
||||
let kind = match self.command {
|
||||
CommandKind::Deploy => requested,
|
||||
CommandKind::Deploy | CommandKind::Rollback => requested,
|
||||
CommandKind::Continuity => RouteActuation::Survival,
|
||||
};
|
||||
self.engine.charge_route_actuation(self.target, kind)
|
||||
|
|
@ -6827,7 +6867,7 @@ impl Engine {
|
|||
.commands
|
||||
.iter()
|
||||
.filter(|stored| {
|
||||
stored.value.kind == CommandKind::Deploy
|
||||
stored.value.kind != CommandKind::Continuity
|
||||
&& !live_commands.contains(&stored.value.command_id)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
|
@ -6897,6 +6937,9 @@ impl Engine {
|
|||
snapshot: &ControlSnapshot,
|
||||
command: &Stored<DeploymentCommand>,
|
||||
) -> Result<bool> {
|
||||
if command.value.kind == CommandKind::Rollback {
|
||||
return self.freeze_rollback(snapshot, command);
|
||||
}
|
||||
let bindings = match load_bindings(&self.options.bindings_dir) {
|
||||
Ok(bindings) => bindings,
|
||||
Err(error) => {
|
||||
|
|
@ -6931,7 +6974,7 @@ impl Engine {
|
|||
.transactions
|
||||
.iter()
|
||||
.filter(|stored| {
|
||||
stored.value.command_kind == CommandKind::Deploy && stored.value.is_live()
|
||||
stored.value.command_kind != CommandKind::Continuity && stored.value.is_live()
|
||||
})
|
||||
.map(|stored| stored.value.target.as_str())
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
|
@ -6972,6 +7015,106 @@ impl Engine {
|
|||
Ok(true)
|
||||
}
|
||||
|
||||
/// Freeze a rollback command into its one transaction, or record why it
|
||||
/// cannot be one. The previous release is derived from what was flipped
|
||||
/// (control.cc and history.cc) and the link as observed, never stored.
|
||||
fn freeze_rollback(
|
||||
&self,
|
||||
snapshot: &ControlSnapshot,
|
||||
command: &Stored<DeploymentCommand>,
|
||||
) -> Result<bool> {
|
||||
let target = command.value.selector.as_str();
|
||||
if snapshot
|
||||
.on_target(target)
|
||||
.any(|stored| stored.value.command_kind != CommandKind::Continuity && stored.value.is_live())
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(history) = self.history_for_decision() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let now = now_millis()?;
|
||||
let transaction = match self.rollback_source(snapshot, &history, target) {
|
||||
Ok(prior) => DeploymentTransaction::from_rollback(&command.value, &prior, now)?,
|
||||
Err(error) => DeploymentTransaction::rejected(&command.value, error, now)?,
|
||||
};
|
||||
ensure!(
|
||||
SingleFileMessagePackBackingStore::new(&self.options.state_store).compare_exchange(
|
||||
&[CultCacheExpectedEnvelope {
|
||||
r#type: DeploymentTransaction::TYPE.into(),
|
||||
key: transaction.transaction_id.clone(),
|
||||
current: None,
|
||||
}],
|
||||
&[transaction_envelope(&transaction, now)?],
|
||||
)?,
|
||||
"rollback command lost its transaction creation CAS"
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The transaction that flipped the release before the newest one, when the
|
||||
/// link still names the newest and that previous release is intact.
|
||||
fn rollback_source(
|
||||
&self,
|
||||
snapshot: &ControlSnapshot,
|
||||
history: &[DeploymentTransaction],
|
||||
target: &str,
|
||||
) -> Result<DeploymentTransaction> {
|
||||
let mut flipped = snapshot
|
||||
.transactions
|
||||
.iter()
|
||||
.map(|stored| &stored.value)
|
||||
.chain(history)
|
||||
.filter(|transaction| {
|
||||
transaction.target == target
|
||||
&& matches!(transaction.completion, Some(TransactionCompletion::Admitted { .. }))
|
||||
&& transaction.flip.as_ref().is_some_and(FlipEvidence::landed)
|
||||
&& transaction.is_static()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
flipped.sort_by_key(|transaction| {
|
||||
(
|
||||
transaction.flip.as_ref().and_then(|flip| flip.flipped_at_unix_millis),
|
||||
transaction.created_at_unix_millis,
|
||||
)
|
||||
});
|
||||
let newest = flipped
|
||||
.last()
|
||||
.with_context(|| format!("target {target} has no completed static release to roll back from"))?;
|
||||
let flip = newest.flip.as_ref().expect("filtered to landed flips");
|
||||
let previous = flip.previous_release_id.clone().with_context(|| {
|
||||
format!("no previous release: {} is the first release {target} was flipped to", flip.release_id)
|
||||
})?;
|
||||
let binding = required(&newest.plan, "newest release plan")?.parsed_inputs()?.1;
|
||||
let workload = binding.workload.static_release()?;
|
||||
let observed = self.static_release.observe_current(workload)?;
|
||||
ensure!(
|
||||
observed.as_deref() == Some(flip.release_id.as_str()),
|
||||
"current names {} but the newest flip was to {}; rollback does not guess",
|
||||
observed.as_deref().unwrap_or("nothing"),
|
||||
flip.release_id
|
||||
);
|
||||
let prior = flipped
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|transaction| transaction.flip.as_ref().is_some_and(|f| f.release_id == previous))
|
||||
.with_context(|| format!("no record of a transaction that flipped the previous release {previous}"))?;
|
||||
// The prior plan is reused verbatim, but the link was observed through
|
||||
// the newest plan's binding: they must be the same release root and link.
|
||||
let prior_binding = required(&prior.plan, "previous release plan")?.parsed_inputs()?.1;
|
||||
let prior_workload = prior_binding.workload.static_release()?;
|
||||
ensure!(
|
||||
prior_workload.release_root == workload.release_root
|
||||
&& prior_workload.current_link == workload.current_link,
|
||||
"the previous release was deployed under a different release root or current link than the newest; rollback does not cross bindings"
|
||||
);
|
||||
self.static_release.verify_installed(
|
||||
workload,
|
||||
required(&prior.sealed_release, "previous sealed release")?,
|
||||
)?;
|
||||
Ok((*prior).clone())
|
||||
}
|
||||
|
||||
fn supervise_one_admitted_generation(&self) -> Result<bool> {
|
||||
let snapshot = ControlSnapshot::read(&self.options.state_store)?;
|
||||
// A wait belongs to the failed record it spaces. Once any write for its
|
||||
|
|
@ -7782,7 +7925,7 @@ impl Engine {
|
|||
let now = now_millis()?;
|
||||
let mut next = current.value.clone();
|
||||
match current.value.command_kind {
|
||||
CommandKind::Deploy => {
|
||||
CommandKind::Deploy | CommandKind::Rollback => {
|
||||
let Some(authorization) = self.deployment_authorization(current, now)? else {
|
||||
return self.record_gate_wait(
|
||||
current,
|
||||
|
|
@ -7916,6 +8059,9 @@ impl Engine {
|
|||
let workload = binding.workload.static_release()?;
|
||||
let now = now_millis()?;
|
||||
let Some(flip) = ¤t.value.flip else {
|
||||
// Nothing has moved yet, so a refusal here ends the transaction
|
||||
// honestly: the release is still what was sealed, or it is not.
|
||||
self.static_release.verify_installed(workload, release)?;
|
||||
let previous_release_id = self.static_release.observe_current(workload)?;
|
||||
let release_id = release.sealed_release_id.clone();
|
||||
return self.persist_same_phase(current, |next| {
|
||||
|
|
@ -7966,7 +8112,10 @@ impl Engine {
|
|||
});
|
||||
complete.post_commit_cleanup = Some(PostCommitCleanup {
|
||||
incumbent: IncumbentCleanupEvidence::SkippedNoIncumbent,
|
||||
source: SourceCleanupEvidence::Pending,
|
||||
source: match current.value.command_kind {
|
||||
CommandKind::Rollback => SourceCleanupEvidence::SkippedContinuity,
|
||||
_ => SourceCleanupEvidence::Pending,
|
||||
},
|
||||
});
|
||||
complete.retime(now);
|
||||
complete.validate()?;
|
||||
|
|
@ -8807,7 +8956,7 @@ impl Engine {
|
|||
},
|
||||
source: match commit_current.value.command_kind {
|
||||
CommandKind::Deploy => SourceCleanupEvidence::Pending,
|
||||
CommandKind::Continuity => SourceCleanupEvidence::SkippedContinuity,
|
||||
CommandKind::Continuity | CommandKind::Rollback => SourceCleanupEvidence::SkippedContinuity,
|
||||
},
|
||||
};
|
||||
let mut complete = commit_current.value.clone();
|
||||
|
|
@ -10127,7 +10276,7 @@ impl Engine {
|
|||
fn settle_before_handover(&self, current: &Stored<DeploymentTransaction>) -> Result<bool> {
|
||||
let held = ¤t.value;
|
||||
match held.command_kind {
|
||||
CommandKind::Deploy => {
|
||||
CommandKind::Deploy | CommandKind::Rollback => {
|
||||
if self.rebase_onto_admitted(current)? {
|
||||
return Ok(true);
|
||||
}
|
||||
|
|
@ -10188,11 +10337,11 @@ impl Engine {
|
|||
// Lost to a concurrent writer: the next step reads the new world.
|
||||
(Handover::Took | Handover::Lost, _) => Ok(()),
|
||||
// Wait, never abort: a pre-fencing error would end the deployment.
|
||||
(Handover::OwnedBy(owner), CommandKind::Deploy) => {
|
||||
(Handover::OwnedBy(owner), CommandKind::Deploy | CommandKind::Rollback) => {
|
||||
self.wait_for_owner(current, &owner)
|
||||
}
|
||||
// The next step rebases onto the generation now admitted.
|
||||
(Handover::Moved, CommandKind::Deploy) => Ok(()),
|
||||
(Handover::Moved, CommandKind::Deploy | CommandKind::Rollback) => Ok(()),
|
||||
(Handover::OwnedBy(owner), CommandKind::Continuity) => {
|
||||
bail!("continuity superseded: target {target} is owned by {owner}")
|
||||
}
|
||||
|
|
@ -10828,7 +10977,7 @@ fn reconcile_failed_candidate_projection(
|
|||
) -> Result<()> {
|
||||
let expected = required(&transaction.expected, "failed Expected projection")?;
|
||||
match transaction.command_kind {
|
||||
CommandKind::Deploy => topology
|
||||
CommandKind::Deploy | CommandKind::Rollback => topology
|
||||
.withdraw_incarnation(
|
||||
expected,
|
||||
provider_anchor,
|
||||
|
|
@ -10932,7 +11081,7 @@ where
|
|||
F: FnOnce() -> Result<()>,
|
||||
{
|
||||
match command_kind {
|
||||
CommandKind::Deploy => validation(),
|
||||
CommandKind::Deploy | CommandKind::Rollback => validation(),
|
||||
CommandKind::Continuity => Ok(()),
|
||||
}
|
||||
}
|
||||
|
|
@ -11245,6 +11394,7 @@ fn usage() -> &'static str {
|
|||
idunn serve [runtime options] [--host-actuator-bind ADDR]\n\
|
||||
idunn up <service|profile:name> [--state-store PATH] [--no-wait]\n\
|
||||
idunn status [--state-store PATH] [--incident-store PATH] [--command ID]\n\
|
||||
idunn rollback <static-target> [--state-store PATH] [--no-wait]\n\
|
||||
idunn cancel <command-id> [--state-store PATH]\n\
|
||||
idunn expire <command-id> [--state-store PATH]\n\
|
||||
idunn validate --recipe PATH [--binding PATH]\n\
|
||||
|
|
@ -11302,6 +11452,7 @@ mod tests {
|
|||
command_id: match kind {
|
||||
CommandKind::Deploy => "up-test".into(),
|
||||
CommandKind::Continuity => "continuity-test".into(),
|
||||
CommandKind::Rollback => "rollback-test".into(),
|
||||
},
|
||||
kind,
|
||||
selector: "ghostlight".into(),
|
||||
|
|
@ -12491,14 +12642,14 @@ mod tests {
|
|||
fn deployment_commit_primitive_is_shared_and_never_overwrites() -> Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
let store = temp.path().join("control.cc");
|
||||
commit_deployment_command(&store, "up-one", "vili", "steward:eureka order ord-1")?;
|
||||
commit_deployment_command(&store, CommandKind::Deploy, "up-one", "vili", "steward:eureka order ord-1")?;
|
||||
let snapshot = ControlSnapshot::read(&store)?;
|
||||
assert_eq!(snapshot.commands.len(), 1);
|
||||
let command = &snapshot.commands[0].value;
|
||||
assert_eq!(command.command_id, "up-one");
|
||||
assert_eq!(command.selector, "vili");
|
||||
assert_eq!(command.requested_by, "steward:eureka order ord-1");
|
||||
assert!(commit_deployment_command(&store, "up-one", "other", "x").is_err());
|
||||
assert!(commit_deployment_command(&store, CommandKind::Deploy, "up-one", "other", "x").is_err());
|
||||
assert_eq!(ControlSnapshot::read(&store)?.commands.len(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -14036,6 +14187,7 @@ mod tests {
|
|||
match kind {
|
||||
CommandKind::Deploy => "up",
|
||||
CommandKind::Continuity => "continuity",
|
||||
CommandKind::Rollback => "rollback",
|
||||
},
|
||||
if incumbent.is_some() { "-again" } else { "" }
|
||||
);
|
||||
|
|
@ -14074,6 +14226,7 @@ mod tests {
|
|||
CommandKind::Continuity => {
|
||||
transaction.lifecycle_authorized_at_unix_millis = Some(now);
|
||||
}
|
||||
CommandKind::Rollback => unreachable!("service fixtures have no rollback"),
|
||||
CommandKind::Deploy => {
|
||||
transaction.frozen_source = Some(FrozenSourceReceipt {
|
||||
transaction_id: transaction.transaction_id.clone(),
|
||||
|
|
@ -21845,6 +21998,7 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide
|
|||
command_id: match kind {
|
||||
CommandKind::Deploy => "up-service",
|
||||
CommandKind::Continuity => "continuity-service",
|
||||
CommandKind::Rollback => "rollback-service",
|
||||
}
|
||||
.into(),
|
||||
kind,
|
||||
|
|
@ -21858,6 +22012,7 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide
|
|||
CommandKind::Continuity => {
|
||||
transaction.lifecycle_authorized_at_unix_millis = Some(now);
|
||||
}
|
||||
CommandKind::Rollback => unreachable!("service fixtures have no rollback"),
|
||||
CommandKind::Deploy => {
|
||||
transaction.frozen_source = Some(FrozenSourceReceipt {
|
||||
transaction_id: transaction.transaction_id.clone(),
|
||||
|
|
@ -25930,13 +26085,79 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide
|
|||
|
||||
/// The Deploy command's transaction, by command id.
|
||||
fn transaction_of(&self, command_id: &str) -> Result<String> {
|
||||
Ok(ControlSnapshot::read(&self.world.state_store)?
|
||||
let live = ControlSnapshot::read(&self.world.state_store)?
|
||||
.transactions
|
||||
.into_iter()
|
||||
.find(|stored| stored.value.command_id == command_id)
|
||||
.context("no transaction for the command")?
|
||||
.value
|
||||
.transaction_id)
|
||||
.map(|stored| stored.value);
|
||||
live.chain(read_history_transactions(&self.world.state_store))
|
||||
.find(|transaction| transaction.command_id == command_id)
|
||||
.map(|transaction| transaction.transaction_id)
|
||||
.context("no transaction for the command")
|
||||
}
|
||||
|
||||
/// A full deploy through the brake, on the stand-in site.
|
||||
fn deploy(&self, command_id: &str) -> Result<DeploymentTransaction> {
|
||||
if command_id != "up-service" {
|
||||
enqueue_deploy(&self.world, command_id)?;
|
||||
}
|
||||
let id = self.transaction_of(command_id)?;
|
||||
let waiting = self.run_to_brake(&id)?;
|
||||
self.release_brake(&waiting)?;
|
||||
self.tick_until(&id, "the deploy end", |t| t.is_terminal())?;
|
||||
latest_id(&self.world, &id)
|
||||
}
|
||||
|
||||
/// Submit a rollback command and tick until the daemon freezes it.
|
||||
fn submit_rollback(&self, command_id: &str) -> Result<String> {
|
||||
commit_deployment_command(
|
||||
&self.world.state_store,
|
||||
CommandKind::Rollback,
|
||||
command_id,
|
||||
"service",
|
||||
"operator",
|
||||
)?;
|
||||
for _ in 0..24 {
|
||||
self.world.engine.resume_backoff.lock().unwrap().clear();
|
||||
let _ = self.world.engine.run_scheduler_tick();
|
||||
if let Ok(id) = self.transaction_of(command_id) {
|
||||
return Ok(id);
|
||||
}
|
||||
}
|
||||
bail!("the rollback command was never frozen")
|
||||
}
|
||||
|
||||
/// Roll back through the brake to the end.
|
||||
fn rollback(&self, command_id: &str) -> Result<DeploymentTransaction> {
|
||||
let id = self.submit_rollback(command_id)?;
|
||||
let waiting = self.run_to_brake(&id)?;
|
||||
self.release_brake(&waiting)?;
|
||||
self.tick_until(&id, "the rollback end", |t| t.is_terminal())?;
|
||||
latest_id(&self.world, &id)
|
||||
}
|
||||
|
||||
fn release_dir(&self, release_id: &str) -> PathBuf {
|
||||
self.site.join("releases").join(release_id)
|
||||
}
|
||||
|
||||
/// Two deploys (config one, then two): current is the second release.
|
||||
fn two_deploys(&self) -> Result<(String, String)> {
|
||||
let first = self.deploy("up-service")?.flip.context("no first flip")?.release_id;
|
||||
std::fs::write(self.site_config(), SITE_CONFIG_TWO)?;
|
||||
let second = self.deploy("up-service-2")?.flip.context("no second flip")?.release_id;
|
||||
assert_eq!(self.current_release().as_deref(), Some(second.as_str()));
|
||||
Ok((first, second))
|
||||
}
|
||||
|
||||
/// The terminal rejection a refused rollback is recorded as.
|
||||
fn refusal_of(&self, command_id: &str) -> Result<String> {
|
||||
let id = self.submit_rollback(command_id)?;
|
||||
self.tick_until(&id, "the refusal", |t| t.is_terminal())?;
|
||||
let refused = latest_id(&self.world, &id)?;
|
||||
let Some(TransactionCompletion::FailedBeforeFencing { error }) = refused.completion else {
|
||||
bail!("the rollback was not refused: {refused:?}");
|
||||
};
|
||||
assert!(refused.plan.is_none() && refused.flip.is_none());
|
||||
Ok(error)
|
||||
}
|
||||
|
||||
/// The operator releases the brake for this exact transaction.
|
||||
|
|
@ -26215,6 +26436,14 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide
|
|||
) -> Result<String> {
|
||||
self.inner.carried_sha256(binding, release_id)
|
||||
}
|
||||
|
||||
fn verify_installed(
|
||||
&self,
|
||||
binding: &crate::deployment::StaticReleaseBinding,
|
||||
release: &SealedRelease,
|
||||
) -> Result<()> {
|
||||
self.inner.verify_installed(binding, release)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the first Deploy through the brake to its end.
|
||||
|
|
@ -26351,5 +26580,218 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide
|
|||
assert!(format!("{refused:#}").contains("can only be cancelled"), "{refused:#}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const SITE_CONFIG_THREE: &[u8] = br#"{"api":"https://three.test"}"#;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn static_rollback_flips_back_as_a_new_brake_gated_transaction() -> Result<()> {
|
||||
let w = static_world()?;
|
||||
let (first, second) = w.two_deploys()?;
|
||||
let second_carry = std::fs::read(w.release_dir(&second).join("config.json"))?;
|
||||
std::fs::write(w.site_config(), SITE_CONFIG_THREE)?;
|
||||
|
||||
let id = w.submit_rollback("rollback-1")?;
|
||||
let waiting = w.run_to_brake(&id)?;
|
||||
// At the brake: a new transaction of its own, nothing moved, nothing frozen.
|
||||
assert_eq!(waiting.command_kind, CommandKind::Rollback);
|
||||
assert!(waiting.frozen_source.is_none() && waiting.expected.is_none() && waiting.flip.is_none());
|
||||
assert_eq!(waiting.sealed_release.as_ref().map(|r| r.sealed_release_id.as_str()), Some(first.as_str()));
|
||||
assert_eq!(w.current_release().as_deref(), Some(second.as_str()), "a braked rollback moved current");
|
||||
assert_eq!(std::fs::read(w.release_dir(&second).join("config.json"))?, second_carry);
|
||||
let printed = {
|
||||
let mut out = Vec::new();
|
||||
status(&mut out, &w.world.state_store, &w.world.engine.options.incident_store, Some("rollback-1"))?;
|
||||
String::from_utf8(out)?
|
||||
};
|
||||
assert!(printed.contains(&format!("release {first}")), "{printed}");
|
||||
|
||||
w.release_brake(&waiting)?;
|
||||
w.tick_until(&id, "the rollback end", |t| t.is_terminal())?;
|
||||
let done = latest_id(&w.world, &id)?;
|
||||
assert!(matches!(done.completion, Some(TransactionCompletion::Admitted { .. })), "{done:?}");
|
||||
let flip = done.flip.as_ref().context("no flip")?;
|
||||
assert_eq!((flip.release_id.as_str(), flip.previous_release_id.as_deref()), (first.as_str(), Some(second.as_str())));
|
||||
// The carry is re-applied from the site file as it is now.
|
||||
assert_eq!(flip.carried_sha256.as_deref(), Some(sha256_of(SITE_CONFIG_THREE).as_str()));
|
||||
assert_eq!(w.current_release().as_deref(), Some(first.as_str()));
|
||||
assert_eq!(std::fs::read(w.current_link().join("config.json"))?, SITE_CONFIG_THREE);
|
||||
assert!(done.deployment_authorization.is_some(), "the brake was not consumed");
|
||||
// Negatives: no source, no source cleanup, no generation, no pruning.
|
||||
assert!(done.frozen_source.is_none());
|
||||
assert_eq!(
|
||||
done.post_commit_cleanup.as_ref().map(|c| c.source),
|
||||
Some(SourceCleanupEvidence::SkippedContinuity)
|
||||
);
|
||||
assert!(ControlSnapshot::read(&w.world.state_store)?.admitted_for("service").is_none());
|
||||
assert!(w.release_dir(&first).is_dir() && w.release_dir(&second).is_dir());
|
||||
assert_eq!(w.world.route_stubs.count(""), 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn static_rollback_of_a_rollback_flips_back() -> Result<()> {
|
||||
let w = static_world()?;
|
||||
let (first, second) = w.two_deploys()?;
|
||||
let one = w.rollback("rollback-1")?;
|
||||
assert_eq!(w.current_release().as_deref(), Some(first.as_str()));
|
||||
assert!(matches!(one.completion, Some(TransactionCompletion::Admitted { .. })));
|
||||
let two = w.rollback("rollback-2")?;
|
||||
let flip = two.flip.as_ref().context("no flip")?;
|
||||
assert_eq!((flip.release_id.as_str(), flip.previous_release_id.as_deref()), (second.as_str(), Some(first.as_str())));
|
||||
assert_eq!(w.current_release().as_deref(), Some(second.as_str()));
|
||||
// A third goes back again: the latest flip always names what it displaced.
|
||||
w.rollback("rollback-3")?;
|
||||
assert_eq!(w.current_release().as_deref(), Some(first.as_str()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn static_rollback_with_no_previous_release_is_a_typed_refusal() -> Result<()> {
|
||||
let w = static_world()?;
|
||||
let first = w.deploy("up-service")?.flip.context("no flip")?.release_id;
|
||||
let error = w.refusal_of("rollback-1")?;
|
||||
assert!(error.contains("no previous release"), "{error}");
|
||||
assert_eq!(w.current_release().as_deref(), Some(first.as_str()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn static_rollback_refuses_when_current_is_not_the_newest_flip() -> Result<()> {
|
||||
let w = static_world()?;
|
||||
let (first, second) = w.two_deploys()?;
|
||||
// The operator pointed current at the older release by hand.
|
||||
std::fs::remove_file(w.current_link())?;
|
||||
std::os::unix::fs::symlink(w.release_dir(&first), w.current_link())?;
|
||||
let error = w.refusal_of("rollback-1")?;
|
||||
assert!(error.contains("rollback does not guess") && error.contains(&second), "{error}");
|
||||
assert_eq!(w.current_release().as_deref(), Some(first.as_str()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn static_rollback_refuses_a_previous_release_that_is_gone_or_drifted() -> Result<()> {
|
||||
let w = static_world()?;
|
||||
let (first, second) = w.two_deploys()?;
|
||||
// Drift: a file in the previous release changed after it was sealed.
|
||||
let page = w.release_dir(&first).join("dist/index.html");
|
||||
std::fs::write(&page, b"tampered")?;
|
||||
let error = w.refusal_of("rollback-1")?;
|
||||
assert!(error.contains("differs from its sealed receipt"), "{error}");
|
||||
// Gone: the directory was removed.
|
||||
std::fs::remove_dir_all(w.release_dir(&first))?;
|
||||
let error = w.refusal_of("rollback-2")?;
|
||||
assert!(error.contains("no longer installed"), "{error}");
|
||||
assert_eq!(w.current_release().as_deref(), Some(second.as_str()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn static_rollback_with_a_missing_site_file_fails_before_the_link_moves() -> Result<()> {
|
||||
let w = static_world()?;
|
||||
let (first, second) = w.two_deploys()?;
|
||||
let id = w.submit_rollback("rollback-1")?;
|
||||
let waiting = w.run_to_brake(&id)?;
|
||||
// The operator moved the site file (the plan's carry source is the old one).
|
||||
std::fs::remove_file(w.site_config())?;
|
||||
w.release_brake(&waiting)?;
|
||||
w.tick_until(&id, "the rollback end", |t| t.is_terminal())?;
|
||||
let ended = latest_id(&w.world, &id)?;
|
||||
let Some(TransactionCompletion::FailedBeforeFencing { error }) = &ended.completion else {
|
||||
bail!("the rollback did not fail before fencing: {ended:?}");
|
||||
};
|
||||
assert!(error.contains("carry source"), "{error}");
|
||||
assert_eq!(w.current_release().as_deref(), Some(second.as_str()), "a failed rollback moved current");
|
||||
assert!(!ended.flip.as_ref().is_some_and(FlipEvidence::landed));
|
||||
assert!(w.release_dir(&first).is_dir());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn static_rollback_error_after_the_rename_still_completes() -> Result<()> {
|
||||
let mut w = static_world()?;
|
||||
let port = FaultyPort::new(false, 0);
|
||||
w.world.engine.static_release = port.clone();
|
||||
let (first, _) = w.two_deploys()?;
|
||||
port.flip_fault_after_rename.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
let done = w.rollback("rollback-1")?;
|
||||
assert!(matches!(done.completion, Some(TransactionCompletion::Admitted { .. })), "{done:?}");
|
||||
assert!(done.pre_fencing_abort.is_none());
|
||||
assert_eq!(w.current_release().as_deref(), Some(first.as_str()));
|
||||
assert!(done.flip.as_ref().is_some_and(FlipEvidence::landed));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rollback_reads_one_target_and_refuses_a_profile() -> Result<()> {
|
||||
let parsed = parse(["rollback", "site", "--no-wait", "--requested-by", "me"].into_iter().map(str::to_owned))?;
|
||||
let Command::Up { kind, selector, requested_by, wait, .. } = parsed else {
|
||||
bail!("expected a rollback command");
|
||||
};
|
||||
assert_eq!((kind, selector.as_str(), requested_by.as_str(), wait), (CommandKind::Rollback, "site", "me", false));
|
||||
assert!(parse(["rollback", "profile:all"].into_iter().map(str::to_owned)).is_err());
|
||||
assert!(parse(["rollback"].into_iter().map(str::to_owned)).is_err());
|
||||
assert!(usage().contains("idunn rollback <static-target>"));
|
||||
let command = |id: &str, kind| DeploymentCommand {
|
||||
schema_version: DEPLOYMENT_COMMAND_SCHEMA.into(),
|
||||
command_id: id.into(),
|
||||
kind,
|
||||
selector: "site".into(),
|
||||
requested_by: "me".into(),
|
||||
requested_at_unix_millis: 100,
|
||||
};
|
||||
command("rollback-1", CommandKind::Rollback).validate()?;
|
||||
assert!(command("up-1", CommandKind::Rollback).validate().is_err());
|
||||
assert!(command("rollback-1", CommandKind::Deploy).validate().is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn static_rollback_drift_after_freezing_ends_before_fencing_with_current_untouched() -> Result<()> {
|
||||
let w = static_world()?;
|
||||
let (first, second) = w.two_deploys()?;
|
||||
let id = w.submit_rollback("rollback-1")?;
|
||||
let waiting = w.run_to_brake(&id)?;
|
||||
// Frozen and intact; now the previous release drifts before Starting.
|
||||
std::fs::write(w.release_dir(&first).join("dist/index.html"), b"tampered")?;
|
||||
w.release_brake(&waiting)?;
|
||||
w.tick_until(&id, "the rollback end", |t| t.is_terminal())?;
|
||||
let ended = latest_id(&w.world, &id)?;
|
||||
let Some(TransactionCompletion::FailedBeforeFencing { error }) = &ended.completion else {
|
||||
bail!("the drifted rollback did not fail before fencing: {ended:?}");
|
||||
};
|
||||
assert!(error.contains("differs from its sealed receipt"), "{error}");
|
||||
assert!(ended.flip.is_none());
|
||||
assert_eq!(w.current_release().as_deref(), Some(second.as_str()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn static_rollback_refuses_a_previous_release_deployed_under_another_binding() -> Result<()> {
|
||||
let w = static_world()?;
|
||||
w.two_deploys()?;
|
||||
let snapshot = ControlSnapshot::read(&w.world.state_store)?;
|
||||
let mut history = read_history_transactions(&w.world.state_store);
|
||||
// The older transaction's plan names another release root.
|
||||
let older = history
|
||||
.iter_mut()
|
||||
.min_by_key(|t| t.created_at_unix_millis)
|
||||
.context("no history")?;
|
||||
let plan = older.plan.as_mut().context("no plan")?;
|
||||
let text = String::from_utf8(plan.binding_blob.clone())?;
|
||||
assert!(text.contains("/releases\""));
|
||||
plan.binding_blob = text.replace("/releases\"", "/releases-old\"").into_bytes();
|
||||
let error = w.world.engine.rollback_source(&snapshot, &history, "service").unwrap_err();
|
||||
assert!(format!("{error:#}").contains("does not cross bindings"), "{error:#}");
|
||||
Ok(())
|
||||
}
|
||||
// END static-release tests
|
||||
}
|
||||
|
|
|
|||
|
|
@ -666,6 +666,11 @@ pub trait StaticReleasePort {
|
|||
fn carry_and_flip(&self, binding: &StaticReleaseBinding, release_id: &str)
|
||||
-> Result<StaticFlip>;
|
||||
|
||||
/// The release is installed where the binding says and every artifact in it
|
||||
/// still digests to its sealed receipt. A rollback reuses a release sealed
|
||||
/// long ago, so this is the check that it is still what was released.
|
||||
fn verify_installed(&self, binding: &StaticReleaseBinding, release: &SealedRelease) -> Result<()>;
|
||||
|
||||
/// The digest of the carry already inside an installed release: what a
|
||||
/// resumed flip records when an earlier attempt landed.
|
||||
fn carried_sha256(&self, binding: &StaticReleaseBinding, release_id: &str) -> Result<String>;
|
||||
|
|
@ -715,6 +720,27 @@ impl StaticReleasePort for FilesystemStaticRelease {
|
|||
Ok(Some(id.to_owned()))
|
||||
}
|
||||
|
||||
fn verify_installed(&self, binding: &StaticReleaseBinding, release: &SealedRelease) -> Result<()> {
|
||||
let root = binding.release_root.join(&release.sealed_release_id);
|
||||
ensure!(
|
||||
fs::symlink_metadata(&root).is_ok_and(|metadata| metadata.is_dir()),
|
||||
"release {} is no longer installed under {}",
|
||||
release.sealed_release_id,
|
||||
binding.release_root.display()
|
||||
);
|
||||
for artifact in &release.artifacts {
|
||||
let (sha256, size_bytes) = digest_artifact(&root.join(&artifact.destination))
|
||||
.with_context(|| format!("digesting installed artifact {}", artifact.artifact_id))?;
|
||||
ensure!(
|
||||
format!("sha256-{sha256}") == artifact.sha256 && size_bytes == artifact.size_bytes,
|
||||
"installed artifact {} of release {} differs from its sealed receipt",
|
||||
artifact.artifact_id,
|
||||
release.sealed_release_id
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn carried_sha256(&self, binding: &StaticReleaseBinding, release_id: &str) -> Result<String> {
|
||||
require_sha256_id(release_id, "release carrying the site file")?;
|
||||
let carried = binding
|
||||
|
|
@ -856,6 +882,10 @@ impl StaticReleasePort for FilesystemStaticRelease {
|
|||
fn carried_sha256(&self, _: &StaticReleaseBinding, _: &str) -> Result<String> {
|
||||
bail!("a static release flip requires Unix symlinks")
|
||||
}
|
||||
|
||||
fn verify_installed(&self, _: &StaticReleaseBinding, _: &SealedRelease) -> Result<()> {
|
||||
bail!("a static release flip requires Unix symlinks")
|
||||
}
|
||||
}
|
||||
|
||||
pub trait TopologyPort {
|
||||
|
|
|
|||
|
|
@ -871,6 +871,7 @@ pub(crate) fn execute(ctx: &Ctx, order_id: &str) -> Result<()> {
|
|||
if let Operation::Deploy { selector } = &order.operation {
|
||||
let outcome = match commit_deployment_command(
|
||||
&ctx.state_store,
|
||||
crate::control_plane::CommandKind::Deploy,
|
||||
&command_id,
|
||||
selector,
|
||||
&requester(&order.principal, order_id),
|
||||
|
|
|
|||
Loading…
Reference in a new issue