microsandbox-rb 0.9.1 → 0.10.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 +111 -0
- data/Cargo.lock +71 -65
- data/README.md +54 -2
- data/ext/microsandbox/Cargo.toml +4 -4
- data/ext/microsandbox/src/error.rs +6 -0
- data/ext/microsandbox/src/image.rs +5 -5
- data/ext/microsandbox/src/lib.rs +2 -2
- data/ext/microsandbox/src/sandbox.rs +197 -4
- data/lib/microsandbox/modification.rb +108 -0
- data/lib/microsandbox/sandbox.rb +177 -1
- data/lib/microsandbox/version.rb +2 -2
- data/lib/microsandbox.rb +1 -0
- data/sig/microsandbox.rbs +43 -1
- metadata +2 -1
|
@@ -82,12 +82,12 @@ fn report_to_hash(report: ImagePruneReport) -> RHash {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
fn get(reference: String) -> Result<RHash, Error> {
|
|
85
|
-
let handle = with_local_backend(async |local| Image::
|
|
85
|
+
let handle = with_local_backend(async |local| Image::get_local(local, &reference).await)?;
|
|
86
86
|
Ok(handle_to_hash(&handle))
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
fn list() -> Result<RArray, Error> {
|
|
90
|
-
let handles = with_local_backend(async |local| Image::
|
|
90
|
+
let handles = with_local_backend(async |local| Image::list_local(local).await)?;
|
|
91
91
|
let arr = ruby().ary_new();
|
|
92
92
|
for h in handles.iter() {
|
|
93
93
|
arr.push(handle_to_hash(h))?;
|
|
@@ -96,16 +96,16 @@ fn list() -> Result<RArray, Error> {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
fn inspect(reference: String) -> Result<RHash, Error> {
|
|
99
|
-
let detail = with_local_backend(async |local| Image::
|
|
99
|
+
let detail = with_local_backend(async |local| Image::inspect_local(local, &reference).await)?;
|
|
100
100
|
Ok(detail_to_hash(detail))
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
fn remove(reference: String, force: bool) -> Result<(), Error> {
|
|
104
|
-
with_local_backend(async |local| Image::
|
|
104
|
+
with_local_backend(async |local| Image::remove_local(local, &reference, force).await)
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
fn prune() -> Result<RHash, Error> {
|
|
108
|
-
let report = with_local_backend(async |local| Image::
|
|
108
|
+
let report = with_local_backend(async |local| Image::prune_local(local).await)?;
|
|
109
109
|
Ok(report_to_hash(report))
|
|
110
110
|
}
|
|
111
111
|
|
data/ext/microsandbox/src/lib.rs
CHANGED
|
@@ -34,7 +34,7 @@ fn version() -> String {
|
|
|
34
34
|
/// helpers (Python/Node/Go).
|
|
35
35
|
fn all_sandbox_metrics() -> Result<RHash, Error> {
|
|
36
36
|
let map = backend::with_local_backend(async |local| {
|
|
37
|
-
microsandbox::sandbox::
|
|
37
|
+
microsandbox::sandbox::all_sandbox_metrics_local(local).await
|
|
38
38
|
})?;
|
|
39
39
|
let hash = runtime::ruby().hash_new();
|
|
40
40
|
for (name, metrics) in &map {
|
|
@@ -113,7 +113,7 @@ fn resolved_msb_path() -> Result<String, Error> {
|
|
|
113
113
|
available_when: "with the local backend".into(),
|
|
114
114
|
})
|
|
115
115
|
})?;
|
|
116
|
-
let path =
|
|
116
|
+
let path = local.config().resolve_msb_path().map_err(error::to_ruby)?;
|
|
117
117
|
Ok(path.to_string_lossy().into_owned())
|
|
118
118
|
}
|
|
119
119
|
|
|
@@ -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
|
-
SandboxFilter, SandboxHandle, SandboxMetrics,
|
|
24
|
-
|
|
21
|
+
AttachOptionsBuilder, DiskImageFormat, EnvVar, FsEntry, FsEntryKind, FsMetadata,
|
|
22
|
+
HostPermissions, Patch, PullPolicy, PullProgress, PullProgressHandle, RlimitResource,
|
|
23
|
+
SandboxBuilder, SandboxFilter, SandboxHandle, SandboxMetrics, SandboxModificationBuilder,
|
|
24
|
+
SandboxModificationPatch, SandboxStatus, SandboxStopResult, SecretBuilder,
|
|
25
|
+
SecretModificationPatch, SecretSource, SecurityProfile, StatVirtualization,
|
|
25
26
|
};
|
|
26
27
|
use microsandbox::LogLevel;
|
|
27
28
|
use microsandbox::MicrosandboxResult;
|
|
@@ -92,9 +93,19 @@ impl Sandbox {
|
|
|
92
93
|
if let Some(v) = conv::opt_u8(opts, "cpus")? {
|
|
93
94
|
b = b.cpus(v);
|
|
94
95
|
}
|
|
96
|
+
// v0.6.6 (#1099): boot-time maximum vCPU / memory ceilings that a later
|
|
97
|
+
// live `modify` can grow up to. `cpus`/`memory` above already raise these
|
|
98
|
+
// to their own value if lower, so a bare `cpus`/`memory` still works;
|
|
99
|
+
// setting these explicitly reserves headroom for a live resize.
|
|
100
|
+
if let Some(v) = conv::opt_u8(opts, "max_cpus")? {
|
|
101
|
+
b = b.max_cpus(v);
|
|
102
|
+
}
|
|
95
103
|
if let Some(v) = conv::opt_u32(opts, "memory")? {
|
|
96
104
|
b = b.memory(v);
|
|
97
105
|
}
|
|
106
|
+
if let Some(v) = conv::opt_u32(opts, "max_memory")? {
|
|
107
|
+
b = b.max_memory(v);
|
|
108
|
+
}
|
|
98
109
|
if let Some(v) = conv::opt_string(opts, "workdir")? {
|
|
99
110
|
b = b.workdir(v);
|
|
100
111
|
}
|
|
@@ -584,6 +595,34 @@ impl Sandbox {
|
|
|
584
595
|
Ok(metrics_to_hash(&m))
|
|
585
596
|
}
|
|
586
597
|
|
|
598
|
+
//----------------------------------------------------------------------
|
|
599
|
+
// Health & modification (v0.6.6 / #1099)
|
|
600
|
+
//----------------------------------------------------------------------
|
|
601
|
+
|
|
602
|
+
/// Health-check the guest agent without refreshing the idle timer. Returns
|
|
603
|
+
/// `{ name, latency_secs }` (round-trip latency as f64 seconds; the Ruby
|
|
604
|
+
/// layer exposes both seconds and milliseconds).
|
|
605
|
+
fn ping(&self) -> Result<RHash, Error> {
|
|
606
|
+
let result = block_on(self.inner.ping()).map_err(error::to_ruby)?;
|
|
607
|
+
Ok(ping_result_to_hash(&result))
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/// Explicitly refresh the idle-activity timer. Returns
|
|
611
|
+
/// `{ name, activity_seq }`.
|
|
612
|
+
fn touch(&self) -> Result<RHash, Error> {
|
|
613
|
+
let result = block_on(self.inner.touch()).map_err(error::to_ruby)?;
|
|
614
|
+
Ok(touch_result_to_hash(&result))
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/// Plan or apply a live sandbox modification. `opts` is the string-keyed
|
|
618
|
+
/// modify Hash normalized by the Ruby layer; returns the resulting
|
|
619
|
+
/// `SandboxModificationPlan` as a JSON string, which the Ruby layer parses
|
|
620
|
+
/// into a `ModificationPlan`. Mirrors the Node native binding (native returns
|
|
621
|
+
/// JSON, the wrapper shapes it).
|
|
622
|
+
fn modify(&self, opts: RHash) -> Result<String, Error> {
|
|
623
|
+
run_modify(self.inner.modify(), opts)
|
|
624
|
+
}
|
|
625
|
+
|
|
587
626
|
/// Read captured logs as an Array of Hashes. `opts`: tail, since_ms,
|
|
588
627
|
/// until_ms, sources (Array of "stdout"/"stderr"/"output"/"system"/"all").
|
|
589
628
|
fn logs(&self, opts: RHash) -> Result<RArray, Error> {
|
|
@@ -1736,6 +1775,133 @@ fn stop_result_to_hash(result: &SandboxStopResult) -> RHash {
|
|
|
1736
1775
|
hash
|
|
1737
1776
|
}
|
|
1738
1777
|
|
|
1778
|
+
//--------------------------------------------------------------------------------------------------
|
|
1779
|
+
// Health & live modification (v0.6.6 / #1099)
|
|
1780
|
+
//--------------------------------------------------------------------------------------------------
|
|
1781
|
+
|
|
1782
|
+
/// A `SandboxPingResult` as `{ name, latency_secs }`. Latency is a `Duration`;
|
|
1783
|
+
/// we hand it to Ruby as f64 seconds and let the Ruby layer derive milliseconds
|
|
1784
|
+
/// (the Python/Node SDKs expose only `latency_ms`).
|
|
1785
|
+
fn ping_result_to_hash(result: µsandbox::sandbox::SandboxPingResult) -> RHash {
|
|
1786
|
+
let hash = ruby().hash_new();
|
|
1787
|
+
let _ = hash.aset("name", result.name.clone());
|
|
1788
|
+
let _ = hash.aset("latency_secs", result.latency.as_secs_f64());
|
|
1789
|
+
hash
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
/// A `SandboxTouchResult` as `{ name, activity_seq }`.
|
|
1793
|
+
fn touch_result_to_hash(result: µsandbox::sandbox::SandboxTouchResult) -> RHash {
|
|
1794
|
+
let hash = ruby().hash_new();
|
|
1795
|
+
let _ = hash.aset("name", result.name.clone());
|
|
1796
|
+
let _ = hash.aset("activity_seq", result.activity_seq);
|
|
1797
|
+
hash
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
/// Drive a modify builder to a plan (dry-run or apply) and return it serialized
|
|
1801
|
+
/// as a JSON string. Shared by `Sandbox::modify` and `SbHandle::modify`. The
|
|
1802
|
+
/// canonical `SandboxModificationPlan` serde shape (snake_case keys, literal-space
|
|
1803
|
+
/// enum strings) is parsed verbatim by the Ruby layer — matching the Python SDK's
|
|
1804
|
+
/// raw-dict contract rather than re-casing it.
|
|
1805
|
+
fn run_modify(builder: SandboxModificationBuilder, opts: RHash) -> Result<String, Error> {
|
|
1806
|
+
let patch = build_modify_patch(opts)?;
|
|
1807
|
+
let policy = conv::opt_string(opts, "policy")?.unwrap_or_else(|| "no_restart".to_string());
|
|
1808
|
+
let dry_run = conv::opt_bool(opts, "dry_run")?;
|
|
1809
|
+
let builder = apply_modify_policy(builder.with_patch(patch), &policy)?;
|
|
1810
|
+
let plan = block_on(async move {
|
|
1811
|
+
if dry_run {
|
|
1812
|
+
builder.dry_run().await
|
|
1813
|
+
} else {
|
|
1814
|
+
builder.apply().await
|
|
1815
|
+
}
|
|
1816
|
+
})
|
|
1817
|
+
.map_err(error::to_ruby)?;
|
|
1818
|
+
serde_json::to_string(&plan)
|
|
1819
|
+
.map_err(|e| error::base_error(format!("failed to serialize modification plan: {e}")))
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
/// Build the canonical `SandboxModificationPatch` from the modify Hash. Env and
|
|
1823
|
+
/// label pairs are sorted so repeated calls with the same arguments produce the
|
|
1824
|
+
/// same patch (and plan) ordering, mirroring the Python binding. `oci_upper_size`
|
|
1825
|
+
/// is deliberately not surfaced (CLI-only upstream; the Python/Node SDKs omit it
|
|
1826
|
+
/// too), so it stays unset via `..Default::default()`.
|
|
1827
|
+
fn build_modify_patch(opts: RHash) -> Result<SandboxModificationPatch, Error> {
|
|
1828
|
+
let mut env_pairs = conv::opt_string_map(opts, "env")?;
|
|
1829
|
+
env_pairs.sort();
|
|
1830
|
+
let mut label_pairs = conv::opt_string_map(opts, "labels")?;
|
|
1831
|
+
label_pairs.sort();
|
|
1832
|
+
|
|
1833
|
+
Ok(SandboxModificationPatch {
|
|
1834
|
+
cpus: conv::opt_u8(opts, "cpus")?,
|
|
1835
|
+
max_cpus: conv::opt_u8(opts, "max_cpus")?,
|
|
1836
|
+
memory_mib: conv::opt_u32(opts, "memory")?,
|
|
1837
|
+
max_memory_mib: conv::opt_u32(opts, "max_memory")?,
|
|
1838
|
+
env: env_pairs
|
|
1839
|
+
.into_iter()
|
|
1840
|
+
.map(|(k, v)| EnvVar::new(k, v))
|
|
1841
|
+
.collect(),
|
|
1842
|
+
env_remove: conv::opt_string_vec(opts, "remove_env")?,
|
|
1843
|
+
labels: label_pairs,
|
|
1844
|
+
labels_remove: conv::opt_string_vec(opts, "remove_labels")?,
|
|
1845
|
+
workdir: conv::opt_string(opts, "workdir")?,
|
|
1846
|
+
secrets: parse_modify_secrets(opts)?,
|
|
1847
|
+
secrets_remove: conv::opt_string_vec(opts, "remove_secrets")?,
|
|
1848
|
+
..Default::default()
|
|
1849
|
+
})
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
/// Parse the `secrets` modify option (an Array of string-keyed Hashes normalized
|
|
1853
|
+
/// by the Ruby layer) into canonical `SecretModificationPatch`es. Each Hash
|
|
1854
|
+
/// carries `name` plus at most one of `env`/`store` (source) or `value` (raw
|
|
1855
|
+
/// material) — the Ruby layer enforces that mutual exclusion — plus optional
|
|
1856
|
+
/// `placeholder` and `allowed_hosts`. The raw `value` moves straight into the
|
|
1857
|
+
/// `Zeroizing` field; it is never echoed in an error.
|
|
1858
|
+
fn parse_modify_secrets(opts: RHash) -> Result<Vec<SecretModificationPatch>, Error> {
|
|
1859
|
+
let mut out = Vec::new();
|
|
1860
|
+
for h in conv::opt_hash_vec(opts, "secrets")? {
|
|
1861
|
+
let name = conv::opt_string(h, "name")?
|
|
1862
|
+
.ok_or_else(|| error::base_error("secret modification spec requires :name"))?;
|
|
1863
|
+
let env = conv::opt_string(h, "env")?;
|
|
1864
|
+
let store = conv::opt_string(h, "store")?;
|
|
1865
|
+
let value = conv::opt_string(h, "value")?;
|
|
1866
|
+
let source = match (env, store) {
|
|
1867
|
+
(Some(var), None) => Some(SecretSource::Env { var }),
|
|
1868
|
+
(None, Some(reference)) => Some(SecretSource::Store { reference }),
|
|
1869
|
+
(None, None) => None,
|
|
1870
|
+
// The Ruby layer rejects >1 source; guard defensively without
|
|
1871
|
+
// echoing any value.
|
|
1872
|
+
(Some(_), Some(_)) => {
|
|
1873
|
+
return Err(error::base_error(format!(
|
|
1874
|
+
"secret {name:?}: \"env\" and \"store\" are mutually exclusive"
|
|
1875
|
+
)))
|
|
1876
|
+
}
|
|
1877
|
+
};
|
|
1878
|
+
out.push(SecretModificationPatch {
|
|
1879
|
+
name,
|
|
1880
|
+
source,
|
|
1881
|
+
value: value.unwrap_or_default().into(),
|
|
1882
|
+
placeholder: conv::opt_string(h, "placeholder")?,
|
|
1883
|
+
allowed_hosts: conv::opt_string_vec(h, "allowed_hosts")?,
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
Ok(out)
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
/// Apply the parsed `policy` string to a modify builder. `no_restart` (default)
|
|
1890
|
+
/// leaves the builder untouched; `next_start`/`restart` select those policies.
|
|
1891
|
+
fn apply_modify_policy(
|
|
1892
|
+
builder: SandboxModificationBuilder,
|
|
1893
|
+
policy: &str,
|
|
1894
|
+
) -> Result<SandboxModificationBuilder, Error> {
|
|
1895
|
+
match policy {
|
|
1896
|
+
"no_restart" => Ok(builder),
|
|
1897
|
+
"next_start" => Ok(builder.next_start()),
|
|
1898
|
+
"restart" => Ok(builder.restart()),
|
|
1899
|
+
other => Err(error::base_error(format!(
|
|
1900
|
+
"unknown policy {other:?}; expected \"no_restart\", \"next_start\", or \"restart\""
|
|
1901
|
+
))),
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1739
1905
|
//--------------------------------------------------------------------------------------------------
|
|
1740
1906
|
// Pull progress (streaming image-pull during create_with_progress)
|
|
1741
1907
|
//--------------------------------------------------------------------------------------------------
|
|
@@ -1987,6 +2153,27 @@ impl SbHandle {
|
|
|
1987
2153
|
let snap = block_on(self.inner.snapshot_to(path)).map_err(error::to_ruby)?;
|
|
1988
2154
|
Ok(crate::snapshot::snapshot_to_hash(&snap))
|
|
1989
2155
|
}
|
|
2156
|
+
|
|
2157
|
+
/// Health-check the guest agent without refreshing the idle timer. A stopped
|
|
2158
|
+
/// sandbox raises `SandboxNotRunningError` (the handle does not start it
|
|
2159
|
+
/// implicitly). Returns `{ name, latency_secs }`.
|
|
2160
|
+
fn ping(&self) -> Result<RHash, Error> {
|
|
2161
|
+
let result = block_on(self.inner.ping()).map_err(error::to_ruby)?;
|
|
2162
|
+
Ok(ping_result_to_hash(&result))
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
/// Explicitly refresh the idle-activity timer. A stopped sandbox raises
|
|
2166
|
+
/// `SandboxNotRunningError`. Returns `{ name, activity_seq }`.
|
|
2167
|
+
fn touch(&self) -> Result<RHash, Error> {
|
|
2168
|
+
let result = block_on(self.inner.touch()).map_err(error::to_ruby)?;
|
|
2169
|
+
Ok(touch_result_to_hash(&result))
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
/// Plan or apply a live sandbox modification (see `Sandbox::modify`). Returns
|
|
2173
|
+
/// the `SandboxModificationPlan` as a JSON string.
|
|
2174
|
+
fn modify(&self, opts: RHash) -> Result<String, Error> {
|
|
2175
|
+
run_modify(self.inner.modify(), opts)
|
|
2176
|
+
}
|
|
1990
2177
|
}
|
|
1991
2178
|
|
|
1992
2179
|
//--------------------------------------------------------------------------------------------------
|
|
@@ -2100,6 +2287,9 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
|
|
|
2100
2287
|
class.define_method("owns_lifecycle", method!(Sandbox::owns_lifecycle, 0))?;
|
|
2101
2288
|
class.define_method("detach", method!(Sandbox::detach, 0))?;
|
|
2102
2289
|
class.define_method("metrics", method!(Sandbox::metrics, 0))?;
|
|
2290
|
+
class.define_method("ping", method!(Sandbox::ping, 0))?;
|
|
2291
|
+
class.define_method("touch", method!(Sandbox::touch, 0))?;
|
|
2292
|
+
class.define_method("modify", method!(Sandbox::modify, 1))?;
|
|
2103
2293
|
class.define_method("metrics_stream", method!(Sandbox::metrics_stream, 1))?;
|
|
2104
2294
|
class.define_method("logs", method!(Sandbox::logs, 1))?;
|
|
2105
2295
|
class.define_method("log_stream", method!(Sandbox::log_stream, 1))?;
|
|
@@ -2148,6 +2338,9 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
|
|
|
2148
2338
|
handle.define_method("config_json", method!(SbHandle::config_json, 0))?;
|
|
2149
2339
|
handle.define_method("snapshot", method!(SbHandle::snapshot, 1))?;
|
|
2150
2340
|
handle.define_method("snapshot_to", method!(SbHandle::snapshot_to, 1))?;
|
|
2341
|
+
handle.define_method("ping", method!(SbHandle::ping, 0))?;
|
|
2342
|
+
handle.define_method("touch", method!(SbHandle::touch, 0))?;
|
|
2343
|
+
handle.define_method("modify", method!(SbHandle::modify, 1))?;
|
|
2151
2344
|
|
|
2152
2345
|
let session = native.define_class("PullSession", ruby.class_object())?;
|
|
2153
2346
|
session.define_method("recv", method!(PullSession::recv, 0))?;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Microsandbox
|
|
4
|
+
# The result of {Sandbox#ping} / {SandboxHandle#ping}: a lightweight health
|
|
5
|
+
# check that confirms the guest agent is reachable without refreshing the
|
|
6
|
+
# sandbox idle timer. Mirrors the official SDKs' `SandboxPingResult`.
|
|
7
|
+
#
|
|
8
|
+
# The native layer reports latency as seconds; {#latency} is the canonical
|
|
9
|
+
# value and {#latency_ms} the convenience the Python/Node SDKs expose.
|
|
10
|
+
class PingResult
|
|
11
|
+
# @return [String] the sandbox name that was pinged
|
|
12
|
+
attr_reader :name
|
|
13
|
+
# @return [Float] round-trip latency in seconds
|
|
14
|
+
attr_reader :latency
|
|
15
|
+
|
|
16
|
+
def initialize(data)
|
|
17
|
+
@name = data["name"]
|
|
18
|
+
@latency = data["latency_secs"]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Float] round-trip latency in milliseconds
|
|
22
|
+
def latency_ms
|
|
23
|
+
@latency * 1000.0
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def inspect
|
|
27
|
+
"#<Microsandbox::PingResult name=#{@name.inspect} latency_ms=#{format("%.3f", latency_ms)}>"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The result of {Sandbox#touch} / {SandboxHandle#touch}: an explicit refresh of
|
|
32
|
+
# the sandbox idle-activity timer. Mirrors the official SDKs' `SandboxTouchResult`.
|
|
33
|
+
class TouchResult
|
|
34
|
+
# @return [String] the sandbox name that was touched
|
|
35
|
+
attr_reader :name
|
|
36
|
+
# @return [Integer] the agent activity sequence after this touch was recorded
|
|
37
|
+
attr_reader :activity_seq
|
|
38
|
+
|
|
39
|
+
def initialize(data)
|
|
40
|
+
@name = data["name"]
|
|
41
|
+
@activity_seq = data["activity_seq"]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def inspect
|
|
45
|
+
"#<Microsandbox::TouchResult name=#{@name.inspect} activity_seq=#{@activity_seq}>"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The plan produced by {Sandbox#modify} / {SandboxHandle#modify}: the classified
|
|
50
|
+
# set of changes a modification requested, and (for a non-dry-run apply) whether
|
|
51
|
+
# they were applied plus any live-resize convergence outcomes. Mirrors the
|
|
52
|
+
# official SDKs' `SandboxModificationPlan`.
|
|
53
|
+
#
|
|
54
|
+
# The nested collections ({#changes}, {#conflicts}, {#warnings},
|
|
55
|
+
# {#resize_status}) are frozen Arrays of frozen, symbol-keyed Hashes carrying
|
|
56
|
+
# the canonical snake_case fields verbatim (e.g. a config change is
|
|
57
|
+
# `{ kind: "config", field:, change:, before:, after:, disposition:, reason: }`;
|
|
58
|
+
# a secret change adds `name:`, `before_ref:`, `after_ref:`, `allow_hosts:`).
|
|
59
|
+
# Enum values stay as the runtime's strings (e.g. a `disposition` of
|
|
60
|
+
# `"next start"`, a `state` of `"guest-refused"`).
|
|
61
|
+
class ModificationPlan
|
|
62
|
+
# @return [String] the sandbox the plan applies to
|
|
63
|
+
attr_reader :sandbox
|
|
64
|
+
# @return [String] the sandbox status used to classify the changes
|
|
65
|
+
attr_reader :status
|
|
66
|
+
# @return [Symbol] :no_restart, :next_start, or :restart
|
|
67
|
+
attr_reader :policy
|
|
68
|
+
# @return [Array<Hash>] the planned changes (config and secret)
|
|
69
|
+
attr_reader :changes
|
|
70
|
+
# @return [Array<Hash>] conflicts that must be resolved before applying
|
|
71
|
+
attr_reader :conflicts
|
|
72
|
+
# @return [Array<Hash>] non-fatal warnings about the patch
|
|
73
|
+
attr_reader :warnings
|
|
74
|
+
# @return [Array<Hash>] live resource-resize outcomes (populated by an apply)
|
|
75
|
+
attr_reader :resize_status
|
|
76
|
+
|
|
77
|
+
def initialize(data)
|
|
78
|
+
@sandbox = data["sandbox"]
|
|
79
|
+
@status = data["status"]
|
|
80
|
+
@applied = data["applied"]
|
|
81
|
+
@policy = data["policy"]&.to_sym
|
|
82
|
+
@changes = freeze_items(data["changes"])
|
|
83
|
+
@conflicts = freeze_items(data["conflicts"])
|
|
84
|
+
@warnings = freeze_items(data["warnings"])
|
|
85
|
+
@resize_status = freeze_items(data["resize_status"])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [Boolean] whether the changes were applied (false for a dry run)
|
|
89
|
+
def applied?
|
|
90
|
+
@applied
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def inspect
|
|
94
|
+
"#<Microsandbox::ModificationPlan sandbox=#{@sandbox.inspect} applied=#{@applied} " \
|
|
95
|
+
"policy=#{@policy} changes=#{@changes.size} conflicts=#{@conflicts.size} " \
|
|
96
|
+
"warnings=#{@warnings.size}>"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# Convert a JSON array of plan entries (string-keyed Hashes) into a frozen
|
|
102
|
+
# Array of frozen, symbol-keyed Hashes. The entries are shallow (all values
|
|
103
|
+
# are scalars or string arrays), so a single `transform_keys` suffices.
|
|
104
|
+
def freeze_items(items)
|
|
105
|
+
Array(items).map { |item| item.transform_keys(&:to_sym).freeze }.freeze
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
data/lib/microsandbox/sandbox.rb
CHANGED
|
@@ -116,6 +116,30 @@ module Microsandbox
|
|
|
116
116
|
JSON.parse(@native.config_json)
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
+
# Health-check the guest agent without refreshing the idle timer. Connects
|
|
120
|
+
# to the running sandbox and sends `core.ping`; a stopped sandbox raises
|
|
121
|
+
# {SandboxNotRunningError} (it is not started implicitly). Mirrors the
|
|
122
|
+
# official SDKs' `ping`.
|
|
123
|
+
# @return [PingResult]
|
|
124
|
+
def ping
|
|
125
|
+
PingResult.new(@native.ping)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Explicitly refresh this sandbox's idle-activity timer. A stopped sandbox
|
|
129
|
+
# raises {SandboxNotRunningError}. Mirrors the official SDKs' `touch`.
|
|
130
|
+
# @return [TouchResult]
|
|
131
|
+
def touch
|
|
132
|
+
TouchResult.new(@native.touch)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Plan or apply a live modification of this sandbox. See {Sandbox#modify} for
|
|
136
|
+
# the full option set.
|
|
137
|
+
# @return [ModificationPlan]
|
|
138
|
+
def modify(**kwargs)
|
|
139
|
+
opts = Sandbox.send(:build_modify_opts, **kwargs)
|
|
140
|
+
ModificationPlan.new(JSON.parse(@native.modify(opts)))
|
|
141
|
+
end
|
|
142
|
+
|
|
119
143
|
# Snapshot this (stopped) sandbox under a bare name, resolved under the
|
|
120
144
|
# snapshots dir. Convenience equivalent of
|
|
121
145
|
# `Snapshot.create(name, name: <snapshot-name>)` addressed by this handle.
|
|
@@ -213,7 +237,12 @@ module Microsandbox
|
|
|
213
237
|
# @param name [String] sandbox name (max 128 UTF-8 bytes)
|
|
214
238
|
# @param image [String, nil] OCI image reference (e.g. "python")
|
|
215
239
|
# @param cpus [Integer, nil] number of vCPUs
|
|
240
|
+
# @param max_cpus [Integer, nil] boot-time maximum vCPU ceiling a later live
|
|
241
|
+
# {Sandbox#modify} can grow up to (defaults to `cpus`; must be >= `cpus`)
|
|
216
242
|
# @param memory [Integer, nil] memory in MiB
|
|
243
|
+
# @param max_memory [Integer, nil] boot-time maximum memory ceiling (MiB) a
|
|
244
|
+
# later live {Sandbox#modify} can grow up to (defaults to `memory`; must
|
|
245
|
+
# be >= `memory`)
|
|
217
246
|
# @param env [Hash, nil] environment variables
|
|
218
247
|
# @param workdir [String, nil] working directory inside the guest
|
|
219
248
|
# @param shell [String, nil] default shell (for {#shell})
|
|
@@ -334,7 +363,8 @@ module Microsandbox
|
|
|
334
363
|
|
|
335
364
|
# @api private
|
|
336
365
|
# Shared keyword-option builder for {create}/{create_with_progress}.
|
|
337
|
-
def build_create_opts(image: nil, cpus: nil,
|
|
366
|
+
def build_create_opts(image: nil, cpus: nil, max_cpus: nil, memory: nil, max_memory: nil,
|
|
367
|
+
env: nil, workdir: nil,
|
|
338
368
|
shell: nil, user: nil, hostname: nil, labels: nil, scripts: nil,
|
|
339
369
|
entrypoint: nil, ports: nil, ports_udp: nil, volumes: nil, network: nil,
|
|
340
370
|
dns: nil, tls: nil, ipv4_pool: nil, ipv6_pool: nil,
|
|
@@ -370,7 +400,9 @@ module Microsandbox
|
|
|
370
400
|
opts["from_snapshot"] = from_snapshot.to_s if from_snapshot
|
|
371
401
|
opts["fstype"] = fstype.to_s if fstype
|
|
372
402
|
opts["cpus"] = Integer(cpus) if cpus
|
|
403
|
+
opts["max_cpus"] = Integer(max_cpus) if max_cpus
|
|
373
404
|
opts["memory"] = Integer(memory) if memory
|
|
405
|
+
opts["max_memory"] = Integer(max_memory) if max_memory
|
|
374
406
|
opts["workdir"] = workdir.to_s if workdir
|
|
375
407
|
opts["shell"] = shell.to_s if shell
|
|
376
408
|
opts["user"] = user.to_s if user
|
|
@@ -522,6 +554,99 @@ module Microsandbox
|
|
|
522
554
|
Array(secrets).map { |spec| normalize_secret(spec) }
|
|
523
555
|
end
|
|
524
556
|
|
|
557
|
+
# @api private
|
|
558
|
+
# Shared keyword-option builder for {Sandbox#modify}/{SandboxHandle#modify}.
|
|
559
|
+
# Produces the string-keyed Hash the native `modify` reads. `policy` is
|
|
560
|
+
# always set (defaulting to "no_restart"); everything else is included only
|
|
561
|
+
# when provided so an unset option means "leave unchanged".
|
|
562
|
+
def build_modify_opts(cpus: nil, max_cpus: nil, memory: nil, max_memory: nil,
|
|
563
|
+
env: nil, remove_env: nil, labels: nil, remove_labels: nil, workdir: nil,
|
|
564
|
+
secrets: nil, remove_secrets: nil, policy: nil, dry_run: false)
|
|
565
|
+
opts = {}
|
|
566
|
+
opts["cpus"] = Integer(cpus) if cpus
|
|
567
|
+
opts["max_cpus"] = Integer(max_cpus) if max_cpus
|
|
568
|
+
opts["memory"] = Integer(memory) if memory
|
|
569
|
+
opts["max_memory"] = Integer(max_memory) if max_memory
|
|
570
|
+
opts["env"] = stringify(env) if env
|
|
571
|
+
opts["remove_env"] = Array(remove_env).map(&:to_s) if remove_env
|
|
572
|
+
opts["labels"] = stringify(labels) if labels
|
|
573
|
+
opts["remove_labels"] = Array(remove_labels).map(&:to_s) if remove_labels
|
|
574
|
+
opts["workdir"] = workdir.to_s if workdir
|
|
575
|
+
opts["secrets"] = normalize_modify_secrets(secrets) if secrets
|
|
576
|
+
opts["remove_secrets"] = Array(remove_secrets).map(&:to_s) if remove_secrets
|
|
577
|
+
opts["policy"] = normalize_modify_policy(policy)
|
|
578
|
+
opts["dry_run"] = true if dry_run
|
|
579
|
+
opts
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
# Normalize the `secrets:` modify option — a Hash of `{ name => spec }` —
|
|
583
|
+
# into the native array of specs, sorted by name for deterministic patch
|
|
584
|
+
# (and plan) ordering. The modify secret vocabulary differs from create's
|
|
585
|
+
# (source-or-value with `env:`/`store:`/`value:` mutually exclusive, keyed
|
|
586
|
+
# by secret name), mirroring the upstream `SecretModifySpec`.
|
|
587
|
+
def normalize_modify_secrets(secrets)
|
|
588
|
+
unless secrets.is_a?(Hash)
|
|
589
|
+
raise ArgumentError, "secrets: must be a Hash of { name => spec } (got #{secrets.class})"
|
|
590
|
+
end
|
|
591
|
+
out = secrets.map { |name, spec| normalize_modify_secret(name, spec) }
|
|
592
|
+
.sort_by { |h| h["name"] }
|
|
593
|
+
# A Symbol and a String key stringify to the same secret name, yielding
|
|
594
|
+
# two same-name specs. Upstream's fluent API dedupes those; the patch
|
|
595
|
+
# path we drive does not, and duplicates make the live value and the
|
|
596
|
+
# persisted config diverge nondeterministically — reject them loudly.
|
|
597
|
+
dup = out.group_by { |h| h["name"] }.find { |_, specs| specs.size > 1 }
|
|
598
|
+
if dup
|
|
599
|
+
raise ArgumentError,
|
|
600
|
+
"secrets: duplicate entries for #{dup.first.inspect} (a Symbol and a String key stringify to the same name)"
|
|
601
|
+
end
|
|
602
|
+
out
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
def normalize_modify_secret(name, spec)
|
|
606
|
+
spec ||= {}
|
|
607
|
+
unless spec.is_a?(Hash)
|
|
608
|
+
# Reject before any spec access: a non-Hash spec (e.g. the tempting
|
|
609
|
+
# `{ name => raw_value }` shorthand) must never reach a method call
|
|
610
|
+
# whose NoMethodError would embed the receiver — cleartext — in the
|
|
611
|
+
# message on Ruby < 3.3.
|
|
612
|
+
raise ArgumentError,
|
|
613
|
+
"secret #{name.to_s.inspect}: spec must be a Hash (e.g. { value: ... } or { env: ... }), got #{spec.class}"
|
|
614
|
+
end
|
|
615
|
+
env = fetch_opt(spec, :env)
|
|
616
|
+
value = fetch_opt(spec, :value)
|
|
617
|
+
store = fetch_opt(spec, :store)
|
|
618
|
+
present = {"env" => env, "value" => value, "store" => store}.reject { |_, v| v.nil? }
|
|
619
|
+
if present.size > 1
|
|
620
|
+
# Report only the conflicting KEYS, never the values — a secret spec's
|
|
621
|
+
# :value is cleartext and error messages routinely land in logs and
|
|
622
|
+
# error trackers. Mirrors normalize_secret above.
|
|
623
|
+
keys = present.keys.map(&:inspect).join(" and ")
|
|
624
|
+
raise ArgumentError,
|
|
625
|
+
"secret #{name.to_s.inspect}: #{keys} are mutually exclusive; set at most one"
|
|
626
|
+
end
|
|
627
|
+
out = {"name" => name.to_s}
|
|
628
|
+
out["env"] = env.to_s if env
|
|
629
|
+
out["value"] = value.to_s if value
|
|
630
|
+
out["store"] = store.to_s if store
|
|
631
|
+
pl = fetch_opt(spec, :placeholder)
|
|
632
|
+
out["placeholder"] = pl.to_s if pl
|
|
633
|
+
hosts = spec[:allowed_hosts] || spec["allowed_hosts"]
|
|
634
|
+
out["allowed_hosts"] = Array(hosts).map(&:to_s) if hosts
|
|
635
|
+
out
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
# Normalize the `policy:` modify option (Symbol or String) to the native
|
|
639
|
+
# string, defaulting to "no_restart".
|
|
640
|
+
def normalize_modify_policy(policy)
|
|
641
|
+
return "no_restart" if policy.nil?
|
|
642
|
+
s = policy.to_s
|
|
643
|
+
unless %w[no_restart next_start restart].include?(s)
|
|
644
|
+
raise ArgumentError,
|
|
645
|
+
"policy: must be :no_restart, :next_start, or :restart (got #{policy.inspect})"
|
|
646
|
+
end
|
|
647
|
+
s
|
|
648
|
+
end
|
|
649
|
+
|
|
525
650
|
def normalize_secret(spec)
|
|
526
651
|
env = spec[:env] || spec["env"]
|
|
527
652
|
value = spec[:value] || spec["value"]
|
|
@@ -941,6 +1066,57 @@ module Microsandbox
|
|
|
941
1066
|
Metrics.new(@native.metrics)
|
|
942
1067
|
end
|
|
943
1068
|
|
|
1069
|
+
# Health-check the guest agent without refreshing the idle timer. Sends
|
|
1070
|
+
# `core.ping` to the running sandbox and reports the round-trip latency.
|
|
1071
|
+
# Mirrors the official SDKs' `ping`.
|
|
1072
|
+
# @return [PingResult]
|
|
1073
|
+
def ping
|
|
1074
|
+
PingResult.new(@native.ping)
|
|
1075
|
+
end
|
|
1076
|
+
|
|
1077
|
+
# Explicitly refresh this sandbox's idle-activity timer (resetting any
|
|
1078
|
+
# `idle_timeout:` countdown). Mirrors the official SDKs' `touch`.
|
|
1079
|
+
# @return [TouchResult]
|
|
1080
|
+
def touch
|
|
1081
|
+
TouchResult.new(@native.touch)
|
|
1082
|
+
end
|
|
1083
|
+
|
|
1084
|
+
# Plan or apply a live modification of this sandbox: resize CPU/memory,
|
|
1085
|
+
# adjust env/labels/workdir, and rotate/remove secrets, without recreating
|
|
1086
|
+
# the sandbox. The apply is all-or-nothing under the chosen `policy:`: the
|
|
1087
|
+
# default `:no_restart` applies only changes that are live-capable *right
|
|
1088
|
+
# now* and raises if any requested change needs a restart (on a running
|
|
1089
|
+
# sandbox that includes env/labels/workdir and *adding* a secret — rotating
|
|
1090
|
+
# or removing an existing one is live). Pass `policy: :next_start` to
|
|
1091
|
+
# persist restart-required changes for the next start, or `:restart` to
|
|
1092
|
+
# restart and apply them now; use `dry_run: true` to preview each change's
|
|
1093
|
+
# disposition without applying anything. Mirrors the official SDKs'
|
|
1094
|
+
# `modify`. Returns a {ModificationPlan} classifying each change.
|
|
1095
|
+
#
|
|
1096
|
+
# @param cpus [Integer, nil] desired effective vCPU count
|
|
1097
|
+
# @param max_cpus [Integer, nil] desired boot-time maximum vCPU ceiling
|
|
1098
|
+
# @param memory [Integer, nil] desired effective guest memory in MiB
|
|
1099
|
+
# @param max_memory [Integer, nil] desired boot-time maximum memory (MiB)
|
|
1100
|
+
# @param env [Hash, nil] environment variables to set for future execs
|
|
1101
|
+
# @param remove_env [Array<String>, nil] environment variable names to remove
|
|
1102
|
+
# @param labels [Hash, nil] labels to set
|
|
1103
|
+
# @param remove_labels [Array<String>, nil] label keys to remove
|
|
1104
|
+
# @param workdir [String, nil] desired working directory for future execs
|
|
1105
|
+
# @param secrets [Hash, nil] desired secret specs keyed by secret name. Each
|
|
1106
|
+
# spec is a Hash with at most one of `env:` (host env var to read),
|
|
1107
|
+
# `store:` (host secret-store reference), or `value:` (raw material), plus
|
|
1108
|
+
# optional `placeholder:` and `allowed_hosts:` (Array of host patterns).
|
|
1109
|
+
# @param remove_secrets [Array<String>, nil] secret names to remove
|
|
1110
|
+
# @param policy [Symbol, String, nil] `:no_restart` (default — apply only
|
|
1111
|
+
# changes that need no restart), `:next_start` (persist for the next
|
|
1112
|
+
# start), or `:restart` (restart to apply restart-required changes)
|
|
1113
|
+
# @param dry_run [Boolean] compute the plan without applying anything
|
|
1114
|
+
# @return [ModificationPlan]
|
|
1115
|
+
def modify(**kwargs)
|
|
1116
|
+
opts = Sandbox.send(:build_modify_opts, **kwargs)
|
|
1117
|
+
ModificationPlan.new(JSON.parse(@native.modify(opts)))
|
|
1118
|
+
end
|
|
1119
|
+
|
|
944
1120
|
# Read captured logs.
|
|
945
1121
|
#
|
|
946
1122
|
# @param tail [Integer, nil] only the last N entries
|
data/lib/microsandbox/version.rb
CHANGED
|
@@ -8,12 +8,12 @@ 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.
|
|
11
|
+
VERSION = "0.10.0"
|
|
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
|
|
15
15
|
# ext/microsandbox/Cargo.toml. Exposed at runtime as
|
|
16
16
|
# {Microsandbox.runtime_version}. spec/unit/version_spec.rb asserts it stays in
|
|
17
17
|
# sync with the Cargo tag so it can't silently drift out of date.
|
|
18
|
-
RUNTIME_VERSION = "v0.6.
|
|
18
|
+
RUNTIME_VERSION = "v0.6.6"
|
|
19
19
|
end
|
data/lib/microsandbox.rb
CHANGED
|
@@ -30,6 +30,7 @@ require_relative "microsandbox/patch"
|
|
|
30
30
|
require_relative "microsandbox/network"
|
|
31
31
|
require_relative "microsandbox/agent"
|
|
32
32
|
require_relative "microsandbox/ssh"
|
|
33
|
+
require_relative "microsandbox/modification"
|
|
33
34
|
require_relative "microsandbox/sandbox"
|
|
34
35
|
|
|
35
36
|
# Microsandbox — lightweight microVM sandboxes for Ruby.
|