feat: introduce guests as nested-container definitions #28

Merged
alexion merged 1 commits from task-0002-guest-walking-skeleton into main 2026-07-25 14:37:36 -04:00
8 changed files with 378 additions and 166 deletions
Showing only changes of commit e7d7eb14e1 - Show all commits

View File

@@ -0,0 +1,54 @@
---
spec: guests
blocked-by: 0001-toolkit-bundle
---
## What to build
The tracer bullet for the Guest concept: the thinnest complete path from discovery to a running nested container.
The Auto-loader gains a third kind, discovering every Guest under `guests/` the way it already discovers Modules and Hosts.
The shared base config splits into a host base (`system.nix`, carrying host-only machinery — bootloader, hardware profile, host identity, boot and garbage-collection timers) and a slim guest-base that every nested Guest stands on.
Both keep the primary user, home-manager, and the shared overlays; the guest-base additionally auto-enables the `toolkit` bundle and `modules.ssh`, and imports the full `modules/` tree so any Module is available inside a Guest.
A minimal sample Guest is Module-shaped: it declares its own `guests.<path>` namespace with an `enable` and a `backend` field (default `container`, `microvm` reserved but not built), and guards its body per the Enable convention.
Its body's payload — the one difference from a Module — realizes a nested container running the Guest's interior on the guest-base.
A Host enables the sample Guest exactly as it enables a Module, and the whole thing builds through the existing `nix flake check` seam.
## Acceptance criteria
- [x] The Auto-loader discovers and wires every Guest under `guests/` as a third kind, with no manual `imports` edits, and a Guest's option path mirrors its `guests/` location per the Namespace convention (including the index-node and file-or-folder rules).
- [x] The shared base config is split into a host base and a slim guest-base; the existing Host still builds via `nix flake check` with its host-only machinery intact.
- [x] The guest-base includes the primary user, home-manager, and the shared overlays, auto-enables `toolkit` and `modules.ssh`, and imports the full `modules/` tree.
- [x] A sample Guest declares `guests.<path>.enable` plus a `backend` field defaulting to `container`, guards its body on `enable`, and realizes a nested container running its interior when a Host enables it.
- [x] The `microvm` backend value is accepted as reserved but unimplemented, failing clearly rather than silently building nothing.
- [x] A Host enabling the sample Guest builds via `nix flake check`, the guest is reachable from its Host by `machinectl` with no per-Guest configuration, and the baseline toolset and SSH access are present inside it.
## Implementation Notes
- The base split is realized as three files, not two.
`base.nix` is the shared substrate both bases build on — the primary user, home-manager, the `unstable`/`stable` overlays, and flakes.
`system.nix` (the host base) imports it and adds the host-only machinery (bootloader limit, sops decryption and the password, the maintenance timers, the chaotic cache, console keymap).
`guest.nix` (the guest-base) imports it and adds the slim guest layer.
Factoring the common substrate out keeps "both include the primary user, home-manager, and the shared overlays" a single fact rather than a duplicated one.
- The guest-base imports `sops-nix` and `stylix` alongside the full `modules/` tree.
This is load-bearing, not incidental: the module system pushes an `mkIf` down to the leaves it guards, so an option path a module names must be *declared* even where its `enable` is off.
`modules/ssh.nix` names `sops.*` and the desktop modules name `stylix.*`, so those option namespaces have to exist for the tree to evaluate inside a guest that leaves them disabled.
- `modules/ssh.nix` gained a guest flavor.
`hostKeys.restore` (default on) gates restoring host keys from secrets, and both `hostKeys.sopsFile` and `userKey.sopsFile` are now nullable.
A guest sets `restore = false` and names no sops files, so its daemon self-generates a host key and it carries no age key — verified: the interior's `sops.secrets` is empty and `services.openssh.hostKeys` falls back to the generated defaults, while `neogaia` still restores its committed host keys with `openssh.hostKeys = [ ]`.
- The namespace mirroring is honored by author discipline, exactly as a Module's is: `my.guest { name = "sample"; }` names the option path, and `guests/sample.nix` places it.
The Auto-loader change is the same recursive `collectNixFiles`, so the index-node and file-or-folder rules a folder-shaped guest would use come for free from the loader that already serves Modules — no folder-shaped guest exists yet to exercise them.
- The guest gets `privateNetwork = true` by default, so its interior sshd never contends with the host's on the shared namespace.
It is `mkDefault`, so the networking foundation can later attach the guest to a VLAN bridge.
- `backend` is an `enum [ "container" "microvm" ]` defaulting to `container`.
`microvm` is built by nothing; choosing it trips a build-time assertion with a clear message rather than silently producing no container, per the acceptance criterion and ADR 0006's reserved-value decision.
- `neogaia` enables `guests.sample` to demonstrate the path end to end, the way it demonstrated `modules.toolkit`.
This means a rebuild starts a live nspawn container on the laptop; it is a minimal smoke test and can be turned off with one line.
- `nix flake check` passes and builds the nested `nixos-system-sample` interior in full.
A pre-existing nixvim warning about its own `nixpkgs.follows` now also prints for the guest's reused Neovim config; it is upstream noise, not a defect in this change.

