From af434e13745729f3c9196f862857e2a848f2a3f6 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 6 Nov 2026 20:25:38 +0100 Subject: [PATCH] nightly vm test: full single-host swarm setup up to the meta flake --- hive-meta-flake/README.md | 5 + hive-meta-flake/src/lib.rs | 35 ++ .../testdata/meta-flake-swarm.nix.in | 94 +++++ nix/rust.nix | 5 +- nix/vm-tests.nix | 1 + nix/vm-tests/full-setup.nix | 386 ++++++++++++++++++ nix/vm-tests/hyperhive-fixture-repo.nix | 33 ++ .../swarm-builder-offline-fixtures.nix | 26 +- 8 files changed, 559 insertions(+), 26 deletions(-) create mode 100644 hive-meta-flake/testdata/meta-flake-swarm.nix.in create mode 100644 nix/vm-tests/full-setup.nix create mode 100644 nix/vm-tests/hyperhive-fixture-repo.nix diff --git a/hive-meta-flake/README.md b/hive-meta-flake/README.md index 24e37f47..3c407c68 100644 --- a/hive-meta-flake/README.md +++ b/hive-meta-flake/README.md @@ -13,3 +13,8 @@ helpers. `testdata/meta-flake.nix.in` is the render for one archetype agent with a placeholder for the hyperhive pin. A unit test keeps it equal to `render_flake`'s output, and the `swarm-builder-offline` VM test (`nix/vm-tests/`) serves it. + +`testdata/meta-flake-swarm.nix.in` is the render for two archetype agents with +the forwarded values of a single-host swarm as `@@` placeholders, kept equal +by a unit test the same way. The `full-setup` VM test fills it in with the +controller's own settings and builds the agents' toplevels from it. diff --git a/hive-meta-flake/src/lib.rs b/hive-meta-flake/src/lib.rs index e7a46c21..2f9ec8e4 100644 --- a/hive-meta-flake/src/lib.rs +++ b/hive-meta-flake/src/lib.rs @@ -583,6 +583,41 @@ mod tests { } } + /// `testdata/meta-flake-swarm.nix.in` is the flake `nix/vm-tests/full-setup.nix` + /// evaluates and serves: two archetype agents, the seven forwarded values a + /// single-host swarm without a display name sets, each as `@@`, and + /// `@HYPERHIVE_URL@` where the pin goes. It must be what [`render_flake`] + /// renders for them. + #[test] + fn the_full_setup_fixture_is_what_render_flake_renders() { + let archetype = || { + Some(ArchetypeRef { + source: "hyperhive".to_owned(), + name: "reviewer".to_owned(), + }) + }; + let vars: Vec<(&'static str, String)> = FORWARDED_VARS + .iter() + .filter(|name| **name != "HYPERHIVE_SWARM_NAME") + .map(|&name| (name, format!("@{name}@"))) + .collect(); + let flake = render_flake( + "@HYPERHIVE_URL@", + &[ + spec("iris", archetype(), None), + spec("ruth", archetype(), None), + ], + &vars, + false, + ) + .unwrap(); + assert_eq!( + flake, + include_str!("../testdata/meta-flake-swarm.nix.in"), + "update the fixture to the rendered flake" + ); + } + #[test] fn a_rendered_flake_names_the_commit_each_input_is_pinned_to() { let flake = rendered(); diff --git a/hive-meta-flake/testdata/meta-flake-swarm.nix.in b/hive-meta-flake/testdata/meta-flake-swarm.nix.in new file mode 100644 index 00000000..ab8865a0 --- /dev/null +++ b/hive-meta-flake/testdata/meta-flake-swarm.nix.in @@ -0,0 +1,94 @@ +{ + description = "hyperhive deployed agents"; + inputs = { + hyperhive.url = "@HYPERHIVE_URL@"; + nixpkgs.follows = "hyperhive/nixpkgs"; + }; + outputs = + { self, hyperhive, ... }@inputs: + let + mkAgent = { name, port, archetype ? null }: + let + base = hyperhive.nixosConfigurations.agent-base; + # Every flake an archetype can come from, by its source name. + archetypeSources = { inherit hyperhive; }; + agentModule = + if archetype == null then + inputs."agent-${name}".nixosModules.default + else + (archetypeSources.${archetype.source} + or (throw "agent ${name}: archetype source ${archetype.source} is not an input of this hive's meta flake") + ).hyperhiveArchetypes.${archetype.name}.module + or (throw "agent ${name}: this hive's ${archetype.source} input has no archetype ${archetype.name}; update that input"); + service = "hive-agent"; + # One attrset for `environment.variables` (login shells), + # `systemd.globalEnvironment` (every unit) and the harness service, + # so the three cannot disagree. The forwarded vars come from the host. + agentEnv = { + HIVE_LABEL = name; + HYPERHIVE_STATE_DIR = "/agents/${name}/state"; + HYPERHIVE_HARNESS_DIR = "/agents/${name}/harness"; + HIVE_AGENT_BAO_ADDR = "@HIVE_AGENT_BAO_ADDR@"; + HIVE_AGENT_BAO_SERVICES_PKI_MOUNT = "@HIVE_AGENT_BAO_SERVICES_PKI_MOUNT@"; + HIVE_AGENT_LOGS_QUERY_URL = "@HIVE_AGENT_LOGS_QUERY_URL@"; + HIVE_AGENT_METRICS_URL = "@HIVE_AGENT_METRICS_URL@"; + HIVE_FORGE_URL = "@HIVE_FORGE_URL@"; + HIVE_FORGE_PUBLIC_URL = "@HIVE_FORGE_PUBLIC_URL@"; + HIVE_MATRIX_URL = "@HIVE_MATRIX_URL@"; + }; + in + base.extendModules { + modules = [ + agentModule + { + services.hyperhive.agent.bao.addr = "@HIVE_AGENT_BAO_ADDR@"; + services.hyperhive.agent.bao.servicesPkiMount = "@HIVE_AGENT_BAO_SERVICES_PKI_MOUNT@"; + services.hyperhive.agent.logs.queryUrl = "@HIVE_AGENT_LOGS_QUERY_URL@"; + services.hyperhive.agent.metrics.url = "@HIVE_AGENT_METRICS_URL@"; + services.hyperhive.agent.forge.url = "@HIVE_FORGE_URL@"; + services.hyperhive.agent.matrix.url = "@HIVE_MATRIX_URL@"; + # The harness service inside the container runs as a + # non-root unix user named after the agent (`damocles`, + # `iris`, `root`, …). UID auto-assigned by NixOS; the + # per-agent override here is what makes + # `services.hyperhive.agent.user.name` match the agent's identity + # instead of the harness default of `"agent"`. + services.hyperhive.agent.user.name = name; + # Without this an agent container is called `nixos` and ships + # every log line under that: nixpkgs sets the hostname from the + # attr name in the `containers.` submodule, and an + # agent's system is its own `nixosSystem` off this flake, which + # never evaluates that option. The machine name, not the + # logical one — it is what `container.name` on our metrics and + # nspawn itself already call this container, so logs join to + # both without a prefix transform. + networking.hostName = "h-${name}"; + programs.git.config.user = { + name = name; + email = "${name}@hyperhive.local"; + }; + # Container-wide env, all from `agentEnv`: every unit and daemon + # (the bash-task runner where `hive-forge` and `git` run included) + # resolves the state dir and the forwarded service URLs without + # redeclaring them. `environment.variables` is what login shells + # read; `systemd.globalEnvironment` is the analogue for units. + environment.variables = agentEnv; + systemd.globalEnvironment = agentEnv; + systemd.services.${service}.environment = agentEnv // { + HIVE_PORT = toString port; + }; + } + # Tells the harness the agent has no config repo of its own. + (if archetype == null then { } else { + systemd.services.${service}.environment.HIVE_ARCHETYPE = archetype.name; + }) + ]; + }; + in + { + nixosConfigurations = { + iris = mkAgent { name = "iris"; port = 8178; archetype = { source = "hyperhive"; name = "reviewer"; }; }; + ruth = mkAgent { name = "ruth"; port = 8232; archetype = { source = "hyperhive"; name = "reviewer"; }; }; + }; + }; +} diff --git a/nix/rust.nix b/nix/rust.nix index e433148d..20453ea2 100644 --- a/nix/rust.nix +++ b/nix/rust.nix @@ -37,15 +37,16 @@ rec { # `*.toml` / `Cargo.lock` edits rebuild the rust derivations. # # The two other files in it are the repo's own review-classifier rules and - # the meta flake fixture of the swarm-builder-offline VM test. A + # the meta flake fixtures of the swarm-builder-offline and full-setup VM tests. A # `swarm-controller` test parses the rules and a `hive-meta-flake` test - # compares the fixture, so both must be in the sandbox. + # compares the fixtures, so all must be in the sandbox. cleanSrc = lib.fileset.toSource { root = ../.; fileset = lib.fileset.unions [ (craneLib.fileset.commonCargoSources ../.) ../.hyperhive/review-classifier.yml ../hive-meta-flake/testdata/meta-flake.nix.in + ../hive-meta-flake/testdata/meta-flake-swarm.nix.in ]; }; diff --git a/nix/vm-tests.nix b/nix/vm-tests.nix index bb04ef65..96d7bad4 100644 --- a/nix/vm-tests.nix +++ b/nix/vm-tests.nix @@ -11,4 +11,5 @@ wg-hive = import ./vm-tests/wg-hive.nix { inherit pkgs self; }; hive-squid = import ./vm-tests/hive-squid.nix { inherit pkgs; }; swarm-builder-offline = import ./vm-tests/swarm-builder-offline.nix { inherit pkgs self; }; + full-setup = import ./vm-tests/full-setup.nix { inherit pkgs self; }; } diff --git a/nix/vm-tests/full-setup.nix b/nix/vm-tests/full-setup.nix new file mode 100644 index 00000000..a8bf4353 --- /dev/null +++ b/nix/vm-tests/full-setup.nix @@ -0,0 +1,386 @@ +# `vmTests..full-setup`: one VM runs a whole single-host swarm +# (`deploy.singleHostSwarm`) with no network and no substituter, and a script +# walks docs/1-getting-started/setup.md until the controller has committed the +# agents' meta flake to the swarm's forge. +# +# Steps of setup.md, as the script runs them: +# 1 secret store `bao operator init`, the granter bootstrap with the root +# token, then the policy units reset and restarted. +# 4 unseal `bao operator unseal` (`deploy.bao.seal` is "shamir"; pkcs11 +# needs a TPM). A pkcs11 store unseals itself at init, so here +# the unseal comes right after init, before the granter. +# 2 SSO account `swarmctl user add mara --group admins`. +# 8 ruth `POST /api/agents` on the controller socket, as the swarm +# UI's create form does, plus `manage_root_agent`. +# 9 first agent The same endpoint, for `iris`: `swarmctl agent create` has no +# archetype flag, and a custom agent's config repo would need +# inputs the VM cannot resolve offline. +# Not scripted yet; a later slice adds them: step 3, the first SSO login to the +# forge (an OIDC code flow against authelia), `swarmctl forge make-admin`, and +# the agents' deploy up to the point where each says it needs a login. Green +# when `internal/meta` on the forge holds the flake the toplevels were built from. +# +# Offline: the test seeds the swarm's forge with `hyperhive/hyperhive` (one +# commit of `self`, ./hyperhive-fixture-repo.nix). The controller renders +# `internal/meta` and the builder locks and builds it as in production. +# `hive-meta-flake/testdata/meta-flake-swarm.nix.in`, filled in with the +# controller's settings, is that flake: the agents' toplevels are built from it +# here, so the builder finds them in the store. The daemon runs with +# `max-jobs = 0`, so a missing toplevel fails the build instead of compiling. +{ pkgs, self }: +let + inherit (pkgs) lib; + + hive = "pr1ma"; + swarmDomain = "swarm.test"; + agents = [ + "iris" + "ruth" + ]; + + inherit (import ./hyperhive-fixture-repo.nix { inherit pkgs self; }) hyperhiveRepo; + + metaTemplate = ../../hive-meta-flake/testdata/meta-flake-swarm.nix.in; + + # The values the fixture has a placeholder for. A swarm whose controller sends + # another set renders another flake, so evaluation stops there. + forwardedVars = [ + "HIVE_AGENT_BAO_ADDR" + "HIVE_AGENT_BAO_SERVICES_PKI_MOUNT" + "HIVE_AGENT_LOGS_QUERY_URL" + "HIVE_AGENT_METRICS_URL" + "HIVE_FORGE_PUBLIC_URL" + "HIVE_FORGE_URL" + "HIVE_MATRIX_URL" + ]; + + # The meta flake the controller renders for `agents`, from its settings JSON + # (`SWARM_CONTROLLER_META_FLAKE`). `hyperhive` is `self`: the agents' toplevels + # do not depend on how the input is pinned. + filled = + settingsJson: pin: + let + settings = builtins.fromJSON settingsJson; + names = [ "HYPERHIVE_URL" ] ++ forwardedVars; + values = [ pin ] ++ map (name: settings.vars.${name}) forwardedVars; + in + assert lib.assertMsg + (builtins.attrNames settings.vars == forwardedVars && !settings.github_disabled) + "the controller's meta flake settings changed: update hive-meta-flake/testdata/meta-flake-swarm.nix.in"; + builtins.replaceStrings (map (name: "@${name}@") names) values (builtins.readFile metaTemplate); + + toplevels = + settingsJson: + let + flake = import (builtins.toFile "meta-flake.nix" (filled settingsJson "unused")); + evaluated = flake.outputs { + self = null; + hyperhive = self; + }; + in + map (agent: evaluated.nixosConfigurations.${agent}.config.system.build.toplevel) agents; +in +pkgs.testers.runNixOSTest ( + { config, ... }: + let + inherit (config.nodes.machine.services.hyperhive) swarm deploy; + settingsJson = + config.nodes.machine.systemd.services.swarm-controller.environment.SWARM_CONTROLLER_META_FLAKE; + controllerSocket = deploy.swarm-controller.socketPath; + + # A derivation no store holds, to show the daemon refuses to build. + control = pkgs.writeText "control.nix" '' + derivation { + name = "build-control"; + system = "${pkgs.stdenv.hostPlatform.system}"; + builder = "/bin/sh"; + args = [ "-c" "echo built > $out" ]; + } + ''; + in + { + name = "full-setup"; + + # Boot of fourteen containers and the setup steps, with the waits' own + # bounds well inside. + globalTimeout = 7200; + + nodes.machine = + { config, ... }: + { + imports = [ self.nixosModules.default ]; + + services.hyperhive = { + deploy.singleHostSwarm = true; + deploy.hive-controller.enable = true; + deploy.hive-controller.hiveName = hive; + deploy.bao.seal = "shamir"; + swarm.domain = swarmDomain; + swarm.hives.${hive} = { }; + }; + system.stateVersion = "25.11"; + + # 12 GB: fourteen service containers, the hive and a Rust-heavy agent + # closure through the controller, the builder and the cache. 4 cores: the + # containers boot in parallel. The disk holds the cache's copy of the + # closure; the store overlay is on disk, not in memory. + virtualisation.memorySize = 12288; + virtualisation.cores = 4; + virtualisation.diskSize = 40960; + virtualisation.writableStoreUseTmpfs = false; + + # The toplevels, and every source tree the meta flake's lock names, keyed + # by narHash: nix takes a locked input from the store when its path is + # valid. + virtualisation.additionalPaths = [ + self + self.inputs.nixpkgs + self.inputs.crane + self.inputs.treefmt-nix + hyperhiveRepo + ] + ++ toplevels config.systemd.services.swarm-controller.environment.SWARM_CONTROLLER_META_FLAKE; + + # No substituter, and no build. + nix.settings = { + substituters = lib.mkForce [ ]; + max-jobs = 0; + }; + + # The store's raft data is on a tmpfs. Raft syncs every write, so on the + # VM's qcow2 root (`cache=writeback`, which passes each guest flush on to + # the host's disk) a login's lease write can outlast the 15 s the store's + # clients wait, and its calls then fail with `context canceled`. The + # state is under `/var/lib/private`, where the store's DynamicUser keeps + # it; the mount sits on that directory in the container's root, and + # nothing the test checks needs it to survive the VM. + fileSystems."/var/lib/nixos-containers/${config.services.hyperhive.swarm.bao.machine}/var/lib/private" = + { + device = "tmpfs"; + fsType = "tmpfs"; + options = [ + "mode=0700" + "size=1G" + ]; + }; + + environment.systemPackages = [ + pkgs.git + pkgs.jq + ]; + }; + + testScript = '' + import json + import shlex + + API = "http://localhost" + SOCKET = "${controllerSocket}" + FORGE = "https://${swarm.forge.domain}" + HIVE = "${hive}" + TOPLEVELS = ${builtins.toJSON (map toString (toplevels settingsJson))} + INIT = "/root/bao-init.json" + FORGE_AUTH = "/root/forge-auth.header" + NIX = "nix --extra-experimental-features nix-command" + ARCHETYPE = {"source": "hyperhive", "name": "reviewer"} + POLICY_UNITS_ACTIVE = ( + "for u in $(systemctl list-unit-files 'swarm-bao-*-policy.service' --no-legend | cut -d' ' -f1 | grep -v operator-viewer) " + "swarm-bao-agent-pki.service; do " + "[ \"$(systemctl is-active $u)\" = active ] || { echo \"$u is not active\"; exit 1; }; done" + ) + + def controller(method, path, body=None, check="--fail-with-body"): + data = f"-H 'Content-Type: application/json' -d {shlex.quote(json.dumps(body))}" if body else "" + return machine.succeed( + f"curl {check} --silent --unix-socket {SOCKET} -X {method} {API}{path} {data}" + ) + + def forge(method, path, body=None): + data = f"-H 'Content-Type: application/json' -d {shlex.quote(json.dumps(body))}" if body else "" + return machine.succeed( + f"curl --fail-with-body --silent --insecure -H @{FORGE_AUTH} " + f"-X {method} {FORGE}/api/v1{path} {data}" + ) + + def diagnose(unit=None, container=None): + # What a timed-out wait leaves behind: the failed units, and the status + # and the end of the journal of the unit that was waited on. + where = f"--machine={container} " if container else "" + units = [unit] if isinstance(unit, str) else list(unit or []) + flags = " ".join(f"-u '{u}'" for u in units) + for command in [ + f"systemctl {where}--failed --no-legend --no-pager", + *([f"systemctl {where}status --no-pager -n 0 -l {' '.join(units)} | head -c 4000"] if units else []), + *([f"journalctl {where}{flags} --no-pager -n 50 | tail -c 6000"] if units else []), + ]: + print(f"--- {command}") + print(machine.execute(command)[1]) + + def settle(command, timeout, unit=None, container=None): + try: + machine.wait_until_succeeds(command, timeout=timeout) + except Exception: + diagnose(unit, container) + raise + + def create_agent(body): + # The controller refuses a create until its dependencies answer (the + # archetype list comes from the builder's evaluation), so ask again. + for _ in range(60): + status, out = machine.execute( + f"curl --silent --unix-socket {SOCKET} -o /root/create.out -w '%{{http_code}}' " + f"-X POST {API}/api/agents -H 'Content-Type: application/json' -d {shlex.quote(json.dumps(body))}" + ) + if out.strip().startswith("2"): + return + machine.sleep(10) + diagnose("swarm-controller") + raise Exception(f"the controller refused to create {body['name']}: HTTP {out}: {machine.succeed('cat /root/create.out')}") + + machine.start() + machine.wait_for_unit("multi-user.target") + + with subtest("the agents' toplevels are in the store, and the daemon builds nothing"): + for toplevel in TOPLEVELS: + machine.succeed(f"{NIX} path-info {toplevel}") + # The control for the lookup: a path that is not there is not found. + machine.fail(f"{NIX} path-info /nix/store/00000000000000000000000000000000-absent") + # The control for the missing build: a derivation no store holds is refused. + machine.fail("NIX_CONFIG='experimental-features = nix-command' nix build --no-link --impure -f ${control}") + + # setup.md step 1: the secret store. + with subtest("step 1: bao operator init"): + machine.wait_until_succeeds("[ \"$(bao status >/dev/null 2>&1; echo $?)\" = 2 ]", timeout=600) + machine.succeed(f"umask 077 && bao operator init -key-shares=1 -key-threshold=1 -format=json > {INIT}") + + with subtest("step 4: bao operator unseal, with the shamir seal"): + machine.succeed(f"bao operator unseal \"$(jq -r '.unseal_keys_b64[0]' {INIT})\"") + machine.succeed("bao status") + + with subtest("step 1: the granter bootstrap"): + machine.succeed( + "set -o pipefail; " + f"export BAO_TOKEN=\"$(jq -r .root_token {INIT})\"; " + "bao policy write bao-bootstrap /etc/hyperhive/bao-bootstrap-policy.hcl; " + "bao token create -policy=bao-bootstrap -ttl=24h -orphan -display-name=bao-bootstrap -field=token " + "| install -D -m 0600 /dev/stdin /var/lib/swarm-bao-bootstrap/grant.token; " + "unset BAO_TOKEN; " + "systemctl restart swarm-bao-granter-role" + ) + machine.wait_until_succeeds( + "journalctl -u swarm-bao-granter-role --no-pager | grep -F 'Uploaded policy: bao-granter'", + timeout=300, + ) + + with subtest("step 1: the policy units that waited for the granter are restarted"): + # Some of these units retry every 30 seconds until something outside + # them exists: the bao UI's viewer policy waits for authelia's secrets + # unit, in its container, to publish the UI's OIDC client secret, and + # that unit waits for the secret publisher's policy written here. So + # the units are started without waiting, and polled until all are active. + machine.succeed("systemctl reset-failed 'swarm-bao-*-policy.service' swarm-bao-agent-pki.service || true") + machine.succeed("systemctl restart --no-block 'swarm-bao-*-policy.service' swarm-bao-agent-pki.service") + machine.wait_until_succeeds("[ \"$(systemctl is-active swarm-bao-agent-pki.service)\" = active ]", timeout=600) + # Under the shamir seal the PKI unit leaves a restart of the store to the + # operator when its listener's client CA changed, and says which command. + if machine.execute("journalctl -u swarm-bao-agent-pki --no-pager | grep -F 'restart openbao.service'")[0] == 0: + machine.succeed("systemctl --machine=swarm-bao restart openbao.service") + machine.wait_until_succeeds("[ \"$(bao status >/dev/null 2>&1; echo $?)\" = 2 ]", timeout=600) + machine.succeed(f"bao operator unseal \"$(jq -r '.unseal_keys_b64[0]' {INIT})\"") + settle(POLICY_UNITS_ACTIVE, 1200, "swarm-bao-*-policy") + machine.succeed( + "journalctl -u swarm-bao-controller-policy --no-pager " + "| grep -F 'Data written to: auth/cert/certs/swarm-controller'" + ) + + with subtest("authelia's mint has published the clients' secrets"): + # The operator viewer policy is left out of the poll above: it waits for + # the UI client's secret, which authelia's secrets unit publishes from + # its container once the store and the trust bundle it needs exist. The + # controller's client secret is published by the same unit, and the + # controller, the hive's store logins and the UI policy all wait for it. + settle( + f"BAO_TOKEN=\"$(jq -r .root_token {INIT})\" bao kv get -field=value " + "secret/swarm/controller/swarm-controller/oidc/client >/dev/null", + 1200, + "authelia-swarm-secrets", + "swarm-authelia", + ) + settle("[ \"$(systemctl is-active swarm-bao-operator-viewer-policy.service)\" = active ]", 300, "swarm-bao-operator-viewer-policy") + + with subtest("step 2: swarmctl user add"): + machine.wait_until_succeeds( + "swarmctl user add mara --display-name Mara --email mara@example.com --group admins " + ">/root/user-add.out 2>&1", + timeout=900, + ) + machine.succeed("[ -s /root/user-add.out ]") + machine.succeed("swarmctl user list | grep -w mara") + + with subtest("the forge holds hyperhive/hyperhive at the fixture's commit"): + # The controller's forge token, from the store as the controller reads it. + # Minted and stored by `forgejo-swarm-controller-account` in the forge + # container, which waits for forgejo and for a login to the store. + settle( + f"BAO_TOKEN=\"$(jq -r .root_token {INIT})\" bao kv get -field=value " + "secret/swarm/controller/swarm-controller/forge/token > /root/forge-token", + 900, + ["forgejo", "forgejo-swarm-controller-account", "forgejo-sso-source"], + "hive-forge", + ) + machine.succeed( + f"umask 077 && printf 'Authorization: token %s\\n' \"$(cat /root/forge-token)\" > {FORGE_AUTH}" + ) + # The token being published says nothing about the gateway's vhost for + # the forge, which the calls below go through. + machine.wait_until_succeeds( + f"curl --fail --silent --insecure -H @{FORGE_AUTH} {FORGE}/api/v1/user >/dev/null", timeout=600 + ) + status = machine.succeed( + f"curl --silent --insecure -o /dev/null -w '%{{http_code}}' -H @{FORGE_AUTH} {FORGE}/api/v1/orgs/hyperhive" + ).strip() + if status != "200": + forge("POST", "/orgs", {"username": "hyperhive", "visibility": "public"}) + forge("POST", "/orgs/hyperhive/repos", {"name": "hyperhive", "private": False, "default_branch": "main"}) + machine.succeed("cp -r ${hyperhiveRepo}/repo.git /root/hyperhive.git && chmod -R u+w /root/hyperhive.git") + machine.succeed( + f"GIT_SSL_NO_VERIFY=1 git -C /root/hyperhive.git -c \"http.extraHeader=$(cat {FORGE_AUTH})\" " + f"push {FORGE}/hyperhive/hyperhive.git main" + ) + pushed = machine.succeed( + f"GIT_SSL_NO_VERIFY=1 git -c \"http.extraHeader=$(cat {FORGE_AUTH})\" " + f"ls-remote {FORGE}/hyperhive/hyperhive.git refs/heads/main" + ).split()[0] + assert pushed == machine.succeed("cat ${hyperhiveRepo}/rev").strip(), pushed + + with subtest("step 8: ruth, through the endpoint the swarm UI calls"): + machine.wait_until_succeeds( + f"curl --fail --silent --unix-socket {SOCKET} {API}/api/permissions >/dev/null", timeout=900 + ) + default = json.loads(controller("GET", "/api/permissions"))["default"] + create_agent({"name": "ruth", "hive": HIVE, "permissions": default + ["manage_root_agent"], "archetype": ARCHETYPE}) + + with subtest("step 9: the first agent"): + create_agent({"name": "iris", "hive": HIVE, "archetype": ARCHETYPE}) + + with subtest("internal/meta holds the flake the toplevels were built from"): + settle( + f"curl --fail --silent --insecure -H @{FORGE_AUTH} {FORGE}/api/v1/repos/internal/meta/raw/flake.nix " + ">/root/meta-flake.nix", + 1800, + "swarm-controller", + ) + rev = machine.succeed("cat ${hyperhiveRepo}/rev").strip() + settings = json.loads(${builtins.toJSON settingsJson}) + expected = open("${metaTemplate}").read().replace( + "@HYPERHIVE_URL@", f"git+{settings['git_base']}/hyperhive/hyperhive.git?rev={rev}" + ) + for name, value in settings["vars"].items(): + expected = expected.replace(f"@{name}@", value) + committed = machine.succeed("cat /root/meta-flake.nix") + assert committed == expected, "the controller's flake differs from the one the toplevels came from:\n" + committed + + # The agents' deploy and the check that they need a login are not scripted yet. + ''; + } +) diff --git a/nix/vm-tests/hyperhive-fixture-repo.nix b/nix/vm-tests/hyperhive-fixture-repo.nix new file mode 100644 index 00000000..922831ef --- /dev/null +++ b/nix/vm-tests/hyperhive-fixture-repo.nix @@ -0,0 +1,33 @@ +# The `hyperhive/hyperhive` repo the offline VM tests put on their forge: one +# commit holding the source tree of `self`, with a fixed author and date, so its +# id depends on the tree alone. +{ pkgs, self }: +let + commitEnv = '' + export HOME=$TMPDIR + export GIT_AUTHOR_NAME=fixture GIT_AUTHOR_EMAIL=fixture@forge.test + export GIT_COMMITTER_NAME=fixture GIT_COMMITTER_EMAIL=fixture@forge.test + export GIT_AUTHOR_DATE="946684800 +0000" GIT_COMMITTER_DATE="946684800 +0000" + ''; +in +{ + inherit commitEnv; + + # `repo.git` (bare, branch `main`) and `rev` (the commit id). + hyperhiveRepo = + pkgs.runCommand "hyperhive-fixture-repo" + { + nativeBuildInputs = [ pkgs.git ]; + } + '' + ${commitEnv} + cp -a ${self} work + chmod -R u+w work + git -C work init -q -b main + git -C work add -f . + git -C work commit -q -m "hyperhive source tree" + mkdir $out + git clone -q --bare work $out/repo.git + git -C work rev-parse HEAD > $out/rev + ''; +} diff --git a/nix/vm-tests/swarm-builder-offline-fixtures.nix b/nix/vm-tests/swarm-builder-offline-fixtures.nix index c3efbbe7..632de605 100644 --- a/nix/vm-tests/swarm-builder-offline-fixtures.nix +++ b/nix/vm-tests/swarm-builder-offline-fixtures.nix @@ -2,7 +2,7 @@ # prebuilds: two deterministic one-commit repos and one toplevel, all built from # `self`. # -# hyperhive/hyperhive the source tree of `self` +# hyperhive/hyperhive the source tree of `self` (./hyperhive-fixture-repo.nix) # internal/meta the meta flake of one archetype agent, pinned to the # first repo's commit # @@ -29,29 +29,7 @@ let hyperhive = self; }).nixosConfigurations.${agent}.config.system.build.toplevel; - commitEnv = '' - export HOME=$TMPDIR - export GIT_AUTHOR_NAME=fixture GIT_AUTHOR_EMAIL=fixture@forge.test - export GIT_COMMITTER_NAME=fixture GIT_COMMITTER_EMAIL=fixture@forge.test - export GIT_AUTHOR_DATE="946684800 +0000" GIT_COMMITTER_DATE="946684800 +0000" - ''; - - hyperhiveRepo = - pkgs.runCommand "hyperhive-fixture-repo" - { - nativeBuildInputs = [ pkgs.git ]; - } - '' - ${commitEnv} - cp -a ${self} work - chmod -R u+w work - git -C work init -q -b main - git -C work add -f . - git -C work commit -q -m "hyperhive source tree" - mkdir $out - git clone -q --bare work $out/repo.git - git -C work rev-parse HEAD > $out/rev - ''; + inherit (import ./hyperhive-fixture-repo.nix { inherit pkgs self; }) commitEnv hyperhiveRepo; # `nix flake lock`'s result for the template: hyperhive's own nodes next to a # `hyperhive` node pinned by URL and rev, whose follows paths start at the