From e7636f9d2c99d7e1ba0d02b38563aef7e6c69e2d Mon Sep 17 00:00:00 2001 From: Rasmus Larsen Date: Sat, 24 Oct 2026 11:22:10 +0000 Subject: [PATCH] agent: drop host-state cleanups for hosts set up before UCloud Removed: - Relay networking: retiring per-sandbox ucloud_relay_ nft tables (legacy_relay_policy_table, in the rebuild and in removal) and per-interface FORWARD -i -j ACCEPT rules (_remove_legacy_relay_accepts and the check in relay policy removal). - vm_init: retiring the ucloud-sandbox-heartbeat timer and service. - Hibernation store: the shared lock on the root-wide .store.lock that only excluded a pre-2026-09-08 process. - gVisor: the non-pinned-commit branches of distribution_files and installed_sidecar_fingerprints (both now refuse any commit but GVISOR_COMMIT and require the manifest and companions), the `if companions` guards in boot_config_sha256 and deploy staging. - Resident memory sampling (agent and noded): the container-id fallback for bundles without linux.cgroupsPath; such a bundle is not sampled. - .ucloud-overlay.json schema 1: Docker-store bundles are now written as schema 2 too, and only schema 2 is read. Why it is safe: every production worker was first set up on UCloud. The 2026-10-24 evidence read on all six workers shows no ucloud_relay_ tables, no per-interface FORWARD ACCEPT rules, no heartbeat units, no old overlay schema, and UCLOUD_DIRECT_RUNSC_COMMIT equal to GVISOR_COMMIT. Production workers use host-EROFS environments, whose bundles were already schema 2 and always name their cgroup. Config: no keys change. The boot fingerprint of a pinned install is unchanged (its companions were already bound). 0.10.0 reads schema-2 bundles, so a rollback needs nothing. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 21 +++++++++ runtime/noded/src/pause_policy/engine.rs | 3 +- runtime/noded/src/pause_policy/resident.rs | 20 ++++----- runtime/noded/tests/resident_python_parity.rs | 24 +++++----- sandboxes/cli.py | 11 +++-- sandboxes/direct_network.py | 35 ++------------- sandboxes/direct_runtime.py | 8 ++-- sandboxes/direct_service.py | 6 +-- sandboxes/gvisor_distribution.py | 21 +++++---- sandboxes/hibernation.py | 11 +---- sandboxes/image_rootfs.py | 33 ++++++-------- sandboxes/relay_network.py | 7 --- sandboxes/resident_memory.py | 12 ++--- sandboxes/vm_init.py | 14 ------ tests/test_deploy.py | 41 +++++++---------- tests/test_gvisor_distribution.py | 13 +++--- tests/test_heartbeat_sender.py | 45 ------------------- tests/test_hibernation.py | 29 ------------ tests/test_image_rootfs.py | 38 ++-------------- tests/test_network_policy.py | 25 ----------- tests/test_relay_network_linux.py | 22 +-------- tests/test_resident_memory.py | 5 +-- tests/test_rules_baseline.json | 6 +-- tests/test_rust_resident_sampling.py | 4 +- 24 files changed, 124 insertions(+), 330 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8a20b89..1a760bea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ ## Unreleased +- **Workers no longer clean up after releases older than the move to UCloud.** + Every production worker was first set up on UCloud, and none carries the + leftovers, so these one-off cleanups are gone: per-sandbox + `ucloud_relay_` nftables tables and per-interface `FORWARD -i -j + ACCEPT` rules (relay networking), the `ucloud-sandbox-heartbeat` timer and + service (vm_init), and the shared lock on the hibernation store's root-wide + `.store.lock`. Other cleanups that went with them: + - Only the pinned gVisor (`GVISOR_COMMIT`) with its build manifest and + companion binaries is accepted: deploy validation and the agent refuse any + other `sandbox.direct_runsc_commit`. Production runs the pin, so checkpoint + fingerprints do not change. + - Resident-memory sampling (agent and noded) reads only the cgroup a bundle's + `linux.cgroupsPath` names; a bundle without one is not sampled. Bundles + from 0.10.0 and this release always name one. + - `.ucloud-overlay.json` is always written and read as schema 2; Docker-store + bundles used to be written as schema 1. Production workers use host-EROFS + environments, which were already schema 2. On a Docker-store host, wake or + delete parked sandboxes before upgrading. + - Rollback to 0.10.0 needs nothing: 0.10.0 reads schema-2 bundles and still + runs the old cleanups, which find nothing to do. + - **Workers always run behind ucloud-noded, with the pause tier, node-local model waits and split memory backing.** `sandbox.direct_node_front_door`, `direct_node_rust_create`, `direct_node_rust_exec`, `direct_node_rust_pause`, diff --git a/runtime/noded/src/pause_policy/engine.rs b/runtime/noded/src/pause_policy/engine.rs index 9dcb6d61..ca77eae3 100644 --- a/runtime/noded/src/pause_policy/engine.rs +++ b/runtime/noded/src/pause_policy/engine.rs @@ -339,7 +339,8 @@ impl Engine { path } }; - self.sampler.sample(key, u32::try_from(pid).ok()?, ticks, &sandbox.container_id, path.as_deref()) + // Every bundle names its incarnation's cgroup; never guess one. + self.sampler.sample(key, u32::try_from(pid).ok()?, ticks, path.as_deref()?) } /// The sampling half of `refresh_resident_memory`: sample every owned, diff --git a/runtime/noded/src/pause_policy/resident.rs b/runtime/noded/src/pause_policy/resident.rs index d2885a97..7d984022 100644 --- a/runtime/noded/src/pause_policy/resident.rs +++ b/runtime/noded/src/pause_policy/resident.rs @@ -157,8 +157,8 @@ impl ResidentSampler { /// Sample one incarnation's cgroup; `None` (and the cache entry dropped) /// on any doubt. - pub fn sample(&self, key: &Key, pid: u32, start_time_ticks: u64, container_id: &str, expected_path: Option<&str>) -> Option { - let result = self.measure(key, pid, start_time_ticks, container_id, expected_path); + pub fn sample(&self, key: &Key, pid: u32, start_time_ticks: u64, expected_path: &str) -> Option { + let result = self.measure(key, pid, start_time_ticks, expected_path); let mut state = self.state(); match result { Some((sample, proof)) => { @@ -174,7 +174,7 @@ impl ResidentSampler { } } - fn measure(&self, key: &Key, pid: u32, ticks: u64, container_id: &str, expected_path: Option<&str>) -> Option<(ResidentSample, (String, u64, u64))> { + fn measure(&self, key: &Key, pid: u32, ticks: u64, expected_path: &str) -> Option<(ResidentSample, (String, u64, u64))> { let same_sentry = || crate::runsc::start_time_ticks(&self.proc_root, pid).is_ok_and(|now| now == ticks); if !same_sentry() { return None; @@ -184,14 +184,9 @@ impl ResidentSampler { let [raw] = unified[..] else { return None }; let relative = raw.strip_prefix('/')?; let parts: Vec<&str> = relative.split('/').filter(|part| !part.is_empty()).collect(); - if parts.is_empty() || raw.split('/').skip(1).any(|part| part == "." || part == "..") { + if parts.is_empty() || raw.split('/').skip(1).any(|part| part == "." || part == "..") || raw != expected_path { return None; } - match expected_path { - Some(expected) if raw != expected => return None, - None if parts.last() != Some(&container_id) => return None, // Never a shared parent. - _ => {} - } let path = self.cgroup_root.join(relative); let mut identity = std::fs::metadata(&path).ok()?; let proof = self.state().contained.get(key).cloned(); @@ -308,8 +303,7 @@ impl Reclaimer<'_> { } let relative = sample.cgroup_path.trim_start_matches('/'); let path = self.sampler.cgroup_root.join(relative); - let container_id = Path::new(relative).file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default(); - let resample = || self.sampler.sample(key, sample.sentry_pid, sample.sentry_start_time_ticks, &container_id, Some(&sample.cgroup_path)); + let resample = || self.sampler.sample(key, sample.sentry_pid, sample.sentry_start_time_ticks, &sample.cgroup_path); let same_cgroup = |observed: &ResidentSample| (observed.cgroup_device, observed.cgroup_inode) == (sample.cgroup_device, sample.cgroup_inode); let (mut requested, mut current, mut reason) = (0u64, sample.clone(), "target_reached"); let outcome = (|| -> std::io::Result<()> { @@ -449,8 +443,10 @@ pub(crate) mod tests { std::fs::write(self.proc.join("123/stat"), format!("123 (sentry) S {}{ticks} 0", "0 ".repeat(18))).unwrap(); } + /// Samples against `expected`, else the incarnation's own cgroup. pub fn sample(&self, expected: Option<&str>) -> Option { - self.sampler.sample(&("s".into(), 1), 123, 456, &self.container, expected) + let own = format!("/ucloud-sandboxes/{}", self.container); + self.sampler.sample(&("s".into(), 1), 123, 456, expected.unwrap_or(&own)) } /// `_anonymous(current)`: all of it tmpfs. diff --git a/runtime/noded/tests/resident_python_parity.rs b/runtime/noded/tests/resident_python_parity.rs index bb3cbfbc..3415e12a 100644 --- a/runtime/noded/tests/resident_python_parity.rs +++ b/runtime/noded/tests/resident_python_parity.rs @@ -47,7 +47,7 @@ before = time.monotonic() sampled = {} for case in request["cases"]: sample = sampler.sample((case["name"], 1), pid=case["pid"], start_time_ticks=case["ticks"], - container_id=case["container_id"], expected_path=case["expected_path"]) + expected_path=case["expected_path"]) sampled[case["name"]] = None if sample is None else dataclasses.asdict(sample) published = PublishedResidentMemory(Path(request["resident_path"]), ResidentMemorySampler( cgroup_root=Path(request["cgroup_root"]), proc_root=Path(request["proc_root"]))) @@ -81,8 +81,8 @@ struct Case { name: &'static str, /// `/proc//cgroup`; `{c}` is the container's cgroup path. membership: &'static str, - /// The OCI config's path (`{c}` as above), or none (legacy). - expected: Option<&'static str>, + /// The OCI config's path (`{c}` as above). + expected: &'static str, /// The sentry's start ticks on disk (the caller expects 456). ticks: u64, files: &'static [(&'static str, &'static str)], @@ -93,7 +93,7 @@ struct Case { const STAT: &str = "shmem 0\nanon 100\nfile 800\nfile_dirty 50\nfile_writeback 20\nworkingset_refault_file 7\n"; fn cases() -> Vec { - let case = |name, files, accepted| Case { name, membership: "0::{c}", expected: Some("{c}"), ticks: 456, files, accepted }; + let case = |name, files, accepted| Case { name, membership: "0::{c}", expected: "{c}", ticks: 456, files, accepted }; vec![ case("base", &[("memory.current", "1000"), ("memory.stat", STAT)], true), case("peak-and-swap", &[("memory.current", "1000"), ("memory.peak", "900"), ("memory.swap.current", "400"), ("memory.stat", STAT)], true), @@ -128,10 +128,9 @@ fn cases() -> Vec { case("three-field-stat-line", &[("memory.current", "1000"), ("memory.stat", "shmem 0 1\nanon 100\nfile 800\nfile_dirty 50\nfile_writeback 20\nworkingset_refault_file 7\n")], false), case("blank-stat-line", &[("memory.current", "1000"), ("memory.stat", "shmem 0\n\nanon 100\nfile 800\nfile_dirty 50\nfile_writeback 20\nworkingset_refault_file 7\n")], false), Case { ticks: 999, ..case("pid-reused", &[("memory.current", "1000"), ("memory.stat", STAT)], false) }, - Case { expected: Some("/another/runtime"), ..case("not-the-oci-cgroup", &[("memory.current", "1000"), ("memory.stat", STAT)], false) }, - Case { expected: None, ..case("legacy-by-container-id", &[("memory.current", "1000"), ("memory.stat", STAT)], true) }, - Case { expected: None, membership: "0::/ucloud-sandboxes", ..case("legacy-shared-parent", &[("memory.current", "1000"), ("memory.stat", STAT)], false) }, - Case { membership: "0::/ucloud-sandboxes/../{c}", expected: None, ..case("dot-dot", &[("memory.current", "1000"), ("memory.stat", STAT)], false) }, + Case { expected: "/another/runtime", ..case("not-the-oci-cgroup", &[("memory.current", "1000"), ("memory.stat", STAT)], false) }, + Case { membership: "0::/ucloud-sandboxes", ..case("shared-parent", &[("memory.current", "1000"), ("memory.stat", STAT)], false) }, + Case { membership: "0::/ucloud-sandboxes/../{c}", ..case("dot-dot", &[("memory.current", "1000"), ("memory.stat", STAT)], false) }, Case { membership: "0::/x{c}\n0::{c}\n", ..case("two-unified", &[("memory.current", "1000"), ("memory.stat", STAT)], false) }, Case { membership: "12:memory:/legacy\n0::{c}\n", ..case("v1-lines-beside-unified", &[("memory.current", "1000"), ("memory.stat", STAT)], true) }, ] @@ -161,8 +160,8 @@ fn the_daemon_samples_and_publishes_exactly_what_the_agent_would() { std::fs::write(process.join("stat"), format!("{pid} (runsc-sandbox) S {}{} 0", "0 ".repeat(18), case.ticks)).unwrap(); std::fs::write(process.join("cgroup"), case.membership.replace("{c}", &path)).unwrap(); requests.push(json!({ - "name": case.name, "pid": pid, "ticks": 456, "container_id": container, - "expected_path": case.expected.map(|expected| expected.replace("{c}", &path)), + "name": case.name, "pid": pid, "ticks": 456, + "expected_path": case.expected.replace("{c}", &path), })); } // A runtime whose cgroup is a link out of the trusted root. @@ -176,7 +175,7 @@ fn the_daemon_samples_and_publishes_exactly_what_the_agent_would() { std::fs::create_dir_all(proc.join("999")).unwrap(); std::fs::write(proc.join("999/stat"), format!("999 (runsc-sandbox) S {}456 0", "0 ".repeat(18))).unwrap(); std::fs::write(proc.join("999/cgroup"), format!("0::/ucloud-sandboxes/{escaping}")).unwrap(); - requests.push(json!({"name": "escaping", "pid": 999, "ticks": 456, "container_id": escaping, "expected_path": format!("/ucloud-sandboxes/{escaping}")})); + requests.push(json!({"name": "escaping", "pid": 999, "ticks": 456, "expected_path": format!("/ucloud-sandboxes/{escaping}")})); // The daemon first, then the agent on the same trees. let clock = Arc::new(SystemClock::new()); @@ -190,8 +189,7 @@ fn the_daemon_samples_and_publishes_exactly_what_the_agent_would() { &(name.clone(), 1), request["pid"].as_u64().unwrap() as u32, 456, - request["container_id"].as_str().unwrap(), - request["expected_path"].as_str(), + request["expected_path"].as_str().unwrap(), ); if let Some(sample) = &sample { published.push(((name, 1), sample.clone())); diff --git a/sandboxes/cli.py b/sandboxes/cli.py index 702909f1..0033833f 100644 --- a/sandboxes/cli.py +++ b/sandboxes/cli.py @@ -2567,13 +2567,12 @@ def get_payload() -> dict[str, Any]: companions = distribution_files( plan.local_direct_runsc, plan.direct_runsc_commit ) - if companions: - companions.append( - ( - plan.local_direct_runsc.parent / "build-manifest.json", - "build-manifest.json", - ) + companions.append( + ( + plan.local_direct_runsc.parent / "build-manifest.json", + "build-manifest.json", ) + ) for local_path, relative in companions: remote_path = str( PurePosixPath(plan.remote_direct_runsc_path).parent / relative diff --git a/sandboxes/direct_network.py b/sandboxes/direct_network.py index daf13ce4..fce6f6f9 100644 --- a/sandboxes/direct_network.py +++ b/sandboxes/direct_network.py @@ -26,7 +26,6 @@ RELAY_FORWARD_MARK, NetworkRelay, apply_nft, - legacy_relay_policy_table, parse_network_relays, relay_bridge_rules, relay_hosts, @@ -430,11 +429,6 @@ def _remove_relay_policy( } if self._apply_relay_delta(relay_policy_removal(lease), remaining): self._relay_applied.pop(lease.slot, None) - # An interface ACCEPT from an earlier release must never outlive the - # relay policy: a reused slot would bypass private-destination denies. - legacy = ("FORWARD", "-i", lease.host_interface, "-j", "ACCEPT") - if self._command_ok(("iptables", "-C", *legacy)): - self.runner(("iptables", "-D", *legacy)) def _apply_relay_delta(self, script: str, state: dict) -> bool: """Apply per-sandbox statements, else rebuild from durable state. @@ -482,24 +476,18 @@ def _relay_fragments( def _rebuild_relay_table( self, fragments: Mapping[int, str], leases: Sequence[DirectNetworkLease] ) -> None: - # One transaction replaces the shared table and retires any per-sandbox - # tables from earlier releases, so there is no unguarded moment. - legacy = "".join( - f"add table inet {table}\ndelete table inet {table}\n" - for table in (legacy_relay_policy_table(lease.slot) for lease in leases) - ) - # The bridge follows the latest resolution (each rebuild path resolves - # the relays just before it). + # One transaction replaces the shared table, so there is no unguarded + # moment. The bridge follows the latest resolution (each rebuild path + # resolves the relays just before it). bridge = relay_bridge_rules(self.relays, { name: self._relay_resolution.get(name, (0.0, ()))[1] for name in self.relays}) self._relay_table_ready = False - self.nft_runner(relay_table_rules() + bridge + "".join(fragments.values()) + legacy) + self.nft_runner(relay_table_rules() + bridge + "".join(fragments.values())) self._relay_applied = dict(fragments) self._relay_bridge_applied = bridge self._relay_table_ready = True if leases: self._ensure_relay_forward_accept() - self._remove_legacy_relay_accepts(leases) def _refresh_relay_policies(self, *, force: bool = False) -> None: # Share the durable lease lock with create/delete so DNS refresh cannot @@ -564,21 +552,6 @@ def _ensure_relay_forward_accept( snapshot=snapshot, ) - def _remove_legacy_relay_accepts( - self, leases: Sequence[DirectNetworkLease] - ) -> None: - # Earlier releases installed one FORWARD ACCEPT per relay interface. - snapshot = self._iptables_snapshot() - for lease in leases: - rule = ("FORWARD", "-i", lease.host_interface, "-j", "ACCEPT") - present = ( - ("filter", "-A", *rule) in snapshot - if snapshot is not None - else self._command_ok(("iptables", "-C", *rule)) - ) - if present: - self.runner(("iptables", "-D", *rule)) - def _ensure_host_rules(self) -> None: # Read a fresh kernel snapshot for this reconciliation, never a cached # assertion that policy remains installed across requests. diff --git a/sandboxes/direct_runtime.py b/sandboxes/direct_runtime.py index 5ced4863..cba4aa21 100644 --- a/sandboxes/direct_runtime.py +++ b/sandboxes/direct_runtime.py @@ -259,11 +259,9 @@ def boot_config_sha256(runsc: Path, runsc_commit: str, *, network: str, host_ero boot_settings["application_memory_backend"] = "reflink-restore-v1" elif ram_backing: boot_settings["application_memory_backend"] = "ram-sparse-capture-v1" - companions = installed_sidecar_fingerprints(runsc, runsc_commit) - if companions: - # Preserve legacy fingerprints, but bind new checkpoints to every - # executable that can implement their kernel and restore operations. - boot_settings["gvisor_companions"] = companions + # Bind checkpoints to every executable that can implement their kernel + # and restore operations. + boot_settings["gvisor_companions"] = installed_sidecar_fingerprints(runsc, runsc_commit) return _canonical_sha256(boot_settings) diff --git a/sandboxes/direct_service.py b/sandboxes/direct_service.py index 3a72661a..fd688f61 100644 --- a/sandboxes/direct_service.py +++ b/sandboxes/direct_service.py @@ -2293,16 +2293,16 @@ def _sample_resident(self, key, sandbox): try: config = json.loads((sandbox.bundle / "config.json").read_text()) path = config.get("linux", {}).get("cgroupsPath") - if path is not None and not isinstance(path, str): - return None self._resident_cgroup_paths[key] = path except (OSError, ValueError, TypeError): return None + # Every bundle names its incarnation's cgroup; never guess one. + if not isinstance(path, str): + return None return self._resident_memory.sample( key, pid=lifecycle.sentry_pid, start_time_ticks=lifecycle.sentry_start_time_ticks, - container_id=sandbox.container_id, expected_path=path, ) diff --git a/sandboxes/gvisor_distribution.py b/sandboxes/gvisor_distribution.py index 06d80ea9..be86b6ba 100644 --- a/sandboxes/gvisor_distribution.py +++ b/sandboxes/gvisor_distribution.py @@ -18,18 +18,24 @@ ) +def _require_pinned(commit: str) -> None: + if commit != GVISOR_COMMIT: + raise ValueError( + f"gVisor runtime commit {commit} is not the pinned {GVISOR_COMMIT}" + ) + + def distribution_files(runsc: Path, commit: str) -> list[tuple[Path, str]]: - """Return verified sidecars for the new pin; legacy deployments stay readable. + """Return the verified sidecars of the pinned distribution. The manifest is provenance inside the deployment's trusted input, not a signature. The node bundle digest authenticates the resulting distribution. """ + _require_pinned(commit) manifest_path = runsc.parent / "build-manifest.json" sidecar_dir = runsc.parent / "gvisor-bin" - if commit != GVISOR_COMMIT and not manifest_path.exists(): - if sidecar_dir.exists(): - raise ValueError("gVisor companion binaries require a build manifest") - return [] + if not manifest_path.is_file(): + raise ValueError("gVisor distribution requires a build manifest") manifest = json.loads(manifest_path.read_text(encoding="utf-8")) if manifest.get("schema") != 2 or manifest.get("gvisor_commit") != commit: raise ValueError("gVisor distribution manifest does not match the runtime pin") @@ -70,11 +76,8 @@ def installed_sidecar_fingerprints(runsc: Path, commit: str) -> dict[str, str]: Bootstrap has already authenticated the node bundle. Hash actual installed bytes here so a different companion cannot reuse an old boot fingerprint. """ + _require_pinned(commit) directory = runsc.parent / "gvisor-bin" - if commit != GVISOR_COMMIT: - if directory.exists(): - raise ValueError("legacy gVisor runtime must not share companion binaries") - return {} if directory.is_symlink() or not directory.is_dir(): raise ValueError("installed gVisor companion directory is missing or linked") if {path.name for path in directory.iterdir()} != set(GVISOR_SIDECARS): diff --git a/sandboxes/hibernation.py b/sandboxes/hibernation.py index 327f9bc6..a7964d24 100644 --- a/sandboxes/hibernation.py +++ b/sandboxes/hibernation.py @@ -522,7 +522,6 @@ class HibernationArtifactStore: MANIFEST_NAME = "manifest.json" COMPLETE_NAME = "COMPLETE" - LOCK_NAME = ".store.lock" def __init__( self, @@ -891,8 +890,7 @@ def delete_retired_generation( str(generation.parent.relative_to(self.root)).encode() ).hexdigest() + ".lock" try: - with (self._lock_file(root_fd, self.LOCK_NAME, fcntl.LOCK_SH), - self._lock_file(root_fd, lock_name, fcntl.LOCK_EX)): + with self._lock_file(root_fd, lock_name, fcntl.LOCK_EX): if not os.path.lexists(generation): return self._require_generation_path(generation) @@ -1045,15 +1043,10 @@ def _locked(self, sandbox_id: str, sandbox_generation: int) -> Iterator[None]: self._ensure_directory(self.root, create=True) root_fd = self._open_directory(self.root) try: - # Retain shared admission through the original lock so an older - # process using its root-wide exclusive lock remains compatible. # Keep incarnation locks in the stable root and never unlink them: # waiters must retain one inode even when generations are deleted # or their storage-native mount is detached and reattached. - with ( - self._lock_file(root_fd, self.LOCK_NAME, fcntl.LOCK_SH), - self._lock_file(root_fd, lock_name, fcntl.LOCK_EX), - ): + with self._lock_file(root_fd, lock_name, fcntl.LOCK_EX): yield finally: os.close(root_fd) diff --git a/sandboxes/image_rootfs.py b/sandboxes/image_rootfs.py index eca5fdb6..a98ed691 100644 --- a/sandboxes/image_rootfs.py +++ b/sandboxes/image_rootfs.py @@ -34,7 +34,6 @@ _SAFE_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}\Z") _OVERLAY2_ROOTFS_SCHEMA = 1 _OVERLAY_METADATA = ".ucloud-overlay.json" -_OVERLAY_METADATA_SCHEMA = 1 def _mount_present(path: Path, runner: CommandRunner, binary: str) -> bool: @@ -1271,9 +1270,9 @@ def prepare( { "lowerdir": str(image.rootfs), "rootfs_identity_sha256": image.rootfs_identity_sha256, - **({"image_id": image.environment.base, "schema": _OVERLAY_METADATA_SCHEMA} - if image.backend_abi == DOCKER_OVERLAY2_ABI else - {"environment": image.environment.to_dict(), "backend_abi": image.backend_abi, "schema": 2}), + "environment": image.environment.to_dict(), + "backend_abi": image.backend_abi, + "schema": 2, } ) + b"\n", @@ -1412,23 +1411,19 @@ def resume_sandbox(self, sandbox: DirectSandbox) -> None: def _decode_environment( metadata: object, expected_fingerprint: str ) -> EnvironmentManifest: - """Read legacy bundles without rewriting or relabeling their identity.""" + """Read a bundle's identity without rewriting or relabeling it.""" try: - if not isinstance(metadata, dict): - raise ValueError("metadata must be an object") - common = {"lowerdir", "rootfs_identity_sha256", "schema"} - if type(metadata.get("schema")) is not int: - raise ValueError("invalid schema") - if metadata["schema"] == 1 and set(metadata) == common | {"image_id"}: - environment = EnvironmentManifest(base=metadata["image_id"]) - backend_abi = DOCKER_OVERLAY2_ABI - elif metadata["schema"] == 2 and set(metadata) == common | { - "environment", "backend_abi" - }: - environment = EnvironmentManifest.from_dict(metadata["environment"]) - backend_abi = metadata["backend_abi"] - else: + if ( + not isinstance(metadata, dict) + or type(metadata.get("schema")) is not int + or metadata["schema"] != 2 + or set(metadata) != { + "lowerdir", "rootfs_identity_sha256", "schema", "environment", "backend_abi" + } + ): raise ValueError("unsupported bundle metadata") + environment = EnvironmentManifest.from_dict(metadata["environment"]) + backend_abi = metadata["backend_abi"] if ( metadata["rootfs_identity_sha256"] != expected_fingerprint or environment.rootfs_fingerprint(backend_abi) != expected_fingerprint diff --git a/sandboxes/relay_network.py b/sandboxes/relay_network.py index 8e27ded2..031abbcf 100644 --- a/sandboxes/relay_network.py +++ b/sandboxes/relay_network.py @@ -86,11 +86,6 @@ def parse_network_relays(raw: object) -> dict[str, NetworkRelay]: SANDBOX_NETWORK = "100.96.0.0/16" -def legacy_relay_policy_table(slot: int) -> str: - """Per-sandbox table name used before the shared table.""" - return f"ucloud_relay_{int(slot)}" - - def relay_table_rules() -> str: """Replace the shared table's skeleton; callers append every sandbox. @@ -239,10 +234,8 @@ def relay_policy_removal(lease: DirectNetworkLease) -> str: """Idempotently remove one sandbox; fails without effect if the table is missing.""" t = RELAY_TABLE chains = [_relay_chain(lease, kind) for kind in _RELAY_CHAIN_KINDS] - legacy = legacy_relay_policy_table(lease.slot) # Create before deleting so the transaction succeeds whatever is present. return ( - f"add table inet {legacy}\ndelete table inet {legacy}\n" "".join(f"add chain inet {t} {chain}\n" for chain in chains) + _relay_elements(lease, "add") + _relay_elements(lease, "delete") diff --git a/sandboxes/resident_memory.py b/sandboxes/resident_memory.py index db249bdd..bc77e3f0 100644 --- a/sandboxes/resident_memory.py +++ b/sandboxes/resident_memory.py @@ -102,8 +102,7 @@ def sample( *, pid: int, start_time_ticks: int, - container_id: str, - expected_path: str | None = None, + expected_path: str, ) -> ResidentMemorySample | None: try: if ( @@ -125,13 +124,8 @@ def sample( or any(part in {".", ".."} for part in raw.split("/")[1:]) ): raise ValueError("runtime cgroup path is invalid") - if expected_path is not None: - if raw != expected_path: - raise ValueError("runtime cgroup differs from OCI ownership") - elif relative.name != container_id: - # Legacy OCI configs let runsc select its default path. Require - # its exact immutable container ID, never a shared parent cgroup. - raise ValueError("runtime cgroup is not incarnation-specific") + if raw != expected_path: + raise ValueError("runtime cgroup differs from OCI ownership") path = self.cgroup_root / relative identity = path.stat() with self._guard: diff --git a/sandboxes/vm_init.py b/sandboxes/vm_init.py index fdceaceb..49ea0ee7 100644 --- a/sandboxes/vm_init.py +++ b/sandboxes/vm_init.py @@ -487,9 +487,6 @@ def render_vm_init_script(options: VmInitOptions) -> str: "/etc/systemd/system/ucloud-storage-native-backend.service" ) storage_service = "/etc/systemd/system/ucloud-storage-native.service" - # Units of the oneshot heartbeat timer that node agents replaced (C4.4). - retired_heartbeat_timer = "/etc/systemd/system/ucloud-sandbox-heartbeat.timer" - retired_heartbeat_service = "/etc/systemd/system/ucloud-sandbox-heartbeat.service" authorized_keys_shell = "\n".join( f" {shlex.quote(key)}" for key in options.init_authorized_keys ) @@ -2106,17 +2103,6 @@ def verified_artifact(metadata: dict, file_name: str, label: str) -> str: WantedBy=multi-user.target NODE_SERVICE -# The node agent sends its own heartbeats. Retire an older release's timer -# before the new agent starts, so a re-initialized node never posts twice. -if [ -e {shlex.quote(retired_heartbeat_timer)} ]; then - $SUDO systemctl disable --now ucloud-sandbox-heartbeat.timer -fi -if [ -e {shlex.quote(retired_heartbeat_service)} ]; then - $SUDO systemctl stop ucloud-sandbox-heartbeat.service - $SUDO systemctl reset-failed ucloud-sandbox-heartbeat.service || true -fi -$SUDO rm -f {shlex.quote(retired_heartbeat_timer)} {shlex.quote(retired_heartbeat_service)} - $SUDO systemctl daemon-reload {environment_start} $SUDO systemctl reset-failed ucloud-sandbox-node.service || true diff --git a/tests/test_deploy.py b/tests/test_deploy.py index 32020010..7649be2c 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -12,6 +12,7 @@ from sandboxes.cli import build_parser from sandboxes.config import DeploymentConfig +from sandboxes.gvisor_distribution import GVISOR_COMMIT, GVISOR_SIDECARS from sandboxes.deploy import ( AllInOneDeployPlan, packaged_systemd_units, @@ -60,7 +61,7 @@ def _config(**overrides: object) -> DeploymentConfig: raw["deployment_id"] = "prod-a" raw["gateway_private_host"] = "sandbox-gateway-prod" raw["registry_private_ip"] = "10.0.0.5" - sandbox["direct_runsc_commit"] = "9f653e577965df2ddd13875b5530cd2588661f1c" + sandbox["direct_runsc_commit"] = GVISOR_COMMIT raw.update(overrides) return DeploymentConfig.from_dict(raw) @@ -78,9 +79,21 @@ def _plan( "sandboxes-0.2.0.dist-info/METADATA", "Metadata-Version: 2.1\nName: sandboxes\nVersion: 0.2.0\n", ) + # The complete pinned gVisor distribution, with its build manifest. + files = {} + for name in ["runsc", *("gvisor-bin/" + name for name in GVISOR_SIDECARS)]: + binary = root / name + binary.parent.mkdir(exist_ok=True) + binary.write_bytes(name.encode()) + binary.chmod(0o755) + files[name] = { + "size": binary.stat().st_size, + "sha256": hashlib.sha256(binary.read_bytes()).hexdigest(), + } + (root / "build-manifest.json").write_text( + json.dumps({"schema": 2, "gvisor_commit": GVISOR_COMMIT, "files": files}) + ) runsc = root / "runsc" - runsc.write_bytes(b"patched-runsc") - runsc.chmod(0o755) managed_init = root / "ucloud-sandbox-init" managed_init.write_bytes(b"managed-process-init") managed_init.chmod(0o755) @@ -268,13 +281,9 @@ def test_stage_file_atomically_replaces_root_owned_release_artifact(self) -> Non self.assertEqual(run.call_args.kwargs["input"], b"new wheel") def test_sandbox_bundle_contains_complete_new_gvisor_distribution(self): - from sandboxes.gvisor_distribution import GVISOR_COMMIT, GVISOR_SIDECARS - with TemporaryDirectory() as directory: root = Path(directory) - raw = self._config().to_dict() - raw["sandbox"]["direct_runsc_commit"] = GVISOR_COMMIT - plan = self._plan(root, config=DeploymentConfig.from_dict(raw)) + plan = self._plan(root) runtime = root / "runtime" (runtime / "debs").mkdir(parents=True) (runtime / "debs/runtime.deb").write_bytes(b"package") @@ -284,22 +293,6 @@ def test_sandbox_bundle_contains_complete_new_gvisor_distribution(self): kernel = root / "kernel" kernel.mkdir() (kernel / "xfs.ko").write_bytes(b"module") - files = {} - names = ["runsc", *("gvisor-bin/" + name for name in GVISOR_SIDECARS)] - for name in names: - binary = root / name - binary.parent.mkdir(exist_ok=True) - binary.write_bytes(name.encode()) - binary.chmod(0o755) - files[name] = { - "size": binary.stat().st_size, - "sha256": hashlib.sha256(binary.read_bytes()).hexdigest(), - } - (root / "build-manifest.json").write_text( - json.dumps( - {"schema": 2, "gvisor_commit": GVISOR_COMMIT, "files": files} - ) - ) script = render_remote_deploy_script(plan) start = script.index("import hashlib\nimport gzip") code = compile( diff --git a/tests/test_gvisor_distribution.py b/tests/test_gvisor_distribution.py index e6f152ab..c1439a14 100644 --- a/tests/test_gvisor_distribution.py +++ b/tests/test_gvisor_distribution.py @@ -87,13 +87,16 @@ def test_rejects_missing_extra_symlink_and_nonexecutable_companions(self): with self.assertRaises(ValueError): distribution_files(runsc, GVISOR_COMMIT) - def test_new_pin_requires_manifest_legacy_does_not(self): + def test_only_the_pinned_commit_with_its_manifest_is_accepted(self): with tempfile.TemporaryDirectory() as directory: runsc = Path(directory) / "runsc" - runsc.write_bytes(b"legacy") - self.assertEqual(distribution_files(runsc, "9" * 40), []) - with self.assertRaises(FileNotFoundError): + runsc.write_bytes(b"runsc") + with self.assertRaisesRegex(ValueError, "build manifest"): distribution_files(runsc, GVISOR_COMMIT) + runsc = self.fixture(Path(directory)) + for check in (distribution_files, installed_sidecar_fingerprints): + with self.subTest(check=check.__name__), self.assertRaisesRegex(ValueError, "pinned"): + check(runsc, "9" * 40) def test_companion_change_changes_installed_checkpoint_identity(self): with tempfile.TemporaryDirectory() as directory: @@ -105,8 +108,6 @@ def test_companion_change_changes_installed_checkpoint_identity(self): after = installed_sidecar_fingerprints(runsc, GVISOR_COMMIT) self.assertEqual(runsc.read_bytes(), original_runsc) self.assertNotEqual(before, after) - with self.assertRaisesRegex(ValueError, "legacy"): - installed_sidecar_fingerprints(runsc, "9" * 40) def test_node_fences_same_runsc_with_changed_companion(self): from sandboxes.direct_runtime import boot_config_sha256 diff --git a/tests/test_heartbeat_sender.py b/tests/test_heartbeat_sender.py index ad970d13..fdeccf08 100644 --- a/tests/test_heartbeat_sender.py +++ b/tests/test_heartbeat_sender.py @@ -11,12 +11,10 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import io import json -import os from pathlib import Path import random import re import shlex -import subprocess import tempfile import threading import time @@ -430,49 +428,6 @@ def test_node_units_carry_the_sender_and_no_timer_is_installed(self) -> None: self.assertEqual((config.url, config.bearer_token, config.interval_seconds, config.labels), (values["UCLOUD_HEARTBEAT_URL"], TOKEN, 7, labels)) - def test_reinit_retires_an_older_releases_timer_before_the_agent_restarts(self) -> None: - script = render_vm_init_script(test_vm_init.VmInitTests._options()) - start = script.index("# The node agent sends its own heartbeats.") - end = script.index("\n", script.index("$SUDO rm -f", start)) - # After the node unit is written, before systemd reloads units and - # before the new agent starts. - written = script.index("<> {shlex.quote(str(log))}\n' - f'case "$1" in {failing or "never"}) exit 1;; esac\n') - fake.chmod(0o755) - snippet = script[start:end].replace("/etc/systemd/system", str(units)) - result = subprocess.run( - ["bash", "-c", "set -euo pipefail\nSUDO=\n" + snippet], - env={**os.environ, "PATH": f"{bin_dir}:{os.environ['PATH']}"}, - capture_output=True, text=True, - ) - calls = log.read_text().splitlines() if log.exists() else [] - if failing: - self.assertNotEqual(result.returncode, 0) - self.assertTrue((units / "ucloud-sandbox-heartbeat.timer").exists(), - "a timer systemd could not stop is left for the operator") - continue - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(list(units.iterdir()), []) - expected = (["disable --now ucloud-sandbox-heartbeat.timer"] if "timer" in present else []) - if "service" in present: - expected += ["stop ucloud-sandbox-heartbeat.service", - "reset-failed ucloud-sandbox-heartbeat.service"] - self.assertEqual(calls, expected) - if __name__ == "__main__": unittest.main() diff --git a/tests/test_hibernation.py b/tests/test_hibernation.py index e2ad63d4..0b9a15ef 100644 --- a/tests/test_hibernation.py +++ b/tests/test_hibernation.py @@ -1,6 +1,5 @@ from dataclasses import replace from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutureTimeoutError -import fcntl import json import os from pathlib import Path @@ -517,34 +516,6 @@ def discard() -> None: manifest, ) - def test_incarnation_lock_retains_legacy_store_lock_exclusion(self) -> None: - with TemporaryDirectory() as raw_dir: - store = HibernationArtifactStore((Path(raw_dir) / "artifacts").resolve()) - store.root.mkdir(mode=0o700) - lock_path = store.root / store.LOCK_NAME - lock_fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600) - started = threading.Event() - - def prepare() -> Path: - started.set() - return store.prepare_generation( - sandbox_id="sandbox-1", sandbox_generation=7, hibernation_generation=1 - ) - - try: - fcntl.flock(lock_fd, fcntl.LOCK_EX) - with ThreadPoolExecutor(max_workers=1) as workers: - preparing = workers.submit(prepare) - try: - self.assertTrue(started.wait(2)) - with self.assertRaises(FutureTimeoutError): - preparing.result(timeout=0.05) - finally: - fcntl.flock(lock_fd, fcntl.LOCK_UN) - self.assertTrue(preparing.result(timeout=2).is_dir()) - finally: - os.close(lock_fd) - def test_artifact_root_initialization_is_safe_across_sandboxes(self) -> None: with TemporaryDirectory() as raw_dir: root = (Path(raw_dir) / "artifacts").resolve() diff --git a/tests/test_image_rootfs.py b/tests/test_image_rootfs.py index f7fdc46a..d937808d 100644 --- a/tests/test_image_rootfs.py +++ b/tests/test_image_rootfs.py @@ -571,35 +571,6 @@ def test_overlay_bundle_uses_shared_lower_and_per_sandbox_upper(self) -> None: self.assertFalse(lease.sandbox.bundle.exists()) self.assertFalse(lease.upper.parent.exists()) - def test_legacy_bundle_rebind_preserves_metadata_and_checkpoint_identity(self) -> None: - with TemporaryDirectory() as raw: - root = Path(raw) - runner = Overlay2Runner(root / "docker") - manager = OverlayRootfsManager( - image_store(root, runner), - writable_root=(root / "writable").resolve(), - bundle_root=(root / "bundles").resolve(), - runner=runner, - ) - with manager.resolve("example/image:latest") as image: - lease = manager.prepare( - sandbox_id="sandbox-legacy", sandbox_generation=1, - image=image, config_template={}, - ) - metadata_path = lease.sandbox.bundle / ".ucloud-overlay.json" - legacy = json.dumps({ - "schema": 1, "image_id": image.image_id, - "lowerdir": str(image.rootfs), - "rootfs_identity_sha256": image.rootfs_identity_sha256, - }).encode("ascii") - metadata_path.write_bytes(legacy) - manager.park_sandbox(lease.sandbox) - manager.resume_sandbox(lease.sandbox) - self.assertIn(str(lease.merged), runner.mounted) - self.assertEqual(metadata_path.read_bytes(), legacy) - self.assertEqual(lease.sandbox.rootfs_sha256, image.rootfs_identity_sha256) - manager.release(lease) - def test_bundle_rejects_changed_environment_before_remount(self) -> None: with TemporaryDirectory() as raw: root = Path(raw) @@ -617,15 +588,14 @@ def test_bundle_rejects_changed_environment_before_remount(self) -> None: ) metadata_path = lease.sandbox.bundle / ".ucloud-overlay.json" metadata = json.loads(metadata_path.read_text()) - self.assertEqual(metadata["schema"], 1) - # Qualify the new reader before any production writer is enabled. - metadata.pop("image_id") - metadata.update({ + self.assertEqual(metadata, { "schema": 2, "environment": image.environment.to_dict(), - "backend_abi": "ucloud-overlay2-rootfs-v1", + "backend_abi": "ucloud-overlay2-rootfs-v1", "lowerdir": str(image.rootfs), + "rootfs_identity_sha256": image.rootfs_identity_sha256, }) manager.park_sandbox(lease.sandbox) for change in ( + {"schema": 1}, {"environment": {**metadata["environment"], "base": "sha256:" + "b" * 64}}, {"backend_abi": "erofs-v1"}, {"environment": {**metadata["environment"], "toolkits": ["sha256:" + "b" * 64]}}, diff --git a/tests/test_network_policy.py b/tests/test_network_policy.py index 121ee365..a343a90c 100644 --- a/tests/test_network_policy.py +++ b/tests/test_network_policy.py @@ -386,31 +386,6 @@ def fail_once(_script): self.assertIn(f"s{lease.slot}_guard", rebuild) self.assertIn(f"s{self.manager.lease('second', 1).slot}_guard", rebuild) - def test_rebuild_retires_per_sandbox_tables_and_interface_accepts(self): - lease = self.create() - runner = self.manager.runner - runner.reset_mock() - legacy = ("FORWARD", "-i", lease.host_interface, "-j", "ACCEPT") - with ( - patch.object( - self.manager, "_iptables_snapshot", return_value={("filter", "-A", *legacy)} - ), - patch.object(self.manager, "_command_ok", return_value=False), - ): - self.manager._refresh_relay_policies(force=True) - rebuild = self.nft.call_args.args[0] - self.assertIn(f"delete table inet ucloud_relay_{lease.slot}\n", rebuild) - # Legacy tables go in the same transaction as the shared table. - self.assertLess(rebuild.index("table inet ucloud_relay {"), - rebuild.index(f"delete table inet ucloud_relay_{lease.slot}")) - commands = [call.args[0] for call in runner.call_args_list] - accept = next(i for i, c in enumerate(commands) if "mark" in c) - self.assertEqual( - commands[accept][:4], ("iptables", "-I", "FORWARD", "1") - ) - self.assertIn("0x1000000/0x1000000", commands[accept]) - self.assertEqual(commands[accept + 1], ("iptables", "-D", *legacy)) - class RelayNetworkFilesTests(unittest.TestCase): def test_hostname_mapping_preserves_tls_name_and_avoids_public_dns(self): diff --git a/tests/test_relay_network_linux.py b/tests/test_relay_network_linux.py index 8c4a1936..2c0e245b 100644 --- a/tests/test_relay_network_linux.py +++ b/tests/test_relay_network_linux.py @@ -305,21 +305,7 @@ def probe(namespace, host, port=8000, *, udp=False, source=None): assert probe(restored.namespace, target), "restore wiring failed" assert not probe(restored.namespace, "203.0.113.2") assert probe(direct.namespace, "203.0.113.2"), "rebuild lost direct egress" - # Upgrade: per-sandbox tables and interface ACCEPTs from earlier - # releases are retired; a stale guard must not outlive the restart. - legacy = f"ucloud_relay_{lease.slot}" - subprocess.run( - ["nft", "-f", "-"], - input=( - f"table inet {legacy} {{ chain guard {{ type filter hook prerouting " - f'priority -150; policy accept; iifname "{lease.host_interface}" drop; }}; }}\n' - ), - text=True, - check=True, - ) - interface_accept = ("FORWARD", "-i", lease.host_interface, "-j", "ACCEPT") - run("iptables", "-I", *interface_accept) - assert not probe(restored.namespace, target), "legacy fixture inactive" + # A daemon restart rebuilds the same guards from durable state. fresh = DirectNetworkManager( Path(raw) / "slots.json", network_relays={"default": "relay.test:8000"}, @@ -327,12 +313,6 @@ def probe(namespace, host, port=8000, *, udp=False, source=None): ) fresh.reconcile() assert probe(restored.namespace, target), "daemon restart failed" - assert subprocess.run( - ["nft", "list", "table", "inet", legacy], capture_output=True - ).returncode != 0, "legacy relay table survived" - assert subprocess.run( - ["iptables", "-C", *interface_accept], capture_output=True - ).returncode != 0, "legacy interface ACCEPT survived" assert not probe(restored.namespace, "203.0.113.2") manager.release("restricted", 1) reused = manager.ensure("reused", 1) diff --git a/tests/test_resident_memory.py b/tests/test_resident_memory.py index 06833bc4..34a86df7 100644 --- a/tests/test_resident_memory.py +++ b/tests/test_resident_memory.py @@ -27,13 +27,12 @@ def setUp(self): ) self.sampler = ResidentMemorySampler(cgroup_root=self.cg, proc_root=self.proc) - def sample(self, **kwargs): + def sample(self, expected_path=None): return self.sampler.sample( ("s", 1), pid=123, start_time_ticks=456, - container_id=self.container, - **kwargs, + expected_path=expected_path or "/ucloud-sandboxes/" + self.container, ) def test_containment_is_resolved_once_per_unchanged_cgroup(self): diff --git a/tests/test_rules_baseline.json b/tests/test_rules_baseline.json index ea7764d6..cd4de1b9 100644 --- a/tests/test_rules_baseline.json +++ b/tests/test_rules_baseline.json @@ -275,7 +275,7 @@ }, "test_heartbeat_sender.py": { "clock": 10, - "private": 6, + "private": 5, "sleep": 7, "test_import": 1 }, @@ -438,8 +438,8 @@ }, "test_network_policy.py": { "new": 3, - "private": 14, - "private_patch": 8 + "private": 13, + "private_patch": 6 }, "test_node_agent.py": { "private": 3 diff --git a/tests/test_rust_resident_sampling.py b/tests/test_rust_resident_sampling.py index da074a4c..233dabbe 100644 --- a/tests/test_rust_resident_sampling.py +++ b/tests/test_rust_resident_sampling.py @@ -150,7 +150,7 @@ def test_the_agents_own_measurements_overlay_noded_by_age(self) -> None: self.published.load() own = sample(current=1 << 29) with patch.object(self.own, "sample", side_effect=lambda key, **_: self.own._samples.setdefault(key, own)): - self.assertEqual(self.published.sample(self.key, pid=10, start_time_ticks=20, container_id="c"), own) + self.assertEqual(self.published.sample(self.key, pid=10, start_time_ticks=20, expected_path="/c"), own) self.assertEqual(self.published.get(self.key), own) # Newer than noded's. newer = sample(current=3 << 29) publish(self.path, 2, {self.key: newer}) @@ -159,7 +159,7 @@ def test_the_agents_own_measurements_overlay_noded_by_age(self) -> None: # A failed measurement is unknown until a later observation, as the # sampler drops its own. with patch.object(self.own, "sample", return_value=None): - self.assertIsNone(self.published.sample(self.key, pid=10, start_time_ticks=20, container_id="c")) + self.assertIsNone(self.published.sample(self.key, pid=10, start_time_ticks=20, expected_path="/c")) self.assertIsNone(self.published.get(self.key)) self.assertIs(self.published.cgroup_root, self.own.cgroup_root)