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:
parent
d2c6f1a967
commit
1de8a01936
6 changed files with 44 additions and 351 deletions
|
|
@ -38,7 +38,7 @@ before the user-permission check, so `/api/v1/admin/*` returns
|
||||||
`403 Forbidden` for any token without the admin scope bits, even when
|
`403 Forbidden` for any token without the admin scope bits, even when
|
||||||
the bearer is a site admin.
|
the bearer is a site admin.
|
||||||
|
|
||||||
**Migration note**: if `PATCH /api/v1/admin/users/{name}` returns 403
|
**Migration note**: if a hive-c0re call to `/api/v1/admin/*` returns 403
|
||||||
on an existing deploy, the core token predates the admin-scope
|
on an existing deploy, the core token predates the admin-scope
|
||||||
addition. Delete `/var/lib/hyperhive/forge-core-token` and restart
|
addition. Delete `/var/lib/hyperhive/forge-core-token` and restart
|
||||||
hive-c0re to re-mint with the new scopes.
|
hive-c0re to re-mint with the new scopes.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
//! Optional Forgejo wiring — per-agent account alignment,
|
//! Optional Forgejo wiring — config-repo mirroring, meta read-access
|
||||||
//! config-repo mirroring, meta read-access grants, and each agent's
|
//! grants, and each agent's read-only grant on `internal/docs` (the
|
||||||
//! read-only grant on `internal/docs` (the swarm-controller creates that
|
//! swarm-controller creates that repo, with the other swarm-wide orgs,
|
||||||
//! repo, with the other swarm-wide orgs, teams and repos).
|
//! teams and repos).
|
||||||
//! No-op when `hive-forge` isn't running. Full design: `docs/integrations/forge.md`.
|
//! No-op when `hive-forge` isn't running. Full design: `docs/integrations/forge.md`.
|
||||||
|
|
||||||
mod ci_runner;
|
mod ci_runner;
|
||||||
|
|
@ -13,9 +13,8 @@ mod users;
|
||||||
pub use pr_comment::{config_repo, merged_pr_for_commit, post_pr_comment};
|
pub use pr_comment::{config_repo, merged_pr_for_commit, post_pr_comment};
|
||||||
pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
|
pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
|
||||||
pub use repos::{
|
pub use repos::{
|
||||||
clone_config_into_proposed, ensure_config_repo, ensure_meta_remote, ensure_repo,
|
clone_config_into_proposed, ensure_meta_remote, ensure_repo, fast_forward_applied_main,
|
||||||
fast_forward_applied_main, fetch_config_main_into_applied, meta_read_access, push_config,
|
fetch_config_main_into_applied, meta_read_access, push_config, push_meta, shared_docs_access,
|
||||||
push_meta, shared_docs_access,
|
|
||||||
};
|
};
|
||||||
pub use users::core_token;
|
pub use users::core_token;
|
||||||
|
|
||||||
|
|
@ -26,10 +25,7 @@ use anyhow::{Context, Result};
|
||||||
use forgejo_api::{Auth, Forgejo};
|
use forgejo_api::{Auth, Forgejo};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use users::{
|
use users::{ensure_core_avatar, ensure_core_user_and_token};
|
||||||
ensure_core_avatar, ensure_core_user_and_token, ensure_repo_creation_disabled,
|
|
||||||
ensure_user_email,
|
|
||||||
};
|
|
||||||
|
|
||||||
const FORGE_CONTAINER: &str = "hive-forge";
|
const FORGE_CONTAINER: &str = "hive-forge";
|
||||||
|
|
||||||
|
|
@ -185,8 +181,7 @@ pub(crate) fn api(token: &str) -> Result<Forgejo> {
|
||||||
Forgejo::new(Auth::Token(token), url).context("build forgejo api client")
|
Forgejo::new(Auth::Token(token), url).context("build forgejo api client")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-agent forge sync: align the agent's forge account (email,
|
/// Per-agent forge sync: mirror the agent's config repo, grant read access
|
||||||
/// repo-creation lockdown), mirror its config repo, grant read access
|
|
||||||
/// to `core/meta`, and wire the `meta` remote in its proposed repo. All operations are idempotent; failures
|
/// to `core/meta`, and wire the `meta` remote in its proposed repo. All operations are idempotent; failures
|
||||||
/// are logged as warnings but don't abort the caller.
|
/// are logged as warnings but don't abort the caller.
|
||||||
///
|
///
|
||||||
|
|
@ -201,27 +196,14 @@ pub(crate) fn api(token: &str) -> Result<Forgejo> {
|
||||||
/// dashboard warning (see [`ensure_all`]); the rebuild path ignores it and
|
/// dashboard warning (see [`ensure_all`]); the rebuild path ignores it and
|
||||||
/// relies on the journal `warn!` lines alone (a rebuild is its own retry).
|
/// relies on the journal `warn!` lines alone (a rebuild is its own retry).
|
||||||
pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool {
|
pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool {
|
||||||
// The agent's forge user and token are swarm-controller's now
|
// The agent's forge account, token and `agent-configs/<name>` repo are
|
||||||
// (`MintAgentForgeToken`); this hive no longer creates or re-mints
|
// swarm-controller's; this hive creates none of them and only pushes
|
||||||
// either.
|
// to the repo.
|
||||||
let mut ok = true;
|
let mut ok = true;
|
||||||
// Align email to match the git user.email set by meta::render_flake
|
|
||||||
// so commits link to the agent's Forgejo profile. Best-effort;
|
|
||||||
// also patches up agents created before this fix (old @hive.local).
|
|
||||||
ensure_user_email(name).await;
|
|
||||||
// Block direct agent-initiated repo creation: agents create
|
|
||||||
// repos through hive-c0re, never with their own token. Idempotent +
|
|
||||||
// marker-guarded; also covers agents provisioned before this landed.
|
|
||||||
ensure_repo_creation_disabled(name).await;
|
|
||||||
// Mirror the agent's applied config repo into agent-configs.
|
// Mirror the agent's applied config repo into agent-configs.
|
||||||
// ensure_config_repo is idempotent; push_config catches any
|
// push_config catches any drift since the last run — e.g. the
|
||||||
// drift since the last run — e.g. the startup migration just
|
// startup migration just relocated `deployed/0`, or a deploy landed
|
||||||
// relocated `deployed/0`, or a deploy landed while the forge
|
// while the forge was down.
|
||||||
// was down.
|
|
||||||
if let Err(e) = ensure_config_repo(name).await {
|
|
||||||
tracing::warn!(%name, error = ?e, "forge: ensure_config_repo failed");
|
|
||||||
ok = false;
|
|
||||||
}
|
|
||||||
if let Err(e) = push_config(name).await {
|
if let Err(e) = push_config(name).await {
|
||||||
tracing::warn!(%name, error = ?e, "forge: push_config failed");
|
tracing::warn!(%name, error = ?e, "forge: push_config failed");
|
||||||
ok = false;
|
ok = false;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//! Repo + org plumbing on the local Forgejo: org / repo creation,
|
//! Repo + org plumbing on the local Forgejo: the meta + shared-docs +
|
||||||
//! the meta + shared-docs + knowledge repos, per-agent config-repo
|
//! knowledge repos, per-agent config-repo
|
||||||
//! mirroring (`push_config` / `push_meta`), collaborator grants,
|
//! mirroring (`push_config` / `push_meta`), collaborator grants,
|
||||||
//! pull-mirrors, and branch-protection rules. The typed API-client
|
//! pull-mirrors, and branch-protection rules. The typed API-client
|
||||||
//! constructor + org-name constants live in the module root (`super`).
|
//! constructor + org-name constants live in the module root (`super`).
|
||||||
|
|
@ -86,15 +86,6 @@ pub async fn ensure_repo(name: &str, token: &str) -> Result<()> {
|
||||||
created_or_exists(res, &format!("core/{name}"))
|
created_or_exists(res, &format!("core/{name}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create `name` inside org `org` (used for `agent-configs/<agent>`).
|
|
||||||
/// Idempotent.
|
|
||||||
async fn ensure_org_repo(org: &str, name: &str, token: &str) -> Result<()> {
|
|
||||||
let res = api(token)?
|
|
||||||
.create_org_repo(org, repo_option(name, true))
|
|
||||||
.await;
|
|
||||||
created_or_exists(res, &format!("{org}/{name}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dashboard-warning guard for the meta non-fast-forward condition.
|
/// Dashboard-warning guard for the meta non-fast-forward condition.
|
||||||
/// Held while `core/meta` remote is ahead of our local mirror; cleared
|
/// Held while `core/meta` remote is ahead of our local mirror; cleared
|
||||||
/// automatically when the next push succeeds.
|
/// automatically when the next push succeeds.
|
||||||
|
|
@ -155,30 +146,6 @@ pub async fn push_meta(dir: &Path) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure the `agent-configs/<name>` repo exists so the first
|
|
||||||
/// `push_config` doesn't 404, and add the agent as a **write** collaborator
|
|
||||||
/// (it can push feature branches + open config PRs). Branch protection on
|
|
||||||
/// `main` is swarm-controller's, not set here. No-op when the forge isn't
|
|
||||||
/// running or the core token isn't minted yet. Safe to call on every spawn
|
|
||||||
/// and on every startup (all steps idempotent).
|
|
||||||
pub async fn ensure_config_repo(name: &str) -> Result<()> {
|
|
||||||
if !is_present().await {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let Some(token) = core_token() else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
ensure_org_repo(CONFIG_ORG, name, &token).await?;
|
|
||||||
add_collaborator(
|
|
||||||
CONFIG_ORG,
|
|
||||||
name,
|
|
||||||
name,
|
|
||||||
AddCollaboratorOptionPermission::Write,
|
|
||||||
&token,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Grant agent `name` read-only collaborator access to `internal/docs`.
|
/// Grant agent `name` read-only collaborator access to `internal/docs`.
|
||||||
/// Idempotent: re-adding an existing collaborator succeeds (Forgejo
|
/// Idempotent: re-adding an existing collaborator succeeds (Forgejo
|
||||||
/// answers 204 either way). Mirrors `meta_read_access` so agents can
|
/// answers 204 either way). Mirrors `meta_read_access` so agents can
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
//! Per-agent Forgejo user + access-token provisioning, account
|
//! The bootstrap `core` admin user: its account, access-token lifecycle
|
||||||
//! policy (email alignment, repo-creation lockdown), avatar uploads,
|
//! and avatar upload. The typed API-client constructor + `forgejo admin`
|
||||||
//! and the bootstrap `core` admin user + token lifecycle. The typed
|
//! helpers live in the module root (`super`).
|
||||||
//! API-client constructor + `forgejo admin` helpers live in the
|
|
||||||
//! module root (`super`).
|
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use forgejo_api::structs::{EditUserOption, UpdateUserAvatarOption};
|
use forgejo_api::structs::UpdateUserAvatarOption;
|
||||||
use forgejo_api::{ApiErrorKind, ForgejoError};
|
use forgejo_api::{ApiErrorKind, ForgejoError};
|
||||||
use reqwest::StatusCode;
|
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
|
// to push the meta repo + drive admin API calls. Root-only. Literal lives
|
||||||
// in `crate::paths`; aliased here under the long-standing name.
|
// in `crate::paths`; aliased here under the long-standing name.
|
||||||
use crate::paths::FORGE_CORE_TOKEN as CORE_TOKEN_PATH;
|
use crate::paths::FORGE_CORE_TOKEN as CORE_TOKEN_PATH;
|
||||||
// Forge provisioning markers (`forge/core-avatar-set`,
|
// The `forge/core-avatar-set` marker lives in `crate::paths` — a one-shot
|
||||||
// `forge/email-aligned-<name>`) live in `crate::paths` — one-shot guards:
|
// guard: the upload runs once, the marker is written, subsequent startups
|
||||||
// the upload/align runs once, the marker is written, subsequent startups
|
// skip. Delete it to force a re-upload.
|
||||||
// skip. Delete one to force its step to re-run.
|
|
||||||
// The avatar PNG path is resolved by `core_avatar_png_path` below —
|
// 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
|
// only-used-here, so it lives in this module rather than the shared
|
||||||
// `hive_sh4re::assets` helpers (which now hold only `prompt_template`, the
|
// `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")
|
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
|
/// Ensure a forgejo user named `name` exists. Idempotent: forgejo
|
||||||
/// returns a "user already exists" error which we treat as success.
|
/// returns a "user already exists" error which we treat as success.
|
||||||
/// `admin` adds `--admin` (site admin) — used for the bootstrap
|
/// `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
|
/// Mint a fresh access token for `name`. Token name is suffixed with
|
||||||
/// a monotonic clock so re-issuing doesn't collide with an existing
|
/// 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
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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 forgejo_api::{ApiError, ApiErrorKind, ForgejoError};
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
|
|
||||||
|
|
@ -521,58 +365,4 @@ mod tests {
|
||||||
CoreTokenCheck::Indeterminate
|
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,22 +71,6 @@ pub fn forge_core_avatar_marker() -> PathBuf {
|
||||||
forge_dir().join("core-avatar-set")
|
forge_dir().join("core-avatar-set")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `forge/email-aligned-<name>` — marker: `<name>`'s forge email aligned.
|
|
||||||
#[must_use]
|
|
||||||
pub fn forge_email_aligned_marker(name: &str) -> PathBuf {
|
|
||||||
forge_dir().join(format!("email-aligned-{name}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `forge/repo-creation-disabled-<name>` — marker: `<name>`'s forge user
|
|
||||||
/// has had `max_repo_creation = 0` applied (blocks direct agent-initiated
|
|
||||||
/// repo creation). One-shot guard so the PATCH runs once per
|
|
||||||
/// agent (including agents provisioned before the change); delete to
|
|
||||||
/// re-apply.
|
|
||||||
#[must_use]
|
|
||||||
pub fn forge_repo_creation_disabled_marker(name: &str) -> PathBuf {
|
|
||||||
forge_dir().join(format!("repo-creation-disabled-{name}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `agent-identity/` — one subdir per agent holding the store identity this
|
/// `agent-identity/` — one subdir per agent holding the store identity this
|
||||||
/// hive collected for it. Staging only: the files exist so `systemd-nspawn`
|
/// hive collected for it. Staging only: the files exist so `systemd-nspawn`
|
||||||
/// has something to `--load-credential` from, and nothing on the host ever
|
/// has something to `--load-credential` from, and nothing on the host ever
|
||||||
|
|
@ -297,18 +281,6 @@ pub fn relocate_legacy_state() {
|
||||||
for (old_rel, new) in &moves {
|
for (old_rel, new) in &moves {
|
||||||
move_if_legacy(&root.join(old_rel), new);
|
move_if_legacy(&root.join(old_rel), new);
|
||||||
}
|
}
|
||||||
// `forge-email-aligned-<name>` markers: glob the flat root.
|
|
||||||
if let Ok(rd) = std::fs::read_dir(&root) {
|
|
||||||
for ent in rd.flatten() {
|
|
||||||
if let Some(name) = ent
|
|
||||||
.file_name()
|
|
||||||
.to_str()
|
|
||||||
.and_then(|s| s.strip_prefix("forge-email-aligned-"))
|
|
||||||
{
|
|
||||||
move_if_legacy(&ent.path(), &forge_email_aligned_marker(name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rename `old` → `new` when `old` exists and `new` doesn't, creating
|
/// Rename `old` → `new` when `old` exists and `new` doesn't, creating
|
||||||
|
|
|
||||||
|
|
@ -397,8 +397,8 @@ impl Client {
|
||||||
/// Then locks the account out of creating repos
|
/// Then locks the account out of creating repos
|
||||||
/// ([`Self::disable_repo_creation`]), whether it was just created or
|
/// ([`Self::disable_repo_creation`]), whether it was just created or
|
||||||
/// already existed. Nothing else does that for a swarm-created agent, so
|
/// already existed. Nothing else does that for a swarm-created agent, so
|
||||||
/// a failure fails the node. Does not align the account's email the way
|
/// a failure fails the node. Leaves an existing account's email as it
|
||||||
/// `hive-c0re`'s `ensure_user_email` does — cosmetic, not a guard.
|
/// finds it.
|
||||||
pub async fn ensure_agent_user(&self, agent: &str) -> Result<()> {
|
pub async fn ensure_agent_user(&self, agent: &str) -> Result<()> {
|
||||||
self.create_agent_user(agent).await?;
|
self.create_agent_user(agent).await?;
|
||||||
self.disable_repo_creation(agent).await
|
self.disable_repo_creation(agent).await
|
||||||
|
|
@ -421,13 +421,8 @@ impl Client {
|
||||||
/// account once and checks the one thing that *is* comparable: the
|
/// account once and checks the one thing that *is* comparable: the
|
||||||
/// email. A match against either [`agent_email`] (what
|
/// email. A match against either [`agent_email`] (what
|
||||||
/// `create_agent_user` itself sets) or [`legacy_agent_email`] (what
|
/// `create_agent_user` itself sets) or [`legacy_agent_email`] (what
|
||||||
/// `hive-c0re::forge::users::ensure_user_exists` used before its own
|
/// older hive-created agent accounts carry) counts as this agent's
|
||||||
/// `ensure_user_email` alignment pass runs) counts as this agent's
|
/// account. A mismatch against both means the
|
||||||
/// account — the legacy value has to be accepted too, not just the
|
|
||||||
/// aligned one, because `ensure_user_email` only runs on the forge-host
|
|
||||||
/// hive and only once it has a core token and has ticked at least once,
|
|
||||||
/// so a real, existing legacy agent can still carry the old email when
|
|
||||||
/// this node reads it mid-rollout. A mismatch against both means the
|
|
||||||
/// name collided with an account this node didn't create, so it fails
|
/// name collided with an account this node didn't create, so it fails
|
||||||
/// the node rather than sending the PATCH (or silently skipping) to an
|
/// the node rather than sending the PATCH (or silently skipping) to an
|
||||||
/// account nobody verified is agent-controlled.
|
/// account nobody verified is agent-controlled.
|
||||||
|
|
@ -1139,30 +1134,23 @@ fn initial_flake_nix() -> &'static str {
|
||||||
/// [`Client::disable_repo_creation`] before it sends the account's PATCH. Same
|
/// [`Client::disable_repo_creation`] before it sends the account's PATCH. Same
|
||||||
/// format string as `hive-c0re::forge::users::agent_email` — not called
|
/// format string as `hive-c0re::forge::users::agent_email` — not called
|
||||||
/// across the crate boundary (see this module's doc comment), but both
|
/// across the crate boundary (see this module's doc comment), but both
|
||||||
/// creators (and `hive-c0re`'s `ensure_user_email`, which aligns older
|
/// creators use this exact value, so it doubles as the
|
||||||
/// accounts to it) converge on this exact value, so it doubles as the
|
|
||||||
/// marker that distinguishes an agent's own account from an unrelated one
|
/// marker that distinguishes an agent's own account from an unrelated one
|
||||||
/// that happens to share its username.
|
/// that happens to share its username.
|
||||||
fn agent_email(agent: &str) -> String {
|
fn agent_email(agent: &str) -> String {
|
||||||
format!("{agent}@hyperhive.local")
|
format!("{agent}@hyperhive.local")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The pre-alignment email `hive-c0re::forge::users::ensure_user_exists`
|
/// The email older hive-created agent accounts carry, and keep: nothing
|
||||||
/// gave an agent account before that crate's own `ensure_user_email`
|
/// rewrites it to [`agent_email`]. Accepted by
|
||||||
/// existed — see that function's doc comment ("Existing agents were
|
/// [`Client::disable_repo_creation`]'s guard alongside [`agent_email`] so
|
||||||
/// created with `{name}@hive.local`; this corrects that"). Accepted by
|
/// such an account is still recognised as the agent's own.
|
||||||
/// [`Client::disable_repo_creation`]'s guard alongside [`agent_email`]
|
|
||||||
/// because `ensure_user_email`'s alignment PATCH only runs on the
|
|
||||||
/// forge-host hive, and only after it has a core token and has ticked at
|
|
||||||
/// least once, so a real legacy agent can still carry this value when this
|
|
||||||
/// node reads it.
|
|
||||||
fn legacy_agent_email(agent: &str) -> String {
|
fn legacy_agent_email(agent: &str) -> String {
|
||||||
format!("{agent}@hive.local")
|
format!("{agent}@hive.local")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `admin_edit_user` body that sets `max_repo_creation = 0` on `agent`
|
/// The `admin_edit_user` body that sets `max_repo_creation = 0` on `agent`
|
||||||
/// and nothing else. Same shape as `hive-c0re::forge::users`'
|
/// and nothing else. `login_name` + `source_id = 0` (local auth,
|
||||||
/// `sparse_edit_user_option`: `login_name` + `source_id = 0` (local auth,
|
|
||||||
/// which is what [`Client::create_agent_user`] creates) ride along because
|
/// which is what [`Client::create_agent_user`] creates) ride along because
|
||||||
/// Forgejo resets `use_custom_avatar` on an admin edit that omits
|
/// Forgejo resets `use_custom_avatar` on an admin edit that omits
|
||||||
/// `login_name`; every other field is left unset so nothing else is touched.
|
/// `login_name`; every other field is left unset so nothing else is touched.
|
||||||
|
|
@ -1548,9 +1536,9 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A refused lockdown fails the node with Forgejo's own message, rather
|
/// A refused lockdown fails the node with Forgejo's own message, rather
|
||||||
/// than warning and reporting the account as provisioned — that is
|
/// than warning and reporting the account as provisioned, which would
|
||||||
/// `hive-c0re`'s `ensure_repo_creation_disabled` shape, which leaves an
|
/// leave an agent able to create and self-merge with nothing marked
|
||||||
/// agent able to create and self-merge with nothing marked failed.
|
/// failed.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_refused_lockdown_fails_the_node() {
|
async fn a_refused_lockdown_fails_the_node() {
|
||||||
let (client, _seen) = stub_forge(
|
let (client, _seen) = stub_forge(
|
||||||
|
|
@ -1598,10 +1586,9 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A legacy account `hive-c0re::forge::users::ensure_user_exists` (or
|
/// A legacy hive-created account can carry the same
|
||||||
/// its `ensure_user_email` alignment pass) created carries the same
|
/// `{agent}@hyperhive.local` marker, plus `login_name`/`source_id` from
|
||||||
/// `{agent}@hyperhive.local` marker, plus the `login_name`/`source_id`
|
/// a hive admin edit — this node must still
|
||||||
/// fields its own `sparse_edit_user_option` sets — this node must still
|
|
||||||
/// lock such an account down, not just ones it created itself.
|
/// lock such an account down, not just ones it created itself.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_legacy_hive_created_agent_account_still_gets_locked_down() {
|
async fn a_legacy_hive_created_agent_account_still_gets_locked_down() {
|
||||||
|
|
@ -1621,14 +1608,9 @@ mod tests {
|
||||||
assert_eq!(body["max_repo_creation"], 0);
|
assert_eq!(body["max_repo_creation"], 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// argus's round-2 🟡: `hive-c0re`'s `ensure_user_email` alignment pass
|
/// A real, pre-existing agent account can carry the legacy
|
||||||
/// (the thing that rewrites a legacy `{name}@hive.local` account to
|
/// `@hive.local` email, and nothing rewrites it. The guard must accept
|
||||||
/// `{name}@hyperhive.local`) only runs on the forge-host hive, and only
|
/// that value too, or such an agent would fail with "not this agent's".
|
||||||
/// once it has a core token and has ticked — so a real, pre-existing
|
|
||||||
/// agent can still be sitting on the pre-alignment `@hive.local` email
|
|
||||||
/// when this node reads it. The guard must accept that value too, not
|
|
||||||
/// just the aligned one, or a real agent would fail with "not this
|
|
||||||
/// agent's" during the rollout window.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_pre_alignment_legacy_email_still_gets_locked_down() {
|
async fn a_pre_alignment_legacy_email_still_gets_locked_down() {
|
||||||
let (client, seen) = stub_forge(
|
let (client, seen) = stub_forge(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue