Skip to main content

alien_core/
sync.rs

1//! Sync protocol types for agent ↔ manager communication.
2//!
3//! The agent periodically calls `POST /v1/sync` with a `SyncRequest` and
4//! receives a `SyncResponse` containing the target deployment state.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::collections::BTreeMap;
9
10use crate::{
11    DeploymentConfig, DeploymentState, ObservedInventoryBatch, ReleaseInfo, ResourceHeartbeat,
12};
13
14/// State of an Operator capability as observed inside the environment.
15#[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    /// The Operator has the permission or local facility needed for the capability.
20    Granted,
21    /// The environment explicitly denied the capability.
22    Denied,
23    /// The capability does not apply in this environment.
24    Unavailable,
25}
26
27/// Report-only Operator capability status.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
30#[serde(rename_all = "camelCase")]
31pub struct OperatorCapabilityReport {
32    /// Stable capability key, such as `k8s-workloads` or `logs`.
33    pub key: String,
34    /// Whether the capability is currently usable.
35    pub state: OperatorCapabilityState,
36    /// Optional human-readable detail from the Operator.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub detail: Option<String>,
39}
40
41/// A single operation the Operator currently has loaded. Opaque identifiers
42/// only — no tier, description, or other plugin business logic crosses into
43/// this public crate.
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
46#[serde(rename_all = "camelCase")]
47pub struct ReportedOperation {
48    /// Name of the plugin that owns this operation.
49    pub plugin: String,
50    /// Version of the plugin that owns this operation.
51    pub plugin_version: String,
52    /// Name of the operation within the plugin.
53    pub name: String,
54}
55
56/// Report-only summary of the operations the Operator has loaded, used to
57/// confirm a bundle sync actually took effect.
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
59#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
60#[serde(rename_all = "camelCase")]
61pub struct OperationsReport {
62    /// Hash of the enabled-plugin bundle set the Operator currently has
63    /// loaded. Compared against the platform's target hash to detect drift.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub loaded_bundle_hash: Option<String>,
66    /// Operations currently loaded and executable by the Operator.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub operations: Vec<ReportedOperation>,
69}
70
71/// Origin of the exact Operator image running this process.
72#[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    /// The image came from an immutable generated package.
77    Package,
78    /// The image was configured directly by the installer.
79    Configured,
80}
81
82/// Exact immutable Operator image identity observed by the running process.
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
84#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
85#[serde(rename_all = "camelCase")]
86pub struct OperatorImageReport {
87    /// How the installer selected this image.
88    pub source: OperatorImageSource,
89    /// Package ID when `source` is `package`; otherwise `null`.
90    pub package_id: Option<String>,
91    /// Package version when `source` is `package`; otherwise `null`.
92    pub package_version: Option<String>,
93    /// Exact OCI image reference in `repository@sha256:digest` form.
94    pub image: String,
95    /// Exact lowercase OCI digest in `sha256:digest` form.
96    pub digest: String,
97}
98
99impl OperatorImageReport {
100    /// Validate that this report identifies one immutable image and that its
101    /// package fields agree with `source`.
102    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/// One bundle the Operator needs to download to reach `targetBundleHash`.
150/// The manager mints a short-lived presigned GET URL per bundle — the
151/// Operator never holds real cloud storage credentials, mirroring the OCI
152/// registry proxy's credential-injection pattern.
153#[derive(Clone, Serialize, Deserialize, PartialEq)]
154#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
155#[serde(rename_all = "camelCase")]
156pub struct OperationsBundleDownload {
157    /// Plugin name this bundle provides.
158    pub plugin: String,
159    /// Plugin version this bundle provides.
160    pub plugin_version: String,
161    /// Presigned URL to GET the bundle ZIP from. Short-lived.
162    pub url: String,
163    /// Environment the plugin process runs with: its settings, which may hold
164    /// secrets. Store only in encrypted state, and never log the values.
165    #[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/// Target operations-bundle set for the Operator to converge its loaded
180/// plugin registry toward, independent of any release/config target — a
181/// plugin can be enabled with no release change, so this is not nested under
182/// `TargetDeployment`.
183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
184#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
185#[serde(rename_all = "camelCase")]
186pub struct TargetOperationsBundleSet {
187    /// Hash identifying this exact enabled-plugin set. Compare against
188    /// `OperationsReport.loadedBundleHash` to detect drift.
189    pub hash: String,
190    /// Presigned downloads for every bundle in the target set. The Operator
191    /// fetches only the ones it doesn't already have loaded at the right
192    /// version; already-loaded bundles are harmless to re-download.
193    #[serde(default, skip_serializing_if = "Vec::is_empty")]
194    pub bundles: Vec<OperationsBundleDownload>,
195}
196
197/// One release-independent container the Operator should run in its namespace.
198/// The manager sends the complete set on every sync. An empty set removes
199/// containers previously owned by this deployment.
200#[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    /// Stop an installed workload when its release no longer admits the image.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub suspended_reason: Option<String>,
221}
222
223// Never include secret values in sync diagnostics.
224impl 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/// What the Operator observed after applying one target generation.
244#[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/// Request sent by the agent to the manager during periodic sync.
266#[derive(Debug, Clone, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase")]
268pub struct SyncRequest {
269    /// The deployment ID this agent is managing.
270    pub deployment_id: String,
271    /// Stable identity for this Operator process. Used to fence update work.
272    #[serde(default)]
273    pub session: String,
274    /// Signals that this Operator persists and echoes execution claims.
275    #[serde(default)]
276    pub supports_execution_claims: bool,
277    /// Signals that this Operator understands container tunnels. Older
278    /// Operators reject stacks that declare one, so the manager leaves
279    /// tunnels out of their targets.
280    #[serde(default)]
281    pub supports_tunnels: bool,
282    /// Exact update claim returned by the previous sync response.
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub execution_claim: Option<SyncExecutionClaim>,
285    /// Current deployment state as seen by the agent.
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub current_state: Option<DeploymentState>,
288    /// Managed Alien resource status samples emitted by the Operator's deployment step.
289    #[serde(
290        default,
291        rename = "resourceHeartbeats",
292        skip_serializing_if = "Vec::is_empty"
293    )]
294    pub heartbeats: Vec<ResourceHeartbeat>,
295    /// Observed raw-resource inventory batches successfully read by the Operator.
296    #[serde(
297        default,
298        rename = "observedInventoryBatches",
299        skip_serializing_if = "Vec::is_empty"
300    )]
301    pub observed_inventory_batches: Vec<ObservedInventoryBatch>,
302    /// Report-only capabilities observed by the Operator.
303    #[serde(default, skip_serializing_if = "Vec::is_empty")]
304    pub capabilities: Vec<OperatorCapabilityReport>,
305    /// Version of the Operator binary reporting this sync.
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub operator_version: Option<String>,
308    /// Report-only summary of the operations the Operator currently has
309    /// loaded. Absent means the Operator does not yet support reporting
310    /// this (older Operator versions), not that it has no operations.
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub operations_report: Option<OperationsReport>,
313    /// Operations an Operator installed on its own declares through its
314    /// environment, without setting values. Absent when its deployment's
315    /// release declares them instead.
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub operations_config: Option<crate::OperationsConfig>,
318}
319
320/// Where the Operator read the application identity from.
321#[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    /// Labels and pod statuses of the observed Kubernetes workloads.
326    Kubernetes,
327}
328
329/// A container image running in one observed application workload.
330#[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    /// Inventory identity of the workload, matching the `rawIdentity` of its
335    /// observed resource sample (for example `apps/v1:Deployment:shop:api`).
336    pub workload: String,
337    /// Container name within the workload.
338    pub container: String,
339    /// Image reference reported by the container runtime.
340    pub image: String,
341    /// Registry manifest digest in `sha256:<hex>` form, when the runtime
342    /// reports one.
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub digest: Option<String>,
345}
346
347/// Application release the Operator observes running in its environment.
348///
349/// This identifies the customer's application, not the Operator: the
350/// Operator's own image is reported separately as [`OperatorImageReport`].
351#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
352#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
353#[serde(rename_all = "camelCase")]
354pub struct ObservedApplicationReport {
355    /// Where this identity was read from.
356    pub source: ObservedApplicationSource,
357    /// Helm chart name from the workloads' `helm.sh/chart` label. Present only
358    /// when every labelled workload names the same chart.
359    #[serde(default, skip_serializing_if = "Option::is_none")]
360    pub chart_name: Option<String>,
361    /// Helm chart version from the same label.
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub chart_version: Option<String>,
364    /// Distinct container images running in the observed workloads.
365    #[serde(default, skip_serializing_if = "Vec::is_empty")]
366    pub images: Vec<ObservedApplicationImage>,
367    /// Whether every workload kind could be listed. When `false`, the chart
368    /// and images describe only the workloads the Operator could read.
369    pub complete: bool,
370    /// When the workloads were read.
371    pub observed_at: DateTime<Utc>,
372}
373
374/// Extensible wire input for sync metadata that is not part of the
375/// long-standing [`SyncRequest`] struct-literal contract.
376#[derive(Debug, Clone, Serialize)]
377#[serde(rename_all = "camelCase")]
378pub struct SyncInput {
379    /// Reader ceiling of this runtime, independent of the deployment writer version.
380    max_supported_deployment_protocol_version: u32,
381    #[serde(flatten)]
382    request: SyncRequest,
383    /// Exact immutable image identity injected by the installer. Absent for
384    /// older installations that do not carry an image receipt.
385    #[serde(skip_serializing_if = "Option::is_none")]
386    operator_image: Option<OperatorImageReport>,
387    /// Application release observed in the environment. Absent when the
388    /// Operator observed none or predates this report.
389    #[serde(skip_serializing_if = "Option::is_none")]
390    application: Option<ObservedApplicationReport>,
391    /// Absent for older Operators. An empty report means no containers remain.
392    #[serde(skip_serializing_if = "Option::is_none")]
393    dynamic_containers: Option<Vec<DynamicContainerReport>>,
394}
395
396impl SyncInput {
397    /// Start building a sync payload while preserving the stable
398    /// [`SyncRequest`] literal surface for downstream callers.
399    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
409/// Builder for optional sync receipts. New optional wire metadata belongs
410/// here so adding it does not break downstream [`SyncRequest`] literals.
411pub 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    /// Attach the immutable image receipt for the running Operator.
420    pub fn operator_image(mut self, operator_image: OperatorImageReport) -> Self {
421        self.operator_image = Some(operator_image);
422        self
423    }
424
425    /// Attach the application release observed in the environment.
426    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    /// Finish the serializable sync payload.
437    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/// Response from the manager to the agent sync request.
450#[derive(Debug, Clone, Serialize, Deserialize)]
451#[serde(rename_all = "camelCase")]
452pub struct SyncResponse {
453    /// Exact operation attempt associated with `target`.
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub execution_claim: Option<SyncExecutionClaim>,
456    /// Authoritative deployment state from the manager.
457    ///
458    /// Pull agents use this to hydrate local state when attaching to an
459    /// already-imported deployment. Absent means the agent's local state is
460    /// already authoritative or no state has been established yet.
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub current_state: Option<DeploymentState>,
463    /// Target deployment the agent should converge toward.
464    /// None means no changes needed or this is an observe-only deployment.
465    #[serde(skip_serializing_if = "Option::is_none")]
466    pub target: Option<TargetDeployment>,
467    /// Public URL for the commands API (e.g. `https://manager.example.com/v1`).
468    /// Operators and app-owned receivers use this to lease pending commands;
469    /// Workers themselves receive pushes.
470    /// When absent, the agent falls back to its sync URL.
471    #[serde(default, skip_serializing_if = "Option::is_none")]
472    pub commands_url: Option<String>,
473    /// Target operations-bundle set the Operator should converge its loaded
474    /// plugin registry toward. None means no enabled plugin set has ever
475    /// been established for this project (nothing to sync), not that the
476    /// Operator is up to date — compare `hash` against the Operator's own
477    /// loaded hash to decide whether to download anything.
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub target_operations_bundle_set: Option<TargetOperationsBundleSet>,
480    /// Complete release-independent target set. None means the manager does
481    /// not support this protocol; Some(empty) means remove owned containers.
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub target_dynamic_containers: Option<Vec<TargetDynamicContainer>>,
484    /// Base URL the Operator opens tunnel connections to. None means the
485    /// manager does not accept tunnels, so the Operator never dials.
486    #[serde(default, skip_serializing_if = "Option::is_none")]
487    pub tunnel_url: Option<String>,
488    /// Operator image the manager wants this Operator to run. Operators that
489    /// manage their own workload update to it; None means no opinion.
490    #[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/// Target deployment state for the agent to converge toward.
502#[derive(Debug, Clone, Serialize, Deserialize)]
503#[serde(rename_all = "camelCase")]
504pub struct TargetDeployment {
505    /// Release information (ID, version, stack definition).
506    pub release_info: ReleaseInfo,
507    /// Full deployment configuration (settings, env vars, etc.).
508    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        // current_state is None → should be omitted
536        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        // target is None → should be omitted
573        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        // Verify camelCase renaming works correctly
599        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        // snake_case should NOT work
607        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        // Simulates an old Operator that doesn't know about operationsReport:
699        // the field must default to None, never be required.
700        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}