refactor: create glue/ and variants/ directories

- Add glue/ for fleet generation logic and common configuration
- Add variants/ for hardware type modules
- Improves separation of concerns and module organization
This commit is contained in:
UGA Innovation Factory
2026-01-06 18:31:40 -05:00
parent 03f532e867
commit cb37fad70e
12 changed files with 1234 additions and 0 deletions

137
glue/boot.nix Normal file
View File

@@ -0,0 +1,137 @@
# ============================================================================
# Boot & Storage Configuration
# ============================================================================
# This module defines:
# - Disko partition layout (EFI, swap, root)
# - Bootloader configuration (systemd-boot with Plymouth)
# - Filesystem options (device, swap size)
# - Build method options (used by installer/artifacts.nix)
# - Convenience options (forUser, useHostPrefix)
{ config, lib, ... }:
{
options.athenix = {
host = {
useHostPrefix = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to prepend the host prefix to the hostname (used in inventory and hosts/default.nix).";
};
filesystem = {
device = lib.mkOption {
type = lib.types.str;
description = "The main disk device to use for installation.";
};
useSwap = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to create and use a swap partition.";
};
swapSize = lib.mkOption {
type = lib.types.str;
description = "The size of the swap partition.";
};
};
};
};
config = {
# ========== Disk Partitioning (Disko) ==========
disko.enableConfig = lib.mkDefault true;
disko.devices = {
disk.main = {
type = "disk";
device = config.athenix.host.filesystem.device;
content = {
type = "gpt";
partitions = {
# EFI System Partition
ESP = {
name = "ESP";
label = "BOOT";
size = "1G";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
extraArgs = [
"-n"
"BOOT"
];
};
};
# Swap Partition (size configurable per host)
swap = lib.mkIf config.athenix.host.filesystem.useSwap {
name = "swap";
label = "swap";
size = config.athenix.host.filesystem.swapSize;
content = {
type = "swap";
};
};
# Root Partition (takes remaining space)
root = {
name = "root";
label = "root";
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
extraArgs = [
"-L"
"ROOT"
];
};
};
};
};
};
};
# Bootloader Configuration
boot = {
loader.systemd-boot.enable = true;
loader.efi.canTouchEfiVariables = true;
plymouth.enable = true;
# Enable "Silent boot"
consoleLogLevel = 3;
initrd.verbose = false;
# Hide the OS choice for bootloaders.
# It's still possible to open the bootloader list by pressing any key
# It will just not appear on screen unless a key is pressed
loader.timeout = lib.mkDefault 0;
};
# Set your time zone.
time.timeZone = "America/New_York";
# Select internationalisation properties.
i18n.defaultLocale = "en_US.UTF-8";
i18n.extraLocaleSettings = {
LC_ADDRESS = "en_US.UTF-8";
LC_IDENTIFICATION = "en_US.UTF-8";
LC_MEASUREMENT = "en_US.UTF-8";
LC_MONETARY = "en_US.UTF-8";
LC_NAME = "en_US.UTF-8";
LC_NUMERIC = "en_US.UTF-8";
LC_PAPER = "en_US.UTF-8";
LC_TELEPHONE = "en_US.UTF-8";
LC_TIME = "en_US.UTF-8";
};
systemd.sleep.extraConfig = ''
SuspendState=freeze
HibernateDelaySec=2h
'';
};
}

99
glue/common.nix Normal file
View File

