1use serde::{de::DeserializeOwned, Deserialize, Serialize};
9use std::path::Path;
10
11pub const OPERATOR_CONFIG_PATH: &str = "/etc/alien/operator-config.json";
13
14pub const MAGIC_BYTES: &[u8; 8] = b"WLCFG001";
16
17pub const FOOTER_SIZE: usize = 12;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct DeployCliConfig {
24 #[serde(skip_serializing_if = "Option::is_none")]
27 pub token: Option<String>,
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub deployment_group_id: Option<String>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub default_platform: Option<String>,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub api_base_url: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub agent_binary_url: Option<String>,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub machine_bundle_url: Option<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub install_script_url: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub setup_revision: Option<String>,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub token_env_var: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none")]
56 pub name: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub display_name: Option<String>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(rename_all = "camelCase")]
65pub struct OperatorConfig {
66 #[serde(skip_serializing_if = "Option::is_none")]
69 pub manager_url: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub token: Option<String>,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub deployment_id: Option<String>,
76 #[serde(default = "default_sync_interval")]
78 pub sync_interval_secs: u64,
79 #[serde(skip_serializing_if = "Option::is_none")]
82 pub name: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub brand: Option<String>,
86 #[serde(skip_serializing_if = "Option::is_none")]
88 pub display_name: Option<String>,
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub env_prefix: Option<String>,
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub label_domain: Option<String>,
95}
96
97fn default_sync_interval() -> u64 {
98 30
99}
100
101pub 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
112pub 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
118pub 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 let magic_start = data.len() - MAGIC_BYTES.len();
130 if &data[magic_start..] != MAGIC_BYTES {
131 return Ok(None);
132 }
133
134 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
156pub 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
172pub 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#[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 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}