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