Watch
0
0
Fork
You've already forked hyperhive
0

hive reads the forge for nix as its own account, not core

hive-c0re copies the hive's store read token to /run/hyperhive/forge-hive-token
for nix's git credential helper, and no longer mints, reads or falls back to
the core admin token. drops the core user sweep, avatar upload, webhook
cleanup and hive-priv's RunForgeAdmin.
This commit is contained in:
atlas 2026-10-25 17:56:14 +01:00
commit b79896c237
23 changed files with 236 additions and 1118 deletions

View file

@ -20,13 +20,20 @@ const CONFIG_BRANCH: &str = "main";
/// [`MetaSource`] over the forge API.
pub(crate) struct ForgeSource {
api: Forgejo,
/// Whether the credential file nix's `git` reads holds this hive's token.
nix_credential: bool,
}
/// A source reading as this hive, or `None` when it holds no forge token.
/// A source reading as this hive, or `None` when it holds no forge token. It
/// also refreshes the credential file nix's `git` fetches with, which the
/// build after this read needs to be current.
pub(crate) async fn internal_meta_source() -> Option<ForgeSource> {
let token: ReaderToken = super::reader_token().await?;
match token.api() {
Ok(api) => Some(ForgeSource { api }),
Ok(api) => Some(ForgeSource {
api,
nix_credential: token.publish_for_nix(),
}),
Err(e) => {
tracing::warn!(error = ?e, "internal/meta: cannot build a forge client");
None
@ -66,7 +73,7 @@ impl ForgeSource {
impl MetaSource for ForgeSource {
fn nix_can_fetch(&self) -> bool {
super::core_token().is_some()
self.nix_credential
}
async fn meta_head(&self) -> anyhow::Result<Option<String>> {
@ -106,3 +113,25 @@ impl MetaSource for ForgeSource {
self.branch_head(CONFIG_ORG, agent, CONFIG_BRANCH).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use forgejo_api::Auth;
fn source(nix_credential: bool) -> ForgeSource {
let url = url::Url::parse("http://forge.test").expect("url");
ForgeSource {
api: Forgejo::new(Auth::None, url).expect("client"),
nix_credential,
}
}
/// The control (`true`) shows the answer follows the field rather than
/// being a constant.
#[test]
fn nix_fetches_only_where_its_credential_was_published() {
assert!(source(true).nix_can_fetch());
assert!(!source(false).nix_can_fetch());
}
}

View file

@ -1,7 +1,6 @@
//! Optional Forgejo wiring — the `core` admin user and token and its avatar.
//! Those writes, as `core`, run only beside a local `hive-forge`
//! container; reads of the forge go through [`reader_token`] on every hive.
//! The swarm-wide orgs, teams and repos, and each agent's read grant on
//! The hive's forge access: reads of the forge go through [`reader_token`] on
//! every hive, and [`ensure_all`] publishes that token for nix's `git`. The
//! swarm-wide orgs, teams and repos, and each agent's read grant on
//! `internal/docs`, are swarm-controller's. Full design:
//! `docs/integrations/forge.md`.
@ -9,24 +8,20 @@ mod internal_meta;
mod reader;
mod reconcile;
mod repos;
mod users;
pub(crate) use internal_meta::internal_meta_source;
pub use reader::reader_token;
pub use reader::{publish_nix_credential, reader_token};
pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
pub use repos::{clone_config_into_proposed, fetch_config_main_into_applied};
pub use users::core_token;
use std::future::IntoFuture;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use std::time::Duration;
use anyhow::{Context, Result};
use forgejo_api::{Auth, Forgejo};
use url::Url;
use users::{ensure_core_avatar, ensure_core_user_and_token};
const FORGE_CONTAINER: &str = "hive-forge";
/// Base HTTP URL for the local Forgejo instance, from `HIVE_FORGE_URL`
@ -55,18 +50,14 @@ pub(crate) fn forge_http_base() -> &'static str {
}
/// Check the env the forge integration cannot run without: `HIVE_FORGE_URL`
/// set and a valid URL, `HIVE_ASSETS_DIR` set. Called once from `main`
/// before any worker is spawned, so a missing or malformed value fails the
/// unit start naming the variable; the lazy accessors then never hit their
/// `expect`s.
/// set and a valid URL. Called once from `main` before any worker is
/// spawned, so a missing or malformed value fails the unit start naming the
/// variable; the lazy accessors then never hit their `expect`s.
pub(crate) fn validate_env() -> Result<()> {
let url = std::env::var("HIVE_FORGE_URL").context(
"HIVE_FORGE_URL is unset (the hyperhive NixOS module sets it on the hive-c0re unit)",
)?;
Url::parse(&url).with_context(|| format!("HIVE_FORGE_URL is not a valid URL: {url:?}"))?;
std::env::var("HIVE_ASSETS_DIR").context(
"HIVE_ASSETS_DIR is unset (the hyperhive NixOS module sets it on the hive-c0re unit)",
)?;
Ok(())
}
@ -74,7 +65,7 @@ pub(crate) fn validate_env() -> Result<()> {
///
/// The token does not go here. A URL is a process argument, and `argv` is
/// world-readable through `/proc/<pid>/cmdline` for as long as the git child
/// lives, so a credentialed remote publishes the core admin token to every
/// lives, so a credentialed remote publishes the forge token to every
/// local user on the host. Credentials travel in the environment instead, via
/// [`basic_auth_header`] and [`crate::lifecycle::git_command_authed`] —
/// `/proc/<pid>/environ` is owner-only.
@ -101,8 +92,7 @@ fn basic_auth_header(login: &str, token: &str) -> String {
format!("Authorization: Basic {basic}")
}
/// Forgejo org grouping every agent's config repo. Core is a site admin
/// and reads + writes every repo here. Each agent is a **write collaborator
/// Forgejo org grouping every agent's config repo. Each agent is a **write collaborator
/// on its own** `agent-configs/<name>` repo — the editable PR surface it
/// pushes config-change branches to. `main`'s branch protection is
/// swarm-controller's: merge and approval allowlisted to the `operators` team,
@ -122,52 +112,10 @@ pub async fn is_present() -> bool {
stdout.lines().any(|l| l.trim() == FORGE_CONTAINER)
}
/// Name a `forgejo admin` invocation for an error message without
/// reproducing its arguments: keep the leading verb path, stop at the
/// first flag. `["user", "create", "--username", "iris", "--password",
/// "…"]` becomes `forgejo admin user create`.
///
/// The verbs are an **allowlist**, and that is the whole point. Some
/// callers pass a live secret as an argument value (`--password`), so a
/// message built from the raw vector puts it in the log. Listing the
/// flags to *hide* instead would repeat the bug this guards against: a
/// newly added secret-bearing flag would leak until someone remembered
/// to extend the list. Verbs are a closed set this crate chooses
/// itself; argument values never are.
fn describe_forge_admin(args: &[&str]) -> String {
let verbs: Vec<&str> = args
.iter()
.take_while(|a| !a.starts_with('-'))
.copied()
.collect();
if verbs.is_empty() {
"forgejo admin".to_owned()
} else {
format!("forgejo admin {}", verbs.join(" "))
}
}
/// 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> {
// Route through hive-priv (root helper) because `nixos-container run`
// uses nsenter to enter the container's namespaces, which requires root.
// hive-c0re runs as the unprivileged `hive-core` user and cannot call
// nsenter directly — doing so produces:
// nsenter: stat of /proc/<pid>/ns/user failed: Permission denied
let (stdout, _stderr) = crate::priv_client::run_forge_admin(args)
.await
.with_context(|| format!("{} (via hive-priv)", describe_forge_admin(args)))?;
Ok(stdout)
}
/// Typed Forgejo API client for the local forge ([`forge_http_base()`]),
/// authenticated as `token`. All Forgejo API calls that don't shell
/// out to `forgejo admin` go through clients built here — one place
/// for the base URL and auth. Tokens differ per call site (core admin
/// token vs per-agent tokens), so the token is passed per call; the
/// base URL is parsed once. Failures surface as
/// authenticated as `token`. All Forgejo API calls go through clients built
/// here — one place for the base URL and auth. The token is passed per call;
/// the base URL is parsed once. Failures surface as
/// `forgejo_api::ForgejoError`, whose Display carries the HTTP status
/// and the API's error message (e.g. the validation reason on a 422).
pub(crate) fn api(token: &str) -> Result<Forgejo> {
@ -197,169 +145,19 @@ where
.map_err(|_| anyhow::anyhow!("{op}: no answer from the forge within {API_TIMEOUT:?}"))
}
/// The `core_token.is_some()` half of [`ensure_all`]: the
/// core avatar, and removing this hive's own config-PR hook —
/// every step that needs an authenticated forge client. The swarm-wide
/// objects (orgs, the `operators` team, mirrors, `internal/docs`,
/// `internal/knowledge`, the `agent-configs` avatar) are the
/// swarm-controller's, not this hive's. Split out purely to keep
/// `ensure_all` under clippy's function-length limit; not meant to be called
/// from anywhere else.
async fn ensure_all_orgs_and_repos(token: &str) {
if let Err(e) = ensure_core_avatar(token).await {
tracing::warn!(error = ?e, "forge: ensure_core_avatar failed");
crate::warnings::set_boot_warning(
"forge_ensure_core_avatar",
"warn",
format!("forge: core avatar upload failed: {e}"),
);
}
if let Err(e) = remove_config_pr_webhook(token).await {
tracing::warn!(error = ?e, "forge: remove_config_pr_webhook failed");
}
}
/// Delete this hive's own `/webhook/config-pr` hook from the
/// `agent-configs` org. No hive serves that route, so such a hook fails on
/// every config-repo event; swarm-controller's hook is the one that acts on
/// config PRs.
///
/// Only a hook whose URL equals this hive's exactly is removed, never one
/// matching by path: the same path on another base is another hive's hook.
/// Idempotent, and a no-op when `HYPERHIVE_HIVE_DOMAIN` is unset.
async fn remove_config_pr_webhook(core_token: &str) -> Result<()> {
let Some(domain) = std::env::var("HYPERHIVE_HIVE_DOMAIN")
.ok()
.filter(|v| !v.is_empty())
else {
return Ok(());
};
let own_url = config_pr_hook_url(&domain);
let client = api(core_token)?;
let hooks = bounded(
&format!("list webhooks for org {CONFIG_ORG}"),
client.org_list_hooks(CONFIG_ORG).send(),
)
.await?
.with_context(|| format!("list webhooks for org {CONFIG_ORG}"))?;
for id in hook_ids_at(&hooks, &own_url) {
bounded(
&format!("delete webhook {id} on org {CONFIG_ORG}"),
client.org_delete_hook(CONFIG_ORG, id).send(),
)
.await?
.with_context(|| format!("delete webhook {id} on org {CONFIG_ORG}"))?;
tracing::info!(%own_url, id, "forge: removed this hive's config-pr webhook");
}
Ok(())
}
/// The URL this hive's config-PR hook was registered under.
fn config_pr_hook_url(hive_domain: &str) -> String {
format!("https://{hive_domain}/webhook/config-pr")
}
/// Ids of the hooks whose URL is exactly `url`.
fn hook_ids_at(hooks: &[forgejo_api::structs::Hook], url: &str) -> Vec<i64> {
hooks
.iter()
.filter(|h| {
h.config
.as_ref()
.and_then(|c| c.get("url"))
.is_some_and(|u| u == url)
})
.filter_map(|h| h.id)
.collect()
}
/// Poll the local Forgejo API until it answers, with a timeout of 1 minute.
/// The whole boot provisioning below hits the API, and [`is_present`] only
/// confirms the container exists — not that Forgejo is *listening*. A
/// `nixos-rebuild` that restarts hive-forge and hive-c0re together races:
/// without this gate every `ensure_*` fires at a refused socket and leaves a
/// stale "provisioning failed" warning that never clears (the pass is one-shot).
/// Returns whether the endpoint became ready before the deadline; on timeout
/// the caller proceeds anyway so a genuinely-down forge still surfaces its real
/// errors. Uses the unauthenticated `get_version` endpoint (no token yet).
async fn wait_until_ready() -> bool {
const READY_TIMEOUT: Duration = Duration::from_mins(1);
const POLL_INTERVAL: Duration = Duration::from_millis(500);
let Ok(url) = Url::parse(forge_http_base()) else {
tracing::warn!("forge: HIVE_FORGE_URL is not a valid URL; skipping readiness wait");
return false;
};
let Ok(client) = Forgejo::new(Auth::None, url) else {
tracing::warn!("forge: could not build readiness client; skipping readiness wait");
return false;
};
let deadline = Instant::now() + READY_TIMEOUT;
let mut waited = false;
loop {
if bounded("forge readiness probe", client.get_version())
.await
.is_ok_and(|probe| probe.is_ok())
{
if waited {
tracing::info!("forge: endpoint ready, proceeding with provisioning");
}
return true;
}
if Instant::now() >= deadline {
tracing::warn!(
"forge: endpoint not ready after {READY_TIMEOUT:?}; provisioning anyway"
);
return false;
}
waited = true;
tokio::time::sleep(POLL_INTERVAL).await;
}
}
/// Sweep the forge at startup: seed the `core` admin user (hive-c0re's own
/// identity for driving the API, and the one nix's git reads as) and its token,
/// then its avatar. Agent accounts,
/// tokens and `agent-configs/<name>` repos are the swarm-controller's, as is
/// the `agent-configs` org; this sweep touches none of them.
/// Called once at hive-c0re startup. Per-step failures are logged
/// but don't abort the sweep.
/// Publish this hive's forge read token for nix's `git` at startup, so a build
/// that starts before any `internal/meta` read still has a credential. Every
/// hive runs it: the token is the hive's own, in the store, whether or not a
/// forge container is local.
pub async fn ensure_all() {
if !is_present().await {
tracing::debug!("forge: hive-forge container absent, skipping user sweep");
return;
}
// The container exists, but its HTTP may not be up yet (a rebuild restarts
// hive-forge + hive-c0re together). Wait for the API to answer before the
// provisioning pass so a boot race doesn't leave stale failure warnings.
wait_until_ready().await;
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");
crate::warnings::set_boot_warning(
"forge_ensure_core_user",
"crit",
format!("forge: core user/token provisioning failed: {e}"),
);
None
}
};
if let Some(token) = core_token.as_deref() {
ensure_all_orgs_and_repos(token).await;
if publish_nix_credential().await {
tracing::debug!("forge: nix's forge credential is current");
}
}
#[cfg(test)]
mod tests {
use super::{
API_TIMEOUT, bounded, config_pr_hook_url, describe_forge_admin, git_url_with_base,
hook_ids_at,
};
fn hook(id: i64, url: &str) -> forgejo_api::structs::Hook {
serde_json::from_value(serde_json::json!({ "id": id, "config": { "url": url } }))
.expect("hook json")
}
use super::{API_TIMEOUT, bounded, git_url_with_base};
/// A forge that never answers surfaces as an error naming the operation,
/// at exactly [`API_TIMEOUT`] — not as a hang.
@ -389,21 +187,6 @@ mod tests {
assert_eq!(failed, Err("409"));
}
/// The same path on another base is another hive's hook, and removing it
/// would cut that hive off. Only an exact URL match is this hive's.
#[test]
fn only_this_hives_own_config_pr_hook_is_removed() {
let own = config_pr_hook_url("hive.example");
let hooks = [
hook(1, &own),
hook(2, "https://other-hive.example/webhook/config-pr"),
hook(3, "http://127.0.0.1:7000/webhook/config-pr"),
hook(4, "https://swarm.example/webhook/forge/config-pr"),
];
assert_eq!(hook_ids_at(&hooks, &own), vec![1]);
assert!(hook_ids_at(&hooks[1..], &own).is_empty());
}
/// The remote carries **no credential**. `argv` is world-readable through
/// `/proc/<pid>/cmdline`, so a token spliced in here would be published to
/// every local user for the life of the git child.
@ -426,58 +209,22 @@ mod tests {
}
/// The credential goes in an `Authorization` header instead — decodable
/// back to `core:<token>`, so the swap is genuinely equivalent auth and
/// back to `<login>:<token>`, so the swap is genuinely equivalent auth and
/// not a silent downgrade to anonymous.
#[test]
fn basic_auth_header_is_basic_core_token() {
fn basic_auth_header_is_basic_login_token() {
use base64::Engine as _;
let header = super::basic_auth_header("core", "s3cret");
let header = super::basic_auth_header("hive-alpha", "s3cret");
let b64 = header
.strip_prefix("Authorization: Basic ")
.expect("basic auth header");
let decoded = base64::engine::general_purpose::STANDARD
.decode(b64)
.expect("valid base64");
assert_eq!(String::from_utf8_lossy(&decoded), "core:s3cret");
assert_eq!(String::from_utf8_lossy(&decoded), "hive-alpha:s3cret");
assert!(
!header.contains("s3cret"),
"token not in the clear: {header}"
);
}
#[test]
fn describe_keeps_the_verb_path_and_drops_every_value() {
assert_eq!(
describe_forge_admin(&[
"user",
"create",
"--username",
"iris",
"--email",
"iris@hyperhive.local",
"--random-password",
]),
"forgejo admin user create"
);
}
#[test]
fn describe_does_not_reproduce_a_password_argument() {
let described = describe_forge_admin(&[
"user",
"change-password",
"--username",
"iris",
"--password",
"correct-horse-battery-staple",
]);
assert_eq!(described, "forgejo admin user change-password");
assert!(!described.contains("correct-horse-battery-staple"));
}
#[test]
fn describe_survives_a_leading_flag_and_an_empty_vector() {
assert_eq!(describe_forge_admin(&["--help"]), "forgejo admin");
assert_eq!(describe_forge_admin(&[]), "forgejo admin");
}
}

View file

@ -1,16 +1,16 @@
//! The credential this hive reads the forge with: its own read-only account,
//! whose token `swarm-controller` mints into the store at
//! `swarm/hives/<hive>/forge/token`, and the core token file where the store
//! holds none.
//! `swarm/hives/<hive>/forge/token`.
//!
//! The file is the fallback so a hive deployed before the controller mints its
//! token keeps the reads it had: the forge host holds the file, no other hive
//! does.
//! nix's `git` cannot reach the store, so [`ReaderToken::publish_for_nix`]
//! copies the token to a runtime file that `git-credential-hive-forge`
//! (`nix/host-modules/lib/hive-forge-git.nix`) reads.
use super::{basic_auth_header, core_token};
use std::path::Path;
/// The basic-auth user [`ReaderToken::auth_header`] names for the core token.
const CORE_LOGIN: &str = "core";
use anyhow::Result;
use super::basic_auth_header;
/// A forge token and the account it belongs to.
///
@ -39,37 +39,59 @@ impl ReaderToken {
pub(crate) fn api(&self) -> anyhow::Result<forgejo_api::Forgejo> {
super::api(&self.value)
}
}
/// The token to read the forge with, or `None` when this hive has neither.
///
/// 🩸 Logs the store path and which source won, never a value.
pub async fn reader_token() -> Option<ReaderToken> {
pick(stored_reader_token().await, core_token())
}
/// The store's token wins over the core file's.
fn pick(stored: Option<ReaderToken>, core: Option<String>) -> Option<ReaderToken> {
let picked = stored.or_else(|| {
core.map(|value| ReaderToken {
login: CORE_LOGIN.to_owned(),
value,
})
});
if let Some(t) = &picked {
tracing::debug!(login = %t.login, "forge: reading as this account");
} else {
tracing::debug!("forge: no read token in the store or the core token file");
/// Copy this token to the file nix's `git` authenticates from
/// ([`crate::paths::FORGE_HIVE_TOKEN`]). Whether the file now holds it; a
/// failed write is logged and leaves the file as it was.
pub(crate) fn publish_for_nix(&self) -> bool {
let path = Path::new(crate::paths::FORGE_HIVE_TOKEN);
match self.write_credential(path) {
Ok(()) => true,
Err(e) => {
tracing::warn!(path = %path.display(), error = ?e, "forge: could not write the nix forge credential");
false
}
}
}
picked
/// Login on the first line, token on the second. Written only when the
/// content differs, so a build's resolve is not a disk write.
fn write_credential(&self, path: &Path) -> Result<()> {
let content = format!("{}\n{}\n", self.login, self.value);
if std::fs::read(path).is_ok_and(|have| have == content.as_bytes()) {
return Ok(());
}
crate::secret_file::write_secret_0600(path, content.as_bytes())
}
}
/// The token to read the forge with, or `None` when the store holds none for
/// this hive.
///
/// 🩸 Logs the store path, never a value.
pub async fn reader_token() -> Option<ReaderToken> {
let token = stored_reader_token().await;
if let Some(t) = &token {
tracing::debug!(login = %t.login, "forge: reading as this account");
}
token
}
/// Publish this hive's read token for nix; see [`ReaderToken::publish_for_nix`].
/// Whether the file holds a token.
pub async fn publish_nix_credential() -> bool {
let Some(token) = reader_token().await else {
tracing::debug!("forge: no hive read token in the store; nix has no forge credential");
return false;
};
token.publish_for_nix()
}
/// This hive's token from the store, under the hive's own identity.
///
/// `None`, never an error, for every way it can come up empty — no hive name,
/// no `BAO_*` identity, an unreachable store, nothing at the path — the same
/// as `matrix::stored_sender_token`, and for the same reason: each sends the
/// caller to the fallback.
/// as `matrix::stored_sender_token`.
async fn stored_reader_token() -> Option<ReaderToken> {
let hive = std::env::var("HYPERHIVE_HIVE_NAME")
.ok()
@ -110,6 +132,7 @@ async fn stored_reader_token() -> Option<ReaderToken> {
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt as _;
fn stored() -> ReaderToken {
ReaderToken {
@ -118,23 +141,71 @@ mod tests {
}
}
#[test]
fn the_store_wins_over_the_core_file() {
let t = pick(Some(stored()), Some("from-the-file".to_owned())).expect("a token");
assert_eq!(t.value, "from-the-store");
assert_eq!(t.login, "hive-alpha");
fn mode(path: &Path) -> u32 {
std::fs::metadata(path).expect("stat").permissions().mode() & 0o777
}
#[test]
fn the_core_file_is_the_fallback() {
let t = pick(None, Some("from-the-file".to_owned())).expect("a token");
assert_eq!(t.value, "from-the-file");
assert_eq!(t.login, CORE_LOGIN);
fn the_nix_credential_is_the_hives_login_then_its_token_at_0600() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("forge-hive-token");
stored().write_credential(&path).expect("written");
assert_eq!(
std::fs::read_to_string(&path).expect("read"),
"hive-alpha\nfrom-the-store\n"
);
assert_eq!(mode(&path), 0o600);
}
#[test]
fn neither_is_none() {
assert!(pick(None, None).is_none());
fn a_rotated_token_replaces_the_nix_credential() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("forge-hive-token");
stored().write_credential(&path).expect("first");
let rotated = ReaderToken {
login: "hive-alpha".to_owned(),
value: "rotated".to_owned(),
};
rotated.write_credential(&path).expect("second");
assert_eq!(
std::fs::read_to_string(&path).expect("read"),
"hive-alpha\nrotated\n"
);
assert_eq!(mode(&path), 0o600);
}
#[test]
fn an_unchanged_credential_is_not_rewritten() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("forge-hive-token");
stored().write_credential(&path).expect("first");
// A rewrite goes through a sibling temp file and a rename, which
// gives the file a new inode.
let before = std::fs::metadata(&path).expect("stat");
stored().write_credential(&path).expect("second");
let after = std::fs::metadata(&path).expect("stat");
assert_eq!(
std::os::unix::fs::MetadataExt::ino(&before),
std::os::unix::fs::MetadataExt::ino(&after)
);
}
/// `hive-forge-git.nix`'s credential helper names this path as a literal.
#[test]
fn the_nix_credential_lives_on_the_runtime_tmpfs_where_the_helper_reads_it() {
assert_eq!(
crate::paths::FORGE_HIVE_TOKEN,
"/run/hyperhive/forge-hive-token"
);
assert!(crate::paths::FORGE_HIVE_TOKEN.starts_with(crate::paths::RUNTIME_ROOT));
}
#[test]
fn a_path_that_cannot_be_written_is_an_error_not_a_panic() {
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("a-file");
std::fs::write(&file, "").expect("file");
assert!(stored().write_credential(&file.join("sub")).is_err());
}
#[test]

View file

@ -1,389 +0,0 @@
//! The bootstrap `core` admin user: its account, access-token lifecycle
//! and avatar upload. The typed API-client constructor + `forgejo admin`
//! helpers live in the module root (`super`).
use std::path::Path;
use anyhow::{Context, Result};
use base64::Engine;
use forgejo_api::structs::UpdateUserAvatarOption;
use forgejo_api::{ApiErrorKind, ForgejoError};
use reqwest::StatusCode;
use super::{api, bounded, forge_admin};
const TOKEN_NAME_PREFIX: &str = "hyperhive";
// Where the host-side `core` admin token lives. Used by hive-c0re itself
// to drive admin API calls and as the credential nix's git reads. Root-only. Literal lives
// in `crate::paths`; aliased here under the long-standing name.
use crate::paths::FORGE_CORE_TOKEN as CORE_TOKEN_PATH;
// The `forge/core-avatar-set` marker lives in `crate::paths` — a one-shot
// guard: the upload runs once, the marker is written, subsequent startups
// skip. Delete it to force a re-upload.
// The avatar PNG path is resolved by `core_avatar_png_path` below —
// only-used-here, so it lives in this module rather than the shared
// `hive_sh4re::assets` helpers (which now hold only `prompt_template`, the
// one asset path every crate needs — the agent icon, the last other one, moved off the
// shared-asset model entirely; see `hive-agent::web_ui::screen`).
/// `$HIVE_ASSETS_DIR/branding/hyperhive.png` — the core mark,
/// rasterised. Not independently configurable — override the whole
/// `services.hyperhive.c0re.assets` package to change it.
fn core_avatar_png_path() -> std::path::PathBuf {
let dir = std::env::var("HIVE_ASSETS_DIR").expect(
"HIVE_ASSETS_DIR is unset — the hyperhive NixOS module sets it from \
services.hyperhive.c0re.assets on the hive-c0re unit, so this \
process was started outside that unit",
);
std::path::PathBuf::from(dir).join("branding/hyperhive.png")
}
/// Bootstrap `core` token scopes — the same set as swarm-controller's
/// `AGENT_TOKEN_SCOPES`, with no admin scope: nothing in hive-c0re calls
/// `/api/v1/admin/*`. See `docs/integrations/forge.md::Token scopes`.
const CORE_TOKEN_SCOPES: &str = "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 `hive_meta_flake::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. The password is random and never
/// read: the account authenticates by token.
async fn ensure_user_exists(name: &str, admin: bool) -> Result<()> {
let email = agent_email(name);
let mut args = vec![
"user",
"create",
"--username",
name,
"--email",
&email,
"--random-password",
"--must-change-password=false",
];
if admin {
args.push("--admin");
}
let result = forge_admin(&args).await;
match result {
Ok(_) => {
tracing::info!(%name, "forge: created user");
Ok(())
}
Err(e) => {
// Forgejo's "already exists" error wording varies; just
// try the next step and let token issuance surface a
// real failure if the user truly isn't there.
let msg = format!("{e:#}");
if msg.contains("already exists") || msg.contains("user already") {
tracing::debug!(%name, "forge: user already exists");
Ok(())
} else {
tracing::warn!(%name, error = %msg, "forge: user create unclear; trying token anyway");
Ok(())
}
}
}
}
/// Mint a fresh access token for `name`. 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`;
/// the bootstrap `core` user, its one caller, passes `CORE_TOKEN_SCOPES`.
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?;
// Deliberately does NOT include `stdout`: on this path forgejo has
// already minted a live token and printed it, and an error string
// propagates into logs the same way any other message does. The
// shape of the output is enough to diagnose a version drift.
let token = extract_token(&stdout).with_context(|| {
format!(
"no token-shaped word (>= 32 hex chars) in forgejo output \
({} bytes, {} lines); output withheld, it carries the token",
stdout.len(),
stdout.lines().count()
)
})?;
tracing::debug!(%name, %token_name, "forge: minted access token");
Ok(token)
}
/// 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<()> {
let token = mint_token("core", CORE_TOKEN_SCOPES).await?;
crate::secret_file::write_secret_0600(path, format!("{token}\n").as_bytes())
.with_context(|| format!("write core token to {}", path.display()))?;
tracing::info!(path = %path.display(), "forge: persisted core access token");
Ok(())
}
/// 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 = core_avatar_png_path();
let png_bytes = tokio::fs::read(&png_path)
.await
.with_context(|| format!("read core avatar PNG from {}", png_path.display()))?;
// The raw-HTTP predecessor POSTed the admin endpoint
// `/admin/users/core/avatar`, which forgejo-api has no method for.
// `token` IS the core user's own token though, so updating "the
// current user's avatar" (`POST /user/avatar`) is behaviorally
// identical.
bounded(
"set core avatar",
api(token)?.user_update_avatar(UpdateUserAvatarOption {
image: Some(base64::engine::general_purpose::STANDARD.encode(&png_bytes)),
}),
)
.await?
.context("set core avatar")?;
if let Err(e) = write_marker(&marker) {
tracing::warn!(marker = %marker.display(), error = ?e, "forge: could not write the avatar marker; the upload repeats on the next start");
}
tracing::info!("forge: set core user avatar to hyperhive logo");
Ok(())
}
/// Create the one-shot marker file, and its directory if needed.
fn write_marker(marker: &Path) -> std::io::Result<()> {
if let Some(parent) = marker.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(marker, "")
}
/// 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.
#[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 a failed token-probe call to a [`CoreTokenCheck`]. Only a
/// definitive auth rejection (401/403 — surfaced by the typed client
/// as `Unauthorized`/`Forbidden` API errors, or defensively as bare
/// `UnexpectedStatusCode`s) is `Invalid`; anything else (transport,
/// 5xx, unexpected shapes) is `Indeterminate`. Pure so the decision
/// logic is unit-testable without a live forge.
fn classify_core_token_error(e: &ForgejoError) -> CoreTokenCheck {
match e {
ForgejoError::ApiError(api) => match api.error_kind() {
ApiErrorKind::Unauthorized | ApiErrorKind::Forbidden => CoreTokenCheck::Invalid,
_ => CoreTokenCheck::Indeterminate,
},
ForgejoError::UnexpectedStatusCode(s)
if *s == StatusCode::UNAUTHORIZED || *s == StatusCode::FORBIDDEN =>
{
CoreTokenCheck::Invalid
}
_ => CoreTokenCheck::Indeterminate,
}
}
/// Probe whether `token` is still accepted by the current forge with a
/// cheap authenticated `user_get_current` (`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 Ok(client) = api(token) else {
return CoreTokenCheck::Indeterminate;
};
let Ok(probe) = bounded("probe core token", client.user_get_current()).await else {
tracing::debug!("forge: core-token probe timed out; treating as indeterminate");
return CoreTokenCheck::Indeterminate;
};
match probe {
Ok(_) => CoreTokenCheck::Valid,
Err(e) => {
let outcome = classify_core_token_error(&e);
if outcome == CoreTokenCheck::Indeterminate {
tracing::debug!(
error = %e,
"forge: core-token probe inconclusive (unreachable / unexpected response); \
treating as indeterminate"
);
}
outcome
}
}
}
/// Ensure the bootstrap `core` admin user + a token at
/// `CORE_TOKEN_PATH`. The token is what hive-c0re uses for forgejo
/// API calls (meta-repo push, collaborator grants).
/// 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).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_error, write_marker};
use forgejo_api::{ApiError, ApiErrorKind, ForgejoError};
use reqwest::StatusCode;
#[test]
fn write_marker_creates_the_missing_parent_dir() {
let dir = tempfile::tempdir().expect("tempdir");
let marker = dir.path().join("not-yet").join("nested").join("avatar-set");
write_marker(&marker).expect("write");
assert!(marker.is_file());
}
/// A parent that is a regular file makes `create_dir_all` fail for any
/// uid, root included.
#[test]
fn write_marker_reports_a_path_it_cannot_create() {
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("a-file");
std::fs::write(&file, "").expect("file");
assert!(write_marker(&file.join("sub").join("avatar-set")).is_err());
}
fn api_err(kind: ApiErrorKind) -> ForgejoError {
ForgejoError::ApiError(ApiError {
message: None,
kind,
})
}
#[test]
fn auth_rejection_errors_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_error(&api_err(ApiErrorKind::Unauthorized)),
CoreTokenCheck::Invalid
);
assert_eq!(
classify_core_token_error(&api_err(ApiErrorKind::Forbidden)),
CoreTokenCheck::Invalid
);
// Defensive: the same statuses arriving as bare status codes
// (endpoint spec didn't list them) must classify identically.
for s in [StatusCode::UNAUTHORIZED, StatusCode::FORBIDDEN] {
assert_eq!(
classify_core_token_error(&ForgejoError::UnexpectedStatusCode(s)),
CoreTokenCheck::Invalid,
"status {s} should be invalid"
);
}
}
#[test]
fn transient_and_unexpected_errors_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_error(&ForgejoError::UnexpectedStatusCode(s)),
CoreTokenCheck::Indeterminate,
"status {s} should be indeterminate"
);
}
assert_eq!(
classify_core_token_error(&api_err(ApiErrorKind::NotFound { errors: None })),
CoreTokenCheck::Indeterminate
);
assert_eq!(
classify_core_token_error(&api_err(ApiErrorKind::Generic)),
CoreTokenCheck::Indeterminate
);
}
}

