add external gitlab accounts: stored in bao, linked per agent, allowed in squid, usable with glab
This commit is contained in:
parent
3b8ab17240
commit
d6e983995d
5 changed files with 1322 additions and 51 deletions
322
swarm-controller/src/gitlab_account.rs
Normal file
322
swarm-controller/src/gitlab_account.rs
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
//! 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: 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),
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
//! The hosts an agent's linked accounts reach, kept as derived entries of the
|
||||
//! egress allow list (`crate::egress_allow`), so a restricted agent with a
|
||||
//! linked GitHub, external matrix or external forge account reaches that
|
||||
//! service through its hive's proxy.
|
||||
//! linked GitHub, external matrix, external forge or GitLab account reaches
|
||||
//! that service through its hive's proxy.
|
||||
//!
|
||||
//! An entry is marked `derived` and carries the account as its `source`
|
||||
//! (`link:github`, `link:matrix:<account>`, `link:forge:<label>`), so the
|
||||
//! backend environment's entries (`crate::backend_egress`) and each account's
|
||||
//! are replaced and dropped independently. A hive renders it like any other.
|
||||
//! (`link:github`, `link:matrix:<account>`, `link:forge:<label>`,
|
||||
//! `link:gitlab:<label>`), so
|
||||
//! the backend environment's entries (`crate::backend_egress`) and each
|
||||
//! account's are replaced and dropped independently. A hive renders it like
|
||||
//! any other.
|
||||
//!
|
||||
//! The controller alone writes the three leaves the entries come from; the
|
||||
//! The controller alone writes the four leaves the entries come from; the
|
||||
//! agent's store policy grants it `create` on its own `backend-env` leaf and
|
||||
//! nothing else (`swarm-secret-client/src/policy.rs`), so what these leaves
|
||||
//! hold is the operator's choice, unlike the backend environment's.
|
||||
|
|
@ -55,6 +57,10 @@ pub(crate) enum Link<'a> {
|
|||
label: &'a str,
|
||||
url: &'a str,
|
||||
},
|
||||
Gitlab {
|
||||
label: &'a str,
|
||||
url: &'a str,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) fn github_source() -> String {
|
||||
|
|
@ -69,12 +75,17 @@ pub(crate) fn forge_source(label: &str) -> String {
|
|||
format!("{LINK_SOURCE_PREFIX}forge:{label}")
|
||||
}
|
||||
|
||||
pub(crate) fn gitlab_source(label: &str) -> String {
|
||||
format!("{LINK_SOURCE_PREFIX}gitlab:{label}")
|
||||
}
|
||||
|
||||
impl Link<'_> {
|
||||
fn source(&self) -> String {
|
||||
match self {
|
||||
Self::Github => github_source(),
|
||||
Self::Matrix { account, .. } => matrix_source(account),
|
||||
Self::Forge { label, .. } => forge_source(label),
|
||||
Self::Gitlab { label, .. } => gitlab_source(label),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +98,7 @@ impl Link<'_> {
|
|||
let hosts: Vec<Option<&str>> = match self {
|
||||
Self::Github => GITHUB_HOSTS.iter().map(|h| Some(*h)).collect(),
|
||||
Self::Matrix { homeserver, .. } => homeserver.iter().map(|u| host_of(u)).collect(),
|
||||
Self::Forge { url, .. } => vec![host_of(url)],
|
||||
Self::Forge { url, .. } | Self::Gitlab { url, .. } => vec![host_of(url)],
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
for host in hosts {
|
||||
|
|
@ -221,6 +232,13 @@ async fn wanted_entries(
|
|||
},
|
||||
None => continue,
|
||||
},
|
||||
AccountKind::Gitlab => match account.host.as_deref() {
|
||||
Some(url) => Link::Gitlab {
|
||||
label: &account.name,
|
||||
url,
|
||||
},
|
||||
None => continue,
|
||||
},
|
||||
AccountKind::Matrix | AccountKind::Backend => continue,
|
||||
};
|
||||
entries.extend(link.entries(agent, base));
|
||||
|
|
@ -370,6 +388,18 @@ mod tests {
|
|||
forge.entries("atlas", &base()),
|
||||
[entry("forge.example.org", "atlas", Some("link:forge:oss"))]
|
||||
);
|
||||
let gitlab = Link::Gitlab {
|
||||
label: "work",
|
||||
url: "https://GitLab.Example.org:8443/sub",
|
||||
};
|
||||
assert_eq!(
|
||||
gitlab.entries("atlas", &base()),
|
||||
[entry(
|
||||
"gitlab.example.org",
|
||||
"atlas",
|
||||
Some("link:gitlab:work")
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -385,6 +415,8 @@ mod tests {
|
|||
] {
|
||||
let forge = Link::Forge { label: "x", url };
|
||||
assert_eq!(hosts(&forge), Vec::<String>::new(), "{url:?}");
|
||||
let gitlab = Link::Gitlab { label: "x", url };
|
||||
assert_eq!(hosts(&gitlab), Vec::<String>::new(), "{url:?}");
|
||||
}
|
||||
// Controls: a public name gets one, and a matrix account with no
|
||||
// homeserver has none to derive.
|
||||
|
|
@ -393,6 +425,11 @@ mod tests {
|
|||
url: "https://forge.example.org",
|
||||
};
|
||||
assert_eq!(hosts(&forge), ["forge.example.org"]);
|
||||
let gitlab = Link::Gitlab {
|
||||
label: "x",
|
||||
url: "https://gitlab.example.org",
|
||||
};
|
||||
assert_eq!(hosts(&gitlab), ["gitlab.example.org"]);
|
||||
let matrix = Link::Matrix {
|
||||
account: "work",
|
||||
homeserver: None,
|
||||
|
|
@ -475,6 +512,22 @@ mod tests {
|
|||
Path(("pr1ma".to_owned(), parts.to_owned()))
|
||||
}
|
||||
|
||||
/// Link `atlas`'s GitLab account `work` through the route.
|
||||
async fn link_gitlab(state: &AppState) {
|
||||
let _ = crate::gitlab_account::put_gitlab_account(
|
||||
State(state.clone()),
|
||||
Path(("pr1ma".to_owned(), "atlas".to_owned(), "work".to_owned())),
|
||||
axum::Json(
|
||||
serde_json::from_value(
|
||||
json!({"url": "https://gitlab.example.org:8443/", "token": "t"}),
|
||||
)
|
||||
.expect("a body"),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("linked");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn linking_and_unlinking_through_the_routes_moves_the_entries() {
|
||||
let nats = TestNats::start();
|
||||
|
|
@ -501,6 +554,7 @@ mod tests {
|
|||
)
|
||||
.await
|
||||
.expect("linked");
|
||||
link_gitlab(&state).await;
|
||||
let _ = crate::matrix_account::put_matrix_account(
|
||||
State(state.clone()),
|
||||
Path(("pr1ma".to_owned(), "atlas".to_owned(), "work".to_owned())),
|
||||
|
|
@ -514,9 +568,10 @@ mod tests {
|
|||
.await
|
||||
.expect("linked");
|
||||
let forge = entry("forge.example.org", "atlas", Some("link:forge:oss"));
|
||||
let gitlab = entry("gitlab.example.org", "atlas", Some("link:gitlab:work"));
|
||||
let matrix = entry("matrix.example.org", "atlas", Some("link:matrix:work"));
|
||||
let mut all = github_entries("atlas");
|
||||
all.extend([forge.clone(), matrix.clone()]);
|
||||
all.extend([forge.clone(), gitlab.clone(), matrix.clone()]);
|
||||
assert_eq!(list_of(&client).await, sorted(all));
|
||||
|
||||
// The backend environment's own save and delete leave them alone.
|
||||
|
|
@ -535,14 +590,14 @@ mod tests {
|
|||
crate::linked_accounts::delete_backend_env(State(state.clone()), path("atlas"))
|
||||
.await
|
||||
.expect("deleted");
|
||||
assert_eq!(list_of(&client).await.len(), 2 + GITHUB_HOSTS.len());
|
||||
assert_eq!(list_of(&client).await.len(), 3 + GITHUB_HOSTS.len());
|
||||
|
||||
crate::linked_accounts::delete_github_account(State(state.clone()), path("atlas"))
|
||||
.await
|
||||
.expect("deleted");
|
||||
assert_eq!(
|
||||
list_of(&client).await,
|
||||
sorted(vec![forge.clone(), matrix.clone()])
|
||||
sorted(vec![forge.clone(), gitlab.clone(), matrix.clone()])
|
||||
);
|
||||
crate::linked_accounts::delete_forge_account(
|
||||
State(state.clone()),
|
||||
|
|
@ -550,6 +605,16 @@ mod tests {
|
|||
)
|
||||
.await
|
||||
.expect("deleted");
|
||||
assert_eq!(
|
||||
list_of(&client).await,
|
||||
sorted(vec![gitlab.clone(), matrix.clone()])
|
||||
);
|
||||
crate::linked_accounts::delete_gitlab_account(
|
||||
State(state.clone()),
|
||||
Path(("pr1ma".to_owned(), "atlas".to_owned(), "work".to_owned())),
|
||||
)
|
||||
.await
|
||||
.expect("deleted");
|
||||
assert_eq!(list_of(&client).await, [matrix]);
|
||||
crate::linked_accounts::delete_matrix_account(
|
||||
State(state.clone()),
|
||||
|
|
@ -571,6 +636,10 @@ mod tests {
|
|||
"swarm/agents/atlas/matrix/work",
|
||||
json!({"value": "t", "homeserver": "https://matrix.example.org"}),
|
||||
);
|
||||
let store = store.with(
|
||||
"swarm/agents/atlas/gitlab/work",
|
||||
json!({"value": "t", "url": "https://gitlab.example.org:8443"}),
|
||||
);
|
||||
// The agent's own account has a homeserver of its own and no entry.
|
||||
let store = store.with(
|
||||
"swarm/agents/atlas/matrix/main",
|
||||
|
|
@ -583,6 +652,7 @@ mod tests {
|
|||
// An orphan: iris has no leaf for it.
|
||||
entry("github.com", "iris", Some("link:github")),
|
||||
// An orphan on a live agent whose forge leaf is gone.
|
||||
entry("gitlab.example.org", "atlas", Some("link:gitlab:old")),
|
||||
entry("forge.example.org", "atlas", Some("link:forge:old")),
|
||||
entry("llm.example.com", "atlas", Some("backend-env")),
|
||||
entry("crates.io", "atlas", None),
|
||||
|
|
@ -596,6 +666,7 @@ mod tests {
|
|||
assert_eq!(reconcile(&client, &store, &live).await.expect("pass"), 0);
|
||||
let mut want = github_entries("atlas");
|
||||
want.extend([
|
||||
entry("gitlab.example.org", "atlas", Some("link:gitlab:work")),
|
||||
entry("matrix.example.org", "atlas", Some("link:matrix:work")),
|
||||
entry("llm.example.com", "atlas", Some("backend-env")),
|
||||
entry("crates.io", "atlas", None),
|
||||
|
|
@ -607,6 +678,38 @@ mod tests {
|
|||
assert_eq!(list_of(&client).await, sorted(want));
|
||||
}
|
||||
|
||||
/// The host of a GitLab entry is the controller-written leaf's `url`. The
|
||||
/// one object an agent can create in the store is its `backend-env` leaf,
|
||||
/// and a GitLab host named there yields no `link:gitlab` entry.
|
||||
#[tokio::test]
|
||||
async fn a_gitlab_entry_comes_from_the_controller_leaf_and_nothing_an_agent_writes() {
|
||||
let agent_written = FakeStore::default().with(
|
||||
"swarm/agents/atlas/backend-env",
|
||||
json!({"env": "GITLAB_HOST=https://evil.example.com\nANTHROPIC_BASE_URL=https://evil.example.com\n"}),
|
||||
);
|
||||
let got = wanted_entries(&agent_written, "atlas", &base())
|
||||
.await
|
||||
.expect("store answers");
|
||||
assert_eq!(got, []);
|
||||
|
||||
// The control: the controller's leaf yields the entry.
|
||||
let leaf = agent_written.with(
|
||||
"swarm/agents/atlas/gitlab/work",
|
||||
json!({"value": "t", "url": "https://gitlab.example.org:8443/sub"}),
|
||||
);
|
||||
let got = wanted_entries(&leaf, "atlas", &base())
|
||||
.await
|
||||
.expect("store answers");
|
||||
assert_eq!(
|
||||
got,
|
||||
[entry(
|
||||
"gitlab.example.org",
|
||||
"atlas",
|
||||
Some("link:gitlab:work")
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_store_error_prunes_nothing_and_adds_nothing() {
|
||||
let nats = TestNats::start();
|
||||
|
|
|
|||
|
|
@ -1,28 +1,31 @@
|
|||
//! The accounts linked to one agent, as kind, name and host: what the swarm
|
||||
//! UI's agent panel lists, and deletes.
|
||||
//!
|
||||
//! [`link`] is the write the three link routes share: it refuses a path that
|
||||
//! [`link`] is the write the four link routes share: it refuses a path that
|
||||
//! already holds an account, so replacing one takes a delete first. The
|
||||
//! backend environment route is the exception: it overwrites.
|
||||
//!
|
||||
//! Read from the paths [`crate::matrix_account`], [`crate::forge_account`],
|
||||
//! [`crate::github_account`] and [`crate::backend_env`] write, plus the
|
||||
//! agent's own `main` matrix account, which `matrix_account::agent_token`
|
||||
//! mints into the same directory. Each entry is read for its host and nothing
|
||||
//! else leaves this module: [`LinkedAccount`] has no field a credential could
|
||||
//! land in. A backend environment is listed as set, with no host.
|
||||
//! [`crate::gitlab_account`], [`crate::github_account`] and
|
||||
//! [`crate::backend_env`] write, plus the agent's own `main` matrix account,
|
||||
//! which `matrix_account::agent_token` mints into the same directory. Each
|
||||
//! entry is read for its host and nothing else leaves this module:
|
||||
//! [`LinkedAccount`] has no field a credential could land in. A backend
|
||||
//! environment is listed as set, with no host.
|
||||
//!
|
||||
//! Naming the matrix accounts and forge labels takes `list` on each agent's
|
||||
//! `matrix` and `forge` metadata directories, and deleting an account takes
|
||||
//! `delete` on its metadata path. `controllerPolicyText` in
|
||||
//! `nix/host-modules/swarm-bao.nix` grants both on those paths alone.
|
||||
//! Naming the matrix accounts, forge labels and GitLab labels takes `list` on
|
||||
//! each agent's `matrix`, `forge` and `gitlab` metadata directories, and
|
||||
//! deleting an account takes `delete` on its metadata path.
|
||||
//! `controllerPolicyText` in `nix/host-modules/swarm-bao.nix` grants both on
|
||||
//! those paths alone.
|
||||
//!
|
||||
//! A delete removes the store entry only, and the agent is not told. Its
|
||||
//! GitHub and forge token files stay in place, since the units that write them
|
||||
//! never delete one; its matrix daemon drops the account when it next re-lists.
|
||||
//! A deleted backend environment leaves the agent's copy in `/run` until its
|
||||
//! next boot, and an agent with a non-empty `backendEnvironmentFile` creates
|
||||
//! the leaf again from that file.
|
||||
//! never delete one; its matrix daemon drops the account when it next re-lists,
|
||||
//! and its GitLab fetch drops the account from the `glab` configuration when it
|
||||
//! next runs. A deleted backend environment leaves the agent's copy in `/run`
|
||||
//! until its next boot, and an agent with a non-empty `backendEnvironmentFile`
|
||||
//! creates the leaf again from that file.
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
|
|
@ -31,7 +34,7 @@ use axum::extract::State;
|
|||
use axum::http::StatusCode;
|
||||
use serde::de::{DeserializeOwned, IgnoredAny};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use swarm_secret_client::{Error, SecretStore, backend_env, forge, github, matrix};
|
||||
use swarm_secret_client::{Error, SecretStore, backend_env, forge, github, gitlab, matrix};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::routes::agents::swarm_hive;
|
||||
|
|
@ -47,6 +50,7 @@ const GITHUB_HOST: &str = "github.com";
|
|||
pub enum AccountKind {
|
||||
Matrix,
|
||||
Forgejo,
|
||||
Gitlab,
|
||||
Github,
|
||||
Backend,
|
||||
}
|
||||
|
|
@ -55,10 +59,11 @@ pub enum AccountKind {
|
|||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, ToSchema)]
|
||||
pub struct LinkedAccount {
|
||||
pub(crate) kind: AccountKind,
|
||||
/// The matrix account name, the forge label, `github`, or `backend-env`.
|
||||
/// The matrix account name, the forge or GitLab label, `github`, or
|
||||
/// `backend-env`.
|
||||
#[schema(example = "main")]
|
||||
pub(crate) name: String,
|
||||
/// The matrix homeserver, the forge base URL, or `github.com`. `None` for a
|
||||
/// The matrix homeserver, the forge or GitLab base URL, or `github.com`. `None` for a
|
||||
/// matrix account stored without a homeserver.
|
||||
#[schema(example = "https://matrix.example.org")]
|
||||
pub(crate) host: Option<String>,
|
||||
|
|
@ -190,7 +195,7 @@ async fn objects(store: &impl AccountStore, dir: &str) -> Result<Vec<String>, Er
|
|||
Ok(keys.into_iter().filter(|k| !k.ends_with('/')).collect())
|
||||
}
|
||||
|
||||
/// Every account the store holds for `agent`: matrix, then forgejo, then
|
||||
/// Every account the store holds for `agent`: matrix, then forgejo, then gitlab, then
|
||||
/// github, then the backend environment.
|
||||
///
|
||||
/// A missing directory lists nothing, and a listed name whose object is gone
|
||||
|
|
@ -230,6 +235,18 @@ pub(crate) async fn linked_accounts(
|
|||
}
|
||||
}
|
||||
|
||||
for label in objects(store, &gitlab::accounts_dir(agent)?).await? {
|
||||
let path = gitlab::account_path(agent, &label)?;
|
||||
if let Some(a) = store.read_optional::<gitlab::Account>(&path).await? {
|
||||
out.push(LinkedAccount {
|
||||
kind: AccountKind::Gitlab,
|
||||
name: label,
|
||||
host: Some(a.url),
|
||||
reserved: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let path = github::account_path(agent)?;
|
||||
if store
|
||||
.read_optional::<github::Credential>(&path)
|
||||
|
|
@ -413,7 +430,7 @@ where
|
|||
remove(store, path).await
|
||||
}
|
||||
|
||||
/// Delete the forge account or GitHub token at `path`.
|
||||
/// Delete the forge account, GitLab account or GitHub token at `path`.
|
||||
async fn remove_plain<T: DeserializeOwned + Send>(
|
||||
store: &impl AccountStore,
|
||||
path: &str,
|
||||
|
|
@ -544,6 +561,53 @@ pub async fn delete_forge_account(
|
|||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Delete an agent's GitLab account from the store, every version of it.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
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 label"),
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "deleted"),
|
||||
(status = 400, description = "the agent or label is not an identifier, or the hive is not in this swarm (problem+json)", body = String),
|
||||
(status = 404, description = "no such account is stored (problem+json)", body = String),
|
||||
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
)]
|
||||
pub async fn delete_gitlab_account(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path((hive, agent, label)): axum::extract::Path<(String, String, String)>,
|
||||
) -> Result<StatusCode, 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 path = gitlab::account_path(&agent, &label)
|
||||
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
|
||||
|
||||
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())
|
||||
})?;
|
||||
remove_plain::<gitlab::Account>(&store, &path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(%hive, %agent, %label, error = ?e, "deleting the gitlab account failed");
|
||||
e.problem()
|
||||
})?;
|
||||
crate::link_egress::removed(&state, &agent, &crate::link_egress::gitlab_source(&label)).await?;
|
||||
|
||||
tracing::info!(%hive, %agent, %label, "gitlab account deleted");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Delete an agent's GitHub token from the store, every version of it.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
|
|
@ -645,7 +709,7 @@ pub(crate) mod tests {
|
|||
AccountKind, AccountStore, LinkedAccount, Removal, linked_accounts, remove_matrix,
|
||||
remove_plain,
|
||||
};
|
||||
use swarm_secret_client::{forge, github};
|
||||
use swarm_secret_client::{forge, github, gitlab};
|
||||
|
||||
/// Objects by path. A list answers the next segment of every path under
|
||||
/// the directory, with a trailing `/` when it goes deeper, as the store
|
||||
|
|
@ -773,6 +837,10 @@ pub(crate) mod tests {
|
|||
"swarm/agents/atlas/forge/myforge",
|
||||
json!({"value": "t0k3n-forge", "url": "https://forge.example.org"}),
|
||||
)
|
||||
.with(
|
||||
"swarm/agents/atlas/gitlab/work",
|
||||
json!({"value": "t0k3n-gl", "url": "https://gitlab.example.org"}),
|
||||
)
|
||||
.with(
|
||||
"swarm/agents/atlas/github-token",
|
||||
json!({"value": "t0k3n-gh"}),
|
||||
|
|
@ -820,6 +888,12 @@ pub(crate) mod tests {
|
|||
Some("https://forge.example.org"),
|
||||
false
|
||||
),
|
||||
row(
|
||||
AccountKind::Gitlab,
|
||||
"work",
|
||||
Some("https://gitlab.example.org"),
|
||||
false
|
||||
),
|
||||
row(AccountKind::Github, "github", Some("github.com"), false),
|
||||
row(AccountKind::Backend, "backend-env", None, false),
|
||||
]
|
||||
|
|
@ -887,6 +961,9 @@ pub(crate) mod tests {
|
|||
remove_plain::<forge::Account>(&store, "swarm/agents/atlas/forge/myforge")
|
||||
.await
|
||||
.expect("stored");
|
||||
remove_plain::<gitlab::Account>(&store, "swarm/agents/atlas/gitlab/work")
|
||||
.await
|
||||
.expect("stored");
|
||||
remove_plain::<github::Credential>(&store, "swarm/agents/atlas/github-token")
|
||||
.await
|
||||
.expect("stored");
|
||||
|
|
@ -898,6 +975,7 @@ pub(crate) mod tests {
|
|||
[
|
||||
"swarm/agents/atlas/matrix/catgirl",
|
||||
"swarm/agents/atlas/forge/myforge",
|
||||
"swarm/agents/atlas/gitlab/work",
|
||||
"swarm/agents/atlas/github-token",
|
||||
"swarm/agents/atlas/backend-env",
|
||||
]
|
||||
|
|
@ -915,6 +993,7 @@ pub(crate) mod tests {
|
|||
)
|
||||
.await,
|
||||
remove_plain::<forge::Account>(&store, "swarm/agents/atlas/forge/myforge").await,
|
||||
remove_plain::<gitlab::Account>(&store, "swarm/agents/atlas/gitlab/work").await,
|
||||
remove_plain::<github::Credential>(&store, "swarm/agents/atlas/github-token").await,
|
||||
remove_plain::<IgnoredAny>(&store, "swarm/agents/atlas/backend-env").await,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ mod forge;
|
|||
mod forge_account;
|
||||
mod forge_events;
|
||||
mod github_account;
|
||||
mod gitlab_account;
|
||||
mod held_deploys;
|
||||
mod inference;
|
||||
mod issue_report;
|
||||
|
|
|
|||
|
|
@ -31,9 +31,9 @@ use crate::{
|
|||
agent_control, agent_custom, agent_icon, agent_input, agent_limits, agent_login,
|
||||
agent_permissions, agent_renewal, agent_state_stream, agent_status, agents_md, alerts,
|
||||
archetypes, auth, backend_env, builder, claude_login, config_pr, egress_allow, forge,
|
||||
forge_account, github_account, inference, issue_report, linked_accounts, matrix_account,
|
||||
message_stream, notify_flows, notify_router_api, pulls, queue_identity, review_classifier,
|
||||
schedules, status, subagent_term, term_stream, wanted, webhook,
|
||||
forge_account, github_account, gitlab_account, inference, issue_report, linked_accounts,
|
||||
matrix_account, message_stream, notify_flows, notify_router_api, pulls, queue_identity,
|
||||
review_classifier, schedules, status, subagent_term, term_stream, wanted, webhook,
|
||||
};
|
||||
|
||||
/// Where the daemon binds, overridable via `SWARM_CONTROLLER_SOCKET`.
|
||||
|
|
@ -587,6 +587,10 @@ fn linked_account_routes(router: OpenApiRouter<AppState>) -> OpenApiRouter<AppSt
|
|||
forge_account::put_forge_account,
|
||||
linked_accounts::delete_forge_account
|
||||
))
|
||||
.routes(routes!(
|
||||
gitlab_account::put_gitlab_account,
|
||||
linked_accounts::delete_gitlab_account
|
||||
))
|
||||
.routes(routes!(
|
||||
github_account::put_github_account,
|
||||
linked_accounts::delete_github_account
|
||||
|
|
|
|||
Loading…
Reference in a new issue