logs and metrics permissions, tolerant record decode, hive capabilities
This commit is contained in:
parent
b2694b0a92
commit
04598542b6
8 changed files with 226 additions and 8 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2092,6 +2092,7 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -396,15 +396,29 @@ pub(crate) fn queue_first_deploy(
|
||||||
coord.job_queue.insert_first_deploy_once(agent)
|
coord.job_queue.insert_first_deploy_once(agent)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Offer one snapshot: this hive's current readiness, under its own key.
|
/// The snapshot a hive offers: its readiness, and what it can do (`swarm_wire::hive_status`).
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct Snapshot {
|
||||||
|
#[serde(flatten)]
|
||||||
|
readiness: crate::warnings::Readiness,
|
||||||
|
capabilities: &'static [&'static str],
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot() -> Snapshot {
|
||||||
|
Snapshot {
|
||||||
|
readiness: crate::warnings::readiness(),
|
||||||
|
capabilities: &[swarm_wire::hive_status::TOLERANT_PERMISSIONS],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Offer one snapshot under this hive's own key.
|
||||||
async fn publish(client: &async_nats::Client, hive: &str) -> Result<()> {
|
async fn publish(client: &async_nats::Client, hive: &str) -> Result<()> {
|
||||||
// An unconnected client does not fail a JetStream request, it hangs
|
// An unconnected client does not fail a JetStream request, it hangs
|
||||||
// on it — which here would hang the loop, shutdown arm included.
|
// on it — which here would hang the loop, shutdown arm included.
|
||||||
swarm_queue_client::ensure_connected(client)?;
|
swarm_queue_client::ensure_connected(client)?;
|
||||||
|
|
||||||
let store = swarm_queue_client::status::open_or_create(client).await?;
|
let store = swarm_queue_client::status::open_or_create(client).await?;
|
||||||
let payload =
|
let payload = serde_json::to_vec(&snapshot()).context("serialising the snapshot")?;
|
||||||
serde_json::to_vec(&crate::warnings::readiness()).context("serialising the readiness")?;
|
|
||||||
store
|
store
|
||||||
.put(hive, payload.into())
|
.put(hive, payload.into())
|
||||||
.await
|
.await
|
||||||
|
|
@ -427,3 +441,20 @@ mod rev_marker_tests {
|
||||||
assert!(!marker_holds(&marker, "bbb"), "another rev still deploys");
|
assert!(!marker_holds(&marker, "bbb"), "another rev still deploys");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod snapshot_tests {
|
||||||
|
use super::snapshot;
|
||||||
|
|
||||||
|
/// The controller reads the capability out of the published bytes with
|
||||||
|
/// the wire type, and the readiness keeps its own top-level fields.
|
||||||
|
#[test]
|
||||||
|
fn the_snapshot_advertises_tolerant_permissions_beside_the_readiness() {
|
||||||
|
let value = serde_json::to_value(snapshot()).unwrap();
|
||||||
|
assert!(
|
||||||
|
swarm_wire::hive_status::Capabilities::of_snapshot(&value)
|
||||||
|
.has(swarm_wire::hive_status::TOLERANT_PERMISSIONS)
|
||||||
|
);
|
||||||
|
assert!(value.get("status").is_some() && value.get("warnings").is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
|
|
||||||
mod permission;
|
mod permission;
|
||||||
|
|
||||||
pub use permission::Permission;
|
pub use permission::{Permission, deserialize_known};
|
||||||
|
|
||||||
pub mod review;
|
pub mod review;
|
||||||
pub mod term_msg;
|
pub mod term_msg;
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,15 @@ pub enum Permission {
|
||||||
/// thing to manage. Kept under that spelling because every stored grant
|
/// thing to manage. Kept under that spelling because every stored grant
|
||||||
/// carries it.
|
/// carries it.
|
||||||
ManageRootAgent,
|
ManageRootAgent,
|
||||||
|
/// Read access to the log store: what `swarm-logs` queries. Checked by
|
||||||
|
/// the log gateway's `swarm-logs-auth` against the grant swarm-controller
|
||||||
|
/// mirrors into the secret store, so `tools()` is empty. Not in
|
||||||
|
/// [`Permission::DEFAULT`].
|
||||||
|
Logs,
|
||||||
|
/// Read access to the metrics store: what `swarm-metrics` queries.
|
||||||
|
/// Checked like [`Permission::Logs`], for the metrics gateway. Not in
|
||||||
|
/// [`Permission::DEFAULT`].
|
||||||
|
Metrics,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Permission {
|
impl Permission {
|
||||||
|
|
@ -49,6 +58,8 @@ impl Permission {
|
||||||
Self::Execution,
|
Self::Execution,
|
||||||
Self::WebTools,
|
Self::WebTools,
|
||||||
Self::ManageRootAgent,
|
Self::ManageRootAgent,
|
||||||
|
Self::Logs,
|
||||||
|
Self::Metrics,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// What an agent the swarm sets nothing for holds, whatever its name.
|
/// What an agent the swarm sets nothing for holds, whatever its name.
|
||||||
|
|
@ -69,8 +80,13 @@ impl Permission {
|
||||||
// — an out-of-process server has no later enforcement point, so
|
// — an out-of-process server has no later enforcement point, so
|
||||||
// that gate is the actual security boundary. `WebTools` grants
|
// that gate is the actual security boundary. `WebTools` grants
|
||||||
// Claude built-ins (`builtin_tools()`); `ManageRootAgent` grants
|
// Claude built-ins (`builtin_tools()`); `ManageRootAgent` grants
|
||||||
// bind mounts.
|
// bind mounts; `Logs` and `Metrics` are checked by the store
|
||||||
Self::Execution | Self::WebTools | Self::ManageRootAgent => &[],
|
// gateways, not by a tool allowlist.
|
||||||
|
Self::Execution
|
||||||
|
| Self::WebTools
|
||||||
|
| Self::ManageRootAgent
|
||||||
|
| Self::Logs
|
||||||
|
| Self::Metrics => &[],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,8 +131,43 @@ impl Permission {
|
||||||
Self::Execution => "execution",
|
Self::Execution => "execution",
|
||||||
Self::WebTools => "web_tools",
|
Self::WebTools => "web_tools",
|
||||||
Self::ManageRootAgent => "manage_root_agent",
|
Self::ManageRootAgent => "manage_root_agent",
|
||||||
|
Self::Logs => "logs",
|
||||||
|
Self::Metrics => "metrics",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The permission stored under `name`; `None` for a name this build does
|
||||||
|
/// not know.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_name(name: &str) -> Option<Self> {
|
||||||
|
Self::ALL.iter().copied().find(|p| p.as_str() == name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a stored permission list, skipping names this build does not know.
|
||||||
|
/// For `#[serde(deserialize_with)]` on a stored record: a permission a newer
|
||||||
|
/// swarm adds must not cost an older reader the rest of the record.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// When the value is not a list of strings.
|
||||||
|
pub fn deserialize_known<'de, D>(d: D) -> Result<Option<Vec<Permission>>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let names: Option<Vec<String>> = Option::deserialize(d)?;
|
||||||
|
Ok(names.map(|names| {
|
||||||
|
names
|
||||||
|
.iter()
|
||||||
|
.filter_map(|name| {
|
||||||
|
let known = Permission::from_name(name);
|
||||||
|
if known.is_none() {
|
||||||
|
tracing::debug!(name, "skipping a permission this build does not know");
|
||||||
|
}
|
||||||
|
known
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Permission> for &'static str {
|
impl From<Permission> for &'static str {
|
||||||
|
|
@ -142,11 +193,46 @@ mod tests {
|
||||||
"inbox",
|
"inbox",
|
||||||
"execution",
|
"execution",
|
||||||
"web_tools",
|
"web_tools",
|
||||||
"manage_root_agent"
|
"manage_root_agent",
|
||||||
|
"logs",
|
||||||
|
"metrics"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reading stores a log or metric grant is never a default: an agent the
|
||||||
|
/// swarm sets nothing for does not hold them.
|
||||||
|
#[test]
|
||||||
|
fn logs_and_metrics_are_off_by_default() {
|
||||||
|
assert!(!Permission::DEFAULT.contains(&Permission::Logs));
|
||||||
|
assert!(!Permission::DEFAULT.contains(&Permission::Metrics));
|
||||||
|
assert!(Permission::ALL.contains(&Permission::Logs));
|
||||||
|
assert!(Permission::ALL.contains(&Permission::Metrics));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Record {
|
||||||
|
#[serde(default, deserialize_with = "deserialize_known")]
|
||||||
|
permissions: Option<Vec<Permission>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_name_is_skipped_not_a_decode_failure() {
|
||||||
|
let r: Record =
|
||||||
|
serde_json::from_str(r#"{"permissions":["messaging","telepathy","logs"]}"#).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
r.permissions,
|
||||||
|
Some(vec![Permission::Messaging, Permission::Logs])
|
||||||
|
);
|
||||||
|
// Presence control: an unset field stays unset, `[]` stays empty.
|
||||||
|
let r: Record = serde_json::from_str("{}").unwrap();
|
||||||
|
assert_eq!(r.permissions, None);
|
||||||
|
let r: Record = serde_json::from_str(r#"{"permissions":[]}"#).unwrap();
|
||||||
|
assert_eq!(r.permissions, Some(vec![]));
|
||||||
|
// A list of non-strings is still a decode failure.
|
||||||
|
assert!(serde_json::from_str::<Record>(r#"{"permissions":[1]}"#).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
/// The serde form of every variant, pinned independently of `as_str`:
|
/// The serde form of every variant, pinned independently of `as_str`:
|
||||||
/// stored grants decode by it, so it must stay byte-identical.
|
/// stored grants decode by it, so it must stay byte-identical.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -158,6 +244,8 @@ mod tests {
|
||||||
(Permission::Execution, "\"execution\""),
|
(Permission::Execution, "\"execution\""),
|
||||||
(Permission::WebTools, "\"web_tools\""),
|
(Permission::WebTools, "\"web_tools\""),
|
||||||
(Permission::ManageRootAgent, "\"manage_root_agent\""),
|
(Permission::ManageRootAgent, "\"manage_root_agent\""),
|
||||||
|
(Permission::Logs, "\"logs\""),
|
||||||
|
(Permission::Metrics, "\"metrics\""),
|
||||||
];
|
];
|
||||||
assert_eq!(want.len(), Permission::ALL.len());
|
assert_eq!(want.len(), Permission::ALL.len());
|
||||||
for (perm, json) in want {
|
for (perm, json) in want {
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,13 @@ pub const IMPORT_REPLY_DEADLINE: std::time::Duration = std::time::Duration::from
|
||||||
/// older hive the fields it knows.
|
/// older hive the fields it knows.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct AgentPermissions {
|
pub struct AgentPermissions {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
/// Names this build does not know are skipped on decode, so a permission
|
||||||
|
/// a newer controller adds does not cost this reader the record.
|
||||||
|
#[serde(
|
||||||
|
default,
|
||||||
|
skip_serializing_if = "Option::is_none",
|
||||||
|
deserialize_with = "hive_types::deserialize_known"
|
||||||
|
)]
|
||||||
pub permissions: Option<Vec<hive_types::Permission>>,
|
pub permissions: Option<Vec<hive_types::Permission>>,
|
||||||
/// Whether the agent's traffic leaves the host unfiltered. Absent is "on":
|
/// Whether the agent's traffic leaves the host unfiltered. Absent is "on":
|
||||||
/// an agent whose key predates the field keeps the open access it had, and
|
/// an agent whose key predates the field keeps the open access it had, and
|
||||||
|
|
@ -155,6 +161,23 @@ mod tests {
|
||||||
assert_eq!(ruth.permissions, Some(vec![Permission::ManageRootAgent]));
|
assert_eq!(ruth.permissions, Some(vec![Permission::ManageRootAgent]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A record naming a permission this build does not know still decodes,
|
||||||
|
/// with that name skipped and the other fields intact: the reader holds
|
||||||
|
/// what it knows instead of nothing.
|
||||||
|
#[test]
|
||||||
|
fn a_record_with_an_unknown_permission_still_decodes() {
|
||||||
|
use hive_types::Permission;
|
||||||
|
let record: AgentPermissions = serde_json::from_str(
|
||||||
|
r#"{"permissions":["messaging","from-the-future","logs"],"unrestricted_internet":false}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
record.permissions,
|
||||||
|
Some(vec![Permission::Messaging, Permission::Logs])
|
||||||
|
);
|
||||||
|
assert_eq!(record.unrestricted_internet, Some(false));
|
||||||
|
}
|
||||||
|
|
||||||
/// The flag is left out when unset, so a record that never named it
|
/// The flag is left out when unset, so a record that never named it
|
||||||
/// encodes as before, and an explicit `false` is kept on the wire.
|
/// encodes as before, and an explicit `false` is kept on the wire.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
73
swarm-wire/src/hive_status.rs
Normal file
73
swarm-wire/src/hive_status.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
//! What a hive's status snapshot (`swarm_queue_client::status`) says about
|
||||||
|
//! what the hive can do, next to its readiness.
|
||||||
|
//!
|
||||||
|
//! The snapshot is opaque to the controller except for this field. A snapshot
|
||||||
|
//! from a hive that predates it has none, which reads as an empty list.
|
||||||
|
|
||||||
|
/// The hive decodes a permission list that names a permission it does not
|
||||||
|
/// know by skipping that name, and runs agents on a harness that does too.
|
||||||
|
/// swarm-controller writes `logs` or `metrics` into an agent's record only
|
||||||
|
/// when the agent's hive advertises this.
|
||||||
|
pub const TOLERANT_PERMISSIONS: &str = "tolerant-permissions";
|
||||||
|
|
||||||
|
/// The part of a hive's status snapshot this module describes.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct Capabilities {
|
||||||
|
/// Capability names; unknown names are ignored by every reader.
|
||||||
|
#[serde(default)]
|
||||||
|
pub capabilities: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Capabilities {
|
||||||
|
/// Whether the hive advertises `name`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn has(&self, name: &str) -> bool {
|
||||||
|
self.capabilities.iter().any(|c| c == name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The capabilities in a stored snapshot; none when it has no such field
|
||||||
|
/// or is not an object.
|
||||||
|
#[must_use]
|
||||||
|
pub fn of_snapshot(snapshot: &serde_json::Value) -> Self {
|
||||||
|
serde_json::from_value(snapshot.clone()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A snapshot from before the field existed: it parses, with nothing
|
||||||
|
/// advertised.
|
||||||
|
#[test]
|
||||||
|
fn a_snapshot_without_the_field_advertises_nothing() {
|
||||||
|
let old = serde_json::json!({"status": "ok", "warnings": []});
|
||||||
|
let caps = Capabilities::of_snapshot(&old);
|
||||||
|
assert!(!caps.has(TOLERANT_PERMISSIONS));
|
||||||
|
assert_eq!(caps, Capabilities::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_advertised_capability_is_found_among_unknown_ones() {
|
||||||
|
let new = serde_json::json!({
|
||||||
|
"status": "ok",
|
||||||
|
"warnings": [],
|
||||||
|
"capabilities": ["from-the-future", TOLERANT_PERMISSIONS],
|
||||||
|
});
|
||||||
|
assert!(Capabilities::of_snapshot(&new).has(TOLERANT_PERMISSIONS));
|
||||||
|
assert!(!Capabilities::of_snapshot(&new).has("other"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A snapshot that is not an object, or whose field is mistyped, grants
|
||||||
|
/// nothing rather than failing the read.
|
||||||
|
#[test]
|
||||||
|
fn a_malformed_snapshot_advertises_nothing() {
|
||||||
|
for bad in [
|
||||||
|
serde_json::json!(null),
|
||||||
|
serde_json::json!("ok"),
|
||||||
|
serde_json::json!({"capabilities": "tolerant-permissions"}),
|
||||||
|
] {
|
||||||
|
assert!(!Capabilities::of_snapshot(&bad).has(TOLERANT_PERMISSIONS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ pub mod builder;
|
||||||
pub mod claude_login;
|
pub mod claude_login;
|
||||||
pub mod egress_allow;
|
pub mod egress_allow;
|
||||||
pub mod forge_events;
|
pub mod forge_events;
|
||||||
|
pub mod hive_status;
|
||||||
pub mod notices;
|
pub mod notices;
|
||||||
pub mod notify_flows;
|
pub mod notify_flows;
|
||||||
pub mod notify_router;
|
pub mod notify_router;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue