Skip to main content

alien_commands_client/
client.rs

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
10/// Configuration for the commands client.
11pub struct CommandsClientConfig {
12    /// Command timeout (default: 60s)
13    pub timeout: Duration,
14    /// Polling interval (default: 500ms)
15    pub poll_interval: Duration,
16    /// Max polling interval (default: 5s)
17    pub max_poll_interval: Duration,
18    /// Backoff multiplier (default: 1.5)
19    pub poll_backoff: f64,
20    /// Allow local file:// storage backends (dev only)
21    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
36/// Options for a single invoke call.
37pub struct InvokeOptions {
38    /// Override the default timeout for this invocation.
39    pub timeout: Option<Duration>,
40    /// Set a deadline for the command (server-side expiry).
41    pub deadline: Option<DateTime<Utc>>,
42    /// Idempotency key to prevent duplicate commands.
43    pub idempotency_key: Option<String>,
44    /// Explicit target resource id within the deployment's stack. When `None`
45    /// the server resolves the target via single-target shorthand (exactly one
46    /// command-capable resource must exist). Set this when a deployment has
47    /// more than one command-capable resource.
48    pub target_resource_id: Option<String>,
49}
50
51/// High-level client for invoking commands on Alien deployments.
52pub struct CommandsClient {
53    manager_url: String,
54    deployment_id: String,
55    http_client: reqwest::Client,
56    config: CommandsClientConfig,
57}
58
59// -- API response types (internal) --
60
61#[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    /// The resolved target this command was addressed to. Carried
74    /// on the wire for observability; the polling logic keys off `state` only.
75    #[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    /// Create a new commands client with default config.
124    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    /// Create a new commands client with custom config.
134    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    /// Build a client over a caller-supplied HTTP client, reusing the headers
161    /// it already carries (the auth header, and the workspace header used in
162    /// platform mode). `with_config` builds a token-only client and can't add
163    /// those.
164    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    /// Invoke a command and wait for the result.
179    ///
180    /// Sends params inline, polls for completion, and decodes the response.
181    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    /// Invoke a command with options and wait for the result.
190    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        // Step 1: Create the command (always inline — server handles storage)
202        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        // Step 2: Poll for completion with exponential backoff
207        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                    // Still in progress — backoff
248                    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    /// Scope this client to one target command-capable resource: every
258    /// `invoke`/`invoke_with_options`/`create` call made through the
259    /// returned builder presets `target_resource_id` to `resource_id`,
260    /// mirroring the TypeScript `.target(name).invoke(...)` shorthand.
261    ///
262    /// If the caller also passes an [`InvokeOptions`] with its own
263    /// `target_resource_id` set, the builder's target silently wins —
264    /// passing two different targets is a programmer error, not a runtime
265    /// conflict this builder tries to detect.
266    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    /// Create a command without waiting for the result. Returns the command ID.
274    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(&params)?;
281        let params_base64 = general_purpose::STANDARD.encode(&params_json);
282
283        let body = self.build_create_body(command, &params_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    /// Poll for a command's status.
299    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    // -- Internal helpers --
313
314    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                // Use a plain client (no auth headers — presigned URL carries auth)
386                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    /// Build the JSON body `create` sends. Pure (no I/O) so the body shape —
452    /// including a builder-preset `targetResourceId` — is directly
453    /// unit-testable.
454    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
485/// A [`CommandsClient`] scoped to one target command-capable resource.
486///
487/// Obtained via [`CommandsClient::target`]; borrows the client rather than
488/// cloning it (the client is not `Clone` and doesn't need to be for this —
489/// the builder is just a thin wrapper that presets one field).
490pub struct TargetedCommands<'a> {
491    client: &'a CommandsClient,
492    resource_id: String,
493}
494
495impl TargetedCommands<'_> {
496    /// Invoke a command against this builder's target and wait for the result.
497    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    /// Invoke a command against this builder's target with options, and wait
506    /// for the result.
507    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    /// Create a command against this builder's target without waiting for
519    /// the result. Returns the command ID.
520    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    /// Preset `target_resource_id` to this builder's resource id, overwriting
531    /// any value already set on `options` (see [`CommandsClient::target`]).
532    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    //! Proves the client-side surface for command targets compiles
547    //! and round-trips over the wire — not the server's routing behavior
548    //! (that's covered by alien-commands' integration tests).
549
550    use super::*;
551
552    /// `InvokeOptions` accepts a `target_resource_id`, which `create()` sends
553    /// as `targetResourceId` in the request body (see the `create` body
554    /// construction above).
555    #[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    /// The client's internal status response type deserializes a status JSON
567    /// payload that carries a resolved `target`, proving the
568    /// generated/hand-written client type is
569    /// wire-compatible with the server's `CommandStatusResponse`.
570    #[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    /// `CommandsClient::target(...)` presets `target_resource_id`
590    /// on an otherwise-empty options value.
591    #[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    /// The builder's target wins over an explicit
602    /// `target_resource_id` the caller already set on `InvokeOptions` — a
603    /// conflict here is a programmer error, not something this builder
604    /// tries to reconcile at runtime (see `CommandsClient::target` docs).
605    #[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    /// The target builder's preset ends up in the actual JSON
621    /// body `create()` sends, as `targetResourceId`.
622    #[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}