Watch
0
0
Fork
You've already forked hyperhive
0

hive-c0re: drop the spawn-time forge user-email, repo-creation and config-repo passes

sync_agent no longer runs ensure_user_email, ensure_repo_creation_disabled
or ensure_config_repo. swarm-controller does all three before any deploy:
CreateForgeUser sets the account email and max_repo_creation = 0 (and the
agent_token backfill re-runs CreateForgeUser), CreateRepo creates and
protects agent-configs/<agent>, AddRepoMember grants the agent write on it,
and InitAgentConfigRepo seeds it.

What goes with them: the email-aligned and repo-creation-disabled marker
paths and the legacy email-marker relocate, sparse_edit_user_option,
is_forbidden and its tests, and ensure_org_repo. A config repo deleted by
hand is no longer recreated on the next spawn; the operator recreates the
agent at swarm level. Comments in swarm-controller that described the
removed hive passes now state only the current behaviour.

Closes #4848
This commit is contained in:
atlas 2026-10-04 20:12:48 +02:00 • committed by mara
commit 1de8a01936
6 changed files with 44 additions and 351 deletions

View file

@ -1,14 +1,12 @@
//! 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`).
//! 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::{EditUserOption, UpdateUserAvatarOption};
use forgejo_api::structs::UpdateUserAvatarOption;
use forgejo_api::{ApiErrorKind, ForgejoError};
use reqwest::StatusCode;
@ -19,10 +17,9 @@ const TOKEN_NAME_PREFIX: &str = "hyperhive";
// 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/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.
// 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
@ -67,49 +64,6 @@ 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
@ -152,116 +106,6 @@ async fn ensure_user_exists(name: &str, admin: bool) -> Result<()> {
}
}
/// 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/integrations/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. 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
@ -461,7 +305,7 @@ pub fn core_token() -> Option<String> {
#[cfg(test)]
mod tests {
use super::{CoreTokenCheck, classify_core_token_error, is_forbidden};
use super::{CoreTokenCheck, classify_core_token_error};
use forgejo_api::{ApiError, ApiErrorKind, ForgejoError};
use reqwest::StatusCode;
@ -521,58 +365,4 @@ mod tests {
CoreTokenCheck::Indeterminate
);
}
/// The typed client only produces `ApiErrorKind::Forbidden` when the
/// endpoint's spec lists 403; every other endpoint surfaces the same
/// response as a bare `UnexpectedStatusCode`. Both shapes must be
/// recognised, because the arms guarded by this predicate are what tell
/// the operator that `ensure_repo_creation_disabled` — the lockdown
/// stopping an agent from creating and self-merging in its own repo —
/// did not apply, and which credential to re-mint to make it apply.
/// Drop the second arm as "redundant" and that lockdown fails with a
/// generic warning that names no remedy.
#[test]
fn a_403_is_recognised_in_both_shapes_the_client_can_produce() {
assert!(is_forbidden(&api_err(ApiErrorKind::Forbidden)));
assert!(is_forbidden(&ForgejoError::UnexpectedStatusCode(
StatusCode::FORBIDDEN
)));
}
/// The other direction, and the more expensive one to get wrong: this
/// predicate gates advice to **delete the core token and restart
/// hive-c0re**. A widened match would hand that advice out for a 502
/// from a restarting forge or a client-side error that never reached
/// the network — destroying a working credential in response to a blip.
/// 401 is called out separately because it is the near miss: it *is* a
/// credential problem, but its remedy is [`classify_core_token_error`]'s
/// automatic re-mint, so classifying it as forbidden buries the cause
/// under a scope warning that does not apply.
#[test]
fn a_non_403_failure_is_not_reported_as_a_missing_admin_scope() {
for kind in [
ApiErrorKind::Unauthorized,
ApiErrorKind::NotFound { errors: None },
ApiErrorKind::ValidationFailed,
ApiErrorKind::Generic,
] {
let e = api_err(kind);
assert!(!is_forbidden(&e), "{e} must not read as forbidden");
}
for s in [
StatusCode::UNAUTHORIZED,
StatusCode::NOT_FOUND,
StatusCode::BAD_GATEWAY,
StatusCode::INTERNAL_SERVER_ERROR,
] {
assert!(
!is_forbidden(&ForgejoError::UnexpectedStatusCode(s)),
"status {s} must not read as forbidden"
);
}
// A failure with no HTTP status at all — the request never got an
// answer, so nothing has been said about the token's scopes.
assert!(!is_forbidden(&ForgejoError::KeyNotAscii));
assert!(!is_forbidden(&ForgejoError::HostRequired));
}
}