View file

@ -119,11 +119,11 @@ pub(super) async fn run_node(
(builder, result)
}
/// Boot-time forge user/token sweep as a DAG node — see
/// [`NodeKind::ForgeSweep`]. `forge::ensure_all` already does its own
/// per-step error handling and boot warnings internally (it's
/// best-effort by design), so this wrapper has nothing left to report;
/// it exists purely to make the sweep a visible unit of work.
/// Boot-time publish of the hive's forge credential for nix, as a DAG node —
/// see [`NodeKind::ForgeSweep`]. `forge::ensure_all` logs its own outcome and
/// is best-effort (a build re-publishes the credential before it fetches), so
/// this wrapper has nothing left to report; it exists to make the step a
/// visible unit of work.
async fn run_forge_sweep() -> Result<()> {
crate::forge::ensure_all().await;
Ok(())

View file

@ -96,8 +96,8 @@ pub enum NodeKind {
/// Write the agent's durable power intent (`wanted = Up` when `up`,
/// else `Offline`) as the head node of a power-op DAG.
SetWanted { agent: String, up: bool },
/// One-shot boot-time forge user/token sweep for every existing
/// container (`forge::ensure_all`). Agentless.
/// One-shot boot-time publish of the hive's forge read token for nix
/// (`forge::ensure_all`). Agentless.
ForgeSweep,
/// Matrix user/space sweep (`matrix::ensure_all`): at boot and every 30
/// minutes. Agentless.

View file

@ -259,7 +259,7 @@ async fn cmd_serve(
tracing::warn!(error = ?e, "auto-update task failed");
}
});
// Forge user sweep: now a `NodeKind::ForgeSweep` DAG node (see
// Forge credential publish: a `NodeKind::ForgeSweep` DAG node (see
// `workers::auto_update::submit_startup_sweep_nodes`), submitted
// unconditionally on every boot — moved off a bare `tokio::spawn` so it
// shows as real work on the dashboard.

