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:
parent
32a4821cb4
commit
b79896c237
23 changed files with 236 additions and 1118 deletions
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
///
|
||||
|
|
|
|||
Loading…
Reference in a new issue