Steward orders: refuse Upholds and ConsistsOf units, pin the rule's gaps, README names the residuals
A unit with Upholds= is refused for start and restart (systemd would start the upheld unit in a full transaction). A wrapper with ConsistsOf= is refused for stop, restart and reload with a typed Refused::Wrapper naming the first member, so it cannot report ran while touching nothing. Tests pin empty BoundBy, a single show per refusal, no show for a refused name and each posted reason. README: the one-job rule's residual full transactions. Removes the unused BTreeMap import and the never_loop in the host actuator test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fb1e7b5dd9
commit
77e2295e97
3 changed files with 133 additions and 10 deletions
15
README.md
15
README.md
|
|
@ -73,11 +73,16 @@ idunn order show <id> # 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 <verb> --job-mode=ignore-requirements -- <unit>`, 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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// The units this one groups (`ConsistsOf=`): the verbs reach them only
|
||||
/// through propagation, which the job mode removes.
|
||||
pub(crate) consists_of: Vec<String>,
|
||||
}
|
||||
|
||||
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<Option<Refused>> {
|
||||
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<Option<R
|
|||
{
|
||||
return Ok(Some(refused(bound, Via::Bound)));
|
||||
}
|
||||
if matches!(verb, Verb::Start | Verb::Restart) && !facts.upholds.is_empty() {
|
||||
return Ok(Some(refused(unit, Via::Upholds)));
|
||||
}
|
||||
if verb != Verb::Start
|
||||
&& let Some(member) = facts.consists_of.first()
|
||||
{
|
||||
return Ok(Some(Refused::Wrapper {
|
||||
member: member.clone(),
|
||||
}));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
|
|
@ -1231,13 +1256,13 @@ fn parse_names(value: &str) -> Vec<String> {
|
|||
}
|
||||
|
||||
/// 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::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
|
@ -1253,6 +1278,8 @@ fn parse_show(output: &str) -> Result<UnitFacts> {
|
|||
"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"]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue