Skip to main content

a3s_box_sdk/sandbox/
options.rs

1use std::collections::BTreeMap;
2use std::net::IpAddr;
3use std::path::PathBuf;
4
5use a3s_box_core::config::ResourceConfig;
6use a3s_box_core::dns::parse_add_host_entries;
7use a3s_box_core::network::NetworkMode;
8use a3s_box_core::{
9    parse_port_mapping, resolve_execution, BoxConfig, CreateExecutionRequest, ExecutionIsolation,
10    ExecutionRecordPolicy, ExecutionSnapshotId, OperationId, PortMapping,
11};
12
13use crate::{A3sBoxClient, ClientError, Result};
14
15/// Default OCI image used by all native local SDKs.
16pub const DEFAULT_SANDBOX_IMAGE: &str = "alpine:3.20";
17
18/// Default lifetime of a locally created Sandbox.
19pub const DEFAULT_SANDBOX_TIMEOUT_SECONDS: u64 = 3_600;
20
21const KEEPALIVE_COMMAND: &[&str] = &["/bin/sh", "-c", "while :; do sleep 3600; done"];
22
23/// Source of one typed volume mount.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum VolumeSource {
26    /// A host path mounted directly into the box.
27    Bind(PathBuf),
28    /// An A3S-managed named volume resolved by the runtime client.
29    Named(String),
30}
31
32/// A typed read-write or read-only mount.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct VolumeMount {
35    pub source: VolumeSource,
36    pub target: String,
37    pub read_only: bool,
38}
39
40impl VolumeMount {
41    pub fn bind(source: impl Into<PathBuf>, target: impl Into<String>) -> Self {
42        Self {
43            source: VolumeSource::Bind(source.into()),
44            target: target.into(),
45            read_only: false,
46        }
47    }
48
49    pub fn named(name: impl Into<String>, target: impl Into<String>) -> Self {
50        Self {
51            source: VolumeSource::Named(name.into()),
52            target: target.into(),
53            read_only: false,
54        }
55    }
56
57    pub const fn read_only(mut self, read_only: bool) -> Self {
58        self.read_only = read_only;
59        self
60    }
61}
62
63/// A typed in-guest tmpfs mount.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct TmpfsMount {
66    pub target: String,
67    pub size_bytes: Option<u64>,
68    pub read_only: bool,
69}
70
71impl TmpfsMount {
72    pub fn new(target: impl Into<String>) -> Self {
73        Self {
74            target: target.into(),
75            size_bytes: None,
76            read_only: false,
77        }
78    }
79
80    pub const fn size_bytes(mut self, size_bytes: u64) -> Self {
81        self.size_bytes = Some(size_bytes);
82        self
83    }
84
85    pub const fn read_only(mut self, read_only: bool) -> Self {
86        self.read_only = read_only;
87        self
88    }
89
90    fn runtime_entry(&self) -> Result<String> {
91        validate_guest_path("tmpfs target", &self.target)?;
92        if self.size_bytes == Some(0) {
93            return Err(ClientError::Validation(
94                "tmpfs size must be greater than zero".to_string(),
95            ));
96        }
97        let mut options = Vec::new();
98        if let Some(size_bytes) = self.size_bytes {
99            options.push(format!("size={size_bytes}"));
100        }
101        if self.read_only {
102            options.push("ro".to_string());
103        }
104        if options.is_empty() {
105            Ok(self.target.clone())
106        } else {
107            Ok(format!("{}:{}", self.target, options.join(",")))
108        }
109    }
110}
111
112/// Network selected for a Sandbox.
113#[derive(Debug, Clone, Default, PartialEq, Eq)]
114pub enum SandboxNetwork {
115    /// Transparent socket impersonation, the default MicroVM network.
116    #[default]
117    Tsi,
118    /// Disable networking entirely.
119    Disabled,
120    /// Join an existing A3S-managed bridge network.
121    Bridge { name: String },
122}
123
124impl SandboxNetwork {
125    pub fn bridge(name: impl Into<String>) -> Self {
126        Self::Bridge { name: name.into() }
127    }
128
129    fn runtime_mode(&self, client: &A3sBoxClient) -> Result<NetworkMode> {
130        match self {
131            Self::Tsi => Ok(NetworkMode::Tsi),
132            Self::Disabled => Ok(NetworkMode::None),
133            Self::Bridge { name } => {
134                if name.trim().is_empty() {
135                    return Err(ClientError::Validation(
136                        "bridge network name cannot be empty".to_string(),
137                    ));
138                }
139                if client.get_network(name)?.is_none() {
140                    return Err(ClientError::Validation(format!(
141                        "network '{name}' does not exist; create it before starting the sandbox"
142                    )));
143                }
144                Ok(NetworkMode::Bridge {
145                    network: name.clone(),
146                })
147            }
148        }
149    }
150}
151
152/// Options for [`super::Sandbox::create_with_options`].
153///
154/// MicroVM isolation is the default. Shared-kernel Sandbox isolation must be
155/// selected explicitly with [`SandboxCreateOptions::isolation`].
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct SandboxCreateOptions {
158    pub image: String,
159    pub timeout_seconds: u64,
160    pub envs: BTreeMap<String, String>,
161    pub metadata: BTreeMap<String, String>,
162    pub name: Option<String>,
163    pub cpus: Option<u32>,
164    pub memory_mb: Option<u32>,
165    pub isolation: ExecutionIsolation,
166    pub rootfs_snapshot_id: Option<ExecutionSnapshotId>,
167    pub workspace: Option<PathBuf>,
168    pub workdir: Option<String>,
169    pub user: Option<String>,
170    pub hostname: Option<String>,
171    pub mounts: Vec<VolumeMount>,
172    pub tmpfs: Vec<TmpfsMount>,
173    pub network: SandboxNetwork,
174    pub ports: Vec<PortMapping>,
175    pub dns_servers: Vec<String>,
176    pub host_aliases: BTreeMap<String, String>,
177    pub read_only: bool,
178    pub persistent: bool,
179    pub auto_remove: bool,
180}
181
182impl SandboxCreateOptions {
183    pub fn new(image: impl Into<String>) -> Self {
184        Self {
185            image: image.into(),
186            ..Self::default()
187        }
188    }
189
190    pub const fn timeout_seconds(mut self, timeout_seconds: u64) -> Self {
191        self.timeout_seconds = timeout_seconds;
192        self
193    }
194
195    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
196        self.envs.insert(key.into(), value.into());
197        self
198    }
199
200    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
201        self.metadata.insert(key.into(), value.into());
202        self
203    }
204
205    pub fn name(mut self, name: impl Into<String>) -> Self {
206        self.name = Some(name.into());
207        self
208    }
209
210    pub const fn cpus(mut self, cpus: u32) -> Self {
211        self.cpus = Some(cpus);
212        self
213    }
214
215    pub const fn memory_mb(mut self, memory_mb: u32) -> Self {
216        self.memory_mb = Some(memory_mb);
217        self
218    }
219
220    pub const fn isolation(mut self, isolation: ExecutionIsolation) -> Self {
221        self.isolation = isolation;
222        self
223    }
224
225    /// Start from a runtime-managed immutable filesystem snapshot.
226    ///
227    /// Snapshot identifiers are typed and validated by the runtime; callers
228    /// cannot provide an arbitrary host path.
229    pub fn filesystem_snapshot(mut self, snapshot_id: ExecutionSnapshotId) -> Self {
230        self.rootfs_snapshot_id = Some(snapshot_id);
231        self
232    }
233
234    pub fn workspace(mut self, path: impl Into<PathBuf>) -> Self {
235        self.workspace = Some(path.into());
236        self
237    }
238
239    pub fn workdir(mut self, path: impl Into<String>) -> Self {
240        self.workdir = Some(path.into());
241        self
242    }
243
244    pub fn user(mut self, user: impl Into<String>) -> Self {
245        self.user = Some(user.into());
246        self
247    }
248
249    pub fn hostname(mut self, hostname: impl Into<String>) -> Self {
250        self.hostname = Some(hostname.into());
251        self
252    }
253
254    pub fn mount(mut self, mount: VolumeMount) -> Self {
255        self.mounts.push(mount);
256        self
257    }
258
259    pub fn tmpfs(mut self, mount: TmpfsMount) -> Self {
260        self.tmpfs.push(mount);
261        self
262    }
263
264    pub fn network(mut self, network: SandboxNetwork) -> Self {
265        self.network = network;
266        self
267    }
268
269    pub fn publish_port(mut self, port: PortMapping) -> Self {
270        self.ports.push(port);
271        self
272    }
273
274    pub fn dns_server(mut self, server: impl Into<String>) -> Self {
275        self.dns_servers.push(server.into());
276        self
277    }
278
279    pub fn host_alias(mut self, host: impl Into<String>, ip: impl Into<String>) -> Self {
280        self.host_aliases.insert(host.into(), ip.into());
281        self
282    }
283
284    pub const fn read_only(mut self, read_only: bool) -> Self {
285        self.read_only = read_only;
286        self
287    }
288
289    pub const fn persistent(mut self, persistent: bool) -> Self {
290        self.persistent = persistent;
291        self
292    }
293
294    pub const fn auto_remove(mut self, auto_remove: bool) -> Self {
295        self.auto_remove = auto_remove;
296        self
297    }
298
299    pub(crate) fn into_runtime_request(
300        self,
301        client: &A3sBoxClient,
302    ) -> Result<(CreateExecutionRequest, OperationId)> {
303        self.validate()?;
304        let network = self.network.runtime_mode(client)?;
305        let (volumes, volume_names) = resolve_mounts(client, self.mounts)?;
306        let port_map = self
307            .ports
308            .into_iter()
309            .map(|port| {
310                let entry = port.runtime_entry();
311                parse_port_mapping(&entry)
312                    .map(|mapping| mapping.runtime_entry())
313                    .map_err(ClientError::Validation)
314            })
315            .collect::<Result<Vec<_>>>()?;
316        let tmpfs = self
317            .tmpfs
318            .iter()
319            .map(TmpfsMount::runtime_entry)
320            .collect::<Result<Vec<_>>>()?;
321        let add_hosts = self
322            .host_aliases
323            .into_iter()
324            .map(|(host, ip)| format!("{host}:{ip}"))
325            .collect::<Vec<_>>();
326        parse_add_host_entries(&add_hosts).map_err(ClientError::Validation)?;
327
328        let identity = uuid::Uuid::new_v4();
329        let mut resources = ResourceConfig {
330            timeout: self.timeout_seconds,
331            ..ResourceConfig::default()
332        };
333        if let Some(cpus) = self.cpus {
334            resources.vcpus = cpus;
335        }
336        if let Some(memory_mb) = self.memory_mb {
337            resources.memory_mb = memory_mb;
338        }
339
340        let config = BoxConfig {
341            isolation: self.isolation,
342            image: self.image,
343            workspace: self.workspace.unwrap_or_default(),
344            resources,
345            cmd: KEEPALIVE_COMMAND
346                .iter()
347                .map(|part| (*part).to_string())
348                .collect(),
349            user: self.user,
350            workdir: self.workdir,
351            hostname: self.hostname,
352            volumes,
353            extra_env: self.envs.into_iter().collect(),
354            port_map,
355            dns: self.dns_servers,
356            add_hosts,
357            network,
358            tmpfs,
359            read_only: self.read_only,
360            persistent: self.persistent,
361            ..BoxConfig::default()
362        };
363        resolve_execution(&config).map_err(ClientError::Runtime)?;
364
365        let operation = OperationId::new(format!("sdk-create-{identity}"))
366            .map_err(|error| ClientError::Validation(error.to_string()))?;
367        Ok((
368            CreateExecutionRequest {
369                external_sandbox_id: format!("local-{identity}"),
370                config,
371                labels: self.metadata,
372                policy: ExecutionRecordPolicy {
373                    name: self.name,
374                    auto_remove: self.auto_remove,
375                    volume_names,
376                    ..ExecutionRecordPolicy::default()
377                },
378                rootfs_snapshot_id: self.rootfs_snapshot_id,
379            },
380            operation,
381        ))
382    }
383
384    fn validate(&self) -> Result<()> {
385        if self.image.trim().is_empty() {
386            return Err(ClientError::Validation(
387                "sandbox image cannot be empty".to_string(),
388            ));
389        }
390        if self.timeout_seconds == 0 {
391            return Err(ClientError::Validation(
392                "sandbox timeout must be greater than zero".to_string(),
393            ));
394        }
395        if self.cpus == Some(0) {
396            return Err(ClientError::Validation(
397                "sandbox CPUs must be greater than zero".to_string(),
398            ));
399        }
400        if self.memory_mb == Some(0) {
401            return Err(ClientError::Validation(
402                "sandbox memory must be greater than zero".to_string(),
403            ));
404        }
405        if let Some(workdir) = &self.workdir {
406            validate_guest_path("working directory", workdir)?;
407        }
408        for server in &self.dns_servers {
409            server.parse::<IpAddr>().map_err(|_| {
410                ClientError::Validation(format!("invalid DNS server address '{server}'"))
411            })?;
412        }
413        Ok(())
414    }
415}
416
417impl Default for SandboxCreateOptions {
418    fn default() -> Self {
419        Self {
420            image: DEFAULT_SANDBOX_IMAGE.to_string(),
421            timeout_seconds: DEFAULT_SANDBOX_TIMEOUT_SECONDS,
422            envs: BTreeMap::new(),
423            metadata: BTreeMap::new(),
424            name: None,
425            cpus: None,
426            memory_mb: None,
427            isolation: ExecutionIsolation::Microvm,
428            rootfs_snapshot_id: None,
429            workspace: None,
430            workdir: None,
431            user: None,
432            hostname: None,
433            mounts: Vec::new(),
434            tmpfs: Vec::new(),
435            network: SandboxNetwork::default(),
436            ports: Vec::new(),
437            dns_servers: Vec::new(),
438            host_aliases: BTreeMap::new(),
439            read_only: false,
440            persistent: false,
441            auto_remove: true,
442        }
443    }
444}
445
446fn resolve_mounts(
447    client: &A3sBoxClient,
448    mounts: Vec<VolumeMount>,
449) -> Result<(Vec<String>, Vec<String>)> {
450    let mut runtime_mounts = Vec::with_capacity(mounts.len());
451    let mut volume_names = Vec::new();
452    for mount in mounts {
453        validate_guest_path("volume target", &mount.target)?;
454        let source = match mount.source {
455            VolumeSource::Bind(path) => {
456                if path.as_os_str().is_empty() {
457                    return Err(ClientError::Validation(
458                        "bind mount source cannot be empty".to_string(),
459                    ));
460                }
461                path.to_string_lossy().into_owned()
462            }
463            VolumeSource::Named(name) => {
464                let volume = client.get_volume(&name)?.ok_or_else(|| {
465                    ClientError::Validation(format!(
466                        "volume '{name}' does not exist; create it before starting the sandbox"
467                    ))
468                })?;
469                if volume.mount_point.is_empty() {
470                    return Err(ClientError::Validation(format!(
471                        "volume '{name}' has no runtime mount point"
472                    )));
473                }
474                if !volume_names.contains(&name) {
475                    volume_names.push(name);
476                }
477                volume.mount_point
478            }
479        };
480        let mode = if mount.read_only { ":ro" } else { ":rw" };
481        runtime_mounts.push(format!("{source}:{}{mode}", mount.target));
482    }
483    Ok((runtime_mounts, volume_names))
484}
485
486fn validate_guest_path(label: &str, path: &str) -> Result<()> {
487    if !path.starts_with('/') || path.contains('\0') {
488        return Err(ClientError::Validation(format!(
489            "{label} must be an absolute guest path without NUL bytes"
490        )));
491    }
492    Ok(())
493}