Skip to main content

solari/
client.rs

1//! `Client` (REST create/connect/get/kill) and the `Sandbox` handle exposing
2//! `.commands()`, `.files()`, `.code()`, and `.git()`. Mirrors
3//! `sandbox-client.ts` (REST shapes) + `SessionHandle` (namespaces).
4
5use std::collections::HashMap;
6use std::future::Future;
7use std::pin::Pin;
8use std::sync::Arc;
9
10use crate::channel::ControlChannel;
11use crate::code::Code;
12use crate::commands::{run_command, Commands, RunOptions};
13use crate::error::SolariError;
14use crate::files::Files;
15use crate::git::{Git, GitRunner};
16use crate::http::{encode_uri_component, new_idempotency_key, HttpOptions, HttpTransport};
17use crate::types::{
18    CommandResult, CreateSandboxRequest, CreateSandboxResponse, Lifecycle, ResumeResponse, StartResponse,
19    SandboxView, VolumeAttachment,
20};
21
22/// The hosted Solari API, used when [`ClientOptions::base_url`] is empty.
23pub const DEFAULT_BASE_URL: &str = "https://api.getsolari.com";
24
25/// Options for constructing a [`Client`].
26#[derive(Debug, Clone)]
27pub struct ClientOptions {
28    pub api_key: String,
29    /// Gateway origin. Empty means [`DEFAULT_BASE_URL`].
30    pub base_url: String,
31    /// Per-call control-WS RPC timeout override (ms). Defaults to 300000.
32    pub call_timeout_ms: Option<u64>,
33}
34
35impl ClientOptions {
36    /// Options for the hosted Solari API ([`DEFAULT_BASE_URL`]).
37    pub fn with_api_key(api_key: impl Into<String>) -> Self {
38        Self::new(api_key, DEFAULT_BASE_URL)
39    }
40
41    pub fn new(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
42        ClientOptions {
43            api_key: api_key.into(),
44            base_url: base_url.into(),
45            call_timeout_ms: None,
46        }
47    }
48}
49
50/// Options accepted by `POST /sandboxes` (headless sandbox flavour).
51#[derive(Default, Clone)]
52pub struct CreateOptions {
53    pub template: Option<String>,
54    pub cpu: Option<u32>,
55    pub mem_mb: Option<u32>,
56    pub disk_gb: Option<u32>,
57    pub envs: Option<HashMap<String, String>>,
58    pub metadata: Option<HashMap<String, String>>,
59    pub timeout_ms: Option<u64>,
60    /// Legacy, gateway-side FALLBACK idle window in seconds, only used when
61    /// `timeout_ms` is unset — prefer `timeout_ms`.
62    pub ttl_seconds: Option<u64>,
63    pub from_snapshot: Option<String>,
64    /// VM isolation tier: `"shared"` (sandbox-only), `"dedicated"` (default)
65    /// or `"hardened"` (own VM, no outbound network). See `types::CreateSandboxRequest::isolation`.
66    pub isolation: Option<String>,
67    pub lifecycle: Option<Lifecycle>,
68    /// Initial display resolution, e.g. `"1280x720"`. Desktops only — a headless
69    /// sandbox has no display.
70    pub resolution: Option<String>,
71    /// Record the session server-side; the create response carries a presigned
72    /// playback URL. Desktops only.
73    pub record: Option<bool>,
74    /// Persistent volumes to mount before the session starts.
75    pub volumes: Option<Vec<VolumeAttachment>>,
76}
77
78impl CreateOptions {
79    /// Build the `POST /sandboxes` body for a given VM flavour.
80    ///
81    /// THE SINGLE PLACE an option becomes a wire field, and therefore the place
82    /// the cross-language wire contract is pinned (see `tests/contract_wire.rs`).
83    /// A field added above but not mapped here is silently invisible on the wire.
84    pub(crate) fn into_request(self, kind: &str) -> CreateSandboxRequest {
85        CreateSandboxRequest {
86            template: self.template,
87            kind: Some(kind.to_string()),
88            cpu: self.cpu,
89            mem_mb: self.mem_mb,
90            disk_gb: self.disk_gb,
91            envs: self.envs,
92            metadata: self.metadata,
93            timeout_ms: self.timeout_ms,
94            ttl_seconds: self.ttl_seconds,
95            from_snapshot: self.from_snapshot,
96            isolation: self.isolation,
97            lifecycle: self.lifecycle,
98            resolution: self.resolution,
99            record: self.record,
100            volumes: self.volumes,
101        }
102    }
103}
104
105impl From<CreateOptions> for CreateSandboxRequest {
106    fn from(o: CreateOptions) -> Self {
107        o.into_request("sandbox")
108    }
109}
110
111/// Talks the SDK ⇆ Gateway `/sandboxes` REST API and hands back [`Sandbox`]es.
112pub struct Client {
113    http: Arc<HttpTransport>,
114    call_timeout_ms: Option<u64>,
115}
116
117impl Client {
118    pub fn new(opts: ClientOptions) -> Result<Self, SolariError> {
119        let base_url = if opts.base_url.is_empty() { DEFAULT_BASE_URL.to_string() } else { opts.base_url };
120        let http = HttpTransport::new(HttpOptions::new(opts.api_key, base_url))?;
121        Ok(Client {
122            http: Arc::new(http),
123            call_timeout_ms: opts.call_timeout_ms,
124        })
125    }
126
127    /// Create a new headless sandbox (`POST /sandboxes`).
128    pub async fn create(&self, opts: CreateOptions) -> Result<Sandbox, SolariError> {
129        self.create_with_kind(opts, "sandbox").await
130    }
131
132    /// Create a GUI desktop VM (`POST /sandboxes` with `kind:"desktop"`).
133    ///
134    /// Mirrors TS `createDesktop()` / Python `create_desktop()`. The returned
135    /// handle carries [`Sandbox::stream_url`] for the live view, and exposes the
136    /// same core namespaces as a sandbox (`commands`/`files`/`code`/`git`).
137    /// Driving the GUI (mouse/keyboard/screenshot) is not part of this surface.
138    ///
139    /// Requires the `desktop` entitlement on the org's plan; the gateway
140    /// answers 402 `FeatureRequiresPlan` otherwise.
141    pub async fn create_desktop(&self, opts: CreateOptions) -> Result<Sandbox, SolariError> {
142        self.create_with_kind(opts, "desktop").await
143    }
144
145    async fn create_with_kind(
146        &self,
147        opts: CreateOptions,
148        kind: &str,
149    ) -> Result<Sandbox, SolariError> {
150        let req = opts.into_request(kind);
151        let body = serde_json::to_value(&req).unwrap();
152        let resp: CreateSandboxResponse = self
153            .http
154            .request("POST", "/sandboxes", Some(body), Some(new_idempotency_key()))
155            .await?;
156        Sandbox::from_response(self.http.clone(), resp, self.call_timeout_ms)
157    }
158
159    /// Re-attach to a running sandbox by id (`GET /sandboxes/:id`).
160    pub async fn connect(&self, sandbox_id: &str) -> Result<Sandbox, SolariError> {
161        // Guard B: the caller supplied this id, so a blank one is their input to
162        // fix -> Validation (distinct from the response-side Other above).
163        if sandbox_id.trim().is_empty() {
164            return Err(SolariError::Validation {
165                message: "Solari: connect() requires a non-blank sandbox id.".to_string(),
166            });
167        }
168        let view = self.get(sandbox_id).await?;
169        let control_url = view.control_url.clone().unwrap_or_else(|| {
170            format!(
171                "{}/control/{}",
172                self.http.ws_origin(),
173                encode_uri_component(sandbox_id)
174            )
175        });
176        // `GET /sandboxes/:id` carries no streamUrl (the gateway's view
177        // serializer omits it), so derive it exactly as control_url is derived
178        // above — both are built from the same origin. Desktops only: a
179        // headless sandbox has no display to stream.
180        let stream_url = if view.kind == "desktop" {
181            Some(format!(
182                "{}/stream/{}",
183                self.http.ws_origin(),
184                encode_uri_component(sandbox_id)
185            ))
186        } else {
187            None
188        };
189        let resp = CreateSandboxResponse {
190            sandbox_id: view.sandbox_id,
191            kind: view.kind,
192            control_url,
193            expires_at: view.expires_at,
194            stream_url,
195            recording_url: None,
196        };
197        Sandbox::from_response(self.http.clone(), resp, self.call_timeout_ms)
198    }
199
200    /// Fetch a sandbox's current view (`GET /sandboxes/:id`).
201    pub async fn get(&self, sandbox_id: &str) -> Result<SandboxView, SolariError> {
202        self.http
203            .request(
204                "GET",
205                &format!("/sandboxes/{}", encode_uri_component(sandbox_id)),
206                None,
207                None,
208            )
209            .await
210    }
211
212    /// Destroy a sandbox (`DELETE /sandboxes/:id`). Idempotent.
213    pub async fn kill(&self, sandbox_id: &str) -> Result<(), SolariError> {
214        let _: serde_json::Value = self
215            .http
216            .request(
217                "DELETE",
218                &format!("/sandboxes/{}", encode_uri_component(sandbox_id)),
219                None,
220                None,
221            )
222            .await?;
223        Ok(())
224    }
225
226    /// Pause a session (`POST /sandboxes/:id/pause`): snapshot its RAM+disk and
227    /// free its host slot. The session keeps its id; bring it back with
228    /// [`Client::resume`].
229    pub async fn pause(&self, sandbox_id: &str) -> Result<(), SolariError> {
230        let _: serde_json::Value = self
231            .http
232            .request(
233                "POST",
234                &format!("/sandboxes/{}/pause", encode_uri_component(sandbox_id)),
235                None,
236                None,
237            )
238            .await?;
239        Ok(())
240    }
241
242    /// Resume a paused session (`POST /sandboxes/:id/resume`) and return the
243    /// control URL to re-attach to.
244    ///
245    /// The session comes back on a FRESH slot, so its pre-pause control URL is
246    /// stale. The gateway normally returns the new one; when it does not, derive
247    /// it from the gateway origin exactly as [`Client::connect`] does.
248    pub async fn resume(&self, sandbox_id: &str) -> Result<String, SolariError> {
249        let r: ResumeResponse = self
250            .http
251            .request(
252                "POST",
253                &format!("/sandboxes/{}/resume", encode_uri_component(sandbox_id)),
254                None,
255                None,
256            )
257            .await?;
258        Ok(r.control_url.unwrap_or_else(|| {
259            format!(
260                "{}/control/{}",
261                self.http.ws_origin(),
262                encode_uri_component(sandbox_id)
263            )
264        }))
265    }
266
267    /// Cold-boot a STOPPED sandbox (`POST /sandboxes/:id/start`) from the disk the
268    /// host kept, and return the fresh control URL to re-attach to.
269    ///
270    /// A sandbox is stopped when its guest powered itself off. This is a cold
271    /// boot, not a resume: running processes and memory are gone, files on disk
272    /// are kept. It takes a concurrency slot (429 at the cap) and opens a new
273    /// billing segment. Idempotent: an already-running sandbox answers 200 and its
274    /// current control URL.
275    ///
276    /// Any other state is refused with `SolariError::Gateway { status: 409, code }`
277    /// where `code` is `NotStopped` (use [`Client::resume`] for a paused one) or
278    /// `StartInProgress`. A stopped disk that cannot be started answers 409 with
279    /// its code (`StoppedDiskMissing`, `StoppedDiskUnsafe`, `GoldenMismatch`,
280    /// `DiskPlatformMismatch`, `DiskShaMismatch`, `DiskExceedsSpec`,
281    /// `DiskOwnerMismatch`). 409 `HostCapabilityMissing` means the host pool is not
282    /// armed for start (not retryable). 503 `NoCapacity` / `StartTimeout` is
283    /// `SolariError::NoCapacity` and 502 `StartHostError` is a gateway error, both
284    /// retryable; 500 `StartFailed` is not, and leaves the sandbox stopped with its
285    /// disk, so a later start is safe.
286    pub async fn start(&self, sandbox_id: &str) -> Result<String, SolariError> {
287        let r: StartResponse = self
288            .http
289            .request(
290                "POST",
291                &format!("/sandboxes/{}/start", encode_uri_component(sandbox_id)),
292                None,
293                None,
294            )
295            .await?;
296        Ok(r.control_url.unwrap_or_else(|| {
297            format!(
298                "{}/control/{}",
299                self.http.ws_origin(),
300                encode_uri_component(sandbox_id)
301            )
302        }))
303    }
304}
305
306/// A live sandbox handle. Exposes the core namespaces.
307pub struct Sandbox {
308    id: String,
309    kind: String,
310    control_url: String,
311    stream_url: Option<String>,
312    recording_url: Option<String>,
313    expires_at: String,
314    http: Arc<HttpTransport>,
315    channel: Arc<ControlChannel>,
316}
317
318impl Sandbox {
319    pub(crate) fn from_response(
320        http: Arc<HttpTransport>,
321        resp: CreateSandboxResponse,
322        call_timeout_ms: Option<u64>,
323    ) -> Result<Self, SolariError> {
324        // A blank id in the response builds a nearly-right "/sandboxes//exec"
325        // (double slash), not an obviously wrong path, so it slips through while
326        // every call the handle makes is malformed. A blank id in a RESPONSE is
327        // a bad reply from our side, not caller input -> Other. (Test blank.)
328        if resp.sandbox_id.trim().is_empty() {
329            return Err(SolariError::Other(
330                "Solari: the session response carried a blank id - a blank id builds a malformed /sandboxes// request"
331                    .to_string(),
332            ));
333        }
334        let channel = ControlChannel::new(
335            resp.control_url.clone(),
336            http.auth_headers(),
337            call_timeout_ms,
338        );
339        Ok(Sandbox {
340            id: resp.sandbox_id,
341            kind: resp.kind,
342            control_url: resp.control_url,
343            stream_url: resp.stream_url,
344            recording_url: resp.recording_url,
345            expires_at: resp.expires_at,
346            http,
347            channel: Arc::new(channel),
348        })
349    }
350
351    /// The session id.
352    pub fn id(&self) -> &str {
353        &self.id
354    }
355    /// The VM flavour — `"sandbox"` or `"desktop"`.
356    pub fn kind(&self) -> &str {
357        &self.kind
358    }
359    /// The control-WS URL.
360    pub fn control_url(&self) -> &str {
361        &self.control_url
362    }
363    /// The live-view stream URL. `Some` for desktops only — a headless sandbox
364    /// has no display, so the gateway omits it.
365    pub fn stream_url(&self) -> Option<&str> {
366        self.stream_url.as_deref()
367    }
368    /// The session recording link, when the create asked for a recording.
369    pub fn recording_url(&self) -> Option<&str> {
370        self.recording_url.as_deref()
371    }
372    /// ISO-8601 expiry timestamp.
373    pub fn expires_at(&self) -> &str {
374        &self.expires_at
375    }
376    /// Whether the control channel is open.
377    pub fn connected(&self) -> bool {
378        self.channel.connected()
379    }
380
381    /// Open the control WebSocket. Idempotent.
382    pub async fn connect(&self) -> Result<(), SolariError> {
383        self.channel.connect().await
384    }
385
386    /// Close the control channel locally (does NOT release the remote session).
387    pub fn close(&self) {
388        self.channel.close();
389    }
390
391    /// Pause this session: snapshot its RAM+disk, free its host slot, and close
392    /// the control channel. The session keeps its id; bring it back with
393    /// [`Sandbox::resume`].
394    ///
395    /// Mirrors TS `handle.pause()` / Python `handle.pause()`: remote call first,
396    /// then the local channel close.
397    pub async fn pause(&self) -> Result<(), SolariError> {
398        let _: serde_json::Value = self
399            .http
400            .request(
401                "POST",
402                &format!("/sandboxes/{}/pause", encode_uri_component(&self.id)),
403                None,
404                None,
405            )
406            .await?;
407        self.close();
408        Ok(())
409    }
410
411    /// Resume this paused session and re-point the control channel at the fresh
412    /// slot it came back on.
413    ///
414    /// Mirrors TS `handle.resume()` / Python `handle.resume()`: resume, adopt the
415    /// new control URL, reconnect.
416    ///
417    /// Takes `&mut self` where the rest of the handle takes `&self`: resuming
418    /// genuinely replaces this handle's `control_url`, and [`Sandbox::control_url`]
419    /// hands out a borrow of it. Interior mutability would let that borrow go
420    /// stale behind the caller's back — the compiler should be the one saying
421    /// this call mutates the handle.
422    pub async fn resume(&mut self) -> Result<(), SolariError> {
423        let r: ResumeResponse = self
424            .http
425            .request(
426                "POST",
427                &format!("/sandboxes/{}/resume", encode_uri_component(&self.id)),
428                None,
429                None,
430            )
431            .await?;
432        let control_url = r.control_url.unwrap_or_else(|| {
433            format!(
434                "{}/control/{}",
435                self.http.ws_origin(),
436                encode_uri_component(&self.id)
437            )
438        });
439        self.control_url = control_url.clone();
440        self.channel.set_control_url(control_url);
441        self.channel.reconnect().await
442    }
443
444    /// Cold-boot this STOPPED session from its kept disk and re-point the control
445    /// channel at the fresh control URL. Processes and memory are gone, files on
446    /// disk are kept. See [`Client::start`] for the refusal codes.
447    ///
448    /// Takes `&mut self` for the same reason as [`Sandbox::resume`]: it replaces
449    /// this handle's `control_url`.
450    pub async fn start(&mut self) -> Result<(), SolariError> {
451        let r: StartResponse = self
452            .http
453            .request(
454                "POST",
455                &format!("/sandboxes/{}/start", encode_uri_component(&self.id)),
456                None,
457                None,
458            )
459            .await?;
460        let control_url = r.control_url.unwrap_or_else(|| {
461            format!(
462                "{}/control/{}",
463                self.http.ws_origin(),
464                encode_uri_component(&self.id)
465            )
466        });
467        self.control_url = control_url.clone();
468        self.channel.set_control_url(control_url);
469        self.channel.reconnect().await
470    }
471
472    /// Destroy the remote session and close the channel. Idempotent.
473    pub async fn kill(&self) -> Result<(), SolariError> {
474        let _: serde_json::Value = self
475            .http
476            .request(
477                "DELETE",
478                &format!("/sandboxes/{}", encode_uri_component(&self.id)),
479                None,
480                None,
481            )
482            .await?;
483        self.close();
484        Ok(())
485    }
486
487    // --- namespaces ----------------------------------------------------------
488
489    pub fn commands(&self) -> Commands<'_> {
490        Commands { sb: self }
491    }
492    pub fn files(&self) -> Files<'_> {
493        Files { sb: self }
494    }
495    pub fn code(&self) -> Code<'_> {
496        Code { sb: self }
497    }
498    pub fn git(&self) -> Git<'_> {
499        Git::new(self)
500    }
501
502    // --- internal accessors (used by the namespace modules) ------------------
503
504    pub(crate) fn channel(&self) -> &ControlChannel {
505        &self.channel
506    }
507    pub(crate) fn channel_arc(&self) -> Arc<ControlChannel> {
508        self.channel.clone()
509    }
510    pub(crate) fn http(&self) -> &HttpTransport {
511        &self.http
512    }
513}
514
515/// `Sandbox` runs `git` through `commands.run("git", …)` (no shell).
516impl GitRunner for Sandbox {
517    fn run_git<'a>(
518        &'a self,
519        args: Vec<String>,
520        cwd: Option<String>,
521    ) -> Pin<Box<dyn Future<Output = Result<CommandResult, SolariError>> + Send + 'a>> {
522        self.run_git_env(args, cwd, None)
523    }
524
525    fn run_git_env<'a>(
526        &'a self,
527        args: Vec<String>,
528        cwd: Option<String>,
529        env: Option<std::collections::HashMap<String, String>>,
530    ) -> Pin<Box<dyn Future<Output = Result<CommandResult, SolariError>> + Send + 'a>> {
531        Box::pin(async move {
532            let opts = RunOptions {
533                args: Some(args),
534                cwd,
535                env,
536                ..Default::default()
537            };
538            run_command(self, "git", &opts).await
539        })
540    }
541}