refactor(flake): move assembly code out of lib
This commit is contained in:
100
lib/default.nix
100
lib/default.nix
@@ -1,100 +0,0 @@
|
||||
{
|
||||
lib,
|
||||
inputs,
|
||||
self,
|
||||
}:
|
||||
let
|
||||
inherit (lib)
|
||||
attrNames
|
||||
filterAttrs
|
||||
genAttrs
|
||||
flatten
|
||||
hasSuffix
|
||||
mapAttrsToList
|
||||
;
|
||||
|
||||
registry = import ./registry.nix { inherit lib self; };
|
||||
|
||||
collectNixFiles =
|
||||
dir:
|
||||
flatten (
|
||||
mapAttrsToList (
|
||||
name: type:
|
||||
let
|
||||
path = dir + "/${name}";
|
||||
in
|
||||
if type == "directory" then
|
||||
collectNixFiles path
|
||||
else if type == "regular" && hasSuffix ".nix" name then
|
||||
[ path ]
|
||||
else
|
||||
[ ]
|
||||
) (builtins.readDir dir)
|
||||
);
|
||||
|
||||
moduleFiles = collectNixFiles (self + "/modules");
|
||||
guestFiles = collectNixFiles (self + "/guests");
|
||||
|
||||
specialArgs = {
|
||||
inherit inputs self;
|
||||
my = self.lib;
|
||||
registry = self.lib.registry.collect { hosts = self.nixosConfigurations; };
|
||||
};
|
||||
|
||||
bridgeName = id: "br-vlan${toString id}";
|
||||
|
||||
guests = import ./guests.nix {
|
||||
inherit
|
||||
inputs
|
||||
lib
|
||||
self
|
||||
specialArgs
|
||||
bridgeName
|
||||
moduleFiles
|
||||
registry
|
||||
;
|
||||
};
|
||||
|
||||
mkHost =
|
||||
{
|
||||
hostName,
|
||||
system ? "x86_64-linux",
|
||||
}:
|
||||
inputs.nixpkgs.lib.nixosSystem {
|
||||
inherit system specialArgs;
|
||||
modules =
|
||||
moduleFiles
|
||||
++ guestFiles
|
||||
++ [
|
||||
inputs.chaotic.nixosModules.default
|
||||
inputs.disko.nixosModules.disko
|
||||
inputs.sops-nix.nixosModules.sops
|
||||
inputs.stylix.nixosModules.stylix
|
||||
registry.module
|
||||
(self + "/hosts/${hostName}")
|
||||
{
|
||||
networking.hostName = hostName;
|
||||
modules.host.enable = true;
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
mkHosts =
|
||||
hostsDir:
|
||||
let
|
||||
hostNames = attrNames (filterAttrs (_name: type: type == "directory") (builtins.readDir hostsDir));
|
||||
in
|
||||
genAttrs hostNames (hostName: mkHost { inherit hostName; });
|
||||
in
|
||||
{
|
||||
inherit
|
||||
collectNixFiles
|
||||
mkHost
|
||||
mkHosts
|
||||
bridgeName
|
||||
registry
|
||||
;
|
||||
|
||||
inherit (guests) deriveMac guest;
|
||||
|
||||
}
|
||||
370
lib/guests.nix
370
lib/guests.nix
@@ -1,370 +0,0 @@
|
||||
{
|
||||
inputs,
|
||||
lib,
|
||||
self,
|
||||
specialArgs,
|
||||
bridgeName,
|
||||
moduleFiles,
|
||||
registry,
|
||||
}:
|
||||
let
|
||||
inherit (lib) escapeShellArg mapAttrsToList;
|
||||
|
||||
deriveMac =
|
||||
name:
|
||||
let
|
||||
hash = builtins.hashString "sha256" name;
|
||||
octet = i: builtins.substring (i * 2) 2 hash;
|
||||
in
|
||||
lib.concatStringsSep ":" ([ "02" ] ++ map octet [ 0 1 2 3 4 ]);
|
||||
|
||||
guest =
|
||||
{
|
||||
name,
|
||||
interior ? { },
|
||||
}:
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
optionPath = [ "guests" ] ++ lib.splitString "." name;
|
||||
cfg = lib.getAttrFromPath optionPath config;
|
||||
machineName = lib.replaceStrings [ "." ] [ "-" ] name;
|
||||
|
||||
networked = cfg.vlan != null;
|
||||
|
||||
userMounts = lib.mapAttrs (_guestPath: m: {
|
||||
inherit (m) hostPath;
|
||||
isReadOnly = m.readOnly;
|
||||
}) cfg.mounts;
|
||||
|
||||
secretMounts = lib.listToAttrs (
|
||||
map (
|
||||
name:
|
||||
let
|
||||
path = config.sops.secrets.${name}.path;
|
||||
in
|
||||
lib.nameValuePair path {
|
||||
hostPath = path;
|
||||
isReadOnly = true;
|
||||
}
|
||||
) cfg.secrets
|
||||
);
|
||||
|
||||
mountCollisions = lib.attrNames (builtins.intersectAttrs userMounts secretMounts);
|
||||
|
||||
limitConfig = lib.filterAttrs (_: v: v != null) {
|
||||
MemoryMax = cfg.limits.memory;
|
||||
CPUQuota = cfg.limits.cpu;
|
||||
TasksMax = cfg.limits.tasksMax;
|
||||
};
|
||||
|
||||
createdMounts = lib.filterAttrs (_guestPath: m: m.create != null) cfg.mounts;
|
||||
mountCreateScript = lib.concatStringsSep "\n" (
|
||||
mapAttrsToList (
|
||||
_guestPath: m:
|
||||
let
|
||||
create = m.create;
|
||||
in
|
||||
''
|
||||
${config.system.path}/bin/install -d -o ${escapeShellArg (toString create.owner)} -g ${escapeShellArg create.group} -m ${escapeShellArg create.mode} ${escapeShellArg m.hostPath}
|
||||
''
|
||||
) createdMounts
|
||||
);
|
||||
|
||||
guestNet =
|
||||
{ lib, ... }:
|
||||
{
|
||||
config = lib.mkIf networked {
|
||||
networking.useNetworkd = true;
|
||||
networking.useHostResolvConf = false;
|
||||
|
||||
systemd.network.networks."20-eth0" = {
|
||||
matchConfig.Name = "eth0";
|
||||
linkConfig.MACAddress = cfg.mac;
|
||||
networkConfig.DHCP = "yes";
|
||||
};
|
||||
};
|
||||
};
|
||||
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.
|
||||
'';
|
||||
};
|
||||
vlan = lib.mkOption {
|
||||
type = lib.types.nullOr (lib.types.ints.between 1 4094);
|
||||
default = null;
|
||||
example = 10;
|
||||
description = ''
|
||||
The tagged VLAN this guest lives on. The guest attaches to its host's
|
||||
`br-vlan<id>` bridge for that VLAN. Left null, the guest keeps a
|
||||
private network with no bridge attachment. The id must be one of the
|
||||
host's `modules.network.vlans.ids`.
|
||||
'';
|
||||
};
|
||||
mac = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = deriveMac name;
|
||||
defaultText = lib.literalMD "a stable address derived from the guest's namespace 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 guest's namespace 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 = self.lib.registry.definitions.route.type;
|
||||
description = "Route registry entry published for this guest.";
|
||||
};
|
||||
};
|
||||
mounts = lib.mkOption {
|
||||
type = lib.types.attrsOf (
|
||||
lib.types.submodule {
|
||||
options = {
|
||||
hostPath = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "/srv/media";
|
||||
description = "The path on the host bind-mounted into the guest.";
|
||||
};
|
||||
readOnly = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Mount the path read-only. Read-write by default, since a
|
||||
service must write to the pool data it owns.
|
||||
'';
|
||||
};
|
||||
create = lib.mkOption {
|
||||
type = lib.types.nullOr (lib.types.submodule {
|
||||
options = {
|
||||
type = lib.mkOption {
|
||||
type = lib.types.enum [ "directory" ];
|
||||
default = "directory";
|
||||
description = "Host path type to create before the guest starts.";
|
||||
};
|
||||
owner = lib.mkOption {
|
||||
type = lib.types.oneOf [
|
||||
lib.types.str
|
||||
lib.types.ints.positive
|
||||
];
|
||||
description = "Owner assigned to the created host path.";
|
||||
};
|
||||
group = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Group assigned to the created host path.";
|
||||
};
|
||||
mode = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
example = "0770";
|
||||
description = "Mode assigned to the created host path.";
|
||||
};
|
||||
};
|
||||
});
|
||||
default = null;
|
||||
description = ''
|
||||
Opt-in host path creation before the guest starts. Existing
|
||||
paths are left enabled unless this is set, so served storage
|
||||
roots can still be required to exist explicitly.
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
||||
);
|
||||
default = { };
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
"/data/media" = { hostPath = "/srv/media"; };
|
||||
"/data/config" = {
|
||||
hostPath = "/srv/config/jellyfin";
|
||||
readOnly = true;
|
||||
};
|
||||
}
|
||||
'';
|
||||
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 or a whole pool. Each mount is
|
||||
read-write unless `readOnly` is set.
|
||||
'';
|
||||
};
|
||||
secrets = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
example = [ "jellyfin-api-key" ];
|
||||
description = ''
|
||||
Names of the secrets this guest needs. The host is the sole
|
||||
decryptor: it decrypts each named secret from its own sops files and
|
||||
bind-mounts the plaintext file into the guest read-only at
|
||||
`/run/secrets/<name>`, the same path it would occupy on a host, so a
|
||||
service reads its credentials at a predictable location. The guest
|
||||
names the files it wants and receives exactly those. It holds no age
|
||||
key and decrypts nothing itself. Ownership carries across unchanged,
|
||||
since the container maps ids one to one, so a secret owned by a uid on
|
||||
the host is owned by that same uid inside the guest.
|
||||
'';
|
||||
};
|
||||
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.
|
||||
'';
|
||||
};
|
||||
};
|
||||
nesting = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Grant the guest's interior the prerequisites to run Podman or other
|
||||
OCI containers of its own. Off by default, so a guest cannot nest
|
||||
containers. On, the guest's container gains the network-administration
|
||||
capability its container runtime uses to build bridges and firewall
|
||||
rules, along with the tun and fuse device nodes such a runtime reaches
|
||||
for, so the interior's `virtualisation.oci-containers` works with
|
||||
Podman as its default runtime.
|
||||
'';
|
||||
};
|
||||
autoStart = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Start the guest at boot. On by default. Disabled, the guest stays
|
||||
defined and can be started on demand, but does not come up at boot.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
sops.secrets = lib.genAttrs cfg.secrets (_: { });
|
||||
|
||||
registry.address.${config.networking.hostName}.${machineName} = lib.mkIf (cfg.address != null) cfg.address;
|
||||
|
||||
registry.route.${config.networking.hostName}.${machineName} = lib.mkIf cfg.reverseProxy.enable cfg.reverseProxy.route;
|
||||
|
||||
assertions = [
|
||||
{
|
||||
assertion = mountCollisions == [ ];
|
||||
message = ''
|
||||
guests.${name} maps a mount at ${lib.concatStringsSep ", " mountCollisions}, colliding with a secret bind-mounted at the same path. Rename the mount or the secret so each in-guest path is used once.
|
||||
'';
|
||||
}
|
||||
{
|
||||
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.
|
||||
'';
|
||||
}
|
||||
{
|
||||
assertion = !networked || 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@${machineName}" = lib.mkIf (cfg.backend == "container") (
|
||||
lib.mkMerge [
|
||||
{
|
||||
serviceConfig = limitConfig;
|
||||
preStart = lib.mkIf (createdMounts != { }) mountCreateScript;
|
||||
}
|
||||
(lib.mkIf networked (
|
||||
let
|
||||
bridgeDevice = "sys-subsystem-net-devices-${lib.replaceStrings [ "-" ] [ "\\x2d" ] (bridgeName cfg.vlan)}.device";
|
||||
in
|
||||
{
|
||||
after = [ bridgeDevice ];
|
||||
wants = [ bridgeDevice ];
|
||||
}
|
||||
))
|
||||
]
|
||||
);
|
||||
|
||||
containers.${machineName} = lib.mkIf (cfg.backend == "container") {
|
||||
autoStart = cfg.autoStart;
|
||||
privateNetwork = lib.mkDefault true;
|
||||
hostBridge = lib.mkIf networked (bridgeName cfg.vlan);
|
||||
privateUsers = lib.mkDefault "no";
|
||||
additionalCapabilities = lib.optionals cfg.nesting [ "CAP_NET_ADMIN" ];
|
||||
allowedDevices = lib.optionals cfg.nesting [
|
||||
{
|
||||
node = "/dev/net/tun";
|
||||
modifier = "rwm";
|
||||
}
|
||||
{
|
||||
node = "/dev/fuse";
|
||||
modifier = "rwm";
|
||||
}
|
||||
];
|
||||
|
||||
bindMounts = userMounts // secretMounts;
|
||||
|
||||
inherit specialArgs;
|
||||
|
||||
config = {
|
||||
imports =
|
||||
moduleFiles
|
||||
++ [
|
||||
registry.module
|
||||
inputs.sops-nix.nixosModules.sops
|
||||
inputs.stylix.nixosModules.stylix
|
||||
{
|
||||
modules.guest.enable = true;
|
||||
}
|
||||
guestNet
|
||||
interior
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit deriveMac guest;
|
||||
}
|
||||
216
lib/registry.nix
216
lib/registry.nix
@@ -1,216 +0,0 @@
|
||||
{ lib, self }:
|
||||
let
|
||||
inherit (lib)
|
||||
all
|
||||
concatLists
|
||||
concatMapStringsSep
|
||||
filter
|
||||
filterAttrs
|
||||
foldl'
|
||||
hasAttr
|
||||
hasSuffix
|
||||
mapAttrs
|
||||
mapAttrs'
|
||||
mapAttrsToList
|
||||
mkOption
|
||||
nameValuePair
|
||||
removeSuffix
|
||||
types
|
||||
unique
|
||||
;
|
||||
|
||||
checkType = types.submodule {
|
||||
options = {
|
||||
assertion = mkOption {
|
||||
type = types.functionTo types.bool;
|
||||
description = ''
|
||||
Predicate run against this registry's flattened collected entries.
|
||||
'';
|
||||
};
|
||||
|
||||
message = mkOption {
|
||||
type = types.str;
|
||||
description = ''
|
||||
Error message shown when this registry validation check fails.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
predicates = {
|
||||
uniqueValue = value: entries: builtins.length (unique (map value entries)) == builtins.length entries;
|
||||
};
|
||||
|
||||
registryLib = lib // {
|
||||
registry = (lib.registry or { }) // {
|
||||
inherit predicates;
|
||||
};
|
||||
};
|
||||
|
||||
registryDefinitionModule = {
|
||||
options = {
|
||||
type = mkOption {
|
||||
type = types.raw;
|
||||
description = ''
|
||||
NixOS option type for one registry entry.
|
||||
'';
|
||||
};
|
||||
|
||||
replicate = mkOption {
|
||||
type = types.bool;
|
||||
default = false;
|
||||
description = ''
|
||||
Whether collected entries are merged and published under every host key.
|
||||
'';
|
||||
};
|
||||
|
||||
checks = mkOption {
|
||||
type = types.listOf checkType;
|
||||
default = [ ];
|
||||
description = ''
|
||||
Validation checks for this registry's flattened collected entries.
|
||||
'';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
registryName = fileName: removeSuffix ".nix" fileName;
|
||||
|
||||
registryOption = registry: mkOption {
|
||||
type = types.attrsOf (types.attrsOf registry.type);
|
||||
default = { };
|
||||
description = ''
|
||||
Lazily collected fleet-visible registry entries, nested by host and entry name.
|
||||
'';
|
||||
};
|
||||
|
||||
mkRegistry = path:
|
||||
let
|
||||
evaluated = lib.evalModules {
|
||||
specialArgs.lib = registryLib;
|
||||
modules = [
|
||||
registryDefinitionModule
|
||||
path
|
||||
];
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit (evaluated.config) type replicate checks;
|
||||
};
|
||||
|
||||
definitions = mapAttrs' (
|
||||
name: type:
|
||||
let
|
||||
path = self + "/registry/${name}";
|
||||
in
|
||||
nameValuePair (registryName name) (mkRegistry path)
|
||||
) (filterAttrs (name: type: type == "regular" && hasSuffix ".nix" name) (builtins.readDir (self + "/registry")));
|
||||
|
||||
flattenRegistryEntries = registryName: data:
|
||||
concatLists (
|
||||
mapAttrsToList (
|
||||
host: entries:
|
||||
mapAttrsToList (entry: value: {
|
||||
path = "registry.${registryName}.${host}.${entry}";
|
||||
inherit host entry value;
|
||||
}) entries
|
||||
) data
|
||||
);
|
||||
|
||||
rawDeclarations = registryName: hosts:
|
||||
mapAttrs (_hostName: host: host.config.registry.${registryName} or { }) hosts;
|
||||
|
||||
replicatedEntries = registryName: raw:
|
||||
let
|
||||
entries = concatLists (
|
||||
concatLists (
|
||||
mapAttrsToList (
|
||||
_evalHost: hostDeclarations:
|
||||
mapAttrsToList (
|
||||
hostName: declarations:
|
||||
mapAttrsToList (entryName: value: {
|
||||
path = "registry.${registryName}.${hostName}.${entryName}";
|
||||
inherit entryName value;
|
||||
}) declarations
|
||||
) hostDeclarations
|
||||
) raw
|
||||
)
|
||||
);
|
||||
in
|
||||
foldl' (
|
||||
acc: entry:
|
||||
if hasAttr entry.entryName acc then
|
||||
if acc.${entry.entryName}.value == entry.value then
|
||||
acc
|
||||
else
|
||||
throw "conflicting replicated registry entry ${entry.path} does not match ${acc.${entry.entryName}.path}"
|
||||
else
|
||||
acc // {
|
||||
${entry.entryName} = entry;
|
||||
}
|
||||
) { } entries;
|
||||
|
||||
replicatedData = registryName: hosts: raw:
|
||||
let
|
||||
entries = mapAttrs (_entryName: entry: entry.value) (replicatedEntries registryName raw);
|
||||
in
|
||||
mapAttrs (_hostName: _host: entries) hosts;
|
||||
|
||||
validationAssertions = data:
|
||||
concatLists (
|
||||
mapAttrsToList (
|
||||
registryName: registry:
|
||||
let
|
||||
entries = flattenRegistryEntries registryName data.${registryName};
|
||||
in
|
||||
map (check: {
|
||||
assertion = check.assertion entries;
|
||||
inherit (check) message;
|
||||
}) registry.checks
|
||||
) definitions
|
||||
);
|
||||
|
||||
assertValid = data:
|
||||
let
|
||||
assertions = validationAssertions data;
|
||||
failures = filter (assertion: !assertion.assertion) assertions;
|
||||
in
|
||||
assert lib.assertMsg (all (assertion: assertion.assertion) assertions) (
|
||||
concatMapStringsSep "\n" (assertion: assertion.message) failures
|
||||
);
|
||||
data;
|
||||
|
||||
join = registry: host: entry:
|
||||
filterAttrs (_name: value: value != null) (
|
||||
mapAttrs (registryName: _registry: (registry.${registryName}.${host} or { }).${entry} or null) definitions
|
||||
);
|
||||
|
||||
collectData =
|
||||
{ hosts }:
|
||||
let
|
||||
raw = mapAttrs (registryName: _registry: rawDeclarations registryName hosts) definitions;
|
||||
data = mapAttrs (
|
||||
registryName: registry:
|
||||
if registry.replicate or false then
|
||||
replicatedData registryName hosts raw.${registryName}
|
||||
else
|
||||
mapAttrs (hostName: host: host.config.registry.${registryName}.${hostName} or { }) hosts
|
||||
) definitions;
|
||||
in
|
||||
assertValid data;
|
||||
|
||||
withOperations = registry:
|
||||
registry
|
||||
// {
|
||||
join = host: entry: join registry host entry;
|
||||
};
|
||||
in
|
||||
{
|
||||
inherit definitions predicates;
|
||||
|
||||
module = {
|
||||
options.registry = mapAttrs (_name: registry: registryOption registry) definitions;
|
||||
};
|
||||
|
||||
collect = args: withOperations (collectData args);
|
||||
}
|
||||
Reference in New Issue
Block a user