1use serde::{de::DeserializeOwned, Deserialize, Serialize};
9
10pub const MAGIC_BYTES: &[u8; 8] = b"WLCFG001";
12
13pub const FOOTER_SIZE: usize = 12;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct DeployCliConfig {
20 #[serde(skip_serializing_if = "Option::is_none")]
23 pub token: Option<String>,
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub deployment_group_id: Option<String>,
27 #[serde(skip_serializing_if = "Option::is_none")]
29 pub default_platform: Option<String>,
30 #[serde(skip_serializing_if = "Option::is_none")]
32 pub api_base_url: Option<String>,
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub agent_binary_url: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub machine_bundle_url: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub install_script_url: Option<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub setup_revision: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub token_env_var: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
52 pub name: Option<String>,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub display_name: Option<String>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61pub struct OperatorConfig {
62 #[serde(skip_serializing_if = "Option::is_none")]
65 pub manager_url: Option<String>,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub token: Option<String>,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub deployment_id: Option<String>,
72 #[serde(default = "default_sync_interval")]
74 pub sync_interval_secs: u64,
75 #[serde(skip_serializing_if = "Option::is_none")]
78 pub name: Option<String>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub brand: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub display_name: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub env_prefix: Option<String>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub label_domain: Option<String>,
91}
92
93fn default_sync_interval() -> u64 {
94 30
95}
96
97pub 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
108pub 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 let magic_start = data.len() - MAGIC_BYTES.len();
120 if &data[magic_start..] != MAGIC_BYTES {
121 return Ok(None);
122 }
123
124 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
146pub 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#[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 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}