Add Incus container support to sandboxed workspaces

- Add incus.nix module for fully declarative Incus/LXC containers
- Build NixOS LXC images using nixpkgs.lib.nixosSystem
- Ephemeral containers: recreated on each start, cleaned up on stop
- Use flock to serialize concurrent container operations
- Deterministic MAC addresses via lib.mkMac to prevent ARP cache issues
- Add veth* to NetworkManager unmanaged interfaces
- Update CLAUDE.md with coding conventions and shared lib docs
This commit is contained in:
2026-02-08 14:55:48 -08:00
parent 5178ea6835
commit cf71b74d6f
8 changed files with 252 additions and 13 deletions

View File

@@ -48,9 +48,31 @@ Configuration: `common/sandboxed-workspace/`
- `common/` - Shared NixOS modules for all machines - `common/` - Shared NixOS modules for all machines
- `home/` - Home Manager configurations - `home/` - Home Manager configurations
- `lib/` - Custom lib functions (extends nixpkgs lib, accessible as `lib.*` in modules)
- `machines/` - Per-machine configurations - `machines/` - Per-machine configurations
- `skills/` - Claude Code skills for common tasks - `skills/` - Claude Code skills for common tasks
## Shared Library
Custom utility functions go in `lib/default.nix`. The flake extends `nixpkgs.lib` with these functions, so they're accessible as `lib.functionName` in all modules. Add reusable functions here when used in multiple places.
## Code Comments
Only add comments that provide value beyond what the code already shows:
- Explain *why* something is done, not *what* is being done
- Document non-obvious constraints or gotchas
- Never add filler comments that repeat the code (e.g. `# Start the service` before a start command)
## Bash Commands
Do not redirect stderr to stdout (no `2>&1`). This can hide important output and errors.
Do not use `doas` or `sudo` - they will fail. Ask the user to run privileged commands themselves.
## Nix Commands
Use `--no-link` with `nix build` to avoid creating `result` symlinks in the working directory.
## Git Commits ## Git Commits
Do not add "Co-Authored-By" lines to commit messages. Do not add "Co-Authored-By" lines to commit messages.

View File

@@ -99,6 +99,7 @@ in
"interface-name:${cfg.bridgeName}" "interface-name:${cfg.bridgeName}"
"interface-name:vm-*" "interface-name:vm-*"
"interface-name:ve-*" "interface-name:ve-*"
"interface-name:veth*"
]; ];
# Make systemd-resolved listen on the bridge for workspace DNS queries. # Make systemd-resolved listen on the bridge for workspace DNS queries.

View File

@@ -67,6 +67,7 @@
# Basic system packages # Basic system packages
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
claude-code
kakoune kakoune
vim vim
git git

View File

