microsandbox-rb 0.10.0 → 0.11.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 +95 -0
- data/Cargo.lock +74 -95
- data/README.md +12 -9
- data/ext/microsandbox/Cargo.toml +4 -4
- data/ext/microsandbox/src/error.rs +4 -0
- data/ext/microsandbox/src/image.rs +38 -0
- data/ext/microsandbox/src/sandbox.rs +165 -37
- data/ext/microsandbox/src/snapshot.rs +79 -35
- data/lib/microsandbox/errors.rb +4 -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 +108 -22
- data/lib/microsandbox/snapshot.rb +87 -37
- data/lib/microsandbox/version.rb +2 -2
- data/lib/microsandbox.rb +1 -0
- data/sig/microsandbox.rbs +32 -11
- metadata +2 -1
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
|
|
8
8
|
use magnus::{function, prelude::*, Error, RArray, RHash, RModule, Ruby};
|
|
9
9
|
use microsandbox::image::{Image, ImageDetail, ImageHandle, ImagePruneReport};
|
|
10
|
+
use microsandbox::ImageArchiveFormat;
|
|
10
11
|
|
|
11
12
|
use crate::backend::with_local_backend;
|
|
12
13
|
use crate::conv;
|
|
14
|
+
use crate::error;
|
|
13
15
|
use crate::runtime::ruby;
|
|
14
16
|
|
|
15
17
|
fn handle_to_hash(h: &ImageHandle) -> RHash {
|
|
@@ -109,6 +111,40 @@ fn prune() -> Result<RHash, Error> {
|
|
|
109
111
|
Ok(report_to_hash(report))
|
|
110
112
|
}
|
|
111
113
|
|
|
114
|
+
/// Load images into the cache from a local `docker save` tarball or an OCI
|
|
115
|
+
/// Image Layout archive. `tags` optionally retags what was loaded. Returns an
|
|
116
|
+
/// Array of image-handle Hashes. Mirrors the Python `Image.load` / Node
|
|
117
|
+
/// `imageLoad` added in v0.6.7. (The `"-"` stdin form is spooled to a temp
|
|
118
|
+
/// file by the Ruby layer — the core reads seekable files only.)
|
|
119
|
+
fn load(input_path: String, tags: Vec<String>) -> Result<RArray, Error> {
|
|
120
|
+
let handles = with_local_backend(async |local| {
|
|
121
|
+
Image::load_local(local, std::path::Path::new(&input_path), tags).await
|
|
122
|
+
})?;
|
|
123
|
+
let arr = ruby().ary_new();
|
|
124
|
+
for h in handles.iter() {
|
|
125
|
+
arr.push(handle_to_hash(h))?;
|
|
126
|
+
}
|
|
127
|
+
Ok(arr)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// Save cached image(s) into a local archive. `format` is "docker" (default
|
|
131
|
+
/// shape callers expect from `docker load`) or "oci" (OCI Image Layout).
|
|
132
|
+
/// Mirrors the Python `Image.save` / Node `imageSave` added in v0.6.7.
|
|
133
|
+
fn save(references: Vec<String>, output_path: String, format: String) -> Result<(), Error> {
|
|
134
|
+
let fmt = match format.as_str() {
|
|
135
|
+
"docker" => ImageArchiveFormat::Docker,
|
|
136
|
+
"oci" => ImageArchiveFormat::Oci,
|
|
137
|
+
other => {
|
|
138
|
+
return Err(error::base_error(format!(
|
|
139
|
+
"invalid archive format {other:?}: expected \"docker\" or \"oci\""
|
|
140
|
+
)))
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
with_local_backend(async |local| {
|
|
144
|
+
Image::save_local(local, &references, std::path::Path::new(&output_path), fmt).await
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
112
148
|
pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
|
|
113
149
|
let class = native.define_class("Image", ruby.class_object())?;
|
|
114
150
|
class.define_singleton_method("get", function!(get, 1))?;
|
|
@@ -116,5 +152,7 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
|
|
|
116
152
|
class.define_singleton_method("inspect", function!(inspect, 1))?;
|
|
117
153
|
class.define_singleton_method("remove", function!(remove, 2))?;
|
|
118
154
|
class.define_singleton_method("prune", function!(prune, 0))?;
|
|
155
|
+
class.define_singleton_method("load", function!(load, 2))?;
|
|
156
|
+
class.define_singleton_method("save", function!(save, 3))?;
|
|
119
157
|
Ok(())
|
|
120
158
|
}
|
|
@@ -20,9 +20,9 @@ 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
|
-
SandboxBuilder, SandboxFilter, SandboxHandle, SandboxMetrics,
|
|
24
|
-
SandboxModificationPatch, SandboxStatus, SandboxStopResult,
|
|
25
|
-
SecretModificationPatch, SecretSource, SecurityProfile, StatVirtualization,
|
|
23
|
+
RootDiskBuilder, SandboxBuilder, SandboxFilter, SandboxHandle, SandboxMetrics,
|
|
24
|
+
SandboxModificationBuilder, SandboxModificationPatch, SandboxStatus, SandboxStopResult,
|
|
25
|
+
SecretBuilder, SecretModificationPatch, SecretSource, SecurityProfile, StatVirtualization,
|
|
26
26
|
};
|
|
27
27
|
use microsandbox::LogLevel;
|
|
28
28
|
use microsandbox::MicrosandboxResult;
|
|
@@ -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
|
|
@@ -1258,12 +1283,14 @@ fn parse_tls(t: RHash) -> Result<TlsSpec, Error> {
|
|
|
1258
1283
|
/// a core `NetworkPolicy`. Returns `None` when the option is absent (bare
|
|
1259
1284
|
/// presets travel via the separate `network` key handled in `create`).
|
|
1260
1285
|
///
|
|
1261
|
-
/// Composition (
|
|
1262
|
-
/// come first (so they outrank
|
|
1263
|
-
/// preset base
|
|
1264
|
-
///
|
|
1265
|
-
///
|
|
1266
|
-
///
|
|
1286
|
+
/// Composition order (first-match-wins per direction): bulk domain-deny rules
|
|
1287
|
+
/// come first (so they outrank everything), then the caller's explicit
|
|
1288
|
+
/// `rules`, then the profile/preset base's expansion LAST — the same order the
|
|
1289
|
+
/// upstream CLI composes `--net` with explicit rules, so a narrower caller
|
|
1290
|
+
/// override (e.g. `Rule.deny_dns`) beats the base's allows instead of dying
|
|
1291
|
+
/// behind them. Per-direction defaults come from the explicit
|
|
1292
|
+
/// `default_egress`/`default_ingress` when set, else the base's defaults, else
|
|
1293
|
+
/// the asymmetric default (deny egress / allow ingress).
|
|
1267
1294
|
fn parse_network_policy(opts: RHash) -> Result<Option<NetworkPolicy>, Error> {
|
|
1268
1295
|
let Some(np) = conv::opt::<RHash>(opts, "network_policy")? else {
|
|
1269
1296
|
return Ok(None);
|
|
@@ -1284,21 +1311,40 @@ fn parse_network_policy(opts: RHash) -> Result<Option<NetworkPolicy>, Error> {
|
|
|
1284
1311
|
rules.push(Rule::deny_egress(Destination::DomainSuffix(suffix)));
|
|
1285
1312
|
}
|
|
1286
1313
|
|
|
1287
|
-
// Optional
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1314
|
+
// Optional base — composable profiles (v0.6.7) or a terminal preset,
|
|
1315
|
+
// mutually exclusive. Its rules and defaults seed the policy.
|
|
1316
|
+
let profile_names = conv::opt_string_vec(np, "profiles")?;
|
|
1317
|
+
let preset = conv::opt_string(np, "preset")?;
|
|
1318
|
+
if !profile_names.is_empty() && preset.is_some() {
|
|
1319
|
+
return Err(error::base_error(
|
|
1320
|
+
"network profiles: and preset: are mutually exclusive",
|
|
1321
|
+
));
|
|
1322
|
+
}
|
|
1323
|
+
let base = if profile_names.is_empty() {
|
|
1324
|
+
preset.map(|p| network_preset(&p)).transpose()?
|
|
1325
|
+
} else {
|
|
1326
|
+
let profiles = profile_names
|
|
1327
|
+
.iter()
|
|
1328
|
+
.map(|s| network_profile_from_str(s))
|
|
1329
|
+
.collect::<Result<Vec<_>, Error>>()?;
|
|
1330
|
+
Some(NetworkPolicy::from_profiles(profiles))
|
|
1331
|
+
};
|
|
1332
|
+
// Caller's explicit rules go BEFORE the base expansion: under
|
|
1333
|
+
// first-match-wins, appending them after would make any override of a
|
|
1334
|
+
// base-allowed destination silently dead (a more permissive policy than
|
|
1335
|
+
// requested). Mirrors the upstream CLI's `--net` + rules composition.
|
|
1336
|
+
for rd in conv::opt_hash_vec(np, "rules")? {
|
|
1337
|
+
rules.push(parse_rule(rd)?);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
let (preset_egress, preset_ingress) = match base {
|
|
1341
|
+
Some(mut base) => {
|
|
1291
1342
|
rules.append(&mut base.rules);
|
|
1292
1343
|
(Some(base.default_egress), Some(base.default_ingress))
|
|
1293
1344
|
}
|
|
1294
1345
|
None => (None, None),
|
|
1295
1346
|
};
|
|
1296
1347
|
|
|
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
1348
|
let default_egress = match conv::opt_string(np, "default_egress")? {
|
|
1303
1349
|
Some(s) => action_from_str(&s)?,
|
|
1304
1350
|
None => preset_egress.unwrap_or(Action::Deny),
|
|
@@ -1318,15 +1364,102 @@ fn parse_network_policy(opts: RHash) -> Result<Option<NetworkPolicy>, Error> {
|
|
|
1318
1364
|
fn network_preset(p: &str) -> Result<NetworkPolicy, Error> {
|
|
1319
1365
|
Ok(match p {
|
|
1320
1366
|
"none" | "disabled" | "disable" | "airgapped" => NetworkPolicy::none(),
|
|
1321
|
-
"public" | "public_only" | "public-only" | "default" => NetworkPolicy::public_only(),
|
|
1322
1367
|
"all" | "allow_all" | "allow-all" => NetworkPolicy::allow_all(),
|
|
1323
|
-
"non_local" | "non-local" | "nonlocal" => NetworkPolicy::non_local(),
|
|
1324
1368
|
other => {
|
|
1325
1369
|
return Err(error::base_error(format!(
|
|
1326
|
-
"unknown network preset {other:?} (expected
|
|
1327
|
-
public_only/
|
|
1370
|
+
"unknown network preset {other:?} (expected none/allow_all; the removed \
|
|
1371
|
+
public_only/non_local presets are replaced by composable profiles)"
|
|
1372
|
+
)))
|
|
1373
|
+
}
|
|
1374
|
+
})
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
fn network_profile_from_str(s: &str) -> Result<NetworkProfile, Error> {
|
|
1378
|
+
match s {
|
|
1379
|
+
"public" => Ok(NetworkProfile::Public),
|
|
1380
|
+
"private" => Ok(NetworkProfile::Private),
|
|
1381
|
+
"host" => Ok(NetworkProfile::Host),
|
|
1382
|
+
other => Err(error::base_error(format!(
|
|
1383
|
+
"unknown network profile {other:?} (expected public/private/host)"
|
|
1384
|
+
))),
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/// Parsed `root_disk` create option. Integer shorthand = managed upper of N
|
|
1389
|
+
/// MiB (mirrors Python's `root_disk=8192` and the CLI bare-size form); Hash =
|
|
1390
|
+
/// explicit kind. Kind/field cross-validation (size on a disk image, etc.)
|
|
1391
|
+
/// lives in the Ruby layer and the core builder — this stays a dumb carrier.
|
|
1392
|
+
struct RootDiskSpec {
|
|
1393
|
+
kind: RootDiskKindSpec,
|
|
1394
|
+
size_mib: Option<u32>,
|
|
1395
|
+
format: Option<DiskImageFormat>,
|
|
1396
|
+
fstype: Option<String>,
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
enum RootDiskKindSpec {
|
|
1400
|
+
Managed,
|
|
1401
|
+
Tmpfs,
|
|
1402
|
+
Disk(String),
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
impl RootDiskSpec {
|
|
1406
|
+
fn apply(self, mut d: RootDiskBuilder) -> RootDiskBuilder {
|
|
1407
|
+
match self.kind {
|
|
1408
|
+
RootDiskKindSpec::Managed => {}
|
|
1409
|
+
RootDiskKindSpec::Tmpfs => d = d.tmpfs(),
|
|
1410
|
+
RootDiskKindSpec::Disk(path) => d = d.disk_image(path),
|
|
1411
|
+
}
|
|
1412
|
+
if let Some(mib) = self.size_mib {
|
|
1413
|
+
d = d.size(mib);
|
|
1414
|
+
}
|
|
1415
|
+
if let Some(f) = self.format {
|
|
1416
|
+
d = d.format(f);
|
|
1417
|
+
}
|
|
1418
|
+
if let Some(ft) = self.fstype {
|
|
1419
|
+
d = d.fstype(ft);
|
|
1420
|
+
}
|
|
1421
|
+
d
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
fn parse_root_disk(v: Value) -> Result<RootDiskSpec, Error> {
|
|
1426
|
+
if let Ok(mib) = u32::try_convert(v) {
|
|
1427
|
+
return Ok(RootDiskSpec {
|
|
1428
|
+
kind: RootDiskKindSpec::Managed,
|
|
1429
|
+
size_mib: Some(mib),
|
|
1430
|
+
format: None,
|
|
1431
|
+
fstype: None,
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
let Ok(h) = RHash::try_convert(v) else {
|
|
1435
|
+
return Err(error::base_error(
|
|
1436
|
+
"root_disk: expects an Integer (managed size in MiB) or a Hash \
|
|
1437
|
+
(use Microsandbox::RootDisk.managed/tmpfs/disk)",
|
|
1438
|
+
));
|
|
1439
|
+
};
|
|
1440
|
+
let kind = match conv::opt_string(h, "kind")?.as_deref() {
|
|
1441
|
+
None | Some("managed") => RootDiskKindSpec::Managed,
|
|
1442
|
+
Some("tmpfs") => RootDiskKindSpec::Tmpfs,
|
|
1443
|
+
Some("disk" | "disk-image" | "disk_image") => {
|
|
1444
|
+
let Some(path) = conv::opt_string(h, "path")? else {
|
|
1445
|
+
return Err(error::base_error("root_disk disk kind requires path:"));
|
|
1446
|
+
};
|
|
1447
|
+
RootDiskKindSpec::Disk(path)
|
|
1448
|
+
}
|
|
1449
|
+
Some(other) => {
|
|
1450
|
+
return Err(error::base_error(format!(
|
|
1451
|
+
"unknown root_disk kind {other:?} (expected managed/tmpfs/disk)"
|
|
1328
1452
|
)))
|
|
1329
1453
|
}
|
|
1454
|
+
};
|
|
1455
|
+
let format = conv::opt_string(h, "format")?
|
|
1456
|
+
.map(|f| disk_format_from_str(&f))
|
|
1457
|
+
.transpose()?;
|
|
1458
|
+
Ok(RootDiskSpec {
|
|
1459
|
+
kind,
|
|
1460
|
+
size_mib: conv::opt_u32(h, "size_mib")?,
|
|
1461
|
+
format,
|
|
1462
|
+
fstype: conv::opt_string(h, "fstype")?,
|
|
1330
1463
|
})
|
|
1331
1464
|
}
|
|
1332
1465
|
|
|
@@ -2143,17 +2276,13 @@ impl SbHandle {
|
|
|
2143
2276
|
/// Snapshot this (stopped) sandbox under a bare name (resolved under the
|
|
2144
2277
|
/// snapshots directory). Returns the same SnapshotInfo Hash as
|
|
2145
2278
|
/// `Snapshot.create`. Mirrors the Python/Node `handle.snapshot(name)`.
|
|
2279
|
+
/// (`snapshot_to` was removed upstream in v0.6.7 — use `Snapshot.create`
|
|
2280
|
+
/// with `dest_dir:` for explicit placement.)
|
|
2146
2281
|
fn snapshot(&self, name: String) -> Result<RHash, Error> {
|
|
2147
2282
|
let snap = block_on(self.inner.snapshot(&name)).map_err(error::to_ruby)?;
|
|
2148
2283
|
Ok(crate::snapshot::snapshot_to_hash(&snap))
|
|
2149
2284
|
}
|
|
2150
2285
|
|
|
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
2286
|
/// Health-check the guest agent without refreshing the idle timer. A stopped
|
|
2158
2287
|
/// sandbox raises `SandboxNotRunningError` (the handle does not start it
|
|
2159
2288
|
/// implicitly). Returns `{ name, latency_secs }`.
|
|
@@ -2337,7 +2466,6 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
|
|
|
2337
2466
|
)?;
|
|
2338
2467
|
handle.define_method("config_json", method!(SbHandle::config_json, 0))?;
|
|
2339
2468
|
handle.define_method("snapshot", method!(SbHandle::snapshot, 1))?;
|
|
2340
|
-
handle.define_method("snapshot_to", method!(SbHandle::snapshot_to, 1))?;
|
|
2341
2469
|
handle.define_method("ping", method!(SbHandle::ping, 0))?;
|
|
2342
2470
|
handle.define_method("touch", method!(SbHandle::touch, 0))?;
|
|
2343
2471
|
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
|
}
|
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
|
data/lib/microsandbox/image.rb
CHANGED
|
@@ -107,6 +107,44 @@ module Microsandbox
|
|
|
107
107
|
def prune
|
|
108
108
|
ImagePruneReport.new(Native::Image.prune)
|
|
109
109
|
end
|
|
110
|
+
|
|
111
|
+
# Load images into the cache from a local `docker save` tarball or an
|
|
112
|
+
# OCI Image Layout archive. Pass `"-"` to read the archive from `$stdin`
|
|
113
|
+
# (spooled to a temp file first, like the Python SDK — the core reads
|
|
114
|
+
# seekable files only). Mirrors the Python `Image.load` / Node
|
|
115
|
+
# `imageLoad` (runtime v0.6.7).
|
|
116
|
+
# @param input_path [String] archive path (or "-")
|
|
117
|
+
# @param tag [String, Array<String>, nil] retag the loaded image(s)
|
|
118
|
+
# @return [Array<ImageInfo>] one entry per loaded image
|
|
119
|
+
def load(input_path, tag: nil)
|
|
120
|
+
tags = Array(tag).map(&:to_s)
|
|
121
|
+
path = input_path.to_s
|
|
122
|
+
if path == "-"
|
|
123
|
+
require "tempfile"
|
|
124
|
+
Tempfile.create(["msb-image-load", ".tar"]) do |tmp|
|
|
125
|
+
tmp.binmode
|
|
126
|
+
IO.copy_stream($stdin, tmp)
|
|
127
|
+
tmp.flush
|
|
128
|
+
return Native::Image.load(tmp.path, tags).map { |info| ImageInfo.new(info) }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
Native::Image.load(path, tags).map { |info| ImageInfo.new(info) }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Save cached image(s) into a local archive.
|
|
135
|
+
# Mirrors the Python `Image.save` / Node `imageSave` (runtime v0.6.7).
|
|
136
|
+
# @param reference [String, Array<String>] image reference(s) to save
|
|
137
|
+
# @param output_path [String] destination archive path
|
|
138
|
+
# @param format [:docker, :oci] archive layout (default :docker, the
|
|
139
|
+
# shape `docker load` expects)
|
|
140
|
+
# @return [nil]
|
|
141
|
+
def save(reference, output_path:, format: :docker)
|
|
142
|
+
refs = Array(reference).map(&:to_s)
|
|
143
|
+
raise ArgumentError, "at least one image reference is required" if refs.empty?
|
|
144
|
+
|
|
145
|
+
Native::Image.save(refs, output_path.to_s, format.to_s)
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
110
148
|
end
|
|
111
149
|
end
|
|
112
150
|
end
|