1use 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
22pub const DEFAULT_BASE_URL: &str = "https://api.getsolari.com";
24
25#[derive(Debug, Clone)]
27pub struct ClientOptions {
28 pub api_key: String,
29 pub base_url: String,
31 pub call_timeout_ms: Option<u64>,
33}
34
35impl ClientOptions {
36 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#[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 pub ttl_seconds: Option<u64>,
63 pub from_snapshot: Option<String>,
64 pub isolation: Option<String>,
67 pub lifecycle: Option<Lifecycle>,
68 pub resolution: Option<String>,
71 pub record: Option<bool>,
74 pub volumes: Option<Vec<VolumeAttachment>>,
76}
77
78impl CreateOptions {
79 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
111pub 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 pub async fn create(&self, opts: CreateOptions) -> Result<Sandbox, SolariError> {
129 self.create_with_kind(opts, "sandbox").await
130 }
131
132 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 pub async fn connect(&self, sandbox_id: &str) -> Result<Sandbox, SolariError> {
161 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 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 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 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 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 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 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
306pub 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 shared: std::sync::atomic::AtomicBool,
318}
319
320impl Sandbox {
321 pub(crate) fn from_response(
322 http: Arc<HttpTransport>,
323 resp: CreateSandboxResponse,
324 call_timeout_ms: Option<u64>,
325 ) -> Result<Self, SolariError> {
326 if resp.sandbox_id.trim().is_empty() {
331 return Err(SolariError::Other(
332 "Solari: the session response carried a blank id - a blank id builds a malformed /sandboxes// request"
333 .to_string(),
334 ));
335 }
336 let channel = ControlChannel::new(
337 resp.control_url.clone(),
338 http.auth_headers(),
339 call_timeout_ms,
340 );
341 Ok(Sandbox {
342 id: resp.sandbox_id,
343 kind: resp.kind,
344 control_url: resp.control_url,
345 stream_url: resp.stream_url,
346 recording_url: resp.recording_url,
347 expires_at: resp.expires_at,
348 http,
349 channel: Arc::new(channel),
350 shared: std::sync::atomic::AtomicBool::new(false),
351 })
352 }
353
354 pub(crate) fn is_shared(&self) -> bool {
355 self.shared.load(std::sync::atomic::Ordering::Relaxed)
356 }
357
358 pub(crate) fn mark_shared(&self) {
359 self.shared
360 .store(true, std::sync::atomic::Ordering::Relaxed);
361 }
362
363 pub fn id(&self) -> &str {
365 &self.id
366 }
367 pub fn kind(&self) -> &str {
369 &self.kind
370 }
371 pub fn control_url(&self) -> &str {
373 &self.control_url
374 }
375 pub fn stream_url(&self) -> Option<&str> {
378 self.stream_url.as_deref()
379 }
380 pub fn recording_url(&self) -> Option<&str> {
382 self.recording_url.as_deref()
383 }
384 pub fn expires_at(&self) -> &str {
386 &self.expires_at
387 }
388 pub fn connected(&self) -> bool {
390 self.channel.connected()
391 }
392
393 pub async fn connect(&self) -> Result<(), SolariError> {
395 self.channel.connect().await
396 }
397
398 pub fn close(&self) {
400 self.channel.close();
401 }
402
403 pub async fn pause(&self) -> Result<(), SolariError> {
410 let _: serde_json::Value = self
411 .http
412 .request(
413 "POST",
414 &format!("/sandboxes/{}/pause", encode_uri_component(&self.id)),
415 None,
416 None,
417 )
418 .await?;
419 self.close();
420 Ok(())
421 }
422
423 pub async fn resume(&mut self) -> Result<(), SolariError> {
435 let r: ResumeResponse = self
436 .http
437 .request(
438 "POST",
439 &format!("/sandboxes/{}/resume", encode_uri_component(&self.id)),
440 None,
441 None,
442 )
443 .await?;
444 let control_url = r.control_url.unwrap_or_else(|| {
445 format!(
446 "{}/control/{}",
447 self.http.ws_origin(),
448 encode_uri_component(&self.id)
449 )
450 });
451 self.control_url = control_url.clone();
452 self.channel.set_control_url(control_url);
453 self.channel.reconnect().await
454 }
455
456 pub async fn start(&mut self) -> Result<(), SolariError> {
463 let r: StartResponse = self
464 .http
465 .request(
466 "POST",
467 &format!("/sandboxes/{}/start", encode_uri_component(&self.id)),
468 None,
469 None,
470 )
471 .await?;
472 let control_url = r.control_url.unwrap_or_else(|| {
473 format!(
474 "{}/control/{}",
475 self.http.ws_origin(),
476 encode_uri_component(&self.id)
477 )
478 });
479 self.control_url = control_url.clone();
480 self.channel.set_control_url(control_url);
481 self.channel.reconnect().await
482 }
483
484 pub async fn kill(&self) -> Result<(), SolariError> {
486 let _: serde_json::Value = self
487 .http
488 .request(
489 "DELETE",
490 &format!("/sandboxes/{}", encode_uri_component(&self.id)),
491 None,
492 None,
493 )
494 .await?;
495 self.close();
496 Ok(())
497 }
498
499 pub fn commands(&self) -> Commands<'_> {
502 Commands { sb: self }
503 }
504 pub fn files(&self) -> Files<'_> {
505 Files { sb: self }
506 }
507 pub fn code(&self) -> Code<'_> {
508 Code { sb: self }
509 }
510 pub fn git(&self) -> Git<'_> {
511 Git::new(self)
512 }
513
514 pub(crate) fn channel(&self) -> &ControlChannel {
517 &self.channel
518 }
519 pub(crate) fn channel_arc(&self) -> Arc<ControlChannel> {
520 self.channel.clone()
521 }
522 pub(crate) fn http(&self) -> &HttpTransport {
523 &self.http
524 }
525}
526
527impl GitRunner for Sandbox {
529 fn run_git<'a>(
530 &'a self,
531 args: Vec<String>,
532 cwd: Option<String>,
533 ) -> Pin<Box<dyn Future<Output = Result<CommandResult, SolariError>> + Send + 'a>> {
534 self.run_git_env(args, cwd, None)
535 }
536
537 fn run_git_env<'a>(
538 &'a self,
539 args: Vec<String>,
540 cwd: Option<String>,
541 env: Option<std::collections::HashMap<String, String>>,
542 ) -> Pin<Box<dyn Future<Output = Result<CommandResult, SolariError>> + Send + 'a>> {
543 Box::pin(async move {
544 let opts = RunOptions {
545 args: Some(args),
546 cwd,
547 env,
548 ..Default::default()
549 };
550 run_command(self, "git", &opts).await
551 })
552 }
553}