Skip to main content

alien_core/
deployment.rs

1use crate::{
2    ExternalBindings, ImagePullCredentials, ManagementConfig, Platform, Stack, StackSettings,
3    StackState,
4};
5use alien_error::AlienError;
6use bon::Builder;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10/// Deployment status in the deployment lifecycle
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13#[serde(rename_all = "kebab-case")]
14pub enum DeploymentStatus {
15    Pending,
16    InitialSetup,
17    InitialSetupFailed,
18    Provisioning,
19    ProvisioningFailed,
20    Running,
21    RefreshFailed,
22    UpdatePending,
23    Updating,
24    UpdateFailed,
25    DeletePending,
26    Deleting,
27    DeleteFailed,
28    Deleted,
29}
30
31impl DeploymentStatus {
32    /// Check if deployment is synced (current state matches desired state).
33    ///
34    /// When synced, no more deployment steps are needed *for the current operation*.
35    /// Note: This doesn't mean the deployment is "done forever":
36    /// - `Running` → heartbeats continue, updates can come
37    /// - `*Failed` → can be retried
38    /// - `Deleted` → can be recreated
39    ///
40    /// "Synced" means: "we've reached the goal of the current deployment phase"
41    pub fn is_synced(&self) -> bool {
42        matches!(
43            self,
44            DeploymentStatus::Running
45                | DeploymentStatus::InitialSetupFailed
46                | DeploymentStatus::ProvisioningFailed
47                | DeploymentStatus::UpdateFailed
48                | DeploymentStatus::DeleteFailed
49                | DeploymentStatus::RefreshFailed
50                | DeploymentStatus::Deleted
51        )
52    }
53
54    /// Check if deployment is in a failed state that requires retry to proceed.
55    pub fn is_failed(&self) -> bool {
56        matches!(
57            self,
58            DeploymentStatus::InitialSetupFailed
59                | DeploymentStatus::ProvisioningFailed
60                | DeploymentStatus::UpdateFailed
61                | DeploymentStatus::DeleteFailed
62                | DeploymentStatus::RefreshFailed
63        )
64    }
65}
66
67/// Release metadata
68///
69/// Identifies a specific release version and includes the stack definition.
70/// The deployment engine uses this to track which release is currently deployed
71/// and which is the target.
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
74#[serde(rename_all = "camelCase")]
75pub struct ReleaseInfo {
76    /// Release ID (e.g., rel_xyz)
77    pub release_id: String,
78    /// Version string (e.g., 2.1.0)
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub version: Option<String>,
81    /// Short description of the release
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub description: Option<String>,
84    /// Stack definition for this release
85    pub stack: Stack,
86}
87
88/// AWS-specific environment information
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
91#[serde(rename_all = "camelCase")]
92pub struct AwsEnvironmentInfo {
93    /// AWS account ID
94    pub account_id: String,
95    /// AWS region
96    pub region: String,
97}
98
99/// GCP-specific environment information
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
101#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
102#[serde(rename_all = "camelCase")]
103pub struct GcpEnvironmentInfo {
104    /// GCP project number (e.g., "123456789012")
105    pub project_number: String,
106    /// GCP project ID (e.g., "my-project")
107    pub project_id: String,
108    /// GCP region
109    pub region: String,
110}
111
112/// Azure-specific environment information
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
114#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
115#[serde(rename_all = "camelCase")]
116pub struct AzureEnvironmentInfo {
117    /// Azure tenant ID
118    pub tenant_id: String,
119    /// Azure subscription ID
120    pub subscription_id: String,
121    /// Azure location/region
122    pub location: String,
123}
124
125/// Local platform environment information
126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
127#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
128#[serde(rename_all = "camelCase")]
129pub struct LocalEnvironmentInfo {
130    /// Hostname of the machine running the deployment
131    pub hostname: String,
132    /// Operating system (e.g., "linux", "macos", "windows")
133    pub os: String,
134    /// Architecture (e.g., "x86_64", "aarch64")
135    pub arch: String,
136}
137
138/// Test platform environment information (mock)
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
140#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
141#[serde(rename_all = "camelCase")]
142pub struct TestEnvironmentInfo {
143    /// Test identifier for this environment
144    pub test_id: String,
145}
146
147/// Platform-specific environment information
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
149#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
150#[serde(rename_all = "camelCase", tag = "platform")]
151pub enum EnvironmentInfo {
152    /// AWS environment information
153    Aws(AwsEnvironmentInfo),
154    /// GCP environment information
155    Gcp(GcpEnvironmentInfo),
156    /// Azure environment information
157    Azure(AzureEnvironmentInfo),
158    /// Local platform environment information
159    Local(LocalEnvironmentInfo),
160    /// Test platform environment information (mock)
161    Test(TestEnvironmentInfo),
162}
163
164impl EnvironmentInfo {
165    /// Get the platform for this environment info
166    pub fn platform(&self) -> Platform {
167        match self {
168            EnvironmentInfo::Aws(_) => Platform::Aws,
169            EnvironmentInfo::Gcp(_) => Platform::Gcp,
170            EnvironmentInfo::Azure(_) => Platform::Azure,
171            EnvironmentInfo::Local(_) => Platform::Local,
172            EnvironmentInfo::Test(_) => Platform::Test,
173        }
174    }
175}
176
177/// Runtime metadata for deployment
178///
179/// Stores deployment state that needs to persist across step calls.
180#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
181#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
182#[serde(rename_all = "camelCase")]
183pub struct RuntimeMetadata {
184    /// Hash of the environment variables snapshot that was last synced to the vault
185    /// Used to avoid redundant sync operations during incremental deployment
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub last_synced_env_vars_hash: Option<String>,
188
189    /// The prepared (mutated) stack from the last successful deployment phase
190    /// This is the stack AFTER mutations have been applied (with service accounts, vault, etc.)
191    /// Used for compatibility checks during updates to compare mutated stacks
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub prepared_stack: Option<Stack>,
194}
195
196/// Certificate status in the certificate lifecycle
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
198#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
199#[serde(rename_all = "kebab-case")]
200pub enum CertificateStatus {
201    Pending,
202    Issued,
203    Renewing,
204    RenewalFailed,
205    Failed,
206    Deleting,
207}
208
209/// DNS record status in the DNS lifecycle
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
211#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
212#[serde(rename_all = "lowercase")]
213pub enum DnsRecordStatus {
214    Pending,
215    Active,
216    Updating,
217    Deleting,
218    Failed,
219}
220
221/// Certificate and DNS metadata for a public resource.
222///
223/// Includes decrypted certificate data for issued certificates.
224/// Private keys are deployment-scoped secrets (like environment variables).
225#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
226#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
227#[serde(rename_all = "camelCase")]
228pub struct ResourceDomainInfo {
229    /// Fully qualified domain name.
230    pub fqdn: String,
231    /// Certificate ID (for tracking/logging).
232    pub certificate_id: String,
233    /// Current certificate status
234    pub certificate_status: CertificateStatus,
235    /// Current DNS record status
236    pub dns_status: DnsRecordStatus,
237    /// Last DNS error message. Present when DNS previously failed, even if status
238    /// was reset to pending for retry. Used to surface actionable error context
239    /// in WaitingForDns failure messages.
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub dns_error: Option<String>,
242    /// Full PEM certificate chain (only present if status is "issued").
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub certificate_chain: Option<String>,
245    /// Decrypted private key (only present if status is "issued").
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub private_key: Option<String>,
248    /// ISO 8601 timestamp when certificate was issued (for renewal detection).
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub issued_at: Option<String>,
251}
252
253/// Domain metadata for auto-managed public resources (no private keys).
254#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
255#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
256#[serde(rename_all = "camelCase")]
257pub struct DomainMetadata {
258    /// Base domain for auto-generated domains (e.g., "vpc.direct").
259    pub base_domain: String,
260    /// Deployment public subdomain (e.g., "k8f2j3").
261    pub public_subdomain: String,
262    /// Hosted zone ID for DNS records.
263    pub hosted_zone_id: String,
264    /// Metadata per resource ID.
265    pub resources: HashMap<String, ResourceDomainInfo>,
266}
267
268/// Deployment state
269///
270/// Represents the current state of deployed infrastructure, including release tracking.
271/// This is platform-agnostic - no backend IDs or database relationships.
272///
273/// The deployment engine manages releases internally: when a deployment succeeds,
274/// it promotes `target_release` to `current_release` and clears `target_release`.
275#[derive(Debug, Clone, Serialize, Deserialize, Builder)]
276#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
277#[serde(rename_all = "camelCase")]
278pub struct DeploymentState {
279    /// Current lifecycle phase
280    pub status: DeploymentStatus,
281    /// Target cloud platform (AWS, GCP, Azure, Kubernetes)
282    pub platform: Platform,
283    /// Currently deployed release (None for first deployment)
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub current_release: Option<ReleaseInfo>,
286    /// Target release to deploy (None when synced with current)
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub target_release: Option<ReleaseInfo>,
289    /// Infrastructure resource tracking (which resources exist, their status, outputs)
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub stack_state: Option<StackState>,
292    /// Cloud account details (account ID, project number, region)
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub environment_info: Option<EnvironmentInfo>,
295    /// Deployment-specific data (prepared stacks, phase tracking, etc.)
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub runtime_metadata: Option<RuntimeMetadata>,
298    /// Whether a retry has been requested for a failed deployment
299    /// When true and status is a failed state, the deployment system will retry failed resources
300    #[serde(default, skip_serializing_if = "is_false")]
301    pub retry_requested: bool,
302}
303
304/// Type of environment variable
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
306#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
307#[serde(rename_all = "lowercase")]
308pub enum EnvironmentVariableType {
309    /// Plain variable (injected directly into function config)
310    Plain,
311    /// Secret variable (stored in vault, loaded at runtime)
312    Secret,
313}
314
315/// Environment variable for deployment
316#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
317#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
318#[serde(rename_all = "camelCase")]
319pub struct EnvironmentVariable {
320    /// Variable name
321    pub name: String,
322    /// Variable value (decrypted - deployment has access to decryption keys)
323    pub value: String,
324    /// Variable type (plain or secret)
325    #[serde(rename = "type")]
326    pub var_type: EnvironmentVariableType,
327    /// Target resource patterns (null = all resources, Some = wildcard patterns)
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub target_resources: Option<Vec<String>>,
330}
331
332/// Snapshot of environment variables at a point in time
333#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
334#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
335#[serde(rename_all = "camelCase")]
336pub struct EnvironmentVariablesSnapshot {
337    /// Environment variables in the snapshot
338    pub variables: Vec<EnvironmentVariable>,
339    /// Deterministic hash of all variables (for change detection)
340    pub hash: String,
341    /// ISO 8601 timestamp when snapshot was created
342    pub created_at: String,
343}
344
345/// Artifact registry configuration for pulling container images.
346///
347/// Used when the deployment needs to pull images from a manager's artifact registry.
348/// This is required for Local platform and can optionally be used by cloud platforms
349/// instead of native registry mechanisms (ECR/GCR/ACR).
350#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
351#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
352#[serde(rename_all = "camelCase")]
353pub struct ArtifactRegistryConfig {
354    /// Manager base URL for fetching credentials and accessing the registry
355    pub manager_url: String,
356    /// Optional authentication token (JWT) for manager API access
357    /// When present, must be included in Authorization header as "Bearer {token}"
358    #[serde(skip_serializing_if = "Option::is_none")]
359    pub auth_token: Option<String>,
360}
361
362/// OTLP log export configuration for a deployment.
363///
364/// When set, all compute workloads (containers and horizond VM workers) export
365/// their logs to the given endpoint via OTLP/HTTP.
366///
367/// The `logs_auth_header` is stored as plain text in DeploymentConfig because
368/// alien-runtime reads `OTEL_EXPORTER_OTLP_HEADERS` at tracing-init time,
369/// before vault secrets load. For horizond, the infra controller writes the
370/// same value to the cloud vault (same pattern as the machine token) and the
371/// startup script fetches it at boot via IAM.
372#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
373#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
374#[serde(rename_all = "camelCase")]
375pub struct OtlpConfig {
376    /// Full OTLP logs endpoint URL.
377    /// Example: "https://<manager-host>/v1/logs"
378    pub logs_endpoint: String,
379    /// Auth header value in "key=value,..." format used for container OTLP env var injection.
380    ///
381    /// `alien-deployment` injects this as the `OTEL_EXPORTER_OTLP_HEADERS` plain env var
382    /// into all containers. It must be plain (not a vault secret) because alien-runtime
383    /// reads `OTEL_EXPORTER_OTLP_HEADERS` at tracing-init time, before vault secrets load.
384    ///
385    /// horizond VM workers do NOT use this field directly. The ContainerCluster infra
386    /// controller writes the same value to the cloud vault (GCP: Secret Manager,
387    /// AWS: Secrets Manager, Azure: Key Vault) and the startup script fetches it at
388    /// boot via IAM — the same pattern as the machine token.
389    ///
390    /// Example: "authorization=Bearer <write-token>"
391    pub logs_auth_header: String,
392    /// Full OTLP metrics endpoint URL (optional).
393    /// When set, horizond exports its own VM/container orchestration metrics here.
394    /// Example: "https://api.axiom.co/v1/metrics"
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub metrics_endpoint: Option<String>,
397    /// Auth header value for the metrics endpoint in "key=value,..." format (optional).
398    ///
399    /// When absent, `logs_auth_header` is reused for metrics — suitable when the same
400    /// credential covers both signals. When present (e.g. Axiom with separate datasets),
401    /// this value is used exclusively for metrics.
402    ///
403    /// Example: "authorization=Bearer <token>,x-axiom-dataset=<metrics-dataset>"
404    #[serde(skip_serializing_if = "Option::is_none")]
405    pub metrics_auth_header: Option<String>,
406}
407
408/// Configuration for a single Horizon cluster.
409///
410/// Contains the cluster ID and management token needed to interact with
411/// the Horizon control plane API for container operations.
412#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
413#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
414#[serde(rename_all = "camelCase")]
415pub struct HorizonClusterConfig {
416    /// Cluster ID (deterministic: workspace/project/deployment/resourceid)
417    pub cluster_id: String,
418
419    /// Management token for API access (hm_...)
420    /// Used by alien-deployment controllers to create/update containers
421    pub management_token: String,
422    // Note: Machine token (hj_...) is NOT in DeploymentConfig
423    // It's added to environmentVariables snapshot as a built-in secret variable
424    // and synced to vault (Parameter Store/Secret Manager/Key Vault)
425}
426
427/// Horizon configuration for container orchestration.
428///
429/// Contains all the information needed for Alien to interact with Horizon
430/// clusters during deployment. Each ContainerCluster resource gets its own
431/// entry in the clusters map.
432#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
433#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
434#[serde(rename_all = "camelCase")]
435pub struct HorizonConfig {
436    /// Horizon API base URL (e.g., "https://horizon.alien.dev")
437    pub url: String,
438
439    /// Base URL for downloading the horizond binary, without arch suffix.
440    ///
441    /// Each cloud controller appends `/linux-{arch}/horizond` to construct the
442    /// final download URL used in VM startup scripts.
443    ///
444    /// Production example: "https://releases.alien.dev/horizond/v0.3.0"
445    /// Dev example (ngrok): "https://abc123.ngrok.io"
446    pub horizond_download_base_url: String,
447
448    /// ETag of the horizond binary fetched from the releases server — used as a
449    /// change-detection signal only. nginx auto-generates ETags from mtime+size,
450    /// so every `cargo zigbuild` changes this value and triggers a rolling update.
451    ///
452    /// Optional: when absent (releases server unreachable), change detection
453    /// falls back to URL-only (sufficient for versioned production releases).
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub horizond_binary_hash: Option<String>,
456
457    /// Cluster configurations (one per ContainerCluster resource)
458    /// Key: ContainerCluster resource ID from stack
459    /// Value: Cluster ID and management token for that cluster
460    pub clusters: HashMap<String, HorizonClusterConfig>,
461}
462
463/// Compute backend for Container and Function resources.
464///
465/// Determines how compute workloads are orchestrated on cloud platforms.
466/// When None, the platform default is used (Horizon for cloud platforms).
467#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
468#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
469#[serde(tag = "type", rename_all = "camelCase")]
470pub enum ComputeBackend {
471    /// VMs with Horizon orchestration (default for cloud platforms)
472    Horizon(HorizonConfig),
473    // Future backends:
474    // /// Deploy to existing Kubernetes cluster (EKS/GKE/AKS)
475    // Kubernetes(KubernetesCredentials),
476    // /// AWS ECS Fargate (serverless containers)
477    // EcsFargate,
478}
479
480/// Deployment configuration
481///
482/// Configuration for how to perform the deployment.
483/// Note: Credentials (ClientConfig) are passed separately to step() function.
484#[derive(Debug, Clone, Serialize, Deserialize, Builder)]
485#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
486#[serde(rename_all = "camelCase")]
487pub struct DeploymentConfig {
488    /// User-customizable deployment settings (network, deployment model, approvals).
489    /// Provided by customer via CloudFormation, Terraform, CLI, or Helm.
490    #[serde(default)]
491    pub stack_settings: StackSettings,
492    /// Platform service account/role that will manage the infrastructure remotely.
493    /// Derived from Manager's ServiceAccount, not user-specified.
494    #[serde(skip_serializing_if = "Option::is_none")]
495    pub management_config: Option<ManagementConfig>,
496    /// Environment variables snapshot
497    pub environment_variables: EnvironmentVariablesSnapshot,
498    /// Allow frozen resource changes during updates
499    /// When true, skips the frozen resources compatibility check.
500    /// This requires running with elevated cloud credentials.
501    #[serde(default, skip_serializing_if = "is_false")]
502    pub allow_frozen_changes: bool,
503    /// Artifact registry configuration for pulling container images.
504    /// Required for Local platform, optional for cloud platforms.
505    /// When present, the deployment will fetch credentials from the manager
506    /// before pulling images, enabling centralized registry access control.
507    #[serde(skip_serializing_if = "Option::is_none")]
508    pub artifact_registry: Option<ArtifactRegistryConfig>,
509    /// Compute backend for Container and Function resources.
510    /// When None, the platform default is used (Horizon for cloud platforms).
511    /// Contains cluster IDs and management tokens for container orchestration.
512    /// Machine tokens are stored in environment_variables as built-in secret vars.
513    #[serde(skip_serializing_if = "Option::is_none")]
514    pub compute_backend: Option<ComputeBackend>,
515    /// External bindings for pre-existing services.
516    /// Required for Kubernetes platform (all infrastructure resources).
517    /// Optional for cloud platforms (override specific resources).
518    #[serde(default)]
519    pub external_bindings: ExternalBindings,
520    /// Image pull credentials for private container registries.
521    /// Used when pulling images from registries that require authentication.
522    #[serde(skip_serializing_if = "Option::is_none")]
523    pub image_pull_credentials: Option<ImagePullCredentials>,
524    /// Public URLs for exposed resources (optional override for all platforms).
525    ///
526    /// - **Kubernetes**: Pre-computed by Helm from services config (highly recommended)
527    /// - **Cloud**: Optional override of domain_metadata or load balancer DNS
528    /// - **Local**: Optional override of dynamic localhost URLs
529    ///
530    /// If not set, platforms determine public URLs from other sources:
531    /// - Cloud: domain_metadata FQDN or load balancer DNS
532    /// - Local: http://localhost:{allocated_port}
533    /// - Kubernetes: None (unless provided by Helm)
534    ///
535    /// Key: resource ID, Value: public URL (e.g., "https://api.acme.com")
536    #[serde(skip_serializing_if = "Option::is_none")]
537    pub public_urls: Option<HashMap<String, String>>,
538    /// Domain metadata for auto-managed public resources (AWS/GCP/Azure).
539    /// Contains certificate data for cloud provider import and renewal detection.
540    /// Not used by Kubernetes (uses TLS Secrets) or Local (no TLS) platforms.
541    #[serde(skip_serializing_if = "Option::is_none")]
542    pub domain_metadata: Option<DomainMetadata>,
543    /// OTLP observability configuration for log export (optional).
544    ///
545    /// When set, alien-deployment injects OTEL_EXPORTER_OTLP_* env vars into
546    /// container/function configs, and alien-infra embeds --otlp-logs-* flags
547    /// into horizond VM startup scripts.
548    #[serde(skip_serializing_if = "Option::is_none")]
549    pub monitoring: Option<OtlpConfig>,
550}
551
552/// Result of a deployment step
553///
554/// Contains the complete next deployment state along with hints for the platform.
555/// This replaces the old delta-based `DeploymentStateUpdate` approach.
556#[derive(Debug, Clone, Serialize, Deserialize)]
557#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
558#[serde(rename_all = "camelCase")]
559pub struct DeploymentStepResult {
560    /// The complete next deployment state
561    pub state: DeploymentState,
562
563    /// Error that occurred during this step (if any)
564    /// - `None`: No error, step succeeded
565    /// - `Some(error)`: Step failed or encountered an error
566    #[serde(skip_serializing_if = "Option::is_none")]
567    pub error: Option<AlienError>,
568
569    /// Suggested delay before next step (optimization hint)
570    /// - `None`: No suggested delay, can poll immediately
571    /// - `Some(ms)`: Wait this many milliseconds before next step
572    #[serde(skip_serializing_if = "Option::is_none")]
573    pub suggested_delay_ms: Option<u64>,
574
575    /// Whether to update heartbeat timestamp (monitoring signal)
576    /// - `false`: Don't update heartbeat (default for most steps)
577    /// - `true`: Update lastHeartbeatAt (for successful health checks in Running state)
578    #[serde(default, skip_serializing_if = "is_false")]
579    pub update_heartbeat: bool,
580}
581
582fn is_false(b: &bool) -> bool {
583    !*b
584}