Watch
0
0
Fork
You've already forked hyperhive
0

controller gates logs and metrics on the hive capability and mirrors the grant

This commit is contained in:
atlas 2026-11-01 17:40:09 +01:00
commit e7e3281098

View file

@ -10,9 +10,11 @@ use axum::{
};
use serde::{Deserialize, Serialize};
use swarm_controller_api::agents::{CreateAgentRequest, CreateAgentResponse};
use swarm_secret_client::observability::Grant;
use utoipa::ToSchema;
use crate::jobq::{SwarmNodeKind, SwarmResourceKind, declare_agent_job};
use crate::observability_grant::{self, grant_of, mirror, require_tolerant};
use crate::routes::hives::HiveEntry;
use crate::routes::{AppState, StatusUnavailable, error_problem, wanted_error_status};
use crate::{
@ -623,26 +625,18 @@ pub(crate) async fn create_agent(
// Checked after the roster, so a mistyped hive stays a 400 while the queue
// is down.
let settings_queue = settings.queue(&state).await?;
let requested = settings.permissions.permissions.clone().unwrap_or_default();
if grant_of(&requested) != Grant::default() {
require_tolerant(&settings_queue, &hive)
.await
.map_err(|why| error_problem(axum::http::StatusCode::CONFLICT, &why))?;
}
// Agent names are one swarm-wide namespace: identity, forge user, store
// path and matrix id all carry the bare name. The same name on the same
// hive is that agent being re-created, and goes through.
let _gate = state.create_gate.lock().await;
let elsewhere = placements_elsewhere(
&agent,
&hive,
|| queued_placements_now(&state),
|| declarations_elsewhere(&state, &agent, &hive),
)
.await?;
if !elsewhere.is_empty() {
let detail = format!(
"agent name {agent:?} is already placed on hive {} — agent names are unique across \
the swarm; destroy it there first to move it, or choose another name",
elsewhere.join(", ")
);
return Err(error_problem(axum::http::StatusCode::CONFLICT, &detail));
}
refuse_if_placed_elsewhere(&state, &agent, &hive).await?;
// Stored ahead of the job graph, not once the agent is declared: the hive
// reads these by agent name when it first runs the agent. A failure from
// here on puts back what was stored, so a refused creation leaves no
@ -657,6 +651,22 @@ pub(crate) async fn create_agent(
settings.agents_md.as_deref(),
)
.await?;
// The record this create replaced is what the mirror may still describe.
// A create that names no list keeps the stored one, and its mirror with it.
let earlier = stored
.permissions
.as_ref()
.filter(|_| settings.permissions.permissions.is_some())
.and_then(|replaced| replaced.previous())
.and_then(|record| record.permissions.clone())
.unwrap_or_default();
if let Err(e) = mirror(&observability_grant::Store, &agent, &earlier, &requested).await {
stored.roll_back(&settings_queue, &agent).await;
return Err(error_problem(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
&format!("mirroring logs/metrics to the secret store failed: {e:#}"),
));
}
let queued = {
let mut sched = state
.jobq
@ -670,6 +680,8 @@ pub(crate) async fn create_agent(
Ok(ids) => ids,
Err(e) => {
stored.roll_back(&settings_queue, &agent).await;
// Best effort, like the roll back: the mirror follows the record.
let _ = mirror(&observability_grant::Store, &agent, &requested, &earlier).await;
return Err(error_problem(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
&e.to_string(),
@ -685,6 +697,30 @@ pub(crate) async fn create_agent(
}))
}
/// A 409 when `agent` is already placed on a hive other than `hive`.
async fn refuse_if_placed_elsewhere(
state: &AppState,
agent: &str,
hive: &str,
) -> Result<(), problem_details::ProblemDetails> {
let elsewhere = placements_elsewhere(
agent,
hive,
|| queued_placements_now(state),
|| declarations_elsewhere(state, agent, hive),
)
.await?;
if elsewhere.is_empty() {
return Ok(());
}
let detail = format!(
"agent name {agent:?} is already placed on hive {} — agent names are unique across \
the swarm; destroy it there first to move it, or choose another name",
elsewhere.join(", ")
);
Err(error_problem(axum::http::StatusCode::CONFLICT, &detail))
}
/// The settings a `POST /api/agents` body gives, as they are stored.
struct CreateSettings {
limits: Option<swarm_wire::agent_limits::AgentCaps>,
@ -1552,6 +1588,35 @@ mod tests {
assert_eq!(node_count(&state.jobq), 0, "nothing may be queued");
}
/// A create naming `logs` or `metrics` on a hive that does not advertise
/// tolerant decoding is a 409 that stores and queues nothing. Control: the
/// same create without them is accepted by that hive.
#[tokio::test]
async fn a_create_granting_logs_needs_a_tolerant_hive() {
let nats = crate::test_nats::TestNats::start();
let client = nats.client().await;
let state = crate::agent_limits::tests::state(&client);
let create = |perms: &str| {
let req: CreateAgentRequest = serde_json::from_str(&format!(
r#"{{"name":"atlas","hive":"pr1ma","permissions":[{perms}]}}"#
))
.expect("body parses");
super::create_agent(axum::extract::State(state.clone()), axum::Json(req))
};
let err = create(r#""messaging","logs""#).await.expect_err("refused");
assert_eq!(
axum::response::IntoResponse::into_response(err).status(),
axum::http::StatusCode::CONFLICT
);
assert_eq!(
crate::agent_permissions::tests::published(&client, "atlas").await,
None
);
let _ = create(r#""messaging""#)
.await
.expect("control: no log grant");
}
/// `state_with_roster` with a second hive, `sec0nd`.
async fn state_with_two_hives() -> (
crate::test_nats::TestNats,