1use chrono::{DateTime, Utc};
40use serde::{Deserialize, Serialize};
41use std::collections::HashMap;
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct DeploymentDirective {
49 pub directive_id: String,
51 pub issued_at: DateTime<Utc>,
53 pub issuer_node_id: String,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub issuer_formation_id: Option<String>,
58 pub scope: DeploymentScope,
60 pub artifact: ArtifactSpec,
62 #[serde(default)]
64 pub capabilities: Vec<String>,
65 #[serde(default)]
67 pub config: serde_json::Value,
68 #[serde(default)]
70 pub options: DeploymentOptions,
71}
72
73impl DeploymentDirective {
74 pub fn new(directive_id: impl Into<String>) -> Self {
76 Self {
77 directive_id: directive_id.into(),
78 issued_at: Utc::now(),
79 issuer_node_id: String::new(),
80 issuer_formation_id: None,
81 scope: DeploymentScope::Broadcast,
82 artifact: ArtifactSpec::default(),
83 capabilities: Vec::new(),
84 config: serde_json::Value::Null,
85 options: DeploymentOptions::default(),
86 }
87 }
88
89 pub fn generate() -> Self {
91 Self::new(uuid::Uuid::new_v4().to_string())
92 }
93
94 pub fn with_issuer(mut self, node_id: impl Into<String>) -> Self {
96 self.issuer_node_id = node_id.into();
97 self
98 }
99
100 pub fn with_formation(mut self, formation_id: impl Into<String>) -> Self {
102 self.issuer_formation_id = Some(formation_id.into());
103 self
104 }
105
106 pub fn with_scope(mut self, scope: DeploymentScope) -> Self {
108 self.scope = scope;
109 self
110 }
111
112 pub fn with_artifact(mut self, artifact: ArtifactSpec) -> Self {
114 self.artifact = artifact;
115 self
116 }
117
118 pub fn with_capabilities(mut self, capabilities: Vec<String>) -> Self {
120 self.capabilities = capabilities;
121 self
122 }
123
124 pub fn with_capability(mut self, capability: impl Into<String>) -> Self {
126 self.capabilities.push(capability.into());
127 self
128 }
129
130 pub fn with_config(mut self, config: serde_json::Value) -> Self {
132 self.config = config;
133 self
134 }
135
136 pub fn with_options(mut self, options: DeploymentOptions) -> Self {
138 self.options = options;
139 self
140 }
141
142 pub fn with_priority(mut self, priority: DeploymentPriority) -> Self {
144 self.options.priority = priority;
145 self
146 }
147
148 pub fn targets_node(&self, node_id: &str) -> bool {
150 match &self.scope {
151 DeploymentScope::Broadcast => true,
152 DeploymentScope::Formation(fid) => {
153 self.issuer_formation_id.as_deref() == Some(fid)
156 }
157 DeploymentScope::Nodes(node_ids) => node_ids.iter().any(|n| n == node_id),
158 DeploymentScope::Capability(filter) => {
159 filter.required_capabilities.is_empty()
162 }
163 }
164 }
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize)]
169#[serde(tag = "type", rename_all = "snake_case")]
170pub enum DeploymentScope {
171 Broadcast,
173 Formation(String),
175 Nodes(Vec<String>),
177 Capability(CapabilityFilter),
179}
180
181impl DeploymentScope {
182 pub fn formation(formation_id: impl Into<String>) -> Self {
184 Self::Formation(formation_id.into())
185 }
186
187 pub fn nodes(node_ids: Vec<String>) -> Self {
189 Self::Nodes(node_ids)
190 }
191
192 pub fn with_capabilities(capabilities: Vec<String>) -> Self {
194 Self::Capability(CapabilityFilter {
195 required_capabilities: capabilities,
196 ..Default::default()
197 })
198 }
199}
200
201#[derive(Debug, Clone, Default, Serialize, Deserialize)]
203pub struct CapabilityFilter {
204 #[serde(skip_serializing_if = "Option::is_none")]
206 pub min_gpu_memory_mb: Option<u64>,
207 #[serde(skip_serializing_if = "Option::is_none")]
209 pub min_memory_mb: Option<u64>,
210 #[serde(skip_serializing_if = "Option::is_none")]
212 pub min_storage_mb: Option<u64>,
213 #[serde(default)]
215 pub required_capabilities: Vec<String>,
216 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
218 pub custom: HashMap<String, String>,
219}
220
221#[derive(Debug, Clone, Default, Serialize, Deserialize)]
223pub struct ArtifactSpec {
224 pub blob_hash: String,
226 pub size_bytes: u64,
228 pub artifact_type: ArtifactType,
230 #[serde(skip_serializing_if = "Option::is_none")]
232 pub sha256: Option<String>,
233 #[serde(skip_serializing_if = "Option::is_none")]
235 pub name: Option<String>,
236 #[serde(skip_serializing_if = "Option::is_none")]
238 pub version: Option<String>,
239}
240
241impl ArtifactSpec {
242 pub fn onnx_model(
244 blob_hash: impl Into<String>,
245 size_bytes: u64,
246 execution_providers: Vec<String>,
247 ) -> Self {
248 Self {
249 blob_hash: blob_hash.into(),
250 size_bytes,
251 artifact_type: ArtifactType::OnnxModel {
252 execution_providers,
253 },
254 sha256: None,
255 name: None,
256 version: None,
257 }
258 }
259
260 pub fn container(
262 blob_hash: impl Into<String>,
263 size_bytes: u64,
264 runtime: ContainerRuntime,
265 ) -> Self {
266 Self {
267 blob_hash: blob_hash.into(),
268 size_bytes,
269 artifact_type: ArtifactType::Container {
270 runtime,
271 ports: Vec::new(),
272 env: HashMap::new(),
273 },
274 sha256: None,
275 name: None,
276 version: None,
277 }
278 }
279
280 pub fn native_binary(
282 blob_hash: impl Into<String>,
283 size_bytes: u64,
284 arch: impl Into<String>,
285 ) -> Self {
286 Self {
287 blob_hash: blob_hash.into(),
288 size_bytes,
289 artifact_type: ArtifactType::NativeBinary {
290 arch: arch.into(),
291 args: Vec::new(),
292 },
293 sha256: None,
294 name: None,
295 version: None,
296 }
297 }
298
299 pub fn with_name(mut self, name: impl Into<String>) -> Self {
301 self.name = Some(name.into());
302 self
303 }
304
305 pub fn with_version(mut self, version: impl Into<String>) -> Self {
307 self.version = Some(version.into());
308 self
309 }
310
311 pub fn with_sha256(mut self, sha256: impl Into<String>) -> Self {
313 self.sha256 = Some(sha256.into());
314 self
315 }
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize)]
320#[serde(tag = "type", rename_all = "snake_case")]
321pub enum ArtifactType {
322 OnnxModel {
324 #[serde(default)]
326 execution_providers: Vec<String>,
327 },
328 Container {
330 runtime: ContainerRuntime,
332 #[serde(default)]
334 ports: Vec<PortMapping>,
335 #[serde(default)]
337 env: HashMap<String, String>,
338 },
339 NativeBinary {
341 arch: String,
343 #[serde(default)]
345 args: Vec<String>,
346 },
347 ConfigPackage {
349 target_path: String,
351 },
352 WasmModule {
354 #[serde(default)]
356 wasi_capabilities: Vec<String>,
357 },
358}
359
360impl Default for ArtifactType {
361 fn default() -> Self {
362 Self::OnnxModel {
363 execution_providers: Vec::new(),
364 }
365 }
366}
367
368#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
370#[serde(rename_all = "lowercase")]
371pub enum ContainerRuntime {
372 #[default]
373 Docker,
374 Podman,
375 Containerd,
376}
377
378#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct PortMapping {
381 pub container_port: u16,
383 pub host_port: u16,
385 #[serde(default = "default_protocol")]
387 pub protocol: String,
388}
389
390fn default_protocol() -> String {
391 "tcp".to_string()
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct DeploymentOptions {
397 #[serde(default)]
399 pub priority: DeploymentPriority,
400 #[serde(default = "default_timeout")]
402 pub timeout_seconds: u32,
403 #[serde(default)]
405 pub replace_existing: bool,
406 #[serde(skip_serializing_if = "Option::is_none")]
408 pub rollback_threshold_percent: Option<u32>,
409 #[serde(default = "default_true")]
411 pub auto_activate: bool,
412}
413
414fn default_timeout() -> u32 {
415 300 }
417
418fn default_true() -> bool {
419 true
420}
421
422impl Default for DeploymentOptions {
423 fn default() -> Self {
424 Self {
425 priority: DeploymentPriority::Normal,
426 timeout_seconds: default_timeout(),
427 replace_existing: false,
428 rollback_threshold_percent: None,
429 auto_activate: true,
430 }
431 }
432}
433
434#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
436#[serde(rename_all = "lowercase")]
437pub enum DeploymentPriority {
438 Critical,
440 High,
442 #[default]
444 Normal,
445 Low,
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct DeploymentStatus {
452 pub directive_id: String,
454 pub node_id: String,
456 pub reported_at: DateTime<Utc>,
458 pub state: DeploymentState,
460 pub progress_percent: u8,
462 #[serde(skip_serializing_if = "Option::is_none")]
464 pub error_message: Option<String>,
465 #[serde(skip_serializing_if = "Option::is_none")]
467 pub instance_id: Option<String>,
468}
469
470impl DeploymentStatus {
471 pub fn new(directive_id: impl Into<String>, node_id: impl Into<String>) -> Self {
473 Self {
474 directive_id: directive_id.into(),
475 node_id: node_id.into(),
476 reported_at: Utc::now(),
477 state: DeploymentState::Pending,
478 progress_percent: 0,
479 error_message: None,
480 instance_id: None,
481 }
482 }
483
484 pub fn downloading(mut self, progress: u8) -> Self {
486 self.state = DeploymentState::Downloading;
487 self.progress_percent = progress.min(99);
488 self
489 }
490
491 pub fn activating(mut self) -> Self {
493 self.state = DeploymentState::Activating;
494 self.progress_percent = 100;
495 self
496 }
497
498 pub fn active(mut self, instance_id: impl Into<String>) -> Self {
500 self.state = DeploymentState::Active;
501 self.progress_percent = 100;
502 self.instance_id = Some(instance_id.into());
503 self
504 }
505
506 pub fn failed(mut self, error: impl Into<String>) -> Self {
508 self.state = DeploymentState::Failed;
509 self.error_message = Some(error.into());
510 self
511 }
512
513 pub fn rolled_back(mut self) -> Self {
515 self.state = DeploymentState::RolledBack;
516 self
517 }
518}
519
520#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
522#[serde(rename_all = "snake_case")]
523pub enum DeploymentState {
524 Pending,
526 Downloading,
528 Activating,
530 Active,
532 Failed,
534 RolledBack,
536}
537
538#[cfg(test)]
539mod tests {
540 use super::*;
541
542 #[test]
543 fn test_directive_creation() {
544 let directive = DeploymentDirective::generate()
545 .with_issuer("c2-node-1")
546 .with_formation("formation-alpha")
547 .with_artifact(ArtifactSpec::onnx_model(
548 "sha256:abc123",
549 500_000_000,
550 vec!["CUDAExecutionProvider".into()],
551 ))
552 .with_capability("object_detection")
553 .with_priority(DeploymentPriority::High);
554
555 assert!(!directive.directive_id.is_empty());
556 assert_eq!(directive.issuer_node_id, "c2-node-1");
557 assert_eq!(directive.capabilities, vec!["object_detection"]);
558 assert_eq!(directive.options.priority, DeploymentPriority::High);
559 }
560
561 #[test]
562 fn test_scope_targeting() {
563 let directive = DeploymentDirective::generate();
565 assert!(directive.targets_node("any-node"));
566
567 let directive = DeploymentDirective::generate().with_scope(DeploymentScope::nodes(vec![
569 "node-1".into(),
570 "node-2".into(),
571 ]));
572 assert!(directive.targets_node("node-1"));
573 assert!(!directive.targets_node("node-3"));
574 }
575
576 #[test]
577 fn test_artifact_spec() {
578 let spec = ArtifactSpec::onnx_model("sha256:abc", 1000, vec!["CUDA".into()])
579 .with_name("YOLOv8n")
580 .with_version("1.0.0");
581
582 assert_eq!(spec.blob_hash, "sha256:abc");
583 assert_eq!(spec.name, Some("YOLOv8n".to_string()));
584 assert!(matches!(spec.artifact_type, ArtifactType::OnnxModel { .. }));
585 }
586
587 #[test]
588 fn test_deployment_status_transitions() {
589 let status = DeploymentStatus::new("directive-1", "node-1");
590 assert_eq!(status.state, DeploymentState::Pending);
591
592 let status = status.downloading(50);
593 assert_eq!(status.state, DeploymentState::Downloading);
594 assert_eq!(status.progress_percent, 50);
595
596 let status = status.activating();
597 assert_eq!(status.state, DeploymentState::Activating);
598
599 let status = status.active("instance-123");
600 assert_eq!(status.state, DeploymentState::Active);
601 assert_eq!(status.instance_id, Some("instance-123".to_string()));
602 }
603
604 #[test]
605 fn test_serialization() {
606 let directive = DeploymentDirective::generate().with_artifact(ArtifactSpec::container(
607 "sha256:def456",
608 100_000_000,
609 ContainerRuntime::Docker,
610 ));
611
612 let json = serde_json::to_string_pretty(&directive).unwrap();
613 let parsed: DeploymentDirective = serde_json::from_str(&json).unwrap();
614
615 assert_eq!(parsed.directive_id, directive.directive_id);
616 assert!(matches!(
617 parsed.artifact.artifact_type,
618 ArtifactType::Container { .. }
619 ));
620 }
621}