microsandbox-rb 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +96 -1
- data/Cargo.lock +1 -1
- data/DESIGN.md +16 -6
- data/README.md +27 -14
- data/ext/microsandbox/Cargo.toml +1 -1
- data/ext/microsandbox/src/agent.rs +18 -7
- data/ext/microsandbox/src/conv.rs +37 -1
- data/ext/microsandbox/src/fs_stream.rs +92 -0
- data/ext/microsandbox/src/image.rs +6 -0
- data/ext/microsandbox/src/lib.rs +40 -0
- data/ext/microsandbox/src/sandbox.rs +673 -64
- data/ext/microsandbox/src/snapshot.rs +72 -6
- data/ext/microsandbox/src/volume.rs +113 -1
- data/lib/microsandbox/agent.rb +3 -1
- data/lib/microsandbox/exec_handle.rb +7 -3
- data/lib/microsandbox/exec_output.rb +7 -7
- data/lib/microsandbox/fs.rb +84 -2
- data/lib/microsandbox/image.rb +2 -1
- data/lib/microsandbox/log_entry.rb +4 -2
- data/lib/microsandbox/metrics.rb +9 -0
- data/lib/microsandbox/sandbox.rb +461 -70
- data/lib/microsandbox/snapshot.rb +63 -6
- data/lib/microsandbox/ssh.rb +14 -9
- data/lib/microsandbox/version.rb +1 -1
- data/lib/microsandbox/volume.rb +100 -1
- data/lib/microsandbox.rb +35 -1
- data/sig/microsandbox.rbs +70 -6
- metadata +2 -1
|
@@ -7,23 +7,32 @@
|
|
|
7
7
|
//! as a plain Ruby `Hash`/`Array`/`String` and shaped into value objects by the
|
|
8
8
|
//! Ruby layer.
|
|
9
9
|
|
|
10
|
+
use std::sync::Arc;
|
|
10
11
|
use std::time::Duration;
|
|
11
12
|
|
|
12
13
|
use chrono::{DateTime, Utc};
|
|
13
|
-
use magnus::{
|
|
14
|
+
use magnus::{
|
|
15
|
+
function, method, prelude::*, Error, RArray, RHash, RModule, RString, Ruby, TryConvert, Value,
|
|
16
|
+
};
|
|
14
17
|
use microsandbox::logs::{
|
|
15
18
|
LogCursor, LogEntry, LogOptions, LogSource, LogStreamOptions, LogStreamStart,
|
|
16
19
|
};
|
|
17
20
|
use microsandbox::sandbox::{
|
|
18
|
-
AttachOptionsBuilder, FsEntry, FsEntryKind, FsMetadata,
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
AttachOptionsBuilder, DiskImageFormat, FsEntry, FsEntryKind, FsMetadata, HostPermissions,
|
|
22
|
+
Patch, PullPolicy, PullProgress, PullProgressHandle, RlimitResource, SandboxBuilder,
|
|
23
|
+
SandboxFilter, SandboxHandle, SandboxMetrics, SandboxStatus, SandboxStopResult, SecretBuilder,
|
|
24
|
+
SecurityProfile, StatVirtualization,
|
|
21
25
|
};
|
|
22
26
|
use microsandbox::LogLevel;
|
|
27
|
+
use microsandbox::MicrosandboxResult;
|
|
23
28
|
use microsandbox::RegistryAuth;
|
|
29
|
+
use microsandbox_network::builder::ViolationActionBuilder;
|
|
30
|
+
use microsandbox_network::dns::Nameserver;
|
|
24
31
|
use microsandbox_network::policy::{
|
|
25
32
|
Action, Destination, DestinationGroup, Direction, NetworkPolicy, PortRange, Protocol, Rule,
|
|
26
33
|
};
|
|
34
|
+
use tokio::sync::Mutex;
|
|
35
|
+
use tokio::task::JoinHandle;
|
|
27
36
|
|
|
28
37
|
use crate::conv;
|
|
29
38
|
use crate::error;
|
|
@@ -45,12 +54,22 @@ impl Sandbox {
|
|
|
45
54
|
// Lifecycle (singleton methods)
|
|
46
55
|
//----------------------------------------------------------------------
|
|
47
56
|
|
|
48
|
-
///
|
|
49
|
-
|
|
57
|
+
/// Build a configured `SandboxBuilder` from a string-keyed options Hash.
|
|
58
|
+
/// Shared by `create` (blocking) and `create_with_progress` (streaming pull).
|
|
59
|
+
fn build_builder(name: String, opts: RHash) -> Result<SandboxBuilder, Error> {
|
|
50
60
|
let mut b = microsandbox::Sandbox::builder(name);
|
|
51
61
|
|
|
52
62
|
if let Some(v) = conv::opt_string(opts, "image")? {
|
|
53
|
-
|
|
63
|
+
if let Some(fstype) = conv::opt_string(opts, "fstype")? {
|
|
64
|
+
// An explicit fstype means `image` names a disk-image rootfs path
|
|
65
|
+
// whose inner filesystem can't be auto-probed: route through
|
|
66
|
+
// image_with(disk().fstype()). (A bare `image` string otherwise
|
|
67
|
+
// auto-detects OCI vs disk by extension.) Errors in disk()/fstype()
|
|
68
|
+
// are captured on the builder and surface at create().
|
|
69
|
+
b = b.image_with(move |i| i.disk(v).fstype(fstype));
|
|
70
|
+
} else {
|
|
71
|
+
b = b.image(v);
|
|
72
|
+
}
|
|
54
73
|
}
|
|
55
74
|
if let Some(v) = conv::opt_string(opts, "from_snapshot")? {
|
|
56
75
|
b = b.from_snapshot(v);
|
|
@@ -89,59 +108,84 @@ impl Sandbox {
|
|
|
89
108
|
for (host, guest) in conv::opt_port_map(opts, "ports")? {
|
|
90
109
|
b = b.port(host, guest);
|
|
91
110
|
}
|
|
92
|
-
// volumes: normalized by the Ruby layer to
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
111
|
+
// volumes: each mount is normalized by the Ruby layer to a string-keyed
|
|
112
|
+
// Hash — guest (req), kind ("bind"/"named"/"tmpfs"/"disk"), source
|
|
113
|
+
// (bind/named/disk), size_mib (tmpfs/disk), format + fstype (disk),
|
|
114
|
+
// readonly/noexec/nosuid/nodev (bool), stat_virtualization,
|
|
115
|
+
// host_permissions. Enum-valued options are validated up front (the
|
|
116
|
+
// volume closure can't return an error); the core validates the rest
|
|
117
|
+
// (e.g. rejecting stat_virtualization on tmpfs/disk) at create().
|
|
118
|
+
for m in conv::opt_hash_vec(opts, "volumes")? {
|
|
119
|
+
let guest = conv::opt_string(m, "guest")?
|
|
120
|
+
.ok_or_else(|| error::base_error("volume mount is missing :guest"))?;
|
|
121
|
+
let kind = conv::opt_string(m, "kind")?
|
|
122
|
+
.ok_or_else(|| error::base_error("volume mount is missing :kind"))?;
|
|
123
|
+
let source = conv::opt_string(m, "source")?;
|
|
124
|
+
let size_mib = conv::opt_u32(m, "size_mib")?;
|
|
125
|
+
let fstype = conv::opt_string(m, "fstype")?;
|
|
126
|
+
let readonly = conv::opt_bool(m, "readonly")?;
|
|
127
|
+
let noexec = conv::opt_bool(m, "noexec")?;
|
|
128
|
+
let nosuid = conv::opt_bool(m, "nosuid")?;
|
|
129
|
+
let nodev = conv::opt_bool(m, "nodev")?;
|
|
130
|
+
let format = conv::opt_string(m, "format")?
|
|
131
|
+
.map(|f| disk_format_from_str(&f))
|
|
132
|
+
.transpose()?;
|
|
133
|
+
let stat_virt = conv::opt_string(m, "stat_virtualization")?
|
|
134
|
+
.map(|s| stat_virtualization_from_str(&s))
|
|
135
|
+
.transpose()?;
|
|
136
|
+
let host_perms = conv::opt_string(m, "host_permissions")?
|
|
137
|
+
.map(|s| host_permissions_from_str(&s))
|
|
138
|
+
.transpose()?;
|
|
139
|
+
// bind/named/disk require a source; tmpfs must not have one.
|
|
100
140
|
match kind.as_str() {
|
|
101
|
-
"bind" | "named" => {}
|
|
102
|
-
|
|
141
|
+
"bind" | "named" | "disk" if source.is_some() => {}
|
|
142
|
+
"bind" | "named" | "disk" => {
|
|
103
143
|
return Err(error::base_error(format!(
|
|
104
|
-
"
|
|
144
|
+
"volume mount kind {kind:?} requires a source"
|
|
105
145
|
)))
|
|
106
146
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
s.split(',')
|
|
113
|
-
.map(|o| o.trim().to_string())
|
|
114
|
-
.filter(|o| !o.is_empty())
|
|
115
|
-
.collect()
|
|
116
|
-
})
|
|
117
|
-
.unwrap_or_default();
|
|
118
|
-
for opt in &mount_opts {
|
|
119
|
-
match opt.as_str() {
|
|
120
|
-
"ro" | "readonly" | "rw" | "noexec" | "nosuid" | "nodev" => {}
|
|
121
|
-
other => {
|
|
122
|
-
return Err(error::base_error(format!(
|
|
123
|
-
"unknown volume mount option {other:?} \
|
|
124
|
-
(expected ro/rw/noexec/nosuid/nodev)"
|
|
125
|
-
)))
|
|
126
|
-
}
|
|
147
|
+
"tmpfs" => {}
|
|
148
|
+
other => {
|
|
149
|
+
return Err(error::base_error(format!(
|
|
150
|
+
"unknown volume mount kind {other:?} (expected bind/named/tmpfs/disk)"
|
|
151
|
+
)))
|
|
127
152
|
}
|
|
128
153
|
}
|
|
129
|
-
b = b.volume(guest, move |
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
154
|
+
b = b.volume(guest, move |mut mb| {
|
|
155
|
+
mb = match kind.as_str() {
|
|
156
|
+
"named" => mb.named(source.unwrap()),
|
|
157
|
+
"tmpfs" => mb.tmpfs(),
|
|
158
|
+
"disk" => mb.disk(source.unwrap()),
|
|
159
|
+
_ => mb.bind(source.unwrap()), // "bind"
|
|
134
160
|
};
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
"ro" | "readonly" => m.readonly(),
|
|
138
|
-
"noexec" => m.noexec(),
|
|
139
|
-
"nosuid" => m.nosuid(),
|
|
140
|
-
"nodev" => m.nodev(),
|
|
141
|
-
_ => m, // "rw" — default; already validated above
|
|
142
|
-
};
|
|
161
|
+
if let Some(n) = size_mib {
|
|
162
|
+
mb = mb.size(n);
|
|
143
163
|
}
|
|
144
|
-
|
|
164
|
+
if let Some(f) = format {
|
|
165
|
+
mb = mb.format(f);
|
|
166
|
+
}
|
|
167
|
+
if let Some(ft) = fstype {
|
|
168
|
+
mb = mb.fstype(ft);
|
|
169
|
+
}
|
|
170
|
+
if readonly {
|
|
171
|
+
mb = mb.readonly();
|
|
172
|
+
}
|
|
173
|
+
if noexec {
|
|
174
|
+
mb = mb.noexec();
|
|
175
|
+
}
|
|
176
|
+
if nosuid {
|
|
177
|
+
mb = mb.nosuid();
|
|
178
|
+
}
|
|
179
|
+
if nodev {
|
|
180
|
+
mb = mb.nodev();
|
|
181
|
+
}
|
|
182
|
+
if let Some(sv) = stat_virt {
|
|
183
|
+
mb = mb.stat_virtualization(sv);
|
|
184
|
+
}
|
|
185
|
+
if let Some(hp) = host_perms {
|
|
186
|
+
mb = mb.host_permissions(hp);
|
|
187
|
+
}
|
|
188
|
+
mb
|
|
145
189
|
});
|
|
146
190
|
}
|
|
147
191
|
// patches: rootfs modifications applied before boot. The Ruby layer
|
|
@@ -207,16 +251,123 @@ impl Sandbox {
|
|
|
207
251
|
for (resource, soft, hard) in parse_rlimits(opts)? {
|
|
208
252
|
b = b.rlimit_range(resource, soft, hard);
|
|
209
253
|
}
|
|
210
|
-
// secrets: normalized by the Ruby layer to
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
254
|
+
// secrets: each entry is normalized by the Ruby layer to a string-keyed
|
|
255
|
+
// Hash — env (req), value (req), hosts / host_patterns (allow lists),
|
|
256
|
+
// placeholder, require_tls, inject_{headers,basic_auth,query,body},
|
|
257
|
+
// on_violation. Routed through the full secret builder (`b.secret`), which
|
|
258
|
+
// auto-enables TLS interception. Mirrors the Python/Node SecretEntry.
|
|
259
|
+
for h in conv::opt_hash_vec(opts, "secrets")? {
|
|
260
|
+
let spec = parse_secret(h)?;
|
|
261
|
+
b = b.secret(move |s| spec.apply(s));
|
|
262
|
+
}
|
|
263
|
+
// Sandbox-level secret-leak policy (block / block_and_log /
|
|
264
|
+
// block_and_terminate / passthrough). Applied via the network builder,
|
|
265
|
+
// which accumulates on top of any policy/dns/tls already configured.
|
|
266
|
+
if let Some(v) = conv::opt::<Value>(opts, "on_secret_violation")? {
|
|
267
|
+
let spec = parse_violation_spec(v)?;
|
|
268
|
+
b = b.network(move |n| n.on_secret_violation(move |va| spec.apply(va)));
|
|
269
|
+
}
|
|
270
|
+
// Advanced network configuration (custom DNS, TLS-interception tuning,
|
|
271
|
+
// guest IP pools, connection cap, host-CA trust), applied via the network
|
|
272
|
+
// builder, which accumulates on top of any policy already configured.
|
|
273
|
+
// Mirrors the Python binding's `apply_network`. Parsed up front because
|
|
274
|
+
// the builder closures cannot return an error.
|
|
275
|
+
let dns = conv::opt::<RHash>(opts, "dns")?
|
|
276
|
+
.map(parse_dns)
|
|
277
|
+
.transpose()?;
|
|
278
|
+
let tls = conv::opt::<RHash>(opts, "tls")?
|
|
279
|
+
.map(parse_tls)
|
|
280
|
+
.transpose()?;
|
|
281
|
+
let ipv4_pool = conv::opt_string(opts, "ipv4_pool")?
|
|
282
|
+
.map(|s| {
|
|
283
|
+
s.parse::<ipnetwork::Ipv4Network>()
|
|
284
|
+
.map_err(|e| error::base_error(format!("invalid ipv4_pool {s:?}: {e}")))
|
|
285
|
+
})
|
|
286
|
+
.transpose()?;
|
|
287
|
+
let ipv6_pool = conv::opt_string(opts, "ipv6_pool")?
|
|
288
|
+
.map(|s| {
|
|
289
|
+
s.parse::<ipnetwork::Ipv6Network>()
|
|
290
|
+
.map_err(|e| error::base_error(format!("invalid ipv6_pool {s:?}: {e}")))
|
|
291
|
+
})
|
|
292
|
+
.transpose()?;
|
|
293
|
+
let max_connections = conv::opt::<usize>(opts, "max_connections")?;
|
|
294
|
+
let trust_host_cas = conv::opt::<bool>(opts, "trust_host_cas")?;
|
|
295
|
+
if dns.is_some()
|
|
296
|
+
|| tls.is_some()
|
|
297
|
+
|| ipv4_pool.is_some()
|
|
298
|
+
|| ipv6_pool.is_some()
|
|
299
|
+
|| max_connections.is_some()
|
|
300
|
+
|| trust_host_cas.is_some()
|
|
301
|
+
{
|
|
302
|
+
b = b.network(move |mut n| {
|
|
303
|
+
if let Some(dns) = dns {
|
|
304
|
+
n = n.dns(move |mut d| {
|
|
305
|
+
if !dns.nameservers.is_empty() {
|
|
306
|
+
d = d.nameservers(dns.nameservers);
|
|
307
|
+
}
|
|
308
|
+
if let Some(rp) = dns.rebind_protection {
|
|
309
|
+
d = d.rebind_protection(rp);
|
|
310
|
+
}
|
|
311
|
+
if let Some(qt) = dns.query_timeout_ms {
|
|
312
|
+
d = d.query_timeout_ms(qt);
|
|
313
|
+
}
|
|
314
|
+
d
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
if let Some(tls) = tls {
|
|
318
|
+
n = n.tls(move |mut t| {
|
|
319
|
+
for pat in tls.bypass {
|
|
320
|
+
t = t.bypass(pat);
|
|
321
|
+
}
|
|
322
|
+
if let Some(v) = tls.verify_upstream {
|
|
323
|
+
t = t.verify_upstream(v);
|
|
324
|
+
}
|
|
325
|
+
if let Some(ports) = tls.intercepted_ports {
|
|
326
|
+
t = t.intercepted_ports(ports);
|
|
327
|
+
}
|
|
328
|
+
if let Some(q) = tls.block_quic {
|
|
329
|
+
t = t.block_quic(q);
|
|
330
|
+
}
|
|
331
|
+
if let Some(p) = tls.upstream_ca_cert {
|
|
332
|
+
t = t.upstream_ca_cert(p);
|
|
333
|
+
}
|
|
334
|
+
if let Some(p) = tls.intercept_ca_cert {
|
|
335
|
+
t = t.intercept_ca_cert(p);
|
|
336
|
+
}
|
|
337
|
+
if let Some(p) = tls.intercept_ca_key {
|
|
338
|
+
t = t.intercept_ca_key(p);
|
|
339
|
+
}
|
|
340
|
+
t
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
if let Some(p) = ipv4_pool {
|
|
344
|
+
n = n.ipv4_pool(p);
|
|
345
|
+
}
|
|
346
|
+
if let Some(p) = ipv6_pool {
|
|
347
|
+
n = n.ipv6_pool(p);
|
|
348
|
+
}
|
|
349
|
+
if let Some(m) = max_connections {
|
|
350
|
+
n = n.max_connections(m);
|
|
351
|
+
}
|
|
352
|
+
if let Some(t) = trust_host_cas {
|
|
353
|
+
n = n.trust_host_cas(t);
|
|
354
|
+
}
|
|
355
|
+
n
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
// init: hand guest PID 1 to an init system. The Ruby layer normalizes
|
|
359
|
+
// `init:` to a Hash { cmd:, args?:, env?: }. `init_with` with empty
|
|
360
|
+
// args/env builds the same HandoffInit as the plain `init(cmd)`, so route
|
|
361
|
+
// everything through the one closure-builder.
|
|
362
|
+
if let Some(h) = conv::opt::<RHash>(opts, "init")? {
|
|
363
|
+
let cmd = conv::opt_string(h, "cmd")?
|
|
364
|
+
.ok_or_else(|| error::base_error("init requires a :cmd"))?;
|
|
365
|
+
let args = conv::opt_string_vec(h, "args")?;
|
|
366
|
+
let env = conv::opt_string_map(h, "env")?;
|
|
367
|
+
b = b.init_with(cmd, move |i| i.args(args).envs(env));
|
|
368
|
+
}
|
|
369
|
+
if conv::opt_bool(opts, "ephemeral")? {
|
|
370
|
+
b = b.ephemeral(true);
|
|
220
371
|
}
|
|
221
372
|
if conv::opt_bool(opts, "detached")? {
|
|
222
373
|
b = b.detached(true);
|
|
@@ -227,10 +378,29 @@ impl Sandbox {
|
|
|
227
378
|
b = b.replace();
|
|
228
379
|
}
|
|
229
380
|
|
|
381
|
+
Ok(b)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/// Create and boot a sandbox. `opts` is a string-keyed options Hash.
|
|
385
|
+
fn create(name: String, opts: RHash) -> Result<Sandbox, Error> {
|
|
386
|
+
let b = Self::build_builder(name, opts)?;
|
|
230
387
|
let inner = block_on(b.create()).map_err(error::to_ruby)?;
|
|
231
388
|
Ok(Sandbox::from_inner(inner))
|
|
232
389
|
}
|
|
233
390
|
|
|
391
|
+
/// Create a sandbox with streaming image-pull progress. Returns a
|
|
392
|
+
/// `PullSession` whose `recv` yields progress events and whose `result`
|
|
393
|
+
/// resolves to the booted sandbox. Mirrors Python `create_with_progress` /
|
|
394
|
+
/// Node `createWithPullProgress`.
|
|
395
|
+
fn create_with_progress(name: String, opts: RHash) -> Result<PullSession, Error> {
|
|
396
|
+
let b = Self::build_builder(name, opts)?;
|
|
397
|
+
// `create_with_pull_progress` spawns a tokio task, so it must run inside
|
|
398
|
+
// the runtime context even though the call itself is synchronous.
|
|
399
|
+
let (handle, join) =
|
|
400
|
+
block_on(async move { b.create_with_pull_progress() }).map_err(error::to_ruby)?;
|
|
401
|
+
Ok(PullSession::new(handle, join))
|
|
402
|
+
}
|
|
403
|
+
|
|
234
404
|
/// Restart a previously-defined sandbox by name.
|
|
235
405
|
fn start(name: String, opts: RHash) -> Result<Sandbox, Error> {
|
|
236
406
|
let detached = conv::opt_bool(opts, "detached")?;
|
|
@@ -495,6 +665,20 @@ impl Sandbox {
|
|
|
495
665
|
block_on(fs.copy_to_host(&guest_path, &host_path)).map_err(error::to_ruby)
|
|
496
666
|
}
|
|
497
667
|
|
|
668
|
+
/// Open a streaming reader over a guest file (for files too large to buffer).
|
|
669
|
+
fn fs_read_stream(&self, path: String) -> Result<crate::fs_stream::FsReadStreamHandle, Error> {
|
|
670
|
+
let fs = self.inner.fs();
|
|
671
|
+
let stream = block_on(fs.read_stream(&path)).map_err(error::to_ruby)?;
|
|
672
|
+
Ok(crate::fs_stream::FsReadStreamHandle::new(stream))
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/// Open a streaming writer to a guest file.
|
|
676
|
+
fn fs_write_stream(&self, path: String) -> Result<crate::fs_stream::FsWriteSinkHandle, Error> {
|
|
677
|
+
let fs = self.inner.fs();
|
|
678
|
+
let sink = block_on(fs.write_stream(&path)).map_err(error::to_ruby)?;
|
|
679
|
+
Ok(crate::fs_stream::FsWriteSinkHandle::new(sink))
|
|
680
|
+
}
|
|
681
|
+
|
|
498
682
|
//----------------------------------------------------------------------
|
|
499
683
|
// SSH (mirror SandboxSshOps)
|
|
500
684
|
//----------------------------------------------------------------------
|
|
@@ -685,6 +869,47 @@ fn parse_rlimits(opts: RHash) -> Result<Vec<(RlimitResource, u64, u64)>, Error>
|
|
|
685
869
|
Ok(out)
|
|
686
870
|
}
|
|
687
871
|
|
|
872
|
+
//--------------------------------------------------------------------------------------------------
|
|
873
|
+
// Mount enum parsing
|
|
874
|
+
//--------------------------------------------------------------------------------------------------
|
|
875
|
+
|
|
876
|
+
fn disk_format_from_str(s: &str) -> Result<DiskImageFormat, Error> {
|
|
877
|
+
// Delegate the qcow2/raw/vmdk mapping to the core's `FromStr` (single source
|
|
878
|
+
// of truth) but keep the friendlier, option-listing error message.
|
|
879
|
+
s.parse::<DiskImageFormat>().map_err(|_| {
|
|
880
|
+
error::base_error(format!(
|
|
881
|
+
"unknown disk format {s:?} (expected qcow2/raw/vmdk)"
|
|
882
|
+
))
|
|
883
|
+
})
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
fn stat_virtualization_from_str(s: &str) -> Result<StatVirtualization, Error> {
|
|
887
|
+
use StatVirtualization::*;
|
|
888
|
+
Ok(match s {
|
|
889
|
+
"strict" => Strict,
|
|
890
|
+
"relaxed" => Relaxed,
|
|
891
|
+
"off" => Off,
|
|
892
|
+
other => {
|
|
893
|
+
return Err(error::base_error(format!(
|
|
894
|
+
"unknown stat_virtualization {other:?} (expected strict/relaxed/off)"
|
|
895
|
+
)))
|
|
896
|
+
}
|
|
897
|
+
})
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
fn host_permissions_from_str(s: &str) -> Result<HostPermissions, Error> {
|
|
901
|
+
use HostPermissions::*;
|
|
902
|
+
Ok(match s {
|
|
903
|
+
"private" => Private,
|
|
904
|
+
"mirror" => Mirror,
|
|
905
|
+
other => {
|
|
906
|
+
return Err(error::base_error(format!(
|
|
907
|
+
"unknown host_permissions {other:?} (expected private/mirror)"
|
|
908
|
+
)))
|
|
909
|
+
}
|
|
910
|
+
})
|
|
911
|
+
}
|
|
912
|
+
|
|
688
913
|
//--------------------------------------------------------------------------------------------------
|
|
689
914
|
// Patch parsing (mirrors the Python binding's `apply_patch`)
|
|
690
915
|
//--------------------------------------------------------------------------------------------------
|
|
@@ -764,6 +989,198 @@ fn parse_patches(opts: RHash) -> Result<Vec<Patch>, Error> {
|
|
|
764
989
|
Ok(out)
|
|
765
990
|
}
|
|
766
991
|
|
|
992
|
+
//--------------------------------------------------------------------------------------------------
|
|
993
|
+
// Secret parsing (mirrors the Python/Node SecretEntry surface)
|
|
994
|
+
//--------------------------------------------------------------------------------------------------
|
|
995
|
+
|
|
996
|
+
/// A secret-leak response. Per-secret (`on_violation:`) and sandbox-level
|
|
997
|
+
/// (`on_secret_violation:`) share the same shape.
|
|
998
|
+
enum ViolationSpec {
|
|
999
|
+
Block,
|
|
1000
|
+
BlockAndLog,
|
|
1001
|
+
BlockAndTerminate,
|
|
1002
|
+
Passthrough {
|
|
1003
|
+
hosts: Vec<String>,
|
|
1004
|
+
patterns: Vec<String>,
|
|
1005
|
+
all: bool,
|
|
1006
|
+
},
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
impl ViolationSpec {
|
|
1010
|
+
fn apply(self, mut v: ViolationActionBuilder) -> ViolationActionBuilder {
|
|
1011
|
+
match self {
|
|
1012
|
+
ViolationSpec::Block => v.block(),
|
|
1013
|
+
ViolationSpec::BlockAndLog => v.block_and_log(),
|
|
1014
|
+
ViolationSpec::BlockAndTerminate => v.block_and_terminate(),
|
|
1015
|
+
ViolationSpec::Passthrough {
|
|
1016
|
+
hosts,
|
|
1017
|
+
patterns,
|
|
1018
|
+
all,
|
|
1019
|
+
} => {
|
|
1020
|
+
for h in hosts {
|
|
1021
|
+
v = v.passthrough_host(h);
|
|
1022
|
+
}
|
|
1023
|
+
for p in patterns {
|
|
1024
|
+
v = v.passthrough_host_pattern(p);
|
|
1025
|
+
}
|
|
1026
|
+
if all {
|
|
1027
|
+
v = v.passthrough_all_hosts(true);
|
|
1028
|
+
}
|
|
1029
|
+
v
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/// Parse `on_violation:` — a String (block variants) or a Hash describing a
|
|
1036
|
+
/// passthrough action — into a [`ViolationSpec`].
|
|
1037
|
+
fn parse_violation_spec(v: Value) -> Result<ViolationSpec, Error> {
|
|
1038
|
+
if let Ok(s) = String::try_convert(v) {
|
|
1039
|
+
return match s.as_str() {
|
|
1040
|
+
"block" => Ok(ViolationSpec::Block),
|
|
1041
|
+
"block_and_log" => Ok(ViolationSpec::BlockAndLog),
|
|
1042
|
+
"block_and_terminate" => Ok(ViolationSpec::BlockAndTerminate),
|
|
1043
|
+
other => Err(error::base_error(format!(
|
|
1044
|
+
"unknown on_violation {other:?} (expected block/block_and_log/\
|
|
1045
|
+
block_and_terminate, or a Hash with :passthrough_hosts/\
|
|
1046
|
+
:passthrough_host_patterns/:passthrough_all_hosts)"
|
|
1047
|
+
))),
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
let h = RHash::try_convert(v)
|
|
1051
|
+
.map_err(|_| error::base_error("on_violation must be a String or a Hash"))?;
|
|
1052
|
+
Ok(ViolationSpec::Passthrough {
|
|
1053
|
+
hosts: conv::opt_string_vec(h, "passthrough_hosts")?,
|
|
1054
|
+
patterns: conv::opt_string_vec(h, "passthrough_host_patterns")?,
|
|
1055
|
+
all: conv::opt_bool(h, "passthrough_all_hosts")?,
|
|
1056
|
+
})
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
struct SecretSpec {
|
|
1060
|
+
env: String,
|
|
1061
|
+
value: String,
|
|
1062
|
+
hosts: Vec<String>,
|
|
1063
|
+
host_patterns: Vec<String>,
|
|
1064
|
+
placeholder: Option<String>,
|
|
1065
|
+
require_tls: Option<bool>,
|
|
1066
|
+
inject_headers: Option<bool>,
|
|
1067
|
+
inject_basic_auth: Option<bool>,
|
|
1068
|
+
inject_query: Option<bool>,
|
|
1069
|
+
inject_body: Option<bool>,
|
|
1070
|
+
on_violation: Option<ViolationSpec>,
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
fn parse_secret(h: RHash) -> Result<SecretSpec, Error> {
|
|
1074
|
+
let env =
|
|
1075
|
+
conv::opt_string(h, "env")?.ok_or_else(|| error::base_error("secret requires :env"))?;
|
|
1076
|
+
let value =
|
|
1077
|
+
conv::opt_string(h, "value")?.ok_or_else(|| error::base_error("secret requires :value"))?;
|
|
1078
|
+
let hosts = conv::opt_string_vec(h, "hosts")?;
|
|
1079
|
+
let host_patterns = conv::opt_string_vec(h, "host_patterns")?;
|
|
1080
|
+
if hosts.is_empty() && host_patterns.is_empty() {
|
|
1081
|
+
return Err(error::base_error(
|
|
1082
|
+
"secret requires at least one allowed host (:host, :hosts, or :host_patterns)",
|
|
1083
|
+
));
|
|
1084
|
+
}
|
|
1085
|
+
let on_violation = conv::opt::<Value>(h, "on_violation")?
|
|
1086
|
+
.map(parse_violation_spec)
|
|
1087
|
+
.transpose()?;
|
|
1088
|
+
Ok(SecretSpec {
|
|
1089
|
+
env,
|
|
1090
|
+
value,
|
|
1091
|
+
hosts,
|
|
1092
|
+
host_patterns,
|
|
1093
|
+
placeholder: conv::opt_string(h, "placeholder")?,
|
|
1094
|
+
require_tls: conv::opt::<bool>(h, "require_tls")?,
|
|
1095
|
+
inject_headers: conv::opt::<bool>(h, "inject_headers")?,
|
|
1096
|
+
inject_basic_auth: conv::opt::<bool>(h, "inject_basic_auth")?,
|
|
1097
|
+
inject_query: conv::opt::<bool>(h, "inject_query")?,
|
|
1098
|
+
inject_body: conv::opt::<bool>(h, "inject_body")?,
|
|
1099
|
+
on_violation,
|
|
1100
|
+
})
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
impl SecretSpec {
|
|
1104
|
+
fn apply(self, mut s: SecretBuilder) -> SecretBuilder {
|
|
1105
|
+
s = s.env(self.env).value(self.value);
|
|
1106
|
+
for host in self.hosts {
|
|
1107
|
+
s = s.allow_host(host);
|
|
1108
|
+
}
|
|
1109
|
+
for pat in self.host_patterns {
|
|
1110
|
+
s = s.allow_host_pattern(pat);
|
|
1111
|
+
}
|
|
1112
|
+
if let Some(p) = self.placeholder {
|
|
1113
|
+
s = s.placeholder(p);
|
|
1114
|
+
}
|
|
1115
|
+
if let Some(rt) = self.require_tls {
|
|
1116
|
+
s = s.require_tls_identity(rt);
|
|
1117
|
+
}
|
|
1118
|
+
if let Some(enabled) = self.inject_headers {
|
|
1119
|
+
s = s.inject_headers(enabled);
|
|
1120
|
+
}
|
|
1121
|
+
if let Some(enabled) = self.inject_basic_auth {
|
|
1122
|
+
s = s.inject_basic_auth(enabled);
|
|
1123
|
+
}
|
|
1124
|
+
if let Some(enabled) = self.inject_query {
|
|
1125
|
+
s = s.inject_query(enabled);
|
|
1126
|
+
}
|
|
1127
|
+
if let Some(enabled) = self.inject_body {
|
|
1128
|
+
s = s.inject_body(enabled);
|
|
1129
|
+
}
|
|
1130
|
+
if let Some(action) = self.on_violation {
|
|
1131
|
+
s = s.on_violation(move |v| action.apply(v));
|
|
1132
|
+
}
|
|
1133
|
+
s
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
//--------------------------------------------------------------------------------------------------
|
|
1138
|
+
// Network connection config parsing (DNS / TLS interception)
|
|
1139
|
+
//--------------------------------------------------------------------------------------------------
|
|
1140
|
+
|
|
1141
|
+
struct DnsSpec {
|
|
1142
|
+
nameservers: Vec<Nameserver>,
|
|
1143
|
+
rebind_protection: Option<bool>,
|
|
1144
|
+
query_timeout_ms: Option<u64>,
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
fn parse_dns(d: RHash) -> Result<DnsSpec, Error> {
|
|
1148
|
+
let mut nameservers = Vec::new();
|
|
1149
|
+
for s in conv::opt_string_vec(d, "nameservers")? {
|
|
1150
|
+
nameservers.push(
|
|
1151
|
+
s.parse::<Nameserver>()
|
|
1152
|
+
.map_err(|e| error::base_error(format!("invalid nameserver {s:?}: {e}")))?,
|
|
1153
|
+
);
|
|
1154
|
+
}
|
|
1155
|
+
Ok(DnsSpec {
|
|
1156
|
+
nameservers,
|
|
1157
|
+
rebind_protection: conv::opt::<bool>(d, "rebind_protection")?,
|
|
1158
|
+
query_timeout_ms: conv::opt::<u64>(d, "query_timeout_ms")?,
|
|
1159
|
+
})
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
struct TlsSpec {
|
|
1163
|
+
bypass: Vec<String>,
|
|
1164
|
+
verify_upstream: Option<bool>,
|
|
1165
|
+
intercepted_ports: Option<Vec<u16>>,
|
|
1166
|
+
block_quic: Option<bool>,
|
|
1167
|
+
upstream_ca_cert: Option<String>,
|
|
1168
|
+
intercept_ca_cert: Option<String>,
|
|
1169
|
+
intercept_ca_key: Option<String>,
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
fn parse_tls(t: RHash) -> Result<TlsSpec, Error> {
|
|
1173
|
+
Ok(TlsSpec {
|
|
1174
|
+
bypass: conv::opt_string_vec(t, "bypass")?,
|
|
1175
|
+
verify_upstream: conv::opt::<bool>(t, "verify_upstream")?,
|
|
1176
|
+
intercepted_ports: conv::opt::<Vec<u16>>(t, "intercepted_ports")?,
|
|
1177
|
+
block_quic: conv::opt::<bool>(t, "block_quic")?,
|
|
1178
|
+
upstream_ca_cert: conv::opt_string(t, "upstream_ca_cert")?,
|
|
1179
|
+
intercept_ca_cert: conv::opt_string(t, "intercept_ca_cert")?,
|
|
1180
|
+
intercept_ca_key: conv::opt_string(t, "intercept_ca_key")?,
|
|
1181
|
+
})
|
|
1182
|
+
}
|
|
1183
|
+
|
|
767
1184
|
//--------------------------------------------------------------------------------------------------
|
|
768
1185
|
// Network policy parsing (mirrors the Python binding's `apply_network`)
|
|
769
1186
|
//--------------------------------------------------------------------------------------------------
|
|
@@ -1205,7 +1622,7 @@ fn fs_entry_kind_str(kind: FsEntryKind) -> &'static str {
|
|
|
1205
1622
|
}
|
|
1206
1623
|
}
|
|
1207
1624
|
|
|
1208
|
-
fn fs_entry_to_hash(entry: &FsEntry) -> RHash {
|
|
1625
|
+
pub(crate) fn fs_entry_to_hash(entry: &FsEntry) -> RHash {
|
|
1209
1626
|
let hash = ruby().hash_new();
|
|
1210
1627
|
let _ = hash.aset("path", entry.path.clone());
|
|
1211
1628
|
let _ = hash.aset("type", fs_entry_kind_str(entry.kind));
|
|
@@ -1218,7 +1635,7 @@ fn fs_entry_to_hash(entry: &FsEntry) -> RHash {
|
|
|
1218
1635
|
hash
|
|
1219
1636
|
}
|
|
1220
1637
|
|
|
1221
|
-
fn fs_metadata_to_hash(meta: &FsMetadata) -> RHash {
|
|
1638
|
+
pub(crate) fn fs_metadata_to_hash(meta: &FsMetadata) -> RHash {
|
|
1222
1639
|
let hash = ruby().hash_new();
|
|
1223
1640
|
let _ = hash.aset("type", fs_entry_kind_str(meta.kind));
|
|
1224
1641
|
let _ = hash.aset("size", meta.size);
|
|
@@ -1241,6 +1658,11 @@ pub(crate) fn metrics_to_hash(m: &SandboxMetrics) -> RHash {
|
|
|
1241
1658
|
let _ = hash.aset("disk_write_bytes", m.disk_write_bytes);
|
|
1242
1659
|
let _ = hash.aset("net_rx_bytes", m.net_rx_bytes);
|
|
1243
1660
|
let _ = hash.aset("net_tx_bytes", m.net_tx_bytes);
|
|
1661
|
+
// OCI writable-upper-layer accounting (Option<u64> → Integer or nil), for
|
|
1662
|
+
// sandboxes capped by `oci_upper_size`. Mirrors the Python/Node metrics.
|
|
1663
|
+
let _ = hash.aset("upper_used_bytes", m.upper_used_bytes);
|
|
1664
|
+
let _ = hash.aset("upper_free_bytes", m.upper_free_bytes);
|
|
1665
|
+
let _ = hash.aset("upper_host_allocated_bytes", m.upper_host_allocated_bytes);
|
|
1244
1666
|
let _ = hash.aset("uptime_secs", m.uptime.as_secs_f64());
|
|
1245
1667
|
let _ = hash.aset("timestamp_ms", m.timestamp.timestamp_millis());
|
|
1246
1668
|
hash
|
|
@@ -1282,6 +1704,159 @@ fn stop_result_to_hash(result: &SandboxStopResult) -> RHash {
|
|
|
1282
1704
|
hash
|
|
1283
1705
|
}
|
|
1284
1706
|
|
|
1707
|
+
//--------------------------------------------------------------------------------------------------
|
|
1708
|
+
// Pull progress (streaming image-pull during create_with_progress)
|
|
1709
|
+
//--------------------------------------------------------------------------------------------------
|
|
1710
|
+
|
|
1711
|
+
/// Convert a core `PullProgress` event into a `{ "kind" => …, fields… }` Hash.
|
|
1712
|
+
/// The match is exhaustive (no wildcard) so a future upstream variant surfaces
|
|
1713
|
+
/// as a compile error rather than a silently-dropped event.
|
|
1714
|
+
fn pull_progress_to_hash(p: &PullProgress) -> RHash {
|
|
1715
|
+
use PullProgress::*;
|
|
1716
|
+
let h = ruby().hash_new();
|
|
1717
|
+
match p {
|
|
1718
|
+
Resolving { reference } => {
|
|
1719
|
+
let _ = h.aset("kind", "resolving");
|
|
1720
|
+
let _ = h.aset("reference", reference.to_string());
|
|
1721
|
+
}
|
|
1722
|
+
Resolved {
|
|
1723
|
+
reference,
|
|
1724
|
+
manifest_digest,
|
|
1725
|
+
layer_count,
|
|
1726
|
+
total_download_bytes,
|
|
1727
|
+
} => {
|
|
1728
|
+
let _ = h.aset("kind", "resolved");
|
|
1729
|
+
let _ = h.aset("reference", reference.to_string());
|
|
1730
|
+
let _ = h.aset("manifest_digest", manifest_digest.to_string());
|
|
1731
|
+
let _ = h.aset("layer_count", *layer_count);
|
|
1732
|
+
let _ = h.aset("total_download_bytes", *total_download_bytes);
|
|
1733
|
+
}
|
|
1734
|
+
LayerDownloadProgress {
|
|
1735
|
+
layer_index,
|
|
1736
|
+
digest,
|
|
1737
|
+
downloaded_bytes,
|
|
1738
|
+
total_bytes,
|
|
1739
|
+
} => {
|
|
1740
|
+
let _ = h.aset("kind", "layer_download_progress");
|
|
1741
|
+
let _ = h.aset("layer_index", *layer_index);
|
|
1742
|
+
let _ = h.aset("digest", digest.to_string());
|
|
1743
|
+
let _ = h.aset("downloaded_bytes", *downloaded_bytes);
|
|
1744
|
+
let _ = h.aset("total_bytes", *total_bytes);
|
|
1745
|
+
}
|
|
1746
|
+
LayerDownloadComplete {
|
|
1747
|
+
layer_index,
|
|
1748
|
+
digest,
|
|
1749
|
+
downloaded_bytes,
|
|
1750
|
+
} => {
|
|
1751
|
+
let _ = h.aset("kind", "layer_download_complete");
|
|
1752
|
+
let _ = h.aset("layer_index", *layer_index);
|
|
1753
|
+
let _ = h.aset("digest", digest.to_string());
|
|
1754
|
+
let _ = h.aset("downloaded_bytes", *downloaded_bytes);
|
|
1755
|
+
}
|
|
1756
|
+
LayerDownloadVerifying {
|
|
1757
|
+
layer_index,
|
|
1758
|
+
digest,
|
|
1759
|
+
} => {
|
|
1760
|
+
let _ = h.aset("kind", "layer_download_verifying");
|
|
1761
|
+
let _ = h.aset("layer_index", *layer_index);
|
|
1762
|
+
let _ = h.aset("digest", digest.to_string());
|
|
1763
|
+
}
|
|
1764
|
+
LayerMaterializeStarted {
|
|
1765
|
+
layer_index,
|
|
1766
|
+
diff_id,
|
|
1767
|
+
} => {
|
|
1768
|
+
let _ = h.aset("kind", "layer_materialize_started");
|
|
1769
|
+
let _ = h.aset("layer_index", *layer_index);
|
|
1770
|
+
let _ = h.aset("diff_id", diff_id.to_string());
|
|
1771
|
+
}
|
|
1772
|
+
LayerMaterializeProgress {
|
|
1773
|
+
layer_index,
|
|
1774
|
+
bytes_read,
|
|
1775
|
+
total_bytes,
|
|
1776
|
+
} => {
|
|
1777
|
+
let _ = h.aset("kind", "layer_materialize_progress");
|
|
1778
|
+
let _ = h.aset("layer_index", *layer_index);
|
|
1779
|
+
let _ = h.aset("bytes_read", *bytes_read);
|
|
1780
|
+
let _ = h.aset("total_bytes", *total_bytes);
|
|
1781
|
+
}
|
|
1782
|
+
LayerMaterializeWriting { layer_index } => {
|
|
1783
|
+
let _ = h.aset("kind", "layer_materialize_writing");
|
|
1784
|
+
let _ = h.aset("layer_index", *layer_index);
|
|
1785
|
+
}
|
|
1786
|
+
LayerMaterializeComplete {
|
|
1787
|
+
layer_index,
|
|
1788
|
+
diff_id,
|
|
1789
|
+
} => {
|
|
1790
|
+
let _ = h.aset("kind", "layer_materialize_complete");
|
|
1791
|
+
let _ = h.aset("layer_index", *layer_index);
|
|
1792
|
+
let _ = h.aset("diff_id", diff_id.to_string());
|
|
1793
|
+
}
|
|
1794
|
+
StitchMergingTrees { layer_count } => {
|
|
1795
|
+
let _ = h.aset("kind", "stitch_merging_trees");
|
|
1796
|
+
let _ = h.aset("layer_count", *layer_count);
|
|
1797
|
+
}
|
|
1798
|
+
StitchWritingFsmeta => {
|
|
1799
|
+
let _ = h.aset("kind", "stitch_writing_fsmeta");
|
|
1800
|
+
}
|
|
1801
|
+
StitchWritingVmdk => {
|
|
1802
|
+
let _ = h.aset("kind", "stitch_writing_vmdk");
|
|
1803
|
+
}
|
|
1804
|
+
StitchComplete => {
|
|
1805
|
+
let _ = h.aset("kind", "stitch_complete");
|
|
1806
|
+
}
|
|
1807
|
+
Complete {
|
|
1808
|
+
reference,
|
|
1809
|
+
layer_count,
|
|
1810
|
+
} => {
|
|
1811
|
+
let _ = h.aset("kind", "complete");
|
|
1812
|
+
let _ = h.aset("reference", reference.to_string());
|
|
1813
|
+
let _ = h.aset("layer_count", *layer_count);
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
h
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
/// A streaming image-pull + create session, from `Sandbox.create_with_progress`.
|
|
1820
|
+
/// `recv` yields progress events; `result` awaits the booted sandbox. The pull
|
|
1821
|
+
/// runs as a tokio task; the progress receiver needs `&mut self` and the join
|
|
1822
|
+
/// handle is consumed once, so both sit behind a `tokio::Mutex`.
|
|
1823
|
+
#[magnus::wrap(class = "Microsandbox::Native::PullSession", free_immediately, size)]
|
|
1824
|
+
pub struct PullSession {
|
|
1825
|
+
progress: Arc<Mutex<PullProgressHandle>>,
|
|
1826
|
+
join: Arc<Mutex<Option<JoinHandle<MicrosandboxResult<microsandbox::Sandbox>>>>>,
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
impl PullSession {
|
|
1830
|
+
fn new(
|
|
1831
|
+
progress: PullProgressHandle,
|
|
1832
|
+
join: JoinHandle<MicrosandboxResult<microsandbox::Sandbox>>,
|
|
1833
|
+
) -> Self {
|
|
1834
|
+
Self {
|
|
1835
|
+
progress: Arc::new(Mutex::new(progress)),
|
|
1836
|
+
join: Arc::new(Mutex::new(Some(join))),
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
/// Next progress event as a Hash, or nil when the pull is finished.
|
|
1841
|
+
fn recv(&self) -> Result<Option<RHash>, Error> {
|
|
1842
|
+
let progress = Arc::clone(&self.progress);
|
|
1843
|
+
let event = block_on(async move { progress.lock().await.recv().await });
|
|
1844
|
+
Ok(event.map(|p| pull_progress_to_hash(&p)))
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
/// Await the booted sandbox. Call after draining `recv` (it joins the
|
|
1848
|
+
/// create task). Consumes the join handle, so it is callable only once.
|
|
1849
|
+
fn result(&self) -> Result<Sandbox, Error> {
|
|
1850
|
+
let join = Arc::clone(&self.join);
|
|
1851
|
+
let taken = block_on(async move { join.lock().await.take() });
|
|
1852
|
+
let handle = taken.ok_or_else(|| error::base_error("pull session result already taken"))?;
|
|
1853
|
+
let inner = block_on(handle)
|
|
1854
|
+
.map_err(|e| error::base_error(format!("sandbox creation task failed: {e}")))?
|
|
1855
|
+
.map_err(error::to_ruby)?;
|
|
1856
|
+
Ok(Sandbox::from_inner(inner))
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1285
1860
|
//--------------------------------------------------------------------------------------------------
|
|
1286
1861
|
// SandboxHandle — the controllable lightweight handle
|
|
1287
1862
|
//--------------------------------------------------------------------------------------------------
|
|
@@ -1361,6 +1936,27 @@ impl SbHandle {
|
|
|
1361
1936
|
let result = block_on(self.inner.wait_until_stopped()).map_err(error::to_ruby)?;
|
|
1362
1937
|
Ok(stop_result_to_hash(&result))
|
|
1363
1938
|
}
|
|
1939
|
+
|
|
1940
|
+
/// The sandbox's stored configuration as a JSON string (synchronous — the
|
|
1941
|
+
/// handle already carries it, no runtime round-trip). The Ruby layer parses
|
|
1942
|
+
/// it into a Hash for `#config`. Mirrors the Python/Node `config_json`.
|
|
1943
|
+
fn config_json(&self) -> String {
|
|
1944
|
+
self.inner.config_json().to_string()
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/// Snapshot this (stopped) sandbox under a bare name (resolved under the
|
|
1948
|
+
/// snapshots directory). Returns the same SnapshotInfo Hash as
|
|
1949
|
+
/// `Snapshot.create`. Mirrors the Python/Node `handle.snapshot(name)`.
|
|
1950
|
+
fn snapshot(&self, name: String) -> Result<RHash, Error> {
|
|
1951
|
+
let snap = block_on(self.inner.snapshot(&name)).map_err(error::to_ruby)?;
|
|
1952
|
+
Ok(crate::snapshot::snapshot_to_hash(&snap))
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
/// Snapshot this (stopped) sandbox to an explicit filesystem path.
|
|
1956
|
+
fn snapshot_to(&self, path: String) -> Result<RHash, Error> {
|
|
1957
|
+
let snap = block_on(self.inner.snapshot_to(path)).map_err(error::to_ruby)?;
|
|
1958
|
+
Ok(crate::snapshot::snapshot_to_hash(&snap))
|
|
1959
|
+
}
|
|
1364
1960
|
}
|
|
1365
1961
|
|
|
1366
1962
|
//--------------------------------------------------------------------------------------------------
|
|
@@ -1450,6 +2046,10 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
|
|
|
1450
2046
|
let class = native.define_class("Sandbox", ruby.class_object())?;
|
|
1451
2047
|
|
|
1452
2048
|
class.define_singleton_method("create", function!(Sandbox::create, 2))?;
|
|
2049
|
+
class.define_singleton_method(
|
|
2050
|
+
"create_with_progress",
|
|
2051
|
+
function!(Sandbox::create_with_progress, 2),
|
|
2052
|
+
)?;
|
|
1453
2053
|
class.define_singleton_method("start", function!(Sandbox::start, 2))?;
|
|
1454
2054
|
class.define_singleton_method("get", function!(Sandbox::get, 1))?;
|
|
1455
2055
|
class.define_singleton_method("list", function!(Sandbox::list, 0))?;
|
|
@@ -1487,6 +2087,8 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
|
|
|
1487
2087
|
class.define_method("fs_stat", method!(Sandbox::fs_stat, 1))?;
|
|
1488
2088
|
class.define_method("fs_copy_from_host", method!(Sandbox::fs_copy_from_host, 2))?;
|
|
1489
2089
|
class.define_method("fs_copy_to_host", method!(Sandbox::fs_copy_to_host, 2))?;
|
|
2090
|
+
class.define_method("fs_read_stream", method!(Sandbox::fs_read_stream, 1))?;
|
|
2091
|
+
class.define_method("fs_write_stream", method!(Sandbox::fs_write_stream, 1))?;
|
|
1490
2092
|
|
|
1491
2093
|
class.define_method("ssh_open_client", method!(Sandbox::ssh_open_client, 1))?;
|
|
1492
2094
|
class.define_method(
|
|
@@ -1513,6 +2115,13 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
|
|
|
1513
2115
|
"wait_until_stopped",
|
|
1514
2116
|
method!(SbHandle::wait_until_stopped, 0),
|
|
1515
2117
|
)?;
|
|
2118
|
+
handle.define_method("config_json", method!(SbHandle::config_json, 0))?;
|
|
2119
|
+
handle.define_method("snapshot", method!(SbHandle::snapshot, 1))?;
|
|
2120
|
+
handle.define_method("snapshot_to", method!(SbHandle::snapshot_to, 1))?;
|
|
2121
|
+
|
|
2122
|
+
let session = native.define_class("PullSession", ruby.class_object())?;
|
|
2123
|
+
session.define_method("recv", method!(PullSession::recv, 0))?;
|
|
2124
|
+
session.define_method("result", method!(PullSession::result, 0))?;
|
|
1516
2125
|
|
|
1517
2126
|
Ok(())
|
|
1518
2127
|
}
|