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}
319
320#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
322#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
323#[serde(rename_all = "camelCase")]
324pub enum ObservedApplicationSource {
325 Kubernetes,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
331#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
332#[serde(rename_all = "camelCase")]
333pub struct ObservedApplicationImage {
334 pub workload: String,
337 pub container: String,
339 pub image: String,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub digest: Option<String>,
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
352#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
353#[serde(rename_all = "camelCase")]
354pub struct ObservedApplicationReport {
355 pub source: ObservedApplicationSource,
357 #[serde(default, skip_serializing_if = "Option::is_none")]
360 pub chart_name: Option<String>,
361 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub chart_version: Option<String>,
364 #[serde(default, skip_serializing_if = "Vec::is_empty")]
366 pub images: Vec<ObservedApplicationImage>,
367 pub complete: bool,
370 pub observed_at: DateTime<Utc>,
372}
373
374#[derive(Debug, Clone, Serialize)]
377#[serde(rename_all = "camelCase")]
378pub struct SyncInput {
379 #[serde(flatten)]
380 request: SyncRequest,
381 #[serde(skip_serializing_if = "Option::is_none")]
384 operator_image: Option<OperatorImageReport>,
385 #[serde(skip_serializing_if = "Option::is_none")]
388 application: Option<ObservedApplicationReport>,
389 #[serde(skip_serializing_if = "Option::is_none")]
391 dynamic_containers: Option<Vec<DynamicContainerReport>>,
392}
393
394impl SyncInput {
395 pub fn builder(request: SyncRequest) -> SyncInputBuilder {
398 SyncInputBuilder {
399 request,
400 operator_image: None,
401 application: None,
402 dynamic_containers: None,
403 }
404 }
405}
406
407pub struct SyncInputBuilder {
410 request: SyncRequest,
411 operator_image: Option<OperatorImageReport>,
412 application: Option<ObservedApplicationReport>,
413 dynamic_containers: Option<Vec<DynamicContainerReport>>,
414}
415
416impl SyncInputBuilder {
417 pub fn operator_image(mut self, operator_image: OperatorImageReport) -> Self {
419 self.operator_image = Some(operator_image);
420 self
421 }
422
423 pub fn application(mut self, application: ObservedApplicationReport) -> Self {
425 self.application = Some(application);
426 self
427 }
428
429 pub fn dynamic_containers(mut self, reports: Vec<DynamicContainerReport>) -> Self {
430 self.dynamic_containers = Some(reports);
431 self
432 }
433
434 pub fn build(self) -> SyncInput {
436 SyncInput {
437 request: self.request,
438 operator_image: self.operator_image,
439 application: self.application,
440 dynamic_containers: self.dynamic_containers,
441 }
442 }
443}
444
445#[derive(Debug, Clone, Serialize, Deserialize)]
447#[serde(rename_all = "camelCase")]
448pub struct SyncResponse {
449 #[serde(default, skip_serializing_if = "Option::is_none")]
451 pub execution_claim: Option<SyncExecutionClaim>,
452 #[serde(default, skip_serializing_if = "Option::is_none")]
458 pub current_state: Option<DeploymentState>,
459 #[serde(skip_serializing_if = "Option::is_none")]
462 pub target: Option<TargetDeployment>,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
468 pub commands_url: Option<String>,
469 #[serde(default, skip_serializing_if = "Option::is_none")]
475 pub target_operations_bundle_set: Option<TargetOperationsBundleSet>,
476 #[serde(default, skip_serializing_if = "Option::is_none")]
479 pub target_dynamic_containers: Option<Vec<TargetDynamicContainer>>,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub tunnel_url: Option<String>,
484 #[serde(default, skip_serializing_if = "Option::is_none")]
487 pub target_operator_image: Option<String>,
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
491#[serde(rename_all = "camelCase")]
492pub struct SyncExecutionClaim {
493 pub operation_id: String,
494 pub attempt_id: String,
495}
496
497#[derive(Debug, Clone, Serialize, Deserialize)]
499#[serde(rename_all = "camelCase")]
500pub struct TargetDeployment {
501 pub release_info: ReleaseInfo,
503 pub config: DeploymentConfig,
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[test]
512 fn test_sync_request_serialization() {
513 let req = SyncRequest {
514 deployment_id: "dep_abc123".to_string(),
515 session: "operator-test".to_string(),
516 supports_execution_claims: true,
517 supports_tunnels: true,
518 execution_claim: None,
519 current_state: None,
520 heartbeats: Vec::new(),
521 observed_inventory_batches: Vec::new(),
522 capabilities: Vec::new(),
523 operator_version: None,
524 operations_report: None,
525 operations_config: None,
526 };
527
528 let json = serde_json::to_value(&req).unwrap();
529 assert_eq!(json["deploymentId"], "dep_abc123");
530 assert_eq!(json["supportsExecutionClaims"], true);
531 assert!(json.get("currentState").is_none());
533 assert!(json.get("resourceHeartbeats").is_none());
534 assert!(json.get("capabilities").is_none());
535 assert!(json.get("operatorVersion").is_none());
536 assert!(json.get("operatorImage").is_none());
537 assert!(json.get("operationsReport").is_none());
538 }
539
540 #[test]
541 fn test_sync_request_deserialization() {
542 let json = r#"{"deploymentId": "dep_xyz"}"#;
543 let req: SyncRequest = serde_json::from_str(json).unwrap();
544 assert_eq!(req.deployment_id, "dep_xyz");
545 assert!(req.session.is_empty());
546 assert!(!req.supports_execution_claims);
547 assert!(req.current_state.is_none());
548 assert!(req.heartbeats.is_empty());
549 assert!(req.observed_inventory_batches.is_empty());
550 assert!(req.capabilities.is_empty());
551 assert!(req.operator_version.is_none());
552 assert!(req.operations_report.is_none());
553 }
554
555 #[test]
556 fn test_sync_response_empty() {
557 let resp = SyncResponse {
558 execution_claim: None,
559 current_state: None,
560 target: None,
561 commands_url: None,
562 target_operations_bundle_set: None,
563 target_dynamic_containers: None,
564 tunnel_url: None,
565 target_operator_image: None,
566 };
567 let json = serde_json::to_value(&resp).unwrap();
568 assert!(json.get("target").is_none());
570 assert!(json.get("currentState").is_none());
571 assert!(json.get("targetOperationsBundleSet").is_none());
572 }
573
574 #[test]
575 fn test_sync_response_roundtrip_no_target() {
576 let resp = SyncResponse {
577 execution_claim: None,
578 current_state: None,
579 target: None,
580 commands_url: None,
581 target_operations_bundle_set: None,
582 target_dynamic_containers: None,
583 tunnel_url: None,
584 target_operator_image: None,
585 };
586 let serialized = serde_json::to_string(&resp).unwrap();
587 let deserialized: SyncResponse = serde_json::from_str(&serialized).unwrap();
588 assert!(deserialized.target.is_none());
589 assert!(deserialized.current_state.is_none());
590 }
591
592 #[test]
593 fn test_sync_request_with_camel_case() {
594 let json = r#"{"deploymentId": "dep_1", "currentState": null}"#;
596 let req: SyncRequest = serde_json::from_str(json).unwrap();
597 assert_eq!(req.deployment_id, "dep_1");
598 assert!(req.current_state.is_none());
599 assert!(req.heartbeats.is_empty());
600 assert!(req.capabilities.is_empty());
601
602 let json = r#"{"deployment_id": "dep_1"}"#;
604 assert!(serde_json::from_str::<SyncRequest>(json).is_err());
605 }
606
607 #[test]
608 fn test_sync_request_heartbeats_roundtrip() {
609 let json = serde_json::json!({
610 "deploymentId": "dep_1",
611 "resourceHeartbeats": [{
612 "deploymentId": "dep_1",
613 "resourceId": "api",
614 "resourceType": "container",
615 "controllerPlatform": "kubernetes",
616 "backend": "kubernetes",
617 "observedAt": "2026-01-01T00:00:00Z",
618 "data": {
619 "resourceType": "container",
620 "data": {
621 "backend": "kubernetes",
622 "status": {
623 "health": "healthy",
624 "lifecycle": "running",
625 "message": null,
626 "stale": false,
627 "partial": false,
628 "collectionIssues": []
629 },
630 "namespace": "default",
631 "name": "api",
632 "workloadKind": "deployment",
633 "replicas": { "desired": 1, "current": 1, "ready": 1, "available": 1, "updated": null, "misscheduled": null },
634 "restarts": 0,
635 "cpu": null,
636 "memory": null,
637 "workload": null,
638 "pods": [],
639 "instances": [],
640 "events": []
641 }
642 },
643 "raw": []
644 }]
645 });
646
647 let req: SyncRequest = serde_json::from_value(json).unwrap();
648 assert_eq!(req.heartbeats.len(), 1);
649 assert_eq!(req.heartbeats[0].resource_id, "api");
650 assert!(req.capabilities.is_empty());
651
652 let serialized = serde_json::to_value(&req).unwrap();
653 assert_eq!(serialized["resourceHeartbeats"][0]["resourceId"], "api");
654 }
655
656 #[test]
657 fn test_sync_response_observe_only_state_roundtrip() {
658 let state = DeploymentState {
659 status: crate::DeploymentStatus::Running,
660 platform: crate::Platform::Kubernetes,
661 current_release: None,
662 target_release: None,
663 stack_state: None,
664 error: None,
665 environment_info: None,
666 runtime_metadata: None,
667 retry_requested: false,
668 protocol_version: crate::DEPLOYMENT_PROTOCOL_VERSION,
669 };
670 assert!(!state.has_desired());
671
672 let resp = SyncResponse {
673 execution_claim: None,
674 current_state: Some(state),
675 target: None,
676 commands_url: None,
677 target_operations_bundle_set: None,
678 target_dynamic_containers: None,
679 tunnel_url: None,
680 target_operator_image: None,
681 };
682
683 let serialized = serde_json::to_string(&resp).unwrap();
684 let deserialized: SyncResponse = serde_json::from_str(&serialized).unwrap();
685 let current_state = deserialized.current_state.unwrap();
686
687 assert_eq!(current_state.status, crate::DeploymentStatus::Running);
688 assert!(!current_state.has_desired());
689 assert!(deserialized.target.is_none());
690 }
691
692 #[test]
693 fn test_sync_request_operations_report_absent_by_default() {
694 let json = r#"{"deploymentId": "dep_old_operator", "operatorVersion": "0.9.0"}"#;
697 let req: SyncRequest = serde_json::from_str(json).unwrap();
698 assert_eq!(req.operator_version.as_deref(), Some("0.9.0"));
699 assert!(req.operations_report.is_none());
700 }
701
702 #[test]
703 fn test_sync_request_operator_image_roundtrip() {
704 let digest = format!("sha256:{}", "a".repeat(64));
705 let report = OperatorImageReport {
706 source: OperatorImageSource::Package,
707 package_id: Some("pkg_operator".to_string()),
708 package_version: Some("1.2.3".to_string()),
709 image: format!("registry.example.com/operator@{digest}"),
710 digest,
711 };
712 report.validate().expect("valid package image report");
713
714 let value = serde_json::to_value(&report).unwrap();
715 assert_eq!(value["source"], "package");
716 assert_eq!(value["packageId"], "pkg_operator");
717 assert_eq!(value["packageVersion"], "1.2.3");
718
719 let decoded: OperatorImageReport = serde_json::from_value(value).unwrap();
720 assert_eq!(decoded, report);
721 }
722
723 #[test]
724 fn sync_input_adds_operator_image_without_expanding_sync_request() {
725 let digest = format!("sha256:{}", "a".repeat(64));
726 let request = SyncRequest {
727 deployment_id: "dep_1".to_string(),
728 session: String::new(),
729 supports_execution_claims: false,
730 supports_tunnels: false,
731 execution_claim: None,
732 current_state: None,
733 heartbeats: Vec::new(),
734 observed_inventory_batches: Vec::new(),
735 capabilities: Vec::new(),
736 operator_version: None,
737 operations_report: None,
738 operations_config: None,
739 };
740 let input = SyncInput::builder(request)
741 .operator_image(OperatorImageReport {
742 source: OperatorImageSource::Configured,
743 package_id: None,
744 package_version: None,
745 image: format!("registry.example.com/operator@{digest}"),
746 digest,
747 })
748 .build();
749
750 let value = serde_json::to_value(input).unwrap();
751 assert_eq!(value["deploymentId"], "dep_1");
752 assert_eq!(value["operatorImage"]["source"], "configured");
753 }
754
755 #[test]
756 fn sync_input_omits_absent_operator_image() {
757 let request = SyncRequest {
758 deployment_id: "dep_1".to_string(),
759 session: String::new(),
760 supports_execution_claims: false,
761 supports_tunnels: false,
762 execution_claim: None,
763 current_state: None,
764 heartbeats: Vec::new(),
765 observed_inventory_batches: Vec::new(),
766 capabilities: Vec::new(),
767 operator_version: None,
768 operations_report: None,
769 operations_config: None,
770 };
771
772 let value = serde_json::to_value(SyncInput::builder(request).build()).unwrap();
773 assert!(value.get("operatorImage").is_none());
774 }
775
776 #[test]
777 fn operator_image_report_rejects_mutable_or_inconsistent_identity() {
778 let digest = format!("sha256:{}", "a".repeat(64));
779 let mutable = OperatorImageReport {
780 source: OperatorImageSource::Configured,
781 package_id: None,
782 package_version: None,
783 image: "registry.example.com/operator:latest".to_string(),
784 digest: digest.clone(),
785 };
786 assert_eq!(
787 mutable.validate(),
788 Err("operator image must use repository@sha256:digest form")
789 );
790
791 let mismatched = OperatorImageReport {
792 source: OperatorImageSource::Configured,
793 package_id: None,
794 package_version: None,
795 image: format!("registry.example.com/operator@sha256:{}", "b".repeat(64)),
796 digest,
797 };
798 assert_eq!(
799 mismatched.validate(),
800 Err("operator image must contain one repository and the reported digest")
801 );
802 }
803
804 #[test]
805 fn operator_image_report_enforces_source_specific_package_fields() {
806 let digest = format!("sha256:{}", "a".repeat(64));
807 let image = format!("registry.example.com/operator@{digest}");
808 let missing_package = OperatorImageReport {
809 source: OperatorImageSource::Package,
810 package_id: None,
811 package_version: None,
812 image: image.clone(),
813 digest: digest.clone(),
814 };
815 assert_eq!(
816 missing_package.validate(),
817 Err("package operator images require package ID and version")
818 );
819
820 let configured_with_package = OperatorImageReport {
821 source: OperatorImageSource::Configured,
822 package_id: Some("pkg_operator".to_string()),
823 package_version: Some("1.2.3".to_string()),
824 image,
825 digest,
826 };
827 assert_eq!(
828 configured_with_package.validate(),
829 Err("configured operator images must not include package identity")
830 );
831 }
832
833 #[test]
834 fn test_sync_request_operations_report_roundtrip() {
835 let req = SyncRequest {
836 deployment_id: "dep_1".to_string(),
837 session: String::new(),
838 supports_execution_claims: false,
839 supports_tunnels: false,
840 execution_claim: None,
841 current_state: None,
842 heartbeats: Vec::new(),
843 observed_inventory_batches: Vec::new(),
844 capabilities: Vec::new(),
845 operator_version: Some("1.2.3".to_string()),
846 operations_report: Some(OperationsReport {
847 loaded_bundle_hash: Some("builtin:s3@1.0.0:key|".to_string()),
848 operations: vec![ReportedOperation {
849 plugin: "s3".to_string(),
850 plugin_version: "1.0.0".to_string(),
851 name: "list-buckets".to_string(),
852 }],
853 }),
854 operations_config: None,
855 };
856
857 let json = serde_json::to_value(&req).unwrap();
858 assert_eq!(
859 json["operationsReport"]["loadedBundleHash"],
860 "builtin:s3@1.0.0:key|"
861 );
862 assert_eq!(json["operationsReport"]["operations"][0]["plugin"], "s3");
863 assert_eq!(
864 json["operationsReport"]["operations"][0]["pluginVersion"],
865 "1.0.0"
866 );
867 assert_eq!(
868 json["operationsReport"]["operations"][0]["name"],
869 "list-buckets"
870 );
871
872 let deserialized: SyncRequest = serde_json::from_value(json).unwrap();
873 assert_eq!(
874 deserialized
875 .operations_report
876 .as_ref()
877 .unwrap()
878 .loaded_bundle_hash,
879 req.operations_report.as_ref().unwrap().loaded_bundle_hash
880 );
881 assert_eq!(
882 deserialized.operations_report.unwrap().operations,
883 req.operations_report.unwrap().operations
884 );
885 }
886
887 #[test]
888 fn test_sync_response_target_operations_bundle_set_roundtrip() {
889 let resp = SyncResponse {
890 execution_claim: None,
891 current_state: None,
892 target: None,
893 commands_url: None,
894 target_operations_bundle_set: Some(TargetOperationsBundleSet {
895 hash: "builtin:s3@1.0.0:key|".to_string(),
896 bundles: vec![OperationsBundleDownload {
897 plugin: "s3".to_string(),
898 plugin_version: "1.0.0".to_string(),
899 url: "https://storage.example.com/bundle.zip?sig=abc".to_string(),
900 env: BTreeMap::new(),
901 }],
902 }),
903 target_dynamic_containers: None,
904 tunnel_url: None,
905 target_operator_image: None,
906 };
907
908 let json = serde_json::to_value(&resp).unwrap();
909 assert_eq!(
910 json["targetOperationsBundleSet"]["hash"],
911 "builtin:s3@1.0.0:key|"
912 );
913 assert_eq!(
914 json["targetOperationsBundleSet"]["bundles"][0]["plugin"],
915 "s3"
916 );
917
918 let deserialized: SyncResponse = serde_json::from_value(json).unwrap();
919 assert_eq!(
920 deserialized.target_operations_bundle_set,
921 resp.target_operations_bundle_set
922 );
923 }
924
925 #[test]
926 fn test_sync_response_target_operations_bundle_set_absent_by_default() {
927 let json = serde_json::json!({});
928 let resp: SyncResponse = serde_json::from_value(json).unwrap();
929 assert!(resp.target_operations_bundle_set.is_none());
930 }
931
932 #[test]
933 fn dynamic_container_sync_preserves_empty_target_and_hides_secrets_in_debug() {
934 let old_manager_response: SyncResponse = serde_json::from_str("{}").unwrap();
935 assert!(old_manager_response.target_dynamic_containers.is_none());
936
937 let empty_target = SyncResponse {
938 execution_claim: None,
939 current_state: None,
940 target: None,
941 commands_url: None,
942 target_operations_bundle_set: None,
943 target_dynamic_containers: Some(vec![]),
944 tunnel_url: None,
945 target_operator_image: None,
946 };
947 let json = serde_json::to_value(&empty_target).unwrap();
948 assert_eq!(json["targetDynamicContainers"], serde_json::json!([]));
949
950 let target = TargetDynamicContainer {
951 name: "api".to_string(),
952 generation: 2,
953 image: "example.com/api@sha256:abc".to_string(),
954 cpu: "0.5".to_string(),
955 memory: "512Mi".to_string(),
956 replicas: 1,
957 ports: vec![8080],
958 deleted: false,
959 env: BTreeMap::new(),
960 secret_env: BTreeMap::from([("TOKEN".to_string(), "private-value".to_string())]),
961 health_check: None,
962 suspended_reason: None,
963 };
964 assert!(!format!("{target:?}").contains("private-value"));
965 let roundtrip: TargetDynamicContainer =
966 serde_json::from_value(serde_json::to_value(&target).unwrap()).unwrap();
967 assert_eq!(roundtrip, target);
968 }
969}