1use crate::identifiers::{is_sensitive_key, validate_text};
4use crate::{
5 BuildId, CapabilityId, ContractError, ContractResult, CoreId, CoreProfile, FeatureId, NodeId,
6 ProviderId, RuntimeMode,
7};
8use serde::{Deserialize, Serialize};
9use std::collections::{BTreeMap, BTreeSet};
10
11pub const RUNTIME_MANIFEST_VERSION: u16 = 1;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum RuntimeHealthStatus {
18 Healthy,
20 Degraded,
22 Unhealthy,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct RuntimeHealth {
29 status: RuntimeHealthStatus,
30 checked_at_ms: u64,
31 details: BTreeMap<String, String>,
32}
33
34impl RuntimeHealth {
35 pub fn new(status: RuntimeHealthStatus, checked_at_ms: u64) -> Self {
37 Self {
38 status,
39 checked_at_ms,
40 details: BTreeMap::new(),
41 }
42 }
43
44 pub fn with_detail(
46 mut self,
47 key: impl Into<String>,
48 value: impl Into<String>,
49 ) -> ContractResult<Self> {
50 let key = key.into();
51 let value = value.into();
52 validate_health_detail(&key, &value)?;
53 self.details.insert(key, value);
54 Ok(self)
55 }
56
57 pub fn status(&self) -> RuntimeHealthStatus {
59 self.status
60 }
61
62 pub fn checked_at_ms(&self) -> u64 {
64 self.checked_at_ms
65 }
66
67 pub fn details(&self) -> &BTreeMap<String, String> {
69 &self.details
70 }
71
72 fn validate(&self) -> ContractResult<()> {
73 for (key, value) in &self.details {
74 validate_health_detail(key, value)?;
75 }
76 Ok(())
77 }
78}
79
80#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum RuntimeOperationalMode {
84 Starting,
86 Discovering,
88 Syncing,
90 ReadOnly,
92 #[default]
94 ReadWrite,
95 Degraded,
97 Isolated,
99}
100
101impl RuntimeOperationalMode {
102 pub fn allows_local_queries(self) -> bool {
104 matches!(
105 self,
106 Self::ReadOnly | Self::ReadWrite | Self::Degraded | Self::Isolated
107 )
108 }
109
110 pub fn allows_writes(self) -> bool {
112 matches!(self, Self::ReadWrite)
113 }
114
115 pub fn as_str(self) -> &'static str {
117 match self {
118 Self::Starting => "starting",
119 Self::Discovering => "discovering",
120 Self::Syncing => "syncing",
121 Self::ReadOnly => "read_only",
122 Self::ReadWrite => "read_write",
123 Self::Degraded => "degraded",
124 Self::Isolated => "isolated",
125 }
126 }
127}
128
129impl TryFrom<&str> for RuntimeOperationalMode {
130 type Error = ContractError;
131
132 fn try_from(value: &str) -> Result<Self, Self::Error> {
133 match value {
134 "starting" => Ok(Self::Starting),
135 "discovering" => Ok(Self::Discovering),
136 "syncing" => Ok(Self::Syncing),
137 "read_only" => Ok(Self::ReadOnly),
138 "read_write" => Ok(Self::ReadWrite),
139 "readonly" | "readwrite" => Err(ContractError::InvalidValue {
140 field: "operational_mode",
141 reason: "NO MORE SUPPORTED PLEASE UPDATE",
142 }),
143 "degraded" => Ok(Self::Degraded),
144 "isolated" => Ok(Self::Isolated),
145 _ => Err(ContractError::InvalidValue {
146 field: "operational_mode",
147 reason: "unsupported operational mode",
148 }),
149 }
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(try_from = "RuntimeManifestData")]
158pub struct RuntimeManifestV1 {
159 manifest_version: u16,
160 runtime_version: String,
161 protocol_version: String,
162 build_id: BuildId,
163 features: BTreeSet<FeatureId>,
164 node_id: NodeId,
165 core_id: CoreId,
166 mode: RuntimeMode,
167 platform: String,
168 architecture: String,
169 storage_backend: ProviderId,
170 health: RuntimeHealth,
171 operational_mode: RuntimeOperationalMode,
172 loaded_capabilities: BTreeSet<CapabilityId>,
173 core_profile: CoreProfile,
174}
175
176#[derive(Deserialize)]
177struct RuntimeManifestData {
178 manifest_version: u16,
179 runtime_version: String,
180 protocol_version: String,
181 build_id: BuildId,
182 features: BTreeSet<FeatureId>,
183 node_id: NodeId,
184 core_id: CoreId,
185 mode: RuntimeMode,
186 platform: String,
187 architecture: String,
188 storage_backend: ProviderId,
189 health: RuntimeHealth,
190 operational_mode: RuntimeOperationalMode,
191 loaded_capabilities: BTreeSet<CapabilityId>,
192 core_profile: CoreProfile,
193}
194
195impl RuntimeManifestV1 {
196 #[allow(clippy::too_many_arguments)]
198 pub fn new(
199 runtime_version: impl Into<String>,
200 protocol_version: impl Into<String>,
201 build_id: BuildId,
202 node_id: NodeId,
203 core_id: CoreId,
204 mode: RuntimeMode,
205 platform: impl Into<String>,
206 architecture: impl Into<String>,
207 storage_backend: ProviderId,
208 health: RuntimeHealth,
209 core_profile: CoreProfile,
210 ) -> ContractResult<Self> {
211 let manifest = Self {
212 manifest_version: RUNTIME_MANIFEST_VERSION,
213 runtime_version: runtime_version.into(),
214 protocol_version: protocol_version.into(),
215 build_id,
216 features: BTreeSet::new(),
217 node_id,
218 core_id,
219 mode,
220 platform: platform.into(),
221 architecture: architecture.into(),
222 storage_backend,
223 health,
224 operational_mode: RuntimeOperationalMode::Starting,
225 loaded_capabilities: BTreeSet::new(),
226 core_profile,
227 };
228 manifest.validate()?;
229 Ok(manifest)
230 }
231
232 pub fn with_feature(mut self, feature: FeatureId) -> Self {
234 self.features.insert(feature);
235 self
236 }
237
238 pub fn with_loaded_capability(mut self, capability: CapabilityId) -> ContractResult<Self> {
240 if !self.core_profile.capabilities().contains(&capability) {
241 return Err(ContractError::InvalidValue {
242 field: "loaded_capabilities",
243 reason: "capability is not declared by the core profile",
244 });
245 }
246 self.loaded_capabilities.insert(capability);
247 Ok(self)
248 }
249
250 pub fn with_health(mut self, health: RuntimeHealth) -> ContractResult<Self> {
252 health.validate()?;
253 self.health = health;
254 Ok(self)
255 }
256
257 pub fn with_operational_mode(mut self, mode: RuntimeOperationalMode) -> Self {
259 self.operational_mode = mode;
260 self
261 }
262
263 pub fn manifest_version(&self) -> u16 {
265 self.manifest_version
266 }
267
268 pub fn runtime_version(&self) -> &str {
270 &self.runtime_version
271 }
272
273 pub fn protocol_version(&self) -> &str {
275 &self.protocol_version
276 }
277
278 pub fn build_id(&self) -> &BuildId {
280 &self.build_id
281 }
282
283 pub fn features(&self) -> &BTreeSet<FeatureId> {
285 &self.features
286 }
287
288 pub fn node_id(&self) -> &NodeId {
290 &self.node_id
291 }
292
293 pub fn core_id(&self) -> &CoreId {
295 &self.core_id
296 }
297
298 pub fn mode(&self) -> RuntimeMode {
300 self.mode
301 }
302
303 pub fn platform(&self) -> &str {
305 &self.platform
306 }
307
308 pub fn architecture(&self) -> &str {
310 &self.architecture
311 }
312
313 pub fn storage_backend(&self) -> &ProviderId {
315 &self.storage_backend
316 }
317
318 pub fn health(&self) -> &RuntimeHealth {
320 &self.health
321 }
322
323 pub fn operational_mode(&self) -> RuntimeOperationalMode {
325 self.operational_mode
326 }
327
328 pub fn loaded_capabilities(&self) -> &BTreeSet<CapabilityId> {
330 &self.loaded_capabilities
331 }
332
333 pub fn core_profile(&self) -> &CoreProfile {
335 &self.core_profile
336 }
337
338 pub fn validate(&self) -> ContractResult<()> {
340 if self.manifest_version != RUNTIME_MANIFEST_VERSION {
341 return Err(ContractError::InvalidValue {
342 field: "manifest_version",
343 reason: "unsupported runtime manifest version",
344 });
345 }
346 validate_text("runtime_version", &self.runtime_version, 64)?;
347 validate_text("protocol_version", &self.protocol_version, 64)?;
348 validate_text("platform", &self.platform, 128)?;
349 validate_text("architecture", &self.architecture, 128)?;
350 self.health.validate()?;
351 self.core_profile.validate()?;
352 if !self
353 .loaded_capabilities
354 .is_subset(self.core_profile.capabilities())
355 {
356 return Err(ContractError::InvalidValue {
357 field: "loaded_capabilities",
358 reason: "loaded capabilities must be declared by the core profile",
359 });
360 }
361 Ok(())
362 }
363}
364
365impl TryFrom<RuntimeManifestData> for RuntimeManifestV1 {
366 type Error = ContractError;
367
368 fn try_from(data: RuntimeManifestData) -> Result<Self, Self::Error> {
369 let manifest = Self {
370 manifest_version: data.manifest_version,
371 runtime_version: data.runtime_version,
372 protocol_version: data.protocol_version,
373 build_id: data.build_id,
374 features: data.features,
375 node_id: data.node_id,
376 core_id: data.core_id,
377 mode: data.mode,
378 platform: data.platform,
379 architecture: data.architecture,
380 storage_backend: data.storage_backend,
381 health: data.health,
382 operational_mode: data.operational_mode,
383 loaded_capabilities: data.loaded_capabilities,
384 core_profile: data.core_profile,
385 };
386 manifest.validate()?;
387 Ok(manifest)
388 }
389}
390
391fn validate_health_detail(key: &str, value: &str) -> ContractResult<()> {
392 validate_text("health.detail.key", key, 128)?;
393 validate_text("health.detail.value", value, 2_048)?;
394 if is_sensitive_key(key) {
395 return Err(ContractError::SecretValue {
396 field: format!("health.details.{key}"),
397 });
398 }
399 Ok(())
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use crate::{
406 CoreRole, LeadershipMode, LeadershipRequirement, ResourceProfile, SchedulingProfile,
407 ServiceId, WorkloadClass,
408 };
409
410 fn profile() -> CoreProfile {
411 let service = ServiceId::new("document.extract").unwrap();
412 CoreProfile::new(
413 CoreRole::Compute,
414 service.clone(),
415 [CapabilityId::new("document.extract").unwrap()],
416 LeadershipRequirement::new(service, LeadershipMode::Required, 30_000).unwrap(),
417 ResourceProfile::new(Some(8), Some(16_000_000_000), 1),
418 SchedulingProfile::new(10, 5, 4, WorkloadClass::Compute).unwrap(),
419 )
420 .unwrap()
421 }
422
423 fn manifest() -> RuntimeManifestV1 {
424 RuntimeManifestV1::new(
425 "0.6.1",
426 "1",
427 BuildId::new("build-123").unwrap(),
428 NodeId::new("node-a").unwrap(),
429 CoreId::new("core-a").unwrap(),
430 RuntimeMode::Cluster,
431 "linux",
432 "aarch64",
433 ProviderId::new("file").unwrap(),
434 RuntimeHealth::new(RuntimeHealthStatus::Healthy, 100),
435 profile(),
436 )
437 .unwrap()
438 .with_loaded_capability(CapabilityId::new("document.extract").unwrap())
439 .unwrap()
440 }
441
442 #[test]
443 fn runtime_manifest_contains_runtime_facts_only() {
444 let encoded = serde_json::to_value(manifest()).unwrap();
445 assert!(encoded.get("application_id").is_none());
446 assert!(encoded.get("vendor").is_none());
447 assert_eq!(encoded["mode"], "cluster");
448 }
449
450 #[test]
451 fn runtime_manifest_round_trip_revalidates_capabilities() {
452 let manifest = manifest();
453 let encoded = serde_json::to_string(&manifest).unwrap();
454 let decoded: RuntimeManifestV1 = serde_json::from_str(&encoded).unwrap();
455 assert_eq!(manifest, decoded);
456 }
457
458 #[test]
459 fn runtime_manifest_matches_v1_fixture() {
460 let expected: serde_json::Value =
461 serde_json::from_str(include_str!("fixtures/runtime-manifest-v1.json")).unwrap();
462 assert_eq!(serde_json::to_value(manifest()).unwrap(), expected);
463 let decoded: RuntimeManifestV1 = serde_json::from_value(expected).unwrap();
464 assert_eq!(decoded, manifest());
465 }
466
467 #[test]
468 fn health_rejects_sensitive_details() {
469 assert!(RuntimeHealth::new(RuntimeHealthStatus::Healthy, 0)
470 .with_detail("access_token", "raw")
471 .is_err());
472 }
473}