microsandbox-rb 0.9.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Microsandbox
4
+ # Factory for an OCI sandbox's writable **root disk** spec (runtime v0.6.7),
5
+ # passed to {Sandbox.create} via `root_disk:`. Three kinds:
6
+ #
7
+ # - {managed} — the default: a sparse ext4 upper file created and resized by
8
+ # the runtime (4 GiB cap when no size is given). A bare Integer passed to
9
+ # `root_disk:` means the same thing.
10
+ # - {tmpfs} — RAM-backed upper, so the rootfs is pristine on every boot.
11
+ # Default size is half the sandbox memory; the size must not exceed sandbox
12
+ # memory (no swap in the guest). Cannot be snapshotted or patched.
13
+ # - {disk} — a user-supplied disk image attached writable as the upper. The
14
+ # file determines its own size; the runtime never creates, resizes, or
15
+ # deletes it. Cannot be snapshotted or patched.
16
+ #
17
+ # @example
18
+ # Sandbox.create("worker", image: "python", root_disk: 8192)
19
+ # Sandbox.create("ci", image: "python", root_disk: Microsandbox::RootDisk.tmpfs(2048))
20
+ # Sandbox.create("warm", image: "python",
21
+ # root_disk: Microsandbox::RootDisk.disk("./scratch.img", fstype: "ext4"))
22
+ #
23
+ # Mirrors the `RootDisk` factory in the official Python/Node/Go SDKs.
24
+ module RootDisk
25
+ module_function
26
+
27
+ # The managed sparse-ext4 upper (the default kind).
28
+ # @param size_mib [Integer, nil] size cap in MiB (default 4096)
29
+ # @return [Hash]
30
+ def managed(size_mib = nil)
31
+ h = {"kind" => "managed"}
32
+ h["size_mib"] = Integer(size_mib) if size_mib
33
+ h
34
+ end
35
+
36
+ # A RAM-backed tmpfs upper — pristine rootfs on every boot.
37
+ # @param size_mib [Integer, nil] size in MiB (default: half sandbox memory)
38
+ # @return [Hash]
39
+ def tmpfs(size_mib = nil)
40
+ h = {"kind" => "tmpfs"}
41
+ h["size_mib"] = Integer(size_mib) if size_mib
42
+ h
43
+ end
44
+
45
+ # A user-supplied disk image attached writable as the upper.
46
+ # @param path [String] path to the image file
47
+ # @param format ["raw", "qcow2", nil] image format (inferred from the
48
+ # .img/.raw/.qcow2 extension when omitted)
49
+ # @param fstype [String, nil] filesystem type inside the image, e.g. "ext4"
50
+ # @return [Hash]
51
+ def disk(path, format: nil, fstype: nil)
52
+ h = {"kind" => "disk", "path" => path.to_s}
53
+ h["format"] = format.to_s if format
54
+ h["fstype"] = fstype.to_s if fstype
55
+ h
56
+ end
57
+ end
58
+ end
@@ -116,22 +116,42 @@ 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
- # `Snapshot.create(name, name: <snapshot-name>)` addressed by this handle.
145
+ # `Snapshot.create(<snapshot-name>, from_sandbox: <this sandbox>)`.
146
+ # (`snapshot_to(path)` was removed in 0.11.0 with runtime v0.6.7 — for
147
+ # explicit placement use `Snapshot.create(name, from_sandbox:, dest_dir:)`;
148
+ # the artifact lands at `dest_dir/<name>`.)
122
149
  # @param name [String] destination snapshot name
123
150
  # @return [SnapshotInfo]
124
151
  def snapshot(name)
125
152
  SnapshotInfo.new(@native.snapshot(name.to_s))
126
153
  end
127
154
 
128
- # Snapshot this (stopped) sandbox to an explicit filesystem path.
129
- # @param path [String] destination directory
130
- # @return [SnapshotInfo]
131
- def snapshot_to(path)
132
- SnapshotInfo.new(@native.snapshot_to(path.to_s))
133
- end
134
-
135
155
  def inspect
