Merge pull request 'Deployment brake is a stop: DeploymentStop record, signed release path deleted, record v6' (#4) from eureka-self/brake-is-a-stop into main
This commit is contained in:
commit
58e744b745
11 changed files with 1255 additions and 792 deletions
|
|
@ -45,7 +45,6 @@ ExecStart=/usr/local/bin/idunn serve \
|
|||
--idunn-identity-store /var/lib/gamecult/idunn/idunn-service-identity.cc \
|
||||
--odin-correlation-store /var/lib/gamecult/odin/topology.cc \
|
||||
--odin-trust-anchor /etc/gamecult/idunn/odin-topology-anchor.cc \
|
||||
--deployment-brake-operator-anchor /etc/gamecult/idunn/deployment-brake-operator-anchor.cc \
|
||||
--host-actuator-bind 10.77.0.1:17890 \
|
||||
--source-uid 986 \
|
||||
--source-gid 978
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ target="${2:-}"
|
|||
compose=/srv/compose/yggdrasil-apps.yaml
|
||||
brake_store=/var/lib/gamecult/idunn-authority/deployment-brake.cc
|
||||
brake_lock=/var/lib/gamecult/idunn-authority/deployment-brake.cc.lock
|
||||
brake_anchor=/etc/gamecult/idunn/deployment-brake-operator-anchor.cc
|
||||
generation_lock=/var/lib/gamecult/idunn-authority/idunn-generation.lock
|
||||
idunn_provision=/usr/local/bin/idunn-provision
|
||||
|
||||
|
|
@ -154,7 +153,7 @@ deployment_target_policy() {
|
|||
esac
|
||||
}
|
||||
|
||||
require_signed_deployment_grant() {
|
||||
require_deployment_admission() {
|
||||
source_commit="$3"
|
||||
deployment_request_id="$4"
|
||||
release_authority_id="$5"
|
||||
|
|
@ -180,28 +179,17 @@ require_signed_deployment_grant() {
|
|||
[ -z "$release_authority_sha256" ] || { echo "source-observed release cannot claim Bifrost authority digest" >&2; exit 1; }
|
||||
|
||||
require_root_owned_resolved_executable "$idunn_provision"
|
||||
require_root_owned_regular_path "$brake_store"
|
||||
require_root_owned_regular_path "$brake_lock"
|
||||
require_root_owned_regular_path "$brake_anchor"
|
||||
|
||||
# This validation is the deployment-admission point. The signed expiry is a
|
||||
# start-before deadline: it denies a later transaction, but does not become a
|
||||
# timer-shaped veto over one already admitted. The root actuator remains the
|
||||
# sole shared-lock holder through the synchronous consequence, so an explicit
|
||||
# engage/release transition cannot race or rewrite that transaction.
|
||||
exec 9<"$brake_lock"
|
||||
# This validation is the deployment-admission point. An absent or clear stop
|
||||
# admits; an engaged or unreadable one makes the status verb exit nonzero.
|
||||
# The root actuator remains the sole shared-lock holder through the
|
||||
# synchronous consequence, so an engage or clear cannot race or rewrite it.
|
||||
# The lock file is created here when no store has ever been written.
|
||||
exec 9>>"$brake_lock"
|
||||
/usr/bin/flock --shared 9
|
||||
now_unix_millis="$(/usr/bin/date +%s%3N)"
|
||||
case "$now_unix_millis" in
|
||||
''|*[!0-9]*) echo "root actuator clock did not produce Unix milliseconds" >&2; exit 1 ;;
|
||||
esac
|
||||
"$idunn_provision" deployment-brake-status \
|
||||
--store "$brake_store" \
|
||||
--operator-anchor "$brake_anchor" \
|
||||
--runtime-id yggdrasil \
|
||||
--release-id "$source_commit" \
|
||||
--deployment-id "$deployment_request_id" \
|
||||
--now-unix-millis "$now_unix_millis"
|
||||
--runtime-id yggdrasil
|
||||
|
||||
if [ "$minimum_source_revision_required" = true ]; then
|
||||
require_root_owned_resolved_executable /usr/local/bin/idunn
|
||||
|
|
@ -322,7 +310,7 @@ case "$action:$target" in
|
|||
manifest="/srv/odin/deploy-manifests/$target"
|
||||
require_root_owned_regular_path "$manifest"
|
||||
[ -x "$manifest" ] || { echo "root actuator manifest is not executable: $manifest" >&2; exit 1; }
|
||||
require_signed_deployment_grant "$@"
|
||||
require_deployment_admission "$@"
|
||||
export IDUNN_ACTUATOR=1
|
||||
export IDUNN_COMMAND_AUTHORITY=idunn-daemon
|
||||
export IDUNN_SOURCE_COMMIT="$source_commit"
|
||||
|
|
|
|||
|
|
@ -240,7 +240,7 @@ Fencing retries its phase forward on a capped backoff and never stops its live
|
|||
candidate, since that is the only running copy of the admitted body; the
|
||||
deadline opens the same incident. The one exception is yielding: a continuity
|
||||
whose route proof has not succeeded gives way, through its own post-fencing
|
||||
abort, to a deployment the deployment brake has released and that awaits the
|
||||
abort, to a deployment the deployment stop admits and that awaits the
|
||||
handover; a continuity that has proven its route never yields. The
|
||||
`ContinuityExhausted` incident (six charged attempts in an hour,
|
||||
`restarts_page`) pages and gates nothing. When continuity next acts is derived
|
||||
|
|
@ -257,9 +257,14 @@ held until a lift succeeds. It is a migration with a deletion line (follow-up
|
|||
still owe dependency failures not growing the streak and the dead-generation
|
||||
route refresh.
|
||||
|
||||
The deployment brake gates changes to source, release, configuration, schema,
|
||||
unit, or authority binding. The lifecycle brake separately gates restart of an
|
||||
already-admitted incarnation. Neither target brake may gate Idunn itself or an
|
||||
The deployment brake is a stop, not a per-deploy gate: it gates changes to
|
||||
source, release, configuration, schema, unit, or authority binding only while
|
||||
engaged. Its one reader and writer is `src/brake.rs` (`idunn.deployment_stop.v1`:
|
||||
engaged or clear, who, why, when, sequence). An absent or clear store admits;
|
||||
a store that is present but unreadable, foreign, for another runtime, a symlink
|
||||
or owned by another uid fails closed. Nothing is signed, bound to a release or
|
||||
consumed, and one clear admits every deploy until it is engaged again. The
|
||||
lifecycle brake separately gates restart of an already-admitted incarnation. Neither target brake may gate Idunn itself or an
|
||||
unrelated service.
|
||||
|
||||
If Odin is unavailable, Idunn preserves existing routes and continuity for
|
||||
|
|
|
|||
|
|
@ -4,6 +4,15 @@ Status: target, Self, 2026-10-19 (campaign `idunn-deploy-provenance` in
|
|||
Eureka's mind). It states the ends. The map will live in
|
||||
`deploy-provenance-map.md`.
|
||||
|
||||
Live as of cut `brake-is-a-stop` (2026-11-13): the deployment brake is the
|
||||
stop of invariant 3 and the signed per-deploy release is gone. `src/brake.rs`
|
||||
reads and writes it; `idunn-provision deployment-brake-engage|clear|status`
|
||||
take only `--store`, `--runtime-id` and `--reason`; the writer's login name and
|
||||
the verb's clock are recorded; an absent or clear store admits. The
|
||||
transaction stamps `deployment_admitted_at_unix_millis` (record v6). Still
|
||||
planned: the typed authority provenance on the transaction (invariant 2), and a
|
||||
steward-scoped store.
|
||||
|
||||
## Why
|
||||
|
||||
Operator, 2026-10-19:
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ and health. CultMesh carries typed projections between them.
|
|||
scaling, restart, host reboot recovery, and crash recovery use the same
|
||||
source-to-recipe-to-artifact provenance and the same distinct write-lease and
|
||||
route-membership primitives.
|
||||
Deployment may change that chain only while the deployment brake admits the
|
||||
exact transaction. Continuity may restart only the already-admitted chain and
|
||||
Deployment may change that chain only while the deployment stop admits it
|
||||
(absent or clear; an engaged stop halts). Continuity may restart only the already-admitted chain and
|
||||
is governed by a separate lifecycle brake.
|
||||
- **Deletion line:** hard-coded target copies, raw `deploy_command` and
|
||||
`restart_command` state, `sh -c` actuation, root Git inspection, staged
|
||||
|
|
@ -209,8 +209,8 @@ 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
|
||||
directories stay, and Idunn prunes none. The deployment stop is read exactly as for a
|
||||
service, by the binding's runtime 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
|
||||
|
|
@ -230,9 +230,8 @@ 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
|
||||
reaches the same deployment stop as a deploy, which admits it unless engaged;
|
||||
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
|
||||
|
|
@ -300,6 +299,31 @@ names both content addresses and the executable artifact digest. Present and
|
|||
Ready remain Odin-owned correlations over independently verified runtime
|
||||
evidence; the deployment-plan module deliberately does not model them.
|
||||
|
||||
## The deployment stop
|
||||
|
||||
`src/brake.rs` is the only reader and writer of the stop. A stop halts work
|
||||
that has not crossed an irreversible step, and it is read again at each such
|
||||
step: Sealing (`advance_sealing`), the static flip intent (`advance_static`) and
|
||||
the entry into Fencing for a deploy (`advance_warming`). An engaged stop there
|
||||
makes the transaction wait with a note naming who engaged it and why; nothing
|
||||
aborts and nothing moves. After the flip intent is recorded, or once a deploy
|
||||
has fenced, the transaction runs to its end: a stop halts before the
|
||||
irreversible step, never mid-fence or mid-flip. Continuity is never gated.
|
||||
The sequence of a stop record is positive and advances by one per verb; nothing
|
||||
reads it for authority, so it is not validated further.
|
||||
|
||||
Absent means the store file is missing in an existing directory. Anything else
|
||||
Idunn cannot judge (a symlink, a zero-length or undecodable file, another
|
||||
runtime's record, another owner, an unsearchable or non-directory parent) reads
|
||||
as unreadable and fails closed; the wait note carries the
|
||||
`idunn-provision deployment-brake-clear` command that replaces it.
|
||||
|
||||
Record v6 (`deployment_admitted_at_unix_millis`, key 38) is forward-only: an
|
||||
Idunn that predates v6 cannot read a migrated `control.cc`, so, as for v5 (see
|
||||
`route-continuity-cut.md`, "An older Idunn refuses the v5 store"), take a
|
||||
byte-compared pre-install backup of `control.cc` and `history.cc` with Idunn
|
||||
stopped, and roll back by restoring the backup, never by swapping the binary.
|
||||
|
||||
## Initial drivers and replaceable boundary
|
||||
|
||||
The first Yggdrasil implementation uses:
|
||||
|
|
@ -338,7 +362,7 @@ For a singleton incarnation:
|
|||
declaration, binding, launch contract, and input digests.
|
||||
5. Derive the release-bound Expected incarnation from the validated sealed
|
||||
release and its exact compiled plan. Publish it only once the deployment
|
||||
brake has admitted this exact transaction, under the candidate's own
|
||||
stop has admitted this transaction (stamping `deployment_admitted_at_unix_millis`), under the candidate's own
|
||||
incarnation key, beside whatever the target's admitted incarnation projects.
|
||||
6. Issue one opaque runtime-instance activation bound to the Expected digest,
|
||||
pass the exact Expected and activation documents through the actuator's
|
||||
|
|
@ -361,8 +385,8 @@ For a singleton incarnation:
|
|||
and stop the incumbent.
|
||||
|
||||
Sealing resolves the source, freezes it, runs every recipe step, and installs
|
||||
the release before the deployment brake is consulted at all (`advance_sealing`).
|
||||
The brake gates step 5, the first Verse-visible change: publishing the
|
||||
the release before the deployment stop is consulted at all (`advance_sealing`).
|
||||
The stop gates step 5, the first Verse-visible change: publishing the
|
||||
candidate's Expected incarnation on the move into `Starting`. Build and test
|
||||
already run unbraked, inside the sealing phase.
|
||||
|
||||
|
|
@ -393,7 +417,7 @@ identity, and route membership. Every restart receives a new runtime-instance
|
|||
activation and, for a state writer, a new process-bound lease only after the new
|
||||
warming presence is observed. A lease from the dead process is never reused. A
|
||||
continuity restart does not fetch a branch, rebuild, rerun a migration, alter
|
||||
the launch contract, or consume the deployment brake. An explicit lifecycle
|
||||
the launch contract, or pass the deployment stop. An explicit lifecycle
|
||||
brake may suspend this actuation without changing deployment authority.
|
||||
|
||||
Continuity never stops. Six charged attempts in an hour only open the
|
||||
|
|
@ -426,7 +450,7 @@ the admitted body. Both operator cases open an `operator-required` incident
|
|||
on.
|
||||
|
||||
The one yield: a continuity whose route proof has not succeeded gives way to a
|
||||
deployment the deployment brake has released that awaits the handover of the
|
||||
deployment the deployment stop admits that awaits the handover of the
|
||||
target. It begins its own post-fencing abort and the deployment fences what is
|
||||
admitted. A continuity that has proven its route is serving never yields.
|
||||
|
||||
|
|
|
|||
|
|
@ -1160,7 +1160,7 @@ target's generation or to its `TargetSupervision` ends that target's iteration:
|
|||
- **Why no existing owner can serve:**
|
||||
- The generation does not exist for a first deploy (the ruling).
|
||||
- The transaction dies with each attempt.
|
||||
- Brakes are operator-signed and are not Idunn's to write.
|
||||
- The deployment brake is a stop that a steward or the operator writes (`src/brake.rs`); Idunn writes no brake.
|
||||
- **What it replaces:** two carry compensators, `for_new_incarnation` and the backoff copy in
|
||||
`from_transaction`, plus a history scan.
|
||||
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ cleaned up.
|
|||
`idunn redeploy --daemon …`. The installed binary has no `redeploy` command
|
||||
(probe of its usage text), so the script is dead and is **not** the current
|
||||
pattern. The current pattern is `idunn up` followed by
|
||||
`idunn-provision deployment-brake-release`
|
||||
`idunn-provision deployment-brake-clear` when a stop is engaged (an absent or clear store admits)
|
||||
(`runbooks/idunn-host-raven.md`, "Deploy Muninn to Raven").
|
||||
- F24. Build placement (`gamecult-ops/inventory.md:559-566`): build on or for the
|
||||
target's host. Yggdrasil has 16 vCPU and 62 GiB and carries live services, so
|
||||
|
|
|
|||
513
src/brake.rs
Normal file
513
src/brake.rs
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
//! The deployment stop: the one reader and the one writer of the record that
|
||||
//! halts deployments of a runtime. Engaged halts; absent or clear admits. Who
|
||||
//! engaged it and why are on the record, and nothing is signed, issued, bound
|
||||
//! to a release or consumed: the brake is a stop, not a per-deploy gate (ruling
|
||||
//! brake-is-a-stop). The lifecycle brake is a different record and a different
|
||||
//! store; nothing here touches it.
|
||||
//!
|
||||
//! The reader fails closed. A store that is present but foreign, ambiguous,
|
||||
//! noncanonical, for another runtime, a symlink, or owned by anyone but the
|
||||
//! expected uid reads as `Unreadable`, which does not admit.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail, ensure};
|
||||
use cultcache_rs::{CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub(crate) const DEPLOYMENT_STOP_SCHEMA: &str = "idunn.deployment_stop.v1";
|
||||
pub(crate) const DEPLOYMENT_STOP_KEY: &str = "deployment-stop";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub(crate) enum StopState {
|
||||
Engaged,
|
||||
Clear,
|
||||
}
|
||||
|
||||
impl StopState {
|
||||
pub(crate) fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Engaged => "engaged",
|
||||
Self::Clear => "clear",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||
#[cultcache(type = "idunn.deployment_stop", schema = "idunn.deployment_stop.v1")]
|
||||
pub(crate) struct DeploymentStop {
|
||||
#[cultcache(key = 0)]
|
||||
pub(crate) schema_version: String,
|
||||
#[cultcache(key = 1)]
|
||||
pub(crate) runtime_id: String,
|
||||
#[cultcache(key = 2)]
|
||||
pub(crate) state: StopState,
|
||||
/// The login name of the user who ran the verb, read from the process uid.
|
||||
#[cultcache(key = 3)]
|
||||
pub(crate) by: String,
|
||||
#[cultcache(key = 4)]
|
||||
pub(crate) reason: String,
|
||||
/// The verb's clock.
|
||||
#[cultcache(key = 5)]
|
||||
pub(crate) at_unix_millis: u64,
|
||||
/// The previous record's sequence plus one; 1 for the first.
|
||||
#[cultcache(key = 6)]
|
||||
pub(crate) sequence: u64,
|
||||
}
|
||||
|
||||
fn bounded(value: &str, label: &str, max: usize) -> Result<()> {
|
||||
ensure!(
|
||||
!value.trim().is_empty() && value.len() <= max && !value.chars().any(char::is_control),
|
||||
"deployment stop {label} is empty, oversized, or contains control characters"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl DeploymentStop {
|
||||
pub(crate) fn validate(&self) -> Result<()> {
|
||||
ensure!(
|
||||
self.schema_version == DEPLOYMENT_STOP_SCHEMA,
|
||||
"deployment stop schema is unsupported"
|
||||
);
|
||||
bounded(&self.runtime_id, "runtime id", 256)?;
|
||||
bounded(&self.by, "author", 256)?;
|
||||
bounded(&self.reason, "reason", 1024)?;
|
||||
ensure!(
|
||||
self.at_unix_millis > 0 && self.sequence > 0,
|
||||
"deployment stop time or sequence is zero"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn envelope(&self) -> Result<CultCacheEnvelope> {
|
||||
self.validate()?;
|
||||
let stored_at = chrono::DateTime::from_timestamp_millis(i64::try_from(self.at_unix_millis)?)
|
||||
.ok_or_else(|| anyhow!("deployment stop time is out of range"))?
|
||||
.to_rfc3339();
|
||||
Ok(CultCacheEnvelope {
|
||||
key: DEPLOYMENT_STOP_KEY.into(),
|
||||
r#type: Self::TYPE.into(),
|
||||
payload: rmp_serde::to_vec(self)?,
|
||||
stored_at,
|
||||
schema_id: Some(DEPLOYMENT_STOP_SCHEMA.into()),
|
||||
})
|
||||
}
|
||||
|
||||
fn decode(envelope: &CultCacheEnvelope) -> Result<Self> {
|
||||
ensure!(
|
||||
envelope.r#type == Self::TYPE
|
||||
&& envelope.key == DEPLOYMENT_STOP_KEY
|
||||
&& envelope.schema_id.as_deref() == Some(DEPLOYMENT_STOP_SCHEMA),
|
||||
"deployment stop store holds a foreign record"
|
||||
);
|
||||
let record: Self = rmp_serde::from_slice(&envelope.payload)?;
|
||||
ensure!(
|
||||
rmp_serde::to_vec(&record)? == envelope.payload,
|
||||
"deployment stop is noncanonical"
|
||||
);
|
||||
record.validate()?;
|
||||
Ok(record)
|
||||
}
|
||||
}
|
||||
|
||||
/// What one store says about a runtime's deployments.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Stop {
|
||||
/// No store, or an empty one: nothing halts.
|
||||
Absent,
|
||||
Clear(DeploymentStop),
|
||||
Engaged(DeploymentStop),
|
||||
/// Present but not a record this runtime can trust. Does not admit.
|
||||
Unreadable(String),
|
||||
}
|
||||
|
||||
impl Stop {
|
||||
pub(crate) fn admits(&self) -> bool {
|
||||
matches!(self, Self::Absent | Self::Clear(_))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn process_uid() -> u32 {
|
||||
// SAFETY: geteuid has no failure mode and no arguments.
|
||||
unsafe { libc::geteuid() }
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub(crate) fn process_uid() -> u32 {
|
||||
0
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn login_name(uid: u32) -> Result<String> {
|
||||
// SAFETY: getpwuid returns null or a pointer into libc's static buffer,
|
||||
// copied before any other call; the verbs are single threaded.
|
||||
unsafe {
|
||||
let entry = libc::getpwuid(uid);
|
||||
ensure!(!entry.is_null(), "uid {uid} has no login name");
|
||||
Ok(std::ffi::CStr::from_ptr((*entry).pw_name)
|
||||
.to_string_lossy()
|
||||
.into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn login_name(_: u32) -> Result<String> {
|
||||
std::env::var("USERNAME").context("USERNAME is unset")
|
||||
}
|
||||
|
||||
/// Open without following a final symlink and judge the file by fstat before
|
||||
/// anything is read from it.
|
||||
#[cfg(unix)]
|
||||
fn check_file(path: &Path, expected_owner_uid: u32) -> Result<()> {
|
||||
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.open(path)
|
||||
.with_context(|| format!("opening deployment stop store {}", path.display()))?;
|
||||
let metadata = file.metadata()?;
|
||||
ensure!(
|
||||
metadata.is_file(),
|
||||
"deployment stop store is not a regular file"
|
||||
);
|
||||
ensure!(
|
||||
metadata.uid() == expected_owner_uid,
|
||||
"deployment stop store is owned by uid {}, not {expected_owner_uid}",
|
||||
metadata.uid()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn check_file(path: &Path, _: u32) -> Result<()> {
|
||||
ensure!(
|
||||
std::fs::metadata(path)?.is_file(),
|
||||
"deployment stop store is not a regular file"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Absent means exactly this: the final component does not exist and its
|
||||
/// parent is an existing directory. Every other failure to look (permission,
|
||||
/// a parent that is a file, a symlink loop) is a store this runtime cannot
|
||||
/// judge, and fails closed.
|
||||
fn is_absent(path: &Path) -> Result<bool> {
|
||||
match std::fs::symlink_metadata(path) {
|
||||
Ok(_) => Ok(false),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
|
||||
let parent = parent.unwrap_or_else(|| Path::new("."));
|
||||
ensure!(
|
||||
std::fs::metadata(parent).is_ok_and(|m| m.is_dir()),
|
||||
"deployment stop store directory {} does not exist",
|
||||
parent.display()
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
Err(error) => Err(error)
|
||||
.with_context(|| format!("looking for deployment stop store {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_checked(path: &Path, runtime_id: &str, owner: u32) -> Result<Option<DeploymentStop>> {
|
||||
if is_absent(path)? {
|
||||
return Ok(None);
|
||||
}
|
||||
check_file(path, owner)?;
|
||||
ensure!(
|
||||
std::fs::metadata(path)?.len() > 0,
|
||||
"deployment stop store is zero length"
|
||||
);
|
||||
let entries = SingleFileMessagePackBackingStore::new(path).pull_all_read_only_snapshot()?;
|
||||
match entries.as_slice() {
|
||||
[] => bail!("deployment stop store holds no record"),
|
||||
[entry] => {
|
||||
let record = DeploymentStop::decode(entry)?;
|
||||
ensure!(
|
||||
record.runtime_id == runtime_id,
|
||||
"deployment stop belongs to runtime {}",
|
||||
record.runtime_id
|
||||
);
|
||||
Ok(Some(record))
|
||||
}
|
||||
_ => bail!("deployment stop store is ambiguous"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn read_stop(path: &Path, runtime_id: &str, expected_owner_uid: u32) -> Stop {
|
||||
match read_checked(path, runtime_id, expected_owner_uid) {
|
||||
Ok(None) => Stop::Absent,
|
||||
Ok(Some(record)) if record.state == StopState::Clear => Stop::Clear(record),
|
||||
Ok(Some(record)) => Stop::Engaged(record),
|
||||
Err(error) => Stop::Unreadable(format!(
|
||||
"{error:#}; to replace it run: idunn-provision deployment-brake-clear \
|
||||
--store {} --runtime-id {runtime_id} --reason <text> (or deployment-brake-engage)",
|
||||
path.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the next record under a compare-exchange of the whole store, so a
|
||||
/// concurrent verb or a store that moved since it was read is refused. The
|
||||
/// author is the login name of the process uid and the sequence is the
|
||||
/// predecessor's plus one (1 when there is none or it cannot be decoded);
|
||||
/// neither is an argument.
|
||||
///
|
||||
/// This is also the operator's break-glass. A predecessor that is a retired v1
|
||||
/// brake, another runtime's record, ambiguous, or zero length is replaced. A
|
||||
/// file that is not a CultCache store at all (junk bytes) is replaced only
|
||||
/// when it is a regular file owned by the caller, by removing it first. A
|
||||
/// symlink or another user's file is refused. The sequence is not validated
|
||||
/// beyond being positive: nothing reads it for authority.
|
||||
pub(crate) fn replace_stop(
|
||||
path: &Path,
|
||||
runtime_id: &str,
|
||||
state: StopState,
|
||||
reason: &str,
|
||||
now_unix_millis: u64,
|
||||
) -> Result<DeploymentStop> {
|
||||
let store = SingleFileMessagePackBackingStore::new(path);
|
||||
let before = match store.pull_all_read_only_snapshot() {
|
||||
Ok(entries) => entries,
|
||||
Err(undecodable) => {
|
||||
check_file(path, process_uid()).with_context(|| {
|
||||
format!("deployment stop store is undecodable ({undecodable:#}) and not the caller's to replace")
|
||||
})?;
|
||||
std::fs::remove_file(path)?;
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let sequence = match before.as_slice() {
|
||||
[entry] => match DeploymentStop::decode(entry) {
|
||||
Ok(previous) => previous.sequence.saturating_add(1),
|
||||
Err(_) => 1,
|
||||
},
|
||||
_ => 1,
|
||||
};
|
||||
let record = DeploymentStop {
|
||||
schema_version: DEPLOYMENT_STOP_SCHEMA.into(),
|
||||
runtime_id: runtime_id.into(),
|
||||
state,
|
||||
by: login_name(process_uid())?,
|
||||
reason: reason.into(),
|
||||
at_unix_millis: now_unix_millis,
|
||||
sequence,
|
||||
};
|
||||
let next = record.envelope()?;
|
||||
ensure!(
|
||||
store.compare_exchange_snapshot(&before, &[next])?,
|
||||
"deployment stop changed during transition"
|
||||
);
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn engage(path: &Path, reason: &str, at: u64) -> Result<DeploymentStop> {
|
||||
replace_stop(path, "runtime", StopState::Engaged, reason, at)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_and_empty_stores_admit_a_clear_one_admits_an_engaged_one_does_not() -> Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("stop.cc");
|
||||
let me = process_uid();
|
||||
assert_eq!(read_stop(&path, "runtime", me), Stop::Absent);
|
||||
engage(&path, "freeze for the audit", 100)?;
|
||||
let Stop::Engaged(held) = read_stop(&path, "runtime", me) else {
|
||||
bail!("an engaged stop did not read as engaged")
|
||||
};
|
||||
assert_eq!(held.reason, "freeze for the audit");
|
||||
assert_eq!(held.by, login_name(me)?);
|
||||
assert!(!Stop::Engaged(held).admits());
|
||||
replace_stop(&path, "runtime", StopState::Clear, "audit done", 200)?;
|
||||
let cleared = read_stop(&path, "runtime", me);
|
||||
assert!(matches!(&cleared, Stop::Clear(record) if record.sequence == 2));
|
||||
assert!(cleared.admits());
|
||||
// Clear is not consumed: the same record admits again.
|
||||
assert!(read_stop(&path, "runtime", me).admits());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_foreign_other_runtime_symlinked_or_foreign_owned_stores_stop() -> Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let me = process_uid();
|
||||
let path = dir.path().join("stop.cc");
|
||||
engage(&path, "held", 100)?;
|
||||
// Another runtime's record.
|
||||
assert!(!read_stop(&path, "other", me).admits());
|
||||
// Owned by someone other than the expected uid: even a CLEAR record
|
||||
// from a foreign owner must not admit.
|
||||
let cleared = dir.path().join("cleared.cc");
|
||||
replace_stop(&cleared, "runtime", StopState::Clear, "thaw", 100)?;
|
||||
assert!(read_stop(&cleared, "runtime", me).admits());
|
||||
assert!(matches!(read_stop(&cleared, "runtime", me + 1), Stop::Unreadable(_)));
|
||||
// A symlink to a valid, clear store.
|
||||
let real = dir.path().join("real.cc");
|
||||
replace_stop(&real, "runtime", StopState::Clear, "clear", 100)?;
|
||||
let link = dir.path().join("link.cc");
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(&real, &link)?;
|
||||
#[cfg(unix)]
|
||||
assert!(matches!(read_stop(&link, "runtime", me), Stop::Unreadable(_)));
|
||||
// Bytes that are no store at all.
|
||||
let junk = dir.path().join("junk.cc");
|
||||
std::fs::write(&junk, b"not msgpack at all")?;
|
||||
assert!(matches!(read_stop(&junk, "runtime", me), Stop::Unreadable(_)));
|
||||
// A record of another type under another key (the retired v1 brake).
|
||||
let foreign = dir.path().join("foreign.cc");
|
||||
let store = SingleFileMessagePackBackingStore::new(&foreign);
|
||||
let envelope = CultCacheEnvelope {
|
||||
key: "deployment".into(),
|
||||
r#type: "idunn.deployment_brake".into(),
|
||||
payload: vec![0x90],
|
||||
stored_at: "1970-01-01T00:00:00+00:00".into(),
|
||||
schema_id: Some("idunn.deployment_brake.v1".into()),
|
||||
};
|
||||
assert!(store.compare_exchange_snapshot(&[], &[envelope])?);
|
||||
assert!(matches!(read_stop(&foreign, "runtime", me), Stop::Unreadable(_)));
|
||||
// A noncanonical payload under the right identity.
|
||||
let mut stop = DeploymentStop::decode(&store_entry(&path)?)?;
|
||||
stop.sequence = 9;
|
||||
let mut loose = stop.envelope()?;
|
||||
loose.payload.push(0xc0);
|
||||
let noncanonical = dir.path().join("noncanonical.cc");
|
||||
assert!(SingleFileMessagePackBackingStore::new(&noncanonical)
|
||||
.compare_exchange_snapshot(&[], &[loose])?);
|
||||
assert!(matches!(read_stop(&noncanonical, "runtime", me), Stop::Unreadable(_)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn store_entry(path: &Path) -> Result<CultCacheEnvelope> {
|
||||
let mut entries = SingleFileMessagePackBackingStore::new(path).pull_all_read_only_snapshot()?;
|
||||
entries.pop().context("no entry")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_verb_replaces_a_retired_brake_record_and_the_sequence_advances_by_one() -> Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("stop.cc");
|
||||
let store = SingleFileMessagePackBackingStore::new(&path);
|
||||
let retired = CultCacheEnvelope {
|
||||
key: "deployment".into(),
|
||||
r#type: "idunn.deployment_brake".into(),
|
||||
payload: vec![0x90],
|
||||
stored_at: "1970-01-01T00:00:00+00:00".into(),
|
||||
schema_id: Some("idunn.deployment_brake.v1".into()),
|
||||
};
|
||||
assert!(store.compare_exchange_snapshot(&[], &[retired])?);
|
||||
assert_eq!(engage(&path, "first", 10)?.sequence, 1);
|
||||
assert_eq!(store.pull_all_read_only_snapshot()?.len(), 1);
|
||||
assert_eq!(engage(&path, "second", 11)?.sequence, 2);
|
||||
assert_eq!(
|
||||
replace_stop(&path, "runtime", StopState::Clear, "third", 12)?.sequence,
|
||||
3
|
||||
);
|
||||
assert_eq!(DeploymentStop::decode(&store_entry(&path)?)?.reason, "third");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_exchange_is_refused_and_a_record_without_a_reason_is_not_written() -> Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("stop.cc");
|
||||
engage(&path, "held", 10)?;
|
||||
let store = SingleFileMessagePackBackingStore::new(&path);
|
||||
let stale = store.pull_all_read_only_snapshot()?;
|
||||
engage(&path, "moved", 11)?;
|
||||
let next = DeploymentStop {
|
||||
schema_version: DEPLOYMENT_STOP_SCHEMA.into(),
|
||||
runtime_id: "runtime".into(),
|
||||
state: StopState::Clear,
|
||||
by: "someone".into(),
|
||||
reason: "late".into(),
|
||||
at_unix_millis: 12,
|
||||
sequence: 2,
|
||||
}
|
||||
.envelope()?;
|
||||
assert!(!store.compare_exchange_snapshot(&stale, &[next])?);
|
||||
assert!(replace_stop(&path, "runtime", StopState::Clear, " ", 13).is_err());
|
||||
assert_eq!(DeploymentStop::decode(&store_entry(&path)?)?.reason, "moved");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_missing_file_in_an_existing_directory_is_absent() -> Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let me = process_uid();
|
||||
// Parent is a regular file: ENOTDIR, not absence.
|
||||
let file = dir.path().join("plain");
|
||||
std::fs::write(&file, b"x")?;
|
||||
assert!(matches!(read_stop(&file.join("stop.cc"), "runtime", me), Stop::Unreadable(_)));
|
||||
// Parent does not exist.
|
||||
assert!(matches!(
|
||||
read_stop(&dir.path().join("nope/stop.cc"), "runtime", me),
|
||||
Stop::Unreadable(_)
|
||||
));
|
||||
// Zero-length file.
|
||||
let empty = dir.path().join("empty.cc");
|
||||
std::fs::write(&empty, b"")?;
|
||||
assert!(matches!(read_stop(&empty, "runtime", me), Stop::Unreadable(_)));
|
||||
// An existing directory with no store is absent.
|
||||
assert_eq!(read_stop(&dir.path().join("stop.cc"), "runtime", me), Stop::Absent);
|
||||
// A parent that cannot be searched (skipped as root, which can).
|
||||
#[cfg(unix)]
|
||||
if me != 0 {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let locked = dir.path().join("locked");
|
||||
std::fs::create_dir(&locked)?;
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000))?;
|
||||
let reading = read_stop(&locked.join("stop.cc"), "runtime", me);
|
||||
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700))?;
|
||||
assert!(matches!(reading, Stop::Unreadable(_)), "{reading:?}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_author_is_the_uid_login_never_the_environment() -> Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let path = dir.path().join("stop.cc");
|
||||
// SAFETY: no other test reads USER, LOGNAME or LNAME.
|
||||
unsafe {
|
||||
std::env::set_var("USER", "spoof");
|
||||
std::env::set_var("LOGNAME", "spoof");
|
||||
}
|
||||
let written = engage(&path, "held", 10)?;
|
||||
assert_eq!(written.by, login_name(process_uid())?);
|
||||
assert_ne!(written.by, "spoof");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_names_the_command_and_a_break_glass_clear_replaces_junk_and_v1() -> Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let me = process_uid();
|
||||
let path = dir.path().join("stop.cc");
|
||||
std::fs::write(&path, b"junk that is no store")?;
|
||||
let Stop::Unreadable(why) = read_stop(&path, "runtime", me) else {
|
||||
bail!("junk admitted")
|
||||
};
|
||||
assert!(why.contains("idunn-provision deployment-brake-clear --store"), "{why}");
|
||||
assert!(why.contains("--runtime-id runtime"), "{why}");
|
||||
let cleared = replace_stop(&path, "runtime", StopState::Clear, "operator break-glass", 50)?;
|
||||
assert_eq!(cleared.sequence, 1);
|
||||
assert!(read_stop(&path, "runtime", me).admits());
|
||||
// A symlink is not the caller's to replace.
|
||||
let real = dir.path().join("real.cc");
|
||||
std::fs::write(&real, b"junk")?;
|
||||
let link = dir.path().join("link.cc");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::os::unix::fs::symlink(&real, &link)?;
|
||||
assert!(replace_stop(&link, "runtime", StopState::Clear, "x", 60).is_err());
|
||||
assert_eq!(std::fs::read(&real)?, b"junk");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,4 @@
|
|||
mod brake;
|
||||
pub mod control_plane;
|
||||
pub mod deployment;
|
||||
pub mod deployment_plan;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use crate::brake::{Stop, StopState, process_uid, read_stop, replace_stop};
|
||||
use anyhow::{Context, Result, anyhow, bail, ensure};
|
||||
use cultcache_rs::{
|
||||
CultCacheEnvelope, CultCacheExpectedEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore,
|
||||
};
|
||||
|
|
@ -6,15 +7,13 @@ use cultnet_rs::{
|
|||
GAMECULT_SERVICE_TRUST_ANCHOR_SCHEMA, GameCultProviderHealthIdentity,
|
||||
GameCultServiceTrustAnchorRecord, IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SCHEMA,
|
||||
IDUNN_AUTHENTICATED_PROVIDER_HEALTH_PROJECTION_SIGNING_PURPOSE,
|
||||
IDUNN_DAEMON_HEALTH_TRUST_BINDING_SCHEMA, IDUNN_DEPLOYMENT_BRAKE_AUTHORITY,
|
||||
IDUNN_DEPLOYMENT_BRAKE_ID, IDUNN_DEPLOYMENT_BRAKE_SCHEMA, IDUNN_DEPLOYMENT_BRAKE_SCOPE,
|
||||
IDUNN_DEPLOYMENT_RELEASE_PURPOSE, IDUNN_LIFECYCLE_BRAKE_AUTHORITY,
|
||||
IDUNN_DAEMON_HEALTH_TRUST_BINDING_SCHEMA,
|
||||
IDUNN_LIFECYCLE_BRAKE_AUTHORITY,
|
||||
IDUNN_LIFECYCLE_BRAKE_SCHEMA, IDUNN_LIFECYCLE_BRAKE_SCOPE, IdunnDaemonHealthTrustBindingRecord,
|
||||
IdunnDeploymentBrakeObservation, IdunnDeploymentBrakeOperatorIdentity,
|
||||
IdunnDeploymentBrakeRecord, IdunnDeploymentBrakeReleasePurpose, IdunnLifecycleBrakeObservation,
|
||||
IdunnLifecycleBrakeObservation,
|
||||
IdunnLifecycleBrakeRecord, IdunnServiceIdentity, OdinTopologyIdentity, ServiceIdentityProfile,
|
||||
ServiceIdentityTrustAnchor, derive_service_identity_id, enroll_service_identity_at,
|
||||
evaluate_idunn_continuity_restart, evaluate_idunn_deployment_brake,
|
||||
evaluate_idunn_continuity_restart,
|
||||
export_service_identity_trust_anchor, open_service_identity_at,
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
|
@ -80,17 +79,8 @@ pub fn run(args: impl IntoIterator<Item = String>) -> Result<()> {
|
|||
"export-odin-topology-public-anchor" => {
|
||||
export_identity_anchor::<OdinTopologyIdentity>(&options, "Odin topology public anchor")?
|
||||
}
|
||||
"enroll-deployment-brake-operator" => {
|
||||
enroll_identity::<IdunnDeploymentBrakeOperatorIdentity>(&options)?
|
||||
}
|
||||
"export-deployment-brake-operator-anchor" => {
|
||||
export_identity_anchor::<IdunnDeploymentBrakeOperatorIdentity>(
|
||||
&options,
|
||||
"deployment brake operator anchor",
|
||||
)?
|
||||
}
|
||||
"deployment-brake-engage" => deployment_brake_engage(&options)?,
|
||||
"deployment-brake-release" => deployment_brake_release(&options)?,
|
||||
"deployment-brake-engage" => deployment_brake_write(&options, StopState::Engaged)?,
|
||||
"deployment-brake-clear" => deployment_brake_write(&options, StopState::Clear)?,
|
||||
"deployment-brake-status" => deployment_brake_status(&options)?,
|
||||
"lifecycle-brake-engage" => lifecycle_brake_engage(&options)?,
|
||||
"lifecycle-brake-release" => lifecycle_brake_release(&options)?,
|
||||
|
|
@ -157,153 +147,58 @@ fn provider_health_public_key(options: &BTreeMap<String, String>) -> Result<Stri
|
|||
encode_public_key(&signer.entry().public_key)
|
||||
}
|
||||
|
||||
fn deployment_brake_engage(options: &BTreeMap<String, String>) -> Result<()> {
|
||||
require_only(
|
||||
options,
|
||||
&[
|
||||
"store",
|
||||
"runtime-id",
|
||||
"owner",
|
||||
"reason",
|
||||
"observed-at-unix-millis",
|
||||
],
|
||||
/// Engage or clear the deployment stop. The author is the process uid's login
|
||||
/// name and the time is this verb's clock: neither is an argument.
|
||||
fn deployment_brake_write(options: &BTreeMap<String, String>, state: StopState) -> Result<()> {
|
||||
require_only(options, &["store", "runtime-id", "reason"])?;
|
||||
let now = u64::try_from(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_millis(),
|
||||
)?;
|
||||
let observed = parse_u64(options, "observed-at-unix-millis")?;
|
||||
let record = IdunnDeploymentBrakeRecord {
|
||||
schema_version: IDUNN_DEPLOYMENT_BRAKE_SCHEMA.into(),
|
||||
brake_id: IDUNN_DEPLOYMENT_BRAKE_ID.into(),
|
||||
authority: IDUNN_DEPLOYMENT_BRAKE_AUTHORITY.into(),
|
||||
runtime_id: required(options, "runtime-id")?.into(),
|
||||
status: "engaged".into(),
|
||||
scope: IDUNN_DEPLOYMENT_BRAKE_SCOPE.into(),
|
||||
reason: required(options, "reason")?.into(),
|
||||
observed_at_unix_millis: observed,
|
||||
expires_at_unix_millis: None,
|
||||
authorization_id: None,
|
||||
authorization_purpose: None,
|
||||
authorized_release_id: None,
|
||||
authorized_deployment_id: None,
|
||||
authorized_by: None,
|
||||
authorization_issued_at_unix_millis: None,
|
||||
authorization_expires_at_unix_millis: None,
|
||||
signature_algorithm: None,
|
||||
signature: None,
|
||||
private_state_exposed: false,
|
||||
updated_by: required(options, "owner")?.into(),
|
||||
};
|
||||
record.validate()?;
|
||||
replace_brake(&path(options, "store")?, record, observed)
|
||||
}
|
||||
|
||||
fn deployment_brake_release(options: &BTreeMap<String, String>) -> Result<()> {
|
||||
require_only(
|
||||
options,
|
||||
&[
|
||||
"store",
|
||||
"private-store",
|
||||
"runtime-id",
|
||||
"owner",
|
||||
"reason",
|
||||
"authorization-id",
|
||||
"release-id",
|
||||
"deployment-id",
|
||||
"issued-at-unix-millis",
|
||||
"expires-at-unix-millis",
|
||||
],
|
||||
)?;
|
||||
let issued = parse_u64(options, "issued-at-unix-millis")?;
|
||||
let expires = parse_u64(options, "expires-at-unix-millis")?;
|
||||
let signer = open_service_identity_at::<IdunnDeploymentBrakeOperatorIdentity>(&path(
|
||||
options,
|
||||
"private-store",
|
||||
)?)?;
|
||||
let mut record = IdunnDeploymentBrakeRecord {
|
||||
schema_version: IDUNN_DEPLOYMENT_BRAKE_SCHEMA.into(),
|
||||
brake_id: IDUNN_DEPLOYMENT_BRAKE_ID.into(),
|
||||
authority: IDUNN_DEPLOYMENT_BRAKE_AUTHORITY.into(),
|
||||
runtime_id: required(options, "runtime-id")?.into(),
|
||||
status: "released".into(),
|
||||
scope: IDUNN_DEPLOYMENT_BRAKE_SCOPE.into(),
|
||||
reason: required(options, "reason")?.into(),
|
||||
observed_at_unix_millis: issued,
|
||||
expires_at_unix_millis: Some(expires),
|
||||
authorization_id: Some(required(options, "authorization-id")?.into()),
|
||||
authorization_purpose: Some(IDUNN_DEPLOYMENT_RELEASE_PURPOSE.into()),
|
||||
authorized_release_id: Some(required(options, "release-id")?.into()),
|
||||
authorized_deployment_id: Some(required(options, "deployment-id")?.into()),
|
||||
authorized_by: Some(signer.trust_anchor()?.identity_id),
|
||||
authorization_issued_at_unix_millis: Some(issued),
|
||||
authorization_expires_at_unix_millis: Some(expires),
|
||||
signature_algorithm: Some("ed25519".into()),
|
||||
signature: None,
|
||||
private_state_exposed: false,
|
||||
updated_by: required(options, "owner")?.into(),
|
||||
};
|
||||
record.signature = Some(
|
||||
signer
|
||||
.sign::<IdunnDeploymentBrakeReleasePurpose>(&rmp_serde::to_vec(&record)?)
|
||||
.signature,
|
||||
);
|
||||
record.validate()?;
|
||||
replace_brake(&path(options, "store")?, record, issued)
|
||||
}
|
||||
|
||||
fn deployment_brake_status(options: &BTreeMap<String, String>) -> Result<()> {
|
||||
allow_only(
|
||||
options,
|
||||
&[
|
||||
"store",
|
||||
"operator-anchor",
|
||||
"runtime-id",
|
||||
"release-id",
|
||||
"deployment-id",
|
||||
"now-unix-millis",
|
||||
],
|
||||
)?;
|
||||
let exact_actuation_fields = ["release-id", "deployment-id", "now-unix-millis"];
|
||||
let exact_actuation_field_count = exact_actuation_fields
|
||||
.into_iter()
|
||||
.filter(|field| options.contains_key(*field))
|
||||
.count();
|
||||
if exact_actuation_field_count != 0
|
||||
&& exact_actuation_field_count != exact_actuation_fields.len()
|
||||
{
|
||||
bail!("exact deployment-brake actuation validation requires release, deployment, and time");
|
||||
}
|
||||
let record = read_brake(&path(options, "store")?)?;
|
||||
if record.runtime_id != required(options, "runtime-id")? {
|
||||
bail!("deployment brake belongs to another runtime")
|
||||
}
|
||||
if record.status == "engaged" {
|
||||
if exact_actuation_field_count != 0 {
|
||||
bail!("engaged deployment brake denies exact actuation validation");
|
||||
}
|
||||
println!(
|
||||
"engaged runtime={} scope={} owner={} reason={}",
|
||||
record.runtime_id, record.scope, record.updated_by, record.reason
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let anchor = read_operator_anchor(&path(options, "operator-anchor")?)?;
|
||||
evaluate_idunn_deployment_brake(
|
||||
IdunnDeploymentBrakeObservation::Present(&record),
|
||||
&anchor,
|
||||
let record = replace_stop(
|
||||
&path(options, "store")?,
|
||||
required(options, "runtime-id")?,
|
||||
required(options, "release-id")?,
|
||||
required(options, "deployment-id")?,
|
||||
parse_u64(options, "now-unix-millis")?,
|
||||
)
|
||||
.map_err(|d| anyhow!("deployment brake denied actuation: {d:?}"))?;
|
||||
state,
|
||||
required(options, "reason")?,
|
||||
now,
|
||||
)?;
|
||||
println!(
|
||||
"released runtime={} release={} deployment={} owner={}",
|
||||
"{} runtime={} by={} sequence={}",
|
||||
record.state.name(),
|
||||
record.runtime_id,
|
||||
record.authorized_release_id.unwrap(),
|
||||
record.authorized_deployment_id.unwrap(),
|
||||
record.updated_by
|
||||
record.by,
|
||||
record.sequence
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Print the stop and exit nonzero unless deployments admit, so a script can
|
||||
/// gate on it as it could on the retired exact-actuation validation.
|
||||
fn deployment_brake_status(options: &BTreeMap<String, String>) -> Result<()> {
|
||||
require_only(options, &["store", "runtime-id"])?;
|
||||
let stop = read_stop(
|
||||
&path(options, "store")?,
|
||||
required(options, "runtime-id")?,
|
||||
process_uid(),
|
||||
);
|
||||
match &stop {
|
||||
Stop::Absent => println!("absent: deployments admit"),
|
||||
Stop::Unreadable(why) => println!("unreadable: deployments wait ({why})"),
|
||||
Stop::Clear(record) | Stop::Engaged(record) => println!(
|
||||
"{} runtime={} by={} at={} sequence={} reason={}",
|
||||
record.state.name(),
|
||||
record.runtime_id,
|
||||
record.by,
|
||||
record.at_unix_millis,
|
||||
record.sequence,
|
||||
record.reason
|
||||
),
|
||||
}
|
||||
ensure!(stop.admits(), "the deployment brake does not admit deployments");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn lifecycle_brake_engage(options: &BTreeMap<String, String>) -> Result<()> {
|
||||
require_only(
|
||||
options,
|
||||
|
|
@ -482,70 +377,6 @@ fn lifecycle_identifier<'a>(options: &'a BTreeMap<String, String>, name: &str) -
|
|||
Ok(value)
|
||||
}
|
||||
|
||||
fn replace_brake(path: &Path, record: IdunnDeploymentBrakeRecord, millis: u64) -> Result<()> {
|
||||
let store = SingleFileMessagePackBackingStore::new(path);
|
||||
let entries = store.pull_all_read_only_snapshot()?;
|
||||
let current = match entries.as_slice() {
|
||||
[] => None,
|
||||
[entry]
|
||||
if entry.r#type == IdunnDeploymentBrakeRecord::TYPE
|
||||
&& entry.key == IDUNN_DEPLOYMENT_BRAKE_ID =>
|
||||
{
|
||||
Some(entry.clone())
|
||||
}
|
||||
_ => bail!("deployment brake store is corrupt or ambiguous"),
|
||||
};
|
||||
let next = typed_envelope(
|
||||
IDUNN_DEPLOYMENT_BRAKE_ID,
|
||||
&record,
|
||||
IDUNN_DEPLOYMENT_BRAKE_SCHEMA,
|
||||
millis,
|
||||
)?;
|
||||
if !store.compare_exchange(
|
||||
&[CultCacheExpectedEnvelope {
|
||||
r#type: IdunnDeploymentBrakeRecord::TYPE.into(),
|
||||
key: IDUNN_DEPLOYMENT_BRAKE_ID.into(),
|
||||
current,
|
||||
}],
|
||||
&[next],
|
||||
)? {
|
||||
bail!("deployment brake changed during transition");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_brake(path: &Path) -> Result<IdunnDeploymentBrakeRecord> {
|
||||
let entries = SingleFileMessagePackBackingStore::new(path).pull_all_read_only_snapshot()?;
|
||||
let [entry] = entries.as_slice() else {
|
||||
bail!("deployment brake is missing or ambiguous")
|
||||
};
|
||||
if entry.r#type != IdunnDeploymentBrakeRecord::TYPE
|
||||
|| entry.key != IDUNN_DEPLOYMENT_BRAKE_ID
|
||||
|| entry.schema_id.as_deref() != Some(IDUNN_DEPLOYMENT_BRAKE_SCHEMA)
|
||||
{
|
||||
bail!("deployment brake is foreign")
|
||||
}
|
||||
let record: IdunnDeploymentBrakeRecord = rmp_serde::from_slice(&entry.payload)?;
|
||||
if rmp_serde::to_vec(&record)? != entry.payload {
|
||||
bail!("deployment brake is noncanonical")
|
||||
}
|
||||
record.validate()?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
fn read_operator_anchor(path: &Path) -> Result<ServiceIdentityTrustAnchor> {
|
||||
let entries = SingleFileMessagePackBackingStore::new(path).pull_all_read_only_snapshot()?;
|
||||
let [entry] = entries.as_slice() else {
|
||||
bail!("operator anchor is missing or ambiguous")
|
||||
};
|
||||
if entry.r#type != IdunnDeploymentBrakeOperatorIdentity::TRUST_ANCHOR_TYPE
|
||||
|| entry.key != IdunnDeploymentBrakeOperatorIdentity::TRUST_ANCHOR_KEY
|
||||
{
|
||||
bail!("operator anchor is foreign")
|
||||
}
|
||||
Ok(rmp_serde::from_slice(&entry.payload)?)
|
||||
}
|
||||
|
||||
fn validate_health_binding_store(path: &Path) -> Result<()> {
|
||||
let entries = SingleFileMessagePackBackingStore::new(path).pull_all_read_only_snapshot()?;
|
||||
validated_health_bindings(&entries)?;
|
||||
|
|
@ -1127,7 +958,7 @@ fn normalized(path: &Path) -> Result<PathBuf> {
|
|||
}
|
||||
|
||||
fn usage() -> &'static str {
|
||||
"Usage: idunn-provision enroll-provider-health-identity --private-store <path>\n idunn-provision provider-health-public-key --private-store <path>\n idunn-provision export-provider-health-public-anchor --private-store <path> --public-anchor <path>\n idunn-provision enroll-idunn-identity --private-store <path>\n idunn-provision export-idunn-public-anchor --private-store <path> --public-anchor <path>\n idunn-provision enroll-odin-topology-identity --private-store <path>\n idunn-provision export-odin-topology-public-anchor --private-store <path> --public-anchor <path>\n idunn-provision enroll-deployment-brake-operator --private-store <path>\n idunn-provision export-deployment-brake-operator-anchor --private-store <path> --public-anchor <path>\n idunn-provision deployment-brake-engage --store <path> --runtime-id <id> --owner <principal> --reason <text> --observed-at-unix-millis <u64>\n idunn-provision deployment-brake-release --store <path> --private-store <path> --runtime-id <id> --owner <principal> --reason <text> --authorization-id <id> --release-id <id> --deployment-id <id> --issued-at-unix-millis <u64> --expires-at-unix-millis <u64>\n idunn-provision deployment-brake-status --store <path> --operator-anchor <path> --runtime-id <id> [--release-id <id> --deployment-id <id> --now-unix-millis <u64>]\n idunn-provision lifecycle-brake-engage --store <path> --runtime-id <id> --target <id> --reason <text> --updated-at-unix-millis <u64>\n idunn-provision lifecycle-brake-release --store <path> --runtime-id <id> --target <id> --reason <text> --updated-at-unix-millis <u64> [--released-until-unix-millis <u64>]\n idunn-provision lifecycle-brake-status --store <path> --runtime-id <id> --target <id> --now-unix-millis <u64>\n idunn-provision create-daemon-health-trust-binding --output <path> --binding-id <id> --daemon <id> --health-contract <id> --source-runtime <id> --signer-public-key-hex <hex> --bound-at-unix-millis <u64> --release-binding-required <true|false>\n idunn-provision add-daemon-health-trust-binding --output <path> --binding-id <id> --daemon <id> --health-contract <id> --source-runtime <id> --signer-public-key-hex <hex> --bound-at-unix-millis <u64> --release-binding-required <true|false>\n idunn-provision require-daemon-health-release-binding --store <path> --binding-id <id> --daemon <id> --health-contract <id> --source-runtime <id> --signer-public-key-hex <hex>\n idunn-provision rotate-daemon-health-trust-signer --output <path> --binding-id <id> --signer-public-key-hex <hex> --bound-at-unix-millis <u64>\n idunn-provision validate-daemon-health-trust-binding --input <path>\n idunn-provision create-provider-projection-trust-anchor --output <path> --trust-anchor-id <id> --runtime-id <id> --idunn-public-anchor <path> --bound-at-unix-millis <u64> --expires-at-unix-millis <u64>\n idunn-provision validate-provider-projection-trust-anchor --input <path> --idunn-public-anchor <path>"
|
||||
"Usage: idunn-provision enroll-provider-health-identity --private-store <path>\n idunn-provision provider-health-public-key --private-store <path>\n idunn-provision export-provider-health-public-anchor --private-store <path> --public-anchor <path>\n idunn-provision enroll-idunn-identity --private-store <path>\n idunn-provision export-idunn-public-anchor --private-store <path> --public-anchor <path>\n idunn-provision enroll-odin-topology-identity --private-store <path>\n idunn-provision export-odin-topology-public-anchor --private-store <path> --public-anchor <path>\n idunn-provision deployment-brake-engage --store <path> --runtime-id <id> --reason <text>\n idunn-provision deployment-brake-clear --store <path> --runtime-id <id> --reason <text>\n idunn-provision deployment-brake-status --store <path> --runtime-id <id>\n idunn-provision lifecycle-brake-engage --store <path> --runtime-id <id> --target <id> --reason <text> --updated-at-unix-millis <u64>\n idunn-provision lifecycle-brake-release --store <path> --runtime-id <id> --target <id> --reason <text> --updated-at-unix-millis <u64> [--released-until-unix-millis <u64>]\n idunn-provision lifecycle-brake-status --store <path> --runtime-id <id> --target <id> --now-unix-millis <u64>\n idunn-provision create-daemon-health-trust-binding --output <path> --binding-id <id> --daemon <id> --health-contract <id> --source-runtime <id> --signer-public-key-hex <hex> --bound-at-unix-millis <u64> --release-binding-required <true|false>\n idunn-provision add-daemon-health-trust-binding --output <path> --binding-id <id> --daemon <id> --health-contract <id> --source-runtime <id> --signer-public-key-hex <hex> --bound-at-unix-millis <u64> --release-binding-required <true|false>\n idunn-provision require-daemon-health-release-binding --store <path> --binding-id <id> --daemon <id> --health-contract <id> --source-runtime <id> --signer-public-key-hex <hex>\n idunn-provision rotate-daemon-health-trust-signer --output <path> --binding-id <id> --signer-public-key-hex <hex> --bound-at-unix-millis <u64>\n idunn-provision validate-daemon-health-trust-binding --input <path>\n idunn-provision create-provider-projection-trust-anchor --output <path> --trust-anchor-id <id> --runtime-id <id> --idunn-public-anchor <path> --bound-at-unix-millis <u64> --expires-at-unix-millis <u64>\n idunn-provision validate-provider-projection-trust-anchor --input <path> --idunn-public-anchor <path>"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -2157,120 +1988,74 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn brake_operator_can_issue_one_exact_bounded_rollout_grant() -> Result<()> {
|
||||
fn provision_engage_clear_sequence() -> Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
let private = temp.path().join("operator-private.cc");
|
||||
let anchor = temp.path().join("operator-public.cc");
|
||||
let store = temp.path().join("brake.cc");
|
||||
invoke(&[
|
||||
"enroll-deployment-brake-operator",
|
||||
"--private-store",
|
||||
private.to_str().unwrap(),
|
||||
])?;
|
||||
invoke(&[
|
||||
"export-deployment-brake-operator-anchor",
|
||||
"--private-store",
|
||||
private.to_str().unwrap(),
|
||||
"--public-anchor",
|
||||
anchor.to_str().unwrap(),
|
||||
])?;
|
||||
invoke(&[
|
||||
"deployment-brake-engage",
|
||||
"--store",
|
||||
store.to_str().unwrap(),
|
||||
"--runtime-id",
|
||||
"yggdrasil",
|
||||
"--owner",
|
||||
"operator/metacrat",
|
||||
"--reason",
|
||||
"sleep mode",
|
||||
"--observed-at-unix-millis",
|
||||
"100",
|
||||
])?;
|
||||
assert_eq!(read_brake(&store)?.status, "engaged");
|
||||
let me = process_uid();
|
||||
let engage = |verb: &str, reason: &str| {
|
||||
invoke(&[
|
||||
verb,
|
||||
"--store",
|
||||
store.to_str().unwrap(),
|
||||
"--runtime-id",
|
||||
"yggdrasil",
|
||||
"--reason",
|
||||
reason,
|
||||
])
|
||||
};
|
||||
assert_eq!(read_stop(&store, "yggdrasil", me), Stop::Absent);
|
||||
invoke(&[
|
||||
"deployment-brake-status",
|
||||
"--store",
|
||||
store.to_str().unwrap(),
|
||||
"--operator-anchor",
|
||||
anchor.to_str().unwrap(),
|
||||
"--runtime-id",
|
||||
"yggdrasil",
|
||||
])?;
|
||||
assert!(
|
||||
engage("deployment-brake-engage", "freeze")?;
|
||||
let status = || {
|
||||
invoke(&[
|
||||
"deployment-brake-status",
|
||||
"--store",
|
||||
store.to_str().unwrap(),
|
||||
"--operator-anchor",
|
||||
anchor.to_str().unwrap(),
|
||||
"--runtime-id",
|
||||
"yggdrasil",
|
||||
"--release-id",
|
||||
"commit-4",
|
||||
"--deployment-id",
|
||||
"request-4",
|
||||
"--now-unix-millis",
|
||||
"150",
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
invoke(&[
|
||||
"deployment-brake-release",
|
||||
"--store",
|
||||
store.to_str().unwrap(),
|
||||
"--private-store",
|
||||
private.to_str().unwrap(),
|
||||
"--runtime-id",
|
||||
"yggdrasil",
|
||||
"--owner",
|
||||
"operator/metacrat",
|
||||
"--reason",
|
||||
"one rollout",
|
||||
"--authorization-id",
|
||||
"auth/r4",
|
||||
"--release-id",
|
||||
"commit-4",
|
||||
"--deployment-id",
|
||||
"request-4",
|
||||
"--issued-at-unix-millis",
|
||||
"200",
|
||||
"--expires-at-unix-millis",
|
||||
"800",
|
||||
])?;
|
||||
invoke(&[
|
||||
"deployment-brake-status",
|
||||
"--store",
|
||||
store.to_str().unwrap(),
|
||||
"--operator-anchor",
|
||||
anchor.to_str().unwrap(),
|
||||
"--runtime-id",
|
||||
"yggdrasil",
|
||||
"--release-id",
|
||||
"commit-4",
|
||||
"--deployment-id",
|
||||
"request-4",
|
||||
"--now-unix-millis",
|
||||
"500",
|
||||
])?;
|
||||
assert!(
|
||||
invoke(&[
|
||||
"deployment-brake-status",
|
||||
"--store",
|
||||
store.to_str().unwrap(),
|
||||
"--operator-anchor",
|
||||
anchor.to_str().unwrap(),
|
||||
"--runtime-id",
|
||||
"yggdrasil",
|
||||
"--release-id",
|
||||
"substituted",
|
||||
"--deployment-id",
|
||||
"request-4",
|
||||
"--now-unix-millis",
|
||||
"500"
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
};
|
||||
assert!(status().is_err(), "an engaged stop must not pass status");
|
||||
let Stop::Engaged(first) = read_stop(&store, "yggdrasil", me) else {
|
||||
bail!("engage did not engage")
|
||||
};
|
||||
assert_eq!((first.sequence, first.reason.as_str()), (1, "freeze"));
|
||||
assert!(!first.by.is_empty() && first.at_unix_millis > 0);
|
||||
engage("deployment-brake-clear", "thawed")?;
|
||||
status()?;
|
||||
let Stop::Clear(second) = read_stop(&store, "yggdrasil", me) else {
|
||||
bail!("clear did not clear")
|
||||
};
|
||||
assert_eq!((second.sequence, second.by), (2, first.by));
|
||||
assert!(second.at_unix_millis >= first.at_unix_millis);
|
||||
// The author and the time are not arguments, and the retired
|
||||
// signing arguments are refused.
|
||||
for stray in ["--owner", "--observed-at-unix-millis", "--private-store"] {
|
||||
assert!(
|
||||
invoke(&[
|
||||
"deployment-brake-engage",
|
||||
"--store",
|
||||
store.to_str().unwrap(),
|
||||
"--runtime-id",
|
||||
"yggdrasil",
|
||||
"--reason",
|
||||
"x",
|
||||
stray,
|
||||
"y",
|
||||
])
|
||||
.is_err(),
|
||||
"{stray}"
|
||||
);
|
||||
}
|
||||
for gone in ["deployment-brake-release", "enroll-deployment-brake-operator"] {
|
||||
assert!(invoke(&[gone]).is_err(), "{gone}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -2309,7 +2094,6 @@ mod tests {
|
|||
assert_eq!(engaged.status, "engaged");
|
||||
assert_eq!(engaged.runtime_id, "idunn-yggdrasil");
|
||||
assert_eq!(engaged.target, "ghostlight");
|
||||
assert!(read_brake(&store).is_err());
|
||||
assert!(
|
||||
invoke(&[
|
||||
"lifecycle-brake-status",
|
||||
|
|
@ -2426,57 +2210,4 @@ mod tests {
|
|||
assert_eq!(std::fs::read(&store)?, released_bytes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brake_compare_exchange_rejects_stale_transition_snapshot() -> Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
let store_path = temp.path().join("brake.cc");
|
||||
let base = IdunnDeploymentBrakeRecord {
|
||||
schema_version: IDUNN_DEPLOYMENT_BRAKE_SCHEMA.into(),
|
||||
brake_id: IDUNN_DEPLOYMENT_BRAKE_ID.into(),
|
||||
authority: IDUNN_DEPLOYMENT_BRAKE_AUTHORITY.into(),
|
||||
runtime_id: "yggdrasil".into(),
|
||||
status: "engaged".into(),
|
||||
scope: IDUNN_DEPLOYMENT_BRAKE_SCOPE.into(),
|
||||
reason: "first".into(),
|
||||
observed_at_unix_millis: 100,
|
||||
expires_at_unix_millis: None,
|
||||
authorization_id: None,
|
||||
authorization_purpose: None,
|
||||
authorized_release_id: None,
|
||||
authorized_deployment_id: None,
|
||||
authorized_by: None,
|
||||
authorization_issued_at_unix_millis: None,
|
||||
authorization_expires_at_unix_millis: None,
|
||||
signature_algorithm: None,
|
||||
signature: None,
|
||||
private_state_exposed: false,
|
||||
updated_by: "operator/a".into(),
|
||||
};
|
||||
replace_brake(&store_path, base.clone(), 100)?;
|
||||
let stale = SingleFileMessagePackBackingStore::new(&store_path)
|
||||
.pull_all_read_only_snapshot()?
|
||||
.remove(0);
|
||||
let mut changed = base.clone();
|
||||
changed.reason = "racing writer".into();
|
||||
changed.observed_at_unix_millis = 101;
|
||||
replace_brake(&store_path, changed, 101)?;
|
||||
let candidate = typed_envelope(
|
||||
IDUNN_DEPLOYMENT_BRAKE_ID,
|
||||
&base,
|
||||
IDUNN_DEPLOYMENT_BRAKE_SCHEMA,
|
||||
102,
|
||||
)?;
|
||||
assert!(
|
||||
!SingleFileMessagePackBackingStore::new(&store_path).compare_exchange(
|
||||
&[CultCacheExpectedEnvelope {
|
||||
r#type: IdunnDeploymentBrakeRecord::TYPE.into(),
|
||||
key: IDUNN_DEPLOYMENT_BRAKE_ID.into(),
|
||||
current: Some(stale)
|
||||
}],
|
||||
&[candidate]
|
||||
)?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue