Steward-owned second deployment stop named by the binding #5
6 changed files with 471 additions and 41 deletions
|
|
@ -318,6 +318,57 @@ 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.
|
||||
|
||||
A binding may name a second stop, the target's steward:
|
||||
|
||||
```toml
|
||||
[brakes.steward]
|
||||
user = "mind-streampixels" # a login, never root
|
||||
store = "/var/lib/gamecult/idunn-steward/streampixels/deployment-brake.cc"
|
||||
```
|
||||
|
||||
Deploys admit only when every stop admits. The steward's store is read as owned
|
||||
by that user's uid: the owner uid proves who wrote the file, and the wait note
|
||||
names the stop by the binding's steward user, never by the record's `by` (which
|
||||
is the verb's own text). Idunn never writes the steward directory and no key,
|
||||
group or ACL is involved. The operator's store is a separate stop the steward
|
||||
cannot clear, and the wait note names each holding stop.
|
||||
|
||||
The steward owns its directory, so it could unlink any file in it. Binding
|
||||
validation therefore refuses a steward store whose directory is shared with,
|
||||
inside or around the directory of any other Idunn authority store (deployment
|
||||
and lifecycle brakes, trust anchor, write-lease record, release authority).
|
||||
Validation cannot see the file system: the root who provisions the directory
|
||||
makes it contain the steward's store and nothing else.
|
||||
|
||||
A steward store owned by another uid, or a steward user that does not resolve
|
||||
to a non-root uid, is unreadable and fails closed. Only the steward (the
|
||||
owner uid) can repair it: a clear run as root leaves a root-owned file that is
|
||||
still unreadable.
|
||||
|
||||
### Provisioning a steward (root, once per target)
|
||||
|
||||
1. Create the store directory, owned by the steward and holding nothing else:
|
||||
`install -d -o mind-streampixels -g mind-streampixels -m 0755
|
||||
/var/lib/gamecult/idunn-steward/streampixels`. It must not be, contain or sit
|
||||
inside any other authority directory, and no workload `ReadWritePaths` may
|
||||
cover it. Idunn (root) reads it without further grants; no group or ACL.
|
||||
2. Add the stanza above to the target's operator binding, after `[brakes]`,
|
||||
with the steward's real login. Deploys planned afterwards see it.
|
||||
3. The steward engages and clears as itself, never through root:
|
||||
`idunn-provision deployment-brake-engage --store <store> --runtime-id <id>
|
||||
--reason <text>` and `deployment-brake-clear` with the same flags.
|
||||
4. The operator reads it with `idunn-provision deployment-brake-status --store
|
||||
<store> --runtime-id <id> --owner-user mind-streampixels`.
|
||||
|
||||
The binding is sealed into each plan. A deploy already planned keeps the
|
||||
binding it was sealed with: editing the stanza does not apply to it. If the
|
||||
steward's login is deleted or its directory is lost, that deploy stays
|
||||
unreadable-held at Sealing (or the static flip intent). `idunn cancel` withdraws
|
||||
only a deploy held for a readiness disagreement before Fencing, and `idunn
|
||||
expire` only one in Warming through Committing, so neither frees a deploy held
|
||||
by the stop. Recovery is to restore the steward's login or recreate the
|
||||
directory owned by it (and have the steward clear its store).
|
||||
|
||||
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
|
||||
|
|
|
|||
202
src/brake.rs
202
src/brake.rs
|
|
@ -11,6 +11,8 @@
|
|||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::deployment::BrakeBinding;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail, ensure};
|
||||
use cultcache_rs::{CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -128,6 +130,72 @@ impl Stop {
|
|||
}
|
||||
}
|
||||
|
||||
/// One stop a binding names, with how to name it in a wait note.
|
||||
pub(crate) struct NamedStop {
|
||||
pub(crate) label: String,
|
||||
/// The login the file system proves wrote the store: the steward user, whose
|
||||
/// uid owns the file. None for the operator's store, whose record carries
|
||||
/// only the verb's own claim of `by`.
|
||||
pub(crate) proven_writer: Option<String>,
|
||||
pub(crate) stop: Stop,
|
||||
}
|
||||
|
||||
impl NamedStop {
|
||||
/// The wait-note line for a stop that does not admit; None when it admits.
|
||||
/// A steward stop is named by the proven owner, never by the record's `by`.
|
||||
pub(crate) fn note(&self) -> Option<String> {
|
||||
let label = &self.label;
|
||||
match &self.stop {
|
||||
Stop::Absent | Stop::Clear(_) => None,
|
||||
Stop::Engaged(held) => Some(match &self.proven_writer {
|
||||
Some(_) => format!("{label} engaged: {}", held.reason),
|
||||
None => format!("{label} engaged by {}: {}", held.by, held.reason),
|
||||
}),
|
||||
Stop::Unreadable(why) => Some(format!("{label} is unreadable and fails closed: {why}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every stop a binding names, each with the words that identify its store in
|
||||
/// a wait note: the operator's, then the steward's when the binding names one.
|
||||
/// Deployments admit iff every one admits. The operator's store is read as
|
||||
/// owned by this process's uid; the steward's as owned by the steward user's
|
||||
/// uid, so the file system proves who wrote it. A steward that does not
|
||||
/// resolve to a non-root uid is an unreadable stop, never an absent one.
|
||||
pub(crate) fn read_stops(brakes: &BrakeBinding, runtime_id: &str) -> Vec<NamedStop> {
|
||||
let mut stops = vec![NamedStop {
|
||||
label: "deployment brake".to_string(),
|
||||
proven_writer: None,
|
||||
stop: read_stop(&brakes.deployment_store, runtime_id, process_uid()),
|
||||
}];
|
||||
if let Some(steward) = &brakes.steward {
|
||||
let label = format!("steward {} deployment brake", steward.user);
|
||||
let stop = match crate::steward_orders::user_uid(&steward.user) {
|
||||
Ok(uid) => read_stop_with_remedy(&steward.store, runtime_id, uid, |path| {
|
||||
// Only the owner uid can mend it: a clear run as root leaves a
|
||||
// root-owned file, which this reader refuses again.
|
||||
format!(
|
||||
"only {} (the store's owner uid) can repair it, by running as that user: \
|
||||
idunn-provision deployment-brake-clear --store {} --runtime-id {runtime_id} \
|
||||
--reason <text>; a clear run as root stays unreadable",
|
||||
steward.user,
|
||||
path.display()
|
||||
)
|
||||
}),
|
||||
Err(why) => Stop::Unreadable(format!(
|
||||
"{why:#}; the binding's steward user {} must be a non-root login",
|
||||
steward.user
|
||||
)),
|
||||
};
|
||||
stops.push(NamedStop {
|
||||
label,
|
||||
proven_writer: Some(steward.user.clone()),
|
||||
stop,
|
||||
});
|
||||
}
|
||||
stops
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn process_uid() -> u32 {
|
||||
// SAFETY: geteuid has no failure mode and no arguments.
|
||||
|
|
@ -140,7 +208,7 @@ pub(crate) fn process_uid() -> u32 {
|
|||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn login_name(uid: u32) -> Result<String> {
|
||||
pub(crate) 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 {
|
||||
|
|
@ -153,7 +221,7 @@ fn login_name(uid: u32) -> Result<String> {
|
|||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn login_name(_: u32) -> Result<String> {
|
||||
pub(crate) fn login_name(_: u32) -> Result<String> {
|
||||
std::env::var("USERNAME").context("USERNAME is unset")
|
||||
}
|
||||
|
||||
|
|
@ -237,15 +305,26 @@ fn read_checked(path: &Path, runtime_id: &str, owner: u32) -> Result<Option<Depl
|
|||
}
|
||||
|
||||
pub(crate) fn read_stop(path: &Path, runtime_id: &str, expected_owner_uid: u32) -> Stop {
|
||||
read_stop_with_remedy(path, runtime_id, expected_owner_uid, |path| {
|
||||
format!(
|
||||
"to replace it run: idunn-provision deployment-brake-clear \
|
||||
--store {} --runtime-id {runtime_id} --reason <text> (or deployment-brake-engage)",
|
||||
path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn read_stop_with_remedy(
|
||||
path: &Path,
|
||||
runtime_id: &str,
|
||||
expected_owner_uid: u32,
|
||||
remedy: impl FnOnce(&Path) -> String,
|
||||
) -> 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()
|
||||
)),
|
||||
Err(error) => Stop::Unreadable(format!("{error:#}; {}", remedy(path))),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -383,6 +462,115 @@ mod tests {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// A binding whose steward is the user running the test, so the steward's
|
||||
/// store can really be owned by the steward. None when run as root, which
|
||||
/// no steward may be.
|
||||
fn binding_with_steward(dir: &Path, user: &str) -> BrakeBinding {
|
||||
BrakeBinding {
|
||||
deployment_store: dir.join("operator.cc"),
|
||||
lifecycle_store: dir.join("lifecycle.cc"),
|
||||
steward: Some(crate::deployment::StewardBrakeBinding {
|
||||
user: user.into(),
|
||||
store: dir.join("steward.cc"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn admits_all(stops: &[NamedStop]) -> bool {
|
||||
stops.iter().all(|named| named.stop.admits())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_steward_store_is_a_second_stop_and_the_operator_store_is_one_it_cannot_clear() -> Result<()> {
|
||||
let me = process_uid();
|
||||
if me == 0 {
|
||||
eprintln!("SKIPPED: a steward is never root, and this run is root");
|
||||
return Ok(());
|
||||
}
|
||||
let dir = tempfile::tempdir()?;
|
||||
let binding = binding_with_steward(dir.path(), &login_name(me)?);
|
||||
let (operator, steward) = (&binding.deployment_store, &binding.steward.as_ref().unwrap().store);
|
||||
// Both absent: admits.
|
||||
assert!(admits_all(&read_stops(&binding, "runtime")));
|
||||
// Steward engaged alone: halts, and the stop names the steward's store.
|
||||
replace_stop(steward, "runtime", StopState::Engaged, "steward freeze", 10)?;
|
||||
let stops = read_stops(&binding, "runtime");
|
||||
assert!(!admits_all(&stops));
|
||||
assert!(matches!(&stops[1].stop, Stop::Engaged(held) if held.reason == "steward freeze"));
|
||||
// The note names the proven owner, and never the record's claimed `by`.
|
||||
assert_eq!(
|
||||
stops[1].note().unwrap(),
|
||||
format!("steward {} deployment brake engaged: steward freeze", login_name(me)?)
|
||||
);
|
||||
// Both engaged, then the steward clears its own: the operator's holds.
|
||||
replace_stop(operator, "runtime", StopState::Engaged, "operator hold", 11)?;
|
||||
replace_stop(steward, "runtime", StopState::Clear, "steward thaw", 12)?;
|
||||
let stops = read_stops(&binding, "runtime");
|
||||
assert!(matches!(&stops[0].stop, Stop::Engaged(_)) && matches!(&stops[1].stop, Stop::Clear(_)));
|
||||
assert!(!admits_all(&stops));
|
||||
// The other order: operator clear, steward engaged still halts.
|
||||
replace_stop(operator, "runtime", StopState::Clear, "operator thaw", 13)?;
|
||||
replace_stop(steward, "runtime", StopState::Engaged, "steward again", 14)?;
|
||||
assert!(!admits_all(&read_stops(&binding, "runtime")));
|
||||
// Both clear: admits.
|
||||
replace_stop(steward, "runtime", StopState::Clear, "steward thaw", 15)?;
|
||||
assert!(admits_all(&read_stops(&binding, "runtime")));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steward_store_owned_by_another_uid_stops() -> Result<()> {
|
||||
let me = process_uid();
|
||||
let dir = tempfile::tempdir()?;
|
||||
// Steward is "nobody" (a real non-root user that is not the writer);
|
||||
// the file is written by this process, so even a Clear record fails.
|
||||
if me == 65534 || user_missing("nobody") {
|
||||
eprintln!("SKIPPED: needs a user `nobody` other than the caller");
|
||||
return Ok(());
|
||||
}
|
||||
let binding = binding_with_steward(dir.path(), "nobody");
|
||||
let steward = &binding.steward.as_ref().unwrap().store;
|
||||
replace_stop(steward, "runtime", StopState::Clear, "forged clear", 10)?;
|
||||
let stops = read_stops(&binding, "runtime");
|
||||
assert!(matches!(&stops[1].stop, Stop::Unreadable(why) if why.contains("is owned by uid")
|
||||
&& why.contains("only nobody (the store's owner uid) can repair it")
|
||||
&& why.contains("stays unreadable")
|
||||
&& why.contains("idunn-provision deployment-brake-clear")));
|
||||
// The operator's stop keeps the operator's remedy.
|
||||
replace_stop(&binding.deployment_store, "other-runtime", StopState::Clear, "x", 11)?;
|
||||
assert!(matches!(&read_stops(&binding, "runtime")[0].stop, Stop::Unreadable(why)
|
||||
if why.contains("to replace it run:") && !why.contains("only ")));
|
||||
assert!(!admits_all(&stops));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn user_missing(name: &str) -> bool {
|
||||
crate::steward_orders::user_uid(name).is_err()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unresolvable_or_root_steward_user_is_an_unreadable_stop_not_an_absent_one() -> Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
for user in ["no-such-login-zz", "root"] {
|
||||
let binding = binding_with_steward(dir.path(), user);
|
||||
let stops = read_stops(&binding, "runtime");
|
||||
assert!(matches!(&stops[1].stop, Stop::Unreadable(_)), "{user}");
|
||||
assert!(!admits_all(&stops));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_binding_without_a_steward_reads_only_the_operator_store() -> Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let mut binding = binding_with_steward(dir.path(), "nobody");
|
||||
binding.steward = None;
|
||||
let stops = read_stops(&binding, "runtime");
|
||||
assert_eq!(stops.len(), 1);
|
||||
assert!(admits_all(&stops));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn store_entry(path: &Path) -> Result<CultCacheEnvelope> {
|
||||
let mut entries = SingleFileMessagePackBackingStore::new(path).pull_all_read_only_snapshot()?;
|
||||
entries.pop().context("no entry")
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ use serde::{Deserialize, Serialize};
|
|||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::brake::{Stop, process_uid, read_stop};
|
||||
use crate::brake::{NamedStop, read_stops};
|
||||
#[cfg(test)]
|
||||
use crate::brake::StopState;
|
||||
use crate::deployment::{
|
||||
|
|
@ -9345,27 +9345,26 @@ impl Engine {
|
|||
/// Read live at each point that is still reversible: Sealing, the static
|
||||
/// flip intent and the entry into Fencing.
|
||||
fn stop_note(&self, current: &Stored<DeploymentTransaction>) -> Result<Option<String>> {
|
||||
Ok(match self.deployment_stop(current)? {
|
||||
Stop::Absent | Stop::Clear(_) => None,
|
||||
Stop::Engaged(held) => Some(format!(
|
||||
"deployment brake engaged by {}: {}",
|
||||
held.by, held.reason
|
||||
)),
|
||||
Stop::Unreadable(why) => Some(format!(
|
||||
"deployment brake is unreadable and fails closed: {why}"
|
||||
)),
|
||||
})
|
||||
let notes: Vec<String> = self
|
||||
.deployment_stops(current)?
|
||||
.iter()
|
||||
.filter_map(NamedStop::note)
|
||||
.collect();
|
||||
Ok((!notes.is_empty()).then(|| notes.join("; ")))
|
||||
}
|
||||
|
||||
/// What the binding's deployment stop says about this transaction's
|
||||
/// runtime. Read live; never cached, never consumed.
|
||||
fn deployment_stop(&self, current: &Stored<DeploymentTransaction>) -> Result<Stop> {
|
||||
/// What the binding's stops say about this transaction's runtime: the
|
||||
/// operator's store and, when the binding names one, the steward's. Read
|
||||
/// live; never cached, never consumed.
|
||||
fn deployment_stops(
|
||||
&self,
|
||||
current: &Stored<DeploymentTransaction>,
|
||||
) -> Result<Vec<NamedStop>> {
|
||||
let plan = required(¤t.value.plan, "transaction plan")?;
|
||||
let binding = plan.parsed_inputs()?.1;
|
||||
Ok(read_stop(
|
||||
&binding.brakes.deployment_store,
|
||||
Ok(read_stops(
|
||||
&binding.brakes,
|
||||
&binding.runtime_identity.runtime_id,
|
||||
process_uid(),
|
||||
))
|
||||
}
|
||||
|
||||
|
|
@ -9377,7 +9376,8 @@ impl Engine {
|
|||
/// hold is a reason to leave a serving or retrying body alone.
|
||||
fn deploy_can_move(&self, deploy: &Stored<DeploymentTransaction>) -> bool {
|
||||
deploy.value.deployment_admitted()
|
||||
|| self.deployment_stop(deploy).is_ok_and(|stop| stop.admits())
|
||||
|| self.deployment_stops(deploy)
|
||||
.is_ok_and(|stops| stops.iter().all(|named| named.stop.admits()))
|
||||
}
|
||||
|
||||
/// The deployment a continuity gives way to: one on its target that awaits
|
||||
|
|
@ -26298,6 +26298,13 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide
|
|||
/// that seals it, and a stand-in site beside it.
|
||||
#[cfg(unix)]
|
||||
fn static_world() -> Result<StaticWorld> {
|
||||
static_world_with_steward(None)
|
||||
}
|
||||
|
||||
/// The static world, its binding naming `steward` (a login) as a second
|
||||
/// stop whose store is `steward/brake.cc`.
|
||||
#[cfg(unix)]
|
||||
fn static_world_with_steward(steward: Option<&str>) -> Result<StaticWorld> {
|
||||
use crate::deployment::tests::{STATIC_BINDING, STATIC_RECIPE};
|
||||
let tools = TempDir::new()?;
|
||||
let tree = tools.path().join("tree");
|
||||
|
|
@ -26311,13 +26318,24 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide
|
|||
STATIC_RECIPE,
|
||||
|root| {
|
||||
site = root.join("site");
|
||||
STATIC_BINDING.replace("@SITE@", &site.display().to_string())
|
||||
let text = STATIC_BINDING.replace("@SITE@", &site.display().to_string());
|
||||
match steward {
|
||||
None => text,
|
||||
Some(user) => text.replace(
|
||||
"[rollout]",
|
||||
&format!(
|
||||
"[brakes.steward]\nuser = \"{user}\"\nstore = \"{}/steward/brake.cc\"\n\n[rollout]",
|
||||
site.display()
|
||||
),
|
||||
),
|
||||
}
|
||||
},
|
||||
Arc::new(InstallingWorkload),
|
||||
)?;
|
||||
std::fs::create_dir_all(site.join("www"))?;
|
||||
std::fs::create_dir_all(site.join("config"))?;
|
||||
std::fs::create_dir_all(site.join("authority"))?;
|
||||
std::fs::create_dir_all(site.join("steward"))?;
|
||||
std::fs::write(site.join("config/site.json"), SITE_CONFIG_ONE)?;
|
||||
*world.engine.runner_stand_in.lock().unwrap() =
|
||||
Some(Arc::new(SealingRunner { tree: tree.clone() }));
|
||||
|
|
@ -26451,6 +26469,61 @@ GAMECULT_RUNTIME_PRESENCE_IDENTITY = "/etc/gamecult/service/runtime-presence-ide
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_steward_stop_holds_a_deploy_beside_the_operators_and_names_who() -> Result<()> {
|
||||
let me = crate::brake::process_uid();
|
||||
if me == 0 {
|
||||
eprintln!("SKIPPED: a steward is never root, and this run is root");
|
||||
return Ok(());
|
||||
}
|
||||
let user = crate::brake::login_name(me)?;
|
||||
let w = static_world_with_steward(Some(&user))?;
|
||||
let steward_store = w.site.join("steward/brake.cc");
|
||||
let op_store = w.site.join("authority/deployment-brake.cc");
|
||||
let stop = |store: &Path, state, reason: &str| -> Result<()> {
|
||||
crate::brake::replace_stop(store, "site-yggdrasil", state, reason, now_millis()?)?;
|
||||
Ok(())
|
||||
};
|
||||
stop(&steward_store, StopState::Engaged, "steward hold")?;
|
||||
let id = w.transaction_of("up-service")?;
|
||||
w.tick_until(&id, "the sealed release", |t| t.installed_release.is_some())?;
|
||||
let settle = |w: &StaticWorld| -> Result<DeploymentTransaction> {
|
||||
for _ in 0..2 {
|
||||
w.world.engine.resume_backoff.lock().unwrap().clear();
|
||||
let _ = w.world.engine.run_scheduler_tick();
|
||||
}
|
||||
latest_id(&w.world, &id)
|
||||
};
|
||||
let held = settle(&w)?;
|
||||
assert_eq!(held.phase, DeploymentPhase::Sealing);
|
||||
assert_eq!(
|
||||
held.last_error.as_deref(),
|
||||
Some(format!("steward {user} deployment brake engaged: steward hold").as_str())
|
||||
);
|
||||
// The operator engages too; the steward clears its own. The operator's holds.
|
||||
stop(&op_store, StopState::Engaged, "operator hold")?;
|
||||
stop(&steward_store, StopState::Clear, "steward thaw")?;
|
||||
let held = settle(&w)?;
|
||||
assert!(!held.deployment_admitted());
|
||||
assert_eq!(
|
||||
held.last_error.as_deref(),
|
||||
Some(format!("deployment brake engaged by {user}: operator hold").as_str())
|
||||
);
|
||||
// Both engaged: both named.
|
||||
stop(&steward_store, StopState::Engaged, "steward again")?;
|
||||
let held = settle(&w)?;
|
||||
assert!(held.last_error.as_deref().is_some_and(|note| note.contains("operator hold")
|
||||
&& note.contains("steward again")));
|
||||
// The operator clears, the steward still holds; then both clear.
|
||||
stop(&op_store, StopState::Clear, "operator thaw")?;
|
||||
assert!(!settle(&w)?.deployment_admitted());
|
||||
stop(&steward_store, StopState::Clear, "steward thaw")?;
|
||||
w.tick_until(&id, "the end", |t| t.is_terminal())?;
|
||||
assert!(latest_id(&w.world, &id)?.deployment_admitted());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn one_clear_admits_many_deploys_and_an_engaged_stop_names_who() -> Result<()> {
|
||||
|
|
|
|||
|
|
@ -753,6 +753,20 @@ impl ProcessWriteLeaseBinding {
|
|||
pub struct BrakeBinding {
|
||||
pub deployment_store: PathBuf,
|
||||
pub lifecycle_store: PathBuf,
|
||||
/// A second deployment stop, written by the target's steward in a
|
||||
/// directory only the steward can write. Absent means the operator's store
|
||||
/// is the only stop.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub steward: Option<StewardBrakeBinding>,
|
||||
}
|
||||
|
||||
/// The steward's own stop: the file's owner uid proves who wrote it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct StewardBrakeBinding {
|
||||
/// Login name; must resolve to a non-root uid when the store is read.
|
||||
pub user: String,
|
||||
pub store: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -1430,6 +1444,39 @@ impl OperatorBinding {
|
|||
self.brakes.deployment_store != self.brakes.lifecycle_store,
|
||||
"deployment and lifecycle brakes are identical"
|
||||
);
|
||||
if let Some(steward) = &self.brakes.steward {
|
||||
require_absolute_path(&steward.store, "steward deployment brake")?;
|
||||
ensure!(
|
||||
crate::steward_orders::login_grammar_ok(&steward.user) && steward.user != "root",
|
||||
"steward brake user is invalid or root"
|
||||
);
|
||||
ensure!(
|
||||
steward.store != self.brakes.deployment_store
|
||||
&& steward.store != self.brakes.lifecycle_store,
|
||||
"steward brake store is identical to another brake store"
|
||||
);
|
||||
// The steward owns its store's directory, so it could unlink any
|
||||
// file there. Every other authority store must therefore live
|
||||
// outside that directory, and the steward's directory must be
|
||||
// clear of every directory an operator store lives in.
|
||||
let steward_dir = steward.store.parent().unwrap_or(&steward.store);
|
||||
let others = [
|
||||
Some(&self.brakes.deployment_store),
|
||||
Some(&self.brakes.lifecycle_store),
|
||||
Some(&self.runtime_identity.trust_anchor_store),
|
||||
self.process_write_lease.as_ref().map(|lease| &lease.record_path),
|
||||
self.repository.release_authority_store.as_ref(),
|
||||
];
|
||||
for other in others.into_iter().flatten() {
|
||||
let other_dir = other.parent().unwrap_or(other);
|
||||
ensure!(
|
||||
!paths_overlap(steward_dir, other_dir),
|
||||
"steward brake directory {} is shared with or nested in the directory of Idunn authority {}: the steward owns its directory and could remove that file",
|
||||
steward_dir.display(),
|
||||
other.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
self.workload.validate_launch_environment()?;
|
||||
let workload = match &self.workload {
|
||||
WorkloadBinding::HostActuator(workload) => {
|
||||
|
|
@ -1462,7 +1509,10 @@ impl OperatorBinding {
|
|||
&self.brakes.deployment_store,
|
||||
&self.brakes.lifecycle_store,
|
||||
&self.runtime_identity.trust_anchor_store,
|
||||
] {
|
||||
]
|
||||
.into_iter()
|
||||
.chain(self.brakes.steward.as_ref().map(|steward| &steward.store))
|
||||
{
|
||||
ensure!(
|
||||
!paths_overlap(authority, &workload.release_root)
|
||||
&& !paths_overlap(authority, &workload.current_link),
|
||||
|
|
@ -1531,6 +1581,7 @@ impl OperatorBinding {
|
|||
self.brakes.deployment_store.clone(),
|
||||
self.brakes.lifecycle_store.clone(),
|
||||
];
|
||||
authority_paths.extend(self.brakes.steward.as_ref().map(|steward| steward.store.clone()));
|
||||
if let Some(write_lease) = &self.process_write_lease {
|
||||
authority_paths.push(write_lease.record_path.clone());
|
||||
authority_paths.push(write_lease.lock_path());
|
||||
|
|
@ -3065,6 +3116,65 @@ nodes = ["yggdrasil"]
|
|||
text
|
||||
}
|
||||
|
||||
const STEWARD: &str = "\n[brakes.steward]\nuser = \"mind-streampixels\"\nstore = \"/var/lib/gamecult/idunn-steward/streampixels/deployment-brake.cc\"\n";
|
||||
|
||||
#[test]
|
||||
fn binding_without_steward_parses_and_admits_on_operator_store_only() {
|
||||
for text in [BINDING.to_string(), static_binding(&[])] {
|
||||
assert_eq!(OperatorBinding::parse(&text).unwrap().brakes.steward, None);
|
||||
}
|
||||
let steward = OperatorBinding::parse(&BINDING.replace("\n[rollout]", &format!("{STEWARD}\n[rollout]")))
|
||||
.unwrap()
|
||||
.brakes
|
||||
.steward
|
||||
.unwrap();
|
||||
assert_eq!(steward.user, "mind-streampixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_steward_brake_must_be_a_distinct_absolute_non_root_login_and_clear_of_the_workload() {
|
||||
let with = |steward: &str| BINDING.replace("\n[rollout]", &format!("{steward}\n[rollout]"));
|
||||
let store = "/var/lib/gamecult/idunn-steward/x/deployment-brake.cc";
|
||||
for bad in [
|
||||
format!("\n[brakes.steward]\nuser = \"root\"\nstore = \"{store}\"\n"),
|
||||
format!("\n[brakes.steward]\nuser = \"Bad User\"\nstore = \"{store}\"\n"),
|
||||
"\n[brakes.steward]\nuser = \"mind\"\nstore = \"relative.cc\"\n".into(),
|
||||
"\n[brakes.steward]\nuser = \"mind\"\nstore = \"/var/lib/gamecult/idunn-authority/test-service-deployment-brake.cc\"\n".into(),
|
||||
"\n[brakes.steward]\nuser = \"mind\"\nstore = \"/var/lib/gamecult/idunn-authority/test-service-lifecycle-brake.cc\"\n".into(),
|
||||
format!("\n[brakes.steward]\nuser = \"mind\"\nstore = \"{store}\"\nextra = 1\n"),
|
||||
] {
|
||||
assert!(OperatorBinding::parse(&with(&bad)).is_err(), "{bad}");
|
||||
}
|
||||
// A steward directory shared with, or enclosing, an operator store
|
||||
// lets the steward unlink a root-owned file: refused.
|
||||
for (deployment, steward_store) in [
|
||||
("/var/lib/gamecult/steward-x/op.cc", "/var/lib/gamecult/steward-x/s.cc"),
|
||||
("/var/lib/gamecult/steward-x/sub/op.cc", "/var/lib/gamecult/steward-x/s.cc"),
|
||||
("/var/lib/gamecult/op.cc", "/var/lib/gamecult/steward-x/s.cc"),
|
||||
] {
|
||||
let shared = BINDING
|
||||
.replace("/var/lib/gamecult/idunn-authority/test-service-deployment-brake.cc", deployment)
|
||||
.replace("\n[rollout]", &format!("\n[brakes.steward]\nuser = \"mind\"\nstore = \"{steward_store}\"\n\n[rollout]"));
|
||||
assert!(OperatorBinding::parse(&shared).is_err(), "{deployment} {steward_store}");
|
||||
}
|
||||
// Disjoint directories are the one admitted shape.
|
||||
let disjoint = BINDING.replace(
|
||||
"\n[rollout]",
|
||||
"\n[brakes.steward]\nuser = \"mind\"\nstore = \"/var/lib/gamecult/steward-x/s.cc\"\n\n[rollout]",
|
||||
);
|
||||
OperatorBinding::parse(&disjoint).unwrap();
|
||||
// steward_store_overlap_with_workload_path_refused
|
||||
let overlapping = BINDING
|
||||
.replace("\n[rollout]", &format!("\n[brakes.steward]\nuser = \"mind\"\nstore = \"{store}\"\n\n[rollout]"))
|
||||
.replace(
|
||||
"cpu_quota_percent = 200\n\n[workload.argument_bindings]",
|
||||
"cpu_quota_percent = 200\nread_write_paths = [\"/var/lib/gamecult/idunn-steward/x\"]\n\n[workload.argument_bindings]",
|
||||
);
|
||||
assert!(OperatorBinding::parse(&overlapping).is_err());
|
||||
let static_overlap = static_binding(&[("[rollout]", "[brakes.steward]\nuser = \"mind\"\nstore = \"/srv/site/releases/x/brake.cc\"\n\n[rollout]")]);
|
||||
assert!(OperatorBinding::parse(&static_overlap).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_recipe_and_binding_admit() {
|
||||
let recipe = TargetDeclaration::parse(STATIC_RECIPE).unwrap();
|
||||
|
|
|
|||
|
|
@ -176,12 +176,14 @@ fn deployment_brake_write(options: &BTreeMap<String, String>, state: StopState)
|
|||
/// 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(),
|
||||
);
|
||||
allow_only(options, &["store", "runtime-id", "owner-user"])?;
|
||||
// A steward's store is owned by the steward: the operator names that user
|
||||
// to read it, and the owner check is the same one Idunn makes.
|
||||
let owner = match options.get("owner-user") {
|
||||
Some(user) => crate::steward_orders::user_uid(user)?,
|
||||
None => process_uid(),
|
||||
};
|
||||
let stop = read_stop(&path(options, "store")?, required(options, "runtime-id")?, owner);
|
||||
match &stop {
|
||||
Stop::Absent => println!("absent: deployments admit"),
|
||||
Stop::Unreadable(why) => println!("unreadable: deployments wait ({why})"),
|
||||
|
|
@ -958,7 +960,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 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>"
|
||||
"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> [--owner-user <login>]\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)]
|
||||
|
|
|
|||
|
|
@ -1528,12 +1528,7 @@ impl StewardBinding {
|
|||
!self.principal.starts_with('-') && !self.principal.contains('/'),
|
||||
"steward principal is invalid"
|
||||
);
|
||||
let user_ok = !self.user.is_empty()
|
||||
&& self.user.len() <= 32
|
||||
&& self.user.bytes().all(|byte| {
|
||||
byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-')
|
||||
})
|
||||
&& !self.user.starts_with(|first: char| first.is_ascii_digit() || first == '-');
|
||||
let user_ok = login_grammar_ok(&self.user);
|
||||
ensure!(user_ok && self.user != "root", "steward user is invalid or root");
|
||||
ensure!(
|
||||
self.mind_control.is_absolute(),
|
||||
|
|
@ -1581,7 +1576,18 @@ fn load_binding(path: &Path) -> Result<StewardBinding> {
|
|||
StewardBinding::parse(&text)
|
||||
}
|
||||
|
||||
fn user_uid(name: &str) -> Result<u32> {
|
||||
/// A login name Idunn will resolve: the one grammar for every binding that names
|
||||
/// a user.
|
||||
pub(crate) fn login_grammar_ok(user: &str) -> bool {
|
||||
!user.is_empty()
|
||||
&& user.len() <= 32
|
||||
&& user.bytes().all(|byte| {
|
||||
byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-')
|
||||
})
|
||||
&& !user.starts_with(|first: char| first.is_ascii_digit() || first == '-')
|
||||
}
|
||||
|
||||
pub(crate) fn user_uid(name: &str) -> Result<u32> {
|
||||
let name = std::ffi::CString::new(name)?;
|
||||
// SAFETY: `passwd` is plain data and getpwnam_r fills it, with strings in
|
||||
// `buffer`, which outlives every read of them below (none are read).
|
||||
|
|
|
|||
Loading…
Reference in a new issue