136
156
  "#<Microsandbox::SandboxHandle name=#{name.inspect} status=#{status}>"
137
157
  end
@@ -213,7 +233,12 @@ module Microsandbox
213
233
  # @param name [String] sandbox name (max 128 UTF-8 bytes)
214
234
  # @param image [String, nil] OCI image reference (e.g. "python")
215
235
  # @param cpus [Integer, nil] number of vCPUs
236
+ # @param max_cpus [Integer, nil] boot-time maximum vCPU ceiling a later live
237
+ # {Sandbox#modify} can grow up to (defaults to `cpus`; must be >= `cpus`)
216
238
  # @param memory [Integer, nil] memory in MiB
239
+ # @param max_memory [Integer, nil] boot-time maximum memory ceiling (MiB) a
240
+ # later live {Sandbox#modify} can grow up to (defaults to `memory`; must
241
+ # be >= `memory`)
217
242
  # @param env [Hash, nil] environment variables
218
243
  # @param workdir [String, nil] working directory inside the guest
219
244
  # @param shell [String, nil] default shell (for {#shell})
@@ -233,13 +258,18 @@ module Microsandbox
233
258
  # `host_permissions:` (:private/:mirror). A bind mount accepts
234
259
  # `quota_mib:` to override the runtime's default guest-write budget
235
260
  # (4 GiB as of `v0.5.10`); the core rejects it on tmpfs/disk/named (for a
236
- # named volume, set its quota via {Volume.create}).
237
- # @param network [String, Symbol, NetworkPolicy, Hash, nil] network policy.
238
- # A preset name ("public_only" (default), "none", "allow_all",
239
- # "non_local"), a {NetworkPolicy} (e.g. {NetworkPolicy.custom}), or a Hash
240
- # describing a custom policy (`default_egress:`, `default_ingress:`,
241
- # `rules:`, `deny_domains:`, `deny_domain_suffixes:`). See {NetworkPolicy}
242
- # and {Rule}.
261
+ # named volume, set its quota via {Volume.create}). Bind/named mounts
262
+ # also accept `follow_root_symlinks: true` to opt out of the default-on
263
+ # mount-root symlink protection (runtime v0.6.7).
264
+ # @param network [Array, String, Symbol, NetworkPolicy, Hash, nil] network
265
+ # policy. Composable profiles (`[:public]` (the default), `[:public,
266
+ # :private]`, `:host`, ), a terminal preset (:none, :allow_all), a
267
+ # {NetworkPolicy} (e.g. {NetworkPolicy.from_profiles},
268
+ # {NetworkPolicy.custom}), or a Hash describing a custom policy
269
+ # (`profiles:`, `default_egress:`, `default_ingress:`, `rules:`,
270
+ # `deny_domains:`, `deny_domain_suffixes:`). See {NetworkPolicy} and
271
+ # {Rule}. The v0.6.6 `public_only`/`non_local` presets were removed in
272
+ # runtime v0.6.7 — use `[:public]` / `[:public, :private]`.
243
273
  # @param dns [Hash, nil] custom DNS: `{ nameservers: [...],
244
274
  # rebind_protection: true, query_timeout_ms: 2000 }`
245
275
  # @param tls [Hash, nil] TLS-interception tuning: `{ bypass: [...patterns],
@@ -267,7 +297,16 @@ module Microsandbox
267
297
  # @param log_level ["error","warn","info","debug","trace", nil] guest log verbosity
268
298
  # @param quiet_logs [Boolean] suppress sandbox process logs
269
299
  # @param security ["default", "restricted", nil] exec security profile
