microsandbox-rb 0.17.0 → 0.17.1
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 +136 -1
- data/Cargo.lock +45 -46
- data/DESIGN.md +6 -37
- data/README.md +176 -667
- data/SECURITY.md +65 -0
- data/docs/configuration.md +154 -0
- data/docs/errors.md +74 -0
- data/docs/execution.md +101 -0
- data/docs/filesystem.md +45 -0
- data/docs/images.md +65 -0
- data/docs/lifecycle.md +108 -0
- data/docs/observability.md +99 -0
- data/docs/releasing.md +66 -0
- data/docs/runtime.md +152 -0
- data/docs/surface.md +70 -0
- data/ext/microsandbox/Cargo.toml +1 -1
- data/ext/microsandbox/src/host_path.rs +108 -0
- data/ext/microsandbox/src/lib.rs +1 -0
- data/ext/microsandbox/src/sandbox.rs +61 -31
- data/ext/microsandbox/src/volume.rs +43 -12
- data/lib/microsandbox/fs.rb +4 -8
- data/lib/microsandbox/image.rb +2 -2
- data/lib/microsandbox/log_entry.rb +1 -1
- data/lib/microsandbox/metrics.rb +1 -1
- data/lib/microsandbox/outbound_proxy.rb +8 -0
- data/lib/microsandbox/sandbox.rb +53 -9
- data/lib/microsandbox/snapshot.rb +1 -1
- data/lib/microsandbox/ssh.rb +2 -10
- data/lib/microsandbox/streams.rb +8 -5
- data/lib/microsandbox/version.rb +1 -1
- data/lib/microsandbox/volume.rb +31 -14
- data/lib/microsandbox-rb.rb +7 -0
- data/lib/microsandbox.rb +88 -31
- metadata +14 -1
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
//! as a plain Ruby `Hash`/`Array`/`String` and shaped into value objects by the
|
|
8
8
|
//! Ruby layer.
|
|
9
9
|
|
|
10
|
+
use std::path::PathBuf;
|
|
10
11
|
use std::sync::Arc;
|
|
11
12
|
use std::time::Duration;
|
|
12
13
|
|
|
@@ -39,6 +40,7 @@ use tokio::task::JoinHandle;
|
|
|
39
40
|
use crate::conv;
|
|
40
41
|
use crate::error;
|
|
41
42
|
use crate::exec::ExecHandle;
|
|
43
|
+
use crate::host_path::{self, HostPaths};
|
|
42
44
|
use crate::runtime::{block_on, ruby};
|
|
43
45
|
use crate::stream::{LogStream, MetricsStream};
|
|
44
46
|
|
|
@@ -75,6 +77,9 @@ impl Sandbox {
|
|
|
75
77
|
/// Shared by `create` (blocking) and `create_with_progress` (streaming pull).
|
|
76
78
|
fn build_builder(name: String, opts: RHash) -> Result<SandboxBuilder, Error> {
|
|
77
79
|
let mut b = microsandbox::Sandbox::builder(name);
|
|
80
|
+
// Relative host paths are anchored to the current directory here, once,
|
|
81
|
+
// before the core persists them (see host_path.rs).
|
|
82
|
+
let host_paths = HostPaths::for_default_backend();
|
|
78
83
|
|
|
79
84
|
if let Some(v) = conv::opt_string(opts, "image")? {
|
|
80
85
|
if let Some(fstype) = conv::opt_string(opts, "fstype")? {
|
|
@@ -83,13 +88,17 @@ impl Sandbox {
|
|
|
83
88
|
// image_with(disk().fstype()). (A bare `image` string otherwise
|
|
84
89
|
// auto-detects OCI vs disk by extension.) Errors in disk()/fstype()
|
|
85
90
|
// are captured on the builder and surface at create().
|
|
86
|
-
|
|
91
|
+
let path = host_paths.pin(v, "root disk image path")?;
|
|
92
|
+
b = b.image_with(move |i| i.disk(path).fstype(fstype));
|
|
87
93
|
} else {
|
|
88
|
-
b =
|
|
94
|
+
b = match host_paths.pin_image(v)? {
|
|
95
|
+
host_path::Image::Text(text) => b.image(text),
|
|
96
|
+
host_path::Image::Path(path) => b.image(path),
|
|
97
|
+
};
|
|
89
98
|
}
|
|
90
99
|
}
|
|
91
100
|
if let Some(v) = conv::opt_string(opts, "from_snapshot")? {
|
|
92
|
-
b = b.from_snapshot(v);
|
|
101
|
+
b = b.from_snapshot(host_paths.pin_snapshot(v)?);
|
|
93
102
|
}
|
|
94
103
|
if let Some(v) = conv::opt_u8(opts, "cpus")? {
|
|
95
104
|
b = b.cpus(v);
|
|
@@ -208,27 +217,35 @@ impl Sandbox {
|
|
|
208
217
|
// the per-mount opt-out. Valid for bind/named-directory mounts —
|
|
209
218
|
// the core rejects it elsewhere at build().
|
|
210
219
|
let follow_root_symlinks = conv::opt::<bool>(m, "follow_root_symlinks")?;
|
|
211
|
-
// bind/named/disk require a source; tmpfs must not have one.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
220
|
+
// bind/named/disk require a source; tmpfs must not have one. A
|
|
221
|
+
// bind or disk source is a host path; a named source is a volume
|
|
222
|
+
// name and is never resolved.
|
|
223
|
+
let backing = match (kind.as_str(), source) {
|
|
224
|
+
("bind", Some(host)) => {
|
|
225
|
+
MountBacking::Bind(host_paths.pin(host, "bind mount host path")?)
|
|
226
|
+
}
|
|
227
|
+
("disk", Some(host)) => {
|
|
228
|
+
MountBacking::Disk(host_paths.pin(host, "disk mount host path")?)
|
|
229
|
+
}
|
|
230
|
+
("named", Some(name)) => MountBacking::Named(name),
|
|
231
|
+
("bind" | "named" | "disk", None) => {
|
|
215
232
|
return Err(error::base_error(format!(
|
|
216
233
|
"volume mount kind {kind:?} requires a source"
|
|
217
234
|
)))
|
|
218
235
|
}
|
|
219
|
-
"tmpfs" =>
|
|
220
|
-
other => {
|
|
236
|
+
("tmpfs", _) => MountBacking::Tmpfs,
|
|
237
|
+
(other, _) => {
|
|
221
238
|
return Err(error::base_error(format!(
|
|
222
239
|
"unknown volume mount kind {other:?} (expected bind/named/tmpfs/disk)"
|
|
223
240
|
)))
|
|
224
241
|
}
|
|
225
|
-
}
|
|
242
|
+
};
|
|
226
243
|
b = b.volume(guest, move |mut mb| {
|
|
227
|
-
mb = match
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
244
|
+
mb = match backing {
|
|
245
|
+
MountBacking::Named(name) => mb.named(name),
|
|
246
|
+
MountBacking::Tmpfs => mb.tmpfs(),
|
|
247
|
+
MountBacking::Disk(host) => mb.disk(host),
|
|
248
|
+
MountBacking::Bind(host) => mb.bind(host),
|
|
232
249
|
};
|
|
233
250
|
if let Some(n) = size_mib {
|
|
234
251
|
mb = mb.size(n);
|
|
@@ -272,7 +289,7 @@ impl Sandbox {
|
|
|
272
289
|
// patches: rootfs modifications applied before boot. The Ruby layer
|
|
273
290
|
// normalizes each `Microsandbox::Patch.*` into a string-keyed Hash with
|
|
274
291
|
// a `kind` discriminator; mirrors the Python binding's `apply_patch`.
|
|
275
|
-
for patch in parse_patches(opts)? {
|
|
292
|
+
for patch in parse_patches(opts, host_paths)? {
|
|
276
293
|
b = b.add_patch(patch);
|
|
277
294
|
}
|
|
278
295
|
if let Some(net) = conv::opt_string(opts, "network")? {
|
|
@@ -326,7 +343,7 @@ impl Sandbox {
|
|
|
326
343
|
// layer maps the deprecated `oci_upper_size:` kwarg to the managed kind
|
|
327
344
|
// before it reaches the wire.
|
|
328
345
|
if let Some(v) = conv::opt::<Value>(opts, "root_disk")? {
|
|
329
|
-
let spec = parse_root_disk(v)?;
|
|
346
|
+
let spec = parse_root_disk(v, host_paths)?;
|
|
330
347
|
b = b.root_disk_with(move |d| spec.apply(d));
|
|
331
348
|
}
|
|
332
349
|
// Registry connection settings, for private / non-default registries:
|
|
@@ -374,7 +391,7 @@ impl Sandbox {
|
|
|
374
391
|
.map(parse_dns)
|
|
375
392
|
.transpose()?;
|
|
376
393
|
let tls = conv::opt::<RHash>(opts, "tls")?
|
|
377
|
-
.map(parse_tls)
|
|
394
|
+
.map(|t| parse_tls(t, host_paths))
|
|
378
395
|
.transpose()?;
|
|
379
396
|
let ipv4_pool = conv::opt_string(opts, "ipv4_pool")?
|
|
380
397
|
.map(|s| {
|
|
@@ -1217,7 +1234,7 @@ fn patch_bytes(h: RHash, key: &str) -> Result<Vec<u8>, Error> {
|
|
|
1217
1234
|
/// Parse the `patches` option into core `Patch` operations. The Ruby layer
|
|
1218
1235
|
/// normalizes each `Microsandbox::Patch.*` into a string-keyed Hash carrying a
|
|
1219
1236
|
/// `kind` discriminator plus the variant-specific fields.
|
|
1220
|
-
fn parse_patches(opts: RHash) -> Result<Vec<Patch>, Error> {
|
|
1237
|
+
fn parse_patches(opts: RHash, host_paths: HostPaths) -> Result<Vec<Patch>, Error> {
|
|
1221
1238
|
let mut out = Vec::new();
|
|
1222
1239
|
for h in conv::opt_hash_vec(opts, "patches")? {
|
|
1223
1240
|
let kind = patch_str(h, "kind")?;
|
|
@@ -1241,13 +1258,13 @@ fn parse_patches(opts: RHash) -> Result<Vec<Patch>, Error> {
|
|
|
1241
1258
|
content: patch_str(h, "content")?,
|
|
1242
1259
|
},
|
|
1243
1260
|
"copy_file" => Patch::CopyFile {
|
|
1244
|
-
src: patch_str(h, "src")
|
|
1261
|
+
src: host_paths.pin(patch_str(h, "src")?, "patch source path")?,
|
|
1245
1262
|
dst: patch_str(h, "dst")?,
|
|
1246
1263
|
mode,
|
|
1247
1264
|
replace,
|
|
1248
1265
|
},
|
|
1249
1266
|
"copy_dir" => Patch::CopyDir {
|
|
1250
|
-
src: patch_str(h, "src")
|
|
1267
|
+
src: host_paths.pin(patch_str(h, "src")?, "patch source path")?,
|
|
1251
1268
|
dst: patch_str(h, "dst")?,
|
|
1252
1269
|
replace,
|
|
1253
1270
|
},
|
|
@@ -1584,20 +1601,25 @@ struct TlsSpec {
|
|
|
1584
1601
|
verify_upstream: Option<bool>,
|
|
1585
1602
|
intercepted_ports: Option<Vec<u16>>,
|
|
1586
1603
|
block_quic: Option<bool>,
|
|
1587
|
-
upstream_ca_cert: Option<
|
|
1588
|
-
intercept_ca_cert: Option<
|
|
1589
|
-
intercept_ca_key: Option<
|
|
1604
|
+
upstream_ca_cert: Option<PathBuf>,
|
|
1605
|
+
intercept_ca_cert: Option<PathBuf>,
|
|
1606
|
+
intercept_ca_key: Option<PathBuf>,
|
|
1590
1607
|
}
|
|
1591
1608
|
|
|
1592
|
-
fn parse_tls(t: RHash) -> Result<TlsSpec, Error> {
|
|
1609
|
+
fn parse_tls(t: RHash, host_paths: HostPaths) -> Result<TlsSpec, Error> {
|
|
1610
|
+
let pin = |key: &str, label: &str| {
|
|
1611
|
+
conv::opt_string(t, key)?
|
|
1612
|
+
.map(|path| host_paths.pin(path, label))
|
|
1613
|
+
.transpose()
|
|
1614
|
+
};
|
|
1593
1615
|
Ok(TlsSpec {
|
|
1594
1616
|
bypass: conv::opt_string_vec(t, "bypass")?,
|
|
1595
1617
|
verify_upstream: conv::opt::<bool>(t, "verify_upstream")?,
|
|
1596
1618
|
intercepted_ports: conv::opt::<Vec<u16>>(t, "intercepted_ports")?,
|
|
1597
1619
|
block_quic: conv::opt::<bool>(t, "block_quic")?,
|
|
1598
|
-
upstream_ca_cert:
|
|
1599
|
-
intercept_ca_cert:
|
|
1600
|
-
intercept_ca_key:
|
|
1620
|
+
upstream_ca_cert: pin("upstream_ca_cert", "upstream CA certificate path")?,
|
|
1621
|
+
intercept_ca_cert: pin("intercept_ca_cert", "interception CA certificate path")?,
|
|
1622
|
+
intercept_ca_key: pin("intercept_ca_key", "interception CA key path")?,
|
|
1601
1623
|
})
|
|
1602
1624
|
}
|
|
1603
1625
|
|
|
@@ -1711,6 +1733,14 @@ fn network_profile_from_str(s: &str) -> Result<NetworkProfile, Error> {
|
|
|
1711
1733
|
}
|
|
1712
1734
|
}
|
|
1713
1735
|
|
|
1736
|
+
/// What backs a volume mount, with any host path already pinned.
|
|
1737
|
+
enum MountBacking {
|
|
1738
|
+
Bind(PathBuf),
|
|
1739
|
+
Disk(PathBuf),
|
|
1740
|
+
Named(String),
|
|
1741
|
+
Tmpfs,
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1714
1744
|
/// Parsed `root_disk` create option. Integer shorthand = managed upper of N
|
|
1715
1745
|
/// MiB (mirrors Python's `root_disk=8192` and the CLI bare-size form); Hash =
|
|
1716
1746
|
/// explicit kind. Kind/field cross-validation (size on a disk image, etc.)
|
|
@@ -1726,7 +1756,7 @@ struct RootDiskSpec {
|
|
|
1726
1756
|
enum RootDiskKindSpec {
|
|
1727
1757
|
Managed,
|
|
1728
1758
|
Tmpfs,
|
|
1729
|
-
Disk(
|
|
1759
|
+
Disk(PathBuf),
|
|
1730
1760
|
Flat,
|
|
1731
1761
|
}
|
|
1732
1762
|
|
|
@@ -1754,7 +1784,7 @@ impl RootDiskSpec {
|
|
|
1754
1784
|
}
|
|
1755
1785
|
}
|
|
1756
1786
|
|
|
1757
|
-
fn parse_root_disk(v: Value) -> Result<RootDiskSpec, Error> {
|
|
1787
|
+
fn parse_root_disk(v: Value, host_paths: HostPaths) -> Result<RootDiskSpec, Error> {
|
|
1758
1788
|
if let Ok(mib) = u32::try_convert(v) {
|
|
1759
1789
|
return Ok(RootDiskSpec {
|
|
1760
1790
|
kind: RootDiskKindSpec::Managed,
|
|
@@ -1777,7 +1807,7 @@ fn parse_root_disk(v: Value) -> Result<RootDiskSpec, Error> {
|
|
|
1777
1807
|
let Some(path) = conv::opt_string(h, "path")? else {
|
|
1778
1808
|
return Err(error::base_error("root_disk disk kind requires path:"));
|
|
1779
1809
|
};
|
|
1780
|
-
RootDiskKindSpec::Disk(path)
|
|
1810
|
+
RootDiskKindSpec::Disk(host_paths.pin(path, "root disk image path")?)
|
|
1781
1811
|
}
|
|
1782
1812
|
Some("flat") => RootDiskKindSpec::Flat,
|
|
1783
1813
|
Some(other) => {
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
//! Mirrors `sdk/python/src/volume.rs`. Static async CRUD over the volume store;
|
|
4
4
|
//! results are returned as plain Ruby Hashes/Arrays.
|
|
5
5
|
|
|
6
|
+
use std::path::{Component, Path};
|
|
6
7
|
use std::sync::Arc;
|
|
7
8
|
|
|
8
9
|
use magnus::{function, method, prelude::*, Error, RArray, RHash, RModule, RString, Ruby};
|
|
9
10
|
use microsandbox::volume::VolumeHandle;
|
|
10
|
-
use microsandbox::Backend;
|
|
11
|
+
use microsandbox::{Backend, MicrosandboxError};
|
|
11
12
|
|
|
12
13
|
use crate::conv;
|
|
13
14
|
use crate::error;
|
|
@@ -104,6 +105,23 @@ fn remove(name: String) -> Result<(), Error> {
|
|
|
104
105
|
block_on(microsandbox::Volume::remove(&name)).map_err(error::to_ruby)
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
/// Volume paths are relative to the volume root and never need a `..` segment,
|
|
109
|
+
/// so one is refused before the path reaches the core's resolver (which keeps
|
|
110
|
+
/// its own containment check). Enforced here rather than only in the Ruby
|
|
111
|
+
/// wrapper because `Microsandbox::Native::VolumeFs` is callable directly.
|
|
112
|
+
fn contained(path: &str) -> Result<&str, Error> {
|
|
113
|
+
if Path::new(path)
|
|
114
|
+
.components()
|
|
115
|
+
.any(|part| matches!(part, Component::ParentDir))
|
|
116
|
+
{
|
|
117
|
+
return Err(error::to_ruby(MicrosandboxError::SandboxFsOps(
|
|
118
|
+
"path traversal outside volume root (`..` segments are not allowed in volume paths)"
|
|
119
|
+
.into(),
|
|
120
|
+
)));
|
|
121
|
+
}
|
|
122
|
+
Ok(path)
|
|
123
|
+
}
|
|
124
|
+
|
|
107
125
|
/// A host-side filesystem view over a named volume — read/write its contents
|
|
108
126
|
/// without a running sandbox. Mirrors the Python `VolumeFs` / Node `VolumeFs`.
|
|
109
127
|
/// The core `VolumeFs<'a>` borrows the volume name, so (like the Python binding)
|
|
@@ -131,26 +149,30 @@ impl VolumeFs {
|
|
|
131
149
|
}
|
|
132
150
|
|
|
133
151
|
fn read(&self, path: String) -> Result<RString, Error> {
|
|
152
|
+
let path = contained(&path)?;
|
|
134
153
|
let fs = self.fs();
|
|
135
|
-
let bytes = block_on(fs.read(
|
|
154
|
+
let bytes = block_on(fs.read(path)).map_err(error::to_ruby)?;
|
|
136
155
|
Ok(ruby().str_from_slice(bytes.as_ref()))
|
|
137
156
|
}
|
|
138
157
|
|
|
139
158
|
fn read_text(&self, path: String) -> Result<String, Error> {
|
|
159
|
+
let path = contained(&path)?;
|
|
140
160
|
let fs = self.fs();
|
|
141
|
-
block_on(fs.read_to_string(
|
|
161
|
+
block_on(fs.read_to_string(path)).map_err(error::to_ruby)
|
|
142
162
|
}
|
|
143
163
|
|
|
144
164
|
fn write(&self, path: String, data: RString) -> Result<(), Error> {
|
|
145
165
|
// Copy the bytes out while the GVL is held (GC.compact could move them).
|
|
146
166
|
let bytes = unsafe { data.as_slice() }.to_vec();
|
|
167
|
+
let path = contained(&path)?;
|
|
147
168
|
let fs = self.fs();
|
|
148
|
-
block_on(fs.write(
|
|
169
|
+
block_on(fs.write(path, &bytes)).map_err(error::to_ruby)
|
|
149
170
|
}
|
|
150
171
|
|
|
151
172
|
fn list(&self, path: String) -> Result<RArray, Error> {
|
|
173
|
+
let path = contained(&path)?;
|
|
152
174
|
let fs = self.fs();
|
|
153
|
-
let entries = block_on(fs.list(
|
|
175
|
+
let entries = block_on(fs.list(path)).map_err(error::to_ruby)?;
|
|
154
176
|
let arr = ruby().ary_new();
|
|
155
177
|
for entry in &entries {
|
|
156
178
|
arr.push(fs_entry_to_hash(entry))?;
|
|
@@ -159,38 +181,47 @@ impl VolumeFs {
|
|
|
159
181
|
}
|
|
160
182
|
|
|
161
183
|
fn mkdir(&self, path: String) -> Result<(), Error> {
|
|
184
|
+
let path = contained(&path)?;
|
|
162
185
|
let fs = self.fs();
|
|
163
|
-
block_on(fs.mkdir(
|
|
186
|
+
block_on(fs.mkdir(path)).map_err(error::to_ruby)
|
|
164
187
|
}
|
|
165
188
|
|
|
166
189
|
fn remove_file(&self, path: String) -> Result<(), Error> {
|
|
190
|
+
let path = contained(&path)?;
|
|
167
191
|
let fs = self.fs();
|
|
168
|
-
block_on(fs.remove(
|
|
192
|
+
block_on(fs.remove(path)).map_err(error::to_ruby)
|
|
169
193
|
}
|
|
170
194
|
|
|
171
195
|
fn remove_dir(&self, path: String) -> Result<(), Error> {
|
|
196
|
+
let path = contained(&path)?;
|
|
172
197
|
let fs = self.fs();
|
|
173
|
-
block_on(fs.remove_dir(
|
|
198
|
+
block_on(fs.remove_dir(path)).map_err(error::to_ruby)
|
|
174
199
|
}
|
|
175
200
|
|
|
176
201
|
fn exists(&self, path: String) -> Result<bool, Error> {
|
|
202
|
+
let path = contained(&path)?;
|
|
177
203
|
let fs = self.fs();
|
|
178
|
-
block_on(fs.exists(
|
|
204
|
+
block_on(fs.exists(path)).map_err(error::to_ruby)
|
|
179
205
|
}
|
|
180
206
|
|
|
181
207
|
fn copy(&self, from: String, to: String) -> Result<(), Error> {
|
|
208
|
+
let from = contained(&from)?;
|
|
209
|
+
let to = contained(&to)?;
|
|
182
210
|
let fs = self.fs();
|
|
183
|
-
block_on(fs.copy(
|
|
211
|
+
block_on(fs.copy(from, to)).map_err(error::to_ruby)
|
|
184
212
|
}
|
|
185
213
|
|
|
186
214
|
fn rename(&self, from: String, to: String) -> Result<(), Error> {
|
|
215
|
+
let from = contained(&from)?;
|
|
216
|
+
let to = contained(&to)?;
|
|
187
217
|
let fs = self.fs();
|
|
188
|
-
block_on(fs.rename(
|
|
218
|
+
block_on(fs.rename(from, to)).map_err(error::to_ruby)
|
|
189
219
|
}
|
|
190
220
|
|
|
191
221
|
fn stat(&self, path: String) -> Result<RHash, Error> {
|
|
222
|
+
let path = contained(&path)?;
|
|
192
223
|
let fs = self.fs();
|
|
193
|
-
let meta = block_on(fs.stat(
|
|
224
|
+
let meta = block_on(fs.stat(path)).map_err(error::to_ruby)?;
|
|
194
225
|
Ok(fs_metadata_to_hash(&meta))
|
|
195
226
|
}
|
|
196
227
|
}
|
data/lib/microsandbox/fs.rb
CHANGED
|
@@ -27,7 +27,7 @@ module Microsandbox
|
|
|
27
27
|
|
|
28
28
|
# @return [Time, nil] last-modified time, if known
|
|
29
29
|
def modified
|
|
30
|
-
|
|
30
|
+
Microsandbox.time_from_ms(@modified_ms)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def file? = @type == :file
|
|
@@ -64,12 +64,12 @@ module Microsandbox
|
|
|
64
64
|
|
|
65
65
|
# @return [Time, nil] last-modified time, if known
|
|
66
66
|
def modified
|
|
67
|
-
|
|
67
|
+
Microsandbox.time_from_ms(@modified_ms)
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
# @return [Time, nil] creation time, if known
|
|
71
71
|
def created
|
|
72
|
-
|
|
72
|
+
Microsandbox.time_from_ms(@created_ms)
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
def inspect
|
|
@@ -188,11 +188,7 @@ module Microsandbox
|
|
|
188
188
|
sink = FsWriteSink.new(@native.fs_write_stream(path.to_s))
|
|
189
189
|
return sink unless block_given?
|
|
190
190
|
|
|
191
|
-
|
|
192
|
-
yield sink
|
|
193
|
-
ensure
|
|
194
|
-
sink.close
|
|
195
|
-
end
|
|
191
|
+
Microsandbox.close_after(sink) { yield sink }
|
|
196
192
|
end
|
|
197
193
|
end
|
|
198
194
|
|
data/lib/microsandbox/image.rb
CHANGED
|
@@ -18,12 +18,12 @@ module Microsandbox
|
|
|
18
18
|
|
|
19
19
|
# @return [Time, nil]
|
|
20
20
|
def created_at
|
|
21
|
-
|
|
21
|
+
Microsandbox.time_from_ms(@created_at_ms)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
# @return [Time, nil]
|
|
25
25
|
def last_used_at
|
|
26
|
-
|
|
26
|
+
Microsandbox.time_from_ms(@last_used_at_ms)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def inspect
|
data/lib/microsandbox/metrics.rb
CHANGED
|
@@ -246,6 +246,14 @@ module Microsandbox
|
|
|
246
246
|
if address.empty?
|
|
247
247
|
raise ArgumentError, "proxy address must be a non-empty \"IP:port\" String (got \"\")"
|
|
248
248
|
end
|
|
249
|
+
# A URL-style address (`socks5://user:pass@host:1080`) is never valid, and
|
|
250
|
+
# the core would quote it back in its error. Refuse it by shape, without
|
|
251
|
+
# echoing it, so the embedded credentials stay out of logs.
|
|
252
|
+
if address.match?(%r{@|://|\s})
|
|
253
|
+
raise ArgumentError,
|
|
254
|
+
"proxy address must be \"IP:port\" with no scheme, credentials or whitespace " \
|
|
255
|
+
"(pass SOCKS5 credentials as username:/password:)"
|
|
256
|
+
end
|
|
249
257
|
# Same rules as the Python SDK's OutboundProxy.__post_init__; the address
|
|
250
258
|
# itself is parsed by the core (which reports e.g. "invalid SOCKS5 proxy
|
|
251
259
|
# address" as an InvalidConfigError at create time).
|
data/lib/microsandbox/sandbox.rb
CHANGED
|
@@ -42,13 +42,13 @@ module Microsandbox
|
|
|
42
42
|
# @return [Time, nil]
|
|
43
43
|
def created_at
|
|
44
44
|
ms = @native.created_at_ms
|
|
45
|
-
|
|
45
|
+
Microsandbox.time_from_ms(ms)
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
# @return [Time, nil]
|
|
49
49
|
def updated_at
|
|
50
50
|
ms = @native.updated_at_ms
|
|
51
|
-
|
|
51
|
+
Microsandbox.time_from_ms(ms)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
# Gracefully stop the sandbox (SIGTERM→SIGKILL escalation, 10s default).
|
|
@@ -263,7 +263,7 @@ module Microsandbox
|
|
|
263
263
|
|
|
264
264
|
# @return [Time] when the stopped state was observed
|
|
265
265
|
def observed_at
|
|
266
|
-
|
|
266
|
+
Microsandbox.time_from_ms(@observed_at_ms)
|
|
267
267
|
end
|
|
268
268
|
|
|
269
269
|
def inspect
|
|
@@ -495,6 +495,7 @@ module Microsandbox
|
|
|
495
495
|
# @return [Sandbox, Object] the sandbox, or the block's return value
|
|
496
496
|
def create(name, **kwargs, &block)
|
|
497
497
|
opts = build_create_opts(**kwargs)
|
|
498
|
+
Microsandbox.ensure_runtime!
|
|
498
499
|
sandbox = new(Native::Sandbox.create(name.to_s, opts))
|
|
499
500
|
return sandbox unless block_given?
|
|
500
501
|
|
|
@@ -551,6 +552,7 @@ module Microsandbox
|
|
|
551
552
|
# @return [Sandbox, Object] the live sandbox, or the block's return value
|
|
552
553
|
def connect_or_create(name, **kwargs, &block)
|
|
553
554
|
opts = build_create_opts(**kwargs)
|
|
555
|
+
Microsandbox.ensure_runtime!
|
|
554
556
|
sandbox = new(Native::Sandbox.connect_or_create(name.to_s, opts))
|
|
555
557
|
return sandbox unless block_given?
|
|
556
558
|
|
|
@@ -582,11 +584,15 @@ module Microsandbox
|
|
|
582
584
|
"for progress, then call #sandbox and stop it when done"
|
|
583
585
|
end
|
|
584
586
|
opts = build_create_opts(**kwargs)
|
|
587
|
+
Microsandbox.ensure_runtime!
|
|
585
588
|
PullSession.new(Native::Sandbox.create_with_progress(name.to_s, opts))
|
|
586
589
|
end
|
|
587
590
|
|
|
588
591
|
# @api private
|
|
589
|
-
# Shared keyword-option builder for {create}/{
|
|
592
|
+
# Shared keyword-option builder for {create}/{connect_or_create}/
|
|
593
|
+
# {create_with_progress}. Pure validation and normalization: the callers
|
|
594
|
+
# provision the runtime ({Microsandbox.ensure_runtime!}) only after this
|
|
595
|
+
# returns, so a rejected option never triggers a runtime download.
|
|
590
596
|
def build_create_opts(image: nil, cpus: nil, max_cpus: nil, memory: nil, max_memory: nil,
|
|
591
597
|
env: nil, workdir: nil,
|
|
592
598
|
shell: nil, user: nil, hostname: nil, labels: nil, scripts: nil,
|
|
@@ -610,7 +616,14 @@ module Microsandbox
|
|
|
610
616
|
if root_disk && oci_upper_size
|
|
611
617
|
raise ArgumentError, "pass either root_disk: or oci_upper_size:, not both"
|
|
612
618
|
end
|
|
613
|
-
|
|
619
|
+
# The core refuses patches on a snapshot-booted sandbox, but only after
|
|
620
|
+
# it has claimed the sandbox name on disk. Refuse the pair before any
|
|
621
|
+
# side effect.
|
|
622
|
+
if from_snapshot && patches && !Array(patches).empty?
|
|
623
|
+
raise ArgumentError,
|
|
624
|
+
"patches: cannot be combined with from_snapshot: (a snapshot restores its own " \
|
|
625
|
+
"root disk; apply changes after boot with sandbox.fs instead)"
|
|
626
|
+
end
|
|
614
627
|
# `fstype:` names the inner filesystem of a disk-image rootfs, so it only
|
|
615
628
|
# applies when `image:` is a disk-image path (a local path ending in
|
|
616
629
|
# .raw/.qcow2/.vmdk). Routing an OCI ref (e.g. "python") through the
|
|
@@ -779,7 +792,16 @@ module Microsandbox
|
|
|
779
792
|
end
|
|
780
793
|
|
|
781
794
|
def stringify(hash)
|
|
782
|
-
|
|
795
|
+
# Anything that converts to pairs is fine (a Hash, ENV, an Array of
|
|
796
|
+
# pairs). For the rest, name the type, not the value: env values are
|
|
797
|
+
# often credentials.
|
|
798
|
+
pairs = begin
|
|
799
|
+
hash.to_h if hash.respond_to?(:to_h)
|
|
800
|
+
rescue TypeError
|
|
801
|
+
nil
|
|
802
|
+
end
|
|
803
|
+
raise ArgumentError, "expected a Hash of names to values (got #{hash.class})" unless pairs
|
|
804
|
+
pairs.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v.to_s }
|
|
783
805
|
end
|
|
784
806
|
|
|
785
807
|
# Wrap a native page Hash ({"sandboxes" => [...], "next_cursor" => ...})
|
|
@@ -844,7 +866,21 @@ module Microsandbox
|
|
|
844
866
|
# :inject_basic_auth / :inject_query / :inject_body, and :on_violation (see
|
|
845
867
|
# {#normalize_violation}).
|
|
846
868
|
def normalize_secrets(secrets)
|
|
847
|
-
|
|
869
|
+
if secrets.is_a?(Hash)
|
|
870
|
+
# One spec given bare is fine. A Hash keyed by variable name is the
|
|
871
|
+
# shape {Sandbox#modify} takes, where `env:` also means something
|
|
872
|
+
# else (the HOST variable to read); it is not a create-time spec.
|
|
873
|
+
unless secrets.key?(:env) || secrets.key?("env")
|
|
874
|
+
raise ArgumentError,
|
|
875
|
+
"secrets: takes an Array of Hashes like { env:, value:, host: } at create time; " \
|
|
876
|
+
"the { \"NAME\" => { ... } } form belongs to #modify"
|
|
877
|
+
end
|
|
878
|
+
secrets = [secrets]
|
|
879
|
+
end
|
|
880
|
+
Array(secrets).map do |spec|
|
|
881
|
+
raise ArgumentError, "secret spec must be a Hash (got #{spec.class})" unless spec.is_a?(Hash)
|
|
882
|
+
normalize_secret(spec)
|
|
883
|
+
end
|
|
848
884
|
end
|
|
849
885
|
|
|
850
886
|
# @api private
|
|
@@ -1409,9 +1445,11 @@ module Microsandbox
|
|
|
1409
1445
|
# plain Hash) into string-keyed Hashes for the native layer. Values are
|
|
1410
1446
|
# passed through unchanged (mode stays Integer, content stays String).
|
|
1411
1447
|
def normalize_patches(patches)
|
|
1448
|
+
patches = [patches] if patches.is_a?(Hash) # a single patch, not a list of pairs
|
|
1412
1449
|
Array(patches).map do |p|
|
|
1413
1450
|
unless p.is_a?(Hash)
|
|
1414
|
-
|
|
1451
|
+
# Name the type, not the value: a patch can carry file content.
|
|
1452
|
+
raise ArgumentError, "patch must be a Hash (use Microsandbox::Patch.*), got #{p.class}"
|
|
1415
1453
|
end
|
|
1416
1454
|
p.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
1417
1455
|
end
|
|
@@ -1957,10 +1995,16 @@ module Microsandbox
|
|
|
1957
1995
|
|
|
1958
1996
|
# The booted sandbox. Joins the create task (draining any remaining pull
|
|
1959
1997
|
# progress first), so call it after iterating progress. The returned
|
|
1960
|
-
# {Sandbox} is live — stop it when done. Memoized
|
|
1998
|
+
# {Sandbox} is live — stop it when done. Memoized: the native result can be
|
|
1999
|
+
# taken only once, so a later call returns the same {Sandbox}, or re-raises
|
|
2000
|
+
# the same error if the create failed.
|
|
1961
2001
|
# @return [Sandbox]
|
|
1962
2002
|
def sandbox
|
|
2003
|
+
raise @failure if @failure
|
|
1963
2004
|
@sandbox ||= Sandbox.new(@native.result)
|
|
2005
|
+
rescue Microsandbox::Error => e
|
|
2006
|
+
@failure ||= e
|
|
2007
|
+
raise
|
|
1964
2008
|
end
|
|
1965
2009
|
end
|
|
1966
2010
|
end
|
data/lib/microsandbox/ssh.rb
CHANGED
|
@@ -173,11 +173,7 @@ module Microsandbox
|
|
|
173
173
|
session = SftpClient.new(@native.sftp)
|
|
174
174
|
return session unless block_given?
|
|
175
175
|
|
|
176
|
-
|
|
177
|
-
yield session
|
|
178
|
-
ensure
|
|
179
|
-
session.close
|
|
180
|
-
end
|
|
176
|
+
Microsandbox.close_after(session) { yield session }
|
|
181
177
|
end
|
|
182
178
|
|
|
183
179
|
# Close the SSH client session. Idempotent. Sends the graceful protocol
|
|
@@ -245,11 +241,7 @@ module Microsandbox
|
|
|
245
241
|
client = SshClient.new(@native.ssh_open_client(opts))
|
|
246
242
|
return client unless block_given?
|
|
247
243
|
|
|
248
|
-
|
|
249
|
-
yield client
|
|
250
|
-
ensure
|
|
251
|
-
client.close
|
|
252
|
-
end
|
|
244
|
+
Microsandbox.close_after(client) { yield client }
|
|
253
245
|
end
|
|
254
246
|
|
|
255
247
|
# Prepare a reusable SSH server endpoint for the sandbox.
|
data/lib/microsandbox/streams.rb
CHANGED
|
@@ -2,17 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
module Microsandbox
|
|
4
4
|
# A live stream of {LogEntry}s, returned by {Sandbox#log_stream}. Enumerable:
|
|
5
|
-
# iterate to consume entries as they are appended.
|
|
6
|
-
# iteration
|
|
7
|
-
#
|
|
5
|
+
# iterate to consume entries as they are appended. Without `follow:` the
|
|
6
|
+
# iteration ends once the historical log is drained. With `follow: true` it
|
|
7
|
+
# keeps waiting for new entries and does NOT end when the sandbox stops: the
|
|
8
|
+
# runtime follows the log file, not the sandbox, and there is no way to
|
|
9
|
+
# cancel the wait.
|
|
8
10
|
#
|
|
9
11
|
# @note **Single-pass, forward-only, single-consumer.** `each` drains a
|
|
10
12
|
# one-shot native channel, so it is *not* rewindable: a second `each` — or
|
|
11
13
|
# any `Enumerable` combinator after a partial drain (`to_a` twice, `count`
|
|
12
14
|
# then `each`, `first(n)` then `each`) — silently yields nothing. Iterate
|
|
13
15
|
# exactly once, from one thread. With `follow: true`, `each` blocks the
|
|
14
|
-
# calling thread uninterruptibly
|
|
15
|
-
#
|
|
16
|
+
# calling thread uninterruptibly and indefinitely (see DESIGN.md on GVL
|
|
17
|
+
# release); a thread blocked there also keeps the process from exiting
|
|
18
|
+
# normally, so follow from a process you are prepared to kill.
|
|
16
19
|
#
|
|
17
20
|
# @example
|
|
18
21
|
# sb.log_stream(follow: true).each { |entry| print entry.text }
|
data/lib/microsandbox/version.rb
CHANGED
|
@@ -8,7 +8,7 @@ module Microsandbox
|
|
|
8
8
|
# Versioning section of the README for the full gem-to-runtime map. Must equal
|
|
9
9
|
# the native ext's Cargo crate version (`Native.version`), enforced by
|
|
10
10
|
# spec/unit/version_spec.rb.
|
|
11
|
-
VERSION = "0.17.
|
|
11
|
+
VERSION = "0.17.1"
|
|
12
12
|
|
|
13
13
|
# The upstream microsandbox runtime release this gem build embeds — the `tag`
|
|
14
14
|
# pinned on the `microsandbox`/`microsandbox-network` git deps in
|