diff --git a/docs/swarm/builder.md b/docs/swarm/builder.md index 1ad34452..89d816fb 100644 --- a/docs/swarm/builder.md +++ b/docs/swarm/builder.md @@ -14,7 +14,10 @@ Where it runs and how it's enabled: [`services.md`](services.md#builder). swarm-controller already registers on `agent-configs`. An `opened`, `reopened` or `synchronized` action on an open PR queues one `build_agent` node carrying the agent, the head commit and the PR number. An edit, a - label or a close builds nothing. + label or a close builds nothing. Every `build_agent` node needs the one + `builder` slot, so builds run one at a time and the rest wait pending. A + newer head cancels its PR's pending build; a build already running + finishes and writes its status on its own, older commit. 2. The node sets the status to `pending`, then sends the build as a request on the queue subject `$SWARM.builder.build` and waits up to 61 minutes for the reply. @@ -67,7 +70,9 @@ creates, puts in the `hives` team of `agent-configs` (read on every config repo) and mints a `read:repository` token for, in a five-minute pass. `git` gets the token through a credential helper reading the child's environment: it's in no argument, URL or lock, so no line `nix` prints can carry it into a -status. The builder holds no write credential; swarm-controller writes the +status. The same `git` fetches a config's own inputs, so the builder registers +the helper for the forge's URL only, and the helper answers only an `https` +request for the forge's host: any other host gets nothing. The builder holds no write credential; swarm-controller writes the status with its own forge token. Details per path: [`credentials.md`](credentials.md). diff --git a/docs/swarm/services.md b/docs/swarm/services.md index 1dcdfcf3..d51c1870 100644 --- a/docs/swarm/services.md +++ b/docs/swarm/services.md @@ -244,14 +244,15 @@ 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`. `allSwarmServices` defaults it on wherever the host -has the builder's store identity, on the same terms as the review classifier. -Set `deploy.builder.enable` directly to override either way. +`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. When an `agent-configs/` PR opens, reopens or gets a new push, -swarm-controller queues a `build_agent` job. The builder builds that config at -the PR's head on the agent base its own host runs, through the -host's nix daemon, and swarm-controller writes the result as a `swarm-builder` +swarm-controller queues a `build_agent` job. Jobs run one at a time, and a +newer push replaces its PR's job while that job is still waiting. The builder +builds that config at the PR's head on the agent base its own host runs, +through the host's nix daemon, and swarm-controller writes the result as a `swarm-builder` commit status on the head. The job shows on the swarm UI's Jobs page. A red status blocks nothing: the merge still deploys. diff --git a/nix/host-modules/deploy.nix b/nix/host-modules/deploy.nix index 97f52fc2..4f905616 100644 --- a/nix/host-modules/deploy.nix +++ b/nix/host-modules/deploy.nix @@ -689,11 +689,13 @@ in this host's nix daemon, and swarm-controller writes the result as a `swarm-builder` commit status on the PR. - 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). + 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. ''; }; diff --git a/nix/host-modules/swarm-builder.nix b/nix/host-modules/swarm-builder.nix index 41cb99f6..59def0b5 100644 --- a/nix/host-modules/swarm-builder.nix +++ b/nix/host-modules/swarm-builder.nix @@ -1,8 +1,9 @@ # The swarm builder, in a `swarm-builder` container on the host that sets -# `deploy.builder.enable`, which `deploy.allSwarmServices` defaults on where -# the host has the builder's store identity. 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 replies. +# `deploy.builder.enable`, which nothing turns on for the operator: 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 +# replies. # docs/swarm/builder.md has the design. # # ⚠️ Its own network namespace on the bridge, as ./swarm-ci-runner.nix has: a diff --git a/nix/host-modules/swarm-required-services.nix b/nix/host-modules/swarm-required-services.nix index 41c9c4ad..31097135 100644 --- a/nix/host-modules/swarm-required-services.nix +++ b/nix/host-modules/swarm-required-services.nix @@ -117,11 +117,4 @@ in && deployCfg.bao.reviewClassifierClientCertFile != null && deployCfg.bao.reviewClassifierClientKeyFile != null ); - - # The swarm builder, defaulted on the same terms as the review classifier. - 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 1fd8eb53..dd2f54b2 100644 --- a/nix/module-eval/builder.nix +++ b/nix/module-eval/builder.nix @@ -33,11 +33,6 @@ let servicesHere = hiveWithController { deploy.allSwarmServices = true; }; - servicesBaoElsewhere = hive { - deploy.allSwarmServices = true; - deploy.bao.enable = false; - }; - natsHere = hive { swarm.nats.enable = true; swarm.nats.authPackage = pkgs.emptyDirectory; @@ -59,16 +54,15 @@ let ok = !(bare.containers ? swarm-builder) && withBuilder.containers ? swarm-builder; } { - # Its module asserts a store identity that only the store's host mints, - # so with the store elsewhere the default stays off rather than failing - # that host's eval. `nats` is the control that the switch is on at all. - name = "the shared services turn the builder on with the store's leaf and leave it off without"; + # 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"; ok = - servicesHere.services.hyperhive.deploy.builder.enable - && servicesHere.containers ? swarm-builder - && !servicesBaoElsewhere.services.hyperhive.deploy.builder.enable - && !(servicesBaoElsewhere.containers ? swarm-builder) - && servicesBaoElsewhere.services.hyperhive.deploy.nats.enable; + !servicesHere.services.hyperhive.deploy.builder.enable + && !(servicesHere.containers ? swarm-builder) + && servicesHere.services.hyperhive.deploy.bao.enable + && servicesHere.services.hyperhive.deploy.nats.enable; } { # A config PR is unreviewed input to an evaluation: the container keeps diff --git a/nix/module-eval/swarm-services-switch.nix b/nix/module-eval/swarm-services-switch.nix index 6a94d2da..dcd92397 100644 --- a/nix/module-eval/swarm-services-switch.nix +++ b/nix/module-eval/swarm-services-switch.nix @@ -472,7 +472,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) == 12 + lib.length (lib.attrNames serviceContainers) == 11 && lib.all ( c: c.privateNetwork == (c.config.services.hyperhive.swarmContainer.privateNetwork or null) ) (lib.attrValues serviceContainers); diff --git a/nix/reserved-names.nix b/nix/reserved-names.nix index 0869f6fd..975d0315 100644 --- a/nix/reserved-names.nix +++ b/nix/reserved-names.nix @@ -22,9 +22,9 @@ # here forbids it for both. That is the point: two lists is how they drift. # # ⚠️ Every entry must be a value some component actually PRODUCES as a message -# `from`/`to`, a component name the collector builds pipelines from, or a -# username the forge refuses — not a word that merely looked risky. A name in -# here that nothing emits or refuses is a refusal with no failure behind it. +# `from`/`to`, a component name the collector builds pipelines from, or a forge +# username the forge refuses or a swarm service owns — not a word that merely +# looked risky. A name nothing emits or refuses is a refusal with no failure. # # ⚠️ Matched by EQUALITY. Words forbidden *inside* a hive name live in # `./reserved-hive-fragments.nix` — read its header before merging the two. @@ -90,6 +90,13 @@ "repo-avatars" "user" "v2" + + # ---- forge accounts swarm-controller owns ---------------------------- + # Service accounts it creates under these logins (`forge::builder_account`, + # `forge::classifier_account`). An agent of the same name would be refused + # one node into provisioning, and would keep the service from its token. + "builder" + "review-classifier" ] # Deliberately absent, and it is a load-bearing omission: # diff --git a/swarm-builder/README.md b/swarm-builder/README.md index 6761be9b..17a60fe3 100644 --- a/swarm-builder/README.md +++ b/swarm-builder/README.md @@ -27,5 +27,6 @@ The container holds one credential: its own store certificate, which nspawn hands in at start without a copy on disk. With it the service reads its queue client secret at start and its read-only forge token at every build. `git` gets the token through a credential helper reading the child's -environment, never in a URL or an argument. Its store role reads exactly +environment, never in a URL or an argument, and the helper answers only an +`https` request for the forge's host. Its store role reads exactly those two paths. diff --git a/swarm-builder/src/main.rs b/swarm-builder/src/main.rs index ecf9a4fe..67534c8b 100644 --- a/swarm-builder/src/main.rs +++ b/swarm-builder/src/main.rs @@ -48,7 +48,7 @@ async fn main() -> Result<()> { check_flake: var("CHECK_FLAKE")?, hyperhive: var("HYPERHIVE_FLAKE")?, nixpkgs: var("NIXPKGS_FLAKE")?, - forge_url: var("FORGE_URL")?.trim_end_matches('/').to_owned(), + forge_url: forge_url()?, config_org: var("CONFIG_ORG")?, deadline: BUILD_DEADLINE, }); @@ -74,14 +74,22 @@ async fn main() -> Result<()> { .with_context(|| format!("subscribing to {SUBJECT}"))?; tracing::info!(subject = SUBJECT, "builder: awaiting builds"); + // One build at a time. swarm-controller already sends one at a time; this + // holds the bound for any request it did not serialise. + let slot = Arc::new(tokio::sync::Semaphore::new(1)); + while let Some(msg) = requests.next().await { let Some(reply_to) = msg.reply.clone() else { tracing::warn!("a build arrived with no reply subject; dropping it"); continue; }; let queue = queue.clone(); + let slot = Arc::clone(&slot); let builder = Arc::clone(&builder); tokio::spawn(async move { + let Ok(_permit) = slot.acquire().await else { + return; + }; let reply = handle(&msg.payload, &builder).await; match serde_json::to_vec(&reply) { Ok(body) => { @@ -188,3 +196,15 @@ async fn client_secret(client_id: &str) -> Result { ), } } + +/// The forge's base URL, which must be `https`: the credential helper offers +/// the token to `https` requests for its host alone. +fn forge_url() -> Result { + let name = format!("{PREFIX}_FORGE_URL"); + let url = std::env::var(&name).with_context(|| format!("{name} is unset"))?; + let url = url.trim_end_matches('/'); + if !url.starts_with("https://") || url["https://".len()..].contains('/') { + bail!("{name} is {url:?}, not https://[:port]"); + } + Ok(url.to_owned()) +} diff --git a/swarm-builder/src/nix_build.rs b/swarm-builder/src/nix_build.rs index 5b42b75d..17d47de9 100644 --- a/swarm-builder/src/nix_build.rs +++ b/swarm-builder/src/nix_build.rs @@ -5,6 +5,11 @@ //! token. The token reaches `git` through a credential helper that reads it //! from the child's environment: it is in no argument, no URL and no lock, so //! no error line `nix` prints can carry it into the reply. +//! +//! ⚠️ A config's own flake inputs are fetched by the same `git`, from hosts the +//! config names. The helper is configured for the forge's URL only, and itself +//! answers only an `https` request for the forge's host, so the token never +//! goes anywhere else. use std::path::PathBuf; use std::process::Stdio; @@ -20,6 +25,10 @@ const TAIL_BYTES: usize = 4000; /// The variable the credential helper reads the token from, in the child only. const TOKEN_VAR: &str = "SWARM_BUILDER_GIT_TOKEN"; +/// The variable holding the one host the helper answers for, in the child +/// only. +const HOST_VAR: &str = "SWARM_BUILDER_GIT_HOST"; + /// What every build is run with, fixed at start. pub struct Builder { /// The `nix` binary. @@ -30,7 +39,7 @@ pub struct Builder { pub hyperhive: String, /// The nixpkgs source this host was deployed from. pub nixpkgs: String, - /// `https://`, with no trailing slash. + /// `https://`, with no trailing slash and no path. pub forge_url: String, /// The org the config repos are in. pub config_org: String, @@ -49,7 +58,11 @@ impl Builder { .env(TOKEN_VAR, token) .env("GIT_TERMINAL_PROMPT", "0") .env("GIT_CONFIG_COUNT", "1") - .env("GIT_CONFIG_KEY_0", "credential.helper") + .env(HOST_VAR, forge_host(&self.forge_url)) + .env( + "GIT_CONFIG_KEY_0", + format!("credential.{}.helper", self.forge_url), + ) .env("GIT_CONFIG_VALUE_0", credential_helper()) .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -97,11 +110,22 @@ impl Builder { } } -/// A `git` credential helper answering every `get` with the builder's login -/// and the token from [`TOKEN_VAR`]. +/// The host part of `forge_url` (`https://[:port]`), as `git` names it +/// to a credential helper. +fn forge_host(forge_url: &str) -> &str { + let rest = forge_url.strip_prefix("https://").unwrap_or(forge_url); + rest.split('/').next().unwrap_or(rest) +} + +/// A `git` credential helper that answers a `get` with the builder's login and +/// the token from [`TOKEN_VAR`] only when the request is `https` to the host in +/// [`HOST_VAR`], and with nothing otherwise. fn credential_helper() -> String { format!( - "!f() {{ test \"$1\" = get && printf 'username=builder\\npassword=%s\\n' \"${TOKEN_VAR}\"; }}; f" + "!f() {{ test \"$1\" = get || return 0; p=; h=; \ + while IFS='=' read -r k v; do case \"$k\" in '') break ;; protocol) p=$v ;; host) h=$v ;; esac; done; \ + test -n \"${HOST_VAR}\" && test \"$p\" = https && test \"$h\" = \"${HOST_VAR}\" \ + && printf 'username=builder\\npassword=%s\\n' \"${TOKEN_VAR}\"; return 0; }}; f" ) } @@ -240,6 +264,61 @@ mod tests { assert!(args.iter().any(|a| a == "--no-write-lock-file")); } + /// What the helper prints for `op` and a request with `protocol` and + /// `host`, run the way `git` runs a `!` helper. + fn helper_answer(op: &str, protocol: &str, host: &str) -> String { + use std::io::Write as _; + + let helper = credential_helper(); + let script = format!("{} {op}", helper.trim_start_matches('!')); + let mut child = std::process::Command::new("sh") + .args(["-c", &script]) + .env(TOKEN_VAR, "s3cret") + .env(HOST_VAR, "forge.example:3000") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("sh runs"); + write!( + child.stdin.take().expect("piped"), + "protocol={protocol}\nhost={host}\npath=agent-configs/atlas\n\n" + ) + .expect("the helper reads its request"); + let out = child.wait_with_output().expect("the helper exits"); + assert!(out.status.success()); + String::from_utf8(out.stdout).expect("utf-8") + } + + #[test] + fn the_helper_gives_the_token_to_the_forge_alone() { + assert_eq!( + helper_answer("get", "https", "forge.example:3000"), + "username=builder\npassword=s3cret\n" + ); + for (op, protocol, host) in [ + ("get", "https", "github.com"), + ("get", "https", "forge.example"), + ("get", "https", "forge.example:3000.evil.example"), + ("get", "http", "forge.example:3000"), + ("store", "https", "forge.example:3000"), + ] { + assert_eq!( + helper_answer(op, protocol, host), + "", + "{op} {protocol}://{host}" + ); + } + } + + #[test] + fn the_helper_is_configured_for_the_forge_url_only() { + assert_eq!( + forge_host("https://forge.example:3000"), + "forge.example:3000" + ); + assert_eq!(forge_host("https://forge.example/sub"), "forge.example"); + } + #[test] fn the_token_is_in_no_argument() { let args = builder().args(&job()).join(" "); diff --git a/swarm-controller/src/builder.rs b/swarm-controller/src/builder.rs index cf1f1bbc..fbb288b7 100644 --- a/swarm-controller/src/builder.rs +++ b/swarm-controller/src/builder.rs @@ -4,6 +4,10 @@ //! The status is the check's only record besides the node itself: nothing is //! pushed, and the closure the builder made stays in its host's store. A red //! status blocks nothing; the merge deploys as before. +//! +//! Builds share one `SwarmResourceKind::Builder` slot, so one runs at a time, +//! and a newer push replaces its PR's build while that build is still +//! pending (`queue_build`). use std::future::Future; @@ -227,6 +231,59 @@ mod tests { ); } + /// A superseded build that finishes after the newer head's writes only on + /// its own commit, so the newer head keeps its status. + #[tokio::test] + async fn a_late_superseded_build_writes_only_on_its_own_commit() { + struct ByRevQueue; + impl BuildSink for ByRevQueue { + async fn build(&self, request: Vec) -> Result { + let sent: BuildAgent = serde_json::from_slice(&request).expect("a build request"); + Ok(if sent.rev == "old" { + Reply::Failed { + error_tail: "error: boom".to_owned(), + } + } else { + Reply::Built { + out_path: "/nix/store/new".to_owned(), + } + }) + } + } + #[derive(Default)] + struct ByRev(Mutex>); + impl StatusSink for ByRev { + async fn set( + &self, + _: &str, + rev: &str, + state: CommitStatusState, + _: &str, + ) -> anyhow::Result<()> { + self.0.lock().unwrap().push((rev.to_owned(), state)); + Ok(()) + } + } + + let statuses = ByRev::default(); + let at = |rev: &str| BuildAgent { + rev: rev.to_owned(), + ..job() + }; + run(Some(&ByRevQueue), Some(&statuses), at("new")).await; + run(Some(&ByRevQueue), Some(&statuses), at("old")).await; + let written = statuses.0.into_inner().unwrap(); + let last_on = |rev: &str| { + written + .iter() + .rev() + .find(|(r, _)| r == rev) + .map(|(_, s)| *s) + }; + assert_eq!(last_on("new"), Some(CommitStatusState::Success)); + assert_eq!(last_on("old"), Some(CommitStatusState::Failure)); + } + #[tokio::test] async fn no_builder_is_an_error_status_not_a_failure() { let (outcome, states) = diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 91375d2b..8224ba5d 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -254,14 +254,20 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind { } } -/// Placeholder resource name — same rationale and same "no variants until a -/// real node needs one" shape as [`SwarmNodeKind`]. +/// A named counting semaphore a node acquires before it starts. Every name +/// has capacity 1: the scheduler's default, which no caller overrides. #[derive(Clone, Debug, PartialEq, Eq, Hash)] -enum SwarmResourceKind {} +enum SwarmResourceKind { + /// The swarm builder: one `BuildAgent` node runs at a time, and the rest + /// wait pending, where a newer push can replace them. + Builder, +} impl hive_jobq_wire::WireResource for SwarmResourceKind { fn name(&self) -> String { - match *self {} + match self { + SwarmResourceKind::Builder => "builder".to_owned(), + } } } @@ -1960,29 +1966,58 @@ fn claiming_hives( /// whichever hive the agent runs: the check builds on the builder's own /// agent base. fn queue_config_build(state: &AppState, changed: config_pr::ChangedConfigPr) { - let config_pr::ChangedConfigPr { agent, rev, pr } = changed; - let inserted = state - .jobq - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert_job(None, |b| { - vec![ - b.node(SwarmNodeKind::BuildAgent { - agent: agent.clone(), - rev: rev.clone(), - pr, - }) - .guid(), - ] - }); - match inserted { - Ok(_) => tracing::info!(%agent, %rev, pr, "config-pr head: build queued"), + let (agent, rev, pr) = (changed.agent.clone(), changed.rev.clone(), changed.pr); + let queued = queue_build( + &mut state + .jobq + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + changed, + ); + match queued { + Ok(superseded) => { + tracing::info!(%agent, %rev, pr, superseded, "config-pr head: build queued"); + } Err(e) => { tracing::warn!(%agent, %rev, pr, error = %e, "config-pr head: queueing the build failed"); } } } +/// Cancel every pending build of the same PR, then insert the new one behind +/// [`SwarmResourceKind::Builder`]. A build already running is left to finish: +/// its status lands on its own commit, not on the new head. Returns how many +/// pending builds were cancelled. +fn queue_build( + sched: &mut hive_jobq::scheduler::Scheduler, + changed: config_pr::ChangedConfigPr, +) -> Result { + let config_pr::ChangedConfigPr { agent, rev, pr } = changed; + let stale: Vec = sched + .graph() + .nodes() + .filter(|n| n.state == hive_jobq::State::Pending) + .filter(|n| { + matches!(&n.payload, SwarmNodeKind::BuildAgent { agent: a, pr: p, .. } if *a == agent && *p == pr) + }) + .map(|n| n.id) + .collect(); + let superseded = stale + .into_iter() + .filter(|&id| sched.cancel_node(id)) + .count(); + sched + .insert_job(None, |b| { + vec![ + b.node(SwarmNodeKind::BuildAgent { agent, rev, pr }) + .needs(SwarmResourceKind::Builder) + .guid(), + ] + }) + .map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(superseded) +} + /// Queue the deploy of a merged config PR on the one hive that runs its agent. /// /// The hive is found by scanning every hive's wanted state, since nothing @@ -4147,6 +4182,101 @@ mod tests { } } + fn changed(agent: &str, rev: &str, pr: u64) -> crate::config_pr::ChangedConfigPr { + crate::config_pr::ChangedConfigPr { + agent: agent.to_owned(), + rev: rev.to_owned(), + pr, + } + } + + fn build_state( + sched: &hive_jobq::scheduler::Scheduler, + rev: &str, + ) -> hive_jobq::State { + sched + .graph() + .nodes() + .find(|n| matches!(&n.payload, SwarmNodeKind::BuildAgent { rev: r, .. } if r == rev)) + .map(|n| n.state) + .expect("a build of that rev") + } + + /// A newer head of the same PR replaces its pending build; another PR's + /// build, and the same PR's running build, are left alone. + #[tokio::test] + async fn a_newer_push_replaces_the_prs_pending_build_only() { + let sched = + std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new( + hive_jobq::Graph::new(), + hive_jobq::resources::ResourceTable::new(), + ))); + let queue = |c| super::queue_build(&mut sched.lock().unwrap(), c).expect("queued"); + assert_eq!(queue(changed("atlas", "r1", 3)), 0); + // r1 takes the builder and stays running: its future is never awaited. + let _running = hive_jobq::scheduler::Scheduler::claim_next(&sched, |_, _, b| async move { + (b, hive_jobq::scheduler::Outcome::Done) + }) + .expect("r1 is runnable"); + assert_eq!( + queue(changed("atlas", "r2", 3)), + 0, + "r1 is running, not pending" + ); + assert_eq!(queue(changed("argus", "o1", 4)), 0); + assert_eq!(queue(changed("atlas", "r3", 3)), 1, "r2 was pending"); + + let guard = sched.lock().unwrap(); + assert_eq!(build_state(&guard, "r1"), hive_jobq::State::Running); + assert_eq!(build_state(&guard, "r2"), hive_jobq::State::Cancelled); + assert_eq!(build_state(&guard, "r3"), hive_jobq::State::Pending); + assert_eq!(build_state(&guard, "o1"), hive_jobq::State::Pending); + } + + /// Builds share one builder slot: a second build is not claimable while + /// the first runs, and is once it has finished. + #[tokio::test] + async fn one_build_runs_at_a_time() { + let sched = + std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new( + hive_jobq::Graph::new(), + hive_jobq::resources::ResourceTable::new(), + ))); + for c in [changed("atlas", "a1", 1), changed("argus", "b1", 2)] { + super::queue_build(&mut sched.lock().unwrap(), c).expect("queued"); + } + let run = |sched| { + hive_jobq::scheduler::Scheduler::claim_next(sched, |_, _, b| async move { + (b, hive_jobq::scheduler::Outcome::Done) + }) + }; + let first = run(&sched).expect("one build is runnable"); + assert!( + run(&sched).is_none(), + "a second build started beside the first" + ); + first.await.1.expect("no growth"); + run(&sched) + .expect("the second build runs once the first is done") + .await + .1 + .expect("no growth"); + } + + #[test] + fn a_new_agent_named_like_a_service_forge_account_is_refused() { + let raw = nix_reserved(); + for name in [ + crate::forge::builder_account::LOGIN, + crate::forge::classifier_account::LOGIN, + ] { + let mut warnings = Vec::new(); + let broken = super::broken_name_rules(name, Some(&raw), &mut warnings); + let err = super::name_verdict(name, &broken, Ok(false), &mut warnings).expect_err(name); + assert!(err.contains("nix/reserved-names.nix"), "{name:?}: {err}"); + } + } + /// One case per shape rule, each named in the refusal. Checked against a /// list without them, so the literal half cannot carry this test. #[test]