microsandbox-rb 0.5.7
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 +7 -0
- data/CHANGELOG.md +94 -0
- data/Cargo.lock +7455 -0
- data/Cargo.toml +16 -0
- data/DESIGN.md +159 -0
- data/LICENSE +201 -0
- data/README.md +328 -0
- data/ext/microsandbox/Cargo.toml +45 -0
- data/ext/microsandbox/extconf.rb +14 -0
- data/ext/microsandbox/src/conv.rs +74 -0
- data/ext/microsandbox/src/error.rs +72 -0
- data/ext/microsandbox/src/exec.rs +158 -0
- data/ext/microsandbox/src/image.rs +114 -0
- data/ext/microsandbox/src/lib.rs +84 -0
- data/ext/microsandbox/src/runtime.rs +92 -0
- data/ext/microsandbox/src/sandbox.rs +812 -0
- data/ext/microsandbox/src/snapshot.rs +158 -0
- data/ext/microsandbox/src/stream.rs +86 -0
- data/ext/microsandbox/src/volume.rs +97 -0
- data/lib/microsandbox/errors.rb +68 -0
- data/lib/microsandbox/exec_handle.rb +154 -0
- data/lib/microsandbox/exec_output.rb +55 -0
- data/lib/microsandbox/fs.rb +172 -0
- data/lib/microsandbox/image.rb +111 -0
- data/lib/microsandbox/log_entry.rb +38 -0
- data/lib/microsandbox/metrics.rb +55 -0
- data/lib/microsandbox/sandbox.rb +461 -0
- data/lib/microsandbox/snapshot.rb +155 -0
- data/lib/microsandbox/streams.rb +54 -0
- data/lib/microsandbox/version.rb +7 -0
- data/lib/microsandbox/volume.rb +79 -0
- data/lib/microsandbox.rb +78 -0
- data/rust-toolchain.toml +5 -0
- data/sig/microsandbox.rbs +321 -0
- metadata +101 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Microsandbox
|
|
4
|
+
# Lightweight metadata about a sandbox, returned by {Sandbox.get} and
|
|
5
|
+
# {Sandbox.list}. This is a snapshot, not a live handle.
|
|
6
|
+
class SandboxInfo
|
|
7
|
+
# @return [String]
|
|
8
|
+
attr_reader :name
|
|
9
|
+
# @return [Symbol] :running, :draining, :paused, :stopped, or :crashed
|
|
10
|
+
attr_reader :status
|
|
11
|
+
|
|
12
|
+
def initialize(data)
|
|
13
|
+
@name = data["name"]
|
|
14
|
+
@status = data["status"].to_sym
|
|
15
|
+
@created_at_ms = data["created_at_ms"]
|
|
16
|
+
@updated_at_ms = data["updated_at_ms"]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def running? = @status == :running
|
|
20
|
+
def stopped? = @status == :stopped
|
|
21
|
+
|
|
22
|
+
# @return [Time, nil]
|
|
23
|
+
def created_at
|
|
24
|
+
@created_at_ms && Time.at(@created_at_ms / 1000.0)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Time, nil]
|
|
28
|
+
def updated_at
|
|
29
|
+
@updated_at_ms && Time.at(@updated_at_ms / 1000.0)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def inspect
|
|
33
|
+
"#<Microsandbox::SandboxInfo name=#{@name.inspect} status=#{@status}>"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The terminal observation of a stopped sandbox, returned by
|
|
38
|
+
# {Sandbox#wait_until_stopped}. Mirrors the official SDKs' `SandboxStopResult`.
|
|
39
|
+
class SandboxStopResult
|
|
40
|
+
# @return [String]
|
|
41
|
+
attr_reader :name
|
|
42
|
+
# @return [Symbol] :running, :draining, :paused, :stopped, or :crashed
|
|
43
|
+
attr_reader :status
|
|
44
|
+
# @return [Integer, nil] process exit code, when observed from an owned child
|
|
45
|
+
attr_reader :exit_code
|
|
46
|
+
# @return [Integer, nil] terminating signal, when observed from an owned child
|
|
47
|
+
attr_reader :signal
|
|
48
|
+
# @return [String, nil] human description of the observation source
|
|
49
|
+
attr_reader :source
|
|
50
|
+
|
|
51
|
+
def initialize(data)
|
|
52
|
+
@name = data["name"]
|
|
53
|
+
@status = data["status"].to_sym
|
|
54
|
+
@exit_code = data["exit_code"]
|
|
55
|
+
@signal = data["signal"]
|
|
56
|
+
@source = data["source"]
|
|
57
|
+
@observed_at_ms = data["observed_at_ms"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def stopped? = @status == :stopped
|
|
61
|
+
def crashed? = @status == :crashed
|
|
62
|
+
|
|
63
|
+
# @return [Time] when the stopped state was observed
|
|
64
|
+
def observed_at
|
|
65
|
+
Time.at(@observed_at_ms / 1000.0)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def inspect
|
|
69
|
+
"#<Microsandbox::SandboxStopResult name=#{@name.inspect} status=#{@status}" \
|
|
70
|
+
"#{@exit_code ? " exit_code=#{@exit_code}" : ""}#{@signal ? " signal=#{@signal}" : ""}>"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A running sandbox (microVM) — the primary entry point of the SDK.
|
|
75
|
+
#
|
|
76
|
+
# @example Block form (auto-stops on exit)
|
|
77
|
+
# Microsandbox::Sandbox.create("hello", image: "python") do |sb|
|
|
78
|
+
# out = sb.exec("python", ["-c", "print('hi')"])
|
|
79
|
+
# puts out.stdout
|
|
80
|
+
# end
|
|
81
|
+
#
|
|
82
|
+
# @example Manual lifecycle
|
|
83
|
+
# sb = Microsandbox::Sandbox.create("hello", image: "python")
|
|
84
|
+
# begin
|
|
85
|
+
# sb.shell("echo hi")
|
|
86
|
+
# ensure
|
|
87
|
+
# sb.stop
|
|
88
|
+
# end
|
|
89
|
+
class Sandbox
|
|
90
|
+
class << self
|
|
91
|
+
# Create and boot a sandbox.
|
|
92
|
+
#
|
|
93
|
+
# When a block is given the sandbox is yielded and stopped automatically
|
|
94
|
+
# when the block returns (the block's value is returned); otherwise the
|
|
95
|
+
# live {Sandbox} is returned and you are responsible for calling {#stop}.
|
|
96
|
+
#
|
|
97
|
+
# @param name [String] sandbox name (max 128 UTF-8 bytes)
|
|
98
|
+
# @param image [String, nil] OCI image reference (e.g. "python")
|
|
99
|
+
# @param cpus [Integer, nil] number of vCPUs
|
|
100
|
+
# @param memory [Integer, nil] memory in MiB
|
|
101
|
+
# @param env [Hash, nil] environment variables
|
|
102
|
+
# @param workdir [String, nil] working directory inside the guest
|
|
103
|
+
# @param shell [String, nil] default shell (for {#shell})
|
|
104
|
+
# @param user [String, nil] default user
|
|
105
|
+
# @param hostname [String, nil] guest hostname
|
|
106
|
+
# @param labels [Hash, nil] metadata labels
|
|
107
|
+
# @param scripts [Hash, nil] named scripts to install
|
|
108
|
+
# @param entrypoint [Array<String>, nil] image entrypoint override
|
|
109
|
+
# @param ports [Hash, nil] host_port => guest_port TCP publications
|
|
110
|
+
# @param ports_udp [Hash, nil] host_port => guest_port UDP publications
|
|
111
|
+
# @param network ["public_only", "none", "allow_all", "non_local", nil] network
|
|
112
|
+
# policy preset (default public_only)
|
|
113
|
+
# @param log_level ["error","warn","info","debug","trace", nil] guest log verbosity
|
|
114
|
+
# @param quiet_logs [Boolean] suppress sandbox process logs
|
|
115
|
+
# @param security ["default", "restricted", nil] exec security profile
|
|
116
|
+
# @param oci_upper_size [Integer, nil] writable upper-layer size cap, in MiB
|
|
117
|
+
# @param max_duration [Integer, nil] hard wall-clock lifetime, in seconds
|
|
118
|
+
# @param idle_timeout [Integer, nil] stop after this many idle seconds
|
|
119
|
+
# @param rlimits [Hash, nil] resource limits: { resource => limit } or
|
|
120
|
+
# { resource => [soft, hard] } (e.g. { nofile: 65_535 })
|
|
121
|
+
# @param pull_policy ["always","if-missing","never", nil] image pull behavior
|
|
122
|
+
# @param secrets [Array<Hash>, nil] placeholder-protected secrets, each
|
|
123
|
+
# { env:, value:, host: } — the value is substituted by the TLS proxy only
|
|
124
|
+
# for the allowed host (auto-enables TLS interception)
|
|
125
|
+
# @param detached [Boolean] keep running after this process exits
|
|
126
|
+
# @param replace [Boolean] replace an existing sandbox with the same name
|
|
127
|
+
# @param replace_with_timeout [Numeric, nil] replace, waiting up to N seconds
|
|
128
|
+
# @yieldparam sandbox [Sandbox]
|
|
129
|
+
# @return [Sandbox, Object] the sandbox, or the block's return value
|
|
130
|
+
def create(name,
|
|
131
|
+
image: nil, cpus: nil, memory: nil, env: nil, workdir: nil,
|
|
132
|
+
shell: nil, user: nil, hostname: nil, labels: nil, scripts: nil,
|
|
133
|
+
entrypoint: nil, ports: nil, ports_udp: nil, volumes: nil, network: nil,
|
|
134
|
+
from_snapshot: nil, log_level: nil, quiet_logs: false, security: nil,
|
|
135
|
+
oci_upper_size: nil, max_duration: nil, idle_timeout: nil, rlimits: nil,
|
|
136
|
+
pull_policy: nil, secrets: nil,
|
|
137
|
+
detached: false, replace: false, replace_with_timeout: nil)
|
|
138
|
+
opts = {}
|
|
139
|
+
opts["image"] = image.to_s if image
|
|
140
|
+
opts["from_snapshot"] = from_snapshot.to_s if from_snapshot
|
|
141
|
+
opts["cpus"] = Integer(cpus) if cpus
|
|
142
|
+
opts["memory"] = Integer(memory) if memory
|
|
143
|
+
opts["workdir"] = workdir.to_s if workdir
|
|
144
|
+
opts["shell"] = shell.to_s if shell
|
|
145
|
+
opts["user"] = user.to_s if user
|
|
146
|
+
opts["hostname"] = hostname.to_s if hostname
|
|
147
|
+
opts["env"] = stringify(env) if env
|
|
148
|
+
opts["labels"] = stringify(labels) if labels
|
|
149
|
+
opts["scripts"] = stringify(scripts) if scripts
|
|
150
|
+
opts["entrypoint"] = Array(entrypoint).map(&:to_s) if entrypoint
|
|
151
|
+
opts["ports"] = intify_ports(ports) if ports
|
|
152
|
+
opts["ports_udp"] = intify_ports(ports_udp) if ports_udp
|
|
153
|
+
opts["volumes"] = normalize_volumes(volumes) if volumes
|
|
154
|
+
opts["network"] = network.to_s if network
|
|
155
|
+
opts["log_level"] = log_level.to_s if log_level
|
|
156
|
+
opts["quiet_logs"] = true if quiet_logs
|
|
157
|
+
opts["security"] = security.to_s if security
|
|
158
|
+
opts["oci_upper_size"] = Integer(oci_upper_size) if oci_upper_size
|
|
159
|
+
opts["max_duration"] = Integer(max_duration) if max_duration
|
|
160
|
+
opts["idle_timeout"] = Integer(idle_timeout) if idle_timeout
|
|
161
|
+
opts["rlimits"] = normalize_rlimits(rlimits) if rlimits
|
|
162
|
+
opts["pull_policy"] = pull_policy.to_s if pull_policy
|
|
163
|
+
opts["secrets"] = normalize_secrets(secrets) if secrets
|
|
164
|
+
opts["detached"] = true if detached
|
|
165
|
+
if replace_with_timeout
|
|
166
|
+
opts["replace_with_timeout"] = Float(replace_with_timeout)
|
|
167
|
+
elsif replace
|
|
168
|
+
opts["replace"] = true
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
sandbox = new(Native::Sandbox.create(name.to_s, opts))
|
|
172
|
+
return sandbox unless block_given?
|
|
173
|
+
|
|
174
|
+
begin
|
|
175
|
+
yield sandbox
|
|
176
|
+
ensure
|
|
177
|
+
begin
|
|
178
|
+
sandbox.stop
|
|
179
|
+
rescue Microsandbox::Error
|
|
180
|
+
# best-effort cleanup; ignore stop failures during teardown
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Restart a previously-defined sandbox by name.
|
|
186
|
+
# @return [Sandbox]
|
|
187
|
+
def start(name, detached: false)
|
|
188
|
+
new(Native::Sandbox.start(name.to_s, { "detached" => detached }))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Fetch metadata for a sandbox by name.
|
|
192
|
+
# @return [SandboxInfo]
|
|
193
|
+
def get(name)
|
|
194
|
+
SandboxInfo.new(Native::Sandbox.get(name.to_s))
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# List all sandboxes.
|
|
198
|
+
# @return [Array<SandboxInfo>]
|
|
199
|
+
def list
|
|
200
|
+
Native::Sandbox.list.map { |info| SandboxInfo.new(info) }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# List sandboxes carrying all of the given labels (AND-matched).
|
|
204
|
+
# @param labels [Hash] required key => value labels
|
|
205
|
+
# @return [Array<SandboxInfo>]
|
|
206
|
+
def list_with(labels: {})
|
|
207
|
+
opts = { "labels" => stringify(labels) }
|
|
208
|
+
Native::Sandbox.list_with(opts).map { |info| SandboxInfo.new(info) }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Remove a (stopped) sandbox by name.
|
|
212
|
+
# @return [nil]
|
|
213
|
+
def remove(name)
|
|
214
|
+
Native::Sandbox.remove(name.to_s)
|
|
215
|
+
nil
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
private
|
|
219
|
+
|
|
220
|
+
def stringify(hash)
|
|
221
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v.to_s }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def intify_ports(ports)
|
|
225
|
+
ports.each_with_object({}) { |(k, v), acc| acc[Integer(k)] = Integer(v) }
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Normalize secrets into [env, value, host] triples for the native layer.
|
|
229
|
+
# Each entry is a Hash { env:, value:, host: } (string or symbol keys).
|
|
230
|
+
def normalize_secrets(secrets)
|
|
231
|
+
Array(secrets).map do |spec|
|
|
232
|
+
env = spec[:env] || spec["env"]
|
|
233
|
+
value = spec[:value] || spec["value"]
|
|
234
|
+
host = spec[:host] || spec["host"]
|
|
235
|
+
unless env && value && host
|
|
236
|
+
raise ArgumentError, "secret spec needs :env, :value, and :host (got #{spec.inspect})"
|
|
237
|
+
end
|
|
238
|
+
[env.to_s, value.to_s, host.to_s]
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Normalize an rlimits Hash into [resource, soft, hard] triples for the
|
|
243
|
+
# native layer. Each value is either a single limit (soft == hard) or a
|
|
244
|
+
# [soft, hard] pair. Shared by {Sandbox.create} and {Sandbox#exec}.
|
|
245
|
+
def normalize_rlimits(rlimits)
|
|
246
|
+
rlimits.map do |resource, limit|
|
|
247
|
+
soft, hard = limit.is_a?(Array) ? [limit[0], limit[1]] : [limit, limit]
|
|
248
|
+
[resource.to_s, Integer(soft), Integer(hard)]
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Normalize volumes (Hash of guest_path => spec) into [guest, kind, source]
|
|
253
|
+
# triples for the native layer. A spec is a host path String (bind mount),
|
|
254
|
+
# or a Hash { bind: "/host" } / { named: "volume-name" }.
|
|
255
|
+
def normalize_volumes(volumes)
|
|
256
|
+
volumes.map do |guest, spec|
|
|
257
|
+
guest = guest.to_s
|
|
258
|
+
case spec
|
|
259
|
+
when String
|
|
260
|
+
[guest, "bind", spec]
|
|
261
|
+
when Hash
|
|
262
|
+
if (named = spec[:named] || spec["named"])
|
|
263
|
+
[guest, "named", named.to_s]
|
|
264
|
+
elsif (bind = spec[:bind] || spec["bind"])
|
|
265
|
+
[guest, "bind", bind.to_s]
|
|
266
|
+
else
|
|
267
|
+
raise ArgumentError, "volume spec for #{guest.inspect} needs :bind or :named"
|
|
268
|
+
end
|
|
269
|
+
else
|
|
270
|
+
raise ArgumentError, "invalid volume spec for #{guest.inspect}: #{spec.inspect}"
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def initialize(native)
|
|
277
|
+
@native = native
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# @return [String] the sandbox name
|
|
281
|
+
def name
|
|
282
|
+
@native.name
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Run a command (no shell interpretation) and collect its output.
|
|
286
|
+
#
|
|
287
|
+
# @param command [String] the executable
|
|
288
|
+
# @param args [Array<String>] arguments
|
|
289
|
+
# @param cwd [String, nil] working directory
|
|
290
|
+
# @param user [String, nil] user to run as
|
|
291
|
+
# @param env [Hash, nil] extra environment variables
|
|
292
|
+
# @param timeout [Numeric, nil] kill after N seconds
|
|
293
|
+
# @param tty [Boolean] allocate a pseudo-terminal
|
|
294
|
+
# @param stdin [String, nil] data to feed to stdin
|
|
295
|
+
# @return [ExecOutput]
|
|
296
|
+
def exec(command, args = [], cwd: nil, user: nil, env: nil, timeout: nil, tty: false, stdin: nil, rlimits: nil)
|
|
297
|
+
ExecOutput.new(@native.exec(command.to_s, Array(args).map(&:to_s),
|
|
298
|
+
exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Run a shell script (pipes, redirects, etc. allowed) and collect output.
|
|
302
|
+
# @return [ExecOutput]
|
|
303
|
+
def shell(script, cwd: nil, user: nil, env: nil, timeout: nil, tty: false, stdin: nil, rlimits: nil)
|
|
304
|
+
ExecOutput.new(@native.shell(script.to_s,
|
|
305
|
+
exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Run a command and stream its output as it arrives.
|
|
309
|
+
# @return [ExecHandle]
|
|
310
|
+
# @see ExecHandle
|
|
311
|
+
def exec_stream(command, args = [], cwd: nil, user: nil, env: nil, timeout: nil, tty: false, stdin: nil, rlimits: nil)
|
|
312
|
+
ExecHandle.new(@native.exec_stream(command.to_s, Array(args).map(&:to_s),
|
|
313
|
+
exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Run a shell script and stream its output as it arrives.
|
|
317
|
+
# @return [ExecHandle]
|
|
318
|
+
def shell_stream(script, cwd: nil, user: nil, env: nil, timeout: nil, tty: false, stdin: nil, rlimits: nil)
|
|
319
|
+
ExecHandle.new(@native.shell_stream(script.to_s,
|
|
320
|
+
exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)))
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Guest filesystem operations.
|
|
324
|
+
# @return [FS]
|
|
325
|
+
def fs
|
|
326
|
+
@fs ||= FS.new(@native)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Latest resource-usage snapshot.
|
|
330
|
+
# @return [Metrics]
|
|
331
|
+
def metrics
|
|
332
|
+
Metrics.new(@native.metrics)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Read captured logs.
|
|
336
|
+
#
|
|
337
|
+
# @param tail [Integer, nil] only the last N entries
|
|
338
|
+
# @param since_ms [Numeric, nil] only entries at/after this Unix ms
|
|
339
|
+
# @param until_ms [Numeric, nil] only entries before this Unix ms
|
|
340
|
+
# @param sources [Array<String,Symbol>, nil] filter by source
|
|
341
|
+
# ("stdout"/"stderr"/"output"/"system"/"all")
|
|
342
|
+
# @return [Array<LogEntry>]
|
|
343
|
+
def logs(tail: nil, since_ms: nil, until_ms: nil, sources: nil)
|
|
344
|
+
opts = {}
|
|
345
|
+
opts["tail"] = Integer(tail) if tail
|
|
346
|
+
opts["since_ms"] = Float(since_ms) if since_ms
|
|
347
|
+
opts["until_ms"] = Float(until_ms) if until_ms
|
|
348
|
+
opts["sources"] = Array(sources).map(&:to_s) if sources
|
|
349
|
+
@native.logs(opts).map { |entry| LogEntry.new(entry) }
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Stream resource-usage snapshots, one per interval tick, until the sandbox
|
|
353
|
+
# stops. Requires metrics to be enabled for the sandbox.
|
|
354
|
+
# @param interval [Numeric] seconds between snapshots
|
|
355
|
+
# @return [MetricsStream] an {Enumerable} of {Metrics}
|
|
356
|
+
def metrics_stream(interval: 1.0)
|
|
357
|
+
MetricsStream.new(@native.metrics_stream(Float(interval)))
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Stream captured logs as they appear.
|
|
361
|
+
#
|
|
362
|
+
# @param sources [Array<String,Symbol>, nil] filter by source
|
|
363
|
+
# ("stdout"/"stderr"/"output"/"system")
|
|
364
|
+
# @param since_ms [Numeric, nil] start at the first entry at/after this Unix ms
|
|
365
|
+
# @param from_cursor [String, nil] resume exactly after a prior {LogEntry#cursor}
|
|
366
|
+
# (mutually exclusive with since_ms; takes precedence if both given)
|
|
367
|
+
# @param until_ms [Numeric, nil] stop before any entry at/after this Unix ms
|
|
368
|
+
# @param follow [Boolean] keep the stream open for new entries past current EOF
|
|
369
|
+
# @return [LogStream] an {Enumerable} of {LogEntry}
|
|
370
|
+
def log_stream(sources: nil, since_ms: nil, from_cursor: nil, until_ms: nil, follow: false)
|
|
371
|
+
opts = {}
|
|
372
|
+
opts["sources"] = Array(sources).map(&:to_s) if sources
|
|
373
|
+
opts["since_ms"] = Float(since_ms) if since_ms
|
|
374
|
+
opts["from_cursor"] = from_cursor.to_s if from_cursor
|
|
375
|
+
opts["until_ms"] = Float(until_ms) if until_ms
|
|
376
|
+
opts["follow"] = true if follow
|
|
377
|
+
LogStream.new(@native.log_stream(opts))
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Gracefully stop the sandbox (and wait for it to terminate).
|
|
381
|
+
# @param timeout [Numeric, nil] seconds to wait before SIGKILL
|
|
382
|
+
# @return [nil]
|
|
383
|
+
def stop(timeout: nil)
|
|
384
|
+
@native.stop(timeout && Float(timeout))
|
|
385
|
+
nil
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Force-kill the sandbox (SIGKILL).
|
|
389
|
+
# @param timeout [Numeric, nil] seconds to wait
|
|
390
|
+
# @return [nil]
|
|
391
|
+
def kill(timeout: nil)
|
|
392
|
+
@native.kill(timeout && Float(timeout))
|
|
393
|
+
nil
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Send the graceful-shutdown request and return immediately, without waiting
|
|
397
|
+
# for the sandbox to terminate. Pair with {#wait_until_stopped}.
|
|
398
|
+
# @return [nil]
|
|
399
|
+
def request_stop
|
|
400
|
+
@native.request_stop
|
|
401
|
+
nil
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Send the force-kill request and return immediately, without waiting.
|
|
405
|
+
# @return [nil]
|
|
406
|
+
def request_kill
|
|
407
|
+
@native.request_kill
|
|
408
|
+
nil
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Request a graceful drain and return immediately, without waiting.
|
|
412
|
+
# @return [nil]
|
|
413
|
+
def request_drain
|
|
414
|
+
@native.request_drain
|
|
415
|
+
nil
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Block until the sandbox is observed in a terminal (non-running) state.
|
|
419
|
+
# @return [SandboxStopResult]
|
|
420
|
+
def wait_until_stopped
|
|
421
|
+
SandboxStopResult.new(@native.wait_until_stopped)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# @return [Boolean] whether this handle owns the sandbox process lifecycle
|
|
425
|
+
# (i.e. stopping it or dropping the handle terminates the sandbox)
|
|
426
|
+
def owns_lifecycle?
|
|
427
|
+
@native.owns_lifecycle
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Detach this handle: disarm the stop-on-drop safety net so the sandbox
|
|
431
|
+
# keeps running after this handle is gone (and after this process exits).
|
|
432
|
+
# @return [nil]
|
|
433
|
+
def detach
|
|
434
|
+
@native.detach
|
|
435
|
+
nil
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def inspect
|
|
439
|
+
"#<Microsandbox::Sandbox name=#{name.inspect}>"
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
private
|
|
443
|
+
|
|
444
|
+
def exec_opts(cwd:, user:, env:, timeout:, tty:, stdin:, rlimits:)
|
|
445
|
+
opts = {}
|
|
446
|
+
opts["cwd"] = cwd.to_s if cwd
|
|
447
|
+
opts["user"] = user.to_s if user
|
|
448
|
+
opts["env"] = env.each_with_object({}) { |(k, v), a| a[k.to_s] = v.to_s } if env
|
|
449
|
+
opts["timeout"] = Float(timeout) if timeout
|
|
450
|
+
opts["tty"] = true if tty
|
|
451
|
+
opts["stdin"] = stdin.to_s if stdin
|
|
452
|
+
if rlimits
|
|
453
|
+
opts["rlimits"] = rlimits.map do |resource, limit|
|
|
454
|
+
soft, hard = limit.is_a?(Array) ? [limit[0], limit[1]] : [limit, limit]
|
|
455
|
+
[resource.to_s, Integer(soft), Integer(hard)]
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
opts
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Microsandbox
|
|
4
|
+
# Metadata for a snapshot artifact, returned by {Snapshot.create}/{Snapshot.get}/
|
|
5
|
+
# {Snapshot.list}/{Snapshot.import}.
|
|
6
|
+
#
|
|
7
|
+
# `digest` and `path` are always present. `create` additionally populates
|
|
8
|
+
# `size_bytes`; `get`/`list`/`import` additionally populate `name`,
|
|
9
|
+
# `parent_digest`, `image_ref`, `format`, and `created_at`.
|
|
10
|
+
class SnapshotInfo
|
|
11
|
+
# @return [String] manifest digest ("sha256:…") — the canonical identity
|
|
12
|
+
attr_reader :digest
|
|
13
|
+
# @return [String] artifact directory path
|
|
14
|
+
attr_reader :path
|
|
15
|
+
# @return [String, nil] name alias (nil for digest-only entries)
|
|
16
|
+
attr_reader :name
|
|
17
|
+
# @return [String, nil] parent snapshot digest
|
|
18
|
+
attr_reader :parent_digest
|
|
19
|
+
# @return [String, nil] source OCI image reference
|
|
20
|
+
attr_reader :image_ref
|
|
21
|
+
# @return [Integer, nil] artifact size in bytes
|
|
22
|
+
attr_reader :size_bytes
|
|
23
|
+
|
|
24
|
+
def initialize(data)
|
|
25
|
+
@digest = data["digest"]
|
|
26
|
+
@path = data["path"]
|
|
27
|
+
@name = data["name"]
|
|
28
|
+
@parent_digest = data["parent_digest"]
|
|
29
|
+
@image_ref = data["image_ref"]
|
|
30
|
+
@format = data["format"]
|
|
31
|
+
@size_bytes = data["size_bytes"]
|
|
32
|
+
@created_at_ms = data["created_at_ms"]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Symbol, nil] disk format (:raw or :qcow2)
|
|
36
|
+
def format
|
|
37
|
+
@format&.to_sym
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Time, nil]
|
|
41
|
+
def created_at
|
|
42
|
+
@created_at_ms && Time.at(@created_at_ms / 1000.0)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def inspect
|
|
46
|
+
"#<Microsandbox::SnapshotInfo digest=#{@digest.inspect}#{@name ? " name=#{@name.inspect}" : ""}>"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The result of {Snapshot.verify}.
|
|
51
|
+
class SnapshotVerifyReport
|
|
52
|
+
# @return [String] manifest digest
|
|
53
|
+
attr_reader :digest
|
|
54
|
+
# @return [String] artifact directory path
|
|
55
|
+
attr_reader :path
|
|
56
|
+
# @return [Symbol] :not_recorded or :verified
|
|
57
|
+
attr_reader :status
|
|
58
|
+
# @return [String, nil] digest algorithm (when :verified)
|
|
59
|
+
attr_reader :algorithm
|
|
60
|
+
# @return [String, nil] matched content digest (when :verified)
|
|
61
|
+
attr_reader :content_digest
|
|
62
|
+
|
|
63
|
+
def initialize(data)
|
|
64
|
+
@digest = data["digest"]
|
|
65
|
+
@path = data["path"]
|
|
66
|
+
@status = data["upper_status"].to_sym
|
|
67
|
+
@algorithm = data["upper_algorithm"]
|
|
68
|
+
@content_digest = data["upper_digest"]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Boolean] whether content integrity was recorded and matched
|
|
72
|
+
def verified? = @status == :verified
|
|
73
|
+
# @return [Boolean] whether no integrity descriptor was recorded
|
|
74
|
+
def not_recorded? = @status == :not_recorded
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Creation and management of sandbox snapshots. A snapshot captures a stopped
|
|
78
|
+
# sandbox's upper layer into a portable artifact; boot from it with
|
|
79
|
+
# `Sandbox.create(from_snapshot: "name-or-digest")`.
|
|
80
|
+
class Snapshot
|
|
81
|
+
class << self
|
|
82
|
+
# Create a snapshot of a stopped sandbox.
|
|
83
|
+
#
|
|
84
|
+
# @param source_sandbox [String] name of the (stopped) source sandbox
|
|
85
|
+
# @param name [String, nil] destination name under the snapshots dir
|
|
86
|
+
# @param path [String, nil] explicit destination directory (alternative to name)
|
|
87
|
+
# @param labels [Hash, nil] user labels
|
|
88
|
+
# @param force [Boolean] overwrite an existing artifact at the destination
|
|
89
|
+
# @param record_integrity [Boolean] compute + record upper-layer integrity
|
|
90
|
+
# @return [SnapshotInfo]
|
|
91
|
+
def create(source_sandbox, name: nil, path: nil, labels: nil, force: false, record_integrity: false)
|
|
92
|
+
opts = {}
|
|
93
|
+
opts["name"] = name.to_s if name
|
|
94
|
+
opts["path"] = path.to_s if path
|
|
95
|
+
opts["labels"] = stringify(labels) if labels
|
|
96
|
+
opts["force"] = true if force
|
|
97
|
+
opts["record_integrity"] = true if record_integrity
|
|
98
|
+
SnapshotInfo.new(Native::Snapshot.create(source_sandbox.to_s, opts))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Metadata for a snapshot by name or digest.
|
|
102
|
+
# @return [SnapshotInfo]
|
|
103
|
+
def get(name_or_digest)
|
|
104
|
+
SnapshotInfo.new(Native::Snapshot.get(name_or_digest.to_s))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# All snapshots.
|
|
108
|
+
# @return [Array<SnapshotInfo>]
|
|
109
|
+
def list
|
|
110
|
+
Native::Snapshot.list.map { |info| SnapshotInfo.new(info) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Remove a snapshot artifact by name or path.
|
|
114
|
+
# @param force [Boolean] remove even if referenced
|
|
115
|
+
# @return [nil]
|
|
116
|
+
def remove(name_or_path, force: false)
|
|
117
|
+
Native::Snapshot.remove(name_or_path.to_s, force)
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Verify a snapshot's recorded upper-layer integrity.
|
|
122
|
+
# @return [SnapshotVerifyReport]
|
|
123
|
+
def verify(name_or_path)
|
|
124
|
+
SnapshotVerifyReport.new(Native::Snapshot.verify(name_or_path.to_s))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Bundle a snapshot into a `.tar.zst` (or plain `.tar`) archive.
|
|
128
|
+
# @param with_parents [Boolean] include ancestor snapshots
|
|
129
|
+
# @param with_image [Boolean] include OCI image artifacts (boots offline)
|
|
130
|
+
# @param plain_tar [Boolean] write an uncompressed `.tar`
|
|
131
|
+
# @return [nil]
|
|
132
|
+
def export(name_or_path, out_path, with_parents: false, with_image: false, plain_tar: false)
|
|
133
|
+
opts = {}
|
|
134
|
+
opts["with_parents"] = true if with_parents
|
|
135
|
+
opts["with_image"] = true if with_image
|
|
136
|
+
opts["plain_tar"] = true if plain_tar
|
|
137
|
+
Native::Snapshot.export(name_or_path.to_s, out_path.to_s, opts)
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Unpack a snapshot archive into the snapshots dir.
|
|
142
|
+
# @param dest [String, nil] explicit destination directory
|
|
143
|
+
# @return [SnapshotInfo]
|
|
144
|
+
def import(archive_path, dest: nil)
|
|
145
|
+
SnapshotInfo.new(Native::Snapshot.import(archive_path.to_s, dest && dest.to_s))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def stringify(hash)
|
|
151
|
+
hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v.to_s }
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Microsandbox
|
|
4
|
+
# A live stream of {LogEntry}s, returned by {Sandbox#log_stream}. Enumerable:
|
|
5
|
+
# iterate to consume entries as they are appended. With `follow: true` the
|
|
6
|
+
# iteration blocks for new entries until the sandbox stops; otherwise it ends
|
|
7
|
+
# once the historical log is drained.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# sb.log_stream(follow: true).each { |entry| print entry.text }
|
|
11
|
+
class LogStream
|
|
12
|
+
include Enumerable
|
|
13
|
+
|
|
14
|
+
def initialize(native)
|
|
15
|
+
@native = native
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @yieldparam entry [LogEntry]
|
|
19
|
+
# @return [self, Enumerator]
|
|
20
|
+
def each
|
|
21
|
+
return enum_for(:each) unless block_given?
|
|
22
|
+
|
|
23
|
+
while (entry = @native.recv)
|
|
24
|
+
yield LogEntry.new(entry)
|
|
25
|
+
end
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A live stream of {Metrics} snapshots, returned by {Sandbox#metrics_stream}.
|
|
31
|
+
# Enumerable: iteration yields one snapshot per interval tick until the
|
|
32
|
+
# sandbox stops.
|
|
33
|
+
#
|
|
34
|
+
# @example
|
|
35
|
+
# sb.metrics_stream(interval: 0.5).each { |m| puts m.cpu_percent }
|
|
36
|
+
class MetricsStream
|
|
37
|
+
include Enumerable
|
|
38
|
+
|
|
39
|
+
def initialize(native)
|
|
40
|
+
@native = native
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @yieldparam metrics [Metrics]
|
|
44
|
+
# @return [self, Enumerator]
|
|
45
|
+
def each
|
|
46
|
+
return enum_for(:each) unless block_given?
|
|
47
|
+
|
|
48
|
+
while (snapshot = @native.recv)
|
|
49
|
+
yield Metrics.new(snapshot)
|
|
50
|
+
end
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|