Watch
0
0
Fork
You've already forked hyperhive
0

swarm-controller gains forge access when the token arrives, without a restart

A controller that started before the forge's host had published its token
ran without forge access until someone restarted it: on a split-host first
deploy that is a manual step. Now the forge client lives in a slot that is
filled once (`Late<T>`, an `Arc<OnceLock<T>>`):

- `forge::enable` tries the store once at start. On failure it logs once and
  re-reads in the background with backoff (30s doubling to a 5-minute cap,
  no log per retry), fills the slot and logs once when the token is there.
- The forge passes (#5014's per-hive token mint among them), the config-PR
  poll and the webhook registration start from that moment, through
  `start_forge`'s callback.
- Job nodes and HTTP handlers read the slot when they run, so they answer
  "no forge" before and work after.

A token replaced in the store after the slot is filled still reaches the
controller with its next restart. The round-1 change that shared
`queue_identity::fetch_with_retry` is reverted; nothing else uses it.

Refs #4998
This commit is contained in:
atlas 2026-10-08 13:40:24 +02:00
commit c45f7a5330
11 changed files with 243 additions and 135 deletions

View file

@ -234,6 +234,10 @@ impl hive_jobq_wire::WireResource for SwarmResourceKind {
}
}
/// A dependency this daemon may only gain after it starts serving: empty
/// until then, filled once. Readers take what is there at the moment they ask.
type Late<T> = Arc<std::sync::OnceLock<T>>;
/// Everything a claimed node's executor arm might need to reach outside
/// this process — bundled into one `Clone` struct rather than growing
/// `run_swarm_node`'s parameter list per node kind (three forge-shaped
@ -245,7 +249,9 @@ impl hive_jobq_wire::WireResource for SwarmResourceKind {
#[derive(Clone)]
struct WorkerDeps {
auth: Option<std::sync::Arc<auth::AuthBridge>>,
forge: Option<std::sync::Arc<forge::Client>>,
/// Empty until the forge token has been read (`forge::enable`), so a node
/// run before then fails as "no forge access", and one run after succeeds.
forge: Late<Arc<forge::Client>>,
/// The swarm queue connection, for nodes whose effect is a published
/// event rather than an API call. A handle rather than the status
/// reader it is cloned from: a node that announces a deploy has no
@ -288,6 +294,7 @@ async fn run_swarm_node(
) {
use hive_jobq::scheduler::Outcome;
let forge = deps.forge.get().cloned();
let outcome = match kind {
SwarmNodeKind::CreateIdentity { agent } => match deps.auth {
None => {
@ -311,7 +318,7 @@ async fn run_swarm_node(
Err(e) => Outcome::Failed(format!("{e:#}")),
},
},
SwarmNodeKind::CreateRepo { agent } => match deps.forge {
SwarmNodeKind::CreateRepo { agent } => match forge {
None => Outcome::Failed(
"no forge access here (SWARM_CONTROLLER_FORGE_URL unset, or its token \
not in the swarm secret store)"
@ -325,7 +332,7 @@ async fn run_swarm_node(
Err(e) => Outcome::Failed(format!("{e:#}")),
},
},
SwarmNodeKind::CreateForgeUser { agent } => match deps.forge {
SwarmNodeKind::CreateForgeUser { agent } => match forge {
None => Outcome::Failed(
"no forge access here (SWARM_CONTROLLER_FORGE_URL unset, or its token \
not in the swarm secret store)"
@ -336,7 +343,7 @@ async fn run_swarm_node(
Err(e) => Outcome::Failed(format!("{e:#}")),
},
},
SwarmNodeKind::AddRepoMember { agent } => match deps.forge {
SwarmNodeKind::AddRepoMember { agent } => match forge {
None => Outcome::Failed(
"no forge access here (SWARM_CONTROLLER_FORGE_URL unset, or its token \
not in the swarm secret store)"
@ -347,8 +354,8 @@ async fn run_swarm_node(
Err(e) => Outcome::Failed(format!("{e:#}")),
},
},
SwarmNodeKind::GrantDocsRead { agent } => grant_docs_read(deps.forge, &agent).await,
SwarmNodeKind::InitAgentConfigRepo { agent } => match deps.forge {
SwarmNodeKind::GrantDocsRead { agent } => grant_docs_read(forge, &agent).await,
SwarmNodeKind::InitAgentConfigRepo { agent } => match forge {
None => Outcome::Failed(
"no forge access here (SWARM_CONTROLLER_FORGE_URL unset, or its token \
not in the swarm secret store)"
@ -360,16 +367,14 @@ async fn run_swarm_node(
},
},
SwarmNodeKind::MintAgentIdentity { agent } => mint_identity(&agent).await,
SwarmNodeKind::MintAgentForgeToken { agent } => mint_forge_token(deps.forge, &agent).await,
SwarmNodeKind::MintAgentForgeToken { agent } => mint_forge_token(forge, &agent).await,
SwarmNodeKind::MintAgentMatrixAccount { agent } => {
mint_matrix_account(deps.matrix_homeserver.as_deref(), &agent).await
}
SwarmNodeKind::MintHiveSenderToken { hive } => {
mint_hive_sender(deps.matrix_homeserver.as_deref(), &hive).await
}
SwarmNodeKind::MintHiveForgeToken { hive } => {
mint_hive_forge_token(deps.forge, &hive).await
}
SwarmNodeKind::MintHiveForgeToken { hive } => mint_hive_forge_token(forge, &hive).await,
SwarmNodeKind::RenewAgentQueueCredential { agent } => renew_queue_credential(&agent).await,
SwarmNodeKind::SetAgentWanted { hive, agent } => match deps.wanted {
None => Outcome::Failed(
@ -774,10 +779,10 @@ struct AppState {
/// takes without a second hop through `String`.
webhook_secret: Option<Arc<str>>,
/// Last successful `agent-configs/*` scan, kept current by
/// [`config_pr::spawn`]. `None` when this host has no forge configured
/// — same "absent means don't ask" shape as `status` and `forge` above,
/// not a startup failure.
config_prs: Option<Arc<config_pr::ConfigPrCache>>,
/// [`config_pr::spawn`]. Empty while this daemon has no forge access —
/// same "absent means don't ask" shape as `status` and `forge`, not a
/// startup failure — and filled when the forge client is.
config_prs: Late<Arc<config_pr::ConfigPrCache>>,
/// The swarm's human display name (`services.hyperhive.swarm.name`),
/// loaded once at startup (`load_swarm_name`). `None` when the
/// operator never set it — a swarm without a display name is a
@ -788,10 +793,9 @@ struct AppState {
/// The forge client itself, for the read-only repo/issue-report
/// routes (`GET /api/repos`, `GET /api/repos/{org}/{repo}/issue-report`)
/// and `POST /api/forge/users/{name}/admin` — distinct from `config_prs`, which holds a *cache* built off this
/// same client rather than the client. `None` under the same
/// "no forge configured on this host" shape every other
/// forge-backed field here uses.
forge: Option<Arc<forge::Client>>,
/// same client rather than the client. Empty while this daemon has no
/// forge access, and filled the moment `forge::enable` builds the client.
forge: Late<Arc<forge::Client>>,
/// Held by `create_agent` from reading where a name is placed until its
/// graph is queued, and by `set_agent_state` from that read until its
/// declaration is written, so two placements of one name cannot both
@ -1433,7 +1437,7 @@ async fn get_agents_status(
})?;
match reader.view(&roster, std::time::SystemTime::now()).await {
Ok(mut rows) => {
if let Some(cache) = state.config_prs.as_ref() {
if let Some(cache) = state.config_prs.get() {
let mut config_prs = cache.snapshot();
for row in &mut rows {
row.config_pr = config_prs.remove(&row.name);
@ -2178,7 +2182,7 @@ async fn get_agent_config_pr(
State(state): State<AppState>,
Path(name): Path<String>,
) -> Result<Json<Option<forge::ConfigPrStatus>>, StatusUnavailable> {
let Some(cache) = state.config_prs.as_ref() else {
let Some(cache) = state.config_prs.get() else {
return Err(StatusUnavailable(
"no forge is configured on this host".to_owned(),
));
@ -2443,16 +2447,13 @@ fn spawn_matrix_account_backfill(
/// registration token, hives' read tokens, agents' `internal/docs` grants, and
/// agents' tokens (which also
/// sweeps legacy tokens; see `forge::legacy_tokens`) —
/// when a forge is configured. Lifted out of `main` for
/// once there is a forge client. Lifted out of `main` for
/// `clippy::too_many_lines`.
fn spawn_forge_workers(
jobq: &Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
forge_client: Option<Arc<forge::Client>>,
client: Arc<forge::Client>,
hives: &[HiveEntry],
) {
let Some(client) = forge_client else {
return;
};
forge::objects::spawn(Arc::clone(&client));
forge::runner_token::spawn(Arc::clone(&client));
let sched = Arc::clone(jobq);
@ -2648,7 +2649,7 @@ async fn make_forge_admin(
let name = hive_types::Ident::parse(&name)
.map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))?
.into_string();
let Some(forge) = state.forge.clone() else {
let Some(forge) = state.forge.get().cloned() else {
return Err(error_problem(
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"no forge is configured on this host",
@ -2828,7 +2829,7 @@ async fn get_agent_icon(
async fn get_config_prs(
State(state): State<AppState>,
) -> Result<Json<std::collections::HashMap<String, forge::ConfigPrStatus>>, StatusUnavailable> {
let Some(cache) = state.config_prs.as_ref() else {
let Some(cache) = state.config_prs.get() else {
return Err(StatusUnavailable(
"no forge is configured on this host".to_owned(),
));
@ -2862,12 +2863,11 @@ const PUBLIC_URL_ENV: &str = "SWARM_CONTROLLER_PUBLIC_URL";
/// daemon keeps running — and the failure is silent at both ends, because the
/// forge has nothing to report about a call that never arrived.
///
/// Silently does nothing when any of the three preconditions is missing —
/// each is a legitimate deployment shape (no forge here, no state directory
/// to hold a secret, no public vhost), and each is already logged where it
/// is discovered.
fn register_swarm_webhooks(forge: Option<Arc<forge::Client>>, secret: Option<Arc<str>>) {
let Some(forge) = forge else { return };
/// Runs once there is a forge client, and silently does nothing when either
/// other precondition is missing — each is a legitimate deployment shape (no
/// state directory to hold a secret, no public vhost), and each is already
/// logged where it is discovered.
fn register_swarm_webhooks(forge: Arc<forge::Client>, secret: Option<Arc<str>>) {
let Some(secret) = secret else { return };
let Ok(public_url) = std::env::var(PUBLIC_URL_ENV) else {
tracing::info!(
@ -2953,18 +2953,32 @@ async fn connect_status_reader() -> Result<Option<Arc<status::StatusReader>>> {
}
}
/// Clones `forge_client` for [`AppState::forge`] before handing the
/// original off to [`register_swarm_webhooks`], which takes it by value —
/// split out of `main` purely to keep that function under clippy's
/// line-count lint (same rationale as [`connect_status_reader`]'s own doc
/// comment), no behavior change.
fn keep_forge_for_state(
forge_client: Option<Arc<forge::Client>>,
/// Fill the forge slots and start everything that runs off the forge client —
/// its periodic passes, the config-PR poll and the webhook registration — now
/// if the token is in the store, otherwise when `forge::enable`'s background
/// re-read finds it. Nothing to start when no forge URL is configured. Lifted
/// out of `main` for `clippy::too_many_lines`.
async fn start_forge(
forge_client: Late<Arc<forge::Client>>,
config_prs: Late<Arc<config_pr::ConfigPrCache>>,
jobq: Arc<Mutex<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>>,
hives: Vec<HiveEntry>,
webhook_secret: Option<Arc<str>>,
) -> Option<Arc<forge::Client>> {
let state_forge = forge_client.clone();
register_swarm_webhooks(forge_client, webhook_secret);
state_forge
) {
let url = match forge::url_from_env() {
Ok(Some(url)) => url,
Ok(None) => return,
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "forge misconfigured; repo provisioning is off");
return;
}
};
forge::enable(url, forge_client, move |client| {
spawn_forge_workers(&jobq, Arc::clone(&client), &hives);
config_prs.get_or_init(|| config_pr::spawn(Arc::clone(&client)));
register_swarm_webhooks(client, webhook_secret);
})
.await;
}
#[tokio::main]
@ -3019,18 +3033,13 @@ async fn main() -> Result<()> {
};
// Same shape again: a controller with no forge access still serves
// everything else, and the forge-shaped node kinds give an honest
// per-job failure rather than this fn refusing to start. A token missing
// from the store lands here too, after `from_env`'s retries.
let forge_client = match forge::Client::from_env().await {
Ok(client) => client.map(Arc::new),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "no forge access; repo provisioning is off");
None
}
};
// per-job failure rather than this fn refusing to start. Both slots stay
// empty until `start_forge` below fills them.
let forge_client: Late<Arc<forge::Client>> = Arc::default();
let config_prs: Late<Arc<config_pr::ConfigPrCache>> = Arc::default();
let deps = WorkerDeps {
auth: auth.clone(),
forge: forge_client.clone(),
forge: Arc::clone(&forge_client),
queue: status.as_ref().map(|s| s.queue_client()),
wanted: wanted_writer(status.as_ref()),
matrix_homeserver: configured_matrix_homeserver(),
@ -3094,9 +3103,14 @@ async fn main() -> Result<()> {
);
}
spawn_forge_workers(&jobq, forge_client.clone(), &hives);
let config_prs = forge_client.clone().map(config_pr::spawn);
let state_forge = keep_forge_for_state(forge_client, webhook_secret.clone());
start_forge(
Arc::clone(&forge_client),
Arc::clone(&config_prs),
Arc::clone(&jobq),
hives.clone(),
webhook_secret.clone(),
)
.await;
spawn_agent_renewal(&jobq, wanted_writer(status.as_ref()), &hives);
spawn_per_agent_upkeep(status.as_ref(), &hives);
@ -3123,7 +3137,7 @@ async fn main() -> Result<()> {
config_prs,
swarm_name: load_swarm_name().map(Arc::from),
auth,
forge: state_forge,
forge: forge_client,
create_gate: Arc::default(),
accounts: linked_accounts::Accounts::Live,
};
@ -3295,13 +3309,13 @@ mod tests {
agent_icons: None,
jobq: std::sync::Arc::clone(&sched),
webhook_secret: None,
config_prs: None,
config_prs: std::sync::Arc::default(),
swarm_name: None,
// No bridge: these tests drive agent *creation*, which queues a
// job and consults one only for a rule-breaking name. The roster
// read is the verb that needs it, and it has its own test below.
auth: None,
forge: None,
forge: std::sync::Arc::default(),
create_gate: std::sync::Arc::default(),
accounts: super::linked_accounts::Accounts::Fake(std::sync::Arc::default()),
};
@ -4197,7 +4211,7 @@ mod tests {
let deps = WorkerDeps {
auth: None,
forge: None,
forge: std::sync::Arc::default(),
queue: None,
wanted: None,
matrix_homeserver: None,
@ -4250,7 +4264,7 @@ mod tests {
let deps = WorkerDeps {
auth: None,
forge: None,
forge: std::sync::Arc::default(),
queue: None,
wanted: None,
matrix_homeserver: None,
@ -4313,7 +4327,7 @@ mod tests {
let deps = WorkerDeps {
auth: None,
forge: None,
forge: std::sync::Arc::default(),
queue: None,
wanted: None,
matrix_homeserver: None,
@ -4364,7 +4378,7 @@ mod tests {
let deps = WorkerDeps {
auth: None,
forge: None,
forge: std::sync::Arc::default(),
queue: None,
wanted: None,
matrix_homeserver: None,