1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9
10use crate::{
11 DeploymentConfig, DeploymentState, ObservedInventoryBatch, ReleaseInfo, ResourceHeartbeat,
12 ValueSlotRef,
13};
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
18#[serde(rename_all = "kebab-case")]
19pub enum OperatorCapabilityState {
20 Granted,
22 Denied,
24 Unavailable,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
31#[serde(rename_all = "camelCase")]
32pub struct OperatorCapabilityReport {
33 pub key: String,
35 pub state: OperatorCapabilityState,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub detail: Option<String>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
46#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
47#[serde(rename_all = "camelCase")]
48pub struct ReportedOperation {
49 pub plugin: String,
51 pub plugin_version: String,
53 pub name: String,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
61#[serde(rename_all = "camelCase")]
62pub struct OperationsReport {
63 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub loaded_bundle_hash: Option<String>,
67 #[serde(default, skip_serializing_if = "Vec::is_empty")]
69 pub operations: Vec<ReportedOperation>,
70}
71
72#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
74#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
75#[serde(rename_all = "kebab-case")]
76pub enum OperatorImageSource {
77 Package,
79 Configured,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
86#[serde(rename_all = "camelCase")]
87pub struct OperatorImageReport {
88 pub source: OperatorImageSource,
90 pub package_id: Option<String>,
92 pub package_version: Option<String>,
94 pub image: String,
96 pub digest: String,
98}
99
100impl OperatorImageReport {
101 pub fn validate(&self) -> Result<(), &'static str> {
104 let Some(encoded_digest) = self.digest.strip_prefix("sha256:") else {
105 return Err("operator image digest must start with sha256:");
106 };
107 if encoded_digest.len() != 64
108 || !encoded_digest
109 .bytes()
110 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
111 {
112 return Err("operator image digest must contain 64 lowercase hex characters");
113 }
114
115 let Some((repository, image_digest)) = self.image.rsplit_once('@') else {
116 return Err("operator image must use repository@sha256:digest form");
117 };
118 if repository.is_empty()
119 || repository.chars().any(char::is_whitespace)
120 || repository.contains('@')
121 || image_digest != self.digest
122 {
123 return Err("operator image must contain one repository and the reported digest");
124 }
125
126 let package_id = self.package_id.as_deref().map(str::trim);
127 let package_version = self.package_version.as_deref().map(str::trim);
128 match self.source {
129 OperatorImageSource::Package
130 if package_id.is_some_and(|value| !value.is_empty())
131 && package_version.is_some_and(|value| !value.is_empty()) =>
132 {
133 Ok(())
134 }
135 OperatorImageSource::Package => {
136 Err("package operator images require package ID and version")
137 }
138 OperatorImageSource::Configured
139 if self.package_id.is_none() && self.package_version.is_none() =>
140 {
141 Ok(())
142 }
143 OperatorImageSource::Configured => {
144 Err("configured operator images must not include package identity")
145 }
146 }
147 }
148}
149
150#[derive(Clone, Serialize, Deserialize, PartialEq)]
155#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
156#[serde(rename_all = "camelCase")]
157pub struct OperationsBundleDownload {
158 pub plugin: String,
160 pub plugin_version: String,
162 pub url: String,
164 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
167 pub env: BTreeMap<String, String>,
168}
169
170impl std::fmt::Debug for OperationsBundleDownload {
171 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172 f.debug_struct("OperationsBundleDownload")
173 .field("plugin", &self.plugin)
174 .field("plugin_version", &self.plugin_version)
175 .field("env", &self.env.keys().collect::<Vec<_>>())
176 .finish_non_exhaustive()
177 }
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
185#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
186#[serde(rename_all = "camelCase")]
187pub struct TargetOperationsBundleSet {
188 pub hash: String,
191 #[serde(default, skip_serializing_if = "Vec::is_empty")]
195 pub bundles: Vec<OperationsBundleDownload>,
196}
197
198#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
202#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
203#[serde(rename_all = "camelCase")]
204pub struct TargetDynamicContainer {
205 pub name: String,
206 pub generation: u64,
207 pub image: String,
208 pub cpu: String,
209 pub memory: String,
210 pub replicas: u32,
211 pub ports: Vec<u16>,
212 pub deleted: bool,
213 #[serde(default)]
214 pub env: BTreeMap<String, String>,
215 #[serde(default)]
216 pub secret_env: BTreeMap<String, String>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
224 #[cfg_attr(feature = "openapi", schema(ignore))]
225 pub value_slots: Option<Vec<ValueSlotRef>>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub health_check: Option<DynamicContainerHealthCheck>,
228 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub suspended_reason: Option<String>,
231}
232
233impl std::fmt::Debug for TargetDynamicContainer {
235 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236 f.debug_struct("TargetDynamicContainer")
237 .field("name", &self.name)
238 .field("generation", &self.generation)
239 .field("image", &self.image)
240 .field("replicas", &self.replicas)
241 .finish_non_exhaustive()
242 }
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
246#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
247#[serde(rename_all = "camelCase")]
248pub struct DynamicContainerHealthCheck {
249 pub path: String,
250 pub port: u16,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
255#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
256#[serde(rename_all = "camelCase")]
257pub struct DynamicContainerReport {
258 pub name: String,
259 pub generation: u64,
260 pub status: DynamicContainerStatus,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub message: Option<String>,
263}
264
265#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
266#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
267#[serde(rename_all = "lowercase")]
268pub enum DynamicContainerStatus {
269 Pending,
270 Running,
271 Failing,
272 Stopped,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
277#[serde(rename_all = "camelCase")]
278pub struct SyncRequest {
279 pub deployment_id: String,
281 #[serde(default)]
283 pub session: String,
284 #[serde(default)]
286 pub supports_execution_claims: bool,
287 #[serde(default)]
291 pub supports_tunnels: bool,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub execution_claim: Option<SyncExecutionClaim>,
295 #[serde(skip_serializing_if = "Option::is_none")]
297 pub current_state: Option<DeploymentState>,
298 #[serde(
300 default,
301 rename = "resourceHeartbeats",
302 skip_serializing_if = "Vec::is_empty"
303 )]
304 pub heartbeats: Vec<ResourceHeartbeat>,
305 #[serde(
307 default,
308 rename = "observedInventoryBatches",
309 skip_serializing_if = "Vec::is_empty"
310 )]
311 pub observed_inventory_batches: Vec<ObservedInventoryBatch>,
312 #[serde(default, skip_serializing_if = "Vec::is_empty")]
314 pub capabilities: Vec<OperatorCapabilityReport>,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub operator_version: Option<String>,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub operations_report: Option<OperationsReport>,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub operations_config: Option<crate::OperationsConfig>,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub installed_setup: Option<InstalledSetup>,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
338#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
339#[serde(rename_all = "camelCase")]
340pub struct InstalledSetup {
341 pub release_id: String,
343 pub setup_target: String,
345 pub setup_fingerprint: String,
347 pub setup_fingerprint_version: u32,
349 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub release_name: Option<String>,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub namespace: Option<String>,
355}
356
357#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
359#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
360#[serde(rename_all = "camelCase")]
361pub enum ObservedApplicationSource {
362 Kubernetes,
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
368#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
369#[serde(rename_all = "camelCase")]
370pub struct ObservedApplicationImage {
371 pub workload: String,
374 pub container: String,
376 pub image: String,
378 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub digest: Option<String>,
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
389#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
390#[serde(rename_all = "camelCase")]
391pub struct ObservedApplicationReport {
392 pub source: ObservedApplicationSource,
394 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub chart_name: Option<String>,
398 #[serde(default, skip_serializing_if = "Option::is_none")]
400 pub chart_version: Option<String>,
401 #[serde(default, skip_serializing_if = "Vec::is_empty")]
403 pub images: Vec<ObservedApplicationImage>,
404 pub complete: bool,
407 pub observed_at: DateTime<Utc>,
409}
410
411#[derive(Debug, Clone, Serialize)]
414#[serde(rename_all = "camelCase")]
415pub struct SyncInput {
416 max_supported_deployment_protocol_version: u32,
418 #[serde(flatten)]
419 request: SyncRequest,
420 #[serde(skip_serializing_if = "Option::is_none")]
423 operator_image: Option<OperatorImageReport>,
424 #[serde(skip_serializing_if = "Option::is_none")]
427 application: Option<ObservedApplicationReport>,
428 #[serde(skip_serializing_if = "Option::is_none")]
430 dynamic_containers: Option<Vec<DynamicContainerReport>>,
431}
432
433impl SyncInput {
434 pub fn builder(request: SyncRequest) -> SyncInputBuilder {
437 SyncInputBuilder {
438 request,
439 operator_image: None,
440 application: None,
441 dynamic_containers: None,
442 }
443 }
444}
445
446pub struct SyncInputBuilder {
449 request: SyncRequest,
450 operator_image: Option<OperatorImageReport>,
451 application: Option<ObservedApplicationReport>,
452 dynamic_containers: Option<Vec<DynamicContainerReport>>,
453}
454
455impl SyncInputBuilder {
456 pub fn operator_image(mut self, operator_image: OperatorImageReport) -> Self {
458 self.operator_image = Some(operator_image);
459 self
460 }
461
462 pub fn application(mut self, application: ObservedApplicationReport) -> Self {
464 self.application = Some(application);
465 self
466 }
467
468 pub fn dynamic_containers(mut self, reports: Vec<DynamicContainerReport>) -> Self {
469 self.dynamic_containers = Some(reports);
470 self
471 }
472
473 pub fn build(self) -> SyncInput {
475 SyncInput {
476 max_supported_deployment_protocol_version:
477 crate::MAX_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION,
478 request: self.request,
479 operator_image: self.operator_image,
480 application: self.application,
481 dynamic_containers: self.dynamic_containers,
482 }
483 }
484}
485
486#[derive(Debug, Clone, Serialize, Deserialize)]
488#[serde(rename_all = "camelCase")]
489pub struct SyncResponse {
490 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub execution_claim: Option<SyncExecutionClaim>,
493 #[serde(default, skip_serializing_if = "Option::is_none")]
499 pub current_state: Option<DeploymentState>,
500 #[serde(skip_serializing_if = "Option::is_none")]
503 pub target: Option<TargetDeployment>,
504 #[serde(default, skip_serializing_if = "Option::is_none")]
509 pub commands_url: Option<String>,
510 #[serde(default, skip_serializing_if = "Option::is_none")]
516 pub target_operations_bundle_set: Option<TargetOperationsBundleSet>,
517 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub target_dynamic_containers: Option<Vec<TargetDynamicContainer>>,
521 #[serde(default, skip_serializing_if = "Option::is_none")]
524 pub tunnel_url: Option<String>,
525 #[serde(default, skip_serializing_if = "Option::is_none")]
528 pub target_operator_image: Option<String>,
529}
530
531#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
532#[serde(rename_all = "camelCase")]
533pub struct SyncExecutionClaim {
534 pub operation_id: String,
535 pub attempt_id: String,
536}
537
538#[derive(Debug, Clone, Serialize, Deserialize)]
540#[serde(rename_all = "camelCase")]
541pub struct TargetDeployment {
542 pub release_info: ReleaseInfo,
544 pub config: DeploymentConfig,
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551
552 #[test]
553 fn test_sync_request_serialization() {
554 let req = SyncRequest {
555 deployment_id: "dep_abc123".to_string(),
556 session: "operator-test".to_string(),
557 supports_execution_claims: true,
558 supports_tunnels: true,
559 execution_claim: None,
560 current_state: None,
561 heartbeats: Vec::new(),
562 observed_inventory_batches: Vec::new(),
563 capabilities: Vec::new(),
564 operator_version: None,
565 operations_report: None,
566 operations_config: None,
567 installed_setup: None,
568 };
569
570 let json = serde_json::to_value(&req).unwrap();
571 assert_eq!(json["deploymentId"], "dep_abc123");
572 assert_eq!(json["supportsExecutionClaims"], true);
573 assert!(json.get("currentState").is_none());
575 assert!(json.get("resourceHeartbeats").is_none());
576 assert!(json.get("capabilities").is_none());
577 assert!(json.get("operatorVersion").is_none());
578 assert!(json.get("operatorImage").is_none());
579 assert!(json.get("operationsReport").is_none());
580 }
581
582 #[test]
583 fn test_sync_request_deserialization() {
584 let json = r#"{"deploymentId": "dep_xyz"}"#;
585 let req: SyncRequest = serde_json::from_str(json).unwrap();
586 assert_eq!(req.deployment_id, "dep_xyz");
587 assert!(req.session.is_empty());
588 assert!(!req.supports_execution_claims);
589 assert!(req.current_state.is_none());
590 assert!(req.heartbeats.is_empty());
591 assert!(req.observed_inventory_batches.is_empty());
592 assert!(req.capabilities.is_empty());
593 assert!(req.operator_version.is_none());
594 assert!(req.operations_report.is_none());
595 }
596
597 #[test]
598 fn test_sync_response_empty() {
599 let resp = SyncResponse {
600 execution_claim: None,
601 current_state: None,
602 target: None,
603 commands_url: None,
604 target_operations_bundle_set: None,
605 target_dynamic_containers: None,
606 tunnel_url: None,
607 target_operator_image: None,
608 };
609 let json = serde_json::to_value(&resp).unwrap();
610 assert!(json.get("target").is_none());
612 assert!(json.get("currentState").is_none());
613 assert!(json.get("targetOperationsBundleSet").is_none());
614 }
615
616 #[test]
617 fn test_sync_response_roundtrip_no_target() {
618 let resp = SyncResponse {
619 execution_claim: None,
620 current_state: None,
621 target: None,
622 commands_url: None,
623 target_operations_bundle_set: None,
624 target_dynamic_containers: None,
625 tunnel_url: None,
626 target_operator_image: None,
627 };
628 let serialized = serde_json::to_string(&resp).unwrap();
629 let deserialized: SyncResponse = serde_json::from_str(&serialized).unwrap();
630 assert!(deserialized.target.is_none());
631 assert!(deserialized.current_state.is_none());
632 }
633
634 #[test]
635 fn test_sync_request_with_camel_case() {
636 let json = r#"{"deploymentId": "dep_1", "currentState": null}"#;
638 let req: SyncRequest = serde_json::from_str(json).unwrap();
639 assert_eq!(req.deployment_id, "dep_1");
640 assert!(req.current_state.is_none());
641 assert!(req.heartbeats.is_empty());
642 assert!(req.capabilities.is_empty());
643
644 let json = r#"{"deployment_id": "dep_1"}"#;
646 assert!(serde_json::from_str::<SyncRequest>(json).is_err());
647 }
648
649 #[test]
650 fn test_sync_request_heartbeats_roundtrip() {
651 let json = serde_json::json!({
652 "deploymentId": "dep_1",
653 "resourceHeartbeats": [{
654 "deploymentId": "dep_1",
655 "resourceId": "api",
656 "resourceType": "container",
657 "controllerPlatform": "kubernetes",
658 "backend": "kubernetes",
659 "observedAt": "2026-01-01T00:00:00Z",
660 "data": {
661 "resourceType": "container",
662 "data": {
663 "backend": "kubernetes",
664 "status": {
665 "health": "healthy",
666 "lifecycle": "running",
667 "message": null,
668 "stale": false,
669 "partial": false,
670 "collectionIssues": []
671 },
672 "namespace": "default",
673 "name": "api",
674 "workloadKind": "deployment",
675 "replicas": { "desired": 1, "current": 1, "ready": 1, "available": 1, "updated": null, "misscheduled": null },
676 "restarts": 0,
677 "cpu": null,
678 "memory": null,
679 "workload": null,
680 "pods": [],
681 "instances": [],
682 "events": []
683 }
684 },
685 "raw": []
686 }]
687 });
688
689 let req: SyncRequest = serde_json::from_value(json).unwrap();
690 assert_eq!(req.heartbeats.len(), 1);
691 assert_eq!(req.heartbeats[0].resource_id, "api");
692 assert!(req.capabilities.is_empty());
693
694 let serialized = serde_json::to_value(&req).unwrap();
695 assert_eq!(serialized["resourceHeartbeats"][0]["resourceId"], "api");
696 }
697
698 #[test]
699 fn test_sync_response_observe_only_state_roundtrip() {
700 let state = DeploymentState {
701 status: crate::DeploymentStatus::Running,
702 platform: crate::Platform::Kubernetes,
703 current_release: None,
704 target_release: None,
705 stack_state: None,
706 error: None,
707 environment_info: None,
708 runtime_metadata: None,
709 retry_requested: false,
710 protocol_version: crate::DEPLOYMENT_PROTOCOL_VERSION,
711 };
712 assert!(!state.has_desired());
713
714 let resp = SyncResponse {
715 execution_claim: None,
716 current_state: Some(state),
717 target: None,
718 commands_url: None,
719 target_operations_bundle_set: None,
720 target_dynamic_containers: None,
721 tunnel_url: None,
722 target_operator_image: None,
723 };
724
725 let serialized = serde_json::to_string(&resp).unwrap();
726 let deserialized: SyncResponse = serde_json::from_str(&serialized).unwrap();
727 let current_state = deserialized.current_state.unwrap();
728
729 assert_eq!(current_state.status, crate::DeploymentStatus::Running);
730 assert!(!current_state.has_desired());
731 assert!(deserialized.target.is_none());
732 }
733
734 #[test]
735 fn test_sync_request_operations_report_absent_by_default() {
736 let json = r#"{"deploymentId": "dep_old_operator", "operatorVersion": "0.9.0"}"#;
739 let req: SyncRequest = serde_json::from_str(json).unwrap();
740 assert_eq!(req.operator_version.as_deref(), Some("0.9.0"));
741 assert!(req.operations_report.is_none());
742 }
743
744 #[test]
745 fn test_sync_request_operator_image_roundtrip() {
746 let digest = format!("sha256:{}", "a".repeat(64));
747 let report = OperatorImageReport {
748 source: OperatorImageSource::Package,
749 package_id: Some("pkg_operator".to_string()),
750 package_version: Some("1.2.3".to_string()),
751 image: format!("registry.example.com/operator@{digest}"),
752 digest,
753 };
754 report.validate().expect("valid package image report");
755
756 let value = serde_json::to_value(&report).unwrap();
757 assert_eq!(value["source"], "package");
758 assert_eq!(value["packageId"], "pkg_operator");
759 assert_eq!(value["packageVersion"], "1.2.3");
760
761 let decoded: OperatorImageReport = serde_json::from_value(value).unwrap();
762 assert_eq!(decoded, report);
763 }
764
765 #[test]
766 fn sync_input_adds_operator_image_without_expanding_sync_request() {
767 let digest = format!("sha256:{}", "a".repeat(64));
768 let request = SyncRequest {
769 deployment_id: "dep_1".to_string(),
770 session: String::new(),
771 supports_execution_claims: false,
772 supports_tunnels: false,
773 execution_claim: None,
774 current_state: None,
775 heartbeats: Vec::new(),
776 observed_inventory_batches: Vec::new(),
777 capabilities: Vec::new(),
778 operator_version: None,
779 operations_report: None,
780 operations_config: None,
781 installed_setup: None,
782 };
783 let input = SyncInput::builder(request)
784 .operator_image(OperatorImageReport {
785 source: OperatorImageSource::Configured,
786 package_id: None,
787 package_version: None,
788 image: format!("registry.example.com/operator@{digest}"),
789 digest,
790 })
791 .build();
792
793 let value = serde_json::to_value(input).unwrap();
794 assert_eq!(
795 value["maxSupportedDeploymentProtocolVersion"],
796 crate::MAX_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION
797 );
798 let legacy_request: SyncRequest = serde_json::from_value(value.clone()).unwrap();
799 assert_eq!(legacy_request.deployment_id, "dep_1");
800 assert_eq!(value["deploymentId"], "dep_1");
801 assert_eq!(value["operatorImage"]["source"], "configured");
802 }
803
804 #[test]
805 fn sync_input_omits_absent_operator_image() {
806 let request = SyncRequest {
807 deployment_id: "dep_1".to_string(),
808 session: String::new(),
809 supports_execution_claims: false,
810 supports_tunnels: false,
811 execution_claim: None,
812 current_state: None,
813 heartbeats: Vec::new(),
814 observed_inventory_batches: Vec::new(),
815 capabilities: Vec::new(),
816 operator_version: None,
817 operations_report: None,
818 operations_config: None,
819 installed_setup: None,
820 };
821
822 let value = serde_json::to_value(SyncInput::builder(request).build()).unwrap();
823 assert!(value.get("operatorImage").is_none());
824 }
825
826 #[test]
827 fn operator_image_report_rejects_mutable_or_inconsistent_identity() {
828 let digest = format!("sha256:{}", "a".repeat(64));
829 let mutable = OperatorImageReport {
830 source: OperatorImageSource::Configured,
831 package_id: None,
832 package_version: None,
833 image: "registry.example.com/operator:latest".to_string(),
834 digest: digest.clone(),
835 };
836 assert_eq!(
837 mutable.validate(),
838 Err("operator image must use repository@sha256:digest form")
839 );
840
841 let mismatched = OperatorImageReport {
842 source: OperatorImageSource::Configured,
843 package_id: None,
844 package_version: None,
845 image: format!("registry.example.com/operator@sha256:{}", "b".repeat(64)),
846 digest,
847 };
848 assert_eq!(
849 mismatched.validate(),
850 Err("operator image must contain one repository and the reported digest")
851 );
852 }
853
854 #[test]
855 fn operator_image_report_enforces_source_specific_package_fields() {
856 let digest = format!("sha256:{}", "a".repeat(64));
857 let image = format!("registry.example.com/operator@{digest}");
858 let missing_package = OperatorImageReport {
859 source: OperatorImageSource::Package,
860 package_id: None,
861 package_version: None,
862 image: image.clone(),
863 digest: digest.clone(),
864 };
865 assert_eq!(
866 missing_package.validate(),
867 Err("package operator images require package ID and version")
868 );
869
870 let configured_with_package = OperatorImageReport {
871 source: OperatorImageSource::Configured,
872 package_id: Some("pkg_operator".to_string()),
873 package_version: Some("1.2.3".to_string()),
874 image,
875 digest,
876 };
877 assert_eq!(
878 configured_with_package.validate(),
879 Err("configured operator images must not include package identity")
880 );
881 }
882
883 #[test]
884 fn test_sync_request_operations_report_roundtrip() {
885 let req = SyncRequest {
886 deployment_id: "dep_1".to_string(),
887 session: String::new(),
888 supports_execution_claims: false,
889 supports_tunnels: false,
890 execution_claim: None,
891 current_state: None,
892 heartbeats: Vec::new(),
893 observed_inventory_batches: Vec::new(),
894 capabilities: Vec::new(),
895 operator_version: Some("1.2.3".to_string()),
896 operations_report: Some(OperationsReport {
897 loaded_bundle_hash: Some("builtin:s3@1.0.0:key|".to_string()),
898 operations: vec![ReportedOperation {
899 plugin: "s3".to_string(),
900 plugin_version: "1.0.0".to_string(),
901 name: "list-buckets".to_string(),
902 }],
903 }),
904 operations_config: None,
905 installed_setup: None,
906 };
907
908 let json = serde_json::to_value(&req).unwrap();
909 assert_eq!(
910 json["operationsReport"]["loadedBundleHash"],
911 "builtin:s3@1.0.0:key|"
912 );
913 assert_eq!(json["operationsReport"]["operations"][0]["plugin"], "s3");
914 assert_eq!(
915 json["operationsReport"]["operations"][0]["pluginVersion"],
916 "1.0.0"
917 );
918 assert_eq!(
919 json["operationsReport"]["operations"][0]["name"],
920 "list-buckets"
921 );
922
923 let deserialized: SyncRequest = serde_json::from_value(json).unwrap();
924 assert_eq!(
925 deserialized
926 .operations_report
927 .as_ref()
928 .unwrap()
929 .loaded_bundle_hash,
930 req.operations_report.as_ref().unwrap().loaded_bundle_hash
931 );
932 assert_eq!(
933 deserialized.operations_report.unwrap().operations,
934 req.operations_report.unwrap().operations
935 );
936 }
937
938 #[test]
939 fn test_sync_response_target_operations_bundle_set_roundtrip() {
940 let resp = SyncResponse {
941 execution_claim: None,
942 current_state: None,
943 target: None,
944 commands_url: None,
945 target_operations_bundle_set: Some(TargetOperationsBundleSet {
946 hash: "builtin:s3@1.0.0:key|".to_string(),
947 bundles: vec![OperationsBundleDownload {
948 plugin: "s3".to_string(),
949 plugin_version: "1.0.0".to_string(),
950 url: "https://storage.example.com/bundle.zip?sig=abc".to_string(),
951 env: BTreeMap::new(),
952 }],
953 }),
954 target_dynamic_containers: None,
955 tunnel_url: None,
956 target_operator_image: None,
957 };
958
959 let json = serde_json::to_value(&resp).unwrap();
960 assert_eq!(
961 json["targetOperationsBundleSet"]["hash"],
962 "builtin:s3@1.0.0:key|"
963 );
964 assert_eq!(
965 json["targetOperationsBundleSet"]["bundles"][0]["plugin"],
966 "s3"
967 );
968
969 let deserialized: SyncResponse = serde_json::from_value(json).unwrap();
970 assert_eq!(
971 deserialized.target_operations_bundle_set,
972 resp.target_operations_bundle_set
973 );
974 }
975
976 #[test]
977 fn test_sync_response_target_operations_bundle_set_absent_by_default() {
978 let json = serde_json::json!({});
979 let resp: SyncResponse = serde_json::from_value(json).unwrap();
980 assert!(resp.target_operations_bundle_set.is_none());
981 }
982
983 #[test]
984 fn dynamic_container_sync_preserves_empty_target_and_hides_secrets_in_debug() {
985 let old_manager_response: SyncResponse = serde_json::from_str("{}").unwrap();
986 assert!(old_manager_response.target_dynamic_containers.is_none());
987
988 let empty_target = SyncResponse {
989 execution_claim: None,
990 current_state: None,
991 target: None,
992 commands_url: None,
993 target_operations_bundle_set: None,
994 target_dynamic_containers: Some(vec![]),
995 tunnel_url: None,
996 target_operator_image: None,
997 };
998 let json = serde_json::to_value(&empty_target).unwrap();
999 assert_eq!(json["targetDynamicContainers"], serde_json::json!([]));
1000
1001 let target = TargetDynamicContainer {
1002 name: "api".to_string(),
1003 generation: 2,
1004 image: "example.com/api@sha256:abc".to_string(),
1005 cpu: "0.5".to_string(),
1006 memory: "512Mi".to_string(),
1007 replicas: 1,
1008 ports: vec![8080],
1009 deleted: false,
1010 env: BTreeMap::new(),
1011 secret_env: BTreeMap::from([("TOKEN".to_string(), "private-value".to_string())]),
1012 value_slots: None,
1013 health_check: None,
1014 suspended_reason: None,
1015 };
1016 assert!(!format!("{target:?}").contains("private-value"));
1017 let roundtrip: TargetDynamicContainer =
1018 serde_json::from_value(serde_json::to_value(&target).unwrap()).unwrap();
1019 assert_eq!(roundtrip, target);
1020 }
1021}