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}