extra-forges: fully dashboard-provisioned, no host config
Per mara's feedback on PR #2407 ("better: you can also provide url in dashboard, same as with matrix, no host config"), drops services.hyperhive.extraForges and the admin-API mint/revoke flow entirely. The operator now creates a token on the external forge themselves and pastes a label + base URL + access token into the dashboard's FORGES tab, the same shape as the GitHub PAT flow plus the base-URL field from the matrix extra-account flow. hive-c0re only ever writes/deletes two local files per account (forge-<label>-token, forge-<label>.json sidecar for the URL) via hive-priv — no remote account creation, no admin token, no revoke-on-the-remote-side, no nix config to enumerate. - nix/host-modules/hive-forge/default.nix: removed the extraForges option, its label-format assertion, and the HYPERHIVE_EXTRA_FORGES env forwarding. - hive-c0re/src/forge/extra.rs: deleted (REST admin-API provisioning, no longer needed). - hive-c0re/src/dashboard/extra_forges.rs: GET /api/extra-forges? agent= lists an agent's stored forges by scanning its state dir (mirrors matrix_accounts.rs's filename-scan listing), POST /api/extra-forge-account (agent/label/base_url/token/ action=add|remove) stores or removes an account. - hive-sh4re/priv_proto.rs + hive-priv/main.rs: new WriteAgentExtraForgeAccount/DeleteAgentExtraForgeAccount priv requests (adds base_url, writes/deletes a JSON sidecar alongside the token). - hive-c0re/src/priv_client.rs: matching wrapper functions. - frontend/packages/dashboard/src/credentials.{html,js}: FORGES tab is a per-agent list + add-account paste form (label/base_url/token), no grant/revoke-from-catalog UI. - docs/web-ui/dashboard.md: FORGES tab section rewritten. Supersedes the design in PR #2407 (already approved+green on the old admin-API model) — opening as a fresh PR against the same issues rather than force-pushing over the approved one.
This commit is contained in:
parent
f025f32ccb
commit
dbf880ac66
10 changed files with 558 additions and 2 deletions
205
hive-c0re/src/dashboard/extra_forges.rs
Normal file
205
hive-c0re/src/dashboard/extra_forges.rs
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
//! 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/modules/hive-forge.nix`'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 the homeserver field from the matrix extra-
|
||||
//! account flow (`post_matrix_account_login`).
|
||||
//!
|
||||
//! 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, mirroring `matrix_accounts.rs`'s
|
||||
//! filename-scan approach — there is no separate "catalog" now that there's
|
||||
//! no nix config to enumerate.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use axum::extract::{Form, Query};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::error_response;
|
||||
use crate::coordinator::Coordinator;
|
||||
|
||||
/// Plain-identifier check matching hive-priv's `validate_name_chars`
|
||||
/// (lowercase ascii + digits + hyphens) — same guard used by
|
||||
/// `matrix_accounts::is_plain_ident`. Duplicated locally (private, not
|
||||
/// worth a shared-util churn for one predicate) rather than exported from
|
||||
/// that module, since both call sites are dashboard-only.
|
||||
fn is_plain_ident(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
}
|
||||
|
||||
#[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)]
|
||||
struct ExtraForgeAccount {
|
||||
label: String,
|
||||
base_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ExtraForgesResponse {
|
||||
forges: Vec<ExtraForgeAccount>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(super) struct ExtraForgesQuery {
|
||||
agent: String,
|
||||
}
|
||||
|
||||
/// `GET /api/extra-forges?agent=<name>` — list the external forge accounts
|
||||
/// currently provisioned for `agent`, derived from every `forge-<label>-
|
||||
/// token` file in its state dir (mirrors `matrix_accounts.rs`'s filename-scan
|
||||
/// listing). `base_url` is backfilled from the matching `forge-<label>.json`
|
||||
/// sidecar when present. Never returns a token.
|
||||
pub(super) async fn get_extra_forges(Query(q): Query<ExtraForgesQuery>) -> Response {
|
||||
let agent = q.agent.trim();
|
||||
if !is_plain_ident(agent) {
|
||||
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)]
|
||||
pub(super) struct ExtraForgeAccountForm {
|
||||
agent: String,
|
||||
label: String,
|
||||
action: String,
|
||||
base_url: Option<String>,
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ExtraForgeAccountResult {
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
/// `POST /api/extra-forge-account` — 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.
|
||||
pub(super) async fn post_extra_forge_account(Form(f): Form<ExtraForgeAccountForm>) -> Response {
|
||||
let agent = f.agent.trim();
|
||||
let label = f.label.trim();
|
||||
if !is_plain_ident(agent) {
|
||||
return error_response(&format!("extra-forge-account: invalid agent {agent:?}"));
|
||||
}
|
||||
if !is_plain_ident(label) {
|
||||
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, label, 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, label).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()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_plain_ident;
|
||||
|
||||
#[test]
|
||||
fn is_plain_ident_matches_validate_name_chars() {
|
||||
assert!(is_plain_ident("codeberg"));
|
||||
assert!(is_plain_ident("my-forge-1"));
|
||||
assert!(!is_plain_ident(""));
|
||||
assert!(!is_plain_ident("MyForge"));
|
||||
assert!(!is_plain_ident("my_forge"));
|
||||
assert!(!is_plain_ident("../escape"));
|
||||
assert!(!is_plain_ident("a/b"));
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ use crate::lifecycle;
|
|||
|
||||
mod approvals;
|
||||
mod build_logs;
|
||||
mod extra_forges;
|
||||
mod infra_containers;
|
||||
mod journal;
|
||||
mod lifecycle_ops;
|
||||
|
|
@ -88,6 +89,11 @@ pub async fn serve(
|
|||
"/api/matrix-accounts",
|
||||
get(matrix_accounts::get_matrix_accounts),
|
||||
)
|
||||
.route("/api/extra-forges", get(extra_forges::get_extra_forges))
|
||||
.route(
|
||||
"/api/extra-forge-account",
|
||||
post(extra_forges::post_extra_forge_account),
|
||||
)
|
||||
.route("/api/reminders", get(reminders::api_reminders))
|
||||
.route("/api/operator-inbox", get(misc_api::api_operator_inbox))
|
||||
.route("/api/stats-hive", get(misc_api::api_stats_hive))
|
||||
|
|
|
|||
|
|
@ -31,7 +31,10 @@ use crate::paths::FORGE_CORE_TOKEN as CORE_TOKEN_PATH;
|
|||
// build.
|
||||
/// Per-agent token scopes (broad-but-not-admin). See
|
||||
/// `docs/forge.md::Token scopes` for the per-scope rationale.
|
||||
const TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc";
|
||||
/// `pub(super)` — also reused by `extra.rs`'s external-forge
|
||||
/// provisioning so a granted agent gets the same scope set on an
|
||||
/// extra forge as on the internal one.
|
||||
pub(super) 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
|
||||
|
|
|
|||
|
|
@ -300,6 +300,37 @@ pub async fn write_agent_github_token(agent_name: &str, token: &str) -> Result<(
|
|||
.await?)
|
||||
}
|
||||
|
||||
/// Write a per-agent account for a dashboard-declared external forge —
|
||||
/// label + base URL + token — to `<state>/forge-<label>-token` +
|
||||
/// `<state>/forge-<label>.json` via hive-priv. Entirely dashboard-
|
||||
/// provisioned, no host-side nix config; `label` is validated root-side as
|
||||
/// a plain identifier before it reaches the filename.
|
||||
pub async fn write_agent_extra_forge_account(
|
||||
agent_name: &str,
|
||||
label: &str,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
) -> Result<()> {
|
||||
ok(call(&PrivRequest::WriteAgentExtraForgeAccount {
|
||||
agent_name: agent_name.to_owned(),
|
||||
label: label.to_owned(),
|
||||
base_url: base_url.to_owned(),
|
||||
token: token.to_owned(),
|
||||
})
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Remove a previously-added extra-forge account — the counterpart of
|
||||
/// [`write_agent_extra_forge_account`]. Idempotent: missing files are not
|
||||
/// an error.
|
||||
pub async fn delete_agent_extra_forge_account(agent_name: &str, label: &str) -> Result<()> {
|
||||
ok(call(&PrivRequest::DeleteAgentExtraForgeAccount {
|
||||
agent_name: agent_name.to_owned(),
|
||||
label: label.to_owned(),
|
||||
})
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Restart `hive-matrix-daemon.service` inside an agent container via
|
||||
/// `systemctl --machine=h-<agent_name> restart hive-matrix-daemon.service`.
|
||||
/// Non-fatal: callers should handle errors gracefully — if the container is
|
||||
|
|
|
|||
Loading…
Reference in a new issue