270
- # @param oci_upper_size [Integer, nil] writable upper-layer size cap, in MiB
300
+ # @param root_disk [Integer, Hash, nil] the OCI sandbox's writable root
301
+ # disk (runtime v0.6.7). An Integer is the managed ext4 upper's size cap
302
+ # in MiB (default kind, 4 GiB when unset); a Hash picks a kind via the
303
+ # {RootDisk} factory — `RootDisk.managed(8192)`, `RootDisk.tmpfs(2048)`
304
+ # (RAM-backed, pristine on every boot), or
305
+ # `RootDisk.disk("./scratch.img", format: "raw", fstype: "ext4")`
306
+ # (user-supplied image attached writable).
307
+ # @param oci_upper_size [Integer, nil] deprecated alias for
308
+ # `root_disk: <Integer>` (the managed kind); warns, and conflicts with
309
+ # `root_disk:`
271
310
  # @param max_duration [Integer, nil] hard wall-clock lifetime, in seconds
272
311
  # @param idle_timeout [Integer, nil] stop after this many idle seconds
273
312
  # @param rlimits [Hash, nil] resource limits: { resource => limit } or
@@ -334,7 +373,8 @@ module Microsandbox
334
373
 
335
374
  # @api private
336
375
  # Shared keyword-option builder for {create}/{create_with_progress}.
