microsandbox-rb 0.16.0 → 0.17.1
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 +234 -1
- data/Cargo.lock +236 -201
- data/DESIGN.md +7 -38
- data/README.md +178 -626
- data/SECURITY.md +65 -0
- data/docs/configuration.md +154 -0
- data/docs/errors.md +74 -0
- data/docs/execution.md +101 -0
- data/docs/filesystem.md +45 -0
- data/docs/images.md +65 -0
- data/docs/lifecycle.md +108 -0
- data/docs/observability.md +99 -0
- data/docs/releasing.md +66 -0
- data/docs/runtime.md +152 -0
- data/docs/surface.md +70 -0
- data/ext/microsandbox/Cargo.toml +4 -4
- data/ext/microsandbox/src/error.rs +7 -0
- data/ext/microsandbox/src/host_path.rs +108 -0
- data/ext/microsandbox/src/lib.rs +1 -0
- data/ext/microsandbox/src/sandbox.rs +143 -31
- data/ext/microsandbox/src/volume.rs +43 -12
- data/lib/microsandbox/fs.rb +4 -8
- data/lib/microsandbox/image.rb +2 -2
- data/lib/microsandbox/log_entry.rb +1 -1
- data/lib/microsandbox/metrics.rb +1 -1
- data/lib/microsandbox/outbound_proxy.rb +340 -0
- data/lib/microsandbox/sandbox.rb +73 -10
- data/lib/microsandbox/snapshot.rb +1 -1
- data/lib/microsandbox/ssh.rb +2 -10
- data/lib/microsandbox/streams.rb +8 -5
- data/lib/microsandbox/version.rb +2 -2
- data/lib/microsandbox/volume.rb +31 -14
- data/lib/microsandbox-rb.rb +7 -0
- data/lib/microsandbox.rb +89 -31
- data/sig/microsandbox.rbs +40 -1
- metadata +15 -1
data/SECURITY.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Reporting a vulnerability
|
|
4
|
+
|
|
5
|
+
Please report security problems **privately**, not in a public issue:
|
|
6
|
+
|
|
7
|
+
- GitHub: <https://github.com/ya-luotao/microsandbox-rb/security/advisories/new>
|
|
8
|
+
("Report a vulnerability" on the repository's Security tab).
|
|
9
|
+
|
|
10
|
+
Include the gem version (`Microsandbox::VERSION`), the embedded runtime
|
|
11
|
+
(`Microsandbox.runtime_version`), the host OS, and the smallest reproduction you
|
|
12
|
+
have. You should get a first reply within a few days.
|
|
13
|
+
|
|
14
|
+
If the problem is in the microsandbox runtime itself (the `msb` binary, the
|
|
15
|
+
guest agent, the network engine) rather than in this Ruby binding, report it
|
|
16
|
+
upstream at <https://github.com/superradcompany/microsandbox/security> — and
|
|
17
|
+
feel free to tell us too, so the fix can be tracked for this gem.
|
|
18
|
+
|
|
19
|
+
## Supported versions
|
|
20
|
+
|
|
21
|
+
Only the latest released version receives fixes. The gem embeds a specific
|
|
22
|
+
upstream runtime; the README's Versioning table maps every gem version to its
|
|
23
|
+
runtime tag.
|
|
24
|
+
|
|
25
|
+
## Upstream advisories and this gem
|
|
26
|
+
|
|
27
|
+
Upstream advisories name the upstream packages (the Rust crate, the CLI, and
|
|
28
|
+
the official SDKs). **They do not list `microsandbox-rb`**, so `bundler-audit`
|
|
29
|
+
and Dependabot will not flag this gem for them. The `rubygems:microsandbox`
|
|
30
|
+
entry in some of them refers to the upstream project's own Ruby gem, not to
|
|
31
|
+
`microsandbox-rb`. This table is the mapping.
|
|
32
|
+
|
|
33
|
+
| Advisory | Severity | Affected `microsandbox-rb` | Status |
|
|
34
|
+
|---|---|---|---|
|
|
35
|
+
| [GHSA-mcw9-9rf6-p4rm](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-mcw9-9rf6-p4rm) — relative bind-mount paths re-resolved against the working directory on restart | High | `<= 0.17.0` | Fixed in `0.17.1` for sandboxes created through this gem (paths are pinned at create time in the binding). Sandboxes created earlier with a relative path keep it: recreate them. The `msb` CLI this gem installs is still runtime `v0.6.18`, so a sandbox created with `msb` directly is not covered: use absolute paths there. |
|
|
36
|
+
| [GHSA-cg9x-2vfm-3j58](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-cg9x-2vfm-3j58) — NAT64 addresses can bypass destination-group network policy | High | all versions (runtime `< v0.7.5`) | **Not fixed in this gem yet**; needs the runtime fix. Only exploitable where the host network routes a NAT64 prefix. Mitigate by denying the prefix explicitly (see below) or blocking it at the host. |
|
|
37
|
+
| [GHSA-3mpp-52gc-935r](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-3mpp-52gc-935r) — hostname allowlist bypass via mismatched HTTP `Host` / `:authority` | High | `<= 0.16.0` | Runtime fix included from `0.17.0`. If you rely on a hostname allowlist for HTTPS without TLS interception, also pass `strict: true` (off by default in this gem). |
|
|
38
|
+
| [GHSA-gj24-29j2-3x78](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-gj24-29j2-3x78) — egress allowlist bypass on malformed HTTP/1 request lines | Medium | `<= 0.12.0` | Runtime fix included from `0.13.0`. |
|
|
39
|
+
| [GHSA-4vq3-cjpp-v7fg](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-4vq3-cjpp-v7fg) — guest-controlled symlinks in `msb copy` | High | `<= 0.10.0` (the bundled `msb` CLI) | Runtime fix included from `0.11.0`. |
|
|
40
|
+
| [GHSA-m8f5-rh7h-vgg3](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-m8f5-rh7h-vgg3) — secret values in world-readable process arguments | Medium | `<= 0.7.0` | Runtime fix included from `0.8.0`. |
|
|
41
|
+
|
|
42
|
+
### Mitigating GHSA-cg9x-2vfm-3j58 before the runtime fix
|
|
43
|
+
|
|
44
|
+
On a host whose network provides NAT64 (typically IPv6-only networks), deny the
|
|
45
|
+
well-known prefix explicitly. Rules you pass are evaluated before the rules a
|
|
46
|
+
profile expands to, so this keeps the default public-internet profile and puts
|
|
47
|
+
the deny ahead of it:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
Microsandbox::Sandbox.create("worker", image: "python",
|
|
51
|
+
network: {
|
|
52
|
+
profiles: [:public],
|
|
53
|
+
rules: [Microsandbox::Rule.deny(destination: "64:ff9b::/96")]
|
|
54
|
+
})
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
If your network uses a different NAT64 prefix, deny that one as well, or block
|
|
58
|
+
the prefix at the host or network boundary. Hosts without NAT64 routing are not
|
|
59
|
+
expected to be exploitable through this vector.
|
|
60
|
+
|
|
61
|
+
The deny rule is this gem's suggestion, relying on the policy engine matching
|
|
62
|
+
rules in order; it has not been exercised on a NAT64 network. The upstream
|
|
63
|
+
advisory's own workarounds are to disable sandbox networking for untrusted
|
|
64
|
+
workloads (`network: :none`) or to block NAT64 prefixes at the host or network
|
|
65
|
+
boundary.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Sandbox configuration
|
|
2
|
+
|
|
3
|
+
All create-time options are keyword arguments to `Sandbox.create`,
|
|
4
|
+
`Sandbox.connect_or_create`, and `Sandbox.create_with_progress`.
|
|
5
|
+
|
|
6
|
+
## Resources, environment, ports, network
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
Microsandbox::Sandbox.create(
|
|
10
|
+
"configured",
|
|
11
|
+
image: "public.ecr.aws/docker/library/python:3-slim",
|
|
12
|
+
cpus: 2,
|
|
13
|
+
memory: 1024, # MiB
|
|
14
|
+
env: { "API_BASE" => "https://example.com" },
|
|
15
|
+
workdir: "/app",
|
|
16
|
+
labels: { "team" => "research" },
|
|
17
|
+
ports: { 8080 => 80 }, # host => guest (TCP)
|
|
18
|
+
network: [:public], # composable profiles; :none for airgapped
|
|
19
|
+
replace: true # replace an existing sandbox of the same name
|
|
20
|
+
) do |sb|
|
|
21
|
+
# ...
|
|
22
|
+
end
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
To leave headroom for a live CPU/memory resize later, reserve a ceiling with
|
|
26
|
+
`max_cpus:`/`max_memory:` (see [observability.md](observability.md#live-modification-and-health)).
|
|
27
|
+
|
|
28
|
+
The full option list — `init`/`ephemeral`, disk-image `fstype`, `log_level`,
|
|
29
|
+
`security`, `rlimits`, `pull_policy`, `secrets`, `patches`
|
|
30
|
+
(`Microsandbox::Patch`), the structured `root_disk:` (`Microsandbox::RootDisk`),
|
|
31
|
+
`from_snapshot:`, `cmd:`, `detached:`, `idle_timeout`/`max_duration`, and more —
|
|
32
|
+
is documented in [surface.md](surface.md) and typed in
|
|
33
|
+
[`sig/microsandbox.rbs`](../sig/microsandbox.rbs).
|
|
34
|
+
|
|
35
|
+
## Network policy
|
|
36
|
+
|
|
37
|
+
`network:` takes composable profiles (`:public`, `:private`, `:host`, `:none`)
|
|
38
|
+
or a custom `Microsandbox::NetworkPolicy` with per-rule CIDR/IP/domain/suffix/
|
|
39
|
+
group allow-deny rules, per-direction defaults, DNS and TLS-interception
|
|
40
|
+
settings, IPv4/IPv6 pools, `max_connections`, and `trust_host_cas`.
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
Microsandbox::Sandbox.create("worker", image: "python",
|
|
44
|
+
network: Microsandbox::NetworkPolicy.custom(default_egress: :deny,
|
|
45
|
+
rules: [{ action: :allow, direction: :egress, protocol: :tcp, port: 443,
|
|
46
|
+
destination: Microsandbox::Destination.domain("api.example.com") }]))
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Strict hostname policy
|
|
50
|
+
|
|
51
|
+
`strict: true` (runtime `v0.6.18`) makes a hostname-rule allow fail closed
|
|
52
|
+
unless the runtime can actually see the request authority (plain-HTTP `Host`,
|
|
53
|
+
or SNI/`:authority` under TLS interception); a bypassed or non-intercepted
|
|
54
|
+
HTTPS flow allowed only by a hostname rule is then denied before the upstream
|
|
55
|
+
dial. Default `false`; create-only. Independently of `strict:`, any policy with
|
|
56
|
+
domain rules checks plain-HTTP `Host` headers against it.
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
Microsandbox::Sandbox.create("worker", image: "python",
|
|
60
|
+
network: Microsandbox::NetworkPolicy.custom(default_egress: :deny,
|
|
61
|
+
rules: [{ action: :allow, direction: :egress, protocol: :tcp, port: 443,
|
|
62
|
+
destination: Microsandbox::Destination.domain("api.example.com") }]),
|
|
63
|
+
strict: true)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Outbound proxy
|
|
67
|
+
|
|
68
|
+
Route the sandbox's egress through a SOCKS4 (TCP) or SOCKS5 (TCP + non-DNS UDP)
|
|
69
|
+
proxy with `proxy:` (runtime `v0.6.17`). The proxy is dialed by the runtime's
|
|
70
|
+
host-side network stack, so its address is resolved from the host (`127.0.0.1`
|
|
71
|
+
is the host's loopback), and the egress policy (`network:`) still governs which
|
|
72
|
+
destinations may be reached. A SOCKS5 password comes from a host environment
|
|
73
|
+
variable via `SecretSource.env` — only the variable's *name* is handed to the
|
|
74
|
+
runtime. Local backend only.
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
Microsandbox::Sandbox.create("worker", image: "python",
|
|
78
|
+
proxy: Microsandbox::OutboundProxy.socks5("127.0.0.1:1080"))
|
|
79
|
+
|
|
80
|
+
Microsandbox::Sandbox.create("worker", image: "python",
|
|
81
|
+
proxy: Microsandbox::OutboundProxy.socks5("10.0.0.5:1080")
|
|
82
|
+
.credentials("sandbox", Microsandbox::SecretSource.env("PROXY_PASSWORD")))
|
|
83
|
+
|
|
84
|
+
Microsandbox::Sandbox.create("worker", image: "python",
|
|
85
|
+
proxy: Microsandbox::OutboundProxy.socks4("127.0.0.1:1080", user_id: "ci"))
|
|
86
|
+
|
|
87
|
+
# The equivalent plain Hash works too:
|
|
88
|
+
Microsandbox::Sandbox.create("worker", image: "python",
|
|
89
|
+
proxy: { protocol: :socks5, address: "10.0.0.5:1080",
|
|
90
|
+
credentials: { username: "sandbox", password: { env: "PROXY_PASSWORD" } } })
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Secrets
|
|
94
|
+
|
|
95
|
+
`secrets:` gives the guest a placeholder and substitutes the real value only in
|
|
96
|
+
requests to the hosts you allow, with multi-host / wildcard allow-lists,
|
|
97
|
+
injection toggles, and a per-secret or sandbox-level violation policy. At
|
|
98
|
+
create time it is an **Array of specs**: `env:` is the variable the guest sees
|
|
99
|
+
(holding the placeholder), `value:` is the real secret, and `host:` / `hosts:` /
|
|
100
|
+
`host_patterns:` is the allow-list.
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
Microsandbox::Sandbox.create("worker", image: "python",
|
|
104
|
+
secrets: [{ env: "API_KEY", value: ENV.fetch("HOST_API_KEY"), host: "api.example.com" }])
|
|
105
|
+
# inside the guest: $API_KEY is "$MSB_API_KEY"; the proxy swaps in the real
|
|
106
|
+
# value for requests to api.example.com only
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
An inline `value:` is part of the sandbox's saved configuration: it is stored
|
|
110
|
+
in cleartext in the local catalog under the runtime home (`~/.microsandbox` by
|
|
111
|
+
default) until the sandbox is removed, and `SandboxHandle#config` returns it.
|
|
112
|
+
Stopping a sandbox does not remove it. Use `ephemeral: true` for short-lived
|
|
113
|
+
work, remove sandboxes you no longer need, and do not log `#config`.
|
|
114
|
+
|
|
115
|
+
Rotating an existing secret on a running sandbox is a live `modify`, which
|
|
116
|
+
takes a **different shape** — a Hash keyed by name, where `env:` names a *host*
|
|
117
|
+
environment variable to read the new value from. See
|
|
118
|
+
[observability.md](observability.md#live-modification-and-health).
|
|
119
|
+
|
|
120
|
+
## Volumes and mounts
|
|
121
|
+
|
|
122
|
+
Persistent storage that outlives individual sandboxes:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
Microsandbox::Volume.create("cache", kind: "disk", size_mib: 512)
|
|
126
|
+
Microsandbox::Volume.list # => [Microsandbox::VolumeInfo, ...]
|
|
127
|
+
|
|
128
|
+
Microsandbox::Sandbox.create("with-vol", image: "public.ecr.aws/docker/library/alpine:latest",
|
|
129
|
+
volumes: { "/data" => { named: "cache" } }) do |sb|
|
|
130
|
+
sb.fs.write("/data/state.txt", "persisted")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
Microsandbox::Volume.remove("cache")
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`volumes:` accepts a host path String (bind mount) or `{ bind: "/host" }` /
|
|
137
|
+
`{ named: "volume-name" }` per guest path (tmpfs and disk mounts, mount
|
|
138
|
+
policies, and a per-bind-mount `quota_mib:` are supported too). A bind or
|
|
139
|
+
named mount may pin the fallback guest owner for host files with `uid:`/`gid:`
|
|
140
|
+
(both required together, runtime `v0.6.15`). Named volumes can also be read and
|
|
141
|
+
written from the host without booting a sandbox via `Volume.fs` /
|
|
142
|
+
`VolumeInfo#fs`.
|
|
143
|
+
|
|
144
|
+
A relative host path (`volumes: { "/data" => "./data" }`, `image: "./rootfs"`,
|
|
145
|
+
a `root_disk:` image, a `Patch.copy_file` source, a `tls:` certificate, a
|
|
146
|
+
path-form `from_snapshot:`) is resolved **once, when the sandbox is created**,
|
|
147
|
+
against the creating process's working directory, and stored as an absolute
|
|
148
|
+
path. Starting the sandbox later from another directory mounts the same host
|
|
149
|
+
directory. Resolution does not follow symlinks. Sandboxes created by gem
|
|
150
|
+
versions before `0.17.1` kept the relative path and resolved it again on every
|
|
151
|
+
start ([GHSA-mcw9-9rf6-p4rm](https://github.com/superradcompany/microsandbox/security/advisories/GHSA-mcw9-9rf6-p4rm));
|
|
152
|
+
recreate those. On a cloud backend, host paths are sent as written.
|
|
153
|
+
|
|
154
|
+
Boot from a snapshot with `Sandbox.create(name, from_snapshot: "snap-name-or-path")`.
|
data/docs/errors.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Error handling
|
|
2
|
+
|
|
3
|
+
All errors descend from `Microsandbox::Error` (itself a `StandardError`) and
|
|
4
|
+
carry a stable, machine-readable `#code`. The native layer raises the matching
|
|
5
|
+
subclass for each core error variant; unmapped variants surface as the base
|
|
6
|
+
`Error`. The hierarchy is flat, mirroring the Python SDK.
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
begin
|
|
10
|
+
Microsandbox::Sandbox.create("dup", image: "public.ecr.aws/docker/library/alpine:latest")
|
|
11
|
+
Microsandbox::Sandbox.create("dup", image: "public.ecr.aws/docker/library/alpine:latest") # name clash
|
|
12
|
+
rescue Microsandbox::SandboxAlreadyExistsError => e
|
|
13
|
+
warn "#{e.code}: #{e.message}" # => "sandbox-already-exists: ..."
|
|
14
|
+
rescue Microsandbox::Error => e
|
|
15
|
+
warn "microsandbox failed: #{e.message}"
|
|
16
|
+
end
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`Microsandbox::SomeError.code` returns the code for a class without an instance.
|
|
20
|
+
Ruby-side keyword validation (a malformed `secrets:` spec, mutually exclusive
|
|
21
|
+
options) raises a plain `ArgumentError` before the native layer is reached.
|
|
22
|
+
|
|
23
|
+
## Classes and codes
|
|
24
|
+
|
|
25
|
+
| Class | `#code` | Raised when |
|
|
26
|
+
|-------|---------|-------------|
|
|
27
|
+
| `Error` | `microsandbox-error` | Base class; unmapped core errors |
|
|
28
|
+
| `InvalidConfigError` | `invalid-config` | Bad create options, invalid backend/cloud configuration |
|
|
29
|
+
| `SandboxNotFoundError` | `sandbox-not-found` | No sandbox with that name |
|
|
30
|
+
| `SandboxNotRunningError` | `sandbox-not-running` | Operation needs a running guest (`ping`/`touch`/`exec` on a stopped sandbox) |
|
|
31
|
+
| `SandboxAlreadyExistsError` | `sandbox-already-exists` | Name clash on `create` without `replace:` |
|
|
32
|
+
| `SandboxStillRunningError` | `sandbox-still-running` | `remove` on a running sandbox |
|
|
33
|
+
| `SandboxReplacedError` | `sandbox-replaced` | The identity a handle was bound to no longer owns the name (see [lifecycle.md](lifecycle.md#convergent-lifecycle)) |
|
|
34
|
+
| `ExecTimeoutError` | `exec-timeout` | `exec(timeout:)` / `shell(timeout:)` deadline hit |
|
|
35
|
+
| `ExecFailedError` | `exec-failed` | Spawn-time failure (e.g. command not found) — a non-zero exit is **not** an error |
|
|
36
|
+
| `NoDefaultCommandError` | `no-default-command` | `exec_default` / `attach_default` on an image with no runnable `ENTRYPOINT`+`CMD` |
|
|
37
|
+
| `FilesystemError` | `filesystem-error` | Guest filesystem I/O failure |
|
|
38
|
+
| `PathNotFoundError` | `path-not-found` | Reserved for parity; the native layer currently reports missing guest paths as `FilesystemError` |
|
|
39
|
+
| `VolumeNotFoundError` | `volume-not-found` | No named volume with that name |
|
|
40
|
+
| `VolumeAlreadyExistsError` | `volume-already-exists` | `Volume.create` name clash |
|
|
41
|
+
| `ImageNotFoundError` | `image-not-found` | Image absent from the cache / registry |
|
|
42
|
+
| `ImageInUseError` | `image-in-use` | `Image.remove` on an image a sandbox still references |
|
|
43
|
+
| `ImagePullFailedError` | `image-pull-failed` | Reserved for parity; not raised by the current native mapping |
|
|
44
|
+
| `SnapshotNotFoundError` | `snapshot-not-found` | No snapshot with that name |
|
|
45
|
+
| `SnapshotAlreadyExistsError` | `snapshot-already-exists` | Snapshot name clash |
|
|
46
|
+
| `SnapshotSandboxRunningError` | `snapshot-sandbox-running` | Snapshot requested from a running source sandbox |
|
|
47
|
+
| `SnapshotImageMissingError` | `snapshot-image-missing` | Restore needs an image that is not cached |
|
|
48
|
+
| `SnapshotIntegrityError` | `snapshot-integrity` | Snapshot payload failed verification |
|
|
49
|
+
| `SnapshotMigrationError` | `snapshot-migration` | Automatic snapshot-descriptor migration was blocked and needs repair |
|
|
50
|
+
| `NetworkPolicyError` | `network-policy-error` | Invalid network policy or `NetworkBuilder` validation failure (an invalid `proxy:` is `InvalidConfigError`) |
|
|
51
|
+
| `SecretViolationError` | `secret-violation` | Reserved for parity; not raised by the current native mapping |
|
|
52
|
+
| `TlsError` | `tls-error` | Reserved for parity; not raised by the current native mapping |
|
|
53
|
+
| `IoError` | `io-error` | Host-side I/O failure |
|
|
54
|
+
| `MetricsDisabledError` | `metrics-disabled` | Metrics turned off for this sandbox |
|
|
55
|
+
| `MetricsUnavailableError` | `metrics-unavailable` | Reserved for parity; not raised at the pinned runtime. The transient "no live metrics slot" window right after boot raises the base `Microsandbox::Error` — match on the message |
|
|
56
|
+
| `UnsupportedOperationError` | `unsupported-operation` | The runtime version does not support the operation |
|
|
57
|
+
| `CloudHttpError` | `cloud-http` | Cloud backend HTTP failure |
|
|
58
|
+
| `UnsupportedError` | `unsupported` | The selected backend does not implement the operation; carries `#operation` (e.g. `"sandbox.kill"`) and `#hint` |
|
|
59
|
+
|
|
60
|
+
The snapshot classes go beyond the Python SDK (which collapses them into its
|
|
61
|
+
base error) and match the Go SDK's per-variant coverage, so callers can rescue
|
|
62
|
+
a missing / duplicate / running-source / missing-image / corrupt snapshot
|
|
63
|
+
specifically.
|
|
64
|
+
|
|
65
|
+
The authoritative list is `lib/microsandbox/errors.rb`; the core-variant to
|
|
66
|
+
class mapping lives in `ext/microsandbox/src/error.rs`, and every class is
|
|
67
|
+
declared in [`sig/microsandbox.rbs`](../sig/microsandbox.rbs). Classes marked
|
|
68
|
+
"reserved" exist so callers can rescue them today without a code change once
|
|
69
|
+
the native layer maps a core variant to them.
|
|
70
|
+
|
|
71
|
+
## Secrets are redacted
|
|
72
|
+
|
|
73
|
+
The native layer redacts secret values from error messages (since `0.8.2`),
|
|
74
|
+
and a rejected proxy credential is reported by kind only, never by value.
|
data/docs/execution.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Executing commands
|
|
2
|
+
|
|
3
|
+
## `exec` and `shell`
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
Microsandbox::Sandbox.create("exec-demo", image: "public.ecr.aws/docker/library/alpine:latest") do |sb|
|
|
7
|
+
# Direct command (no shell)
|
|
8
|
+
out = sb.exec("ls", ["-la", "/etc"], cwd: "/", timeout: 30)
|
|
9
|
+
out.exit_code # => 0
|
|
10
|
+
out.success? # => true
|
|
11
|
+
out.stdout # => "..." (UTF-8)
|
|
12
|
+
out.stderr_bytes # => raw ASCII-8BIT bytes
|
|
13
|
+
|
|
14
|
+
# Shell script (pipes, redirects, &&)
|
|
15
|
+
sb.shell("cat /etc/os-release | grep VERSION").stdout
|
|
16
|
+
|
|
17
|
+
# Environment, stdin, working directory
|
|
18
|
+
sb.exec("cat", [], stdin: "piped data")
|
|
19
|
+
sb.exec("sh", ["-c", "echo $GREETING"], env: { "GREETING" => "hi" })
|
|
20
|
+
end
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
A non-zero exit is **not** an error — inspect `exit_code`/`success?`. Spawn-time
|
|
24
|
+
failures (e.g. command not found) and timeouts raise typed errors
|
|
25
|
+
([errors.md](errors.md)). `exec`/`shell` also take per-call `rlimits`.
|
|
26
|
+
|
|
27
|
+
## Default workload
|
|
28
|
+
|
|
29
|
+
`create` is strictly boot-only (runtime `v0.6.9`): it never runs the image's
|
|
30
|
+
`ENTRYPOINT`/`CMD`. Execute the image's own command explicitly:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
Microsandbox::Sandbox.create("worker", image: "example/worker:latest",
|
|
34
|
+
cmd: ["worker.py", "--once"]) do |sb| # cmd: overrides the durable image CMD
|
|
35
|
+
out = sb.exec_default(timeout: 300) # buffered; exec-style options
|
|
36
|
+
handle = sb.exec_default_stream # or streaming (returns an ExecHandle)
|
|
37
|
+
sb.attach_default # or interactive (host TTY)
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
An image whose entrypoint and CMD resolve to no executable command raises
|
|
42
|
+
`Microsandbox::NoDefaultCommandError`.
|
|
43
|
+
|
|
44
|
+
## Streaming output
|
|
45
|
+
|
|
46
|
+
For long-running commands, stream events as they arrive instead of waiting:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
Microsandbox::Sandbox.create("stream", image: "public.ecr.aws/docker/library/python:3-slim") do |sb|
|
|
50
|
+
handle = sb.exec_stream("python", ["-u", "-c", "import time\nfor i in range(3): print(i); time.sleep(1)"])
|
|
51
|
+
handle.each do |event| # ExecHandle is Enumerable
|
|
52
|
+
print event.text if event.stdout?
|
|
53
|
+
end
|
|
54
|
+
# or: out = handle.collect → ExecOutput (drain to the end)
|
|
55
|
+
# interactive stdin — create the stream with stdin: :pipe to get a writable sink:
|
|
56
|
+
# h = sb.exec_stream("cat", [], stdin: :pipe)
|
|
57
|
+
# sink = h.stdin; sink.write("data\n"); sink.close # close sends EOF
|
|
58
|
+
# control: handle.signal(15), handle.kill, handle.resize(rows, cols)
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`shell_stream` is the shell-script counterpart. For a host-TTY-coupled
|
|
63
|
+
interactive session (raw mode + SIGWINCH), use `attach`/`attach_shell`.
|
|
64
|
+
|
|
65
|
+
### Streams are single-pass
|
|
66
|
+
|
|
67
|
+
`ExecHandle`, `LogStream`, `MetricsStream`, `FsReadStream`, `PullSession`, and
|
|
68
|
+
`AgentStream` are `Enumerable`, but `each` drains a one-shot native channel —
|
|
69
|
+
they are forward-only, not rewindable, and meant for a single consumer. Iterate
|
|
70
|
+
(or `collect`/`read`) exactly once: a second `each`, or a combinator after a
|
|
71
|
+
partial drain (`count` then `each`, `to_a` twice), silently yields nothing.
|
|
72
|
+
Don't share one handle across threads.
|
|
73
|
+
|
|
74
|
+
## Threads, the GVL, and timeouts
|
|
75
|
+
|
|
76
|
+
The GVL is released during sandbox calls, so *other* Ruby threads keep
|
|
77
|
+
running. The *calling* thread blocks uninterruptibly until the call returns:
|
|
78
|
+
`Timeout::timeout`, `Thread#kill`, and Ctrl-C cannot interrupt a blocked native
|
|
79
|
+
call. Bound long-running work with a real deadline where one exists:
|
|
80
|
+
|
|
81
|
+
- `exec(timeout:)` / `shell(timeout:)` kill the guest command after N seconds.
|
|
82
|
+
- `AgentClient.connect_sandbox` / `connect_path` take a `timeout:` that bounds
|
|
83
|
+
only the connect handshake.
|
|
84
|
+
- The streaming paths and `AgentClient#request`/`#stream` have no timeout knob
|
|
85
|
+
and can block indefinitely if the guest wedges.
|
|
86
|
+
|
|
87
|
+
See [DESIGN.md](../DESIGN.md#sync-not-async) for the sync-over-async bridge
|
|
88
|
+
behind this.
|
|
89
|
+
|
|
90
|
+
## SSH and SFTP
|
|
91
|
+
|
|
92
|
+
`Sandbox#ssh` opens an in-process SSH client to the guest (`SshClient`, with an
|
|
93
|
+
`SftpClient` for file transfer) or prepares an `SshServer`. `open_client` /
|
|
94
|
+
`prepare_server` accept `inactivity_timeout:` in seconds (`0` disables, `nil`
|
|
95
|
+
inherits the 600s global default).
|
|
96
|
+
|
|
97
|
+
## Raw agent client
|
|
98
|
+
|
|
99
|
+
`Microsandbox::AgentClient` gives byte-level access to the guest `agentd`
|
|
100
|
+
protocol (`AgentStream`/`AgentFrame`) for protocol-level work the higher-level
|
|
101
|
+
API doesn't cover.
|
data/docs/filesystem.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Guest filesystem
|
|
2
|
+
|
|
3
|
+
`Sandbox#fs` is a `Microsandbox::FS` bound to the running guest.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
Microsandbox::Sandbox.create("fs-demo", image: "public.ecr.aws/docker/library/alpine:latest") do |sb|
|
|
7
|
+
sb.fs.write("/tmp/data.txt", "hello")
|
|
8
|
+
sb.fs.read_text("/tmp/data.txt") # => "hello" (UTF-8)
|
|
9
|
+
sb.fs.read("/tmp/data.txt") # => raw bytes (ASCII-8BIT)
|
|
10
|
+
sb.fs.exists?("/tmp/data.txt") # => true
|
|
11
|
+
|
|
12
|
+
sb.fs.mkdir("/tmp/sub")
|
|
13
|
+
sb.fs.copy("/tmp/data.txt", "/tmp/sub/copy.txt")
|
|
14
|
+
sb.fs.rename("/tmp/sub/copy.txt", "/tmp/sub/renamed.txt")
|
|
15
|
+
sb.fs.list("/tmp/sub") # => [Microsandbox::FsEntry, ...]
|
|
16
|
+
sb.fs.stat("/tmp/data.txt") # => Microsandbox::FsMetadata
|
|
17
|
+
|
|
18
|
+
# Host <-> guest copies
|
|
19
|
+
sb.fs.copy_from_host("./local.txt", "/tmp/local.txt")
|
|
20
|
+
sb.fs.copy_to_host("/tmp/out.txt", "./out.txt")
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Streaming reads and writes
|
|
25
|
+
|
|
26
|
+
Large files stream instead of buffering: `fs.read_stream(path)` returns a
|
|
27
|
+
single-pass `FsReadStream` (see
|
|
28
|
+
[execution.md](execution.md#streams-are-single-pass)), and
|
|
29
|
+
`fs.write_stream(path)` returns an `FsWriteSink` to write into and close.
|
|
30
|
+
|
|
31
|
+
## Errors
|
|
32
|
+
|
|
33
|
+
Guest filesystem failures, including missing paths, raise
|
|
34
|
+
`Microsandbox::FilesystemError` (`#code` `filesystem-error`); the message
|
|
35
|
+
carries the guest-side detail. See [errors.md](errors.md).
|
|
36
|
+
|
|
37
|
+
## Host-side volume access
|
|
38
|
+
|
|
39
|
+
Named volumes can be read and written from the host without a running sandbox
|
|
40
|
+
through `Microsandbox::Volume.fs(name)` / `VolumeInfo#fs` (a `VolumeFs`). See
|
|
41
|
+
[configuration.md](configuration.md#volumes-and-mounts).
|
|
42
|
+
|
|
43
|
+
Paths are relative to the volume root; a leading `/` is the volume root, not the
|
|
44
|
+
host root. A path containing a `..` segment is refused with `FilesystemError` in
|
|
45
|
+
every operation.
|
data/docs/images.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Images, registries, and snapshots
|
|
2
|
+
|
|
3
|
+
## The local image cache
|
|
4
|
+
|
|
5
|
+
Images are pulled automatically on `create` (governed by `pull_policy:`). The
|
|
6
|
+
cache itself is managed through `Microsandbox::Image`:
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
Microsandbox::Image.list # => [Microsandbox::ImageInfo, ...]
|
|
10
|
+
Microsandbox::Image.get("public.ecr.aws/docker/library/alpine:latest") # => Microsandbox::ImageInfo
|
|
11
|
+
Microsandbox::Image.inspect("public.ecr.aws/docker/library/alpine:latest").layers # => [{...}, ...]
|
|
12
|
+
Microsandbox::Image.remove("public.ecr.aws/docker/library/alpine:latest", force: true)
|
|
13
|
+
report = Microsandbox::Image.prune
|
|
14
|
+
report.bytes_reclaimed
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`Image.save`/`Image.load` move images through OCI archives, and
|
|
18
|
+
`Sandbox.create_with_progress` returns a `PullSession` that streams pull
|
|
19
|
+
progress before yielding the booted sandbox.
|
|
20
|
+
|
|
21
|
+
## Why the examples use `public.ecr.aws/docker/library/...`
|
|
22
|
+
|
|
23
|
+
Anonymous **Docker Hub** pulls are rate-limited and often fail with
|
|
24
|
+
`registry error: Not authorized`. The examples pull from AWS's public mirror of
|
|
25
|
+
the Docker Library instead. Plain short names like `image: "python"` work too if
|
|
26
|
+
you aren't rate-limited; for authenticated Docker Hub, pass `registry_auth:`.
|
|
27
|
+
|
|
28
|
+
## Private & authenticated registries
|
|
29
|
+
|
|
30
|
+
For a private registry — or to lift Docker Hub's anonymous rate limit — pass
|
|
31
|
+
`registry_auth:` with a username and a password or token:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
Microsandbox::Sandbox.create(
|
|
35
|
+
"private",
|
|
36
|
+
image: "registry.example.com/team/app:latest",
|
|
37
|
+
registry_auth: { username: "ci-bot", password: ENV.fetch("REGISTRY_TOKEN") }
|
|
38
|
+
) do |sb|
|
|
39
|
+
# ...
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
For self-hosted registries you can also reach the registry over plain HTTP and
|
|
44
|
+
trust a private CA:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
Microsandbox::Sandbox.create(
|
|
48
|
+
"internal",
|
|
49
|
+
image: "registry.internal:5000/app:latest",
|
|
50
|
+
registry_insecure: true, # plain HTTP instead of HTTPS
|
|
51
|
+
registry_ca_certs: File.read("/etc/pki/internal-ca.pem") # String or Array of PEMs
|
|
52
|
+
)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Without `registry_auth:`, the core's default credential resolution still applies
|
|
56
|
+
(OS keyring, global config, and `~/.docker/config.json`), so an existing
|
|
57
|
+
`docker login` is honored automatically.
|
|
58
|
+
|
|
59
|
+
## Snapshots
|
|
60
|
+
|
|
61
|
+
`Microsandbox::Snapshot` covers create / open / get / list / list_dir / reindex /
|
|
62
|
+
remove / verify / save / load, and `Sandbox.create(name, from_snapshot: ...)` boots
|
|
63
|
+
from one. Snapshot failures raise the typed `Snapshot*Error` classes listed in
|
|
64
|
+
[errors.md](errors.md). Payload integrity is opt-in (`record_integrity:`), and
|
|
65
|
+
`verify` can report `:not_recorded` for snapshots taken without it.
|
data/docs/lifecycle.md
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# Sandbox lifecycle
|
|
2
|
+
|
|
3
|
+
`Sandbox.create` boots a microVM and returns a live `Microsandbox::Sandbox`.
|
|
4
|
+
`Sandbox.get`/`Sandbox.list` return a controllable `Microsandbox::SandboxHandle`
|
|
5
|
+
for sandboxes that already exist. The live object owns the coarse controls
|
|
6
|
+
(`stop`/`kill`/`drain`/`wait`); the handle owns the fine-grained ones
|
|
7
|
+
(`stop_with_timeout`, `request_*`, `wait_until_stopped`, `config`, `snapshot`).
|
|
8
|
+
|
|
9
|
+
## Create, stop, inspect
|
|
10
|
+
|
|
11
|
+
Stopping is not removing. The block form **stops** the sandbox when the block
|
|
12
|
+
returns; the stopped sandbox keeps its name, its saved configuration and its
|
|
13
|
+
disk state in the catalog until you remove it, and creating another sandbox
|
|
14
|
+
with the same name raises `SandboxAlreadyExistsError`. Three ways to finish:
|
|
15
|
+
|
|
16
|
+
- `ephemeral: true` at create — removed as soon as it stops; right for one-off
|
|
17
|
+
work and for code that runs repeatedly under a fixed name.
|
|
18
|
+
- `sb.destroy` (or `Sandbox.remove(name)` once stopped) — remove explicitly.
|
|
19
|
+
- Leave it stopped and bring it back later with `Sandbox.start(name)`.
|
|
20
|
+
|
|
21
|
+
Block forms used in a loop without one of the first two accumulate stopped
|
|
22
|
+
sandboxes (and their disk usage) indefinitely.
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# Block form — recommended; stops the sandbox automatically (even on error)
|
|
26
|
+
Microsandbox::Sandbox.create("box", image: "public.ecr.aws/docker/library/alpine:latest") do |sb|
|
|
27
|
+
# ...
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Manual form — you are responsible for stopping it
|
|
31
|
+
sb = Microsandbox::Sandbox.create("box", image: "public.ecr.aws/docker/library/alpine:latest")
|
|
32
|
+
begin
|
|
33
|
+
# ...
|
|
34
|
+
ensure
|
|
35
|
+
sb.stop # graceful (SIGTERM→SIGKILL escalation, 10s default)
|
|
36
|
+
# sb.stop_and_wait # graceful, then wait → ExitStatus(#exit_code, #success?)
|
|
37
|
+
# sb.kill # force (SIGKILL); sb.drain for a graceful drain
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Inspect / manage existing sandboxes. `list` returns a cursor-paginated,
|
|
41
|
+
# Enumerable SandboxPage of handles.
|
|
42
|
+
Microsandbox::Sandbox.list # => Microsandbox::SandboxPage (first page)
|
|
43
|
+
Microsandbox::Sandbox.list.map(&:name) # enumerate the page's handles
|
|
44
|
+
# next page: Sandbox.list_with(cursor: page.next_cursor, limit: 50)
|
|
45
|
+
h = Microsandbox::Sandbox.get("box") # => Microsandbox::SandboxHandle
|
|
46
|
+
h.status # :running, :stopped, :created, ...
|
|
47
|
+
h.stop_with_timeout(5) # custom escalation timeout
|
|
48
|
+
h.request_stop # fire-and-return; pair with #wait_until_stopped
|
|
49
|
+
h.request_kill
|
|
50
|
+
h.request_drain
|
|
51
|
+
h.wait_until_stopped # => Microsandbox::SandboxStopResult
|
|
52
|
+
Microsandbox::Sandbox.start("box") # restart a stopped sandbox
|
|
53
|
+
Microsandbox::Sandbox.remove("box") # remove a stopped sandbox
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`Sandbox.create` is strictly boot-only: it never runs the image's
|
|
57
|
+
`ENTRYPOINT`/`CMD`. See [execution.md](execution.md#default-workload) for
|
|
58
|
+
`exec_default`.
|
|
59
|
+
|
|
60
|
+
## Convergent lifecycle
|
|
61
|
+
|
|
62
|
+
Idempotent operations that take a name to the state you want, whatever state
|
|
63
|
+
it is in now (runtime `v0.6.16`):
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
# Create it, or connect to (and start) the one that is already there. The
|
|
67
|
+
# keyword options are `create`'s and apply only when a create actually happens.
|
|
68
|
+
sb = Microsandbox::Sandbox.connect_or_create("box", image: "public.ecr.aws/docker/library/alpine:latest")
|
|
69
|
+
|
|
70
|
+
sb.id # opaque identity of the *persisted* sandbox — unlike
|
|
71
|
+
# the reusable name, it changes on remove+recreate
|
|
72
|
+
sb.wait_for_status(:running) # => SandboxHandle (no built-in timeout)
|
|
73
|
+
sb = sb.restart # stop + start; => a new live Sandbox
|
|
74
|
+
sb.destroy # stop + remove, in one step
|
|
75
|
+
|
|
76
|
+
h = Microsandbox::Sandbox.get("box")
|
|
77
|
+
h.connect_or_start # connect if running, start if not => Sandbox
|
|
78
|
+
h.restart(force: true, timeout: 5)
|
|
79
|
+
h.destroy
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Everything that acts on an *existing* receiver — `wait_for_status`, `restart`,
|
|
83
|
+
`destroy`, `connect_or_start` — compares the identity it was bound to against
|
|
84
|
+
the name's current owner first, raising `Microsandbox::SandboxReplacedError`
|
|
85
|
+
rather than touching a sandbox someone else recreated under the same name.
|
|
86
|
+
(`connect_or_create` is the entry point, so it has no prior identity to check:
|
|
87
|
+
it simply converges on whichever sandbox now owns the name.)
|
|
88
|
+
|
|
89
|
+
`connect_or_create`'s block form stops the sandbox only when the call owns its
|
|
90
|
+
lifecycle — i.e. when it created it attached. A sandbox it merely connected to,
|
|
91
|
+
or started with `detached: true`, is left running. And the stop it does issue is
|
|
92
|
+
scoped to that sandbox's `id`, so a name removed and recreated while the block
|
|
93
|
+
ran cannot be taken down by the teardown either. `Sandbox#owns_lifecycle?`
|
|
94
|
+
reports which case you are in; `Sandbox#detach` hands the VM off so it
|
|
95
|
+
outlives the block.
|
|
96
|
+
|
|
97
|
+
## The live `Sandbox` / `SandboxHandle` split
|
|
98
|
+
|
|
99
|
+
Upstream `v0.5.8` split the lifecycle into the live `Sandbox` and a
|
|
100
|
+
controllable `SandboxHandle`, and the gem mirrors it. The live
|
|
101
|
+
`Sandbox#stop`/`#kill` do not take a `timeout:`; `#request_stop`/
|
|
102
|
+
`#request_kill`/`#request_drain`/`#wait_until_stopped` and a custom stop timeout
|
|
103
|
+
live on the `SandboxHandle` from `Sandbox.get`. `Sandbox.get`/`.list` return a
|
|
104
|
+
`SandboxHandle` (formerly a read-only `SandboxInfo`, kept as a deprecated
|
|
105
|
+
alias).
|
|
106
|
+
|
|
107
|
+
`SandboxHandle` also carries `#ping`/`#touch`/`#modify` — see
|
|
108
|
+
[observability.md](observability.md).
|