@@ -0,0 +1,99 @@
# ============================================================================
# Common Host Module
# ============================================================================
# This module contains all the common configuration shared by all host types.
# It is automatically imported by the fleet generator for every host.
{ inputs }:
{
config,
lib,
...
}:
{
imports = [
./boot.nix
./user-config.nix
../sw
../users.nix
inputs.home-manager.nixosModules.home-manager
inputs.agenix.nixosModules.default
inputs.disko.nixosModules.disko
];
# Define garbage collection options here since they're consumed in this module
options.athenix = {
forUser = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Convenience option to configure a host for a specific user.
Automatically enables the user (sets athenix.users.username.enable = true).
Value should be a username from athenix.users.accounts.
'';
};
system.gc = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to enable automatic garbage collection.";
};
frequency = lib.mkOption {
type = lib.types.str;
default = "weekly";
description = "How often to run garbage collection (systemd timer format).";
};
retentionDays = lib.mkOption {
type = lib.types.int;
default = 30;
description = "Number of days to keep old generations before deletion.";
};
optimise = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to automatically optimize the Nix store.";
};
};
host.buildMethods = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ "installer-iso" ];
description = ''
List of allowed build methods for this host (used by installer/artifacts.nix).
Supported methods:
- "installer-iso": Generates an auto-install ISO that installs this configuration to disk.
- "iso": Generates a live ISO (using nixos-generators).
- "ipxe": Generates iPXE netboot artifacts (kernel, initrd, script).
- "lxc": Generates an LXC container tarball.
- "proxmox": Generates a Proxmox VMA archive.
'';
};
};
config = lib.mkMerge [
# Enable forUser if specified
(lib.mkIf (config.athenix.forUser != null) {
athenix.users.${config.athenix.forUser}.enable = true;
})
{
system.stateVersion = "25.11";
nix.settings.experimental-features = [
"nix-command"
"flakes"
];
# Automatic Garbage Collection
nix.gc = lib.mkIf config.athenix.system.gc.enable {
automatic = true;
dates = config.athenix.system.gc.frequency;
options = "--delete-older-than ${toString config.athenix.system.gc.retentionDays}d";
};
# Optimize storage
nix.optimise.automatic = config.athenix.system.gc.optimise;
}
];
}

278
glue/fleet.nix Normal file
View File

