Takes the crate from 26 rustdoc warnings to 1, on top of the ten in the previous commit. argus's review findings: - agent_sockets.rs: [`write`] was still ambiguous (function vs macro). The previous change narrowed the qualifier and left the ambiguity; [`write()`] is what resolves it. - forge/users.rs <hex> and stats/container_stats.rs <name>: unclosed HTML tags in prose, now backticked. The rest of the crate, so the count actually reaches zero: - job_queue/mod.rs: Queue::graph_snapshot -> JobQueue::graph_snapshot (there is no Queue type), and super::scheduler -> scheduler (mod.rs *is* job_queue, so super:: pointed outside it) - job_queue/resource.rs: NodeKind -> super::model::NodeKind - matrix.rs: password_path(name) -> password_path; and forge::provision_user_token -> crate::forge::provision_user_token. Note the path has no `users` segment: forge/mod.rs declares `mod users` private and re-exports it, so the canonical path comes from the re-export rather than the directory tree. - socket_server/lifecycle_handlers.rs: InfraContainer -> hive_priv_sock::InfraContainer - stats/otel_metrics.rs: crate::meta::otel_config is a private fn no path can name from another module, so it becomes prose - main.rs: redundant explicit link target dropped coordinator.rs:405 (CrashWatchGuard) is deliberately untouched: #3244 deletes that doc block, so fixing it here would conflict with an open PR and repair a symbol that is about to stop existing.
657 lines
28 KiB
Rust
657 lines
28 KiB
Rust
//! Per-agent Forgejo user + access-token provisioning, account
|
|
//! policy (email alignment, repo-creation lockdown), avatar uploads,
|
|
//! and the bootstrap `core` admin user + token lifecycle. 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::{EditUserOption, UpdateUserAvatarOption};
|
|
use forgejo_api::{ApiErrorKind, ForgejoError};
|
|
use reqwest::StatusCode;
|
|
|
|
use super::{CONFIG_ORG, api, forge_admin, is_present};
|
|
|
|
const TOKEN_NAME_PREFIX: &str = "hyperhive";
|
|
// Where the host-side `core` admin token lives. Used by hive-c0re itself
|
|
// to push the meta repo + drive admin API calls. Root-only. Literal lives
|
|
// in `crate::paths`; aliased here under the long-standing name.
|
|
use crate::paths::FORGE_CORE_TOKEN as CORE_TOKEN_PATH;
|
|
// Forge provisioning markers (`forge/core-avatar-set`,
|
|
// `forge/agent-configs-avatar-set`, `forge/email-aligned-<name>`) live
|
|
// in `crate::paths` — one-shot guards: the upload/align runs once, the
|
|
// marker is written, subsequent startups skip. Delete one to force its
|
|
// step to re-run.
|
|
// Avatar PNG paths are resolved by `core_avatar_png_path` /
|
|
// `config_org_avatar_png_path` below — only-used-here, so they live
|
|
// 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 (unlike the org avatar
|
|
/// below) — 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 — hive-c0re.nix sets it unconditionally, \
|
|
so this process was started outside the NixOS module",
|
|
);
|
|
std::path::PathBuf::from(dir).join("branding/hyperhive.png")
|
|
}
|
|
|
|
/// Path to the `agent-configs` org's avatar PNG. Independently
|
|
/// configurable via `services.hyperhive.c0re.orgAvatarPng` — the nix
|
|
/// module resolves `HIVE_ORG_AVATAR_PNG` to that option's value, or
|
|
/// the bundled `agent-configs.png` from `assets` when unset, so an
|
|
/// operator can override just this image without replacing the whole
|
|
/// `assets` package.
|
|
fn config_org_avatar_png_path() -> std::path::PathBuf {
|
|
std::path::PathBuf::from(std::env::var("HIVE_ORG_AVATAR_PNG").expect(
|
|
"HIVE_ORG_AVATAR_PNG is unset — hive-c0re.nix sets it unconditionally \
|
|
(from services.hyperhive.c0re.orgAvatarPng or the bundled default), \
|
|
so this process was started outside the NixOS module",
|
|
))
|
|
}
|
|
/// Per-agent token scopes (broad-but-not-admin) for tokens hive-c0re
|
|
/// mints itself on the **internal** forge. See `docs/forge.md::Token
|
|
/// scopes` for the per-scope rationale. Not `pub(super)` — external
|
|
/// forges (`dashboard/extra_forges.rs`) take an operator-pasted token
|
|
/// verbatim, so their scope is whatever the operator's remote account
|
|
/// happened to grant; we never mint there and don't need to know it.
|
|
const TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc";
|
|
|
|
/// Bootstrap `core` token scopes — adds `read:admin,write:admin` on
|
|
/// top of `TOKEN_SCOPES` so the host daemon can drive
|
|
/// `/api/v1/admin/*`. Site-admin membership alone isn't enough: the
|
|
/// token's own scope gate runs before the user-permission check.
|
|
/// See `docs/forge.md::Token scopes`.
|
|
const CORE_TOKEN_SCOPES: &str = "read:admin,write:admin,read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc";
|
|
|
|
/// Pull the access token out of forgejo's success message. Format
|
|
/// has shifted across versions (table form vs. "Access token was
|
|
/// successfully created: `<hex>`"), so just hunt the output for the
|
|
/// first long hex-looking word.
|
|
fn extract_token(output: &str) -> Option<String> {
|
|
output
|
|
.split(|c: char| c.is_whitespace() || c == ',' || c == ':')
|
|
.find(|w| w.len() >= 32 && w.chars().all(|c| c.is_ascii_hexdigit()))
|
|
.map(str::to_owned)
|
|
}
|
|
|
|
/// Canonical email address for a hive agent's Forgejo account.
|
|
/// Must match the `user.email` set by `meta::render_flake` so commits
|
|
/// by the agent link back to their Forgejo profile page.
|
|
fn agent_email(name: &str) -> String {
|
|
format!("{name}@hyperhive.local")
|
|
}
|
|
|
|
/// `EditUserOption` with every field unset except the ones Forgejo's
|
|
/// validator effectively requires: `login_name` and `source_id = 0`
|
|
/// (local auth, the default for users hive-c0re creates). Omitting
|
|
/// `login_name` made Forgejo reset `use_custom_avatar` on every admin
|
|
/// edit — the same reason the old raw JSON bodies always carried both
|
|
/// fields. Callers set only the field(s) they mean to change on top.
|
|
fn sparse_edit_user_option(name: &str) -> EditUserOption {
|
|
EditUserOption {
|
|
active: None,
|
|
admin: None,
|
|
allow_create_organization: None,
|
|
allow_git_hook: None,
|
|
allow_import_local: None,
|
|
description: None,
|
|
email: None,
|
|
full_name: None,
|
|
hide_email: None,
|
|
location: None,
|
|
login_name: Some(name.to_owned()),
|
|
max_repo_creation: None,
|
|
must_change_password: None,
|
|
password: None,
|
|
prohibit_login: None,
|
|
pronouns: None,
|
|
restricted: None,
|
|
source_id: Some(0),
|
|
visibility: None,
|
|
website: None,
|
|
}
|
|
}
|
|
|
|
/// Whether a Forgejo API error is a definitive 403. The typed client
|
|
/// maps a 403 response to `ApiErrorKind::Forbidden` when the endpoint
|
|
/// spec lists it, or to `UnexpectedStatusCode(403)` otherwise — check
|
|
/// both defensively.
|
|
fn is_forbidden(e: &ForgejoError) -> bool {
|
|
match e {
|
|
ForgejoError::ApiError(api) => matches!(api.error_kind(), ApiErrorKind::Forbidden),
|
|
ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::FORBIDDEN,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Ensure a forgejo user named `name` exists. Idempotent: forgejo
|
|
/// returns a "user already exists" error which we treat as success.
|
|
/// `admin` adds `--admin` (site admin) — used for the bootstrap
|
|
/// `core` user that drives the API. `password` picks the initial
|
|
/// account password: `None` uses `--random-password` (the existing
|
|
/// agent provisioning shape — the password is never read, agents auth
|
|
/// by token); `Some(pw)` uses `--password <pw>` so the operator path
|
|
/// in `hivectl` can set a real password for matrix-style web-UI login.
|
|
async fn ensure_user_exists(name: &str, admin: bool, password: Option<&str>) -> Result<()> {
|
|
let email = agent_email(name);
|
|
let mut args = vec!["user", "create", "--username", name, "--email", &email];
|
|
match password {
|
|
Some(pw) => args.extend(["--password", pw, "--must-change-password=false"]),
|
|
None => args.extend(["--random-password", "--must-change-password=false"]),
|
|
}
|
|
if admin {
|
|
args.push("--admin");
|
|
}
|
|
let result = forge_admin(&args).await;
|
|
match result {
|
|
Ok(_) => {
|
|
tracing::info!(%name, "forge: created user");
|
|
Ok(())
|
|
}
|
|
Err(e) => {
|
|
// Forgejo's "already exists" error wording varies; just
|
|
// try the next step and let token issuance surface a
|
|
// real failure if the user truly isn't there.
|
|
let msg = format!("{e:#}");
|
|
if msg.contains("already exists") || msg.contains("user already") {
|
|
tracing::debug!(%name, "forge: user already exists");
|
|
Ok(())
|
|
} else {
|
|
tracing::warn!(%name, error = %msg, "forge: user create unclear; trying token anyway");
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Set the forgejo password for an existing user. Used by the operator
|
|
/// path in `hivectl forge create-user --password` so re-running on an
|
|
/// already-created account still updates the password (covers the
|
|
/// "I forgot the password I set last week" case + the "argus retried
|
|
/// the verb to verify the fix" case — `forgejo admin user create`
|
|
/// silently skips a password change once the account exists). Idempotent
|
|
/// from the operator's point of view: same password input → same final
|
|
/// account state.
|
|
async fn change_user_password(name: &str, password: &str) -> Result<()> {
|
|
let args = [
|
|
"user",
|
|
"change-password",
|
|
"--username",
|
|
name,
|
|
"--password",
|
|
password,
|
|
];
|
|
forge_admin(&args)
|
|
.await
|
|
.with_context(|| format!("forgejo admin user change-password {name}"))?;
|
|
tracing::info!(%name, "forge: changed user password");
|
|
Ok(())
|
|
}
|
|
|
|
/// Idempotently align the Forgejo account email to `agent_email(name)`.
|
|
/// Existing agents were created with `{name}@hive.local`; this corrects
|
|
/// that so git commits (which use `{name}@hyperhive`) link to profiles.
|
|
/// Best-effort: failures are warned, not propagated.
|
|
///
|
|
/// Marker-guarded: writes `EMAIL_ALIGNED_MARKER_PREFIX{name}` on first
|
|
/// success and skips the PATCH on all subsequent calls. This prevents
|
|
/// Forgejo's admin-user-edit endpoint from resetting `use_custom_avatar`
|
|
/// on every `sync_agent` tick. Delete the marker to force re-alignment.
|
|
///
|
|
/// Uses the admin REST API (`admin_edit_user`, i.e. `PATCH
|
|
/// /api/v1/admin/users/{name}`) rather than `forgejo admin user edit`
|
|
/// because the CLI dropped the `edit` subcommand somewhere between
|
|
/// forgejo 8 and current. The edit body carries `login_name` +
|
|
/// `source_id = 0` via [`sparse_edit_user_option`].
|
|
pub(super) async fn ensure_user_email(name: &str) {
|
|
let marker = crate::paths::forge_email_aligned_marker(name);
|
|
if marker.exists() {
|
|
return;
|
|
}
|
|
let Some(token) = core_token() else {
|
|
tracing::debug!(%name, "forge: skipping ensure_user_email — no core token yet");
|
|
return;
|
|
};
|
|
let email = agent_email(name);
|
|
let mut edit = sparse_edit_user_option(name);
|
|
edit.email = Some(email.clone());
|
|
let client = match api(&token) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
tracing::warn!(%name, error = %e, "forge: PATCH user email: client build failed");
|
|
return;
|
|
}
|
|
};
|
|
match client.admin_edit_user(name, edit).await {
|
|
Ok(_) => {
|
|
if let Some(parent) = marker.parent() {
|
|
std::fs::create_dir_all(parent).ok();
|
|
}
|
|
std::fs::write(&marker, "").ok();
|
|
tracing::info!(%name, %email, "forge: user email aligned");
|
|
}
|
|
Err(e) if is_forbidden(&e) => {
|
|
// Core token missing admin scope — see
|
|
// `docs/forge.md::Token scopes` migration note.
|
|
tracing::warn!(
|
|
%name, %email, error = %e,
|
|
"forge: PATCH user email forbidden — core token likely missing admin scope. \
|
|
Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes."
|
|
);
|
|
}
|
|
Err(e) => tracing::warn!(%name, %email, error = %e, "forge: PATCH user email failed"),
|
|
}
|
|
}
|
|
|
|
/// Disable direct repo creation for agent `name` by setting
|
|
/// `max_repo_creation = 0` on its Forgejo account. Agents must
|
|
/// create repos *through hive-c0re* (which owns the perms), never with
|
|
/// their own token — a write-scoped token can otherwise create + own
|
|
/// repos and self-merge, bypassing the operator-only-merge policy.
|
|
///
|
|
/// `max_repo_creation = 0` means `CanCreateRepo()` is false for any
|
|
/// count (Forgejo: `MaxRepoCreation >= 0 && NumRepos >= MaxRepoCreation`),
|
|
/// so creation is refused while push / PR / clone stay intact. **Existing
|
|
/// repos are untouched** — this only blocks *new* direct creation.
|
|
///
|
|
/// Marker-guarded like [`ensure_user_email`]: the edit runs once per
|
|
/// agent (delete the marker to re-apply). The edit body carries
|
|
/// `login_name` + `source_id` for the same reason `ensure_user_email`
|
|
/// does — omitting `login_name` makes Forgejo's `EditUserOption`
|
|
/// validator reset `use_custom_avatar`. Best-effort: failures warn,
|
|
/// don't propagate.
|
|
pub(super) async fn ensure_repo_creation_disabled(name: &str) {
|
|
let marker = crate::paths::forge_repo_creation_disabled_marker(name);
|
|
if marker.exists() {
|
|
return;
|
|
}
|
|
let Some(token) = core_token() else {
|
|
tracing::debug!(%name, "forge: skipping ensure_repo_creation_disabled — no core token yet");
|
|
return;
|
|
};
|
|
let mut edit = sparse_edit_user_option(name);
|
|
edit.max_repo_creation = Some(0);
|
|
let client = match api(&token) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
tracing::warn!(%name, error = %e, "forge: PATCH max_repo_creation: client build failed");
|
|
return;
|
|
}
|
|
};
|
|
match client.admin_edit_user(name, edit).await {
|
|
Ok(_) => {
|
|
if let Some(parent) = marker.parent() {
|
|
std::fs::create_dir_all(parent).ok();
|
|
}
|
|
std::fs::write(&marker, "").ok();
|
|
tracing::info!(%name, "forge: disabled direct repo creation (max_repo_creation=0)");
|
|
}
|
|
Err(e) if is_forbidden(&e) => {
|
|
tracing::warn!(
|
|
%name, error = %e,
|
|
"forge: PATCH max_repo_creation forbidden — core token likely missing admin scope. \
|
|
Delete {CORE_TOKEN_PATH} and restart hive-c0re to re-mint with the new scopes."
|
|
);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(%name, error = %e, "forge: PATCH max_repo_creation failed");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Mint a fresh access token for `name`. Token name is suffixed with
|
|
/// a monotonic clock so re-issuing doesn't collide with an existing
|
|
/// token of the same name in the DB. `scopes` is the scope string
|
|
/// passed to `forgejo admin user generate-access-token --scopes`;
|
|
/// use `TOKEN_SCOPES` for agents, `CORE_TOKEN_SCOPES` for the
|
|
/// bootstrap `core` user.
|
|
async fn mint_token(name: &str, scopes: &str) -> Result<String> {
|
|
let token_name = format!(
|
|
"{TOKEN_NAME_PREFIX}-{}",
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map_or(0, |d| d.as_secs())
|
|
);
|
|
let stdout = forge_admin(&[
|
|
"user",
|
|
"generate-access-token",
|
|
"--username",
|
|
name,
|
|
"--token-name",
|
|
&token_name,
|
|
"--scopes",
|
|
scopes,
|
|
])
|
|
.await?;
|
|
// 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 an agent and write it to the
|
|
/// agent's state dir via hive-priv. hive-c0re runs unprivileged and
|
|
/// cannot write to agent-owned (0755) state directories directly.
|
|
async fn mint_and_persist_agent_token(name: &str) -> Result<()> {
|
|
let token = mint_token(name, TOKEN_SCOPES).await?;
|
|
crate::priv_client::write_agent_forge_token(name, &token)
|
|
.await
|
|
.with_context(|| format!("write forge-token for {name} via hive-priv"))
|
|
}
|
|
|
|
/// Mint a fresh Forgejo access token for the `core` admin user and
|
|
/// write it directly to `path`. Unlike agent tokens this path is owned
|
|
/// by hive-c0re itself (under `/var/lib/hyperhive/`), so a direct
|
|
/// write is both correct and necessary (no priv round-trip).
|
|
async fn mint_and_persist_core_token(path: &Path) -> Result<()> {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let token = mint_token("core", CORE_TOKEN_SCOPES).await?;
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent).ok();
|
|
}
|
|
std::fs::write(path, format!("{token}\n"))
|
|
.with_context(|| format!("write core token to {}", path.display()))?;
|
|
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
|
tracing::info!(path = %path.display(), "forge: persisted core access token");
|
|
Ok(())
|
|
}
|
|
|
|
/// Ensure `name` has a forgejo user + token file. Always re-mints the
|
|
/// token so the on-disk file always reflects the current `TOKEN_SCOPES`.
|
|
/// Safe to call on every spawn and on every hive-c0re startup.
|
|
pub async fn ensure_user_for(name: &str) -> Result<()> {
|
|
if !is_present().await {
|
|
return Ok(());
|
|
}
|
|
ensure_user_exists(name, false, None).await?;
|
|
ensure_user_email(name).await;
|
|
mint_and_persist_agent_token(name).await
|
|
}
|
|
|
|
/// Provision a forgejo user for `name` and return the freshly-minted
|
|
/// token. Unlike [`ensure_user_for`], the token is **not** persisted to
|
|
/// disk — the caller is responsible for storing it. Used by `hivectl
|
|
/// forge create-user` for human (non-agent) accounts so we don't create
|
|
/// stray `/var/lib/hyperhive/agents/<name>/` directories for users that
|
|
/// aren't agents.
|
|
///
|
|
/// `password` picks the account password. `None` keeps the existing
|
|
/// random-throwaway shape (caller doesn't need web UI access — token
|
|
/// alone is enough). `Some(pw)` sets `pw` as the password, including
|
|
/// running `forgejo admin user change-password` if the account already
|
|
/// exists, so the operator can log into the forge web UI afterwards.
|
|
/// Idempotent: re-running with the same `Some(pw)` lands on the same
|
|
/// final state.
|
|
pub async fn provision_user_token(name: &str, password: Option<&str>) -> Result<String> {
|
|
if !is_present().await {
|
|
anyhow::bail!(
|
|
"hive-forge container not running — wait for hive-c0re to start it before provisioning forge users"
|
|
);
|
|
}
|
|
ensure_user_exists(name, false, password).await?;
|
|
if let Some(pw) = password {
|
|
// `user create` silently no-ops on an existing account, so
|
|
// we run change-password unconditionally when the caller
|
|
// asked for a specific password — keeps the verb idempotent
|
|
// for "set or reset" use.
|
|
change_user_password(name, pw).await?;
|
|
}
|
|
ensure_user_email(name).await;
|
|
mint_token(name, TOKEN_SCOPES).await
|
|
}
|
|
|
|
/// Set `core`'s Forgejo avatar to the hyperhive logo once, then
|
|
/// remember it so subsequent startups don't re-upload. Best-effort
|
|
/// — any non-2xx is logged at the caller; the project runs fine
|
|
/// with the default hash identicon.
|
|
pub(super) async fn ensure_core_avatar(token: &str) -> Result<()> {
|
|
let marker = crate::paths::forge_core_avatar_marker();
|
|
if marker.exists() {
|
|
return Ok(());
|
|
}
|
|
let png_path = 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.
|
|
api(token)?
|
|
.user_update_avatar(UpdateUserAvatarOption {
|
|
image: Some(base64::engine::general_purpose::STANDARD.encode(&png_bytes)),
|
|
})
|
|
.await
|
|
.context("set core avatar")?;
|
|
if let Some(parent) = marker.parent() {
|
|
std::fs::create_dir_all(parent).ok();
|
|
}
|
|
std::fs::write(marker, "").ok();
|
|
tracing::info!("forge: set core user avatar to hyperhive logo");
|
|
Ok(())
|
|
}
|
|
|
|
/// Set the `agent-configs` org's Forgejo avatar to the
|
|
/// configs-stack glyph once. Sibling to `ensure_core_avatar`:
|
|
/// one-shot, marker-guarded, best-effort. Uses `org_update_avatar`
|
|
/// (`POST /api/v1/orgs/{org}/avatar`, base64-PNG payload).
|
|
pub(super) async fn ensure_config_org_avatar(token: &str) -> Result<()> {
|
|
let marker = crate::paths::forge_config_org_avatar_marker();
|
|
if marker.exists() {
|
|
return Ok(());
|
|
}
|
|
let png_path = config_org_avatar_png_path();
|
|
let png_bytes = tokio::fs::read(&png_path)
|
|
.await
|
|
.with_context(|| format!("read {CONFIG_ORG} avatar PNG from {}", png_path.display()))?;
|
|
api(token)?
|
|
.org_update_avatar(
|
|
CONFIG_ORG,
|
|
UpdateUserAvatarOption {
|
|
image: Some(base64::engine::general_purpose::STANDARD.encode(&png_bytes)),
|
|
},
|
|
)
|
|
.await
|
|
.with_context(|| format!("set {CONFIG_ORG} avatar"))?;
|
|
if let Some(parent) = marker.parent() {
|
|
std::fs::create_dir_all(parent).ok();
|
|
}
|
|
std::fs::write(marker, "").ok();
|
|
tracing::info!(
|
|
org = CONFIG_ORG,
|
|
"forge: set org avatar to configs-stack logo"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Outcome of probing whether the persisted core token still works
|
|
/// against the *current* forge. Existence on disk is not validity: a
|
|
/// token minted before a forge rebuild / re-provision is unknown to the
|
|
/// new forge's DB and 401s on every call — which silently breaks the
|
|
/// hive-ci runner-registration prefetch (it reads this same token to
|
|
/// fetch a runner registration token).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum CoreTokenCheck {
|
|
/// Token authenticated successfully — keep using it.
|
|
Valid,
|
|
/// Forge explicitly rejected the token (401/403) — re-mint.
|
|
Invalid,
|
|
/// Couldn't determine (forge unreachable / 5xx). Don't re-mint on a
|
|
/// transient: keep the existing token and let a later ensure pass
|
|
/// re-check once the forge is responsive. Re-minting here would both
|
|
/// fail (mint needs the forge too) and churn tokens needlessly.
|
|
Indeterminate,
|
|
}
|
|
|
|
/// Map 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;
|
|
};
|
|
match client.user_get_current().await {
|
|
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 (org creation, meta-repo push, and the hive-ci
|
|
/// runner-registration prefetch). Returns the token.
|
|
///
|
|
/// Idempotent, but validity-aware: when a token file is already present
|
|
/// it is **probed against the current forge** before being trusted. A
|
|
/// token persisted before a forge rebuild / re-provision is stale (the
|
|
/// new forge DB doesn't know it) and would 401 every caller — so on a
|
|
/// definitive rejection the token is re-minted. A merely-unreachable
|
|
/// forge leaves the existing token in place (a later ensure pass
|
|
/// re-checks) rather than churning tokens on a transient.
|
|
pub(super) async fn ensure_core_user_and_token() -> Result<String> {
|
|
let path = std::path::Path::new(CORE_TOKEN_PATH);
|
|
if let Ok(existing) = std::fs::read_to_string(path) {
|
|
let trimmed = existing.trim().to_owned();
|
|
if !trimmed.is_empty() {
|
|
match check_core_token(&trimmed).await {
|
|
CoreTokenCheck::Valid | CoreTokenCheck::Indeterminate => return Ok(trimmed),
|
|
CoreTokenCheck::Invalid => {
|
|
tracing::warn!(
|
|
path = %path.display(),
|
|
"forge: persisted core token rejected by forge (stale after rebuild?); \
|
|
re-minting"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ensure_user_exists("core", true, None).await?;
|
|
mint_and_persist_core_token(path).await?;
|
|
let raw = std::fs::read_to_string(path)
|
|
.with_context(|| format!("read {CORE_TOKEN_PATH} after mint"))?;
|
|
Ok(raw.trim().to_owned())
|
|
}
|
|
|
|
/// Read the persisted core token, or None when the forge isn't
|
|
/// seeded yet. Cheap — just a file read.
|
|
pub fn core_token() -> Option<String> {
|
|
std::fs::read_to_string(CORE_TOKEN_PATH)
|
|
.ok()
|
|
.map(|s| s.trim().to_owned())
|
|
.filter(|s| !s.is_empty())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{CoreTokenCheck, classify_core_token_error};
|
|
use forgejo_api::{ApiError, ApiErrorKind, ForgejoError};
|
|
use reqwest::StatusCode;
|
|
|
|
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
|
|
);
|
|
}
|
|
}
|