feat: try rework

This commit is contained in:
Katharina Heidenreich 2026-04-04 11:42:19 +02:00
parent 1ddbd3b8b6
commit ecf10628c3
51 changed files with 1941 additions and 445 deletions

28
validation/README.md Normal file
View file

@ -0,0 +1,28 @@
# Validation Layer
This folder contains **shape/type validation only**.
## Rule of Responsibility
- `validation/*`: syntax checks, required fields, allowed keys, and value types.
- `intermediate/*` and service modules: semantic checks (contradictions/conflicts/business rules).
Examples:
- Shape/type (validation): endpoint has `content` attrset, `port` is int, unknown keys are rejected.
- Semantic (kept outside): `force_ssl = true` with `port = 80`, duplicate routes on same host key, incompatible TLS groupings.
## Files
- `validation/endpoints.nix`
: Validates endpoint schema and content schema for `proxy` and `web`.
- `validation/auto_ssh.nix`
: Validates `devices`/`auto_ssh` shapes and remote port map structure.
- `validation/network_devices.nix`
: Validates local device shapes and DHCP reservation field shapes used by intermediate DHCP/DNS models.
- `validation/storage.nix`
: Validates storage config entry shapes consumed by intermediate storage derivation.
- `validation/secrets.nix`
: Validates keystore entry/reference shapes consumed by config modules before service migration.
- `validation/service/*`
: Validates service-backed config shapes consumed by service modules, such as `kiwix`, `qbittorrent`, and `continuwuity`.
## Usage Pattern
Import validators and run them first, then apply semantic checks locally.

61
validation/auto_ssh.nix Normal file
View file

@ -0,0 +1,61 @@
let
lib = import <nixpkgs/lib>;
getDevices = net:
if net ? devices && builtins.isAttrs net.devices then
net.devices
else
throw "config/network.nix must define devices as an attrset.";
getAutoSshDevices = devices:
lib.filterAttrs (_: device:
if !builtins.isAttrs device then
throw "Every device in config/network.nix.devices must be an attrset."
else if !(device ? type) then
throw "Every device in config/network.nix.devices must define a type."
else
device.type == "auto_ssh"
) devices;
getAutoSshDomains = autoSshDevices:
map (device:
if !(device ? domain) || !builtins.isString device.domain || device.domain == "" then
throw "Every auto_ssh device in config/network.nix must define domain as a non-empty string."
else
device.domain
) (builtins.attrValues autoSshDevices);
getAutoSshConfig = deviceName: device:
if !(device ? auto_ssh) then
throw "Auto SSH device '${deviceName}' is missing required field: auto_ssh."
else if !builtins.isAttrs device.auto_ssh then
throw "Auto SSH device '${deviceName}' field auto_ssh must be an attrset."
else
device.auto_ssh;
getRemotePortMap = device:
if !(device ? auto_ssh) then
[]
else if !builtins.isAttrs device.auto_ssh then
throw "Device auto_ssh must be an attrset when present."
else if !(device.auto_ssh ? remotePortMap) then
[]
else if !builtins.isList device.auto_ssh.remotePortMap then
throw "Device auto_ssh.remotePortMap must be a list of { localPort = int; remotePort = int; }."
else if !lib.all (entry:
builtins.isAttrs entry
&& entry ? localPort
&& entry ? remotePort
&& builtins.isInt entry.localPort
&& builtins.isInt entry.remotePort
) device.auto_ssh.remotePortMap then
throw "Every remotePortMap entry must be { localPort = int; remotePort = int; }."
else
device.auto_ssh.remotePortMap;
isSafeName = name:
builtins.match "^[a-z_][a-z0-9_-]*$" name != null;
in
{
inherit getDevices getAutoSshDevices getAutoSshDomains getAutoSshConfig getRemotePortMap isSafeName;
}

141
validation/endpoints.nix Normal file
View file

@ -0,0 +1,141 @@
let
lib = import <nixpkgs/lib>;
allowedEndpointKeys = [ "type" "domain" "endpoint" "port" "force_ssl" "content" ];
allowedProxyContentKeys = [ "type" "ip" "port" "proxyWebsockets" ];
allowedWebContentKeys = [ "type" "files" ];
allowedWebFileKeys = [ "path" "filePath" "contentType" "status" ];
ensureNoUnknownKeys = context: obj: allowedKeys:
let
unknown = builtins.filter (key: !(lib.elem key allowedKeys)) (builtins.attrNames obj);
in
if unknown != [] then
throw "${context} contains unknown keys: ${builtins.concatStringsSep ", " unknown}"
else
obj;
ensurePath = value:
builtins.isString value
&& builtins.substring 0 1 value == "/";
ensurePort = value:
builtins.isInt value && value > 0 && value <= 65535;
validateEndpointShape = index: endpoint:
let
_ = if !builtins.isAttrs endpoint then throw "Endpoint at index ${toString index} must be an attrset." else null;
__ = ensureNoUnknownKeys "Endpoint at index ${toString index}" endpoint allowedEndpointKeys;
___ =
if endpoint ? forceSsl then
throw "Endpoint at index ${toString index} uses forceSsl. Use force_ssl instead."
else
null;
typeValue =
if endpoint ? type && builtins.isString endpoint.type then
endpoint.type
else
throw "Endpoint at index ${toString index} must define type as a string.";
_type =
if lib.elem typeValue [ "proxy" "web" ] then
null
else
throw "Endpoint at index ${toString index} type must be 'proxy' or 'web'.";
_domain =
if endpoint ? domain && builtins.isString endpoint.domain && endpoint.domain != "" then
null
else
throw "Endpoint at index ${toString index} must define a non-empty domain string.";
_endpoint =
if endpoint ? endpoint && ensurePath endpoint.endpoint then
null
else
throw "Endpoint at index ${toString index} must define endpoint as a path starting with '/'.";
_port =
if endpoint ? port && ensurePort endpoint.port then
null
else
throw "Endpoint at index ${toString index} must define port as int in range 1..65535.";
_forceSsl =
if endpoint ? force_ssl && builtins.isBool endpoint.force_ssl then
null
else
throw "Endpoint at index ${toString index} must define force_ssl as a bool.";
contentValue =
if endpoint ? content && builtins.isAttrs endpoint.content then
endpoint.content
else
throw "Endpoint at index ${toString index} must define content as an attrset.";
_content =
if typeValue == "proxy" then
let
____ = ensureNoUnknownKeys "Proxy content at endpoint index ${toString index}" contentValue allowedProxyContentKeys;
in
if !(contentValue ? type) || !builtins.isString contentValue.type then
throw "Proxy endpoint at index ${toString index} must define content.type as a string."
else if !(contentValue ? ip) || !builtins.isString contentValue.ip || contentValue.ip == "" then
throw "Proxy endpoint at index ${toString index} must define content.ip as a non-empty string."
else if !(contentValue ? port) || !ensurePort contentValue.port then
throw "Proxy endpoint at index ${toString index} must define content.port as int in range 1..65535."
else if !(contentValue ? proxyWebsockets) || !builtins.isBool contentValue.proxyWebsockets then
throw "Proxy endpoint at index ${toString index} must define content.proxyWebsockets as a bool."
else
null
else
let
____ = ensureNoUnknownKeys "Web content at endpoint index ${toString index}" contentValue allowedWebContentKeys;
filesValue =
if contentValue ? files && builtins.isList contentValue.files then
contentValue.files
else
throw "Web endpoint at index ${toString index} must define content.files as a list.";
_____ =
if contentValue ? type && contentValue.type == "store" then
null
else
throw "Web endpoint at index ${toString index} must define content.type = 'store'.";
______ = lib.imap0 (fileIndex: file:
let
_______ =
if builtins.isAttrs file then
null
else
throw "Web endpoint at index ${toString index} file at position ${toString fileIndex} must be an attrset.";
________ = ensureNoUnknownKeys "Web endpoint at index ${toString index} file at position ${toString fileIndex}" file allowedWebFileKeys;
_________ =
if file ? path && builtins.isString file.path && file.path != "" && builtins.substring 0 1 file.path != "/" then
null
else
throw "Web endpoint at index ${toString index} file at position ${toString fileIndex} must define path as a relative path string.";
__________ =
if file ? filePath && (builtins.isString file.filePath || builtins.isPath file.filePath) then
null
else
throw "Web endpoint at index ${toString index} file at position ${toString fileIndex} must define filePath as string or path.";
___________ =
if file ? contentType && builtins.isString file.contentType && file.contentType != "" then
null
else
throw "Web endpoint at index ${toString index} file at position ${toString fileIndex} must define contentType as non-empty string.";
____________ =
if file ? status && builtins.isInt file.status then
null
else
throw "Web endpoint at index ${toString index} file at position ${toString fileIndex} must define status as int.";
in
file
) filesValue;
in
null;
in
endpoint;
validateEndpointsShape = endpoints:
if !builtins.isList endpoints then
throw "config/endpoints.nix must evaluate to a list of endpoint attrsets."
else
lib.imap0 validateEndpointShape endpoints;
in
{
inherit validateEndpointShape validateEndpointsShape;
}

View file

