diff --git a/README.md b/README.md index 3a695a4..8c6935c 100644 --- a/README.md +++ b/README.md @@ -73,11 +73,16 @@ idunn order show # status and outp idunn order list ``` -A unit order acts on exactly the unit it names: every verb runs +A unit order submits exactly one job, for the unit it names: every verb runs `systemctl --job-mode=ignore-requirements -- `, so systemd builds a transaction of one job. It does not start what the unit requires or restart what is part of it: order the dependency first, and order `postgresql@16-main.service` -rather than the `postgresql.service` wrapper. Targets are not orderable. A unit +rather than the `postgresql.service` wrapper. Targets are not orderable. The job is one; what the unit does afterwards is +not: systemd still runs full transactions of its own for `Restart=` after a +failure, for a `StopWhenUnneeded=` dependency left without a user, and for a +`Conflicts=` partner of a unit that fails, so an ordered stop of a unit +that something restarts does not last, and a start of a `Restart=always` +unit can report failure and be active seconds later. A unit order may act on any unit except Idunn's own (`idunn-*`) and the minds' own (`mind-self@*`, `mind-persona@*`), and the units that end or take the host (`systemd-suspend`, `systemd-hibernate`, `systemd-hybrid-sleep`, @@ -88,7 +93,11 @@ when it has an emergency action (`SuccessAction=`, `FailureAction=`, `StartLimitAction=`, `JobTimeoutAction=`) other than `none` or an `OnFailure=` or `OnSuccess=` unit (not for a reload); or, for a stop or restart, when another unit is `BoundBy=` it, because systemd stops that unit with it whatever the job -mode. A unit `systemctl show` cannot resolve fails the order. Slices, scopes, +mode; for a start or restart, when it has an `Upholds=` unit, which systemd +starts in a full transaction whatever the job mode; or, for a stop, restart or +reload, when it has a `ConsistsOf=` member, a wrapper such as +`postgresql.service` whose verbs would exit 0 and touch nothing. A unit +`systemctl show` cannot resolve fails the order. Slices, scopes, targets and globs are not units an order can name. A package order installs the packages it names and nothing else: apt runs with diff --git a/src/host_actuator.rs b/src/host_actuator.rs index f7bf4c7..8fd776c 100644 --- a/src/host_actuator.rs +++ b/src/host_actuator.rs @@ -1151,7 +1151,7 @@ mod tests { let answerer = thread::spawn(move || -> Result<()> { for _ in 0..500 { client.poll_resends()?; - while let Some(frame) = client.receive_once()? { + if let Some(frame) = client.receive_once()? { let envelope = HostActuatorEnvelope::decode(&frame.payload)?; let request = envelope.open_request("raven", &idunn_anchor)?; assert_eq!(envelope.session_nonce, nonce); diff --git a/src/steward_orders.rs b/src/steward_orders.rs index 3b40c88..d4be1ee 100644 --- a/src/steward_orders.rs +++ b/src/steward_orders.rs @@ -21,7 +21,6 @@ //! field and a code. A refused unit that a record names was resolved by //! systemd and passed the unit grammar. -use std::collections::BTreeMap; use std::fmt; use std::io::{Read, Write}; use std::os::unix::fs::{MetadataExt, PermissionsExt}; @@ -391,12 +390,16 @@ pub(crate) enum Via { Host, OnEnd, Bound, + Upholds, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub(crate) enum Refused { AlwaysRefused { unit: String, via: Via }, + /// The unit only wraps `member` (`ConsistsOf=`): a stop, restart or reload + /// of the wrapper exits 0 and leaves the member alone. + Wrapper { member: String }, Braked, /// The order's principal or issuing uid is not the steward bound now. NotSteward, @@ -437,8 +440,13 @@ impl Outcome { Via::Host => "host", Via::OnEnd => "on-end", Via::Bound => "bound", + Via::Upholds => "upholds", } ), + Outcome::Refused(Refused::Wrapper { member }) => format!( + "refused: wrapper unit, it only wraps {}", + escape(member.as_bytes()) + ), Outcome::Refused(Refused::Braked) => "refused: braked".into(), Outcome::Refused(Refused::NotSteward) => { "refused: the issuer is not the current steward".into() @@ -650,6 +658,12 @@ pub(crate) struct UnitFacts { /// The units bound to this one at runtime (`BoundBy=`): systemd stops /// them when this unit stops, whatever the job mode. pub(crate) bound_by: Vec, + /// The units this one upholds (`Upholds=`): systemd starts them in a full + /// transaction whenever this unit is active, whatever the job mode. + pub(crate) upholds: Vec, + /// The units this one groups (`ConsistsOf=`): the verbs reach them only + /// through propagation, which the job mode removes. + pub(crate) consists_of: Vec, } impl UnitFacts { @@ -715,7 +729,8 @@ fn refused(unit: &str, via: Via) -> Refused { /// Would `verb` on `unit` be refused. systemd builds the transaction and the /// order tells it to build one job (`--job-mode=ignore-requirements`), so this /// reads only what that job mode cannot remove from the one named unit: the -/// name it answers to, what fires when it ends, and who is bound to it. +/// name it answers to, what fires when it ends, who is bound to it, what it upholds and +/// what it only wraps. fn refusal_for(systemd: &dyn Systemd, unit: &str, verb: Verb) -> Result> { if is_refused_name(unit) { return Ok(Some(refused(unit, Via::Name))); @@ -738,6 +753,16 @@ fn refusal_for(systemd: &dyn Systemd, unit: &str, verb: Verb) -> Result Vec { } /// The properties one `systemctl show` is asked for: identity, what fires at -/// the unit's end, and who is bound to it. +/// the unit's end, who is bound to it, and what it upholds or groups. fn show_properties() -> String { ["Id", "Names", "LoadState"] .into_iter() .chain(ACTIONS) .chain(ON_END) - .chain(["BoundBy"]) + .chain(["BoundBy", "Upholds", "ConsistsOf"]) .collect::>() .join(",") } @@ -1253,6 +1278,8 @@ fn parse_show(output: &str) -> Result { "Names" => facts.names = parse_names(value), "LoadState" => facts.not_found = value == "not-found", "BoundBy" => facts.bound_by = parse_names(value), + "Upholds" => facts.upholds = parse_names(value), + "ConsistsOf" => facts.consists_of = parse_names(value), _ if ACTIONS.contains(&key) => { if !matches!(value, "" | "none") { facts.on_end.push(key.to_owned()); @@ -2543,6 +2570,91 @@ mod tests { } } + #[test] + fn a_unit_that_upholds_others_is_refused_where_it_would_start() { + let h = harness(); + h.describe("holder.service", &[("Upholds", "held.service")]); + for verb in [Verb::Start, Verb::Restart] { + assert_eq!( + outcome_of_unit(&h, verb, "holder.service"), + Outcome::Refused(Refused::AlwaysRefused { + unit: "holder.service".into(), + via: Via::Upholds + }), + "{verb:?}" + ); + } + assert!(h.runs().is_empty()); + for verb in [Verb::Stop, Verb::Reload] { + assert_eq!(outcome_of_unit(&h, verb, "holder.service"), Outcome::Ran, "{verb:?}"); + } + } + + #[test] + fn a_wrapper_unit_is_refused_where_the_verb_would_touch_nothing() { + let h = harness(); + h.describe("wrapper.service", &[("ConsistsOf", "member@1.service member@2.service")]); + for verb in [Verb::Stop, Verb::Restart, Verb::Reload] { + assert_eq!( + outcome_of_unit(&h, verb, "wrapper.service"), + Outcome::Refused(Refused::Wrapper { member: "member@1.service".into() }), + "{verb:?}" + ); + } + assert!(h.runs().is_empty()); + assert_eq!(outcome_of_unit(&h, Verb::Start, "wrapper.service"), Outcome::Ran); + } + + #[test] + fn empty_dependency_lists_name_no_one() { + let h = harness(); + // systemctl prints every property it is asked for, empty or not. + h.describe( + "alone.service", + &[("BoundBy", ""), ("Upholds", ""), ("ConsistsOf", "")], + ); + for verb in all_verbs() { + assert_eq!(outcome_of_unit(&h, verb, "alone.service"), Outcome::Ran, "{verb:?}"); + } + } + + #[test] + fn a_refusal_reads_the_named_unit_once_and_a_refused_name_not_at_all() { + let h = harness(); + h.describe("base.service", &[("BoundBy", "first.service")]); + h.describe("idunn-ghost.service", &[("LoadState", "not-found")]); + assert!(hit(&outcome_of_unit(&h, Verb::Stop, "base.service"), Via::Bound).is_some()); + assert_eq!(h.systemd.shows.lock().unwrap().clone(), ["base.service"]); + h.systemd.shows.lock().unwrap().clear(); + assert_eq!( + hit(&outcome_of_unit(&h, Verb::Stop, "idunn-ghost.service"), Via::Name), + Some("idunn-ghost.service") + ); + assert!(h.systemd.shows.lock().unwrap().is_empty()); + } + + #[test] + fn each_refusal_is_posted_under_its_own_reason() { + for (via, word) in [ + (Via::Name, "name"), + (Via::Alias, "alias"), + (Via::Host, "host"), + (Via::OnEnd, "on-end"), + (Via::Bound, "bound"), + (Via::Upholds, "upholds"), + ] { + assert_eq!( + Outcome::Refused(Refused::AlwaysRefused { unit: "u.service".into(), via }) + .describe(), + format!("refused: always-refused unit u.service reached by {word}") + ); + } + assert_eq!( + Outcome::Refused(Refused::Wrapper { member: "m.service".into() }).describe(), + "refused: wrapper unit, it only wraps m.service" + ); + } + #[test] fn a_unit_systemd_cannot_show_is_a_failure_not_a_leaf() { let h = harness(); @@ -3004,7 +3116,7 @@ mod tests { "Id=a.service\nNames=a.service b.service\nLoadState=loaded\nActiveState=active\n\ SuccessAction=none\nFailureAction=reboot-force\nStartLimitAction=\nJobTimeoutAction=none\n\ OnFailure=f.service g.service\nOnSuccess=\nBoundBy=\"q\\\\x2dr.service\" s.service\n\ - Requires=r.service\nConflicts=x.service\nOther=x\n", + Upholds=u.service\nConsistsOf=m.service n.service\nRequires=r.service\nConflicts=x.service\nOther=x\n", ) .unwrap(); assert_eq!(facts.id, "a.service"); @@ -3013,12 +3125,14 @@ mod tests { assert_eq!(facts.on_end, ["FailureAction", "OnFailure", "OnFailure"]); // A quoted name holds one backslash, not two and not a quote mark. assert_eq!(facts.bound_by, ["q\\x2dr.service", "s.service"]); + assert_eq!(facts.upholds, ["u.service"]); + assert_eq!(facts.consists_of, ["m.service", "n.service"]); assert!(parse_show("Names=a.service\n").is_err()); // The request asks for exactly the properties the parse reads. assert_eq!( show_properties(), "Id,Names,LoadState,SuccessAction,FailureAction,StartLimitAction,JobTimeoutAction,\ - OnFailure,OnSuccess,BoundBy" + OnFailure,OnSuccess,BoundBy,Upholds,ConsistsOf" ); // A backslash outside quotes is a backslash; inside, it escapes one character. assert_eq!(parse_names("a\\b c\\\\d"), ["a\\b", "c\\\\d"]);