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