1use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use crate::{
14 manifest::{CapabilityDeclarations, ProviderRole},
15 scope::{ScopeEnded, ScopeRecord, ScopeRecordResult, ScopeStamp, ScopeStatus},
16 BindIdentity, Principal, RouteCloseReason, RouteTarget,
17};
18
19pub const MODULE_CONTROL_OP_HEALTH_CHECK: &str = "health.check";
20pub const MODULE_TO_SUBC_OP_CATALOG_UPDATE: &str = "catalog.update";
21
22#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
23#[serde(rename_all = "snake_case")]
24pub enum HealthStatus {
25 Ok,
26 Degraded,
27 Failing,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31pub struct HealthReport {
32 pub status: HealthStatus,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub detail: Option<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub metrics: Option<Value>,
37}
38
39impl HealthReport {
40 pub fn ok() -> Self {
41 Self {
42 status: HealthStatus::Ok,
43 detail: None,
44 metrics: None,
45 }
46 }
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51#[serde(tag = "op")]
52#[allow(clippy::large_enum_variant)]
55pub enum ModuleControlRequest {
56 #[serde(rename = "route.bind")]
57 RouteBind {
58 route_channel: u16,
59 epoch: u32,
60 target: RouteTarget,
61 identity: BindIdentity,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
76 principal: Option<Principal>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
85 consumer_capabilities: Option<Vec<String>>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
95 role_versions: Option<BTreeMap<String, String>>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 admission_facts: Option<Value>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
109 scope: Option<ScopeStamp>,
110 },
111 #[serde(rename = "health.check")]
112 HealthCheck {},
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
117#[serde(tag = "op")]
118pub enum ModuleControlCommand {
119 #[serde(rename = "module.draining")]
120 Draining {
121 reason: RouteCloseReason,
122 deadline_ms: u64,
138 },
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
143#[serde(tag = "op")]
144pub enum ModuleControlResponse {
145 #[serde(rename = "route.bind")]
147 RouteBindAck {},
148 #[serde(rename = "health.check")]
149 HealthCheck {
150 status: HealthStatus,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 detail: Option<String>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 metrics: Option<Value>,
155 },
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
164#[serde(tag = "op")]
165pub enum ModuleControlRequestFromModule {
166 #[serde(rename = "catalog.update")]
167 CatalogUpdate {
168 provides: Vec<ProviderRole>,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
173 capabilities: Option<CapabilityDeclarations>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
179 ready: Option<bool>,
180 },
181 #[serde(rename = "supervisor.live_roots")]
182 LiveRoots {},
183 #[serde(rename = "scope.sync")]
189 ScopeSync {
190 generation: u64,
191 scopes: Vec<ScopeRecord>,
192 },
193 #[serde(rename = "scope.describe")]
195 ScopeDescribe {
196 owner: Principal,
197 #[serde(rename = "ref")]
198 scope_ref: String,
199 },
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
204pub struct LiveRoot {
205 pub project_root: std::path::PathBuf,
206 pub bound: u64,
207 pub pending: u64,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
212#[serde(tag = "op")]
213pub enum ModuleControlResponseToModule {
214 #[serde(rename = "catalog.update")]
215 CatalogUpdate {},
216 #[serde(rename = "supervisor.live_roots")]
217 LiveRoots {
218 roots: Vec<LiveRoot>,
219 unknown_root_bindings: u64,
220 total_bindings: u64,
221 },
222 #[serde(rename = "scope.sync")]
223 ScopeSync {
224 generation: u64,
225 results: Vec<ScopeRecordResult>,
226 #[serde(default, skip_serializing_if = "Vec::is_empty")]
227 ended: Vec<ScopeEnded>,
228 },
229 #[serde(rename = "scope.describe")]
230 ScopeDescribe {
231 status: ScopeStatus,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
234 scope_epoch: Option<u64>,
235 daemon_incarnation: String,
236 owner_synced: bool,
238 owner_configured: bool,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
242 scope: Option<ScopeStamp>,
243 },
244}
245
246#[non_exhaustive]
252#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
253pub struct OperatorConfirmRequest {
254 op: OperatorConfirmOp,
255 pub summary: String,
256 pub route_channel: u16,
258 pub route_epoch: u32,
260}
261
262impl OperatorConfirmRequest {
263 pub fn new(summary: impl Into<String>, route_channel: u16, route_epoch: u32) -> Self {
264 Self {
265 op: OperatorConfirmOp::Confirm,
266 summary: summary.into(),
267 route_channel,
268 route_epoch,
269 }
270 }
271}
272
273#[non_exhaustive]
278#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
279pub struct OperatorConfirmReply {
280 op: OperatorConfirmOp,
281 pub outcome: String,
282}
283
284impl OperatorConfirmReply {
285 pub fn confirmed() -> Self {
286 Self {
287 op: OperatorConfirmOp::Confirm,
288 outcome: "confirmed".to_string(),
289 }
290 }
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
296enum OperatorConfirmOp {
297 #[serde(rename = "operator.confirm")]
298 Confirm,
299}
300
301impl From<HealthReport> for ModuleControlResponse {
302 fn from(report: HealthReport) -> Self {
303 Self::HealthCheck {
304 status: report.status,
305 detail: report.detail,
306 metrics: report.metrics,
307 }
308 }
309}
310
311impl ModuleControlResponse {
312 pub fn health_report(&self) -> Option<HealthReport> {
313 match self {
314 Self::HealthCheck {
315 status,
316 detail,
317 metrics,
318 } => Some(HealthReport {
319 status: *status,
320 detail: detail.clone(),
321 metrics: metrics.clone(),
322 }),
323 Self::RouteBindAck {} => None,
324 }
325 }
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
330#[serde(tag = "op")]
331pub enum ModuleControlPush {
332 #[serde(rename = "route.status")]
333 RouteStatus {
334 route_channel: u16,
335 route_epoch: u32,
336 status: String,
337 },
338}
339
340pub const ROLE_VERSIONS_FIELD: &str = "role_versions";
344
345pub const MAX_ROLE_VERSIONS: usize = 8;
347
348pub const MAX_ROLE_NAME_LEN: usize = 64;
350
351#[derive(Clone, Debug, PartialEq, Eq)]
353#[non_exhaustive]
354pub enum RoleVersionsError {
355 TooMany { count: usize },
357 InvalidRole { role: String },
361 InvalidVersion { role: String, version: String },
364}
365
366impl RoleVersionsError {
367 pub fn field(&self) -> &'static str {
369 ROLE_VERSIONS_FIELD
370 }
371}
372
373impl std::fmt::Display for RoleVersionsError {
374 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
375 match self {
376 Self::TooMany { count } => write!(
377 f,
378 "{ROLE_VERSIONS_FIELD} has {count} entries; at most {MAX_ROLE_VERSIONS} are allowed"
379 ),
380 Self::InvalidRole { role } => write!(
381 f,
382 "{ROLE_VERSIONS_FIELD} names role {role:?}, which is not lowercase letters and \
383 digits in words joined by '-', at most {MAX_ROLE_NAME_LEN} bytes"
384 ),
385 Self::InvalidVersion { role, version } => write!(
386 f,
387 "{ROLE_VERSIONS_FIELD} gives role {role:?} version {version:?}, which is not 'v' \
388 followed by a positive integer without leading zeros"
389 ),
390 }
391 }
392}
393
394impl std::error::Error for RoleVersionsError {}
395
396pub fn validate_role_versions(
404 role_versions: &BTreeMap<String, String>,
405) -> Result<(), RoleVersionsError> {
406 if role_versions.len() > MAX_ROLE_VERSIONS {
407 return Err(RoleVersionsError::TooMany {
408 count: role_versions.len(),
409 });
410 }
411 for (role, version) in role_versions {
412 if !is_role_name(role) {
413 return Err(RoleVersionsError::InvalidRole { role: role.clone() });
414 }
415 if !is_role_version(version) {
416 return Err(RoleVersionsError::InvalidVersion {
417 role: role.clone(),
418 version: version.clone(),
419 });
420 }
421 }
422 Ok(())
423}
424
425fn is_role_name(role: &str) -> bool {
426 !role.is_empty()
427 && role.len() <= MAX_ROLE_NAME_LEN
428 && role.split('-').all(|word| {
429 !word.is_empty()
430 && word
431 .bytes()
432 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
433 })
434}
435
436fn is_role_version(version: &str) -> bool {
437 let bytes = version.as_bytes();
438 bytes.len() >= 2
439 && bytes[0] == b'v'
440 && (b'1'..=b'9').contains(&bytes[1])
441 && bytes[2..].iter().all(u8::is_ascii_digit)
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447
448 fn map(entries: &[(&str, &str)]) -> BTreeMap<String, String> {
449 entries
450 .iter()
451 .map(|(role, version)| (role.to_string(), version.to_string()))
452 .collect()
453 }
454
455 #[test]
456 fn role_versions_accept_role_names_and_positive_versions() {
457 for entries in [
458 vec![],
459 vec![("tool-provider", "v1")],
460 vec![("a", "v9"), ("b2", "v10"), ("x-1-y", "v1203")],
461 ] {
462 assert_eq!(
463 validate_role_versions(&map(&entries)),
464 Ok(()),
465 "{entries:?}"
466 );
467 }
468 let longest = "a".repeat(MAX_ROLE_NAME_LEN);
469 assert_eq!(validate_role_versions(&map(&[(&longest, "v1")])), Ok(()));
470 let full: BTreeMap<String, String> = (0..MAX_ROLE_VERSIONS)
471 .map(|index| (format!("role-{index}"), "v1".to_string()))
472 .collect();
473 assert_eq!(validate_role_versions(&full), Ok(()));
474 }
475
476 #[test]
477 fn role_versions_refuse_malformed_role_names() {
478 let too_long = "a".repeat(MAX_ROLE_NAME_LEN + 1);
479 for role in [
480 "",
481 "Tool-provider",
482 "tool_provider",
483 "tool provider",
484 "-tool",
485 "tool-",
486 "tool--provider",
487 "tool.provider",
488 "outil-é",
489 too_long.as_str(),
490 ] {
491 let error = validate_role_versions(&map(&[(role, "v1")])).unwrap_err();
492 assert_eq!(
493 error,
494 RoleVersionsError::InvalidRole {
495 role: role.to_string()
496 },
497 "{role:?}"
498 );
499 assert_eq!(error.field(), "role_versions");
500 }
501 }
502
503 #[test]
504 fn role_versions_refuse_malformed_versions() {
505 for version in [
506 "", "v", "v0", "v01", "1", "V1", "v1.0", "v-1", "v1 ", " v1", "vx",
507 ] {
508 let error = validate_role_versions(&map(&[("tool-provider", version)])).unwrap_err();
509 assert_eq!(
510 error,
511 RoleVersionsError::InvalidVersion {
512 role: "tool-provider".to_string(),
513 version: version.to_string(),
514 },
515 "{version:?}"
516 );
517 assert_eq!(error.field(), "role_versions");
518 }
519 }
520
521 #[test]
522 fn role_versions_refuse_more_than_eight_entries() {
523 let nine: BTreeMap<String, String> = (0..=MAX_ROLE_VERSIONS)
524 .map(|index| (format!("role-{index}"), "v1".to_string()))
525 .collect();
526 let error = validate_role_versions(&nine).unwrap_err();
527 assert_eq!(error, RoleVersionsError::TooMany { count: 9 });
528 assert_eq!(error.field(), "role_versions");
529 assert!(error.to_string().starts_with("role_versions"), "{error}");
530 }
531
532 #[test]
533 fn route_bind_omits_absent_role_versions_and_carries_present_ones_verbatim() {
534 let bind = |role_versions| ModuleControlRequest::RouteBind {
535 route_channel: 1,
536 epoch: 1,
537 target: crate::RouteTarget::ToolProvider {
538 module_id: "aft".to_string(),
539 },
540 identity: crate::BindIdentity::new("/tmp/p", "h", "s"),
541 principal: None,
542 consumer_capabilities: None,
543 role_versions,
544 admission_facts: None,
545 scope: None,
546 };
547 let absent = serde_json::to_value(bind(None)).unwrap();
548 assert!(absent.get("role_versions").is_none(), "{absent}");
549 let present = bind(Some(map(&[("tool-provider", "v1")])));
550 let encoded = serde_json::to_value(&present).unwrap();
551 assert_eq!(
552 encoded["role_versions"],
553 serde_json::json!({ "tool-provider": "v1" })
554 );
555 let decoded: ModuleControlRequest = serde_json::from_value(encoded).unwrap();
556 assert_eq!(decoded, present);
557 }
558}