microsandbox-rb 0.10.0 → 0.12.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 +139 -0
- data/Cargo.lock +82 -95
- data/README.md +21 -13
- data/ext/microsandbox/Cargo.toml +4 -4
- data/ext/microsandbox/src/backend.rs +7 -9
- data/ext/microsandbox/src/error.rs +89 -1
- data/ext/microsandbox/src/image.rs +53 -5
- data/ext/microsandbox/src/lib.rs +9 -9
- data/ext/microsandbox/src/sandbox.rs +198 -55
- data/ext/microsandbox/src/snapshot.rs +79 -35
- data/ext/microsandbox/src/volume.rs +5 -3
- data/lib/microsandbox/errors.rb +16 -0
- data/lib/microsandbox/image.rb +38 -0
- data/lib/microsandbox/network.rb +139 -31
- data/lib/microsandbox/root_disk.rb +58 -0
- data/lib/microsandbox/sandbox.rb +174 -30
- data/lib/microsandbox/snapshot.rb +87 -37
- data/lib/microsandbox/version.rb +2 -2
- data/lib/microsandbox.rb +1 -0
- data/sig/microsandbox.rbs +51 -14
- metadata +2 -1
|
@@ -20,7 +20,7 @@ use microsandbox::logs::{
|
|
|
20
20
|
use microsandbox::sandbox::{
|
|
21
21
|
AttachOptionsBuilder, DiskImageFormat, EnvVar, FsEntry, FsEntryKind, FsMetadata,
|
|
22
22
|
HostPermissions, Patch, PullPolicy, PullProgress, PullProgressHandle, RlimitResource,
|
|
23
|
-
|
|
23
|
+
RootDiskBuilder, SandboxBuilder, SandboxHandle, SandboxMetrics, SandboxModificationBuilder,
|
|
24
24
|
SandboxModificationPatch, SandboxStatus, SandboxStopResult, SecretBuilder,
|
|
25
25
|
SecretModificationPatch, SecretSource, SecurityProfile, StatVirtualization,
|
|
26
26
|
};
|
|
@@ -30,7 +30,8 @@ use microsandbox::RegistryAuth;
|
|
|
30
30
|
use microsandbox_network::builder::ViolationActionBuilder;
|
|
31
31
|
use microsandbox_network::dns::Nameserver;
|
|
32
32
|
use microsandbox_network::policy::{
|
|
33
|
-
Action, Destination, DestinationGroup, Direction, NetworkPolicy,
|
|
33
|
+
Action, Destination, DestinationGroup, Direction, NetworkPolicy, NetworkProfile, PortRange,
|
|
34
|
+
Protocol, Rule,
|
|
34
35
|
};
|
|
35
36
|
use tokio::sync::Mutex;
|
|
36
37
|
use tokio::task::JoinHandle;
|
|
@@ -166,6 +167,10 @@ impl Sandbox {
|
|
|
166
167
|
let host_perms = conv::opt_string(m, "host_permissions")?
|
|
167
168
|
.map(|s| host_permissions_from_str(&s))
|
|
168
169
|
.transpose()?;
|
|
170
|
+
// v0.6.7: mount-root symlink protection is on by default; this is
|
|
171
|
+
// the per-mount opt-out. Valid for bind/named-directory mounts —
|
|
172
|
+
// the core rejects it elsewhere at build().
|
|
173
|
+
let follow_root_symlinks = conv::opt::<bool>(m, "follow_root_symlinks")?;
|
|
169
174
|
// bind/named/disk require a source; tmpfs must not have one.
|
|
170
175
|
match kind.as_str() {
|
|
171
176
|
"bind" | "named" | "disk" if source.is_some() => {}
|
|
@@ -218,6 +223,9 @@ impl Sandbox {
|
|
|
218
223
|
if let Some(hp) = host_perms {
|
|
219
224
|
mb = mb.host_permissions(hp);
|
|
220
225
|
}
|
|
226
|
+
if let Some(follow) = follow_root_symlinks {
|
|
227
|
+
mb = mb.follow_root_symlinks(follow);
|
|
228
|
+
}
|
|
221
229
|
mb
|
|
222
230
|
});
|
|
223
231
|
}
|
|
@@ -230,18 +238,29 @@ impl Sandbox {
|
|
|
230
238
|
if let Some(net) = conv::opt_string(opts, "network")? {
|
|
231
239
|
match net.as_str() {
|
|
232
240
|
"none" | "disabled" | "disable" | "airgapped" => b = b.disable_network(),
|
|
233
|
-
// Default policy is public-only, so no builder call is needed.
|
|
234
|
-
"public" | "public_only" | "default" => {}
|
|
235
241
|
"all" | "allow_all" => b = b.network(|n| n.policy(NetworkPolicy::allow_all())),
|
|
236
|
-
"non_local" | "nonlocal" => b = b.network(|n| n.policy(NetworkPolicy::non_local())),
|
|
237
242
|
other => {
|
|
238
243
|
return Err(error::base_error(format!(
|
|
239
|
-
"unknown network mode {other:?} (expected
|
|
240
|
-
|
|
244
|
+
"unknown network mode {other:?} (expected none/allow_all; compose \
|
|
245
|
+
public/private/host access via network profiles)"
|
|
241
246
|
)))
|
|
242
247
|
}
|
|
243
248
|
}
|
|
244
249
|
}
|
|
250
|
+
// Composable network profiles (v0.6.7): expanded to the canonical rule
|
|
251
|
+
// set plus a gateway-DNS allow by the core's
|
|
252
|
+
// `NetworkPolicy::from_profiles`. The Ruby layer routes
|
|
253
|
+
// `network: [:public, :host]` (and single-profile sugar) here. An empty
|
|
254
|
+
// profile set never reaches this key — the Ruby layer sends it as an
|
|
255
|
+
// explicit empty custom policy, which has identical semantics.
|
|
256
|
+
let profile_names = conv::opt_string_vec(opts, "network_profiles")?;
|
|
257
|
+
if !profile_names.is_empty() {
|
|
258
|
+
let profiles = profile_names
|
|
259
|
+
.iter()
|
|
260
|
+
.map(|s| network_profile_from_str(s))
|
|
261
|
+
.collect::<Result<Vec<_>, Error>>()?;
|
|
262
|
+
b = b.network(move |n| n.policy(NetworkPolicy::from_profiles(profiles)));
|
|
263
|
+
}
|
|
245
264
|
// Custom network policy: an ordered allow/deny rule list with per-direction
|
|
246
265
|
// defaults and bulk domain denials. The Ruby layer routes bare presets to
|
|
247
266
|
// the `network` key above and full policies here; mirrors the Python
|
|
@@ -261,8 +280,14 @@ impl Sandbox {
|
|
|
261
280
|
if let Some(policy) = conv::opt_string(opts, "pull_policy")? {
|
|
262
281
|
b = b.pull_policy(pull_policy_from_str(&policy)?);
|
|
263
282
|
}
|
|
264
|
-
|
|
265
|
-
|
|
283
|
+
// Writable root-disk spec for OCI sandboxes (v0.6.7): Integer = managed
|
|
284
|
+
// ext4 upper size in MiB (the pre-0.6.7 `oci_upper_size` meaning), Hash =
|
|
285
|
+
// {kind: managed|tmpfs|disk, size_mib, path, format, fstype}. The Ruby
|
|
286
|
+
// layer maps the deprecated `oci_upper_size:` kwarg to the managed kind
|
|
287
|
+
// before it reaches the wire.
|
|
288
|
+
if let Some(v) = conv::opt::<Value>(opts, "root_disk")? {
|
|
289
|
+
let spec = parse_root_disk(v)?;
|
|
290
|
+
b = b.root_disk_with(move |d| spec.apply(d));
|
|
266
291
|
}
|
|
267
292
|
// Registry connection settings, for private / non-default registries:
|
|
268
293
|
// Basic auth (username + password/token), plain-HTTP `insecure`, and
|
|
@@ -452,29 +477,44 @@ impl Sandbox {
|
|
|
452
477
|
Ok(SbHandle::from_inner(handle))
|
|
453
478
|
}
|
|
454
479
|
|
|
455
|
-
///
|
|
456
|
-
|
|
457
|
-
|
|
480
|
+
/// Convert one `SandboxPage` into a `{ "sandboxes" => [SbHandle], "next_cursor" => String|nil }`
|
|
481
|
+
/// Ruby Hash (v0.6.8 paginated listing contract).
|
|
482
|
+
fn page_to_hash(page: microsandbox::sandbox::SandboxPage) -> Result<RHash, Error> {
|
|
458
483
|
let arr = ruby().ary_new();
|
|
459
|
-
for h in
|
|
484
|
+
for h in page.sandboxes {
|
|
460
485
|
arr.push(SbHandle::from_inner(h))?;
|
|
461
486
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
487
|
+
let hash = ruby().hash_new();
|
|
488
|
+
hash.aset("sandboxes", arr)?;
|
|
489
|
+
hash.aset("next_cursor", page.next_cursor)?;
|
|
490
|
+
Ok(hash)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/// First page of sandboxes (default page size), as a page Hash of
|
|
494
|
+
/// controllable handles.
|
|
495
|
+
fn list() -> Result<RHash, Error> {
|
|
496
|
+
let page = block_on(microsandbox::Sandbox::list()).map_err(error::to_ruby)?;
|
|
497
|
+
Self::page_to_hash(page)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/// One configured page of sandboxes as a page Hash. `opts` carries an
|
|
501
|
+
/// optional string→string `labels` map (AND-matched), an optional `limit`
|
|
502
|
+
/// (1..=100) and an optional opaque `cursor` from a previous page.
|
|
503
|
+
fn list_with(opts: RHash) -> Result<RHash, Error> {
|
|
504
|
+
let labels = conv::opt_string_map(opts, "labels")?;
|
|
505
|
+
let limit = conv::opt_u32(opts, "limit")?;
|
|
506
|
+
let cursor = conv::opt_string(opts, "cursor")?;
|
|
507
|
+
let page = block_on(microsandbox::Sandbox::list_with(move |mut b| {
|
|
508
|
+
if let Some(limit) = limit {
|
|
509
|
+
b = b.limit(limit);
|
|
510
|
+
}
|
|
511
|
+
if let Some(cursor) = cursor {
|
|
512
|
+
b = b.cursor(cursor);
|
|
513
|
+
}
|
|
514
|
+
b.labels(labels)
|
|
515
|
+
}))
|
|
516
|
+
.map_err(error::to_ruby)?;
|
|
517
|
+
Self::page_to_hash(page)
|
|
478
518
|
}
|
|
479
519
|
|
|
480
520
|
/// Remove a (stopped) sandbox by name.
|
|
@@ -1258,12 +1298,14 @@ fn parse_tls(t: RHash) -> Result<TlsSpec, Error> {
|
|
|
1258
1298
|
/// a core `NetworkPolicy`. Returns `None` when the option is absent (bare
|
|
1259
1299
|
/// presets travel via the separate `network` key handled in `create`).
|
|
1260
1300
|
///
|
|
1261
|
-
/// Composition (
|
|
1262
|
-
/// come first (so they outrank
|
|
1263
|
-
/// preset base
|
|
1264
|
-
///
|
|
1265
|
-
///
|
|
1266
|
-
///
|
|
1301
|
+
/// Composition order (first-match-wins per direction): bulk domain-deny rules
|
|
1302
|
+
/// come first (so they outrank everything), then the caller's explicit
|
|
1303
|
+
/// `rules`, then the profile/preset base's expansion LAST — the same order the
|
|
1304
|
+
/// upstream CLI composes `--net` with explicit rules, so a narrower caller
|
|
1305
|
+
/// override (e.g. `Rule.deny_dns`) beats the base's allows instead of dying
|
|
1306
|
+
/// behind them. Per-direction defaults come from the explicit
|
|
1307
|
+
/// `default_egress`/`default_ingress` when set, else the base's defaults, else
|
|
1308
|
+
/// the asymmetric default (deny egress / allow ingress).
|
|
1267
1309
|
fn parse_network_policy(opts: RHash) -> Result<Option<NetworkPolicy>, Error> {
|
|
1268
1310
|
let Some(np) = conv::opt::<RHash>(opts, "network_policy")? else {
|
|
1269
1311
|
return Ok(None);
|
|
@@ -1284,21 +1326,40 @@ fn parse_network_policy(opts: RHash) -> Result<Option<NetworkPolicy>, Error> {
|
|
|
1284
1326
|
rules.push(Rule::deny_egress(Destination::DomainSuffix(suffix)));
|
|
1285
1327
|
}
|
|
1286
1328
|
|
|
1287
|
-
// Optional
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1329
|
+
// Optional base — composable profiles (v0.6.7) or a terminal preset,
|
|
1330
|
+
// mutually exclusive. Its rules and defaults seed the policy.
|
|
1331
|
+
let profile_names = conv::opt_string_vec(np, "profiles")?;
|
|
1332
|
+
let preset = conv::opt_string(np, "preset")?;
|
|
1333
|
+
if !profile_names.is_empty() && preset.is_some() {
|
|
1334
|
+
return Err(error::base_error(
|
|
1335
|
+
"network profiles: and preset: are mutually exclusive",
|
|
1336
|
+
));
|
|
1337
|
+
}
|
|
1338
|
+
let base = if profile_names.is_empty() {
|
|
1339
|
+
preset.map(|p| network_preset(&p)).transpose()?
|
|
1340
|
+
} else {
|
|
1341
|
+
let profiles = profile_names
|
|
1342
|
+
.iter()
|
|
1343
|
+
.map(|s| network_profile_from_str(s))
|
|
1344
|
+
.collect::<Result<Vec<_>, Error>>()?;
|
|
1345
|
+
Some(NetworkPolicy::from_profiles(profiles))
|
|
1346
|
+
};
|
|
1347
|
+
// Caller's explicit rules go BEFORE the base expansion: under
|
|
1348
|
+
// first-match-wins, appending them after would make any override of a
|
|
1349
|
+
// base-allowed destination silently dead (a more permissive policy than
|
|
1350
|
+
// requested). Mirrors the upstream CLI's `--net` + rules composition.
|
|
1351
|
+
for rd in conv::opt_hash_vec(np, "rules")? {
|
|
1352
|
+
rules.push(parse_rule(rd)?);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
let (preset_egress, preset_ingress) = match base {
|
|
1356
|
+
Some(mut base) => {
|
|
1291
1357
|
rules.append(&mut base.rules);
|
|
1292
1358
|
(Some(base.default_egress), Some(base.default_ingress))
|
|
1293
1359
|
}
|
|
1294
1360
|
None => (None, None),
|
|
1295
1361
|
};
|
|
1296
1362
|
|
|
1297
|
-
// Caller's explicit rules come after preset rules.
|
|
1298
|
-
for rd in conv::opt_hash_vec(np, "rules")? {
|
|
1299
|
-
rules.push(parse_rule(rd)?);
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
1363
|
let default_egress = match conv::opt_string(np, "default_egress")? {
|
|
1303
1364
|
Some(s) => action_from_str(&s)?,
|
|
1304
1365
|
None => preset_egress.unwrap_or(Action::Deny),
|
|
@@ -1318,18 +1379,105 @@ fn parse_network_policy(opts: RHash) -> Result<Option<NetworkPolicy>, Error> {
|
|
|
1318
1379
|
fn network_preset(p: &str) -> Result<NetworkPolicy, Error> {
|
|
1319
1380
|
Ok(match p {
|
|
1320
1381
|
"none" | "disabled" | "disable" | "airgapped" => NetworkPolicy::none(),
|
|
1321
|
-
"public" | "public_only" | "public-only" | "default" => NetworkPolicy::public_only(),
|
|
1322
1382
|
"all" | "allow_all" | "allow-all" => NetworkPolicy::allow_all(),
|
|
1323
|
-
"non_local" | "non-local" | "nonlocal" => NetworkPolicy::non_local(),
|
|
1324
1383
|
other => {
|
|
1325
1384
|
return Err(error::base_error(format!(
|
|
1326
|
-
"unknown network preset {other:?} (expected
|
|
1327
|
-
public_only/
|
|
1385
|
+
"unknown network preset {other:?} (expected none/allow_all; the removed \
|
|
1386
|
+
public_only/non_local presets are replaced by composable profiles)"
|
|
1328
1387
|
)))
|
|
1329
1388
|
}
|
|
1330
1389
|
})
|
|
1331
1390
|
}
|
|
1332
1391
|
|
|
1392
|
+
fn network_profile_from_str(s: &str) -> Result<NetworkProfile, Error> {
|
|
1393
|
+
match s {
|
|
1394
|
+
"public" => Ok(NetworkProfile::Public),
|
|
1395
|
+
"private" => Ok(NetworkProfile::Private),
|
|
1396
|
+
"host" => Ok(NetworkProfile::Host),
|
|
1397
|
+
other => Err(error::base_error(format!(
|
|
1398
|
+
"unknown network profile {other:?} (expected public/private/host)"
|
|
1399
|
+
))),
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/// Parsed `root_disk` create option. Integer shorthand = managed upper of N
|
|
1404
|
+
/// MiB (mirrors Python's `root_disk=8192` and the CLI bare-size form); Hash =
|
|
1405
|
+
/// explicit kind. Kind/field cross-validation (size on a disk image, etc.)
|
|
1406
|
+
/// lives in the Ruby layer and the core builder — this stays a dumb carrier.
|
|
1407
|
+
struct RootDiskSpec {
|
|
1408
|
+
kind: RootDiskKindSpec,
|
|
1409
|
+
size_mib: Option<u32>,
|
|
1410
|
+
format: Option<DiskImageFormat>,
|
|
1411
|
+
fstype: Option<String>,
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
enum RootDiskKindSpec {
|
|
1415
|
+
Managed,
|
|
1416
|
+
Tmpfs,
|
|
1417
|
+
Disk(String),
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
impl RootDiskSpec {
|
|
1421
|
+
fn apply(self, mut d: RootDiskBuilder) -> RootDiskBuilder {
|
|
1422
|
+
match self.kind {
|
|
1423
|
+
RootDiskKindSpec::Managed => {}
|
|
1424
|
+
RootDiskKindSpec::Tmpfs => d = d.tmpfs(),
|
|
1425
|
+
RootDiskKindSpec::Disk(path) => d = d.disk_image(path),
|
|
1426
|
+
}
|
|
1427
|
+
if let Some(mib) = self.size_mib {
|
|
1428
|
+
d = d.size(mib);
|
|
1429
|
+
}
|
|
1430
|
+
if let Some(f) = self.format {
|
|
1431
|
+
d = d.format(f);
|
|
1432
|
+
}
|
|
1433
|
+
if let Some(ft) = self.fstype {
|
|
1434
|
+
d = d.fstype(ft);
|
|
1435
|
+
}
|
|
1436
|
+
d
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
fn parse_root_disk(v: Value) -> Result<RootDiskSpec, Error> {
|
|
1441
|
+
if let Ok(mib) = u32::try_convert(v) {
|
|
1442
|
+
return Ok(RootDiskSpec {
|
|
1443
|
+
kind: RootDiskKindSpec::Managed,
|
|
1444
|
+
size_mib: Some(mib),
|
|
1445
|
+
format: None,
|
|
1446
|
+
fstype: None,
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
let Ok(h) = RHash::try_convert(v) else {
|
|
1450
|
+
return Err(error::base_error(
|
|
1451
|
+
"root_disk: expects an Integer (managed size in MiB) or a Hash \
|
|
1452
|
+
(use Microsandbox::RootDisk.managed/tmpfs/disk)",
|
|
1453
|
+
));
|
|
1454
|
+
};
|
|
1455
|
+
let kind = match conv::opt_string(h, "kind")?.as_deref() {
|
|
1456
|
+
None | Some("managed") => RootDiskKindSpec::Managed,
|
|
1457
|
+
Some("tmpfs") => RootDiskKindSpec::Tmpfs,
|
|
1458
|
+
Some("disk" | "disk-image" | "disk_image") => {
|
|
1459
|
+
let Some(path) = conv::opt_string(h, "path")? else {
|
|
1460
|
+
return Err(error::base_error("root_disk disk kind requires path:"));
|
|
1461
|
+
};
|
|
1462
|
+
RootDiskKindSpec::Disk(path)
|
|
1463
|
+
}
|
|
1464
|
+
Some(other) => {
|
|
1465
|
+
return Err(error::base_error(format!(
|
|
1466
|
+
"unknown root_disk kind {other:?} (expected managed/tmpfs/disk)"
|
|
1467
|
+
)))
|
|
1468
|
+
}
|
|
1469
|
+
};
|
|
1470
|
+
let format = conv::opt_string(h, "format")?
|
|
1471
|
+
.map(|f| disk_format_from_str(&f))
|
|
1472
|
+
.transpose()?;
|
|
1473
|
+
Ok(RootDiskSpec {
|
|
1474
|
+
kind,
|
|
1475
|
+
size_mib: conv::opt_u32(h, "size_mib")?,
|
|
1476
|
+
format,
|
|
1477
|
+
fstype: conv::opt_string(h, "fstype")?,
|
|
1478
|
+
})
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1333
1481
|
fn action_from_str(s: &str) -> Result<Action, Error> {
|
|
1334
1482
|
match s {
|
|
1335
1483
|
"allow" => Ok(Action::Allow),
|
|
@@ -2143,17 +2291,13 @@ impl SbHandle {
|
|
|
2143
2291
|
/// Snapshot this (stopped) sandbox under a bare name (resolved under the
|
|
2144
2292
|
/// snapshots directory). Returns the same SnapshotInfo Hash as
|
|
2145
2293
|
/// `Snapshot.create`. Mirrors the Python/Node `handle.snapshot(name)`.
|
|
2294
|
+
/// (`snapshot_to` was removed upstream in v0.6.7 — use `Snapshot.create`
|
|
2295
|
+
/// with `dest_dir:` for explicit placement.)
|
|
2146
2296
|
fn snapshot(&self, name: String) -> Result<RHash, Error> {
|
|
2147
2297
|
let snap = block_on(self.inner.snapshot(&name)).map_err(error::to_ruby)?;
|
|
2148
2298
|
Ok(crate::snapshot::snapshot_to_hash(&snap))
|
|
2149
2299
|
}
|
|
2150
2300
|
|
|
2151
|
-
/// Snapshot this (stopped) sandbox to an explicit filesystem path.
|
|
2152
|
-
fn snapshot_to(&self, path: String) -> Result<RHash, Error> {
|
|
2153
|
-
let snap = block_on(self.inner.snapshot_to(path)).map_err(error::to_ruby)?;
|
|
2154
|
-
Ok(crate::snapshot::snapshot_to_hash(&snap))
|
|
2155
|
-
}
|
|
2156
|
-
|
|
2157
2301
|
/// Health-check the guest agent without refreshing the idle timer. A stopped
|
|
2158
2302
|
/// sandbox raises `SandboxNotRunningError` (the handle does not start it
|
|
2159
2303
|
/// implicitly). Returns `{ name, latency_secs }`.
|
|
@@ -2337,7 +2481,6 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
|
|
|
2337
2481
|
)?;
|
|
2338
2482
|
handle.define_method("config_json", method!(SbHandle::config_json, 0))?;
|
|
2339
2483
|
handle.define_method("snapshot", method!(SbHandle::snapshot, 1))?;
|
|
2340
|
-
handle.define_method("snapshot_to", method!(SbHandle::snapshot_to, 1))?;
|
|
2341
2484
|
handle.define_method("ping", method!(SbHandle::ping, 0))?;
|
|
2342
2485
|
handle.define_method("touch", method!(SbHandle::touch, 0))?;
|
|
2343
2486
|
handle.define_method("modify", method!(SbHandle::modify, 1))?;
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
//! Snapshot management: `Microsandbox::Native::Snapshot`.
|
|
2
2
|
//!
|
|
3
|
-
//! Snapshots capture a stopped sandbox's
|
|
3
|
+
//! Snapshots capture a stopped sandbox's disk state into a portable artifact
|
|
4
4
|
//! that a later `Sandbox.create(from_snapshot:)` can boot from. Exposed as
|
|
5
5
|
//! singleton functions returning plain Hashes/Arrays (shaped into value objects
|
|
6
6
|
//! by the Ruby layer) — there is no long-lived handle to own.
|
|
7
|
+
//!
|
|
8
|
+
//! v0.6.7 descriptor contract: an artifact is identified by its own `name`
|
|
9
|
+
//! (`Snapshot::builder(name).from_sandbox(src)`), lives at `dest_dir/<name>`,
|
|
10
|
+
//! and its descriptor file is `snapshot.json`. `manifest.json` artifacts from
|
|
11
|
+
//! v0.6.6 are auto-migrated by the core on first backend connect.
|
|
7
12
|
|
|
8
13
|
use std::path::{Path, PathBuf};
|
|
9
14
|
|
|
10
15
|
use magnus::{function, prelude::*, Error, RArray, RHash, RModule, Ruby};
|
|
11
16
|
use microsandbox::snapshot::{
|
|
12
|
-
|
|
13
|
-
|
|
17
|
+
SaveOpts, Snapshot, SnapshotFormat, SnapshotHandle, SnapshotScope, SnapshotVerifyReport,
|
|
18
|
+
UpperVerifyStatus,
|
|
14
19
|
};
|
|
15
20
|
|
|
16
21
|
use crate::conv;
|
|
@@ -24,6 +29,13 @@ fn format_str(format: SnapshotFormat) -> &'static str {
|
|
|
24
29
|
}
|
|
25
30
|
}
|
|
26
31
|
|
|
32
|
+
fn scope_str(scope: SnapshotScope) -> &'static str {
|
|
33
|
+
match scope {
|
|
34
|
+
SnapshotScope::Disk => "disk",
|
|
35
|
+
SnapshotScope::Resumable => "resumable",
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
/// Parse a manifest's RFC 3339 `created_at` into epoch-ms (nil if unparseable).
|
|
28
40
|
fn created_at_ms(rfc3339: &str) -> Option<i64> {
|
|
29
41
|
chrono::DateTime::parse_from_rfc3339(rfc3339)
|
|
@@ -33,18 +45,31 @@ fn created_at_ms(rfc3339: &str) -> Option<i64> {
|
|
|
33
45
|
|
|
34
46
|
/// Convert a fully-opened `Snapshot` into the `SnapshotInfo` Hash. Unlike a
|
|
35
47
|
/// `SnapshotHandle` (a lightweight index row), an opened snapshot carries the
|
|
36
|
-
/// full
|
|
37
|
-
/// and the `SandboxHandle#snapshot
|
|
48
|
+
/// full descriptor, so this is the richest shape — `create`/`open`/`list_dir`
|
|
49
|
+
/// and the `SandboxHandle#snapshot` shortcut all funnel here. `format`/
|
|
50
|
+
/// `fstype`/`size_bytes` are nil for checkpoint-state snapshots, and the
|
|
51
|
+
/// `checkpoint_*` keys are nil for file-state ones — the two state families
|
|
52
|
+
/// are disjoint by contract.
|
|
38
53
|
pub(crate) fn snapshot_to_hash(snap: &Snapshot) -> RHash {
|
|
39
54
|
let m = snap.manifest();
|
|
55
|
+
let state = snap.state();
|
|
56
|
+
let file = state.as_file();
|
|
57
|
+
let checkpoint = state.as_checkpoint();
|
|
40
58
|
let hash = ruby().hash_new();
|
|
41
59
|
let _ = hash.aset("digest", snap.digest().to_string());
|
|
42
60
|
let _ = hash.aset("path", snap.path().to_string_lossy().into_owned());
|
|
43
61
|
let _ = hash.aset("size_bytes", snap.size_bytes());
|
|
62
|
+
let _ = hash.aset("scope", scope_str(m.scope));
|
|
63
|
+
let _ = hash.aset("state_kind", state.kind());
|
|
44
64
|
let _ = hash.aset("image_ref", m.image.reference.clone());
|
|
45
65
|
let _ = hash.aset("image_manifest_digest", m.image.manifest_digest.clone());
|
|
46
|
-
let _ = hash.aset("format", format_str(
|
|
47
|
-
let _ = hash.aset("fstype",
|
|
66
|
+
let _ = hash.aset("format", file.map(|f| format_str(f.format)));
|
|
67
|
+
let _ = hash.aset("fstype", file.map(|f| f.fstype.clone()));
|
|
68
|
+
let _ = hash.aset("checkpoint_id", checkpoint.map(|c| c.checkpoint_id.clone()));
|
|
69
|
+
let _ = hash.aset(
|
|
70
|
+
"checkpoint_manifest_digest",
|
|
71
|
+
checkpoint.map(|c| c.manifest.clone()),
|
|
72
|
+
);
|
|
48
73
|
let _ = hash.aset("parent_digest", m.parent.clone());
|
|
49
74
|
let _ = hash.aset("created_at_ms", created_at_ms(&m.created_at));
|
|
50
75
|
let _ = hash.aset("source_sandbox", m.source_sandbox.clone());
|
|
@@ -56,18 +81,21 @@ pub(crate) fn snapshot_to_hash(snap: &Snapshot) -> RHash {
|
|
|
56
81
|
hash
|
|
57
82
|
}
|
|
58
83
|
|
|
59
|
-
/// Create a snapshot
|
|
60
|
-
/// labels, force, record_integrity
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
84
|
+
/// Create a snapshot artifact named `name` from a stopped sandbox. `opts`:
|
|
85
|
+
/// from_sandbox (required), dest_dir, labels, force, record_integrity,
|
|
86
|
+
/// resumable. Returns the full SnapshotInfo Hash.
|
|
87
|
+
fn create(name: String, opts: RHash) -> Result<RHash, Error> {
|
|
88
|
+
let mut b = Snapshot::builder(name);
|
|
89
|
+
match conv::opt_string(opts, "from_sandbox")? {
|
|
90
|
+
Some(src) => b = b.from_sandbox(src),
|
|
91
|
+
None => {
|
|
92
|
+
return Err(error::base_error(
|
|
93
|
+
"snapshot create needs from_sandbox: (the source sandbox name)",
|
|
94
|
+
));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if let Some(dir) = conv::opt_string(opts, "dest_dir")? {
|
|
98
|
+
b = b.dest_dir(PathBuf::from(dir));
|
|
71
99
|
}
|
|
72
100
|
for (k, v) in conv::opt_string_map(opts, "labels")? {
|
|
73
101
|
b = b.label(k, v);
|
|
@@ -78,6 +106,9 @@ fn create(source_sandbox: String, opts: RHash) -> Result<RHash, Error> {
|
|
|
78
106
|
if conv::opt_bool(opts, "record_integrity")? {
|
|
79
107
|
b = b.record_integrity();
|
|
80
108
|
}
|
|
109
|
+
if conv::opt_bool(opts, "resumable")? {
|
|
110
|
+
b = b.resumable();
|
|
111
|
+
}
|
|
81
112
|
|
|
82
113
|
let snap = block_on(b.create()).map_err(error::to_ruby)?;
|
|
83
114
|
Ok(snapshot_to_hash(&snap))
|
|
@@ -92,8 +123,8 @@ fn open(path_or_name: String) -> Result<RHash, Error> {
|
|
|
92
123
|
Ok(snapshot_to_hash(&snap))
|
|
93
124
|
}
|
|
94
125
|
|
|
95
|
-
/// Walk `dir` and parse each subdirectory's `
|
|
96
|
-
/// local index — for enumerating external/un-imported snapshot collections.
|
|
126
|
+
/// Walk `dir` and parse each subdirectory's `snapshot.json` without touching
|
|
127
|
+
/// the local index — for enumerating external/un-imported snapshot collections.
|
|
97
128
|
fn list_dir(dir: String) -> Result<RArray, Error> {
|
|
98
129
|
let snaps = block_on(Snapshot::list_dir(Path::new(&dir))).map_err(error::to_ruby)?;
|
|
99
130
|
let arr = ruby().ary_new();
|
|
@@ -140,7 +171,9 @@ fn remove(name_or_path: String, force: bool) -> Result<(), Error> {
|
|
|
140
171
|
}
|
|
141
172
|
|
|
142
173
|
/// Verify a snapshot's recorded upper-layer integrity. Returns
|
|
143
|
-
/// {digest, path, upper_status, upper_algorithm
|
|
174
|
+
/// {digest, path, upper_status, upper_algorithm, upper_digest}. Schema-1
|
|
175
|
+
/// descriptors always record integrity, so the status is always "verified"
|
|
176
|
+
/// on success (mismatches raise SnapshotIntegrityError instead).
|
|
144
177
|
fn verify(name_or_path: String) -> Result<RHash, Error> {
|
|
145
178
|
let snap = block_on(Snapshot::open(&name_or_path)).map_err(error::to_ruby)?;
|
|
146
179
|
let report = block_on(snap.verify()).map_err(error::to_ruby)?;
|
|
@@ -149,25 +182,25 @@ fn verify(name_or_path: String) -> Result<RHash, Error> {
|
|
|
149
182
|
|
|
150
183
|
/// Bundle a snapshot into a `.tar.zst` (or plain `.tar`) archive. `opts`:
|
|
151
184
|
/// with_parents, with_image, plain_tar.
|
|
152
|
-
fn
|
|
153
|
-
let
|
|
185
|
+
fn save(name_or_path: String, out_path: String, opts: RHash) -> Result<(), Error> {
|
|
186
|
+
let save_opts = SaveOpts {
|
|
154
187
|
with_parents: conv::opt_bool(opts, "with_parents")?,
|
|
155
188
|
with_image: conv::opt_bool(opts, "with_image")?,
|
|
156
189
|
plain_tar: conv::opt_bool(opts, "plain_tar")?,
|
|
157
190
|
};
|
|
158
|
-
block_on(Snapshot::
|
|
191
|
+
block_on(Snapshot::save(
|
|
159
192
|
&name_or_path,
|
|
160
193
|
std::path::Path::new(&out_path),
|
|
161
|
-
|
|
194
|
+
save_opts,
|
|
162
195
|
))
|
|
163
196
|
.map_err(error::to_ruby)
|
|
164
197
|
}
|
|
165
198
|
|
|
166
|
-
/// Unpack a snapshot archive into the snapshots dir. Returns the
|
|
199
|
+
/// Unpack a snapshot archive into the snapshots dir. Returns the loaded
|
|
167
200
|
/// snapshot's metadata hash. `dest` is an optional explicit directory.
|
|
168
|
-
fn
|
|
201
|
+
fn load(archive_path: String, dest: Option<String>) -> Result<RHash, Error> {
|
|
169
202
|
let dest_path = dest.map(PathBuf::from);
|
|
170
|
-
let handle = block_on(Snapshot::
|
|
203
|
+
let handle = block_on(Snapshot::load(
|
|
171
204
|
std::path::Path::new(&archive_path),
|
|
172
205
|
dest_path.as_deref(),
|
|
173
206
|
))
|
|
@@ -180,9 +213,23 @@ fn handle_to_hash(handle: &SnapshotHandle) -> RHash {
|
|
|
180
213
|
let _ = hash.aset("digest", handle.digest().to_string());
|
|
181
214
|
let _ = hash.aset("name", handle.name().map(str::to_string));
|
|
182
215
|
let _ = hash.aset("parent_digest", handle.parent_digest().map(str::to_string));
|
|
216
|
+
let _ = hash.aset("scope", scope_str(handle.scope()));
|
|
217
|
+
let _ = hash.aset("state_kind", handle.state_kind().to_string());
|
|
183
218
|
let _ = hash.aset("image_ref", handle.image_ref().to_string());
|
|
184
|
-
let _ = hash.aset("format",
|
|
219
|
+
let _ = hash.aset("format", handle.format().map(format_str));
|
|
220
|
+
let _ = hash.aset("fstype", handle.fstype().map(str::to_string));
|
|
221
|
+
let _ = hash.aset(
|
|
222
|
+
"checkpoint_manifest_digest",
|
|
223
|
+
handle.checkpoint_manifest_digest().map(str::to_string),
|
|
224
|
+
);
|
|
185
225
|
let _ = hash.aset("size_bytes", handle.size_bytes());
|
|
226
|
+
let _ = hash.aset("locality", handle.locality().to_string());
|
|
227
|
+
let _ = hash.aset("availability", handle.availability().to_string());
|
|
228
|
+
let _ = hash.aset("migration_state", handle.migration_state().to_string());
|
|
229
|
+
let _ = hash.aset(
|
|
230
|
+
"migration_error_code",
|
|
231
|
+
handle.migration_error_code().map(str::to_string),
|
|
232
|
+
);
|
|
186
233
|
let _ = hash.aset("path", handle.path().to_string_lossy().into_owned());
|
|
187
234
|
let _ = hash.aset(
|
|
188
235
|
"created_at_ms",
|
|
@@ -196,9 +243,6 @@ fn verify_report_to_hash(report: &SnapshotVerifyReport) -> RHash {
|
|
|
196
243
|
let _ = hash.aset("digest", report.digest.clone());
|
|
197
244
|
let _ = hash.aset("path", report.path.to_string_lossy().into_owned());
|
|
198
245
|
match &report.upper {
|
|
199
|
-
UpperVerifyStatus::NotRecorded => {
|
|
200
|
-
let _ = hash.aset("upper_status", "not_recorded");
|
|
201
|
-
}
|
|
202
246
|
UpperVerifyStatus::Verified { algorithm, digest } => {
|
|
203
247
|
let _ = hash.aset("upper_status", "verified");
|
|
204
248
|
let _ = hash.aset("upper_algorithm", algorithm.clone());
|
|
@@ -218,7 +262,7 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
|
|
|
218
262
|
class.define_singleton_method("reindex", function!(reindex, 1))?;
|
|
219
263
|
class.define_singleton_method("remove", function!(remove, 2))?;
|
|
220
264
|
class.define_singleton_method("verify", function!(verify, 1))?;
|
|
221
|
-
class.define_singleton_method("
|
|
222
|
-
class.define_singleton_method("
|
|
265
|
+
class.define_singleton_method("save", function!(save, 3))?;
|
|
266
|
+
class.define_singleton_method("load", function!(load, 2))?;
|
|
223
267
|
Ok(())
|
|
224
268
|
}
|
|
@@ -9,7 +9,6 @@ use magnus::{function, method, prelude::*, Error, RArray, RHash, RModule, RStrin
|
|
|
9
9
|
use microsandbox::volume::VolumeHandle;
|
|
10
10
|
use microsandbox::Backend;
|
|
11
11
|
|
|
12
|
-
use crate::backend::local_backend;
|
|
13
12
|
use crate::conv;
|
|
14
13
|
use crate::error;
|
|
15
14
|
use crate::runtime::{block_on, ruby};
|
|
@@ -108,10 +107,13 @@ pub struct VolumeFs {
|
|
|
108
107
|
}
|
|
109
108
|
|
|
110
109
|
impl VolumeFs {
|
|
111
|
-
/// Resolve the
|
|
110
|
+
/// Resolve the ambient backend once and bind it to `name`. As of v0.6.8
|
|
111
|
+
/// every `VolumeFs` op dispatches through the backend's `VolumeBackend`
|
|
112
|
+
/// trait, which yields precise per-operation `Unsupported` errors on
|
|
113
|
+
/// backends that can't serve it — no local-only downcast needed here.
|
|
112
114
|
fn for_volume(name: String) -> Result<VolumeFs, Error> {
|
|
113
115
|
Ok(VolumeFs {
|
|
114
|
-
backend:
|
|
116
|
+
backend: microsandbox::default_backend(),
|
|
115
117
|
name,
|
|
116
118
|
})
|
|
117
119
|
}
|
data/lib/microsandbox/errors.rb
CHANGED
|
@@ -62,6 +62,10 @@ module Microsandbox
|
|
|
62
62
|
define_error(:SnapshotSandboxRunningError, "snapshot-sandbox-running")
|
|
63
63
|
define_error(:SnapshotImageMissingError, "snapshot-image-missing")
|
|
64
64
|
define_error(:SnapshotIntegrityError, "snapshot-integrity")
|
|
65
|
+
# v0.6.7: the automatic v0.6.6→v0.6.7 snapshot-descriptor migration (run at
|
|
66
|
+
# backend connect / artifact open) was blocked and needs repair. Mirrors the
|
|
67
|
+
# Python SDK's SnapshotMigrationError (code "snapshot-migration").
|
|
68
|
+
define_error(:SnapshotMigrationError, "snapshot-migration")
|
|
65
69
|
|
|
66
70
|
# Networking / secrets errors ---------------------------------------------
|
|
67
71
|
# NetworkPolicyError now also carries the core's `NetworkBuilder` build/parse
|
|
@@ -86,4 +90,16 @@ module Microsandbox
|
|
|
86
90
|
# cloud backend) is distinct from `UnsupportedOperationError` above.
|
|
87
91
|
define_error(:CloudHttpError, "cloud-http")
|
|
88
92
|
define_error(:UnsupportedError, "unsupported")
|
|
93
|
+
|
|
94
|
+
# As of runtime v0.6.8 the core keys Unsupported errors by a structured
|
|
95
|
+
# (operation, reason) pair. The native layer renders both into the message
|
|
96
|
+
# ("sandbox.kill is not supported by this backend: ...") and also attaches
|
|
97
|
+
# them here as structured attributes, mirroring the Python SDK's
|
|
98
|
+
# `UnsupportedError.operation` / `.hint`.
|
|
99
|
+
class UnsupportedError
|
|
100
|
+
# @return [String, nil] the rejected API in Ruby rendering, e.g. "sandbox.kill"
|
|
101
|
+
attr_reader :operation
|
|
102
|
+
# @return [String, nil] why it was rejected / what to use instead
|
|
103
|
+
attr_reader :hint
|
|
104
|
+
end
|
|
89
105
|
end
|