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
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
//! Snapshot management: `Microsandbox::Native::Snapshot`.
|
|
2
2
|
//!
|
|
3
|
-
//! Snapshots capture a stopped sandbox's
|
|
3
|
+
//! Snapshots capture a stopped sandbox's disk state into a portable artifact
|
|
4
4
|
//! that a later `Sandbox.create(from_snapshot:)` can boot from. Exposed as
|
|
5
5
|
//! singleton functions returning plain Hashes/Arrays (shaped into value objects
|
|
6
6
|
//! by the Ruby layer) — there is no long-lived handle to own.
|
|
7
|
+
//!
|
|
8
|
+
//! v0.6.7 descriptor contract: an artifact is identified by its own `name`
|
|
9
|
+
//! (`Snapshot::builder(name).from_sandbox(src)`), lives at `dest_dir/<name>`,
|
|
10
|
+
//! and its descriptor file is `snapshot.json`. `manifest.json` artifacts from
|
|
11
|
+
//! v0.6.6 are auto-migrated by the core on first backend connect.
|
|
7
12
|
|
|
8
13
|
use std::path::{Path, PathBuf};
|
|
9
14
|
|
|
10
15
|
use magnus::{function, prelude::*, Error, RArray, RHash, RModule, Ruby};
|
|
11
16
|
use microsandbox::snapshot::{
|
|
12
|
-
|
|
13
|
-
|
|
17
|
+
SaveOpts, Snapshot, SnapshotFormat, SnapshotHandle, SnapshotScope, SnapshotVerifyReport,
|
|
18
|
+
UpperVerifyStatus,
|
|
14
19
|
};
|
|
15
20
|
|
|
16
21
|
use crate::conv;
|
|
@@ -24,6 +29,13 @@ fn format_str(format: SnapshotFormat) -> &'static str {
|
|
|
24
29
|
}
|
|
25
30
|
}
|
|
26
31
|
|
|
32
|
+
fn scope_str(scope: SnapshotScope) -> &'static str {
|
|
33
|
+
match scope {
|
|
34
|
+
SnapshotScope::Disk => "disk",
|
|
35
|
+
SnapshotScope::Resumable => "resumable",
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
/// Parse a manifest's RFC 3339 `created_at` into epoch-ms (nil if unparseable).
|
|
28
40
|
fn created_at_ms(rfc3339: &str) -> Option<i64> {
|
|
29
41
|
chrono::DateTime::parse_from_rfc3339(rfc3339)
|
|
@@ -33,18 +45,31 @@ fn created_at_ms(rfc3339: &str) -> Option<i64> {
|
|
|
33
45
|
|
|
34
46
|
/// Convert a fully-opened `Snapshot` into the `SnapshotInfo` Hash. Unlike a
|
|
35
47
|
/// `SnapshotHandle` (a lightweight index row), an opened snapshot carries the
|
|
36
|
-
/// full
|
|
37
|
-
/// and the `SandboxHandle#snapshot
|
|
48
|
+
/// full descriptor, so this is the richest shape — `create`/`open`/`list_dir`
|
|
49
|
+
/// and the `SandboxHandle#snapshot` shortcut all funnel here. `format`/
|
|
50
|
+
/// `fstype`/`size_bytes` are nil for checkpoint-state snapshots, and the
|
|
51
|
+
/// `checkpoint_*` keys are nil for file-state ones — the two state families
|
|
52
|
+
/// are disjoint by contract.
|
|
38
53
|
pub(crate) fn snapshot_to_hash(snap: &Snapshot) -> RHash {
|
|
39
54
|
let m = snap.manifest();
|
|
55
|
+
let state = snap.state();
|
|
56
|
+
let file = state.as_file();
|
|
57
|
+
let checkpoint = state.as_checkpoint();
|
|
40
58
|
let hash = ruby().hash_new();
|
|
41
59
|
let _ = hash.aset("digest", snap.digest().to_string());
|
|
42
60
|
let _ = hash.aset("path", snap.path().to_string_lossy().into_owned());
|
|
43
61
|
let _ = hash.aset("size_bytes", snap.size_bytes());
|
|
62
|
+
let _ = hash.aset("scope", scope_str(m.scope));
|
|
63
|
+
let _ = hash.aset("state_kind", state.kind());
|
|
44
64
|
let _ = hash.aset("image_ref", m.image.reference.clone());
|
|
45
65
|
let _ = hash.aset("image_manifest_digest", m.image.manifest_digest.clone());
|
|
46
|
-
let _ = hash.aset("format", format_str(
|
|
47
|
-
let _ = hash.aset("fstype",
|
|
66
|
+
let _ = hash.aset("format", file.map(|f| format_str(f.format)));
|
|
67
|
+
let _ = hash.aset("fstype", file.map(|f| f.fstype.clone()));
|
|
68
|
+
let _ = hash.aset("checkpoint_id", checkpoint.map(|c| c.checkpoint_id.clone()));
|
|
69
|
+
let _ = hash.aset(
|
|
70
|
+
"checkpoint_manifest_digest",
|
|
71
|
+
checkpoint.map(|c| c.manifest.clone()),
|
|
72
|
+
);
|
|
48
73
|
let _ = hash.aset("parent_digest", m.parent.clone());
|
|
49
74
|
let _ = hash.aset("created_at_ms", created_at_ms(&m.created_at));
|
|
50
75
|
let _ = hash.aset("source_sandbox", m.source_sandbox.clone());
|
|
@@ -56,18 +81,21 @@ pub(crate) fn snapshot_to_hash(snap: &Snapshot) -> RHash {
|
|
|
56
81
|
hash
|
|
57
82
|
}
|
|
58
83
|
|
|
59
|
-
/// Create a snapshot
|
|
60
|
-
/// labels, force, record_integrity
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
84
|
+
/// Create a snapshot artifact named `name` from a stopped sandbox. `opts`:
|
|
85
|
+
/// from_sandbox (required), dest_dir, labels, force, record_integrity,
|
|
86
|
+
/// resumable. Returns the full SnapshotInfo Hash.
|
|
87
|
+
fn create(name: String, opts: RHash) -> Result<RHash, Error> {
|
|
88
|
+
let mut b = Snapshot::builder(name);
|
|
89
|
+
match conv::opt_string(opts, "from_sandbox")? {
|
|
90
|
+
Some(src) => b = b.from_sandbox(src),
|
|
91
|
+
None => {
|
|
92
|
+
return Err(error::base_error(
|
|
93
|
+
"snapshot create needs from_sandbox: (the source sandbox name)",
|
|
94
|
+
));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if let Some(dir) = conv::opt_string(opts, "dest_dir")? {
|
|
98
|
+
b = b.dest_dir(PathBuf::from(dir));
|
|
71
99
|
}
|
|
72
100
|
for (k, v) in conv::opt_string_map(opts, "labels")? {
|
|
73
101
|
b = b.label(k, v);
|
|
@@ -78,6 +106,9 @@ fn create(source_sandbox: String, opts: RHash) -> Result<RHash, Error> {
|
|
|
78
106
|
if conv::opt_bool(opts, "record_integrity")? {
|
|
79
107
|
b = b.record_integrity();
|
|
80
108
|
}
|
|
109
|
+
if conv::opt_bool(opts, "resumable")? {
|
|
110
|
+
b = b.resumable();
|
|
111
|
+
}
|
|
81
112
|
|
|
82
113
|
let snap = block_on(b.create()).map_err(error::to_ruby)?;
|
|
83
114
|
Ok(snapshot_to_hash(&snap))
|
|
@@ -92,8 +123,8 @@ fn open(path_or_name: String) -> Result<RHash, Error> {
|
|
|
92
123
|
Ok(snapshot_to_hash(&snap))
|
|
93
124
|
}
|
|
94
125
|
|
|
95
|
-
/// Walk `dir` and parse each subdirectory's `
|
|
96
|
-
/// local index — for enumerating external/un-imported snapshot collections.
|
|
126
|
+
/// Walk `dir` and parse each subdirectory's `snapshot.json` without touching
|
|
127
|
+
/// the local index — for enumerating external/un-imported snapshot collections.
|
|
97
128
|
fn list_dir(dir: String) -> Result<RArray, Error> {
|
|
98
129
|
let snaps = block_on(Snapshot::list_dir(Path::new(&dir))).map_err(error::to_ruby)?;
|
|
99
130
|
let arr = ruby().ary_new();
|
|
@@ -140,7 +171,9 @@ fn remove(name_or_path: String, force: bool) -> Result<(), Error> {
|
|
|
140
171
|
}
|
|
141
172
|
|
|
142
173
|
/// Verify a snapshot's recorded upper-layer integrity. Returns
|
|
143
|
-
/// {digest, path, upper_status, upper_algorithm
|
|
174
|
+
/// {digest, path, upper_status, upper_algorithm, upper_digest}. Schema-1
|
|
175
|
+
/// descriptors always record integrity, so the status is always "verified"
|
|
176
|
+
/// on success (mismatches raise SnapshotIntegrityError instead).
|
|
144
177
|
fn verify(name_or_path: String) -> Result<RHash, Error> {
|
|
145
178
|
let snap = block_on(Snapshot::open(&name_or_path)).map_err(error::to_ruby)?;
|
|
146
179
|
let report = block_on(snap.verify()).map_err(error::to_ruby)?;
|
|
@@ -149,25 +182,25 @@ fn verify(name_or_path: String) -> Result<RHash, Error> {
|
|
|
149
182
|
|
|
150
183
|
/// Bundle a snapshot into a `.tar.zst` (or plain `.tar`) archive. `opts`:
|
|
151
184
|
/// with_parents, with_image, plain_tar.
|
|
152
|
-
fn
|
|
153
|
-
let
|
|
185
|
+
fn save(name_or_path: String, out_path: String, opts: RHash) -> Result<(), Error> {
|
|
186
|
+
let save_opts = SaveOpts {
|
|
154
187
|
with_parents: conv::opt_bool(opts, "with_parents")?,
|
|
155
188
|
with_image: conv::opt_bool(opts, "with_image")?,
|
|
156
189
|
plain_tar: conv::opt_bool(opts, "plain_tar")?,
|
|
157
190
|
};
|
|
158
|
-
block_on(Snapshot::
|
|
191
|
+
block_on(Snapshot::save(
|
|
159
192
|
&name_or_path,
|
|
160
193
|
std::path::Path::new(&out_path),
|
|
161
|
-
|
|
194
|
+
save_opts,
|
|
162
195
|
))
|
|
163
196
|
.map_err(error::to_ruby)
|
|
164
197
|
}
|
|
165
198
|
|
|
166
|
-
/// Unpack a snapshot archive into the snapshots dir. Returns the
|
|
199
|
+
/// Unpack a snapshot archive into the snapshots dir. Returns the loaded
|
|
167
200
|
/// snapshot's metadata hash. `dest` is an optional explicit directory.
|
|
168
|
-
fn
|
|
201
|
+
fn load(archive_path: String, dest: Option<String>) -> Result<RHash, Error> {
|
|
169
202
|
let dest_path = dest.map(PathBuf::from);
|
|
170
|
-
let handle = block_on(Snapshot::
|
|
203
|
+
let handle = block_on(Snapshot::load(
|
|
171
204
|
std::path::Path::new(&archive_path),
|
|
172
205
|
dest_path.as_deref(),
|
|
173
206
|
))
|
|
@@ -180,9 +213,23 @@ fn handle_to_hash(handle: &SnapshotHandle) -> RHash {
|
|
|
180
213
|
let _ = hash.aset("digest", handle.digest().to_string());
|
|
181
214
|
let _ = hash.aset("name", handle.name().map(str::to_string));
|
|
182
215
|
let _ = hash.aset("parent_digest", handle.parent_digest().map(str::to_string));
|
|
216
|
+
let _ = hash.aset("scope", scope_str(handle.scope()));
|
|
217
|
+
let _ = hash.aset("state_kind", handle.state_kind().to_string());
|
|
183
218
|
let _ = hash.aset("image_ref", handle.image_ref().to_string());
|
|
184
|
-
let _ = hash.aset("format",
|
|
219
|
+
let _ = hash.aset("format", handle.format().map(format_str));
|
|
220
|
+
let _ = hash.aset("fstype", handle.fstype().map(str::to_string));
|
|
221
|
+
let _ = hash.aset(
|
|
222
|
+
"checkpoint_manifest_digest",
|
|
223
|
+
handle.checkpoint_manifest_digest().map(str::to_string),
|
|
224
|
+
);
|
|
185
225
|
let _ = hash.aset("size_bytes", handle.size_bytes());
|
|
226
|
+
let _ = hash.aset("locality", handle.locality().to_string());
|
|
227
|
+
let _ = hash.aset("availability", handle.availability().to_string());
|
|
228
|
+
let _ = hash.aset("migration_state", handle.migration_state().to_string());
|
|
229
|
+
let _ = hash.aset(
|
|
230
|
+
"migration_error_code",
|
|
231
|
+
handle.migration_error_code().map(str::to_string),
|
|
232
|
+
);
|
|
186
233
|
let _ = hash.aset("path", handle.path().to_string_lossy().into_owned());
|
|
187
234
|
let _ = hash.aset(
|
|
188
235
|
"created_at_ms",
|
|
@@ -196,9 +243,6 @@ fn verify_report_to_hash(report: &SnapshotVerifyReport) -> RHash {
|
|
|
196
243
|
let _ = hash.aset("digest", report.digest.clone());
|
|
197
244
|
let _ = hash.aset("path", report.path.to_string_lossy().into_owned());
|
|
198
245
|
match &report.upper {
|
|
199
|
-
UpperVerifyStatus::NotRecorded => {
|
|
200
|
-
let _ = hash.aset("upper_status", "not_recorded");
|
|
201
|
-
}
|
|
202
246
|
UpperVerifyStatus::Verified { algorithm, digest } => {
|
|
203
247
|
let _ = hash.aset("upper_status", "verified");
|
|
204
248
|
let _ = hash.aset("upper_algorithm", algorithm.clone());
|
|
@@ -218,7 +262,7 @@ pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
|
|
|
218
262
|
class.define_singleton_method("reindex", function!(reindex, 1))?;
|
|
219
263
|
class.define_singleton_method("remove", function!(remove, 2))?;
|
|
220
264
|
class.define_singleton_method("verify", function!(verify, 1))?;
|
|
221
|
-
class.define_singleton_method("
|
|
222
|
-
class.define_singleton_method("
|
|
265
|
+
class.define_singleton_method("save", function!(save, 3))?;
|
|
266
|
+
class.define_singleton_method("load", function!(load, 2))?;
|
|
223
267
|
Ok(())
|
|
224
268
|
}
|
data/lib/microsandbox/errors.rb
CHANGED
|
@@ -62,6 +62,10 @@ module Microsandbox
|
|
|
62
62
|
define_error(:SnapshotSandboxRunningError, "snapshot-sandbox-running")
|
|
63
63
|
define_error(:SnapshotImageMissingError, "snapshot-image-missing")
|
|
64
64
|
define_error(:SnapshotIntegrityError, "snapshot-integrity")
|
|
65
|
+
# v0.6.7: the automatic v0.6.6→v0.6.7 snapshot-descriptor migration (run at
|
|
66
|
+
# backend connect / artifact open) was blocked and needs repair. Mirrors the
|
|
67
|
+
# Python SDK's SnapshotMigrationError (code "snapshot-migration").
|
|
68
|
+
define_error(:SnapshotMigrationError, "snapshot-migration")
|
|
65
69
|
|
|
66
70
|
# Networking / secrets errors ---------------------------------------------
|
|
67
71
|
# NetworkPolicyError now also carries the core's `NetworkBuilder` build/parse
|
data/lib/microsandbox/image.rb
CHANGED
|
@@ -107,6 +107,44 @@ module Microsandbox
|
|
|
107
107
|
def prune
|
|
108
108
|
ImagePruneReport.new(Native::Image.prune)
|
|
109
109
|
end
|
|
110
|
+
|
|
111
|
+
# Load images into the cache from a local `docker save` tarball or an
|
|
112
|
+
# OCI Image Layout archive. Pass `"-"` to read the archive from `$stdin`
|
|
113
|
+
# (spooled to a temp file first, like the Python SDK — the core reads
|
|
114
|
+
# seekable files only). Mirrors the Python `Image.load` / Node
|
|
115
|
+
# `imageLoad` (runtime v0.6.7).
|
|
116
|
+
# @param input_path [String] archive path (or "-")
|
|
117
|
+
# @param tag [String, Array<String>, nil] retag the loaded image(s)
|
|
118
|
+
# @return [Array<ImageInfo>] one entry per loaded image
|
|
119
|
+
def load(input_path, tag: nil)
|
|
120
|
+
tags = Array(tag).map(&:to_s)
|
|
121
|
+
path = input_path.to_s
|
|
122
|
+
if path == "-"
|
|
123
|
+
require "tempfile"
|
|
124
|
+
Tempfile.create(["msb-image-load", ".tar"]) do |tmp|
|
|
125
|
+
tmp.binmode
|
|
126
|
+
IO.copy_stream($stdin, tmp)
|
|
127
|
+
tmp.flush
|
|
128
|
+
return Native::Image.load(tmp.path, tags).map { |info| ImageInfo.new(info) }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
Native::Image.load(path, tags).map { |info| ImageInfo.new(info) }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Save cached image(s) into a local archive.
|
|
135
|
+
# Mirrors the Python `Image.save` / Node `imageSave` (runtime v0.6.7).
|
|
136
|
+
# @param reference [String, Array<String>] image reference(s) to save
|
|
137
|
+
# @param output_path [String] destination archive path
|
|
138
|
+
# @param format [:docker, :oci] archive layout (default :docker, the
|
|
139
|
+
# shape `docker load` expects)
|
|
140
|
+
# @return [nil]
|
|
141
|
+
def save(reference, output_path:, format: :docker)
|
|
142
|
+
refs = Array(reference).map(&:to_s)
|
|
143
|
+
raise ArgumentError, "at least one image reference is required" if refs.empty?
|
|
144
|
+
|
|
145
|
+
Native::Image.save(refs, output_path.to_s, format.to_s)
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
110
148
|
end
|
|
111
149
|
end
|
|
112
150
|
end
|
|
@@ -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
|
data/lib/microsandbox/network.rb
CHANGED
|
@@ -62,6 +62,24 @@ module Microsandbox
|
|
|
62
62
|
build("deny", destination, direction, protocol, protocols, port, ports)
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
# Allow plain DNS (UDP/53 and TCP/53) to the sandbox gateway — the rule
|
|
66
|
+
# the composable profiles prepend automatically. Use it in custom policies
|
|
67
|
+
# that need name resolution. Expands to the same rule as the core's
|
|
68
|
+
# `Rule::allow_dns` (egress → group :host, udp+tcp, port 53).
|
|
69
|
+
# @return [Hash]
|
|
70
|
+
def allow_dns
|
|
71
|
+
{"action" => "allow", "direction" => "egress",
|
|
72
|
+
"destination_kind" => "group", "destination" => "host",
|
|
73
|
+
"protocols" => %w[udp tcp], "ports" => ["53"]}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Deny plain DNS to the sandbox gateway — the deny counterpart of
|
|
77
|
+
# {allow_dns}, useful as an override placed before profile-generated rules.
|
|
78
|
+
# @return [Hash]
|
|
79
|
+
def deny_dns
|
|
80
|
+
allow_dns.merge("action" => "deny")
|
|
81
|
+
end
|
|
82
|
+
|
|
65
83
|
# @api private
|
|
66
84
|
def build(action, destination, direction, protocol, protocols, port, ports)
|
|
67
85
|
rule = {"action" => action, "direction" => direction.to_s}
|
|
@@ -84,20 +102,29 @@ module Microsandbox
|
|
|
84
102
|
end
|
|
85
103
|
end
|
|
86
104
|
|
|
87
|
-
# A sandbox network policy: a preset, or a custom
|
|
88
|
-
# with per-direction default actions and bulk
|
|
105
|
+
# A sandbox network policy: composed profiles, a terminal preset, or a custom
|
|
106
|
+
# set of allow/deny {Rule}s with per-direction default actions and bulk
|
|
107
|
+
# domain denials.
|
|
89
108
|
#
|
|
90
|
-
# Pass to {Sandbox.create} via `network:` —
|
|
91
|
-
#
|
|
109
|
+
# Pass to {Sandbox.create} via `network:` — a {NetworkPolicy}, an Array of
|
|
110
|
+
# profile names (`[:public, :host]`), a single profile or preset name
|
|
111
|
+
# (String/Symbol), or a plain Hash with the same keys as {custom}.
|
|
92
112
|
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
113
|
+
# Profiles (runtime v0.6.7) compose: `:public` (public internet), `:private`
|
|
114
|
+
# (LAN/private ranges), `:host` (the host machine/gateway). Any non-empty
|
|
115
|
+
# combination automatically allows gateway DNS. The default policy — public
|
|
116
|
+
# internet only — equals `from_profiles(:public)`.
|
|
117
|
+
#
|
|
118
|
+
# @example profiles
|
|
119
|
+
# Sandbox.create("b", image: "alpine", network: [:public, :private])
|
|
95
120
|
# Sandbox.create("b", image: "alpine", network: :none)
|
|
121
|
+
# Sandbox.create("b", image: "alpine", network: NetworkPolicy.from_profiles(:host))
|
|
96
122
|
#
|
|
97
123
|
# @example custom
|
|
98
124
|
# policy = Microsandbox::NetworkPolicy.custom(
|
|
99
125
|
# default_egress: :deny,
|
|
100
126
|
# rules: [
|
|
127
|
+
# Microsandbox::Rule.allow_dns,
|
|
101
128
|
# Microsandbox::Rule.allow(destination: "api.openai.com", protocol: :tcp, port: "443"),
|
|
102
129
|
# ],
|
|
103
130
|
# deny_domain_suffixes: [".ads.example"],
|
|
@@ -106,18 +133,47 @@ module Microsandbox
|
|
|
106
133
|
#
|
|
107
134
|
# Mirrors `NetworkPolicy` / `Network` in the official Python/Node/Go SDKs.
|
|
108
135
|
class NetworkPolicy
|
|
109
|
-
#
|
|
136
|
+
# Composable profile names (runtime v0.6.7).
|
|
137
|
+
PROFILES = %w[public private host].freeze
|
|
138
|
+
|
|
139
|
+
# Canonical preset names keyed by every accepted alias. Only the terminal
|
|
140
|
+
# presets survive v0.6.7; the removed ones get migration guidance in
|
|
141
|
+
# {canonical_preset}.
|
|
110
142
|
PRESET_ALIASES = {
|
|
111
143
|
"none" => "none", "disabled" => "none", "disable" => "none", "airgapped" => "none",
|
|
112
|
-
"
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
144
|
+
"all" => "allow_all", "allow_all" => "allow_all", "allow-all" => "allow_all"
|
|
145
|
+
}.freeze
|
|
146
|
+
|
|
147
|
+
# Removed v0.6.6 preset spellings → the exact profile replacement, used to
|
|
148
|
+
# build actionable migration errors. (`public_only` expanded to precisely
|
|
149
|
+
# `from_profiles(:public)`, `non_local` to `from_profiles(:public, :private)` —
|
|
150
|
+
# the replacements are rule-for-rule equivalent.)
|
|
151
|
+
REMOVED_PRESETS = {
|
|
152
|
+
"public_only" => "network: [:public] (or NetworkPolicy.from_profiles(:public); " \
|
|
153
|
+
"this is still the default policy)",
|
|
154
|
+
"public-only" => "network: [:public]",
|
|
155
|
+
"non_local" => "network: [:public, :private] " \
|
|
156
|
+
"(or NetworkPolicy.from_profiles(:public, :private))",
|
|
157
|
+
"non-local" => "network: [:public, :private]",
|
|
158
|
+
"nonlocal" => "network: [:public, :private]"
|
|
116
159
|
}.freeze
|
|
117
160
|
|
|
118
161
|
class << self
|
|
119
|
-
#
|
|
120
|
-
|
|
162
|
+
# Compose a policy from network profiles (`:public`, `:private`, `:host`).
|
|
163
|
+
# Order and duplicates don't matter — the runtime expands the set into a
|
|
164
|
+
# canonical rule list plus a gateway-DNS allow. An empty set is a valid
|
|
165
|
+
# deny-egress/allow-ingress policy with no rules (and no DNS).
|
|
166
|
+
# @return [NetworkPolicy]
|
|
167
|
+
def from_profiles(*profiles)
|
|
168
|
+
list = normalize_profiles(profiles)
|
|
169
|
+
if list.empty?
|
|
170
|
+
# Same semantics as the runtime's from_profiles([]) — expressed as an
|
|
171
|
+
# explicit empty custom policy so the wire needs no empty-array case.
|
|
172
|
+
custom(default_egress: :deny, default_ingress: :allow, rules: [])
|
|
173
|
+
else
|
|
174
|
+
new("profiles" => list)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
121
177
|
|
|
122
178
|
# @return [NetworkPolicy] block all network access
|
|
123
179
|
def none = preset("none")
|
|
@@ -125,19 +181,17 @@ module Microsandbox
|
|
|
125
181
|
# @return [NetworkPolicy] permit all traffic
|
|
126
182
|
def allow_all = preset("allow_all")
|
|
127
183
|
|
|
128
|
-
# @return [NetworkPolicy]
|
|
129
|
-
def non_local = preset("non_local")
|
|
130
|
-
|
|
131
|
-
# @return [NetworkPolicy] a bare preset policy
|
|
184
|
+
# @return [NetworkPolicy] a bare preset policy (:none or :allow_all)
|
|
132
185
|
def preset(name)
|
|
133
186
|
new("preset" => canonical_preset(name))
|
|
134
187
|
end
|
|
135
188
|
|
|
136
189
|
# Build a custom policy — an ordered rule list with per-direction default
|
|
137
|
-
# actions. A custom policy stands on its own
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
190
|
+
# actions. A custom policy stands on its own; to start from a profile
|
|
191
|
+
# base, use the Hash form passed to {Sandbox.create} (`profiles:` composes
|
|
192
|
+
# with `rules:`/deny lists). A terminal `preset:` stays exclusive with
|
|
193
|
+
# custom rules/defaults, mirroring the official SDKs. Custom policies
|
|
194
|
+
# need an explicit {Rule.allow_dns} if the sandbox should resolve names.
|
|
141
195
|
#
|
|
142
196
|
# @param default_egress [:deny, :allow, nil] fall-through for unmatched
|
|
143
197
|
# outbound traffic (default :deny)
|
|
@@ -163,26 +217,64 @@ module Microsandbox
|
|
|
163
217
|
def coerce(network)
|
|
164
218
|
case network
|
|
165
219
|
when NetworkPolicy then network.to_h
|
|
166
|
-
when
|
|
220
|
+
when Array then from_profiles(*network).to_h
|
|
221
|
+
when String, Symbol then coerce_name(network)
|
|
167
222
|
when Hash then from_hash(network)
|
|
168
223
|
else
|
|
169
224
|
raise ArgumentError,
|
|
170
|
-
"network: expects
|
|
225
|
+
"network: expects profile(s) (:public/:private/:host, or an Array of them), " \
|
|
226
|
+
"a preset (:none/:allow_all), a Microsandbox::NetworkPolicy, or a Hash " \
|
|
171
227
|
"(got #{network.class})"
|
|
172
228
|
end
|
|
173
229
|
end
|
|
174
230
|
|
|
175
231
|
private
|
|
176
232
|
|
|
177
|
-
# A
|
|
178
|
-
# a
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
233
|
+
# A bare String/Symbol names a single profile (`:public`/`:private`/
|
|
234
|
+
# `:host`) or a terminal preset (`:none`/`:allow_all` and aliases).
|
|
235
|
+
# `"default"` stays accepted and means the default policy, which is
|
|
236
|
+
# exactly the `:public` profile.
|
|
237
|
+
def coerce_name(name)
|
|
238
|
+
key = name.to_s.downcase
|
|
239
|
+
return {"profiles" => [key]} if PROFILES.include?(key)
|
|
240
|
+
return {"profiles" => ["public"]} if key == "default"
|
|
241
|
+
|
|
242
|
+
{"preset" => canonical_preset(key)}
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Validate + stringify a profile list. Removed-preset spellings get the
|
|
246
|
+
# same migration guidance here as in {canonical_preset}, since
|
|
247
|
+
# `network: [:non_local]` is a plausible mis-migration.
|
|
248
|
+
def normalize_profiles(profiles)
|
|
249
|
+
Array(profiles).flatten.map do |p|
|
|
250
|
+
key = p.to_s.downcase
|
|
251
|
+
next key if PROFILES.include?(key)
|
|
252
|
+
|
|
253
|
+
if (replacement = REMOVED_PRESETS[key])
|
|
254
|
+
raise ArgumentError,
|
|
255
|
+
"#{p.inspect} is a removed v0.6.6 network preset, not a profile; use #{replacement}"
|
|
256
|
+
end
|
|
257
|
+
raise ArgumentError,
|
|
258
|
+
"unknown network profile #{p.inspect} (expected :public/:private/:host)"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# A `network:` Hash is composed profiles (`profiles:` + optional rules/
|
|
263
|
+
# defaults/deny lists), a terminal preset (`preset:` + optional deny
|
|
264
|
+
# lists), or a custom policy (`default_egress:`/`default_ingress:`/
|
|
265
|
+
# `rules:` + optional deny lists). Profiles compose with everything —
|
|
266
|
+
# they expand to an ordinary rule base that explicit rules append to —
|
|
267
|
+
# but a preset stays exclusive with rules/defaults: it already defines
|
|
268
|
+
# its rules and defaults, so layering more on top would silently override
|
|
269
|
+
# them. A bare preset or bare profile list is routed to its dedicated
|
|
270
|
+
# wire key by {Sandbox.create}, so the runtime's own expansion applies.
|
|
184
271
|
def from_hash(hash)
|
|
185
272
|
sym = hash.transform_keys(&:to_sym)
|
|
273
|
+
profiles = sym.key?(:profiles) ? normalize_profiles(sym[:profiles]) : nil
|
|
274
|
+
if profiles && sym.key?(:preset)
|
|
275
|
+
raise ArgumentError, "network profiles: and preset: are mutually exclusive"
|
|
276
|
+
end
|
|
277
|
+
|
|
186
278
|
if sym.key?(:preset)
|
|
187
279
|
if sym.key?(:rules) || sym.key?(:default_egress) || sym.key?(:default_ingress)
|
|
188
280
|
raise ArgumentError,
|
|
@@ -193,6 +285,18 @@ module Microsandbox
|
|
|
193
285
|
h = {"preset" => canonical_preset(sym[:preset])}
|
|
194
286
|
add_deny_lists(h, sym[:deny_domains], sym[:deny_domain_suffixes])
|
|
195
287
|
h
|
|
288
|
+
elsif profiles
|
|
289
|
+
h = {}
|
|
290
|
+
h["profiles"] = profiles unless profiles.empty?
|
|
291
|
+
h["default_egress"] = action_str(sym[:default_egress]) if sym[:default_egress]
|
|
292
|
+
h["default_ingress"] = action_str(sym[:default_ingress]) if sym[:default_ingress]
|
|
293
|
+
rules = Array(sym[:rules]).map { |r| normalize_rule(r) }
|
|
294
|
+
h["rules"] = rules unless rules.empty?
|
|
295
|
+
add_deny_lists(h, sym[:deny_domains], sym[:deny_domain_suffixes])
|
|
296
|
+
# `profiles: []` alone is the runtime's empty composed policy:
|
|
297
|
+
# deny-egress/allow-ingress with no rules (and no DNS).
|
|
298
|
+
h = {"default_egress" => "deny", "default_ingress" => "allow", "rules" => []} if h.empty?
|
|
299
|
+
h
|
|
196
300
|
elsif sym.key?(:rules) || sym.key?(:default_egress) || sym.key?(:default_ingress)
|
|
197
301
|
# An explicit custom policy: the caller chose the rule list and/or the
|
|
198
302
|
# fall-through defaults (which default to :deny / :allow).
|
|
@@ -231,10 +335,14 @@ module Microsandbox
|
|
|
231
335
|
|
|
232
336
|
def canonical_preset(name)
|
|
233
337
|
key = name.to_s.downcase
|
|
338
|
+
if (replacement = REMOVED_PRESETS[key])
|
|
339
|
+
raise ArgumentError,
|
|
340
|
+
"the #{name.inspect} network preset was removed in runtime v0.6.7; use #{replacement}"
|
|
341
|
+
end
|
|
234
342
|
PRESET_ALIASES[key] ||
|
|
235
343
|
raise(ArgumentError,
|
|
236
344
|
"unknown network preset #{name.inspect} " \
|
|
237
|
-
"(expected
|
|
345
|
+
"(expected none/allow_all, or profiles :public/:private/:host)")
|
|
238
346
|
end
|
|
239
347
|
|
|
240
348
|
def action_str(action)
|