forge: seed core admin user + 'core'/'agents' orgs on startup

new ensure_core_user_and_token mints a site-admin 'core' user with
its token at /var/lib/hyperhive/forge-core-token (root 0600) —
hive-c0re's own forge identity for pushing the meta repo + driving
the admin API. that token then drives ensure_org for 'core' (meta
repo lives here) and 'agents' (per-agent applied config repos).
both org-create calls are idempotent: HTTP 422/409 treated as
success. failures log but don't abort the rest of the sweep.

curl is shelled out from the host — already on the hive-c0re
service PATH via /run/current-system/sw, no new dep.
This commit is contained in:
müde 2026-05-17 01:47:54 +02:00
parent bf20d99142
commit db87167469

View file

@ -18,7 +18,17 @@ use tokio::process::Command;
use crate::coordinator::Coordinator; use crate::coordinator::Coordinator;
const FORGE_CONTAINER: &str = "hive-forge"; const FORGE_CONTAINER: &str = "hive-forge";
const FORGE_HTTP: &str = "http://localhost:3000";
const TOKEN_NAME_PREFIX: &str = "hyperhive"; const TOKEN_NAME_PREFIX: &str = "hyperhive";
/// Where the host-side `core` admin token lives. Used by hive-c0re
/// itself to push the meta repo + drive admin API calls (org
/// creation, future webhook setup, etc.). Root-only.
const CORE_TOKEN_PATH: &str = "/var/lib/hyperhive/forge-core-token";
/// Forgejo orgs hive-c0re ensures on startup. `core` holds the meta
/// repo (pushed by the `core` user); `agents` holds per-agent
/// applied config repos (pushed by hive-c0re on every deploy, or by
/// the manager when staging proposals).
const SEEDED_ORGS: &[&str] = &["core", "agents"];
/// Forgejo scopes the agent's token gets. Broad-but-not-admin: every /// Forgejo scopes the agent's token gets. Broad-but-not-admin: every
/// repo / PR / issue thing an agent needs day-to-day, no admin /// repo / PR / issue thing an agent needs day-to-day, no admin
/// surface. /// surface.
@ -111,18 +121,23 @@ fn extract_token(output: &str) -> Option<String> {
/// Ensure a forgejo user named `name` exists. Idempotent: forgejo /// Ensure a forgejo user named `name` exists. Idempotent: forgejo
/// returns a "user already exists" error which we treat as success. /// returns a "user already exists" error which we treat as success.
async fn ensure_user_exists(name: &str) -> Result<()> { /// `admin` adds `--admin` (site admin) — used for the bootstrap
let result = forge_admin(&[ /// `core` user that drives the API.
async fn ensure_user_exists(name: &str, admin: bool) -> Result<()> {
let mut args = vec![
"user", "user",
"create", "create",
"--username", "--username",
name, name,
"--email", "--email",
&format!("{name}@hive.local"), ];
"--random-password", let email = format!("{name}@hive.local");
"--must-change-password=false", args.push(&email);
]) args.extend(["--random-password", "--must-change-password=false"]);
.await; if admin {
args.push("--admin");
}
let result = forge_admin(&args).await;
match result { match result {
Ok(_) => { Ok(_) => {
tracing::info!(%name, "forge: created user"); tracing::info!(%name, "forge: created user");
@ -191,18 +206,97 @@ pub async fn ensure_user_for(name: &str) -> Result<()> {
if path.exists() { if path.exists() {
return Ok(()); return Ok(());
} }
ensure_user_exists(name).await?; ensure_user_exists(name, false).await?;
mint_and_persist_token(name, &path).await mint_and_persist_token(name, &path).await
} }
/// Ensure the bootstrap `core` admin user + a token at
/// `CORE_TOKEN_PATH`. The token is what hive-c0re uses for forgejo
/// API calls (org creation now, meta-repo push later). Returns the
/// token. Idempotent: skips creation when user exists, skips token
/// when the file is present.
async fn ensure_core_user_and_token() -> Result<String> {
let path = std::path::Path::new(CORE_TOKEN_PATH);
if let Ok(existing) = std::fs::read_to_string(path) {
let trimmed = existing.trim().to_owned();
if !trimmed.is_empty() {
return Ok(trimmed);
}
}
ensure_user_exists("core", true).await?;
mint_and_persist_token("core", path).await?;
let raw = std::fs::read_to_string(path)
.with_context(|| format!("read {CORE_TOKEN_PATH} after mint"))?;
Ok(raw.trim().to_owned())
}
/// POST `/api/v1/orgs` to create an org named `name`. Idempotent:
/// HTTP 422 ("user already exists") is treated as success.
async fn ensure_org(name: &str, admin_token: &str) -> Result<()> {
let body = format!(r#"{{"username":"{name}"}}"#);
let url = format!("{FORGE_HTTP}/api/v1/orgs");
let out = Command::new("curl")
.args([
"-sS",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"-X",
"POST",
"-H",
"Content-Type: application/json",
"-H",
&format!("Authorization: token {admin_token}"),
"-d",
&body,
&url,
])
.output()
.await
.context("invoke curl POST /api/v1/orgs")?;
let code = String::from_utf8_lossy(&out.stdout).trim().to_owned();
match code.as_str() {
"201" => {
tracing::info!(%name, "forge: created org");
Ok(())
}
"422" | "409" => {
tracing::debug!(%name, "forge: org already exists");
Ok(())
}
other => anyhow::bail!(
"POST /api/v1/orgs name={name} returned HTTP {other}: {}",
String::from_utf8_lossy(&out.stderr).trim()
),
}
}
/// Sweep every existing container (manager + sub-agents) and ensure /// Sweep every existing container (manager + sub-agents) and ensure
/// each has a forgejo user + token. Called once at hive-c0re /// each has a forgejo user + token. Also seeds the `core` admin
/// startup. Per-agent failures are logged but don't abort the sweep. /// user (hive-c0re's own identity for pushing the meta repo + driving
/// the API) and the `core` / `agents` orgs the system pushes into.
/// Called once at hive-c0re startup. Per-step failures are logged
/// but don't abort the sweep.
pub async fn ensure_all() { pub async fn ensure_all() {
if !is_present().await { if !is_present().await {
tracing::debug!("forge: hive-forge container absent, skipping user sweep"); tracing::debug!("forge: hive-forge container absent, skipping user sweep");
return; return;
} }
let core_token = match ensure_core_user_and_token().await {
Ok(t) => Some(t),
Err(e) => {
tracing::warn!(error = ?e, "forge: ensure_core_user_and_token failed");
None
}
};
if let Some(token) = core_token.as_deref() {
for org in SEEDED_ORGS {
if let Err(e) = ensure_org(org, token).await {
tracing::warn!(%org, error = ?e, "forge: ensure_org failed");
}
}
}
let Ok(containers) = crate::lifecycle::list().await else { let Ok(containers) = crate::lifecycle::list().await else {
tracing::warn!("forge: nixos-container list failed; skipping user sweep"); tracing::warn!("forge: nixos-container list failed; skipping user sweep");
return; return;