@ -0,0 +1,40 @@
let
lib = import <nixpkgs/lib>;
getDevices = net:
if net ? devices && builtins.isAttrs net.devices then
net.devices
else
throw "config/network.nix must define devices as an attrset.";
getLocalDevices = devices:
lib.filterAttrs (deviceName: device:
if !builtins.isAttrs device then
throw "Device '${deviceName}' must be an attrset."
else if !(device ? type) || !builtins.isString device.type then
throw "Device '${deviceName}' must define type as a string."
else if !(device ? ip) || !builtins.isString device.ip || device.ip == "" then
throw "Device '${deviceName}' must define ip as a non-empty string."
else
device.type == "local"
) devices;
validateReservationShape = deviceName: reservation:
let
_ = if !builtins.isAttrs reservation then throw "Device '${deviceName}' reservation must be an attrset." else null;
_hw =
if reservation ? hw_address && builtins.isString reservation.hw_address && reservation.hw_address != "" then
null
else
throw "Device '${deviceName}' reservation must define hw_address as a non-empty string.";
_host =
if reservation ? hostname && builtins.isString reservation.hostname && reservation.hostname != "" then
null
else
throw "Device '${deviceName}' reservation must define hostname as a non-empty string.";
in
reservation;
in
{
inherit getDevices getLocalDevices validateReservationShape;
}

71
validation/secrets.nix Normal file
View file

@ -0,0 +1,71 @@
let
lib = import <nixpkgs/lib>;
helperNames = [ "getRuntimePath" "getSopsKey" ];
validateLeaf = context: leaf:
if builtins.isString leaf || builtins.isPath leaf then
# String or path: file path (binary or single-value secret)
leaf
else if builtins.isAttrs leaf then
# Object: must have 'file', may have 'keys' list and optional deployment metadata
let
_ =
if leaf ? file && (builtins.isPath leaf.file || builtins.isString leaf.file) then
null
else
throw "${context}.file must be a path or string.";
__ =
if !(leaf ? keys) || (builtins.isList leaf.keys && builtins.all builtins.isString leaf.keys) then
null
else
throw "${context}.keys must be a list of strings when provided.";
___ =
if !(leaf ? path) || builtins.isString leaf.path then
null
else
throw "${context}.path must be a string when provided.";
____ =
if !(leaf ? owner) || builtins.isString leaf.owner then
null
else
throw "${context}.owner must be a string when provided.";
_____ =
if !(leaf ? group) || builtins.isString leaf.group then
null
else
throw "${context}.group must be a string when provided.";
______ =
if !(leaf ? mode) || builtins.isString leaf.mode then
null
else
throw "${context}.mode must be a string when provided.";
in
leaf
else
throw "${context} must be a string, path, or an attrset with 'file' key.";
validateTree = context: node:
if builtins.isAttrs node then
lib.mapAttrs (name: value:
if lib.elem name helperNames then
value
else
validateTree "${context}.${name}" value
) node
else
throw "${context} must be an attrset at non-leaf level.";
getSecretsConfig = secretsConfig:
let
_ =
if builtins.isAttrs secretsConfig then
null
else
throw "config/secrets.nix must evaluate to an attrset.";
__ = validateTree "config/secrets.nix" secretsConfig;
in
secretsConfig;
in
rec {
inherit getSecretsConfig validateLeaf validateTree;
}

View file

@ -0,0 +1,34 @@
let
ensureAttrset = context: value:
if builtins.isAttrs value then
value
else
throw "${context} must be an attrset.";
ensureString = context: value:
if builtins.isString value then
value
else
throw "${context} must be a string.";
ensureNonEmptyString = context: value:
if builtins.isString value && value != "" then
value
else
throw "${context} must be a non-empty string.";
ensureInt = context: value:
if builtins.isInt value then
value
else
throw "${context} must be an int.";
ensureList = context: value:
if builtins.isList value then
value
else
throw "${context} must be a list.";
in
rec {
inherit ensureAttrset ensureString ensureNonEmptyString ensureInt ensureList;
}

View file

@ -0,0 +1,20 @@
let
common = import ./common.nix;
in
rec {
getTrustedServers = serviceData:
let
matrix =
if serviceData ? matrix then
common.ensureAttrset "config/services.nix matrix" serviceData.matrix
else
throw "config/services.nix must define matrix attrset.";
trustedServers =
if matrix ? trusted_servers then
common.ensureList "config/services.nix matrix.trusted_servers" matrix.trusted_servers
else
throw "config/services.nix matrix.trusted_servers must exist.";
in
trustedServers;
}

View file