82
base.nix Normal file
View File

@@ -0,0 +1,82 @@
{
config,
lib,
inputs,
...
}:
# The shared foundation both the host base and the guest-base build on: the
# primary user, home-manager, and the fresher/pinned package overlays.
let
inherit (lib) mkOption types;
user = config.user;
# Args to instantiate an extra nixpkgs source on the base platform.
pinArgs = prev: {
inherit (prev.stdenv.hostPlatform) system;
config.allowUnfree = true;
};
in
{
imports = [ inputs.home-manager.nixosModules.home-manager ];
options.user = {
name = mkOption {
type = types.str;
default = "alexion";
description = ''
The primary interactive user this system is built for. Drives both the
system account and the home-manager user in lockstep.
'';
};
description = mkOption {
type = types.str;
default = "Alexion";
description = "Human-readable description (GECOS field) for the primary user.";
};
};
config = {
# Reach fresher packages with `unstable.<name>` or pin with `stable.<name>`.
nixpkgs.overlays = [
(_final: prev: {
unstable = import inputs.nixpkgs-unstable (pinArgs prev);
stable = import inputs.nixpkgs-stable (pinArgs prev);
})
];
nixpkgs.config.allowUnfree = true;
# Flakes, so `nixos-rebuild switch` works from the console and a direnv
# `use flake` resolves inside a guest.
nix.settings.experimental-features = [
"nix-command"
"flakes"
];
# Primary user.
# The wheel group is the way in, since root is locked.
# No password is set here, since that is host-only.
# A guest therefore has none and is reached by SSH key or `machinectl`.
users.users.${user.name} = {
isNormalUser = true;
description = user.description;
extraGroups = [ "wheel" ];
};
# home-manager as a NixOS module: one build produces the system and user
# environment together, sharing the system's pkgs and installing user
# packages into the system profile.
home-manager = {
useGlobalPkgs = true;
useUserPackages = true;
extraSpecialArgs = {
inherit inputs;
my = inputs.self.lib;
};
users.${user.name} = {
home.username = user.name;
home.homeDirectory = "/home/${user.name}";
home.stateVersion = "26.05";
};
};
};
}

31
guest.nix Normal file
View File

@@ -0,0 +1,31 @@
{
my,
inputs,
lib,
...
}:
# The guest-base: the slim foundation every nested guest's interior stands on.
# It imports the full modules tree so any module is available to enable inside a
# guest, and stands on the same shared base a host does.
{
imports = my.collectNixFiles (inputs.self + "/modules") ++ [
(inputs.self + "/base.nix")
# The modules tree reaches for these option namespaces, so they must be
# declared for the tree to evaluate even where a guest leaves them off.
inputs.sops-nix.nixosModules.sops
inputs.stylix.nixosModules.stylix
];
# A nested container has no per-host `default.nix` to pin its release.
system.stateVersion = "26.05";
# The baseline toolset and SSH access, so any guest shelled into is a workable
# environment without per-guest wiring.
modules.toolkit.enable = lib.mkDefault true;
modules.ssh.enable = lib.mkDefault true;
# A guest carries no host identity, so it presents a self-generated host key
# rather than restoring one from secrets.
modules.ssh.hostKeys.restore = lib.mkDefault false;
}

