From 73d0e301758d8afc1bce4cc84698d92841a86a1b Mon Sep 17 00:00:00 2001 From: fwazb Date: Thu, 23 Jul 2026 00:52:00 -0700 Subject: [PATCH] Handle systemd 252 absent command properties --- ops/install_gcp_leoclean_nosend_release.py | 10 ++++++--- tests/test_gcp_leoclean_nosend_installer.py | 24 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/ops/install_gcp_leoclean_nosend_release.py b/ops/install_gcp_leoclean_nosend_release.py index 42f2db0..785d793 100644 --- a/ops/install_gcp_leoclean_nosend_release.py +++ b/ops/install_gcp_leoclean_nosend_release.py @@ -694,10 +694,14 @@ def _parse_properties(raw: bytes) -> dict[str, str]: separator == "=" and key in SERVICE_PROPERTIES and key not in value, "systemd property output was invalid" ) value[key] = item - # systemd 252 omits EnvironmentFiles entirely when its string array is - # empty. A nonempty array is emitted and remains subject to the exact - # no-EnvironmentFile checks below. + # systemd 252 omits empty array/command properties. For a nonexistent + # service, there cannot be an effective command to conceal, so normalize + # the three omitted Exec* properties to their explicit empty form. Loaded + # units must still report every command property for exact verification. value.setdefault("EnvironmentFiles", "") + if value.get("LoadState") == "not-found": + for property_name in SERVICE_EXEC_PROPERTIES: + value.setdefault(property_name, "") _require(set(value) == set(SERVICE_PROPERTIES), "systemd property output was incomplete") return value diff --git a/tests/test_gcp_leoclean_nosend_installer.py b/tests/test_gcp_leoclean_nosend_installer.py index 2b4fbf8..b2039ab 100644 --- a/tests/test_gcp_leoclean_nosend_installer.py +++ b/tests/test_gcp_leoclean_nosend_installer.py @@ -118,6 +118,7 @@ class FakeHost: self.race_applied = False self.property_overrides: dict[str, str] = {} self.omitted_properties: set[str] = set() + self.not_found_omitted_properties: set[str] = set() self.not_found_security_defaults: dict[str, str] = {} self.unstable_readbacks = False self.show_count = 0 @@ -241,6 +242,7 @@ class FakeHost: f"{name}={value[name]}\n" for name in installer.SERVICE_PROPERTIES if name not in self.omitted_properties + and (loaded or name not in self.not_found_omitted_properties) ).encode() return subprocess.CompletedProcess(arguments, 0, stdout=payload, stderr=b"") @@ -475,6 +477,28 @@ def test_systemd_252_omitted_environment_files_property_is_normalized(tmp_path: assert host.restart_calls == 1 +def test_systemd_252_omitted_not_found_commands_are_normalized(tmp_path: Path) -> None: + bundle, release, image = release_bundle(tmp_path, "2") + paths = install_paths(tmp_path) + host = FakeHost(paths) + host.add_release(release, image) + host.not_found_security_defaults = dict(PRODUCTION_SHAPED_NOT_FOUND_SECURITY) + host.not_found_omitted_properties.update({"EnvironmentFiles", *installer.SERVICE_EXEC_PROPERTIES}) + + receipt = installer.install_release( + bundle, + paths=paths, + source_binding=source_binding(), + runner=host, + sleeper=lambda _seconds: None, + execute_restart=True, + ) + + assert receipt["status"] == "pass" + assert receipt["release_sha256"] == release["release_sha256"] + assert host.restart_calls == 1 + + def test_systemd_252_omission_does_not_hide_a_later_dropin(tmp_path: Path) -> None: bundle, release, image = release_bundle(tmp_path, "2") paths = install_paths(tmp_path)