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