feat(guests): bind-mount host pool paths into guests #32
35
.claude/tasks/0006-guest-storage-placement.md
Normal file
35
.claude/tasks/0006-guest-storage-placement.md
Normal 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
47
lib.nix
@@ -160,6 +160,43 @@ let
|
|||||||
guest takes its address by DHCP, keeping IP management at the router.
|
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 {
|
config = lib.mkIf cfg.enable {
|
||||||
@@ -189,6 +226,16 @@ let
|
|||||||
# a first-class L2 citizen on that segment.
|
# a first-class L2 citizen on that segment.
|
||||||
hostBridge = lib.mkIf networked (bridgeName cfg.vlan);
|
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;
|
inherit specialArgs;
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
|
|||||||
Reference in New Issue
Block a user