Steward orders: typed package, unit and deploy orders from one steward, in a sibling root unit
idunn orders serve takes orders over a root-only socket from the steward uid, records order/start/outcome/post in its own orders.cc, and posts acceptance and result to #operator as a retried projection. Unit orders may act on any unit except the compiled always-refused set (idunn-*, mind-self@*, mind-persona@*), matched on a unit's Id and every name, and for stop and restart on the units systemd stops with it. A refusal names a field and a code, never the value. A started order is never run twice. Deploy orders commit through the same primitive as idunn up. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
024f2d7a09
commit
e50a721dbc
6 changed files with 3036 additions and 16 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -482,6 +482,7 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"rmp-serde",
|
"rmp-serde",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"toml",
|
"toml",
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ serde = { version = "1", features = ["derive"] }
|
||||||
ed25519-dalek = "2.2"
|
ed25519-dalek = "2.2"
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
|
||||||
|
|
@ -2934,6 +2934,10 @@ enum Command {
|
||||||
recipe: PathBuf,
|
recipe: PathBuf,
|
||||||
binding: Option<PathBuf>,
|
binding: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
|
#[cfg(unix)]
|
||||||
|
OrdersServe(crate::steward_orders::ServeOptions),
|
||||||
|
#[cfg(unix)]
|
||||||
|
Order(crate::steward_orders::OrderCommand),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run(args: impl Iterator<Item = String>) -> Result<()> {
|
pub fn run(args: impl Iterator<Item = String>) -> Result<()> {
|
||||||
|
|
@ -2973,6 +2977,10 @@ pub fn run(args: impl Iterator<Item = String>) -> Result<()> {
|
||||||
requested_by,
|
requested_by,
|
||||||
} => expire(&state_store, &command_id, &requested_by),
|
} => expire(&state_store, &command_id, &requested_by),
|
||||||
Command::Validate { recipe, binding } => validate(&recipe, binding.as_deref()),
|
Command::Validate { recipe, binding } => validate(&recipe, binding.as_deref()),
|
||||||
|
#[cfg(unix)]
|
||||||
|
Command::OrdersServe(options) => crate::steward_orders::serve(options),
|
||||||
|
#[cfg(unix)]
|
||||||
|
Command::Order(order) => crate::steward_orders::run_order(order),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3025,6 +3033,10 @@ fn parse(args: impl Iterator<Item = String>) -> Result<Command> {
|
||||||
"cancel" => parse_cancel(args),
|
"cancel" => parse_cancel(args),
|
||||||
"expire" => parse_expire(args),
|
"expire" => parse_expire(args),
|
||||||
"validate" => parse_validate(args),
|
"validate" => parse_validate(args),
|
||||||
|
#[cfg(unix)]
|
||||||
|
"orders" => crate::steward_orders::parse_serve(args).map(Command::OrdersServe),
|
||||||
|
#[cfg(unix)]
|
||||||
|
"order" => crate::steward_orders::parse_order(args).map(Command::Order),
|
||||||
"--help" | "-h" | "help" => bail!(usage()),
|
"--help" | "-h" | "help" => bail!(usage()),
|
||||||
_ => bail!("unknown Idunn command {command:?}\n\n{}", usage()),
|
_ => bail!("unknown Idunn command {command:?}\n\n{}", usage()),
|
||||||
}
|
}
|
||||||
|
|
@ -4730,20 +4742,21 @@ fn resolve_selector(
|
||||||
Ok(targets)
|
Ok(targets)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn submit(
|
/// The one commit primitive for a deployment command: `idunn up` and a steward
|
||||||
|
/// deploy order both write theirs here. Returns the commit time.
|
||||||
|
pub(crate) fn commit_deployment_command(
|
||||||
store_path: &Path,
|
store_path: &Path,
|
||||||
|
command_id: &str,
|
||||||
selector: &str,
|
selector: &str,
|
||||||
requested_by: &str,
|
requested_by: &str,
|
||||||
wait: bool,
|
) -> Result<u64> {
|
||||||
timeout_seconds: u64,
|
|
||||||
) -> Result<()> {
|
|
||||||
if let Some(parent) = store_path.parent() {
|
if let Some(parent) = store_path.parent() {
|
||||||
fs::create_dir_all(parent)?;
|
fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
let now = now_millis()?;
|
let now = now_millis()?;
|
||||||
let command = DeploymentCommand {
|
let command = DeploymentCommand {
|
||||||
schema_version: DEPLOYMENT_COMMAND_SCHEMA.into(),
|
schema_version: DEPLOYMENT_COMMAND_SCHEMA.into(),
|
||||||
command_id: format!("up-{}", Uuid::new_v4()),
|
command_id: command_id.into(),
|
||||||
kind: CommandKind::Deploy,
|
kind: CommandKind::Deploy,
|
||||||
selector: selector.into(),
|
selector: selector.into(),
|
||||||
requested_by: requested_by.into(),
|
requested_by: requested_by.into(),
|
||||||
|
|
@ -4761,7 +4774,33 @@ fn submit(
|
||||||
)?,
|
)?,
|
||||||
"deployment command id collided"
|
"deployment command id collided"
|
||||||
);
|
);
|
||||||
println!("{}", command.command_id);
|
Ok(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A command's present state as `idunn status --command` derives it, for the
|
||||||
|
/// steward order poster: `queued` and `running` are not terminal.
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub(crate) fn command_state(
|
||||||
|
state_store: &Path,
|
||||||
|
command_id: &str,
|
||||||
|
) -> Result<(&'static str, String)> {
|
||||||
|
let snapshot = ControlSnapshot::read(state_store)?;
|
||||||
|
let archived = read_history_transactions(state_store);
|
||||||
|
let mut transactions = snapshot.transaction_for_command(command_id);
|
||||||
|
transactions.extend(archived.iter().filter(|value| value.command_id == command_id));
|
||||||
|
Ok(derived_command_status(&transactions))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn submit(
|
||||||
|
store_path: &Path,
|
||||||
|
selector: &str,
|
||||||
|
requested_by: &str,
|
||||||
|
wait: bool,
|
||||||
|
timeout_seconds: u64,
|
||||||
|
) -> Result<()> {
|
||||||
|
let command_id = format!("up-{}", Uuid::new_v4());
|
||||||
|
let now = commit_deployment_command(store_path, &command_id, selector, requested_by)?;
|
||||||
|
println!("{command_id}");
|
||||||
if !wait {
|
if !wait {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
@ -4772,10 +4811,10 @@ fn submit(
|
||||||
snapshot
|
snapshot
|
||||||
.commands
|
.commands
|
||||||
.iter()
|
.iter()
|
||||||
.any(|stored| stored.value.command_id == command.command_id),
|
.any(|stored| stored.value.command_id == command_id),
|
||||||
"submitted deployment command disappeared"
|
"submitted deployment command disappeared"
|
||||||
);
|
);
|
||||||
let transactions = snapshot.transaction_for_command(&command.command_id);
|
let transactions = snapshot.transaction_for_command(&command_id);
|
||||||
if !transactions.is_empty() && transactions.iter().all(|value| value.is_terminal()) {
|
if !transactions.is_empty() && transactions.iter().all(|value| value.is_terminal()) {
|
||||||
if let Some(error) =
|
if let Some(error) =
|
||||||
transactions
|
transactions
|
||||||
|
|
@ -4800,7 +4839,7 @@ fn submit(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn status(
|
pub(crate) fn status(
|
||||||
out: &mut impl Write,
|
out: &mut impl Write,
|
||||||
store_path: &Path,
|
store_path: &Path,
|
||||||
incident_store: &Path,
|
incident_store: &Path,
|
||||||
|
|
@ -10592,7 +10631,13 @@ fn required<'a, T>(value: &'a Option<T>, label: &str) -> Result<&'a T> {
|
||||||
value.as_ref().with_context(|| format!("{label} is absent"))
|
value.as_ref().with_context(|| format!("{label} is absent"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn now_millis() -> Result<u64> {
|
/// The control store `idunn up` and `idunn serve` use unless told otherwise.
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub(crate) fn default_state_store() -> PathBuf {
|
||||||
|
RuntimeOptions::default().state_store
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn now_millis() -> Result<u64> {
|
||||||
Ok(SystemTime::now()
|
Ok(SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
.context("system clock predates Unix epoch")?
|
.context("system clock predates Unix epoch")?
|
||||||
|
|
@ -10612,12 +10657,12 @@ fn sibling_path(path: &Path, suffix: &str) -> PathBuf {
|
||||||
PathBuf::from(value)
|
PathBuf::from(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn string_value(args: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
|
pub(crate) fn string_value(args: &mut impl Iterator<Item = String>, name: &str) -> Result<String> {
|
||||||
args.next()
|
args.next()
|
||||||
.ok_or_else(|| anyhow!("{name} requires a value"))
|
.ok_or_else(|| anyhow!("{name} requires a value"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn path_value(args: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
pub(crate) fn path_value(args: &mut impl Iterator<Item = String>, name: &str) -> Result<PathBuf> {
|
||||||
Ok(PathBuf::from(string_value(args, name)?))
|
Ok(PathBuf::from(string_value(args, name)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -10633,7 +10678,7 @@ fn u64_value(args: &mut impl Iterator<Item = String>, name: &str) -> Result<u64>
|
||||||
.with_context(|| format!("{name} requires a u64"))
|
.with_context(|| format!("{name} requires a u64"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn require_selector(value: &str) -> Result<()> {
|
pub(crate) fn require_selector(value: &str) -> Result<()> {
|
||||||
let value = value.strip_prefix("profile:").unwrap_or(value);
|
let value = value.strip_prefix("profile:").unwrap_or(value);
|
||||||
require_id(value, "deployment selector")
|
require_id(value, "deployment selector")
|
||||||
}
|
}
|
||||||
|
|
@ -10757,7 +10802,10 @@ fn usage() -> &'static str {
|
||||||
idunn status [--state-store PATH] [--incident-store PATH] [--command ID]\n\
|
idunn status [--state-store PATH] [--incident-store PATH] [--command ID]\n\
|
||||||
idunn cancel <command-id> [--state-store PATH]\n\
|
idunn cancel <command-id> [--state-store PATH]\n\
|
||||||
idunn expire <command-id> [--state-store PATH]\n\
|
idunn expire <command-id> [--state-store PATH]\n\
|
||||||
idunn validate --recipe PATH [--binding PATH]\n\n\
|
idunn validate --recipe PATH [--binding PATH]\n\
|
||||||
|
idunn orders serve [--store DIR] [--steward-binding PATH] [--socket PATH]\n\
|
||||||
|
idunn order <package-install PKG... | unit VERB UNIT | deploy SELECTOR> --reason TEXT\n\
|
||||||
|
idunn order show ID | idunn order list\n\n\
|
||||||
Recipes describe capability and process requirements. Idunn seals exact\n\
|
Recipes describe capability and process requirements. Idunn seals exact\n\
|
||||||
source and artifacts, admits one incarnation, and delegates execution to\n\
|
source and artifacts, admits one incarnation, and delegates execution to\n\
|
||||||
systemd and routing mechanics to the configured proxy."
|
systemd and routing mechanics to the configured proxy."
|
||||||
|
|
@ -11969,9 +12017,45 @@ mod tests {
|
||||||
vec!["serve", "--swarm-profile", "yggdrasil-local"],
|
vec!["serve", "--swarm-profile", "yggdrasil-local"],
|
||||||
vec!["serve", "--restart-command", "bad"],
|
vec!["serve", "--restart-command", "bad"],
|
||||||
vec!["validate-runtime-admission"],
|
vec!["validate-runtime-admission"],
|
||||||
|
vec!["order", "unit", "restart", "nginx.service", "--reason", "r", "--command", "sh -c bad"],
|
||||||
|
vec!["order", "unit", "restart", "nginx.service", "--reason", "r", "--shell"],
|
||||||
|
vec!["order", "run", "sh", "--reason", "r"],
|
||||||
|
vec!["order", "package-install", "git-lfs", "--reason", "r", "--deploy-command", "x"],
|
||||||
|
vec!["order", "unit", "restart", "nginx.service", "extra", "--reason", "r"],
|
||||||
|
vec!["order", "deploy", "vili", "--reason", "r", "--requested-by", "someone"],
|
||||||
|
vec!["orders", "serve", "--allow-units", "nginx.service"],
|
||||||
|
vec!["orders", "run"],
|
||||||
] {
|
] {
|
||||||
assert!(parse(arguments.into_iter().map(str::to_owned)).is_err());
|
assert!(parse(arguments.into_iter().map(str::to_owned)).is_err());
|
||||||
}
|
}
|
||||||
|
// The declarative order forms parse, and only as order commands.
|
||||||
|
#[cfg(unix)]
|
||||||
|
for arguments in [
|
||||||
|
vec!["order", "unit", "restart", "nginx.service", "--reason", "r"],
|
||||||
|
vec!["order", "package-install", "git-lfs", "--reason", "r"],
|
||||||
|
vec!["order", "deploy", "vili", "--reason", "r"],
|
||||||
|
vec!["order", "list"],
|
||||||
|
vec!["orders", "serve"],
|
||||||
|
] {
|
||||||
|
let parsed = parse(arguments.into_iter().map(str::to_owned)).unwrap();
|
||||||
|
assert!(matches!(parsed, Command::Order(_) | Command::OrdersServe(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deployment_commit_primitive_is_shared_and_never_overwrites() -> Result<()> {
|
||||||
|
let temp = TempDir::new()?;
|
||||||
|
let store = temp.path().join("control.cc");
|
||||||
|
commit_deployment_command(&store, "up-one", "vili", "steward:eureka order ord-1")?;
|
||||||
|
let snapshot = ControlSnapshot::read(&store)?;
|
||||||
|
assert_eq!(snapshot.commands.len(), 1);
|
||||||
|
let command = &snapshot.commands[0].value;
|
||||||
|
assert_eq!(command.command_id, "up-one");
|
||||||
|
assert_eq!(command.selector, "vili");
|
||||||
|
assert_eq!(command.requested_by, "steward:eureka order ord-1");
|
||||||
|
assert!(commit_deployment_command(&store, "up-one", "other", "x").is_err());
|
||||||
|
assert_eq!(ControlSnapshot::read(&store)?.commands.len(), 1);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,5 @@ pub mod launcher;
|
||||||
mod incident;
|
mod incident;
|
||||||
mod runner_evidence;
|
mod runner_evidence;
|
||||||
pub mod provisioning;
|
pub mod provisioning;
|
||||||
|
#[cfg(unix)]
|
||||||
|
mod steward_orders;
|
||||||
|
|
|
||||||
|
|
@ -366,7 +366,7 @@ pub(crate) fn show_status(
|
||||||
/// printable ASCII and tab pass; every other code point is a visible
|
/// printable ASCII and tab pass; every other code point is a visible
|
||||||
/// `\u{..}` escape and every invalid UTF-8 byte a `\x..` escape. A newline
|
/// `\u{..}` escape and every invalid UTF-8 byte a `\x..` escape. A newline
|
||||||
/// inside a line is escaped too: this function writes the only ones.
|
/// inside a line is escaped too: this function writes the only ones.
|
||||||
fn emit(out: &mut impl std::io::Write, lines: &[Vec<u8>]) -> std::io::Result<()> {
|
pub(crate) fn emit(out: &mut impl std::io::Write, lines: &[Vec<u8>]) -> std::io::Result<()> {
|
||||||
for line in lines {
|
for line in lines {
|
||||||
out.write_all(escape(line).as_bytes())?;
|
out.write_all(escape(line).as_bytes())?;
|
||||||
out.write_all(b"\n")?;
|
out.write_all(b"\n")?;
|
||||||
|
|
@ -374,7 +374,7 @@ fn emit(out: &mut impl std::io::Write, lines: &[Vec<u8>]) -> std::io::Result<()>
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn escape(bytes: &[u8]) -> String {
|
pub(crate) fn escape(bytes: &[u8]) -> String {
|
||||||
let mut shown = String::with_capacity(bytes.len());
|
let mut shown = String::with_capacity(bytes.len());
|
||||||
for chunk in bytes.utf8_chunks() {
|
for chunk in bytes.utf8_chunks() {
|
||||||
for character in chunk.valid().chars() {
|
for character in chunk.valid().chars() {
|
||||||
|
|
|
||||||
2932
src/steward_orders.rs
Normal file
2932
src/steward_orders.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue