microsandbox-rb 0.9.2 → 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.
data/README.md CHANGED
@@ -229,6 +229,55 @@ Microsandbox::Sandbox.create("obs", image: "public.ecr.aws/docker/library/alpine
229
229
  end
230
230
  ```
231
231
 
232
+ ### Live modification & health
233
+
234
+ Resize, reconfigure, or rotate secrets on a sandbox **without recreating it**,
235
+ and probe the guest agent's liveness. To leave headroom for a live CPU/memory
236
+ resize, reserve a ceiling at create time with `max_cpus:`/`max_memory:`:
237
+
238
+ ```ruby
239
+ Microsandbox::Sandbox.create("live", image: "public.ecr.aws/docker/library/alpine:latest",
240
+ cpus: 1, max_cpus: 4, memory: 512, max_memory: 2048) do |sb|
241
+ # Health check — does NOT refresh the idle timer:
242
+ ping = sb.ping # => Microsandbox::PingResult
243
+ ping.latency_ms # round-trip latency
244
+
245
+ # Explicitly refresh the idle-activity timer (resets any idle_timeout:):
246
+ sb.touch.activity_seq # => Microsandbox::TouchResult
247
+
248
+ # Preview a change without applying it:
249
+ plan = sb.modify(cpus: 2, memory: 1024, dry_run: true)
250
+ plan.applied? # => false
251
+ plan.changes # => [{ kind: "config", field: "cpus", ... }, ...]
252
+
253
+ # Live resize — applies to the running VM under the default :no_restart policy:
254
+ sb.modify(cpus: 2, memory: 1024)
255
+
256
+ # env/labels/workdir changes on a *running* sandbox require a restart, so the
257
+ # default :no_restart policy rejects the whole apply (it raises rather than
258
+ # partially applying). Persist them for the next start — or restart now —
259
+ # by saying so explicitly:
260
+ sb.modify(env: { "TIER" => "prod" }, remove_env: ["DEBUG"],
261
+ labels: { "role" => "worker" }, policy: :next_start) # or policy: :restart
262
+
263
+ # Rotating/removing an *existing* secret (or updating its allowed hosts) is
264
+ # live; *adding* a new secret is restart-required, like env. Specs are keyed
265
+ # by name; env:/store:/value: are mutually exclusive:
266
+ sb.modify(
267
+ secrets: { "API_KEY" => { env: "HOST_API_KEY", allowed_hosts: ["api.example.com"] } },
268
+ remove_secrets: ["OLD_TOKEN"],
269
+ )
270
+ end
271
+ ```
272
+
273
+ The apply is **all-or-nothing**: under a given `policy:` every planned change
274
+ must be applicable, or the whole `modify` raises — nothing is partially
275
+ applied. Use `dry_run: true` to inspect each change's `disposition` (`"live"`,
276
+ `"next start"`, `"requires restart"`) before committing.
277
+
278
+ `SandboxHandle` (from `Sandbox.get`/`list`) carries the same `#ping`/`#touch`/
279
+ `#modify`; on a stopped sandbox `#ping`/`#touch` raise `SandboxNotRunningError`.
280
+
232
281
  ### Streaming output
233
282
 
234
283
  For long-running commands, stream events as they arrive instead of waiting:
@@ -393,8 +442,8 @@ change diverged the two numbers — the gem version is **not** a reliable indica
393
442
  of the embedded runtime version. To learn which runtime a build wraps, ask it:
394
443
 
395
444
  ```ruby
396
- Microsandbox::VERSION # => "0.9.2" (the gem's own version)
397
- Microsandbox.runtime_version # => "v0.6.3" (the embedded upstream runtime tag)
445
+ Microsandbox::VERSION # => "0.10.0" (the gem's own version)
446
+ Microsandbox.runtime_version # => "v0.6.6" (the embedded upstream runtime tag)
398
447
  ```
399
448
 
400
449
  | Gem version | Upstream runtime | Notes |
@@ -413,6 +462,8 @@ Microsandbox.runtime_version # => "v0.6.3" (the embedded upstream runtime tag
413
462
  | `0.9.0` | `v0.6.1` | adopts upstream `v0.6.0`+`v0.6.1` (zombie-runtime wait fix, secret substitution through CONNECT proxies, stale-sandbox cleanup); upstream public API is additive — no Ruby surface change |
414
463
  | `0.9.1` | `v0.6.2` | adopts upstream `v0.6.2` (faster image loads/pulls via early cache gate + zlib-rs); upstream API unchanged — no Ruby surface change |
415
464
  | `0.9.2` | `v0.6.3` | adopts upstream `v0.6.3` (v4-only sandboxes stop advertising AAAA DNS answers — fixes guest gRPC/c-ares preferring unreachable IPv6; scoped upstream TLS verification); glue moves to `*_local` SDK variants — no Ruby surface change |
465
+ | `0.9.3` | `v0.6.6` | adopts upstream `v0.6.4`+`v0.6.6` (`v0.6.5` was yanked upstream): snapshot restore by pinned digest — fixes fatal restore-after-tag-republish bug, fragmented-UDP/PMTU relay fixes, exec kills the whole process group, ephemeral stop-wait tolerance, readdir RSS-leak fix; upstream API growth is additive-only — no Ruby surface change |
466
+ | `0.10.0` | `v0.6.6` | `v0.6.6` API parity: live `modify`/resize, `ping`/`touch`, create `max_cpus`/`max_memory` |
416
467
 
417
468
  **Going forward** — the gem version moves on its own semver track and no longer
418
469
  mirrors the upstream tag:
@@ -6,8 +6,8 @@ name = "microsandbox_rb"
6
6
  description = "Ruby SDK native extension for microsandbox — secure, fast microVM-based sandboxing."
7
7
  # Must equal Microsandbox::VERSION (lib/microsandbox/version.rb) — Native.version
8
8
  # returns this via env!("CARGO_PKG_VERSION") and version_spec.rb asserts equality.
9
- # The core-crate dependency below stays pinned at its own tag (v0.6.3).
10
- version = "0.9.2"
9
+ # The core-crate dependency below stays pinned at its own tag (v0.6.6).
10
+ version = "0.10.0"
11
11
  authors = ["Super Rad Company <development@superrad.company>"]
12
12
  repository = "https://github.com/superradcompany/microsandbox"
13
13
  license = "Apache-2.0"
@@ -35,8 +35,8 @@ rb-sys = "0.9"
35
35
  # `.cargo/config.toml.example`). "ssh" matches the feature set the Python/Node
36
36
  # SDKs ship with; default features add "prebuilt" (provisions msb + libkrunfw at
37
37
  # build time), "net", and "keyring".
38
- microsandbox = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.3", default-features = true, features = ["ssh"] }
39
- microsandbox-network = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.3" }
38
+ microsandbox = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.6", default-features = true, features = ["ssh"] }
39
+ microsandbox-network = { git = "https://github.com/superradcompany/microsandbox", tag = "v0.6.6" }
40
40
 
41
41
  # Async core bridged to Ruby's synchronous API via a blocking tokio runtime.
42
42
  tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
@@ -17,6 +17,12 @@ fn class_name(err: &MicrosandboxError) -> &'static str {
17
17
  SandboxNotFound(_) => "SandboxNotFoundError",
18
18
  SandboxAlreadyExists(_) => "SandboxAlreadyExistsError",
19
19
  SandboxStillRunning(_) => "SandboxStillRunningError",
20
+ // v0.6.6 (#1099): the sandbox exists but isn't running. Raised by the
21
+ // handle's exec/attach/ping/touch not-running guards and the fs
22
+ // agent-endpoint lookup. The `SandboxNotRunningError` class already
23
+ // existed (mirroring the Python SDK); this wires the new core variant to
24
+ // it instead of letting it collapse to the base `Error`.
25
+ SandboxNotRunning(_) => "SandboxNotRunningError",
20
26
  ExecTimeout(_) => "ExecTimeoutError",
21
27
  ExecFailed(_) => "ExecFailedError",
22
28
  SandboxFsOps(_) => "FilesystemError",
@@ -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