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