From 34bde7a5af66c5b3ee4e96bd8040c55d4ddebe2c Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 12 Oct 2026 10:48:32 +0200 Subject: [PATCH] nix: allSwarmServices turns on the review classifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploy.review-classifier.enable now defaults to allSwarmServices, as the other shared services do, but only where the host has the classifier's store identity (deploy.bao.reviewClassifierClient{Cert,Key}File). The classifier module asserts that pair; glue-bao-tls.nix mints it only on the host that runs the store, so a services host with the store placed elsewhere would otherwise fail eval. An explicit value still wins. The operator still writes the Anthropic key by hand; until then each run fails with "no API key…" while the service runs. --- docs/swarm/services.md | 21 +++++----- nix/host-modules/deploy.nix | 10 +++-- nix/host-modules/swarm-required-services.nix | 12 ++++++ nix/host-modules/swarm-review-classifier.nix | 5 ++- nix/module-eval/swarm-services-switch.nix | 41 ++++++++++++++++---- 5 files changed, 66 insertions(+), 23 deletions(-) diff --git a/docs/swarm/services.md b/docs/swarm/services.md index a830e4fd..38ce804b 100644 --- a/docs/swarm/services.md +++ b/docs/swarm/services.md @@ -236,17 +236,18 @@ the container and the threat model are in ### Review classifier The review classifier's try-out service, in a `swarm-review-classifier` -container on the host that sets `deploy.review-classifier.enable`. It doesn't -derive from `allSwarmServices`: every run spends money on an Anthropic API -key, so running one is a decision of its own. +container on the host that sets `deploy.review-classifier.enable`. +`allSwarmServices` defaults it on wherever the host has the classifier's store +identity: the host that runs the store has it, and a services host with the +store elsewhere gets the classifier only once it holds a copy of the pair (see +below). Set `deploy.review-classifier.enable` directly to override either way. -Two steps turn it on: - -1. Write the key in the secret store's UI at - `secret/swarm/services/review-classifier/anthropic-api-key`, field - `api_key`. The service reads it on every run, so writing or removing it - takes effect on the next run, with no restart. -2. Set `services.hyperhive.deploy.review-classifier.enable = true` on a host. +The operator writes the Anthropic key by hand, in the secret store's UI at +`secret/swarm/services/review-classifier/anthropic-api-key`, field `api_key`. +Nothing writes it for you: until it exists the service runs and every run fails +with `no API key…`. The service reads the key on every run, so writing or +removing it takes effect on the next run, with no restart. Every run spends +money on that key. It needs neither the forge nor a hive on its host. It reaches the queue and the secret store by name, under its own `review-classifier` leaf. On a host diff --git a/nix/host-modules/deploy.nix b/nix/host-modules/deploy.nix index 5fec12f1..972c5079 100644 --- a/nix/host-modules/deploy.nix +++ b/nix/host-modules/deploy.nix @@ -653,9 +653,13 @@ in `secret/swarm/services/review-classifier/anthropic-api-key`, field `api_key`. - Not derived from - {option}`services.hyperhive.deploy.allSwarmServices`: every run spends - money on that key, so running one is a decision of its own. + On by default where + {option}`services.hyperhive.deploy.allSwarmServices` is set and this + host has the classifier's store identity (the pair + {option}`services.hyperhive.deploy.bao.reviewClassifierClientCertFile` + and `reviewClassifierClientKeyFile`, minted on the store's host). The + key is never written for you: until the operator writes it the service + runs and every run fails with "no API key…". ''; }; diff --git a/nix/host-modules/swarm-required-services.nix b/nix/host-modules/swarm-required-services.nix index 9dabbe4e..31097135 100644 --- a/nix/host-modules/swarm-required-services.nix +++ b/nix/host-modules/swarm-required-services.nix @@ -105,4 +105,16 @@ in # `singleHostSwarm` box, which gets here through `allSwarmServices`, would # have no forge at all. config.services.hyperhive.deploy.forgejo.enable = lib.mkDefault deployCfg.allSwarmServices; + + # The review classifier. Its module asserts a store identity, which + # glue-bao-tls.nix mints only on the host that runs the store; with the store + # placed elsewhere both files stay null and an unconditional `true` here would + # fail that host's eval. So the default follows the identity, and a host + # holding a copy of the pair (`deploy.bao.reviewClassifierClient{Cert,Key}File`) + # gets the classifier too. + config.services.hyperhive.deploy.review-classifier.enable = lib.mkDefault ( + deployCfg.allSwarmServices + && deployCfg.bao.reviewClassifierClientCertFile != null + && deployCfg.bao.reviewClassifierClientKeyFile != null + ); } diff --git a/nix/host-modules/swarm-review-classifier.nix b/nix/host-modules/swarm-review-classifier.nix index 5a64f7c3..ca9c78f5 100644 --- a/nix/host-modules/swarm-review-classifier.nix +++ b/nix/host-modules/swarm-review-classifier.nix @@ -1,6 +1,7 @@ # 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 +# that sets `deploy.review-classifier.enable`, which `deploy.allSwarmServices` +# defaults on where the host has the classifier's store identity. 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. # diff --git a/nix/module-eval/swarm-services-switch.nix b/nix/module-eval/swarm-services-switch.nix index 9d13a4c7..46a3b015 100644 --- a/nix/module-eval/swarm-services-switch.nix +++ b/nix/module-eval/swarm-services-switch.nix @@ -93,6 +93,12 @@ let # swarm-required-services.nix documents the switch for. swarmServicesHere = hiveWithController { deploy.allSwarmServices = true; }; + # Same, with the operator opting out of the classifier alone. + swarmServicesWithoutClassifier = hiveWithController { + deploy.allSwarmServices = true; + deploy.review-classifier.enable = false; + }; + # Same, with one of those services placed on another host. Every derivation # in that module is `mkDefault` so this stays expressible. swarmServicesBaoElsewhere = hive { @@ -158,7 +164,8 @@ let lib.filter (l: swarmServiceLinkDomains ? ${l.label}) cfg.services.hyperhive.swarm.controller.links; # Every service container at once: the services-only host plus the CI - # runner, the one container on a netns of its own. + # runner. The CI runner and the review classifier are the two containers on + # a netns of their own. serviceContainersWithCi = hive { deploy.hive-controller.enable = false; deploy.allSwarmServices = true; @@ -178,15 +185,33 @@ let lib.length (lib.attrNames es) == 9 && lib.all lib.id (lib.attrValues es); } { - # Every run spends money on an operator's key, so hosting the shared - # services does not start the classifier. The queue is the control: - # the same fixture does turn the switch's own services on. - name = "hosting the swarm's shared services leaves the review classifier off"; + # The container is the control that the toggle reaches the module, not + # only the option; `nats` is the control that the switch is on at all. + name = "hosting the swarm's shared services turns on the review classifier"; ok = - !swarmServicesHere.services.hyperhive.deploy.review-classifier.enable - && !(swarmServicesHere.containers ? swarm-review-classifier) + swarmServicesHere.services.hyperhive.deploy.review-classifier.enable + && swarmServicesHere.containers ? swarm-review-classifier && swarmServicesHere.services.hyperhive.deploy.nats.enable; } + { + # The default yields to an operator who has spoken. The same fixture + # without the line is the control. + name = "an explicit false keeps the review classifier off where the shared services are hosted"; + ok = + !swarmServicesWithoutClassifier.services.hyperhive.deploy.review-classifier.enable + && !(swarmServicesWithoutClassifier.containers ? swarm-review-classifier) + && swarmServicesWithoutClassifier.services.hyperhive.deploy.nats.enable; + } + { + # The classifier's module asserts a store identity that only the store's + # host mints, so with the store placed elsewhere the default stays off + # rather than failing that host's eval. `nats` is the control. + name = "the review classifier stays off where the shared services are hosted without the store"; + ok = + !swarmServicesBaoElsewhere.services.hyperhive.deploy.review-classifier.enable + && !(swarmServicesBaoElsewhere.containers ? swarm-review-classifier) + && swarmServicesBaoElsewhere.services.hyperhive.deploy.nats.enable; + } { # The switch fills in for an operator who has not spoken and yields to # one who has — that is what keeps a shared service placeable on a host @@ -431,7 +456,7 @@ let # that lost a container can't pass by comparing fewer of them. name = "every service container restates its host-side privateNetwork"; ok = - lib.length (lib.attrNames serviceContainers) == 10 + lib.length (lib.attrNames serviceContainers) == 11 && lib.all ( c: c.privateNetwork == (c.config.services.hyperhive.swarmContainer.privateNetwork or null) ) (lib.attrValues serviceContainers);