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    /// The setup the installed chart carries. An observation, never a grant:
319    /// the manager decides whether it completes a pending setup update.
320    /// Absent when the Operator was not installed from a chart that records it.
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub installed_setup: Option<InstalledSetup>,
323}
324
325/// The setup an installed chart was built for, read from the Operator's own
326/// environment. It changes only when the chart's setup changes.
327#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
328#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
329#[serde(rename_all = "camelCase")]
330pub struct InstalledSetup {
331    /// Release the chart was built from.
332    pub release_id: String,
333    /// Setup target the chart was generated for.
334    pub setup_target: String,
335    /// Setup compatibility fingerprint embedded in the chart.
336    pub setup_fingerprint: String,
337    /// Fingerprint algorithm version.
338    pub setup_fingerprint_version: u32,
339    /// Helm release name the chart is installed as.
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub release_name: Option<String>,
342    /// Kubernetes namespace of the Helm release.
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub namespace: Option<String>,
345}
346
347/// Where the Operator read the application identity from.
348#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
349#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
350#[serde(rename_all = "camelCase")]
351pub enum ObservedApplicationSource {
352    /// Labels and pod statuses of the observed Kubernetes workloads.
353    Kubernetes,
354}
355
356/// A container image running in one observed application workload.
357#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
358#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
359#[serde(rename_all = "camelCase")]
360pub struct ObservedApplicationImage {
361    /// Inventory identity of the workload, matching the `rawIdentity` of its
362    /// observed resource sample (for example `apps/v1:Deployment:shop:api`).
363    pub workload: String,
364    /// Container name within the workload.
365    pub container: String,
366    /// Image reference reported by the container runtime.
367    pub image: String,
368    /// Registry manifest digest in `sha256:<hex>` form, when the runtime
369    /// reports one.
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub digest: Option<String>,
372}
373
374/// Application release the Operator observes running in its environment.
375///
376/// This identifies the customer's application, not the Operator: the
377/// Operator's own image is reported separately as [`OperatorImageReport`].
378#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
379#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
380#[serde(rename_all = "camelCase")]
381pub struct ObservedApplicationReport {
382    /// Where this identity was read from.
383    pub source: ObservedApplicationSource,
384    /// Helm chart name from the workloads' `helm.sh/chart` label. Present only
385    /// when every labelled workload names the same chart.
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub chart_name: Option<String>,
388    /// Helm chart version from the same label.
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub chart_version: Option<String>,
391    /// Distinct container images running in the observed workloads.
392    #[serde(default, skip_serializing_if = "Vec::is_empty")]
393    pub images: Vec<ObservedApplicationImage>,
394    /// Whether every workload kind could be listed. When `false`, the chart
395    /// and images describe only the workloads the Operator could read.
396    pub complete: bool,
397    /// When the workloads were read.
398    pub observed_at: DateTime<Utc>,
399}
400
401/// Extensible wire input for sync metadata that is not part of the
402/// long-standing [`SyncRequest`] struct-literal contract.
403#[derive(Debug, Clone, Serialize)]
404#[serde(rename_all = "camelCase")]
405pub struct SyncInput {
406    /// Reader ceiling of this runtime, independent of the deployment writer version.
407    max_supported_deployment_protocol_version: u32,
408    #[serde(flatten)]
409    request: SyncRequest,
410    /// Exact immutable image identity injected by the installer. Absent for
411    /// older installations that do not carry an image receipt.
412    #[serde(skip_serializing_if = "Option::is_none")]
413    operator_image: Option<OperatorImageReport>,
414    /// Application release observed in the environment. Absent when the
415    /// Operator observed none or predates this report.
416    #[serde(skip_serializing_if = "Option::is_none")]
417    application: Option<ObservedApplicationReport>,
418    /// Absent for older Operators. An empty report means no containers remain.
419    #[serde(skip_serializing_if = "Option::is_none")]
420    dynamic_containers: Option<Vec<DynamicContainerReport>>,
421}
422
423impl SyncInput {
424    /// Start building a sync payload while preserving the stable
425    /// [`SyncRequest`] literal surface for downstream callers.
426    pub fn builder(request: SyncRequest) -> SyncInputBuilder {
427        SyncInputBuilder {
428            request,
429            operator_image: None,
430            application: None,
431            dynamic_containers: None,
432        }
433    }
434}
435
436/// Builder for optional sync receipts. New optional wire metadata belongs
437/// here so adding it does not break downstream [`SyncRequest`] literals.
438pub struct SyncInputBuilder {
439    request: SyncRequest,
440    operator_image: Option<OperatorImageReport>,
441    application: Option<ObservedApplicationReport>,
442    dynamic_containers: Option<Vec<DynamicContainerReport>>,
443}
444
445impl SyncInputBuilder {
446    /// Attach the immutable image receipt for the running Operator.
447    pub fn operator_image(mut self, operator_image: OperatorImageReport) -> Self {
448        self.operator_image = Some(operator_image);
449        self
450    }
451
452    /// Attach the application release observed in the environment.
453    pub fn application(mut self, application: ObservedApplicationReport) -> Self {
454        self.application = Some(application);
455        self
456    }
457
458    pub fn dynamic_containers(mut self, reports: Vec<DynamicContainerReport>) -> Self {
459        self.dynamic_containers = Some(reports);
460        self
461    }
462
463    /// Finish the serializable sync payload.
464    pub fn build(self) -> SyncInput {
465        SyncInput {
466            max_supported_deployment_protocol_version:
467                crate::MAX_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION,
468            request: self.request,
469            operator_image: self.operator_image,
470            application: self.application,
471            dynamic_containers: self.dynamic_containers,
472        }
473    }
474}
475
476/// Response from the manager to the agent sync request.
477#[derive(Debug, Clone, Serialize, Deserialize)]
478#[serde(rename_all = "camelCase")]
479pub struct SyncResponse {
480    /// Exact operation attempt associated with `target`.
481    #[serde(default, skip_serializing_if = "Option::is_none")]
482    pub execution_claim: Option<SyncExecutionClaim>,
483    /// Authoritative deployment state from the manager.
484    ///
485    /// Pull agents use this to hydrate local state when attaching to an
486    /// already-imported deployment. Absent means the agent's local state is
487    /// already authoritative or no state has been established yet.
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub current_state: Option<DeploymentState>,
490    /// Target deployment the agent should converge toward.
491    /// None means no changes needed or this is an observe-only deployment.
492    #[serde(skip_serializing_if = "Option::is_none")]
493    pub target: Option<TargetDeployment>,
494    /// Public URL for the commands API (e.g. `https://manager.example.com/v1`).
495    /// Operators and app-owned receivers use this to lease pending commands;
496    /// Workers themselves receive pushes.
497    /// When absent, the agent falls back to its sync URL.
498    #[serde(default, skip_serializing_if = "Option::is_none")]
499    pub commands_url: Option<String>,
500    /// Target operations-bundle set the Operator should converge its loaded
501    /// plugin registry toward. None means no enabled plugin set has ever
502    /// been established for this project (nothing to sync), not that the
503    /// Operator is up to date — compare `hash` against the Operator's own
504    /// loaded hash to decide whether to download anything.
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub target_operations_bundle_set: Option<TargetOperationsBundleSet>,
507    /// Complete release-independent target set. None means the manager does
508    /// not support this protocol; Some(empty) means remove owned containers.
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub target_dynamic_containers: Option<Vec<TargetDynamicContainer>>,
511    /// Base URL the Operator opens tunnel connections to. None means the
512    /// manager does not accept tunnels, so the Operator never dials.
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    pub tunnel_url: Option<String>,
515    /// Operator image the manager wants this Operator to run. Operators that
516    /// manage their own workload update to it; None means no opinion.
517    #[serde(default, skip_serializing_if = "Option::is_none")]
518    pub target_operator_image: Option<String>,
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
522#[serde(rename_all = "camelCase")]
523pub struct SyncExecutionClaim {
524    pub operation_id: String,
525    pub attempt_id: String,
526}
527
528/// Target deployment state for the agent to converge toward.
529#[derive(Debug, Clone, Serialize, Deserialize)]
530#[serde(rename_all = "camelCase")]
531pub struct TargetDeployment {
532    /// Release information (ID, version, stack definition).
533    pub release_info: ReleaseInfo,
534    /// Full deployment configuration (settings, env vars, etc.).
535    pub config: DeploymentConfig,
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    #[test]
543    fn test_sync_request_serialization() {
544        let req = SyncRequest {
545            deployment_id: "dep_abc123".to_string(),
546            session: "operator-test".to_string(),
547            supports_execution_claims: true,
548            supports_tunnels: true,
549            execution_claim: None,
550            current_state: None,
551            heartbeats: Vec::new(),
552            observed_inventory_batches: Vec::new(),
553            capabilities: Vec::new(),
554            operator_version: None,
555            operations_report: None,
556            operations_config: None,
557            installed_setup: None,
558        };
559
560        let json = serde_json::to_value(&req).unwrap();
561        assert_eq!(json["deploymentId"], "dep_abc123");
562        assert_eq!(json["supportsExecutionClaims"], true);
563        // current_state is None → should be omitted
564        assert!(json.get("currentState").is_none());
565        assert!(json.get("resourceHeartbeats").is_none());
566        assert!(json.get("capabilities").is_none());
567        assert!(json.get("operatorVersion").is_none());
568        assert!(json.get("operatorImage").is_none());
569        assert!(json.get("operationsReport").is_none());
570    }
571
572    #[test]
573    fn test_sync_request_deserialization() {
574        let json = r#"{"deploymentId": "dep_xyz"}"#;
575        let req: SyncRequest = serde_json::from_str(json).unwrap();
576        assert_eq!(req.deployment_id, "dep_xyz");
577        assert!(req.session.is_empty());
578        assert!(!req.supports_execution_claims);
579        assert!(req.current_state.is_none());
580        assert!(req.heartbeats.is_empty());
581        assert!(req.observed_inventory_batches.is_empty());
582        assert!(req.capabilities.is_empty());
583        assert!(req.operator_version.is_none());
584        assert!(req.operations_report.is_none());
585    }
586
587    #[test]
588    fn test_sync_response_empty() {
589        let resp = SyncResponse {
590            execution_claim: None,
591            current_state: None,
592            target: None,
593            commands_url: None,
594            target_operations_bundle_set: None,
595            target_dynamic_containers: None,
596            tunnel_url: None,
597            target_operator_image: None,
598        };
599        let json = serde_json::to_value(&resp).unwrap();
600        // target is None → should be omitted
601        assert!(json.get("target").is_none());
602        assert!(json.get("currentState").is_none());
603        assert!(json.get("targetOperationsBundleSet").is_none());
604    }
605
606    #[test]
607    fn test_sync_response_roundtrip_no_target() {
608        let resp = SyncResponse {
609            execution_claim: None,
610            current_state: None,
611            target: None,
612            commands_url: None,
613            target_operations_bundle_set: None,
614            target_dynamic_containers: None,
615            tunnel_url: None,
616            target_operator_image: None,
617        };
618        let serialized = serde_json::to_string(&resp).unwrap();
619        let deserialized: SyncResponse = serde_json::from_str(&serialized).unwrap();
620        assert!(deserialized.target.is_none());
621        assert!(deserialized.current_state.is_none());
622    }
623
624    #[test]
625    fn test_sync_request_with_camel_case() {
626        // Verify camelCase renaming works correctly
627        let json = r#"{"deploymentId": "dep_1", "currentState": null}"#;
628        let req: SyncRequest = serde_json::from_str(json).unwrap();
629        assert_eq!(req.deployment_id, "dep_1");
630        assert!(req.current_state.is_none());
631        assert!(req.heartbeats.is_empty());
632        assert!(req.capabilities.is_empty());
633
634        // snake_case should NOT work
635        let json = r#"{"deployment_id": "dep_1"}"#;
636        assert!(serde_json::from_str::<SyncRequest>(json).is_err());
637    }
638
639    #[test]
640    fn test_sync_request_heartbeats_roundtrip() {
641        let json = serde_json::json!({
642            "deploymentId": "dep_1",
643            "resourceHeartbeats": [{
644                "deploymentId": "dep_1",
645                "resourceId": "api",
646                "resourceType": "container",
647                "controllerPlatform": "kubernetes",
648                "backend": "kubernetes",
649                "observedAt": "2026-01-01T00:00:00Z",
650                "data": {
651                    "resourceType": "container",
652                    "data": {
653                        "backend": "kubernetes",
654                        "status": {
655                            "health": "healthy",
656                            "lifecycle": "running",
657                            "message": null,
658                            "stale": false,
659                            "partial": false,
660                            "collectionIssues": []
661                        },
662                        "namespace": "default",
663                        "name": "api",
664                        "workloadKind": "deployment",
665                        "replicas": { "desired": 1, "current": 1, "ready": 1, "available": 1, "updated": null, "misscheduled": null },
666                        "restarts": 0,
667                        "cpu": null,
668                        "memory": null,
669                        "workload": null,
670                        "pods": [],
671                        "instances": [],
672                        "events": []
673                    }
674                },
675                "raw": []
676            }]
677        });
678
679        let req: SyncRequest = serde_json::from_value(json).unwrap();
680        assert_eq!(req.heartbeats.len(), 1);
681        assert_eq!(req.heartbeats[0].resource_id, "api");
682        assert!(req.capabilities.is_empty());
683
684        let serialized = serde_json::to_value(&req).unwrap();
685        assert_eq!(serialized["resourceHeartbeats"][0]["resourceId"], "api");
686    }
687
688    #[test]
689    fn test_sync_response_observe_only_state_roundtrip() {
690        let state = DeploymentState {
691            status: crate::DeploymentStatus::Running,
692            platform: crate::Platform::Kubernetes,
693            current_release: None,
694            target_release: None,
695            stack_state: None,
696            error: None,
697            environment_info: None,
698            runtime_metadata: None,
699            retry_requested: false,
700            protocol_version: crate::DEPLOYMENT_PROTOCOL_VERSION,
701        };
702        assert!(!state.has_desired());
703
704        let resp = SyncResponse {
705            execution_claim: None,
706            current_state: Some(state),
707            target: None,
708            commands_url: None,
709            target_operations_bundle_set: None,
710            target_dynamic_containers: None,
711            tunnel_url: None,
712            target_operator_image: None,
713        };
714
715        let serialized = serde_json::to_string(&resp).unwrap();
716        let deserialized: SyncResponse = serde_json::from_str(&serialized).unwrap();
717        let current_state = deserialized.current_state.unwrap();
718
719        assert_eq!(current_state.status, crate::DeploymentStatus::Running);
720        assert!(!current_state.has_desired());
721        assert!(deserialized.target.is_none());
722    }
723
724    #[test]
725    fn test_sync_request_operations_report_absent_by_default() {
726        // Simulates an old Operator that doesn't know about operationsReport:
727        // the field must default to None, never be required.
728        let json = r#"{"deploymentId": "dep_old_operator", "operatorVersion": "0.9.0"}"#;
729        let req: SyncRequest = serde_json::from_str(json).unwrap();
730        assert_eq!(req.operator_version.as_deref(), Some("0.9.0"));
731        assert!(req.operations_report.is_none());
732    }
733
734    #[test]
735    fn test_sync_request_operator_image_roundtrip() {
736        let digest = format!("sha256:{}", "a".repeat(64));
737        let report = OperatorImageReport {
738            source: OperatorImageSource::Package,
739            package_id: Some("pkg_operator".to_string()),
740            package_version: Some("1.2.3".to_string()),
741            image: format!("registry.example.com/operator@{digest}"),
742            digest,
743        };
744        report.validate().expect("valid package image report");
745
746        let value = serde_json::to_value(&report).unwrap();
747        assert_eq!(value["source"], "package");
748        assert_eq!(value["packageId"], "pkg_operator");
749        assert_eq!(value["packageVersion"], "1.2.3");
750
751        let decoded: OperatorImageReport = serde_json::from_value(value).unwrap();
752        assert_eq!(decoded, report);
753    }
754
755    #[test]
756    fn sync_input_adds_operator_image_without_expanding_sync_request() {
757        let digest = format!("sha256:{}", "a".repeat(64));
758        let request = SyncRequest {
759            deployment_id: "dep_1".to_string(),
760            session: String::new(),
761            supports_execution_claims: false,
762            supports_tunnels: false,
763            execution_claim: None,
764            current_state: None,
765            heartbeats: Vec::new(),
766            observed_inventory_batches: Vec::new(),
767            capabilities: Vec::new(),
768            operator_version: None,
769            operations_report: None,
770            operations_config: None,
771            installed_setup: None,
772        };
773        let input = SyncInput::builder(request)
774            .operator_image(OperatorImageReport {
775                source: OperatorImageSource::Configured,
776                package_id: None,
777                package_version: None,
778                image: format!("registry.example.com/operator@{digest}"),
779                digest,
780            })
781            .build();
782
783        let value = serde_json::to_value(input).unwrap();
784        assert_eq!(
785            value["maxSupportedDeploymentProtocolVersion"],
786            crate::MAX_SUPPORTED_DEPLOYMENT_PROTOCOL_VERSION
787        );
788        let legacy_request: SyncRequest = serde_json::from_value(value.clone()).unwrap();
789        assert_eq!(legacy_request.deployment_id, "dep_1");
790        assert_eq!(value["deploymentId"], "dep_1");
791        assert_eq!(value["operatorImage"]["source"], "configured");
792    }
793
794    #[test]
795    fn sync_input_omits_absent_operator_image() {
796        let request = SyncRequest {
797            deployment_id: "dep_1".to_string(),
798            session: String::new(),
799            supports_execution_claims: false,
800            supports_tunnels: false,
801            execution_claim: None,
802            current_state: None,
803            heartbeats: Vec::new(),
804            observed_inventory_batches: Vec::new(),
805            capabilities: Vec::new(),
806            operator_version: None,
807            operations_report: None,
808            operations_config: None,
809            installed_setup: None,
810        };
811
812        let value = serde_json::to_value(SyncInput::builder(request).build()).unwrap();
813        assert!(value.get("operatorImage").is_none());
814    }
815
816    #[test]
817    fn operator_image_report_rejects_mutable_or_inconsistent_identity() {
818        let digest = format!("sha256:{}", "a".repeat(64));
819        let mutable = OperatorImageReport {
820            source: OperatorImageSource::Configured,
821            package_id: None,
822            package_version: None,
823            image: "registry.example.com/operator:latest".to_string(),
824            digest: digest.clone(),
825        };
826        assert_eq!(
827            mutable.validate(),
828            Err("operator image must use repository@sha256:digest form")
829        );
830
831        let mismatched = OperatorImageReport {
832            source: OperatorImageSource::Configured,
833            package_id: None,
834            package_version: None,
835            image: format!("registry.example.com/operator@sha256:{}", "b".repeat(64)),
836            digest,
837        };
838        assert_eq!(
839            mismatched.validate(),
840            Err("operator image must contain one repository and the reported digest")
841        );
842    }
843
844    #[test]
845    fn operator_image_report_enforces_source_specific_package_fields() {
846        let digest = format!("sha256:{}", "a".repeat(64));
847        let image = format!("registry.example.com/operator@{digest}");
848        let missing_package = OperatorImageReport {
849            source: OperatorImageSource::Package,
850            package_id: None,
851            package_version: None,
852            image: image.clone(),
853            digest: digest.clone(),
854        };
855        assert_eq!(
856            missing_package.validate(),
857            Err("package operator images require package ID and version")
858        );
859
860        let configured_with_package = OperatorImageReport {
861            source: OperatorImageSource::Configured,
862            package_id: Some("pkg_operator".to_string()),
863            package_version: Some("1.2.3".to_string()),
864            image,
865            digest,
866        };
867        assert_eq!(
868            configured_with_package.validate(),
869            Err("configured operator images must not include package identity")
870        );
871    }
872
873    #[test]
874    fn test_sync_request_operations_report_roundtrip() {
875        let req = SyncRequest {
876            deployment_id: "dep_1".to_string(),
877            session: String::new(),
878            supports_execution_claims: false,
879            supports_tunnels: false,
880            execution_claim: None,
881            current_state: None,
882            heartbeats: Vec::new(),
883            observed_inventory_batches: Vec::new(),
884            capabilities: Vec::new(),
885            operator_version: Some("1.2.3".to_string()),
886            operations_report: Some(OperationsReport {
887                loaded_bundle_hash: Some("builtin:s3@1.0.0:key|".to_string()),
888                operations: vec![ReportedOperation {
889                    plugin: "s3".to_string(),
890                    plugin_version: "1.0.0".to_string(),
891                    name: "list-buckets".to_string(),
892                }],
893            }),
894            operations_config: None,
895            installed_setup: None,
896        };
897
898        let json = serde_json::to_value(&req).unwrap();
899        assert_eq!(
900            json["operationsReport"]["loadedBundleHash"],
901            "builtin:s3@1.0.0:key|"
902        );
903        assert_eq!(json["operationsReport"]["operations"][0]["plugin"], "s3");
904        assert_eq!(
905            json["operationsReport"]["operations"][0]["pluginVersion"],
906            "1.0.0"
907        );
908        assert_eq!(
909            json["operationsReport"]["operations"][0]["name"],
910            "list-buckets"
911        );
912
913        let deserialized: SyncRequest = serde_json::from_value(json).unwrap();
914        assert_eq!(
915            deserialized
916                .operations_report
917                .as_ref()
918                .unwrap()
919                .loaded_bundle_hash,
920            req.operations_report.as_ref().unwrap().loaded_bundle_hash
921        );
922        assert_eq!(
923            deserialized.operations_report.unwrap().operations,
924            req.operations_report.unwrap().operations
925        );
926    }
927
928    #[test]
929    fn test_sync_response_target_operations_bundle_set_roundtrip() {
930        let resp = SyncResponse {
931            execution_claim: None,
932            current_state: None,
933            target: None,
934            commands_url: None,
935            target_operations_bundle_set: Some(TargetOperationsBundleSet {
936                hash: "builtin:s3@1.0.0:key|".to_string(),
937                bundles: vec![OperationsBundleDownload {
938                    plugin: "s3".to_string(),
939                    plugin_version: "1.0.0".to_string(),
940                    url: "https://storage.example.com/bundle.zip?sig=abc".to_string(),
941                    env: BTreeMap::new(),
942                }],
943            }),
944            target_dynamic_containers: None,
945            tunnel_url: None,
946            target_operator_image: None,
947        };
948
949        let json = serde_json::to_value(&resp).unwrap();
950        assert_eq!(
951            json["targetOperationsBundleSet"]["hash"],
952            "builtin:s3@1.0.0:key|"
953        );
954        assert_eq!(
955            json["targetOperationsBundleSet"]["bundles"][0]["plugin"],
956            "s3"
957        );
958
959        let deserialized: SyncResponse = serde_json::from_value(json).unwrap();
960        assert_eq!(
961            deserialized.target_operations_bundle_set,
962            resp.target_operations_bundle_set
963        );
964    }
965
966    #[test]
967    fn test_sync_response_target_operations_bundle_set_absent_by_default() {
968        let json = serde_json::json!({});
969        let resp: SyncResponse = serde_json::from_value(json).unwrap();
970        assert!(resp.target_operations_bundle_set.is_none());
971    }
972
973    #[test]
974    fn dynamic_container_sync_preserves_empty_target_and_hides_secrets_in_debug() {
975        let old_manager_response: SyncResponse = serde_json::from_str("{}").unwrap();
976        assert!(old_manager_response.target_dynamic_containers.is_none());
977
978        let empty_target = SyncResponse {
979            execution_claim: None,
980            current_state: None,
981            target: None,
982            commands_url: None,
983            target_operations_bundle_set: None,
984            target_dynamic_containers: Some(vec![]),
985            tunnel_url: None,
986            target_operator_image: None,
987        };
988        let json = serde_json::to_value(&empty_target).unwrap();
989        assert_eq!(json["targetDynamicContainers"], serde_json::json!([]));
990
991        let target = TargetDynamicContainer {
992            name: "api".to_string(),
993            generation: 2,
994            image: "example.com/api@sha256:abc".to_string(),
995            cpu: "0.5".to_string(),
996            memory: "512Mi".to_string(),
997            replicas: 1,
998            ports: vec![8080],
999            deleted: false,
1000            env: BTreeMap::new(),
1001            secret_env: BTreeMap::from([("TOKEN".to_string(), "private-value".to_string())]),
1002            health_check: None,
1003            suspended_reason: None,
1004        };
1005        assert!(!format!("{target:?}").contains("private-value"));
1006        let roundtrip: TargetDynamicContainer =
1007            serde_json::from_value(serde_json::to_value(&target).unwrap()).unwrap();
1008        assert_eq!(roundtrip, target);
1009    }
1010}