1use std::sync::Arc;
10
11use axum::{
12 extract::{Path, Query, State},
13 http::StatusCode,
14 response::{IntoResponse, Response},
15 routing::{get, patch},
16 Json, Router,
17};
18use breathe_facade::{BreatheStore, DimensionId, StoreError};
19use serde::Deserialize;
20use serde_json::{json, Value};
21
22pub type SharedStore = Arc<dyn BreatheStore>;
23
24#[must_use]
27pub fn router(store: SharedStore) -> Router {
28 let schema = graphql::schema(store.clone());
29 Router::new()
30 .route("/healthz", get(|| async { "ok" }))
31 .route("/api/v1/catalog", get(catalog))
32 .route("/api/v1/bands/:kind", get(list_bands))
33 .route("/api/v1/bands/:kind/:namespace/:name", get(get_band).patch(patch_band))
34 .route("/api/v1/bands/:kind/:namespace/:name/dry-run", patch(set_dry_run))
35 .route("/api/v1/bands/:kind/:namespace/:name/write-intent", patch(set_write_intent))
36 .route("/api/v1/bands/:kind/:namespace/:name/confirm", patch(confirm_band))
37 .route("/api/v1/nodepools", get(list_pools))
38 .route("/api/v1/nodepools/:name", get(get_pool))
39 .route("/api/v1/nodepools/:name/write-enabled", patch(set_write_enabled))
40 .route_service("/graphql", async_graphql_axum::GraphQL::new(schema))
41 .with_state(store)
42}
43
44fn respond(r: Result<Value, StoreError>) -> Response {
46 match r {
47 Ok(v) => (StatusCode::OK, Json(v)).into_response(),
48 Err(StoreError::BadRequest(m)) => (StatusCode::BAD_REQUEST, Json(json!({ "error": m }))).into_response(),
49 Err(e) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e.to_string() }))).into_response(),
50 }
51}
52
53fn kind_or_400(s: &str) -> Result<DimensionId, Response> {
54 DimensionId::parse(s).ok_or_else(|| {
55 let known: Vec<&str> = DimensionId::ALL.iter().map(|d| d.as_str()).collect();
56 (StatusCode::BAD_REQUEST, Json(json!({ "error": format!("unknown band kind '{s}'"), "known": known })))
57 .into_response()
58 })
59}
60
61#[derive(Deserialize)]
62struct NsQuery {
63 namespace: Option<String>,
64}
65
66async fn catalog(State(store): State<SharedStore>) -> Response {
67 (StatusCode::OK, Json(store.catalog())).into_response()
68}
69
70async fn list_bands(State(store): State<SharedStore>, Path(kind): Path<String>, Query(q): Query<NsQuery>) -> Response {
71 match kind_or_400(&kind) {
72 Ok(k) => respond(store.list_bands(k, q.namespace).await),
73 Err(r) => r,
74 }
75}
76
77async fn get_band(State(store): State<SharedStore>, Path((kind, ns, name)): Path<(String, String, String)>) -> Response {
78 match kind_or_400(&kind) {
79 Ok(k) => respond(store.get_band(k, ns, name).await),
80 Err(r) => r,
81 }
82}
83
84async fn patch_band(
85 State(store): State<SharedStore>,
86 Path((kind, ns, name)): Path<(String, String, String)>,
87 Json(spec): Json<Value>,
88) -> Response {
89 match kind_or_400(&kind) {
90 Ok(k) => respond(store.patch_band_spec(k, ns, name, spec).await),
91 Err(r) => r,
92 }
93}
94
95#[derive(Deserialize)]
96struct DryRunBody {
97 #[serde(rename = "dryRun")]
98 dry_run: bool,
99}
100
101async fn set_dry_run(
110 State(store): State<SharedStore>,
111 Path((kind, ns, name)): Path<(String, String, String)>,
112 Json(b): Json<DryRunBody>,
113) -> Response {
114 match kind_or_400(&kind) {
115 Ok(k) if !k.dry_run_is_honored() => (
116 StatusCode::BAD_REQUEST,
117 Json(json!({
118 "error": "spec.dryRun has no effect on this band kind",
119 "kind": k.as_str(),
120 "retiredSince": "breathe@76924b0 (2026-06-19)",
121 "useInstead": "PATCH /api/v1/bands/{kind}/{namespace}/{name}/write-intent",
122 "honoredBy": ["host-param", "kube-param"],
123 "wroteNothing": true,
124 })),
125 )
126 .into_response(),
127 Ok(k) => respond(store.patch_band_spec(k, ns, name, json!({ "dryRun": b.dry_run })).await),
128 Err(r) => r,
129 }
130}
131
132#[derive(Deserialize)]
133struct WriteIntentBody {
134 intent: String,
135 #[serde(rename = "confirmAfterSeconds", default)]
136 confirm_after_seconds: Option<u64>,
137 #[serde(rename = "authorizedBy", default)]
138 authorized_by: Option<String>,
139}
140
141async fn set_write_intent(
143 State(store): State<SharedStore>,
144 Path((kind, ns, name)): Path<(String, String, String)>,
145 Json(b): Json<WriteIntentBody>,
146) -> Response {
147 let k = match kind_or_400(&kind) {
148 Ok(k) => k,
149 Err(r) => return r,
150 };
151 if b.intent == "write" && b.authorized_by.as_deref().map(str::trim).unwrap_or_default().is_empty() {
155 return (
156 StatusCode::BAD_REQUEST,
157 Json(json!({
158 "error": "intent=write requires authorizedBy",
159 "why": "a live carve must name who authorized it; the witness is carried into \
160 status.effectiveGate so 'why is this band writing?' is answerable from the CR alone",
161 "wroteNothing": true,
162 })),
163 )
164 .into_response();
165 }
166 let mut intent = json!({ "intent": b.intent });
167 if let Some(secs) = b.confirm_after_seconds {
168 intent["confirmAfterSeconds"] = json!(secs);
169 }
170 if let Some(by) = b.authorized_by {
171 intent["authorizedBy"] = json!(by);
172 }
173 respond(store.patch_band_spec(k, ns, name, json!({ "writeIntent": intent })).await)
174}
175
176#[derive(Deserialize)]
177struct ConfirmBody {
178 confirmed: bool,
179}
180
181async fn confirm_band(
184 State(store): State<SharedStore>,
185 Path((kind, ns, name)): Path<(String, String, String)>,
186 Json(b): Json<ConfirmBody>,
187) -> Response {
188 match kind_or_400(&kind) {
189 Ok(k) => {
191 let v = if b.confirmed { json!("true") } else { Value::Null };
192 respond(store.annotate_band(k, ns, name, json!({ breathe_provider::CONFIRMED_ANNOTATION: v })).await)
193 }
194 Err(r) => r,
195 }
196}
197
198async fn list_pools(State(store): State<SharedStore>) -> Response {
199 respond(store.list_pools().await)
200}
201
202async fn get_pool(State(store): State<SharedStore>, Path(name): Path<String>) -> Response {
203 respond(store.get_pool(name).await)
204}
205
206#[derive(Deserialize)]
207struct WriteEnabledBody {
208 #[serde(rename = "writeEnabled")]
209 write_enabled: bool,
210}
211
212async fn set_write_enabled(
213 State(store): State<SharedStore>,
214 Path(name): Path<String>,
215 Json(b): Json<WriteEnabledBody>,
216) -> Response {
217 respond(store.patch_pool_spec(name, json!({ "writeEnabled": b.write_enabled })).await)
218}
219
220pub mod graphql {
225 use super::{DimensionId, SharedStore, StoreError};
226 use async_graphql::{Context, EmptySubscription, Json, Object, Schema};
227 use serde_json::Value;
228
229 fn gql(e: StoreError) -> async_graphql::Error {
230 async_graphql::Error::new(e.to_string())
231 }
232 fn parse_kind(s: &str) -> async_graphql::Result<DimensionId> {
233 DimensionId::parse(s).ok_or_else(|| {
234 let known: Vec<&str> = DimensionId::ALL.iter().map(|d| d.as_str()).collect();
235 async_graphql::Error::new(format!("unknown band kind '{s}' (known: {})", known.join(", ")))
236 })
237 }
238 fn store<'a>(ctx: &Context<'a>) -> async_graphql::Result<&'a SharedStore> {
239 ctx.data::<SharedStore>()
240 }
241
242 pub struct Query;
243 #[Object]
244 impl Query {
245 async fn catalog(&self, ctx: &Context<'_>) -> async_graphql::Result<Json<Value>> {
247 Ok(Json(store(ctx)?.catalog()))
248 }
249 async fn bands(&self, ctx: &Context<'_>, kind: String, namespace: Option<String>) -> async_graphql::Result<Json<Value>> {
251 Ok(Json(store(ctx)?.list_bands(parse_kind(&kind)?, namespace).await.map_err(gql)?))
252 }
253 async fn band(&self, ctx: &Context<'_>, kind: String, namespace: String, name: String) -> async_graphql::Result<Json<Value>> {
255 Ok(Json(store(ctx)?.get_band(parse_kind(&kind)?, namespace, name).await.map_err(gql)?))
256 }
257 async fn nodepools(&self, ctx: &Context<'_>) -> async_graphql::Result<Json<Value>> {
259 Ok(Json(store(ctx)?.list_pools().await.map_err(gql)?))
260 }
261 async fn nodepool(&self, ctx: &Context<'_>, name: String) -> async_graphql::Result<Json<Value>> {
262 Ok(Json(store(ctx)?.get_pool(name).await.map_err(gql)?))
263 }
264 }
265
266 pub struct Mutation;
267 #[Object]
268 impl Mutation {
269 async fn patch_band(&self, ctx: &Context<'_>, kind: String, namespace: String, name: String, spec: Json<Value>) -> async_graphql::Result<Json<Value>> {
271 Ok(Json(store(ctx)?.patch_band_spec(parse_kind(&kind)?, namespace, name, spec.0).await.map_err(gql)?))
272 }
273 async fn set_write_intent(
277 &self,
278 ctx: &Context<'_>,
279 kind: String,
280 namespace: String,
281 name: String,
282 intent: String,
283 confirm_after_seconds: Option<u64>,
284 authorized_by: Option<String>,
285 ) -> async_graphql::Result<Json<Value>> {
286 if intent == "write" && authorized_by.as_deref().map(str::trim).unwrap_or_default().is_empty() {
287 return Err(async_graphql::Error::new(
288 "intent=write requires authorizedBy — a live carve must name who authorized it; nothing was written",
289 ));
290 }
291 let mut body = serde_json::json!({ "intent": intent });
292 if let Some(secs) = confirm_after_seconds {
293 body["confirmAfterSeconds"] = serde_json::json!(secs);
294 }
295 if let Some(by) = authorized_by {
296 body["authorizedBy"] = serde_json::json!(by);
297 }
298 let spec = serde_json::json!({ "writeIntent": body });
299 Ok(Json(store(ctx)?.patch_band_spec(parse_kind(&kind)?, namespace, name, spec).await.map_err(gql)?))
300 }
301 async fn confirm_band(&self, ctx: &Context<'_>, kind: String, namespace: String, name: String, confirmed: bool) -> async_graphql::Result<Json<Value>> {
304 let v = if confirmed { serde_json::json!("true") } else { Value::Null };
305 let ann = serde_json::json!({ breathe_provider::CONFIRMED_ANNOTATION: v });
306 Ok(Json(store(ctx)?.annotate_band(parse_kind(&kind)?, namespace, name, ann).await.map_err(gql)?))
307 }
308 async fn set_dry_run(&self, ctx: &Context<'_>, kind: String, namespace: String, name: String, dry_run: bool) -> async_graphql::Result<Json<Value>> {
313 let k = parse_kind(&kind)?;
314 if !k.dry_run_is_honored() {
315 return Err(async_graphql::Error::new(format!(
316 "spec.dryRun has no effect on {kind} bands (retired breathe@76924b0, 2026-06-19); \
317 use setWriteIntent. Only host-param and kube-param read it. Nothing was written."
318 )));
319 }
320 Ok(Json(store(ctx)?.patch_band_spec(k, namespace, name, serde_json::json!({ "dryRun": dry_run })).await.map_err(gql)?))
321 }
322 async fn set_write_enabled(&self, ctx: &Context<'_>, name: String, write_enabled: bool) -> async_graphql::Result<Json<Value>> {
325 Ok(Json(store(ctx)?.patch_pool_spec(name, serde_json::json!({ "writeEnabled": write_enabled })).await.map_err(gql)?))
326 }
327 }
328
329 pub type BreatheSchema = Schema<Query, Mutation, EmptySubscription>;
330
331 #[must_use]
332 pub fn schema(store: SharedStore) -> BreatheSchema {
333 Schema::build(Query, Mutation, EmptySubscription).data(store).finish()
334 }
335}
336
337pub mod grpc {
346 use super::{DimensionId, SharedStore, StoreError};
347 use serde_json::Value;
348 use tonic::{Request, Response, Status};
349
350 pub mod pb {
354 tonic::include_proto!("breathe.v1");
355 include!(concat!(env!("OUT_DIR"), "/breathe.v1.serde.rs"));
356 }
357
358 fn st(e: StoreError) -> Status {
359 match e {
360 StoreError::BadRequest(m) => Status::invalid_argument(m),
361 other => Status::internal(other.to_string()),
362 }
363 }
364
365 fn typed<T: serde::de::DeserializeOwned>(v: Value) -> Result<T, Status> {
370 serde_json::from_value(v)
371 .map_err(|e| Status::internal(format!("response did not match the typed schema (spec drift?): {e}")))
372 }
373
374 fn opt_ns(s: String) -> Option<String> {
375 if s.is_empty() { None } else { Some(s) }
376 }
377
378 pub fn kind_of(k: i32) -> Result<DimensionId, Status> {
386 use pb::BandKind as P;
387 let p = P::try_from(k).map_err(|_| Status::invalid_argument("unknown band kind"))?;
388 match p {
389 P::Memory => Ok(DimensionId::Memory),
390 P::Cpu => Ok(DimensionId::Cpu),
391 P::Storage => Ok(DimensionId::Storage),
392 P::Replica => Ok(DimensionId::Replica),
393 P::Arc => Ok(DimensionId::Arc),
394 P::Cgroup => Ok(DimensionId::Cgroup),
395 P::CgroupCpu => Ok(DimensionId::CgroupCpu),
396 P::HostParam => Ok(DimensionId::HostParam),
397 P::KubeParam => Ok(DimensionId::KubeParam),
398 P::AppParam => Ok(DimensionId::AppParam),
399 P::Request => Ok(DimensionId::Request),
400 P::Unspecified => Err(Status::invalid_argument("band kind unspecified")),
401 }
402 }
403
404 pub struct GrpcService {
405 pub store: SharedStore,
406 }
407
408 #[tonic::async_trait]
409 impl pb::breathe_server::Breathe for GrpcService {
410 async fn band_list(&self, req: Request<pb::BandListRequest>) -> Result<Response<pb::BandListResponse>, Status> {
411 let r = req.into_inner();
412 let v = self.store.list_bands(kind_of(r.kind)?, opt_ns(r.namespace)).await.map_err(st)?;
413 Ok(Response::new(pb::BandListResponse { items: typed(v)? }))
414 }
415 async fn band_get(&self, req: Request<pb::BandGetRequest>) -> Result<Response<pb::Band>, Status> {
416 let r = req.into_inner();
417 let v = self.store.get_band(kind_of(r.kind)?, r.namespace, r.name).await.map_err(st)?;
418 Ok(Response::new(typed(v)?))
419 }
420 async fn band_patch(&self, req: Request<pb::BandPatchRequest>) -> Result<Response<pb::Band>, Status> {
421 let r = req.into_inner();
422 let spec = serde_json::to_value(r.body.unwrap_or_default()).map_err(|e| Status::internal(e.to_string()))?;
427 if spec["writeIntent"]["intent"] == "write"
432 && spec["writeIntent"]["authorizedBy"].as_str().map(str::trim).unwrap_or_default().is_empty()
433 {
434 return Err(Status::invalid_argument(
435 "writeIntent.intent=write requires authorizedBy — a live carve must name who \
436 authorized it. Nothing was written.",
437 ));
438 }
439 let v = self.store.patch_band_spec(kind_of(r.kind)?, r.namespace, r.name, spec).await.map_err(st)?;
440 Ok(Response::new(typed(v)?))
441 }
442 async fn band_set_dry_run(&self, req: Request<pb::BandSetDryRunRequest>) -> Result<Response<pb::Band>, Status> {
447 let r = req.into_inner();
448 let k = kind_of(r.kind)?;
449 if !k.dry_run_is_honored() {
450 return Err(Status::failed_precondition(
451 "spec.dryRun has no effect on this band kind (retired breathe@76924b0, 2026-06-19); \
452 set BandSpec.write_intent via BandPatch instead. Only host-param and kube-param \
453 read dryRun. Nothing was written.",
454 ));
455 }
456 let v = self.store.patch_band_spec(k, r.namespace, r.name, serde_json::json!({ "dryRun": r.dry_run })).await.map_err(st)?;
457 Ok(Response::new(typed(v)?))
458 }
459 async fn catalog_list(&self, _req: Request<pb::CatalogListRequest>) -> Result<Response<pb::Catalog>, Status> {
460 Ok(Response::new(typed(self.store.catalog())?))
461 }
462 async fn nodepool_list(&self, _req: Request<pb::NodepoolListRequest>) -> Result<Response<pb::NodepoolListResponse>, Status> {
463 let v = self.store.list_pools().await.map_err(st)?;
464 Ok(Response::new(pb::NodepoolListResponse { items: typed(v)? }))
465 }
466 async fn nodepool_get(&self, req: Request<pb::NodepoolGetRequest>) -> Result<Response<pb::NodePool>, Status> {
467 let v = self.store.get_pool(req.into_inner().name).await.map_err(st)?;
468 Ok(Response::new(typed(v)?))
469 }
470 async fn nodepool_set_write_enabled(&self, req: Request<pb::NodepoolSetWriteEnabledRequest>) -> Result<Response<pb::NodePool>, Status> {
471 let r = req.into_inner();
472 let v = self.store.patch_pool_spec(r.name, serde_json::json!({ "writeEnabled": r.write_enabled })).await.map_err(st)?;
473 Ok(Response::new(typed(v)?))
474 }
475 async fn healthz(&self, _req: Request<pb::HealthzRequest>) -> Result<Response<::pbjson_types::Empty>, Status> {
476 Ok(Response::new(::pbjson_types::Empty {}))
477 }
478 }
479
480 #[must_use]
481 pub fn server(store: SharedStore) -> pb::breathe_server::BreatheServer<GrpcService> {
482 pb::breathe_server::BreatheServer::new(GrpcService { store })
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use async_trait::async_trait;
490 use axum::body::Body;
491 use axum::http::Request;
492 use http_body_util::BodyExt;
493 use std::sync::Mutex;
494 use tower::ServiceExt;
495
496 #[derive(Default)]
497 struct MockStore {
498 patches: Mutex<Vec<(String, Value)>>,
499 }
500 #[async_trait]
501 impl BreatheStore for MockStore {
502 async fn list_bands(&self, kind: DimensionId, _ns: Option<String>) -> Result<Value, StoreError> {
503 Ok(json!([{ "kind": kind.as_str() }]))
504 }
505 async fn get_band(&self, _kind: DimensionId, ns: String, name: String) -> Result<Value, StoreError> {
506 Ok(json!({
509 "apiVersion": "breathe.pleme.io/v1",
510 "kind": "ArcBand",
511 "metadata": { "name": name, "namespace": ns, "resourceVersion": "42" },
512 "spec": { "setpoint": 0.8, "dryRun": false },
513 "status": { "phase": "Holding" }
514 }))
515 }
516 async fn patch_band_spec(&self, _k: DimensionId, _ns: String, name: String, spec: Value) -> Result<Value, StoreError> {
517 self.patches.lock().unwrap().push((name, spec.clone()));
518 Ok(json!({ "spec": spec }))
519 }
520 async fn annotate_band(&self, _k: DimensionId, _ns: String, name: String, ann: Value) -> Result<Value, StoreError> {
521 self.patches.lock().unwrap().push((name, ann.clone()));
522 Ok(json!({ "metadata": { "annotations": ann } }))
523 }
524 async fn list_pools(&self) -> Result<Value, StoreError> {
525 Ok(json!([{ "metadata": { "name": "rio" }, "spec": { "nodeName": "rio", "arcMaxGiB": 6 } }]))
526 }
527 async fn get_pool(&self, name: String) -> Result<Value, StoreError> {
528 Ok(json!({
529 "apiVersion": "breathe.pleme.io/v1",
530 "kind": "BreatheNodePool",
531 "metadata": { "name": name },
532 "spec": { "nodeName": name, "arcMaxGiB": 6, "writeEnabled": true },
533 "status": { "phase": "Active" }
534 }))
535 }
536 async fn patch_pool_spec(&self, name: String, spec: Value) -> Result<Value, StoreError> {
537 self.patches.lock().unwrap().push((name, spec.clone()));
538 Ok(json!({ "spec": spec }))
539 }
540 async fn list_postures(&self) -> Result<Value, StoreError> {
541 Ok(json!([{ "metadata": { "name": "platform-default" }, "spec": { "setpoint": 0.8 } }]))
542 }
543 async fn get_posture(&self, name: String) -> Result<Value, StoreError> {
544 Ok(json!({
545 "apiVersion": "breathe.pleme.io/v1",
546 "kind": "BreathePosture",
547 "metadata": { "name": name },
548 "spec": { "setpoint": 0.8, "growAbove": 0.85, "growFactor": 1.25, "shrinkBelow": 0.7, "shrinkFactor": 0.9, "cooldownSeconds": 600, "maxStalenessSeconds": 120, "disruptionPolicy": "restartFreeOnly" }
549 }))
550 }
551 async fn patch_posture_spec(&self, name: String, spec: Value) -> Result<Value, StoreError> {
552 self.patches.lock().unwrap().push((name, spec.clone()));
553 Ok(json!({ "spec": spec }))
554 }
555 fn catalog(&self) -> Value {
556 breathe_facade::catalog_json()
557 }
558 }
559
560 async fn body_json(resp: Response) -> Value {
561 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
562 serde_json::from_slice(&bytes).unwrap()
563 }
564
565 #[tokio::test]
570 async fn set_dry_run_route_refuses_where_the_field_is_inert() {
571 let mock = Arc::new(MockStore::default());
572 let app = router(mock.clone());
573 let resp = app
574 .oneshot(
575 Request::builder()
576 .method("PATCH")
577 .uri("/api/v1/bands/arc/pangea-system/rio-arc/dry-run")
578 .header("content-type", "application/json")
579 .body(Body::from(r#"{"dryRun":false}"#))
580 .unwrap(),
581 )
582 .await
583 .unwrap();
584 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
585 assert!(mock.patches.lock().unwrap().is_empty(), "a refused call must not reach the store");
586 }
587
588 #[tokio::test]
590 async fn set_dry_run_route_applies_on_a_param_kind() {
591 let mock = Arc::new(MockStore::default());
592 let app = router(mock.clone());
593 let resp = app
594 .oneshot(
595 Request::builder()
596 .method("PATCH")
597 .uri("/api/v1/bands/host-param/camelot/vm-dirty/dry-run")
598 .header("content-type", "application/json")
599 .body(Body::from(r#"{"dryRun":true}"#))
600 .unwrap(),
601 )
602 .await
603 .unwrap();
604 assert_eq!(resp.status(), StatusCode::OK);
605 assert_eq!(mock.patches.lock().unwrap()[0].1, json!({ "dryRun": true }));
606 }
607
608 #[tokio::test]
611 async fn every_shipped_dimension_is_routable() {
612 for kind in DimensionId::ALL {
613 let app = router(Arc::new(MockStore::default()));
614 let uri = ["/api/v1/bands/", kind.as_str()].concat();
615 let resp = app.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()).await.unwrap();
616 assert_eq!(resp.status(), StatusCode::OK, "{kind} must be routable");
617 }
618 }
619
620 #[tokio::test]
621 async fn write_intent_route_writes_the_intent_and_refuses_an_unattributed_go_live() {
622 let mock = Arc::new(MockStore::default());
623 let app = router(mock.clone());
624 let patch_intent = |app: Router, body: &'static str| async move {
625 app.oneshot(
626 Request::builder()
627 .method("PATCH")
628 .uri("/api/v1/bands/cpu/camelot/coredns/write-intent")
629 .header("content-type", "application/json")
630 .body(Body::from(body))
631 .unwrap(),
632 )
633 .await
634 .unwrap()
635 };
636 let resp = patch_intent(app.clone(), r#"{"intent":"write"}"#).await;
637 assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "an unattributed go-live is refused");
638 assert!(mock.patches.lock().unwrap().is_empty());
639
640 let resp = patch_intent(app, r#"{"intent":"write","authorizedBy":"drzzln 2026-07-26"}"#).await;
641 assert_eq!(resp.status(), StatusCode::OK);
642 assert_eq!(
643 mock.patches.lock().unwrap()[0].1,
644 json!({ "writeIntent": { "intent": "write", "authorizedBy": "drzzln 2026-07-26" } })
645 );
646 }
647
648 #[tokio::test]
649 async fn confirm_route_sets_the_operator_annotation() {
650 let mock = Arc::new(MockStore::default());
651 let app = router(mock.clone());
652 let resp = app
653 .oneshot(
654 Request::builder()
655 .method("PATCH")
656 .uri("/api/v1/bands/memory/camelot/b/confirm")
657 .header("content-type", "application/json")
658 .body(Body::from(r#"{"confirmed":true}"#))
659 .unwrap(),
660 )
661 .await
662 .unwrap();
663 assert_eq!(resp.status(), StatusCode::OK);
664 assert_eq!(mock.patches.lock().unwrap()[0].1, json!({ breathe_provider::CONFIRMED_ANNOTATION: "true" }));
665 }
666
667 #[tokio::test]
668 async fn unknown_band_kind_is_400() {
669 let app = router(Arc::new(MockStore::default()));
670 let resp = app
671 .oneshot(Request::builder().uri("/api/v1/bands/bogus").body(Body::empty()).unwrap())
672 .await
673 .unwrap();
674 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
675 }
676
677 #[tokio::test]
678 async fn catalog_route_returns_all_dimensions() {
679 let app = router(Arc::new(MockStore::default()));
680 let resp = app
681 .oneshot(Request::builder().uri("/api/v1/catalog").body(Body::empty()).unwrap())
682 .await
683 .unwrap();
684 assert_eq!(resp.status(), StatusCode::OK);
685 let v = body_json(resp).await;
686 assert_eq!(v["dimensions"].as_array().unwrap().len(), breathe_catalog::ALL_DIMENSIONS.len());
689 }
690
691 #[tokio::test]
692 async fn write_enabled_route_patches_the_pool() {
693 let mock = Arc::new(MockStore::default());
694 let app = router(mock.clone());
695 let resp = app
696 .oneshot(
697 Request::builder()
698 .method("PATCH")
699 .uri("/api/v1/nodepools/rio/write-enabled")
700 .header("content-type", "application/json")
701 .body(Body::from(r#"{"writeEnabled":true}"#))
702 .unwrap(),
703 )
704 .await
705 .unwrap();
706 assert_eq!(resp.status(), StatusCode::OK);
707 assert_eq!(mock.patches.lock().unwrap()[0].1, json!({ "writeEnabled": true }));
708 }
709
710 #[tokio::test]
711 async fn graphql_set_dry_run_errors_where_the_field_is_inert() {
712 let mock = Arc::new(MockStore::default());
713 let schema = graphql::schema(mock.clone());
714 let resp = schema
715 .execute(r#"mutation { setDryRun(kind:"arc", namespace:"pangea-system", name:"rio-arc", dryRun:false) }"#)
716 .await;
717 assert!(!resp.errors.is_empty(), "an inert dryRun write must not report success");
718 assert!(mock.patches.lock().unwrap().is_empty());
719 }
720
721 #[tokio::test]
722 async fn graphql_set_write_intent_mutation_patches_the_band() {
723 let mock = Arc::new(MockStore::default());
724 let schema = graphql::schema(mock.clone());
725 let resp = schema
726 .execute(r#"mutation { setWriteIntent(kind:"cpu", namespace:"camelot", name:"coredns", intent:"observe") }"#)
727 .await;
728 assert!(resp.errors.is_empty(), "{:?}", resp.errors);
729 assert_eq!(mock.patches.lock().unwrap()[0].1, json!({ "writeIntent": { "intent": "observe" } }));
730 }
731
732 #[tokio::test]
734 async fn graphql_reaches_the_previously_invisible_kinds() {
735 let schema = graphql::schema(Arc::new(MockStore::default()));
736 for kind in ["cgroup-cpu", "host-param", "kube-param", "app-param", "replica"] {
737 let q = ["{ bands(kind:\"", kind, "\") }"].concat();
738 let resp = schema.execute(q).await;
739 assert!(resp.errors.is_empty(), "{kind}: {:?}", resp.errors);
740 }
741 }
742
743 #[tokio::test]
744 async fn graphql_catalog_query_returns_dimensions() {
745 let schema = graphql::schema(Arc::new(MockStore::default()));
746 let resp = schema.execute("{ catalog }").await;
747 assert!(resp.errors.is_empty(), "{:?}", resp.errors);
748 assert!(resp.data.to_string().contains("dimensions"));
749 }
750
751 #[tokio::test]
752 async fn grpc_set_write_enabled_returns_typed_nodepool() {
753 use grpc::pb::breathe_server::Breathe;
754 let mock = Arc::new(MockStore::default());
755 let svc = grpc::GrpcService { store: mock.clone() };
756 let resp = svc
757 .nodepool_set_write_enabled(tonic::Request::new(grpc::pb::NodepoolSetWriteEnabledRequest { name: "rio".into(), write_enabled: true }))
758 .await
759 .unwrap();
760 assert!(resp.into_inner().spec.unwrap().write_enabled);
762 assert_eq!(mock.patches.lock().unwrap()[0].1, json!({ "writeEnabled": true }));
763 }
764
765 #[tokio::test]
766 async fn grpc_band_get_returns_typed_band() {
767 use grpc::pb::breathe_server::Breathe;
768 let svc = grpc::GrpcService { store: Arc::new(MockStore::default()) };
769 let resp = svc
770 .band_get(tonic::Request::new(grpc::pb::BandGetRequest {
771 kind: grpc::pb::BandKind::Arc as i32,
772 namespace: "pangea-system".into(),
773 name: "rio-arc".into(),
774 }))
775 .await
776 .unwrap();
777 let band = resp.into_inner();
778 assert_eq!(band.api_version, "breathe.pleme.io/v1");
779 assert!((band.spec.unwrap().setpoint.unwrap() - 0.8).abs() < 1e-9);
781 assert_eq!(band.status.unwrap().phase, "Holding");
782 }
783
784 #[tokio::test]
785 async fn grpc_band_patch_transmits_zero_values_via_field_presence() {
786 use grpc::pb::breathe_server::Breathe;
790 let mock = Arc::new(MockStore::default());
791 let svc = grpc::GrpcService { store: mock.clone() };
792 let body = grpc::pb::BandSpec { dry_run: Some(false), ..Default::default() };
793 svc.band_patch(tonic::Request::new(grpc::pb::BandPatchRequest {
794 kind: grpc::pb::BandKind::Arc as i32,
795 namespace: "pangea-system".into(),
796 name: "rio-arc".into(),
797 body: Some(body),
798 }))
799 .await
800 .unwrap();
801 assert_eq!(mock.patches.lock().unwrap()[0].1, json!({ "dryRun": false }));
803 }
804
805 #[tokio::test]
806 async fn grpc_band_list_returns_typed_items() {
807 use grpc::pb::breathe_server::Breathe;
808 let svc = grpc::GrpcService { store: Arc::new(MockStore::default()) };
809 let resp = svc
810 .band_list(tonic::Request::new(grpc::pb::BandListRequest {
811 kind: grpc::pb::BandKind::Arc as i32,
812 namespace: String::new(),
813 }))
814 .await
815 .unwrap();
816 assert_eq!(resp.into_inner().items.len(), 1);
817 }
818
819 #[tokio::test]
820 async fn grpc_catalog_list_returns_typed_catalog() {
821 use grpc::pb::breathe_server::Breathe;
823 let svc = grpc::GrpcService { store: Arc::new(MockStore::default()) };
824 let resp = svc.catalog_list(tonic::Request::new(grpc::pb::CatalogListRequest {})).await.unwrap();
825 let cat = resp.into_inner();
826 assert_eq!(cat.dimensions.len(), breathe_catalog::ALL_DIMENSIONS.len());
829 assert!(cat.dimensions.iter().any(|d| d.id == "arc" && d.is_host));
830 assert!(cat.dimensions.iter().any(|d| d.id == "cgroup-cpu" && d.is_host));
831 assert!(cat.dimensions.iter().any(|d| d.id == "memory" && !d.is_host));
832 }
833
834 #[tokio::test]
835 async fn grpc_set_dry_run_refuses_where_inert_and_applies_where_honored() {
836 use grpc::pb::breathe_server::Breathe;
837 let mock = Arc::new(MockStore::default());
838 let svc = grpc::GrpcService { store: mock.clone() };
839 let call = |kind: grpc::pb::BandKind| {
840 tonic::Request::new(grpc::pb::BandSetDryRunRequest {
841 kind: kind as i32,
842 namespace: "pangea-system".into(),
843 name: "b".into(),
844 dry_run: false,
845 })
846 };
847 let err = svc.band_set_dry_run(call(grpc::pb::BandKind::Arc)).await.unwrap_err();
848 assert_eq!(err.code(), tonic::Code::FailedPrecondition);
849 assert!(mock.patches.lock().unwrap().is_empty());
850
851 let resp = svc.band_set_dry_run(call(grpc::pb::BandKind::HostParam)).await.unwrap();
852 assert_eq!(resp.into_inner().spec.unwrap().dry_run, Some(false));
853 assert_eq!(mock.patches.lock().unwrap()[0].1, json!({ "dryRun": false }));
854 }
855
856 #[tokio::test]
861 async fn grpc_band_patch_carries_write_intent_and_refuses_an_unattributed_go_live() {
862 use grpc::pb::breathe_server::Breathe;
863 let mock = Arc::new(MockStore::default());
864 let svc = grpc::GrpcService { store: mock.clone() };
865 let call = |wi: grpc::pb::WriteIntent| {
866 tonic::Request::new(grpc::pb::BandPatchRequest {
867 kind: grpc::pb::BandKind::Cpu as i32,
868 namespace: "camelot".into(),
869 name: "coredns".into(),
870 body: Some(grpc::pb::BandSpec { write_intent: Some(wi), ..Default::default() }),
871 })
872 };
873 let unattributed =
874 grpc::pb::WriteIntent { intent: "write".into(), confirm_after_seconds: None, authorized_by: None };
875 let err = svc.band_patch(call(unattributed)).await.unwrap_err();
876 assert_eq!(err.code(), tonic::Code::InvalidArgument);
877 assert!(mock.patches.lock().unwrap().is_empty(), "a refused go-live must not reach the store");
878
879 let observe =
880 grpc::pb::WriteIntent { intent: "observe".into(), confirm_after_seconds: None, authorized_by: None };
881 svc.band_patch(call(observe)).await.unwrap();
882 assert_eq!(mock.patches.lock().unwrap()[0].1["writeIntent"]["intent"], "observe");
883 }
884
885 #[test]
895 fn openapi_spec_band_kinds_match_the_code() {
896 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../spec/breathe.openapi.yaml");
897 let raw = std::fs::read_to_string(path).expect("the spec is where the crate doc says it is");
898 let spec: serde_yaml::Value = serde_yaml::from_str(&raw).expect("spec/breathe.openapi.yaml must be valid YAML");
899
900 let declared: Vec<String> = spec["components"]["schemas"]["BandKind"]["enum"]
901 .as_sequence()
902 .expect("BandKind.enum is a sequence")
903 .iter()
904 .map(|v| v.as_str().expect("each enum value is a string").to_owned())
905 .collect();
906 let canonical: Vec<String> = DimensionId::ALL.iter().map(|d| d.as_str().to_owned()).collect();
907 assert_eq!(declared, canonical, "the OpenAPI BandKind enum has drifted from DimensionId::ALL");
908
909 let paths = spec["paths"].as_mapping().expect("paths is a mapping");
912 for route in [
913 "/api/v1/bands/{kind}/{namespace}/{name}/write-intent",
914 "/api/v1/bands/{kind}/{namespace}/{name}/confirm",
915 ] {
916 assert!(
917 paths.contains_key(serde_yaml::Value::from(route)),
918 "{route} is served by the router but absent from the spec"
919 );
920 }
921 }
922
923 #[test]
927 fn grpc_band_kind_covers_every_dimension_without_renumbering() {
928 use grpc::pb::BandKind as P;
929 for (p, d) in [
930 (P::Memory, DimensionId::Memory),
931 (P::Cpu, DimensionId::Cpu),
932 (P::Storage, DimensionId::Storage),
933 (P::Replica, DimensionId::Replica),
934 (P::Arc, DimensionId::Arc),
935 (P::Cgroup, DimensionId::Cgroup),
936 (P::CgroupCpu, DimensionId::CgroupCpu),
937 (P::HostParam, DimensionId::HostParam),
938 (P::KubeParam, DimensionId::KubeParam),
939 (P::AppParam, DimensionId::AppParam),
940 (P::Request, DimensionId::Request),
941 ] {
942 assert_eq!(grpc::kind_of(p as i32).unwrap(), d);
943 }
944 assert_eq!(P::Memory as i32, 1);
946 assert_eq!(P::Cpu as i32, 2);
947 assert_eq!(P::Storage as i32, 3);
948 assert_eq!(P::Arc as i32, 4);
949 assert_eq!(P::Cgroup as i32, 5);
950 assert_eq!(P::Request as i32, 11);
952 }
953
954 #[test]
959 fn every_dimension_has_a_wire_number() {
960 for d in DimensionId::ALL {
961 let found = (1..=64).any(|n| grpc::kind_of(n).ok() == Some(d));
962 assert!(found, "dimension {d} has no BandKind wire number — add one, never renumber");
963 }
964 }
965}