swarm-bao-grafana-controller-token, on the host that runs Grafana, logs in to Grafana's API over its socket as the built-in admin (the password swarm-bao-grafana-admin minted, #5138) and keeps a `swarm-controller` service account that is an enabled Viewer. It mints a token for that account and writes it to bao at swarm/controller/swarm-controller/grafana/token under Grafana's leaf. A ten-minute timer runs it again. The unit keeps a stored token Grafana accepts. If Grafana refuses the stored token with 401, or bao holds none, the unit deletes every token of the account and mints a new one. Grafana's grant gains create/read/update on that leaf, and the controller's gains read. swarm-controller serves GET /api/alerts from Grafana's /api/prometheus/grafana/api/v1/alerts. It reads the token from bao, keeps it in memory, and re-reads bao once on a 401. The HTTP client has 5s connect and 15s request timeouts. Any failure is a 503 that names its cause. swarm-ui gets an /alerts page listing them, built the same way as the hives page. This is option (c) on #5129, the operator's choice.
244 lines
9.6 KiB
Rust
244 lines
9.6 KiB
Rust
//! 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::linked_accounts::link;
|
|
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://forge.example.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, unless one is stored under the
|
|
/// label already.
|
|
#[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 = 409, description = "an account is stored under the label already; nothing was written (problem+json)", body = String),
|
|
(status = 500, description = "the store could not be read or written (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 = state.accounts.open().await.map_err(|e| {
|
|
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
|
|
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
|
})?;
|
|
link(&store, &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, "linking the forge account failed");
|
|
e.problem(&existing(&agent, &label))
|
|
})?;
|
|
|
|
tracing::info!(%hive, %agent, %label, url = %account.url, "forge account stored");
|
|
Ok(Json(PutForgeAccountResponse { url: account.url }))
|
|
}
|
|
|
|
/// The account a refused link names.
|
|
fn existing(agent: &str, label: &str) -> String {
|
|
format!("agent {agent} already has forge account {label:?}")
|
|
}
|
|
|
|
/// 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 std::sync::Arc;
|
|
|
|
use super::super::linked_accounts::{Accounts, tests::FakeStore};
|
|
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://forge.example.org/ ", "t0k3n")).expect("valid");
|
|
assert_eq!(a.url, "https://forge.example.org");
|
|
assert_eq!(a.value, "t0k3n");
|
|
}
|
|
|
|
#[test]
|
|
fn a_url_that_is_not_http_is_refused() {
|
|
assert!(account(request("forge.example.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://forge.example.org", " ")).is_err());
|
|
}
|
|
|
|
/// Bare-minimum `AppState`, as `matrix_account`'s tests build it.
|
|
fn state(store: &Arc<FakeStore>) -> 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,
|
|
reported_inference: None,
|
|
agent_login: 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: std::sync::Arc::default(),
|
|
swarm_name: None,
|
|
auth: None,
|
|
forge: std::sync::Arc::default(),
|
|
create_gate: std::sync::Arc::default(),
|
|
accounts: Accounts::Fake(Arc::clone(store)),
|
|
try_outs: super::super::review_classifier::TryOuts::default(),
|
|
reviews: std::sync::Arc::default(),
|
|
runs: crate::review_classifier::runs::Runs::Live(None),
|
|
alerts: None,
|
|
}
|
|
}
|
|
|
|
async fn put(
|
|
store: &Arc<FakeStore>,
|
|
label: &str,
|
|
) -> Result<axum::Json<super::PutForgeAccountResponse>, problem_details::ProblemDetails> {
|
|
super::put_forge_account(
|
|
axum::extract::State(state(store)),
|
|
axum::extract::Path(("pr1ma".to_owned(), "atlas".to_owned(), label.to_owned())),
|
|
axum::Json(request("https://forge.example.org", "t0k3n")),
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// A label `hive-forge -f` could not name is refused before the store.
|
|
#[tokio::test]
|
|
async fn a_label_hive_forge_cannot_name_is_refused_before_the_store() {
|
|
let store = Arc::default();
|
|
for bad in ["MyForge", "my_forge", "../x"] {
|
|
let problem = put(&store, bad).await.expect_err("refused");
|
|
assert_eq!(
|
|
problem.status,
|
|
Some(axum::http::StatusCode::BAD_REQUEST),
|
|
"{bad}: {problem:?}"
|
|
);
|
|
}
|
|
assert!(store.written().is_empty());
|
|
}
|
|
|
|
/// The control: a plain label is written.
|
|
#[tokio::test]
|
|
async fn a_plain_label_reaches_the_store() {
|
|
let store = Arc::default();
|
|
let stored = put(&store, "myforge").await.expect("nothing is stored");
|
|
assert_eq!(stored.url, "https://forge.example.org");
|
|
assert_eq!(store.written(), ["swarm/agents/atlas/forge/myforge"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn linking_a_label_twice_is_a_409_and_the_first_account_stays() {
|
|
use super::super::linked_accounts::{AccountStore, link};
|
|
use swarm_secret_client::forge;
|
|
|
|
let store = FakeStore::default();
|
|
let path = forge::account_path("atlas", "myforge").expect("a valid path");
|
|
let first = account(request("https://forge.example.org", "t0k3n-first")).expect("valid");
|
|
let second = account(request("https://forge.lan", "t0k3n-second")).expect("valid");
|
|
|
|
link(&store, &path, &first)
|
|
.await
|
|
.expect("nothing is stored");
|
|
let problem = link(&store, &path, &second)
|
|
.await
|
|
.expect_err("an account is stored")
|
|
.problem(&super::existing("atlas", "myforge"));
|
|
|
|
assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT));
|
|
let detail = problem.detail.expect("a detail");
|
|
assert!(
|
|
detail.contains(r#"agent atlas already has forge account "myforge""#),
|
|
"{detail}"
|
|
);
|
|
assert_eq!(store.written(), std::slice::from_ref(&path));
|
|
let kept: forge::Account = store
|
|
.read_optional(&path)
|
|
.await
|
|
.expect("store answers")
|
|
.expect("still stored");
|
|
assert_eq!(kept.url, "https://forge.example.org");
|
|
assert_eq!(kept.value, "t0k3n-first");
|
|
}
|
|
}
|