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    #[serde(flatten)]
380    request: SyncRequest,
381    /// Exact immutable image identity injected by the installer. Absent for
382    /// older installations that do not carry an image receipt.
383    #[serde(skip_serializing_if = "Option::is_none")]
384    operator_image: Option<OperatorImageReport>,
385    /// Application release observed in the environment. Absent when the
386    /// Operator observed none or predates this report.
387    #[serde(skip_serializing_if = "Option::is_none")]
388    application: Option<ObservedApplicationReport>,
389    /// Absent for older Operators. An empty report means no containers remain.
390    #[serde(skip_serializing_if = "Option::is_none")]
391    dynamic_containers: Option<Vec<DynamicContainerReport>>,
392}
393
394impl SyncInput {
395    /// Start building a sync payload while preserving the stable
396    /// [`SyncRequest`] literal surface for downstream callers.
397    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
407/// Builder for optional sync receipts. New optional wire metadata belongs
408/// here so adding it does not break downstream [`SyncRequest`] literals.
409pub 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    /// Attach the immutable image receipt for the running Operator.
418    pub fn operator_image(mut self, operator_image: OperatorImageReport) -> Self {
419        self.operator_image = Some(operator_image);
420        self
421    }
422
423    /// Attach the application release observed in the environment.
424    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    /// Finish the serializable sync payload.
435    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/// Response from the manager to the agent sync request.
446#[derive(Debug, Clone, Serialize, Deserialize)]
447#[serde(rename_all = "camelCase")]
448pub struct SyncResponse {
449    /// Exact operation attempt associated with `target`.
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub execution_claim: Option<SyncExecutionClaim>,
452    /// Authoritative deployment state from the manager.
453    ///
454    /// Pull agents use this to hydrate local state when attaching to an
455    /// already-imported deployment. Absent means the agent's local state is
456    /// already authoritative or no state has been established yet.
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    pub current_state: Option<DeploymentState>,
459    /// Target deployment the agent should converge toward.
460    /// None means no changes needed or this is an observe-only deployment.
461    #[serde(skip_serializing_if = "Option::is_none")]
462    pub target: Option<TargetDeployment>,
463    /// Public URL for the commands API (e.g. `https://manager.example.com/v1`).
464    /// Operators and app-owned receivers use this to lease pending commands;
465    /// Workers themselves receive pushes.
466    /// When absent, the agent falls back to its sync URL.
467    #[serde(default, skip_serializing_if = "Option::is_none")]
468    pub commands_url: Option<String>,
469    /// Target operations-bundle set the Operator should converge its loaded
470    /// plugin registry toward. None means no enabled plugin set has ever
471    /// been established for this project (nothing to sync), not that the
472    /// Operator is up to date — compare `hash` against the Operator's own
473    /// loaded hash to decide whether to download anything.
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub target_operations_bundle_set: Option<TargetOperationsBundleSet>,
476    /// Complete release-independent target set. None means the manager does
477    /// not support this protocol; Some(empty) means remove owned containers.
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub target_dynamic_containers: Option<Vec<TargetDynamicContainer>>,
480    /// Base URL the Operator opens tunnel connections to. None means the
481    /// manager does not accept tunnels, so the Operator never dials.
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub tunnel_url: Option<String>,
484    /// Operator image the manager wants this Operator to run. Operators that
485    /// manage their own workload update to it; None means no opinion.
486    #[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/// Target deployment state for the agent to converge toward.
498#[derive(Debug, Clone, Serialize, Deserialize)]
499#[serde(rename_all = "camelCase")]
500pub struct TargetDeployment {
501    /// Release information (ID, version, stack definition).
502    pub release_info: ReleaseInfo,
503    /// Full deployment configuration (settings, env vars, etc.).
504    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        // current_state is None → should be omitted
532        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        // target is None → should be omitted
569        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        // Verify camelCase renaming works correctly
595        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        // snake_case should NOT work
603        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        // Simulates an old Operator that doesn't know about operationsReport:
695        // the field must default to None, never be required.
696        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}