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
246impl From<HealthReport> for ModuleControlResponse {
247 fn from(report: HealthReport) -> Self {
248 Self::HealthCheck {
249 status: report.status,
250 detail: report.detail,
251 metrics: report.metrics,
252 }
253 }
254}
255
256impl ModuleControlResponse {
257 pub fn health_report(&self) -> Option<HealthReport> {
258 match self {
259 Self::HealthCheck {
260 status,
261 detail,
262 metrics,
263 } => Some(HealthReport {
264 status: *status,
265 detail: detail.clone(),
266 metrics: metrics.clone(),
267 }),
268 Self::RouteBindAck {} => None,
269 }
270 }
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
275#[serde(tag = "op")]
276pub enum ModuleControlPush {
277 #[serde(rename = "route.status")]
278 RouteStatus {
279 route_channel: u16,
280 route_epoch: u32,
281 status: String,
282 },
283}
284
285pub const ROLE_VERSIONS_FIELD: &str = "role_versions";
289
290pub const MAX_ROLE_VERSIONS: usize = 8;
292
293pub const MAX_ROLE_NAME_LEN: usize = 64;
295
296#[derive(Clone, Debug, PartialEq, Eq)]
298#[non_exhaustive]
299pub enum RoleVersionsError {
300 TooMany { count: usize },
302 InvalidRole { role: String },
306 InvalidVersion { role: String, version: String },
309}
310
311impl RoleVersionsError {
312 pub fn field(&self) -> &'static str {
314 ROLE_VERSIONS_FIELD
315 }
316}
317
318impl std::fmt::Display for RoleVersionsError {
319 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
320 match self {
321 Self::TooMany { count } => write!(
322 f,
323 "{ROLE_VERSIONS_FIELD} has {count} entries; at most {MAX_ROLE_VERSIONS} are allowed"
324 ),
325 Self::InvalidRole { role } => write!(
326 f,
327 "{ROLE_VERSIONS_FIELD} names role {role:?}, which is not lowercase letters and \
328 digits in words joined by '-', at most {MAX_ROLE_NAME_LEN} bytes"
329 ),
330 Self::InvalidVersion { role, version } => write!(
331 f,
332 "{ROLE_VERSIONS_FIELD} gives role {role:?} version {version:?}, which is not 'v' \
333 followed by a positive integer without leading zeros"
334 ),
335 }
336 }
337}
338
339impl std::error::Error for RoleVersionsError {}
340
341pub fn validate_role_versions(
349 role_versions: &BTreeMap<String, String>,
350) -> Result<(), RoleVersionsError> {
351 if role_versions.len() > MAX_ROLE_VERSIONS {
352 return Err(RoleVersionsError::TooMany {
353 count: role_versions.len(),
354 });
355 }
356 for (role, version) in role_versions {
357 if !is_role_name(role) {
358 return Err(RoleVersionsError::InvalidRole { role: role.clone() });
359 }
360 if !is_role_version(version) {
361 return Err(RoleVersionsError::InvalidVersion {
362 role: role.clone(),
363 version: version.clone(),
364 });
365 }
366 }
367 Ok(())
368}
369
370fn is_role_name(role: &str) -> bool {
371 !role.is_empty()
372 && role.len() <= MAX_ROLE_NAME_LEN
373 && role.split('-').all(|word| {
374 !word.is_empty()
375 && word
376 .bytes()
377 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
378 })
379}
380
381fn is_role_version(version: &str) -> bool {
382 let bytes = version.as_bytes();
383 bytes.len() >= 2
384 && bytes[0] == b'v'
385 && (b'1'..=b'9').contains(&bytes[1])
386 && bytes[2..].iter().all(u8::is_ascii_digit)
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 fn map(entries: &[(&str, &str)]) -> BTreeMap<String, String> {
394 entries
395 .iter()
396 .map(|(role, version)| (role.to_string(), version.to_string()))
397 .collect()
398 }
399
400 #[test]
401 fn role_versions_accept_role_names_and_positive_versions() {
402 for entries in [
403 vec![],
404 vec![("tool-provider", "v1")],
405 vec![("a", "v9"), ("b2", "v10"), ("x-1-y", "v1203")],
406 ] {
407 assert_eq!(
408 validate_role_versions(&map(&entries)),
409 Ok(()),
410 "{entries:?}"
411 );
412 }
413 let longest = "a".repeat(MAX_ROLE_NAME_LEN);
414 assert_eq!(validate_role_versions(&map(&[(&longest, "v1")])), Ok(()));
415 let full: BTreeMap<String, String> = (0..MAX_ROLE_VERSIONS)
416 .map(|index| (format!("role-{index}"), "v1".to_string()))
417 .collect();
418 assert_eq!(validate_role_versions(&full), Ok(()));
419 }
420
421 #[test]
422 fn role_versions_refuse_malformed_role_names() {
423 let too_long = "a".repeat(MAX_ROLE_NAME_LEN + 1);
424 for role in [
425 "",
426 "Tool-provider",
427 "tool_provider",
428 "tool provider",
429 "-tool",
430 "tool-",
431 "tool--provider",
432 "tool.provider",
433 "outil-é",
434 too_long.as_str(),
435 ] {
436 let error = validate_role_versions(&map(&[(role, "v1")])).unwrap_err();
437 assert_eq!(
438 error,
439 RoleVersionsError::InvalidRole {
440 role: role.to_string()
441 },
442 "{role:?}"
443 );
444 assert_eq!(error.field(), "role_versions");
445 }
446 }
447
448 #[test]
449 fn role_versions_refuse_malformed_versions() {
450 for version in [
451 "", "v", "v0", "v01", "1", "V1", "v1.0", "v-1", "v1 ", " v1", "vx",
452 ] {
453 let error = validate_role_versions(&map(&[("tool-provider", version)])).unwrap_err();
454 assert_eq!(
455 error,
456 RoleVersionsError::InvalidVersion {
457 role: "tool-provider".to_string(),
458 version: version.to_string(),
459 },
460 "{version:?}"
461 );
462 assert_eq!(error.field(), "role_versions");
463 }
464 }
465
466 #[test]
467 fn role_versions_refuse_more_than_eight_entries() {
468 let nine: BTreeMap<String, String> = (0..=MAX_ROLE_VERSIONS)
469 .map(|index| (format!("role-{index}"), "v1".to_string()))
470 .collect();
471 let error = validate_role_versions(&nine).unwrap_err();
472 assert_eq!(error, RoleVersionsError::TooMany { count: 9 });
473 assert_eq!(error.field(), "role_versions");
474 assert!(error.to_string().starts_with("role_versions"), "{error}");
475 }
476
477 #[test]
478 fn route_bind_omits_absent_role_versions_and_carries_present_ones_verbatim() {
479 let bind = |role_versions| ModuleControlRequest::RouteBind {
480 route_channel: 1,
481 epoch: 1,
482 target: crate::RouteTarget::ToolProvider {
483 module_id: "aft".to_string(),
484 },
485 identity: crate::BindIdentity::new("/tmp/p", "h", "s"),
486 principal: None,
487 consumer_capabilities: None,
488 role_versions,
489 admission_facts: None,
490 scope: None,
491 };
492 let absent = serde_json::to_value(bind(None)).unwrap();
493 assert!(absent.get("role_versions").is_none(), "{absent}");
494 let present = bind(Some(map(&[("tool-provider", "v1")])));
495 let encoded = serde_json::to_value(&present).unwrap();
496 assert_eq!(
497 encoded["role_versions"],
498 serde_json::json!({ "tool-provider": "v1" })
499 );
500 let decoded: ModuleControlRequest = serde_json::from_value(encoded).unwrap();
501 assert_eq!(decoded, present);
502 }
503}