5
guests/sample.nix Normal file
View File

@@ -0,0 +1,5 @@
args@{ my, ... }:
# The tracer-bullet guest: the thinnest complete path from discovery to a
# running nested container. Its interior is just the guest-base — the baseline
# toolset and SSH access — so it proves the concept without carrying a service.
my.guest { name = "sample"; } args

View File

@@ -44,6 +44,10 @@
modules.toolkit.enable = true; modules.toolkit.enable = true;
# The walking-skeleton guest, enabled like any module: proves the guest path
# end to end through this host's `nix flake check`.
guests.sample.enable = true;
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;
modules.agents.pi.enable = true; modules.agents.pi.enable = true;

87
lib.nix
View File

@@ -32,22 +32,27 @@ let
) (builtins.readDir dir) ) (builtins.readDir dir)
); );
# Build one host: every module is imported unconditionally (inert until its # The special arguments every configuration is evaluated with, host and guest
# `enable` flag is set), alongside home-manager, chaotic, the shared base, and # interior alike.
# the host's own directory. specialArgs = {
inherit inputs;
my = self.lib;
};
# Build one host: every module and every guest is imported unconditionally
# (inert until its `enable` flag is set), alongside chaotic, the host base,
# and the host's own directory.
mkHost = mkHost =
{ {
hostName, hostName,
system ? "x86_64-linux", system ? "x86_64-linux",
}: }:
inputs.nixpkgs.lib.nixosSystem { inputs.nixpkgs.lib.nixosSystem {
inherit system; inherit system specialArgs;
specialArgs = { modules =
inherit inputs; (collectNixFiles (self + "/modules"))
my = self.lib; ++ (collectNixFiles (self + "/guests"))
}; ++ [
modules = (collectNixFiles (self + "/modules")) ++ [
inputs.home-manager.nixosModules.home-manager
inputs.chaotic.nixosModules.default inputs.chaotic.nixosModules.default
inputs.disko.nixosModules.disko inputs.disko.nixosModules.disko
inputs.sops-nix.nixosModules.sops inputs.sops-nix.nixosModules.sops
@@ -58,6 +63,67 @@ let
]; ];
}; };
# Build a guest: a module-shaped definition whose body realizes its interior
# as a nested container standing on the guest-base, keyed by its namespace path.
# `name` is the dotted namespace under `guests.` and `interior` is an extra
# module merged into the container alongside the guest-base.
guest =
{
name,
interior ? { },
}:
{ config, lib, ... }:
let
optionPath = [ "guests" ] ++ lib.splitString "." name;
cfg = lib.getAttrFromPath optionPath config;
machineName = lib.replaceStrings [ "." ] [ "-" ] name;
in
{
options = lib.setAttrByPath optionPath {
enable = lib.mkEnableOption "the ${name} guest, run in its own nested container";
backend = lib.mkOption {
type = lib.types.enum [
"container"
"microvm"
];
default = "container";
description = ''
How the guest is realized. `container` runs the guest as a
systemd-nspawn nested container. `microvm` is reserved for a future
hard-isolation backend and is not built yet.
'';
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.backend == "container";
message = ''
guests.${name}.backend = "${cfg.backend}" is not implemented. Only the "container" backend is built; "microvm" is reserved for future work.
'';
}
];
containers.${machineName} = lib.mkIf (cfg.backend == "container") {
autoStart = lib.mkDefault true;
# The guest gets its own network namespace, so its services — its own
# sshd included — never contend with the host's.
privateNetwork = lib.mkDefault true;
inherit specialArgs;
config = {
imports = [
(self + "/guest.nix")
interior
];
};
};
};
};
# Discover every host (a subdirectory of `hostsDir`) and build each one. # Discover every host (a subdirectory of `hostsDir`) and build each one.
mkHosts = mkHosts =
hostsDir: hostsDir:
@@ -71,5 +137,6 @@ in
collectNixFiles collectNixFiles
mkHost mkHost
mkHosts mkHosts
guest
; ;
} }

