feat(guests): bind-mount host pool paths into guests

Add a `mounts` placement option to the guest builder: a Host maps guest
interior paths to host paths, each read-write by default or read-only per
mount, realized as the nested container's bind mounts. A guest sees exactly the
data it should, at any granularity from a single folder to a whole pool.

Pin the container to the host's uid and gid space with `privateUsers = "no"`,
so a guest process writing as the shared storage group lands on a bind-mounted
pool as that same group without permission juggling.
This commit was merged in pull request #32.
This commit is contained in:
2026-07-25 17:53:14 -04:00
parent 78ab95922f
commit ab9b9e9f8f
2 changed files with 82 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
---
spec: guests
blocked-by: [0002-guest-walking-skeleton, 0005-storage-zfs-foundation]
---
## What to build
The Host-side placement that lets a Guest mount shared pool paths at any granularity and write to them without permission errors.
A Host sets `mounts`, a map of guest path to host path, each with a per-mount `readOnly`, defaulting to read-write to match the migration reality that services must write to pools.
Each entry is realized as a bind mount of the nested container, so a Guest sees exactly the data it should — a single folder or a whole pool, read-only or read-write.
Because the container backend uses identity mapping, a guest service that writes as the shared `storage` group lands on the pool as that same group, which is the entire "no permission errors" mechanism and the privileged-container equivalent the operator already trusts from Proxmox.
## Acceptance criteria
- [x] A Host setting `guests.<path>.mounts` bind-mounts each host path at its guest path inside the nested container.
- [x] Each mount honours its per-mount `readOnly`, defaulting to read-write.
- [x] Identity mapping is configured so a guest service writing as the `storage` group lands on the host pool as that same group with no permission error.
- [x] A Host with a Guest that has pool mounts builds, and the resolved bind mounts are verifiable by `nix eval`. Verified by ad-hoc mounts on the sample guest; no host commits mounts (see notes).
## Implementation Notes
`mounts` is a placement option on the `guest` builder: an attribute set keyed by the guest-interior path, each value carrying `hostPath` and a `readOnly` flag that defaults to `false`.
It is realized as the nested container's `bindMounts`, where the container option's `mountPoint` defaults to the attribute key, so the guest path is stated once as the key.
The upstream `bindMounts` `isReadOnly` defaults to read-only, so it is driven explicitly from `readOnly` to make read-write the default here, matching the migration reality that services must write to their pools.
Identity mapping is pinned with `privateUsers = "no"`, which runs the container in the host's uid and gid space one to one.
That is the current NixOS default, but the option documents `"pick"` (a shifting map that would break pool writes) as its recommended value, so the property is pinned rather than left to a default that may drift.
With this mapping and the shared `storage` group from the storage foundation (identical gid on host and guest), a guest process writing as that group lands on a bind-mounted pool as the same group.
The write itself is verified manually on the target host per the spec's testing decisions, since a real identity-mapped ZFS write cannot be reproduced in the build.
No host commits pool mounts.
The repo's only host, `neogaia`, is a laptop with no ZFS pools, so a committed mount would point at a host path that does not exist and would fail the bind at container start.
This mirrors the storage and networking foundations (tasks 0005 and 0003), which verify by ad-hoc enablement rather than committing a placement a host cannot honestly carry.
The realization was verified through `nixosConfigurations.neogaia.extendModules`, setting two mounts on the sample guest: the resolved `containers.sample.bindMounts` carried the right `hostPath`, `mountPoint`, and per-mount `isReadOnly` (read-write and read-only), `privateUsers` resolved to `"no"`, the `storage` gid was identical on host and guest interior, and the full toplevel built.
`nix flake check` passes on the committed tree, where the sample guest declares no mounts.

47
lib.nix
View File

@@ -160,6 +160,43 @@ let
guest takes its address by DHCP, keeping IP management at the router.
'';
};
mounts = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = {
hostPath = lib.mkOption {
type = lib.types.str;
example = "/srv/media";
description = "The path on the host bind-mounted into the guest.";
};
readOnly = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Mount the path read-only. Read-write by default, since a
service must write to the pool data it owns.
'';
};
};
}
);
default = { };
example = lib.literalExpression ''
{
"/data/media" = { hostPath = "/srv/media"; };
"/data/config" = {
hostPath = "/srv/config/jellyfin";
readOnly = true;
};
}
'';
description = ''
Host paths bind-mounted into the guest, keyed by the path they appear
at inside the guest, so a guest sees exactly the data it should at any
granularity a single folder or a whole pool. Each mount is
read-write unless `readOnly` is set.
'';
};
};
config = lib.mkIf cfg.enable {
@@ -189,6 +226,16 @@ let
# a first-class L2 citizen on that segment.
hostBridge = lib.mkIf networked (bridgeName cfg.vlan);
# The container shares the host's uid and gid space one to one.
# A guest process writing as the shared storage group then lands on a bind-mounted pool as that same group, with no permission juggling.
# A private-user mapping would shift the ids and reintroduce those errors, so it stays off.
privateUsers = lib.mkDefault "no";
bindMounts = lib.mapAttrs (_guestPath: m: {
inherit (m) hostPath;
isReadOnly = m.readOnly;
}) cfg.mounts;
inherit specialArgs;
config = {