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