microsandbox-rb 0.9.2 → 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 +176 -0
- data/Cargo.lock +126 -141
- data/README.md +63 -9
- data/ext/microsandbox/Cargo.toml +4 -4
- data/ext/microsandbox/src/error.rs +10 -0
- data/ext/microsandbox/src/image.rs +38 -0
- data/ext/microsandbox/src/sandbox.rs +357 -36
- 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/modification.rb +108 -0
- data/lib/microsandbox/network.rb +139 -31
- data/lib/microsandbox/root_disk.rb +58 -0
- data/lib/microsandbox/sandbox.rb +285 -23
- data/lib/microsandbox/snapshot.rb +87 -37
- data/lib/microsandbox/version.rb +2 -2
- data/lib/microsandbox.rb +2 -0
- data/sig/microsandbox.rbs +75 -12
- metadata +3 -1
|
@@ -18,10 +18,11 @@ use microsandbox::logs::{
|
|
|
18
18
|
LogCursor, LogEntry, LogOptions, LogSource, LogStreamOptions, LogStreamStart,
|
|
19
19
|
};
|
|
20
20
|
use microsandbox::sandbox::{
|
|
21
|
-
AttachOptionsBuilder, DiskImageFormat, FsEntry, FsEntryKind, FsMetadata,
|
|
22
|
-
Patch, PullPolicy, PullProgress, PullProgressHandle, RlimitResource,
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
AttachOptionsBuilder, DiskImageFormat, EnvVar, FsEntry, FsEntryKind, FsMetadata,
|
|
22
|
+
HostPermissions, Patch, PullPolicy, PullProgress, PullProgressHandle, RlimitResource,
|
|
23
|
+
RootDiskBuilder, SandboxBuilder, SandboxFilter, SandboxHandle, SandboxMetrics,
|
|
24
|
+
SandboxModificationBuilder, SandboxModificationPatch, SandboxStatus, SandboxStopResult,
|
|
25
|
+
SecretBuilder, SecretModificationPatch, SecretSource, SecurityProfile, StatVirtualization,
|
|
25
26
|
};
|
|
26
27
|
use microsandbox::LogLevel;
|
|
27
28
|
use microsandbox::MicrosandboxResult;
|
|
@@ -29,7 +30,8 @@ use microsandbox::RegistryAuth;
|
|
|
29
30
|
use microsandbox_network::builder::ViolationActionBuilder;
|
|
30
31
|
use microsandbox_network::dns::Nameserver;
|
|
31
32
|
use microsandbox_network::policy::{
|
|
32
|
-
Action, Destination, DestinationGroup, Direction, NetworkPolicy,
|
|
33
|
+
Action, Destination, DestinationGroup, Direction, NetworkPolicy, NetworkProfile, PortRange,
|
|
34
|
+
Protocol, Rule,
|
|
33
35
|
};
|
|
34
36
|
use tokio::sync::Mutex;
|
|
35
37
|
use tokio::task::JoinHandle;
|
|
@@ -92,9 +94,19 @@ impl Sandbox {
|
|
|
92
94
|
if let Some(v) = conv::opt_u8(opts, "cpus")? {
|
|
93
95
|
b = b.cpus(v);
|
|
94
96
|
}
|
|
97
|
+
// v0.6.6 (#1099): boot-time maximum vCPU / memory ceilings that a later
|
|
98
|
+
// live `modify` can grow up to. `cpus`/`memory` above already raise these
|
|
99
|
+
// to their own value if lower, so a bare `cpus`/`memory` still works;
|
|
100
|
+
// setting these explicitly reserves headroom for a live resize.
|
|
101
|
+
if let Some(v) = conv::opt_u8(opts, "max_cpus")? {
|
|
102
|
+
b = b.max_cpus(v);
|
|
103
|
+
}
|
|
95
104
|
if let Some(v) = conv::opt_u32(opts, "memory")? {
|
|
96
105
|
b = b.memory(v);
|
|
97
106
|
}
|
|
107
|
+
if let Some(v) = conv::opt_u32(opts, "max_memory")? {
|
|
108
|
+
b = b.max_memory(v);
|
|
109
|
+
}
|
|
98
110
|
if let Some(v) = conv::opt_string(opts, "workdir")? {
|
|
99
111
|
b = b.workdir(v);
|
|
100
112
|
}
|
|
@@ -155,6 +167,10 @@ impl Sandbox {
|
|
|
155
167
|
let host_perms = conv::opt_string(m, "host_permissions")?
|
|
156
168
|
.map(|s| host_permissions_from_str(&s))
|
|
157
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")?;
|
|
158
174
|
// bind/named/disk require a source; tmpfs must not have one.
|
|
159
175
|
match kind.as_str() {
|
|
160
176
|
"bind" | "named" | "disk" if source.is_some() => {}
|
|
@@ -207,6 +223,9 @@ impl Sandbox {
|
|
|
207
223
|
if let Some(hp) = host_perms {
|
|
208
224
|
mb = mb.host_permissions(hp);
|
|
209
225
|
}
|
|
226
|
+
if let Some(follow) = follow_root_symlinks {
|
|
227
|
+
mb = mb.follow_root_symlinks(follow);
|
|
228
|
+
}
|
|
210
229
|
mb
|
|
211
230
|
});
|
|
212
231
|
}
|
|
@@ -219,18 +238,29 @@ impl Sandbox {
|
|
|
219
238
|
if let Some(net) = conv::opt_string(opts, "network")? {
|
|
220
239
|
match net.as_str() {
|
|
221
240
|
"none" | "disabled" | "disable" | "airgapped" => b = b.disable_network(),
|
|
222
|
-
// Default policy is public-only, so no builder call is needed.
|
|
223
|
-
"public" | "public_only" | "default" => {}
|
|
224
241
|
"all" | "allow_all" => b = b.network(|n| n.policy(NetworkPolicy::allow_all())),
|
|
225
|
-
"non_local" | "nonlocal" => b = b.network(|n| n.policy(NetworkPolicy::non_local())),
|
|
226
242
|
other => {
|
|
227
243
|
return Err(error::base_error(format!(
|
|
228
|
-
"unknown network mode {other:?} (expected
|
|
229
|
-
|
|
244
|
+
"unknown network mode {other:?} (expected none/allow_all; compose \
|
|
245
|
+
public/private/host access via network profiles)"
|
|
230
246
|
)))
|
|
231
247
|
}
|
|
232
248
|
}
|
|
233
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
|
+
}
|
|
234
264
|
// Custom network policy: an ordered allow/deny rule list with per-direction
|
|
235
265
|
// defaults and bulk domain denials. The Ruby layer routes bare presets to
|
|
236
266
|
// the `network` key above and full policies here; mirrors the Python
|
|
@@ -250,8 +280,14 @@ impl Sandbox {
|
|
|
250
280
|
if let Some(policy) = conv::opt_string(opts, "pull_policy")? {
|
|
251
281
|
b = b.pull_policy(pull_policy_from_str(&policy)?);
|
|
252
282
|
}
|
|
253
|
-
|
|
254
|
-
|
|
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));
|
|
255
291
|
}
|
|
256
292
|
// Registry connection settings, for private / non-default registries:
|
|
257
293
|
// Basic auth (username + password/token), plain-HTTP `insecure`, and
|
|
@@ -584,6 +620,34 @@ impl Sandbox {
|
|
|
584
620
|
Ok(metrics_to_hash(&m))
|
|
585
621
|
}
|
|
586
622
|
|
|
623
|
+
//----------------------------------------------------------------------
|
|
624
|
+
// Health & modification (v0.6.6 / #1099)
|
|
625
|
+
//----------------------------------------------------------------------
|
|
626
|
+
|
|
627
|
+
/// Health-check the guest agent without refreshing the idle timer. Returns
|
|
628
|
+
/// `{ name, latency_secs }` (round-trip latency as f64 seconds; the Ruby
|
|
629
|
+
/// layer exposes both seconds and milliseconds).
|
|
630
|
+
fn ping(&self) -> Result<RHash, Error> {
|
|
631
|
+
let result = block_on(self.inner.ping()).map_err(error::to_ruby)?;
|
|
632
|
+
Ok(ping_result_to_hash(&result))
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/// Explicitly refresh the idle-activity timer. Returns
|
|
636
|
+
/// `{ name, activity_seq }`.
|
|
637
|
+
fn touch(&self) -> Result<RHash, Error> {
|
|
638
|
+
let result = block_on(self.inner.touch()).map_err(error::to_ruby)?;
|
|
639
|
+
Ok(touch_result_to_hash(&result))
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/// Plan or apply a live sandbox modification. `opts` is the string-keyed
|
|
643
|
+
/// modify Hash normalized by the Ruby layer; returns the resulting
|
|
644
|
+
/// `SandboxModificationPlan` as a JSON string, which the Ruby layer parses
|
|
645
|
+
/// into a `ModificationPlan`. Mirrors the Node native binding (native returns
|
|
646
|
+
/// JSON, the wrapper shapes it).
|
|
647
|
+
fn modify(&self, opts: RHash) -> Result<String, Error> {
|
|
648
|
+
run_modify(self.inner.modify(), opts)
|
|
649
|
+
}
|
|
650
|
+
|
|
587
651
|
/// Read captured logs as an Array of Hashes. `opts`: tail, since_ms,
|
|
588
652
|
/// until_ms, sources (Array of "stdout"/"stderr"/"output"/"system"/"all").
|
|
589
653
|
fn logs(&self, opts: RHash) -> Result<RArray, Error> {
|
|
@@ -1219,12 +1283,14 @@ fn parse_tls(t: RHash) -> Result<TlsSpec, Error> {
|
|
|
1219
1283
|
/// a core `NetworkPolicy`. Returns `None` when the option is absent (bare
|
|
1220
1284
|
/// presets travel via the separate `network` key handled in `create`).
|
|
1221
1285
|
///
|
|
1222
|
-
/// Composition (
|
|
1223
|
-
/// come first (so they outrank
|
|
1224
|
-
/// preset base
|
|
1225
|
-
///
|
|
1226
|
-
///
|
|
1227
|
-
///
|
|
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).
|
|
1228
1294
|
fn parse_network_policy(opts: RHash) -> Result<Option<NetworkPolicy>, Error> {
|
|
1229
1295
|
let Some(np) = conv::opt::<RHash>(opts, "network_policy")? else {
|
|
1230
1296
|
return Ok(None);
|
|
@@ -1245,21 +1311,40 @@ fn parse_network_policy(opts: RHash) -> Result<Option<NetworkPolicy>, Error> {
|
|
|
1245
1311
|
rules.push(Rule::deny_egress(Destination::DomainSuffix(suffix)));
|
|
1246
1312
|
}
|
|
1247
1313
|
|
|
1248
|
-
// Optional
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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) => {
|
|
1252
1342
|
rules.append(&mut base.rules);
|
|
1253
1343
|
(Some(base.default_egress), Some(base.default_ingress))
|
|
1254
1344
|
}
|
|
1255
1345
|
None => (None, None),
|
|
1256
1346
|
};
|
|
1257
1347
|
|
|
1258
|
-
// Caller's explicit rules come after preset rules.
|
|
1259
|
-
for rd in conv::opt_hash_vec(np, "rules")? {
|
|
1260
|
-
rules.push(parse_rule(rd)?);
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
1348
|
let default_egress = match conv::opt_string(np, "default_egress")? {
|
|
1264
1349
|
Some(s) => action_from_str(&s)?,
|
|
1265
1350
|
None => preset_egress.unwrap_or(Action::Deny),
|
|
@@ -1279,18 +1364,105 @@ fn parse_network_policy(opts: RHash) -> Result<Option<NetworkPolicy>, Error> {
|
|
|
1279
1364
|
fn network_preset(p: &str) -> Result<NetworkPolicy, Error> {
|
|
1280
1365
|
Ok(match p {
|
|
1281
1366
|
"none" | "disabled" | "disable" | "airgapped" => NetworkPolicy::none(),
|
|
1282
|
-
"public" | "public_only" | "public-only" | "default" => NetworkPolicy::public_only(),
|
|
1283
1367
|
"all" | "allow_all" | "allow-all" => NetworkPolicy::allow_all(),
|
|
1284
|
-
"non_local" | "non-local" | "nonlocal" => NetworkPolicy::non_local(),
|
|
1285
1368
|
other => {
|
|
1286
1369
|
return Err(error::base_error(format!(
|
|
1287
|
-
"unknown network preset {other:?} (expected
|
|
1288
|
-
public_only/
|
|
1370
|
+
"unknown network preset {other:?} (expected none/allow_all; the removed \
|
|
1371
|
+
public_only/non_local presets are replaced by composable profiles)"
|
|
1289
1372
|
)))
|
|
1290
1373
|
}
|
|
1291
1374
|
})
|
|
1292
1375
|
}
|
|
1293
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)"
|
|
1452
|
+
)))
|
|
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")?,
|
|
1463
|
+
})
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1294
1466
|
fn action_from_str(s: &str) -> Result<Action, Error> {
|
|
1295
1467
|
match s {
|
|
1296
1468
|
"allow" => Ok(Action::Allow),
|
|
@@ -1736,6 +1908,133 @@ fn stop_result_to_hash(result: &SandboxStopResult) -> RHash {
|
|
|
1736
1908
|
hash
|
|
1737
1909
|
}
|
|
1738
1910
|
|
|
1911
|
+
//--------------------------------------------------------------------------------------------------
|
|
1912
|
+
// Health & live modification (v0.6.6 / #1099)
|
|
1913
|
+
//--------------------------------------------------------------------------------------------------
|
|
1914
|
+
|
|
1915
|
+
/// A `SandboxPingResult` as `{ name, latency_secs }`. Latency is a `Duration`;
|
|
1916
|
+
/// we hand it to Ruby as f64 seconds and let the Ruby layer derive milliseconds
|
|
1917
|
+
/// (the Python/Node SDKs expose only `latency_ms`).
|
|
1918
|
+
fn ping_result_to_hash(result: µsandbox::sandbox::SandboxPingResult) -> RHash {
|
|
1919
|
+
let hash = ruby().hash_new();
|
|
1920
|
+
let _ = hash.aset("name", result.name.clone());
|
|
1921
|
+
let _ = hash.aset("latency_secs", result.latency.as_secs_f64());
|
|
1922
|
+
hash
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
/// A `SandboxTouchResult` as `{ name, activity_seq }`.
|
|
1926
|
+
fn touch_result_to_hash(result: µsandbox::sandbox::SandboxTouchResult) -> RHash {
|
|
1927
|
+
let hash = ruby().hash_new();
|
|
1928
|
+
let _ = hash.aset("name", result.name.clone());
|
|
1929
|
+
let _ = hash.aset("activity_seq", result.activity_seq);
|
|
1930
|
+
hash
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
/// Drive a modify builder to a plan (dry-run or apply) and return it serialized
|
|
1934
|
+
/// as a JSON string. Shared by `Sandbox::modify` and `SbHandle::modify`. The
|
|
1935
|
+
/// canonical `SandboxModificationPlan` serde shape (snake_case keys, literal-space
|
|
1936
|
+
/// enum strings) is parsed verbatim by the Ruby layer — matching the Python SDK's
|
|
1937
|
+
/// raw-dict contract rather than re-casing it.
|
|
1938
|
+
fn run_modify(builder: SandboxModificationBuilder, opts: RHash) -> Result<String, Error> {
|
|
1939
|
+
let patch = build_modify_patch(opts)?;
|
|
1940
|
+
let policy = conv::opt_string(opts, "policy")?.unwrap_or_else(|| "no_restart".to_string());
|
|
1941
|
+
let dry_run = conv::opt_bool(opts, "dry_run")?;
|
|
1942
|
+
let builder = apply_modify_policy(builder.with_patch(patch), &policy)?;
|
|
1943
|
+
let plan = block_on(async move {
|
|
1944
|
+
if dry_run {
|
|
1945
|
+
builder.dry_run().await
|
|
1946
|
+
} else {
|
|
1947
|
+
builder.apply().await
|
|
1948
|
+
}
|
|
1949
|
+
})
|
|
1950
|
+
.map_err(error::to_ruby)?;
|
|
1951
|
+
serde_json::to_string(&plan)
|
|
1952
|
+
.map_err(|e| error::base_error(format!("failed to serialize modification plan: {e}")))
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
/// Build the canonical `SandboxModificationPatch` from the modify Hash. Env and
|
|
1956
|
+
/// label pairs are sorted so repeated calls with the same arguments produce the
|
|
1957
|
+
/// same patch (and plan) ordering, mirroring the Python binding. `oci_upper_size`
|
|
1958
|
+
/// is deliberately not surfaced (CLI-only upstream; the Python/Node SDKs omit it
|
|
1959
|
+
/// too), so it stays unset via `..Default::default()`.
|
|
1960
|
+
fn build_modify_patch(opts: RHash) -> Result<SandboxModificationPatch, Error> {
|
|
1961
|
+
let mut env_pairs = conv::opt_string_map(opts, "env")?;
|
|
1962
|
+
env_pairs.sort();
|
|
1963
|
+
let mut label_pairs = conv::opt_string_map(opts, "labels")?;
|
|
1964
|
+
label_pairs.sort();
|
|
1965
|
+
|
|
1966
|
+
Ok(SandboxModificationPatch {
|
|
1967
|
+
cpus: conv::opt_u8(opts, "cpus")?,
|
|
1968
|
+
max_cpus: conv::opt_u8(opts, "max_cpus")?,
|
|
1969
|
+
memory_mib: conv::opt_u32(opts, "memory")?,
|
|
1970
|
+
max_memory_mib: conv::opt_u32(opts, "max_memory")?,
|
|
1971
|
+
env: env_pairs
|
|
1972
|
+
.into_iter()
|
|
1973
|
+
.map(|(k, v)| EnvVar::new(k, v))
|
|
1974
|
+
.collect(),
|
|
1975
|
+
env_remove: conv::opt_string_vec(opts, "remove_env")?,
|
|
1976
|
+
labels: label_pairs,
|
|
1977
|
+
labels_remove: conv::opt_string_vec(opts, "remove_labels")?,
|
|
1978
|
+
workdir: conv::opt_string(opts, "workdir")?,
|
|
1979
|
+
secrets: parse_modify_secrets(opts)?,
|
|
1980
|
+
secrets_remove: conv::opt_string_vec(opts, "remove_secrets")?,
|
|
1981
|
+
..Default::default()
|
|
1982
|
+
})
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
/// Parse the `secrets` modify option (an Array of string-keyed Hashes normalized
|
|
1986
|
+
/// by the Ruby layer) into canonical `SecretModificationPatch`es. Each Hash
|
|
1987
|
+
/// carries `name` plus at most one of `env`/`store` (source) or `value` (raw
|
|
1988
|
+
/// material) — the Ruby layer enforces that mutual exclusion — plus optional
|
|
1989
|
+
/// `placeholder` and `allowed_hosts`. The raw `value` moves straight into the
|
|
1990
|
+
/// `Zeroizing` field; it is never echoed in an error.
|
|
1991
|
+
fn parse_modify_secrets(opts: RHash) -> Result<Vec<SecretModificationPatch>, Error> {
|
|
1992
|
+
let mut out = Vec::new();
|
|
1993
|
+
for h in conv::opt_hash_vec(opts, "secrets")? {
|
|
1994
|
+
let name = conv::opt_string(h, "name")?
|
|
1995
|
+
.ok_or_else(|| error::base_error("secret modification spec requires :name"))?;
|
|
1996
|
+
let env = conv::opt_string(h, "env")?;
|
|
1997
|
+
let store = conv::opt_string(h, "store")?;
|
|
1998
|
+
let value = conv::opt_string(h, "value")?;
|
|
1999
|
+
let source = match (env, store) {
|
|
2000
|
+
(Some(var), None) => Some(SecretSource::Env { var }),
|
|
2001
|
+
(None, Some(reference)) => Some(SecretSource::Store { reference }),
|
|
2002
|
+
(None, None) => None,
|
|
2003
|
+
// The Ruby layer rejects >1 source; guard defensively without
|
|
2004
|
+
// echoing any value.
|
|
2005
|
+
(Some(_), Some(_)) => {
|
|
2006
|
+
return Err(error::base_error(format!(
|
|
2007
|
+
"secret {name:?}: \"env\" and \"store\" are mutually exclusive"
|
|
2008
|
+
)))
|
|
2009
|
+
}
|
|
2010
|
+
};
|
|
2011
|
+
out.push(SecretModificationPatch {
|
|
2012
|
+
name,
|
|
2013
|
+
source,
|
|
2014
|
+
value: value.unwrap_or_default().into(),
|
|
2015
|
+
placeholder: conv::opt_string(h, "placeholder")?,
|
|
2016
|
+
allowed_hosts: conv::opt_string_vec(h, "allowed_hosts")?,
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
Ok(out)
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
/// Apply the parsed `policy` string to a modify builder. `no_restart` (default)
|
|
2023
|
+
/// leaves the builder untouched; `next_start`/`restart` select those policies.
|
|
2024
|
+
fn apply_modify_policy(
|
|
2025
|
+
builder: SandboxModificationBuilder,
|
|
2026
|
+
policy: &str,
|
|
2027
|
+
) -> Result<SandboxModificationBuilder, Error> {
|
|
2028
|
+
match policy {
|
|
2029
|
+
"no_restart" => Ok(builder),
|
|
2030
|
+
"next_start" => Ok(builder.next_start()),
|
|
2031
|
+
"restart" => Ok(builder.restart()),
|
|
2032
|
+
other => Err(error::base_error(format!(
|
|
2033
|
+
"unknown policy {other:?}; expected \"no_restart\", \"next_start\", or \"restart\""
|
|
2034
|
+
))),
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
|
|
1739
2038
|
//--------------------------------------------------------------------------------------------------
|
|
1740
2039
|
// Pull progress (streaming image-pull during create_with_progress)
|
|
1741
2040
|
//--------------------------------------------------------------------------------------------------
|
|
@@ -1977,15 +2276,32 @@ impl SbHandle {
|
|
|
1977
2276
|
/// Snapshot this (stopped) sandbox under a bare name (resolved under the
|
|
1978
2277
|
/// snapshots directory). Returns the same SnapshotInfo Hash as
|
|
1979
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.)
|
|
1980
2281
|
fn snapshot(&self, name: String) -> Result<RHash, Error> {
|
|
1981
2282
|
let snap = block_on(self.inner.snapshot(&name)).map_err(error::to_ruby)?;
|
|
1982
2283
|
Ok(crate::snapshot::snapshot_to_hash(&snap))
|
|
1983
2284
|
}
|
|
1984
2285
|
|
|
1985
|
-
///
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
2286
|
+
/// Health-check the guest agent without refreshing the idle timer. A stopped
|
|
2287
|
+
/// sandbox raises `SandboxNotRunningError` (the handle does not start it
|
|
2288
|
+
/// implicitly). Returns `{ name, latency_secs }`.
|
|
2289
|
+
fn ping(&self) -> Result<RHash, Error> {
|
|
2290
|
+
let result = block_on(self.inner.ping()).map_err(error::to_ruby)?;
|
|
2291
|
+
Ok(ping_result_to_hash(&result))
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
/// Explicitly refresh the idle-activity timer. A stopped sandbox raises
|
|
2295
|
+
/// `SandboxNotRunningError`. Returns `{ name, activity_seq }`.
|
|
2296
|
+
fn touch(&self) -> Result<RHash, Error> {
|
|
2297
|
+
let result = block_on(self.inner.touch()).map_err(error::to_ruby)?;
|
|
2298
|
+
Ok(touch_result_to_hash(&result))
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
/// Plan or apply a live sandbox modification (see `Sandbox::modify`). Returns
|
|
2302
|
+
/// the `SandboxModificationPlan` as a JSON string.
|
|
2303
|
+
fn modify(&self, opts: RHash) -> Result<String, Error> {
|
|
2304
|
+
run_modify(self.inner.modify(), opts)
|
|
1989
2305
|
}
|
|
1990
2306
|
}
|
|
1991
2307
|
|
|
@@ -2100,6 +2416,9 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
|
|
|
2100
2416
|
class.define_method("owns_lifecycle", method!(Sandbox::owns_lifecycle, 0))?;
|
|
2101
2417
|
class.define_method("detach", method!(Sandbox::detach, 0))?;
|
|
2102
2418
|
class.define_method("metrics", method!(Sandbox::metrics, 0))?;
|
|
2419
|
+
class.define_method("ping", method!(Sandbox::ping, 0))?;
|
|
2420
|
+
class.define_method("touch", method!(Sandbox::touch, 0))?;
|
|
2421
|
+
class.define_method("modify", method!(Sandbox::modify, 1))?;
|
|
2103
2422
|
class.define_method("metrics_stream", method!(Sandbox::metrics_stream, 1))?;
|
|
2104
2423
|
class.define_method("logs", method!(Sandbox::logs, 1))?;
|
|
2105
2424
|
class.define_method("log_stream", method!(Sandbox::log_stream, 1))?;
|
|
@@ -2147,7 +2466,9 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
|
|
|
2147
2466
|
)?;
|
|
2148
2467
|
handle.define_method("config_json", method!(SbHandle::config_json, 0))?;
|
|
2149
2468
|
handle.define_method("snapshot", method!(SbHandle::snapshot, 1))?;
|
|
2150
|
-
handle.define_method("
|
|
2469
|
+
handle.define_method("ping", method!(SbHandle::ping, 0))?;
|
|
2470
|
+
handle.define_method("touch", method!(SbHandle::touch, 0))?;
|
|
2471
|
+
handle.define_method("modify", method!(SbHandle::modify, 1))?;
|
|
2151
2472
|
|
|
2152
2473
|
let session = native.define_class("PullSession", ruby.class_object())?;
|
|
2153
2474
|
session.define_method("recv", method!(PullSession::recv, 0))?;
|