let the operator set, replace and remove an agent's backend env from the swarm ui
This commit is contained in:
parent
398b2af44b
commit
57b04d6c49
4 changed files with 795 additions and 201 deletions
296
swarm-controller/src/backend_env.rs
Normal file
296
swarm-controller/src/backend_env.rs
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
//! An agent's backend environment: an operator hands us the text of a systemd
|
||||
//! `EnvironmentFile`, and we put it in the swarm's secret store.
|
||||
//!
|
||||
//! The agent end is `nix/agent-modules/backend-env.nix`, which reads it under
|
||||
//! the agent's own certificate into the file its harness and subagent daemon
|
||||
//! load as `EnvironmentFile=`. This is the operator's path: it replaces
|
||||
//! whatever the leaf holds, including a value the agent wrote itself while the
|
||||
//! leaf was empty, and the store's value wins over the agent's
|
||||
//! `backendEnvironmentFile`.
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use swarm_secret_client::backend_env::{self, BackendEnv};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use super::linked_accounts::AccountStore;
|
||||
use crate::routes::agents::swarm_hive;
|
||||
use crate::routes::{AppState, error_problem};
|
||||
|
||||
/// The most text one agent's environment may hold.
|
||||
const MAX_BYTES: usize = 16 * 1024;
|
||||
|
||||
/// The text to store for one agent.
|
||||
///
|
||||
/// No `Debug` derive: this carries live credentials.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct PutBackendEnvRequest {
|
||||
/// The file text, `NAME=VALUE` per line; blank lines and lines starting
|
||||
/// with `#` or `;` are allowed. Never logged, and never returned by this
|
||||
/// route.
|
||||
env: String,
|
||||
}
|
||||
|
||||
/// Store an agent's backend environment, replacing any stored one.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/hives/{hive}/agents/{agent}/backend-env",
|
||||
params(
|
||||
("hive" = String, Path, description = "hive the agent runs on"),
|
||||
("agent" = String, Path, description = "agent the environment belongs to"),
|
||||
),
|
||||
request_body = PutBackendEnvRequest,
|
||||
responses(
|
||||
(status = 204, description = "stored"),
|
||||
(status = 400, description = "the agent is not an identifier, the text is empty, too large or not `NAME=VALUE` lines, or the hive is not in this swarm (problem+json)", body = String),
|
||||
(status = 500, description = "the store could not be written (problem+json)", body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
)]
|
||||
pub async fn put_backend_env(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path((hive, agent)): axum::extract::Path<(String, String)>,
|
||||
Json(req): Json<PutBackendEnvRequest>,
|
||||
) -> 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 path = backend_env::env_path(&agent)
|
||||
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
|
||||
let stored = BackendEnv {
|
||||
env: validated(&req.env).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())
|
||||
})?;
|
||||
// A plain write, not a create: the operator is authoritative over a value
|
||||
// the agent planted in the empty leaf.
|
||||
store.write(&path, &stored).await.map_err(|e| {
|
||||
// The path names the agent; the value is not in it.
|
||||
tracing::warn!(%path, error = %e, "storing the backend env failed");
|
||||
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||
})?;
|
||||
|
||||
tracing::info!(%hive, %agent, "backend env stored");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// The text as it is stored, or why it cannot be.
|
||||
///
|
||||
/// systemd skips a line it cannot parse and goes on, so a typo would load as
|
||||
/// a missing key. A quote that is not closed would swallow the lines after it
|
||||
/// into one value. The reasons name a line number and never its text.
|
||||
fn validated(text: &str) -> Result<String, String> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Err("env is required".to_owned());
|
||||
}
|
||||
if text.len() > MAX_BYTES {
|
||||
return Err(format!("env is over {MAX_BYTES} bytes"));
|
||||
}
|
||||
if text
|
||||
.chars()
|
||||
.any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t'))
|
||||
{
|
||||
return Err("env holds a control character".to_owned());
|
||||
}
|
||||
for (n, line) in text.lines().enumerate() {
|
||||
if !valid_line(line.trim()) {
|
||||
return Err(format!("line {} is not NAME=VALUE", n + 1));
|
||||
}
|
||||
}
|
||||
Ok(format!("{text}\n"))
|
||||
}
|
||||
|
||||
/// A blank line, a comment, or `NAME=VALUE` with a closed quote and no
|
||||
/// trailing backslash.
|
||||
fn valid_line(line: &str) -> bool {
|
||||
if line.is_empty() || line.starts_with(['#', ';']) {
|
||||
return true;
|
||||
}
|
||||
let Some((name, value)) = line.split_once('=') else {
|
||||
return false;
|
||||
};
|
||||
let mut chars = name.chars();
|
||||
let name_ok = chars
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||||
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
|
||||
let value = value.trim();
|
||||
let closed = match value.chars().next() {
|
||||
Some(q @ ('"' | '\'')) => value.len() > 1 && value.ends_with(q),
|
||||
_ => true,
|
||||
};
|
||||
name_ok && closed && !value.ends_with('\\')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
use swarm_secret_client::backend_env::BackendEnv;
|
||||
|
||||
use super::super::linked_accounts::{AccountStore, Accounts, tests::FakeStore};
|
||||
use super::{MAX_BYTES, PutBackendEnvRequest, validated};
|
||||
|
||||
const SECRET: &str = "sk-live-0123";
|
||||
|
||||
/// Bare-minimum `AppState`, as `github_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,
|
||||
env: &str,
|
||||
) -> Result<StatusCode, problem_details::ProblemDetails> {
|
||||
super::put_backend_env(
|
||||
axum::extract::State(state(store)),
|
||||
axum::extract::Path(("pr1ma".to_owned(), agent.to_owned())),
|
||||
axum::Json(PutBackendEnvRequest {
|
||||
env: env.to_owned(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn stored(store: &FakeStore, agent: &str) -> Option<String> {
|
||||
store
|
||||
.read_optional::<BackendEnv>(&format!("swarm/agents/{agent}/backend-env"))
|
||||
.await
|
||||
.expect("store answers")
|
||||
.map(|b| b.env)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_lines_comments_and_quoted_values_are_accepted() {
|
||||
let text = "# openrouter\nANTHROPIC_BASE_URL=https://openrouter.ai/api/v1\n\n\
|
||||
; key\n_K9=\"a b\"\nEMPTY=\nS='x'\n";
|
||||
let kept = validated(text).expect("valid");
|
||||
assert_eq!(kept, text.trim().to_owned() + "\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_systemd_would_skip_or_misread_is_refused() {
|
||||
for bad in [
|
||||
"",
|
||||
" \n ",
|
||||
"KEY",
|
||||
"=value",
|
||||
"1KEY=v",
|
||||
"KE Y=v",
|
||||
"KEY=\"open",
|
||||
"KEY='",
|
||||
"KEY=trailing\\",
|
||||
"A=1\nnot a line\nB=2",
|
||||
"A=b\0c",
|
||||
] {
|
||||
assert!(validated(bad).is_err(), "{bad:?}");
|
||||
}
|
||||
assert!(validated(&format!("A={}", "x".repeat(MAX_BYTES))).is_err());
|
||||
// The control: the size cap is the only difference.
|
||||
assert!(validated(&format!("A={}", "x".repeat(MAX_BYTES - 2))).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_names_the_line_and_not_its_text() {
|
||||
let reason = validated(&format!("A=1\n{SECRET}\n")).expect_err("no `=`");
|
||||
assert_eq!(reason, "line 2 is not NAME=VALUE");
|
||||
assert!(!reason.contains(SECRET), "{reason}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_plain_request_is_stored_under_the_agent() {
|
||||
let store = Arc::default();
|
||||
let status = put(&store, "atlas", &format!("ANTHROPIC_API_KEY={SECRET}"))
|
||||
.await
|
||||
.expect("stored");
|
||||
assert_eq!(status, StatusCode::NO_CONTENT);
|
||||
assert_eq!(store.written(), ["swarm/agents/atlas/backend-env"]);
|
||||
assert_eq!(
|
||||
stored(&store, "atlas").await.as_deref(),
|
||||
Some("ANTHROPIC_API_KEY=sk-live-0123\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// The operator's write replaces a value an agent planted in the leaf.
|
||||
#[tokio::test]
|
||||
async fn a_value_already_in_the_leaf_is_overwritten() {
|
||||
let store = Arc::new(FakeStore::default().with(
|
||||
"swarm/agents/atlas/backend-env",
|
||||
json!({"env": "PLANTED=1\n"}),
|
||||
));
|
||||
put(&store, "atlas", "ANTHROPIC_API_KEY=operator")
|
||||
.await
|
||||
.expect("overwritten");
|
||||
assert_eq!(
|
||||
stored(&store, "atlas").await.as_deref(),
|
||||
Some("ANTHROPIC_API_KEY=operator\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_bad_agent_or_body_is_refused_before_the_store() {
|
||||
let store = Arc::default();
|
||||
let leaky = format!("{SECRET}-no-equals");
|
||||
for (agent, env) in [
|
||||
("Atlas", "A=1"),
|
||||
("../x", "A=1"),
|
||||
("atlas", " "),
|
||||
("atlas", leaky.as_str()),
|
||||
] {
|
||||
let problem = put(&store, agent, env).await.expect_err("refused");
|
||||
assert_eq!(problem.status, Some(StatusCode::BAD_REQUEST), "{problem:?}");
|
||||
assert!(!format!("{problem:?}").contains(SECRET), "{problem:?}");
|
||||
}
|
||||
assert!(store.written().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_store_failure_does_not_echo_the_value() {
|
||||
let store = Arc::new(FakeStore::denying());
|
||||
let problem = put(&store, "atlas", &format!("ANTHROPIC_API_KEY={SECRET}"))
|
||||
.await
|
||||
.expect_err("the store refuses");
|
||||
assert_eq!(problem.status, Some(StatusCode::INTERNAL_SERVER_ERROR));
|
||||
assert!(!format!("{problem:?}").contains(SECRET), "{problem:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -2,13 +2,15 @@
|
|||
//! UI's agent panel lists, and deletes.
|
||||
//!
|
||||
//! [`link`] is the write the three link routes share: it refuses a path that
|
||||
//! already holds an account, so replacing one takes a delete first.
|
||||
//! 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`] and
|
||||
//! [`crate::github_account`] 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.
|
||||
//! 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.
|
||||
//!
|
||||
//! Naming the matrix accounts and forge labels takes `list` on each agent's
|
||||
//! `matrix` and `forge` metadata directories, and deleting an account takes
|
||||
|
|
@ -18,6 +20,9 @@
|
|||
//! 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.
|
||||
|
||||
use std::future::Future;
|
||||
|
||||
|
|
@ -26,7 +31,7 @@ use axum::extract::State;
|
|||
use axum::http::StatusCode;
|
||||
use serde::de::{DeserializeOwned, IgnoredAny};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use swarm_secret_client::{Error, SecretStore, forge, github, matrix};
|
||||
use swarm_secret_client::{Error, SecretStore, backend_env, forge, github, matrix};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::routes::agents::swarm_hive;
|
||||
|
|
@ -43,13 +48,14 @@ pub enum AccountKind {
|
|||
Matrix,
|
||||
Forgejo,
|
||||
Github,
|
||||
Backend,
|
||||
}
|
||||
|
||||
/// One account linked to an agent. Never carries the credential.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, ToSchema)]
|
||||
pub struct LinkedAccount {
|
||||
kind: AccountKind,
|
||||
/// The matrix account name, the forge label, or `github`.
|
||||
/// The matrix account name, the forge label, `github`, or `backend-env`.
|
||||
#[schema(example = "main")]
|
||||
name: String,
|
||||
/// The matrix homeserver, the forge base URL, or `github.com`. `None` for a
|
||||
|
|
@ -185,7 +191,7 @@ async fn objects(store: &impl AccountStore, dir: &str) -> Result<Vec<String>, Er
|
|||
}
|
||||
|
||||
/// Every account the store holds for `agent`: matrix, then forgejo, then
|
||||
/// github.
|
||||
/// github, then the backend environment.
|
||||
///
|
||||
/// A missing directory lists nothing, and a listed name whose object is gone
|
||||
/// gets no entry.
|
||||
|
|
@ -238,6 +244,16 @@ pub(crate) async fn linked_accounts(
|
|||
});
|
||||
}
|
||||
|
||||
let path = backend_env::env_path(agent)?;
|
||||
if store.read_optional::<IgnoredAny>(&path).await?.is_some() {
|
||||
out.push(LinkedAccount {
|
||||
kind: AccountKind::Backend,
|
||||
name: "backend-env".to_owned(),
|
||||
host: None,
|
||||
reserved: false,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
|
@ -567,13 +583,55 @@ pub async fn delete_github_account(
|
|||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Delete an agent's backend environment from the store, every version of it.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/hives/{hive}/agents/{agent}/backend-env",
|
||||
params(
|
||||
("hive" = String, Path, description = "hive the agent runs on"),
|
||||
("agent" = String, Path, description = "agent the environment belongs to"),
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "deleted"),
|
||||
(status = 400, description = "the agent is not an identifier, or the hive is not in this swarm (problem+json)", body = String),
|
||||
(status = 404, description = "no backend environment 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_backend_env(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path((hive, agent)): axum::extract::Path<(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 path = backend_env::env_path(&agent)
|
||||
.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::<IgnoredAny>(&store, &path)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::warn!(%hive, %agent, error = ?e, "deleting the backend env failed");
|
||||
e.problem()
|
||||
})?;
|
||||
|
||||
tracing::info!(%hive, %agent, "backend env deleted");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::de::{DeserializeOwned, IgnoredAny};
|
||||
use serde_json::{Value, json};
|
||||
use swarm_secret_client::Error;
|
||||
|
||||
|
|
@ -596,7 +654,15 @@ pub(crate) mod tests {
|
|||
}
|
||||
|
||||
impl FakeStore {
|
||||
fn with(self, path: &str, object: Value) -> Self {
|
||||
/// A store whose every call fails.
|
||||
pub(crate) fn denying() -> Self {
|
||||
Self {
|
||||
denied: true,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with(self, path: &str, object: Value) -> Self {
|
||||
self.objects
|
||||
.lock()
|
||||
.expect("not poisoned")
|
||||
|
|
@ -705,6 +771,10 @@ pub(crate) mod tests {
|
|||
"swarm/agents/atlas/github-token",
|
||||
json!({"value": "t0k3n-gh"}),
|
||||
)
|
||||
.with(
|
||||
"swarm/agents/atlas/backend-env",
|
||||
json!({"env": "ANTHROPIC_API_KEY=t0k3n-env\n"}),
|
||||
)
|
||||
// Another agent's accounts, and the agent's own forge token beside
|
||||
// its `forge/` directory: neither is a linked account of atlas.
|
||||
.with(
|
||||
|
|
@ -745,6 +815,7 @@ pub(crate) mod tests {
|
|||
false
|
||||
),
|
||||
row(AccountKind::Github, "github", Some("github.com"), false),
|
||||
row(AccountKind::Backend, "backend-env", None, false),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
@ -813,12 +884,16 @@ pub(crate) mod tests {
|
|||
remove_plain::<github::Credential>(&store, "swarm/agents/atlas/github-token")
|
||||
.await
|
||||
.expect("stored");
|
||||
remove_plain::<IgnoredAny>(&store, "swarm/agents/atlas/backend-env")
|
||||
.await
|
||||
.expect("stored");
|
||||
assert_eq!(
|
||||
deleted(&store),
|
||||
[
|
||||
"swarm/agents/atlas/matrix/catgirl",
|
||||
"swarm/agents/atlas/forge/myforge",
|
||||
"swarm/agents/atlas/github-token",
|
||||
"swarm/agents/atlas/backend-env",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
@ -835,6 +910,7 @@ pub(crate) mod tests {
|
|||
.await,
|
||||
remove_plain::<forge::Account>(&store, "swarm/agents/atlas/forge/myforge").await,
|
||||
remove_plain::<github::Credential>(&store, "swarm/agents/atlas/github-token").await,
|
||||
remove_plain::<IgnoredAny>(&store, "swarm/agents/atlas/backend-env").await,
|
||||
];
|
||||
for result in results {
|
||||
let removal = result.expect_err("nothing is stored");
|
||||
|
|
@ -1023,4 +1099,31 @@ pub(crate) mod tests {
|
|||
assert_eq!(status, axum::http::StatusCode::NO_CONTENT);
|
||||
assert_eq!(deleted(&store), ["swarm/agents/atlas/matrix/catgirl"]);
|
||||
}
|
||||
|
||||
async fn delete_backend(
|
||||
store: &Arc<FakeStore>,
|
||||
agent: &str,
|
||||
) -> Result<axum::http::StatusCode, problem_details::ProblemDetails> {
|
||||
super::delete_backend_env(
|
||||
axum::extract::State(state(store)),
|
||||
axum::extract::Path(("pr1ma".to_owned(), agent.to_owned())),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_backend_env_removes_that_agents_leaf_only() {
|
||||
let store = Arc::new(every_kind());
|
||||
let status = delete_backend(&store, "atlas").await.expect("stored");
|
||||
assert_eq!(status, axum::http::StatusCode::NO_CONTENT);
|
||||
assert_eq!(deleted(&store), ["swarm/agents/atlas/backend-env"]);
|
||||
|
||||
let problem = delete_backend(&store, "red").await.expect_err("none");
|
||||
assert_eq!(problem.status, Some(axum::http::StatusCode::NOT_FOUND));
|
||||
for bad in ["../x", "Atlas"] {
|
||||
let problem = delete_backend(&store, bad).await.expect_err("refused");
|
||||
assert_eq!(problem.status, Some(axum::http::StatusCode::BAD_REQUEST));
|
||||
}
|
||||
assert_eq!(deleted(&store).len(), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ mod alerts;
|
|||
mod archetypes;
|
||||
mod attic_copy;
|
||||
mod auth;
|
||||
mod backend_env;
|
||||
mod builder;
|
||||
mod claude_login;
|
||||
mod config_pr;
|
||||
|
|
|
|||
|
|
@ -29,11 +29,11 @@ use crate::routes::hives::{HiveEntry, ServiceLink};
|
|||
use crate::routes::{self, AppState, agents, config_prs, forge_admin, hives, jobq_graph};
|
||||
use crate::{
|
||||
agent_control, agent_icon, agent_input, agent_limits, agent_login, agent_permissions,
|
||||
agent_renewal, agent_state_stream, agent_status, agents_md, alerts, archetypes, auth, builder,
|
||||
claude_login, config_pr, 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,
|
||||
agent_renewal, agent_state_stream, agent_status, agents_md, alerts, archetypes, auth,
|
||||
backend_env, builder, claude_login, config_pr, 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,
|
||||
};
|
||||
|
||||
/// Where the daemon binds, overridable via `SWARM_CONTROLLER_SOCKET`.
|
||||
|
|
@ -552,49 +552,10 @@ pub(crate) async fn start_forge(
|
|||
.await;
|
||||
}
|
||||
|
||||
/// Every route this daemon serves, wired to its state.
|
||||
///
|
||||
/// Split out of `main` because the route list is the part that grows, and
|
||||
/// `main` sat exactly on the `too_many_lines` limit — adding an endpoint
|
||||
/// tripped a lint about the startup sequence, which is not where the change
|
||||
/// was. A new route now costs one line here and none there.
|
||||
pub(crate) fn build_app(state: AppState) -> axum::Router {
|
||||
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
|
||||
.routes(routes!(routes::health))
|
||||
.routes(routes!(alerts::get_alerts))
|
||||
.routes(routes!(hives::get_hives))
|
||||
.routes(routes!(hives::get_hives_status))
|
||||
.routes(routes!(hives::get_links))
|
||||
.routes(routes!(hives::get_swarm_info))
|
||||
.routes(routes!(jobq_graph::get_jobq_graph))
|
||||
.routes(routes!(jobq_graph::get_jobq_rollup))
|
||||
.routes(routes!(config_prs::get_agent_config_pr))
|
||||
.routes(routes!(agents::get_agent_icon))
|
||||
.routes(routes!(agents_md::get_agents_md, agents_md::put_agents_md))
|
||||
.routes(routes!(
|
||||
inference::get_agent_inference,
|
||||
inference::put_agent_inference,
|
||||
inference::delete_agent_inference
|
||||
))
|
||||
.routes(routes!(
|
||||
agent_limits::get_agent_limits,
|
||||
agent_limits::put_agent_limits,
|
||||
agent_limits::delete_agent_limits
|
||||
))
|
||||
.routes(routes!(
|
||||
agent_permissions::get_agent_permissions,
|
||||
agent_permissions::put_agent_permissions,
|
||||
agent_permissions::delete_agent_permissions
|
||||
))
|
||||
.routes(routes!(agent_permissions::get_permission_catalogue))
|
||||
.routes(routes!(archetypes::get_archetypes))
|
||||
.routes(routes!(config_prs::get_config_prs))
|
||||
.routes(routes!(agents::create_agent))
|
||||
.routes(routes!(forge_admin::mint_agent_forge_token))
|
||||
.routes(routes!(forge_admin::make_forge_admin))
|
||||
.routes(routes!(agents::get_agents))
|
||||
.routes(routes!(agents::get_agents_status))
|
||||
.routes(routes!(agents::set_agent_state))
|
||||
/// The routes that write, delete and list an agent's linked accounts and
|
||||
/// backend environment, kept apart so `build_app` stays under the line limit.
|
||||
fn linked_account_routes(router: OpenApiRouter<AppState>) -> OpenApiRouter<AppState> {
|
||||
router
|
||||
.routes(routes!(
|
||||
matrix_account::put_matrix_account,
|
||||
linked_accounts::delete_matrix_account
|
||||
|
|
@ -607,50 +568,100 @@ pub(crate) fn build_app(state: AppState) -> axum::Router {
|
|||
github_account::put_github_account,
|
||||
linked_accounts::delete_github_account
|
||||
))
|
||||
.routes(routes!(
|
||||
backend_env::put_backend_env,
|
||||
linked_accounts::delete_backend_env
|
||||
))
|
||||
.routes(routes!(linked_accounts::get_linked_accounts))
|
||||
.routes(routes!(agents::get_hive_wanted))
|
||||
.routes(routes!(term_stream::stream_agent_term))
|
||||
.routes(routes!(message_stream::stream_messages))
|
||||
.routes(routes!(subagent_term::list_subagents))
|
||||
.routes(routes!(subagent_term::stream_subagent_term))
|
||||
.routes(routes!(agent_state_stream::stream_agent_state))
|
||||
.routes(routes!(agent_input::post_agent_input))
|
||||
.routes(routes!(agent_input::post_agents_broadcast))
|
||||
.routes(routes!(agent_control::post_agent_cancel))
|
||||
.routes(routes!(
|
||||
claude_login::post_claude_login,
|
||||
claude_login::delete_claude_login
|
||||
))
|
||||
.routes(routes!(claude_login::post_claude_login_code))
|
||||
.routes(routes!(schedules::list_schedules, schedules::add_schedule))
|
||||
.routes(routes!(
|
||||
schedules::edit_schedule,
|
||||
schedules::remove_schedule
|
||||
))
|
||||
.routes(routes!(issue_report::get_repos))
|
||||
.routes(routes!(issue_report::get_issue_report_all))
|
||||
.routes(routes!(issue_report::get_issue_report))
|
||||
.routes(routes!(pulls::get_pull_repos))
|
||||
.routes(routes!(pulls::get_pulls))
|
||||
.routes(routes!(review_classifier::post_try_out))
|
||||
.routes(routes!(review_classifier::get_try_out))
|
||||
.routes(routes!(review_classifier::get_rules))
|
||||
.routes(routes!(review_classifier::open_pr::post_open_pr))
|
||||
.routes(routes!(review_classifier::runs::get_runs))
|
||||
.routes(routes!(builder::runs::get_runs))
|
||||
.routes(routes!(builder::runs::get_run))
|
||||
.routes(routes!(
|
||||
review_classifier::settings::get_settings,
|
||||
review_classifier::settings::put_settings
|
||||
))
|
||||
.routes(routes!(notify_flows::get_flows, notify_flows::put_flows))
|
||||
.routes(routes!(notify_router_api::get_deliveries))
|
||||
.routes(routes!(
|
||||
notify_router_api::get_dry_run,
|
||||
notify_router_api::post_dry_run
|
||||
))
|
||||
.routes(routes!(webhook::post_webhook_forge))
|
||||
.split_for_parts();
|
||||
}
|
||||
|
||||
/// Every route this daemon serves, wired to its state.
|
||||
///
|
||||
/// Split out of `main` because the route list is the part that grows, and
|
||||
/// `main` sat exactly on the `too_many_lines` limit — adding an endpoint
|
||||
/// tripped a lint about the startup sequence, which is not where the change
|
||||
/// was. A new route now costs one line here and none there.
|
||||
pub(crate) fn build_app(state: AppState) -> axum::Router {
|
||||
let (router, api) =
|
||||
linked_account_routes(OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi()))
|
||||
.routes(routes!(routes::health))
|
||||
.routes(routes!(alerts::get_alerts))
|
||||
.routes(routes!(hives::get_hives))
|
||||
.routes(routes!(hives::get_hives_status))
|
||||
.routes(routes!(hives::get_links))
|
||||
.routes(routes!(hives::get_swarm_info))
|
||||
.routes(routes!(jobq_graph::get_jobq_graph))
|
||||
.routes(routes!(jobq_graph::get_jobq_rollup))
|
||||
.routes(routes!(config_prs::get_agent_config_pr))
|
||||
.routes(routes!(agents::get_agent_icon))
|
||||
.routes(routes!(agents_md::get_agents_md, agents_md::put_agents_md))
|
||||
.routes(routes!(
|
||||
inference::get_agent_inference,
|
||||
inference::put_agent_inference,
|
||||
inference::delete_agent_inference
|
||||
))
|
||||
.routes(routes!(
|
||||
agent_limits::get_agent_limits,
|
||||
agent_limits::put_agent_limits,
|
||||
agent_limits::delete_agent_limits
|
||||
))
|
||||
.routes(routes!(
|
||||
agent_permissions::get_agent_permissions,
|
||||
agent_permissions::put_agent_permissions,
|
||||
agent_permissions::delete_agent_permissions
|
||||
))
|
||||
.routes(routes!(agent_permissions::get_permission_catalogue))
|
||||
.routes(routes!(archetypes::get_archetypes))
|
||||
.routes(routes!(config_prs::get_config_prs))
|
||||
.routes(routes!(agents::create_agent))
|
||||
.routes(routes!(forge_admin::mint_agent_forge_token))
|
||||
.routes(routes!(forge_admin::make_forge_admin))
|
||||
.routes(routes!(agents::get_agents))
|
||||
.routes(routes!(agents::get_agents_status))
|
||||
.routes(routes!(agents::set_agent_state))
|
||||
.routes(routes!(agents::get_hive_wanted))
|
||||
.routes(routes!(term_stream::stream_agent_term))
|
||||
.routes(routes!(message_stream::stream_messages))
|
||||
.routes(routes!(subagent_term::list_subagents))
|
||||
.routes(routes!(subagent_term::stream_subagent_term))
|
||||
.routes(routes!(agent_state_stream::stream_agent_state))
|
||||
.routes(routes!(agent_input::post_agent_input))
|
||||
.routes(routes!(agent_input::post_agents_broadcast))
|
||||
.routes(routes!(agent_control::post_agent_cancel))
|
||||
.routes(routes!(
|
||||
claude_login::post_claude_login,
|
||||
claude_login::delete_claude_login
|
||||
))
|
||||
.routes(routes!(claude_login::post_claude_login_code))
|
||||
.routes(routes!(schedules::list_schedules, schedules::add_schedule))
|
||||
.routes(routes!(
|
||||
schedules::edit_schedule,
|
||||
schedules::remove_schedule
|
||||
))
|
||||
.routes(routes!(issue_report::get_repos))
|
||||
.routes(routes!(issue_report::get_issue_report_all))
|
||||
.routes(routes!(issue_report::get_issue_report))
|
||||
.routes(routes!(pulls::get_pull_repos))
|
||||
.routes(routes!(pulls::get_pulls))
|
||||
.routes(routes!(review_classifier::post_try_out))
|
||||
.routes(routes!(review_classifier::get_try_out))
|
||||
.routes(routes!(review_classifier::get_rules))
|
||||
.routes(routes!(review_classifier::open_pr::post_open_pr))
|
||||
.routes(routes!(review_classifier::runs::get_runs))
|
||||
.routes(routes!(builder::runs::get_runs))
|
||||
.routes(routes!(builder::runs::get_run))
|
||||
.routes(routes!(
|
||||
review_classifier::settings::get_settings,
|
||||
review_classifier::settings::put_settings
|
||||
))
|
||||
.routes(routes!(notify_flows::get_flows, notify_flows::put_flows))
|
||||
.routes(routes!(notify_router_api::get_deliveries))
|
||||
.routes(routes!(
|
||||
notify_router_api::get_dry_run,
|
||||
notify_router_api::post_dry_run
|
||||
))
|
||||
.routes(routes!(webhook::post_webhook_forge))
|
||||
.split_for_parts();
|
||||
// Just the JSON, not the UI — Swagger UI itself is nginx-hosted from
|
||||
// the nix store (see the module doc comment above). `api` is
|
||||
// `Clone`; each request gets its own owned copy for `Json` to
|
||||
|
|
|
|||
Loading…
Reference in a new issue