hive.nix
1# The Colmena hive, built from the inventory in hosts.json. Each entry:
2#
3# "<fqdn>": {
4# "roles": ["moods", "sovrn-metrics", ...], (see `roleModules`)
5# "system": "x86_64-linux" | "aarch64-linux",
6# "nixpkgs": "shared" | "stable", (default "shared")
7# "layout": "standard" | "legacy", (default "standard")
8# "sshHostKey": "ssh-ed25519 AAAA...", (pinned; `just known-hosts`)
9# "ipv4": "<public IPv4>",
10# "ipv6": "<addr>/64" | null,
11#
12# standard layout only (disko + modules/vm.nix + a provider module):
13# "stateVersion": "26.05", (required; never change it)
14# "provider": "hetzner" | "netcup",
15# "disk": "/dev/sda" | "/dev/vda",
16# "ipv4Prefix": 22, "ipv4Gateway": "<IPv4>", (netcup)
17# "settings": { "<project>": { ... } } (per-host project overrides)
18# }
19#
20# Legacy hosts were installed before the fleet (nixos-infect): they import
21# hosts/<fqdn with dashes>/ for hardware, networking and stateVersion
22# instead of the standard layout.
23#
24# The inventory is JSON so recipes can edit it with jq. Flakes only see files
25# the VCS tracks: a new file must be tracked (jj st) before evaluation sees it.
26{ inputs }:
27
28let
29 inherit (inputs.nixpkgs) lib;
30
31 hosts = lib.mapAttrs (name: h: h // { inherit name; }) (
32 builtins.fromJSON (builtins.readFile ./hosts.json)
33 );
34
35 channels = {
36 shared = inputs.nixpkgs;
37 stable = inputs.nixpkgs-stable;
38 };
39 channelOf =
40 host:
41 channels.${host.nixpkgs or "shared"}
42 or (throw "${host.name}: unknown nixpkgs \"${host.nixpkgs}\" in hosts.json (known: ${lib.concatStringsSep ", " (lib.attrNames channels)})");
43
44 # sovrn and moods build their dev shells and VM tests on the nixpkgs they
45 # lock. Hosts run their modules on the fleet's, so the two must be the same
46 # revision or a host would run builds nobody tested.
47 nixpkgsMatches = lib.all (
48 project:
49 let
50 theirs = inputs.${project}.inputs.nixpkgs.rev;
51 in
52 lib.assertMsg (theirs == inputs.nixpkgs.rev) ''
53 ${project} locks nixpkgs ${theirs}, the fleet pins ${inputs.nixpkgs.rev}.
54 Bump them together: `just update-nixpkgs` in ${project}, then
55 `just sync-nixpkgs` here.''
56 ) [ "sovrn" "moods" ];
57
58 # Role name in hosts.json -> NixOS modules. Project roles come from the
59 # projects' flakes; per-host project settings from hosts.json "settings".
60 roleModules = host: {
61 forge = [
62 ./roles/forge
63 { _module.args.pgit = inputs.pgit; }
64 ];
65 moods = [
66 inputs.moods.nixosModules.app
67 { moods.settings = (host.settings or { }).moods or { }; }
68 ];
69 sovrn-metrics = [
70 inputs.sovrn.nixosModules.metrics
71 {
72 sovrn.fleet = (host.settings or { }).sovrn or { };
73 # The DANE watch: every cell's TLSA must match its certificate; the
74 # relays must have none (the backup MX fails open, bug 209274a).
75 sovrn.daneCheck = {
76 hosts = map (h: h.name) (hostsWithRole "sovrn-cell");
77 failOpenHosts = map (h: h.settings.sovrnRelay.hostname) (hostsWithRole "sovrn-relay");
78 };
79 }
80 ];
81 sovrn-cell = [
82 inputs.sovrn.nixosModules.cell
83 (
84 { config, ... }:
85 let
86 cells = map (h: h.name) (hostsWithRole "sovrn-cell");
87 inherit (config.sovrn.fleet) rootHost;
88 in
89 {
90 # Exactly one cell serves sovrn.at (the landing page and its
91 # certificate): sovrn's fleet.rootHost, one value for every cell.
92 assertions = [
93 {
94 assertion = !(((host.settings or { }).sovrn or { }) ? rootHost);
95 message = "${host.name}: settings.sovrn.rootHost in hosts.json would give cells different landing cells; change rootHost in sovrn's nix/fleet.nix instead";
96 }
97 {
98 assertion = lib.elem rootHost cells;
99 message = "sovrn's rootHost ${rootHost} is not a sovrn-cell in hosts.json (cells: ${lib.concatStringsSep ", " cells}): sovrn.at would be served nowhere";
100 }
101 ];
102 }
103 )
104 {
105 sovrn.fleet = (host.settings or { }).sovrn or { };
106 sovrn.cell.pdsAutoProvision = host.pdsAutoProvision or false;
107 # Only the inventory's relays may reach the relay ingress port.
108 sovrn.cell.relaySources = lib.concatMap hostAddresses (hostsWithRole "sovrn-relay");
109 }
110 ];
111 sovrn-relay = [
112 inputs.sovrn.nixosModules.relay
113 {
114 sovrn.fleet = (host.settings or { }).sovrn or { };
115 # hostname (required) and any other sovrn.relay.* from
116 # hosts.json settings.sovrnRelay; the cells it covers are every
117 # sovrn-cell host in the inventory.
118 sovrn.relay = ((host.settings or { }).sovrnRelay or { }) // {
119 cells = map (h: h.name) (hostsWithRole "sovrn-cell");
120 };
121 }
122 ];
123 };
124
125 # sovrn's telemetry edge comes with its metrics and relay roles, which
126 # label their own services by their hostnames (cell and host). On a host
127 # they share with other projects, `cell` says whose the rest is: moods'
128 # (units moods*), else the fleet's; `host` stays the machine.
129 sharedTelemetry =
130 host:
131 let
132 has = role: lib.elem role (host.roles or [ ]);
133 in
134 lib.optional (has "sovrn-metrics" || has "sovrn-relay") {
135 sovrn.telemetry.edge = {
136 cell = "servers";
137 units = lib.optionalAttrs (has "moods") { moods.cell = "moods"; };
138 };
139 };
140
141 hostsWithRole = role: lib.filter (h: lib.elem role (h.roles or [ ])) (lib.attrValues hosts);
142 # A host's public addresses as firewall sources (IPv6 without its prefix
143 # length: the host's own address, not its /64).
144 hostAddresses =
145 h:
146 lib.optional (h ? ipv4) h.ipv4
147 ++ lib.optional ((h.ipv6 or null) != null) (builtins.head (lib.splitString "/" h.ipv6));
148 modulesForRole =
149 host: role:
150 (roleModules host).${role}
151 or (throw "${host.name}: unknown role \"${role}\" in hosts.json (known: ${lib.concatStringsSep ", " (lib.attrNames (roleModules host))})");
152
153 providers = {
154 hetzner = ./modules/hetzner.nix;
155 netcup = ./modules/netcup.nix;
156 };
157 provider =
158 host:
159 let
160 name = host.provider or (throw "${host.name}: standard hosts need \"provider\" in hosts.json");
161 in
162 providers.${name}
163 or (throw "${host.name}: unknown provider \"${name}\" in hosts.json (known: ${lib.concatStringsSep ", " (lib.attrNames providers)})");
164
165 layouts = {
166 # hosts/<fqdn with dashes>/, if present, holds settings for that host only.
167 standard =
168 host:
169 [
170 inputs.disko.nixosModules.disko
171 ./modules/base.nix
172 ./modules/vm.nix
173 (provider host)
174 {
175 system.stateVersion =
176 host.stateVersion or (throw "${host.name}: standard hosts need \"stateVersion\" in hosts.json");
177 }
178 ]
179 ++ lib.optional (builtins.pathExists ./hosts/${dirName host}) ./hosts/${dirName host};
180 # What `nixos-rebuild --flake .#<name>` built before the fleet (minus
181 # agenix since plan 2.4); hosts/<name>/ brings its own hardware,
182 # networking and stateVersion.
183 legacy = host: [ ./hosts/${dirName host} ];
184 };
185 layout =
186 host:
187 let
188 name = host.layout or "standard";
189 in
190 (layouts.${name} or (throw "${host.name}: unknown layout \"${name}\" in hosts.json")) host;
191
192 dirName = host: lib.replaceStrings [ "." ] [ "-" ] host.name;
193
194 modulesFor =
195 host:
196 let
197 nixpkgs = channelOf host;
198 in
199 layout host
200 ++ [
201 ./modules/fleet.nix
202 ./modules/caddy.nix
203 ./modules/shell.nix
204 { fleet.roles = host.roles or [ ]; }
205 ]
206 ++ lib.concatMap (modulesForRole host) (host.roles or [ ])
207 ++ sharedTelemetry host
208 ++ [
209 {
210 # Colmena evaluates nixpkgs outside nixosSystem, which would label the
211 # system "pre-git"; record the locked revision instead (as sovrn's and
212 # moods' hives do).
213 system.nixos.versionSuffix = ".${builtins.substring 0 8 (nixpkgs.lastModifiedDate or "0")}.${nixpkgs.shortRev or "dirty"}";
214 system.nixos.revision = nixpkgs.rev or null;
215 }
216 # Legacy hosts were built by nixosSystem, which also sets the flake
217 # source (nix registry, NIX_PATH). Standard hosts come from Colmena
218 # hives that never did; each keeps what it had.
219 (lib.optionalAttrs ((host.layout or "standard") == "legacy") {
220 nixpkgs.flake.source = nixpkgs.outPath;
221 })
222 ];
223in
224assert nixpkgsMatches;
225{
226 meta = {
227 nixpkgs = import inputs.nixpkgs { system = "x86_64-linux"; };
228 nodeNixpkgs = lib.mapAttrs (_: host: import (channelOf host) { inherit (host) system; }) hosts;
229 nodeSpecialArgs = lib.mapAttrs (
230 _: host:
231 {
232 inherit host;
233 }
234 // lib.optionalAttrs ((host.layout or "standard") == "legacy") {
235 # What the pre-fleet flake passed to hosts/<name>/.
236 flakeRoot = ./.;
237 flakeHostName = dirName host;
238 }
239 ) hosts;
240 };
241}
242// lib.mapAttrs (_: host: {
243 imports = modulesFor host;
244 deployment = {
245 # Connect by name, so the deployer's ~/.ssh/config applies. Needs DNS for
246 # the host; known_hosts pins both name and IP (`just known-hosts`).
247 targetHost = host.name;
248 targetUser = "root";
249 # Build here; aarch64 through binfmt emulation, as in moods.
250 buildOnTarget = false;
251 tags = host.roles or [ ];
252 };
253}) hosts