hyperhive/hive-c0re/src/forge.rs

403 lines
15 KiB
Rust

//! Optional Forgejo wiring. When the `hive-forge` nixos-container is
//! present and running, hive-c0re ensures every agent (and the
//! manager) has a corresponding forgejo user with an API token
//! written to `<agent-state>/forge-token` — visible inside the
//! container as `/state/forge-token`. Idempotent: skips creation
//! when the user already exists, skips token issuance when the file
//! is already there.
//!
//! No-op when `hive-forge` isn't enabled (detected via
//! `nixos-container list`), so operators who don't run the bundled
//! forge pay nothing.
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use tokio::process::Command;
use crate::coordinator::Coordinator;
const FORGE_CONTAINER: &str = "hive-forge";
const FORGE_HTTP: &str = "http://localhost:3000";
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. The meta repo lives
/// at `core/meta` (the `core` user's own namespace — no org needed);
/// `agents` holds per-agent applied config repos so they're
/// visible/grouped together and access can be granted org-wide
/// (RO membership for the future shared docs/skills repo).
const SEEDED_ORGS: &[&str] = &["agents"];
/// Forgejo scopes the agent's token gets. Broad-but-not-admin: every
/// repo / PR / issue thing an agent needs day-to-day, no admin
/// surface.
/// - `write:repository` — create, clone, push, delete repos in the
/// user's own namespace; merge PRs.
/// - `write:issue` — open / comment / review issues *and* pull
/// requests (forgejo namespaces PR conversation under issues).
/// - `write:user` — edit own profile, create repos under own user.
/// - `write:organization` — create + manage orgs (lets agents share
/// a forge namespace).
/// - `read:user` — token-owner endpoint clients call to introspect.
/// - `write:misc` — hooks, attachments, the rest of the long tail.
const TOKEN_SCOPES: &str =
"read:user,write:user,write:repository,write:issue,write:organization,write:misc";
/// Token file inside the agent's bind-mounted state dir (visible as
/// `/state/forge-token` from inside the container).
fn token_path(name: &str) -> PathBuf {
Coordinator::agent_notes_dir(name).join("forge-token")
}
/// Probe whether `hive-forge` exists as a nixos-container. Cheap —
/// `nixos-container list` is just a directory scan in /etc.
pub async fn is_present() -> bool {
let Ok(out) = Command::new("nixos-container").arg("list").output().await else {
return false;
};
if !out.status.success() {
return false;
}
String::from_utf8_lossy(&out.stdout)
.lines()
.any(|l| l.trim() == FORGE_CONTAINER)
}
/// Run `forgejo admin <args>` inside the hive-forge container as the
/// forgejo user (the only uid with write access to the state dir).
/// Returns stdout on success; bails with stderr context on failure.
async fn forge_admin(args: &[&str]) -> Result<String> {
let mut cmd = Command::new("nixos-container");
// `runuser` (util-linux, always present in a NixOS container)
// beats `sudo` here — sudo isn't installed unless `security.sudo`
// is enabled, and we don't want to depend on that.
//
// `--work-path` is mandatory: without it, the admin CLI defaults
// WorkPath to `dirname(executable)` (a RO nix-store path), then
// looks for `<WorkPath>/custom/conf/app.ini` which doesn't
// exist, falls back to defaults, and F3 init tries to mkdir
// under the nix store and fatals. The systemd unit sets
// WORK_PATH for the daemon; we mirror it here for the CLI.
cmd.args([
"run",
FORGE_CONTAINER,
"--",
"runuser",
"-u",
"forgejo",
"--",
"forgejo",
"--work-path",
"/var/lib/forgejo",
"admin",
]);
cmd.args(args);
let out = cmd
.output()
.await
.context("invoke nixos-container run hive-forge -- forgejo admin")?;
if !out.status.success() {
anyhow::bail!(
"forgejo admin {} failed ({}): {}",
args.join(" "),
out.status,
String::from_utf8_lossy(&out.stderr).trim(),
);
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// Pull the access token out of forgejo's success message. Format
/// has shifted across versions (table form vs. "Access token was
/// successfully created: <hex>"), so just hunt the output for the
/// first long hex-looking word.
fn extract_token(output: &str) -> Option<String> {
output
.split(|c: char| c.is_whitespace() || c == ',' || c == ':')
.find(|w| w.len() >= 32 && w.chars().all(|c| c.is_ascii_hexdigit()))
.map(str::to_owned)
}
/// Ensure a forgejo user named `name` exists. Idempotent: forgejo
/// returns a "user already exists" error which we treat as success.
/// `admin` adds `--admin` (site admin) — used for the bootstrap
/// `core` user that drives the API.
async fn ensure_user_exists(name: &str, admin: bool) -> Result<()> {
let mut args = vec![
"user",
"create",
"--username",
name,
"--email",
];
let email = format!("{name}@hive.local");
args.push(&email);
args.extend(["--random-password", "--must-change-password=false"]);
if admin {
args.push("--admin");
}
let result = forge_admin(&args).await;
match result {
Ok(_) => {
tracing::info!(%name, "forge: created user");
Ok(())
}
Err(e) => {
// Forgejo's "already exists" error wording varies; just
// try the next step and let token issuance surface a
// real failure if the user truly isn't there.
let msg = format!("{e:#}");
if msg.contains("already exists") || msg.contains("user already") {
tracing::debug!(%name, "forge: user already exists");
Ok(())
} else {
tracing::warn!(%name, error = %msg, "forge: user create unclear; trying token anyway");
Ok(())
}
}
}
}
/// Mint a fresh access token for `name` and persist it to
/// `<state>/forge-token` (0600). Token name is suffixed with a
/// monotonic clock so re-issuing doesn't collide with an existing
/// token of the same name in the DB.
async fn mint_and_persist_token(name: &str, path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let token_name = format!(
"{TOKEN_NAME_PREFIX}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
);
let stdout = forge_admin(&[
"user",
"generate-access-token",
"--username",
name,
"--token-name",
&token_name,
"--scopes",
TOKEN_SCOPES,
])
.await?;
let token = extract_token(&stdout)
.with_context(|| format!("parse token from forgejo output: {stdout:?}"))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(path, format!("{token}\n"))
.with_context(|| format!("write token to {}", path.display()))?;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
tracing::info!(%name, path = %path.display(), %token_name, "forge: persisted access token");
Ok(())
}
/// Ensure `name` has a forgejo user + token file. No-op when the
/// token file is already present. Safe to call on every spawn and
/// on every hive-c0re startup.
pub async fn ensure_user_for(name: &str) -> Result<()> {
if !is_present().await {
return Ok(());
}
let path = token_path(name);
if path.exists() {
return Ok(());
}
ensure_user_exists(name, false).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/user/repos` to create a repo in the authenticated
/// user's own namespace. `token` belongs to the user we want the
/// repo owned by (we use `core`'s token for `core/meta`). Idempotent:
/// HTTP 409 ("repository already exists") is treated as success.
pub async fn ensure_repo(name: &str, token: &str) -> Result<()> {
let body = format!(r#"{{"name":"{name}","auto_init":false,"private":true,"default_branch":"main"}}"#);
let url = format!("{FORGE_HTTP}/api/v1/user/repos");
let out = Command::new("curl")
.args([
"-sS",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"-X",
"POST",
"-H",
"Content-Type: application/json",
"-H",
&format!("Authorization: token {token}"),
"-d",
&body,
&url,
])
.output()
.await
.context("invoke curl POST /api/v1/user/repos")?;
let code = String::from_utf8_lossy(&out.stdout).trim().to_owned();
match code.as_str() {
"201" => {
tracing::info!(%name, "forge: created repo");
Ok(())
}
"409" | "422" => {
tracing::debug!(%name, "forge: repo already exists");
Ok(())
}
other => anyhow::bail!(
"POST /api/v1/user/repos name={name} returned HTTP {other}"
),
}
}
/// Read the persisted core token, or None when the forge isn't
/// seeded yet. Cheap — just a file read.
pub fn core_token() -> Option<String> {
std::fs::read_to_string(CORE_TOKEN_PATH)
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
}
/// Push `dir` (the meta repo) to `core/meta` on the local forge.
/// Best-effort: returns Err which callers log + ignore. No-op when
/// the core token isn't present (forge not enabled).
pub async fn push_meta(dir: &Path) -> Result<()> {
let Some(token) = core_token() else {
return Ok(());
};
// Token-in-URL push. Forgejo accepts `oauth2:<token>` or just
// any-username:<token>; using `core` matches the owner so the
// remote name is self-describing.
let url = format!("http://core:{token}@localhost:3000/core/meta.git");
let out = Command::new("git")
.current_dir(dir)
.args(["push", "--force", &url, "HEAD:main"])
.output()
.await
.context("invoke git push core/meta")?;
if !out.status.success() {
anyhow::bail!(
"git push core/meta failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
tracing::info!("forge: pushed meta to core/meta");
Ok(())
}
/// 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
/// each has a forgejo user + token. Also seeds the `core` admin
/// 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() {
if !is_present().await {
tracing::debug!("forge: hive-forge container absent, skipping user sweep");
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");
}
}
// Meta repo lives at core/meta — pushed from git_commit in
// meta.rs on every deploy/lock-update. Make sure it exists
// before the first push hits a 404.
if let Err(e) = ensure_repo("meta", token).await {
tracing::warn!(error = ?e, "forge: ensure_repo core/meta failed");
}
}
let Ok(containers) = crate::lifecycle::list().await else {
tracing::warn!("forge: nixos-container list failed; skipping user sweep");
return;
};
for c in containers {
let name = if c == crate::lifecycle::MANAGER_NAME {
c
} else if let Some(n) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) {
n.to_owned()
} else {
continue;
};
if let Err(e) = ensure_user_for(&name).await {
tracing::warn!(%name, error = ?e, "forge: ensure_user failed");
}
}
}