refactor(flake): split assembly code #73
@@ -39,7 +39,6 @@ let
|
||||
inherit
|
||||
inputs
|
||||
lib
|
||||
self
|
||||
specialArgs
|
||||
registryAssembly
|
||||
util
|
||||
|
||||
496
flake/guest.nix
496
flake/guest.nix
@@ -1,158 +1,18 @@
|
||||
{
|
||||
inputs,
|
||||
lib,
|
||||
self,
|
||||
specialArgs,
|
||||
repositoryModules,
|
||||
registryAssembly,
|
||||
util,
|
||||
}:
|
||||
let
|
||||
mkGuestOptions =
|
||||
{
|
||||
mkGuest =
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
name,
|
||||
}:
|
||||
{
|
||||
enable = lib.mkEnableOption "the ${name} guest, run in its own nested container";
|
||||
vlan = lib.mkOption {
|
||||
type = lib.types.ints.between 1 4094;
|
||||
example = 10;
|
||||
description = ''
|
||||
The tagged VLAN this guest lives on. The guest attaches to its host's
|
||||
`br-vlan<id>` bridge for that VLAN. The id must be one of the host's
|
||||
`modules.network.vlans.ids`.
|
||||
'';
|
||||
};
|
||||
mac = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = util.deriveMac "${config.networking.hostName}.${name}";
|
||||
defaultText = lib.literalMD "a stable address derived from the host name and guest option path";
|
||||
example = "bc:24:11:00:00:01";
|
||||
description = ''
|
||||
The guest's MAC address on its VLAN, pinned inside the guest by its
|
||||
own networkd. Set it to reuse an existing address so a router's DHCP
|
||||
reservation keeps working. Left unset, a stable address is derived
|
||||
from the host name and guest option path in the locally-administered range.
|
||||
'';
|
||||
};
|
||||
address = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "10.23.20.126";
|
||||
description = ''
|
||||
Expected IP address assigned outside NixOS for this guest.
|
||||
Left null, this guest contributes no address to the address registry.
|
||||
'';
|
||||
};
|
||||
reverseProxy = {
|
||||
enable = lib.mkEnableOption "a reverse-proxy route for this guest";
|
||||
|
||||
route = lib.mkOption {
|
||||
type = registryAssembly.definitions.route.type;
|
||||
description = "Route registry entry published for this guest.";
|
||||
};
|
||||
};
|
||||
mounts = lib.mkOption {
|
||||
type = lib.types.attrsOf (
|
||||
lib.types.submodule {
|
||||
options = {
|
||||
directory = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/srv/media";
|
||||
description = "Host directory bind-mounted into the guest.";
|
||||
};
|
||||
file = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/run/secrets/service-password";
|
||||
description = "Host file bind-mounted into the guest.";
|
||||
};
|
||||
readOnly = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Mount the host path read-only inside the guest.";
|
||||
};
|
||||
owner = lib.mkOption {
|
||||
type = lib.types.nullOr (
|
||||
lib.types.oneOf [
|
||||
lib.types.str
|
||||
lib.types.ints.positive
|
||||
]
|
||||
);
|
||||
default = null;
|
||||
description = "Owner assigned to the host directory before the guest starts.";
|
||||
};
|
||||
group = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Group assigned to the host directory before the guest starts.";
|
||||
};
|
||||
mode = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "0770";
|
||||
description = "Mode assigned to the host directory before the guest starts.";
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
default = { };
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
"/data/media" = { directory = "/srv/media"; };
|
||||
"/data/config" = { directory = "/srv/config/jellyfin"; };
|
||||
}
|
||||
'';
|
||||
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, a whole pool, or a decrypted secret.
|
||||
Mounts are read-write unless `readOnly` is set.
|
||||
'';
|
||||
};
|
||||
limits = {
|
||||
memory = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "2G";
|
||||
description = ''
|
||||
Cap on the guest's memory, applied to its unit as `MemoryMax`.
|
||||
Accepts systemd size suffixes such as `512M` or `2G`. Left null,
|
||||
the guest's memory is uncapped.
|
||||
'';
|
||||
};
|
||||
cpu = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "150%";
|
||||
description = ''
|
||||
Cap on the guest's CPU, applied to its unit as `CPUQuota`, where
|
||||
`100%` is one full core. Left null, the guest's CPU is uncapped.
|
||||
'';
|
||||
};
|
||||
tasksMax = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.ints.positive;
|
||||
default = null;
|
||||
example = 512;
|
||||
description = ''
|
||||
Cap on the number of processes and threads the guest may spawn,
|
||||
applied to its unit as `TasksMax`. Left null, the task count is
|
||||
uncapped.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
mkGuestConfig =
|
||||
{
|
||||
config,
|
||||
interior,
|
||||
lib,
|
||||
name,
|
||||
interior ? { },
|
||||
}:
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
cfg = config.guests.${name};
|
||||
|
||||
@@ -162,131 +22,247 @@ let
|
||||
lib.mapAttrsToList (
|
||||
_: mount:
|
||||
''
|
||||
${config.system.path}/bin/install -d ${lib.optionalString (mount.owner != null) "-o ${lib.escapeShellArg (toString mount.owner)}"} ${lib.optionalString (mount.group != null) "-g ${lib.escapeShellArg mount.group}"} ${lib.optionalString (mount.mode != null) "-m ${lib.escapeShellArg mount.mode}"} ${lib.escapeShellArg mount.directory}
|
||||
${config.system.path}/bin/install -d \
|
||||
${lib.optionalString (mount.owner != null) "-o ${lib.escapeShellArg (toString mount.owner)}"} \
|
||||
${lib.optionalString (mount.group != null) "-g ${lib.escapeShellArg mount.group}"} \
|
||||
${lib.optionalString (mount.mode != null) "-m ${lib.escapeShellArg mount.mode}"} \
|
||||
${lib.escapeShellArg mount.directory}
|
||||
''
|
||||
) directoryMounts
|
||||
);
|
||||
in
|
||||
{
|
||||
options.guests.${name} = {
|
||||
enable = lib.mkEnableOption "the ${name} guest, run in its own nested container";
|
||||
vlan = lib.mkOption {
|
||||
type = lib.types.ints.between 1 4094;
|
||||
example = 10;
|
||||
description = ''
|
||||
The tagged VLAN this guest lives on. The guest attaches to its host's
|
||||
`br-vlan<id>` bridge for that VLAN. The id must be one of the host's
|
||||
`modules.network.vlans.ids`.
|
||||
'';
|
||||
};
|
||||
mac = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = util.deriveMac "${config.networking.hostName}.${name}";
|
||||
defaultText = lib.literalMD "a stable address derived from the host name and guest option path";
|
||||
example = "bc:24:11:00:00:01";
|
||||
description = ''
|
||||
The guest's MAC address on its VLAN, pinned inside the guest by its
|
||||
own networkd. Set it to reuse an existing address so a router's DHCP
|
||||
reservation keeps working. Left unset, a stable address is derived
|
||||
from the host name and guest option path in the locally-administered range.
|
||||
'';
|
||||
};
|
||||
address = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "10.23.20.126";
|
||||
description = ''
|
||||
Expected IP address assigned outside NixOS for this guest.
|
||||
Left null, this guest contributes no address to the address registry.
|
||||
'';
|
||||
};
|
||||
reverseProxy = {
|
||||
enable = lib.mkEnableOption "a reverse-proxy route for this guest";
|
||||
|
||||
guestNet =
|
||||
{ lib, ... }:
|
||||
{
|
||||
networking.useNetworkd = true;
|
||||
networking.useHostResolvConf = false;
|
||||
|
||||
systemd.network.networks."20-eth0" = {
|
||||
matchConfig.Name = "eth0";
|
||||
linkConfig.MACAddress = cfg.mac;
|
||||
networkConfig.DHCP = "yes";
|
||||
route = lib.mkOption {
|
||||
type = registryAssembly.definitions.route.type;
|
||||
description = "Route registry entry published for this guest.";
|
||||
};
|
||||
};
|
||||
in
|
||||
lib.mkIf cfg.enable {
|
||||
registry.address.${config.networking.hostName}.${name} = lib.mkIf (cfg.address != null) cfg.address;
|
||||
|
||||
registry.route.${config.networking.hostName}.${name} = lib.mkIf cfg.reverseProxy.enable cfg.reverseProxy.route;
|
||||
|
||||
assertions =
|
||||
let
|
||||
hasDirectorySettings = mount: mount.owner != null || mount.group != null || mount.mode != null;
|
||||
hasCompleteDirectorySettings = mount: mount.owner != null && mount.group != null && mount.mode != null;
|
||||
hasOneSource = mount: (mount.directory != null) != (mount.file != null);
|
||||
mountsWithoutOneSource = lib.attrNames (lib.filterAttrs (_: mount: !hasOneSource mount) cfg.mounts);
|
||||
fileMountsWithDirectorySettings = lib.attrNames (
|
||||
lib.filterAttrs (_: mount: mount.file != null && hasDirectorySettings mount) cfg.mounts
|
||||
);
|
||||
incompleteHostDirectoryMounts = lib.attrNames (
|
||||
lib.filterAttrs (_: mount: mount.directory != null && hasDirectorySettings mount && !hasCompleteDirectorySettings mount) cfg.mounts
|
||||
);
|
||||
in
|
||||
[
|
||||
{
|
||||
assertion = mountsWithoutOneSource == [ ];
|
||||
message = ''
|
||||
guests.${name} mounts must set exactly one of directory or file for ${lib.concatStringsSep ", " mountsWithoutOneSource}.
|
||||
'';
|
||||
}
|
||||
{
|
||||
assertion = fileMountsWithDirectorySettings == [ ];
|
||||
message = ''
|
||||
guests.${name} file mounts must not set owner, group, or mode for ${lib.concatStringsSep ", " fileMountsWithDirectorySettings}.
|
||||
'';
|
||||
}
|
||||
{
|
||||
assertion = incompleteHostDirectoryMounts == [ ];
|
||||
message = ''
|
||||
guests.${name} has incomplete host directory settings for ${lib.concatStringsSep ", " incompleteHostDirectoryMounts}. Set owner, group, and mode together.
|
||||
'';
|
||||
}
|
||||
{
|
||||
assertion = lib.elem cfg.vlan config.modules.network.vlans.ids;
|
||||
message = ''
|
||||
guests.${name}.vlan = ${toString cfg.vlan} is not among its host's modules.network.vlans.ids (${lib.concatMapStringsSep ", " toString config.modules.network.vlans.ids}). Declare the VLAN on the host or correct the guest's placement.
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
systemd.services."container@${name}" = lib.mkMerge [
|
||||
{
|
||||
serviceConfig = lib.filterAttrs (_: v: v != null) {
|
||||
MemoryMax = cfg.limits.memory;
|
||||
CPUQuota = cfg.limits.cpu;
|
||||
TasksMax = cfg.limits.tasksMax;
|
||||
};
|
||||
preStart = lib.mkIf (directoryMounts != { }) directoryScript;
|
||||
}
|
||||
(let
|
||||
bridgeDevice = "sys-subsystem-net-devices-${lib.replaceStrings [ "-" ] [ "\\x2d" ] (util.bridgeName cfg.vlan)}.device";
|
||||
in
|
||||
{
|
||||
after = [ bridgeDevice ];
|
||||
wants = [ bridgeDevice ];
|
||||
})
|
||||
];
|
||||
|
||||
containers.${name} = {
|
||||
autoStart = true;
|
||||
privateNetwork = lib.mkDefault true;
|
||||
hostBridge = util.bridgeName cfg.vlan;
|
||||
privateUsers = lib.mkDefault "no";
|
||||
bindMounts = lib.mapAttrs (_: mount: {
|
||||
hostPath = if mount.directory != null then mount.directory else mount.file;
|
||||
isReadOnly = mount.readOnly;
|
||||
}) cfg.mounts;
|
||||
|
||||
inherit specialArgs;
|
||||
|
||||
config = {
|
||||
imports = [
|
||||
repositoryModules
|
||||
registryAssembly.module
|
||||
inputs.sops-nix.nixosModules.sops
|
||||
inputs.stylix.nixosModules.stylix
|
||||
{
|
||||
modules.guest.enable = true;
|
||||
mounts = lib.mkOption {
|
||||
type = lib.types.attrsOf (
|
||||
lib.types.submodule {
|
||||
options = {
|
||||
directory = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/srv/media";
|
||||
description = "Host directory bind-mounted into the guest.";
|
||||
};
|
||||
file = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "/run/secrets/service-password";
|
||||
description = "Host file bind-mounted into the guest.";
|
||||
};
|
||||
readOnly = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Mount the host path read-only inside the guest.";
|
||||
};
|
||||
owner = lib.mkOption {
|
||||
type = lib.types.nullOr (
|
||||
lib.types.oneOf [
|
||||
lib.types.str
|
||||
lib.types.ints.positive
|
||||
]
|
||||
);
|
||||
default = null;
|
||||
description = "Owner assigned to the host directory before the guest starts.";
|
||||
};
|
||||
group = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = "Group assigned to the host directory before the guest starts.";
|
||||
};
|
||||
mode = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "0770";
|
||||
description = "Mode assigned to the host directory before the guest starts.";
|
||||
};
|
||||
};
|
||||
}
|
||||
guestNet
|
||||
interior
|
||||
];
|
||||
);
|
||||
default = { };
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
"/data/media" = { directory = "/srv/media"; };
|
||||
"/data/config" = { directory = "/srv/config/jellyfin"; };
|
||||
}
|
||||
'';
|
||||
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, a whole pool, or a decrypted secret.
|
||||
Mounts are read-write unless `readOnly` is set.
|
||||
'';
|
||||
};
|
||||
limits = {
|
||||
memory = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "2G";
|
||||
description = ''
|
||||
Cap on the guest's memory, applied to its unit as `MemoryMax`.
|
||||
Accepts systemd size suffixes such as `512M` or `2G`. Left null,
|
||||
the guest's memory is uncapped.
|
||||
'';
|
||||
};
|
||||
cpu = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
example = "150%";
|
||||
description = ''
|
||||
Cap on the guest's CPU, applied to its unit as `CPUQuota`, where
|
||||
`100%` is one full core. Left null, the guest's CPU is uncapped.
|
||||
'';
|
||||
};
|
||||
tasksMax = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.ints.positive;
|
||||
default = null;
|
||||
example = 512;
|
||||
description = ''
|
||||
Cap on the number of processes and threads the guest may spawn,
|
||||
applied to its unit as `TasksMax`. Left null, the task count is
|
||||
uncapped.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
mkGuest =
|
||||
{
|
||||
name,
|
||||
interior ? { },
|
||||
}:
|
||||
{ config, lib, ... }:
|
||||
{
|
||||
options.guests.${name} = mkGuestOptions { inherit config lib name; };
|
||||
config = mkGuestConfig {
|
||||
inherit
|
||||
config
|
||||
interior
|
||||
lib
|
||||
name
|
||||
;
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
assertions =
|
||||
let
|
||||
hasDirectorySettings = mount: mount.owner != null || mount.group != null || mount.mode != null;
|
||||
hasCompleteDirectorySettings = mount: mount.owner != null && mount.group != null && mount.mode != null;
|
||||
hasOneSource = mount: (mount.directory != null) != (mount.file != null);
|
||||
mountsWithInvalidSource = lib.attrNames (lib.filterAttrs (_: mount: !hasOneSource mount) cfg.mounts);
|
||||
fileMountsWithHostSettings = lib.attrNames (
|
||||
lib.filterAttrs (_: mount: mount.file != null && hasDirectorySettings mount) cfg.mounts
|
||||
);
|
||||
directoryMountsWithPartialHostSettings = lib.attrNames (
|
||||
lib.filterAttrs (_: mount: mount.directory != null && hasDirectorySettings mount && !hasCompleteDirectorySettings mount) cfg.mounts
|
||||
);
|
||||
in
|
||||
[
|
||||
{
|
||||
assertion = mountsWithInvalidSource == [ ];
|
||||
message = ''
|
||||
guests.${name} mounts ${lib.concatStringsSep ", " mountsWithInvalidSource} must set exactly one of `directory` or `file`.
|
||||
'';
|
||||
}
|
||||
{
|
||||
assertion = fileMountsWithHostSettings == [ ];
|
||||
message = ''
|
||||
guests.${name} file mounts ${lib.concatStringsSep ", " fileMountsWithHostSettings} must not set `owner`, `group`, or `mode`.
|
||||
'';
|
||||
}
|
||||
{
|
||||
assertion = directoryMountsWithPartialHostSettings == [ ];
|
||||
message = ''
|
||||
guests.${name} directory mounts ${lib.concatStringsSep ", " directoryMountsWithPartialHostSettings} must set `owner`, `group`, and `mode` together.
|
||||
'';
|
||||
}
|
||||
{
|
||||
assertion = lib.elem cfg.vlan config.modules.network.vlans.ids;
|
||||
message = ''
|
||||
guests.${name}.vlan = ${toString cfg.vlan} is not among its host's modules.network.vlans.ids (${lib.concatMapStringsSep ", " toString config.modules.network.vlans.ids}). Declare the VLAN on the host or correct the guest's placement.
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
registry.address.${config.networking.hostName}.${name} =
|
||||
lib.mkIf (cfg.address != null) cfg.address;
|
||||
|
||||
registry.route.${config.networking.hostName}.${name} =
|
||||
lib.mkIf cfg.reverseProxy.enable cfg.reverseProxy.route;
|
||||
|
||||
systemd.services."container@${name}" =
|
||||
let
|
||||
bridgeDevice = "sys-subsystem-net-devices-${lib.replaceStrings [ "-" ] [ "\\x2d" ] (util.bridgeName cfg.vlan)}.device";
|
||||
in
|
||||
{
|
||||
after = [ bridgeDevice ];
|
||||
wants = [ bridgeDevice ];
|
||||
|
||||
serviceConfig = lib.filterAttrs (_: v: v != null) {
|
||||
MemoryMax = cfg.limits.memory;
|
||||
CPUQuota = cfg.limits.cpu;
|
||||
TasksMax = cfg.limits.tasksMax;
|
||||
};
|
||||
preStart = lib.mkIf (directoryMounts != { }) directoryScript;
|
||||
};
|
||||
|
||||
containers.${name} = {
|
||||
autoStart = true;
|
||||
privateNetwork = lib.mkDefault true;
|
||||
hostBridge = util.bridgeName cfg.vlan;
|
||||
privateUsers = lib.mkDefault "no";
|
||||
bindMounts = lib.mapAttrs (_: mount: {
|
||||
hostPath = if mount.directory != null then mount.directory else mount.file;
|
||||
isReadOnly = mount.readOnly;
|
||||
}) cfg.mounts;
|
||||
|
||||
inherit specialArgs;
|
||||
|
||||
config = {
|
||||
imports = [
|
||||
inputs.sops-nix.nixosModules.sops
|
||||
inputs.stylix.nixosModules.stylix
|
||||
|
||||
repositoryModules
|
||||
registryAssembly.module
|
||||
interior
|
||||
|
||||
{
|
||||
modules.guest.enable = true;
|
||||
|
||||
networking.useNetworkd = true;
|
||||
networking.useHostResolvConf = false;
|
||||
|
||||
systemd.network.networks."20-eth0" = {
|
||||
matchConfig.Name = "eth0";
|
||||
linkConfig.MACAddress = cfg.mac;
|
||||
networkConfig.DHCP = "yes";
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user