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};
9
10/// Magic bytes at the end of a binary with embedded config.
11pub const MAGIC_BYTES: &[u8; 8] = b"WLCFG001";
12
13/// Size of the footer: 4 bytes (length) + 8 bytes (magic).
14pub const FOOTER_SIZE: usize = 12;
15
16/// Configuration embedded in alien-deploy-cli binaries.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct DeployCliConfig {
20    // --- Connection (for pre-configured binaries) ---
21    /// Authentication token for the platform/manager API.
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub token: Option<String>,
24    /// Deployment group ID.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub deployment_group_id: Option<String>,
27    /// Default platform for deployments.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub default_platform: Option<String>,
30    /// Platform API base URL used for manager discovery.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub api_base_url: Option<String>,
33    /// Exact agent binary URL to install for local pull-model deployments.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub agent_binary_url: Option<String>,
36    /// Machine bootstrap bundle manifest URL used by `alien-deploy join`.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub machine_bundle_url: Option<String>,
39    /// Unix install script URL for this CLI package.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub install_script_url: Option<String>,
42    /// Package build revision for setup-affecting code and artifacts. Existing
43    /// direct-setup deployments reconcile setup once when this value changes.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub setup_revision: Option<String>,
46    /// Branded environment variable that contains the deployment token.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub token_env_var: Option<String>,
49    // --- Branding (for rebranded binaries) ---
50    /// Binary name (e.g., "acme-deploy").
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub name: Option<String>,
53    /// Human-friendly display name (e.g., "Acme Deploy CLI").
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub display_name: Option<String>,
56}
57
58/// Configuration embedded in alien-operator binaries.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61pub struct OperatorConfig {
62    // --- Connection (for pre-configured/OSS binaries) ---
63    /// Manager URL to connect to.
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub manager_url: Option<String>,
66    /// Authentication token for the manager API.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub token: Option<String>,
69    /// Deployment ID this operator manages.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub deployment_id: Option<String>,
72    /// Sync interval in seconds (default: 30).
73    #[serde(default = "default_sync_interval")]
74    pub sync_interval_secs: u64,
75    // --- Branding (for rebranded binaries) ---
76    /// Binary name (e.g., "acme-operator").
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub name: Option<String>,
79    /// Short brand slug used for generated resource names.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub brand: Option<String>,
82    /// Human-friendly display name (e.g., "Acme Operator").
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub display_name: Option<String>,
85    /// Branded environment variable prefix (e.g., "ACME").
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub env_prefix: Option<String>,
88    /// Branded Kubernetes/cloud label domain (e.g., "acme.dev").
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub label_domain: Option<String>,
91}
92
93fn default_sync_interval() -> u64 {
94    30
95}
96
97/// Load embedded configuration from the current binary.
98///
99/// Reads the binary's own file, checks for the magic trailer, extracts the
100/// JSON payload, and deserializes it into the requested type.
101///
102/// Returns `None` if the binary has no embedded config (no magic trailer).
103pub fn load_embedded_config<T: DeserializeOwned>() -> Result<Option<T>, EmbeddedConfigError> {
104    let exe_path = std::env::current_exe().map_err(EmbeddedConfigError::Io)?;
105    load_embedded_config_from_path(&exe_path)
106}
107
108/// Load embedded configuration from a specific binary path.
109pub fn load_embedded_config_from_path<T: DeserializeOwned>(
110    path: &std::path::Path,
111) -> Result<Option<T>, EmbeddedConfigError> {
112    let data = std::fs::read(path).map_err(EmbeddedConfigError::Io)?;
113
114    if data.len() < FOOTER_SIZE {
115        return Ok(None);
116    }
117
118    // Check magic bytes at the end
119    let magic_start = data.len() - MAGIC_BYTES.len();
120    if &data[magic_start..] != MAGIC_BYTES {
121        return Ok(None);
122    }
123
124    // Read the 4-byte little-endian length before the magic
125    let len_start = magic_start - 4;
126    let len_bytes: [u8; 4] = data[len_start..magic_start]
127        .try_into()
128        .map_err(|_| EmbeddedConfigError::InvalidFormat("invalid length bytes".into()))?;
129    let json_len = u32::from_le_bytes(len_bytes) as usize;
130
131    if json_len == 0 || len_start < json_len {
132        return Err(EmbeddedConfigError::InvalidFormat(
133            "config length exceeds binary size".into(),
134        ));
135    }
136
137    let json_start = len_start - json_len;
138    let json_bytes = &data[json_start..len_start];
139
140    let config: T =
141        serde_json::from_slice(json_bytes).map_err(EmbeddedConfigError::Deserialization)?;
142
143    Ok(Some(config))
144}
145
146/// Append embedded configuration to a binary.
147///
148/// Writes: original binary bytes + JSON payload + 4-byte LE length + magic bytes.
149pub fn append_embedded_config<T: Serialize>(
150    binary_data: &[u8],
151    config: &T,
152) -> Result<Vec<u8>, EmbeddedConfigError> {
153    let json_bytes = serde_json::to_vec(config).map_err(EmbeddedConfigError::Deserialization)?;
154    let json_len = json_bytes.len() as u32;
155
156    let mut result = Vec::with_capacity(binary_data.len() + json_bytes.len() + FOOTER_SIZE);
157    result.extend_from_slice(binary_data);
158    result.extend_from_slice(&json_bytes);
159    result.extend_from_slice(&json_len.to_le_bytes());
160    result.extend_from_slice(MAGIC_BYTES);
161
162    Ok(result)
163}
164
165/// Errors from embedded config operations.
166#[derive(Debug)]
167pub enum EmbeddedConfigError {
168    Io(std::io::Error),
169    InvalidFormat(String),
170    Deserialization(serde_json::Error),
171}
172
173impl std::fmt::Display for EmbeddedConfigError {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        match self {
176            Self::Io(e) => write!(f, "IO error reading embedded config: {}", e),
177            Self::InvalidFormat(msg) => write!(f, "invalid embedded config format: {}", msg),
178            Self::Deserialization(e) => write!(f, "failed to deserialize embedded config: {}", e),
179        }
180    }
181}
182
183impl std::error::Error for EmbeddedConfigError {}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn test_roundtrip_deploy_cli_config() {
191        let config = DeployCliConfig {
192            token: Some("ax_dg_abc123".into()),
193            deployment_group_id: Some("dg_xyz".into()),
194            display_name: Some("Production".into()),
195            default_platform: Some("aws".into()),
196            api_base_url: Some("https://api.example.com".into()),
197            agent_binary_url: Some("https://packages.example.com/acme/agent".into()),
198            machine_bundle_url: Some(
199                "https://packages.example.com/acme/machine-bundle.json".into(),
200            ),
201            install_script_url: Some("https://packages.example.com/acme/install.sh".into()),
202            setup_revision: Some("build-123".into()),
203            token_env_var: Some("ACME_DEPLOYMENT_TOKEN".into()),
204            name: Some("acme-deploy".into()),
205        };
206
207        let binary = b"fake binary content";
208        let embedded = append_embedded_config(binary, &config).unwrap();
209
210        let loaded: Option<DeployCliConfig> =
211            load_embedded_config_from_path_bytes(&embedded).unwrap();
212        let loaded = loaded.unwrap();
213
214        assert_eq!(loaded.token, config.token);
215        assert_eq!(loaded.deployment_group_id, config.deployment_group_id);
216        assert_eq!(loaded.default_platform, config.default_platform);
217        assert_eq!(loaded.api_base_url, config.api_base_url);
218        assert_eq!(loaded.agent_binary_url, config.agent_binary_url);
219        assert_eq!(loaded.machine_bundle_url, config.machine_bundle_url);
220        assert_eq!(loaded.install_script_url, config.install_script_url);
221        assert_eq!(loaded.token_env_var, config.token_env_var);
222        assert_eq!(loaded.display_name, config.display_name);
223        assert_eq!(loaded.name, config.name);
224    }
225
226    #[test]
227    fn test_no_embedded_config() {
228        let binary = b"just a regular binary";
229        let result: Option<DeployCliConfig> = load_embedded_config_from_path_bytes(binary).unwrap();
230        assert!(result.is_none());
231    }
232
233    #[test]
234    fn test_roundtrip_operator_config() {
235        let config = OperatorConfig {
236            manager_url: Some("https://manager.example.com".into()),
237            token: Some("ax_dep_operator123".into()),
238            deployment_id: Some("dep_abc".into()),
239            sync_interval_secs: 60,
240            name: Some("acme-operator".into()),
241            brand: Some("acme".into()),
242            display_name: Some("Acme Operator".into()),
243            env_prefix: Some("ACME".into()),
244            label_domain: Some("acme.dev".into()),
245        };
246
247        let binary = b"operator binary";
248        let embedded = append_embedded_config(binary, &config).unwrap();
249
250        let loaded: Option<OperatorConfig> =
251            load_embedded_config_from_path_bytes(&embedded).unwrap();
252        let loaded = loaded.unwrap();
253
254        assert_eq!(loaded.manager_url, config.manager_url);
255        assert_eq!(loaded.deployment_id, config.deployment_id);
256        assert_eq!(loaded.sync_interval_secs, 60);
257        assert_eq!(loaded.name, config.name);
258        assert_eq!(loaded.brand, config.brand);
259        assert_eq!(loaded.display_name, config.display_name);
260        assert_eq!(loaded.env_prefix, config.env_prefix);
261        assert_eq!(loaded.label_domain, config.label_domain);
262    }
263
264    /// Helper that works on in-memory bytes (for tests that don't need files).
265    fn load_embedded_config_from_path_bytes<T: DeserializeOwned>(
266        data: &[u8],
267    ) -> Result<Option<T>, EmbeddedConfigError> {
268        if data.len() < FOOTER_SIZE {
269            return Ok(None);
270        }
271
272        let magic_start = data.len() - MAGIC_BYTES.len();
273        if &data[magic_start..] != MAGIC_BYTES {
274            return Ok(None);
275        }
276
277        let len_start = magic_start - 4;
278        let len_bytes: [u8; 4] = data[len_start..magic_start]
279            .try_into()
280            .map_err(|_| EmbeddedConfigError::InvalidFormat("invalid length bytes".into()))?;
281        let json_len = u32::from_le_bytes(len_bytes) as usize;
282
283        if json_len == 0 || len_start < json_len {
284            return Err(EmbeddedConfigError::InvalidFormat(
285                "config length exceeds binary size".into(),
286            ));
287        }
288
289        let json_start = len_start - json_len;
290        let json_bytes = &data[json_start..len_start];
291
292        let config: T =
293            serde_json::from_slice(json_bytes).map_err(EmbeddedConfigError::Deserialization)?;
294
295        Ok(Some(config))
296    }
297}