From 77ab46b956b0e7f9c04471fd07430972477165c4 Mon Sep 17 00:00:00 2001 From: fwazb Date: Wed, 22 Jul 2026 23:19:13 -0700 Subject: [PATCH] Handle systemd 252 EnvironmentFiles readback --- ops/install_gcp_leoclean_nosend_release.py | 4 + tests/test_gcp_leoclean_nosend_installer.py | 97 ++++++++++++++++++++- 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/ops/install_gcp_leoclean_nosend_release.py b/ops/install_gcp_leoclean_nosend_release.py index 01bed06..42f2db0 100644 --- a/ops/install_gcp_leoclean_nosend_release.py +++ b/ops/install_gcp_leoclean_nosend_release.py @@ -694,6 +694,10 @@ 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. + value.setdefault("EnvironmentFiles", "") _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 ce43976..2b4fbf8 100644 --- a/tests/test_gcp_leoclean_nosend_installer.py +++ b/tests/test_gcp_leoclean_nosend_installer.py @@ -117,6 +117,7 @@ class FakeHost: self.race_kind: str | None = None self.race_applied = False self.property_overrides: dict[str, str] = {} + self.omitted_properties: set[str] = set() self.not_found_security_defaults: dict[str, str] = {} self.unstable_readbacks = False self.show_count = 0 @@ -236,7 +237,11 @@ class FakeHost: value.update(self.property_overrides) if self.unstable_readbacks: value["NRestarts"] = str(self.n_restarts + self.show_count % 2) - payload = "".join(f"{name}={value[name]}\n" for name in installer.SERVICE_PROPERTIES).encode() + payload = "".join( + f"{name}={value[name]}\n" + for name in installer.SERVICE_PROPERTIES + if name not in self.omitted_properties + ).encode() return subprocess.CompletedProcess(arguments, 0, stdout=payload, stderr=b"") def __call__(self, arguments: list[str]) -> subprocess.CompletedProcess[bytes]: @@ -435,6 +440,96 @@ def test_first_install_accepts_production_shaped_systemd_not_found_defaults(tmp_ assert receipt["release_sha256"] == release["release_sha256"] assert host.restart_calls == 1 + repeated = installer.install_release( + bundle, + paths=paths, + source_binding=source_binding(), + runner=host, + sleeper=lambda _seconds: None, + execute_restart=True, + ) + + assert repeated["result"] == "already_installed" + assert host.restart_calls == 1 + + +def test_systemd_252_omitted_environment_files_property_is_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.omitted_properties.add("EnvironmentFiles") + + 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) + host = FakeHost(paths) + host.add_release(release, image) + host.not_found_security_defaults = dict(PRODUCTION_SHAPED_NOT_FOUND_SECURITY) + host.omitted_properties.add("EnvironmentFiles") + host.fail_dropin_release = str(release["release_sha256"]) + + with pytest.raises(installer.InstallError, match="later drop-in"): + installer.install_release( + bundle, + paths=paths, + source_binding=source_binding(), + runner=host, + sleeper=lambda _seconds: None, + execute_restart=True, + ) + + assert host.restart_calls == 1 + assert not host.active + assert host.container is None + assert not paths.unit.exists() + assert not paths.state_dir.exists() + + +@pytest.mark.parametrize( + "property_name", + tuple(name for name in installer.SERVICE_PROPERTIES if name != "EnvironmentFiles"), +) +def test_systemd_252_compatibility_still_requires_other_properties( + tmp_path: Path, + property_name: str, +) -> 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.omitted_properties.update({"EnvironmentFiles", property_name}) + + with pytest.raises(installer.InstallError, match="property output was incomplete"): + installer.install_release( + bundle, + paths=paths, + source_binding=source_binding(), + runner=host, + sleeper=lambda _seconds: None, + execute_restart=True, + ) + + assert host.restart_calls == 0 + assert not paths.unit.exists() + assert not paths.state_dir.exists() + @pytest.mark.parametrize("property_name", installer.ABSENT_SERVICE_IDENTITY_PROPERTIES) def test_first_install_rejects_identity_bearing_not_found_property(