Idunn: idunn rollback <target>, a brake-gated transaction flipping to the previous static release
CommandKind::Rollback (command ids rollback-*). The daemon freezes the command into one transaction built like from_continuity: plan, sealed release and installed release copied from the transaction that flipped the previous release; no source, no build, source cleanup SkippedContinuity, no admitted generation. It reaches the deployment brake and advance_static unchanged. The previous release is derived from the newest Admitted static flip (control.cc + history.cc) and the observed link, and refused as a rejected transaction when there is no completed release, no previous release, current is not the newest flip, the previous directory is gone, or an artifact digest drifted (new port method verify_installed, also checked before any flip intent). Rollback counts as a deploy for the busy and live-per-target checks. CLI: idunn rollback via parse_up(kind); commit_deployment_command and submit take the kind. Tests: control_plane::tests::static_rollback_ (7) and parse_rollback (1). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9f15cee11f
commit
01b6016193
4 changed files with 490 additions and 36 deletions
|
|
@ -222,6 +222,38 @@ 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.
|
||||
|
||||
## 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,97 @@ 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}"))?;
|
||||
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 +7916,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 +8050,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 +8103,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 +8947,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 +10267,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 +10328,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 +10968,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 +11072,7 @@ where
|
|||
F: FnOnce() -> Result<()>,
|
||||
{
|
||||
match command_kind {
|
||||
CommandKind::Deploy => validation(),
|
||||
CommandKind::Deploy | CommandKind::Rollback => validation(),
|
||||
CommandKind::Continuity => Ok(()),
|
||||
}
|
||||
}
|
||||
|
|
@ -11245,6 +11385,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 +11443,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 +12633,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 +14178,7 @@ mod tests {
|
|||
match kind {
|
||||
CommandKind::Deploy => "up",
|
||||
CommandKind::Continuity => "continuity",
|
||||
CommandKind::Rollback => "rollback",
|
||||
},
|
||||
if incumbent.is_some() { "-again" } else { "" }
|
||||
);
|
||||
|
|
@ -14074,6 +14217,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 +21989,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 +22003,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 +26076,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 +26427,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 +26571,176 @@ 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(())
|
||||
}
|
||||
// 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