@ -0,0 +1,36 @@
let
common = import ./common.nix;
in
rec {
getKiwix = serviceData:
let
kiwix =
if serviceData ? kiwix then
common.ensureAttrset "config/services.nix kiwix" serviceData.kiwix
else
throw "config/services.nix must define kiwix attrset.";
rootDir =
if kiwix ? root_dir then
common.ensureNonEmptyString "config/services.nix kiwix.root_dir" kiwix.root_dir
else
throw "config/services.nix kiwix.root_dir must exist.";
webPort =
if kiwix ? port then
common.ensureInt "config/services.nix kiwix.port" kiwix.port
else
throw "config/services.nix kiwix.port must exist.";
zimUrls =
if kiwix ? urls then
common.ensureList "config/services.nix kiwix.urls" kiwix.urls
else
throw "config/services.nix kiwix.urls must exist.";
in
{
root_dir = rootDir;
port = webPort;
urls = zimUrls;
};
}

View file

@ -0,0 +1,51 @@
let
common = import ./common.nix;
in
rec {
getQbittorrent = serviceData:
let
qbt =
if serviceData ? qbittorrent then
common.ensureAttrset "config/services.nix qbittorrent" serviceData.qbittorrent
else
throw "config/services.nix must define qbittorrent attrset.";
webPort =
if qbt ? port then
common.ensureInt "config/services.nix qbittorrent.port" qbt.port
else
throw "config/services.nix qbittorrent.port must exist.";
rootDir =
if qbt ? root_dir then
common.ensureNonEmptyString "config/services.nix qbittorrent.root_dir" qbt.root_dir
else
throw "config/services.nix qbittorrent.root_dir must exist.";
vpn =
if qbt ? vpn then
common.ensureAttrset "config/services.nix qbittorrent.vpn" qbt.vpn
else
throw "config/services.nix qbittorrent.vpn must exist.";
usernameFile =
if vpn ? username_file then
common.ensureNonEmptyString "config/services.nix qbittorrent.vpn.username_file" vpn.username_file
else
throw "config/services.nix qbittorrent.vpn.username_file must exist.";
passwordFile =
if vpn ? password_file then
common.ensureNonEmptyString "config/services.nix qbittorrent.vpn.password_file" vpn.password_file
else
throw "config/services.nix qbittorrent.vpn.password_file must exist.";
in
{
port = webPort;
root_dir = rootDir;
vpn = {
username_file = usernameFile;
password_file = passwordFile;
};
};
}

57
validation/storage.nix Normal file
View file

@ -0,0 +1,57 @@
let
lib = import <nixpkgs/lib>;
validateStorageEntry = name: entry:
let
_ =
if builtins.isAttrs entry then
null
else
throw "config/storage.nix entry '${name}' must be an attrset.";
__ =
if entry ? path && builtins.isString entry.path && entry.path != "" then
null
else
throw "config/storage.nix entry '${name}' must define path as a non-empty string.";
___ =
if entry ? source && builtins.isString entry.source && entry.source != "" then
null
else
throw "config/storage.nix entry '${name}' must define source as a non-empty string.";
____ =
if entry ? type && builtins.isString entry.type && entry.type != "" then
null
else
throw "config/storage.nix entry '${name}' must define type as a non-empty string.";
_____ =
if entry ? options && builtins.isList entry.options then
null
else
throw "config/storage.nix entry '${name}' must define options as a list.";
______ =
if lib.all builtins.isString entry.options then
null
else
throw "config/storage.nix entry '${name}' options must be a list of strings.";
_______ =
if !(entry ? extra) || builtins.isAttrs entry.extra then
null
else
throw "config/storage.nix entry '${name}' field extra must be an attrset when present.";
in
entry;
getStorageConfig = config:
let
_ =
if builtins.isAttrs config then
null
else
throw "config/storage.nix must evaluate to an attrset.";
__ = lib.mapAttrs validateStorageEntry config;
in
config;
in
rec {
inherit getStorageConfig validateStorageEntry;
}

40
validation/web.nix Normal file
View file

@ -0,0 +1,40 @@
let
lib = import <nixpkgs/lib>;
ensureStoreName = name:
if builtins.match "^[A-Za-z_][A-Za-z0-9_-]*$" name != null then
name
else
throw "config/web.nix store name '${name}' is invalid. Use letters, numbers, '_' or '-', starting with a letter or '_' .";
validateStore = name: store:
let
_ = ensureStoreName name;
__ =
if builtins.isAttrs store then
null
else
throw "config/web.nix stores.${name} must be an attrset.";
___ =
if store ? root && builtins.isPath store.root then
null
else
throw "config/web.nix stores.${name}.root must be a Nix path.";
in
store;
getStores = webConfig:
let
stores =
if builtins.isAttrs webConfig && webConfig ? stores && builtins.isAttrs webConfig.stores then
webConfig.stores
else
throw "config/web.nix must define stores as an attrset.";
_ = lib.mapAttrs validateStore stores;
in
stores;
in
rec {
inherit getStores;
}