View file

@ -19,8 +19,8 @@ use swarm_queue_client::agent_archetype::ArchetypeRef;
/// What the forge says about `internal/meta` and an agent's config repo.
pub(crate) trait MetaSource {
/// Whether nix's own `git` can authenticate to the forge: it reads as
/// `core`, from the token file, whatever token this hive reads with.
/// Whether nix's own `git` can authenticate to the forge: it reads as this
/// hive's account, from the credential file the source publishes.
fn nix_can_fetch(&self) -> bool;
/// The default branch's head commit of `internal/meta`; `None` when the
/// repo is absent or has no commit.

View file

@ -5,9 +5,9 @@
//! modules (`broker.sqlite`, `matrix-sender-token`, `agent-sockets.json`,
//! …). This module is the **single Rust-side source** for every host
//! path — the strictly host-side ones grouped into subdirs (`db/`,
//! `forge/`, `matrix/`, `run/`), plus the **nix-coupled** roots
//! `matrix/`, `run/`), plus the **nix-coupled** roots
//! (`agents/`, `applied/`, `meta/`, `shared/`, `gateway/`, `knowledge/`,
//! the register/core tokens, the `/run` runtime dirs).
//! the `/run` runtime dirs).
//!
//! Nix-coupled paths are NOT excluded (the old stance) — they're moved
//! here too, each carrying a `// nix: …` comment naming the module /
@ -63,18 +63,6 @@ pub fn db_dir() -> PathBuf {
state_root().join("db")
}
/// `forge/` — hive-c0re's own forge provisioning markers.
#[must_use]
pub fn forge_dir() -> PathBuf {
state_root().join("forge")
}
/// `forge/core-avatar-set` — marker: core account avatar uploaded.
#[must_use]
pub fn forge_core_avatar_marker() -> PathBuf {
forge_dir().join("core-avatar-set")
}
/// `agent-identity/` — one subdir per agent holding the store identity this
/// hive collected for it. Staging only: the files exist so `systemd-nspawn`
/// has something to `--load-credential` from, and nothing on the host ever
@ -246,12 +234,12 @@ pub fn gateway_agents_conf() -> PathBuf {
gateway_dir().join("agents.conf")
}
/// `forge-core-token` — the hive-c0re forge account API token. A `&str`
/// (used in `Path::new` + user-facing `format!` messages), so it stays a
/// const rather than a `PathBuf` fn.
// nix: read at provisioning; the same literal is in
// `nix/host-modules/hive-c0re/default.nix` — must match.
pub const FORGE_CORE_TOKEN: &str = "/var/lib/hyperhive/forge-core-token";
/// `forge-hive-token` — this hive's read-only forge token (login on the first
/// line, token on the second), copied from the swarm secret store for nix's
/// `git`. Tmpfs: re-published on every boot.
// nix: read by `git-credential-hive-forge`; the same literal is in
// `nix/host-modules/lib/hive-forge-git.nix` — must match.
pub const FORGE_HIVE_TOKEN: &str = "/run/hyperhive/forge-hive-token";
/// `/run/hyperhive` — the runtime root (host admin socket + per-agent dirs).
#[must_use]
@ -282,10 +270,9 @@ pub fn agent_runtime_dir(name: &str) -> PathBuf {
/// within the same filesystem is atomic.
pub fn relocate_legacy_state() {
let root = state_root();
let moves: [(&str, PathBuf); 6] = [
let moves: [(&str, PathBuf); 5] = [
("broker.sqlite", db_dir().join("broker.sqlite")),
("build_logs.sqlite", db_dir().join("build_logs.sqlite")),
("forge-core-avatar-set", forge_core_avatar_marker()),
("matrix-sender-token", matrix_sender_token()),
("matrix-space-room-id", matrix_space_room_id()),
("agent-sockets.json", agent_sockets_file()),

View file

@ -344,14 +344,6 @@ pub async fn reload_gateway_nginx() -> Result<()> {
ok(call(&PrivRequest::ReloadGatewayNginx).await?)
}
/// Run `forgejo admin <args>` inside the `hive-forge` container via
/// hive-priv (which runs as root and can nsenter into the container).
/// Returns `(stdout, stderr)` on success.
pub async fn run_forge_admin(args: &[&str]) -> Result<(String, String)> {
let owned: Vec<String> = args.iter().map(|s| (*s).to_owned()).collect();
check(call(&PrivRequest::RunForgeAdmin { args: owned }).await?)
}
/// Create (`paused: true`) or remove (`paused: false`) the pause marker in
/// `agent_name`'s harness dir via hive-priv (running as root).
///