@@ -0,0 +1,278 @@
{
inputs,
fleet ? import ../inventory.nix,
...
}:
# ============================================================================
# Fleet Generator
# ============================================================================
# This file contains the logic to generate NixOS configurations for all hosts
# defined in inventory.nix. It supports both hostname-based and count-based
# configurations with flexible type associations.
let
nixpkgs = inputs.nixpkgs;
lib = nixpkgs.lib;
# Load all available host types from hosts/
hostTypes = import ../variants { inherit inputs; };
# Helper to create a single NixOS system configuration
mkHost =
{
hostName,
system ? "x86_64-linux",
hostType,
configOverrides ? { },
externalModuleThunk ? null,
}:
let
# Lazy evaluation: only fetch external module when building this host
externalModulePath =
if externalModuleThunk != null then
let
# Force evaluation of the thunk (fetchGit, fetchTarball, etc.)
fetchedPath = externalModuleThunk;
# Extract outPath from fetchGit/fetchTarball results
extractedPath =
if builtins.isAttrs fetchedPath && fetchedPath ? outPath then fetchedPath.outPath else fetchedPath;
in
if builtins.isPath extractedPath then
extractedPath + "/default.nix"
else if lib.isDerivation extractedPath then
extractedPath + "/default.nix"
else
extractedPath + "/default.nix"
else
null;
# Load users.nix to find external user modules
pkgs = nixpkgs.legacyPackages.${system};
usersData = import ../users.nix { inherit pkgs; };
accounts = usersData.athenix.users or { };
# Build a map of user names to their nixos module paths (if they exist)
# We'll use this to conditionally import modules based on user.enable
userNixosModulePaths = lib.filterAttrs (_: v: v != null) (
lib.mapAttrs (
name: user:
if (user ? external && user.external != null) then
let
externalPath =
if builtins.isAttrs user.external && user.external ? outPath then
user.external.outPath
else
user.external;
nixosModulePath = externalPath + "/nixos.nix";
in
if
(builtins.isPath externalPath || (builtins.isString externalPath && lib.hasPrefix "/" externalPath))
&& builtins.pathExists nixosModulePath
then
nixosModulePath
else
null
else
null
) accounts
);
# Create conditional wrapper modules for each user's nixos.nix
# Each wrapper checks if the user is enabled before applying the module content
userNixosModules = lib.mapAttrsToList (
name: modulePath:
{
config,
lib,
pkgs,
...
}@args:
let
# Import the user's nixos module - it returns a function or attrset
importedModuleFunc = import modulePath { inherit inputs; };
# If it's a function, call it with the module args; otherwise use as-is
importedModule =
if lib.isFunction importedModuleFunc then importedModuleFunc args else importedModuleFunc;
in
{
config = lib.mkIf (config.athenix.users.${name}.enable or false) importedModule;
}
) userNixosModulePaths;
# Get the host type module from the hostTypes attribute set
typeModule =
hostTypes.${hostType}
or (throw "Host type '${hostType}' not found. Available types: ${lib.concatStringsSep ", " (lib.attrNames hostTypes)}");
# External module from fetchGit/fetchurl
externalPathModule =
if externalModulePath != null then import externalModulePath { inherit inputs; } else { };
# Config override module - translate special keys to athenix options
overrideModule =
{ ... }:
let
cleanConfig = lib.removeAttrs configOverrides [
"type"
"count"
"devices"
"overrides"
"defaultCount"
"buildMethods"
];
specialConfig = lib.optionalAttrs (configOverrides ? buildMethods) {
athenix.host.buildMethods = configOverrides.buildMethods;
};
in
{
config = lib.mkMerge [
cleanConfig
specialConfig
];
};
allModules =
userNixosModules
++ [
(import ./common.nix { inherit inputs; })
typeModule
overrideModule
{ networking.hostName = hostName; }
]
++ lib.optional (externalModulePath != null) externalPathModule;
in
{
system = lib.nixosSystem {
inherit system;
specialArgs = { inherit inputs; };
modules = allModules;
};
modules = allModules;
};
# Process inventory entries - top-level keys are always prefixes
processInventory = lib.mapAttrs (
prefix: config:
let
hostType = config.type or prefix;
system = config.system or "x86_64-linux";
devices = config.devices or { };
hasCount = config ? count;
# Helper to generate hostname from prefix and suffix
# Numbers get no dash: "nix-surface1", "nix-surface2"
# Letters get dash: "nix-surface-alpha", "nix-surface-beta"
mkHostName =
prefix: suffix: usePrefix:
if !usePrefix then
suffix
else if lib.match "[0-9]+" suffix != null then
"${prefix}${suffix}" # numeric: no dash
else
"${prefix}-${suffix}"; # non-numeric: add dash
# Extract common overrides and default count
overrides = config.overrides or { };
defaultCount = config.defaultCount or 0;
# If devices is a number, treat it as count
devicesValue = config.devices or { };
actualDevices = if lib.isInt devicesValue then { } else devicesValue;
actualCount = if lib.isInt devicesValue then devicesValue else (config.count or 0);
# Clean base config - remove inventory management keys
baseConfig = lib.removeAttrs config [
"type"
"system"
"count"
"devices"
"overrides"
"defaultCount"
];
# Generate hosts from explicit device definitions
deviceHosts = lib.listToAttrs (
lib.mapAttrsToList (
deviceKey: deviceConfig:
let
# Check if deviceConfig has an 'external' field for lazy evaluation
hasExternalField = builtins.isAttrs deviceConfig && deviceConfig ? external;
# Extract external module thunk if present (don't evaluate yet!)
externalModuleThunk = if hasExternalField then deviceConfig.external else null;
# Remove 'external' from config to avoid conflicts
cleanDeviceConfig =
if hasExternalField then lib.removeAttrs deviceConfig [ "external" ] else deviceConfig;
# Merge: base config -> overrides -> device-specific config
mergedConfig = lib.recursiveUpdate (lib.recursiveUpdate baseConfig overrides) cleanDeviceConfig;
# Check useHostPrefix from the merged config
usePrefix = mergedConfig.athenix.host.useHostPrefix or true;
hostName = mkHostName prefix deviceKey usePrefix;
in
{
name = hostName;
value = mkHost {
inherit
hostName
system
hostType
externalModuleThunk
;
configOverrides = mergedConfig;
};
}
) actualDevices
);
# Generate numbered hosts from count or defaultCount
# If devices are specified, defaultCount fills in the gaps
countToUse = if actualCount > 0 then actualCount else defaultCount;
# Get which numbered keys are already defined in devices
existingNumbers = lib.filter (k: lib.match "[0-9]+" k != null) (lib.attrNames actualDevices);
countHosts =
if countToUse > 0 then
lib.listToAttrs (
lib.filter (x: x != null) (
map (
i:
let
deviceKey = toString i;
# Skip if this number is already in explicit devices
alreadyDefined = lib.elem deviceKey existingNumbers;
in
if alreadyDefined then
null
else
let
hostName = mkHostName prefix deviceKey true;
mergedConfig = lib.recursiveUpdate baseConfig overrides;
in
{
name = hostName;
value = mkHost {
inherit hostName system hostType;
configOverrides = mergedConfig;
};
}
) (lib.range 1 countToUse)
)
)
else
{ };
in
lib.recursiveUpdate deviceHosts countHosts
) fleet;
# Flatten the nested structure
allHosts = lib.foldl' lib.recursiveUpdate { } (lib.attrValues processInventory);
in
{
nixosConfigurations = lib.mapAttrs (n: v: v.system) allHosts;
modules = lib.mapAttrs (n: v: v.modules) allHosts;
}

