Per @mara on #328: the hand-rolled encoder was over-cautious. Swap for base64 = 0.22 from crates.io — a standard, widely-trusted dep, no maintenance surface to carry. Drops the 15-line encoder and its two RFC 4648 unit tests.
649 lines
25 KiB
Rust
649 lines
25 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.
|
|
//!
|
|
//! It also mirrors each agent's hive-c0re-owned *applied* config repo
|
|
//! into the private `agent-configs` org (`push_config`), so every
|
|
//! deploy / approval tag core plants is visible on the forge. Each
|
|
//! agent is a read-only collaborator on `core/meta` (the meta flake)
|
|
//! so they can fetch their deployment context; the `agent-configs`
|
|
//! repos remain core-only.
|
|
//!
|
|
//! 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 base64::Engine;
|
|
use reqwest::StatusCode;
|
|
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";
|
|
/// Marker that records whether `ensure_core_avatar` has successfully
|
|
/// uploaded the hyperhive logo as `core`'s avatar (issue #320). One-shot:
|
|
/// the upload runs once, the marker is written, subsequent startups skip
|
|
/// the call. Delete to force re-upload.
|
|
const CORE_AVATAR_MARKER: &str = "/var/lib/hyperhive/forge-core-avatar-set";
|
|
/// Hyperhive logo bytes, baked into the daemon. Uploaded once via the
|
|
/// admin avatar API so the `core` Forgejo user shows the project mark
|
|
/// next to commits in `agent-configs/*`, `core/meta`, etc. instead of
|
|
/// the default hash identicon.
|
|
const CORE_AVATAR_PNG: &[u8] = include_bytes!("../../branding/hyperhive.png");
|
|
/// Forgejo org grouping every agent's applied config repo. Core is a
|
|
/// site admin and reads + writes every repo here; agents are NOT
|
|
/// members and the repos are private, so no agent — not even the one
|
|
/// a repo describes — can reach a config repo through the forge. The
|
|
/// applied repos stay hive-c0re-owned on disk; this org is just a
|
|
/// mirror target core pushes to.
|
|
const CONFIG_ORG: &str = "agent-configs";
|
|
/// Forgejo orgs hive-c0re ensures on startup. The meta repo lives at
|
|
/// `core/meta` (the `core` user's own namespace — no org needed).
|
|
const SEEDED_ORGS: &[&str] = &[CONFIG_ORG];
|
|
/// 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.
|
|
/// - `read:notification` — required by `forge_notify` to poll
|
|
/// `GET /notifications` for unread PR/review events.
|
|
/// - `write:notification` — required by `forge_notify` to mark
|
|
/// notifications as read via `PATCH /notifications/threads/{id}`.
|
|
const TOKEN_SCOPES: &str =
|
|
"read:user,write:user,read:notification,write:notification,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)
|
|
}
|
|
|
|
/// Canonical email address for a hive agent's Forgejo account.
|
|
/// Must match the `user.email` set by `meta::render_flake` so commits
|
|
/// by the agent link back to their Forgejo profile page.
|
|
fn agent_email(name: &str) -> String {
|
|
format!("{name}@hyperhive")
|
|
}
|
|
|
|
/// Thin Forgejo REST helper. Sends `method` to `url` with a JSON body
|
|
/// and `Authorization: token <token>`, returns the HTTP status code.
|
|
/// All Forgejo API calls that don't shell out to `forgejo admin` go
|
|
/// through here — one place for auth header, content-type, error
|
|
/// propagation, and the shared reqwest Client.
|
|
async fn forge_http(
|
|
method: reqwest::Method,
|
|
url: &str,
|
|
token: &str,
|
|
body: &str,
|
|
) -> Result<StatusCode> {
|
|
let client = reqwest::Client::new();
|
|
let resp = client
|
|
.request(method, url)
|
|
.header("Authorization", format!("token {token}"))
|
|
.header("Content-Type", "application/json")
|
|
.body(body.to_owned())
|
|
.send()
|
|
.await
|
|
.with_context(|| format!("forge HTTP request to {url}"))?;
|
|
Ok(resp.status())
|
|
}
|
|
|
|
/// 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 = agent_email(name);
|
|
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(())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Idempotently align the Forgejo account email to `agent_email(name)`.
|
|
/// Existing agents were created with `{name}@hive.local`; this corrects
|
|
/// that so git commits (which use `{name}@hyperhive`) link to profiles.
|
|
/// Best-effort: failures are warned, not propagated.
|
|
async fn ensure_user_email(name: &str) {
|
|
let email = agent_email(name);
|
|
match forge_admin(&["user", "edit", "--username", name, "--email", &email]).await {
|
|
Ok(_) => tracing::debug!(%name, %email, "forge: user email aligned"),
|
|
Err(e) => tracing::warn!(%name, error = %e, "forge: could not align user email"),
|
|
}
|
|
}
|
|
|
|
/// 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. Always re-mints the
|
|
/// token so the on-disk file always reflects the current `TOKEN_SCOPES`.
|
|
/// 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(());
|
|
}
|
|
ensure_user_exists(name, false).await?;
|
|
ensure_user_email(name).await;
|
|
mint_and_persist_token(name, &token_path(name)).await
|
|
}
|
|
|
|
/// Set `core`'s Forgejo avatar to the hyperhive logo once, then
|
|
/// remember it so subsequent startups don't re-upload (issue #320).
|
|
/// Best-effort — any non-2xx is logged at the caller; the project
|
|
/// runs fine with the default hash identicon.
|
|
async fn ensure_core_avatar(token: &str) -> Result<()> {
|
|
let marker = std::path::Path::new(CORE_AVATAR_MARKER);
|
|
if marker.exists() {
|
|
return Ok(());
|
|
}
|
|
let body = format!(
|
|
r#"{{"image":"{}"}}"#,
|
|
base64::engine::general_purpose::STANDARD.encode(CORE_AVATAR_PNG),
|
|
);
|
|
let url = format!("{FORGE_HTTP}/api/v1/admin/users/core/avatar");
|
|
let status = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
|
if !status.is_success() {
|
|
anyhow::bail!("set core avatar: HTTP {status}");
|
|
}
|
|
if let Some(parent) = marker.parent() {
|
|
std::fs::create_dir_all(parent).ok();
|
|
}
|
|
std::fs::write(marker, "").ok();
|
|
tracing::info!("forge: set core user avatar to hyperhive logo");
|
|
Ok(())
|
|
}
|
|
|
|
/// 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())
|
|
}
|
|
|
|
/// JSON body for a private, empty repo defaulting to `main`.
|
|
fn repo_body(name: &str) -> String {
|
|
format!(r#"{{"name":"{name}","auto_init":false,"private":true,"default_branch":"main"}}"#)
|
|
}
|
|
|
|
/// POST a repo-creation request to `url` and fold "already exists"
|
|
/// (HTTP 409 / 422) into success. `label` is `<owner>/<name>` — purely
|
|
/// for log + error context.
|
|
async fn create_repo(url: &str, body: &str, token: &str, label: &str) -> Result<()> {
|
|
let status = forge_http(reqwest::Method::POST, url, token, body).await?;
|
|
match status.as_u16() {
|
|
201 => {
|
|
tracing::info!(%label, "forge: created repo");
|
|
Ok(())
|
|
}
|
|
409 | 422 => {
|
|
tracing::debug!(%label, "forge: repo already exists");
|
|
Ok(())
|
|
}
|
|
other => anyhow::bail!("POST {url} ({label}) returned HTTP {other}"),
|
|
}
|
|
}
|
|
|
|
/// Create a repo in the token-owner's own namespace. `token` belongs
|
|
/// to the user we want the repo owned by (we use `core`'s token for
|
|
/// `core/meta`). Idempotent.
|
|
pub async fn ensure_repo(name: &str, token: &str) -> Result<()> {
|
|
create_repo(
|
|
&format!("{FORGE_HTTP}/api/v1/user/repos"),
|
|
&repo_body(name),
|
|
token,
|
|
&format!("core/{name}"),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Create `name` inside org `org` (used for `agent-configs/<agent>`).
|
|
/// Idempotent.
|
|
async fn ensure_org_repo(org: &str, name: &str, token: &str) -> Result<()> {
|
|
create_repo(
|
|
&format!("{FORGE_HTTP}/api/v1/orgs/{org}/repos"),
|
|
&repo_body(name),
|
|
token,
|
|
&format!("{org}/{name}"),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// 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(())
|
|
}
|
|
|
|
/// Ensure the `agent-configs/<name>` repo exists so the first
|
|
/// `push_config` doesn't 404. No-op when the forge isn't running or
|
|
/// the core token isn't minted yet. Safe to call on every spawn and
|
|
/// on every startup.
|
|
pub async fn ensure_config_repo(name: &str) -> Result<()> {
|
|
if !is_present().await {
|
|
return Ok(());
|
|
}
|
|
let Some(token) = core_token() else {
|
|
return Ok(());
|
|
};
|
|
ensure_org_repo(CONFIG_ORG, name, &token).await
|
|
}
|
|
|
|
/// Grant agent `name` read-only collaborator access to `core/meta` on
|
|
/// the forge so the agent can clone/fetch the meta flake. Idempotent:
|
|
/// HTTP 204 (already a collaborator) is treated as success.
|
|
pub async fn meta_read_access(name: &str, core_token: &str) -> Result<()> {
|
|
let url = format!("{FORGE_HTTP}/api/v1/repos/core/meta/collaborators/{name}");
|
|
let body = r#"{"permission":"read"}"#;
|
|
let out = Command::new("curl")
|
|
.args([
|
|
"-sS",
|
|
"-o",
|
|
"/dev/null",
|
|
"-w",
|
|
"%{http_code}",
|
|
"-X",
|
|
"PUT",
|
|
"-H",
|
|
"Content-Type: application/json",
|
|
"-H",
|
|
&format!("Authorization: token {core_token}"),
|
|
"-d",
|
|
body,
|
|
&url,
|
|
])
|
|
.output()
|
|
.await
|
|
.context("invoke curl PUT core/meta/collaborators")?;
|
|
let code = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
|
match code.as_str() {
|
|
"204" => {
|
|
tracing::info!(%name, "forge: granted meta read access");
|
|
Ok(())
|
|
}
|
|
other => anyhow::bail!(
|
|
"PUT core/meta/collaborators/{name} returned HTTP {other}"
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Add `http://localhost:3000/core/meta.git` as the `meta` remote in
|
|
/// the agent's proposed config repo so the agent (and the manager) can
|
|
/// fetch the meta flake from the forge. Idempotent: no-op when the
|
|
/// remote already points at the right URL, or when the proposed repo
|
|
/// does not exist yet. No-op when the forge is not running.
|
|
pub async fn ensure_meta_remote(name: &str) -> Result<()> {
|
|
if !is_present().await {
|
|
return Ok(());
|
|
}
|
|
let proposed_dir = Coordinator::agent_proposed_dir(name);
|
|
if !proposed_dir.join(".git").exists() {
|
|
return Ok(());
|
|
}
|
|
let want = format!("{FORGE_HTTP}/core/meta.git");
|
|
let existing = crate::lifecycle::git_command()
|
|
.current_dir(&proposed_dir)
|
|
.args(["remote", "get-url", "meta"])
|
|
.output()
|
|
.await
|
|
.context("git remote get-url meta")?;
|
|
if existing.status.success() {
|
|
let current = String::from_utf8_lossy(&existing.stdout).trim().to_owned();
|
|
if current == want {
|
|
return Ok(());
|
|
}
|
|
crate::lifecycle::git(&proposed_dir, &["remote", "set-url", "meta", &want]).await
|
|
} else {
|
|
crate::lifecycle::git(&proposed_dir, &["remote", "add", "meta", &want]).await
|
|
}
|
|
}
|
|
|
|
/// Mirror agent `name`'s applied config repo — `main` plus every tag
|
|
/// (`proposal` / `approved` / `building` / `deployed` / `failed` /
|
|
/// `denied`) — to `agent-configs/<name>` on the local forge.
|
|
/// Best-effort: returns Err which callers log + ignore. No-op when the
|
|
/// forge isn't seeded or the applied repo doesn't exist yet.
|
|
///
|
|
/// Call this after every hive-c0re mutation of an applied repo's refs
|
|
/// so the forge copy always reflects what core actually did. `--force`
|
|
/// because a failed build rolls `main` backwards to the last-good sha.
|
|
///
|
|
/// The tokenised URL is passed straight to `git push` and deliberately
|
|
/// never stored as a named remote: the applied repo is bind-mounted
|
|
/// READ-ONLY into the manager container (`/applied`), so a token in
|
|
/// `.git/config` would leak core's admin credential to an agent.
|
|
pub async fn push_config(name: &str) -> Result<()> {
|
|
let Some(token) = core_token() else {
|
|
return Ok(());
|
|
};
|
|
let dir = Coordinator::agent_applied_dir(name);
|
|
if !dir.join(".git").exists() {
|
|
return Ok(());
|
|
}
|
|
let url = format!("http://core:{token}@localhost:3000/{CONFIG_ORG}/{name}.git");
|
|
let out = crate::lifecycle::git_command()
|
|
.current_dir(&dir)
|
|
.args([
|
|
"push",
|
|
"--force",
|
|
&url,
|
|
"refs/heads/main:refs/heads/main",
|
|
"refs/tags/*:refs/tags/*",
|
|
])
|
|
.output()
|
|
.await
|
|
.context("invoke git push agent-configs")?;
|
|
if !out.status.success() {
|
|
anyhow::bail!(
|
|
"git push {CONFIG_ORG}/{name} failed ({}): {}",
|
|
out.status,
|
|
String::from_utf8_lossy(&out.stderr).trim()
|
|
);
|
|
}
|
|
tracing::info!(%name, "forge: mirrored applied config to agent-configs");
|
|
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 status = forge_http(reqwest::Method::POST, &url, admin_token, &body).await?;
|
|
match status.as_u16() {
|
|
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}"),
|
|
}
|
|
}
|
|
|
|
/// Per-agent forge sync: ensure the agent has a forgejo user + token,
|
|
/// a mirrored config repo, read access to `core/meta`, and the `meta`
|
|
/// remote in its proposed repo. All operations are idempotent; failures
|
|
/// are logged as warnings but don't abort the caller.
|
|
///
|
|
/// `core_token` is `core_token()` — passed in so callers that already
|
|
/// fetched it don't re-read the file. Pass `None` to skip the
|
|
/// `meta_read_access` step (safe: the access grant is best-effort).
|
|
///
|
|
/// Called by both `ensure_all()` (startup sweep) and `rebuild_agent`
|
|
/// (per-rebuild) so the two paths stay equivalent.
|
|
pub async fn sync_agent(name: &str, core_token: Option<&str>) {
|
|
if let Err(e) = ensure_user_for(name).await {
|
|
tracing::warn!(%name, error = ?e, "forge: ensure_user failed");
|
|
}
|
|
// Align email to match the git user.email set by meta::render_flake
|
|
// so commits link to the agent's Forgejo profile. Best-effort;
|
|
// also patches up agents created before this fix (old @hive.local).
|
|
ensure_user_email(name).await;
|
|
// Mirror the agent's applied config repo into agent-configs.
|
|
// ensure_config_repo is idempotent; push_config catches any
|
|
// drift since the last run — e.g. the startup migration just
|
|
// relocated `deployed/0`, or a deploy landed while the forge
|
|
// was down.
|
|
if let Err(e) = ensure_config_repo(name).await {
|
|
tracing::warn!(%name, error = ?e, "forge: ensure_config_repo failed");
|
|
}
|
|
if let Err(e) = push_config(name).await {
|
|
tracing::warn!(%name, error = ?e, "forge: push_config failed");
|
|
}
|
|
// Grant read-only access to core/meta and wire the `meta` remote
|
|
// into the proposed repo so agents can fetch their deployment context.
|
|
if let Some(token) = core_token
|
|
&& let Err(e) = meta_read_access(name, token).await {
|
|
tracing::warn!(%name, error = ?e, "forge: ensure_meta_read_access failed");
|
|
}
|
|
if let Err(e) = ensure_meta_remote(name).await {
|
|
tracing::warn!(%name, error = ?e, "forge: ensure_meta_remote failed");
|
|
}
|
|
}
|
|
|
|
/// Sweep every existing container (manager + sub-agents) and ensure
|
|
/// each has a forgejo user + token, plus an `agent-configs/<name>`
|
|
/// repo mirroring its applied config. Also seeds the `core` admin
|
|
/// user (hive-c0re's own identity for pushing the meta repo + driving
|
|
/// the API), the `agent-configs` org, and the `core/meta` repo.
|
|
/// 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");
|
|
}
|
|
if let Err(e) = ensure_core_avatar(token).await {
|
|
tracing::warn!(error = ?e, "forge: ensure_core_avatar 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;
|
|
};
|
|
sync_agent(&name, core_token.as_deref()).await;
|
|
}
|
|
}
|