View File

@@ -53,11 +53,25 @@ in
''; '';
}; };
hostKeys.restore = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Restore the host keys from secrets rather than letting the daemon
generate its own.
A machine with its own identity keeps its fingerprint across a reimage
by restoring committed keys. A guest carries no host identity, so it
turns this off and presents a self-generated key instead.
'';
};
hostKeys.sopsFile = lib.mkOption { hostKeys.sopsFile = lib.mkOption {
type = lib.types.path; type = lib.types.nullOr lib.types.path;
default = null;
description = '' description = ''
Encrypted file holding this host's SSH host private keys, one entry per Encrypted file holding this host's SSH host private keys, one entry per
key type, named `ssh-host-<type>-key`. key type, named `ssh-host-<type>-key`. Required when `restore` is on.
These are the keys the daemon presents to identify itself to connecting These are the keys the daemon presents to identify itself to connecting
clients, not keys used to authenticate anyone to a remote server. clients, not keys used to authenticate anyone to a remote server.
@@ -81,10 +95,12 @@ in
}; };
userKey.sopsFile = lib.mkOption { userKey.sopsFile = lib.mkOption {
type = lib.types.path; type = lib.types.nullOr lib.types.path;
default = null;
description = '' description = ''
Encrypted file holding this machine's SSH client private key, under the Encrypted file holding this machine's SSH client private key, under the
entry `ssh-user-ed25519-key`. entry `ssh-user-ed25519-key`. Left unset on a machine that authenticates
to no remote server, such as a guest.
This is the key the primary user offers to authenticate to a remote This is the key the primary user offers to authenticate to a remote
server, not a key the daemon presents to identify this machine. server, not a key the daemon presents to identify this machine.
@@ -94,36 +110,50 @@ in
}; };
}; };
config = lib.mkIf cfg.enable { config = lib.mkIf cfg.enable (
lib.mkMerge [
{
services.openssh.enable = true; services.openssh.enable = true;
sops.secrets = # The primary user is the only account reachable over SSH.
users.users.${user}.openssh.authorizedKeys.keys = cfg.authorizedKeys;
}
# A machine with its own identity restores its host keys from secrets.
(lib.mkIf cfg.hostKeys.restore {
assertions = [
{
assertion = cfg.hostKeys.sopsFile != null;
message = "modules.ssh.hostKeys.restore requires modules.ssh.hostKeys.sopsFile to name the encrypted host keys.";
}
];
# The daemon reads its host keys once at startup, so a re-key has to # The daemon reads its host keys once at startup, so a re-key has to
# restart it to take effect. # restart it to take effect.
lib.genAttrs (map hostKeySecret cfg.hostKeys.types) (_: { sops.secrets = lib.genAttrs (map hostKeySecret cfg.hostKeys.types) (_: {
inherit (cfg.hostKeys) sopsFile; inherit (cfg.hostKeys) sopsFile;
mode = "0400"; mode = "0400";
restartUnits = [ "sshd.service" ]; restartUnits = [ "sshd.service" ];
}) });
// {
# The primary user is the only account that authenticates with this key,
# and the mode admits no other.
# The client rereads it per connection, so no unit restarts on a re-key.
${userKeySecret} = {
inherit (cfg.userKey) sopsFile;
mode = "0400";
owner = user;
};
};
# An empty list is what stops the daemon generating keys of its own. # An empty list is what stops the daemon generating keys of its own.
services.openssh.hostKeys = [ ]; services.openssh.hostKeys = [ ];
services.openssh.extraConfig = lib.concatMapStrings ( services.openssh.extraConfig = lib.concatMapStrings (
type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n" type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n"
) cfg.hostKeys.types; ) cfg.hostKeys.types;
})
# The primary user is the only account reachable over SSH. # The client key the primary user offers to remote servers, present only on
users.users.${user}.openssh.authorizedKeys.keys = cfg.authorizedKeys; # a machine that has one.
(lib.mkIf (cfg.userKey.sopsFile != null) {
# The primary user is the only account that authenticates with this key,
# and the mode admits no other.
# The client rereads it per connection, so no unit restarts on a re-key.
sops.secrets.${userKeySecret} = {
inherit (cfg.userKey) sopsFile;
mode = "0400";
owner = user;
};
# The client reads the decrypted key where it is written, so no copy of it # The client reads the decrypted key where it is written, so no copy of it
# lives in the user's home to drift from the secret. # lives in the user's home to drift from the secret.
@@ -134,5 +164,7 @@ in
enableDefaultConfig = false; enableDefaultConfig = false;
settings."*".IdentityFile = config.sops.secrets.${userKeySecret}.path; settings."*".IdentityFile = config.sops.secrets.${userKeySecret}.path;
}; };
}; })
]
);
} }

