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/// OTLP log export configuration for a deployment.
212///
213/// When set, injected compute runtimes export captured application logs
214/// through the given endpoint via OTLP/HTTP; which resources are injected
215/// is platform-dependent. Workers read auth headers from a runtime-only
216/// secret. Runtime-less Containers and Daemons receive standard OTEL auth
217/// variables only at the final hosting boundary: Local passes them directly
218/// to the process and Kubernetes projects them from a per-workload Secret.
219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
220#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
221#[serde(rename_all = "camelCase")]
222pub struct OtlpConfig {
223 /// Full OTLP logs endpoint URL.
224 /// Example: "https://<manager-host>/v1/logs"
225 pub logs_endpoint: String,
226 /// Auth header value in "key=value,..." format.
227 /// Example: "authorization=Bearer <write-token>"
228 pub logs_auth_header: String,
229 /// Full OTLP metrics endpoint URL (optional).
230 /// When set, the worker runtime exports its own VM/container orchestration metrics here.
231 /// Example: "https://api.axiom.co/v1/metrics"
232 #[serde(skip_serializing_if = "Option::is_none")]
233 pub metrics_endpoint: Option<String>,
234 /// Auth header value for the metrics endpoint in "key=value,..." format (optional).
235 ///
236 /// When absent, `logs_auth_header` is reused for metrics -- suitable when the same
237 /// credential covers both signals. When present (e.g. Axiom with separate datasets),
238 /// this value is used exclusively for metrics.
239 ///
240 /// Example: "authorization=Bearer <token>,x-axiom-dataset=<metrics-dataset>"
241 #[serde(skip_serializing_if = "Option::is_none")]
242 pub metrics_auth_header: Option<String>,
243 /// Resource attributes attached to every OTLP signal emitted for this deployment.
244 ///
245 /// Platform managers use this for stable identity such as `alien.workspace_id`,
246 /// `alien.project_id`, `alien.deployment_group_id`, and `alien.deployment_id`.
247 /// Runtime-specific resource attributes such as `service.name` remain owned by
248 /// the runtime/exporter.
249 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
250 pub resource_attributes: HashMap<String, String>,
251}
252
253#[cfg(test)]
254mod input_values_tests {
255 use super::*;
256
257 /// Older actors serialize configs without the field; it must deserialize
258 /// to an empty map, and an empty map must not serialize at all.
259 #[test]
260 fn input_values_default_to_empty_and_stay_off_the_wire() {
261 let json = serde_json::json!({
262 "stackSettings": serde_json::to_value(crate::StackSettings::default()).unwrap(),
263 "environmentVariables": {
264 "variables": [],
265 "hash": "hash",
266 "createdAt": "2026-01-01T00:00:00Z",
267 },
268 "externalBindings": {},
269 });
270 let config: DeploymentConfig =
271 serde_json::from_value(json).expect("old shape deserializes");
272 assert!(config.input_values.is_empty());
273 assert!(config.stored_secret_input_ids.is_none());
274
275 let round = serde_json::to_value(&config).expect("serializes");
276 assert!(round.get("storedSecretInputIds").is_none());
277 assert!(
278 round.get("inputValues").is_none(),
279 "empty map must not serialize"
280 );
281 }
282 #[test]
283 fn stored_secret_presence_roundtrips_without_values_or_gate_answers() {
284 let mut config = DeploymentConfig::builder()
285 .stack_settings(StackSettings::default())
286 .environment_variables(EnvironmentVariablesSnapshot {
287 variables: vec![],
288 hash: String::new(),
289 created_at: String::new(),
290 })
291 .allow_frozen_changes(false)
292 .external_bindings(ExternalBindings::default())
293 .build();
294 assert!(config.stored_secret_input_ids.is_none());
295 config.stored_secret_input_ids = Some(Vec::new());
296 let empty = serde_json::to_value(&config).unwrap();
297 assert_eq!(empty["storedSecretInputIds"], serde_json::json!([]));
298 assert_eq!(
299 serde_json::from_value::<DeploymentConfig>(empty)
300 .unwrap()
301 .stored_secret_input_ids,
302 Some(Vec::new())
303 );
304 config.stored_secret_input_ids =
305 Some(vec!["apiKey".to_string(), "enableFeature".to_string()]);
306 let wire = serde_json::to_value(&config).unwrap();
307 assert_eq!(
308 wire["storedSecretInputIds"],
309 serde_json::json!(["apiKey", "enableFeature"])
310 );
311 assert!(wire.get("inputValues").is_none());
312 let decoded: DeploymentConfig = serde_json::from_value(wire.clone()).unwrap();
313 assert_eq!(
314 decoded.stored_secret_input_ids,
315 config.stored_secret_input_ids
316 );
317 assert_eq!(serde_json::to_value(decoded.clone()).unwrap(), wire);
318 assert!(decoded.input_values.is_empty());
319 for default in [false, true] {
320 let input: crate::StackInputDefinition = serde_json::from_value(serde_json::json!({
321 "id": "enableFeature", "kind": "boolean", "providedBy": ["deployer"],
322 "required": false, "label": "Enable feature", "description": "", "default": crate::StackInputDefaultValue::Boolean(default)
323 }))
324 .unwrap();
325 assert_eq!(
326 crate::gate_resolves_true(
327 &[input],
328 "enableFeature",
329 &decoded.input_values,
330 "worker",
331 crate::Platform::Aws
332 )
333 .unwrap(),
334 default
335 );
336 }
337 assert!(crate::gate_resolves_true(
338 &[],
339 "enableFeature",
340 &decoded.input_values,
341 "worker",
342 crate::Platform::Aws
343 )
344 .is_err());
345 }
346
347 fn deployment_config_with_cluster(
348 cluster_image: Option<serde_json::Value>,
349 ) -> serde_json::Value {
350 let config = DeploymentConfig::builder()
351 .stack_settings(StackSettings::default())
352 .environment_variables(EnvironmentVariablesSnapshot {
353 variables: vec![],
354 hash: String::new(),
355 created_at: String::new(),
356 })
357 .allow_frozen_changes(false)
358 .external_bindings(ExternalBindings::default())
359 .compute_backend(crate::ComputeBackend::Horizon(crate::HorizonConfig {
360 url: "https://horizon.example.com".to_string(),
361 horizon_machine_image: None,
362 clusters: HashMap::from([(
363 "compute".to_string(),
364 crate::HorizonClusterConfig {
365 cluster_id: "cluster-1".to_string(),
366 management_token: "hm_test".to_string(),
367 horizon_machine_image: cluster_image
368 .map(|image| serde_json::from_value(image).unwrap()),
369 },
370 )]),
371 }))
372 .build();
373 serde_json::to_value(&config).unwrap()
374 }
375
376 fn machine_image(version: &str) -> serde_json::Value {
377 serde_json::json!({
378 "channel": "stable",
379 "machineImageVersion": version,
380 "horizondVersion": version,
381 "gitSha": "abc123",
382 "createdAt": "2026-01-01T00:00:00Z",
383 "baseImage": { "name": "linux", "version": "1" },
384 "horizondArtifacts": {}
385 })
386 }
387
388 /// A deployment config written before clusters could carry a machine image, as managers
389 /// receive and store it, reads back with no cluster image.
390 #[test]
391 fn deployment_config_without_cluster_machine_image_reads_as_before() {
392 let wire = deployment_config_with_cluster(None);
393 let cluster = &wire["computeBackend"]["clusters"]["compute"];
394 assert_eq!(
395 cluster,
396 &serde_json::json!({ "clusterId": "cluster-1", "managementToken": "hm_test" })
397 );
398
399 let config: DeploymentConfig = serde_json::from_value(wire).unwrap();
400 let Some(crate::ComputeBackend::Horizon(horizon)) = config.compute_backend else {
401 panic!("the Horizon backend reads back");
402 };
403 assert_eq!(horizon.clusters["compute"].horizon_machine_image, None);
404 }
405
406 /// The types on the path from the deployment config to a cluster as they were before the
407 /// cluster machine image existed: none of them rejects unknown fields.
408 #[derive(Debug, Deserialize)]
409 #[serde(rename_all = "camelCase")]
410 struct PreviousDeploymentConfig {
411 compute_backend: Option<PreviousComputeBackend>,
412 }
413
414 #[derive(Debug, Deserialize)]
415 #[serde(tag = "type", rename_all = "camelCase")]
416 enum PreviousComputeBackend {
417 Horizon(PreviousHorizonConfig),
418 }
419
420 #[derive(Debug, Deserialize)]
421 #[serde(rename_all = "camelCase")]
422 struct PreviousHorizonConfig {
423 clusters: HashMap<String, PreviousHorizonClusterConfig>,
424 }
425
426 #[derive(Debug, Deserialize, PartialEq)]
427 #[serde(rename_all = "camelCase")]
428 struct PreviousHorizonClusterConfig {
429 cluster_id: String,
430 management_token: String,
431 }
432
433 /// A manager built before the field reads a deployment config that sets it and ignores it.
434 #[test]
435 fn deployment_config_with_cluster_machine_image_reads_in_older_readers() {
436 let wire = deployment_config_with_cluster(Some(machine_image("1")));
437 assert!(wire["computeBackend"]["clusters"]["compute"]["horizonMachineImage"].is_object());
438
439 let previous: PreviousDeploymentConfig = serde_json::from_value(wire.clone())
440 .expect("an older reader must accept a cluster that carries its own image");
441 let Some(PreviousComputeBackend::Horizon(horizon)) = previous.compute_backend else {
442 panic!("the Horizon backend reads back");
443 };
444 assert_eq!(
445 horizon.clusters["compute"],
446 PreviousHorizonClusterConfig {
447 cluster_id: "cluster-1".to_string(),
448 management_token: "hm_test".to_string(),
449 }
450 );
451
452 let current: DeploymentConfig = serde_json::from_value(wire).unwrap();
453 let Some(crate::ComputeBackend::Horizon(horizon)) = current.compute_backend else {
454 panic!("the Horizon backend reads back");
455 };
456 assert_eq!(
457 horizon
458 .machine_image_for("compute")
459 .map(|image| image.machine_image_version.as_str()),
460 Some("1")
461 );
462 }
463}