derive per-agent egress entries from operator-linked accounts
This commit is contained in:
parent
7eb9db47d6
commit
9193f37963
1 changed files with 852 additions and 38 deletions
|
|
@ -19,16 +19,18 @@
|
|||
//! environment, so it is never derived either.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use problem_details::ProblemDetails;
|
||||
use swarm_secret_client::backend_env::{self, BackendEnv};
|
||||
use swarm_wire::egress_allow::{AllowList, Entry, MAX_ENTRIES};
|
||||
use swarm_wire::egress_allow::{AllowList, BACKEND_ENV_SOURCE, Entry, MAX_ENTRIES};
|
||||
|
||||
use crate::egress_allow::{base_domains, checked, modify};
|
||||
use crate::linked_accounts::AccountStore;
|
||||
use crate::routes::{AppState, error_problem, wanted_error_status};
|
||||
use crate::schedules::queue;
|
||||
use crate::wanted::WantedWriter;
|
||||
|
||||
/// `ANTHROPIC_BASE_URL` and every variable named `<PROVIDER>_BASE_URL`, which
|
||||
/// is where a provider's address sits for an ACP agent that loads the same
|
||||
|
|
@ -71,7 +73,7 @@ fn unquoted(value: &str) -> &str {
|
|||
/// The host of `url`, as written: its scheme (`http` or `https`, or none),
|
||||
/// user information, port, path, query and fragment are dropped. Nothing is
|
||||
/// validated; the result goes through `egress_allow::checked`.
|
||||
fn host_of(url: &str) -> Option<&str> {
|
||||
pub(crate) fn host_of(url: &str) -> Option<&str> {
|
||||
let rest = match url.split_once("://") {
|
||||
Some(("http" | "https", rest)) => rest,
|
||||
Some(_) => return None,
|
||||
|
|
@ -117,6 +119,7 @@ fn derive_named<'a>(agent: &str, env: &'a str, base: &[String]) -> Vec<(&'a str,
|
|||
name,
|
||||
Entry {
|
||||
derived: true,
|
||||
source: Some(BACKEND_ENV_SOURCE.to_owned()),
|
||||
..entry
|
||||
},
|
||||
)),
|
||||
|
|
@ -128,12 +131,14 @@ fn derive_named<'a>(agent: &str, env: &'a str, base: &[String]) -> Vec<(&'a str,
|
|||
entries
|
||||
}
|
||||
|
||||
/// `list` with, for each agent in `desired`, its derived entries replaced by
|
||||
/// the given ones. Entries of other agents and operator-added entries are
|
||||
/// `list` with, for each agent in `desired`, its entries derived from the backend
|
||||
/// environment replaced by the given ones. Entries of other agents,
|
||||
/// operator-added entries and entries derived from linked accounts are
|
||||
/// untouched. A full list takes no more derived entries.
|
||||
fn replaced(mut list: AllowList, desired: &BTreeMap<String, Vec<Entry>>) -> AllowList {
|
||||
list.entries
|
||||
.retain(|e| !(e.derived && e.agent.as_ref().is_some_and(|a| desired.contains_key(a))));
|
||||
list.entries.retain(|e| {
|
||||
!(e.from_backend_env() && e.agent.as_ref().is_some_and(|a| desired.contains_key(a)))
|
||||
});
|
||||
for entry in desired.values().flatten() {
|
||||
if list.entries.len() >= MAX_ENTRIES {
|
||||
tracing::warn!(
|
||||
|
|
@ -190,15 +195,15 @@ pub(crate) async fn follow_env(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// `list` without the derived entries of the agents in `still_named` whose host
|
||||
/// is not in that agent's set. Nothing is added; entries of agents not in the
|
||||
/// map and operator-added entries are untouched.
|
||||
/// `list` without the backend-env entries of the agents in `still_named` whose
|
||||
/// host is not in that agent's set. Nothing is added; entries of agents not in
|
||||
/// the map, operator-added entries and link-derived entries are untouched.
|
||||
fn pruned(mut list: AllowList, still_named: &BTreeMap<String, BTreeSet<String>>) -> AllowList {
|
||||
list.entries.retain(|e| {
|
||||
let Some(hosts) = e.agent.as_ref().and_then(|a| still_named.get(a)) else {
|
||||
return true;
|
||||
};
|
||||
!e.derived || hosts.contains(&e.domain)
|
||||
!e.from_backend_env() || hosts.contains(&e.domain)
|
||||
});
|
||||
list
|
||||
}
|
||||
|
|
@ -223,7 +228,7 @@ pub(crate) async fn reconcile(
|
|||
return Ok(0);
|
||||
};
|
||||
let mut derived: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
|
||||
for entry in current.entries.iter().filter(|e| e.derived) {
|
||||
for entry in current.entries.iter().filter(|e| e.from_backend_env()) {
|
||||
if let Some(agent) = &entry.agent {
|
||||
derived
|
||||
.entry(agent.clone())
|
||||
|
|
@ -273,16 +278,24 @@ pub(crate) async fn reconcile(
|
|||
Ok(unreadable)
|
||||
}
|
||||
|
||||
/// Run [`reconcile`] once the queue and the store answer, retrying with a
|
||||
/// growing wait until a pass reads every agent that has an entry.
|
||||
pub(crate) fn spawn_backfill(client: async_nats::Client) {
|
||||
/// Run [`reconcile`] and `link_egress::reconcile` once the queue and the store
|
||||
/// answer, retrying with a growing wait until a pass reads every agent that
|
||||
/// has an entry and some hive has declared an agent.
|
||||
pub(crate) fn spawn_backfill(
|
||||
client: async_nats::Client,
|
||||
wanted: Arc<WantedWriter>,
|
||||
hives: Vec<String>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut wait = Duration::from_secs(5);
|
||||
loop {
|
||||
let pass = async {
|
||||
swarm_queue_client::ensure_connected(&client)?;
|
||||
let store = crate::store::connect().await?;
|
||||
reconcile(&client, &store).await
|
||||
let live = crate::subagent_term::live_agents(&wanted, &hives).await;
|
||||
let unreadable = reconcile(&client, &store).await?
|
||||
+ crate::link_egress::reconcile(&client, &store, &live).await?;
|
||||
Ok::<_, anyhow::Error>(unreadable + usize::from(live.is_empty()))
|
||||
}
|
||||
.await;
|
||||
match pass {
|
||||
|
|
@ -395,6 +408,7 @@ mod tests {
|
|||
domain: "llm.example.com".to_owned(),
|
||||
agent: Some("atlas".to_owned()),
|
||||
derived: true,
|
||||
source: Some(BACKEND_ENV_SOURCE.to_owned()),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
|
@ -404,12 +418,14 @@ mod tests {
|
|||
domain: domain.to_owned(),
|
||||
agent: Some(agent.to_owned()),
|
||||
derived: false,
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn derived(domain: &str, agent: &str) -> Entry {
|
||||
Entry {
|
||||
derived: true,
|
||||
source: Some(BACKEND_ENV_SOURCE.to_owned()),
|
||||
..manual(domain, agent)
|
||||
}
|
||||
}
|
||||
|
|
@ -440,6 +456,68 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
fn linked(domain: &str, agent: &str, source: &str) -> Entry {
|
||||
Entry {
|
||||
derived: true,
|
||||
source: Some(source.to_owned()),
|
||||
..manual(domain, agent)
|
||||
}
|
||||
}
|
||||
|
||||
/// A derived entry written before sources existed.
|
||||
fn untagged(domain: &str, agent: &str) -> Entry {
|
||||
Entry {
|
||||
source: None,
|
||||
..derived(domain, agent)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backend_env_save_leaves_link_entries_and_replaces_untagged_ones() {
|
||||
let list = AllowList {
|
||||
entries: vec![
|
||||
linked("github.com", "atlas", "link:github"),
|
||||
linked("hs.example.org", "atlas", "link:matrix:work"),
|
||||
untagged("old.example.com", "atlas"),
|
||||
],
|
||||
};
|
||||
let desired = BTreeMap::from([(
|
||||
"atlas".to_owned(),
|
||||
vec![derived("new.example.com", "atlas")],
|
||||
)]);
|
||||
let after = replaced(list, &desired);
|
||||
assert_eq!(
|
||||
after.entries,
|
||||
[
|
||||
linked("github.com", "atlas", "link:github"),
|
||||
linked("hs.example.org", "atlas", "link:matrix:work"),
|
||||
derived("new.example.com", "atlas"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pruning_the_backend_env_leaves_link_entries_and_drops_untagged_ones() {
|
||||
let list = AllowList {
|
||||
entries: vec![
|
||||
linked("github.com", "atlas", "link:github"),
|
||||
untagged("old.example.com", "atlas"),
|
||||
derived("kept.example.com", "atlas"),
|
||||
],
|
||||
};
|
||||
let still_named = BTreeMap::from([(
|
||||
"atlas".to_owned(),
|
||||
BTreeSet::from(["kept.example.com".to_owned()]),
|
||||
)]);
|
||||
assert_eq!(
|
||||
pruned(list, &still_named).entries,
|
||||
[
|
||||
linked("github.com", "atlas", "link:github"),
|
||||
derived("kept.example.com", "atlas"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The state of a test swarm whose `pr1ma` declares `atlas`, with `store`
|
||||
/// as the secret store.
|
||||
async fn swarm(client: &async_nats::Client, store: &Arc<FakeStore>) -> AppState {
|
||||
|
|
|
|||
Loading…
Reference in a new issue