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 A4D8C9172; Thu, 06 Aug 2026 01:19:48 +0000 (UTC) Received: by atuin.qyliss.net (Postfix, from userid 993) id A09F790C2; Thu, 06 Aug 2026 01:19:44 +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-yw1-x1130.google.com (mail-yw1-x1130.google.com [IPv6:2607:f8b0:4864:20::1130]) by atuin.qyliss.net (Postfix) with ESMTPS id 631E890BD for ; Thu, 06 Aug 2026 01:19:43 +0000 (UTC) Received: by mail-yw1-x1130.google.com with SMTP id 00721157ae682-7ff05e5d009so19827677b3.1 for ; Wed, 05 Aug 2026 18:19:43 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20251104; t=1785979182; x=1786583982; 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=/zbaU82NCyMf7JgO6qtX8FJx8xi/C67EaHCqRIV5UiE=; b=Tb+1qbd5BONjxrGUj3ZVriAfCuX5O5RAEtavDz4q3T3dbbrLDYMHLyBj/tHdmvJrTU 7ZuEVIbVkzypOLG5F3uNHiILk6y1FC36ae+Z4rdt3GBzr1W3/oO/uRHTFWXX8vRYdVu3 kj+VPaWqeAvrU+sFhCs7fNFuzP9FBgYlxoSBnO7oqn2ipI9jvI5Me73j6k3OOPOT9VYq zHWVMdhpmoVKrPrTzJ3X2uLtxQbAtxR0n5vdu7hJcbofGLa80IArAK9StkqQMk4MvA3y srY9OMhlOcY/81Ippm8eitY+KEL9snw3cRCpy/8Yksckz4uoQE3yBTip6hdsmNdHhMIO 4OOg== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20251104; t=1785979182; x=1786583982; 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=/zbaU82NCyMf7JgO6qtX8FJx8xi/C67EaHCqRIV5UiE=; b=mXMZZ55qqqBXb0oYqSZTkG5eO2PdCboBSFlEtjQ9Pbk27bDUYM484E3M3QeKRSMDJl Tvrr/rza4R9Z/vi/6ARpIMSf4El5ogje49zjKEA20F/OL/u0/27EOQD4I5XQTvwkDOul kl0Af1SvNNQcMrEyyqTuU122kGRO2S/NL4/W4swWbVWm/+yrmEWrnmn8VdqxRyYDmDDM q1WtXnvfJ7OsNbkZWOqqp/x6X3zCLVAV667CevwOvazsz85Te13op5nIl7UJekVzWns0 zOozSFtsXMzINXkPUNTvsad7Sk8nUTAoF2AehHJnDbyRZek2XNQFFsDzj6tuZPeW1ybA G+MA== X-Gm-Message-State: AOJu0YwKj7DmT9dI5bi1KHAmXs+FBy3wJ/wvlcfk+gZzsTCHg2w4mY/j 2LV6i7lvm3EDFjHBwaCSluRRfq0ZauwCb6G+HPp9S9BLh4xTyShMWCv/7DfGgw== X-Gm-Gg: AR+sD11JgqMaXrWctoIaI7jb/V9yjCWIbDjZFL2AMreYRLr58E9evaBQx2EeRY54htV uRVm/80mkdVVcqmCGxgE3KO3/PdUjGkHYAIjnAcnv1apg2QH2b4ffMkCWLRaSoOvdiN0NFFkEui S/NFYwIkiYon0oCTv/Ub8/Hl59BiFrQwOZ5GW7rKlVF3QUGbQDaJXpy9tEWwf4uKoeIgTBLBlR+ VCkLvcqNZkHG/34Swys0xO5GLEZ8oBYtSNqD+1DSk/EO+znS3a8TXNtjv4bWDztgR9mvtb7ql88 vCVuaTzr++xUMLHYPiexAqfdl5YC2d8W050vcIIuJxSdEDHS60TYHGhZ73Xqei177F1hUVPfO6D FlwLgQbKTd5Lv/StsXKO38XqA1PhvwTcBCSMPuo6avmpDmtKNkyCHzlfb5pcsKHKuprbpplQXyD MrHGh0FrIHy7MX7HOhkVLP+Xc7FZ+HP5Y4YBCcAB6BID+QZxvkLObCeCmqER3hvPELCL5Z7ZNF2 Q== X-Received: by 2002:a05:690c:e3ca:b0:81f:64cf:2c44 with SMTP id 00721157ae682-82022322fbfmr67562677b3.14.1785979181986; Wed, 05 Aug 2026 18:19:41 -0700 (PDT) Received: from localhost.localdomain ([185.98.168.14]) by smtp.gmail.com with UTF8SMTPSA id 00721157ae682-820134ccc5csm30888157b3.43.2026.08.05.18.19.41 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Wed, 05 Aug 2026 18:19:41 -0700 (PDT) From: Demi Marie Obenour Date: Wed, 05 Aug 2026 21:16:09 -0400 Subject: [PATCH v6 02/19] tools: Add control group manager MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 7bit Message-Id: <20260805-cgroups-v6-2-086c0f00f55f@gmail.com> References: <20260805-cgroups-v6-0-086c0f00f55f@gmail.com> In-Reply-To: <20260805-cgroups-v6-0-086c0f00f55f@gmail.com> To: Spectrum OS Development X-Mailer: b4 0.15.2 X-Developer-Signature: v=1; a=ed25519-sha256; t=1785978967; l=24899; i=demiobenour@gmail.com; s=20250729; h=from:subject:message-id; bh=Blj15zKzGCUiBbbRtoO7R5cYX23xkODfBUzrfshp3LI=; b=zGlmQjyFDd1Jrt53e1cRDFiWMLH4WuMRUh6Qd6NcJL0vOqmdN0ouFprg1HvOXAc1HvaSpGanP +Mxx45LMsztCutl+ftWkJohwER8ne/pSO0gRr49cjgsp2VZrvkPnG9a X-Developer-Key: i=demiobenour@gmail.com; a=ed25519; pk=X57Q4/YQDj9t4SBeKaDwvXYKB6quZJVx/DE2Ly2out0= Message-ID-Hash: RUEHUT6Y3WNYEKBA6QDZOCZWLESFNF6F X-Message-ID-Hash: RUEHUT6Y3WNYEKBA6QDZOCZWLESFNF6F 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: The cgroup-setup Rust program can create and purge cgroups. It can also wait for one to become empty, spawn a program in a cgroup, and more. In the future, it will also support cgroup-based resource control. 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 | 269 ++++++++++++++++++++++++++++++++++ tools/cgroup-setup/src/main.rs | 167 +++++++++++++++++++++ 9 files changed, 542 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..c63d7e5a4aa73429578704401c58bbafc79e7c3f --- /dev/null +++ b/tools/cgroup-setup/src/cgroup.rs @@ -0,0 +1,269 @@ +// 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() + } +} + +fn assert_single_component(component: &Path) { + match component.as_os_str().as_bytes() { + b"" | b"." | b".." => panic!("bad component"), + c if c.contains(&b'\0') => panic!("NUL in component"), + c if c.contains(&b'/') => panic!("/ in component"), + _ => {} + } +} + +// 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"; + +pub fn check_path(path: &Path) -> Result<(), 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(()) +} + +// Remove all subdirectories of the given directory recursively, +// but not the directory itself. The directory file descriptor +// is closed. +// +// 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 algorith, 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(element) = dirfd.next() { + let parent_fd = dirfd.fd().unwrap(); + let element = element.expect("Iterating through a cgroup directory failed?"); + let path = element.file_name(); + if element.file_type() != rustix::fs::FileType::Directory || path == c"." || path == c".." { + continue; + } + let fd = openat2_simple(parent_fd, path, OpenFlags::Directory)?; + remove_recursively(Dir::new(fd).unwrap(), remaining_depth - 1)?; + match rustix::fs::unlinkat(parent_fd, path, AtFlags::REMOVEDIR) { + Err(Errno::NOTEMPTY | Errno::BUSY | Errno::NOENT) | Ok(()) => {} + bad => return bad, + } + } + Ok(()) +} + +// If the path is absolute, make it relative. +// Otherwise, read the current cgroup from /proc/thread-self/cgroup +// and prepend it to the path. +fn prepend_current_cgroup_if_needed(path: &Path) -> PathBuf { + if let Ok(suffix) = path.strip_prefix("/") { + suffix.to_owned() + } else { + // /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")) + .expect("you don't have cgroups v2 mounted"); + 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()); + } + current_cgroup.push(path); + 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}"))?; + 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"); + } + } + drop(wait_fd); + Ok(()) + } + + pub fn purge_child(&mut self, path: &Path) -> Result<(), String> { + assert_single_component(path); + // See if we can just delete the child directly. + match rustix::fs::unlinkat(&self, Path::new(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}")) + .inspect_err(|_| { + self.fd.pop().unwrap(); + })?; + + // 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..58ca7295bb348a2c92640a68c60b285b2d7a1494 --- /dev/null +++ b/tools/cgroup-setup/src/main.rs @@ -0,0 +1,167 @@ +// 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, + fs::File, + io::Read as _, + os::unix::prelude::*, + path::{Path, PathBuf}, +}; + +// Check that the path is canonical, +// then split it into basename and filename. +fn split_path(path: &Path) -> Result<(&Path, &Path), String> { + cgroup::check_path(path)?; + Ok((path.parent().unwrap(), Path::new(path.file_name().unwrap()))) +} + +fn enable_subtree_control(fd: &dyn AsFd) -> Result<(), String> { + let mut buf = Vec::new(); + File::from( + openat2_simple(fd, c"cgroup.controllers", OpenFlags::Read) + .map_err(|e| format!("Cannot open cgroup.controllers: {e}"))?, + ) + .read_to_end(&mut buf) + .map_err(|e| format!("Cannot read cgroup.controllers: {e}"))?; + let mut subtree = vec![]; + for controller in buf.split(|&b| b == b' ') { + if !subtree.is_empty() { + subtree.push(b' '); + } + subtree.push(b'+'); + subtree.extend_from_slice(controller); + } + if !subtree.is_empty() { + write_value(&fd, Path::new("cgroup.subtree_control"), &subtree)?; + } + Ok(()) +} + +fn cgroup_setup(args: ArgsOs) -> Result<(), String> { + let mut leaf = false; + let mut systemd_delegate = false; + 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(arg_) = arg.as_bytes().strip_prefix(b"--") else { + return Err("takes no short options".to_owned()); + }; + match arg_ { + b"" => break, + b"leaf" => leaf = true, + b"no-wait" => wait = false, + b"systemd-delegate" => systemd_delegate = true, + _ => 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_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 make child cgroup {child_cgroup_path:?}: {e}" + )); + } + } + let child = openat2_simple(&cgroup, child_cgroup_path, OpenFlags::Directory) + .map_err(|e| format!("Cannot make 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 spawning 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}"))?; + } + let pid = std::process::id().to_string(); + if leaf { + if args.len() != 0 { + // If we aren't delegating any cgroups, don't create a sub-cgroup. + write_value(&child, Path::new("cgroup.procs"), pid.as_bytes()) + .map_err(|e| format!("Cannot move process to child cgroup: {e}"))?; + } + } else { + // 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 make child cgroup: {e}")), + } + if args.len() != 0 { + let child_proc_path = Path::new(cgroup::DEFAULT_LEAF).join(Path::new("cgroup.procs")); + write_value(&child, &child_proc_path, pid.as_bytes()) + .map_err(|e| format!("Cannot move process to child cgroup: {e}"))?; + } + if systemd_delegate { + // systemd-aware programs expect to have user.delegate=1 + // and to set cgroup.subtree_control themselves + rustix::fs::fsetxattr(&child, c"user.delegate", b"1", XattrFlags::empty()).map_err( + |e| format!("Cannot enable cgroup delegation in {parent_cgroup_path:?}: {e}"), + )? + } else { + // Spectrum's programs do not check for user.delegate=1 + // and expect the caller to set cgroup.subtree_control. + enable_subtree_control(&child)?; + } + } + let Some(program_name) = args.next() else { + return Ok(()); + }; + let e = std::process::Command::new(&program_name).args(args).exec(); + Err(format!("Cannot spawn child {program_name:?}: {e}",)) +} + +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_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