An agent's store identity was signed in swarm-controller's memory by a CA a controller-host unit generated on disk, and the listener never trusted that CA. Agent leaves now come from the store itself: a `pki-agents` PKI mount whose root openbao generates internally, so the agent CA's key never exists outside the store. - swarm-bao-agent-pki (new, store host, as the bao granter): enables and tunes the mount, generates the root once (guarded on an empty issuer list, no replace branch), upserts the `swarm-agent` role (client certificates named `hive-agent-*` only, 90 days), caches the CA at /var/lib/swarm-bao-tls/agent-ca.pem and composes the listener bundle. - The listener's tls_client_ca_file is a new listener-client-ca.pem (client-ca.pem, then the agent CA). Host cert-auth roles still pin client-ca.pem, so an agent leaf satisfies no host role. swarm-bao-certs composes the same bundle before openbao starts. - openbao reads tls_client_ca_file only at start, so when the bundle changed after openbao started, swarm-bao-agent-pki restarts openbao.service in the container; under `seal = "shamir"` it prints the step instead. Once swarm-bao-certs has a cached CA, later boots start openbao with it and do not restart. - The controller policy gains exactly `update` on pki-agents/issue/swarm-agent. mint_and_verify now asks that role for the leaf (the store generates the key), writes the agent's cert-auth role pinning the issuing CA bao returned, and writes the agent's policy as render_agent alone: the hive-shared queue credential stanza is gone. - deploy.bao.agentPkiRoleName (must start `swarm-`, asserted with the other pki role names); swarm-controller gets SWARM_CONTROLLER_AGENT_PKI_MOUNT/_ROLE from the deploy.bao options. Deleted: swarm-controller-agent-ca and its options (agentCaFile, agentCaKeyFile), env, LoadCredential entries and assertion; agent_identity's Authority, rcgen signing and validity window; the rcgen and time dependencies of swarm-controller (rcgen leaves the workspace); policy::render_agent_with_queue and its tests. The CN-prefix assertion policy.rs said was owed is not: agent and host roles pin different CAs. Migration is re-creating each agent after deploy; that overwrites the stale role and policy. Closes #4756
526 lines
23 KiB
Rust
526 lines
23 KiB
Rust
//! The read agreement: which credentials a principal's own token may fetch.
|
|
//!
|
|
//! The mirror of [`crate::matrix`] and [`crate::queue`]. Those modules say
|
|
//! where a credential lives; this one says who is allowed to read it, and the
|
|
//! two have to agree on the same path or a delivery fails with a 403 that names
|
|
//! nothing.
|
|
//!
|
|
//! The document has one stanza per kind a hive reads, and they are not all
|
|
//! scoped alike — which is the point rather than an inconsistency:
|
|
//!
|
|
//! ⚠️ The **agent** stanza grants read on *every* agent's credentials rather
|
|
//! than on the ones that hive hosts. That is a decision, not an oversight: an
|
|
//! agent's path does not name its hive, so a per-hive grant has to be
|
|
//! enumerated and re-emitted, and an enumeration that can silently drift
|
|
//! advertises a boundary it does not hold. A wide grant that says so beats a
|
|
//! narrow one that only looks narrow. The narrower shapes, and what they would
|
|
//! cost, are in `docs/trust-boundary/security.md`.
|
|
//!
|
|
//! ⚠️ The **service** stanza is wide for the same shape of reason: a swarm
|
|
//! service's client is registered once per swarm, so its path names the service
|
|
//! and never the host, and which hive runs a service is a `deploy.*` fact with
|
|
//! no swarm-wide spelling to scope against. Cost: the same doc.
|
|
//!
|
|
//! The **hive** stanza has no such problem and is therefore narrow: that path
|
|
//! names its principal, so scoping it to the reader's own name costs nothing
|
|
//! and drifts nowhere. Do not widen it to match its neighbours — the asymmetry
|
|
//! is the point, and only holds if a one-per-hive credential is stored under
|
|
//! [`Kind::Hive`], not [`Kind::Service`] — see docs/trust-boundary/security.md.
|
|
//!
|
|
//! Rendering stays separate from writing so the text can be asserted with no store to talk to.
|
|
|
|
use crate::{
|
|
Error,
|
|
path::{Kind, MOUNT, ROOT, checked_segment},
|
|
};
|
|
|
|
/// Namespace for a hive's own policy and cert-auth role.
|
|
///
|
|
/// The controller's own grant is scoped to `hive-*` for both, so this prefix is
|
|
/// the difference between a hive the controller may provision and a policy it
|
|
/// must not be able to rewrite — including its own.
|
|
pub const HIVE_PREFIX: &str = "hive-";
|
|
|
|
/// The policy and cert-auth role name for `hive`. One name, both objects: the
|
|
/// role attaches the policy by spelling it identically.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::PathSegment`] when `hive` holds anything but `[A-Za-z0-9_-]`.
|
|
pub fn hive_object_name(hive: &str) -> Result<String, Error> {
|
|
checked_segment("hive", hive)?;
|
|
Ok(format!("{HIVE_PREFIX}{hive}"))
|
|
}
|
|
|
|
/// Namespace for an agent's own policy and cert-auth role.
|
|
///
|
|
/// Deliberately *inside* [`HIVE_PREFIX`] rather than beside it.
|
|
/// `sys/policies/acl/hive-*` and `auth/cert/certs/hive-*` are the whole of what
|
|
/// the controller's grant lets it create (`swarm-bao.nix`'s
|
|
/// `controllerPolicyText`), and the controller is the only principal that
|
|
/// learns an agent exists — a name outside that prefix is one nothing can
|
|
/// write. It costs no authority: the controller already holds `create`/`update`
|
|
/// on `secret/data/swarm/agents/*`, so it can already replace every credential
|
|
/// this policy grants read on.
|
|
///
|
|
/// It still cannot collide with a hive's. [`hive_object_name`] renders
|
|
/// `hive-<hive>`, so a collision needs a hive named `agent-<agent>` — and
|
|
/// `nix/reserved-hive-fragments.nix` forbids the substring `agent` in any hive
|
|
/// name (and `hive` too), applied to every entry of the swarm directory in
|
|
/// `swarm.nix`. That guard was written for the queue's `hive-<name>-agent`
|
|
/// client ids; this is a second identifier family leaning on it, which is why
|
|
/// the fragment list is what to read before renaming either.
|
|
///
|
|
/// Host principals' **common names** are operator-set options
|
|
/// (`deploy.bao.controllerCommonName`, `secretPublisherCommonName`, …) that
|
|
/// may spell this prefix, and that is harmless: an agent's cert-auth role pins
|
|
/// the store's agent CA (`deploy.bao.agentPkiMountPath`), which signs no host
|
|
/// leaf, and every host role pins `deploy.bao.clientCaFile`, which signs no
|
|
/// agent leaf. A CN match alone logs nobody in.
|
|
pub const AGENT_PREFIX: &str = "hive-agent-";
|
|
|
|
/// The policy and cert-auth role name for `agent`, and the common name of the
|
|
/// certificate that authenticates it. One string, three objects: the role
|
|
/// attaches the policy and matches the subject by spelling all three the same.
|
|
///
|
|
/// Injective in `agent` — the prefix is fixed and [`checked_segment`] has
|
|
/// already refused anything that could re-punctuate the suffix — and agent
|
|
/// names are one swarm-wide namespace (an agent's path is
|
|
/// `swarm/agents/<agent>`, with no hive segment to disambiguate two that
|
|
/// matched), so two agents cannot land on one name.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::PathSegment`] when `agent` holds anything but `[A-Za-z0-9_-]`.
|
|
pub fn agent_object_name(agent: &str) -> Result<String, Error> {
|
|
checked_segment("agent", agent)?;
|
|
Ok(format!("{AGENT_PREFIX}{agent}"))
|
|
}
|
|
|
|
/// The agents among `names`, a listing of cert-auth roles: the inverse of
|
|
/// [`agent_object_name`], for a caller that needs the set of agents holding a
|
|
/// store identity.
|
|
///
|
|
/// A name that carries the prefix but whose suffix is not a legal segment is
|
|
/// dropped rather than returned: [`agent_object_name`] could not have produced
|
|
/// it, so it is not an agent this crate minted.
|
|
#[must_use]
|
|
pub fn agents_from_role_names(names: &[String]) -> Vec<String> {
|
|
names
|
|
.iter()
|
|
.filter_map(|name| name.strip_prefix(AGENT_PREFIX))
|
|
.filter(|agent| checked_segment("agent", agent).is_ok())
|
|
.map(str::to_owned)
|
|
.collect()
|
|
}
|
|
|
|
/// One read stanza. The only shape this module emits, so "read-only" is a
|
|
/// property of the renderer rather than of each call site.
|
|
fn read_stanza(path: &str) -> String {
|
|
format!("path \"{path}\" {{\n capabilities = [\"read\"]\n}}\n")
|
|
}
|
|
|
|
/// Render `hive`'s policy document: read on every agent's credentials, on this
|
|
/// hive's own, and on the swarm services'.
|
|
///
|
|
/// The name is the only input, and it is deploy-time — so the document is
|
|
/// still a deploy-time object rather than derived state with a re-emission to
|
|
/// get wrong. Nothing about which agents exist changes the text.
|
|
///
|
|
/// Read-only: the controller mints these and never reads one back.
|
|
///
|
|
/// ⚠️ The controller kind is deliberately absent: a hive has no business
|
|
/// reading the credentials of the thing that provisions it. Adding it is a
|
|
/// boundary decision, not a consequence of the namespace growing.
|
|
///
|
|
/// The **service** kind is granted, and that was such a decision rather than a
|
|
/// consequence: a service whose identity provider is on another host reads its
|
|
/// own OIDC client secret with the certificate of the hive it runs on, that
|
|
/// being the only identity such a host has — so every hive can read every
|
|
/// service's. Bought and paid for in `docs/trust-boundary/security.md`.
|
|
///
|
|
/// The hive's *own* kind is granted, and that is the decision the agent-only
|
|
/// version of this grant said had to be made rather than assumed: a hive holds
|
|
/// the queue credential its own agents authenticate with, so it has to read the
|
|
/// one principal named after itself — and only that one, which is why the path
|
|
/// interpolates the name instead of widening to the whole kind.
|
|
///
|
|
/// # Errors
|
|
/// [`Error::PathSegment`] when `hive` holds anything but `[A-Za-z0-9_-]` — the
|
|
/// name is interpolated into a policy path, so a name that could close the
|
|
/// stanza could grant itself anything.
|
|
pub fn render(hive: &str) -> Result<String, Error> {
|
|
checked_segment("hive", hive)?;
|
|
let agents = read_stanza(&format!(
|
|
"{MOUNT}/data/{ROOT}/{}/*",
|
|
<&str>::from(Kind::Agent)
|
|
));
|
|
let own = read_stanza(&format!(
|
|
"{MOUNT}/data/{ROOT}/{}/{hive}/*",
|
|
<&str>::from(Kind::Hive)
|
|
));
|
|
let services = read_stanza(&format!(
|
|
"{MOUNT}/data/{ROOT}/{}/*",
|
|
<&str>::from(Kind::Service)
|
|
));
|
|
Ok(format!("{agents}{own}{services}"))
|
|
}
|
|
|
|
/// Render `agent`'s policy document: read on that one agent's credentials, and
|
|
/// on nothing else at all.
|
|
///
|
|
/// One stanza, and the single interpolated name in it is the whole document: an
|
|
/// agent authenticating with its own certificate gets a token that fetches
|
|
/// `swarm/agents/<agent>/…` and is refused every other path in the store. That
|
|
/// is the point — the reason to give an agent an identity is that it then
|
|
/// depends on its hive for one file (the certificate) rather than for every
|
|
/// credential it uses.
|
|
///
|
|
/// ⚠️ **None of [`render`]'s breadth is inherited.** A hive's document grants
|
|
/// read on `swarm/agents/*` — *every* agent's credentials, not the ones that
|
|
/// hive hosts — and on `swarm/services/*` the same way; the module header says
|
|
/// why each is a decision rather than an oversight. Neither reason transfers:
|
|
/// an agent's path names the agent, so scoping costs nothing and drifts nowhere
|
|
/// (the argument that keeps [`render`]'s hive stanza narrow), and an agent has
|
|
/// no business with a service's OIDC secret, another agent's credentials, a
|
|
/// hive's, or the controller's. Narrow here does **not** narrow the hive's: a
|
|
/// hive still reads this agent's secrets, and closing that is its own decision.
|
|
/// Read-only, for the reason the hive's is: an agent that could write its own
|
|
/// credentials could hand itself an identity it was never issued.
|
|
///
|
|
/// ⚠️ Unlike [`render`]'s, this name is not deploy-time at every layer: nothing
|
|
/// in `nix/host-modules/` knows which agents exist — hive-c0re creates them at
|
|
/// runtime into its own meta flake (`hive_c0re::meta`).
|
|
///
|
|
/// # Errors
|
|
/// [`Error::PathSegment`] when `agent` holds anything but `[A-Za-z0-9_-]` — it
|
|
/// is interpolated into a policy path, so a name that could close the stanza
|
|
/// could grant itself anything.
|
|
pub fn render_agent(agent: &str) -> Result<String, Error> {
|
|
checked_segment("agent", agent)?;
|
|
Ok(read_stanza(&format!(
|
|
"{MOUNT}/data/{ROOT}/{}/{agent}/*",
|
|
<&str>::from(Kind::Agent)
|
|
)))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_document_grants_two_whole_prefixes_and_this_hive_alone() {
|
|
assert_eq!(
|
|
render("pr1ma").expect("a plain name is legal"),
|
|
"path \"secret/data/swarm/agents/*\" {\n capabilities = [\"read\"]\n}\n\
|
|
path \"secret/data/swarm/hives/pr1ma/*\" {\n capabilities = [\"read\"]\n}\n\
|
|
path \"secret/data/swarm/services/*\" {\n capabilities = [\"read\"]\n}\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_service_stanza_covers_a_service_this_hive_was_never_named_beside() {
|
|
// The property the swarm-grafana delivery depends on: the host running a
|
|
// swarm service reads that service's client secret with its own hive
|
|
// certificate, and the path names the service rather than the host. A
|
|
// stanza narrowed to the reader's name would 403 every such read.
|
|
let p = render("pr1ma").expect("legal");
|
|
assert!(p.contains("path \"secret/data/swarm/services/*\""));
|
|
assert!(
|
|
!p.contains("services/pr1ma"),
|
|
"the service stanza is not scoped to the reader"
|
|
);
|
|
}
|
|
|
|
/// 🩸 The per-hive matrix sender-token scoping, asserted on the rendered
|
|
/// document rather than on the path function alone: it is the policy text
|
|
/// that decides what a hive may fetch, so "the path is per-hive" only
|
|
/// means something if the stanza that reaches it is too.
|
|
///
|
|
/// The `services/*` stanza above is deliberate and stays — it is how a
|
|
/// host reads the OIDC secret of a service it runs. The property here is
|
|
/// that the matrix sender token is no longer *inside* it.
|
|
#[test]
|
|
fn a_hive_reaches_its_own_matrix_sender_token_and_no_other_hives() {
|
|
let alpha = render("alpha").expect("legal");
|
|
let own = crate::matrix::sender_token_path("alpha").expect("legal");
|
|
let other = crate::matrix::sender_token_path("beta").expect("legal");
|
|
|
|
// Reachable: the hive's own stanza is the prefix of its own path.
|
|
assert!(
|
|
alpha.contains("path \"secret/data/swarm/hives/alpha/*\""),
|
|
"{alpha}"
|
|
);
|
|
assert!(own.starts_with("swarm/hives/alpha/"), "{own}");
|
|
|
|
// Unreachable: no stanza in alpha's document is a prefix of beta's
|
|
// path. Checked by walking the stanzas rather than by asserting the
|
|
// absence of the string "beta", so a future stanza that happened to
|
|
// cover it — `swarm/hives/*`, say — would fail this too.
|
|
assert!(
|
|
!stanza_paths(&alpha)
|
|
.iter()
|
|
.any(|granted| covers(granted, &other)),
|
|
"alpha's document reaches {other}:\n{alpha}"
|
|
);
|
|
}
|
|
|
|
/// Every `path "…"` a rendered document grants, with the `secret/data/`
|
|
/// ACL prefix stripped so it compares against a store path.
|
|
fn stanza_paths(document: &str) -> Vec<String> {
|
|
document
|
|
.lines()
|
|
.filter_map(|line| line.strip_prefix("path \""))
|
|
.filter_map(|rest| rest.split('"').next())
|
|
.filter_map(|p| p.strip_prefix("secret/data/"))
|
|
.map(str::to_owned)
|
|
.collect()
|
|
}
|
|
|
|
/// Does a granted policy path cover `path`? Only the trailing-`*` form
|
|
/// this module renders, which is the only form it has to understand.
|
|
fn covers(granted: &str, path: &str) -> bool {
|
|
match granted.strip_suffix('*') {
|
|
Some(prefix) => path.starts_with(prefix),
|
|
None => granted == path,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_grant_is_read_only() {
|
|
// A hive reads credentials; a hive that could write one could hand
|
|
// itself an agent's identity.
|
|
let p = render("pr1ma").expect("legal");
|
|
assert!(!p.contains("create"));
|
|
assert!(!p.contains("update"));
|
|
assert!(!p.contains("delete"));
|
|
assert!(!p.contains("list"));
|
|
}
|
|
|
|
#[test]
|
|
fn one_hives_document_does_not_reach_another_hives_path() {
|
|
// Replaces `every_hive_gets_a_byte_identical_document`: the hive stanza
|
|
// is per-reader now, so identical text is no longer the property. This
|
|
// is what that test was protecting — that a document says only what the
|
|
// deploy-time name puts in it.
|
|
let a = render("alpha").expect("legal");
|
|
assert!(a.contains("swarm/hives/alpha/*"));
|
|
assert!(!a.contains("beta"));
|
|
assert!(!a.contains("swarm/hives/*"), "the hive stanza stays narrow");
|
|
}
|
|
|
|
#[test]
|
|
fn the_same_name_still_renders_byte_identically() {
|
|
// The half of the old property that survives: the text is a function of
|
|
// the deploy-time name alone, so a re-emission cannot drift.
|
|
assert_eq!(
|
|
render("pr1ma").expect("legal"),
|
|
render("pr1ma").expect("legal")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_name_that_could_close_the_stanza_is_refused() {
|
|
// The name reaches the document now, which it did not before — so the
|
|
// injection case is live again in the policy TEXT, not just in the
|
|
// policy's identifier.
|
|
assert!(render("alpha/*\" { capabilities = [\"root\"] }").is_err());
|
|
assert!(
|
|
hive_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err(),
|
|
"the object NAME is a place a name can do damage too"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_legal_charset_is_actually_reachable() {
|
|
// The control for the case above: if every name were refused, that
|
|
// assertion would pass while proving nothing.
|
|
assert!(hive_object_name("a-b_C9").is_ok());
|
|
assert!(render("a-b_C9").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn the_object_name_sits_inside_the_namespace_the_controller_may_write() {
|
|
// `hive-` is what the controller's own policy scopes both
|
|
// `sys/policies/acl/` and `auth/cert/certs/` to, so a name outside it
|
|
// is one the controller cannot create at all.
|
|
let n = hive_object_name("pr1ma").expect("legal");
|
|
assert_eq!(n, "hive-pr1ma");
|
|
assert!(n.starts_with(HIVE_PREFIX));
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_document_is_one_stanza_naming_that_agent() {
|
|
assert_eq!(
|
|
render_agent("atlas").expect("a plain name is legal"),
|
|
"path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_document_does_not_grant_the_whole_agent_prefix() {
|
|
// The arm the whole change exists for. `render`'s agent stanza is
|
|
// `agents/*` on purpose, and inheriting one character of that here
|
|
// would give every agent every other agent's credentials while the
|
|
// document still read as per-agent.
|
|
let p = render_agent("atlas").expect("legal");
|
|
assert!(
|
|
!p.contains("swarm/agents/*"),
|
|
"the agent stanza must not widen to the kind: {p}"
|
|
);
|
|
assert!(p.contains("swarm/agents/atlas/*"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_document_reaches_nothing_but_that_agent() {
|
|
// Stated as an exhaustive check over the kinds rather than as a list of
|
|
// paths, so a kind added to `path::Kind` cannot be granted here by a
|
|
// renderer nobody re-read.
|
|
let p = render_agent("atlas").expect("legal");
|
|
for kind in Kind::ALL {
|
|
let segment = <&str>::from(kind);
|
|
let expected = kind == Kind::Agent;
|
|
assert_eq!(
|
|
p.contains(&format!("swarm/{segment}/")),
|
|
expected,
|
|
"kind {kind:?} in an agent's document: {p}"
|
|
);
|
|
}
|
|
assert!(!p.contains("argus"), "no other principal is named");
|
|
assert_eq!(
|
|
p.matches("path \"").count(),
|
|
1,
|
|
"one stanza, or the document grants something unaccounted for: {p}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_document_is_exactly_its_own_read_stanza() {
|
|
// Pinned byte for byte: an added stanza (a hive's queue credential, a
|
|
// second agent) is exactly what a presence check misses.
|
|
assert_eq!(
|
|
render_agent("atlas").expect("legal"),
|
|
"path \"secret/data/swarm/agents/atlas/*\" {\n capabilities = [\"read\"]\n}\n"
|
|
);
|
|
// The control: the pin discriminates between agents.
|
|
assert!(
|
|
!render_agent("other")
|
|
.expect("legal")
|
|
.contains("agents/atlas/")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_grant_is_read_only() {
|
|
// An agent that could write its own credentials could hand itself an
|
|
// identity it was never issued — and `create`/`update` on that path is
|
|
// exactly what the controller holds, so the wall is the capability.
|
|
let p = render_agent("atlas").expect("legal");
|
|
for capability in ["create", "update", "delete", "list", "sudo", "patch"] {
|
|
assert!(
|
|
!p.contains(capability),
|
|
"an agent's document must not grant {capability}: {p}"
|
|
);
|
|
}
|
|
assert!(p.contains("capabilities = [\"read\"]"));
|
|
}
|
|
|
|
#[test]
|
|
fn one_agents_document_does_not_reach_another_agents_path() {
|
|
let a = render_agent("atlas").expect("legal");
|
|
assert!(a.contains("swarm/agents/atlas/*"));
|
|
assert!(!a.contains("argus"));
|
|
assert_eq!(a, render_agent("atlas").expect("legal"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_agent_name_that_could_close_the_stanza_is_refused() {
|
|
// Same live injection surface as the hive renderer's: the name reaches
|
|
// the document text, not just an identifier.
|
|
assert!(render_agent("atlas/*\" { capabilities = [\"root\"] }").is_err());
|
|
assert!(render_agent("").is_err());
|
|
assert!(agent_object_name("atlas/*\" { capabilities = [\"root\"] }").is_err());
|
|
// The control: if everything were refused the arms above would pass for
|
|
// the wrong reason.
|
|
assert!(render_agent("a-b_C9").is_ok());
|
|
assert!(agent_object_name("a-b_C9").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_object_name_sits_where_the_controller_can_write_it() {
|
|
// `hive-*` is the whole of what `controllerPolicyText` lets the
|
|
// controller create under `sys/policies/acl/` and `auth/cert/certs/`,
|
|
// and the controller is the only principal that learns an agent exists.
|
|
let n = agent_object_name("atlas").expect("legal");
|
|
assert_eq!(n, "hive-agent-atlas");
|
|
assert!(n.starts_with(HIVE_PREFIX));
|
|
assert!(n.starts_with(AGENT_PREFIX));
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_object_name_cannot_be_spelled_by_a_legal_hive_name() {
|
|
// The collision argument, as an assertion rather than as prose: the
|
|
// only hive name that renders an agent's object name is one carrying
|
|
// the substring `agent`, which `nix/reserved-hive-fragments.nix`
|
|
// forbids — so the guard this leans on is named in a test that fails if
|
|
// the prefix is ever changed to something that guard does not cover.
|
|
let agent = agent_object_name("atlas").expect("legal");
|
|
let colliding_hive = agent
|
|
.strip_prefix(HIVE_PREFIX)
|
|
.expect("an agent's name is inside the hive namespace");
|
|
assert_eq!(colliding_hive, "agent-atlas");
|
|
assert_eq!(
|
|
hive_object_name(colliding_hive).expect("legal as a path segment"),
|
|
agent,
|
|
"this is the hive name a collision would need"
|
|
);
|
|
assert!(
|
|
colliding_hive.contains("agent"),
|
|
"and it is unrepresentable only because a hive name may not contain `agent`"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_agents_document_is_not_the_hives() {
|
|
// The pin the two renderers need against each other: a later edit that
|
|
// made `render_agent` delegate to `render`, or vice versa, would hand
|
|
// an agent the hive's two wide stanzas.
|
|
let agent = render_agent("atlas").expect("legal");
|
|
let hive = render("atlas").expect("legal");
|
|
assert_ne!(agent, hive);
|
|
assert!(!agent.contains("swarm/services/"));
|
|
assert!(!agent.contains("swarm/hives/"));
|
|
// And the hive's is untouched by this module's growth: still the three
|
|
// stanzas `the_document_grants_two_whole_prefixes_and_this_hive_alone`
|
|
// pins byte for byte, none of them narrowed to make the agent's look
|
|
// consistent with it.
|
|
assert!(hive.contains("path \"secret/data/swarm/agents/*\""));
|
|
}
|
|
|
|
#[test]
|
|
fn only_agent_roles_come_back_and_without_their_prefix() {
|
|
let roles: Vec<String> = [
|
|
"hive-agent-atlas",
|
|
"hive-pr1ma",
|
|
"swarm-controller",
|
|
"hive-agent-argus",
|
|
]
|
|
.map(str::to_owned)
|
|
.to_vec();
|
|
assert_eq!(agents_from_role_names(&roles), ["atlas", "argus"]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_role_agent_object_name_could_not_have_produced_is_dropped() {
|
|
// An empty suffix and one carrying a dot are both names the prefix
|
|
// matches and no agent can have.
|
|
let roles: Vec<String> = ["hive-agent-", "hive-agent-a.b", "hive-agent-ok"]
|
|
.map(str::to_owned)
|
|
.to_vec();
|
|
assert_eq!(agents_from_role_names(&roles), ["ok"]);
|
|
}
|
|
|
|
#[test]
|
|
fn the_listing_inverts_agent_object_name() {
|
|
let name = agent_object_name("atlas").expect("legal");
|
|
assert_eq!(agents_from_role_names(&[name]), ["atlas"]);
|
|
}
|
|
}
|