Files
omarchy-pkgs/tests/oma-service-removal.py
T

357 lines
18 KiB
Python

#!/usr/bin/env python3
"""Removal regression fixtures. No real systemd manager, user home or package is touched."""
import os
from pathlib import Path
import socket
import subprocess
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
# Characters that make systemd's shell_maybe_quote() quote a value, a copy of
# SHELL_NEED_ESCAPE, GLOB_CHARS and the rest of SHELL_NEED_QUOTES in escape.h.
SHELL_NEED_QUOTES = '"\\`$*?[]' + "'()<>|&;!"
def systemd_environment_value(value):
r"""Return VALUE as ``systemctl show-environment`` would print it.
print_variable() in systemctl-set-environment.c hands every value to
shell_maybe_quote(SHELL_ESCAPE_POSIX) quotes special values and uses
cescape_char() for control bytes.
"""
if not any(c in SHELL_NEED_QUOTES or c.isspace() or ord(c) < 0x20 or c == "\x7f"
for c in value):
return value
escapes = dict(zip("\a\b\f\n\r\t\v\\'", (r"\a", r"\b", r"\f", r"\n", r"\r", r"\t", r"\v", r"\\", r"\'")))
return "$'" + "".join(escapes.get(c, f"\\{ord(c):03o}" if ord(c) < 0x20 or c == "\x7f" else c)
for c in value) + "'"
class Removal(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="oma-removal-")
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.home = self.root / "home"
self.runtime = self.root / "runtime"
self.units = self.home / ".config/systemd/user"
self.units.mkdir(parents=True)
self.runtime.mkdir()
self.bin = self.root / "bin"
self.bin.mkdir()
self.log = self.root / "calls"
self.env = dict(os.environ, PATH=f"{self.bin}:{os.environ['PATH']}", CALLS=str(self.log))
self.executable("systemctl", '''#!/bin/bash
printf '%s\\n' "$*" >> "$CALLS"
case "$*" in
*show-environment*) [[ -z ${MANAGER_FAIL-} ]] || exit 1
# print_variable() prints every value the way a shell would read it, so the
# fixture, not this stub, decides how the value is quoted.
echo "XDG_CONFIG_HOME=${CONFIG_HOME_RAW-${CONFIG_HOME-}}" ;;
*property=ExecStart*) echo "${EFFECTIVE-}" ;;
*property=LoadState*) echo "${LOAD_STATE-loaded}" ;;
# A unit that failed stays failed after it is stopped, and systemd refuses
# reset-failed for every other state, reporting the unit as not loaded.
*property=ActiveState*) echo "${ACTIVE_STATE-inactive}" ;;
*" reset-failed "*) [[ ${ACTIVE_STATE-inactive} == failed && -z ${RESET_FAIL-} ]] || {
printf 'Failed to reset failed state of unit: Unit is not loaded.\n' >&2; exit 1; } ;;
*" stop "*) [[ -z ${STOP_FAIL-} ]] || exit 1 ;;
esac
''')
def executable(self, name, source):
path = self.bin / name
path.write_text(source)
path.chmod(0o755)
def online(self):
sock = socket.socket(socket.AF_UNIX)
sock.bind(str(self.runtime / "bus"))
self.addCleanup(sock.close)
def install(self, app, binary=None):
unit = self.units / f"{app}.service"
unit.write_text(f'[Service]\nExecStart="{binary or "/usr/bin/" + app}" --config "{self.home}/config.toml" daemon\n')
target = self.units / "graphical-session.target.wants"
target.mkdir(exist_ok=True)
link = target / unit.name
link.symlink_to(f"../{unit.name}")
return unit, link
def run_remove(self, app):
self.log.unlink(missing_ok=True) # Every run is judged on its own calls.
return subprocess.run(["bash", str(ROOT / f"pkgbuilds/{app}-bin/package-remove"),
"--user", app, str(self.home), str(self.runtime)],
env=self.env, text=True, capture_output=True)
def test_logged_out_users_and_data_preservation(self):
for app in ("omawake", "omaspeak"):
unit, link = self.install(app)
config = self.home / f"{app}.toml"
config.write_text("keep settings and models")
result = self.run_remove(app)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(unit.exists())
self.assertFalse(link.is_symlink())
self.assertEqual(config.read_text(), "keep settings and models")
self.assertFalse(self.log.exists(), "offline cleanup contacted systemd")
def test_active_unit_is_stopped_before_removing_it(self):
self.online()
for app in ("omawake", "omaspeak"):
unit, link = self.install(app)
self.env["EFFECTIVE"] = f"{{ path=/usr/bin/{app} ; argv[]=/usr/bin/{app} daemon ; }}"
result = self.run_remove(app)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(unit.exists())
self.assertFalse(link.is_symlink())
calls = self.log.read_text().splitlines()
self.assertLess(calls.index(f"--user stop {app}.service"), calls.index(f"--user disable {app}.service"))
self.assertEqual(calls[-1], "--user daemon-reload")
def test_failed_stop_prevents_unit_deletion_and_fails_hook(self):
self.online()
unit, link = self.install("omawake")
self.env.update(STOP_FAIL="1", EFFECTIVE="{ path=/usr/bin/omawake ; }")
result = self.run_remove("omawake")
self.assertNotEqual(result.returncode, 0)
self.assertTrue(unit.exists())
self.assertTrue(link.is_symlink())
self.assertNotIn("disable", self.log.read_text())
def test_reset_failed_is_requested_only_for_a_unit_that_failed(self):
self.online()
for app in ("omawake", "omaspeak"):
unit, link = self.install(app)
self.env["EFFECTIVE"] = f"{{ path=/usr/bin/{app} ; argv[]=/usr/bin/{app} daemon ; }}"
# A loaded unit that never failed is not failed, and asking systemd to
# reset it fails with "Unit is not loaded": that must not abort removal.
self.env["ACTIVE_STATE"] = "active"
result = self.run_remove(app)
self.assertEqual(result.returncode, 0, result.stderr)
calls = [call for call in self.log.read_text().splitlines() if call.startswith("--user")]
self.assertNotIn(f"--user reset-failed {app}.service", calls)
# Reading the manager's state is welcome; the only changes asked for
# are the stop, the disable and the reload that follow them.
self.assertEqual([call for call in calls
if "show-environment" not in call and "--property=" not in call],
[f"--user stop {app}.service",
f"--user disable {app}.service", "--user daemon-reload"])
self.assertFalse(unit.exists())
self.assertFalse(link.is_symlink())
# A unit that failed does keep that state once stopped, and there the
# reset belongs between stopping the service and disabling the unit.
unit, link = self.install(app)
self.env["ACTIVE_STATE"] = "failed"
result = self.run_remove(app)
self.assertEqual(result.returncode, 0, result.stderr)
calls = self.log.read_text().splitlines()
reset = f"--user reset-failed {app}.service"
self.assertIn(reset, calls)
self.assertLess(calls.index(f"--user stop {app}.service"), calls.index(reset))
self.assertLess(calls.index(reset), calls.index(f"--user disable {app}.service"))
self.assertFalse(unit.exists())
self.assertFalse(link.is_symlink())
del self.env["ACTIVE_STATE"]
def test_reset_refused_by_a_healthy_manager_still_fails_the_transaction(self):
self.online()
unit, link = self.install("omaspeak")
self.env.update(EFFECTIVE="{ path=/usr/bin/omaspeak ; }",
ACTIVE_STATE="failed", RESET_FAIL="1")
result = self.run_remove("omaspeak")
self.assertNotEqual(result.returncode, 0)
self.assertTrue(unit.exists())
self.assertTrue(link.is_symlink())
def test_custom_build_and_mask_are_preserved(self):
unit, link = self.install("omawake", "/home/user/dev/omawake")
self.assertEqual(self.run_remove("omawake").returncode, 0)
self.assertTrue(unit.exists())
self.assertTrue(link.is_symlink())
unit.unlink()
unit.symlink_to("/dev/null")
self.assertEqual(self.run_remove("omawake").returncode, 0)
self.assertTrue(unit.is_symlink())
self.assertFalse(self.log.exists())
def test_effective_override_and_missing_online_unit(self):
self.online()
unit, _ = self.install("omaspeak")
self.env["EFFECTIVE"] = "{ path=/home/user/development/omaspeak ; }"
self.assertEqual(self.run_remove("omaspeak").returncode, 0)
self.assertTrue(unit.exists())
self.assertNotIn(" stop ", self.log.read_text())
unit.unlink()
self.env.update(EFFECTIVE="", LOAD_STATE="not-found")
self.assertEqual(self.run_remove("omaspeak").returncode, 0)
self.assertNotIn(" stop ", self.log.read_text())
def test_manager_config_home_and_unavailable_manager(self):
self.online()
default = self.units
self.units = self.home / "custom-config/systemd/user"
self.units.mkdir(parents=True)
unit, link = self.install("omawake")
self.env.update(CONFIG_HOME=str(self.home / "custom-config"), MANAGER_FAIL="1")
self.assertNotEqual(self.run_remove("omawake").returncode, 0)
self.assertTrue(unit.exists())
del self.env["MANAGER_FAIL"]
self.assertEqual(self.run_remove("omawake").returncode, 0)
self.assertFalse(unit.exists())
self.assertFalse(link.is_symlink())
self.assertTrue(default.exists())
def test_root_dispatch_drops_privileges_and_propagates_failure(self):
passwd = f"fixture:x:12345:12345::{self.home}:/bin/bash"
self.executable("getent", f"#!/bin/sh\nprintf '%s\\n' '{passwd}'\n")
self.executable("runuser", '#!/bin/sh\nprintf "%s\\n" "$*" >> "$CALLS"\nexit 1\n')
result = subprocess.run(["bash", str(ROOT / "pkgbuilds/omawake-bin/package-remove"), "omawake"], env=self.env, capture_output=True)
self.assertNotEqual(result.returncode, 0)
self.assertIn("-u fixture -- env", self.log.read_text())
self.assertIn("--user omawake", self.log.read_text())
def test_offline_executable_overrides_are_preserved(self):
for app in ("omawake", "omaspeak"):
for spacing in ("ExecStart=\nExecStart={command}",
# systemd's parser throws the whitespace around an
# assignment away, so both of these spellings still
# name a development build, exactly as the first does.
"ExecStart =\nExecStart = {command}",
"\tExecStart\t=\t{command}"):
unit, link = self.install(app)
dropins = Path(str(unit) + ".d")
dropins.mkdir(exist_ok=True)
(dropins / "override.conf").write_text("[Service]\n" + spacing.format(
command=f"/home/user/build/{app} daemon") + "\n")
try:
with self.subTest(app=app, spacing=spacing):
self.assertEqual(self.run_remove(app).returncode, 0)
self.assertTrue(unit.exists(), "removed a service with an override")
self.assertTrue(link.is_symlink(), "unlinked a service with an override")
finally:
unit.unlink(missing_ok=True)
link.unlink(missing_ok=True)
self.assertFalse(self.log.exists(), "offline cleanup contacted systemd")
def test_shell_quoted_manager_config_home_is_resolved(self):
self.online()
default_units = self.units
for app in ("omawake", "omaspeak"):
config_home = self.home / f"{app} custom's \\ config"
self.units = config_home / "systemd/user"
self.units.mkdir(parents=True)
unit, link = self.install(app)
# A unit in the directory the manager reads nothing from is no unit of
# the manager's, and the helper has no business reaching for it.
stray = default_units / f"{app}.service"
stray.write_text(f'[Service]\nExecStart="/usr/bin/{app}" daemon\n')
printed = self.env["CONFIG_HOME_RAW"] = systemd_environment_value(str(config_home))
with self.subTest(app=app, printed=printed):
self.assertTrue(printed.startswith("$'") and printed.endswith("'"),
"a path of spaces is not what a plain value looks like")
result = self.run_remove(app)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(unit.exists())
self.assertFalse(link.is_symlink())
self.assertTrue(stray.is_file(), "guessed at a directory no manager reads")
del self.env["CONFIG_HOME_RAW"]
def test_control_character_manager_config_home_is_resolved(self):
self.online()
for app in ("omawake", "omaspeak"):
for suffix in ("tab\tpath", "newline\npath\n", "\a\b\f\r\v", "\x01\x1b\x7f"):
config_home = self.home / (app + suffix)
self.units = config_home / "systemd/user"
self.units.mkdir(parents=True)
unit, link = self.install(app)
self.env["CONFIG_HOME_RAW"] = systemd_environment_value(str(config_home))
with self.subTest(app=app, suffix=suffix):
result = self.run_remove(app)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(unit.exists())
self.assertFalse(link.is_symlink())
def test_unreadable_manager_config_home_aborts_the_cleanup(self):
self.online()
for app in ("omawake", "omaspeak"):
unit, link = self.install(app)
# Truncated output must not make cleanup guess at a directory.
self.env["CONFIG_HOME_RAW"] = "$'" + str(self.home / f"broken {app} config")
with self.subTest(app=app):
result = self.run_remove(app)
self.assertNotEqual(result.returncode, 0)
self.assertTrue(unit.exists())
self.assertTrue(link.is_symlink())
self.assertNotIn(" stop ", self.log.read_text())
del self.env["CONFIG_HOME_RAW"]
def test_relative_manager_config_home_keeps_the_default_directory(self):
self.online()
for app in ("omawake", "omaspeak"):
unit, link = self.install(app)
# An XDG_CONFIG_HOME that is not absolute is no setting at all: the
# manager itself reads the home's .config directory then.
self.env["CONFIG_HOME_RAW"] = systemd_environment_value(f"relative {app} config")
with self.subTest(app=app):
result = self.run_remove(app)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(unit.exists())
self.assertFalse(link.is_symlink())
del self.env["CONFIG_HOME_RAW"]
def test_packaging_installs_hooks_and_helpers(self):
import shutil
for app in ("omawake", "omaspeak"):
directory = ROOT / f"pkgbuilds/{app}-bin"
version = next(
line.removeprefix("pkgver=")
for line in (directory / "PKGBUILD").read_text().splitlines()
if line.startswith("pkgver=")
)
source = self.root / app / "src"
package = self.root / app / "pkg"
release = source / f"{app}-{version}-linux-x86_64"
release.mkdir(parents=True)
for path in [app, "lib/libaudiocpp.so.0.1.0", f"packaging/systemd/{app}.service",
"README.md", "INSTALL.md", "ACCELERATOR_SETUP.md", "CHANGELOG.md",
"RELEASE_NOTES.md", "DEMO.md", "RUNTIME.md", "config.example.toml",
"licenses/LICENSE", "assets/fixture", "benchmarks/fixture"]:
target = release / path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("fixture")
for name in ("package-remove", "remove-user-services.hook"):
shutil.copyfile(directory / name, source / name)
env = dict(self.env, srcdir=str(source), pkgdir=str(package), CARCH="x86_64")
result = subprocess.run(["bash", "-c", 'source "$1"; package', "package-fixture", str(directory / "PKGBUILD")], env=env, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
helper = package / f"usr/lib/{app}/package-remove"
self.assertEqual(helper.read_bytes(), (directory / "package-remove").read_bytes())
self.assertEqual(helper.stat().st_mode & 0o777, 0o755)
self.assertTrue((package / f"usr/share/libalpm/hooks/30-{app}-remove-user-services.hook").exists())
def test_hook_contract_and_package_release(self):
scripts = []
for app in ("omawake", "omaspeak"):
directory = ROOT / f"pkgbuilds/{app}-bin"
hook = (directory / "remove-user-services.hook").read_text()
self.assertIn("Operation = Remove", hook)
self.assertNotIn("Operation = Upgrade", hook)
self.assertIn("When = PreTransaction", hook)
self.assertIn("AbortOnFail", hook)
self.assertIn(f"Exec = /usr/lib/{app}/package-remove {app}", hook)
release = next(
line for line in (directory / "PKGBUILD").read_text().splitlines()
if line.startswith("pkgrel=")
)
self.assertGreaterEqual(int(release.removeprefix("pkgrel=")), 1)
scripts.append((directory / "package-remove").read_bytes())
self.assertEqual(*scripts)
if __name__ == "__main__":
unittest.main()