1use std::time::Duration;
2
3use base64::{engine::general_purpose, Engine as _};
4use chrono::{DateTime, Utc};
5use serde::{de::DeserializeOwned, Deserialize, Serialize};
6use tracing::debug;
7
8use crate::error::CommandError;
9
10pub struct CommandsClientConfig {
12 pub timeout: Duration,
14 pub poll_interval: Duration,
16 pub max_poll_interval: Duration,
18 pub poll_backoff: f64,
20 pub allow_local_storage: bool,
22}
23
24impl Default for CommandsClientConfig {
25 fn default() -> Self {
26 Self {
27 timeout: Duration::from_secs(60),
28 poll_interval: Duration::from_millis(500),
29 max_poll_interval: Duration::from_secs(5),
30 poll_backoff: 1.5,
31 allow_local_storage: false,
32 }
33 }
34}
35
36pub struct InvokeOptions {
38 pub timeout: Option<Duration>,
40 pub deadline: Option<DateTime<Utc>>,
42 pub idempotency_key: Option<String>,
44 pub target_resource_id: Option<String>,
49}
50
51pub struct CommandsClient {
53 manager_url: String,
54 deployment_id: String,
55 http_client: reqwest::Client,
56 config: CommandsClientConfig,
57}
58
59#[derive(Deserialize)]
62#[serde(rename_all = "camelCase")]
63struct CreateCommandResponse {
64 command_id: String,
65}
66
67#[derive(Deserialize)]
68#[serde(rename_all = "camelCase")]
69struct CommandStatusResponse {
70 state: String,
71 #[serde(default)]
72 response: Option<CommandResponseBody>,
73 #[serde(default)]
76 #[allow(dead_code)]
77 target: Option<alien_core::CommandTarget>,
78}
79
80#[derive(Deserialize)]
81#[serde(rename_all = "camelCase")]
82struct CommandResponseBody {
83 #[serde(default)]
84 response: Option<BodySpecResponse>,
85 #[serde(default)]
86 code: Option<String>,
87 #[serde(default)]
88 message: Option<String>,
89}
90
91#[derive(Deserialize)]
92#[serde(rename_all = "camelCase")]
93struct BodySpecResponse {
94 mode: String,
95 #[serde(default)]
96 inline_base64: Option<String>,
97 #[serde(default)]
98 storage_get_request: Option<StorageGetRequest>,
99}
100
101#[derive(Deserialize)]
102#[serde(rename_all = "camelCase")]
103struct StorageGetRequest {
104 backend: StorageBackend,
105}
106
107#[derive(Deserialize)]
108#[serde(rename_all = "camelCase")]
109struct StorageBackend {
110 #[serde(rename = "type")]
111 backend_type: String,
112 #[serde(default)]
113 url: Option<String>,
114 #[serde(default)]
115 method: Option<String>,
116 #[serde(default)]
117 headers: Option<std::collections::HashMap<String, String>>,
118 #[serde(default, rename = "filePath")]
119 file_path: Option<String>,
120}
121
122impl CommandsClient {
123 pub fn new(manager_url: &str, deployment_id: &str, token: &str) -> Self {
125 Self::with_config(
126 manager_url,
127 deployment_id,
128 token,
129 CommandsClientConfig::default(),
130 )
131 }
132
133 pub fn with_config(
135 manager_url: &str,
136 deployment_id: &str,
137 token: &str,
138 config: CommandsClientConfig,
139 ) -> Self {
140 let mut headers = reqwest::header::HeaderMap::new();
141 headers.insert(
142 reqwest::header::AUTHORIZATION,
143 reqwest::header::HeaderValue::from_str(&format!("Bearer {}", token))
144 .expect("invalid token"),
145 );
146
147 let http_client = reqwest::Client::builder()
148 .default_headers(headers)
149 .build()
150 .expect("failed to build HTTP client");
151
152 Self {
153 manager_url: manager_url.trim_end_matches('/').to_string(),
154 deployment_id: deployment_id.to_string(),
155 http_client,
156 config,
157 }
158 }
159
160 pub fn with_http_client(
165 manager_url: &str,
166 deployment_id: &str,
167 http_client: reqwest::Client,
168 config: CommandsClientConfig,
169 ) -> Self {
170 Self {
171 manager_url: manager_url.trim_end_matches('/').to_string(),
172 deployment_id: deployment_id.to_string(),
173 http_client,
174 config,
175 }
176 }
177
178 pub async fn invoke<P: Serialize, R: DeserializeOwned>(
182 &self,
183 command: &str,
184 params: P,
185 ) -> Result<R, CommandError> {
186 self.invoke_with_options(command, params, None).await
187 }
188
189 pub async fn invoke_with_options<P: Serialize, R: DeserializeOwned>(
191 &self,
192 command: &str,
193 params: P,
194 options: Option<InvokeOptions>,
195 ) -> Result<R, CommandError> {
196 let timeout = options
197 .as_ref()
198 .and_then(|o| o.timeout)
199 .unwrap_or(self.config.timeout);
200
201 let command_id = self.create(command, params, options.as_ref()).await?;
203
204 debug!(command_id = %command_id, command = %command, "Command created, polling for result");
205
206 let start = tokio::time::Instant::now();
208 let mut interval = self.config.poll_interval;
209
210 loop {
211 if start.elapsed() > timeout {
212 return Err(CommandError::Timeout {
213 command_id,
214 last_state: "polling".to_string(),
215 });
216 }
217
218 tokio::time::sleep(interval).await;
219
220 let status = self.get_status(&command_id).await?;
221
222 match status.state.as_str() {
223 "SUCCEEDED" => {
224 return self.decode_response(&command_id, status.response).await;
225 }
226 "FAILED" => {
227 let (code, message) = status
228 .response
229 .as_ref()
230 .map(|r| {
231 (
232 r.code.clone().unwrap_or_default(),
233 r.message.clone().unwrap_or_default(),
234 )
235 })
236 .unwrap_or_default();
237 return Err(CommandError::DeploymentError {
238 command_id,
239 code,
240 message,
241 });
242 }
243 "EXPIRED" => {
244 return Err(CommandError::Expired { command_id });
245 }
246 _ => {
247 interval = Duration::from_secs_f64(
249 (interval.as_secs_f64() * self.config.poll_backoff)
250 .min(self.config.max_poll_interval.as_secs_f64()),
251 );
252 }
253 }
254 }
255 }
256
257 pub fn target(&self, resource_id: impl Into<String>) -> TargetedCommands<'_> {
267 TargetedCommands {
268 client: self,
269 resource_id: resource_id.into(),
270 }
271 }
272
273 pub async fn create<P: Serialize>(
275 &self,
276 command: &str,
277 params: P,
278 options: Option<&InvokeOptions>,
279 ) -> Result<String, CommandError> {
280 let params_json = serde_json::to_vec(¶ms)?;
281 let params_base64 = general_purpose::STANDARD.encode(¶ms_json);
282
283 let body = self.build_create_body(command, ¶ms_base64, options);
284
285 let url = format!("{}/commands", self.manager_url);
286 let resp = self.http_client.post(&url).json(&body).send().await?;
287
288 if !resp.status().is_success() {
289 let status = resp.status().as_u16();
290 let body = resp.text().await.unwrap_or_default();
291 return Err(CommandError::CreationFailed { status, body });
292 }
293
294 let result: CreateCommandResponse = resp.json().await?;
295 Ok(result.command_id)
296 }
297
298 async fn get_status(&self, command_id: &str) -> Result<CommandStatusResponse, CommandError> {
300 let url = format!("{}/commands/{}", self.manager_url, command_id);
301 let resp = self.http_client.get(&url).send().await?;
302
303 if !resp.status().is_success() {
304 let status = resp.status().as_u16();
305 let body = resp.text().await.unwrap_or_default();
306 return Err(CommandError::CreationFailed { status, body });
307 }
308
309 Ok(resp.json().await?)
310 }
311
312 async fn decode_response<R: DeserializeOwned>(
315 &self,
316 command_id: &str,
317 response: Option<CommandResponseBody>,
318 ) -> Result<R, CommandError> {
319 let resp = response.ok_or_else(|| CommandError::ResponseDecodingFailed {
320 command_id: command_id.to_string(),
321 reason: "No response body in SUCCEEDED status".to_string(),
322 })?;
323
324 let body = resp
325 .response
326 .ok_or_else(|| CommandError::ResponseDecodingFailed {
327 command_id: command_id.to_string(),
328 reason: "No response field in success response".to_string(),
329 })?;
330
331 let bytes = match body.mode.as_str() {
332 "inline" => {
333 let base64_data =
334 body.inline_base64
335 .ok_or_else(|| CommandError::ResponseDecodingFailed {
336 command_id: command_id.to_string(),
337 reason: "Inline response missing inlineBase64 field".to_string(),
338 })?;
339
340 general_purpose::STANDARD
341 .decode(&base64_data)
342 .map_err(|e| CommandError::ResponseDecodingFailed {
343 command_id: command_id.to_string(),
344 reason: format!("Base64 decode failed: {}", e),
345 })?
346 }
347 "storage" => {
348 let get_request = body.storage_get_request.ok_or_else(|| {
349 CommandError::ResponseDecodingFailed {
350 command_id: command_id.to_string(),
351 reason: "Storage response missing storageGetRequest".to_string(),
352 }
353 })?;
354
355 self.download_from_storage(&get_request).await?
356 }
357 other => {
358 return Err(CommandError::ResponseDecodingFailed {
359 command_id: command_id.to_string(),
360 reason: format!("Unknown response mode: {}", other),
361 })
362 }
363 };
364
365 serde_json::from_slice(&bytes).map_err(|e| CommandError::ResponseDecodingFailed {
366 command_id: command_id.to_string(),
367 reason: format!("JSON decode failed: {}", e),
368 })
369 }
370
371 async fn download_from_storage(
372 &self,
373 get_request: &StorageGetRequest,
374 ) -> Result<Vec<u8>, CommandError> {
375 match get_request.backend.backend_type.as_str() {
376 "http" => {
377 let url = get_request.backend.url.as_deref().ok_or_else(|| {
378 CommandError::StorageOperationFailed {
379 reason: "HTTP storage backend missing url".to_string(),
380 }
381 })?;
382
383 let method = get_request.backend.method.as_deref().unwrap_or("GET");
384
385 let plain_http = reqwest::Client::new();
387 let mut req = match method {
388 "PUT" => plain_http.put(url),
389 "POST" => plain_http.post(url),
390 _ => plain_http.get(url),
391 };
392
393 if let Some(headers) = &get_request.backend.headers {
394 for (k, v) in headers {
395 req = req.header(k.as_str(), v.as_str());
396 }
397 }
398
399 let resp = req
400 .send()
401 .await
402 .map_err(|e| CommandError::StorageOperationFailed {
403 reason: format!("Storage download failed: {}", e.without_url()),
404 })?;
405
406 if !resp.status().is_success() {
407 return Err(CommandError::StorageOperationFailed {
408 reason: format!("Storage download returned HTTP {}", resp.status()),
409 });
410 }
411
412 resp.bytes().await.map(|b| b.to_vec()).map_err(|e| {
413 CommandError::StorageOperationFailed {
414 reason: format!(
415 "Failed to read storage response bytes: {}",
416 e.without_url()
417 ),
418 }
419 })
420 }
421 "local" if self.config.allow_local_storage => {
422 let file_path = get_request.backend.file_path.as_deref().ok_or_else(|| {
423 CommandError::StorageOperationFailed {
424 reason: "Local storage backend missing filePath".to_string(),
425 }
426 })?;
427
428 let path = std::path::Path::new(file_path);
429 if path.is_absolute() || file_path.contains("..") {
430 return Err(CommandError::StorageOperationFailed {
431 reason: "Local storage path traversal detected".to_string(),
432 });
433 }
434
435 tokio::fs::read(file_path)
436 .await
437 .map_err(|e| CommandError::StorageOperationFailed {
438 reason: format!("Failed to read local file {}: {}", file_path, e),
439 })
440 }
441 "local" => Err(CommandError::StorageOperationFailed {
442 reason: "Local storage backend not allowed (set allow_local_storage: true)"
443 .to_string(),
444 }),
445 other => Err(CommandError::StorageOperationFailed {
446 reason: format!("Unknown storage backend type: {}", other),
447 }),
448 }
449 }
450
451 fn build_create_body(
455 &self,
456 command: &str,
457 params_base64: &str,
458 options: Option<&InvokeOptions>,
459 ) -> serde_json::Value {
460 let mut body = serde_json::json!({
461 "deploymentId": self.deployment_id,
462 "command": command,
463 "params": {
464 "mode": "inline",
465 "inlineBase64": params_base64,
466 },
467 });
468
469 if let Some(opts) = options {
470 if let Some(deadline) = opts.deadline {
471 body["deadline"] = serde_json::Value::String(deadline.to_rfc3339());
472 }
473 if let Some(ref key) = opts.idempotency_key {
474 body["idempotencyKey"] = serde_json::Value::String(key.clone());
475 }
476 if let Some(ref target) = opts.target_resource_id {
477 body["targetResourceId"] = serde_json::Value::String(target.clone());
478 }
479 }
480
481 body
482 }
483}
484
485pub struct TargetedCommands<'a> {
491 client: &'a CommandsClient,
492 resource_id: String,
493}
494
495impl TargetedCommands<'_> {
496 pub async fn invoke<P: Serialize, R: DeserializeOwned>(
498 &self,
499 command: &str,
500 params: P,
501 ) -> Result<R, CommandError> {
502 self.invoke_with_options(command, params, None).await
503 }
504
505 pub async fn invoke_with_options<P: Serialize, R: DeserializeOwned>(
508 &self,
509 command: &str,
510 params: P,
511 options: Option<InvokeOptions>,
512 ) -> Result<R, CommandError> {
513 self.client
514 .invoke_with_options(command, params, Some(self.preset(options)))
515 .await
516 }
517
518 pub async fn create<P: Serialize>(
521 &self,
522 command: &str,
523 params: P,
524 options: Option<InvokeOptions>,
525 ) -> Result<String, CommandError> {
526 let options = self.preset(options);
527 self.client.create(command, params, Some(&options)).await
528 }
529
530 fn preset(&self, options: Option<InvokeOptions>) -> InvokeOptions {
533 let mut options = options.unwrap_or(InvokeOptions {
534 timeout: None,
535 deadline: None,
536 idempotency_key: None,
537 target_resource_id: None,
538 });
539 options.target_resource_id = Some(self.resource_id.clone());
540 options
541 }
542}
543
544#[cfg(test)]
545mod target_tests {
546 use super::*;
551
552 #[test]
556 fn invoke_options_carries_target_resource_id() {
557 let options = InvokeOptions {
558 timeout: None,
559 deadline: None,
560 idempotency_key: None,
561 target_resource_id: Some("worker-7".to_string()),
562 };
563 assert_eq!(options.target_resource_id.as_deref(), Some("worker-7"));
564 }
565
566 #[test]
571 fn command_status_response_deserializes_target() {
572 let json = serde_json::json!({
573 "state": "SUCCEEDED",
574 "target": {
575 "resourceId": "worker-7",
576 "resourceType": "worker",
577 },
578 });
579
580 let status: CommandStatusResponse =
581 serde_json::from_value(json).expect("status JSON with target should deserialize");
582
583 assert_eq!(status.state, "SUCCEEDED");
584 let target = status.target.expect("target field should be present");
585 assert_eq!(target.resource_id, "worker-7");
586 assert_eq!(target.resource_type, alien_core::CommandTargetType::Worker);
587 }
588
589 #[test]
592 fn target_builder_presets_target_resource_id() {
593 let client = CommandsClient::new("http://localhost:9090", "dep_123", "token");
594 let targeted = client.target("worker-9");
595
596 let options = targeted.preset(None);
597
598 assert_eq!(options.target_resource_id.as_deref(), Some("worker-9"));
599 }
600
601 #[test]
606 fn target_builder_overrides_conflicting_explicit_target() {
607 let client = CommandsClient::new("http://localhost:9090", "dep_123", "token");
608 let targeted = client.target("worker-9");
609
610 let options = targeted.preset(Some(InvokeOptions {
611 timeout: None,
612 deadline: None,
613 idempotency_key: None,
614 target_resource_id: Some("worker-other".to_string()),
615 }));
616
617 assert_eq!(options.target_resource_id.as_deref(), Some("worker-9"));
618 }
619
620 #[test]
623 fn target_builder_presets_field_in_create_request_body() {
624 let client = CommandsClient::new("http://localhost:9090", "dep_123", "token");
625 let targeted = client.target("worker-9");
626 let options = targeted.preset(None);
627
628 let body = client.build_create_body("generate-report", "e30=", Some(&options));
629
630 assert_eq!(body["targetResourceId"], "worker-9");
631 }
632
633 #[tokio::test]
634 async fn storage_transport_error_does_not_expose_presigned_url_token() {
635 let secret = "do-not-log-response-token";
636 let client = CommandsClient::new("http://localhost:9090", "dep_123", "token");
637 let request = StorageGetRequest {
638 backend: StorageBackend {
639 backend_type: "http".to_string(),
640 url: Some(format!(
641 "http://127.0.0.1:0/blob?response_token={secret}&expires=1"
642 )),
643 method: Some("GET".to_string()),
644 headers: None,
645 file_path: None,
646 },
647 };
648
649 let error = client
650 .download_from_storage(&request)
651 .await
652 .expect_err("port zero must reject the storage download");
653 let display = error.to_string();
654 let debug = format!("{error:?}");
655
656 assert!(!display.contains(secret), "display error leaked token");
657 assert!(!debug.contains(secret), "debug error leaked token");
658 }
659}