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
15pub const DEFAULT_SANDBOX_IMAGE: &str = "alpine:3.20";
17
18pub const DEFAULT_SANDBOX_TIMEOUT_SECONDS: u64 = 3_600;
20
21const KEEPALIVE_COMMAND: &[&str] = &["/bin/sh", "-c", "while :; do sleep 3600; done"];
22
23#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum VolumeSource {
26 Bind(PathBuf),
28 Named(String),
30}
31
32#[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#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
114pub enum SandboxNetwork {
115 #[default]
117 Tsi,
118 Disabled,
120 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#[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 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}