Skip to main content

alien_core/deployment/
config.rs

1//! Deployment configuration and OTLP observability settings.
2
3use crate::{ExternalBindings, ManagementConfig, StackSettings};
4use bon::Builder;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8use super::{
9    is_false, ComputeBackend, DeletionPolicy, DomainMetadata, EnvironmentVariablesSnapshot,
10};
11
12/// Deployment configuration
13///
14/// Configuration for how to perform the deployment.
15/// Note: Credentials (ClientConfig) are passed separately to step() function.
16#[derive(Debug, Clone, Serialize, Deserialize, Builder)]
17#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
18#[serde(rename_all = "camelCase")]
19pub struct DeploymentConfig {
20    /// This invocation is an explicit setup run using the installer's credentials.
21    /// Not serialized: a release or stored configuration cannot request an identity refresh.
22    #[serde(skip)]
23    #[cfg_attr(feature = "openapi", schema(ignore))]
24    #[builder(default)]
25    pub setup_run: bool,
26    /// Human-readable deployment name for cloud console metadata.
27    ///
28    /// This is separate from the physical resource prefix in StackState. It is
29    /// used only for display text such as IAM role descriptions, service
30    /// account descriptions, and custom role titles.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub deployment_name: Option<String>,
33    /// User-customizable deployment settings (network, deployment model, approvals).
34    /// Provided by customer via CloudFormation, Terraform, CLI, or Helm.
35    #[serde(default)]
36    pub stack_settings: StackSettings,
37    /// Platform service account/role that will manage the infrastructure remotely.
38    /// Derived from Manager's ServiceAccount, not user-specified.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub management_config: Option<ManagementConfig>,
41    /// Registered installation identity for target federation setup.
42    /// Callers derive this from registration, separately from display metadata.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub gcp_federation_scope: Option<crate::GcpFederationScope>,
45    /// Environment variables snapshot
46    pub environment_variables: EnvironmentVariablesSnapshot,
47    /// Deployer-provided stack input values, keyed by input id. A resource
48    /// gated with `.enabled(input)` and a Live lifecycle follows these
49    /// values; a missing key falls back to the input's declared boolean
50    /// default. Suppliers must not place secret-kind input values here:
51    /// this map serializes and debug-prints unredacted.
52    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
53    #[builder(default)]
54    pub input_values: HashMap<String, serde_json::Value>,
55    /// IDs of applicable secret inputs stored for this exact deployment target.
56    /// Trusted presence metadata only: never values, gate answers, or authority.
57    /// Absent on legacy targets; an explicit empty list means no stored secrets.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    #[cfg_attr(feature = "openapi", schema(nullable = false))]
60    pub stored_secret_input_ids: Option<Vec<String>>,
61    /// Allow frozen resource changes during updates
62    /// When true, skips the frozen resources compatibility check.
63    /// This requires running with elevated cloud credentials.
64    #[serde(default, skip_serializing_if = "is_false")]
65    pub allow_frozen_changes: bool,
66    /// Compute backend for Container and Worker resources.
67    /// When None, the platform default is used for cloud platforms.
68    /// Contains cluster IDs and management tokens for container orchestration.
69    /// Worker runtime credentials are provided through cloud identity and vault-backed secrets.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub compute_backend: Option<ComputeBackend>,
72    /// External bindings for pre-existing services.
73    /// Required for Kubernetes platform (all infrastructure resources).
74    /// Optional for cloud platforms (override specific resources).
75    #[serde(default)]
76    pub external_bindings: ExternalBindings,
77    /// Cloud platform that owns imported base infrastructure for a Kubernetes
78    /// runtime deployment.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub base_platform: Option<crate::Platform>,
81    /// DNS-style label domain used for Kubernetes resource ownership labels.
82    ///
83    /// Defaults to `alien.dev` when absent. Whitelabeled Operator builds set this
84    /// so generated workloads and optional log collectors share the same label
85    /// namespace.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub label_domain: Option<String>,
88    /// Kubernetes label selector that narrows which raw resources the observe
89    /// pass reports (e.g. `app.kubernetes.io/part-of=my-app`). `None` observes
90    /// everything in the namespace. Ignored by cloud observers.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub observe_label_selector: Option<String>,
93    /// When true the observe pass reports raw resources across every namespace
94    /// (cluster scope); otherwise it stays within the operator's own namespace.
95    /// The label selector, if any, still filters within whichever scope applies.
96    /// Ignored by cloud observers.
97    #[serde(default)]
98    #[builder(default)]
99    pub observe_all_namespaces: bool,
100    /// Public endpoint URLs for exposed resources (optional override).
101    ///
102    /// Use this only when a caller already knows the public URL. Managed public
103    /// endpoint flows should prefer `domain_metadata` plus controller-reported
104    /// load balancer outputs so DNS, certificate renewal, and route readiness
105    /// stay tied to the resource state.
106    ///
107    /// If not set, platforms determine public endpoint URLs from other sources:
108    /// - Managed DNS/TLS flows: `domain_metadata` FQDN or load balancer DNS
109    /// - Local: `http://localhost:{allocated_port}`
110    /// - Custom or disabled exposure: no public endpoint URL unless a controller reports one
111    ///
112    /// Outer key: resource ID. Inner key: endpoint name. Value: public URL.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub public_endpoints: Option<HashMap<String, HashMap<String, String>>>,
115    /// Domain metadata for auto-managed public resources.
116    ///
117    /// Contains generated hostnames, DNS record state, certificate material,
118    /// and renewal markers for platforms that use managed public endpoints.
119    /// Kubernetes uses this only when its exposure mode is `generated`; BYO and
120    /// disabled Kubernetes exposure do not receive managed domain metadata.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub domain_metadata: Option<DomainMetadata>,
123    /// OTLP observability configuration for log export (optional).
124    ///
125    /// When set, worker runtimes export captured application logs through this
126    /// endpoint. Container orchestrators may use it for their node-level log
127    /// collectors, but app container configs must not receive the auth header.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub monitoring: Option<OtlpConfig>,
130    /// Manager base URL (e.g., "https://manager.alien.dev").
131    ///
132    /// The manager IS the container registry — its `/v2/` endpoint serves as
133    /// the OCI Distribution API. Controllers derive the proxy host from this
134    /// to configure pull auth (RegistryCredentials, imagePullSecrets).
135    ///
136    /// When None (e.g., `alien dev`), controllers use image URIs as-is.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub manager_url: Option<String>,
139    /// Deployment token for pull authentication with the manager's registry.
140    ///
141    /// Used by controllers to configure registry credentials so cloud platforms
142    /// and K8s can pull images from the manager's `/v2/` endpoint.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub deployment_token: Option<String>,
145    /// Native image registry host+prefix for platforms that require it.
146    ///
147    /// Lambda (ECR) and Cloud Run (GAR) require native registry URIs. Other
148    /// runtimes, including Azure Container Apps, pull through the manager's
149    /// registry proxy.
150    ///
151    /// Derived by the manager from the artifact registry binding:
152    /// - ECR: `{account_id}.dkr.ecr.{region}.amazonaws.com/{repository_prefix}`
153    /// - GAR: `{region}-docker.pkg.dev/{project_id}/{repository_name}`
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub native_image_host: Option<String>,
156    /// Operator requests to replace a replica's persistent volume with a new
157    /// volume made from a snapshot. A container controller performs each
158    /// request once, identified by its `request_id`, and reports it in
159    /// `ContainerOutputs.volumes`. Only a volume that a controller reports in
160    /// `ContainerOutputs.volumes` can be the target of a request.
161    #[serde(default, skip_serializing_if = "Vec::is_empty")]
162    #[builder(default)]
163    pub volume_restores: Vec<VolumeRestoreRequest>,
164    /// How the deletion this run performs treats data. Under `Erase`, controllers also
165    /// delete what they would otherwise keep after the resource is gone, such as a
166    /// container's final volume snapshots.
167    /// Not serialized: deletion sets it from the policy persisted in runtime metadata,
168    /// so a stored configuration cannot request an erase.
169    #[serde(skip)]
170    #[cfg_attr(feature = "openapi", schema(ignore))]
171    #[builder(default)]
172    pub deletion_policy: DeletionPolicy,
173}
174
175/// Replace one replica's persistent volume with a new volume made from a snapshot.
176///
177/// The controller stops the replica, snapshots the volume it is about to
178/// replace (so the restore can be undone), creates the new volume in the same
179/// zone, starts the replica on it, and deletes the replaced volume.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
182#[serde(rename_all = "camelCase")]
183pub struct VolumeRestoreRequest {
184    /// Unique ID of this request. A controller performs each request once.
185    pub request_id: String,
186    /// ID of the container resource that owns the volume
187    pub resource_id: String,
188    /// Replica ordinal whose volume is replaced
189    pub ordinal: u32,
190    /// Cloud ID of the snapshot to restore: an EBS snapshot ID, a Compute
191    /// Engine snapshot name, or an Azure snapshot resource ID
192    pub snapshot_id: String,
193}
194
195/// Resource-attribute key marking OTLP telemetry as Alien system-component
196/// output (infrastructure daemons and internal runtimes) rather than user
197/// workload. Log consumers — the CLI log viewer and the dashboard — hide
198/// telemetry carrying this attribute by default. The value is the string
199/// `"true"`.
200///
201/// System components set this on their own telemetry's resource attributes so
202/// consumers can filter generically, without enumerating component names.
203pub const ALIEN_SYSTEM_RESOURCE_ATTRIBUTE: &str = "alien.system";
204
205/// OTLP log export configuration for a deployment.
206///
207/// When set, injected compute runtimes export captured application logs
208/// through the given endpoint via OTLP/HTTP; which resources are injected
209/// is platform-dependent. Workers read auth headers from a runtime-only
210/// secret. Runtime-less Containers and Daemons receive standard OTEL auth
211/// variables only at the final hosting boundary: Local passes them directly
212/// to the process and Kubernetes projects them from a per-workload Secret.
213#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
214#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
215#[serde(rename_all = "camelCase")]
216pub struct OtlpConfig {
217    /// Full OTLP logs endpoint URL.
218    /// Example: "https://<manager-host>/v1/logs"
219    pub logs_endpoint: String,
220    /// Auth header value in "key=value,..." format.
221    /// Example: "authorization=Bearer <write-token>"
222    pub logs_auth_header: String,
223    /// Full OTLP metrics endpoint URL (optional).
224    /// When set, the worker runtime exports its own VM/container orchestration metrics here.
225    /// Example: "https://api.axiom.co/v1/metrics"
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub metrics_endpoint: Option<String>,
228    /// Auth header value for the metrics endpoint in "key=value,..." format (optional).
229    ///
230    /// When absent, `logs_auth_header` is reused for metrics -- suitable when the same
231    /// credential covers both signals. When present (e.g. Axiom with separate datasets),
232    /// this value is used exclusively for metrics.
233    ///
234    /// Example: "authorization=Bearer <token>,x-axiom-dataset=<metrics-dataset>"
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub metrics_auth_header: Option<String>,
237    /// Resource attributes attached to every OTLP signal emitted for this deployment.
238    ///
239    /// Platform managers use this for stable identity such as `alien.workspace_id`,
240    /// `alien.project_id`, `alien.deployment_group_id`, and `alien.deployment_id`.
241    /// Runtime-specific resource attributes such as `service.name` remain owned by
242    /// the runtime/exporter.
243    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
244    pub resource_attributes: HashMap<String, String>,
245}
246
247#[cfg(test)]
248mod input_values_tests {
249    use super::*;
250
251    /// Older actors serialize configs without the field; it must deserialize
252    /// to an empty map, and an empty map must not serialize at all.
253    #[test]
254    fn input_values_default_to_empty_and_stay_off_the_wire() {
255        let json = serde_json::json!({
256            "stackSettings": serde_json::to_value(crate::StackSettings::default()).unwrap(),
257            "environmentVariables": {
258                "variables": [],
259                "hash": "hash",
260                "createdAt": "2026-01-01T00:00:00Z",
261            },
262            "externalBindings": {},
263        });
264        let config: DeploymentConfig =
265            serde_json::from_value(json).expect("old shape deserializes");
266        assert!(config.input_values.is_empty());
267        assert!(config.stored_secret_input_ids.is_none());
268
269        let round = serde_json::to_value(&config).expect("serializes");
270        assert!(round.get("storedSecretInputIds").is_none());
271        assert!(
272            round.get("inputValues").is_none(),
273            "empty map must not serialize"
274        );
275    }
276    #[test]
277    fn stored_secret_presence_roundtrips_without_values_or_gate_answers() {
278        let mut config = DeploymentConfig::builder()
279            .stack_settings(StackSettings::default())
280            .environment_variables(EnvironmentVariablesSnapshot {
281                variables: vec![],
282                hash: String::new(),
283                created_at: String::new(),
284            })
285            .allow_frozen_changes(false)
286            .external_bindings(ExternalBindings::default())
287            .build();
288        assert!(config.stored_secret_input_ids.is_none());
289        config.stored_secret_input_ids = Some(Vec::new());
290        let empty = serde_json::to_value(&config).unwrap();
291        assert_eq!(empty["storedSecretInputIds"], serde_json::json!([]));
292        assert_eq!(
293            serde_json::from_value::<DeploymentConfig>(empty)
294                .unwrap()
295                .stored_secret_input_ids,
296            Some(Vec::new())
297        );
298        config.stored_secret_input_ids =
299            Some(vec!["apiKey".to_string(), "enableFeature".to_string()]);
300        let wire = serde_json::to_value(&config).unwrap();
301        assert_eq!(
302            wire["storedSecretInputIds"],
303            serde_json::json!(["apiKey", "enableFeature"])
304        );
305        assert!(wire.get("inputValues").is_none());
306        let decoded: DeploymentConfig = serde_json::from_value(wire.clone()).unwrap();
307        assert_eq!(
308            decoded.stored_secret_input_ids,
309            config.stored_secret_input_ids
310        );
311        assert_eq!(serde_json::to_value(decoded.clone()).unwrap(), wire);
312        assert!(decoded.input_values.is_empty());
313        for default in [false, true] {
314            let input: crate::StackInputDefinition = serde_json::from_value(serde_json::json!({
315                "id": "enableFeature", "kind": "boolean", "providedBy": ["deployer"],
316                "required": false, "label": "Enable feature", "description": "", "default": crate::StackInputDefaultValue::Boolean(default)
317            }))
318            .unwrap();
319            assert_eq!(
320                crate::gate_resolves_true(
321                    &[input],
322                    "enableFeature",
323                    &decoded.input_values,
324                    "worker",
325                    crate::Platform::Aws
326                )
327                .unwrap(),
328                default
329            );
330        }
331        assert!(crate::gate_resolves_true(
332            &[],
333            "enableFeature",
334            &decoded.input_values,
335            "worker",
336            crate::Platform::Aws
337        )
338        .is_err());
339    }
340
341    fn deployment_config_with_cluster(
342        cluster_image: Option<serde_json::Value>,
343    ) -> serde_json::Value {
344        let config = DeploymentConfig::builder()
345            .stack_settings(StackSettings::default())
346            .environment_variables(EnvironmentVariablesSnapshot {
347                variables: vec![],
348                hash: String::new(),
349                created_at: String::new(),
350            })
351            .allow_frozen_changes(false)
352            .external_bindings(ExternalBindings::default())
353            .compute_backend(crate::ComputeBackend::Horizon(crate::HorizonConfig {
354                url: "https://horizon.example.com".to_string(),
355                horizon_machine_image: None,
356                clusters: HashMap::from([(
357                    "compute".to_string(),
358                    crate::HorizonClusterConfig {
359                        cluster_id: "cluster-1".to_string(),
360                        management_token: "hm_test".to_string(),
361                        horizon_machine_image: cluster_image
362                            .map(|image| serde_json::from_value(image).unwrap()),
363                    },
364                )]),
365            }))
366            .build();
367        serde_json::to_value(&config).unwrap()
368    }
369
370    fn machine_image(version: &str) -> serde_json::Value {
371        serde_json::json!({
372            "channel": "stable",
373            "machineImageVersion": version,
374            "horizondVersion": version,
375            "gitSha": "abc123",
376            "createdAt": "2026-01-01T00:00:00Z",
377            "baseImage": { "name": "linux", "version": "1" },
378            "horizondArtifacts": {}
379        })
380    }
381
382    /// A deployment config written before clusters could carry a machine image, as managers
383    /// receive and store it, reads back with no cluster image.
384    #[test]
385    fn deployment_config_without_cluster_machine_image_reads_as_before() {
386        let wire = deployment_config_with_cluster(None);
387        let cluster = &wire["computeBackend"]["clusters"]["compute"];
388        assert_eq!(
389            cluster,
390            &serde_json::json!({ "clusterId": "cluster-1", "managementToken": "hm_test" })
391        );
392
393        let config: DeploymentConfig = serde_json::from_value(wire).unwrap();
394        let Some(crate::ComputeBackend::Horizon(horizon)) = config.compute_backend else {
395            panic!("the Horizon backend reads back");
396        };
397        assert_eq!(horizon.clusters["compute"].horizon_machine_image, None);
398    }
399
400    /// The types on the path from the deployment config to a cluster as they were before the
401    /// cluster machine image existed: none of them rejects unknown fields.
402    #[derive(Debug, Deserialize)]
403    #[serde(rename_all = "camelCase")]
404    struct PreviousDeploymentConfig {
405        compute_backend: Option<PreviousComputeBackend>,
406    }
407
408    #[derive(Debug, Deserialize)]
409    #[serde(tag = "type", rename_all = "camelCase")]
410    enum PreviousComputeBackend {
411        Horizon(PreviousHorizonConfig),
412    }
413
414    #[derive(Debug, Deserialize)]
415    #[serde(rename_all = "camelCase")]
416    struct PreviousHorizonConfig {
417        clusters: HashMap<String, PreviousHorizonClusterConfig>,
418    }
419
420    #[derive(Debug, Deserialize, PartialEq)]
421    #[serde(rename_all = "camelCase")]
422    struct PreviousHorizonClusterConfig {
423        cluster_id: String,
424        management_token: String,
425    }
426
427    /// A manager built before the field reads a deployment config that sets it and ignores it.
428    #[test]
429    fn deployment_config_with_cluster_machine_image_reads_in_older_readers() {
430        let wire = deployment_config_with_cluster(Some(machine_image("1")));
431        assert!(wire["computeBackend"]["clusters"]["compute"]["horizonMachineImage"].is_object());
432
433        let previous: PreviousDeploymentConfig = serde_json::from_value(wire.clone())
434            .expect("an older reader must accept a cluster that carries its own image");
435        let Some(PreviousComputeBackend::Horizon(horizon)) = previous.compute_backend else {
436            panic!("the Horizon backend reads back");
437        };
438        assert_eq!(
439            horizon.clusters["compute"],
440            PreviousHorizonClusterConfig {
441                cluster_id: "cluster-1".to_string(),
442                management_token: "hm_test".to_string(),
443            }
444        );
445
446        let current: DeploymentConfig = serde_json::from_value(wire).unwrap();
447        let Some(crate::ComputeBackend::Horizon(horizon)) = current.compute_backend else {
448            panic!("the Horizon backend reads back");
449        };
450        assert_eq!(
451            horizon
452                .machine_image_for("compute")
453                .map(|image| image.machine_image_version.as_str()),
454            Some("1")
455        );
456    }
457}