diff --git a/docs/swarm/services.md b/docs/swarm/services.md index 3a097d7c..54a4bc2b 100644 --- a/docs/swarm/services.md +++ b/docs/swarm/services.md @@ -268,9 +268,11 @@ the container and the threat model are in ### Builder The swarm builder, in a `swarm-builder` container on the host that sets -`deploy.builder.enable`. It doesn't derive from `allSwarmServices`: it -evaluates and builds unreviewed config PRs through its host's nix daemon, so -running one is a decision of its own, as for the CI runner. +`deploy.builder.enable`. `allSwarmServices` defaults it on wherever the host +has the builder's store identity, as for the review classifier below: the host +that runs the store has it. Set `deploy.builder.enable = false` to opt out. It +evaluates unreviewed config PRs and builds them through its host's nix daemon, +from a container with its own network namespace. When an `agent-configs/` PR opens, reopens or gets a new push, swarm-controller queues a `build_agent` job. Jobs run one at a time, and a diff --git a/docs/trust-boundary/security.md b/docs/trust-boundary/security.md index 7348cd68..7f63df48 100644 --- a/docs/trust-boundary/security.md +++ b/docs/trust-boundary/security.md @@ -133,7 +133,8 @@ checkpoints**, not about sandboxing the agent from its own tools: scheduled prompts are operator-only, set at swarm level (see [`schedules.md`](../swarm/schedules.md)). Before that merge, the swarm builder evaluates an unmerged config PR - off-hive, on any host that sets `deploy.builder.enable`: it builds the + off-hive, on any host that sets `deploy.builder.enable` (the + all-swarm-services switch defaults it on): it builds the PR's head with a read-only forge token, in its own netns, as an untrusted client of its host's nix daemon (see [`builder.md`](../swarm/builder.md)). diff --git a/nix/host-modules/deploy.nix b/nix/host-modules/deploy.nix index c555e92b..a8c72b17 100644 --- a/nix/host-modules/deploy.nix +++ b/nix/host-modules/deploy.nix @@ -694,13 +694,13 @@ in this host's nix daemon, and swarm-controller writes the result as a `swarm-builder` commit status on the PR. - Not derived from - {option}`services.hyperhive.deploy.allSwarmServices`: the builder - evaluates and builds unreviewed config PRs on this host's nix daemon, - so running one is a decision of its own, as for the CI runner. It needs - the builder's store identity - ({option}`services.hyperhive.deploy.bao.builderClientCertFile` and - `builderClientKeyFile`), which the store's host mints. + On by default where + {option}`services.hyperhive.deploy.allSwarmServices` is set and this + host has the builder's store identity (the pair + {option}`services.hyperhive.deploy.bao.builderClientCertFile` and + `builderClientKeyFile`, minted on the store's host). It evaluates + unreviewed config PRs and builds them through this host's nix daemon, + from a container with its own network namespace. ''; }; diff --git a/nix/host-modules/swarm-builder.nix b/nix/host-modules/swarm-builder.nix index 59def0b5..a4aa0f4f 100644 --- a/nix/host-modules/swarm-builder.nix +++ b/nix/host-modules/swarm-builder.nix @@ -1,5 +1,6 @@ # The swarm builder, in a `swarm-builder` container on the host that sets -# `deploy.builder.enable`, which nothing turns on for the operator: it runs +# `deploy.builder.enable`, which `deploy.allSwarmServices` defaults on where the +# host has the builder's store identity (./swarm-required-services.nix). It runs # unreviewed config PRs through the host's nix daemon. It builds agent configs # for swarm-controller: the controller sends a config PR's head over the # queue, and the daemon builds that config on this host's agent base and diff --git a/nix/host-modules/swarm-required-services.nix b/nix/host-modules/swarm-required-services.nix index 31097135..aa5162d0 100644 --- a/nix/host-modules/swarm-required-services.nix +++ b/nix/host-modules/swarm-required-services.nix @@ -117,4 +117,13 @@ in && deployCfg.bao.reviewClassifierClientCertFile != null && deployCfg.bao.reviewClassifierClientKeyFile != null ); + + # The builder, by the same rule and for the same reason: its module asserts + # the builder's store identity. It evaluates unreviewed config PRs, which is + # why ./swarm-builder.nix gives it a container with its own netns. + config.services.hyperhive.deploy.builder.enable = lib.mkDefault ( + deployCfg.allSwarmServices + && deployCfg.bao.builderClientCertFile != null + && deployCfg.bao.builderClientKeyFile != null + ); } diff --git a/nix/module-eval/builder.nix b/nix/module-eval/builder.nix index dd2f54b2..c552bc0b 100644 --- a/nix/module-eval/builder.nix +++ b/nix/module-eval/builder.nix @@ -32,6 +32,10 @@ let }; servicesHere = hiveWithController { deploy.allSwarmServices = true; }; + # Short-circuits on `assertion`, so only a failing one has its message read. + refusedWithoutIdentity = + m: + lib.any (a: !a.assertion && lib.hasInfix "deploy.builder.enable requires" a.message) m.assertions; natsHere = hive { swarm.nats.enable = true; @@ -54,13 +58,17 @@ let ok = !(bare.containers ? swarm-builder) && withBuilder.containers ? swarm-builder; } { - # It runs unreviewed config PRs through the host's nix daemon, so hosting - # the shared services, even with the store's leaf here, does not turn it - # on. `nats` is the control that the switch is on at all. - name = "hosting the shared services leaves the builder off"; + # The store's host mints the builder's leaf, so the switch needs no + # manual identity step: the pair is set and the module's identity + # assertion, which nothing else reads in this fixture, passes. `nats` is + # the control that the switch is on. + name = "hosting the shared services turns the builder on, with its store identity"; ok = - !servicesHere.services.hyperhive.deploy.builder.enable - && !(servicesHere.containers ? swarm-builder) + servicesHere.services.hyperhive.deploy.builder.enable + && servicesHere.containers ? swarm-builder + && servicesHere.services.hyperhive.deploy.bao.builderClientCertFile != null + && servicesHere.services.hyperhive.deploy.bao.builderClientKeyFile != null + && !(refusedWithoutIdentity servicesHere) && servicesHere.services.hyperhive.deploy.bao.enable && servicesHere.services.hyperhive.deploy.nats.enable; } diff --git a/nix/module-eval/swarm-services-switch.nix b/nix/module-eval/swarm-services-switch.nix index c2430f99..fb7b38c4 100644 --- a/nix/module-eval/swarm-services-switch.nix +++ b/nix/module-eval/swarm-services-switch.nix @@ -90,10 +90,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 { + # Same, with the operator opting out of the classifier and the builder, + # which the switch defaults on and everything else it owns stays on. + swarmServicesOptedOut = hiveWithController { deploy.allSwarmServices = true; deploy.review-classifier.enable = false; + deploy.builder.enable = false; }; # Same, with one of those services placed on another host. Every derivation @@ -161,8 +163,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 CI runner, the review classifier and the metrics store are the - # three containers on a netns of their own. + # runner. The CI runner, the review classifier, the builder and the metrics + # store are the four containers on a netns of their own. serviceContainersWithCi = hive { deploy.hive-controller.enable = false; deploy.allSwarmServices = true; @@ -212,9 +214,19 @@ let # 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; + !swarmServicesOptedOut.services.hyperhive.deploy.review-classifier.enable + && !(swarmServicesOptedOut.containers ? swarm-review-classifier) + && swarmServicesOptedOut.services.hyperhive.deploy.nats.enable; + } + { + # Same as the classifier's case above, for the builder: the opt-out is + # the only difference from `swarmServicesHere`, which has the container. + name = "an explicit false keeps the swarm builder off where the shared services are hosted"; + ok = + !swarmServicesOptedOut.services.hyperhive.deploy.builder.enable + && !(swarmServicesOptedOut.containers ? swarm-builder) + && swarmServicesHere.containers ? swarm-builder + && swarmServicesOptedOut.services.hyperhive.deploy.nats.enable; } { # The classifier's module asserts a store identity that only the store's @@ -226,6 +238,15 @@ let && !(swarmServicesBaoElsewhere.containers ? swarm-review-classifier) && swarmServicesBaoElsewhere.services.hyperhive.deploy.nats.enable; } + { + # The builder's module asserts its own store identity, so the same + # holds for it. `nats` is the control. + name = "the swarm builder stays off where the shared services are hosted without the store"; + ok = + !swarmServicesBaoElsewhere.services.hyperhive.deploy.builder.enable + && !(swarmServicesBaoElsewhere.containers ? swarm-builder) + && 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 @@ -489,7 +510,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) == 11 + lib.length (lib.attrNames serviceContainers) == 12 && lib.all ( c: c.privateNetwork == (c.config.services.hyperhive.swarmContainer.privateNetwork or null) ) (lib.attrValues serviceContainers);