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