From mboxrd@z Thu Jan 1 00:00:00 1970 Received: from atuin.qyliss.net (localhost [IPv6:::1]) by atuin.qyliss.net (Postfix) with ESMTP id 63BEEA2DF; Sun, 30 Aug 2026 06:45:12 +0000 (UTC) Received: by atuin.qyliss.net (Postfix, from userid 993) id C054CA2CA; Sun, 30 Aug 2026 06:45:09 +0000 (UTC) X-Spam-Checker-Version: SpamAssassin 4.0.1 (2024-03-26) on atuin.qyliss.net X-Spam-Level: X-Spam-Status: No, score=-0.1 required=3.0 tests=DKIM_SIGNED,DKIM_VALID, DKIM_VALID_AU,DMARC_PASS,FREEMAIL_FROM,RCVD_IN_DNSWL_NONE, SPF_HELO_NONE autolearn=unavailable autolearn_force=no version=4.0.1 Received: from mail-yx1-xb130.google.com (mail-yx1-xb130.google.com [IPv6:2607:f8b0:4864:20::b130]) by atuin.qyliss.net (Postfix) with ESMTPS id C51A6A29A for ; Sun, 30 Aug 2026 06:45:07 +0000 (UTC) Received: by mail-yx1-xb130.google.com with SMTP id 956f58d0204a3-66d0743503aso3456670d50.3 for ; Sat, 29 Aug 2026 23:45:07 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20251104; t=1788072306; x=1788677106; darn=spectrum-os.org; h=cc:to:in-reply-to:references:message-id:content-transfer-encoding :content-type:mime-version:subject:date:from:from:to:cc:subject:date :message-id:reply-to:content-type; bh=j6e8ew3y+QhmD9TEa4AYk0FUE0I2GIrKw4SkKs6f4mA=; b=WJfishKdNuubHaL0UA2CmfSrd+DPLIcHro+n5eQIiP6UbtmGnT+8lnV8uyxNnoangJ ktimyR04wdMUwds0IrCsqZhnaijpKXq4lTsL4fQwIzykyvOt6Mt7DGfjrBq858A5bHWR gswRUby165z/JbqAqAmihdJ8BdS0ADo9AMq3Lu5kdWdkzvGQFzlR/qOjnJZ0qRrsvysL 5WtGkITywwPpSGwM8V7KswdkeZNmqx7LRS/MNwbvIV3GKf/TmaciwXVWk3VjL+TgmsrV IhAe+TfUWjqxpX+8Y/MnbpO0sLjjKXZo0BX4KsPuas5t2PQCGnvAZs9TbgEWxDIhHV7j UKRQ== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20251104; t=1788072306; x=1788677106; h=cc:to:in-reply-to:references:message-id:content-transfer-encoding :content-type:mime-version:subject:date:from:x-gm-gg :x-gm-message-state:from:to:cc:subject:date:message-id:reply-to :content-type; bh=j6e8ew3y+QhmD9TEa4AYk0FUE0I2GIrKw4SkKs6f4mA=; b=nJA9URqLmu6pEkXrhcJv6FnUC5FqsSjySmW/Ur/DEytrAC8fMMD57C0/RnnnNmR+gv xp+XeU7GO9HVwH6evDYTQ3a/g5elIVRo18vMKLWikS2nUeo+Um+NfqfNUSyODvFKxKFD OXLnpMgUePvxeu8A9o11tqa4wC7snQG3H5/teBG8OCWG4hrYArb6kxy3QjdU7oZdSAgU uzYROurT+bSlvcHx7PPFI7zXzxN/negbXOz5vXemFNxqzmHQ6HoIujkMCdaiTJ89SD73 W/u9AP/pfi2aUQ/b3zq1UjnwyA9eG7VQlfQjKHXj5jjxafXyCPtf84vEof7ck/1UYov3 gFwQ== X-Gm-Message-State: AFuF++lzMyymgjhZTzufYd/cWtozakMPH5Pn/T8bwf6WD8gI+yoh2NO8 8ls2Pda3wvUOQzQRyS4QnviEtnv47a08Q0N99EazZuYmByBHWMrdPQLbwVC30g== X-Gm-Gg: AYBFou1u1rwaPvX4BCBTal1Du7ct/eoQmvWdJJd/7n9odfCf2qVPQInRVmTDBM7/aFH Zyuy/UVlEKPfrCAs2k7rR6VE1cACZkN8aoN6MxNudNfKDsuFESrEupcVqre40VY+7Max1gqQqpk L1HYNpHHtMjIKUe7ek/4V1GpsMojyCgEsGxnSROh9OamB5D/OUIWSW5ofqrzjCNOLQzX3XQErZ/ llrF/cmdxw1RkWqu7DKiKgeVqClG19yQ01B8amsKVd+PMCGrvCaIWNPMZ5/eb97+E6eQQEihqDE v4G0l8zUahsJb5pEMQy/X8ULtcDTBc5iSIezsJb0nWqpwXUb9RJRdEOPGdHbCsHX3ROcNq7VTaH cr5Bzz/VEIgIJOGOltNguC91VrvpHGYoYDyY8uudJltobSyXyaf4EF5a/s011tfHkyQZNNllZ4S BqwlRFtCv9jotnmQp9OqrHneCX5i08N38t7iP+GQ+WUZamkrFI5y7ZXUv7eVAKnO4Biyp27xmMM xL1EmL1OAs7 X-Received: by 2002:a05:690e:168d:b0:664:e4b2:4e66 with SMTP id 956f58d0204a3-66e4c5ed20emr5105257d50.7.1788072306261; Sat, 29 Aug 2026 23:45:06 -0700 (PDT) Received: from localhost.localdomain ([185.98.168.14]) by smtp.gmail.com with UTF8SMTPSA id 956f58d0204a3-66e4ed1fbebsm3707070d50.17.2026.08.29.23.45.03 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Sat, 29 Aug 2026 23:45:04 -0700 (PDT) From: Demi Marie Obenour Date: Sun, 30 Aug 2026 02:06:36 -0400 Subject: [PATCH v8 02/18] tools: Add control group manager MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 7bit Message-Id: <20260830-cgroups-v8-2-239b09ba7013@gmail.com> References: <20260830-cgroups-v8-0-239b09ba7013@gmail.com> In-Reply-To: <20260830-cgroups-v8-0-239b09ba7013@gmail.com> To: Spectrum OS Development X-Mailer: b4 0.15.2 X-Developer-Signature: v=1; a=ed25519-sha256; t=1788069995; l=23614; i=demiobenour@gmail.com; s=20250729; h=from:subject:message-id; bh=ZDQaE8HQpxUYCqJK2O/Pp5jukvgrYjni0hjRMQCyC7A=; b=WhU8kNWp2OpnawOHamDIQoXSL+C7zH19Z7jK7ACb6WSOoXlcybin/skwFT25EL5D9X/fGay8i erWXsZohmf9Ao8XMzA/5MFnn7i/Nsjz/uUi481gNXnuXvOAtijmRoZ9 X-Developer-Key: i=demiobenour@gmail.com; a=ed25519; pk=X57Q4/YQDj9t4SBeKaDwvXYKB6quZJVx/DE2Ly2out0= Message-ID-Hash: BF26VEKQTSHENCZGGSARNS3UZEG3C6XP X-Message-ID-Hash: BF26VEKQTSHENCZGGSARNS3UZEG3C6XP X-MailFrom: demiobenour@gmail.com X-Mailman-Rule-Hits: member-moderation X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; header-match-devel.spectrum-os.org-0; header-match-devel.spectrum-os.org-1; header-match-devel.spectrum-os.org-2; header-match-devel.spectrum-os.org-3; header-match-devel.spectrum-os.org-4; emergency CC: Demi Marie Obenour , Alyssa Ross X-Mailman-Version: 3.3.10 Precedence: list List-Id: Patches and low-level development discussion Archived-At: List-Archive: List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: This program has two modes: 1. Create a control group if it doesn't exist, optionally wait for other programs in it to exit, and exec another program in it. 2. Purge a control group: kill all programs in it, then delete it. Locking is used to ensure that concurrent invocations are safe. Signed-off-by: Demi Marie Obenour --- .codespellrc | 2 +- host/rootfs/default.nix | 6 +- pkgs/default.nix | 1 + tools/cgroup-setup/Cargo.lock | 67 +++++++++ tools/cgroup-setup/Cargo.lock.license | 2 + tools/cgroup-setup/Cargo.toml | 10 ++ tools/cgroup-setup/default.nix | 22 +++ tools/cgroup-setup/src/cgroup.rs | 253 ++++++++++++++++++++++++++++++++++ tools/cgroup-setup/src/main.rs | 144 +++++++++++++++++++ 9 files changed, 503 insertions(+), 4 deletions(-) diff --git a/.codespellrc b/.codespellrc index d8023afc64ec44e98a88c5e397c8d5c681dde063..ae20da8309530759ac729689825a4afc683afa09 100644 --- a/.codespellrc +++ b/.codespellrc @@ -2,4 +2,4 @@ # SPDX-License-Identifier: CC0-1.0 [codespell] -ignore-words-list = crate,passt,rouge,ser +ignore-words-list = crate,passt,rouge,ser,WRONLY diff --git a/host/rootfs/default.nix b/host/rootfs/default.nix index 6bfeefbe0a5f76c1538ccb40e5eb8f291f5d3592..ccf626e2ec0f4bf96573dc5edf058c9375bb65f6 100644 --- a/host/rootfs/default.nix +++ b/host/rootfs/default.nix @@ -8,7 +8,7 @@ import ../../lib/call-package.nix ( }: pkgsMusl.callPackage ( -{ spectrum-host-tools, spectrum-router +{ spectrum-host-tools, spectrum-router, spectrum-cgroup-setup , lib, stdenvNoCC, nixos, runCommand, writeClosure, erofs-utils, s6-rc , btrfs-progs, bubblewrap, busybox, cloud-hypervisor, cosmic-files , crosvm, cryptsetup, dejavu_fonts, dbus, execline, foot, fuse3 @@ -27,8 +27,8 @@ let packages = [ btrfs-progs bubblewrap cloud-hypervisor cosmic-files crosvm cryptsetup dbus execline fuse3 inotify-tools iproute2 jq kmod mdevd mount-flatpak s6 - s6-linux-init s6-rc shadow socat spectrum-host-tools spectrum-router - virtiofsd xdg-desktop-portal-spectrum-host + s6-linux-init s6-rc shadow socat spectrum-cgroup-setup spectrum-host-tools + spectrum-router virtiofsd xdg-desktop-portal-spectrum-host (foot.override { allowPgo = false; }) diff --git a/pkgs/default.nix b/pkgs/default.nix index 44f7b5ff78cb6b9e755292a6a417d0b627ed3fb0..0a13393164ad5d7f752e630763f3f97166479af5 100644 --- a/pkgs/default.nix +++ b/pkgs/default.nix @@ -51,6 +51,7 @@ let driverSupport = true; }; spectrum-router = self.callSpectrumPackage ../tools/router {}; + spectrum-cgroup-setup = self.callSpectrumPackage ../tools/cgroup-setup {}; xdg-desktop-portal-spectrum-host = self.callSpectrumPackage ../tools/xdg-desktop-portal-spectrum-host {}; diff --git a/tools/cgroup-setup/Cargo.lock b/tools/cgroup-setup/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..fe967b3aa02c296c87b6b36ac59253dbe0a32de9 --- /dev/null +++ b/tools/cgroup-setup/Cargo.lock @@ -0,0 +1,67 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "cgroup-setup" +version = "0.0.0" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/tools/cgroup-setup/Cargo.lock.license b/tools/cgroup-setup/Cargo.lock.license new file mode 100644 index 0000000000000000000000000000000000000000..f80078163f4428881b86cb6fef0c90c0c1ebaa91 --- /dev/null +++ b/tools/cgroup-setup/Cargo.lock.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2026 Demi Marie Obenour +SPDX-License-Identifier: CC0-1.0 diff --git a/tools/cgroup-setup/Cargo.toml b/tools/cgroup-setup/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..e44ef64119aefc23a3119fe0ce01566f5d7ec98c --- /dev/null +++ b/tools/cgroup-setup/Cargo.toml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2026 Demi Marie Obenour +# SPDX-License-Identifier: CC0-1.0 + +[package] +name = "cgroup-setup" +edition = "2024" + +[dependencies] +libc = "0.2.177" +rustix = { version = "1.1.2", features = ["fs"] } diff --git a/tools/cgroup-setup/default.nix b/tools/cgroup-setup/default.nix new file mode 100644 index 0000000000000000000000000000000000000000..fe3a8bcd4e6118f3cbf49c2fc0e3cda01570c484 --- /dev/null +++ b/tools/cgroup-setup/default.nix @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2024 Alyssa Ross +# SPDX-FileCopyrightText: 2025 Yureka Lilian +# SPDX-License-Identifier: MIT + +import ../../lib/call-package.nix ( +{ src, lib, rustPlatform }: + +rustPlatform.buildRustPackage { + name = "spectrum-cgroup-setup"; + + src = lib.fileset.toSource { + root = ../..; + fileset = lib.fileset.intersection src ./.; + }; + sourceRoot = "source/tools/cgroup-setup"; + + cargoLock.lockFile = ./Cargo.lock; + + postInstall = '' + ln -s -- cgroup-setup "$out/bin/cgroup-purge" + ''; +}) (_: {}) diff --git a/tools/cgroup-setup/src/cgroup.rs b/tools/cgroup-setup/src/cgroup.rs new file mode 100644 index 0000000000000000000000000000000000000000..f17856e8eaf8b6d7b18619cef41b18d649365c16 --- /dev/null +++ b/tools/cgroup-setup/src/cgroup.rs @@ -0,0 +1,253 @@ +// SPDX-FileCopyrightText: 2026 Demi Marie Obenour +// SPDX-License-Identifier: EUPL-1.2+ + +use std::ffi::OsStr; +use std::fs::File; +use std::io::{Read as _, Seek as _, Write as _}; +use std::os::unix::prelude::*; + +use std::path::{Component, Path, PathBuf}; + +use rustix::fs::{AtFlags, CWD, Dir, FlockOperation}; +use rustix::path; +use rustix::{ + fs::{Mode, OFlags, ResolveFlags}, + io::Errno, +}; + +pub enum OpenFlags { + Read, + Write, + Directory, +} + +#[derive(Debug)] +pub(crate) struct Cgroup { + fd: Vec, +} + +impl AsFd for Cgroup { + fn as_fd(&self) -> BorrowedFd<'_> { + self.fd.last().unwrap().as_fd() + } +} + +// Wrapper around openat2() with better defaults. +pub fn openat2_simple( + fd: impl AsFd, + path: impl path::Arg, + flags: OpenFlags, +) -> Result { + rustix::fs::openat2( + fd.as_fd(), + path, + OFlags::CLOEXEC + | match flags { + OpenFlags::Read => OFlags::RDONLY | OFlags::NOCTTY, + OpenFlags::Write => OFlags::WRONLY | OFlags::NOCTTY, + OpenFlags::Directory => OFlags::RDONLY | OFlags::DIRECTORY, + }, + Mode::empty(), + ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS | ResolveFlags::NO_XDEV, + ) +} + +pub const DEFAULT_LEAF: &str = "$inner.service"; + +// Remove all subdirectories of the given directory recursively, but not the +// directory itself. +// +// This isn't the most efficient possible algorithm, but simplicity is more +// important than performance in this case. Also, it keeps open more file +// descriptors than strictly necessary, but Spectrum runs with a very high limit +// for the number of open file descriptors, and it uses shallow control group +// hierarchies. +// +// This uses a recursive algorithm, but so does std::fs::remove_dir_all(). Trying +// to be more robust than the standard library is not worthwhile. In particular, +// the standard library function must be safe on systems where untrusted users (or +// even network endpoints!) can create deeply nested directory trees, whereas in +// Spectrum cgroups are only writeable by root. +fn remove_recursively(mut dirfd: Dir, remaining_depth: usize) -> Result<(), Errno> { + if remaining_depth < 1 { + panic!("control groups too deeply nested"); + } + while let Some(entry) = dirfd.next() { + let entry = entry.expect("Iterating through a cgroup directory failed?"); + let name = entry.file_name(); + if entry.file_type() != rustix::fs::FileType::Directory || name == c"." || name == c".." { + continue; + } + let parent_fd = dirfd.fd().unwrap(); + let fd = openat2_simple(parent_fd, name, OpenFlags::Directory)?; + remove_recursively(Dir::new(fd).unwrap(), remaining_depth - 1)?; + rustix::fs::unlinkat(parent_fd, name, AtFlags::REMOVEDIR)?; + } + Ok(()) +} + +// Convert the cgroup path to one relative to /sys/fs/cgroup. +// +// If the path starts with /, the leading / is removed and the result is returned +// without further processing. Otherwise, the current cgroup is read from +// /proc/thread-self/cgroup. If its last component is $inner.service, that is +// removed. Finally, the current cgroup is prepended to the provided cgroup path, +// with a single / as separator. The result of this operation is returned. +fn prepend_current_cgroup_if_needed(path: &Path) -> Result { + if let Ok(suffix) = path.strip_prefix("/") { + return Ok(suffix.to_owned()); + } + // /proc/thread-self is the same as /proc/self, except for the current thread + // instead of the initial thread. In this case, the two are identical, but + // using /proc/thread-self is better practice as it is correct in more cases. + // Reading /proc/thread-self/cgroup should never fail unless the system is + // seriously broken. + let current_cgroup = + std::fs::read("/proc/thread-self/cgroup").expect("cannot read /proc/thread-self/cgroup"); + // Using this on a system without cgroups v2 mounted is user error + // and not supported. + let current_cgroup = current_cgroup + .strip_prefix(b"0::/") + .and_then(|e| e.strip_suffix(b"\n")) + .ok_or_else(|| { + "/proc/thread-self/cgroup doesn't start with 0::/ or doesn't end with a newline.\n\ + Either cgroups aren't in use at all, or you are using cgroups v1." + .to_owned() + })?; + let mut current_cgroup = PathBuf::from(OsStr::from_bytes(current_cgroup)); + // Strip the implied $inner.service suffix. + // This is used to satisfy the "no internal processes" rule. + if current_cgroup.ends_with(Path::new(DEFAULT_LEAF)) { + assert!(current_cgroup.pop()); + } + // "." refers to the current cgroup. + if path != Path::new(".") { + current_cgroup.push(path); + } + Ok(current_cgroup) +} + +pub(crate) fn write_value(fd: &dyn AsFd, name: &Path, value: &[u8]) -> Result<(), String> { + let fd = openat2_simple(fd, name, OpenFlags::Write) + .map_err(|e| format!("Cannot open {name:?}: {e}"))?; + File::from(fd).write_all(value).map_err(|e| { + format!( + "Cannot write {:?} to {name:?}: {e}", + OsStr::from_bytes(value) + ) + }) +} + +impl Cgroup { + pub fn new(path: &Path) -> Result { + let cgroup_root = rustix::fs::openat2( + CWD, + Path::new("/sys/fs/cgroup"), + OFlags::CLOEXEC | OFlags::DIRECTORY | OFlags::RDONLY, + Mode::empty(), + ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS, + ) + .map_err(|e| format!("Cannot open /sys/fs/cgroup: {e}"))?; + // It's simpler to always have the root cgroup at the bottom of the stack, + // even though no lock needs to be taken on it. Otherwise, one would need + // to special-case the cgroup root. One could remove the first element if + // there is more than one element in the vector, but that's not worth it. + // cgroup-setup doesn't operate in an environment where FDs are a limited + // resource. + let mut cgroup = Self { + fd: vec![cgroup_root], + }; + + let path = prepend_current_cgroup_if_needed(path)?; + for component in path.components() { + let Component::Normal(component) = component else { + unreachable!() + }; + let sub_fd = openat2_simple(&cgroup, component, OpenFlags::Directory) + .map_err(|e| format!("Cannot open sub-cgroup {component:?}: {e}"))?; + // Take a shared lock on the cgroup. + rustix::fs::flock(&sub_fd, FlockOperation::LockShared) + .map_err(|e| format!("Cannot lock sub-cgroup {component:?}: {e}"))?; + cgroup.fd.push(sub_fd); + } + Ok(cgroup) + } + + pub fn wait_for_empty(fd: &dyn AsFd) -> std::io::Result<()> { + let wait_file = openat2_simple(fd, c"cgroup.events", OpenFlags::Read)?; + let mut wait_fd = File::from(wait_file); + let mut v = vec![]; + loop { + v.clear(); + wait_fd + .seek(std::io::SeekFrom::Start(0)) + .expect("Seek on control group file should succeed"); + wait_fd + .read_to_end(&mut v) + .expect("reading from control group should work"); + // Check that the cgroup isn't already empty. If it was, + // the kernel would not send an event and poll() would wait + // forever. + if v.split(|&c| c == b'\n').any(|line| line == b"populated 0") { + break; + } + let mut fds = libc::pollfd { + fd: wait_fd.as_raw_fd(), + events: libc::POLLPRI | libc::POLLERR, + revents: 0, + }; + // SAFETY: FFI call, valid arguments, fds contains 1 element + if unsafe { libc::poll(&raw mut fds, 1, -1) } != 1 { + panic!("poll failed"); + } + } + Ok(()) + } + + pub fn purge_child(&mut self, path: &Path) -> Result<(), String> { + // See if we can just delete the child directly. + match rustix::fs::unlinkat(&self, path, AtFlags::REMOVEDIR) { + // If the cgroup was successfully deleted, or if it + // has already been deleted, we are done. + Ok(()) | Err(Errno::NOENT) => return Ok(()), + // If this cgroup is in use, keep going. + Err(Errno::BUSY) => {} + Err(e) => return Err(format!("Cannot purge {path:?}: {e}")), + } + + let sub_fd = match openat2_simple(&self, path, OpenFlags::Directory) { + Ok(sub_fd) => sub_fd, + Err(Errno::NOENT) => return Ok(()), + Err(e) => { + return Err(format!("Cannot open sub-cgroup {path:?}: {e}",)); + } + }; + + // Take an exclusive lock on the cgroup that is about to be removed. This + // avoids concurrent executions of this program operating on deleted + // sub-cgroups. + rustix::fs::flock(&sub_fd, FlockOperation::LockExclusive) + .map_err(|e| format!("Cannot lock sub-cgroup: {e}"))?; + + // Kill all processes in the child cgroup. + write_value(&sub_fd, Path::new("cgroup.kill"), b"1")?; + + // Wait for the child cgroup to become empty. + Self::wait_for_empty(&sub_fd) + .map_err(|e| format!("Cannot wait for cgroup to become empty: {e}"))?; + + // Remove the child cgroup and its contents recursively. + remove_recursively(Dir::new(sub_fd).unwrap(), 1000) + .map_err(|e| format!("Cannot remove: {e}"))?; + + // Delete the cgroup. If it's been re-created in the meantime and is + // currently in use, this is not an error. Another process deleting the + // cgroup is also not an error. Both of these can happen because of the + // time period between remove_child_directories() closing the file + // descriptor (releasing its lock) and the above call to flock(). + match rustix::fs::unlinkat(&self, path, AtFlags::REMOVEDIR) { + Ok(()) | Err(Errno::BUSY) | Err(Errno::NOENT) => Ok(()), + Err(e) => Err(format!("Cannot delete: {e}")), + } + } +} diff --git a/tools/cgroup-setup/src/main.rs b/tools/cgroup-setup/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..965116ec05b9db22ab20c3a18aa3a199d6cedc04 --- /dev/null +++ b/tools/cgroup-setup/src/main.rs @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: 2026 Demi Marie Obenour +// SPDX-License-Identifier: EUPL-1.2+ + +mod cgroup; + +use cgroup::{Cgroup, OpenFlags, openat2_simple, write_value}; +use rustix::{ + fs::{FlockOperation, Mode, XattrFlags}, + io::Errno, +}; +use std::{ + env::ArgsOs, + os::unix::prelude::*, + path::{Path, PathBuf}, +}; + +fn exec_in_cgroup( + mut args: std::iter::Peekable, + cgroup: Option<&dyn AsFd>, +) -> Result<(), String> { + let Some(program_name) = args.next() else { + return Ok(()); + }; + if let Some(cgroup) = cgroup { + let pid = std::process::id().to_string(); + write_value( + cgroup, + Path::new("$inner.service/cgroup.procs"), + pid.as_bytes(), + ) + .map_err(|e| format!("Cannot move process to child cgroup: {e}"))?; + } + let e = std::process::Command::new(&program_name).args(args).exec(); + Err(format!("Cannot exec child {program_name:?}: {e}")) +} + +// Check that the path is canonical, +// then split it into basename and filename. +fn split_canonical_path(path: &Path) -> Result<(&Path, &Path), String> { + let bytes = path.as_os_str().as_bytes(); + // Path::components() skips ., so use string manipulation instead. + for component in bytes[path.is_absolute() as usize..].split(|&b| b == b'/') { + if matches!(component, b"" | b"." | b"..") { + return Err(format!("cgroup path {path:?} isn't canonical")); + } + } + Ok((path.parent().unwrap(), Path::new(path.file_name().unwrap()))) +} + +fn cgroup_setup(args: ArgsOs) -> Result<(), String> { + let mut wait = true; + let mut args = args.peekable(); + while let Some(arg) = args.peek() { + if !arg.as_bytes().starts_with(b"-") { + break; + } + let arg = args.next().unwrap(); + let Some(option) = arg.as_bytes().strip_prefix(b"--") else { + return Err("takes no short options".to_owned()); + }; + match option { + b"" => break, + b"no-wait" => wait = false, + _ => return Err(format!("unknown long option {arg:?}")), + } + } + let Some(cgroup_path) = args.next().map(PathBuf::from) else { + return Err("have no positional arguments, expected at least 1".to_owned()); + }; + + let (parent_cgroup_path, child_cgroup_path) = split_canonical_path(&cgroup_path)?; + let cgroup = Cgroup::new(parent_cgroup_path)?; + match rustix::fs::mkdirat(&cgroup, child_cgroup_path, Mode::from_raw_mode(0o755)) { + Ok(()) | Err(Errno::EXIST) => {} + Err(e) => { + return Err(format!( + "Cannot create child cgroup {child_cgroup_path:?}: {e}" + )); + } + } + + let child = openat2_simple(&cgroup, child_cgroup_path, OpenFlags::Directory) + .map_err(|e| format!("Cannot open child cgroup: {e}"))?; + + // While waiting, hold an exclusive lock on the child. + // This avoids two processes both waiting for the same cgroup to become + // empty, then execing processes in the same cgroup. + rustix::fs::flock(&child, FlockOperation::LockExclusive) + .map_err(|e| format!("Cannot take an exclusive lock on child cgroup: {e}"))?; + if wait { + Cgroup::wait_for_empty(&child) + .map_err(|e| format!("Cannot wait for {parent_cgroup_path:?} to be empty: {e}"))?; + } + + // systemd-aware programs expect to have user.delegate=1 + rustix::fs::fsetxattr(&child, c"user.delegate", b"1", XattrFlags::empty()) + .map_err(|e| format!("Cannot enable cgroup delegation: {e}"))?; + + // If the child process will need to manage cgroups itself, it will need + // to set up a sub-cgroup due to the "no internal processes" rule. It's + // simplest to just do it automatically. If the cgroup already exists, + // that isn't an error. + match rustix::fs::mkdirat(&child, cgroup::DEFAULT_LEAF, Mode::from_raw_mode(0o755)) { + Ok(()) | Err(Errno::EXIST) => {} + Err(e) => return Err(format!("Cannot create $inner.service cgroup: {e}")), + } + + exec_in_cgroup(args, Some(&child)) +} + +fn cgroup_purge(mut args: ArgsOs) -> Result<(), String> { + if args.len() != 1 { + return Err("usage: cgroup-purge CGROUP_TO_PURGE".to_owned()); + } + let arg = args.next().unwrap(); + let (parent, child) = split_canonical_path(Path::new(&arg))?; + Cgroup::new(parent)?.purge_child(child) +} + +fn run(prog_name: &Path, args: ArgsOs) -> Result<(), String> { + match prog_name.file_name().map(|f| f.as_bytes()) { + Some(b"cgroup-setup") => cgroup_setup(args), + Some(b"cgroup-purge") => cgroup_purge(args), + _ => Err(format!( + "must be invoked as \"cgroup-setup\" or \ + \"cgroup-purge\", got {prog_name:?}", + )), + } +} + +fn main() { + let mut args = std::env::args_os(); + let Some(prog_name) = args.next() else { + eprintln!("No command line arguments (argv[0] is NULL)"); + std::process::exit(1); + }; + match run(Path::new(&prog_name), args) { + Ok(()) => {} + Err(e) => { + eprintln!("{prog_name:?}: {}", e); + std::process::exit(1); + } + } +} -- 2.55.0