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

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;
# 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.tools.gitea-axi.enable = true;
modules.agents.pi.enable = true;

87
lib.nix
View File

@@ -32,22 +32,27 @@ let
) (builtins.readDir dir)
);
# Build one host: every module is imported unconditionally (inert until its
# `enable` flag is set), alongside home-manager, chaotic, the shared base, and
# the host's own directory.
# The special arguments every configuration is evaluated with, host and guest
# interior alike.
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 =
{
hostName,
system ? "x86_64-linux",
}:
inputs.nixpkgs.lib.nixosSystem {
inherit system;
specialArgs = {
inherit inputs;
my = self.lib;
};
modules = (collectNixFiles (self + "/modules")) ++ [
inputs.home-manager.nixosModules.home-manager
inherit system specialArgs;
modules =
(collectNixFiles (self + "/modules"))
++ (collectNixFiles (self + "/guests"))
++ [
inputs.chaotic.nixosModules.default
inputs.disko.nixosModules.disko
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.
mkHosts =
hostsDir:
@@ -71,5 +137,6 @@ in
collectNixFiles
mkHost
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 {
type = lib.types.path;
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
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
clients, not keys used to authenticate anyone to a remote server.
@@ -81,10 +95,12 @@ in
};
userKey.sopsFile = lib.mkOption {
type = lib.types.path;
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
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
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;
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
# 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;
mode = "0400";
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.
services.openssh.hostKeys = [ ];
services.openssh.extraConfig = lib.concatMapStrings (
type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n"
) cfg.hostKeys.types;
})
# The primary user is the only account reachable over SSH.
users.users.${user}.openssh.authorizedKeys.keys = cfg.authorizedKeys;
# The client key the primary user offers to remote servers, present only on
# 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
# lives in the user's home to drift from the secret.
@@ -134,5 +164,7 @@ in
enableDefaultConfig = false;
settings."*".IdentityFile = config.sops.secrets.${userKeySecret}.path;
};
};
})
]
);
}

View File

@@ -2,55 +2,17 @@
config,
lib,
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
inherit (lib) mkOption types;
user = config.user;
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
{
options.user = {
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"
];
imports = [ ./base.nix ];
# chaotic's binary cache, so the CachyOS kernel is fetched rather than compiled.
# 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.
sops.secrets.${passwordSecret}.neededForUsers = true;
# Primary user.
# 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";
};
};
};
users.users.${user.name}.hashedPasswordFile = config.sops.secrets.${passwordSecret}.path;
}