337
- def build_create_opts(image: nil, cpus: nil, memory: nil, env: nil, workdir: nil,
376
+ def build_create_opts(image: nil, cpus: nil, max_cpus: nil, memory: nil, max_memory: nil,
377
+ env: nil, workdir: nil,
338
378
  shell: nil, user: nil, hostname: nil, labels: nil, scripts: nil,
339
379
  entrypoint: nil, ports: nil, ports_udp: nil, volumes: nil, network: nil,
340
380
  dns: nil, tls: nil, ipv4_pool: nil, ipv6_pool: nil,
@@ -342,7 +382,7 @@ module Microsandbox
342
382
  patches: nil,
343
383
  from_snapshot: nil, fstype: nil, init: nil, ephemeral: false,
344
384
  log_level: nil, quiet_logs: false, security: nil,
345
- oci_upper_size: nil, max_duration: nil, idle_timeout: nil, rlimits: nil,
385
+ root_disk: nil, oci_upper_size: nil, max_duration: nil, idle_timeout: nil, rlimits: nil,
346
386
  pull_policy: nil, registry_auth: nil, registry_insecure: false,
347
387
  registry_ca_certs: nil, secrets: nil, on_secret_violation: nil,
348
388
  detached: false, replace: false, replace_with_timeout: nil)
@@ -352,6 +392,9 @@ module Microsandbox
352
392
  if image && from_snapshot
353
393
  raise ArgumentError, "provide either image: or from_snapshot:, not both"
354
394
  end
395
+ if root_disk && oci_upper_size
396
+ raise ArgumentError, "pass either root_disk: or oci_upper_size:, not both"
397
+ end
355
398
  Microsandbox.ensure_runtime!
356
399
  # `fstype:` names the inner filesystem of a disk-image rootfs, so it only
357
400
  # applies when `image:` is a disk-image path (a local path ending in
@@ -370,7 +413,9 @@ module Microsandbox
370
413
  opts["from_snapshot"] = from_snapshot.to_s if from_snapshot
371
414
  opts["fstype"] = fstype.to_s if fstype
372
415
  opts["cpus"] = Integer(cpus) if cpus
416
+ opts["max_cpus"] = Integer(max_cpus) if max_cpus
373
417
  opts["memory"] = Integer(memory) if memory
418
+ opts["max_memory"] = Integer(max_memory) if max_memory
374
419
  opts["workdir"] = workdir.to_s if workdir
375
420
  opts["shell"] = shell.to_s if shell
376
421
  opts["user"] = user.to_s if user
@@ -393,7 +438,14 @@ module Microsandbox
393
438
  opts["log_level"] = log_level.to_s if log_level
394
439
  opts["quiet_logs"] = true if quiet_logs
395
440
  opts["security"] = security.to_s if security
396
- opts["oci_upper_size"] = Integer(oci_upper_size) if oci_upper_size
441
+ if oci_upper_size
442
+ warn "Microsandbox: oci_upper_size: is deprecated; use root_disk: " \
443
+ "(an Integer size in MiB, or RootDisk.managed/tmpfs/disk)",
444
+ uplevel: 2
445
+ opts["root_disk"] = coerce_root_disk_size(oci_upper_size, "oci_upper_size:")
446
+ elsif root_disk
447
+ opts["root_disk"] = normalize_root_disk(root_disk)
448
+ end
397
449
  opts["max_duration"] = Integer(max_duration) if max_duration
398
450
  opts["idle_timeout"] = Integer(idle_timeout) if idle_timeout
399
451
  opts["rlimits"] = normalize_rlimits(rlimits) if rlimits
@@ -522,6 +574,99 @@ module Microsandbox
522
574
  Array(secrets).map { |spec| normalize_secret(spec) }
523
575
  end
524
576
 
577
+ # @api private
578
+ # Shared keyword-option builder for {Sandbox#modify}/{SandboxHandle#modify}.
579
+ # Produces the string-keyed Hash the native `modify` reads. `policy` is
580
+ # always set (defaulting to "no_restart"); everything else is included only
581
+ # when provided so an unset option means "leave unchanged".
582
+ def build_modify_opts(cpus: nil, max_cpus: nil, memory: nil, max_memory: nil,
583
+ env: nil, remove_env: nil, labels: nil, remove_labels: nil, workdir: nil,
584
+ secrets: nil, remove_secrets: nil, policy: nil, dry_run: false)
585
+ opts = {}
586
+ opts["cpus"] = Integer(cpus) if cpus
587
+ opts["max_cpus"] = Integer(max_cpus) if max_cpus
588
+ opts["memory"] = Integer(memory) if memory
589
+ opts["max_memory"] = Integer(max_memory) if max_memory
590
+ opts["env"] = stringify(env) if env
591
+ opts["remove_env"] = Array(remove_env).map(&:to_s) if remove_env
592
+ opts["labels"] = stringify(labels) if labels
593
+ opts["remove_labels"] = Array(remove_labels).map(&:to_s) if remove_labels
594
+ opts["workdir"] = workdir.to_s if workdir
595
+ opts["secrets"] = normalize_modify_secrets(secrets) if secrets
596
+ opts["remove_secrets"] = Array(remove_secrets).map(&:to_s) if remove_secrets
597
+ opts["policy"] = normalize_modify_policy(policy)
598
+ opts["dry_run"] = true if dry_run
599
+ opts
600
+ end
601
+
602
+ # Normalize the `secrets:` modify option — a Hash of `{ name => spec }` —
603
+ # into the native array of specs, sorted by name for deterministic patch
604
+ # (and plan) ordering. The modify secret vocabulary differs from create's
605
+ # (source-or-value with `env:`/`store:`/`value:` mutually exclusive, keyed
606
+ # by secret name), mirroring the upstream `SecretModifySpec`.
607
+ def normalize_modify_secrets(secrets)
608
+ unless secrets.is_a?(Hash)
609
+ raise ArgumentError, "secrets: must be a Hash of { name => spec } (got #{secrets.class})"
610
+ end
611
+ out = secrets.map { |name, spec| normalize_modify_secret(name, spec) }
612
+ .sort_by { |h| h["name"] }
613
+ # A Symbol and a String key stringify to the same secret name, yielding
614
+ # two same-name specs. Upstream's fluent API dedupes those; the patch
615
+ # path we drive does not, and duplicates make the live value and the
616
+ # persisted config diverge nondeterministically — reject them loudly.
617
+ dup = out.group_by { |h| h["name"] }.find { |_, specs| specs.size > 1 }
618
+ if dup
619
+ raise ArgumentError,
620
+ "secrets: duplicate entries for #{dup.first.inspect} (a Symbol and a String key stringify to the same name)"
621
+ end
622
+ out
623
+ end
624
+
625
+ def normalize_modify_secret(name, spec)
626
+ spec ||= {}
627
+ unless spec.is_a?(Hash)
628
+ # Reject before any spec access: a non-Hash spec (e.g. the tempting
629
+ # `{ name => raw_value }` shorthand) must never reach a method call
630
+ # whose NoMethodError would embed the receiver — cleartext — in the
631
+ # message on Ruby < 3.3.
632
+ raise ArgumentError,
633
+ "secret #{name.to_s.inspect}: spec must be a Hash (e.g. { value: ... } or { env: ... }), got #{spec.class}"
634
+ end
635
+ env = fetch_opt(spec, :env)
636
+ value = fetch_opt(spec, :value)
637
+ store = fetch_opt(spec, :store)
638
+ present = {"env" => env, "value" => value, "store" => store}.reject { |_, v| v.nil? }
639
+ if present.size > 1
640
+ # Report only the conflicting KEYS, never the values — a secret spec's
641
+ # :value is cleartext and error messages routinely land in logs and
642
+ # error trackers. Mirrors normalize_secret above.
643
+ keys = present.keys.map(&:inspect).join(" and ")
644
+ raise ArgumentError,
645
+ "secret #{name.to_s.inspect}: #{keys} are mutually exclusive; set at most one"
646
+ end
647
+ out = {"name" => name.to_s}
648
+ out["env"] = env.to_s if env
649
+ out["value"] = value.to_s if value
650
+ out["store"] = store.to_s if store
651
+ pl = fetch_opt(spec, :placeholder)
652
+ out["placeholder"] = pl.to_s if pl
653
+ hosts = spec[:allowed_hosts] || spec["allowed_hosts"]
654
+ out["allowed_hosts"] = Array(hosts).map(&:to_s) if hosts
655
+ out
656
+ end
657
+
658
+ # Normalize the `policy:` modify option (Symbol or String) to the native
659
+ # string, defaulting to "no_restart".
660
+ def normalize_modify_policy(policy)
661
+ return "no_restart" if policy.nil?
662
+ s = policy.to_s
663
+ unless %w[no_restart next_start restart].include?(s)
664
+ raise ArgumentError,
665
+ "policy: must be :no_restart, :next_start, or :restart (got #{policy.inspect})"
666
+ end
667
+ s
668
+ end
669
+
525
670
  def normalize_secret(spec)
526
671
  env = spec[:env] || spec["env"]
527
672
  value = spec[:value] || spec["value"]
@@ -691,6 +836,58 @@ module Microsandbox
691
836
  # { disk: "/img.raw", format: "raw", fstype: "ext4" } # disk-image mount
692
837
  # Any mount may also carry stat_virtualization: (:strict/:relaxed/:off) and
693
838
  # host_permissions: (:private/:mirror); a bind mount may carry quota_mib:.
839
+ # Coerce the `root_disk:` argument — an Integer (managed size in MiB), a
840
+ # {RootDisk} factory Hash, or an equivalent hand-written Hash — into the
841
+ # wire shape, validating kind/field combinations up front (mirrors the
842
+ # Python SDK's extract_root_disk guards).
843
+ def normalize_root_disk(root_disk)
844
+ return coerce_root_disk_size(root_disk, "root_disk:") unless root_disk.is_a?(Hash)
845
+
846
+ spec = root_disk.transform_keys(&:to_s)
847
+ kind = (spec["kind"] || "managed").to_s
848
+ case kind
849
+ when "managed", "tmpfs"
850
+ %w[path format fstype].each do |key|
851
+ if spec[key]
852
+ raise ArgumentError, "root_disk #{key}: is only valid for the disk kind"
853
+ end
854
+ end
855
+ h = {"kind" => kind}
856
+ if spec["size_mib"]
857
+ h["size_mib"] = coerce_root_disk_size(spec["size_mib"], "root_disk size_mib:")
858
+ end
859
+ h
860
+ when "disk", "disk-image", "disk_image"
861
+ path = spec["path"]
862
+ if path.nil? || path.to_s.empty?
863
+ raise ArgumentError, "root_disk disk kind requires path:"
864
+ end
865
+ if spec["size_mib"]
866
+ raise ArgumentError, "root_disk size_mib: is not valid for a disk image " \
867
+ "(the image file determines the size)"
868
+ end
869
+ h = {"kind" => "disk", "path" => path.to_s}
870
+ h["format"] = spec["format"].to_s if spec["format"]
871
+ h["fstype"] = spec["fstype"].to_s if spec["fstype"]
872
+ h
873
+ else
874
+ raise ArgumentError,
875
+ "unknown root_disk kind #{kind.inspect} (expected managed/tmpfs/disk)"
876
+ end
877
+ end
878
+
879
+ # Coerce + range-check a root-disk size. The wire carries u32 MiB; an
880
+ # out-of-range value would otherwise surface as a confusing ext-level
881
+ # type error (or a raw RangeError from the native conversion).
882
+ def coerce_root_disk_size(value, label)
883
+ mib = Integer(value)
884
+ unless mib.positive? && mib <= 4_294_967_295
885
+ raise ArgumentError,
886
+ "#{label} must be a positive size in MiB that fits in 32 bits (got #{mib})"
887
+ end
888
+ mib
889
+ end
890
+
694
891
  def normalize_volumes(volumes)
695
892
  volumes.map do |guest, spec|
696
893
  mount = {"guest" => guest.to_s}
@@ -743,6 +940,9 @@ module Microsandbox
743
940
  # read-only; `noexec:`/`nosuid:`/`nodev:` set the matching flags;
744
941
  # `stat_virtualization:`/`host_permissions:` set the passthrough policies
745
942
  # (only valid on bind/named — the core rejects them on tmpfs/disk).
943
+ # `follow_root_symlinks:` opts out of the default-on mount-root symlink
944
+ # protection (runtime v0.6.7; bind/named only — the core silently ignores
945
+ # it on tmpfs/disk mounts, so reject those here instead).
746
946
  def apply_mount_flags(mount, spec)
747
947
  mount["readonly"] = true if spec[:ro] || spec["ro"] || spec[:readonly] || spec["readonly"]
748
948
  mount["noexec"] = true if spec[:noexec] || spec["noexec"]
@@ -753,6 +953,15 @@ module Microsandbox
753
953
  mount["stat_virtualization"] = sv.to_s if sv
754
954
  hp = spec[:host_permissions] || spec["host_permissions"]
755
955
  mount["host_permissions"] = hp.to_s if hp
956
+ follow = spec.fetch(:follow_root_symlinks, spec["follow_root_symlinks"])
957
+ unless follow.nil?
958
+ unless %w[bind named].include?(mount["kind"])
959
+ raise ArgumentError,
960
+ "follow_root_symlinks: only applies to bind/named mounts " \
961
+ "(got a #{mount["kind"]} mount); a #{mount["kind"]} mount has no host root to protect"
962
+ end
963
+ mount["follow_root_symlinks"] = !!follow
964
+ end
756
965
  end
757
966
 
758
967
  # Translate the pre-0.7.0 `options:` array form (e.g. options: %w[ro noexec])
@@ -793,16 +1002,18 @@ module Microsandbox
793
1002
  end
794
1003
  end
795
1004
 
796
- # Route the `network:` argument to either the preset path
797
- # (`opts["network"]`, the original string-preset behavior) or the custom
798
- # policy path (`opts["network_policy"]`). Accepts a preset String/Symbol, a
799
- # {NetworkPolicy}, or a plain Hash.
1005
+ # Route the `network:` argument to the preset path (`opts["network"]`),
1006
+ # the composed-profiles path (`opts["network_profiles"]`), or the custom
1007
+ # policy path (`opts["network_policy"]`). Accepts profile Symbol(s)/Array,
1008
+ # a preset String/Symbol, a {NetworkPolicy}, or a plain Hash.
800
1009
  def apply_network_opts(opts, network)
801
1010
  norm = NetworkPolicy.coerce(network)
802
1011
  return if norm.empty? # e.g. network: {} — leave the default policy in place
803
1012
 
804
1013
  if norm.keys == ["preset"]
805
1014
  opts["network"] = norm["preset"]
1015
+ elsif norm.keys == ["profiles"]
1016
+ opts["network_profiles"] = norm["profiles"]
806
1017
  else
807
1018
  opts["network_policy"] = norm
808
1019
  end
@@ -941,6 +1152,57 @@ module Microsandbox
941
1152
  Metrics.new(@native.metrics)
942
1153
  end
943
1154
 
1155
+ # Health-check the guest agent without refreshing the idle timer. Sends
1156
+ # `core.ping` to the running sandbox and reports the round-trip latency.
1157
+ # Mirrors the official SDKs' `ping`.
1158
+ # @return [PingResult]
1159
+ def ping
1160
+ PingResult.new(@native.ping)
1161
+ end
1162
+
1163
+ # Explicitly refresh this sandbox's idle-activity timer (resetting any
1164
+ # `idle_timeout:` countdown). Mirrors the official SDKs' `touch`.
1165
+ # @return [TouchResult]
1166
+ def touch
1167
+ TouchResult.new(@native.touch)
1168
+ end
1169
+
1170
+ # Plan or apply a live modification of this sandbox: resize CPU/memory,
1171
+ # adjust env/labels/workdir, and rotate/remove secrets, without recreating
1172
+ # the sandbox. The apply is all-or-nothing under the chosen `policy:`: the
1173
+ # default `:no_restart` applies only changes that are live-capable *right
1174
+ # now* and raises if any requested change needs a restart (on a running
1175
+ # sandbox that includes env/labels/workdir and *adding* a secret — rotating
1176
+ # or removing an existing one is live). Pass `policy: :next_start` to
1177
+ # persist restart-required changes for the next start, or `:restart` to
1178
+ # restart and apply them now; use `dry_run: true` to preview each change's
1179
+ # disposition without applying anything. Mirrors the official SDKs'
1180
+ # `modify`. Returns a {ModificationPlan} classifying each change.
1181
+ #
1182
+ # @param cpus [Integer, nil] desired effective vCPU count
1183
+ # @param max_cpus [Integer, nil] desired boot-time maximum vCPU ceiling
1184
+ # @param memory [Integer, nil] desired effective guest memory in MiB
1185
+ # @param max_memory [Integer, nil] desired boot-time maximum memory (MiB)
1186
+ # @param env [Hash, nil] environment variables to set for future execs
1187
+ # @param remove_env [Array<String>, nil] environment variable names to remove
1188
+ # @param labels [Hash, nil] labels to set
1189
+ # @param remove_labels [Array<String>, nil] label keys to remove
1190
+ # @param workdir [String, nil] desired working directory for future execs
1191
+ # @param secrets [Hash, nil] desired secret specs keyed by secret name. Each
1192
+ # spec is a Hash with at most one of `env:` (host env var to read),
1193
+ # `store:` (host secret-store reference), or `value:` (raw material), plus
1194
+ # optional `placeholder:` and `allowed_hosts:` (Array of host patterns).
1195
+ # @param remove_secrets [Array<String>, nil] secret names to remove
1196
+ # @param policy [Symbol, String, nil] `:no_restart` (default — apply only
1197
+ # changes that need no restart), `:next_start` (persist for the next
1198
+ # start), or `:restart` (restart to apply restart-required changes)
1199
+ # @param dry_run [Boolean] compute the plan without applying anything
1200
+ # @return [ModificationPlan]
1201
+ def modify(**kwargs)
1202
+ opts = Sandbox.send(:build_modify_opts, **kwargs)
1203
+ ModificationPlan.new(JSON.parse(@native.modify(opts)))
1204
+ end
1205
+
944
1206
  # Read captured logs.
945
1207
  #
946
1208
  # @param tail [Integer, nil] only the last N entries