276
glue/user-config.nix Normal file
View File

@@ -0,0 +1,276 @@
{
pkgs,
config,
lib,
inputs,
...
}:
# ============================================================================
# User Configuration Module
# ============================================================================
# This module defines the schema for user accounts and handles their creation.
# It bridges the gap between the data in 'users.nix' and the actual NixOS
# and Home Manager configuration.
let
# Load users.nix to get account definitions
usersData = import ../users.nix { inherit pkgs; };
accounts = usersData.athenix.users or { };
# Helper: Resolve external module path from fetchGit/fetchTarball/path
resolveExternalPath =
external:
if external == null then
null
else if builtins.isAttrs external && external ? outPath then
external.outPath
else
external;
# Helper: Check if path exists and is valid
isValidPath =
path:
path != null
&& (builtins.isPath path || (builtins.isString path && lib.hasPrefix "/" path))
&& builtins.pathExists path;
# Extract athenix.users options from external user.nix modules
# First, build a cache of options per user from their external user.nix (if any).
externalUserModuleOptions = lib.genAttrs (lib.attrNames accounts) (
name:
let
user = accounts.${name};
externalPath = resolveExternalPath (user.external or null);
userNixPath = if externalPath != null then externalPath + "/user.nix" else null;
in
if isValidPath userNixPath then
let
# Import and evaluate the module with minimal args
outerModule = import userNixPath { inherit inputs; };
evaluatedModule = outerModule {
config = { };
inherit lib pkgs;
osConfig = null;
};
# Extract just the athenix.users.<name> options
athenixUsers = evaluatedModule.athenix.users or { };
in
athenixUsers.${name} or { }
else
{ }
);
# externalUserOptions only contains users that actually have options defined
externalUserOptions = lib.filterAttrs (
_: moduleOptions: moduleOptions != { }
) externalUserModuleOptions;
# Submodule defining the structure of a user account
userSubmodule = lib.types.submodule {
options = {
isNormalUser = lib.mkOption {
type = lib.types.bool;
default = true;
};
description = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
};
extraGroups = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
};
hashedPassword = lib.mkOption {
type = lib.types.str;
default = "!";
};
extraPackages = lib.mkOption {
type = lib.types.listOf lib.types.package;
default = [ ];
};
excludePackages = lib.mkOption {
type = lib.types.listOf lib.types.package;
default = [ ];
};
homePackages = lib.mkOption {
type = lib.types.listOf lib.types.package;
default = [ ];
};
extraImports = lib.mkOption {
type = lib.types.listOf lib.types.path;
default = [ ];
};
external = lib.mkOption {
type = lib.types.nullOr (
lib.types.oneOf [
lib.types.path
lib.types.package
lib.types.attrs
]
);
default = null;
description = ''
External user configuration module. Can be:
- A path to a local module directory
- A fetchGit/fetchTarball result pointing to a repository
The external module can contain:
- user.nix (optional): Sets athenix.users.<name> options AND home-manager config
- nixos.nix (optional): System-level NixOS configuration
Example: builtins.fetchGit { url = "https://github.com/user/dotfiles"; rev = "..."; }
'';
};
opensshKeys = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "List of SSH public keys for the user.";
};
shell = lib.mkOption {
type = lib.types.nullOr lib.types.package;
default = null;
description = "The shell for this user.";
};
editor = lib.mkOption {
type = lib.types.nullOr lib.types.package;
default = null;
description = "The default editor for this user.";
};
useZshTheme = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to apply the system Zsh theme.";
};
useNvimPlugins = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to apply the system Neovim configuration.";
};
enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether this user account is enabled on this system.";
};
};
};
in
{
options.athenix.users = lib.mkOption {
type = lib.types.attrsOf userSubmodule;
default = { };
description = "User accounts configuration. Set enable=true for users that should exist on this system.";
};
config = {
# Merge user definitions from users.nix with options from external user.nix modules
# External options take precedence over users.nix (which uses lib.mkDefault)
athenix.users = lib.mapAttrs (
name: user:
user
// {
description = lib.mkDefault (user.description or null);
shell = lib.mkDefault (user.shell or null);
extraGroups = lib.mkDefault (user.extraGroups or [ ]);
}
// (externalUserOptions.${name} or { })
) accounts;
# Generate NixOS users
users.users =
let
enabledAccounts = lib.filterAttrs (_: user: user.enable) config.athenix.users;
in
lib.mapAttrs (
name: user:
let
isPlasma6 = config.services.desktopManager.plasma6.enable;
defaultPackages = lib.optionals (isPlasma6 && name != "root") [ pkgs.kdePackages.kate ];
finalPackages = lib.subtractLists user.excludePackages (defaultPackages ++ user.extraPackages);
in
{
inherit (user) isNormalUser extraGroups hashedPassword;
description = if user.description != null then user.description else lib.mkDefault "";
openssh.authorizedKeys.keys = user.opensshKeys;
packages = finalPackages;
shell = if user.shell != null then user.shell else pkgs.bash;
}
) enabledAccounts;
# Home Manager configs per user
home-manager = {
useGlobalPkgs = true;
useUserPackages = true;
extraSpecialArgs = {
osConfig = config;
inherit inputs;
};
users =
let
enabledAccounts = lib.filterAttrs (_: user: user.enable) config.athenix.users;
in
lib.mapAttrs (
name: user:
let
# Resolve external module paths
hasExternal = user.external != null;
externalPath = resolveExternalPath user.external;
userNixPath = if externalPath != null then externalPath + "/user.nix" else null;
hasExternalUser = isValidPath userNixPath;
# Import external user.nix for home-manager (filter out athenix.* options)
externalUserModule =
if hasExternalUser then
let
fullModule = import userNixPath { inherit inputs; };
in
# Only pass through non-athenix options to home-manager
{
config,
lib,
pkgs,
osConfig,
...
}:
let
evaluated = fullModule {
inherit
config
lib
pkgs
osConfig
;
};
in
lib.filterAttrs (attrName: _: attrName != "athenix") evaluated
else
{ };
# Common imports based on user flags
commonImports = lib.optional user.useZshTheme ../sw/theme.nix ++ [
(import ../sw/nvim.nix { inherit user; })
];
# Build imports list
allImports = user.extraImports ++ commonImports ++ lib.optional hasExternalUser externalUserModule;
in
lib.mkMerge [
{
imports = allImports;
# Always set these required options
home.username = name;
home.homeDirectory = if name == "root" then "/root" else "/home/${name}";
home.stateVersion = "25.11";
}
(lib.mkIf (!hasExternal) {
# For local users only, add their packages
home.packages = user.homePackages;
})
]
) enabledAccounts;
};
};
}