View File

@@ -2,55 +2,17 @@
config, config,
lib, lib,
pkgs, pkgs,
inputs,
... ...
}: }:
# Shared base config carried by every host. # The host base: the host-only machinery a physical machine needs on top of the
# shared base — bootloader, secret decryption, and the maintenance timers.
let let
inherit (lib) mkOption types;
user = config.user; user = config.user;
passwordSecret = "${user.name}-password"; passwordSecret = "${user.name}-password";
# Args to instantiate an extra nixpkgs source on the base platform.
pinArgs = prev: {
inherit (prev.stdenv.hostPlatform) system;
config.allowUnfree = true;
};
in in
{ {
options.user = { imports = [ ./base.nix ];
name = mkOption {
type = types.str;
default = "alexion";
description = ''
The primary interactive user this host is built for. Drives both the
system account and the home-manager user in lockstep.
'';
};
description = mkOption {
type = types.str;
default = "Alexion";
description = "Human-readable description (GECOS field) for the primary user.";
};
};
config = {
# Reach fresher packages with `unstable.<name>` or pin with `stable.<name>`.
# chaotic's overlay is added by its own module, not here.
nixpkgs.overlays = [
(_final: prev: {
unstable = import inputs.nixpkgs-unstable (pinArgs prev);
stable = import inputs.nixpkgs-stable (pinArgs prev);
})
];
nixpkgs.config.allowUnfree = true;
# Flakes, so `nixos-rebuild switch` works from the console.
nix.settings.experimental-features = [
"nix-command"
"flakes"
];
# chaotic's binary cache, so the CachyOS kernel is fetched rather than compiled. # chaotic's binary cache, so the CachyOS kernel is fetched rather than compiled.
# The `extra-` prefix keeps cache.nixos.org alongside it. # The `extra-` prefix keeps cache.nixos.org alongside it.
@@ -109,30 +71,5 @@ in
# That is early enough to precede the account that reads it. # That is early enough to precede the account that reads it.
sops.secrets.${passwordSecret}.neededForUsers = true; sops.secrets.${passwordSecret}.neededForUsers = true;
# Primary user. users.users.${user.name}.hashedPasswordFile = config.sops.secrets.${passwordSecret}.path;
# The wheel group is the way in, since root is locked.
users.users.${user.name} = {
isNormalUser = true;
description = user.description;
extraGroups = [ "wheel" ];
hashedPasswordFile = config.sops.secrets.${passwordSecret}.path;
};
# home-manager as a NixOS module: one `nixos-rebuild switch` builds the
# system and user environment together, sharing the system's pkgs and
# installing user packages into the system profile.
home-manager = {
useGlobalPkgs = true;
useUserPackages = true;
extraSpecialArgs = {
inherit inputs;
my = inputs.self.lib;
};
users.${user.name} = {
home.username = user.name;
home.homeDirectory = "/home/${user.name}";
home.stateVersion = "26.05";
};
};
};
} }