forge: external forge accounts live in swarm bao; the agent fetches them itself
An operator now links an agent's external forge account (label, base URL, token) in the swarm UI. swarm-controller stores it at swarm/agents/<agent>/forge/<label>. There is no index: the store's listing of the agent's forge/ directory is the set of accounts. In the agent, hive-agent-forge-accounts (oneshot + 2-minute timer, as the agent user, under its own store certificate) lists swarm/agents/<agent>/forge/ with the `list` #4866 grants an agent on its own metadata subtree, reads each account, and writes <state>/forge-<label>-token and forge-<label>.json in the names and shape hive-forge -f already reads. An empty listing (a 404, which `bao kv list -format=json` answers with `{}` and an empty stderr) is zero accounts; a denial or an unreachable store fails the unit. It never deletes: files for labels not listed, including ones the hive wrote, stay as they are. Removed: the dashboard FORGES tab (credentials.js/html section and its CSS), hive-c0re's extra_forges.rs and its routes, priv_client's extra-forge calls, and hive-priv's WriteAgentExtraForgeAccount / DeleteAgentExtraForgeAccount with their helpers. The GITHUB tab and WriteAgentGithubToken stay. Also: persistence.md's matrix avatar note names the exit-75 restart on a changed account listing, not the dashboard, as what brings a linked account up. Refs #4348
This commit is contained in:
parent
97fb76ce99
commit
2c7e586f47
27 changed files with 815 additions and 748 deletions
|
|
@ -1,206 +0,0 @@
|
|||
//! Dashboard-driven external (non-internal) Forgejo/Gitea/Codeberg-compatible
|
||||
//! forge accounts, per agent. Entirely dashboard-provisioned — there is no
|
||||
//! host-side nix config for these (see `nix/host-modules/hive-forge/`'s
|
||||
//! removed `extraForges` option). The operator manually creates a token on
|
||||
//! the external forge themselves (however that forge lets them: PAT UI, a
|
||||
//! teammate with admin, whatever) and pastes a label + base URL + token into
|
||||
//! the dashboard's FORGES tab, same shape as the GitHub PAT flow
|
||||
//! (`post_github_account`) plus a base URL.
|
||||
//!
|
||||
//! No remote account minting, no admin API, no revoke-on-the-remote-side —
|
||||
//! this module only ever touches the *local* agent state dir. hive-c0re
|
||||
//! persists the token to `<state>/forge-<label>-token` (0600) and the base
|
||||
//! URL to a `<state>/forge-<label>.json` sidecar (not secret, but kept next
|
||||
//! to the token so both survive together) via hive-priv. Listing derives the
|
||||
//! configured set from those files — there is no separate "catalog", since
|
||||
//! there is no nix config to enumerate.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use axum::extract::{Form, Query};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use super::{Ident, error_response};
|
||||
use crate::coordinator::Coordinator;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ForgeSidecar {
|
||||
base_url: String,
|
||||
}
|
||||
|
||||
/// Read the base URL an agent stashed for `label` from its
|
||||
/// `forge-<label>.json` sidecar. `None` if the sidecar is missing or
|
||||
/// unparseable (e.g. a token file left over from a partial/older write).
|
||||
fn read_base_url(dir: &Path, label: &str) -> Option<String> {
|
||||
let s = std::fs::read_to_string(dir.join(format!("forge-{label}.json"))).ok()?;
|
||||
serde_json::from_str::<ForgeSidecar>(&s)
|
||||
.ok()
|
||||
.map(|s| s.base_url)
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
struct ExtraForgeAccount {
|
||||
label: String,
|
||||
base_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
struct ExtraForgesResponse {
|
||||
forges: Vec<ExtraForgeAccount>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub(super) struct ExtraForgesQuery {
|
||||
agent: String,
|
||||
}
|
||||
|
||||
/// List the external forge
|
||||
/// accounts currently provisioned for `agent`.
|
||||
///
|
||||
/// Derived from every `forge-<label>-token` file in its state dir. `base_url`
|
||||
/// is backfilled from the matching `forge-<label>.json` sidecar when
|
||||
/// present. Never returns a token.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/extra-forges",
|
||||
params(ExtraForgesQuery),
|
||||
responses(
|
||||
(status = 200, description = "provisioned extra forge accounts for the agent", body = ExtraForgesResponse),
|
||||
(status = 500, description = "invalid agent name, or a state-dir read failed"),
|
||||
),
|
||||
tag = "extra_forges"
|
||||
)]
|
||||
pub(super) async fn get_extra_forges(Query(q): Query<ExtraForgesQuery>) -> Response {
|
||||
let agent = q.agent.trim();
|
||||
let Ok(agent) = Ident::parse(agent) else {
|
||||
return error_response(&format!("extra-forges: invalid agent {agent:?}"));
|
||||
};
|
||||
let dir = Coordinator::agent_notes_dir(&agent);
|
||||
let mut forges = Vec::new();
|
||||
match std::fs::read_dir(&dir) {
|
||||
Ok(entries) => {
|
||||
for entry in entries.flatten() {
|
||||
if !entry.file_type().is_ok_and(|ft| ft.is_file()) {
|
||||
continue;
|
||||
}
|
||||
let fname = entry.file_name();
|
||||
let Some(fname) = fname.to_str() else {
|
||||
continue;
|
||||
};
|
||||
// `forge-token` (no suffix) is the mandatory internal forge —
|
||||
// not one of these dashboard-provisioned extra accounts.
|
||||
let Some(label) = fname
|
||||
.strip_prefix("forge-")
|
||||
.and_then(|s| s.strip_suffix("-token"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if label.is_empty() {
|
||||
continue;
|
||||
}
|
||||
forges.push(ExtraForgeAccount {
|
||||
base_url: read_base_url(&dir, label),
|
||||
label: label.to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => {
|
||||
return error_response(&format!("extra-forges: read {}: {e}", dir.display()));
|
||||
}
|
||||
}
|
||||
forges.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
axum::Json(ExtraForgesResponse { forges }).into_response()
|
||||
}
|
||||
|
||||
/// Form body for `POST /api/extra-forge-account` (urlencoded, the
|
||||
/// dashboard's mutation convention). `action` is `"add"` (needs `base_url` +
|
||||
/// `token`) or `"remove"`.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(super) struct ExtraForgeAccountForm {
|
||||
agent: String,
|
||||
label: String,
|
||||
action: String,
|
||||
base_url: Option<String>,
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
struct ExtraForgeAccountResult {
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
/// Add persists the operator-pasted
|
||||
/// label/base-URL/token to the agent's state dir via hive-priv; remove
|
||||
/// deletes both files.
|
||||
///
|
||||
/// Purely local — no remote account creation or revocation, there is
|
||||
/// no admin access assumed on the external forge.
|
||||
/// Operator-authenticated (dashboard). Never echoes the token back.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/extra-forge-account",
|
||||
request_body(content = ExtraForgeAccountForm, content_type = "application/x-www-form-urlencoded"),
|
||||
responses(
|
||||
(status = 200, description = "provisioned or removed", body = ExtraForgeAccountResult),
|
||||
(status = 500, description = "invalid input, or the state-dir write/delete failed"),
|
||||
),
|
||||
tag = "extra_forges"
|
||||
)]
|
||||
pub(super) async fn post_extra_forge_account(Form(f): Form<ExtraForgeAccountForm>) -> Response {
|
||||
let agent = f.agent.trim();
|
||||
let label = f.label.trim();
|
||||
let Ok(agent) = Ident::parse(agent) else {
|
||||
return error_response(&format!("extra-forge-account: invalid agent {agent:?}"));
|
||||
};
|
||||
let Ok(label) = Ident::parse(label) else {
|
||||
return error_response(&format!("extra-forge-account: invalid label {label:?}"));
|
||||
};
|
||||
|
||||
match f.action.as_str() {
|
||||
"add" => {
|
||||
let base_url = f.base_url.as_deref().unwrap_or_default().trim();
|
||||
let base_url = base_url.trim_end_matches('/');
|
||||
if !(base_url.starts_with("http://") || base_url.starts_with("https://")) {
|
||||
return error_response(&format!(
|
||||
"extra-forge-account: base_url must be an http(s) URL, got {base_url:?}"
|
||||
));
|
||||
}
|
||||
let Some(token) = f.token.as_deref().filter(|t| !t.is_empty()) else {
|
||||
return error_response("extra-forge-account: token is required");
|
||||
};
|
||||
if let Err(e) = crate::priv_client::write_agent_extra_forge_account(
|
||||
agent.as_str(),
|
||||
label.as_str(),
|
||||
base_url,
|
||||
token,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return error_response(&format!(
|
||||
"extra-forge-account: write account failed: {e:#}"
|
||||
));
|
||||
}
|
||||
tracing::info!(%agent, %label, "extra-forge-account: provisioned");
|
||||
}
|
||||
"remove" => {
|
||||
if let Err(e) =
|
||||
crate::priv_client::delete_agent_extra_forge_account(agent.as_str(), label.as_str())
|
||||
.await
|
||||
{
|
||||
return error_response(&format!(
|
||||
"extra-forge-account: delete account failed: {e:#}"
|
||||
));
|
||||
}
|
||||
tracing::info!(%agent, %label, "extra-forge-account: removed");
|
||||
}
|
||||
other => {
|
||||
return error_response(&format!(
|
||||
"extra-forge-account: unknown action {other:?} (want add|remove)"
|
||||
));
|
||||
}
|
||||
}
|
||||
axum::Json(ExtraForgeAccountResult { ok: true }).into_response()
|
||||
}
|
||||
|
|
@ -37,7 +37,6 @@ use crate::lifecycle;
|
|||
(name = "journal", description = "container + host journal reads"),
|
||||
(name = "approvals", description = "approve/deny pending approval rows"),
|
||||
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
|
||||
(name = "extra_forges", description = "external (non-internal) forge account provisioning"),
|
||||
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
|
||||
(name = "matrix_accounts", description = "github account provisioning for agents"),
|
||||
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
|
||||
|
|
@ -54,7 +53,6 @@ struct ApiDoc;
|
|||
|
||||
mod approvals;
|
||||
mod build_logs;
|
||||
mod extra_forges;
|
||||
// The single validated identifier type — homed in `hive-host-sock` (the crate
|
||||
// owning agent-path facts) so every dashboard path-param validates through the
|
||||
// same type used to build agent paths. Re-exported so submodules + the socket
|
||||
|
|
@ -143,8 +141,6 @@ pub async fn serve(
|
|||
matrix_accounts::post_github_account,
|
||||
matrix_accounts::get_github_account
|
||||
))
|
||||
.routes(routes!(extra_forges::get_extra_forges))
|
||||
.routes(routes!(extra_forges::post_extra_forge_account))
|
||||
.routes(routes!(misc_api::api_operator_inbox))
|
||||
.routes(routes!(misc_api::api_stats_hive))
|
||||
.routes(routes!(misc_api::api_container_resources))
|
||||
|
|
@ -379,8 +375,6 @@ mod router_build_probe {
|
|||
matrix_accounts::post_github_account,
|
||||
matrix_accounts::get_github_account
|
||||
))
|
||||
.routes(routes!(extra_forges::get_extra_forges))
|
||||
.routes(routes!(extra_forges::post_extra_forge_account))
|
||||
.routes(routes!(misc_api::api_operator_inbox))
|
||||
.routes(routes!(misc_api::api_stats_hive))
|
||||
.routes(routes!(misc_api::api_container_resources))
|
||||
|
|
|
|||
Loading…
Reference in a new issue