main
  1{
  2  lib,
  3  den,
  4  ...
  5}: {
  6  den.schema.host = {
  7    options = let
  8      # NOTE: Copied from https://github.com/sini/nix-config/blob/1a9f6a8f6ec5f6c324f9f4ce01630703dd6c1eaa/modules/den/schema/host.nix#L121
  9      # TODO: This feature may be merge into den in furture. Check https://github.com/denful/den/pull/563
 10      # Dynamic settings type — recursively discovers aspects that declare .settings.
 11      # Mirrors the aspect tree: den.aspects.disk.zfs-disk-single.settings →
 12      # host.settings.disk.zfs-disk-single.*
 13      settingsType = let
 14        inherit (den.lib.aspects.fx.keyClassification) structuralKeysSet;
 15        classKeys = den.classes or {};
 16        quirkKeys = den.quirks or {};
 17        skipKey = k: k == "settings" || structuralKeysSet ? ${k} || classKeys ? ${k} || quirkKeys ? ${k};
 18
 19        # Settings declarations may be plain option attrsets
 20        # (`{ foo = mkOption {...}; }`) or module-shaped with explicit
 21        # imports/config. Default the module keys so plain attrsets work.
 22        #
 23        # imports'/config' are bound under distinct names on purpose: writing
 24        # `imports = raw.imports or [ ]` here gets rewritten by statix (W04) to
 25        # `inherit (raw) imports`, which DROPS the `or` default and throws when
 26        # raw is a plain options attrset with no imports/config key.
 27        reshapeSettings = raw: let
 28          imports' = raw.imports or [];
 29          config' = raw.config or {};
 30        in {
 31          imports = imports';
 32          config = config';
 33          options = removeAttrs raw [
 34            "imports"
 35            "config"
 36          ];
 37        };
 38
 39        # True if a node has .settings anywhere in its aspect subtree.
 40        hasSettingsDeep = node:
 41          builtins.isAttrs node
 42          && (
 43            (node ? settings)
 44            || lib.any (k: !(skipKey k) && hasSettingsDeep (node.${k} or null)) (builtins.attrNames node)
 45          );
 46
 47        # Build the submodule for one aspect-tree node, mirroring the tree.
 48        # A node may be BOTH an aspect with .settings AND a parent of child
 49        # aspects that have settings (e.g. services.bgp has localAsn settings and
 50        # also parents services.bgp.cilium-bgp). Merge the node's own settings
 51        # options with recursion into its settings-bearing children.
 52        nodeModule = node: let
 53          ownSettings =
 54            if node ? settings
 55            then reshapeSettings node.settings
 56            else {
 57              imports = [];
 58              config = {};
 59              options = {};
 60            };
 61          settingChildren =
 62            lib.filterAttrs (
 63              k: v: !(skipKey k) && builtins.isAttrs v && hasSettingsDeep v
 64            )
 65            node;
 66          childOptions =
 67            lib.mapAttrs (
 68              name: child:
 69                lib.mkOption {
 70                  type = lib.types.submodule (nodeModule child);
 71                  default = {};
 72                  description = "Settings under ${name}";
 73                }
 74            )
 75            settingChildren;
 76          # Distinct names so statix (W04) can't rewrite to
 77          # `inherit (ownSettings) imports`, which would drop the `or` default.
 78          ownImports = ownSettings.imports or [];
 79          ownConfig = ownSettings.config or {};
 80        in {
 81          imports = ownImports;
 82          config = ownConfig;
 83          options = (ownSettings.options or {}) // childOptions;
 84        };
 85      in
 86        lib.types.submodule (nodeModule (den.aspects or {}));
 87    in {
 88      settings = lib.mkOption {
 89        description = "Per-aspect typed settings";
 90        type = settingsType;
 91        default = {};
 92      };
 93
 94      pubKey = lib.mkOption {
 95        description = "Public SSH key for the host.";
 96        type = lib.types.nullOr lib.types.str;
 97        default = null;
 98      };
 99
100      rootHashedPasswordFileAged = lib.mkOption {
101        description = "Age encrypted hashed password file path for host root user.";
102        type = lib.types.nullOr lib.types.path;
103        default = null;
104      };
105
106      address = {
107        ipv4 = {
108          clearText = lib.mkOption {
109            description = "Clear text of public IPv4 address for this host.";
110            type = lib.types.nullOr lib.types.str;
111            default = null;
112          };
113          secret = {
114            name = lib.mkOption {
115              description = "Secret name of public IPv4 address for this host.";
116              type = lib.types.nullOr lib.types.str;
117              default = null;
118            };
119            file = lib.mkOption {
120              description = "Secret file of public IPv4 address for this host.";
121              type = lib.types.nullOr lib.types.path;
122              default = null;
123            };
124          };
125          tailscale = lib.mkOption {
126            description = "Clear text of tailscale IPv4 address for this host.";
127            type = lib.types.nullOr lib.types.str;
128            default = null;
129          };
130        };
131        ipv6 = {
132          clearText = lib.mkOption {
133            description = "Clear text of public IPv6 address for this host.";
134            type = lib.types.nullOr lib.types.str;
135            default = null;
136          };
137          secret = {
138            name = lib.mkOption {
139              description = "Secret name of public IPv6 address for this host.";
140              type = lib.types.nullOr lib.types.str;
141              default = null;
142            };
143            file = lib.mkOption {
144              description = "Secret file of public IPv6 address for this host.";
145              type = lib.types.nullOr lib.types.path;
146              default = null;
147            };
148          };
149          tailscale = lib.mkOption {
150            description = "Clear text of tailscale IPv6 address for this host.";
151            type = lib.types.nullOr lib.types.str;
152            default = null;
153          };
154        };
155      };
156    };
157  };
158}