638 lines
23 KiB
Rust
638 lines
23 KiB
Rust
//! 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.
|
|
//!
|
|
//! 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.
|
|
//!
|
|
//! The controller alone writes the three 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.
|
|
//! [`added`] and [`removed`] follow the operator's link and unlink, and
|
|
//! [`reconcile`] makes the entries match the leaves at start: it adds the
|
|
//! entries of accounts linked before this existed and drops those of accounts
|
|
//! whose leaf is gone, and an agent whose leaves cannot be read keeps what it
|
|
//! has.
|
|
//!
|
|
//! The `homeserver` and `url` of an account go through
|
|
//! [`crate::backend_egress::host_of`] and `egress_allow::checked`, the check
|
|
//! an operator's own entry gets. An agent's `matrixAccounts.*.homeserver` in
|
|
//! its own configuration is not read: the controller never sees it.
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
use problem_details::ProblemDetails;
|
|
use swarm_wire::egress_allow::{AllowList, Entry, LINK_SOURCE_PREFIX, MAX_ENTRIES};
|
|
|
|
use crate::backend_egress::host_of;
|
|
use crate::egress_allow::{base_domains, checked, current, modify};
|
|
use crate::linked_accounts::{AccountKind, AccountStore, linked_accounts};
|
|
use crate::routes::{AppState, error_problem, wanted_error_status};
|
|
use crate::schedules::queue;
|
|
|
|
/// What the GitHub token is used against: `gh`'s API, git over HTTPS, and the
|
|
/// raw file, archive and release-asset downloads.
|
|
const GITHUB_HOSTS: [&str; 5] = [
|
|
"github.com",
|
|
"api.github.com",
|
|
"raw.githubusercontent.com",
|
|
"codeload.github.com",
|
|
"objects.githubusercontent.com",
|
|
];
|
|
|
|
/// An account the operator links to an agent.
|
|
pub(crate) enum Link<'a> {
|
|
Github,
|
|
Matrix {
|
|
account: &'a str,
|
|
homeserver: Option<&'a str>,
|
|
},
|
|
Forge {
|
|
label: &'a str,
|
|
url: &'a str,
|
|
},
|
|
}
|
|
|
|
pub(crate) fn github_source() -> String {
|
|
format!("{LINK_SOURCE_PREFIX}github")
|
|
}
|
|
|
|
pub(crate) fn matrix_source(account: &str) -> String {
|
|
format!("{LINK_SOURCE_PREFIX}matrix:{account}")
|
|
}
|
|
|
|
pub(crate) fn forge_source(label: &str) -> String {
|
|
format!("{LINK_SOURCE_PREFIX}forge:{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),
|
|
}
|
|
}
|
|
|
|
/// The entries for `agent`: one per host that passes
|
|
/// `egress_allow::checked`. A host that does not (an IP address,
|
|
/// `localhost`, a name the base list already allows) is logged and left
|
|
/// out. The URL is never logged: it can carry credentials.
|
|
fn entries(&self, agent: &str, base: &[String]) -> Vec<Entry> {
|
|
let source = self.source();
|
|
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)],
|
|
};
|
|
let mut entries = Vec::new();
|
|
for host in hosts {
|
|
let Some(host) = host else {
|
|
tracing::warn!(%agent, %source, "link egress: not an http(s) URL; no entry");
|
|
continue;
|
|
};
|
|
let request = swarm_controller_api::egress::AllowedDomain {
|
|
domain: host.to_owned(),
|
|
agent: Some(agent.to_owned()),
|
|
derived: false,
|
|
};
|
|
match checked(&request, base) {
|
|
Ok(entry) => entries.push(Entry {
|
|
derived: true,
|
|
source: Some(source.clone()),
|
|
..entry
|
|
}),
|
|
Err(why) => {
|
|
tracing::warn!(%agent, %source, %why, "link egress: no entry for this host");
|
|
}
|
|
}
|
|
}
|
|
entries
|
|
}
|
|
}
|
|
|
|
/// `list` without the entries `stale` names, plus `add`. A full list takes no
|
|
/// more entries.
|
|
fn replaced(mut list: AllowList, stale: impl Fn(&Entry) -> bool, add: &[Entry]) -> AllowList {
|
|
list.entries.retain(|e| !stale(e));
|
|
for entry in add {
|
|
if list.entries.len() >= MAX_ENTRIES {
|
|
tracing::warn!(
|
|
domain = %entry.domain,
|
|
"link egress: the allow list is full; no entry for this host"
|
|
);
|
|
break;
|
|
}
|
|
list.entries.push(entry.clone());
|
|
}
|
|
list
|
|
}
|
|
|
|
/// Whether `e` is `agent`'s entry derived from exactly `source`.
|
|
fn is_of(e: &Entry, agent: &str, source: &str) -> bool {
|
|
e.derived && e.agent.as_deref() == Some(agent) && e.source.as_deref() == Some(source)
|
|
}
|
|
|
|
/// Replace `agent`'s entries of `source` with `entries`. A host without a
|
|
/// queue (`state.status` unset) has no allow list, so nothing happens.
|
|
async fn follow(
|
|
state: &AppState,
|
|
agent: &str,
|
|
source: &str,
|
|
entries: Vec<Entry>,
|
|
stored: &str,
|
|
) -> Result<(), ProblemDetails> {
|
|
if state.status.is_none() {
|
|
return Ok(());
|
|
}
|
|
let updated = async {
|
|
let client = queue(state).map_err(|(_, why)| anyhow::anyhow!(why))?;
|
|
modify(&client, |list| {
|
|
replaced(list, |e| is_of(e, agent, source), &entries)
|
|
})
|
|
.await
|
|
}
|
|
.await;
|
|
match updated {
|
|
Ok(true) => tracing::info!(%agent, %source, "link egress: derived entries updated"),
|
|
Ok(false) => {}
|
|
Err(e) => {
|
|
tracing::warn!(%agent, %source, error = %format!("{e:#}"), "link egress: updating the allow list failed");
|
|
return Err(error_problem(
|
|
wanted_error_status(&e),
|
|
&format!(
|
|
"the account is {stored}, but the allowed domains were not updated \
|
|
({e:#}); the controller corrects them when it next starts"
|
|
),
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Give `agent` the entries of the account `link` just stored.
|
|
///
|
|
/// # Errors
|
|
/// A problem naming that the account is linked but the list was not updated.
|
|
pub(crate) async fn added(
|
|
state: &AppState,
|
|
agent: &str,
|
|
link: &Link<'_>,
|
|
) -> Result<(), ProblemDetails> {
|
|
let entries = link.entries(agent, &base_domains());
|
|
follow(state, agent, &link.source(), entries, "linked").await
|
|
}
|
|
|
|
/// Take from `agent` the entries of the account stored at `source`, which was
|
|
/// just deleted.
|
|
///
|
|
/// # Errors
|
|
/// A problem naming that the account is deleted but the list was not updated.
|
|
pub(crate) async fn removed(
|
|
state: &AppState,
|
|
agent: &str,
|
|
source: &str,
|
|
) -> Result<(), ProblemDetails> {
|
|
follow(state, agent, source, Vec::new(), "deleted").await
|
|
}
|
|
|
|
/// The entries the accounts stored for `agent` call for.
|
|
async fn wanted_entries(
|
|
store: &impl AccountStore,
|
|
agent: &str,
|
|
base: &[String],
|
|
) -> Result<Vec<Entry>, swarm_secret_client::Error> {
|
|
let mut entries = Vec::new();
|
|
for account in linked_accounts(store, agent).await? {
|
|
let link = match account.kind {
|
|
AccountKind::Github => Link::Github,
|
|
AccountKind::Matrix if !account.reserved => Link::Matrix {
|
|
account: &account.name,
|
|
homeserver: account.host.as_deref(),
|
|
},
|
|
AccountKind::Forgejo => match account.host.as_deref() {
|
|
Some(url) => Link::Forge {
|
|
label: &account.name,
|
|
url,
|
|
},
|
|
None => continue,
|
|
},
|
|
AccountKind::Matrix | AccountKind::Backend => continue,
|
|
};
|
|
entries.extend(link.entries(agent, base));
|
|
}
|
|
Ok(entries)
|
|
}
|
|
|
|
/// `list` with, for each agent in `desired`, its link-derived entries replaced
|
|
/// by the given ones. Entries of other agents, operator-added entries and
|
|
/// entries derived from the backend environment are untouched.
|
|
fn synced(list: AllowList, desired: &BTreeMap<String, Vec<Entry>>) -> AllowList {
|
|
let add: Vec<Entry> = desired.values().flatten().cloned().collect();
|
|
replaced(
|
|
list,
|
|
|e| e.from_link() && e.agent.as_ref().is_some_and(|a| desired.contains_key(a)),
|
|
&add,
|
|
)
|
|
}
|
|
|
|
/// Make the link-derived entries match the stored accounts of every agent in
|
|
/// `live` and of every agent that has one: add what a stored account calls for,
|
|
/// drop what no stored account does. Logs the counts.
|
|
///
|
|
/// An agent whose accounts cannot be read keeps its entries and is counted in
|
|
/// the answer, which is how many agents that was.
|
|
///
|
|
/// # Errors
|
|
/// A queue failure reading or writing the list.
|
|
pub(crate) async fn reconcile(
|
|
client: &async_nats::Client,
|
|
store: &impl AccountStore,
|
|
live: &BTreeSet<String>,
|
|
) -> anyhow::Result<usize> {
|
|
let before = current(client).await?.unwrap_or_default();
|
|
let mut agents = live.clone();
|
|
agents.extend(
|
|
before
|
|
.entries
|
|
.iter()
|
|
.filter(|e| e.from_link())
|
|
.filter_map(|e| e.agent.clone()),
|
|
);
|
|
let base = base_domains();
|
|
let mut desired = BTreeMap::new();
|
|
let mut unreadable = 0;
|
|
for agent in agents {
|
|
match wanted_entries(store, &agent, &base).await {
|
|
Ok(entries) => {
|
|
desired.insert(agent, entries);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(%agent, error = %e, "link egress: reading the linked accounts failed; its entries stay");
|
|
unreadable += 1;
|
|
}
|
|
}
|
|
}
|
|
let had: BTreeSet<&Entry> = before.entries.iter().filter(|e| e.from_link()).collect();
|
|
let after = synced(before.clone(), &desired);
|
|
let has: BTreeSet<&Entry> = after.entries.iter().filter(|e| e.from_link()).collect();
|
|
let (added, removed) = (has.difference(&had).count(), had.difference(&has).count());
|
|
modify(client, |list| synced(list, &desired)).await?;
|
|
tracing::info!(
|
|
agents = desired.len(),
|
|
unreadable,
|
|
added,
|
|
removed,
|
|
"link egress: entries match the linked accounts"
|
|
);
|
|
Ok(unreadable)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use axum::extract::{Path, State};
|
|
use serde_json::json;
|
|
|
|
use super::*;
|
|
use crate::agent_limits::tests::declared;
|
|
use crate::linked_accounts::tests::FakeStore;
|
|
use crate::test_nats::TestNats;
|
|
|
|
fn base() -> Vec<String> {
|
|
vec!["api.anthropic.com".to_owned()]
|
|
}
|
|
|
|
fn hosts(link: &Link<'_>) -> Vec<String> {
|
|
link.entries("atlas", &base())
|
|
.into_iter()
|
|
.map(|e| e.domain)
|
|
.collect()
|
|
}
|
|
|
|
fn entry(domain: &str, agent: &str, source: Option<&str>) -> Entry {
|
|
Entry {
|
|
domain: domain.to_owned(),
|
|
agent: Some(agent.to_owned()),
|
|
derived: source.is_some(),
|
|
source: source.map(str::to_owned),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn each_kind_yields_the_hosts_its_service_is_reached_at() {
|
|
let github = Link::Github.entries("atlas", &base());
|
|
assert_eq!(
|
|
github,
|
|
[
|
|
entry("github.com", "atlas", Some("link:github")),
|
|
entry("api.github.com", "atlas", Some("link:github")),
|
|
entry("raw.githubusercontent.com", "atlas", Some("link:github")),
|
|
entry("codeload.github.com", "atlas", Some("link:github")),
|
|
entry(
|
|
"objects.githubusercontent.com",
|
|
"atlas",
|
|
Some("link:github")
|
|
),
|
|
]
|
|
);
|
|
// A port, a path, a trailing slash and a scheme-less value all reduce
|
|
// to the bare host.
|
|
for homeserver in [
|
|
"https://Matrix.Example.org:8448/_matrix/",
|
|
"https://matrix.example.org/",
|
|
"matrix.example.org",
|
|
] {
|
|
let link = Link::Matrix {
|
|
account: "work",
|
|
homeserver: Some(homeserver),
|
|
};
|
|
assert_eq!(hosts(&link), ["matrix.example.org"], "{homeserver}");
|
|
assert_eq!(
|
|
link.entries("atlas", &base()),
|
|
[entry(
|
|
"matrix.example.org",
|
|
"atlas",
|
|
Some("link:matrix:work")
|
|
)]
|
|
);
|
|
}
|
|
let forge = Link::Forge {
|
|
label: "oss",
|
|
url: "https://forge.example.org:3000/sub",
|
|
};
|
|
assert_eq!(
|
|
forge.entries("atlas", &base()),
|
|
[entry("forge.example.org", "atlas", Some("link:forge:oss"))]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_host_the_swarm_would_refuse_gets_no_entry() {
|
|
for url in [
|
|
"http://10.0.0.5:3000",
|
|
"https://localhost",
|
|
"ftp://forge.example.org",
|
|
"https://",
|
|
"https://*.example.org",
|
|
// The base list already allows it.
|
|
"https://api.anthropic.com",
|
|
] {
|
|
let forge = Link::Forge { label: "x", url };
|
|
assert_eq!(hosts(&forge), Vec::<String>::new(), "{url:?}");
|
|
}
|
|
// Controls: a public name gets one, and a matrix account with no
|
|
// homeserver has none to derive.
|
|
let forge = Link::Forge {
|
|
label: "x",
|
|
url: "https://forge.example.org",
|
|
};
|
|
assert_eq!(hosts(&forge), ["forge.example.org"]);
|
|
let matrix = Link::Matrix {
|
|
account: "work",
|
|
homeserver: None,
|
|
};
|
|
assert_eq!(hosts(&matrix), Vec::<String>::new());
|
|
}
|
|
|
|
#[test]
|
|
fn unlinking_removes_only_that_sources_entries() {
|
|
let list = AllowList {
|
|
entries: vec![
|
|
entry("github.com", "atlas", Some("link:github")),
|
|
entry("github.com", "iris", Some("link:github")),
|
|
entry("forge.example.org", "atlas", Some("link:forge:oss")),
|
|
entry("forge.example.org", "atlas", Some("link:forge:work")),
|
|
entry("llm.example.com", "atlas", Some("backend-env")),
|
|
entry("old.example.com", "atlas", Some("backend-env")),
|
|
entry("github.com", "atlas", None),
|
|
],
|
|
};
|
|
let after = replaced(
|
|
list.clone(),
|
|
|e| is_of(e, "atlas", &forge_source("oss")),
|
|
&[],
|
|
);
|
|
assert_eq!(after.entries.len(), list.entries.len() - 1);
|
|
assert!(!after.entries.contains(&entry(
|
|
"forge.example.org",
|
|
"atlas",
|
|
Some("link:forge:oss")
|
|
)));
|
|
// Control: the same lookup finds the entry it removed.
|
|
assert!(
|
|
list.entries
|
|
.iter()
|
|
.any(|e| is_of(e, "atlas", "link:forge:oss"))
|
|
);
|
|
// The manual entry for the same name as a derived one stays.
|
|
let after = replaced(list, |e| is_of(e, "atlas", &github_source()), &[]);
|
|
assert!(after.entries.contains(&entry("github.com", "atlas", None)));
|
|
assert!(
|
|
!after
|
|
.entries
|
|
.contains(&entry("github.com", "atlas", Some("link:github")))
|
|
);
|
|
}
|
|
|
|
/// 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 {
|
|
AppState {
|
|
accounts: crate::linked_accounts::Accounts::Fake(Arc::clone(store)),
|
|
..declared(client).await
|
|
}
|
|
}
|
|
|
|
async fn list_of(client: &async_nats::Client) -> Vec<Entry> {
|
|
let mut entries = current(client)
|
|
.await
|
|
.expect("the queue answers")
|
|
.map(|l| l.entries)
|
|
.unwrap_or_default();
|
|
entries.sort();
|
|
entries
|
|
}
|
|
|
|
fn sorted(mut entries: Vec<Entry>) -> Vec<Entry> {
|
|
entries.sort();
|
|
entries
|
|
}
|
|
|
|
fn github_entries(agent: &str) -> Vec<Entry> {
|
|
GITHUB_HOSTS
|
|
.iter()
|
|
.map(|h| entry(h, agent, Some("link:github")))
|
|
.collect()
|
|
}
|
|
|
|
fn path(parts: &str) -> Path<(String, String)> {
|
|
Path(("pr1ma".to_owned(), parts.to_owned()))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn linking_and_unlinking_through_the_routes_moves_the_entries() {
|
|
let nats = TestNats::start();
|
|
let client = nats.client().await;
|
|
let store = Arc::default();
|
|
let state = swarm(&client, &store).await;
|
|
|
|
let _ = crate::github_account::put_github_account(
|
|
State(state.clone()),
|
|
path("atlas"),
|
|
axum::Json(serde_json::from_value(json!({"token": "t"})).expect("a body")),
|
|
)
|
|
.await
|
|
.expect("linked");
|
|
let _ = crate::forge_account::put_forge_account(
|
|
State(state.clone()),
|
|
Path(("pr1ma".to_owned(), "atlas".to_owned(), "oss".to_owned())),
|
|
axum::Json(
|
|
serde_json::from_value(
|
|
json!({"url": "https://forge.example.org:3000/", "token": "t"}),
|
|
)
|
|
.expect("a body"),
|
|
),
|
|
)
|
|
.await
|
|
.expect("linked");
|
|
let _ = crate::matrix_account::put_matrix_account(
|
|
State(state.clone()),
|
|
Path(("pr1ma".to_owned(), "atlas".to_owned(), "work".to_owned())),
|
|
axum::Json(
|
|
serde_json::from_value(json!({
|
|
"mode": "token", "token": "t", "homeserver": "https://matrix.example.org/"
|
|
}))
|
|
.expect("a body"),
|
|
),
|
|
)
|
|
.await
|
|
.expect("linked");
|
|
let forge = entry("forge.example.org", "atlas", Some("link:forge:oss"));
|
|
let matrix = entry("matrix.example.org", "atlas", Some("link:matrix:work"));
|
|
let mut all = github_entries("atlas");
|
|
all.extend([forge.clone(), matrix.clone()]);
|
|
assert_eq!(list_of(&client).await, sorted(all));
|
|
|
|
// The backend environment's own save and delete leave them alone.
|
|
crate::backend_env::put_backend_env(
|
|
State(state.clone()),
|
|
path("atlas"),
|
|
axum::Json(
|
|
serde_json::from_value(
|
|
json!({"env": "ANTHROPIC_BASE_URL=https://llm.example.com\n"}),
|
|
)
|
|
.expect("a body"),
|
|
),
|
|
)
|
|
.await
|
|
.expect("stored");
|
|
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());
|
|
|
|
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()])
|
|
);
|
|
crate::linked_accounts::delete_forge_account(
|
|
State(state.clone()),
|
|
Path(("pr1ma".to_owned(), "atlas".to_owned(), "oss".to_owned())),
|
|
)
|
|
.await
|
|
.expect("deleted");
|
|
assert_eq!(list_of(&client).await, [matrix]);
|
|
crate::linked_accounts::delete_matrix_account(
|
|
State(state.clone()),
|
|
Path(("pr1ma".to_owned(), "atlas".to_owned(), "work".to_owned())),
|
|
axum::extract::Query(serde_json::from_value(json!({})).expect("a query")),
|
|
)
|
|
.await
|
|
.expect("deleted");
|
|
assert_eq!(list_of(&client).await, []);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_backfill_adds_entries_for_existing_links_and_prunes_orphans() {
|
|
let nats = TestNats::start();
|
|
let client = nats.client().await;
|
|
let store =
|
|
FakeStore::default().with("swarm/agents/atlas/github-token", json!({"value": "t"}));
|
|
let store = store.with(
|
|
"swarm/agents/atlas/matrix/work",
|
|
json!({"value": "t", "homeserver": "https://matrix.example.org"}),
|
|
);
|
|
// The agent's own account has a homeserver of its own and no entry.
|
|
let store = store.with(
|
|
"swarm/agents/atlas/matrix/main",
|
|
json!({"value": "t", "homeserver": "https://hive.example.org"}),
|
|
);
|
|
modify(&client, |mut list| {
|
|
list.entries.extend([
|
|
// A link from before the host list grew: the pass adds the rest.
|
|
entry("github.com", "atlas", Some("link:github")),
|
|
// 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("forge.example.org", "atlas", Some("link:forge:old")),
|
|
entry("llm.example.com", "atlas", Some("backend-env")),
|
|
entry("crates.io", "atlas", None),
|
|
]);
|
|
list
|
|
})
|
|
.await
|
|
.expect("seed");
|
|
|
|
let live = BTreeSet::from(["atlas".to_owned()]);
|
|
assert_eq!(reconcile(&client, &store, &live).await.expect("pass"), 0);
|
|
let mut want = github_entries("atlas");
|
|
want.extend([
|
|
entry("matrix.example.org", "atlas", Some("link:matrix:work")),
|
|
entry("llm.example.com", "atlas", Some("backend-env")),
|
|
entry("crates.io", "atlas", None),
|
|
]);
|
|
assert_eq!(list_of(&client).await, sorted(want.clone()));
|
|
|
|
// A second pass changes nothing.
|
|
reconcile(&client, &store, &live).await.expect("again");
|
|
assert_eq!(list_of(&client).await, sorted(want));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_store_error_prunes_nothing_and_adds_nothing() {
|
|
let nats = TestNats::start();
|
|
let client = nats.client().await;
|
|
let seeded = vec![
|
|
entry("github.com", "iris", Some("link:github")),
|
|
entry("forge.example.org", "atlas", Some("link:forge:old")),
|
|
];
|
|
modify(&client, |mut list| {
|
|
list.entries.extend(seeded.clone());
|
|
list
|
|
})
|
|
.await
|
|
.expect("seed");
|
|
|
|
let live = BTreeSet::from(["atlas".to_owned()]);
|
|
let unreadable = reconcile(&client, &FakeStore::denying(), &live)
|
|
.await
|
|
.expect("pass");
|
|
assert_eq!(unreadable, 2, "atlas and iris");
|
|
assert_eq!(list_of(&client).await, sorted(seeded.clone()));
|
|
|
|
// Control: a store that answers, and holds nothing, prunes both.
|
|
reconcile(&client, &FakeStore::default(), &live)
|
|
.await
|
|
.expect("pass");
|
|
assert_eq!(list_of(&client).await, []);
|
|
}
|
|
}
|