# The review classifier, in a `swarm-review-classifier` container on the host # that sets `deploy.review-classifier.enable`. It answers the swarm UI's review # try-outs: swarm-controller sends a PR and the operator's classifications over # the queue, and the daemon runs `claude` once per classification and replies. # docs/swarm/review-classifier.md has the design. # # ⚠️ Its own network namespace on the bridge, as ./swarm-ci-runner.nix has: a # PR's diff and commit messages are model input, so the container reaches the # swarm services by name and nothing on the host's loopback. # # The container holds one credential, its own store leaf, which the host unit # below copies in. The daemon reads everything else from the store with it: # its queue client secret at start and the API key on every run. { pkgs, lib, config, ... }: let hyperhiveCfg = config.services.hyperhive; cfg = hyperhiveCfg.deploy.review-classifier; baoCfg = hyperhiveCfg.swarm.bao; baoDeploy = hyperhiveCfg.deploy.bao; natsCfg = hyperhiveCfg.swarm.nats; autheliaCfg = hyperhiveCfg.swarm.authelia; gatewayCfg = hyperhiveCfg.gateway; networkCfg = hyperhiveCfg.network; otelCfg = hyperhiveCfg.otel; tlsCfg = hyperhiveCfg.deploy.hive-controller.tls; machine = "swarm-review-classifier"; unit = "swarm-review-classifier"; clientId = hyperhiveCfg.swarm.review-classifier.clientId; # The daemon reaches authelia, the store and the queue by name over TLS, and # in self-signed mode their leaves chain to the hive CA. caTrust = import ./lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; }; caBundleModule = caTrust.trustBundle { inherit pkgs; name = machine; consumers = [ unit ]; }; haveClientIdentity = baoDeploy.reviewClassifierClientCertFile != null && baoDeploy.reviewClassifierClientKeyFile != null; # Credential id → host source, copied into the container and loaded by the # daemon's unit alone. storeFiles = { "bao-client.pem" = baoDeploy.reviewClassifierClientCertFile; "bao-client-key.pem" = baoDeploy.reviewClassifierClientKeyFile; } // lib.optionalAttrs (baoDeploy.serverCaFile != null) { "bao-ca.pem" = baoDeploy.serverCaFile; }; secretDirInContainer = "/var/lib/swarm-review-classifier"; inContainer = id: "${secretDirInContainer}/${id}"; secretDir = "/var/lib/nixos-containers/${machine}${secretDirInContainer}"; credential = id: "/run/credentials/${unit}.service/${id}"; # What `claude` exports its metrics with, where this host runs a hive-tier # collector on the bridge. The daemon adds the repo and the PR to the # resource attributes for each run. otelEnv = lib.optionalAttrs otelCfg.enable { CLAUDE_CODE_ENABLE_TELEMETRY = "1"; OTEL_METRICS_EXPORTER = "otlp"; OTEL_EXPORTER_OTLP_ENDPOINT = "http://${networkCfg.bridgeIp}:${toString otelCfg.collector.port}"; OTEL_EXPORTER_OTLP_PROTOCOL = "http/protobuf"; # Claude Code defaults to delta, which the metrics store drops. OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE = "cumulative"; OTEL_RESOURCE_ATTRIBUTES = "service.name=hyperhive-review-classifier,swarm=${ if hyperhiveCfg.swarm.name == null then "unknown" else hyperhiveCfg.swarm.name }"; }; privateNetwork = true; in { options.services.hyperhive.swarm.review-classifier.clientId = lib.mkOption { type = lib.types.str; readOnly = true; default = "swarm-review-classifier"; description = '' The OAuth2 client id the review classifier presents to the swarm queue. Read-only: the host running authelia registers it, the queue's auth-callout responder grants it the try-out subject, and the secret publisher carries its secret to the store, all from this one value. ''; }; options.services.hyperhive.deploy.review-classifier.package = lib.mkOption { type = lib.types.package; defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-review-classifier"; description = "The review classifier daemon package."; }; config = lib.mkIf cfg.enable { assertions = [ { assertion = haveClientIdentity; message = '' services.hyperhive.deploy.review-classifier.enable requires the classifier's swarm-secret-store client identity: set both services.hyperhive.deploy.bao.reviewClassifierClientCertFile services.hyperhive.deploy.bao.reviewClassifierClientKeyFile The classifier reads its queue client secret and the API key with it. Both default to the pair glue-bao-tls.nix mints when services.hyperhive.deploy.bao.enable is true (the host that runs the store). Otherwise copy review-classifier.pem and review-classifier-key.pem from the store host's /var/lib/swarm-bao-pki/ and name them in those two options. ''; } ]; # The container hangs off the bridge, takes its address from the gateway's # DHCP and resolves through its dnsmasq. services.hyperhive.network.enable = lib.mkDefault true; services.hyperhive.gateway.enable = lib.mkDefault true; services.hyperhive.gateway.dns.enable = lib.mkDefault true; # A copy rather than a bind mount: nixos-container refuses to start on a # missing bind source, and the leaf may not be minted yet. systemd.services.swarm-review-classifier-identity = { description = "deliver the review classifier's store identity into its container"; before = [ "container@${machine}.service" ]; wantedBy = [ "container@${machine}.service" ]; # Ordering only: where the store's PKI is minted on this host, the leaf # is one of its files. after = [ "swarm-bao-pki.service" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; SyslogIdentifier = "swarm-review-classifier-identity"; }; path = [ pkgs.coreutils ]; script = '' set -euo pipefail install -d -m 0700 ${lib.escapeShellArg secretDir} '' + lib.concatStrings ( lib.mapAttrsToList (id: source: '' install -m 0400 ${lib.escapeShellArg source} ${lib.escapeShellArg "${secretDir}/${id}"} '') storeFiles ); }; systemd.services."container@${machine}" = caTrust.containerOrdering; containers.${machine} = { autoStart = true; # Not ephemeral: an ephemeral root lives under /run, where the identity # copy above does not write. The daemon itself keeps no state. ephemeral = false; # Journal files on the host, not inside the container: nixpkgs hardcodes # --link-journal=try-guest, and EXTRA_NSPAWN_FLAGS expands after it. extraFlags = [ "--link-journal=host" ]; inherit privateNetwork; hostBridge = networkCfg.bridgeName; bindMounts = caTrust.bindMount; config = { pkgs, lib, ... }: { imports = [ ../container-modules/swarm-container.nix caBundleModule ]; services.hyperhive.swarmContainer = { inherit privateNetwork; writesOwnResolvConf = false; }; networking.nameservers = [ networkCfg.bridgeIp ]; networking.interfaces.eth0.useDHCP = true; # `claude-code` is unfree, and this container evaluates its own # nixpkgs, so the host's allowance does not reach here. nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ "claude-code" ]; systemd.services.${unit} = { description = "answer the swarm UI's review try-outs"; wantedBy = [ "multi-user.target" ]; wants = [ "network-online.target" ]; after = [ "network-online.target" ]; environment = { BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}"; BAO_CLIENT_CERT = credential "bao-client.pem"; BAO_CLIENT_KEY = credential "bao-client-key.pem"; SWARM_REVIEW_CLASSIFIER_NATS_URL = "tls://${natsCfg.domain}:${toString natsCfg.port}"; SWARM_REVIEW_CLASSIFIER_OIDC_TOKEN_ENDPOINT = "${autheliaCfg.url}/api/oidc/token"; SWARM_REVIEW_CLASSIFIER_OIDC_CLIENT_ID = clientId; SWARM_REVIEW_CLASSIFIER_CLAUDE = "${pkgs.claude-code}/bin/claude"; } // lib.optionalAttrs (storeFiles ? "bao-ca.pem") { BAO_CACERT = credential "bao-ca.pem"; } // lib.optionalAttrs caTrust.useSelfSigned { SWARM_REVIEW_CLASSIFIER_OIDC_CA_FILE = caTrust.bundlePathFor machine; } // otelEnv; serviceConfig = { ExecStart = "${cfg.package}/bin/swarm-review-classifier"; DynamicUser = true; # Each classification's scratch directory, gone with the unit. PrivateTmp = true; LoadCredential = lib.mapAttrsToList (id: _: "${id}:${inContainer id}") storeFiles; Restart = "on-failure"; RestartSec = "10s"; SyslogIdentifier = unit; }; }; }; }; }; }