From 0b7d409fbced162c61e847985d4dfa36972f92b7 Mon Sep 17 00:00:00 2001 From: alexion Date: Sat, 25 Jul 2026 21:26:53 -0400 Subject: [PATCH] feat(guests): cap guest resources and gate boot-start (task 0008) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give the Host-side placement a `limits` field — `memory`, `cpu`, and `tasksMax` — that caps the guest's `container@.service` unit via `MemoryMax`, `CPUQuota`, and `TasksMax`, uncapped by default so an unset limit contributes no key and systemd keeps its default. Add an `autoStart` placement option, on by default, driving the container's boot-start so a Host can define a guest without starting it at boot. Give neogaia's skeleton guest modest demonstrative caps. --- .claude/tasks/0008-guest-resource-limits.md | 29 +++++++++++ hosts/neogaia/default.nix | 6 +++ lib.nix | 53 ++++++++++++++++++++- 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 .claude/tasks/0008-guest-resource-limits.md diff --git a/.claude/tasks/0008-guest-resource-limits.md b/.claude/tasks/0008-guest-resource-limits.md new file mode 100644 index 0000000..eaa6457 --- /dev/null +++ b/.claude/tasks/0008-guest-resource-limits.md @@ -0,0 +1,29 @@ +--- +spec: guests +blocked-by: 0002-guest-walking-skeleton +--- + +## What to build + +The Host-side placement that caps a Guest's resources so one misbehaving service cannot starve its Host, plus the boot-start toggle. +A Host sets `limits` — `memory`, `cpu`, and `tasksMax` — applied to the guest's unit, uncapped by default. +A Host sets `autoStart` to control whether the Guest starts at boot, on by default. + +## Acceptance criteria + +- [x] A Host setting `guests..limits.memory`, `.cpu`, or `.tasksMax` applies the corresponding cap to the guest's unit. +- [x] Each limit is uncapped when unset. +- [x] `autoStart` starts the Guest at boot by default, and disabling it leaves the Guest defined but not started at boot. +- [x] A Host with a capped Guest builds via `nix flake check`, and the resolved unit caps are verifiable by `nix eval`. + +## Implementation Notes + +- The caps map to the guest's own systemd unit, `container@.service`, which the container backend generates. +The guest module contributes `serviceConfig.MemoryMax`, `.CPUQuota`, and `.TasksMax`, and the module system merges these with the backend's own `serviceConfig` for that unit. +Only set caps appear: a `filterAttrs` drops any limit left null, so an unset limit contributes no key and systemd keeps its uncapped default rather than the module writing an explicit "infinity". +- `limits.memory` and `limits.cpu` are strings passed through to systemd verbatim (`2G`, `150%`), since systemd already parses size and percentage forms and re-inventing the parsing here would only narrow what the operator can express. +`limits.tasksMax` is a positive int, matching `TasksMax`'s count. +- `autoStart` is now a placement option defaulting true, and the container's `autoStart` reads from it directly rather than the previous `mkDefault true`. +The backend gates `wantedBy = [ "machines.target" ]` on `autoStart`, so `false` leaves the `container@.service` unit fully defined but out of `machines.target` — startable on demand, not at boot — verified by evaluation. +- `neogaia`'s skeleton guest carries modest demonstrative caps (`memory = "1G"`, `cpu = "100%"`, `tasksMax = 512`), following task 0002's precedent of exercising the guest path on this host through its own `nix flake check`. +Its `autoStart` is left at the default so 0002's live boot smoke test is unaffected. diff --git a/hosts/neogaia/default.nix b/hosts/neogaia/default.nix index d91ea56..460db7b 100644 --- a/hosts/neogaia/default.nix +++ b/hosts/neogaia/default.nix @@ -46,7 +46,13 @@ # The walking-skeleton guest, enabled like any module: proves the guest path # end to end through this host's `nix flake check`. + # Modest caps keep the skeleton guest from starving the laptop. guests.sample.enable = true; + guests.sample.limits = { + memory = "1G"; + cpu = "100%"; + tasksMax = 512; + }; modules.agents.claude-code.enable = true; modules.agents.tools.gitea-axi.enable = true; diff --git a/lib.nix b/lib.nix index f43e32d..92532d2 100644 --- a/lib.nix +++ b/lib.nix @@ -123,6 +123,14 @@ let # below would otherwise resolve silently in the secret's favour. mountCollisions = lib.attrNames (builtins.intersectAttrs userMounts secretMounts); + # The resource caps the operator places on the guest's unit, dropping any + # left unset so systemd keeps its uncapped default for those. + limitConfig = lib.filterAttrs (_: v: v != null) { + MemoryMax = cfg.limits.memory; + CPUQuota = cfg.limits.cpu; + TasksMax = cfg.limits.tasksMax; + }; + # A networked guest owns its bridged interface through its own networkd, the only stable MAC pin for a nested container. # The interface is eth0, the name a nested container gives its bridged veth. # It takes the placement MAC, and the static address or DHCP when that is unset. @@ -240,6 +248,45 @@ let the host is owned by that same uid inside the guest. ''; }; + limits = { + memory = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "2G"; + description = '' + Cap on the guest's memory, applied to its unit as `MemoryMax`. + Accepts systemd size suffixes such as `512M` or `2G`. Left null, + the guest's memory is uncapped. + ''; + }; + cpu = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "150%"; + description = '' + Cap on the guest's CPU, applied to its unit as `CPUQuota`, where + `100%` is one full core. Left null, the guest's CPU is uncapped. + ''; + }; + tasksMax = lib.mkOption { + type = lib.types.nullOr lib.types.ints.positive; + default = null; + example = 512; + description = '' + Cap on the number of processes and threads the guest may spawn, + applied to its unit as `TasksMax`. Left null, the task count is + uncapped. + ''; + }; + }; + autoStart = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Start the guest at boot. On by default. Disabled, the guest stays + defined and can be started on demand, but does not come up at boot. + ''; + }; }; config = lib.mkIf cfg.enable { @@ -268,8 +315,12 @@ let } ]; + # The operator's resource caps land on the guest's own unit. + systemd.services."container@${machineName}".serviceConfig = + lib.mkIf (cfg.backend == "container") limitConfig; + containers.${machineName} = lib.mkIf (cfg.backend == "container") { - autoStart = lib.mkDefault true; + autoStart = cfg.autoStart; # The guest gets its own network namespace, so its services — its own # sshd included — never contend with the host's. -- 2.47.3