Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/gitlab_account.rs
atlas 68b38a9d44 swarm-controller: keep retrying the forge webhook secret and registration after a startup outage
The webhook secret slot is filled in the background, on the forge token's
capped unbounded backoff, when the store can't answer at start. The hook
registration waits for the secret and retries on the same backoff instead
of giving up after five tries.

Closes #6101
2026-11-06 09:21:59 +01:00

322 lines
13 KiB
Rust

//! An agent's accounts on GitLab instances: 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/gitlab-accounts.nix`, which lists the
//! agent's accounts and reads each under the agent's own certificate, and writes
//! the `glab` configuration. 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::gitlab;
use utoipa::ToSchema;
use super::linked_accounts::link;
use crate::backend_egress::host_of;
use crate::routes::agents::swarm_hive;
use crate::routes::{AppState, error_problem};
/// The account to store for one agent's GitLab instance.
///
/// No `Debug` derive: this carries a token.
#[derive(Deserialize, ToSchema)]
pub struct PutGitlabAccountRequest {
/// The instance's base URL, `http://` or `https://`, with a DNS name as its
/// host. A trailing slash is dropped.
#[schema(example = "https://gitlab.example.org")]
url: String,
/// The access token, scoped `api` and `write_repository`. Never logged, and
/// never returned by this route.
token: String,
}
/// `put_gitlab_account`'s success body.
#[derive(Debug, Serialize, ToSchema)]
pub struct PutGitlabAccountResponse {
/// The base URL as stored.
url: String,
}
/// Store an agent's GitLab account, unless one is stored under the label
/// already.
#[utoipa::path(
put,
path = "/api/hives/{hive}/agents/{agent}/gitlab-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 account's name"),
),
request_body = PutGitlabAccountRequest,
responses(
(status = 200, description = "stored", body = PutGitlabAccountResponse),
(status = 400, description = "the agent or label is not an identifier, the URL is not http(s) with a DNS name as its host, 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_gitlab_account(
State(state): State<AppState>,
axum::extract::Path((hive, agent, label)): axum::extract::Path<(String, String, String)>,
Json(req): Json<PutGitlabAccountRequest>,
) -> Result<Json<PutGitlabAccountResponse>, 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();
let label = hive_types::Ident::parse(&label)
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
.into_string();
let secret_path = gitlab::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 gitlab account failed");
e.problem(&existing(&agent, &label))
})?;
crate::link_egress::added(
&state,
&agent,
&crate::link_egress::Link::Gitlab {
label: &label,
url: &account.url,
},
)
.await?;
tracing::info!(%hive, %agent, %label, url = %account.url, "gitlab account stored");
Ok(Json(PutGitlabAccountResponse { url: account.url }))
}
/// The account a refused link names.
fn existing(agent: &str, label: &str) -> String {
format!("agent {agent} already has gitlab account {label:?}")
}
/// The request as it is stored, or why it cannot be.
///
/// The URL must match [`gitlab::URL_PATTERN`], the rule the agent end filters by,
/// so a stored account is never one the agent drops. The host must also be a
/// name the egress allow list takes: the agent reaches the instance through its
/// hive's proxy, so an address or `localhost` would be stored and never reachable.
fn account(req: PutGitlabAccountRequest) -> Result<gitlab::Account, String> {
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://".to_owned());
}
let authority = url
.split_once("://")
.and_then(|(_, rest)| rest.split(['/', '?', '#']).next())
.unwrap_or_default();
if authority.contains('@') {
return Err("url must not carry user information; the token is its own field".to_owned());
}
if !gitlab::is_instance_url(url) {
return Err(format!(
"url must match {}: http(s), a host name, an optional port, and an optional path of \
letters, digits and . _ ~ / - (no query, fragment, '@' or spaces)",
gitlab::URL_PATTERN
));
}
let host = host_of(url).ok_or("url has no host")?;
swarm_wire::egress_allow::normalize_domain(host).map_err(|why| format!("url host: {why}"))?;
if req.token.trim().is_empty() {
return Err("token is required".to_owned());
}
Ok(gitlab::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::{PutGitlabAccountRequest, account};
fn request(url: &str, token: &str) -> PutGitlabAccountRequest {
PutGitlabAccountRequest {
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://gitlab.example.org/ ", "t0k3n")).expect("valid");
assert_eq!(a.url, "https://gitlab.example.org");
assert_eq!(a.value, "t0k3n");
// A port and a subfolder are part of the instance's address.
let a = account(request("https://gitlab.example.org:8443/gl/", "t")).expect("valid");
assert_eq!(a.url, "https://gitlab.example.org:8443/gl");
}
#[test]
fn a_url_that_is_not_http_is_refused() {
assert!(account(request("gitlab.example.org", "t")).is_err());
assert!(account(request("file:///etc/passwd", "t")).is_err());
// The control: plain http with a name is accepted.
assert!(account(request("http://gitlab.lan", "t")).is_ok());
}
#[test]
fn a_host_the_egress_list_would_refuse_is_refused() {
for url in [
"http://10.0.0.5",
"https://10.0.0.5:8443",
"http://[::1]",
"https://localhost",
"https://gitlab",
"https://*.example.org",
"https://",
] {
assert!(account(request(url, "t")).is_err(), "{url}");
}
}
#[test]
fn user_information_in_the_url_is_refused() {
assert!(account(request("https://oauth2:t0k3n@gitlab.example.org", "t")).is_err());
// A path character the agent end drops is refused here too.
for url in [
"https://gitlab.example.org/@x",
"https://gitlab.example.org/?x=1",
"https://gitlab.example.org/#f",
"https://gitlab.example.org/a b",
] {
assert!(account(request(url, "t")).is_err(), "{url}");
}
}
#[test]
fn an_empty_token_is_refused() {
assert!(account(request("https://gitlab.example.org", " ")).is_err());
}
/// Bare-minimum `AppState`, as `forge_account`'s tests build it.
fn state(store: &Arc<FakeStore>) -> crate::routes::AppState {
crate::routes::AppState {
hives: std::sync::Arc::new(vec![crate::routes::hives::HiveEntry {
name: "pr1ma".to_owned(),
domain: "pr1ma.example".to_owned(),
dashboard: "pr1ma.hives.example".to_owned(),
}]),
links: std::sync::Arc::new(Vec::new()),
status: None,
wanted: None,
agent_status: None,
agent_icons: None,
agents_md: 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: std::sync::Arc::default(),
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),
builds: crate::builder::runs::Builds::Live(None),
alerts: None,
}
}
async fn put(
store: &Arc<FakeStore>,
agent: &str,
label: &str,
url: &str,
) -> Result<axum::Json<super::PutGitlabAccountResponse>, problem_details::ProblemDetails> {
super::put_gitlab_account(
axum::extract::State(state(store)),
axum::extract::Path(("pr1ma".to_owned(), agent.to_owned(), label.to_owned())),
axum::Json(request(url, "t0k3n")),
)
.await
}
#[tokio::test]
async fn a_plain_account_reaches_the_store_under_the_agents_gitlab_directory() {
let store = Arc::default();
let stored = put(&store, "atlas", "work", "https://gitlab.example.org")
.await
.expect("nothing is stored");
assert_eq!(stored.url, "https://gitlab.example.org");
assert_eq!(store.written(), ["swarm/agents/atlas/gitlab/work"]);
}
/// Refused before the store, whatever the part: an address, a label or an
/// agent that is not an identifier.
#[tokio::test]
async fn a_request_the_swarm_would_refuse_is_a_400_and_writes_nothing() {
let store = Arc::default();
for (agent, label, url) in [
("atlas", "work", "http://10.0.0.5:8080"),
("atlas", "work", "https://localhost"),
("atlas", "work", "https://gitlab.example.org/@x"),
("atlas", "work", "https://gitlab.example.org/?x=1"),
("atlas", "My_Label", "https://gitlab.example.org"),
("atlas", "../x", "https://gitlab.example.org"),
("../x", "work", "https://gitlab.example.org"),
] {
let problem = put(&store, agent, label, url).await.expect_err("refused");
assert_eq!(
problem.status,
Some(axum::http::StatusCode::BAD_REQUEST),
"{agent} {label} {url}: {problem:?}"
);
}
assert!(store.written().is_empty());
}
#[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::gitlab;
let store = FakeStore::default();
let path = gitlab::account_path("atlas", "work").expect("a valid path");
let first = account(request("https://gitlab.example.org", "t0k3n-first")).expect("valid");
let second = account(request("https://gitlab.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", "work"));
assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT));
let detail = problem.detail.expect("a detail");
assert!(
detail.contains(r#"agent atlas already has gitlab account "work""#),
"{detail}"
);
assert_eq!(store.written(), std::slice::from_ref(&path));
let kept: gitlab::Account = store
.read_optional(&path)
.await
.expect("store answers")
.expect("still stored");
assert_eq!(kept.url, "https://gitlab.example.org");
assert_eq!(kept.value, "t0k3n-first");
}
}