refactor(hive-c0re): split forge into submodules
mod.rs keeps the shared admin/http helpers + ensure_all/sync_agent; user/token provisioning, repo/org/mirror ops, and the trust-boundary PR-merge primitives move to users.rs / repos.rs / pr_merge.rs
This commit is contained in:
parent
8cdebb1752
commit
a17015f01e
4 changed files with 1468 additions and 1409 deletions
File diff suppressed because it is too large
Load diff
295
hive-c0re/src/forge/pr_merge.rs
Normal file
295
hive-c0re/src/forge/pr_merge.rs
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
//! PR-based config-flow merge primitives — the forge-side mechanics
|
||||
//! hive-c0re's approve-handler (`run_merge_config_pr`) orchestrates to
|
||||
//! land an operator-approved config PR. Part of the operator trust
|
||||
//! boundary; moved verbatim from the `forge` module root.
|
||||
|
||||
use anyhow::Context;
|
||||
|
||||
use crate::coordinator::Coordinator;
|
||||
|
||||
use super::{CONFIG_ORG, FORGE_HTTP, core_token, forge_http};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PR-based config-flow merge primitives (part of the
|
||||
// dashboard-approve-driven config-change flow).
|
||||
//
|
||||
// The dashboard-approve-driven flow has hive-c0re verify an operator-approved
|
||||
// config PR, then land it: ff-push the verified sha to the protected default
|
||||
// branch (= the merge) and mark the PR merged manually. These three fns are
|
||||
// the forge-side mechanics the c0re approve-handler (`run_merge_config_pr`)
|
||||
// orchestrates; the orchestration fetches the verified sha into the agent's
|
||||
// applied repo before calling `ff_push_to_main`. The core token is sourced
|
||||
// internally (`core_token`), never passed in. `repo` is the agent's editable
|
||||
// forge config repo in `owner/name` form (e.g. `agent-configs/<agent>`).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Typed failure for the merge primitives so the c0re approve-handler can
|
||||
/// `match` recoverable drift (refresh the request sha + re-verify) against a
|
||||
/// hard failure (fail the approval). The two drift variants carry the observed
|
||||
/// sha so the handler can re-pin to it; `Other` is everything else (transport,
|
||||
/// API, unexpected) and is not auto-retried.
|
||||
#[derive(Debug)]
|
||||
pub enum ForgeMergeError {
|
||||
/// The PR head moved off `expected` (now at `actual`), seen at
|
||||
/// mark-merged time. The handler's pre-merge `pr_head_sha` re-read is the
|
||||
/// primary drift gate; this is the belt-and-suspenders race catch.
|
||||
HeadDrift { expected: String, actual: String },
|
||||
/// `main` (`actual_head`) is not a descendant of `expected_ancestor`, so
|
||||
/// pushing the verified sha would not be a fast-forward — `main` raced.
|
||||
NotFastForward {
|
||||
expected_ancestor: String,
|
||||
actual_head: String,
|
||||
},
|
||||
/// Transport / API / unexpected failure — hard-fail, no auto-retry.
|
||||
Other(anyhow::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ForgeMergeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::HeadDrift { expected, actual } => {
|
||||
write!(f, "PR head drifted: expected {expected}, found {actual}")
|
||||
}
|
||||
Self::NotFastForward {
|
||||
expected_ancestor,
|
||||
actual_head,
|
||||
} => write!(
|
||||
f,
|
||||
"not a fast-forward: main {actual_head} is not a descendant of {expected_ancestor}"
|
||||
),
|
||||
Self::Other(e) => write!(f, "{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ForgeMergeError {}
|
||||
|
||||
impl From<anyhow::Error> for ForgeMergeError {
|
||||
fn from(e: anyhow::Error) -> Self {
|
||||
Self::Other(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Agent name from an `owner/name` forge repo string (the trailing segment).
|
||||
fn repo_agent_name(repo: &str) -> &str {
|
||||
repo.rsplit('/').next().unwrap_or(repo)
|
||||
}
|
||||
|
||||
/// Token-in-URL clone/push URL for a forge repo (`owner/name`). Mirrors
|
||||
/// `push_config`'s pattern; the token is passed straight to git and never
|
||||
/// stored as a named remote.
|
||||
fn tokenised_repo_url(repo: &str, token: &str) -> String {
|
||||
format!("http://core:{token}@localhost:3000/{repo}.git")
|
||||
}
|
||||
|
||||
/// Resolve a PR's head sha via `git ls-remote <repo> refs/pull/<pr>/head`
|
||||
/// (Forgejo exposes PR heads there). Pure read, no mutation — the handler's
|
||||
/// primary drift gate (compare against the approved sha), and `mark_pr_merged`
|
||||
/// uses it to detect head-drift on failure.
|
||||
///
|
||||
/// # Errors
|
||||
/// `Other` on transport failure or an empty/missing ref.
|
||||
pub async fn pr_head_sha(repo: &str, pr: u64) -> Result<String, ForgeMergeError> {
|
||||
let token = core_token()
|
||||
.ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?;
|
||||
let url = tokenised_repo_url(repo, &token);
|
||||
let refspec = format!("refs/pull/{pr}/head");
|
||||
let out = crate::lifecycle::git_command()
|
||||
.args(["ls-remote", &url, &refspec])
|
||||
.output()
|
||||
.await
|
||||
.context("git ls-remote PR head")?;
|
||||
if !out.status.success() {
|
||||
return Err(ForgeMergeError::Other(anyhow::anyhow!(
|
||||
"git ls-remote {repo} {refspec} failed ({}): {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
)));
|
||||
}
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let sha = stdout
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| {
|
||||
ForgeMergeError::Other(anyhow::anyhow!(
|
||||
"no head ref for PR #{pr} in {repo} (ls-remote empty)"
|
||||
))
|
||||
})?;
|
||||
Ok(sha.to_string())
|
||||
}
|
||||
|
||||
/// Full `owner/name` path of an agent's config repo on the forge — the
|
||||
/// `agent-configs` org mirror that the PR-merge flow reads + fast-forwards.
|
||||
pub fn config_repo(agent: &str) -> String {
|
||||
format!("{CONFIG_ORG}/{agent}")
|
||||
}
|
||||
|
||||
/// Fetch PR #`pr`'s head into the agent's applied repo via
|
||||
/// `refs/pull/<pr>/head` (which Forgejo always serves — a bare-sha fetch can
|
||||
/// be refused by uploadpack policy). This makes the reviewed head an object
|
||||
/// in the applied repo so the ancestor check in [`ff_push_to_main`], the
|
||||
/// `git_update_ref(main, …)` in the deploy tail, and the eval-verify all
|
||||
/// resolve it locally before the irreversible push.
|
||||
///
|
||||
/// # Errors
|
||||
/// `Other` on transport failure or a non-zero git exit.
|
||||
pub async fn fetch_pr_head_into_applied(repo: &str, pr: u64) -> Result<(), ForgeMergeError> {
|
||||
let token = core_token()
|
||||
.ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?;
|
||||
let url = tokenised_repo_url(repo, &token);
|
||||
let applied = Coordinator::agent_applied_dir(repo_agent_name(repo));
|
||||
let refspec = format!("refs/pull/{pr}/head");
|
||||
let out = crate::lifecycle::git_command()
|
||||
.current_dir(&applied)
|
||||
.args(["fetch", "--no-tags", &url, &refspec])
|
||||
.output()
|
||||
.await
|
||||
.context("git fetch PR head into applied")?;
|
||||
if !out.status.success() {
|
||||
return Err(ForgeMergeError::Other(anyhow::anyhow!(
|
||||
"git fetch {repo} {refspec} into applied failed ({}): {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fast-forward the forge repo's `main` to `sha` — THE merge in the PR flow.
|
||||
/// Reads `main`'s current sha (`git ls-remote … refs/heads/main`), verifies it
|
||||
/// is a strict ancestor of `sha` (`git merge-base --is-ancestor`, run in the
|
||||
/// agent's applied repo where the orchestration has already fetched `sha`),
|
||||
/// then does a **non-force** `git push <sha>:refs/heads/main`. The ancestor
|
||||
/// pre-check and the non-force push are two independent guards: either catches
|
||||
/// a raced `main` (→ `NotFastForward`) rather than clobbering reviewed history.
|
||||
///
|
||||
/// # Errors
|
||||
/// `NotFastForward` if `main` raced ahead of `sha`; `Other` on transport/other.
|
||||
pub async fn ff_push_to_main(repo: &str, sha: &str) -> Result<(), ForgeMergeError> {
|
||||
let token = core_token()
|
||||
.ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?;
|
||||
let url = tokenised_repo_url(repo, &token);
|
||||
let applied = Coordinator::agent_applied_dir(repo_agent_name(repo));
|
||||
|
||||
// Current `main` on the forge repo.
|
||||
let ls = crate::lifecycle::git_command()
|
||||
.args(["ls-remote", &url, "refs/heads/main"])
|
||||
.output()
|
||||
.await
|
||||
.context("git ls-remote main")?;
|
||||
if !ls.status.success() {
|
||||
return Err(ForgeMergeError::Other(anyhow::anyhow!(
|
||||
"git ls-remote {repo} refs/heads/main failed ({}): {}",
|
||||
ls.status,
|
||||
String::from_utf8_lossy(&ls.stderr).trim()
|
||||
)));
|
||||
}
|
||||
let ls_out = String::from_utf8_lossy(&ls.stdout);
|
||||
let main_sha = ls_out.split_whitespace().next().unwrap_or("").to_string();
|
||||
|
||||
// Strict-ancestor check: `main` must be an ancestor of `sha` for a true
|
||||
// fast-forward. Skip when `main` is unborn (empty) — the push creates it.
|
||||
if !main_sha.is_empty() {
|
||||
let anc = crate::lifecycle::git_command()
|
||||
.current_dir(&applied)
|
||||
.args(["merge-base", "--is-ancestor", &main_sha, sha])
|
||||
.output()
|
||||
.await
|
||||
.context("git merge-base --is-ancestor")?;
|
||||
match anc.status.code() {
|
||||
Some(0) => {} // ancestor → fast-forward safe
|
||||
Some(1) => {
|
||||
return Err(ForgeMergeError::NotFastForward {
|
||||
expected_ancestor: main_sha,
|
||||
actual_head: sha.to_string(),
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
return Err(ForgeMergeError::Other(anyhow::anyhow!(
|
||||
"git merge-base --is-ancestor errored ({}): {}",
|
||||
anc.status,
|
||||
String::from_utf8_lossy(&anc.stderr).trim()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Non-force push `sha` → `main`. Without `--force`, git rejects a
|
||||
// non-fast-forward (a race between the check above and now), surfaced as
|
||||
// `NotFastForward` rather than clobbering the remote.
|
||||
let push = crate::lifecycle::git_command()
|
||||
.current_dir(&applied)
|
||||
.args(["push", &url, &format!("{sha}:refs/heads/main")])
|
||||
.output()
|
||||
.await
|
||||
.context("git push sha:main")?;
|
||||
if !push.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&push.stderr);
|
||||
if stderr.contains("non-fast-forward") || stderr.contains("fetch first") {
|
||||
return Err(ForgeMergeError::NotFastForward {
|
||||
expected_ancestor: main_sha,
|
||||
actual_head: sha.to_string(),
|
||||
});
|
||||
}
|
||||
return Err(ForgeMergeError::Other(anyhow::anyhow!(
|
||||
"git push {repo} {sha}:main failed ({}): {}",
|
||||
push.status,
|
||||
stderr.trim()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark PR `pr` as **manually merged** at `sha` (Forgejo
|
||||
/// `POST …/pulls/{pr}/merge` with `Do=manually-merged`, `MergeCommitID=sha`).
|
||||
/// `ff_push_to_main` must have already set `main` to `sha` (Forgejo requires
|
||||
/// the branch already be at the merge commit). On a non-2xx, re-reads the PR
|
||||
/// head to distinguish drift (`HeadDrift`) from a generic failure (`Other`) —
|
||||
/// best-effort, since the handler's pre-merge head re-read is the real gate.
|
||||
///
|
||||
/// # Errors
|
||||
/// `HeadDrift` if the PR head no longer matches `sha`; `Other` otherwise.
|
||||
pub async fn mark_pr_merged(repo: &str, pr: u64, sha: &str) -> Result<(), ForgeMergeError> {
|
||||
let token = core_token()
|
||||
.ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?;
|
||||
let url = format!("{FORGE_HTTP}/api/v1/repos/{repo}/pulls/{pr}/merge");
|
||||
let body = format!(r#"{{"Do":"manually-merged","MergeCommitID":"{sha}"}}"#);
|
||||
let (status, _) = forge_http(reqwest::Method::POST, &url, &token, &body)
|
||||
.await
|
||||
.context("POST pulls/<pr>/merge (manually-merged)")?;
|
||||
if status.is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
// Best-effort drift detection: if the live head no longer matches `sha`,
|
||||
// that's a head-drift race; otherwise surface as a hard failure.
|
||||
match pr_head_sha(repo, pr).await {
|
||||
Ok(actual) if actual != sha => Err(ForgeMergeError::HeadDrift {
|
||||
expected: sha.to_string(),
|
||||
actual,
|
||||
}),
|
||||
_ => Err(ForgeMergeError::Other(anyhow::anyhow!(
|
||||
"mark PR #{pr} in {repo} manually-merged at {sha} failed: HTTP {status}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{repo_agent_name, tokenised_repo_url};
|
||||
|
||||
#[test]
|
||||
fn repo_agent_name_takes_trailing_segment() {
|
||||
assert_eq!(repo_agent_name("agent-configs/atlas"), "atlas");
|
||||
assert_eq!(repo_agent_name("atlas"), "atlas");
|
||||
assert_eq!(repo_agent_name("a/b/c"), "c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokenised_repo_url_shape() {
|
||||
assert_eq!(
|
||||
tokenised_repo_url("agent-configs/iris", "tok"),
|
||||
"http://core:tok@localhost:3000/agent-configs/iris.git"
|
||||
);
|
||||
}
|
||||
}
|
||||
621
hive-c0re/src/forge/repos.rs
Normal file
621
hive-c0re/src/forge/repos.rs
Normal file
|
|
@ -0,0 +1,621 @@
|
|||
//! Repo + org plumbing on the local Forgejo: org / repo creation,
|
||||
//! the meta + shared-docs + knowledge repos, per-agent config-repo
|
||||
//! mirroring (`push_config` / `push_meta`), collaborator grants,
|
||||
//! pull-mirrors, and branch-protection rules. Shared HTTP helpers +
|
||||
//! org-name constants live in the module root (`super`).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::coordinator::Coordinator;
|
||||
|
||||
use super::{
|
||||
AGENTS_ORG, CONFIG_ORG, FORGE_HTTP, KNOWLEDGE_REPO, OPERATORS_TEAM, SHARED_DOCS_REPO,
|
||||
SHARED_ORG, core_token, forge_http, is_present,
|
||||
};
|
||||
|
||||
/// 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"}}"#)
|
||||
}
|
||||
|
||||
/// JSON body for a public, empty repo defaulting to `main`.
|
||||
fn repo_body_public(name: &str) -> String {
|
||||
format!(r#"{{"name":"{name}","auto_init":false,"private":false,"default_branch":"main"}}"#)
|
||||
}
|
||||
|
||||
/// Set an existing repo to public visibility. No-op if the repo is
|
||||
/// already public. Used for `internal/knowledge` which may have been
|
||||
/// created as private on an older deployment.
|
||||
async fn set_repo_public(owner: &str, repo: &str, token: &str) -> Result<()> {
|
||||
let url = format!("{FORGE_HTTP}/api/v1/repos/{owner}/{repo}");
|
||||
let (status, _) =
|
||||
forge_http(reqwest::Method::PATCH, &url, token, r#"{"private":false}"#).await?;
|
||||
match status.as_u16() {
|
||||
200 => {
|
||||
tracing::debug!(%owner, %repo, "forge: repo set to public");
|
||||
Ok(())
|
||||
}
|
||||
other => anyhow::bail!("PATCH {owner}/{repo} (set public) returned HTTP {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create `name` inside org `org` as a public repo. Idempotent.
|
||||
async fn ensure_org_repo_public(org: &str, name: &str, token: &str) -> Result<()> {
|
||||
create_repo(
|
||||
&format!("{FORGE_HTTP}/api/v1/orgs/{org}/repos"),
|
||||
&repo_body_public(name),
|
||||
token,
|
||||
&format!("{org}/{name}"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// 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 yet (forge container not provisioned).
|
||||
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, and wire it as the agent-editable PR surface:
|
||||
/// the agent is a **write** collaborator (can push feature branches +
|
||||
/// open config PRs) and `main` is branch-protected core-only (only hive-c0re's
|
||||
/// merge handler lands on it; operator approval required). 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 (all steps idempotent).
|
||||
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?;
|
||||
// Agent = write collaborator: it can push config-PR branches + open PRs,
|
||||
// but the branch protection below keeps it off `main` directly.
|
||||
add_collaborator(CONFIG_ORG, name, name, "write", &token).await?;
|
||||
// Protect `main` core-only, fast-forward-only (no auto force-push).
|
||||
apply_config_repo_branch_protection(name, &token).await
|
||||
}
|
||||
|
||||
/// Ensure the `internal/docs` repo exists. Called once at startup
|
||||
/// after `ensure_org(SHARED_ORG)`. Idempotent — `ensure_org_repo`
|
||||
/// treats 409 as success.
|
||||
pub async fn ensure_shared_docs_repo(core_token: &str) -> Result<()> {
|
||||
ensure_org_repo(SHARED_ORG, SHARED_DOCS_REPO, core_token).await
|
||||
}
|
||||
|
||||
/// Grant agent `name` read-only collaborator access to `internal/docs`.
|
||||
/// Idempotent: HTTP 204 (already a collaborator) is treated as success.
|
||||
/// Mirrors `meta_read_access` so agents can clone the shared docs repo
|
||||
/// without authentication hassle.
|
||||
pub async fn shared_docs_access(name: &str, core_token: &str) -> Result<()> {
|
||||
let url =
|
||||
format!("{FORGE_HTTP}/api/v1/repos/{SHARED_ORG}/{SHARED_DOCS_REPO}/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 internal/docs/collaborators")?;
|
||||
let code = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
||||
match code.as_str() {
|
||||
"204" => {
|
||||
tracing::info!(%name, "forge: granted shared-docs read access");
|
||||
Ok(())
|
||||
}
|
||||
other => anyhow::bail!(
|
||||
"PUT {SHARED_ORG}/{SHARED_DOCS_REPO}/collaborators/{name} returned HTTP {other}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure the `internal/knowledge` repo exists and is public.
|
||||
/// Called once at startup after `ensure_org(SHARED_ORG)`. Idempotent.
|
||||
///
|
||||
/// The repo is created as public so any agent with a forge account can
|
||||
/// fork it and open PRs to contribute. Existing deployments that ended
|
||||
/// up with a private repo are patched to public on the next hive-c0re
|
||||
/// startup via `set_repo_public`.
|
||||
pub async fn ensure_knowledge_repo(core_token: &str) -> Result<()> {
|
||||
ensure_org_repo_public(SHARED_ORG, KNOWLEDGE_REPO, core_token).await?;
|
||||
// Ensure public even if the repo already existed as private (older deployment).
|
||||
set_repo_public(SHARED_ORG, KNOWLEDGE_REPO, core_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.
|
||||
///
|
||||
/// Never force-pushes. The status tags are id-suffixed
|
||||
/// (`proposal/<id>`, `deployed/<id>`, …) and therefore add-only, and
|
||||
/// `main` is published history — after a failed deploy rolls the LOCAL
|
||||
/// applied `main` back to last-good, the forge `main` may legitimately
|
||||
/// be ahead (e.g. an operator-merged config PR whose rebuild failed).
|
||||
/// Rewinding it would erase that merged commit from the forge, which
|
||||
/// is exactly the incident this guards against: the local repo tracks
|
||||
/// "what last built", the forge tracks "what was approved", and the
|
||||
/// `failed/<id>` tag records the divergence. A non-fast-forward
|
||||
/// rejection of `main` is therefore expected + logged at info; the
|
||||
/// tags in the same push still land (git pushes refspecs
|
||||
/// independently). Any other failure is a real error.
|
||||
///
|
||||
/// 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",
|
||||
&url,
|
||||
"refs/heads/main:refs/heads/main",
|
||||
"refs/tags/*:refs/tags/*",
|
||||
])
|
||||
.output()
|
||||
.await
|
||||
.context("invoke git push agent-configs")?;
|
||||
if !out.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
if stderr.contains("non-fast-forward") {
|
||||
tracing::info!(
|
||||
%name,
|
||||
"forge: mirror push of main rejected (non-fast-forward) — forge main is \
|
||||
ahead of local applied main (rolled-back deploy); leaving forge history intact"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
anyhow::bail!(
|
||||
"git push {CONFIG_ORG}/{name} failed ({}): {}",
|
||||
out.status,
|
||||
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.
|
||||
pub(super) 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}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// One operator-declared pull-mirror, forwarded from the nix
|
||||
/// `services.hyperhive.forge.mirrors` option as JSON in
|
||||
/// `HYPERHIVE_FORGE_MIRRORS`.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Mirror {
|
||||
/// Upstream clone URL to mirror from (e.g. `https://github.com/actions/checkout`).
|
||||
upstream: String,
|
||||
/// Local `<owner>/<repo>` the mirror is created at.
|
||||
dest: String,
|
||||
}
|
||||
|
||||
/// Ensure each `HYPERHIVE_FORGE_MIRRORS` entry exists as a real Forgejo
|
||||
/// pull-mirror. The env carries the JSON-encoded nix `forge.mirrors` list
|
||||
/// (plus the CI-auto `actions/checkout` entry). Absent/empty env = no-op.
|
||||
/// Per-mirror failures warn and continue — never abort the startup sweep.
|
||||
pub(super) async fn ensure_mirrors(admin_token: &str) {
|
||||
let raw = match std::env::var("HYPERHIVE_FORGE_MIRRORS") {
|
||||
Ok(s) if !s.trim().is_empty() => s,
|
||||
_ => return,
|
||||
};
|
||||
let mirrors: Vec<Mirror> = match serde_json::from_str(&raw) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "forge: HYPERHIVE_FORGE_MIRRORS is not valid JSON; skipping mirror seed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
for m in mirrors {
|
||||
let Some((owner, repo)) = m.dest.split_once('/') else {
|
||||
tracing::warn!(dest = %m.dest, "forge: mirror dest is not <owner>/<repo>; skipping");
|
||||
continue;
|
||||
};
|
||||
// Create the dest org first (idempotent); the mirror can't land
|
||||
// without its owner existing.
|
||||
if let Err(e) = ensure_org(owner, admin_token).await {
|
||||
tracing::warn!(%owner, error = ?e, "forge: ensure_org for mirror failed");
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = ensure_mirror_repo(&m.upstream, owner, repo, admin_token).await {
|
||||
tracing::warn!(dest = %m.dest, error = ?e, "forge: ensure_mirror_repo failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Periodic sync interval for pull-mirrors. Forgejo syncs mirrors
|
||||
/// on-access by default, which re-introduces external DNS latency on
|
||||
/// every `git clone` (the hive-ci runner shares the host netns and is
|
||||
/// therefore affected by host resolver blips). A fixed periodic interval
|
||||
/// isolates CI from transient DNS failures — a stale mirror is
|
||||
/// acceptable; a broken clone because of a momentary DNS blip is not.
|
||||
const MIRROR_INTERVAL: &str = "8h0m0s";
|
||||
|
||||
/// Create `owner/repo` as a pull-mirror of `upstream` via the migrate API.
|
||||
/// Idempotent: if the repo already exists this function patches its
|
||||
/// `mirror_interval` to ensure it matches (covers mirrors that were
|
||||
/// created before the interval was introduced). A 409 on the migrate
|
||||
/// POST (a race between the GET check and the POST) is also success.
|
||||
async fn ensure_mirror_repo(
|
||||
upstream: &str,
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
admin_token: &str,
|
||||
) -> Result<()> {
|
||||
let repo_url = format!("{FORGE_HTTP}/api/v1/repos/{owner}/{repo}");
|
||||
let (status, _) = forge_http(reqwest::Method::GET, &repo_url, admin_token, "").await?;
|
||||
if status.is_success() {
|
||||
// Mirror already present. Patch interval so mirrors seeded before
|
||||
// this field was introduced (or with a different value) converge.
|
||||
let patch_body = serde_json::json!({ "mirror_interval": MIRROR_INTERVAL }).to_string();
|
||||
let (patch_status, patch_text) =
|
||||
forge_http(reqwest::Method::PATCH, &repo_url, admin_token, &patch_body).await?;
|
||||
if patch_status.is_success() {
|
||||
tracing::debug!(%owner, %repo, interval = MIRROR_INTERVAL, "forge: pull-mirror interval updated");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
%owner, %repo, status = %patch_status, body = %patch_text,
|
||||
"forge: failed to set mirror_interval on existing pull-mirror"
|
||||
);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// serde_json::json! → the upstream URL is escaped safely (no string
|
||||
// interpolation into the JSON body).
|
||||
let body = serde_json::json!({
|
||||
"clone_addr": upstream,
|
||||
"repo_owner": owner,
|
||||
"repo_name": repo,
|
||||
"mirror": true,
|
||||
// Periodic refresh instead of on-access sync — keeps CI isolated
|
||||
// from external DNS failures at clone time.
|
||||
"interval": MIRROR_INTERVAL,
|
||||
"service": "git",
|
||||
"private": false,
|
||||
})
|
||||
.to_string();
|
||||
let url = format!("{FORGE_HTTP}/api/v1/repos/migrate");
|
||||
let (status, text) = forge_http(reqwest::Method::POST, &url, admin_token, &body).await?;
|
||||
match status.as_u16() {
|
||||
201 => {
|
||||
tracing::info!(%owner, %repo, %upstream, interval = MIRROR_INTERVAL, "forge: created pull-mirror");
|
||||
Ok(())
|
||||
}
|
||||
// 409 = a race created it between our GET check and here (the GET
|
||||
// is the real idempotency guard). NOT 422: for the migrate endpoint
|
||||
// 422 is a validation error (bad clone_addr / service), so it must
|
||||
// surface via the bail arm, not be swallowed as "already exists".
|
||||
409 => {
|
||||
tracing::debug!(%owner, %repo, "forge: pull-mirror already exists (race)");
|
||||
Ok(())
|
||||
}
|
||||
other => {
|
||||
anyhow::bail!("POST /api/v1/repos/migrate {owner}/{repo} returned HTTP {other}: {text}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provision the [`OPERATORS_TEAM`] inside `org` as an **empty** team.
|
||||
/// Branch protection on that org's repos references it as the
|
||||
/// merge/approval whitelist; the operator adds herself as a member via the
|
||||
/// forge UI / hivectl. `includes_all_repositories` so the gate applies to
|
||||
/// every repo in the org; `write` is enough to approve + merge. hive-c0re
|
||||
/// never manages membership. Idempotent (422/409 = already exists).
|
||||
///
|
||||
/// Must run for BOTH [`AGENTS_ORG`] and [`CONFIG_ORG`]: Gitea teams are
|
||||
/// org-scoped, so a config-repo branch-protection rule referencing
|
||||
/// `operators` needs the team to exist in `agent-configs` too. Missing it
|
||||
/// there 422'd every `apply_config_repo_branch_protection`, leaving config
|
||||
/// repos unprotected — operator-merged config PRs then bypassed the deploy
|
||||
/// pipeline and silently didn't apply.
|
||||
pub(super) async fn ensure_operators_team(org: &str, token: &str) -> Result<()> {
|
||||
let url = format!("{FORGE_HTTP}/api/v1/orgs/{org}/teams");
|
||||
let body = format!(
|
||||
r#"{{"name":"{OPERATORS_TEAM}","description":"hyperhive operators — merge gate for agent repos","permission":"write","includes_all_repositories":true,"can_create_org_repo":false}}"#
|
||||
);
|
||||
let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
||||
match status.as_u16() {
|
||||
201 => {
|
||||
tracing::info!(%org, "forge: created {OPERATORS_TEAM} team");
|
||||
Ok(())
|
||||
}
|
||||
409 | 422 => {
|
||||
tracing::debug!(%org, "forge: {OPERATORS_TEAM} team already exists");
|
||||
Ok(())
|
||||
}
|
||||
other => {
|
||||
anyhow::bail!("POST /orgs/{org}/teams ({OPERATORS_TEAM}) returned HTTP {other}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add `user` as a collaborator on `owner/repo` at `permission`
|
||||
/// (`read` / `write` / `admin`). Idempotent: 201 (added) and 204 (already a
|
||||
/// collaborator / permission updated) both count as success.
|
||||
async fn add_collaborator(
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
user: &str,
|
||||
permission: &str,
|
||||
token: &str,
|
||||
) -> Result<()> {
|
||||
let url = format!("{FORGE_HTTP}/api/v1/repos/{owner}/{repo}/collaborators/{user}");
|
||||
let body = format!(r#"{{"permission":"{permission}"}}"#);
|
||||
let (status, _) = forge_http(reqwest::Method::PUT, &url, token, &body).await?;
|
||||
match status.as_u16() {
|
||||
201 | 204 => {
|
||||
tracing::debug!(%owner, %repo, %user, %permission, "forge: collaborator set");
|
||||
Ok(())
|
||||
}
|
||||
other => {
|
||||
anyhow::bail!("PUT {owner}/{repo}/collaborators/{user} returned HTTP {other}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the operator merge-gate branch protection to `repo`'s default
|
||||
/// branch: only [`OPERATORS_TEAM`] members can merge, and an
|
||||
/// approving review from that team is required — so the author (a write-level
|
||||
/// agent, not in the team) cannot merge its own PR. Idempotent: an existing
|
||||
/// rule for the branch (200/409/422) is treated as success.
|
||||
async fn apply_operator_branch_protection(repo: &str, token: &str) -> Result<()> {
|
||||
let url = format!("{FORGE_HTTP}/api/v1/repos/{AGENTS_ORG}/{repo}/branch_protections");
|
||||
let body = format!(
|
||||
r#"{{"branch_name":"main","enable_merge_whitelist":true,"merge_whitelist_teams":["{OPERATORS_TEAM}"],"enable_approvals_whitelist":true,"approvals_whitelist_teams":["{OPERATORS_TEAM}"],"required_approvals":1,"block_on_official_review_requests":true}}"#
|
||||
);
|
||||
let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
||||
match status.as_u16() {
|
||||
201 => {
|
||||
tracing::info!(%repo, "forge: applied operator branch protection");
|
||||
Ok(())
|
||||
}
|
||||
200 | 409 | 422 => {
|
||||
tracing::debug!(%repo, "forge: branch protection already present");
|
||||
Ok(())
|
||||
}
|
||||
other => anyhow::bail!("POST {AGENTS_ORG}/{repo}/branch_protections returned HTTP {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply branch protection to an `agent-configs/<name>` repo's `main` so it
|
||||
/// can serve as the agent-editable, PR-merge config surface:
|
||||
/// - **push + merge whitelists are `core`-only** — the agent (a write
|
||||
/// collaborator) can push feature branches and open config PRs, but only
|
||||
/// hive-c0re lands on `main`, via its verify-and-ff-push merge handler
|
||||
/// (`run_merge_config_pr`). The agent can never push `main` directly.
|
||||
/// - **operator-team approval is required** to merge, and the author (not in
|
||||
/// the team) cannot self-approve.
|
||||
/// - **`enable_force_push` is `false`** — `main` only ever advances by
|
||||
/// fast-forward. The merge handler's `ff_push_to_main` is already a
|
||||
/// non-force push, so it lands fine. The legacy `push_config` mirror DOES
|
||||
/// force-push (it re-points status tags and rewinds `main` on a failed-build
|
||||
/// rollback), so the protection now rejects those non-ff updates — that
|
||||
/// mirror runs best-effort until the agent-opened PR-merge flow retires it.
|
||||
/// (Auto force-push is intentionally not allowed: per operator directive a
|
||||
/// silent force-push is a bug, not a feature.)
|
||||
///
|
||||
/// Idempotent: an existing rule for the branch (200/409/422) is success.
|
||||
async fn apply_config_repo_branch_protection(repo: &str, token: &str) -> Result<()> {
|
||||
let url = format!("{FORGE_HTTP}/api/v1/repos/{CONFIG_ORG}/{repo}/branch_protections");
|
||||
let body = format!(
|
||||
r#"{{"branch_name":"main","enable_push_whitelist":true,"push_whitelist_usernames":["core"],"enable_merge_whitelist":true,"merge_whitelist_usernames":["core"],"enable_approvals_whitelist":true,"approvals_whitelist_teams":["{OPERATORS_TEAM}"],"required_approvals":1,"block_on_official_review_requests":true,"allow_manual_merge":true,"enable_force_push":false}}"#
|
||||
);
|
||||
let (status, resp_body) = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
||||
if status.as_u16() == 201 {
|
||||
tracing::info!(%repo, "forge: applied config-repo branch protection");
|
||||
return Ok(());
|
||||
}
|
||||
// Non-201 is ambiguous: it can mean "rule already exists" (idempotent
|
||||
// success) OR a silent rejection — e.g. a 422 where Forgejo refused
|
||||
// the request and created NO rule. The old code treated 200/409/422
|
||||
// all as success, so a rejected POST left the repo unprotected with
|
||||
// no error (the reported case: a new agent's config repo had no
|
||||
// `main` rule and nothing was logged). Don't trust the status code:
|
||||
// verify the `main` rule actually exists, and on failure surface the
|
||||
// POST's response body so the real reason is in the journal.
|
||||
let main_url = format!("{url}/main");
|
||||
let (check, _) = forge_http(reqwest::Method::GET, &main_url, token, "").await?;
|
||||
if check.as_u16() == 200 {
|
||||
tracing::debug!(%repo, %status, "forge: config-repo branch protection already present");
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"branch protection for {CONFIG_ORG}/{repo} not applied: POST -> HTTP {status} \
|
||||
(body: {body}); GET main -> HTTP {check}, no `main` rule present",
|
||||
body = resp_body.trim(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a repo for `agent` in the c0re-owned [`AGENTS_ORG`] and wire the
|
||||
/// perms: the org owns it (perms stay c0re-managed), the agent is added
|
||||
/// as a **write** collaborator (not owner — can push + open PRs but can't
|
||||
/// bypass branch protection), and the default branch gets the operator
|
||||
/// merge gate. This is the sanctioned create path now that agents can't
|
||||
/// create repos directly (`max_repo_creation = 0`). Idempotent.
|
||||
pub async fn create_agent_repo(agent: &str, repo: &str, core_token: &str) -> Result<String> {
|
||||
ensure_org_repo(AGENTS_ORG, repo, core_token).await?;
|
||||
add_collaborator(AGENTS_ORG, repo, agent, "write", core_token).await?;
|
||||
apply_operator_branch_protection(repo, core_token).await?;
|
||||
tracing::info!(%agent, %repo, "forge: created agent repo in {AGENTS_ORG} with operator merge gate");
|
||||
Ok(format!("{AGENTS_ORG}/{repo}"))
|
||||
}
|
||||
534
hive-c0re/src/forge/users.rs
Normal file
534
hive-c0re/src/forge/users.rs
Normal file
|
|
@ -0,0 +1,534 @@
|
|||
//! Per-agent Forgejo user + access-token provisioning, account
|
||||
//! policy (email alignment, repo-creation lockdown), avatar uploads,
|
||||
//! and the bootstrap `core` admin user + token lifecycle. Shared
|
||||
//! HTTP / `forgejo admin` helpers live in the module root (`super`).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use reqwest::StatusCode;
|
||||
|
||||
use super::{CONFIG_ORG, FORGE_HTTP, forge_admin, forge_http, is_present};
|
||||
|
||||
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";
|
||||
// Forge provisioning markers (`forge/core-avatar-set`,
|
||||
// `forge/agent-configs-avatar-set`, `forge/email-aligned-<name>`) live
|
||||
// in `crate::paths` — one-shot guards: the upload/align runs once, the
|
||||
// marker is written, subsequent startups skip. Delete one to force its
|
||||
// step to re-run.
|
||||
// Avatar PNGs are loaded at runtime from
|
||||
// `$HIVE_ASSETS_DIR/branding/{hyperhive,agent-configs}.png` via the
|
||||
// helpers in `hive_sh4re::assets`. The `agent-configs.png` is
|
||||
// rendered from its SVG during the `hyperhive-assets` derivation's
|
||||
// build.
|
||||
/// Per-agent token scopes (broad-but-not-admin). See
|
||||
/// `docs/forge.md::Token scopes` for the per-scope rationale.
|
||||
const TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc";
|
||||
|
||||
/// Bootstrap `core` token scopes — adds `read:admin,write:admin` on
|
||||
/// top of `TOKEN_SCOPES` so the host daemon can drive
|
||||
/// `/api/v1/admin/*`. Site-admin membership alone isn't enough: the
|
||||
/// token's own scope gate runs before the user-permission check.
|
||||
/// See `docs/forge.md::Token scopes`.
|
||||
const CORE_TOKEN_SCOPES: &str = "read:admin,write:admin,read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc";
|
||||
|
||||
/// 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.local")
|
||||
}
|
||||
|
||||
/// 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. `password` picks the initial
|
||||
/// account password: `None` uses `--random-password` (the existing
|
||||
/// agent provisioning shape — the password is never read, agents auth
|
||||
/// by token); `Some(pw)` uses `--password <pw>` so the operator path
|
||||
/// in `hivectl` can set a real password for matrix-style web-UI login.
|
||||
async fn ensure_user_exists(name: &str, admin: bool, password: Option<&str>) -> Result<()> {
|
||||
let email = agent_email(name);
|
||||
let mut args = vec!["user", "create", "--username", name, "--email", &email];
|
||||
match password {
|
||||
Some(pw) => args.extend(["--password", pw, "--must-change-password=false"]),
|
||||
None => 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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the forgejo password for an existing user. Used by the operator
|
||||
/// path in `hivectl forge create-user --password` so re-running on an
|
||||
/// already-created account still updates the password (covers the
|
||||
/// "I forgot the password I set last week" case + the "argus retried
|
||||
/// the verb to verify the fix" case — `forgejo admin user create`
|
||||
/// silently skips a password change once the account exists). Idempotent
|
||||
/// from the operator's point of view: same password input → same final
|
||||
/// account state.
|
||||
async fn change_user_password(name: &str, password: &str) -> Result<()> {
|
||||
let args = [
|
||||
"user",
|
||||
"change-password",
|
||||
"--username",
|
||||
name,
|
||||
"--password",
|
||||
password,
|
||||
];
|
||||
forge_admin(&args)
|
||||
.await
|
||||
.with_context(|| format!("forgejo admin user change-password {name}"))?;
|
||||
tracing::info!(%name, "forge: changed user password");
|
||||
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.
|
||||
///
|
||||
/// Marker-guarded: writes `EMAIL_ALIGNED_MARKER_PREFIX{name}` on first
|
||||
/// success and skips the PATCH on all subsequent calls. This prevents
|
||||
/// Forgejo's admin-user-edit endpoint from resetting `use_custom_avatar`
|
||||
/// on every `sync_agent` tick. Delete the marker to force re-alignment.
|
||||
///
|
||||
/// Uses the admin REST API (`PATCH /api/v1/admin/users/{name}`) rather
|
||||
/// than `forgejo admin user edit` because the CLI dropped the `edit`
|
||||
/// subcommand somewhere between forgejo 8 and current. Body includes
|
||||
/// `login_name` (required by Forgejo's `EditUserOption` validator) and
|
||||
/// `source_id = 0` (local auth, the default for users hive-c0re creates).
|
||||
pub(super) async fn ensure_user_email(name: &str) {
|
||||
let marker = crate::paths::forge_email_aligned_marker(name);
|
||||
if marker.exists() {
|
||||
return;
|
||||
}
|
||||
let Some(token) = core_token() else {
|
||||
tracing::debug!(%name, "forge: skipping ensure_user_email — no core token yet");
|
||||
return;
|
||||
};
|
||||
let email = agent_email(name);
|
||||
// `login_name` is required by Forgejo's EditUserOption validator.
|
||||
// Omitting it caused Forgejo to reset use_custom_avatar on each call.
|
||||
let body = format!(r#"{{"email":"{email}","login_name":"{name}","source_id":0}}"#);
|
||||
let url = format!("{FORGE_HTTP}/api/v1/admin/users/{name}");
|
||||
match forge_http(reqwest::Method::PATCH, &url, &token, &body).await {
|
||||
Ok((status, _)) if status.is_success() => {
|
||||
if let Some(parent) = marker.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
std::fs::write(&marker, "").ok();
|
||||
tracing::info!(%name, %email, "forge: user email aligned");
|
||||
}
|
||||
Ok((status, _)) if status == reqwest::StatusCode::FORBIDDEN => {
|
||||
// Core token missing admin scope — see
|
||||
// `docs/forge.md::Token scopes` migration note.
|
||||
tracing::warn!(
|
||||
%name, %email, %status,
|
||||
"forge: PATCH user email forbidden — core token likely missing admin scope. \
|
||||
Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes."
|
||||
);
|
||||
}
|
||||
Ok((status, _)) => {
|
||||
tracing::warn!(%name, %email, %status, "forge: PATCH user email returned non-success");
|
||||
}
|
||||
Err(e) => tracing::warn!(%name, error = %e, "forge: PATCH user email transport error"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Disable direct repo creation for agent `name` by setting
|
||||
/// `max_repo_creation = 0` on its Forgejo account. Agents must
|
||||
/// create repos *through hive-c0re* (which owns the perms), never with
|
||||
/// their own token — a write-scoped token can otherwise create + own
|
||||
/// repos and self-merge, bypassing the operator-only-merge policy.
|
||||
///
|
||||
/// `max_repo_creation = 0` means `CanCreateRepo()` is false for any
|
||||
/// count (Forgejo: `MaxRepoCreation >= 0 && NumRepos >= MaxRepoCreation`),
|
||||
/// so creation is refused while push / PR / clone stay intact. **Existing
|
||||
/// repos are untouched** — this only blocks *new* direct creation.
|
||||
///
|
||||
/// Marker-guarded like [`ensure_user_email`]: the PATCH runs once per
|
||||
/// agent (delete the marker to re-apply). Body carries `login_name` +
|
||||
/// `source_id` for the same reason `ensure_user_email` does — omitting
|
||||
/// `login_name` makes Forgejo's `EditUserOption` validator reset
|
||||
/// `use_custom_avatar`. Best-effort: failures warn, don't propagate.
|
||||
pub(super) async fn ensure_repo_creation_disabled(name: &str) {
|
||||
let marker = crate::paths::forge_repo_creation_disabled_marker(name);
|
||||
if marker.exists() {
|
||||
return;
|
||||
}
|
||||
let Some(token) = core_token() else {
|
||||
tracing::debug!(%name, "forge: skipping ensure_repo_creation_disabled — no core token yet");
|
||||
return;
|
||||
};
|
||||
let body = format!(r#"{{"login_name":"{name}","source_id":0,"max_repo_creation":0}}"#);
|
||||
let url = format!("{FORGE_HTTP}/api/v1/admin/users/{name}");
|
||||
match forge_http(reqwest::Method::PATCH, &url, &token, &body).await {
|
||||
Ok((status, _)) if status.is_success() => {
|
||||
if let Some(parent) = marker.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
std::fs::write(&marker, "").ok();
|
||||
tracing::info!(%name, "forge: disabled direct repo creation (max_repo_creation=0)");
|
||||
}
|
||||
Ok((status, _)) if status == reqwest::StatusCode::FORBIDDEN => {
|
||||
tracing::warn!(
|
||||
%name, %status,
|
||||
"forge: PATCH max_repo_creation forbidden — core token likely missing admin scope. \
|
||||
Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes."
|
||||
);
|
||||
}
|
||||
Ok((status, _)) => {
|
||||
tracing::warn!(%name, %status, "forge: PATCH max_repo_creation returned non-success");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(%name, error = %e, "forge: PATCH max_repo_creation transport error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mint a fresh access token for `name`. 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. `scopes` is the scope string
|
||||
/// passed to `forgejo admin user generate-access-token --scopes`;
|
||||
/// use `TOKEN_SCOPES` for agents, `CORE_TOKEN_SCOPES` for the
|
||||
/// bootstrap `core` user.
|
||||
async fn mint_token(name: &str, scopes: &str) -> Result<String> {
|
||||
let token_name = format!(
|
||||
"{TOKEN_NAME_PREFIX}-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs())
|
||||
);
|
||||
let stdout = forge_admin(&[
|
||||
"user",
|
||||
"generate-access-token",
|
||||
"--username",
|
||||
name,
|
||||
"--token-name",
|
||||
&token_name,
|
||||
"--scopes",
|
||||
scopes,
|
||||
])
|
||||
.await?;
|
||||
let token = extract_token(&stdout)
|
||||
.with_context(|| format!("parse token from forgejo output: {stdout:?}"))?;
|
||||
tracing::debug!(%name, %token_name, "forge: minted access token");
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Mint a fresh Forgejo access token for an agent and write it to the
|
||||
/// agent's state dir via hive-priv. hive-c0re runs unprivileged and
|
||||
/// cannot write to agent-owned (0755) state directories directly.
|
||||
async fn mint_and_persist_agent_token(name: &str) -> Result<()> {
|
||||
let token = mint_token(name, TOKEN_SCOPES).await?;
|
||||
crate::priv_client::write_agent_forge_token(name, &token)
|
||||
.await
|
||||
.with_context(|| format!("write forge-token for {name} via hive-priv"))
|
||||
}
|
||||
|
||||
/// Mint a fresh Forgejo access token for the `core` admin user and
|
||||
/// write it directly to `path`. Unlike agent tokens this path is owned
|
||||
/// by hive-c0re itself (under `/var/lib/hyperhive/`), so a direct
|
||||
/// write is both correct and necessary (no priv round-trip).
|
||||
async fn mint_and_persist_core_token(path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let token = mint_token("core", CORE_TOKEN_SCOPES).await?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
std::fs::write(path, format!("{token}\n"))
|
||||
.with_context(|| format!("write core token to {}", path.display()))?;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||
tracing::info!(path = %path.display(), "forge: persisted core 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, None).await?;
|
||||
ensure_user_email(name).await;
|
||||
mint_and_persist_agent_token(name).await
|
||||
}
|
||||
|
||||
/// Provision a forgejo user for `name` and return the freshly-minted
|
||||
/// token. Unlike [`ensure_user_for`], the token is **not** persisted to
|
||||
/// disk — the caller is responsible for storing it. Used by `hivectl
|
||||
/// forge create-user` for human (non-agent) accounts so we don't create
|
||||
/// stray `/var/lib/hyperhive/agents/<name>/` directories for users that
|
||||
/// aren't agents.
|
||||
///
|
||||
/// `password` picks the account password. `None` keeps the existing
|
||||
/// random-throwaway shape (caller doesn't need web UI access — token
|
||||
/// alone is enough). `Some(pw)` sets `pw` as the password, including
|
||||
/// running `forgejo admin user change-password` if the account already
|
||||
/// exists, so the operator can log into the forge web UI afterwards.
|
||||
/// Idempotent: re-running with the same `Some(pw)` lands on the same
|
||||
/// final state.
|
||||
pub async fn provision_user_token(name: &str, password: Option<&str>) -> Result<String> {
|
||||
if !is_present().await {
|
||||
anyhow::bail!(
|
||||
"hive-forge container not running — wait for hive-c0re to start it before provisioning forge users"
|
||||
);
|
||||
}
|
||||
ensure_user_exists(name, false, password).await?;
|
||||
if let Some(pw) = password {
|
||||
// `user create` silently no-ops on an existing account, so
|
||||
// we run change-password unconditionally when the caller
|
||||
// asked for a specific password — keeps the verb idempotent
|
||||
// for "set or reset" use.
|
||||
change_user_password(name, pw).await?;
|
||||
}
|
||||
ensure_user_email(name).await;
|
||||
mint_token(name, TOKEN_SCOPES).await
|
||||
}
|
||||
|
||||
/// Set `core`'s Forgejo avatar to the hyperhive logo once, then
|
||||
/// remember it so subsequent startups don't re-upload. Best-effort
|
||||
/// — any non-2xx is logged at the caller; the project runs fine
|
||||
/// with the default hash identicon.
|
||||
pub(super) async fn ensure_core_avatar(token: &str) -> Result<()> {
|
||||
let marker = crate::paths::forge_core_avatar_marker();
|
||||
if marker.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let png_path = hive_sh4re::assets::core_avatar_png();
|
||||
let png_bytes = tokio::fs::read(&png_path)
|
||||
.await
|
||||
.with_context(|| format!("read core avatar PNG from {}", png_path.display()))?;
|
||||
let body = format!(
|
||||
r#"{{"image":"{}"}}"#,
|
||||
base64::engine::general_purpose::STANDARD.encode(&png_bytes),
|
||||
);
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Set the `agent-configs` org's Forgejo avatar to the
|
||||
/// configs-stack glyph once. Sibling to `ensure_core_avatar`:
|
||||
/// one-shot, marker-guarded, best-effort. Forgejo's per-org avatar
|
||||
/// endpoint is `POST /api/v1/orgs/{org}/avatar` with a base64-PNG
|
||||
/// JSON body — same shape as the admin user endpoint above.
|
||||
pub(super) async fn ensure_config_org_avatar(token: &str) -> Result<()> {
|
||||
let marker = crate::paths::forge_config_org_avatar_marker();
|
||||
if marker.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let png_path = hive_sh4re::assets::config_org_avatar_png();
|
||||
let png_bytes = tokio::fs::read(&png_path)
|
||||
.await
|
||||
.with_context(|| format!("read {CONFIG_ORG} avatar PNG from {}", png_path.display()))?;
|
||||
let body = format!(
|
||||
r#"{{"image":"{}"}}"#,
|
||||
base64::engine::general_purpose::STANDARD.encode(&png_bytes),
|
||||
);
|
||||
let url = format!("{FORGE_HTTP}/api/v1/orgs/{CONFIG_ORG}/avatar");
|
||||
let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("set {CONFIG_ORG} avatar: HTTP {status}");
|
||||
}
|
||||
if let Some(parent) = marker.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
std::fs::write(marker, "").ok();
|
||||
tracing::info!(
|
||||
org = CONFIG_ORG,
|
||||
"forge: set org avatar to configs-stack logo"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Outcome of probing whether the persisted core token still works
|
||||
/// against the *current* forge. Existence on disk is not validity: a
|
||||
/// token minted before a forge rebuild / re-provision is unknown to the
|
||||
/// new forge's DB and 401s on every call — which silently breaks the
|
||||
/// hive-ci runner-registration prefetch (it reads this same token to
|
||||
/// fetch a runner registration token).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum CoreTokenCheck {
|
||||
/// Token authenticated successfully — keep using it.
|
||||
Valid,
|
||||
/// Forge explicitly rejected the token (401/403) — re-mint.
|
||||
Invalid,
|
||||
/// Couldn't determine (forge unreachable / 5xx). Don't re-mint on a
|
||||
/// transient: keep the existing token and let a later ensure pass
|
||||
/// re-check once the forge is responsive. Re-minting here would both
|
||||
/// fail (mint needs the forge too) and churn tokens needlessly.
|
||||
Indeterminate,
|
||||
}
|
||||
|
||||
/// Map the HTTP status of the token-probe call to a [`CoreTokenCheck`].
|
||||
/// Pure so the decision logic is unit-testable without a live forge.
|
||||
fn classify_core_token_status(status: StatusCode) -> CoreTokenCheck {
|
||||
if status.is_success() {
|
||||
CoreTokenCheck::Valid
|
||||
} else if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
|
||||
CoreTokenCheck::Invalid
|
||||
} else {
|
||||
CoreTokenCheck::Indeterminate
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe whether `token` is still accepted by the current forge with a
|
||||
/// cheap authenticated `GET /api/v1/user` (covered by the core token's
|
||||
/// `read:user` scope). See [`CoreTokenCheck`] for how the outcome is
|
||||
/// interpreted.
|
||||
async fn check_core_token(token: &str) -> CoreTokenCheck {
|
||||
let url = format!("{FORGE_HTTP}/api/v1/user");
|
||||
match forge_http(reqwest::Method::GET, &url, token, "").await {
|
||||
Ok((status, _)) => classify_core_token_status(status),
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
error = %e,
|
||||
"forge: core-token probe could not reach forge; treating as indeterminate"
|
||||
);
|
||||
CoreTokenCheck::Indeterminate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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, meta-repo push, and the hive-ci
|
||||
/// runner-registration prefetch). Returns the token.
|
||||
///
|
||||
/// Idempotent, but validity-aware: when a token file is already present
|
||||
/// it is **probed against the current forge** before being trusted. A
|
||||
/// token persisted before a forge rebuild / re-provision is stale (the
|
||||
/// new forge DB doesn't know it) and would 401 every caller — so on a
|
||||
/// definitive rejection the token is re-minted. A merely-unreachable
|
||||
/// forge leaves the existing token in place (a later ensure pass
|
||||
/// re-checks) rather than churning tokens on a transient.
|
||||
pub(super) 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() {
|
||||
match check_core_token(&trimmed).await {
|
||||
CoreTokenCheck::Valid | CoreTokenCheck::Indeterminate => return Ok(trimmed),
|
||||
CoreTokenCheck::Invalid => {
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
"forge: persisted core token rejected by forge (stale after rebuild?); \
|
||||
re-minting"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ensure_user_exists("core", true, None).await?;
|
||||
mint_and_persist_core_token(path).await?;
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("read {CORE_TOKEN_PATH} after mint"))?;
|
||||
Ok(raw.trim().to_owned())
|
||||
}
|
||||
|
||||
/// 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())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{CoreTokenCheck, classify_core_token_status};
|
||||
use reqwest::StatusCode;
|
||||
|
||||
#[test]
|
||||
fn success_statuses_are_valid() {
|
||||
assert_eq!(
|
||||
classify_core_token_status(StatusCode::OK),
|
||||
CoreTokenCheck::Valid
|
||||
);
|
||||
assert_eq!(
|
||||
classify_core_token_status(StatusCode::NO_CONTENT),
|
||||
CoreTokenCheck::Valid
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_rejection_statuses_are_invalid() {
|
||||
// The whole point: a stale token (forge rebuilt out from under it)
|
||||
// 401s, and 401/403 are the only outcomes that trigger a re-mint.
|
||||
assert_eq!(
|
||||
classify_core_token_status(StatusCode::UNAUTHORIZED),
|
||||
CoreTokenCheck::Invalid
|
||||
);
|
||||
assert_eq!(
|
||||
classify_core_token_status(StatusCode::FORBIDDEN),
|
||||
CoreTokenCheck::Invalid
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transient_and_unexpected_statuses_are_indeterminate() {
|
||||
// Never re-mint on a transient — minting needs the forge too, and
|
||||
// churning tokens on a blip is worse than keeping the existing one.
|
||||
for s in [
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
StatusCode::BAD_GATEWAY,
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
StatusCode::NOT_FOUND,
|
||||
] {
|
||||
assert_eq!(
|
||||
classify_core_token_status(s),
|
||||
CoreTokenCheck::Indeterminate,
|
||||
"status {s} should be indeterminate"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue