feat(guests): cap guest resources and gate boot-start (task 0008)

Give the Host-side placement a `limits` field — `memory`, `cpu`, and
`tasksMax` — that caps the guest's `container@<name>.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.
This commit was merged in pull request #34.
This commit is contained in:
2026-07-25 21:26:53 -04:00
parent d637d3e7f6
commit 0b7d409fbc
3 changed files with 87 additions and 1 deletions

View File

@@ -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.<path>.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@<name>.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@<name>.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.

View File

@@ -46,7 +46,13 @@
# The walking-skeleton guest, enabled like any module: proves the guest path # The walking-skeleton guest, enabled like any module: proves the guest path
# end to end through this host's `nix flake check`. # 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.enable = true;
guests.sample.limits = {
memory = "1G";
cpu = "100%";
tasksMax = 512;
};
modules.agents.claude-code.enable = true; modules.agents.claude-code.enable = true;
modules.agents.tools.gitea-axi.enable = true; modules.agents.tools.gitea-axi.enable = true;

53
lib.nix
View File

@@ -123,6 +123,14 @@ let
# below would otherwise resolve silently in the secret's favour. # below would otherwise resolve silently in the secret's favour.
mountCollisions = lib.attrNames (builtins.intersectAttrs userMounts secretMounts); 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. # 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. # 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. # 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. 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 { 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") { 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 # The guest gets its own network namespace, so its services — its own
# sshd included — never contend with the host's. # sshd included — never contend with the host's.