@@ -12,6 +12,7 @@ in
imports = [ imports = [
./vm.nix ./vm.nix
./container.nix ./container.nix
./incus.nix
]; ];
options.sandboxed-workspace = { options.sandboxed-workspace = {
@@ -21,11 +22,12 @@ in
type = types.attrsOf (types.submodule { type = types.attrsOf (types.submodule {
options = { options = {
type = mkOption { type = mkOption {
type = types.enum [ "vm" "container" ]; type = types.enum [ "vm" "container" "incus" ];
description = '' description = ''
Backend type for this workspace: Backend type for this workspace:
- "vm": microVM with cloud-hypervisor (more isolation, uses virtiofs) - "vm": microVM with cloud-hypervisor (more isolation, uses virtiofs)
- "container": systemd-nspawn container (less overhead, uses bind mounts) - "container": systemd-nspawn via NixOS containers (less overhead, uses bind mounts)
- "incus": Incus/LXD container (unprivileged, better security than NixOS containers)
''; '';
}; };
@@ -102,11 +104,13 @@ in
cfg.workspaces); cfg.workspaces);
# Shell aliases for workspace management # Shell aliases for workspace management
# Service names differ by type: microvm@<name> for VMs, container@<name> for containers
environment.shellAliases = lib.mkMerge (lib.mapAttrsToList environment.shellAliases = lib.mkMerge (lib.mapAttrsToList
(name: ws: (name: ws:
let let
serviceName = if ws.type == "vm" then "microvm@${name}" else "container@${name}"; serviceName =
if ws.type == "vm" then "microvm@${name}"
else if ws.type == "incus" then "incus-workspace-${name}"
else "container@${name}";
in in
{ {
"workspace_${name}" = "ssh googlebot@workspace-${name}"; "workspace_${name}" = "ssh googlebot@workspace-${name}";
@@ -144,7 +148,10 @@ in
(lib.mapAttrs' (lib.mapAttrs'
(name: ws: (name: ws:
let let
serviceName = if ws.type == "vm" then "microvm@${name}" else "container@${name}"; serviceName =
if ws.type == "vm" then "microvm@${name}"
else if ws.type == "incus" then "incus-workspace-${name}"
else "container@${name}";
claudeConfig = builtins.toJSON { claudeConfig = builtins.toJSON {
hasCompletedOnboarding = true; hasCompletedOnboarding = true;
theme = "dark"; theme = "dark";
@@ -187,6 +194,13 @@ in
echo '${claudeConfig}' > /home/googlebot/sandboxed/${name}/claude-config/.claude.json echo '${claudeConfig}' > /home/googlebot/sandboxed/${name}/claude-config/.claude.json
chown googlebot:users /home/googlebot/sandboxed/${name}/claude-config/.claude.json chown googlebot:users /home/googlebot/sandboxed/${name}/claude-config/.claude.json
fi fi
'' + lib.optionalString (ws.type == "incus") ''
# Copy credentials for incus (can't use bind mount for files inside another mount)
if [ -f /home/googlebot/.claude/.credentials.json ]; then
cp /home/googlebot/.claude/.credentials.json /home/googlebot/sandboxed/${name}/claude-config/.credentials.json
chown googlebot:users /home/googlebot/sandboxed/${name}/claude-config/.credentials.json
chmod 600 /home/googlebot/sandboxed/${name}/claude-config/.credentials.json
fi
''; '';
} }
) )

View File

@@ -0,0 +1,173 @@
{ config, lib, pkgs, ... }:
# Incus-specific configuration for sandboxed workspaces
# Creates fully declarative Incus containers from NixOS configurations
with lib;
let
cfg = config.sandboxed-workspace;
hostConfig = config;
incusWorkspaces = filterAttrs (n: ws: ws.type == "incus") cfg.workspaces;
# Build a NixOS LXC image for a workspace
mkContainerImage = name: ws:
let
nixpkgs = hostConfig.inputs.nixpkgs;
containerSystem = nixpkgs.lib.nixosSystem {
modules = [
(import ./base.nix {
inherit hostConfig;
workspaceName = name;
ip = ws.ip;
networkInterface = { Name = "eth0"; };
})
(import ws.config)
({ config, lib, pkgs, ... }: {
nixpkgs.hostPlatform = hostConfig.currentSystem;
boot.isContainer = true;
networking.useHostResolvConf = false;
nixpkgs.config.allowUnfree = true;
})
];
};
in
{
rootfs = containerSystem.config.system.build.images.lxc;
metadata = containerSystem.config.system.build.images.lxc-metadata;
toplevel = containerSystem.config.system.build.toplevel;
};
mkIncusService = name: ws:
let
images = mkContainerImage name ws;
hash = builtins.substring 0 12 (builtins.hashString "sha256" "${images.rootfs}");
imageName = "nixos-workspace-${name}-${hash}";
containerName = "workspace-${name}";
bridgeName = config.networking.sandbox.bridgeName;
mac = lib.mkMac "incus-${name}";
addDevices = ''
incus config device add ${containerName} eth0 nic nictype=bridged parent=${bridgeName} hwaddr=${mac}
incus config device add ${containerName} workspace disk source=/home/googlebot/sandboxed/${name}/workspace path=/home/googlebot/workspace shift=true
incus config device add ${containerName} ssh-keys disk source=/home/googlebot/sandboxed/${name}/ssh-host-keys path=/etc/ssh-host-keys shift=true
incus config device add ${containerName} claude-config disk source=/home/googlebot/sandboxed/${name}/claude-config path=/home/googlebot/claude-config shift=true
'';
in
{
description = "Incus workspace ${name}";
after = [ "incus.service" "incus-preseed.service" "workspace-${name}-setup.service" ];
requires = [ "incus.service" ];
wants = [ "workspace-${name}-setup.service" ];
wantedBy = optional ws.autoStart "multi-user.target";
path = [ config.virtualisation.incus.package pkgs.gnutar pkgs.xz pkgs.util-linux ];
restartTriggers = [ images.rootfs images.metadata ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
set -euo pipefail
# Serialize incus operations - concurrent container creation causes race conditions
exec 9>/run/incus-workspace.lock
flock -x 9
# Import image if not present
if ! incus image list --format csv | grep -q "${imageName}"; then
metadata_tarball=$(echo ${images.metadata}/tarball/*.tar.xz)
rootfs_tarball=$(echo ${images.rootfs}/tarball/*.tar.xz)
incus image import "$metadata_tarball" "$rootfs_tarball" --alias ${imageName}
# Clean up old images for this workspace
incus image list --format csv | grep "nixos-workspace-${name}-" | grep -v "${imageName}" | cut -d, -f2 | while read old_image; do
incus image delete "$old_image" || true
done || true
fi
# Always recreate container for ephemeral behavior
incus stop ${containerName} --force 2>/dev/null || true
incus delete ${containerName} --force 2>/dev/null || true
incus init ${imageName} ${containerName}
${addDevices}
incus start ${containerName}
# Wait for container to start
for i in $(seq 1 30); do
if incus list --format csv | grep -q "^${containerName},RUNNING"; then
exit 0
fi
sleep 1
done
exit 1
'';
preStop = ''
exec 9>/run/incus-workspace.lock
flock -x 9
incus stop ${containerName} --force 2>/dev/null || true
incus delete ${containerName} --force 2>/dev/null || true
# Clean up all images for this workspace
incus image list --format csv 2>/dev/null | grep "nixos-workspace-${name}-" | cut -d, -f2 | while read img; do
incus image delete "$img" 2>/dev/null || true
done
'';
};
in
{
config = mkIf (cfg.enable && incusWorkspaces != { }) {
virtualisation.incus.enable = true;
networking.nftables.enable = true;
virtualisation.incus.preseed = {
storage_pools = [{
name = "default";
driver = "dir";
config = {
source = "/var/lib/incus/storage-pools/default";
};
}];
profiles = [{
name = "default";
config = {
"security.privileged" = "false";
"security.idmap.isolated" = "true";
};
devices = {
root = {
path = "/";
pool = "default";
type = "disk";
};
};
}];
};
systemd.services = mapAttrs'
(name: ws: nameValuePair "incus-workspace-${name}" (mkIncusService name ws))
incusWorkspaces;
# Extra alias for incus shell access (ssh is also available via default.nix aliases)
environment.shellAliases = mkMerge (mapAttrsToList
(name: ws: {
"workspace_${name}_shell" = "doas incus exec workspace-${name} -- su -l googlebot";
})
incusWorkspaces);
};
}

View File

@@ -1,4 +1,4 @@
{ config, lib, ... }: { config, lib, pkgs, ... }:
# VM-specific configuration for sandboxed workspaces using microvm.nix # VM-specific configuration for sandboxed workspaces using microvm.nix
# This module is imported by default.nix for workspaces with type = "vm" # This module is imported by default.nix for workspaces with type = "vm"
@@ -47,6 +47,7 @@ let
# Generate VM configuration for a workspace # Generate VM configuration for a workspace
mkVmConfig = name: ws: { mkVmConfig = name: ws: {
inherit pkgs; # Use host's pkgs (includes allowUnfree)
config = import ws.config; config = import ws.config;
specialArgs = { inputs = hostConfig.inputs; }; specialArgs = { inputs = hostConfig.inputs; };
extraModules = [ extraModules = [
@@ -57,6 +58,21 @@ let
networkInterface = { Type = "ether"; }; networkInterface = { Type = "ether"; };
}) })
{ {
# Copy credentials from host mount to per-workspace config
systemd.services.claude-credentials = {
description = "Copy Claude credentials from host";
wantedBy = [ "multi-user.target" ];
before = [ "multi-user.target" ];
serviceConfig.Type = "oneshot";
script = ''
if [ -f /home/googlebot/.claude-credentials/.credentials.json ]; then
install -m 600 -o googlebot -g users \
/home/googlebot/.claude-credentials/.credentials.json \
/home/googlebot/claude-config/.credentials.json
fi
'';
};
# MicroVM specific configuration # MicroVM specific configuration
microvm = { microvm = {
# Use cloud-hypervisor for better performance # Use cloud-hypervisor for better performance
@@ -100,6 +116,14 @@ let
source = "/home/googlebot/sandboxed/${name}/claude-config"; source = "/home/googlebot/sandboxed/${name}/claude-config";
mountPoint = "/home/googlebot/claude-config"; mountPoint = "/home/googlebot/claude-config";
} }
{
# Credentials-only directory (read-only)
# This directory should contain only .credentials.json
proto = "virtiofs";
tag = "claude-credentials";
source = "/home/googlebot/.claude-credentials";
mountPoint = "/home/googlebot/.claude-credentials";
}
]; ];
# Writeable overlay for /nix/store # Writeable overlay for /nix/store
@@ -111,12 +135,7 @@ let
interfaces = [{ interfaces = [{
type = "tap"; type = "tap";
id = "vm-${name}"; id = "vm-${name}";
# Generate a deterministic MAC from workspace name (02: prefix = locally administered) mac = lib.mkMac "vm-${name}";
mac =
let
hash = builtins.hashString "sha256" name;
in
"02:${builtins.substring 0 2 hash}:${builtins.substring 2 2 hash}:${builtins.substring 4 2 hash}:${builtins.substring 6 2 hash}:${builtins.substring 8 2 hash}";
}]; }];
# Enable vsock for systemd-notify integration # Enable vsock for systemd-notify integration

View File

@@ -53,4 +53,13 @@ with lib;
getElem = x: y: elemAt (elemAt ll y) x; getElem = x: y: elemAt (elemAt ll y) x;
in in
genList (y: genList (x: f x y (getElem x y)) innerSize) outerSize; genList (y: genList (x: f x y (getElem x y)) innerSize) outerSize;
# Generate a deterministic MAC address from a name
# Uses locally administered unicast range (02:xx:xx:xx:xx:xx)
mkMac = name:
let
hash = builtins.hashString "sha256" name;
octets = map (i: builtins.substring i 2 hash) [ 0 2 4 6 8 ];
in
"02:${builtins.concatStringsSep ":" octets}";
} }

View File

@@ -4,7 +4,7 @@
# #
# Add to sandboxed-workspace.workspaces in machines/fry/default.nix: # Add to sandboxed-workspace.workspaces in machines/fry/default.nix:
# sandboxed-workspace.workspaces.test-container = { # sandboxed-workspace.workspaces.test-container = {
# type = "container"; # type = "container" OR "incus";
# config = ./workspaces/test-container.nix; # config = ./workspaces/test-container.nix;
# ip = "192.168.83.50"; # ip = "192.168.83.50";
# }; # };