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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +176 -0
- data/Cargo.lock +126 -141
- data/README.md +63 -9
- data/ext/microsandbox/Cargo.toml +4 -4
- data/ext/microsandbox/src/error.rs +10 -0
- data/ext/microsandbox/src/image.rs +38 -0
- data/ext/microsandbox/src/sandbox.rs +357 -36
- data/ext/microsandbox/src/snapshot.rs +79 -35
- data/lib/microsandbox/errors.rb +4 -0
- data/lib/microsandbox/image.rb +38 -0
- data/lib/microsandbox/modification.rb +108 -0
- data/lib/microsandbox/network.rb +139 -31
- data/lib/microsandbox/root_disk.rb +58 -0
- data/lib/microsandbox/sandbox.rb +285 -23
- data/lib/microsandbox/snapshot.rb +87 -37
- data/lib/microsandbox/version.rb +2 -2
- data/lib/microsandbox.rb +2 -0
- data/sig/microsandbox.rbs +75 -12
- metadata +3 -1
|
@@ -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
|
data/lib/microsandbox/sandbox.rb
CHANGED
|
@@ -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(
|
|
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
|
-
#
|
|
238
|
-
#
|
|
239
|
-
#
|
|
240
|
-
#
|
|
241
|
-
#
|
|
242
|
-
#
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
797
|
-
# (`opts["
|
|
798
|
-
# policy path (`opts["network_policy"]`). Accepts
|
|
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
|