From 9b99f81f5f20bde865549912a03ff59ac89b2d7c Mon Sep 17 00:00:00 2001 From: autonomic-bot Date: Mon, 7 Sep 2026 19:56:58 +0000 Subject: [PATCH] nix: export the CI server as nixosModules.cc-ci-server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole server (every service module, the harness tooling, sops wiring, acme-dns) becomes one reusable module, nix/modules/default.nix, so another flake can run cc-ci on a host it defines. First consumer: the cc-ci-orchestrator repo's `#cc-ci` host, which runs the CI server and the orchestrator together on one Hetzner machine. Two things the modules hard-coded become options (nix/modules/options.nix): - cc-ci.publicIPv4 — acme-dns's listen address and ns-acme glue record. - cc-ci.sopsFile — the secrets.yaml path; defaults to the secrets/ submodule, but a consumer that imports cc-ci as a plain input (no private submodule) points it at the deployed --recursive checkout and sops-nix reads it at activation (validateSopsFiles off for that case). The standalone host (nix/hosts/cc-ci-hetzner) now only carries hardware, networking and identity and imports the module via the flake. Verified: the `#cc-ci` system derivation is byte-identical before and after (/nix/store/ckp1244bz86fz3qbx81n5kx60c1lak3m-…531670d.drv on both). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01FqkQq3CDmFWcQ7u1LzoyRz --- flake.nix | 18 +++++++-- nix/hosts/cc-ci-hetzner/configuration.nix | 45 ++++++----------------- nix/modules/acme-dns.nix | 9 +++-- nix/modules/default.nix | 44 ++++++++++++++++++++++ nix/modules/options.nix | 33 +++++++++++++++++ nix/modules/secrets.nix | 9 ++++- 6 files changed, 118 insertions(+), 40 deletions(-) create mode 100644 nix/modules/default.nix create mode 100644 nix/modules/options.nix diff --git a/flake.nix b/flake.nix index 39fb727..898dcd7 100644 --- a/flake.nix +++ b/flake.nix @@ -16,7 +16,7 @@ sops-nix.inputs.nixpkgs.follows = "nixpkgs"; }; - outputs = { nixpkgs, sops-nix, ... }: + outputs = { self, nixpkgs, sops-nix, ... }: let system = "x86_64-linux"; pkgs = nixpkgs.legacyPackages.${system}; @@ -35,13 +35,25 @@ ]; in { + # The whole CI server as one reusable module (nix/modules/default.nix): every service, + # the harness tooling, sops wiring and acme-dns — but no hardware, networking, tailscale + # node, root keys or stateVersion. sops-nix's module comes bundled so a consumer only has + # to import this and set `cc-ci.publicIPv4` (+ `cc-ci.sopsFile` when it is not built from + # a --recursive clone). A consuming flake MUST make its `cc-ci` input follow its own + # `nixpkgs` and `sops-nix`, otherwise two sops-nix module trees collide. + # Consumer: recipe-maintainers/cc-ci-orchestrator `#cc-ci` (CI server + orchestrator on + # one Hetzner host, 2026-09). + nixosModules.cc-ci-server = { + imports = [ sops-nix.nixosModules.sops ./nix/modules ]; + }; + nixosConfigurations = { # Canonical live host target: the Hetzner cc-ci server. # Use `.#cc-ci` for the current production host. cc-ci = nixpkgs.lib.nixosSystem { inherit system; modules = [ - sops-nix.nixosModules.sops + self.nixosModules.cc-ci-server ./nix/hosts/cc-ci-hetzner/configuration.nix ]; }; @@ -61,7 +73,7 @@ cc-ci-hetzner = nixpkgs.lib.nixosSystem { inherit system; modules = [ - sops-nix.nixosModules.sops + self.nixosModules.cc-ci-server ./nix/hosts/cc-ci-hetzner/configuration.nix ]; }; diff --git a/nix/hosts/cc-ci-hetzner/configuration.nix b/nix/hosts/cc-ci-hetzner/configuration.nix index 3ee791f..a15b089 100644 --- a/nix/hosts/cc-ci-hetzner/configuration.nix +++ b/nix/hosts/cc-ci-hetzner/configuration.nix @@ -1,38 +1,23 @@ -# cc-ci on Hetzner Cloud — NixOS configuration. -# Extends the shared cc-ci modules (same services as the Incus host) with -# Hetzner-specific hardware + networking. Run in parallel with the Incus cc-ci -# host during transition; make this the canonical cc-ci after cutover (plan §7). +# cc-ci on Hetzner Cloud — the canonical STANDALONE CI-server host. +# Hardware + networking + host identity only; every cc-ci service comes from the shared +# `nixosModules.cc-ci-server` module (nix/modules/default.nix), which flake.nix adds to this +# host. The same module builds the combined CI-server + orchestrator host declared in +# recipe-maintainers/cc-ci-orchestrator (`#cc-ci`), which is where cc-ci is moving (2026-09). # # To apply after `terraform apply` + nixos-infect: # git clone --recursive https://git.autonomic.zone/recipe-maintainers/cc-ci.git /etc/cc-ci # install -m600 /var/lib/sops-nix/key.txt -# nixos-rebuild switch --flake /etc/cc-ci#cc-ci-hetzner +# nixos-rebuild switch --flake 'git+file:///etc/cc-ci?submodules=1#cc-ci' { pkgs, ... }: { imports = [ ./hardware.nix ./networking.nix - ../../modules/packages.nix - ../../modules/secrets.nix - ../../modules/acme-dns.nix - ../../modules/swarm.nix - ../../modules/docker-prune.nix - ../../modules/abra.nix - ../../modules/proxy.nix - ../../modules/drone.nix - ../../modules/drone-runner.nix - ../../modules/bridge.nix - ../../modules/dashboard.nix - ../../modules/reports.nix - ../../modules/backupbot.nix - ../../modules/harness.nix - ../../modules/warm-keycloak.nix - ../../modules/nightly-sweep.nix ]; - # Timezone (same as Incus host — see configuration.nix there for rationale). - time.timeZone = "UTC"; - environment.etc."timezone".text = "UTC\n"; + # This host's public address: acme-dns listens on it and publishes it as the ns-acme glue. + cc-ci.publicIPv4 = "91.98.47.73"; + # Built from a --recursive clone, so the sops file is the default (the secrets/ submodule). # Tailscale — keeps the orchestrator→cc-ci access path unchanged (direct peer). # On the Hetzner host the auth key is also seeded via /etc/ts-auth-key. @@ -64,15 +49,9 @@ allowedTCPPorts = [ 22 80 443 ]; }; - # Phase `nixenv`: the Drone exec runner resolves recipe shell-outs from this host PATH - # (PATH=/run/current-system/sw/bin). Reference the SINGLE shared harness tool set - # (pkgs.ccciRuntimeTools — includes git-lfs, openssl, etc.) instead of a hand-maintained list, - # so the Drone path and the harness env (cc-ci-run / sweep) can never diverge. `openssh` is a - # host-only addition (ssh client), not part of the recipe-test tool set. Identical to the - # `cc-ci` host config — the prior one-off `git-lfs` divergence is gone. - environment.systemPackages = pkgs.ccciRuntimeTools ++ [ pkgs.openssh ]; - - nix.settings.experimental-features = [ "nix-command" "flakes" ]; + # The recipe-test tool set (ccciRuntimeTools) is installed by the cc-ci-server module; the ssh + # client is a host-only addition (not part of the recipe-test tool set). + environment.systemPackages = [ pkgs.openssh ]; system.stateVersion = "24.11"; } diff --git a/nix/modules/acme-dns.nix b/nix/modules/acme-dns.nix index b2c5bae..71c1a25 100644 --- a/nix/modules/acme-dns.nix +++ b/nix/modules/acme-dns.nix @@ -3,18 +3,19 @@ # This host is authoritative only for acme.commoninternet.net. Gandi continues # to own commoninternet.net; it delegates this narrow zone and one permanent # _acme-challenge CNAME manually. No Gandi credential is present here. -{ pkgs, ... }: +{ config, pkgs, ... }: let + publicIPv4 = config.cc-ci.publicIPv4; acmeDnsConfig = pkgs.writeText "cc-ci-acme-dns.conf" '' [general] - listen = "91.98.47.73:53" + listen = "${publicIPv4}:53" protocol = "both4" domain = "acme.commoninternet.net" nsname = "ns-acme.commoninternet.net" nsadmin = "hostmaster.commoninternet.net" records = [ "acme.commoninternet.net. NS ns-acme.commoninternet.net.", - "ns-acme.commoninternet.net. A 91.98.47.73", + "ns-acme.commoninternet.net. A ${publicIPv4}", ] debug = false @@ -48,6 +49,8 @@ let ''; in { + imports = [ ./options.nix ]; + users.groups.acme-dns = { }; users.users.acme-dns = { isSystemUser = true; diff --git a/nix/modules/default.nix b/nix/modules/default.nix new file mode 100644 index 0000000..df84660 --- /dev/null +++ b/nix/modules/default.nix @@ -0,0 +1,44 @@ +# The cc-ci CI server as ONE reusable NixOS module — exported from flake.nix as +# `nixosModules.cc-ci-server`. Everything a host needs to BE cc-ci, except what is physical or +# identity and therefore belongs to the host that imports it: hardware, networking, the tailscale +# node, root SSH keys, `system.stateVersion`. A host sets `cc-ci.publicIPv4` (and, when it is not +# built from a --recursive clone, `cc-ci.sopsFile`) and imports this. +# +# Consumers: nix/hosts/cc-ci-hetzner (the canonical standalone host) and +# recipe-maintainers/cc-ci-orchestrator's `#cc-ci` host, which runs the CI server and the +# orchestrator together (2026-09). +{ pkgs, ... }: +{ + imports = [ + ./options.nix + ./packages.nix + ./secrets.nix + ./acme-dns.nix + ./swarm.nix + ./docker-prune.nix + ./abra.nix + ./proxy.nix + ./drone.nix + ./drone-runner.nix + ./bridge.nix + ./dashboard.nix + ./reports.nix + ./backupbot.nix + ./harness.nix + ./warm-keycloak.nix + ./nightly-sweep.nix + ]; + + # Recipes bind-mount /etc/localtime and /etc/timezone; the harness compares timestamps across + # host and containers, so the host is UTC like every container. + time.timeZone = "UTC"; + environment.etc."timezone".text = "UTC\n"; + + # Phase `nixenv`: the Drone exec runner resolves recipe shell-outs from this host PATH + # (/run/current-system/sw/bin). Install the SINGLE shared harness tool set (pkgs.ccciRuntimeTools, + # defined in packages.nix) so the Drone path and the harness env (cc-ci-run / sweep) can never + # diverge. + environment.systemPackages = pkgs.ccciRuntimeTools; + + nix.settings.experimental-features = [ "nix-command" "flakes" ]; +} diff --git a/nix/modules/options.nix b/nix/modules/options.nix new file mode 100644 index 0000000..4efc8f4 --- /dev/null +++ b/nix/modules/options.nix @@ -0,0 +1,33 @@ +# The few host-identity values the cc-ci modules need but must not hard-code, so that the same +# modules can build the canonical Hetzner host, a throwaway rebuild VM, or a combined host that +# also runs the cc-ci orchestrator (2026-09: recipe-maintainers/cc-ci-orchestrator imports +# `nixosModules.cc-ci-server` from this repo and runs both on one box). +{ lib, ... }: +{ + options.cc-ci = { + publicIPv4 = lib.mkOption { + type = lib.types.str; + example = "91.98.47.73"; + description = '' + The host's public IPv4 address. acme-dns binds its authoritative listener to it and + publishes it as the `ns-acme` glue record. Must match the Gandi A record for + ns-acme.commoninternet.net and the address the cc-ci DNS names point at. + ''; + }; + + sopsFile = lib.mkOption { + type = lib.types.path; + default = ../../secrets/secrets.yaml; + defaultText = lib.literalExpression "../../secrets/secrets.yaml"; + example = "/etc/cc-ci/secrets/secrets.yaml"; + description = '' + The sops-encrypted secrets.yaml (recipe-maintainers/cc-ci-secrets). The default is the + `secrets/` git submodule inside this repo, which only exists when this flake is built from + a `--recursive` clone (`git+file:///root/cc-ci?submodules=1`). A consumer that imports + cc-ci as a plain flake input (no submodule) sets this to an absolute path on the host + instead — e.g. the `/etc/cc-ci` deployed checkout's `secrets/secrets.yaml` — which + sops-nix then reads at activation time rather than copying into the store. + ''; + }; + }; +} diff --git a/nix/modules/secrets.nix b/nix/modules/secrets.nix index 4fe46b7..b19f188 100644 --- a/nix/modules/secrets.nix +++ b/nix/modules/secrets.nix @@ -6,8 +6,15 @@ # off-box master recovery key). { config, ... }: { + imports = [ ./options.nix ]; + sops = { - defaultSopsFile = ../../secrets/secrets.yaml; + # See options.nix: the submodule path by default; an absolute host path on a combined host + # that imports cc-ci as a flake input without the private submodule. + defaultSopsFile = config.cc-ci.sopsFile; + # sops-nix validates store-path sops files at build time. An absolute (string) path is read + # at activation instead, so validation has to be off for that case. + validateSopsFiles = builtins.isPath config.cc-ci.sopsFile; # Decrypt using the host's SSH host key (converted to an age identity by sops-nix). age.sshKeyPaths = [ "/etc/ssh/ssh_host_ed25519_key" ]; # Phase-1c: also accept a bootstrap age key at a fixed path — THE one out-of-band secret, -- 2.54.0