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.
@@ -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::get(local, &reference).await)?;
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::list(local).await)?;
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::inspect(local, &reference).await)?;
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::remove(local, &reference, force).await)
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::prune(local).await)?;
108
+ let report = with_local_backend(async |local| Image::prune_local(local).await)?;
109
109
  Ok(report_to_hash(report))
110
110
  }
111
111
 
@@ -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::all_sandbox_metrics(local).await
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 = microsandbox::config::resolve_msb_path(local.config()).map_err(error::to_ruby)?;
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, HostPermissions,
22
- Patch, PullPolicy, PullProgress, PullProgressHandle, RlimitResource, SandboxBuilder,
23
- SandboxFilter, SandboxHandle, SandboxMetrics, SandboxStatus, SandboxStopResult, SecretBuilder,
24
- SecurityProfile, StatVirtualization,
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: &microsandbox::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: &microsandbox::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
@@ -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, memory: nil, env: nil, workdir: 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
@@ -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.9.1"
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.2"
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.