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::{is_false, ComputeBackend, DomainMetadata, EnvironmentVariablesSnapshot};
9
10/// Deployment configuration
11///
12/// Configuration for how to perform the deployment.
13/// Note: Credentials (ClientConfig) are passed separately to step() function.
14#[derive(Debug, Clone, Serialize, Deserialize, Builder)]
15#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
16#[serde(rename_all = "camelCase")]
17pub struct DeploymentConfig {
18 /// This invocation is an explicit setup run using the installer's credentials.
19 /// Not serialized: a release or stored configuration cannot request an identity refresh.
20 #[serde(skip)]
21 #[cfg_attr(feature = "openapi", schema(ignore))]
22 #[builder(default)]
23 pub setup_run: bool,
24 /// Human-readable deployment name for cloud console metadata.
25 ///
26 /// This is separate from the physical resource prefix in StackState. It is
27 /// used only for display text such as IAM role descriptions, service
28 /// account descriptions, and custom role titles.
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub deployment_name: Option<String>,
31 /// User-customizable deployment settings (network, deployment model, approvals).
32 /// Provided by customer via CloudFormation, Terraform, CLI, or Helm.
33 #[serde(default)]
34 pub stack_settings: StackSettings,
35 /// Platform service account/role that will manage the infrastructure remotely.
36 /// Derived from Manager's ServiceAccount, not user-specified.
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub management_config: Option<ManagementConfig>,
39 /// Environment variables snapshot
40 pub environment_variables: EnvironmentVariablesSnapshot,
41 /// Deployer-provided stack input values, keyed by input id. A resource
42 /// gated with `.enabled(input)` and a Live lifecycle follows these
43 /// values; a missing key falls back to the input's declared boolean
44 /// default. Suppliers must not place secret-kind input values here:
45 /// this map serializes and debug-prints unredacted.
46 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
47 #[builder(default)]
48 pub input_values: HashMap<String, serde_json::Value>,
49 /// IDs of applicable secret inputs stored for this exact deployment target.
50 /// Trusted presence metadata only: never values, gate answers, or authority.
51 /// Absent on legacy targets; an explicit empty list means no stored secrets.
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 #[cfg_attr(feature = "openapi", schema(nullable = false))]
54 pub stored_secret_input_ids: Option<Vec<String>>,
55 /// Allow frozen resource changes during updates
56 /// When true, skips the frozen resources compatibility check.
57 /// This requires running with elevated cloud credentials.
58 #[serde(default, skip_serializing_if = "is_false")]
59 pub allow_frozen_changes: bool,
60 /// Compute backend for Container and Worker resources.
61 /// When None, the platform default is used for cloud platforms.
62 /// Contains cluster IDs and management tokens for container orchestration.
63 /// Worker runtime credentials are provided through cloud identity and vault-backed secrets.
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub compute_backend: Option<ComputeBackend>,
66 /// External bindings for pre-existing services.
67 /// Required for Kubernetes platform (all infrastructure resources).
68 /// Optional for cloud platforms (override specific resources).
69 #[serde(default)]
70 pub external_bindings: ExternalBindings,
71 /// Cloud platform that owns imported base infrastructure for a Kubernetes
72 /// runtime deployment.
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub base_platform: Option<crate::Platform>,
75 /// DNS-style label domain used for Kubernetes resource ownership labels.
76 ///
77 /// Defaults to `alien.dev` when absent. Whitelabeled Operator builds set this
78 /// so generated workloads and optional log collectors share the same label
79 /// namespace.
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub label_domain: Option<String>,
82 /// Kubernetes label selector that narrows which raw resources the observe
83 /// pass reports (e.g. `app.kubernetes.io/part-of=my-app`). `None` observes
84 /// everything in the namespace. Ignored by cloud observers.
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub observe_label_selector: Option<String>,
87 /// When true the observe pass reports raw resources across every namespace
88 /// (cluster scope); otherwise it stays within the operator's own namespace.
89 /// The label selector, if any, still filters within whichever scope applies.
90 /// Ignored by cloud observers.
91 #[serde(default)]
92 #[builder(default)]
93 pub observe_all_namespaces: bool,
94 /// Public endpoint URLs for exposed resources (optional override).
95 ///
96 /// Use this only when a caller already knows the public URL. Managed public
97 /// endpoint flows should prefer `domain_metadata` plus controller-reported
98 /// load balancer outputs so DNS, certificate renewal, and route readiness
99 /// stay tied to the resource state.
100 ///
101 /// If not set, platforms determine public endpoint URLs from other sources:
102 /// - Managed DNS/TLS flows: `domain_metadata` FQDN or load balancer DNS
103 /// - Local: `http://localhost:{allocated_port}`
104 /// - Custom or disabled exposure: no public endpoint URL unless a controller reports one
105 ///
106 /// Outer key: resource ID. Inner key: endpoint name. Value: public URL.
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub public_endpoints: Option<HashMap<String, HashMap<String, String>>>,
109 /// Domain metadata for auto-managed public resources.
110 ///
111 /// Contains generated hostnames, DNS record state, certificate material,
112 /// and renewal markers for platforms that use managed public endpoints.
113 /// Kubernetes uses this only when its exposure mode is `generated`; BYO and
114 /// disabled Kubernetes exposure do not receive managed domain metadata.
115 #[serde(skip_serializing_if = "Option::is_none")]
116 pub domain_metadata: Option<DomainMetadata>,
117 /// OTLP observability configuration for log export (optional).
118 ///
119 /// When set, worker runtimes export captured application logs through this
120 /// endpoint. Container orchestrators may use it for their node-level log
121 /// collectors, but app container configs must not receive the auth header.
122 #[serde(skip_serializing_if = "Option::is_none")]
123 pub monitoring: Option<OtlpConfig>,
124 /// Manager base URL (e.g., "https://manager.alien.dev").
125 ///
126 /// The manager IS the container registry — its `/v2/` endpoint serves as
127 /// the OCI Distribution API. Controllers derive the proxy host from this
128 /// to configure pull auth (RegistryCredentials, imagePullSecrets).
129 ///
130 /// When None (e.g., `alien dev`), controllers use image URIs as-is.
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub manager_url: Option<String>,
133 /// Deployment token for pull authentication with the manager's registry.
134 ///
135 /// Used by controllers to configure registry credentials so cloud platforms
136 /// and K8s can pull images from the manager's `/v2/` endpoint.
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub deployment_token: Option<String>,
139 /// Native image registry host+prefix for platforms that require it.
140 ///
141 /// Lambda (ECR) and Cloud Run (GAR) require native registry URIs. Other
142 /// runtimes, including Azure Container Apps, pull through the manager's
143 /// registry proxy.
144 ///
145 /// Derived by the manager from the artifact registry binding:
146 /// - ECR: `{account_id}.dkr.ecr.{region}.amazonaws.com/{repository_prefix}`
147 /// - GAR: `{region}-docker.pkg.dev/{project_id}/{repository_name}`
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub native_image_host: Option<String>,
150 /// Operator requests to replace a replica's persistent volume with a new
151 /// volume made from a snapshot. A container controller performs each
152 /// request once, identified by its `request_id`, and reports it in
153 /// `ContainerOutputs.volumes`. Only a volume that a controller reports in
154 /// `ContainerOutputs.volumes` can be the target of a request.
155 #[serde(default, skip_serializing_if = "Vec::is_empty")]
156 #[builder(default)]
157 pub volume_restores: Vec<VolumeRestoreRequest>,
158}
159
160/// Replace one replica's persistent volume with a new volume made from a snapshot.
161///
162/// The controller stops the replica, snapshots the volume it is about to
163/// replace (so the restore can be undone), creates the new volume in the same
164/// zone, starts the replica on it, and deletes the replaced volume.
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
167#[serde(rename_all = "camelCase")]
168pub struct VolumeRestoreRequest {
169 /// Unique ID of this request. A controller performs each request once.
170 pub request_id: String,
171 /// ID of the container resource that owns the volume
172 pub resource_id: String,
173 /// Replica ordinal whose volume is replaced
174 pub ordinal: u32,
175 /// Cloud ID of the snapshot to restore: an EBS snapshot ID, a Compute
176 /// Engine snapshot name, or an Azure snapshot resource ID
177 pub snapshot_id: String,
178}
179
180/// Resource-attribute key marking OTLP telemetry as Alien system-component
181/// output (infrastructure daemons and internal runtimes) rather than user
182/// workload. Log consumers — the CLI log viewer and the dashboard — hide
183/// telemetry carrying this attribute by default. The value is the string
184/// `"true"`.
185///
186/// System components set this on their own telemetry's resource attributes so
187/// consumers can filter generically, without enumerating component names.
188pub const ALIEN_SYSTEM_RESOURCE_ATTRIBUTE: &str = "alien.system";
189
190/// OTLP log export configuration for a deployment.
191///
192/// When set, injected compute runtimes export captured application logs
193/// through the given endpoint via OTLP/HTTP; which resources are injected
194/// is platform-dependent. Workers read auth headers from a runtime-only
195/// secret. Runtime-less Containers and Daemons receive standard OTEL auth
196/// variables only at the final hosting boundary: Local passes them directly
197/// to the process and Kubernetes projects them from a per-workload Secret.
198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
199#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
200#[serde(rename_all = "camelCase")]
201pub struct OtlpConfig {
202 /// Full OTLP logs endpoint URL.
203 /// Example: "https://<manager-host>/v1/logs"
204 pub logs_endpoint: String,
205 /// Auth header value in "key=value,..." format.
206 /// Example: "authorization=Bearer <write-token>"
207 pub logs_auth_header: String,
208 /// Full OTLP metrics endpoint URL (optional).
209 /// When set, the worker runtime exports its own VM/container orchestration metrics here.
210 /// Example: "https://api.axiom.co/v1/metrics"
211 #[serde(skip_serializing_if = "Option::is_none")]
212 pub metrics_endpoint: Option<String>,
213 /// Auth header value for the metrics endpoint in "key=value,..." format (optional).
214 ///
215 /// When absent, `logs_auth_header` is reused for metrics -- suitable when the same
216 /// credential covers both signals. When present (e.g. Axiom with separate datasets),
217 /// this value is used exclusively for metrics.
218 ///
219 /// Example: "authorization=Bearer <token>,x-axiom-dataset=<metrics-dataset>"
220 #[serde(skip_serializing_if = "Option::is_none")]
221 pub metrics_auth_header: Option<String>,
222 /// Resource attributes attached to every OTLP signal emitted for this deployment.
223 ///
224 /// Platform managers use this for stable identity such as `alien.workspace_id`,
225 /// `alien.project_id`, `alien.deployment_group_id`, and `alien.deployment_id`.
226 /// Runtime-specific resource attributes such as `service.name` remain owned by
227 /// the runtime/exporter.
228 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
229 pub resource_attributes: HashMap<String, String>,
230}
231
232#[cfg(test)]
233mod input_values_tests {
234 use super::*;
235
236 /// Older actors serialize configs without the field; it must deserialize
237 /// to an empty map, and an empty map must not serialize at all.
238 #[test]
239 fn input_values_default_to_empty_and_stay_off_the_wire() {
240 let json = serde_json::json!({
241 "stackSettings": serde_json::to_value(crate::StackSettings::default()).unwrap(),
242 "environmentVariables": {
243 "variables": [],
244 "hash": "hash",
245 "createdAt": "2026-01-01T00:00:00Z",
246 },
247 "externalBindings": {},
248 });
249 let config: DeploymentConfig =
250 serde_json::from_value(json).expect("old shape deserializes");
251 assert!(config.input_values.is_empty());
252 assert!(config.stored_secret_input_ids.is_none());
253
254 let round = serde_json::to_value(&config).expect("serializes");
255 assert!(round.get("storedSecretInputIds").is_none());
256 assert!(
257 round.get("inputValues").is_none(),
258 "empty map must not serialize"
259 );
260 }
261 #[test]
262 fn stored_secret_presence_roundtrips_without_values_or_gate_answers() {
263 let mut config = DeploymentConfig::builder()
264 .stack_settings(StackSettings::default())
265 .environment_variables(EnvironmentVariablesSnapshot {
266 variables: vec![],
267 hash: String::new(),
268 created_at: String::new(),
269 })
270 .allow_frozen_changes(false)
271 .external_bindings(ExternalBindings::default())
272 .build();
273 assert!(config.stored_secret_input_ids.is_none());
274 config.stored_secret_input_ids = Some(Vec::new());
275 let empty = serde_json::to_value(&config).unwrap();
276 assert_eq!(empty["storedSecretInputIds"], serde_json::json!([]));
277 assert_eq!(
278 serde_json::from_value::<DeploymentConfig>(empty)
279 .unwrap()
280 .stored_secret_input_ids,
281 Some(Vec::new())
282 );
283 config.stored_secret_input_ids =
284 Some(vec!["apiKey".to_string(), "enableFeature".to_string()]);
285 let wire = serde_json::to_value(&config).unwrap();
286 assert_eq!(
287 wire["storedSecretInputIds"],
288 serde_json::json!(["apiKey", "enableFeature"])
289 );
290 assert!(wire.get("inputValues").is_none());
291 let decoded: DeploymentConfig = serde_json::from_value(wire.clone()).unwrap();
292 assert_eq!(
293 decoded.stored_secret_input_ids,
294 config.stored_secret_input_ids
295 );
296 assert_eq!(serde_json::to_value(decoded.clone()).unwrap(), wire);
297 assert!(decoded.input_values.is_empty());
298 for default in [false, true] {
299 let input: crate::StackInputDefinition = serde_json::from_value(serde_json::json!({
300 "id": "enableFeature", "kind": "boolean", "providedBy": ["deployer"],
301 "required": false, "label": "Enable feature", "description": "", "default": crate::StackInputDefaultValue::Boolean(default)
302 }))
303 .unwrap();
304 assert_eq!(
305 crate::gate_resolves_true(
306 &[input],
307 "enableFeature",
308 &decoded.input_values,
309 "worker"
310 )
311 .unwrap(),
312 default
313 );
314 }
315 assert!(
316 crate::gate_resolves_true(&[], "enableFeature", &decoded.input_values, "worker")
317 .is_err()
318 );
319 }
320}