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 or `input:ID` for a stack
193/// input. An input's stored value is the JSON text of the input value.
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
196#[serde(rename_all = "camelCase")]
197pub struct ValueSlotRef {
198 /// `env:NAME` or `input:ID`.
199 pub slot: String,
200 /// Whether the value is a secret. The manager checks it against the stored kind.
201 pub kind: EnvironmentVariableType,
202 /// Keyed digest of the value, as lowercase hex.
203 pub digest: String,
204 /// Variable name, repeated for display. The slot decides the name.
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub env_name: Option<String>,
207 /// Target resource patterns for an environment variable. `None` means all resources.
208 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub targets: Option<Vec<String>>,
210}
211
212/// Replace one replica's persistent volume with a new volume made from a snapshot.
213///
214/// The controller stops the replica, snapshots the volume it is about to
215/// replace (so the restore can be undone), creates the new volume in the same
216/// zone, starts the replica on it, and deletes the replaced volume.
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
219#[serde(rename_all = "camelCase")]
220pub struct VolumeRestoreRequest {
221 /// Unique ID of this request. A controller performs each request once.
222 pub request_id: String,
223 /// ID of the container resource that owns the volume
224 pub resource_id: String,
225 /// Replica ordinal whose volume is replaced
226 pub ordinal: u32,
227 /// Cloud ID of the snapshot to restore: an EBS snapshot ID, a Compute
228 /// Engine snapshot name, or an Azure snapshot resource ID
229 pub snapshot_id: String,
230}
231
232/// Resource-attribute key marking OTLP telemetry as Alien system-component
233/// output (infrastructure daemons and internal runtimes) rather than user
234/// workload. Log consumers — the CLI log viewer and the dashboard — hide
235/// telemetry carrying this attribute by default. The value is the string
236/// `"true"`.
237///
238/// System components set this on their own telemetry's resource attributes so
239/// consumers can filter generically, without enumerating component names.
240pub const ALIEN_SYSTEM_RESOURCE_ATTRIBUTE: &str = "alien.system";
241
242/// Prefix that marks one captured output line of a Worker as Alien system output.
243///
244/// The worker runtime strips it from the line and records
245/// [`ALIEN_SYSTEM_RESOURCE_ATTRIBUTE`] on the exported record. Alien components that run as a
246/// Worker's application write it at the start of every line they print. Like the attribute, it
247/// only hides a line by default; it grants nothing.
248pub const ALIEN_SYSTEM_LOG_LINE_PREFIX: &str = "\u{1e}ALIEN_SYSTEM\u{1f}";
249
250/// OTLP log export configuration for a deployment.
251///
252/// When set, injected compute runtimes export captured application logs
253/// through the given endpoint via OTLP/HTTP; which resources are injected
254/// is platform-dependent. Workers read auth headers from a runtime-only
255/// secret. Runtime-less Containers and Daemons receive standard OTEL auth
256/// variables only at the final hosting boundary: Local passes them directly
257/// to the process and Kubernetes projects them from a per-workload Secret.
258#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
259#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
260#[serde(rename_all = "camelCase")]
261pub struct OtlpConfig {
262 /// Full OTLP logs endpoint URL.
263 /// Example: "https://<manager-host>/v1/logs"
264 pub logs_endpoint: String,
265 /// Auth header value in "key=value,..." format.
266 /// Example: "authorization=Bearer <write-token>"
267 pub logs_auth_header: String,
268 /// Full OTLP metrics endpoint URL (optional).
269 /// When set, the worker runtime exports its own VM/container orchestration metrics here.
270 /// Example: "https://api.axiom.co/v1/metrics"
271 #[serde(skip_serializing_if = "Option::is_none")]
272 pub metrics_endpoint: Option<String>,
273 /// Auth header value for the metrics endpoint in "key=value,..." format (optional).
274 ///
275 /// When absent, `logs_auth_header` is reused for metrics -- suitable when the same
276 /// credential covers both signals. When present (e.g. Axiom with separate datasets),
277 /// this value is used exclusively for metrics.
278 ///
279 /// Example: "authorization=Bearer <token>,x-axiom-dataset=<metrics-dataset>"
280 #[serde(skip_serializing_if = "Option::is_none")]
281 pub metrics_auth_header: Option<String>,
282 /// Resource attributes attached to every OTLP signal emitted for this deployment.
283 ///
284 /// Platform managers use this for stable identity such as `alien.workspace_id`,
285 /// `alien.project_id`, `alien.deployment_group_id`, and `alien.deployment_id`.
286 /// Runtime-specific resource attributes such as `service.name` remain owned by
287 /// the runtime/exporter.
288 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
289 pub resource_attributes: HashMap<String, String>,
290}
291
292#[cfg(test)]
293mod input_values_tests {
294 use super::*;
295
296 /// Older actors serialize configs without the field; it must deserialize
297 /// to an empty map, and an empty map must not serialize at all.
298 #[test]
299 fn input_values_default_to_empty_and_stay_off_the_wire() {
300 let json = serde_json::json!({
301 "stackSettings": serde_json::to_value(crate::StackSettings::default()).unwrap(),
302 "environmentVariables": {
303 "variables": [],
304 "hash": "hash",
305 "createdAt": "2026-01-01T00:00:00Z",
306 },
307 "externalBindings": {},
308 });
309 let config: DeploymentConfig =
310 serde_json::from_value(json).expect("old shape deserializes");
311 assert!(config.input_values.is_empty());
312 assert!(config.stored_secret_input_ids.is_none());
313
314 let round = serde_json::to_value(&config).expect("serializes");
315 assert!(round.get("storedSecretInputIds").is_none());
316 assert!(
317 round.get("inputValues").is_none(),
318 "empty map must not serialize"
319 );
320 }
321 #[test]
322 fn stored_secret_presence_roundtrips_without_values_or_gate_answers() {
323 let mut config = DeploymentConfig::builder()
324 .stack_settings(StackSettings::default())
325 .environment_variables(EnvironmentVariablesSnapshot {
326 variables: vec![],
327 hash: String::new(),
328 created_at: String::new(),
329 })
330 .allow_frozen_changes(false)
331 .external_bindings(ExternalBindings::default())
332 .build();
333 assert!(config.stored_secret_input_ids.is_none());
334 config.stored_secret_input_ids = Some(Vec::new());
335 let empty = serde_json::to_value(&config).unwrap();
336 assert_eq!(empty["storedSecretInputIds"], serde_json::json!([]));
337 assert_eq!(
338 serde_json::from_value::<DeploymentConfig>(empty)
339 .unwrap()
340 .stored_secret_input_ids,
341 Some(Vec::new())
342 );
343 config.stored_secret_input_ids =
344 Some(vec!["apiKey".to_string(), "enableFeature".to_string()]);
345 let wire = serde_json::to_value(&config).unwrap();
346 assert_eq!(
347 wire["storedSecretInputIds"],
348 serde_json::json!(["apiKey", "enableFeature"])
349 );
350 assert!(wire.get("inputValues").is_none());
351 let decoded: DeploymentConfig = serde_json::from_value(wire.clone()).unwrap();
352 assert_eq!(
353 decoded.stored_secret_input_ids,
354 config.stored_secret_input_ids
355 );
356 assert_eq!(serde_json::to_value(decoded.clone()).unwrap(), wire);
357 assert!(decoded.input_values.is_empty());
358 for default in [false, true] {
359 let input: crate::StackInputDefinition = serde_json::from_value(serde_json::json!({
360 "id": "enableFeature", "kind": "boolean", "providedBy": ["deployer"],
361 "required": false, "label": "Enable feature", "description": "", "default": crate::StackInputDefaultValue::Boolean(default)
362 }))
363 .unwrap();
364 assert_eq!(
365 crate::gate_resolves_true(
366 &[input],
367 "enableFeature",
368 &decoded.input_values,
369 "worker",
370 crate::Platform::Aws
371 )
372 .unwrap(),
373 default
374 );
375 }
376 assert!(crate::gate_resolves_true(
377 &[],
378 "enableFeature",
379 &decoded.input_values,
380 "worker",
381 crate::Platform::Aws
382 )
383 .is_err());
384 }
385
386 fn deployment_config_with_cluster(
387 cluster_image: Option<serde_json::Value>,
388 ) -> serde_json::Value {
389 let config = DeploymentConfig::builder()
390 .stack_settings(StackSettings::default())
391 .environment_variables(EnvironmentVariablesSnapshot {
392 variables: vec![],
393 hash: String::new(),
394 created_at: String::new(),
395 })
396 .allow_frozen_changes(false)
397 .external_bindings(ExternalBindings::default())
398 .compute_backend(crate::ComputeBackend::Horizon(crate::HorizonConfig {
399 url: "https://horizon.example.com".to_string(),
400 horizon_machine_image: None,
401 clusters: HashMap::from([(
402 "compute".to_string(),
403 crate::HorizonClusterConfig {
404 cluster_id: "cluster-1".to_string(),
405 management_token: "hm_test".to_string(),
406 horizon_machine_image: cluster_image
407 .map(|image| serde_json::from_value(image).unwrap()),
408 },
409 )]),
410 }))
411 .build();
412 serde_json::to_value(&config).unwrap()
413 }
414
415 fn machine_image(version: &str) -> serde_json::Value {
416 serde_json::json!({
417 "channel": "stable",
418 "machineImageVersion": version,
419 "horizondVersion": version,
420 "gitSha": "abc123",
421 "createdAt": "2026-01-01T00:00:00Z",
422 "baseImage": { "name": "linux", "version": "1" },
423 "horizondArtifacts": {}
424 })
425 }
426
427 /// A deployment config written before clusters could carry a machine image, as managers
428 /// receive and store it, reads back with no cluster image.
429 #[test]
430 fn deployment_config_without_cluster_machine_image_reads_as_before() {
431 let wire = deployment_config_with_cluster(None);
432 let cluster = &wire["computeBackend"]["clusters"]["compute"];
433 assert_eq!(
434 cluster,
435 &serde_json::json!({ "clusterId": "cluster-1", "managementToken": "hm_test" })
436 );
437
438 let config: DeploymentConfig = serde_json::from_value(wire).unwrap();
439 let Some(crate::ComputeBackend::Horizon(horizon)) = config.compute_backend else {
440 panic!("the Horizon backend reads back");
441 };
442 assert_eq!(horizon.clusters["compute"].horizon_machine_image, None);
443 }
444
445 /// The types on the path from the deployment config to a cluster as they were before the
446 /// cluster machine image existed: none of them rejects unknown fields.
447 #[derive(Debug, Deserialize)]
448 #[serde(rename_all = "camelCase")]
449 struct PreviousDeploymentConfig {
450 compute_backend: Option<PreviousComputeBackend>,
451 }
452
453 #[derive(Debug, Deserialize)]
454 #[serde(tag = "type", rename_all = "camelCase")]
455 enum PreviousComputeBackend {
456 Horizon(PreviousHorizonConfig),
457 }
458
459 #[derive(Debug, Deserialize)]
460 #[serde(rename_all = "camelCase")]
461 struct PreviousHorizonConfig {
462 clusters: HashMap<String, PreviousHorizonClusterConfig>,
463 }
464
465 #[derive(Debug, Deserialize, PartialEq)]
466 #[serde(rename_all = "camelCase")]
467 struct PreviousHorizonClusterConfig {
468 cluster_id: String,
469 management_token: String,
470 }
471
472 /// A manager built before the field reads a deployment config that sets it and ignores it.
473 #[test]
474 fn deployment_config_with_cluster_machine_image_reads_in_older_readers() {
475 let wire = deployment_config_with_cluster(Some(machine_image("1")));
476 assert!(wire["computeBackend"]["clusters"]["compute"]["horizonMachineImage"].is_object());
477
478 let previous: PreviousDeploymentConfig = serde_json::from_value(wire.clone())
479 .expect("an older reader must accept a cluster that carries its own image");
480 let Some(PreviousComputeBackend::Horizon(horizon)) = previous.compute_backend else {
481 panic!("the Horizon backend reads back");
482 };
483 assert_eq!(
484 horizon.clusters["compute"],
485 PreviousHorizonClusterConfig {
486 cluster_id: "cluster-1".to_string(),
487 management_token: "hm_test".to_string(),
488 }
489 );
490
491 let current: DeploymentConfig = serde_json::from_value(wire).unwrap();
492 let Some(crate::ComputeBackend::Horizon(horizon)) = current.compute_backend else {
493 panic!("the Horizon backend reads back");
494 };
495 assert_eq!(
496 horizon
497 .machine_image_for("compute")
498 .map(|image| image.machine_image_version.as_str()),
499 Some("1")
500 );
501 }
502}