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    ValueSlotRef,
13};
14
15/// State of an Operator capability as observed inside the environment.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
18#[serde(rename_all = "kebab-case")]
19pub enum OperatorCapabilityState {
20    /// The Operator has the permission or local facility needed for the capability.
21    Granted,
22    /// The environment explicitly denied the capability.
23    Denied,
24    /// The capability does not apply in this environment.
25    Unavailable,
26}
27
28/// Report-only Operator capability status.
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
31#[serde(rename_all = "camelCase")]
32pub struct OperatorCapabilityReport {
33    /// Stable capability key, such as `k8s-workloads` or `logs`.
34    pub key: String,
35    /// Whether the capability is currently usable.
36    pub state: OperatorCapabilityState,
37    /// Optional human-readable detail from the Operator.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub detail: Option<String>,
40}
41
42/// A single operation the Operator currently has loaded. Opaque identifiers
43/// only — no tier, description, or other plugin business logic crosses into
44/// this public crate.
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
46#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
47#[serde(rename_all = "camelCase")]
48pub struct ReportedOperation {
49    /// Name of the plugin that owns this operation.
50    pub plugin: String,
51    /// Version of the plugin that owns this operation.
52    pub plugin_version: String,
53    /// Name of the operation within the plugin.
54    pub name: String,
55}
56
57/// Report-only summary of the operations the Operator has loaded, used to
58/// confirm a bundle sync actually took effect.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
61#[serde(rename_all = "camelCase")]
62pub struct OperationsReport {
63    /// Hash of the enabled-plugin bundle set the Operator currently has
64    /// loaded. Compared against the platform's target hash to detect drift.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub loaded_bundle_hash: Option<String>,
67    /// Operations currently loaded and executable by the Operator.
68    #[serde(default, skip_serializing_if = "Vec::is_empty")]
69    pub operations: Vec<ReportedOperation>,
70}
71
72/// Origin of the exact Operator image running this process.
73#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
74#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
75#[serde(rename_all = "kebab-case")]
76pub enum OperatorImageSource {
77    /// The image came from an immutable generated package.
78    Package,
79    /// The image was configured directly by the installer.
80    Configured,
81}
82
83/// Exact immutable Operator image identity observed by the running process.
84#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
85#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
86#[serde(rename_all = "camelCase")]
87pub struct OperatorImageReport {
88    /// How the installer selected this image.
89    pub source: OperatorImageSource,
90    /// Package ID when `source` is `package`; otherwise `null`.
91    pub package_id: Option<String>,
92    /// Package version when `source` is `package`; otherwise `null`.
93    pub package_version: Option<String>,
94    /// Exact OCI image reference in `repository@sha256:digest` form.
95    pub image: String,
96    /// Exact lowercase OCI digest in `sha256:digest` form.
97    pub digest: String,
98}
99
100impl OperatorImageReport {
101    /// Validate that this report identifies one immutable image and that its
102    /// package fields agree with `source`.
103    pub fn validate(&self) -> Result<(), &'static str> {
104        let Some(encoded_digest) = self.digest.strip_prefix("sha256:") else {
105            return Err("operator image digest must start with sha256:");
106        };
107        if encoded_digest.len() != 64
108            || !encoded_digest
109                .bytes()
110                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
111        {
112            return Err("operator image digest must contain 64 lowercase hex characters");
113        }
114
115        let Some((repository, image_digest)) = self.image.rsplit_once('@') else {
116            return Err("operator image must use repository@sha256:digest form");
117        };
118        if repository.is_empty()
119            || repository.chars().any(char::is_whitespace)
120            || repository.contains('@')
121            || image_digest != self.digest
122        {
123            return Err("operator image must contain one repository and the reported digest");
124        }
125
126        let package_id = self.package_id.as_deref().map(str::trim);
127        let package_version = self.package_version.as_deref().map(str::trim);
128        match self.source {
129            OperatorImageSource::Package
130                if package_id.is_some_and(|value| !value.is_empty())
131                    && package_version.is_some_and(|value| !value.is_empty()) =>
132            {
133                Ok(())
134            }
135            OperatorImageSource::Package => {
136                Err("package operator images require package ID and version")
137            }
138            OperatorImageSource::Configured
139                if self.package_id.is_none() && self.package_version.is_none() =>
140            {
141                Ok(())
142            }
143            OperatorImageSource::Configured => {
144                Err("configured operator images must not include package identity")
145            }
146        }
147    }
148}
149
150/// One bundle the Operator needs to download to reach `targetBundleHash`.
151/// The manager mints a short-lived presigned GET URL per bundle — the
152/// Operator never holds real cloud storage credentials, mirroring the OCI
153/// registry proxy's credential-injection pattern.
154#[derive(Clone, Serialize, Deserialize, PartialEq)]
155#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
156#[serde(rename_all = "camelCase")]
157pub struct OperationsBundleDownload {
158    /// Plugin name this bundle provides.
159    pub plugin: String,
160    /// Plugin version this bundle provides.
161    pub plugin_version: String,
162    /// Presigned URL to GET the bundle ZIP from. Short-lived.
163    pub url: String,
164    /// Environment the plugin process runs with: its settings, which may hold
165    /// secrets. Store only in encrypted state, and never log the values.
166    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
167    pub env: BTreeMap<String, String>,
168}
169
170impl std::fmt::Debug for OperationsBundleDownload {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        f.debug_struct("OperationsBundleDownload")
173            .field("plugin", &self.plugin)
174            .field("plugin_version", &self.plugin_version)
175            .field("env", &self.env.keys().collect::<Vec<_>>())
176            .finish_non_exhaustive()
177    }
178}
179
180/// Target operations-bundle set for the Operator to converge its loaded
181/// plugin registry toward, independent of any release/config target — a
182/// plugin can be enabled with no release change, so this is not nested under
183/// `TargetDeployment`.
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
185#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
186#[serde(rename_all = "camelCase")]
187pub struct TargetOperationsBundleSet {
188    /// Hash identifying this exact enabled-plugin set. Compare against
189    /// `OperationsReport.loadedBundleHash` to detect drift.
190    pub hash: String,
191    /// Presigned downloads for every bundle in the target set. The Operator
192    /// fetches only the ones it doesn't already have loaded at the right
193    /// version; already-loaded bundles are harmless to re-download.
194    #[serde(default, skip_serializing_if = "Vec::is_empty")]
195    pub bundles: Vec<OperationsBundleDownload>,
196}
197
198/// One release-independent container the Operator should run in its namespace.
199/// The manager sends the complete set on every sync. An empty set removes
200/// containers previously owned by this deployment.
201#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
202#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
203#[serde(rename_all = "camelCase")]
204pub struct TargetDynamicContainer {
205    pub name: String,
206    pub generation: u64,
207    pub image: String,
208    pub cpu: String,
209    pub memory: String,
210    pub replicas: u32,
211    pub ports: Vec<u16>,
212    pub deleted: bool,
213    #[serde(default)]
214    pub env: BTreeMap<String, String>,
215    #[serde(default)]
216    pub secret_env: BTreeMap<String, String>,
217    /// The container's variables its manager holds, as `cenv.<name>.<NAME>` slots with the
218    /// digests the sender planned with. When present, the manager adds exactly these values,
219    /// each checked against its kind and digest, and ignores anything else stored for the
220    /// container. When absent, the manager adds every value stored under the container's
221    /// prefix, as for senders that predate slot references. The manager removes the field
222    /// before the target reaches the Operator, so its API never carries it.
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    #[cfg_attr(feature = "openapi", schema(ignore))]
225    pub value_slots: Option<Vec<ValueSlotRef>>,
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub health_check: Option<DynamicContainerHealthCheck>,
228    /// Stop an installed workload when its release no longer admits the image.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub suspended_reason: Option<String>,
231}
232
233// Never include secret values in sync diagnostics.
234impl std::fmt::Debug for TargetDynamicContainer {
235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        f.debug_struct("TargetDynamicContainer")
237            .field("name", &self.name)
238            .field("generation", &self.generation)
239            .field("image", &self.image)
240            .field("replicas", &self.replicas)
241            .finish_non_exhaustive()
242    }
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
246#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
247#[serde(rename_all = "camelCase")]
248pub struct DynamicContainerHealthCheck {
249    pub path: String,
250    pub port: u16,
251}
252
253/// What the Operator observed after applying one target generation.
254#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
255#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
256#[serde(rename_all = "camelCase")]
257pub struct DynamicContainerReport {
258    pub name: String,
259    pub generation: u64,
260    pub status: DynamicContainerStatus,
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub message: Option<String>,
263}
264
265#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
266#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
267#[serde(rename_all = "lowercase")]
268pub enum DynamicContainerStatus {
269    Pending,
270    Running,
271    Failing,
272    Stopped,
273}
274
275/// Request sent by the agent to the manager during periodic sync.
276#[derive(Debug, Clone, Serialize, Deserialize)]
277#[serde(rename_all = "camelCase")]
278pub struct SyncRequest {
279    /// The deployment ID this agent is managing.
280    pub deployment_id: String,
281    /// Stable identity for this Operator process. Used to fence update work.
282    #[serde(default)]
283    pub session: String,
284    /// Signals that this Operator persists and echoes execution claims.
285    #[serde(default)]
286    pub supports_execution_claims: bool,
287    /// Signals that this Operator understands container tunnels. Older
288    /// Operators reject stacks that declare one, so the manager leaves
289    /// tunnels out of their targets.
290    #[serde(default)]
291    pub supports_tunnels: bool,
292    /// Exact update claim returned by the previous sync response.
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub execution_claim: Option<SyncExecutionClaim>,
295    /// Current deployment state as seen by the agent.
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub current_state: Option<DeploymentState>,
298    /// Managed Alien resource status samples emitted by the Operator's deployment step.
299    #[serde(
300        default,
301        rename = "resourceHeartbeats",
302        skip_serializing_if = "Vec::is_empty"
303    )]
304    pub heartbeats: Vec<ResourceHeartbeat>,
305    /// Observed raw-resource inventory batches successfully read by the Operator.
306    #[serde(
307        default,
308        rename = "observedInventoryBatches",
309        skip_serializing_if = "Vec::is_empty"
310    )]
311    pub observed_inventory_batches: Vec<ObservedInventoryBatch>,
312    /// Report-only capabilities observed by the Operator.
313    #[serde(default, skip_serializing_if = "Vec::is_empty")]
314    pub capabilities: Vec<OperatorCapabilityReport>,
315    /// Version of the Operator binary reporting this sync.
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub operator_version: Option<String>,
318    /// Report-only summary of the operations the Operator currently has
319    /// loaded. Absent means the Operator does not yet support reporting
320    /// this (older Operator versions), not that it has no operations.
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub operations_report: Option<OperationsReport>,
323    /// Operations an Operator installed on its own declares through its
324    /// environment, without setting values. Absent when its deployment's
325    /// release declares them instead.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub operations_config: Option<crate::OperationsConfig>,
328    /// The setup the installed chart carries. An observation, never a grant:
329    /// the manager decides whether it completes a pending setup update.
330    /// Absent when the Operator was not installed from a chart that records it.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub installed_setup: Option<InstalledSetup>,
333}
334
335/// The setup an installed chart was built for, read from the Operator's own
336/// environment. It changes only when the chart's setup changes.
337#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
338#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
339#[serde(rename_all = "camelCase")]
340pub struct InstalledSetup {
341    /// Release the chart was built from.
342    pub release_id: String,
343    /// Setup target the chart was generated for.
344    pub setup_target: String,
345    /// Setup compatibility fingerprint embedded in the chart.
346    pub setup_fingerprint: String,
347    /// Fingerprint algorithm version.
348    pub setup_fingerprint_version: u32,
349    /// Helm release name the chart is installed as.
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub release_name: Option<String>,
352    /// Kubernetes namespace of the Helm release.
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub namespace: Option<String>,
355}
356
357/// Where the Operator read the application identity from.
358#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
359#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
360#[serde(rename_all = "camelCase")]
361pub enum ObservedApplicationSource {
362    /// Labels and pod statuses of the observed Kubernetes workloads.
363    Kubernetes,
364}
365
366/// A container image running in one observed application workload.
367#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
368#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
369#[serde(rename_all = "camelCase")]
370pub struct ObservedApplicationImage {
371    /// Inventory identity of the workload, matching the `rawIdentity` of its
372    /// observed resource sample (for example `apps/v1:Deployment:shop:api`).
373    pub workload: String,
374    /// Container name within the workload.
375    pub container: String,
376    /// Image reference reported by the container runtime.
377    pub image: String,
378    /// Registry manifest digest in `sha256:<hex>` form, when the runtime
379    /// reports one.
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub digest: Option<String>,
382}
383
384/// Application release the Operator observes running in its environment.
385///
386/// This identifies the customer's application, not the Operator: the
387/// Operator's own image is reported separately as [`OperatorImageReport`].
388#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
389#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
390#[serde(rename_all = "camelCase")]
391pub struct ObservedApplicationReport {
392    /// Where this identity was read from.
393    pub source: ObservedApplicationSource,
394    /// Helm chart name from the workloads' `helm.sh/chart` label. Present only
395    /// when every labelled workload names the same chart.
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub chart_name: Option<String>,
398    /// Helm chart version from the same label.
399    #[serde(default, skip_serializing_if = "Option::is_none")]
400    pub chart_version: Option<String>,
401    /// Distinct container images running in the observed workloads.
402    #[serde(default, skip_serializing_if = "Vec::is_empty")]
403    pub images: Vec<ObservedApplicationImage>,
404    /// Whether every workload kind could be listed. When `false`, the chart
405    /// and images describe only the workloads the Operator could read.
406    pub complete: bool,
407    /// When the workloads were read.
408    pub observed_at: DateTime<Utc>,
409}
410
411/// Extensible wire input for sync metadata that is not part of the
412/// long-standing [`SyncRequest`] struct-literal contract.
413#[derive(Debug, Clone, Serialize)]
414#[serde(rename_all = "camelCase")]
415pub struct SyncInput {
416    /// Reader ceiling of this runtime, independent of the deployment writer version.
417    max_supported_deployment_protocol_version: u32,
418    #[serde(flatten)]
419    request: SyncRequest,
420    /// Exact immutable image identity injected by the installer. Absent for
421    /// older installations that do not carry an image receipt.
422    #[serde(skip_serializing_if = "Option::is_none")]
423    operator_image: Option<OperatorImageReport>,
424    /// Application release observed in the environment. Absent when the
425    /// Operator observed none or predates this report.
426    #[serde(skip_serializing_if = "Option::is_none")]
427    application: Option<ObservedApplicationReport>,
428    /// Absent for older Operators. An empty report means no containers remain.
429    #[serde(skip_serializing_if = "Option::is_none")]
430    dynamic_containers: Option<Vec<DynamicContainerReport>>,
431}
432
433impl SyncInput {
434    /// Start building a sync payload while preserving the stable
435    /// [`SyncRequest`] literal surface for downstream callers.
436    pub fn builder(request: SyncRequest) -> SyncInputBuilder {
437        SyncInputBuilder {
438            request,
439            operator_image: None,
440            application: None,
441            dynamic_containers: None,
442        }
443    }
444}
445
446/// Builder for optional sync receipts. New optional wire metadata belongs
447/// here so adding it does not break downstream [`SyncRequest`] literals.
448pub struct SyncInputBuilder {
449    request: SyncRequest,
450    operator_image: Option<OperatorImageReport>,
451    application: Option<ObservedApplicationReport>,
452    dynamic_containers: Option<Vec<DynamicContainerReport>>,
453}
454
455impl SyncInputBuilder {
456    /// Attach the immutable image receipt for the running Operator.
457    pub fn operator_image(mut self, operator_image: OperatorImageReport) -> Self {
458        self.operator_image = Some(operator_image);
459        self
460    }
461
462    /// Attach the application release observed in the environment.
463    pub fn application(mut self, application: ObservedApplicationReport) -> Self {
464        self.application = Some(application);
465        self
466    }
467
468    pub fn dynamic_containers(mut self, reports: Vec<DynamicContainerReport>) -> Self {
469        self.dynamic_containers = Some(reports);
470        self
471    }
472
473    /// Finish the serializable sync payload.
474    pub fn build(self) -> SyncInput {
475        SyncInput {
476            max_supported_deployment_protocol_version:
477                crate::MAX_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION,
478            request: self.request,
479            operator_image: self.operator_image,
480            application: self.application,
481            dynamic_containers: self.dynamic_containers,
482        }
483    }
484}
485
486/// Response from the manager to the agent sync request.
487#[derive(Debug, Clone, Serialize, Deserialize)]
488#[serde(rename_all = "camelCase")]
489pub struct SyncResponse {
490    /// Exact operation attempt associated with `target`.
491    #[serde(default, skip_serializing_if = "Option::is_none")]
492    pub execution_claim: Option<SyncExecutionClaim>,
493    /// Authoritative deployment state from the manager.
494    ///
495    /// Pull agents use this to hydrate local state when attaching to an
496    /// already-imported deployment. Absent means the agent's local state is
497    /// already authoritative or no state has been established yet.
498    #[serde(default, skip_serializing_if = "Option::is_none")]
499    pub current_state: Option<DeploymentState>,
500    /// Target deployment the agent should converge toward.
501    /// None means no changes needed or this is an observe-only deployment.
502    #[serde(skip_serializing_if = "Option::is_none")]
503    pub target: Option<TargetDeployment>,
504    /// Public URL for the commands API (e.g. `https://manager.example.com/v1`).
505    /// Operators and app-owned receivers use this to lease pending commands;
506    /// Workers themselves receive pushes.
507    /// When absent, the agent falls back to its sync URL.
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    pub commands_url: Option<String>,
510    /// Target operations-bundle set the Operator should converge its loaded
511    /// plugin registry toward. None means no enabled plugin set has ever
512    /// been established for this project (nothing to sync), not that the
513    /// Operator is up to date — compare `hash` against the Operator's own
514    /// loaded hash to decide whether to download anything.
515    #[serde(default, skip_serializing_if = "Option::is_none")]
516    pub target_operations_bundle_set: Option<TargetOperationsBundleSet>,
517    /// Complete release-independent target set. None means the manager does
518    /// not support this protocol; Some(empty) means remove owned containers.
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub target_dynamic_containers: Option<Vec<TargetDynamicContainer>>,
521    /// Base URL the Operator opens tunnel connections to. None means the
522    /// manager does not accept tunnels, so the Operator never dials.
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    pub tunnel_url: Option<String>,
525    /// Operator image the manager wants this Operator to run. Operators that
526    /// manage their own workload update to it; None means no opinion.
527    #[serde(default, skip_serializing_if = "Option::is_none")]
528    pub target_operator_image: Option<String>,
529}
530
531#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
532#[serde(rename_all = "camelCase")]
533pub struct SyncExecutionClaim {
534    pub operation_id: String,
535    pub attempt_id: String,
536}
537
538/// Target deployment state for the agent to converge toward.
539#[derive(Debug, Clone, Serialize, Deserialize)]
540#[serde(rename_all = "camelCase")]
541pub struct TargetDeployment {
542    /// Release information (ID, version, stack definition).
543    pub release_info: ReleaseInfo,
544    /// Full deployment configuration (settings, env vars, etc.).
545    pub config: DeploymentConfig,
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    #[test]
553    fn test_sync_request_serialization() {
554        let req = SyncRequest {
555            deployment_id: "dep_abc123".to_string(),
556            session: "operator-test".to_string(),
557            supports_execution_claims: true,
558            supports_tunnels: true,
559            execution_claim: None,
560            current_state: None,
561            heartbeats: Vec::new(),
562            observed_inventory_batches: Vec::new(),
563            capabilities: Vec::new(),
564            operator_version: None,
565            operations_report: None,
566            operations_config: None,
567            installed_setup: None,
568        };
569
570        let json = serde_json::to_value(&req).unwrap();
571        assert_eq!(json["deploymentId"], "dep_abc123");
572        assert_eq!(json["supportsExecutionClaims"], true);
573        // current_state is None → should be omitted
574        assert!(json.get("currentState").is_none());
575        assert!(json.get("resourceHeartbeats").is_none());
576        assert!(json.get("capabilities").is_none());
577        assert!(json.get("operatorVersion").is_none());
578        assert!(json.get("operatorImage").is_none());
579        assert!(json.get("operationsReport").is_none());
580    }
581
582    #[test]
583    fn test_sync_request_deserialization() {
584        let json = r#"{"deploymentId": "dep_xyz"}"#;
585        let req: SyncRequest = serde_json::from_str(json).unwrap();
586        assert_eq!(req.deployment_id, "dep_xyz");
587        assert!(req.session.is_empty());
588        assert!(!req.supports_execution_claims);
589        assert!(req.current_state.is_none());
590        assert!(req.heartbeats.is_empty());
591        assert!(req.observed_inventory_batches.is_empty());
592        assert!(req.capabilities.is_empty());
593        assert!(req.operator_version.is_none());
594        assert!(req.operations_report.is_none());
595    }
596
597    #[test]
598    fn test_sync_response_empty() {
599        let resp = SyncResponse {
600            execution_claim: None,
601            current_state: None,
602            target: None,
603            commands_url: None,
604            target_operations_bundle_set: None,
605            target_dynamic_containers: None,
606            tunnel_url: None,
607            target_operator_image: None,
608        };
609        let json = serde_json::to_value(&resp).unwrap();
610        // target is None → should be omitted
611        assert!(json.get("target").is_none());
612        assert!(json.get("currentState").is_none());
613        assert!(json.get("targetOperationsBundleSet").is_none());
614    }
615
616    #[test]
617    fn test_sync_response_roundtrip_no_target() {
618        let resp = SyncResponse {
619            execution_claim: None,
620            current_state: None,
621            target: None,
622            commands_url: None,
623            target_operations_bundle_set: None,
624            target_dynamic_containers: None,
625            tunnel_url: None,
626            target_operator_image: None,
627        };
628        let serialized = serde_json::to_string(&resp).unwrap();
629        let deserialized: SyncResponse = serde_json::from_str(&serialized).unwrap();
630        assert!(deserialized.target.is_none());
631        assert!(deserialized.current_state.is_none());
632    }
633
634    #[test]
635    fn test_sync_request_with_camel_case() {
636        // Verify camelCase renaming works correctly
637        let json = r#"{"deploymentId": "dep_1", "currentState": null}"#;
638        let req: SyncRequest = serde_json::from_str(json).unwrap();
639        assert_eq!(req.deployment_id, "dep_1");
640        assert!(req.current_state.is_none());
641        assert!(req.heartbeats.is_empty());
642        assert!(req.capabilities.is_empty());
643
644        // snake_case should NOT work
645        let json = r#"{"deployment_id": "dep_1"}"#;
646        assert!(serde_json::from_str::<SyncRequest>(json).is_err());
647    }
648
649    #[test]
650    fn test_sync_request_heartbeats_roundtrip() {
651        let json = serde_json::json!({
652            "deploymentId": "dep_1",
653            "resourceHeartbeats": [{
654                "deploymentId": "dep_1",
655                "resourceId": "api",
656                "resourceType": "container",
657                "controllerPlatform": "kubernetes",
658                "backend": "kubernetes",
659                "observedAt": "2026-01-01T00:00:00Z",
660                "data": {
661                    "resourceType": "container",
662                    "data": {
663                        "backend": "kubernetes",
664                        "status": {
665                            "health": "healthy",
666                            "lifecycle": "running",
667                            "message": null,
668                            "stale": false,
669                            "partial": false,
670                            "collectionIssues": []
671                        },
672                        "namespace": "default",
673                        "name": "api",
674                        "workloadKind": "deployment",
675                        "replicas": { "desired": 1, "current": 1, "ready": 1, "available": 1, "updated": null, "misscheduled": null },
676                        "restarts": 0,
677                        "cpu": null,
678                        "memory": null,
679                        "workload": null,
680                        "pods": [],
681                        "instances": [],
682                        "events": []
683                    }
684                },
685                "raw": []
686            }]
687        });
688
689        let req: SyncRequest = serde_json::from_value(json).unwrap();
690        assert_eq!(req.heartbeats.len(), 1);
691        assert_eq!(req.heartbeats[0].resource_id, "api");
692        assert!(req.capabilities.is_empty());
693
694        let serialized = serde_json::to_value(&req).unwrap();
695        assert_eq!(serialized["resourceHeartbeats"][0]["resourceId"], "api");
696    }
697
698    #[test]
699    fn test_sync_response_observe_only_state_roundtrip() {
700        let state = DeploymentState {
701            status: crate::DeploymentStatus::Running,
702            platform: crate::Platform::Kubernetes,
703            current_release: None,
704            target_release: None,
705            stack_state: None,
706            error: None,
707            environment_info: None,
708            runtime_metadata: None,
709            retry_requested: false,
710            protocol_version: crate::DEPLOYMENT_PROTOCOL_VERSION,
711        };
712        assert!(!state.has_desired());
713
714        let resp = SyncResponse {
715            execution_claim: None,
716            current_state: Some(state),
717            target: None,
718            commands_url: None,
719            target_operations_bundle_set: None,
720            target_dynamic_containers: None,
721            tunnel_url: None,
722            target_operator_image: None,
723        };
724
725        let serialized = serde_json::to_string(&resp).unwrap();
726        let deserialized: SyncResponse = serde_json::from_str(&serialized).unwrap();
727        let current_state = deserialized.current_state.unwrap();
728
729        assert_eq!(current_state.status, crate::DeploymentStatus::Running);
730        assert!(!current_state.has_desired());
731        assert!(deserialized.target.is_none());
732    }
733
734    #[test]
735    fn test_sync_request_operations_report_absent_by_default() {
736        // Simulates an old Operator that doesn't know about operationsReport:
737        // the field must default to None, never be required.
738        let json = r#"{"deploymentId": "dep_old_operator", "operatorVersion": "0.9.0"}"#;
739        let req: SyncRequest = serde_json::from_str(json).unwrap();
740        assert_eq!(req.operator_version.as_deref(), Some("0.9.0"));
741        assert!(req.operations_report.is_none());
742    }
743
744    #[test]
745    fn test_sync_request_operator_image_roundtrip() {
746        let digest = format!("sha256:{}", "a".repeat(64));
747        let report = OperatorImageReport {
748            source: OperatorImageSource::Package,
749            package_id: Some("pkg_operator".to_string()),
750            package_version: Some("1.2.3".to_string()),
751            image: format!("registry.example.com/operator@{digest}"),
752            digest,
753        };
754        report.validate().expect("valid package image report");
755
756        let value = serde_json::to_value(&report).unwrap();
757        assert_eq!(value["source"], "package");
758        assert_eq!(value["packageId"], "pkg_operator");
759        assert_eq!(value["packageVersion"], "1.2.3");
760
761        let decoded: OperatorImageReport = serde_json::from_value(value).unwrap();
762        assert_eq!(decoded, report);
763    }
764
765    #[test]
766    fn sync_input_adds_operator_image_without_expanding_sync_request() {
767        let digest = format!("sha256:{}", "a".repeat(64));
768        let request = SyncRequest {
769            deployment_id: "dep_1".to_string(),
770            session: String::new(),
771            supports_execution_claims: false,
772            supports_tunnels: false,
773            execution_claim: None,
774            current_state: None,
775            heartbeats: Vec::new(),
776            observed_inventory_batches: Vec::new(),
777            capabilities: Vec::new(),
778            operator_version: None,
779            operations_report: None,
780            operations_config: None,
781            installed_setup: None,
782        };
783        let input = SyncInput::builder(request)
784            .operator_image(OperatorImageReport {
785                source: OperatorImageSource::Configured,
786                package_id: None,
787                package_version: None,
788                image: format!("registry.example.com/operator@{digest}"),
789                digest,
790            })
791            .build();
792
793        let value = serde_json::to_value(input).unwrap();
794        assert_eq!(
795            value["maxSupportedDeploymentProtocolVersion"],
796            crate::MAX_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION
797        );
798        let legacy_request: SyncRequest = serde_json::from_value(value.clone()).unwrap();
799        assert_eq!(legacy_request.deployment_id, "dep_1");
800        assert_eq!(value["deploymentId"], "dep_1");
801        assert_eq!(value["operatorImage"]["source"], "configured");
802    }
803
804    #[test]
805    fn sync_input_omits_absent_operator_image() {
806        let request = SyncRequest {
807            deployment_id: "dep_1".to_string(),
808            session: String::new(),
809            supports_execution_claims: false,
810            supports_tunnels: false,
811            execution_claim: None,
812            current_state: None,
813            heartbeats: Vec::new(),
814            observed_inventory_batches: Vec::new(),
815            capabilities: Vec::new(),
816            operator_version: None,
817            operations_report: None,
818            operations_config: None,
819            installed_setup: None,
820        };
821
822        let value = serde_json::to_value(SyncInput::builder(request).build()).unwrap();
823        assert!(value.get("operatorImage").is_none());
824    }
825
826    #[test]
827    fn operator_image_report_rejects_mutable_or_inconsistent_identity() {
828        let digest = format!("sha256:{}", "a".repeat(64));
829        let mutable = OperatorImageReport {
830            source: OperatorImageSource::Configured,
831            package_id: None,
832            package_version: None,
833            image: "registry.example.com/operator:latest".to_string(),
834            digest: digest.clone(),
835        };
836        assert_eq!(
837            mutable.validate(),
838            Err("operator image must use repository@sha256:digest form")
839        );
840
841        let mismatched = OperatorImageReport {
842            source: OperatorImageSource::Configured,
843            package_id: None,
844            package_version: None,
845            image: format!("registry.example.com/operator@sha256:{}", "b".repeat(64)),
846            digest,
847        };
848        assert_eq!(
849            mismatched.validate(),
850            Err("operator image must contain one repository and the reported digest")
851        );
852    }
853
854    #[test]
855    fn operator_image_report_enforces_source_specific_package_fields() {
856        let digest = format!("sha256:{}", "a".repeat(64));
857        let image = format!("registry.example.com/operator@{digest}");
858        let missing_package = OperatorImageReport {
859            source: OperatorImageSource::Package,
860            package_id: None,
861            package_version: None,
862            image: image.clone(),
863            digest: digest.clone(),
864        };
865        assert_eq!(
866            missing_package.validate(),
867            Err("package operator images require package ID and version")
868        );
869
870        let configured_with_package = OperatorImageReport {
871            source: OperatorImageSource::Configured,
872            package_id: Some("pkg_operator".to_string()),
873            package_version: Some("1.2.3".to_string()),
874            image,
875            digest,
876        };
877        assert_eq!(
878            configured_with_package.validate(),
879            Err("configured operator images must not include package identity")
880        );
881    }
882
883    #[test]
884    fn test_sync_request_operations_report_roundtrip() {
885        let req = SyncRequest {
886            deployment_id: "dep_1".to_string(),
887            session: String::new(),
888            supports_execution_claims: false,
889            supports_tunnels: false,
890            execution_claim: None,
891            current_state: None,
892            heartbeats: Vec::new(),
893            observed_inventory_batches: Vec::new(),
894            capabilities: Vec::new(),
895            operator_version: Some("1.2.3".to_string()),
896            operations_report: Some(OperationsReport {
897                loaded_bundle_hash: Some("builtin:s3@1.0.0:key|".to_string()),
898                operations: vec![ReportedOperation {
899                    plugin: "s3".to_string(),
900                    plugin_version: "1.0.0".to_string(),
901                    name: "list-buckets".to_string(),
902                }],
903            }),
904            operations_config: None,
905            installed_setup: None,
906        };
907
908        let json = serde_json::to_value(&req).unwrap();
909        assert_eq!(
910            json["operationsReport"]["loadedBundleHash"],
911            "builtin:s3@1.0.0:key|"
912        );
913        assert_eq!(json["operationsReport"]["operations"][0]["plugin"], "s3");
914        assert_eq!(
915            json["operationsReport"]["operations"][0]["pluginVersion"],
916            "1.0.0"
917        );
918        assert_eq!(
919            json["operationsReport"]["operations"][0]["name"],
920            "list-buckets"
921        );
922
923        let deserialized: SyncRequest = serde_json::from_value(json).unwrap();
924        assert_eq!(
925            deserialized
926                .operations_report
927                .as_ref()
928                .unwrap()
929                .loaded_bundle_hash,
930            req.operations_report.as_ref().unwrap().loaded_bundle_hash
931        );
932        assert_eq!(
933            deserialized.operations_report.unwrap().operations,
934            req.operations_report.unwrap().operations
935        );
936    }
937
938    #[test]
939    fn test_sync_response_target_operations_bundle_set_roundtrip() {
940        let resp = SyncResponse {
941            execution_claim: None,
942            current_state: None,
943            target: None,
944            commands_url: None,
945            target_operations_bundle_set: Some(TargetOperationsBundleSet {
946                hash: "builtin:s3@1.0.0:key|".to_string(),
947                bundles: vec![OperationsBundleDownload {
948                    plugin: "s3".to_string(),
949                    plugin_version: "1.0.0".to_string(),
950                    url: "https://storage.example.com/bundle.zip?sig=abc".to_string(),
951                    env: BTreeMap::new(),
952                }],
953            }),
954            target_dynamic_containers: None,
955            tunnel_url: None,
956            target_operator_image: None,
957        };
958
959        let json = serde_json::to_value(&resp).unwrap();
960        assert_eq!(
961            json["targetOperationsBundleSet"]["hash"],
962            "builtin:s3@1.0.0:key|"
963        );
964        assert_eq!(
965            json["targetOperationsBundleSet"]["bundles"][0]["plugin"],
966            "s3"
967        );
968
969        let deserialized: SyncResponse = serde_json::from_value(json).unwrap();
970        assert_eq!(
971            deserialized.target_operations_bundle_set,
972            resp.target_operations_bundle_set
973        );
974    }
975
976    #[test]
977    fn test_sync_response_target_operations_bundle_set_absent_by_default() {
978        let json = serde_json::json!({});
979        let resp: SyncResponse = serde_json::from_value(json).unwrap();
980        assert!(resp.target_operations_bundle_set.is_none());
981    }
982
983    #[test]
984    fn dynamic_container_sync_preserves_empty_target_and_hides_secrets_in_debug() {
985        let old_manager_response: SyncResponse = serde_json::from_str("{}").unwrap();
986        assert!(old_manager_response.target_dynamic_containers.is_none());
987
988        let empty_target = SyncResponse {
989            execution_claim: None,
990            current_state: None,
991            target: None,
992            commands_url: None,
993            target_operations_bundle_set: None,
994            target_dynamic_containers: Some(vec![]),
995            tunnel_url: None,
996            target_operator_image: None,
997        };
998        let json = serde_json::to_value(&empty_target).unwrap();
999        assert_eq!(json["targetDynamicContainers"], serde_json::json!([]));
1000
1001        let target = TargetDynamicContainer {
1002            name: "api".to_string(),
1003            generation: 2,
1004            image: "example.com/api@sha256:abc".to_string(),
1005            cpu: "0.5".to_string(),
1006            memory: "512Mi".to_string(),
1007            replicas: 1,
1008            ports: vec![8080],
1009            deleted: false,
1010            env: BTreeMap::new(),
1011            secret_env: BTreeMap::from([("TOKEN".to_string(), "private-value".to_string())]),
1012            value_slots: None,
1013            health_check: None,
1014            suspended_reason: None,
1015        };
1016        assert!(!format!("{target:?}").contains("private-value"));
1017        let roundtrip: TargetDynamicContainer =
1018            serde_json::from_value(serde_json::to_value(&target).unwrap()).unwrap();
1019        assert_eq!(roundtrip, target);
1020    }
1021}