Skip to main content

alien_core/
embedded_config.rs

1//! Embedded configuration support for alien-deploy-cli and alien-operator binaries.
2//!
3//! The package builder appends a JSON-encoded config struct to the end of the
4//! binary, followed by a 4-byte little-endian length and 8-byte magic trailer.
5//! This allows a single binary to be customized per deployment group without
6//! recompilation.
7
8use serde::{de::DeserializeOwned, Deserialize, Serialize};
9use std::path::Path;
10
11/// Default location of the operator configuration packaged in a container image.
12pub const OPERATOR_CONFIG_PATH: &str = "/etc/alien/operator-config.json";
13
14/// Magic bytes at the end of a binary with embedded config.
15pub const MAGIC_BYTES: &[u8; 8] = b"WLCFG001";
16
17/// Size of the footer: 4 bytes (length) + 8 bytes (magic).
18pub const FOOTER_SIZE: usize = 12;
19
20/// Configuration embedded in alien-deploy-cli binaries.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct DeployCliConfig {
24    // --- Connection (for pre-configured binaries) ---
25    /// Authentication token for the platform/manager API.
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub token: Option<String>,
28    /// Deployment group ID.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub deployment_group_id: Option<String>,
31    /// Default platform for deployments.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub default_platform: Option<String>,
34    /// Platform API base URL used for manager discovery.
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub api_base_url: Option<String>,
37    /// Exact agent binary URL to install for local pull-model deployments.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub agent_binary_url: Option<String>,
40    /// Machine bootstrap bundle manifest URL used by `alien-deploy join`.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub machine_bundle_url: Option<String>,
43    /// Unix install script URL for this CLI package.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub install_script_url: Option<String>,
46    /// Package build revision for setup-affecting code and artifacts. Existing
47    /// direct-setup deployments reconcile setup once when this value changes.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub setup_revision: Option<String>,
50    /// Branded environment variable that contains the deployment token.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub token_env_var: Option<String>,
53    // --- Branding (for rebranded binaries) ---
54    /// Binary name (e.g., "acme-deploy").
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub name: Option<String>,
57    /// Human-friendly display name (e.g., "Acme Deploy CLI").
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub display_name: Option<String>,
60}
61
62/// Configuration embedded in alien-operator binaries.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct OperatorConfig {
66    // --- Connection (for pre-configured/OSS binaries) ---
67    /// Manager URL to connect to.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub manager_url: Option<String>,
70    /// Authentication token for the manager API.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub token: Option<String>,
73    /// Deployment ID this operator manages.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub deployment_id: Option<String>,
76    /// Sync interval in seconds (default: 30).
77    #[serde(default = "default_sync_interval")]
78    pub sync_interval_secs: u64,
79    // --- Branding (for rebranded binaries) ---
80    /// Binary name (e.g., "acme-operator").
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub name: Option<String>,
83    /// Short brand slug used for generated resource names.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub brand: Option<String>,
86    /// Human-friendly display name (e.g., "Acme Operator").
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub display_name: Option<String>,
89    /// Branded environment variable prefix (e.g., "ACME").
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub env_prefix: Option<String>,
92    /// Branded Kubernetes/cloud label domain (e.g., "acme.dev").
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub label_domain: Option<String>,
95}
96
97fn default_sync_interval() -> u64 {
98    30
99}
100
101/// Load embedded configuration from the current binary.
102///
103/// Reads the binary's own file, checks for the magic trailer, extracts the
104/// JSON payload, and deserializes it into the requested type.
105///
106/// Returns `None` if the binary has no embedded config (no magic trailer).
107pub fn load_embedded_config<T: DeserializeOwned>() -> Result<Option<T>, EmbeddedConfigError> {
108    let exe_path = std::env::current_exe().map_err(EmbeddedConfigError::Io)?;
109    load_embedded_config_from_path(&exe_path)
110}
111
112/// Load a JSON configuration file.
113pub fn load_config_file<T: DeserializeOwned>(path: &Path) -> Result<T, EmbeddedConfigError> {
114    let data = std::fs::read(path).map_err(EmbeddedConfigError::Io)?;
115    serde_json::from_slice(&data).map_err(EmbeddedConfigError::Deserialization)
116}
117
118/// Load embedded configuration from a specific binary path.
119pub fn load_embedded_config_from_path<T: DeserializeOwned>(
120    path: &std::path::Path,
121) -> Result<Option<T>, EmbeddedConfigError> {
122    let data = std::fs::read(path).map_err(EmbeddedConfigError::Io)?;
123
124    if data.len() < FOOTER_SIZE {
125        return Ok(None);
126    }
127
128    // Check magic bytes at the end
129    let magic_start = data.len() - MAGIC_BYTES.len();
130    if &data[magic_start..] != MAGIC_BYTES {
131        return Ok(None);
132    }
133
134    // Read the 4-byte little-endian length before the magic
135    let len_start = magic_start - 4;
136    let len_bytes: [u8; 4] = data[len_start..magic_start]
137        .try_into()
138        .map_err(|_| EmbeddedConfigError::InvalidFormat("invalid length bytes".into()))?;
139    let json_len = u32::from_le_bytes(len_bytes) as usize;
140
141    if json_len == 0 || len_start < json_len {
142        return Err(EmbeddedConfigError::InvalidFormat(
143            "config length exceeds binary size".into(),
144        ));
145    }
146
147    let json_start = len_start - json_len;
148    let json_bytes = &data[json_start..len_start];
149
150    let config: T =
151        serde_json::from_slice(json_bytes).map_err(EmbeddedConfigError::Deserialization)?;
152
153    Ok(Some(config))
154}
155
156/// Append embedded configuration to a binary.
157///
158/// Writes: original binary bytes + JSON payload + 4-byte LE length + magic bytes.
159pub fn append_embedded_config<T: Serialize>(
160    binary_data: &[u8],
161    config: &T,
162) -> Result<Vec<u8>, EmbeddedConfigError> {
163    let trailer = encode_embedded_config_trailer(config)?;
164
165    let mut result = Vec::with_capacity(binary_data.len() + trailer.len());
166    result.extend_from_slice(binary_data);
167    result.extend_from_slice(&trailer);
168
169    Ok(result)
170}
171
172/// Encode the self-contained trailer appended to a binary with embedded config.
173///
174/// Writes: JSON payload + 4-byte LE length + magic bytes. Keeping this separate
175/// lets object stores compose an existing binary with a small configuration
176/// object without downloading and uploading the binary again.
177pub fn encode_embedded_config_trailer<T: Serialize>(
178    config: &T,
179) -> Result<Vec<u8>, EmbeddedConfigError> {
180    let json_bytes = serde_json::to_vec(config).map_err(EmbeddedConfigError::Deserialization)?;
181    let json_len = json_bytes.len() as u32;
182
183    let mut result = Vec::with_capacity(json_bytes.len() + FOOTER_SIZE);
184    result.extend_from_slice(&json_bytes);
185    result.extend_from_slice(&json_len.to_le_bytes());
186    result.extend_from_slice(MAGIC_BYTES);
187
188    Ok(result)
189}
190
191/// Errors from embedded config operations.
192#[derive(Debug)]
193pub enum EmbeddedConfigError {
194    Io(std::io::Error),
195    InvalidFormat(String),
196    Deserialization(serde_json::Error),
197}
198
199impl std::fmt::Display for EmbeddedConfigError {
200    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201        match self {
202            Self::Io(e) => write!(f, "IO error reading embedded config: {}", e),
203            Self::InvalidFormat(msg) => write!(f, "invalid embedded config format: {}", msg),
204            Self::Deserialization(e) => write!(f, "failed to deserialize embedded config: {}", e),
205        }
206    }
207}
208
209impl std::error::Error for EmbeddedConfigError {}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn encoded_trailer_matches_append_output_exactly() {
217        let config = DeployCliConfig {
218            token: Some("secret".into()),
219            deployment_group_id: Some("dg_123".into()),
220            default_platform: Some("aws".into()),
221            api_base_url: Some("https://api.example.com".into()),
222            agent_binary_url: None,
223            machine_bundle_url: None,
224            install_script_url: None,
225            setup_revision: Some("revision".into()),
226            token_env_var: Some("EXAMPLE_TOKEN".into()),
227            name: Some("example-deploy".into()),
228            display_name: Some("Example Deploy".into()),
229        };
230        let binary = b"an existing executable";
231
232        let appended = append_embedded_config(binary, &config).expect("config should append");
233        let trailer = encode_embedded_config_trailer(&config).expect("trailer should encode");
234
235        assert_eq!(appended, [binary.as_slice(), trailer.as_slice()].concat());
236
237        let loaded: DeployCliConfig = load_embedded_config_from_path_bytes(&appended)
238            .expect("appended config should parse")
239            .expect("appended config should exist");
240        assert_eq!(loaded.token, config.token);
241        assert_eq!(loaded.deployment_group_id, config.deployment_group_id);
242        assert_eq!(loaded.setup_revision, config.setup_revision);
243        assert_eq!(loaded.name, config.name);
244    }
245
246    #[test]
247    fn test_roundtrip_deploy_cli_config() {
248        let config = DeployCliConfig {
249            token: Some("ax_dg_abc123".into()),
250            deployment_group_id: Some("dg_xyz".into()),
251            display_name: Some("Production".into()),
252            default_platform: Some("aws".into()),
253            api_base_url: Some("https://api.example.com".into()),
254            agent_binary_url: Some("https://packages.example.com/acme/agent".into()),
255            machine_bundle_url: Some(
256                "https://packages.example.com/acme/machine-bundle.json".into(),
257            ),
258            install_script_url: Some("https://packages.example.com/acme/install.sh".into()),
259            setup_revision: Some("build-123".into()),
260            token_env_var: Some("ACME_DEPLOYMENT_TOKEN".into()),
261            name: Some("acme-deploy".into()),
262        };
263
264        let binary = b"fake binary content";
265        let embedded = append_embedded_config(binary, &config).unwrap();
266
267        let loaded: Option<DeployCliConfig> =
268            load_embedded_config_from_path_bytes(&embedded).unwrap();
269        let loaded = loaded.unwrap();
270
271        assert_eq!(loaded.token, config.token);
272        assert_eq!(loaded.deployment_group_id, config.deployment_group_id);
273        assert_eq!(loaded.default_platform, config.default_platform);
274        assert_eq!(loaded.api_base_url, config.api_base_url);
275        assert_eq!(loaded.agent_binary_url, config.agent_binary_url);
276        assert_eq!(loaded.machine_bundle_url, config.machine_bundle_url);
277        assert_eq!(loaded.install_script_url, config.install_script_url);
278        assert_eq!(loaded.token_env_var, config.token_env_var);
279        assert_eq!(loaded.display_name, config.display_name);
280        assert_eq!(loaded.name, config.name);
281    }
282
283    #[test]
284    fn test_no_embedded_config() {
285        let binary = b"just a regular binary";
286        let result: Option<DeployCliConfig> = load_embedded_config_from_path_bytes(binary).unwrap();
287        assert!(result.is_none());
288    }
289
290    #[test]
291    fn test_roundtrip_operator_config() {
292        let config = OperatorConfig {
293            manager_url: Some("https://manager.example.com".into()),
294            token: Some("ax_dep_operator123".into()),
295            deployment_id: Some("dep_abc".into()),
296            sync_interval_secs: 60,
297            name: Some("acme-operator".into()),
298            brand: Some("acme".into()),
299            display_name: Some("Acme Operator".into()),
300            env_prefix: Some("ACME".into()),
301            label_domain: Some("acme.dev".into()),
302        };
303
304        let binary = b"operator binary";
305        let embedded = append_embedded_config(binary, &config).unwrap();
306
307        let loaded: Option<OperatorConfig> =
308            load_embedded_config_from_path_bytes(&embedded).unwrap();
309        let loaded = loaded.unwrap();
310
311        assert_eq!(loaded.manager_url, config.manager_url);
312        assert_eq!(loaded.deployment_id, config.deployment_id);
313        assert_eq!(loaded.sync_interval_secs, 60);
314        assert_eq!(loaded.name, config.name);
315        assert_eq!(loaded.brand, config.brand);
316        assert_eq!(loaded.display_name, config.display_name);
317        assert_eq!(loaded.env_prefix, config.env_prefix);
318        assert_eq!(loaded.label_domain, config.label_domain);
319    }
320
321    #[test]
322    fn operator_config_roundtrips_through_packaged_file() {
323        let config = OperatorConfig {
324            manager_url: None,
325            token: None,
326            deployment_id: None,
327            sync_interval_secs: 17,
328            name: Some("acme-operator".into()),
329            brand: Some("acme".into()),
330            display_name: Some("Acme Operator".into()),
331            env_prefix: Some("ACME".into()),
332            label_domain: Some("acme.dev".into()),
333        };
334        let path = std::env::temp_dir().join(format!(
335            "alien-operator-config-{}-{}.json",
336            std::process::id(),
337            std::thread::current().name().unwrap_or("test")
338        ));
339        std::fs::write(
340            &path,
341            serde_json::to_vec(&config).expect("serialize operator config"),
342        )
343        .expect("write operator config");
344
345        let loaded: OperatorConfig = load_config_file(&path).expect("load operator config");
346
347        assert_eq!(loaded.sync_interval_secs, 17);
348        assert_eq!(loaded.name.as_deref(), Some("acme-operator"));
349        assert_eq!(loaded.brand.as_deref(), Some("acme"));
350        assert_eq!(loaded.display_name.as_deref(), Some("Acme Operator"));
351        assert_eq!(loaded.env_prefix.as_deref(), Some("ACME"));
352        assert_eq!(loaded.label_domain.as_deref(), Some("acme.dev"));
353        std::fs::remove_file(path).expect("remove operator config");
354    }
355
356    /// Helper that works on in-memory bytes (for tests that don't need files).
357    fn load_embedded_config_from_path_bytes<T: DeserializeOwned>(
358        data: &[u8],
359    ) -> Result<Option<T>, EmbeddedConfigError> {
360        if data.len() < FOOTER_SIZE {
361            return Ok(None);
362        }
363
364        let magic_start = data.len() - MAGIC_BYTES.len();
365        if &data[magic_start..] != MAGIC_BYTES {
366            return Ok(None);
367        }
368
369        let len_start = magic_start - 4;
370        let len_bytes: [u8; 4] = data[len_start..magic_start]
371            .try_into()
372            .map_err(|_| EmbeddedConfigError::InvalidFormat("invalid length bytes".into()))?;
373        let json_len = u32::from_le_bytes(len_bytes) as usize;
374
375        if json_len == 0 || len_start < json_len {
376            return Err(EmbeddedConfigError::InvalidFormat(
377                "config length exceeds binary size".into(),
378            ));
379        }
380
381        let json_start = len_start - json_len;
382        let json_bytes = &data[json_start..len_start];
383
384        let config: T =
385            serde_json::from_slice(json_bytes).map_err(EmbeddedConfigError::Deserialization)?;
386
387        Ok(Some(config))
388    }
389}