Steward unit orders: refuse by the systemd transaction, install exactly the named packages
An order is refused when the closure systemd would build for it reaches an always-refused unit: a start pulls Requires/Requisite/BindsTo/Wants/Upholds and stops Conflicts/ConflictedBy, a stop takes down RequiredBy/BoundBy/RequisiteOf/ ConsistsOf/PropagatesStopTo/StopPropagatedFrom, a restart does both. A refused unit already running is left alone by a start. Names are unquoted from systemctl show; a unit systemd cannot show fails the order. Package installs run apt-get --no-remove with ?exact-name(<name>) per package and a final dpkg -s step that fails unless every named package is installed. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
7d5cd85728
commit
ec6222e2a1
1 changed files with 545 additions and 133 deletions
|
|
@ -213,11 +213,6 @@ impl Verb {
|
|||
.into_iter()
|
||||
.find(|verb| verb.as_str() == word)
|
||||
}
|
||||
|
||||
/// Verbs systemd propagates down the dependency graph.
|
||||
fn propagates(self) -> bool {
|
||||
matches!(self, Verb::Stop | Verb::Restart)
|
||||
}
|
||||
}
|
||||
|
||||
/// The closed set of things an order can ask for.
|
||||
|
|
@ -259,8 +254,21 @@ impl Operation {
|
|||
let apt_environment = vec![("DEBIAN_FRONTEND".to_owned(), "noninteractive".to_owned())];
|
||||
match self {
|
||||
Operation::PackageInstall { packages } => {
|
||||
let mut install: Vec<String> = words(&["apt-get", "install", "-y", "-q", "--"]);
|
||||
install.extend(packages.iter().cloned());
|
||||
// Never a removal, never a pattern: each name selects exactly
|
||||
// itself, and apt aborts rather than displace an installed package.
|
||||
let mut install: Vec<String> =
|
||||
words(&["apt-get", "install", "-y", "-q", "--no-remove", "--"]);
|
||||
let mut present: Vec<String> = words(&["dpkg", "-s", "--"]);
|
||||
for package in packages {
|
||||
let (name, version) = package.split_once('=').unwrap_or((package, ""));
|
||||
install.push(match version {
|
||||
"" => format!("?exact-name({name})"),
|
||||
version => format!("?exact-name({name})={version}"),
|
||||
});
|
||||
present.push(name.to_owned());
|
||||
}
|
||||
// An exact name that matches nothing installs nothing and exits 0,
|
||||
// so the last step fails unless every named package is installed.
|
||||
vec![
|
||||
Step {
|
||||
argv: words(&["apt-get", "update", "-q"]),
|
||||
|
|
@ -270,6 +278,10 @@ impl Operation {
|
|||
argv: install,
|
||||
environment: apt_environment,
|
||||
},
|
||||
Step {
|
||||
argv: present,
|
||||
environment: Vec::new(),
|
||||
},
|
||||
]
|
||||
}
|
||||
Operation::UnitAction { verb, unit } => vec![Step {
|
||||
|
|
@ -595,14 +607,45 @@ impl OrderStore {
|
|||
|
||||
// ------------------------------------------------------------------ ports --
|
||||
|
||||
/// Which way systemd moves along a dependency edge when an order is given.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Edge {
|
||||
/// Units a start brings up with this one.
|
||||
Pulls,
|
||||
/// Units a start stops: the unit's `Conflicts=` and the units that conflict with it.
|
||||
Conflicts,
|
||||
/// Units a stop takes down with this one.
|
||||
Stops,
|
||||
}
|
||||
|
||||
/// The `systemctl show` properties the closure reads, and the way each one
|
||||
/// moves. This is the only list: the request and the parse both read it.
|
||||
const EDGES: [(&str, Edge); 13] = [
|
||||
("Requires", Edge::Pulls),
|
||||
("Requisite", Edge::Pulls),
|
||||
("BindsTo", Edge::Pulls),
|
||||
("Wants", Edge::Pulls),
|
||||
("Upholds", Edge::Pulls),
|
||||
("Conflicts", Edge::Conflicts),
|
||||
("ConflictedBy", Edge::Conflicts),
|
||||
("RequiredBy", Edge::Stops),
|
||||
("BoundBy", Edge::Stops),
|
||||
("RequisiteOf", Edge::Stops),
|
||||
("ConsistsOf", Edge::Stops),
|
||||
("PropagatesStopTo", Edge::Stops),
|
||||
("StopPropagatedFrom", Edge::Stops),
|
||||
];
|
||||
|
||||
/// What `systemctl show` says about one unit.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct UnitFacts {
|
||||
pub(crate) id: String,
|
||||
pub(crate) names: Vec<String>,
|
||||
pub(crate) required_by: Vec<String>,
|
||||
pub(crate) bound_by: Vec<String>,
|
||||
pub(crate) consists_of: Vec<String>,
|
||||
pub(crate) not_found: bool,
|
||||
pub(crate) running: bool,
|
||||
pub(crate) pulls: Vec<String>,
|
||||
pub(crate) conflicts: Vec<String>,
|
||||
pub(crate) stops: Vec<String>,
|
||||
}
|
||||
|
||||
impl UnitFacts {
|
||||
|
|
@ -610,12 +653,20 @@ impl UnitFacts {
|
|||
std::iter::once(&self.id).chain(self.names.iter())
|
||||
}
|
||||
|
||||
/// The units systemd stops with this one.
|
||||
fn dependents(&self) -> impl Iterator<Item = &String> {
|
||||
self.required_by
|
||||
.iter()
|
||||
.chain(self.bound_by.iter())
|
||||
.chain(self.consists_of.iter())
|
||||
fn along(&self, edge: Edge) -> &[String] {
|
||||
match edge {
|
||||
Edge::Pulls => &self.pulls,
|
||||
Edge::Conflicts => &self.conflicts,
|
||||
Edge::Stops => &self.stops,
|
||||
}
|
||||
}
|
||||
|
||||
fn along_mut(&mut self, edge: Edge) -> &mut Vec<String> {
|
||||
match edge {
|
||||
Edge::Pulls => &mut self.pulls,
|
||||
Edge::Conflicts => &mut self.conflicts,
|
||||
Edge::Stops => &mut self.stops,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -673,40 +724,95 @@ fn refused(unit: &str, via: Via) -> Refused {
|
|||
}
|
||||
}
|
||||
|
||||
/// The units systemd would touch for one order, read from `systemctl show`.
|
||||
struct Transaction<'a> {
|
||||
systemd: &'a dyn Systemd,
|
||||
facts: BTreeMap<String, UnitFacts>,
|
||||
}
|
||||
|
||||
impl Transaction<'_> {
|
||||
fn facts(&mut self, unit: &str) -> Result<&UnitFacts> {
|
||||
if !self.facts.contains_key(unit) {
|
||||
ensure!(
|
||||
self.facts.len() < CLOSURE_LIMIT,
|
||||
"the units an order would reach are too many to check"
|
||||
);
|
||||
let facts = self.systemd.show(unit)?;
|
||||
ensure!(!facts.not_found, "systemd has no such unit to check");
|
||||
self.facts.insert(unit.to_owned(), facts);
|
||||
}
|
||||
Ok(&self.facts[unit])
|
||||
}
|
||||
|
||||
/// Every unit reached from `seeds` along `edge`, seeds included.
|
||||
fn reach(&mut self, seeds: &BTreeSet<String>, edge: Edge) -> Result<BTreeSet<String>> {
|
||||
let mut reached = BTreeSet::new();
|
||||
let mut queue: VecDeque<String> = seeds.iter().cloned().collect();
|
||||
while let Some(next) = queue.pop_front() {
|
||||
if !reached.insert(next.clone()) {
|
||||
continue;
|
||||
}
|
||||
queue.extend(self.facts(&next)?.along(edge).iter().cloned());
|
||||
}
|
||||
Ok(reached)
|
||||
}
|
||||
|
||||
/// Every unit a start of `started` stops through `Conflicts=`, and what
|
||||
/// that stop takes down with it.
|
||||
fn conflicted(&mut self, started: &BTreeSet<String>) -> Result<BTreeSet<String>> {
|
||||
let mut seeds = BTreeSet::new();
|
||||
for unit in started {
|
||||
seeds.extend(self.facts(unit)?.along(Edge::Conflicts).iter().cloned());
|
||||
}
|
||||
self.reach(&seeds, Edge::Stops)
|
||||
}
|
||||
}
|
||||
|
||||
/// Would `verb` on `unit` reach an always-refused unit: by its name, by an
|
||||
/// alias it resolves from, or, for the verbs systemd propagates, by the units
|
||||
/// systemd stops with it. Resolution reads systemd before anything runs.
|
||||
/// alias it resolves from, or through the transaction systemd builds for it.
|
||||
/// A start pulls in what the unit requires or wants and stops what conflicts;
|
||||
/// a stop takes down what depends on the unit; a restart does both. A unit
|
||||
/// the transaction would stop is refused when it is refused; a unit it would
|
||||
/// start is refused when it is refused and not running. Every unit in the
|
||||
/// closure must be one systemd can show.
|
||||
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)));
|
||||
}
|
||||
let facts = systemd.show(unit)?;
|
||||
if let Some(name) = facts.identities().find(|name| is_refused_name(name)) {
|
||||
let mut transaction = Transaction {
|
||||
systemd,
|
||||
facts: BTreeMap::new(),
|
||||
};
|
||||
if let Some(name) = transaction
|
||||
.facts(unit)?
|
||||
.identities()
|
||||
.find(|name| is_refused_name(name))
|
||||
{
|
||||
return Ok(Some(refused(name, Via::Alias)));
|
||||
}
|
||||
if !verb.propagates() {
|
||||
return Ok(None);
|
||||
let root = BTreeSet::from([unit.to_owned()]);
|
||||
let mut stopped = BTreeSet::new();
|
||||
let mut started = BTreeSet::new();
|
||||
match verb {
|
||||
Verb::Reload => {}
|
||||
Verb::Start => started = transaction.reach(&root, Edge::Pulls)?,
|
||||
Verb::Stop => stopped = transaction.reach(&root, Edge::Stops)?,
|
||||
Verb::Restart => {
|
||||
stopped = transaction.reach(&root, Edge::Stops)?;
|
||||
started = transaction.reach(&stopped, Edge::Pulls)?;
|
||||
}
|
||||
}
|
||||
let mut seen: BTreeSet<String> = facts.identities().cloned().collect();
|
||||
seen.insert(unit.to_owned());
|
||||
let mut queue: VecDeque<String> = facts.dependents().cloned().collect();
|
||||
while let Some(next) = queue.pop_front() {
|
||||
if is_refused_name(&next) {
|
||||
return Ok(Some(refused(&next, Via::Propagation)));
|
||||
stopped.extend(transaction.conflicted(&started)?);
|
||||
for (units, only_if_stopped) in [(&stopped, false), (&started, true)] {
|
||||
for member in units {
|
||||
let facts = transaction.facts(member)?;
|
||||
if only_if_stopped && facts.running {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = facts.identities().find(|name| is_refused_name(name)) {
|
||||
return Ok(Some(refused(name, Via::Propagation)));
|
||||
}
|
||||
}
|
||||
if !seen.insert(next.clone()) {
|
||||
continue;
|
||||
}
|
||||
ensure!(
|
||||
seen.len() <= CLOSURE_LIMIT,
|
||||
"the units a stop would reach are too many to check"
|
||||
);
|
||||
let facts = systemd.show(&next)?;
|
||||
if let Some(name) = facts.identities().find(|name| is_refused_name(name)) {
|
||||
return Ok(Some(refused(name, Via::Propagation)));
|
||||
}
|
||||
seen.extend(facts.identities().cloned());
|
||||
queue.extend(facts.dependents().cloned());
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
|
@ -1148,8 +1254,39 @@ pub(crate) fn handle(
|
|||
/// `systemctl` and `systemd-run`, found by name.
|
||||
pub(crate) struct HostSystemd;
|
||||
|
||||
/// The names in one `systemctl show` list value. systemd quotes a name that
|
||||
/// holds a backslash and doubles the backslash inside the quotes.
|
||||
fn parse_names(value: &str) -> Vec<String> {
|
||||
value.split_whitespace().map(str::to_owned).collect()
|
||||
let mut names = Vec::new();
|
||||
let mut current = String::new();
|
||||
let (mut quoted, mut open, mut escaped) = (false, false, false);
|
||||
for character in value.chars() {
|
||||
match character {
|
||||
_ if escaped => {
|
||||
current.push(character);
|
||||
escaped = false;
|
||||
}
|
||||
'\\' if quoted => escaped = true,
|
||||
'"' => {
|
||||
quoted = !quoted;
|
||||
open = true;
|
||||
}
|
||||
_ if character.is_whitespace() && !quoted => {
|
||||
if open {
|
||||
names.push(std::mem::take(&mut current));
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
current.push(character);
|
||||
open = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if open {
|
||||
names.push(current);
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
fn parse_show(output: &str) -> Result<UnitFacts> {
|
||||
|
|
@ -1161,10 +1298,15 @@ fn parse_show(output: &str) -> Result<UnitFacts> {
|
|||
match key {
|
||||
"Id" => facts.id = value.to_owned(),
|
||||
"Names" => facts.names = parse_names(value),
|
||||
"RequiredBy" => facts.required_by = parse_names(value),
|
||||
"BoundBy" => facts.bound_by = parse_names(value),
|
||||
"ConsistsOf" => facts.consists_of = parse_names(value),
|
||||
_ => {}
|
||||
"LoadState" => facts.not_found = value == "not-found",
|
||||
"ActiveState" => {
|
||||
facts.running = matches!(value, "active" | "activating" | "reloading");
|
||||
}
|
||||
_ => {
|
||||
if let Some((_, edge)) = EDGES.iter().find(|(property, _)| *property == key) {
|
||||
facts.along_mut(*edge).extend(parse_names(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ensure!(!facts.id.is_empty(), "systemctl named no unit");
|
||||
|
|
@ -1173,14 +1315,15 @@ fn parse_show(output: &str) -> Result<UnitFacts> {
|
|||
|
||||
impl Systemd for HostSystemd {
|
||||
fn show(&self, unit: &str) -> Result<UnitFacts> {
|
||||
let properties = ["Id", "Names", "LoadState", "ActiveState"]
|
||||
.into_iter()
|
||||
.chain(EDGES.iter().map(|(property, _)| *property))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let output = Command::new("systemctl")
|
||||
.args([
|
||||
"show",
|
||||
"--no-pager",
|
||||
"--property=Id,Names,RequiredBy,BoundBy,ConsistsOf",
|
||||
"--",
|
||||
unit,
|
||||
])
|
||||
.args(["show", "--no-pager"])
|
||||
.arg(format!("--property={properties}"))
|
||||
.args(["--", unit])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.context("running systemctl show")?;
|
||||
|
|
@ -1750,8 +1893,23 @@ mod tests {
|
|||
const STEWARD: u32 = 1000;
|
||||
const CANARY: &str = "CANARY-7f3a91";
|
||||
|
||||
/// What `systemctl show` prints for a loaded, active unit with no edges,
|
||||
/// plus the given properties (a given key replaces its default).
|
||||
fn show_text(id: &str, properties: &[(&str, &str)]) -> String {
|
||||
let mut text = format!("Id={id}\n");
|
||||
for (key, default) in [("Names", id), ("LoadState", "loaded"), ("ActiveState", "active")] {
|
||||
if !properties.iter().any(|(given, _)| *given == key) {
|
||||
text += &format!("{key}={default}\n");
|
||||
}
|
||||
}
|
||||
for (key, value) in properties {
|
||||
text += &format!("{key}={value}\n");
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
struct FakeSystemd {
|
||||
units: Mutex<BTreeMap<String, UnitFacts>>,
|
||||
units: Mutex<BTreeMap<String, String>>,
|
||||
runs: Mutex<Vec<(String, Step)>>,
|
||||
report: Mutex<RunReport>,
|
||||
}
|
||||
|
|
@ -1759,11 +1917,7 @@ mod tests {
|
|||
impl Systemd for FakeSystemd {
|
||||
fn show(&self, unit: &str) -> Result<UnitFacts> {
|
||||
let known = self.units.lock().unwrap().get(unit).cloned();
|
||||
Ok(known.unwrap_or_else(|| UnitFacts {
|
||||
id: unit.into(),
|
||||
names: vec![unit.into()],
|
||||
..UnitFacts::default()
|
||||
}))
|
||||
parse_show(&known.unwrap_or_else(|| show_text(unit, &[])))
|
||||
}
|
||||
|
||||
fn invocation(&self, _unit: &str) -> Option<String> {
|
||||
|
|
@ -1907,8 +2061,13 @@ mod tests {
|
|||
self.systemd.runs.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn describe_unit(&self, name: &str, facts: UnitFacts) {
|
||||
self.systemd.units.lock().unwrap().insert(name.into(), facts);
|
||||
/// Tell the fake `systemctl show` what it prints for `name`.
|
||||
fn describe_unit(&self, name: &str, text: String) {
|
||||
self.systemd.units.lock().unwrap().insert(name.into(), text);
|
||||
}
|
||||
|
||||
fn describe(&self, name: &str, properties: &[(&str, &str)]) {
|
||||
self.describe_unit(name, show_text(name, properties));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2065,19 +2224,52 @@ mod tests {
|
|||
execute(&h.ctx, &id).unwrap();
|
||||
assert_eq!(h.outcome(&id), Outcome::Ran);
|
||||
let runs = h.runs();
|
||||
assert_eq!(runs.len(), 2);
|
||||
assert_eq!(runs.len(), 3);
|
||||
assert_eq!(runs[0].1.argv, ["apt-get", "update", "-q"]);
|
||||
let mut install = vec!["apt-get", "install", "-y", "-q", "--"];
|
||||
install.extend(good);
|
||||
// Each name selects exactly itself and apt may remove nothing.
|
||||
let mut install = vec!["apt-get".to_owned(), "install".into(), "-y".into(), "-q".into()];
|
||||
install.extend(["--no-remove".to_owned(), "--".into()]);
|
||||
install.extend(good.iter().map(|spec| match spec.split_once('=') {
|
||||
Some((name, version)) => format!("?exact-name({name})={version}"),
|
||||
None => format!("?exact-name({spec})"),
|
||||
}));
|
||||
assert_eq!(runs[1].1.argv, install);
|
||||
assert!(install.contains(&"?exact-name(libstdc++6)".to_owned()));
|
||||
assert!(install.contains(&"?exact-name(nginx)=1.24.0-2ubuntu7".to_owned()));
|
||||
// The last step fails unless every named package is installed.
|
||||
let mut present = vec!["dpkg".to_owned(), "-s".into(), "--".into()];
|
||||
present.extend(good.iter().map(|spec| spec.split('=').next().unwrap().to_owned()));
|
||||
assert_eq!(runs[2].1.argv, present);
|
||||
for (unit, step) in &runs {
|
||||
assert!(unit.starts_with("idunn-order-"));
|
||||
let apt = step.argv[0] == "apt-get";
|
||||
assert_eq!(
|
||||
step.environment,
|
||||
[("DEBIAN_FRONTEND".to_owned(), "noninteractive".to_owned())]
|
||||
if apt {
|
||||
vec![("DEBIAN_FRONTEND".to_owned(), "noninteractive".to_owned())]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
);
|
||||
}
|
||||
assert_ne!(runs[0].0, runs[1].0, "each step is its own transient unit");
|
||||
assert_ne!(runs[1].0, runs[2].0, "each step is its own transient unit");
|
||||
|
||||
// A package whose name apt would read as a regex or a conflict is
|
||||
// exactly itself and never a removal.
|
||||
for spec in ["cowsa.", "iptables-persistent", "a.", "nginx+"] {
|
||||
let argv = Operation::PackageInstall { packages: vec![spec.into()] }.steps()[1].argv.clone();
|
||||
assert_eq!(argv[4..], ["--no-remove", "--", &format!("?exact-name({spec})")]);
|
||||
}
|
||||
// A step that failed ends the order: the absent package is a failure.
|
||||
*h.systemd.report.lock().unwrap() = RunReport {
|
||||
exit_code: Some(1),
|
||||
..RunReport::default()
|
||||
};
|
||||
let absent = h.accept(h.issue(Operation::PackageInstall { packages: vec!["cowsay".into()] }));
|
||||
execute(&h.ctx, &absent).unwrap();
|
||||
assert!(matches!(h.outcome(&absent), Outcome::Failed { exit_code: Some(1), .. }));
|
||||
assert_eq!(h.runs().len(), 4, "the update failed, so nothing else ran");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2121,74 +2313,28 @@ mod tests {
|
|||
let h = harness();
|
||||
h.describe_unit(
|
||||
"backup-name.service",
|
||||
UnitFacts {
|
||||
id: "idunn-yggdrasil.service".into(),
|
||||
names: vec!["idunn-yggdrasil.service".into(), "backup-name.service".into()],
|
||||
..UnitFacts::default()
|
||||
},
|
||||
show_text(
|
||||
"idunn-yggdrasil.service",
|
||||
&[("Names", "idunn-yggdrasil.service backup-name.service")],
|
||||
),
|
||||
);
|
||||
h.describe_unit(
|
||||
h.describe(
|
||||
"quiet-alias.service",
|
||||
UnitFacts {
|
||||
id: "quiet-alias.service".into(),
|
||||
names: vec!["quiet-alias.service".into(), "mind-persona@void.service".into()],
|
||||
..UnitFacts::default()
|
||||
},
|
||||
&[("Names", "quiet-alias.service mind-persona@void.service")],
|
||||
);
|
||||
// fuse <- wrapper <- (bound by) mind-self@eureka
|
||||
h.describe_unit(
|
||||
"fuse.service",
|
||||
UnitFacts {
|
||||
id: "fuse.service".into(),
|
||||
names: vec!["fuse.service".into()],
|
||||
required_by: vec!["wrapper.service".into()],
|
||||
..UnitFacts::default()
|
||||
},
|
||||
);
|
||||
h.describe_unit(
|
||||
"wrapper.service",
|
||||
UnitFacts {
|
||||
id: "wrapper.service".into(),
|
||||
names: vec!["wrapper.service".into()],
|
||||
bound_by: vec!["mind-self@eureka.service".into()],
|
||||
..UnitFacts::default()
|
||||
},
|
||||
);
|
||||
h.describe_unit(
|
||||
"stack.target",
|
||||
UnitFacts {
|
||||
id: "stack.target".into(),
|
||||
names: vec!["stack.target".into()],
|
||||
consists_of: vec!["mind-persona@void.service".into()],
|
||||
..UnitFacts::default()
|
||||
},
|
||||
);
|
||||
h.describe("fuse.service", &[("RequiredBy", "wrapper.service")]);
|
||||
h.describe("wrapper.service", &[("BoundBy", "mind-self@eureka.service")]);
|
||||
h.describe("stack.target", &[("ConsistsOf", "mind-persona@void.service")]);
|
||||
// A cycle that reaches an alias of a refused unit on its second hop.
|
||||
h.describe_unit(
|
||||
"loop-a.service",
|
||||
UnitFacts {
|
||||
id: "loop-a.service".into(),
|
||||
names: vec!["loop-a.service".into()],
|
||||
required_by: vec!["loop-b.service".into()],
|
||||
..UnitFacts::default()
|
||||
},
|
||||
);
|
||||
h.describe_unit(
|
||||
"loop-b.service",
|
||||
UnitFacts {
|
||||
id: "loop-b.service".into(),
|
||||
names: vec!["loop-b.service".into()],
|
||||
required_by: vec!["loop-a.service".into(), "hidden.service".into()],
|
||||
..UnitFacts::default()
|
||||
},
|
||||
);
|
||||
h.describe("loop-a.service", &[("RequiredBy", "loop-b.service")]);
|
||||
h.describe("loop-b.service", &[("RequiredBy", "loop-a.service hidden.service")]);
|
||||
h.describe_unit(
|
||||
"hidden.service",
|
||||
UnitFacts {
|
||||
id: "idunn-orders.service".into(),
|
||||
names: vec!["idunn-orders.service".into(), "hidden.service".into()],
|
||||
..UnitFacts::default()
|
||||
},
|
||||
show_text(
|
||||
"idunn-orders.service",
|
||||
&[("Names", "idunn-orders.service hidden.service")],
|
||||
),
|
||||
);
|
||||
|
||||
let direct = [
|
||||
|
|
@ -2260,7 +2406,7 @@ mod tests {
|
|||
}
|
||||
}
|
||||
assert!(h.runs().is_empty(), "no systemd verb ran for any refused order");
|
||||
// start and reload propagate nothing, so the same units are not refused.
|
||||
// start and reload take nothing down, so the same units are not refused.
|
||||
for verb in [Verb::Start, Verb::Reload] {
|
||||
let id = h.accept(h.issue_unit(verb, "fuse.service"));
|
||||
execute(&h.ctx, &id).unwrap();
|
||||
|
|
@ -2284,6 +2430,248 @@ mod tests {
|
|||
assert!(ledger.starts.len() == 2);
|
||||
}
|
||||
|
||||
/// Yggdrasil's `systemctl show` shapes (systemd 257, 2026-11-01), cut down
|
||||
/// to the units an order can reach: seven refused units all conflict with
|
||||
/// shutdown.target, and the shutdown services require it.
|
||||
fn yggdrasil(h: &Harness, idunn_running: bool) {
|
||||
let state = if idunn_running { "active" } else { "inactive" };
|
||||
let refused = [
|
||||
"idunn-yggdrasil.service",
|
||||
"idunn-orders.service",
|
||||
"idunn-vili-e0ada640.service",
|
||||
"mind-self@eureka.service",
|
||||
"mind-persona@void.service",
|
||||
];
|
||||
h.describe(
|
||||
"shutdown.target",
|
||||
&[
|
||||
("ActiveState", "inactive"),
|
||||
(
|
||||
"RequiredBy",
|
||||
"systemd-soft-reboot.service systemd-reboot.service systemd-poweroff.service",
|
||||
),
|
||||
(
|
||||
"ConflictedBy",
|
||||
"nginx.service idunn-yggdrasil.service idunn-orders.service \
|
||||
idunn-vili-e0ada640.service mind-self@eureka.service mind-persona@void.service",
|
||||
),
|
||||
],
|
||||
);
|
||||
for refused in refused {
|
||||
h.describe(
|
||||
refused,
|
||||
&[
|
||||
("Requires", "sysinit.target system.slice -.mount"),
|
||||
("Wants", "tmp.mount network-online.target"),
|
||||
("Conflicts", "shutdown.target"),
|
||||
("ActiveState", if refused == "idunn-yggdrasil.service" { state } else { "inactive" }),
|
||||
],
|
||||
);
|
||||
}
|
||||
h.describe("nginx.service", &[("Wants", "network-online.target"), ("Conflicts", "shutdown.target")]);
|
||||
for (service, target) in [
|
||||
("systemd-poweroff.service", "poweroff.target"),
|
||||
("systemd-reboot.service", "reboot.target"),
|
||||
("systemd-soft-reboot.service", "soft-reboot.target"),
|
||||
] {
|
||||
h.describe(
|
||||
service,
|
||||
&[
|
||||
("Requires", "shutdown.target final.target system.slice umount.target"),
|
||||
("RequiredBy", target),
|
||||
("ActiveState", "inactive"),
|
||||
],
|
||||
);
|
||||
}
|
||||
for (target, service) in [
|
||||
("poweroff.target", "systemd-poweroff.service"),
|
||||
("reboot.target", "systemd-reboot.service"),
|
||||
("soft-reboot.target", "systemd-soft-reboot.service"),
|
||||
] {
|
||||
h.describe(target, &[("Requires", service), ("ActiveState", "inactive")]);
|
||||
}
|
||||
h.describe(
|
||||
"umount.target",
|
||||
&[
|
||||
("ActiveState", "inactive"),
|
||||
("RequiredBy", "systemd-poweroff.service systemd-reboot.service"),
|
||||
("ConflictedBy", "boot-efi.mount boot.mount \"blockdev@dev-vda2.target\""),
|
||||
],
|
||||
);
|
||||
h.describe(
|
||||
"multi-user.target",
|
||||
&[
|
||||
("Requires", "basic.target"),
|
||||
("Wants", "cron.service ssh.service idunn-yggdrasil.service"),
|
||||
("Conflicts", "shutdown.target rescue.target rescue.service"),
|
||||
],
|
||||
);
|
||||
h.describe(
|
||||
"voidbot.service",
|
||||
&[
|
||||
("Requires", "docker.service -.mount sysinit.target system.slice"),
|
||||
("Wants", "idunn-yggdrasil.service network-online.target"),
|
||||
("Conflicts", "shutdown.target"),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
fn outcome_of_unit(h: &Harness, verb: Verb, unit: &str) -> Outcome {
|
||||
let id = h.accept(h.issue_unit(verb, unit));
|
||||
execute(&h.ctx, &id).unwrap();
|
||||
h.outcome(&id)
|
||||
}
|
||||
|
||||
fn hit(outcome: &Outcome) -> Option<&str> {
|
||||
match outcome {
|
||||
Outcome::Refused(Refused::AlwaysRefused { unit, via: Via::Propagation }) => Some(unit.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transaction_that_reaches_a_refused_unit_is_refused() {
|
||||
let h = harness();
|
||||
yggdrasil(&h, true);
|
||||
// The targets and services that power the host down reach every refused
|
||||
// unit through Conflicts=, however many requirements away.
|
||||
for (verb, unit) in [
|
||||
(Verb::Start, "shutdown.target"),
|
||||
(Verb::Start, "poweroff.target"),
|
||||
(Verb::Restart, "poweroff.target"),
|
||||
(Verb::Start, "reboot.target"),
|
||||
(Verb::Restart, "reboot.target"),
|
||||
(Verb::Start, "soft-reboot.target"),
|
||||
(Verb::Start, "systemd-soft-reboot.service"),
|
||||
(Verb::Restart, "systemd-poweroff.service"),
|
||||
] {
|
||||
let outcome = outcome_of_unit(&h, verb, unit);
|
||||
assert!(hit(&outcome).is_some(), "{verb:?} {unit} was {outcome:?}");
|
||||
}
|
||||
assert!(h.runs().is_empty(), "no refused order ran anything");
|
||||
// A stop of shutdown.target takes down what requires it and runs no start.
|
||||
let outcome = outcome_of_unit(&h, Verb::Stop, "shutdown.target");
|
||||
assert_eq!(outcome, Outcome::Ran);
|
||||
|
||||
// A start that wants a refused unit that is stopped would start it.
|
||||
h.describe("idunn-yggdrasil.service", &[("ActiveState", "inactive"), ("Conflicts", "shutdown.target")]);
|
||||
for (verb, unit) in [
|
||||
(Verb::Start, "multi-user.target"),
|
||||
(Verb::Restart, "multi-user.target"),
|
||||
(Verb::Start, "voidbot.service"),
|
||||
(Verb::Restart, "voidbot.service"),
|
||||
] {
|
||||
let outcome = outcome_of_unit(&h, verb, unit);
|
||||
assert_eq!(hit(&outcome), Some("idunn-yggdrasil.service"), "{verb:?} {unit}");
|
||||
}
|
||||
// The same orders start nothing while the refused unit runs.
|
||||
h.describe("idunn-yggdrasil.service", &[("ActiveState", "active"), ("Conflicts", "shutdown.target")]);
|
||||
for (verb, unit) in [
|
||||
(Verb::Start, "multi-user.target"),
|
||||
(Verb::Restart, "voidbot.service"),
|
||||
(Verb::Start, "voidbot.service"),
|
||||
] {
|
||||
assert_eq!(outcome_of_unit(&h, verb, unit), Outcome::Ran, "{verb:?} {unit}");
|
||||
}
|
||||
// A unit that wants only what is not refused runs, and so does a target
|
||||
// that unmounts nothing a refused unit requires.
|
||||
for verb in [Verb::Start, Verb::Restart, Verb::Stop, Verb::Reload] {
|
||||
assert_eq!(outcome_of_unit(&h, verb, "nginx.service"), Outcome::Ran);
|
||||
}
|
||||
assert_eq!(outcome_of_unit(&h, Verb::Start, "umount.target"), Outcome::Ran);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_closure_follows_every_edge_and_every_hop() {
|
||||
let h = harness();
|
||||
// Start: three hops of Wants/Requires/Requisite/BindsTo/Upholds to a
|
||||
// stopped refused unit.
|
||||
h.describe("hop-1.service", &[("Wants", "hop-2.service")]);
|
||||
h.describe("hop-2.service", &[("Requires", "hop-3.service")]);
|
||||
h.describe("hop-3.service", &[("Requisite", "hop-4.service")]);
|
||||
h.describe("hop-4.service", &[("BindsTo", "hop-5.service")]);
|
||||
h.describe("hop-5.service", &[("Upholds", "idunn-orders.service")]);
|
||||
h.describe("idunn-orders.service", &[("ActiveState", "inactive")]);
|
||||
let outcome = outcome_of_unit(&h, Verb::Start, "hop-1.service");
|
||||
assert_eq!(hit(&outcome), Some("idunn-orders.service"));
|
||||
// Conflicts of a pulled-in unit stop it and what requires it: the
|
||||
// conflict, then two hops of reverse requirement, to a refused unit.
|
||||
h.describe("rival.service", &[("Conflicts", "victim.service")]);
|
||||
h.describe("victim.service", &[("RequiredBy", "mid.service")]);
|
||||
h.describe("mid.service", &[("BoundBy", "mind-self@eureka.service")]);
|
||||
h.describe("pulls-rival.service", &[("Requires", "rival.service")]);
|
||||
for unit in ["rival.service", "pulls-rival.service"] {
|
||||
let outcome = outcome_of_unit(&h, Verb::Start, unit);
|
||||
assert_eq!(hit(&outcome), Some("mind-self@eureka.service"), "{unit}");
|
||||
}
|
||||
// The reverse Conflicts edge: a unit conflicted by a refused one.
|
||||
h.describe("incumbent.service", &[("ConflictedBy", "mind-persona@void.service")]);
|
||||
let outcome = outcome_of_unit(&h, Verb::Start, "incumbent.service");
|
||||
assert_eq!(hit(&outcome), Some("mind-persona@void.service"));
|
||||
// Stop and restart: every stop edge, one at a time.
|
||||
for (property, reached) in [
|
||||
("RequiredBy", "idunn-vili-x.service"),
|
||||
("BoundBy", "idunn-vili-x.service"),
|
||||
("RequisiteOf", "idunn-vili-x.service"),
|
||||
("ConsistsOf", "idunn-vili-x.service"),
|
||||
("PropagatesStopTo", "mind-persona@void.service"),
|
||||
("StopPropagatedFrom", "mind-persona@void.service"),
|
||||
] {
|
||||
let name = format!("edge-{property}.service");
|
||||
h.describe(&name, &[(property, reached)]);
|
||||
for verb in [Verb::Stop, Verb::Restart] {
|
||||
let outcome = outcome_of_unit(&h, verb, &name);
|
||||
assert_eq!(hit(&outcome), Some(reached), "{verb:?} {property}");
|
||||
}
|
||||
}
|
||||
// A restart starts what its stopped dependents pull in.
|
||||
h.describe("base.service", &[("RequiredBy", "dependent.service")]);
|
||||
h.describe("dependent.service", &[("Wants", "idunn-orders.service")]);
|
||||
let outcome = outcome_of_unit(&h, Verb::Restart, "base.service");
|
||||
assert_eq!(hit(&outcome), Some("idunn-orders.service"));
|
||||
assert_eq!(outcome_of_unit(&h, Verb::Stop, "base.service"), Outcome::Ran);
|
||||
assert_eq!(outcome_of_unit(&h, Verb::Reload, "base.service"), Outcome::Ran);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quoted_name_in_a_dependency_list_is_the_name_it_quotes() {
|
||||
let h = harness();
|
||||
// systemctl show quotes a name with a backslash and doubles the backslash.
|
||||
h.describe(
|
||||
"huginn.service",
|
||||
&[("RequiredBy", "\"mind-self@foo\\\\x2dbar.service\"")],
|
||||
);
|
||||
let outcome = outcome_of_unit(&h, Verb::Stop, "huginn.service");
|
||||
assert_eq!(hit(&outcome), Some("mind-self@foo\\x2dbar.service"));
|
||||
h.describe("quiet.service", &[("Wants", "\"idunn-a\\\\x2db.service\" other.service")]);
|
||||
h.describe("idunn-a\\x2db.service", &[("ActiveState", "inactive")]);
|
||||
let outcome = outcome_of_unit(&h, Verb::Start, "quiet.service");
|
||||
assert_eq!(hit(&outcome), Some("idunn-a\\x2db.service"));
|
||||
assert!(h.runs().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_unit_systemd_cannot_show_is_a_failure_not_a_leaf() {
|
||||
let h = harness();
|
||||
h.describe("ghost.service", &[("LoadState", "not-found")]);
|
||||
h.describe("pulls-ghost.service", &[("Wants", "ghost.service")]);
|
||||
h.describe("needed-by-ghost.service", &[("RequiredBy", "ghost.service")]);
|
||||
for (verb, unit) in [
|
||||
(Verb::Start, "ghost.service"),
|
||||
(Verb::Start, "pulls-ghost.service"),
|
||||
(Verb::Stop, "needed-by-ghost.service"),
|
||||
(Verb::Restart, "needed-by-ghost.service"),
|
||||
] {
|
||||
let id = h.accept(h.issue_unit(verb, unit));
|
||||
execute(&h.ctx, &id).unwrap();
|
||||
assert!(
|
||||
matches!(h.outcome(&id), Outcome::Failed { exit_code: None, signal: None }),
|
||||
"{verb:?} {unit}"
|
||||
);
|
||||
}
|
||||
assert!(h.runs().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn globs_slices_and_scopes_are_not_units() {
|
||||
let h = harness();
|
||||
|
|
@ -2715,16 +3103,40 @@ mod tests {
|
|||
#[test]
|
||||
fn systemctl_show_output_is_read_into_facts() {
|
||||
let facts = parse_show(
|
||||
"Id=a.service\nNames=a.service b.service\nRequiredBy=c.service\n\
|
||||
BoundBy=\nConsistsOf=d.target e.service\nOther=x\n",
|
||||
"Id=a.service\nNames=a.service b.service\nLoadState=loaded\nActiveState=inactive\n\
|
||||
Requires=r.service \"q\\\\x2dr.slice\" -.mount\nRequisite=t.service\nWants=w.service\n\
|
||||
BindsTo=b.service\nUpholds=u.service\nConflicts=x.target\nConflictedBy=y.service\n\
|
||||
RequiredBy=c.service\nBoundBy=\nRequisiteOf=ro.service\nConsistsOf=d.target e.service\n\
|
||||
PropagatesStopTo=p.service\nStopPropagatedFrom=f.service\nOther=x\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(facts.id, "a.service");
|
||||
assert_eq!(facts.names, ["a.service", "b.service"]);
|
||||
assert_eq!(facts.required_by, ["c.service"]);
|
||||
assert!(facts.bound_by.is_empty());
|
||||
assert_eq!(facts.consists_of, ["d.target", "e.service"]);
|
||||
assert!(!facts.not_found && !facts.running);
|
||||
// A quoted name holds one backslash, not two and not a quote mark.
|
||||
assert_eq!(
|
||||
facts.pulls,
|
||||
["r.service", "q\\x2dr.slice", "-.mount", "t.service", "w.service", "b.service", "u.service"]
|
||||
);
|
||||
assert_eq!(facts.conflicts, ["x.target", "y.service"]);
|
||||
assert_eq!(
|
||||
facts.stops,
|
||||
["c.service", "ro.service", "d.target", "e.service", "p.service", "f.service"]
|
||||
);
|
||||
assert!(parse_show("Names=a.service\n").is_err());
|
||||
let gone = parse_show("Id=\\x22a.service\\x22.service\nLoadState=not-found\n").unwrap();
|
||||
assert!(gone.not_found);
|
||||
for (state, running) in [
|
||||
("active", true),
|
||||
("activating", true),
|
||||
("reloading", true),
|
||||
("inactive", false),
|
||||
("deactivating", false),
|
||||
("failed", false),
|
||||
] {
|
||||
let facts = parse_show(&format!("Id=a.service\nActiveState={state}\n")).unwrap();
|
||||
assert_eq!(facts.running, running, "{state}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue