microsandbox-rb 0.6.0 → 0.7.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.
@@ -5,7 +5,7 @@
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
7
 
8
- use std::path::PathBuf;
8
+ use std::path::{Path, PathBuf};
9
9
 
10
10
  use magnus::{function, prelude::*, Error, RArray, RHash, RModule, Ruby};
11
11
  use microsandbox::snapshot::{
@@ -24,6 +24,38 @@ fn format_str(format: SnapshotFormat) -> &'static str {
24
24
  }
25
25
  }
26
26
 
27
+ /// Parse a manifest's RFC 3339 `created_at` into epoch-ms (nil if unparseable).
28
+ fn created_at_ms(rfc3339: &str) -> Option<i64> {
29
+ chrono::DateTime::parse_from_rfc3339(rfc3339)
30
+ .ok()
31
+ .map(|dt| dt.timestamp_millis())
32
+ }
33
+
34
+ /// Convert a fully-opened `Snapshot` into the `SnapshotInfo` Hash. Unlike a
35
+ /// `SnapshotHandle` (a lightweight index row), an opened snapshot carries the
36
+ /// full manifest, so this is the richest shape — `create`/`open`/`list_dir`
37
+ /// and the `SandboxHandle#snapshot`/`#snapshot_to` shortcuts all funnel here.
38
+ pub(crate) fn snapshot_to_hash(snap: &Snapshot) -> RHash {
39
+ let m = snap.manifest();
40
+ let hash = ruby().hash_new();
41
+ let _ = hash.aset("digest", snap.digest().to_string());
42
+ let _ = hash.aset("path", snap.path().to_string_lossy().into_owned());
43
+ let _ = hash.aset("size_bytes", snap.size_bytes());
44
+ let _ = hash.aset("image_ref", m.image.reference.clone());
45
+ let _ = hash.aset("image_manifest_digest", m.image.manifest_digest.clone());
46
+ let _ = hash.aset("format", format_str(m.format));
47
+ let _ = hash.aset("fstype", m.fstype.clone());
48
+ let _ = hash.aset("parent_digest", m.parent.clone());
49
+ let _ = hash.aset("created_at_ms", created_at_ms(&m.created_at));
50
+ let _ = hash.aset("source_sandbox", m.source_sandbox.clone());
51
+ let labels = ruby().hash_new();
52
+ for (k, v) in &m.labels {
53
+ let _ = labels.aset(k.as_str(), v.as_str());
54
+ }
55
+ let _ = hash.aset("labels", labels);
56
+ hash
57
+ }
58
+
27
59
  /// Create a snapshot of a stopped sandbox. `opts`: name | path (destination),
28
60
  /// labels, force, record_integrity. Returns {digest, path, size_bytes}.
29
61
  fn create(source_sandbox: String, opts: RHash) -> Result<RHash, Error> {
@@ -48,11 +80,42 @@ fn create(source_sandbox: String, opts: RHash) -> Result<RHash, Error> {
48
80
  }
49
81
 
50
82
  let snap = block_on(b.create()).map_err(error::to_ruby)?;
51
- let hash = ruby().hash_new();
52
- hash.aset("digest", snap.digest().to_string())?;
53
- hash.aset("path", snap.path().to_string_lossy().into_owned())?;
54
- hash.aset("size_bytes", snap.size_bytes())?;
55
- Ok(hash)
83
+ Ok(snapshot_to_hash(&snap))
84
+ }
85
+
86
+ /// Open an existing snapshot artifact by bare name or path. Cheap metadata
87
+ /// validation only (does not read the upper file). Returns a full SnapshotInfo
88
+ /// Hash — the only way to inspect an artifact addressed by path (`get`/`list`
89
+ /// read the local index, which path-addressed artifacts are absent from).
90
+ fn open(path_or_name: String) -> Result<RHash, Error> {
91
+ let snap = block_on(Snapshot::open(&path_or_name)).map_err(error::to_ruby)?;
92
+ Ok(snapshot_to_hash(&snap))
93
+ }
94
+
95
+ /// Walk `dir` and parse each subdirectory's `manifest.json` without touching the
96
+ /// local index — for enumerating external/un-imported snapshot collections.
97
+ fn list_dir(dir: String) -> Result<RArray, Error> {
98
+ let snaps = block_on(Snapshot::list_dir(Path::new(&dir))).map_err(error::to_ruby)?;
99
+ let arr = ruby().ary_new();
100
+ for snap in &snaps {
101
+ arr.push(snapshot_to_hash(snap))?;
102
+ }
103
+ Ok(arr)
104
+ }
105
+
106
+ /// Rebuild the local snapshot index from `dir` (defaults to the configured
107
+ /// snapshots directory). Returns the number of indexed snapshots — the repair
108
+ /// for index drift or out-of-band imports that `get`/`list` can't see.
109
+ fn reindex(dir: Option<String>) -> Result<u64, Error> {
110
+ let dir: PathBuf = match dir {
111
+ Some(d) => PathBuf::from(d),
112
+ None => microsandbox::default_backend()
113
+ .as_local()
114
+ .map(|l| l.snapshots_dir())
115
+ .unwrap_or_else(|| PathBuf::from(".")),
116
+ };
117
+ let n = block_on(Snapshot::reindex(&dir)).map_err(error::to_ruby)?;
118
+ Ok(n as u64)
56
119
  }
57
120
 
58
121
  /// Metadata for one snapshot by name or digest.
@@ -148,8 +211,11 @@ fn verify_report_to_hash(report: &SnapshotVerifyReport) -> RHash {
148
211
  pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
149
212
  let class = native.define_class("Snapshot", ruby.class_object())?;
150
213
  class.define_singleton_method("create", function!(create, 2))?;
214
+ class.define_singleton_method("open", function!(open, 1))?;
151
215
  class.define_singleton_method("get", function!(get, 1))?;
152
216
  class.define_singleton_method("list", function!(list, 0))?;
217
+ class.define_singleton_method("list_dir", function!(list_dir, 1))?;
218
+ class.define_singleton_method("reindex", function!(reindex, 1))?;
153
219
  class.define_singleton_method("remove", function!(remove, 2))?;
154
220
  class.define_singleton_method("verify", function!(verify, 1))?;
155
221
  class.define_singleton_method("export", function!(export, 3))?;
@@ -3,12 +3,17 @@
3
3
  //! Mirrors `sdk/python/src/volume.rs`. Static async CRUD over the volume store;
4
4
  //! results are returned as plain Ruby Hashes/Arrays.
5
5
 
6
- use magnus::{function, prelude::*, Error, RArray, RHash, RModule, Ruby};
6
+ use std::sync::Arc;
7
+
8
+ use magnus::{function, method, prelude::*, Error, RArray, RHash, RModule, RString, Ruby};
7
9
  use microsandbox::volume::VolumeHandle;
10
+ use microsandbox::Backend;
8
11
 
12
+ use crate::backend::local_backend;
9
13
  use crate::conv;
10
14
  use crate::error;
11
15
  use crate::runtime::{block_on, ruby};
16
+ use crate::sandbox::{fs_entry_to_hash, fs_metadata_to_hash};
12
17
 
13
18
  fn handle_to_hash(h: &VolumeHandle) -> RHash {
14
19
  let hash = ruby().hash_new();
@@ -92,11 +97,118 @@ fn remove(name: String) -> Result<(), Error> {
92
97
  block_on(microsandbox::Volume::remove(&name)).map_err(error::to_ruby)
93
98
  }
94
99
 
100
+ /// A host-side filesystem view over a named volume — read/write its contents
101
+ /// without a running sandbox. Mirrors the Python `VolumeFs` / Node `VolumeFs`.
102
+ /// The core `VolumeFs<'a>` borrows the volume name, so (like the Python binding)
103
+ /// each operation rebuilds it from the stored backend + name.
104
+ #[magnus::wrap(class = "Microsandbox::Native::VolumeFs", free_immediately, size)]
105
+ pub struct VolumeFs {
106
+ backend: Arc<dyn Backend>,
107
+ name: String,
108
+ }
109
+
110
+ impl VolumeFs {
111
+ /// Resolve the (local) backend once and bind it to `name`.
112
+ fn for_volume(name: String) -> Result<VolumeFs, Error> {
113
+ Ok(VolumeFs {
114
+ backend: local_backend().map_err(error::to_ruby)?,
115
+ name,
116
+ })
117
+ }
118
+
119
+ fn fs(&self) -> microsandbox::volume::VolumeFs<'_> {
120
+ microsandbox::volume::VolumeFs::with_backend(self.backend.clone(), &self.name)
121
+ }
122
+
123
+ fn read(&self, path: String) -> Result<RString, Error> {
124
+ let fs = self.fs();
125
+ let bytes = block_on(fs.read(&path)).map_err(error::to_ruby)?;
126
+ Ok(ruby().str_from_slice(bytes.as_ref()))
127
+ }
128
+
129
+ fn read_text(&self, path: String) -> Result<String, Error> {
130
+ let fs = self.fs();
131
+ block_on(fs.read_to_string(&path)).map_err(error::to_ruby)
132
+ }
133
+
134
+ fn write(&self, path: String, data: RString) -> Result<(), Error> {
135
+ // Copy the bytes out while the GVL is held (GC.compact could move them).
136
+ let bytes = unsafe { data.as_slice() }.to_vec();
137
+ let fs = self.fs();
138
+ block_on(fs.write(&path, &bytes)).map_err(error::to_ruby)
139
+ }
140
+
141
+ fn list(&self, path: String) -> Result<RArray, Error> {
142
+ let fs = self.fs();
143
+ let entries = block_on(fs.list(&path)).map_err(error::to_ruby)?;
144
+ let arr = ruby().ary_new();
145
+ for entry in &entries {
146
+ arr.push(fs_entry_to_hash(entry))?;
147
+ }
148
+ Ok(arr)
149
+ }
150
+
151
+ fn mkdir(&self, path: String) -> Result<(), Error> {
152
+ let fs = self.fs();
153
+ block_on(fs.mkdir(&path)).map_err(error::to_ruby)
154
+ }
155
+
156
+ fn remove_file(&self, path: String) -> Result<(), Error> {
157
+ let fs = self.fs();
158
+ block_on(fs.remove(&path)).map_err(error::to_ruby)
159
+ }
160
+
161
+ fn remove_dir(&self, path: String) -> Result<(), Error> {
162
+ let fs = self.fs();
163
+ block_on(fs.remove_dir(&path)).map_err(error::to_ruby)
164
+ }
165
+
166
+ fn exists(&self, path: String) -> Result<bool, Error> {
167
+ let fs = self.fs();
168
+ block_on(fs.exists(&path)).map_err(error::to_ruby)
169
+ }
170
+
171
+ fn copy(&self, from: String, to: String) -> Result<(), Error> {
172
+ let fs = self.fs();
173
+ block_on(fs.copy(&from, &to)).map_err(error::to_ruby)
174
+ }
175
+
176
+ fn rename(&self, from: String, to: String) -> Result<(), Error> {
177
+ let fs = self.fs();
178
+ block_on(fs.rename(&from, &to)).map_err(error::to_ruby)
179
+ }
180
+
181
+ fn stat(&self, path: String) -> Result<RHash, Error> {
182
+ let fs = self.fs();
183
+ let meta = block_on(fs.stat(&path)).map_err(error::to_ruby)?;
184
+ Ok(fs_metadata_to_hash(&meta))
185
+ }
186
+ }
187
+
188
+ /// Open a host-side filesystem view over a named volume.
189
+ fn fs(name: String) -> Result<VolumeFs, Error> {
190
+ VolumeFs::for_volume(name)
191
+ }
192
+
95
193
  pub fn define(ruby: &Ruby, native: &RModule) -> Result<(), Error> {
96
194
  let class = native.define_class("Volume", ruby.class_object())?;
97
195
  class.define_singleton_method("create", function!(create, 2))?;
98
196
  class.define_singleton_method("get", function!(get, 1))?;
99
197
  class.define_singleton_method("list", function!(list, 0))?;
100
198
  class.define_singleton_method("remove", function!(remove, 1))?;
199
+ class.define_singleton_method("fs", function!(fs, 1))?;
200
+
201
+ let vfs = native.define_class("VolumeFs", ruby.class_object())?;
202
+ vfs.define_method("read", method!(VolumeFs::read, 1))?;
203
+ vfs.define_method("read_text", method!(VolumeFs::read_text, 1))?;
204
+ vfs.define_method("write", method!(VolumeFs::write, 2))?;
205
+ vfs.define_method("list", method!(VolumeFs::list, 1))?;
206
+ vfs.define_method("mkdir", method!(VolumeFs::mkdir, 1))?;
207
+ vfs.define_method("remove_file", method!(VolumeFs::remove_file, 1))?;
208
+ vfs.define_method("remove_dir", method!(VolumeFs::remove_dir, 1))?;
209
+ vfs.define_method("exists", method!(VolumeFs::exists, 1))?;
210
+ vfs.define_method("copy", method!(VolumeFs::copy, 2))?;
211
+ vfs.define_method("rename", method!(VolumeFs::rename, 2))?;
212
+ vfs.define_method("stat", method!(VolumeFs::stat, 1))?;
101
213
  Ok(())
102
214
  }
@@ -100,7 +100,9 @@ module Microsandbox
100
100
  # Connect to a running sandbox by name (max 128 UTF-8 bytes). With a block,
101
101
  # the client is yielded and closed when the block returns.
102
102
  # @param name [String]
103
- # @param timeout [Numeric, nil] handshake timeout in seconds (default ~10s)
103
+ # @param timeout [Numeric, nil] handshake timeout in seconds. nil (the
104
+ # default) uses the core default (~10s); 0 fails fast (an immediate
105
+ # deadline); a negative or non-finite value raises {Error}.
104
106
  # @yieldparam client [AgentClient]
105
107
  # @return [AgentClient, Object]
106
108
  def connect_sandbox(name, timeout: nil, &block)
@@ -20,9 +20,10 @@ module Microsandbox
20
20
  @data = event["data"]
21
21
  end
22
22
 
23
- # @return [String, nil] {#data} decoded as UTF-8 (lenient)
23
+ # @return [String, nil] {#data} decoded as UTF-8 (lossy — invalid byte
24
+ # sequences are replaced with U+FFFD, so the result is always valid UTF-8)
24
25
  def text
25
- @data&.dup&.force_encoding(Encoding::UTF_8)
26
+ @data&.dup&.force_encoding(Encoding::UTF_8)&.scrub
26
27
  end
27
28
 
28
29
  def started? = @type == :started
@@ -62,9 +63,12 @@ module Microsandbox
62
63
  end
63
64
 
64
65
  # Write data to the process stdin.
66
+ # @param data [String] raw bytes to write (binary-safe)
67
+ # @raise [TypeError] if +data+ is not a String
65
68
  # @return [self]
66
69
  def write(data)
67
- @native.write(data.to_s)
70
+ bytes = Microsandbox.coerce_write_bytes(data)
71
+ @native.write(bytes)
68
72
  self
69
73
  end
70
74
 
@@ -3,9 +3,9 @@
3
3
  module Microsandbox
4
4
  # The result of a completed `exec`/`shell` call.
5
5
  #
6
- # `stdout`/`stderr` return the captured bytes decoded as UTF-8 (lenient the
7
- # bytes are preserved even if they are not valid UTF-8); use `stdout_bytes`/
8
- # `stderr_bytes` for the raw ASCII-8BIT bytes.
6
+ # `stdout`/`stderr` return the captured bytes decoded as UTF-8 (lossy
7
+ # invalid byte sequences are replaced with U+FFFD, so the result is always
8
+ # valid UTF-8); use `stdout_bytes`/`stderr_bytes` for the raw ASCII-8BIT bytes.
9
9
  class ExecOutput
10
10
  # @return [Integer] the process exit code
11
11
  attr_reader :exit_code
@@ -32,14 +32,14 @@ module Microsandbox
32
32
  !@success
33
33
  end
34
34
 
35
- # @return [String] stdout decoded as UTF-8
35
+ # @return [String] stdout decoded as UTF-8 (lossy)
36
36
  def stdout
37
- @stdout ||= @stdout_bytes.dup.force_encoding(Encoding::UTF_8)
37
+ @stdout ||= @stdout_bytes.dup.force_encoding(Encoding::UTF_8).scrub
38
38
  end
39
39
 
40
- # @return [String] stderr decoded as UTF-8
40
+ # @return [String] stderr decoded as UTF-8 (lossy)
41
41
  def stderr
42
- @stderr ||= @stderr_bytes.dup.force_encoding(Encoding::UTF_8)
42
+ @stderr ||= @stderr_bytes.dup.force_encoding(Encoding::UTF_8).scrub
43
43
  end
44
44
 
45
45
  # @return [String] stdout decoded as UTF-8 (alias for {#stdout})
@@ -96,10 +96,14 @@ module Microsandbox
96
96
  @native.fs_read_text(path.to_s)
97
97
  end
98
98
 
99
- # Write data (a String) to a file, creating or truncating it.
99
+ # Write data to a file, creating or truncating it.
100
+ # @param data [String] raw bytes to write (binary-safe; ASCII-8BIT is fine)
101
+ # @raise [TypeError] if +data+ is not a String (rather than silently writing
102
+ # its +to_s+ form, e.g. the inspect string of a StringIO or "42")
100
103
  # @return [nil]
101
104
  def write(path, data)
102
- @native.fs_write(path.to_s, data.to_s)
105
+ bytes = Microsandbox.coerce_write_bytes(data)
106
+ @native.fs_write(path.to_s, bytes)
103
107
  nil
104
108
  end
105
109
 
@@ -168,5 +172,83 @@ module Microsandbox
168
172
  @native.fs_copy_to_host(guest_path.to_s, host_path.to_s)
169
173
  nil
170
174
  end
175
+
176
+ # Open a streaming reader over a guest file — for files too large to read
177
+ # into memory at once (unlike {#read}, which buffers the whole file).
178
+ # @return [FsReadStream] an {Enumerable} of byte chunks (ASCII-8BIT)
179
+ def read_stream(path)
180
+ FsReadStream.new(@native.fs_read_stream(path.to_s))
181
+ end
182
+
183
+ # Open a streaming writer to a guest file. With a block, the sink is yielded
184
+ # and closed (flushed) when the block returns.
185
+ # @yieldparam sink [FsWriteSink]
186
+ # @return [FsWriteSink, Object]
187
+ def write_stream(path)
188
+ sink = FsWriteSink.new(@native.fs_write_stream(path.to_s))
189
+ return sink unless block_given?
190
+
191
+ begin
192
+ yield sink
193
+ ensure
194
+ sink.close
195
+ end
196
+ end
197
+ end
198
+
199
+ # A streaming reader over a guest file, from {FS#read_stream}. Iterate it (it
200
+ # is {Enumerable}) to consume byte chunks (ASCII-8BIT) as they arrive, or call
201
+ # {#read} to drain it into one String.
202
+ class FsReadStream
203
+ include Enumerable
204
+
205
+ def initialize(native)
206
+ @native = native
207
+ end
208
+
209
+ # Yield each chunk of bytes until the stream ends. Returns an Enumerator when
210
+ # called without a block.
211
+ # @yieldparam chunk [String] raw bytes (ASCII-8BIT)
212
+ # @return [self, Enumerator]
213
+ def each
214
+ return enum_for(:each) unless block_given?
215
+
216
+ while (chunk = @native.recv)
217
+ yield chunk
218
+ end
219
+ self
220
+ end
221
+
222
+ # Drain the stream into a single byte String.
223
+ # @return [String] raw bytes (ASCII-8BIT)
224
+ def read
225
+ buffer = +"".b
226
+ each { |chunk| buffer << chunk }
227
+ buffer
228
+ end
229
+ end
230
+
231
+ # A streaming writer to a guest file, from {FS#write_stream}.
232
+ class FsWriteSink
233
+ def initialize(native)
234
+ @native = native
235
+ end
236
+
237
+ # Write a chunk of bytes.
238
+ # @param data [String] raw bytes (binary-safe)
239
+ # @raise [TypeError] if +data+ is not a String
240
+ # @return [self]
241
+ def write(data)
242
+ bytes = Microsandbox.coerce_write_bytes(data)
243
+ @native.write(bytes)
244
+ self
245
+ end
246
+
247
+ # Flush and close the sink. Idempotent.
248
+ # @return [nil]
249
+ def close
250
+ @native.close
251
+ nil
252
+ end
171
253
  end
172
254
  end
@@ -35,7 +35,8 @@ module Microsandbox
35
35
  class ImageDetail
36
36
  # @return [ImageInfo]
37
37
  attr_reader :handle
38
- # @return [Hash, nil] OCI config (digest, env, cmd, entrypoint, working_dir, user, stop_signal)
38
+ # @return [Hash, nil] OCI config (digest, env, cmd, entrypoint, working_dir,
39
+ # user, labels, stop_signal). `labels` is a Hash (or nil) of OCI config labels.
39
40
  attr_reader :config
40
41
  # @return [Array<Hash>] layer descriptors
41
42
  attr_reader :layers
@@ -25,9 +25,11 @@ module Microsandbox
25
25
  Time.at(@timestamp_ms / 1000.0)
26
26
  end
27
27
 
28
- # @return [String] the captured bytes decoded as UTF-8 (lenient)
28
+ # @return [String] the captured bytes decoded as UTF-8 (lossy — invalid byte
29
+ # sequences are replaced with U+FFFD, so the result is always valid UTF-8;
30
+ # use {#data} for the raw ASCII-8BIT bytes)
29
31
  def text
30
- @data.dup.force_encoding(Encoding::UTF_8)
32
+ @data.dup.force_encoding(Encoding::UTF_8).scrub
31
33
  end
32
34
 
33
35
  def inspect
@@ -24,6 +24,12 @@ module Microsandbox
24
24
  attr_reader :net_rx_bytes
25
25
  # @return [Integer] cumulative bytes transmitted over the network
26
26
  attr_reader :net_tx_bytes
27
+ # @return [Integer, nil] bytes used in the writable OCI upper layer
28
+ attr_reader :upper_used_bytes
29
+ # @return [Integer, nil] bytes free in the writable OCI upper layer
30
+ attr_reader :upper_free_bytes
31
+ # @return [Integer, nil] host bytes allocated to back the upper layer
32
+ attr_reader :upper_host_allocated_bytes
27
33
  # @return [Float] sandbox uptime in seconds
28
34
  attr_reader :uptime_secs
29
35
 
@@ -38,6 +44,9 @@ module Microsandbox
38
44
  @disk_write_bytes = data["disk_write_bytes"]
39
45
  @net_rx_bytes = data["net_rx_bytes"]
40
46
  @net_tx_bytes = data["net_tx_bytes"]
47
+ @upper_used_bytes = data["upper_used_bytes"]
48
+ @upper_free_bytes = data["upper_free_bytes"]
49
+ @upper_host_allocated_bytes = data["upper_host_allocated_bytes"]
41
50
  @uptime_secs = data["uptime_secs"]
42
51
  @timestamp_ms = data["timestamp_ms"]
43
52
  end