diff --git a/docs/1-getting-started/nixos.md b/docs/1-getting-started/nixos.md index 411c90004..ca94489d4 100644 --- a/docs/1-getting-started/nixos.md +++ b/docs/1-getting-started/nixos.md @@ -85,6 +85,21 @@ services.hyperhive = { Run `nixos-rebuild switch --flake .#my-host`. The swarm UI then answers at `https:///`. +### The hyperhive mirror + +The swarm controller pins the hive's flake inputs to a commit of hyperhive on +the swarm's own forge, so the forge needs a `hyperhive/hyperhive` repo before +the first agent builds. On a host that runs the forge and the controller, +the controller creates it as a pull-mirror of +`https://forge.darkest.space/hyperhive/hyperhive`. You run no extra step. + +| option | what it does | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `deploy.forgejo.hyperhiveUpstream` | The clone URL the mirror pulls from. Set another URL to mirror a fork, or `null` to seed nothing and provide the repo yourself. The controller creates the mirror only if `hyperhive/hyperhive` doesn't exist, so a repo already there stays as it is. | + +Without the repo, the first agent's `update_meta` job step fails with +`hyperhive/hyperhive is empty or absent on the swarm forge`. + ## Everything else is optional Leave the other options at their defaults until you need them: diff --git a/docs/1-getting-started/setup.md b/docs/1-getting-started/setup.md index 458c6a520..0b2c9dc70 100644 --- a/docs/1-getting-started/setup.md +++ b/docs/1-getting-started/setup.md @@ -149,7 +149,10 @@ swarmctl agent create iris --hive firsthive The controller provisions iris's identity, forge user and config repo (`agent-configs/iris`, from the default template), then has the hive build -and start the container. It returns once the scheduler queues the job — watch the +and start the container. The hive builds from a copy of hyperhive on the swarm's own forge: the +controller mirrors it there as `hyperhive/hyperhive` on a fresh swarm, so the +first agent needs no extra step (see +[`nixos.md`](nixos.md#the-hyperhive-mirror)). It returns once the scheduler queues the job — watch the swarm UI's job view for progress. Later config changes are PRs on `agent-configs/iris`; you merge them on the diff --git a/docs/2-handbook/swarm/README.md b/docs/2-handbook/swarm/README.md index 7df93a914..a0897de44 100644 --- a/docs/2-handbook/swarm/README.md +++ b/docs/2-handbook/swarm/README.md @@ -412,7 +412,8 @@ one per hive. It ensures them at start and every five minutes after repo with no `main` rule alone; - the pull-mirrors from `deploy.forgejo.mirrors` on the controller's host (with the `actions/checkout` and `actions/upload-artifact` ones - `deploy.forgejo.actionsFromSelf` adds); + `deploy.forgejo.actionsFromSelf` adds, and `hyperhive/hyperhive` from + `deploy.forgejo.hyperhiveUpstream`); - `internal/docs` (private) and `internal/knowledge` (public, with a seed `README.md` while empty); - the `agent-configs` org avatar diff --git a/docs/4-implementation/scheduler/ci.md b/docs/4-implementation/scheduler/ci.md index b8d859849..796a2403f 100644 --- a/docs/4-implementation/scheduler/ci.md +++ b/docs/4-implementation/scheduler/ci.md @@ -251,6 +251,14 @@ environment (JSON-encoded `[{upstream, dest}]` list). It reads the list of the host the controller runs on: mirrors declared on any other host aren't seeded, and evaluation warns about them. +**The hyperhive mirror**: the same list carries `hyperhive/hyperhive`, from +`services.hyperhive.deploy.forgejo.hyperhiveUpstream` (default +`https://forge.darkest.space/hyperhive/hyperhive` on a host that runs the +controller, unset elsewhere). The controller creates the mirror only when +`hyperhive/hyperhive` doesn't exist, and it leaves a repo that exists but is +not a mirror unchanged. `deploy.forgejo.mirrors` takes precedence for the same +`dest`. + **General-purpose mirrors**: you can pre-seed any external repo as a pull-mirror via `services.hyperhive.deploy.forgejo.mirrors`: diff --git a/nix/host-modules/glue-forge-controller.nix b/nix/host-modules/glue-forge-controller.nix index ccce7ab19..0cc1a24f9 100644 --- a/nix/host-modules/glue-forge-controller.nix +++ b/nix/host-modules/glue-forge-controller.nix @@ -1,8 +1,9 @@ # Glue: wire the forge and the swarm-controller together when one host runs both. # # ONE PAIRING PER FILE — forge ← swarm-controller, and nothing else. Deleting -# this leaves the controller seeding no pull-mirrors and the forge resolving -# workflow actions from `data.forgejo.org`. +# this leaves the controller seeding no pull-mirrors, the forge resolving +# workflow actions from `data.forgejo.org` and a fresh swarm without the hyperhive +# repo its meta flake pins. # # ⚠️ Gated on BOTH services being here: the mirrors reach a controller on this # host only, and the forge's own `config` block hangs off @@ -21,6 +22,12 @@ in # resolve actions from itself. `mkDefault`: an operator's explicit value wins. services.hyperhive.deploy.forgejo.actionsFromSelf = lib.mkDefault true; + # The meta flake pins the hyperhive repo on this forge, and a fresh forge has + # none. The controller creates the mirror only if `hyperhive/hyperhive` is + # absent, so a swarm that hosts the real repo is not touched. + services.hyperhive.deploy.forgejo.hyperhiveUpstream = + lib.mkDefault "https://forge.darkest.space/hyperhive/hyperhive"; + # JSON-encoded list of { upstream, dest }, seeded by the controller's # forge-objects pass (`swarm-controller/src/forge/objects.rs`). systemd.services.swarm-controller.environment.SWARM_CONTROLLER_FORGE_MIRRORS = diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index e6bab15d6..295ac6a57 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -143,11 +143,17 @@ let "checkout" "upload-artifact" ]; + # The swarm-controller reads the hyperhive repo from this forge (the pin of + # the meta flake), so a swarm that has no copy mirrors it. + hyperhiveMirrors = lib.optional (deployCfg.forgejo.hyperhiveUpstream != null) { + upstream = deployCfg.forgejo.hyperhiveUpstream; + dest = "hyperhive/hyperhive"; + }; # Minus any dest the operator already declared, which would duplicate it. effectiveMirrors = deployCfg.forgejo.mirrors - ++ lib.optionals actionsFromSelf ( - lib.filter (a: !(lib.any (m: m.dest == a.dest) deployCfg.forgejo.mirrors)) actionMirrors + ++ lib.filter (a: !(lib.any (m: m.dest == a.dest) deployCfg.forgejo.mirrors)) ( + lib.optionals actionsFromSelf actionMirrors ++ hyperhiveMirrors ); # Share host netns — forgejo's HTTP / SSH listeners then look @@ -266,7 +272,8 @@ in With {option}`services.hyperhive.deploy.forgejo.actionsFromSelf` enabled (the default on a host that runs the swarm-controller), `actions/checkout` and `actions/upload-artifact` mirrors are appended - to this list. + to this list, and so is `hyperhive/hyperhive` with + {option}`services.hyperhive.deploy.forgejo.hyperhiveUpstream`. ''; }; @@ -293,13 +300,33 @@ in ''; }; + hyperhiveUpstream = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "https://forge.darkest.space/hyperhive/hyperhive"; + description = '' + Clone URL of the hyperhive repo to pull-mirror to `hyperhive/hyperhive` + on this forge, or `null` for no mirror. The swarm-controller pins the + meta flake's `hyperhive` input to a commit of that repo and every + build fetches it from there, so without it the first agent's + `update_meta` step fails and no agent container is built. + + Defaults to `https://forge.darkest.space/hyperhive/hyperhive` when this + host also runs the swarm-controller, which seeds the mirror (set by + `glue-forge-controller.nix`), and to `null` otherwise. The controller + creates the mirror only when `hyperhive/hyperhive` does not exist: a + swarm that already holds the repo as a normal repo keeps it unchanged. A `hyperhive/hyperhive` entry in + {option}`services.hyperhive.deploy.forgejo.mirrors` takes precedence. + ''; + }; + effectiveMirrors = lib.mkOption { type = lib.types.listOf lib.types.attrs; internal = true; readOnly = true; description = '' {option}`mirrors` plus the `actions/*` mirrors {option}`actionsFromSelf` - adds; what `glue-forge-controller.nix` forwards to the controller. + adds and the `hyperhive/hyperhive` mirror {option}`hyperhiveUpstream` adds; what `glue-forge-controller.nix` forwards to the controller. ''; }; }; @@ -1375,8 +1402,8 @@ in # it to be noticed as a missing repo. services.hyperhive.deploy.forgejo.effectiveMirrors = effectiveMirrors; warnings = lib.optional (effectiveMirrors != [ ] && !deployCfg.swarm-controller.enable) '' - services.hyperhive.deploy.forgejo.mirrors (or the actions/* mirrors - deploy.forgejo.actionsFromSelf adds) is set on a host that does not run the + services.hyperhive.deploy.forgejo.mirrors (or the actions/* and hyperhive mirrors + deploy.forgejo.actionsFromSelf and hyperhiveUpstream add) is set on a host that does not run the swarm-controller. The controller seeds mirrors from its own host's list, so these are not seeded: declare them on the swarm-controller's host. ''; diff --git a/nix/module-eval/swarm-services-switch.nix b/nix/module-eval/swarm-services-switch.nix index b38d81ce1..42574ad88 100644 --- a/nix/module-eval/swarm-services-switch.nix +++ b/nix/module-eval/swarm-services-switch.nix @@ -118,11 +118,13 @@ let upstream = "https://example.invalid/tool"; dest = "mirrors/tool"; }; + hyperhiveElsewhere = "https://example.invalid/hyperhive"; controllerWithMirrors = hiveWithController { deploy.swarm-controller.enable = true; deploy.forgejo.enable = true; deploy.forgejo.mirrors = [ aMirror ]; deploy.forgejo.actionsFromSelf = false; + deploy.forgejo.hyperhiveUpstream = null; deploy.swarm-controller.configOrgAvatarPng = "/etc/marker-avatar.png"; }; mirrorsNoController = hive { @@ -139,6 +141,7 @@ let deploy.forgejo.enable = true; deploy.forgejo.mirrors = [ aMirror ]; deploy.forgejo.actionsFromSelf = true; + deploy.forgejo.hyperhiveUpstream = hyperhiveElsewhere; }; # The controller with every shared service on another host, and the same @@ -1371,6 +1374,7 @@ let "mirrors/tool" "actions/checkout" "actions/upload-artifact" + "hyperhive/hyperhive" ] && actionsUrlOf controllerWithSelfActions == "self" && mirrorsOf controllerWithMirrors == [ aMirror ] @@ -1401,6 +1405,28 @@ let && actionsUrlOf forgeWithController == "self" && lib.elem "actions/checkout" (map (m: m.dest) (mirrorsOf forgeWithController)); } + { + # The meta flake pins the hyperhive repo on the swarm forge, and a fresh + # forge has none, so the controller seeds a mirror of it: by default, + # with another upstream when the option says so, and not at all when it + # is null (controllerWithMirrors) or without the controller (forgeOnly). + name = "a forge beside the controller mirrors hyperhive/hyperhive unless told not to"; + ok = + let + mirrorsOf = + m: builtins.fromJSON m.systemd.services.swarm-controller.environment.SWARM_CONTROLLER_FORGE_MIRRORS; + hyperhiveOf = m: lib.filter (e: e.dest == "hyperhive/hyperhive") (mirrorsOf m); + in + hyperhiveOf forgeWithController == [ + { + upstream = "https://forge.darkest.space/hyperhive/hyperhive"; + dest = "hyperhive/hyperhive"; + } + ] + && map (e: e.upstream) (hyperhiveOf controllerWithSelfActions) == [ hyperhiveElsewhere ] + && hyperhiveOf controllerWithMirrors == [ ] + && forgeOnly.services.hyperhive.deploy.forgejo.hyperhiveUpstream == null; + } { name = "the org avatar lands on the controller's unit, not hive-c0re's"; ok = diff --git a/nix/vm-tests/full-setup.nix b/nix/vm-tests/full-setup.nix index ab0fca5a5..198fcb82e 100644 --- a/nix/vm-tests/full-setup.nix +++ b/nix/vm-tests/full-setup.nix @@ -115,6 +115,9 @@ pkgs.testers.runNixOSTest ( deploy.hive-controller.enable = true; deploy.hive-controller.hiveName = hive; deploy.bao.seal = "shamir"; + # The test pushes its own hyperhive/hyperhive; a mirror of the + # upstream could not be created offline and would race the push. + deploy.forgejo.hyperhiveUpstream = null; swarm.domain = swarmDomain; swarm.hives.${hive} = { }; }; diff --git a/swarm-controller/src/forge/objects.rs b/swarm-controller/src/forge/objects.rs index 7d957c3a6..17ab75bde 100644 --- a/swarm-controller/src/forge/objects.rs +++ b/swarm-controller/src/forge/objects.rs @@ -311,6 +311,8 @@ struct TeamState { struct RepoState { private: bool, empty: bool, + /// A pull-mirror. A plain repo at a mirror's `dest` is left as it is. + mirror: bool, mirror_interval: Option, } @@ -485,7 +487,9 @@ fn plan(desired: &Desired, observed: &Observed) -> Vec { repo: m.repo.clone(), upstream: m.upstream.clone(), }), - Seen::Present(s) if s.mirror_interval.as_deref() != Some(MIRROR_INTERVAL) => { + Seen::Present(s) + if s.mirror && s.mirror_interval.as_deref() != Some(MIRROR_INTERVAL) => + { actions.push(Action::SetMirrorInterval { owner: m.owner.clone(), repo: m.repo.clone(), @@ -697,6 +701,7 @@ impl Client { |r| RepoState { private: r.private.unwrap_or(false), empty: r.empty.unwrap_or(false), + mirror: r.mirror.unwrap_or(false), mirror_interval: r.mirror_interval, }, ); @@ -1191,10 +1196,18 @@ mod tests { RepoState { private, empty, + mirror: false, mirror_interval: interval.map(str::to_owned), } } + fn mirror_state(interval: Option<&str>) -> RepoState { + RepoState { + mirror: true, + ..repo_state(false, false, interval) + } + } + /// Every object of [`desired`] present with its desired settings. fn converged() -> Observed { let mut o = Observed::default(); @@ -1218,7 +1231,7 @@ mod tests { ); o.repos.insert( key("actions", "checkout"), - Seen::Present(repo_state(false, false, Some(MIRROR_INTERVAL))), + Seen::Present(mirror_state(Some(MIRROR_INTERVAL))), ); o.avatar_set = true; o @@ -1299,7 +1312,7 @@ mod tests { ); o.repos.insert( (s("actions"), s("checkout")), - Seen::Present(repo_state(false, false, None)), + Seen::Present(mirror_state(None)), ); o.avatar_set = false; assert_eq!( @@ -1329,6 +1342,28 @@ mod tests { ); } + #[test] + fn a_plain_repo_at_a_mirror_dest_is_left_alone() { + let mut o = converged(); + o.repos.insert( + (s("actions"), s("checkout")), + Seen::Present(repo_state(false, false, None)), + ); + assert_eq!(plan(&desired(), &o), Vec::new()); + // Control: the same repo as a mirror with no interval is patched. + o.repos.insert( + (s("actions"), s("checkout")), + Seen::Present(mirror_state(None)), + ); + assert_eq!( + plan(&desired(), &o), + vec![Action::SetMirrorInterval { + owner: s("actions"), + repo: s("checkout"), + }] + ); + } + #[test] fn an_unreadable_object_gets_no_write() { let mut o = nothing(); diff --git a/swarm-controller/src/meta_flake.rs b/swarm-controller/src/meta_flake.rs index 4c036778f..9f3908b21 100644 --- a/swarm-controller/src/meta_flake.rs +++ b/swarm-controller/src/meta_flake.rs @@ -40,6 +40,15 @@ const REV_FILE: &str = "/etc/hyperhive/swarm-controller.json"; const HYPERHIVE_OWNER: &str = "hyperhive"; const HYPERHIVE_REPO: &str = "hyperhive"; +/// The error for a swarm forge with no readable hyperhive repo, naming the nix +/// option that mirrors one there. +fn missing_hyperhive() -> String { + format!( + "{HYPERHIVE_OWNER}/{HYPERHIVE_REPO} is empty or absent on the swarm forge; \ + services.hyperhive.deploy.forgejo.hyperhiveUpstream mirrors it there" + ) +} + /// What every agent of the swarm gets, the same on every hive. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] @@ -365,8 +374,9 @@ async fn hyperhive_pin( let Some(flake) = committed_flake else { let head = forge .head_files(HYPERHIVE_OWNER, HYPERHIVE_REPO, None, &[]) - .await? - .with_context(|| format!("{HYPERHIVE_OWNER}/{HYPERHIVE_REPO} is empty or absent"))?; + .await + .with_context(missing_hyperhive)? + .with_context(missing_hyperhive)?; return Ok(HyperhivePin { rev: head.commit, moved: false, @@ -841,6 +851,29 @@ mod tests { ); } + #[tokio::test] + async fn a_forge_without_hyperhive_names_the_option_that_mirrors_it() { + let mut forge = forge_with(&[]); + forge.repos.remove("hyperhive/hyperhive"); + let err = update( + &forge, + &FakeLocker::locked(), + &settings(), + None, + &argus_and_reviewer(), + &[], + ) + .await + .unwrap_err(); + let err = format!("{err:#}"); + assert!( + err.contains("hyperhive/hyperhive is empty or absent") + && err.contains("services.hyperhive.deploy.forgejo.hyperhiveUpstream"), + "{err}" + ); + assert!(forge.commits.lock().unwrap().is_empty()); + } + #[tokio::test] async fn a_refused_lock_commits_nothing() { let forge = forge_with(&[]);