1use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13use crate::{
14 call_mode::CallMode,
15 manifest::{CapabilityDeclarations, ProviderRole},
16 scope::{
17 ScopeEnd, ScopeEndResult, ScopeEnded, ScopeRecord, ScopeRecordResult, ScopeStamp,
18 ScopeStatus,
19 },
20 BindIdentity, Principal, RouteCloseReason, RouteTarget,
21};
22
23pub const MODULE_CONTROL_OP_HEALTH_CHECK: &str = "health.check";
24pub const MODULE_TO_SUBC_OP_CATALOG_UPDATE: &str = "catalog.update";
25
26#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
27#[serde(rename_all = "snake_case")]
28pub enum HealthStatus {
29 Ok,
30 Degraded,
31 Failing,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35pub struct HealthReport {
36 pub status: HealthStatus,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub detail: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub metrics: Option<Value>,
41}
42
43impl HealthReport {
44 pub fn ok() -> Self {
45 Self {
46 status: HealthStatus::Ok,
47 detail: None,
48 metrics: None,
49 }
50 }
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
55#[serde(tag = "op")]
56#[allow(clippy::large_enum_variant)]
59pub enum ModuleControlRequest {
60 #[serde(rename = "route.bind")]
61 RouteBind {
62 route_channel: u16,
63 epoch: u32,
64 target: RouteTarget,
65 identity: BindIdentity,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
80 principal: Option<Principal>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
90 consumer_capabilities: Option<Vec<String>>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
100 role_versions: Option<BTreeMap<String, String>>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 admission_facts: Option<Value>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
114 scope: Option<ScopeStamp>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
120 call_mode: Option<CallMode>,
121 },
122 #[serde(rename = "health.check")]
123 HealthCheck {},
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
128#[serde(tag = "op")]
129pub enum ModuleControlCommand {
130 #[serde(rename = "module.draining")]
131 Draining {
132 reason: RouteCloseReason,
133 deadline_ms: u64,
149 },
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
154#[serde(tag = "op")]
155pub enum ModuleControlResponse {
156 #[serde(rename = "route.bind")]
158 RouteBindAck {},
159 #[serde(rename = "health.check")]
160 HealthCheck {
161 status: HealthStatus,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 detail: Option<String>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 metrics: Option<Value>,
166 },
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
175#[serde(tag = "op")]
176pub enum ModuleControlRequestFromModule {
177 #[serde(rename = "catalog.update")]
178 CatalogUpdate {
179 provides: Vec<ProviderRole>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
184 capabilities: Option<CapabilityDeclarations>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
190 ready: Option<bool>,
191 },
192 #[serde(rename = "supervisor.live_roots")]
193 LiveRoots {},
194 #[serde(rename = "scope.sync")]
200 ScopeSync {
201 generation: u64,
202 scopes: Vec<ScopeRecord>,
203 },
204 #[serde(rename = "scope.apply")]
209 ScopeApply {
210 generation: u64,
211 upsert: Vec<ScopeRecord>,
212 end: Vec<ScopeEnd>,
213 },
214 #[serde(rename = "scope.describe")]
216 ScopeDescribe {
217 owner: Principal,
218 #[serde(rename = "ref")]
219 scope_ref: String,
220 },
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
225pub struct LiveRoot {
226 pub project_root: std::path::PathBuf,
227 pub bound: u64,
228 pub pending: u64,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
233#[serde(tag = "op")]
234pub enum ModuleControlResponseToModule {
235 #[serde(rename = "catalog.update")]
236 CatalogUpdate {},
237 #[serde(rename = "supervisor.live_roots")]
238 LiveRoots {
239 roots: Vec<LiveRoot>,
240 unknown_root_bindings: u64,
241 total_bindings: u64,
242 },
243 #[serde(rename = "scope.sync")]
244 ScopeSync {
245 generation: u64,
246 results: Vec<ScopeRecordResult>,
247 #[serde(default, skip_serializing_if = "Vec::is_empty")]
248 ended: Vec<ScopeEnded>,
249 },
250 #[serde(rename = "scope.apply")]
251 ScopeApply {
252 generation: u64,
253 results: Vec<ScopeRecordResult>,
254 end_results: Vec<ScopeEndResult>,
255 #[serde(default, skip_serializing_if = "Vec::is_empty")]
256 ended: Vec<ScopeEnded>,
257 },
258 #[serde(rename = "scope.describe")]
259 ScopeDescribe {
260 status: ScopeStatus,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
263 scope_epoch: Option<u64>,
264 daemon_incarnation: String,
265 owner_synced: bool,
267 owner_configured: bool,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
271 scope: Option<ScopeStamp>,
272 },
273}
274
275#[non_exhaustive]
281#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
282pub struct OperatorConfirmRequest {
283 op: OperatorConfirmOp,
284 pub summary: String,
285 pub route_channel: u16,
287 pub route_epoch: u32,
289}
290
291impl OperatorConfirmRequest {
292 pub fn new(summary: impl Into<String>, route_channel: u16, route_epoch: u32) -> Self {
293 Self {
294 op: OperatorConfirmOp::Confirm,
295 summary: summary.into(),
296 route_channel,
297 route_epoch,
298 }
299 }
300}
301
302#[non_exhaustive]
307#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
308pub struct OperatorConfirmReply {
309 op: OperatorConfirmOp,
310 pub outcome: String,
311}
312
313impl OperatorConfirmReply {
314 pub fn confirmed() -> Self {
315 Self {
316 op: OperatorConfirmOp::Confirm,
317 outcome: "confirmed".to_string(),
318 }
319 }
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
325enum OperatorConfirmOp {
326 #[serde(rename = "operator.confirm")]
327 Confirm,
328}
329
330impl From<HealthReport> for ModuleControlResponse {
331 fn from(report: HealthReport) -> Self {
332 Self::HealthCheck {
333 status: report.status,
334 detail: report.detail,
335 metrics: report.metrics,
336 }
337 }
338}
339
340impl ModuleControlResponse {
341 pub fn health_report(&self) -> Option<HealthReport> {
342 match self {
343 Self::HealthCheck {
344 status,
345 detail,
346 metrics,
347 } => Some(HealthReport {
348 status: *status,
349 detail: detail.clone(),
350 metrics: metrics.clone(),
351 }),
352 Self::RouteBindAck {} => None,
353 }
354 }
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
359#[serde(tag = "op")]
360pub enum ModuleControlPush {
361 #[serde(rename = "route.status")]
362 RouteStatus {
363 route_channel: u16,
364 route_epoch: u32,
365 status: String,
366 },
367}
368
369pub const ROLE_VERSIONS_FIELD: &str = "role_versions";
373
374pub const MAX_ROLE_VERSIONS: usize = 8;
376
377pub const MAX_ROLE_NAME_LEN: usize = 64;
379
380#[derive(Clone, Debug, PartialEq, Eq)]
382#[non_exhaustive]
383pub enum RoleVersionsError {
384 TooMany { count: usize },
386 InvalidRole { role: String },
390 InvalidVersion { role: String, version: String },
393}
394
395impl RoleVersionsError {
396 pub fn field(&self) -> &'static str {
398 ROLE_VERSIONS_FIELD
399 }
400}
401
402impl std::fmt::Display for RoleVersionsError {
403 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
404 match self {
405 Self::TooMany { count } => write!(
406 f,
407 "{ROLE_VERSIONS_FIELD} has {count} entries; at most {MAX_ROLE_VERSIONS} are allowed"
408 ),
409 Self::InvalidRole { role } => write!(
410 f,
411 "{ROLE_VERSIONS_FIELD} names role {role:?}, which is not lowercase letters and \
412 digits in words joined by '-', at most {MAX_ROLE_NAME_LEN} bytes"
413 ),
414 Self::InvalidVersion { role, version } => write!(
415 f,
416 "{ROLE_VERSIONS_FIELD} gives role {role:?} version {version:?}, which is not 'v' \
417 followed by a positive integer without leading zeros"
418 ),
419 }
420 }
421}
422
423impl std::error::Error for RoleVersionsError {}
424
425pub fn validate_role_versions(
433 role_versions: &BTreeMap<String, String>,
434) -> Result<(), RoleVersionsError> {
435 if role_versions.len() > MAX_ROLE_VERSIONS {
436 return Err(RoleVersionsError::TooMany {
437 count: role_versions.len(),
438 });
439 }
440 for (role, version) in role_versions {
441 if !is_role_name(role) {
442 return Err(RoleVersionsError::InvalidRole { role: role.clone() });
443 }
444 if !is_role_version(version) {
445 return Err(RoleVersionsError::InvalidVersion {
446 role: role.clone(),
447 version: version.clone(),
448 });
449 }
450 }
451 Ok(())
452}
453
454fn is_role_name(role: &str) -> bool {
455 !role.is_empty()
456 && role.len() <= MAX_ROLE_NAME_LEN
457 && role.split('-').all(|word| {
458 !word.is_empty()
459 && word
460 .bytes()
461 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
462 })
463}
464
465fn is_role_version(version: &str) -> bool {
466 let bytes = version.as_bytes();
467 bytes.len() >= 2
468 && bytes[0] == b'v'
469 && (b'1'..=b'9').contains(&bytes[1])
470 && bytes[2..].iter().all(u8::is_ascii_digit)
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 fn map(entries: &[(&str, &str)]) -> BTreeMap<String, String> {
478 entries
479 .iter()
480 .map(|(role, version)| (role.to_string(), version.to_string()))
481 .collect()
482 }
483
484 #[test]
485 fn role_versions_accept_role_names_and_positive_versions() {
486 for entries in [
487 vec![],
488 vec![("tool-provider", "v1")],
489 vec![("a", "v9"), ("b2", "v10"), ("x-1-y", "v1203")],
490 ] {
491 assert_eq!(
492 validate_role_versions(&map(&entries)),
493 Ok(()),
494 "{entries:?}"
495 );
496 }
497 let longest = "a".repeat(MAX_ROLE_NAME_LEN);
498 assert_eq!(validate_role_versions(&map(&[(&longest, "v1")])), Ok(()));
499 let full: BTreeMap<String, String> = (0..MAX_ROLE_VERSIONS)
500 .map(|index| (format!("role-{index}"), "v1".to_string()))
501 .collect();
502 assert_eq!(validate_role_versions(&full), Ok(()));
503 }
504
505 #[test]
506 fn role_versions_refuse_malformed_role_names() {
507 let too_long = "a".repeat(MAX_ROLE_NAME_LEN + 1);
508 for role in [
509 "",
510 "Tool-provider",
511 "tool_provider",
512 "tool provider",
513 "-tool",
514 "tool-",
515 "tool--provider",
516 "tool.provider",
517 "outil-é",
518 too_long.as_str(),
519 ] {
520 let error = validate_role_versions(&map(&[(role, "v1")])).unwrap_err();
521 assert_eq!(
522 error,
523 RoleVersionsError::InvalidRole {
524 role: role.to_string()
525 },
526 "{role:?}"
527 );
528 assert_eq!(error.field(), "role_versions");
529 }
530 }
531
532 #[test]
533 fn role_versions_refuse_malformed_versions() {
534 for version in [
535 "", "v", "v0", "v01", "1", "V1", "v1.0", "v-1", "v1 ", " v1", "vx",
536 ] {
537 let error = validate_role_versions(&map(&[("tool-provider", version)])).unwrap_err();
538 assert_eq!(
539 error,
540 RoleVersionsError::InvalidVersion {
541 role: "tool-provider".to_string(),
542 version: version.to_string(),
543 },
544 "{version:?}"
545 );
546 assert_eq!(error.field(), "role_versions");
547 }
548 }
549
550 #[test]
551 fn role_versions_refuse_more_than_eight_entries() {
552 let nine: BTreeMap<String, String> = (0..=MAX_ROLE_VERSIONS)
553 .map(|index| (format!("role-{index}"), "v1".to_string()))
554 .collect();
555 let error = validate_role_versions(&nine).unwrap_err();
556 assert_eq!(error, RoleVersionsError::TooMany { count: 9 });
557 assert_eq!(error.field(), "role_versions");
558 assert!(error.to_string().starts_with("role_versions"), "{error}");
559 }
560
561 #[test]
562 fn route_bind_omits_absent_role_versions_and_carries_present_ones_verbatim() {
563 let bind = |role_versions| ModuleControlRequest::RouteBind {
564 route_channel: 1,
565 epoch: 1,
566 target: crate::RouteTarget::ToolProvider {
567 module_id: "aft".to_string(),
568 },
569 identity: crate::BindIdentity::new("/tmp/p", "h", "s"),
570 principal: None,
571 consumer_capabilities: None,
572 role_versions,
573 admission_facts: None,
574 scope: None,
575 call_mode: None,
576 };
577 let absent = serde_json::to_value(bind(None)).unwrap();
578 assert!(absent.get("role_versions").is_none(), "{absent}");
579 let present = bind(Some(map(&[("tool-provider", "v1")])));
580 let encoded = serde_json::to_value(&present).unwrap();
581 assert_eq!(
582 encoded["role_versions"],
583 serde_json::json!({ "tool-provider": "v1" })
584 );
585 let decoded: ModuleControlRequest = serde_json::from_value(encoded).unwrap();
586 assert_eq!(decoded, present);
587 }
588}