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
200
swarm-controller/src/forge_account.rs
Normal file
200
swarm-controller/src/forge_account.rs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
//! An agent's accounts on external forges: an operator hands us a base URL and
|
||||
//! a token, we put them in the swarm's secret store.
|
||||
//!
|
||||
//! The agent end is `nix/agent-modules/forge-accounts.nix`, which lists the
|
||||
//! agent's accounts and reads each under the agent's own certificate, and writes
|
||||
//! the files `hive-forge -f <label>` reads. No hive is in the path.
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use swarm_secret_client::forge;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use super::{AppState, error_problem, swarm_hive};
|
||||
|
||||
/// The account to store for one agent's external forge.
|
||||
///
|
||||
/// No `Debug` derive: this carries a token.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct PutForgeAccountRequest {
|
||||
/// The forge's base URL, `http://` or `https://`. A trailing slash is
|
||||
/// dropped.
|
||||
#[schema(example = "https://codeberg.org")]
|
||||
url: String,
|
||||
/// The access token. Never logged, and never returned by this route.
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// `put_forge_account`'s success body.
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct PutForgeAccountResponse {
|
||||
/// The base URL as stored.
|
||||
url: String,
|
||||
}
|
||||
|
||||
/// Store an agent's external forge account.
|
||||
///
|
||||
/// Idempotent: the store keeps versions, so repeating a call replaces the
|
||||
/// account the agent will next read rather than adding a second one.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/hives/{hive}/agents/{agent}/forge-accounts/{label}",
|
||||
params(
|
||||
("hive" = String, Path, description = "hive the agent runs on"),
|
||||
("agent" = String, Path, description = "agent the account belongs to"),
|
||||
("label" = String, Path, description = "the name the agent passes to `hive-forge -f`"),
|
||||
),
|
||||
request_body = PutForgeAccountRequest,
|
||||
responses(
|
||||
(status = 200, description = "stored", body = PutForgeAccountResponse),
|
||||
(status = 400, description = "the agent or label is not an identifier, the URL is not http(s), the token is empty, or the hive is not in this swarm (problem+json)", body = String),
|
||||
(status = 500, description = "the store write failed (problem+json)", body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
)]
|
||||
pub async fn put_forge_account(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path((hive, agent, label)): axum::extract::Path<(String, String, String)>,
|
||||
Json(req): Json<PutForgeAccountRequest>,
|
||||
) -> Result<Json<PutForgeAccountResponse>, problem_details::ProblemDetails> {
|
||||
let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?;
|
||||
let agent = hive_types::Ident::parse(&agent)
|
||||
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
|
||||
.into_string();
|
||||
// `hive-forge` accepts only `[a-z0-9-]` after `-f`, narrower than the
|
||||
// store's charset, so a label it would refuse is refused here.
|
||||
let label = hive_types::Ident::parse(&label)
|
||||
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
|
||||
.into_string();
|
||||
let secret_path = forge::account_path(&agent, &label)
|
||||
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
|
||||
let account = account(req).map_err(|e| error_problem(StatusCode::BAD_REQUEST, e))?;
|
||||
|
||||
let store = crate::store::connect().await.map_err(|e| {
|
||||
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
|
||||
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||
})?;
|
||||
store.write(&secret_path, &account).await.map_err(|e| {
|
||||
// The path names the agent and the label; the value is not in it.
|
||||
tracing::warn!(path = %secret_path, error = %e, "writing the forge account failed");
|
||||
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||
})?;
|
||||
|
||||
tracing::info!(%hive, %agent, %label, url = %account.url, "forge account stored");
|
||||
Ok(Json(PutForgeAccountResponse { url: account.url }))
|
||||
}
|
||||
|
||||
/// The request as it is stored, or why it cannot be.
|
||||
fn account(req: PutForgeAccountRequest) -> Result<forge::Account, &'static str> {
|
||||
let url = req.url.trim().trim_end_matches('/');
|
||||
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
||||
return Err("url must start with http:// or https://");
|
||||
}
|
||||
if req.token.trim().is_empty() {
|
||||
return Err("token is required");
|
||||
}
|
||||
Ok(forge::Account {
|
||||
value: req.token,
|
||||
url: url.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{PutForgeAccountRequest, account};
|
||||
|
||||
fn request(url: &str, token: &str) -> PutForgeAccountRequest {
|
||||
PutForgeAccountRequest {
|
||||
url: url.to_owned(),
|
||||
token: token.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_url_loses_its_trailing_slash_and_the_token_is_kept() {
|
||||
let a = account(request("https://codeberg.org/ ", "t0k3n")).expect("valid");
|
||||
assert_eq!(a.url, "https://codeberg.org");
|
||||
assert_eq!(a.value, "t0k3n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_url_that_is_not_http_is_refused() {
|
||||
assert!(account(request("codeberg.org", "t")).is_err());
|
||||
assert!(account(request("file:///etc/passwd", "t")).is_err());
|
||||
// The control: plain http is accepted.
|
||||
assert!(account(request("http://forge.lan", "t")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_token_is_refused() {
|
||||
assert!(account(request("https://codeberg.org", " ")).is_err());
|
||||
}
|
||||
|
||||
/// Bare-minimum `AppState`, as `matrix_account`'s tests build it.
|
||||
fn state() -> super::super::AppState {
|
||||
super::super::AppState {
|
||||
hives: std::sync::Arc::new(vec![super::super::HiveEntry {
|
||||
name: "pr1ma".to_owned(),
|
||||
domain: "pr1ma.example".to_owned(),
|
||||
}]),
|
||||
links: std::sync::Arc::new(Vec::new()),
|
||||
status: None,
|
||||
wanted: None,
|
||||
agent_status: None,
|
||||
agent_icons: None,
|
||||
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
||||
hive_jobq::Graph::new(),
|
||||
hive_jobq::resources::ResourceTable::new(),
|
||||
))),
|
||||
webhook_secret: None,
|
||||
config_prs: None,
|
||||
swarm_name: None,
|
||||
auth: None,
|
||||
forge: None,
|
||||
create_gate: std::sync::Arc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn put(label: &str) -> problem_details::ProblemDetails {
|
||||
super::put_forge_account(
|
||||
axum::extract::State(state()),
|
||||
axum::extract::Path(("pr1ma".to_owned(), "atlas".to_owned(), label.to_owned())),
|
||||
axum::Json(request("https://codeberg.org", "t0k3n")),
|
||||
)
|
||||
.await
|
||||
.expect_err("no store is configured in a test")
|
||||
}
|
||||
|
||||
/// A label `hive-forge -f` could not name is refused before the store: with
|
||||
/// `BAO_*` unset a store connect would answer 500.
|
||||
#[tokio::test]
|
||||
async fn a_label_hive_forge_cannot_name_is_refused_before_the_store() {
|
||||
for var in ["BAO_ADDR", "BAO_CLIENT_CERT", "BAO_CLIENT_KEY"] {
|
||||
assert!(
|
||||
std::env::var(var).is_err(),
|
||||
"{var} must be unset for this test to prove anything"
|
||||
);
|
||||
}
|
||||
for bad in ["Codeberg", "code_berg", "../x"] {
|
||||
let problem = put(bad).await;
|
||||
assert_eq!(
|
||||
problem.status,
|
||||
Some(axum::http::StatusCode::BAD_REQUEST),
|
||||
"{bad}: {problem:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The control: a plain label reaches the store connect.
|
||||
#[tokio::test]
|
||||
async fn a_plain_label_reaches_the_store() {
|
||||
let problem = put("codeberg").await;
|
||||
assert_eq!(
|
||||
problem.status,
|
||||
Some(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
|
||||
"{problem:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -48,6 +48,7 @@ mod agent_status;
|
|||
mod auth;
|
||||
mod config_pr;
|
||||
mod forge;
|
||||
mod forge_account;
|
||||
mod issue_report;
|
||||
mod matrix_account;
|
||||
mod otel_http_client;
|
||||
|
|
@ -2888,6 +2889,7 @@ fn build_app(state: AppState) -> axum::Router {
|
|||
.routes(routes!(get_agents_status))
|
||||
.routes(routes!(set_agent_state))
|
||||
.routes(routes!(matrix_account::put_matrix_account))
|
||||
.routes(routes!(forge_account::put_forge_account))
|
||||
.routes(routes!(get_hive_wanted))
|
||||
.routes(routes!(term_stream::stream_agent_term))
|
||||
.routes(routes!(agent_state_stream::stream_agent_state))
|
||||
|
|
|
|||
Loading…
Reference in a new issue