refactor(hive-c0re): port forge module + knowledge hooks to forgejo-api
This commit is contained in:
parent
4468e86e2d
commit
b8a3927c43
5 changed files with 651 additions and 426 deletions
|
|
@ -1,15 +1,18 @@
|
|||
//! Per-agent Forgejo user + access-token provisioning, account
|
||||
//! policy (email alignment, repo-creation lockdown), avatar uploads,
|
||||
//! and the bootstrap `core` admin user + token lifecycle. Shared
|
||||
//! HTTP / `forgejo admin` helpers live in the module root (`super`).
|
||||
//! 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, FORGE_HTTP, forge_admin, forge_http, is_present};
|
||||
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
|
||||
|
|
@ -55,6 +58,49 @@ 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
|
||||
|
|
@ -129,11 +175,11 @@ async fn change_user_password(name: &str, password: &str) -> Result<()> {
|
|||
/// Forgejo's admin-user-edit endpoint from resetting `use_custom_avatar`
|
||||
/// on every `sync_agent` tick. Delete the marker to force re-alignment.
|
||||
///
|
||||
/// Uses the admin REST API (`PATCH /api/v1/admin/users/{name}`) rather
|
||||
/// than `forgejo admin user edit` because the CLI dropped the `edit`
|
||||
/// subcommand somewhere between forgejo 8 and current. Body includes
|
||||
/// `login_name` (required by Forgejo's `EditUserOption` validator) and
|
||||
/// `source_id = 0` (local auth, the default for users hive-c0re creates).
|
||||
/// 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() {
|
||||
|
|
@ -144,31 +190,33 @@ pub(super) async fn ensure_user_email(name: &str) {
|
|||
return;
|
||||
};
|
||||
let email = agent_email(name);
|
||||
// `login_name` is required by Forgejo's EditUserOption validator.
|
||||
// Omitting it caused Forgejo to reset use_custom_avatar on each call.
|
||||
let body = format!(r#"{{"email":"{email}","login_name":"{name}","source_id":0}}"#);
|
||||
let url = format!("{FORGE_HTTP}/api/v1/admin/users/{name}");
|
||||
match forge_http(reqwest::Method::PATCH, &url, &token, &body).await {
|
||||
Ok((status, _)) if status.is_success() => {
|
||||
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");
|
||||
}
|
||||
Ok((status, _)) if status == reqwest::StatusCode::FORBIDDEN => {
|
||||
Err(e) if is_forbidden(&e) => {
|
||||
// Core token missing admin scope — see
|
||||
// `docs/forge.md::Token scopes` migration note.
|
||||
tracing::warn!(
|
||||
%name, %email, %status,
|
||||
%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."
|
||||
);
|
||||
}
|
||||
Ok((status, _)) => {
|
||||
tracing::warn!(%name, %email, %status, "forge: PATCH user email returned non-success");
|
||||
}
|
||||
Err(e) => tracing::warn!(%name, error = %e, "forge: PATCH user email transport error"),
|
||||
Err(e) => tracing::warn!(%name, %email, error = %e, "forge: PATCH user email failed"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -183,11 +231,12 @@ pub(super) async fn ensure_user_email(name: &str) {
|
|||
/// so creation is refused while push / PR / clone stay intact. **Existing
|
||||
/// repos are untouched** — this only blocks *new* direct creation.
|
||||
///
|
||||
/// Marker-guarded like [`ensure_user_email`]: the PATCH runs once per
|
||||
/// agent (delete the marker to re-apply). Body carries `login_name` +
|
||||
/// `source_id` for the same reason `ensure_user_email` does — omitting
|
||||
/// `login_name` makes Forgejo's `EditUserOption` validator reset
|
||||
/// `use_custom_avatar`. Best-effort: failures warn, don't propagate.
|
||||
/// 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() {
|
||||
|
|
@ -197,28 +246,32 @@ pub(super) async fn ensure_repo_creation_disabled(name: &str) {
|
|||
tracing::debug!(%name, "forge: skipping ensure_repo_creation_disabled — no core token yet");
|
||||
return;
|
||||
};
|
||||
let body = format!(r#"{{"login_name":"{name}","source_id":0,"max_repo_creation":0}}"#);
|
||||
let url = format!("{FORGE_HTTP}/api/v1/admin/users/{name}");
|
||||
match forge_http(reqwest::Method::PATCH, &url, &token, &body).await {
|
||||
Ok((status, _)) if status.is_success() => {
|
||||
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)");
|
||||
}
|
||||
Ok((status, _)) if status == reqwest::StatusCode::FORBIDDEN => {
|
||||
Err(e) if is_forbidden(&e) => {
|
||||
tracing::warn!(
|
||||
%name, %status,
|
||||
%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."
|
||||
);
|
||||
}
|
||||
Ok((status, _)) => {
|
||||
tracing::warn!(%name, %status, "forge: PATCH max_repo_creation returned non-success");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(%name, error = %e, "forge: PATCH max_repo_creation transport error");
|
||||
tracing::warn!(%name, error = %e, "forge: PATCH max_repo_creation failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -337,15 +390,17 @@ pub(super) async fn ensure_core_avatar(token: &str) -> Result<()> {
|
|||
let png_bytes = tokio::fs::read(&png_path)
|
||||
.await
|
||||
.with_context(|| format!("read core avatar PNG from {}", png_path.display()))?;
|
||||
let body = format!(
|
||||
r#"{{"image":"{}"}}"#,
|
||||
base64::engine::general_purpose::STANDARD.encode(&png_bytes),
|
||||
);
|
||||
let url = format!("{FORGE_HTTP}/api/v1/admin/users/core/avatar");
|
||||
let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("set core avatar: HTTP {status}");
|
||||
}
|
||||
// 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();
|
||||
}
|
||||
|
|
@ -356,9 +411,8 @@ pub(super) async fn ensure_core_avatar(token: &str) -> Result<()> {
|
|||
|
||||
/// Set the `agent-configs` org's Forgejo avatar to the
|
||||
/// configs-stack glyph once. Sibling to `ensure_core_avatar`:
|
||||
/// one-shot, marker-guarded, best-effort. Forgejo's per-org avatar
|
||||
/// endpoint is `POST /api/v1/orgs/{org}/avatar` with a base64-PNG
|
||||
/// JSON body — same shape as the admin user endpoint above.
|
||||
/// 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() {
|
||||
|
|
@ -368,15 +422,15 @@ pub(super) async fn ensure_config_org_avatar(token: &str) -> Result<()> {
|
|||
let png_bytes = tokio::fs::read(&png_path)
|
||||
.await
|
||||
.with_context(|| format!("read {CONFIG_ORG} avatar PNG from {}", png_path.display()))?;
|
||||
let body = format!(
|
||||
r#"{{"image":"{}"}}"#,
|
||||
base64::engine::general_purpose::STANDARD.encode(&png_bytes),
|
||||
);
|
||||
let url = format!("{FORGE_HTTP}/api/v1/orgs/{CONFIG_ORG}/avatar");
|
||||
let (status, _) = forge_http(reqwest::Method::POST, &url, token, &body).await?;
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("set {CONFIG_ORG} avatar: HTTP {status}");
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
|
@ -407,32 +461,47 @@ enum CoreTokenCheck {
|
|||
Indeterminate,
|
||||
}
|
||||
|
||||
/// Map the HTTP status of the token-probe call to a [`CoreTokenCheck`].
|
||||
/// Pure so the decision logic is unit-testable without a live forge.
|
||||
fn classify_core_token_status(status: StatusCode) -> CoreTokenCheck {
|
||||
if status.is_success() {
|
||||
CoreTokenCheck::Valid
|
||||
} else if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
|
||||
CoreTokenCheck::Invalid
|
||||
} else {
|
||||
CoreTokenCheck::Indeterminate
|
||||
/// 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 `GET /api/v1/user` (covered by the core token's
|
||||
/// `read:user` scope). See [`CoreTokenCheck`] for how the outcome is
|
||||
/// interpreted.
|
||||
/// 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 url = format!("{FORGE_HTTP}/api/v1/user");
|
||||
match forge_http(reqwest::Method::GET, &url, token, "").await {
|
||||
Ok((status, _)) => classify_core_token_status(status),
|
||||
let Ok(client) = api(token) else {
|
||||
return CoreTokenCheck::Indeterminate;
|
||||
};
|
||||
match client.user_get_current().await {
|
||||
Ok(_) => CoreTokenCheck::Valid,
|
||||
Err(e) => {
|
||||
tracing::debug!(
|
||||
error = %e,
|
||||
"forge: core-token probe could not reach forge; treating as indeterminate"
|
||||
);
|
||||
CoreTokenCheck::Indeterminate
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -484,37 +553,42 @@ pub fn core_token() -> Option<String> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{CoreTokenCheck, classify_core_token_status};
|
||||
use super::{CoreTokenCheck, classify_core_token_error};
|
||||
use forgejo_api::{ApiError, ApiErrorKind, ForgejoError};
|
||||
use reqwest::StatusCode;
|
||||
|
||||
#[test]
|
||||
fn success_statuses_are_valid() {
|
||||
assert_eq!(
|
||||
classify_core_token_status(StatusCode::OK),
|
||||
CoreTokenCheck::Valid
|
||||
);
|
||||
assert_eq!(
|
||||
classify_core_token_status(StatusCode::NO_CONTENT),
|
||||
CoreTokenCheck::Valid
|
||||
);
|
||||
fn api_err(kind: ApiErrorKind) -> ForgejoError {
|
||||
ForgejoError::ApiError(ApiError {
|
||||
message: None,
|
||||
kind,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_rejection_statuses_are_invalid() {
|
||||
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_status(StatusCode::UNAUTHORIZED),
|
||||
classify_core_token_error(&api_err(ApiErrorKind::Unauthorized)),
|
||||
CoreTokenCheck::Invalid
|
||||
);
|
||||
assert_eq!(
|
||||
classify_core_token_status(StatusCode::FORBIDDEN),
|
||||
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_statuses_are_indeterminate() {
|
||||
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 [
|
||||
|
|
@ -525,10 +599,18 @@ mod tests {
|
|||
StatusCode::NOT_FOUND,
|
||||
] {
|
||||
assert_eq!(
|
||||
classify_core_token_status(s),
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue