Skip to main content

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