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