diff --git a/cli/README.md b/cli/README.md index 441d6b7a1..758571bb2 100644 --- a/cli/README.md +++ b/cli/README.md @@ -303,6 +303,7 @@ The main command groups are: - `osb command`: command execution and persistent sessions - `osb file`: file and directory operations - `osb egress`: runtime egress policy +- `osb credential-vault`: manage the Credential Vault - `osb diagnostics`: stable diagnostics logs and events - `osb devops`: experimental legacy diagnostics - `osb config`: local CLI configuration @@ -327,6 +328,7 @@ Bundled skills: - `command-execution` - `file-operations` - `network-egress` +- `credential-vault` - `sandbox-troubleshooting` Supported targets: diff --git a/cli/src/opensandbox_cli/commands/command.py b/cli/src/opensandbox_cli/commands/command.py index e95ce6b32..7801cde07 100644 --- a/cli/src/opensandbox_cli/commands/command.py +++ b/cli/src/opensandbox_cli/commands/command.py @@ -131,7 +131,13 @@ def _handle_execution_error(obj: ClientContext, execution) -> None: @click.option("-d", "--background", is_flag=True, default=False, help="Run in background.") @click.option("-w", "--workdir", default=None, help="Working directory.") @click.option("-t", "--timeout", type=DURATION, default=None, help="Command timeout (e.g. 30s, 5m).") -@output_option("table", "json", "yaml", "raw") +@output_option( + "table", + "json", + "yaml", + "raw", + help_text="Output format. Foreground mode: raw only (default). Background mode: table, json, or yaml.", +) @click.pass_obj @handle_errors def command_run( diff --git a/cli/src/opensandbox_cli/commands/sandbox.py b/cli/src/opensandbox_cli/commands/sandbox.py index 4053261fb..1a8d773af 100644 --- a/cli/src/opensandbox_cli/commands/sandbox.py +++ b/cli/src/opensandbox_cli/commands/sandbox.py @@ -51,9 +51,6 @@ def sandbox_group(ctx: click.Context) -> None: click.echo(ctx.get_help()) -# Alias: osb sb ... -sandbox_group.name = "sandbox" - _SANDBOX_STATE_CANONICAL = { state.lower(): state for state in SandboxState.values() } diff --git a/cli/src/opensandbox_cli/commands/skills.py b/cli/src/opensandbox_cli/commands/skills.py index 43d858c12..6b36abbc8 100644 --- a/cli/src/opensandbox_cli/commands/skills.py +++ b/cli/src/opensandbox_cli/commands/skills.py @@ -184,6 +184,7 @@ class TargetConfig(TypedDict): "command-execution": "Execution", "file-operations": "Files", "network-egress": "Network", + "credential-vault": "Credentials", "sandbox-troubleshooting": "Troubleshooting", } @@ -418,9 +419,6 @@ def _install_guidance_text() -> str: " osb skills install --target --scope \n\n" " Install all bundled skills for one tool:\n" " osb skills install --all-builtins --target --scope \n\n" - " Discover skills and targets:\n" - " osb skills list\n" - " osb skills show \n\n" f" Available skills: {', '.join(_ALL_SKILL_NAMES)}\n" f" Available targets: {', '.join(_ALL_TARGET_NAMES)}\n" f" Available scopes: {', '.join(_ALL_SCOPE_NAMES)}" diff --git a/cli/src/opensandbox_cli/utils.py b/cli/src/opensandbox_cli/utils.py index 35ebad0a9..d60f8033c 100644 --- a/cli/src/opensandbox_cli/utils.py +++ b/cli/src/opensandbox_cli/utils.py @@ -148,7 +148,7 @@ def select_output_format( allowed: tuple[str, ...], fallback: str, ) -> str: - """Resolve a command-scoped output format from explicit input, config, and fallback.""" + """Resolve a command-scoped output format from explicit input and fallback.""" if requested: if requested not in allowed: allowed_list = ", ".join(allowed) diff --git a/cli/tests/test_commands.py b/cli/tests/test_commands.py index 462b561a4..aebea7bd9 100644 --- a/cli/tests/test_commands.py +++ b/cli/tests/test_commands.py @@ -72,7 +72,7 @@ def _make_output(fmt: str) -> OutputFormatter: ctx.make_output.side_effect = _make_output ctx.get_manager.return_value = manager or MagicMock() ctx.connect_sandbox.return_value = sandbox or MagicMock() - ctx.resolve_sandbox_id.side_effect = lambda prefix: prefix # passthrough + ctx.resolve_sandbox_id.side_effect = lambda prefix: prefix ctx.connection_config = MagicMock() ctx.close = MagicMock() return ctx diff --git a/components/egress/Dockerfile b/components/egress/Dockerfile index 28ec0e10e..00aae98d4 100644 --- a/components/egress/Dockerfile +++ b/components/egress/Dockerfile @@ -124,10 +124,13 @@ RUN chown mitmproxy:mitmproxy /var/lib/mitmproxy/.mitmproxy/config.yaml \ COPY --from=builder /out/egress /opt/opensandbox-egress/egress COPY --from=builder /out/opensandbox-supervisor /opt/opensandbox-egress/supervisor # Pre-start hook: reap any mitmdump left over from a previous crashed -# egress so the new launch can bind the transparent-MITM listen port. -# Intentionally does NOT touch iptables/nft rules — the sidecar shares -# a network namespace with the workload, so leaving rules in place keeps -# egress filtering active across the supervisor's backoff window. +# egress so the new launch can bind the transparent-MITM listen port, and +# remove stale redirect rules (iptables DNS 53 redirect, transparent-MITM +# REDIRECT, and the opensandbox_dns_redirect nft table) left pointing at the +# dead processes. It intentionally leaves the `inet opensandbox` policy +# table untouched — the sidecar shares a network namespace with the +# workload, so leaving the filter rules in place keeps egress enforcement +# active across the supervisor's backoff window. COPY components/egress/scripts/cleanup.sh /opt/opensandbox-egress/cleanup.sh RUN chmod 0755 /opt/opensandbox-egress/cleanup.sh \ /opt/opensandbox-egress/egress \ diff --git a/components/egress/docs/opentelemetry.md b/components/egress/docs/opentelemetry.md index a7830def8..565ffa58f 100644 --- a/components/egress/docs/opentelemetry.md +++ b/components/egress/docs/opentelemetry.md @@ -29,7 +29,7 @@ Do not drop them: the instrument records **seconds**, while the SDK default boun the spec's millisecond ladder (`0, 5, 10, … 10000`), so every realistic latency would fall into the single `le=5` bucket and the quantiles would be meaningless. -The head resolves a cache hit (sub-millisecond) up to one upstream timeout +The head spans a fast single-upstream exchange (sub-millisecond) up to one upstream timeout (`OPENSANDBOX_EGRESS_DNS_UPSTREAM_TIMEOUT`, 5s by default). The coarse tail exists because the recorded duration covers the **whole resolver chain**: forwarding walks the upstreams serially, each with the full timeout, so a query can legitimately take @@ -84,12 +84,11 @@ All egress metrics may include shared attributes: ## OTEL Endpoint Configuration -Metric export is enabled only when at least one OTLP endpoint is set. +Metric export is enabled when an OTLP endpoint is set, or when a node IP is available via `HOST_IP` or `/etc/hostinfo` (metrics are then exported insecurely to `:4318`). - `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` (preferred) - `OTEL_EXPORTER_OTLP_ENDPOINT` (fallback) - -If both are unset, egress keeps metrics local (no OTLP export). +- `HOST_IP` (fallback endpoint host when both OTLP env vars are unset) ### Minimal Example diff --git a/components/egress/nameserver_test.go b/components/egress/nameserver_test.go index 92960be07..1b5c88685 100644 --- a/components/egress/nameserver_test.go +++ b/components/egress/nameserver_test.go @@ -37,7 +37,6 @@ func TestAllowIPsForNft_EmptyResolv(t *testing.T) { func TestAllowIPsForNft_ValidNameservers(t *testing.T) { dir := t.TempDir() resolv := filepath.Join(dir, "resolv.conf") - // Standard resolv.conf with two nameservers content := "nameserver 192.168.65.7\nnameserver 10.0.0.1\n" require.NoError(t, os.WriteFile(resolv, []byte(content), 0644)) ips := AllowIPsForNft(resolv) diff --git a/components/egress/pkg/policy/policy_test.go b/components/egress/pkg/policy/policy_test.go index 92673d171..38fbf233e 100644 --- a/components/egress/pkg/policy/policy_test.go +++ b/components/egress/pkg/policy/policy_test.go @@ -102,7 +102,6 @@ func TestWithExtraAllowIPs(t *testing.T) { require.Len(t, allowV6, 1, "allowV6 length mismatch") require.Equal(t, "2001:db8::1", allowV6[0]) - // nil/empty ips returns same policy require.Same(t, p, p.WithExtraAllowIPs(nil), "WithExtraAllowIPs(nil) should return same policy") require.Same(t, p, p.WithExtraAllowIPs([]netip.Addr{}), "WithExtraAllowIPs([]) should return same policy") } diff --git a/components/egress/pkg/telemetry/metrics.go b/components/egress/pkg/telemetry/metrics.go index 1f4e06e48..666232799 100644 --- a/components/egress/pkg/telemetry/metrics.go +++ b/components/egress/pkg/telemetry/metrics.go @@ -108,7 +108,7 @@ func registerEgressMetrics() error { // boundaries are the spec's millisecond ladder (0, 5, 10, ... 10000), so every // realistic DNS latency lands in the same bucket and the quantiles are noise. // - // The head spans a cache hit (sub-ms) to one upstream timeout + // The head spans a fast single-upstream exchange (sub-ms) to one upstream timeout // (DefaultDNSUpstreamTimeoutSec = 5s). The coarse tail covers the retry chain: // forward() walks the resolvers serially, each with the full timeout, and the // recorded duration is the whole chain — so a query can legitimately take diff --git a/components/egress/policy_server.go b/components/egress/policy_server.go index ac510f3cc..3a42ac163 100644 --- a/components/egress/policy_server.go +++ b/components/egress/policy_server.go @@ -54,7 +54,8 @@ type nftApplier interface { RemoveEnforcement(context.Context) error } -// startPolicyServer: runtime POST/GET /policy, GET /healthz. nameserverIPs are merged into every nft +// startPolicyServer serves the runtime policy API (GET/POST/PUT/PATCH/DELETE /policy, +// /credential-vault* subroutes) and GET /healthz. nameserverIPs are merged into every nft // static apply so the pod’s resolv / private DNS still works alongside user egress rules. func startPolicyServer( proxy policyUpdater, diff --git a/components/egress/policy_utils.go b/components/egress/policy_utils.go index 938c1fbdb..1b695503f 100644 --- a/components/egress/policy_utils.go +++ b/components/egress/policy_utils.go @@ -111,7 +111,8 @@ func removeRulesByTarget(rules []policy.EgressRule, targets []string) (kept, rem return kept, removed } -// mergeKey: domain targets lowercased for dedupe; IP/CIDR left as-is. +// mergeKey lowercases targets for dedupe (IP/CIDR strings are lowered too, +// which is lossless for canonical IP/CIDR forms). func mergeKey(r policy.EgressRule) string { if r.Target == "" { return r.Target diff --git a/components/execd/RELEASE_NOTES.md b/components/execd/RELEASE_NOTES.md index e732438a1..2b7895efb 100644 --- a/components/execd/RELEASE_NOTES.md +++ b/components/execd/RELEASE_NOTES.md @@ -109,8 +109,8 @@ Thanks to these contributors ❤️ - @csdbianhua --- -- Docker Hub: opensandbox/execd:v1.0.9 -- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.9 +- Docker Hub: opensandbox/execd:v1.0.8 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.8 # components/execd 1.0.7 @@ -144,8 +144,8 @@ Thanks to these contributors ❤️ - @dependabot --- -- Docker Hub: opensandbox/execd:v1.0.9 -- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.9 +- Docker Hub: opensandbox/execd:v1.0.7 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.7 # components/execd 1.0.6 @@ -167,8 +167,8 @@ Thanks to these contributors ❤️ - @dependabot --- -- Docker Hub: opensandbox/execd:v1.0.9 -- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.9 +- Docker Hub: opensandbox/execd:v1.0.6 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.6 # components/execd 1.0.5 @@ -185,8 +185,8 @@ Thanks to these contributors ❤️ - @Pangjiping --- -- Docker Hub: opensandbox/execd:v1.0.9 -- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.9 +- Docker Hub: opensandbox/execd:v1.0.5 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.5 # components/execd 1.0.4 @@ -209,8 +209,8 @@ Thanks to these contributors ❤️ - @ninan-nn --- -- Docker Hub: opensandbox/execd:v1.0.9 -- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.9 +- Docker Hub: opensandbox/execd:v1.0.4 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.4 # components/execd 1.0.3 @@ -233,8 +233,8 @@ Thanks to these contributors ❤️ - @jwx0925 --- -- Docker Hub: opensandbox/execd:v1.0.9 -- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.9 +- Docker Hub: opensandbox/execd:v1.0.3 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.3 # components/execd 1.0.2 @@ -262,8 +262,8 @@ Thanks to these contributors ❤️ - @ninan-nn --- -- Docker Hub: opensandbox/execd:v1.0.9 -- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.9 +- Docker Hub: opensandbox/execd:v1.0.2 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.2 # components/execd 1.0.1 @@ -295,8 +295,8 @@ Thanks to these contributors ❤️ - @jwx0925 --- -- Docker Hub: opensandbox/execd:v1.0.9 -- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.9 +- Docker Hub: opensandbox/execd:v1.0.1 +- Aliyun Registry: sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd:v1.0.1 # components/execd 1.0.0 diff --git a/components/execd/docs/network-namespace-analysis.md b/components/execd/docs/network-namespace-analysis.md new file mode 100644 index 000000000..166b5be79 --- /dev/null +++ b/components/execd/docs/network-namespace-analysis.md @@ -0,0 +1,163 @@ +# Network Namespace Isolation Analysis + +## Background + +Analysis of per-session network namespace isolation approaches, comparing the +current execd implementation with Idealab's `ip netns` approach and evaluating +potential improvements. + +## Current State (execd) + +execd uses bwrap's `--unshare-net` as a binary switch: + +```go +if !opts.ShareNet { + argv = append(argv, "--unshare-net") +} +``` + +| ShareNet | Behavior | +|---|---| +| `true` | All sessions share Pod network namespace | +| `false` | Session gets isolated namespace with loopback only | + +**Limitation**: no middle ground. Two sessions in the same Pod cannot both +`bind()` to port 8080 when `share_net=true` (`EADDRINUSE`). When +`share_net=false`, both can bind 8080 but neither is externally reachable. + +## Idealab Approach: ip netns + veth + +Idealab runs inside Kubernetes Pods but creates per-session network namespaces: + +```bash +sudo ip netns exec sandbox86 \ + bash -c 'ip link set eth0 address 02:f3:15:b2:6f:36 && \ + exec unshare --pid --fork --mount-proc -- \ + bash -c "... bwrap (no --unshare-net) ..."' +``` + +Flow: +1. Pre-create network namespace `sandbox86` via `ip netns add` +2. Create veth pair, move one end into the namespace +3. Assign IP and configure routing +4. `ip netns exec sandbox86` enters the namespace +5. Set MAC address for policy identification +6. bwrap inherits the pre-configured network (no `--unshare-net`) + +Each session gets its own IP, so multiple sessions can bind the same port: + +``` +Pod network (eth0) + └─ bridge (br0) + ├─ veth-a (10.0.2.1) → session A :8080 + └─ veth-b (10.0.2.2) → session B :8080 +``` + +Requirements: `CAP_NET_ADMIN`, veth pair management, bridge/routing configuration. + +## Alternative Approaches Evaluated + +### 1. Random Port + Environment Variable + +Allocate a random available port per session, pass via env var: + +``` +session A → SANDBOX_PORT=10234, app listens on $SANDBOX_PORT +session B → SANDBOX_PORT=10567, app listens on $SANDBOX_PORT +``` + +- Pro: zero architecture change, no extra capabilities +- Con: requires application cooperation (cannot hardcode port 8080) +- Con: external routing still needs port mapping + +### 2. slirp4netns + +User-mode networking for isolated network namespaces: + +```bash +bwrap --unshare-net ... & +slirp4netns --configure --api-socket /tmp/slirp.sock $BWRAP_PID tap0 +# Add port forward: host:10234 → namespace:8080 +echo '{"execute":"add_hostfwd","arguments":{ + "proto":"tcp","host_addr":"0.0.0.0", + "host_port":10234,"guest_addr":"10.0.2.100","guest_port":8080 +}}' | nc -U /tmp/slirp.sock +``` + +- Pro: application-transparent (app binds 8080 as usual) +- Pro: no `CAP_NET_ADMIN` required +- Con: still requires host-side port mapping (Pod has one IP) +- Con: user-mode packet forwarding has performance overhead +- Con: static build difficult (libslirp → glib2 dependency chain) + +### 3. pasta (recommended over slirp4netns) + +Modern replacement for slirp4netns from the passt project: + +```bash +pasta --tcp-ports 8080 --pid $BWRAP_PID +``` + +- Pro: minimal dependencies (Linux headers only), easy static musl build +- Pro: zero-copy using kernel splice, better performance than slirp4netns +- Pro: single binary, injectable like bwrap via init container +- Pro: Podman 5.0+ default rootless networking +- Con: same host-side port mapping requirement as slirp4netns +- Con: still cannot give each session its own externally-routable IP + +Static build: + +```dockerfile +FROM alpine:latest AS pasta-builder +RUN apk add --no-cache build-base linux-headers git +RUN git clone https://passt.top/passt /build/passt +WORKDIR /build/passt +RUN make static +# Output: /build/passt/pasta (single static binary) +``` + +### 4. ip netns + veth (Idealab approach) + +Full kernel-level per-session network isolation. + +- Pro: each session gets its own routable IP, no port mapping needed +- Pro: kernel-mode forwarding, best performance +- Pro: per-session iptables/network policy possible +- Con: requires `CAP_NET_ADMIN` +- Con: highest implementation complexity (veth lifecycle, bridge, routing, cleanup) + +## Comparison Matrix + +| | Random port | slirp4netns | pasta | ip netns + veth | +|---|---|---|---|---| +| App transparency | No | Yes | Yes | Yes | +| Same-port bind | N/A | Yes | Yes | Yes | +| External reachable | Via port map | Via port map | Via port map | Via own IP | +| No port mapping | No | No | No | Yes | +| Extra capabilities | None | None | None | CAP_NET_ADMIN | +| Static binary | N/A | Hard | Easy | N/A (ip/bridge) | +| Performance | Native | User-mode | splice | Kernel | +| Complexity | Low | Medium | Medium | High | + +## Conclusion + +The fundamental constraint is **a Pod has one IP**. Any solution that keeps +sessions in separate network namespaces but needs external reachability on +the same port requires either: + +1. **Port remapping** (slirp4netns, pasta) — app-transparent but external + callers see different ports per session. +2. **Per-session IP** (ip netns + veth) — fully transparent but requires + `CAP_NET_ADMIN` and bridge/routing management. + +### Recommendation + +- If the primary need is just avoiding `EADDRINUSE` and external routing + can tolerate port mapping: **pasta** is the best fit for execd's + "injectable static binary" model. +- If per-session network policy or same-port-same-IP access is required: + **ip netns + veth** is the only option, but it's a significant + subsystem to build and maintain. +- If neither is urgent: the current `ShareNet` bool is sufficient. Pod-level + network isolation via K8s NetworkPolicy + egress sidecar covers most use + cases. diff --git a/components/execd/docs/opentelemetry.md b/components/execd/docs/opentelemetry.md index 8b16a26ef..6c37d027a 100644 --- a/components/execd/docs/opentelemetry.md +++ b/components/execd/docs/opentelemetry.md @@ -18,6 +18,9 @@ This page lists the OpenTelemetry metrics currently implemented in execd. | `execd.system.memory.usage_bytes` | Observable Gauge | `By` | - | System memory used bytes (gopsutil). | | `execd.system.network.io.bytes` | Observable Counter | `By` | `direction` (`in`/`out`) | Cumulative network I/O bytes. | | `execd.system.network.connections.active` | Observable Gauge | - | `protocol` (`tcp`/`udp`) | Current active network connections. | +| `execd.isolation.run.duration` | Histogram | `ms` | `result` | Duration of isolated session runs by result. | +| `execd.isolation.session.count` | Observable Gauge | - | - | Current number of active isolated sessions. | +| `execd.isolation.upper.usage_bytes` | Observable Gauge | `By` | - | Total bytes used by isolated session upper directories. | ## Shared Attributes @@ -28,12 +31,11 @@ All execd metrics may include shared attributes: ## OTEL Endpoint Configuration -Metric export is enabled only when at least one OTLP endpoint is set. +Metric export is enabled when an OTLP endpoint is set, or (unless explicitly disabled) when a node IP is available via `HOST_IP` or `/etc/hostinfo`, in which case metrics are exported insecurely to `:4318`. - `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` (preferred) - `OTEL_EXPORTER_OTLP_ENDPOINT` (fallback) - -If both are unset, execd keeps metrics local (no OTLP export). +- `HOST_IP` (fallback endpoint host when both OTLP env vars are unset) ### Minimal Example diff --git a/components/execd/pkg/isolation/bwrap.go b/components/execd/pkg/isolation/bwrap.go index 09c11b483..cc912ec50 100644 --- a/components/execd/pkg/isolation/bwrap.go +++ b/components/execd/pkg/isolation/bwrap.go @@ -356,7 +356,8 @@ func bwrapEnvSegment(spec EnvSpec) []string { } } -// strictEnvBlacklist defines glob patterns stripped in strict profile. +// strictEnvBlacklist defines glob patterns stripped when no explicit env +// passthrough mode is set (default sessions) and for deny mode with no keys. var strictEnvBlacklist = []string{ "*_API_KEY", "*_TOKEN", "*_SECRET", "*_PASSWORD", "AWS_*", "ALI_*", "ALIYUN_*", "K8S_*", "KUBE_*", diff --git a/components/execd/pkg/isolation/upper_test.go b/components/execd/pkg/isolation/upper_test.go index d0b2ba683..02ed39fa4 100644 --- a/components/execd/pkg/isolation/upper_test.go +++ b/components/execd/pkg/isolation/upper_test.go @@ -36,7 +36,6 @@ func TestNewUpperManager(t *testing.T) { t.Errorf("MaxBytes() = %d, want %d", mgr.MaxBytes(), 8<<30) } - // Verify directory was created. if _, err := os.Stat(root); os.IsNotExist(err) { t.Error("root dir not created") } @@ -63,14 +62,12 @@ func TestUpperManager_Allocate(t *testing.T) { t.Error("empty directories") } - // Verify directories exist. for _, p := range []string{upper1, work1} { if _, err := os.Stat(p); os.IsNotExist(err) { t.Errorf("directory %s not created", p) } } - // Verify entries tracked. mgr.mu.Lock() e := mgr.entries[id1] mgr.mu.Unlock() @@ -121,7 +118,6 @@ func TestUpperManager_Remove(t *testing.T) { t.Fatal(err) } - // Upper parent should be gone. upperParent := filepath.Dir(upper) if _, err := os.Stat(upperParent); !os.IsNotExist(err) { t.Error("upper parent should be removed") @@ -228,13 +224,11 @@ func TestUpperManager_Collect(t *testing.T) { t.Errorf("freed id = %q, want %q", freed[0], id1) } - // Freed directory should be gone. upperParent1 := filepath.Dir(upper1) if _, err := os.Stat(upperParent1); !os.IsNotExist(err) { t.Error("freed upper should be removed") } - // Non-freed directory should still exist. if _, err := os.Stat(upper2); os.IsNotExist(err) { t.Error("in-use upper should not be removed") } @@ -243,7 +237,6 @@ func TestUpperManager_Collect(t *testing.T) { func TestUpperManager_Usage(t *testing.T) { mgr := newTestUpperManager(t) - // Empty usage. usage, err := mgr.Usage() if err != nil { t.Fatal(err) @@ -252,7 +245,6 @@ func TestUpperManager_Usage(t *testing.T) { t.Errorf("empty usage = %d, want 0", usage) } - // Allocate and write a file. _, upper, _, _ := mgr.Allocate() if err := os.WriteFile(filepath.Join(upper, "test.txt"), []byte("hello"), 0o644); err != nil { t.Fatal(err) @@ -344,8 +336,6 @@ func TestUpperManager_AllocateNoLimitWhenZero(t *testing.T) { } } -// Helpers - func newTestUpperManager(t *testing.T) *UpperManager { t.Helper() root := filepath.Join(t.TempDir(), "isolation") diff --git a/components/execd/pkg/jupyter/execute/execute.go b/components/execd/pkg/jupyter/execute/execute.go index 0c7611e46..5c9ea9754 100644 --- a/components/execd/pkg/jupyter/execute/execute.go +++ b/components/execd/pkg/jupyter/execute/execute.go @@ -87,9 +87,6 @@ func (c *Client) Connect(wsURL string) error { } c.conn = conn - // Register default message handlers - c.registerDefaultHandlers() - // Start message receiving goroutine safego.Go(func() { c.receiveMessages() }) @@ -420,7 +417,6 @@ func (c *Client) ExecuteCodeWithCallback(code string, handler CallbackHandler) e return } - // calls callback functions handler.OnExecuteResult(&execResult) }) } @@ -433,7 +429,6 @@ func (c *Client) ExecuteCodeWithCallback(code string, handler CallbackHandler) e return } - // calls callback functions handler.OnStream(&stream) }) } @@ -446,7 +441,6 @@ func (c *Client) ExecuteCodeWithCallback(code string, handler CallbackHandler) e return } - // calls callback functions handler.OnDisplayData(&display) }) } @@ -459,7 +453,6 @@ func (c *Client) ExecuteCodeWithCallback(code string, handler CallbackHandler) e return } - // calls callback functions handler.OnError(&errOutput) }) } @@ -472,7 +465,6 @@ func (c *Client) ExecuteCodeWithCallback(code string, handler CallbackHandler) e return } - // calls callback functions handler.OnStatus(&status) }) } @@ -488,11 +480,6 @@ func (c *Client) ExecuteCodeWithCallback(code string, handler CallbackHandler) e return nil } -// Register default message handlers -func (c *Client) registerDefaultHandlers() { - // default message handlers can be registered here -} - // Register temporary message handler func (c *Client) registerHandler(msgType MessageType, handler func(*Message)) { c.mu.Lock() @@ -505,7 +492,6 @@ func (c *Client) clearTemporaryHandlers() { c.mu.Lock() defer c.mu.Unlock() c.handlers = make(map[MessageType]func(*Message)) - c.registerDefaultHandlers() } // Receive WebSocket messages diff --git a/components/execd/pkg/jupyter/execute/execute_test.go b/components/execd/pkg/jupyter/execute/execute_test.go index 7ed18db30..ee5a8337d 100644 --- a/components/execd/pkg/jupyter/execute/execute_test.go +++ b/components/execd/pkg/jupyter/execute/execute_test.go @@ -28,10 +28,8 @@ import ( execdflag "github.com/alibaba/opensandbox/execd/pkg/flag" ) -// Create WebSocket test server func createTestServer(t *testing.T, handleFunc func(conn *websocket.Conn)) *httptest.Server { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Validate request path if !strings.HasPrefix(r.URL.Path, "/api/kernels/") { t.Errorf("expected path to start with '/api/kernels/', got '%s'", r.URL.Path) } @@ -39,7 +37,6 @@ func createTestServer(t *testing.T, handleFunc func(conn *websocket.Conn)) *http t.Errorf("expected path to end with '/channels', got '%s'", r.URL.Path) } - // Upgrade HTTP connection to WebSocket upgrader := websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } @@ -49,25 +46,20 @@ func createTestServer(t *testing.T, handleFunc func(conn *websocket.Conn)) *http } defer conn.Close() - // Handle WebSocket connection handleFunc(conn) })) return server } -// Test streaming code execution func TestExecuteCodeStream(t *testing.T) { - // Spin up mock WebSocket server server := createTestServer(t, func(conn *websocket.Conn) { - // Read execution request var executeRequest Message err := conn.ReadJSON(&executeRequest) if err != nil { t.Fatalf("failed to read execution request: %v", err) } - // Send multiple stream messages for i := 0; i < 3; i++ { streamContent, _ := json.Marshal(StreamOutput{ Name: StreamStdout, @@ -87,7 +79,6 @@ func TestExecuteCodeStream(t *testing.T) { time.Sleep(100 * time.Millisecond) } - // Send execution result resultContent, _ := json.Marshal(ExecuteResult{ ExecutionCount: 1, Data: map[string]interface{}{ @@ -107,7 +98,6 @@ func TestExecuteCodeStream(t *testing.T) { } conn.WriteJSON(executeResultMsg) - // Send status message statusContent, _ := json.Marshal(StatusUpdate{ ExecutionState: StateIdle, }) @@ -128,24 +118,20 @@ func TestExecuteCodeStream(t *testing.T) { // Convert HTTP URL to WebSocket URL wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/kernels/test-kernel-id/channels" - // Create executor client executor := NewExecutor(wsURL, nil) - // Connect to WebSocket err := executor.Connect() if err != nil { t.Fatalf("failed to connect to WebSocket: %v", err) } defer executor.Disconnect() - // Execute code in streaming mode resultChan := make(chan *ExecutionResult, 10) err = executor.ExecuteCodeStream("for i in range(3):\n print(f'Line {i}')", resultChan) if err != nil { t.Fatalf("failed to start streaming execution: %v", err) } - // Receive and verify stream results resultCount := 0 for result := range resultChan { if result == nil { diff --git a/components/execd/pkg/jupyter/integration_test.go b/components/execd/pkg/jupyter/integration_test.go index 2d371daa2..9d9f61555 100644 --- a/components/execd/pkg/jupyter/integration_test.go +++ b/components/execd/pkg/jupyter/integration_test.go @@ -27,26 +27,20 @@ import ( // Test integration flow: authentication -> get kernel specs -> create session -> execute code -> close session func TestIntegrationFlow(t *testing.T) { - // Create mock HTTP server httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Handle authentication validation request if r.URL.Path == "/api/status" { - // Check authentication token auth := r.Header.Get("Authorization") if auth != "token test-token" { w.WriteHeader(http.StatusUnauthorized) return } - // Return status information w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"status": "ok"}`)) return } - // Handle kernel specs request if r.URL.Path == "/api/kernelspecs" { - // Return kernel specs w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{ "default": "python3", @@ -61,10 +55,8 @@ func TestIntegrationFlow(t *testing.T) { return } - // Handle session-related requests if r.URL.Path == "/api/sessions" { if r.Method == http.MethodGet { - // List sessions w.Header().Set("Content-Type", "application/json") w.Write([]byte(`[{ "id": "test-session-id", @@ -78,7 +70,6 @@ func TestIntegrationFlow(t *testing.T) { }]`)) return } else if r.Method == http.MethodPost { - // Create session w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) w.Write([]byte(`{ @@ -95,14 +86,11 @@ func TestIntegrationFlow(t *testing.T) { } } - // Handle specific session requests if strings.HasPrefix(r.URL.Path, "/api/sessions/test-session-id") { if r.Method == http.MethodDelete { - // Delete session w.WriteHeader(http.StatusNoContent) return } else if r.Method == http.MethodPatch { - // Modify session w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{ "id": "test-session-id", @@ -116,7 +104,6 @@ func TestIntegrationFlow(t *testing.T) { }`)) return } else if r.Method == http.MethodGet { - // Get session w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{ "id": "test-session-id", @@ -132,10 +119,8 @@ func TestIntegrationFlow(t *testing.T) { } } - // Handle kernel requests if r.URL.Path == "/api/kernels" { if r.Method == http.MethodGet { - // List kernels w.Header().Set("Content-Type", "application/json") w.Write([]byte(`[{ "id": "test-kernel-id", @@ -146,10 +131,8 @@ func TestIntegrationFlow(t *testing.T) { } } - // Handle specific kernel requests if strings.HasPrefix(r.URL.Path, "/api/kernels/test-kernel-id") { if r.Method == http.MethodGet { - // Get kernel w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{ "id": "test-kernel-id", @@ -158,7 +141,6 @@ func TestIntegrationFlow(t *testing.T) { }`)) return } else if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/restart") { - // Restart kernel w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{ "id": "test-kernel-id", @@ -169,26 +151,22 @@ func TestIntegrationFlow(t *testing.T) { } } - // If it's a WebSocket connection request, upgrade to WebSocket if strings.HasSuffix(r.URL.Path, "/channels") { // Return 404, as WebSocket connections will be handled by a dedicated WebSocket server w.WriteHeader(http.StatusNotFound) return } - // For other requests, return 404 w.WriteHeader(http.StatusNotFound) })) defer httpServer.Close() - // Create mock WebSocket server for code execution wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasSuffix(r.URL.Path, "/channels") { w.WriteHeader(http.StatusNotFound) return } - // Upgrade HTTP connection to WebSocket upgrader := websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } @@ -198,18 +176,14 @@ func TestIntegrationFlow(t *testing.T) { } defer conn.Close() - // Continuously handle WebSocket messages for { - // Read request message var msg execute.Message err := conn.ReadJSON(&msg) if err != nil { break } - // If it's an execute request, send mock response if msg.Header.MessageType == string(execute.MsgExecuteRequest) { - // Send stream output streamContent, _ := json.Marshal(execute.StreamOutput{ Name: execute.StreamStdout, Text: "Hello from test WebSocket!\n", @@ -226,7 +200,6 @@ func TestIntegrationFlow(t *testing.T) { } conn.WriteJSON(streamMsg) - // Send execution result resultContent, _ := json.Marshal(execute.ExecuteResult{ ExecutionCount: 1, Data: map[string]interface{}{ @@ -246,7 +219,6 @@ func TestIntegrationFlow(t *testing.T) { } conn.WriteJSON(executeResultMsg) - // Send status message statusContent, _ := json.Marshal(execute.StatusUpdate{ ExecutionState: execute.StateIdle, }) @@ -266,11 +238,9 @@ func TestIntegrationFlow(t *testing.T) { })) defer wsServer.Close() - // Create Jupyter client client := NewClient(httpServer.URL) client.SetToken("test-token") - // Test 1: Validate authentication status, err := client.ValidateAuth() if err != nil { t.Fatalf("Authentication validation failed: %v", err) @@ -279,7 +249,6 @@ func TestIntegrationFlow(t *testing.T) { t.Errorf("Authentication status incorrect, expected 'ok', got '%s'", status) } - // Test 2: Get kernel specs specs, err := client.GetKernelSpecs() if err != nil { t.Fatalf("Failed to get kernel specs: %v", err) @@ -291,7 +260,6 @@ func TestIntegrationFlow(t *testing.T) { t.Errorf("Kernel count incorrect, expected 1, got %d", len(specs.Kernelspecs)) } - // Test 3: Create session session, err := client.CreateSession("Test Session", "/path/to/notebook.ipynb", "python3") if err != nil { t.Fatalf("Failed to create session: %v", err) @@ -306,7 +274,6 @@ func TestIntegrationFlow(t *testing.T) { // Modify WebSocket URL to point to WebSocket test server wsURL := "ws" + strings.TrimPrefix(wsServer.URL, "http") + "/api/kernels/test-kernel-id/channels" - // Test 4: Connect to kernel and execute code executor := execute.NewExecutor(wsURL, nil) err = executor.Connect() if err != nil { @@ -314,13 +281,11 @@ func TestIntegrationFlow(t *testing.T) { } defer executor.Disconnect() - // Execute code err = executor.ExecuteCodeWithCallback("print('Hello from integration test!')", execute.CallbackHandler{}) if err != nil { t.Fatalf("Failed to execute code: %v", err) } - // Test 5: Delete session err = client.DeleteSession(session.ID) if err != nil { t.Fatalf("Failed to delete session: %v", err) diff --git a/components/execd/pkg/jupyter/kernel/kernel.go b/components/execd/pkg/jupyter/kernel/kernel.go index 3badcfd1a..d626b7de7 100644 --- a/components/execd/pkg/jupyter/kernel/kernel.go +++ b/components/execd/pkg/jupyter/kernel/kernel.go @@ -42,28 +42,23 @@ func NewClient(baseURL string, httpClient *http.Client) *Client { // GetKernelSpecs retrieves the list of available kernel specifications func (c *Client) GetKernelSpecs() (*KernelSpecs, error) { - // Build request URL url := fmt.Sprintf("%s/api/kernelspecs", c.baseURL) - // Send GET request resp, err := c.httpClient.Get(url) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() - // Check response status if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) } - // Read response body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } - // Parse JSON response var specs KernelSpecs if err := json.Unmarshal(body, &specs); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) @@ -74,28 +69,23 @@ func (c *Client) GetKernelSpecs() (*KernelSpecs, error) { // ListKernels retrieves the list of all running kernels func (c *Client) ListKernels() ([]*Kernel, error) { - // Build request URL url := fmt.Sprintf("%s/api/kernels", c.baseURL) - // Send GET request resp, err := c.httpClient.Get(url) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() - // Check response status if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) } - // Read response body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } - // Parse JSON response var kernels []*Kernel if err := json.Unmarshal(body, &kernels); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) @@ -106,28 +96,23 @@ func (c *Client) ListKernels() ([]*Kernel, error) { // GetKernel retrieves information about a specific kernel func (c *Client) GetKernel(kernelId string) (*Kernel, error) { - // Build request URL url := fmt.Sprintf("%s/api/kernels/%s", c.baseURL, kernelId) - // Send GET request resp, err := c.httpClient.Get(url) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() - // Check response status if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) } - // Read response body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } - // Parse JSON response var kernel Kernel if err := json.Unmarshal(body, &kernel); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) @@ -138,7 +123,6 @@ func (c *Client) GetKernel(kernelId string) (*Kernel, error) { // StartKernel starts a new kernel func (c *Client) StartKernel(name string) (*Kernel, error) { - // Build request URL url := fmt.Sprintf("%s/api/kernels", c.baseURL) // Build request body @@ -166,18 +150,15 @@ func (c *Client) StartKernel(name string) (*Kernel, error) { } defer resp.Body.Close() - // Check response status if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) } - // Read response body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } - // Parse JSON response var kernel Kernel if err := json.Unmarshal(body, &kernel); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) @@ -188,7 +169,6 @@ func (c *Client) StartKernel(name string) (*Kernel, error) { // RestartKernel restarts the specified kernel func (c *Client) RestartKernel(kernelId string) (bool, error) { - // Build request URL url := fmt.Sprintf("%s/api/kernels/%s/restart", c.baseURL, kernelId) // Create POST request @@ -205,18 +185,15 @@ func (c *Client) RestartKernel(kernelId string) (bool, error) { } defer resp.Body.Close() - // Check response status if resp.StatusCode != http.StatusOK { return false, fmt.Errorf("server returned error status code: %d", resp.StatusCode) } - // Read response body, err := io.ReadAll(resp.Body) if err != nil { return false, fmt.Errorf("failed to read response: %w", err) } - // Parse JSON response var response KernelRestartResponse if err := json.Unmarshal(body, &response); err != nil { return false, fmt.Errorf("failed to parse response: %w", err) @@ -227,7 +204,6 @@ func (c *Client) RestartKernel(kernelId string) (bool, error) { // InterruptKernel interrupts the specified kernel func (c *Client) InterruptKernel(kernelId string) error { - // Build request URL url := fmt.Sprintf("%s/api/kernels/%s/interrupt", c.baseURL, kernelId) // Create POST request @@ -244,7 +220,6 @@ func (c *Client) InterruptKernel(kernelId string) error { } defer resp.Body.Close() - // Check response status if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { return fmt.Errorf("server returned error status code: %d", resp.StatusCode) } diff --git a/components/execd/pkg/jupyter/live_integration_test.go b/components/execd/pkg/jupyter/live_integration_test.go index f03a57da6..c295478bb 100644 --- a/components/execd/pkg/jupyter/live_integration_test.go +++ b/components/execd/pkg/jupyter/live_integration_test.go @@ -26,17 +26,14 @@ import ( // TestLiveServerIntegration tests SDK integration with a real Jupyter server func TestLiveServerIntegration(t *testing.T) { - // Get configuration from environment variables, use default values if not set jupyterURL := getEnv("JUPYTER_URL", "") jupyterToken := getEnv("JUPYTER_TOKEN", "") if jupyterURL == "" || jupyterToken == "" { t.Skip("JUPYTER_URL and JUPYTER_TOKEN environment variables must be set to run this test") } - // Output test information t.Logf("Connecting to Jupyter server: %s", jupyterURL) - // Create HTTP client with authentication capability httpClient := &http.Client{ Transport: &AuthTransport{ Token: jupyterToken, @@ -44,12 +41,10 @@ func TestLiveServerIntegration(t *testing.T) { }, } - // Create client and set authentication client := NewClient(jupyterURL, WithToken(jupyterToken), // Keep Token setting to support ValidateAuth and WebSocket connections WithHTTPClient(httpClient)) - // Test 1: Validate authentication t.Run("Validate Authentication", func(t *testing.T) { status, err := client.ValidateAuth() if err != nil { @@ -61,7 +56,6 @@ func TestLiveServerIntegration(t *testing.T) { t.Logf("Authentication validation successful! Status: %s", status) }) - // Test 2: Get kernel specs var kernelName string t.Run("Get Kernel Specs", func(t *testing.T) { specs, err := client.GetKernelSpecs() @@ -88,7 +82,6 @@ func TestLiveServerIntegration(t *testing.T) { t.Logf("Available kernels: %v", specs.Kernelspecs) }) - // Test 3: List sessions t.Run("List Sessions", func(t *testing.T) { sessions, err := client.ListSessions() if err != nil { @@ -100,10 +93,8 @@ func TestLiveServerIntegration(t *testing.T) { } }) - // Test 4: Create new session var sessionID string t.Run("Create Session", func(t *testing.T) { - // Generate unique name for test session sessionName := fmt.Sprintf("test-session-%d", time.Now().Unix()) sessionPath := "/test-notebook.ipynb" @@ -124,7 +115,6 @@ func TestLiveServerIntegration(t *testing.T) { t.Logf("Create session successful! Session ID: %s, Kernel ID: %s", session.ID, session.Kernel.ID) }) - // Test 5: Get created session var kernelID string t.Run("Get Session", func(t *testing.T) { if sessionID == "" { @@ -145,7 +135,6 @@ func TestLiveServerIntegration(t *testing.T) { t.Logf("Get session successful! Session name: %s, Kernel name: %s", session.Name, session.Kernel.Name) }) - // Test 6: List all kernels t.Run("List Kernels", func(t *testing.T) { kernels, err := client.ListKernels() if err != nil { @@ -156,7 +145,6 @@ func TestLiveServerIntegration(t *testing.T) { t.Logf("Kernel %d: ID=%s, Name=%s, State=%s", i+1, k.ID, k.Name, k.ExecutionState) } - // Verify that the created kernel is in the list if kernelID != "" { found := false for _, k := range kernels { @@ -171,20 +159,17 @@ func TestLiveServerIntegration(t *testing.T) { } }) - // Test 7: Connect to kernel and execute code t.Run("Execute Code", func(t *testing.T) { if kernelID == "" { t.Skip("No kernel ID, skipping test") } - // Connect to kernel err := client.ConnectToKernel(kernelID) if err != nil { t.Fatalf("Failed to connect to kernel: %v", err) } defer client.DisconnectFromKernel() - // Execute simple code code := "print('Hello, Jupyter!')\nresult = 2 + 2\nresult" t.Logf("Executing code:\n%s", code) @@ -194,20 +179,17 @@ func TestLiveServerIntegration(t *testing.T) { } }) - // Test 7: Connect to kernel and execute code t.Run("Execute Code", func(t *testing.T) { if kernelID == "" { t.Skip("No kernel ID, skipping test") } - // Connect to kernel err := client.ConnectToKernel(kernelID) if err != nil { t.Fatalf("Failed to connect to kernel: %v", err) } defer client.DisconnectFromKernel() - // Execute simple code code := "print(f'2 + 2 = {result}')\nresult" t.Logf("Executing code:\n%s", code) @@ -217,20 +199,17 @@ func TestLiveServerIntegration(t *testing.T) { } }) - // Test 8: Execute complex code with different types of output t.Run("Execute Complex Code", func(t *testing.T) { if kernelID == "" { t.Skip("No kernel ID, skipping test") } - // Connect to kernel err := client.ConnectToKernel(kernelID) if err != nil { t.Fatalf("Failed to connect to kernel: %v", err) } defer client.DisconnectFromKernel() - // Execute code that generates multiple output types code := ` # Display table data import pandas as pd @@ -263,22 +242,18 @@ except Exception as e: } }) - // Test 9: Restart kernel t.Run("Restart Kernel", func(t *testing.T) { if kernelID == "" { t.Skip("No kernel ID, skipping test") } - // Restart kernel restarted, err := client.RestartKernel(kernelID) if err != nil { t.Fatalf("Failed to restart kernel: %v", err) } - // Wait for kernel restart to complete time.Sleep(2 * time.Second) - // Verify kernel state kernel, err := client.GetKernel(kernelID) if err != nil { t.Fatalf("Failed to get kernel: %v", err) @@ -287,19 +262,16 @@ except Exception as e: t.Logf("Restart kernel successful! Restart status: %v, Kernel state: %s", restarted, kernel.ExecutionState) }) - // Test 10: Close session t.Run("Close Session", func(t *testing.T) { if sessionID == "" { t.Skip("No session ID, skipping test") } - // Delete session err := client.DeleteSession(sessionID) if err != nil { t.Fatalf("Failed to delete session: %v", err) } - // Verify session is deleted sessions, err := client.ListSessions() if err != nil { t.Fatalf("Failed to list sessions: %v", err) diff --git a/components/execd/pkg/jupyter/session/session.go b/components/execd/pkg/jupyter/session/session.go index eb700d0ad..c07d6c766 100644 --- a/components/execd/pkg/jupyter/session/session.go +++ b/components/execd/pkg/jupyter/session/session.go @@ -42,28 +42,23 @@ func NewClient(baseURL string, httpClient *http.Client) *Client { // ListSessions retrieves the list of all active sessions func (c *Client) ListSessions() ([]*Session, error) { - // Build request URL url := fmt.Sprintf("%s/api/sessions", c.baseURL) - // Send GET request resp, err := c.httpClient.Get(url) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() - // Check response status if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) } - // Read response body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } - // Parse JSON response var sessions []*Session if err := json.Unmarshal(body, &sessions); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) @@ -74,28 +69,23 @@ func (c *Client) ListSessions() ([]*Session, error) { // GetSession retrieves information about a specific session func (c *Client) GetSession(sessionId string) (*Session, error) { - // Build request URL url := fmt.Sprintf("%s/api/sessions/%s", c.baseURL, sessionId) - // Send GET request resp, err := c.httpClient.Get(url) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() - // Check response status if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) } - // Read response body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } - // Parse JSON response var session Session if err := json.Unmarshal(body, &session); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) @@ -106,7 +96,6 @@ func (c *Client) GetSession(sessionId string) (*Session, error) { // CreateSession creates a new session func (c *Client) CreateSession(name, ipynb, kernel string) (*Session, error) { - // Build request URL url := fmt.Sprintf("%s/api/sessions", c.baseURL) // Build request body @@ -139,18 +128,15 @@ func (c *Client) CreateSession(name, ipynb, kernel string) (*Session, error) { } defer resp.Body.Close() - // Check response status if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) } - // Read response body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } - // Parse JSON response var session Session if err := json.Unmarshal(body, &session); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) @@ -161,7 +147,6 @@ func (c *Client) CreateSession(name, ipynb, kernel string) (*Session, error) { // DeleteSession deletes the specified session func (c *Client) DeleteSession(sessionId string) error { - // Build request URL url := fmt.Sprintf("%s/api/sessions/%s", c.baseURL, sessionId) // Create DELETE request @@ -177,7 +162,6 @@ func (c *Client) DeleteSession(sessionId string) error { } defer resp.Body.Close() - // Check response status if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { return fmt.Errorf("server returned error status code: %d", resp.StatusCode) } @@ -185,9 +169,8 @@ func (c *Client) DeleteSession(sessionId string) error { return nil } -// CreateSessionWithOptions usingoption to create a new session +// CreateSessionWithOptions creates a new Jupyter session. func (c *Client) CreateSessionWithOptions(options *SessionOptions) (*Session, error) { - // Build request URL url := fmt.Sprintf("%s/api/sessions", c.baseURL) // Build request body @@ -236,18 +219,15 @@ func (c *Client) CreateSessionWithOptions(options *SessionOptions) (*Session, er } defer resp.Body.Close() - // Check response status if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("server returned error status code: %d", resp.StatusCode) } - // Read response body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } - // Parse JSON response var session Session if err := json.Unmarshal(body, &session); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) diff --git a/components/execd/pkg/jupyter/session/session_test.go b/components/execd/pkg/jupyter/session/session_test.go index 66654b82d..5215d3fc0 100644 --- a/components/execd/pkg/jupyter/session/session_test.go +++ b/components/execd/pkg/jupyter/session/session_test.go @@ -21,11 +21,8 @@ import ( "testing" ) -// Test listing sessions func TestListSessions(t *testing.T) { - // Create mock server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Verify request method and path if r.Method != http.MethodGet { t.Errorf("expected request method GET, got %s", r.Method) } @@ -33,7 +30,6 @@ func TestListSessions(t *testing.T) { t.Errorf("expected request path /api/sessions, got %s", r.URL.Path) } - // Return mocked session list response := `[ { "id": "session-1", @@ -69,21 +65,17 @@ func TestListSessions(t *testing.T) { })) defer server.Close() - // Create client client := NewClient(server.URL, &http.Client{}) - // Fetch session list sessions, err := client.ListSessions() if err != nil { t.Fatalf("failed to list sessions: %v", err) } - // Validate session count if len(sessions) != 2 { t.Errorf("expected 2 sessions, got %d", len(sessions)) } - // Validate first session fields if sessions[0].ID != "session-1" { t.Errorf("expected session ID 'session-1', got '%s'", sessions[0].ID) } @@ -97,7 +89,6 @@ func TestListSessions(t *testing.T) { t.Errorf("expected session type 'notebook', got '%s'", sessions[0].Type) } - // Validate first session kernel fields if sessions[0].Kernel.ID != "kernel-1" { t.Errorf("expected kernel ID 'kernel-1', got '%s'", sessions[0].Kernel.ID) } @@ -106,11 +97,8 @@ func TestListSessions(t *testing.T) { } } -// Test creating session func TestCreateSession(t *testing.T) { - // Create mock server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Verify request method and path if r.Method != http.MethodPost { t.Errorf("expected request method POST, got %s", r.Method) } @@ -118,7 +106,6 @@ func TestCreateSession(t *testing.T) { t.Errorf("expected request path /api/sessions, got %s", r.URL.Path) } - // Parse request body var requestBody SessionCreateRequest decoder := json.NewDecoder(r.Body) if err := decoder.Decode(&requestBody); err != nil { @@ -139,7 +126,6 @@ func TestCreateSession(t *testing.T) { t.Errorf("expected kernel name 'python3', got '%s'", requestBody.Kernel.Name) } - // Return mocked create response response := `{ "id": "new-session-id", "path": "/path/to/notebook.ipynb", @@ -160,16 +146,13 @@ func TestCreateSession(t *testing.T) { })) defer server.Close() - // Create client client := NewClient(server.URL, &http.Client{}) - // Create session newSession, err := client.CreateSession("Test Session", "/path/to/notebook.ipynb", "python3") if err != nil { t.Fatalf("failed to create session: %v", err) } - // Validate created session if newSession.ID != "new-session-id" { t.Errorf("expected session ID 'new-session-id', got '%s'", newSession.ID) } @@ -184,13 +167,10 @@ func TestCreateSession(t *testing.T) { } } -// Test fetching a specific session func TestGetSession(t *testing.T) { sessionID := "test-session-id" - // Create mock server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Verify request method and path if r.Method != http.MethodGet { t.Errorf("expected request method GET, got %s", r.Method) } @@ -200,7 +180,6 @@ func TestGetSession(t *testing.T) { t.Errorf("expected request path '%s', got '%s'", expectedPath, r.URL.Path) } - // Return mocked session response := `{ "id": "test-session-id", "path": "/path/to/notebook.ipynb", @@ -221,16 +200,13 @@ func TestGetSession(t *testing.T) { })) defer server.Close() - // Create client client := NewClient(server.URL, &http.Client{}) - // Fetch session session, err := client.GetSession(sessionID) if err != nil { t.Fatalf("failed to get session: %v", err) } - // Validate session if session.ID != sessionID { t.Errorf("expected session ID '%s', got '%s'", sessionID, session.ID) } diff --git a/components/execd/pkg/runtime/bwrap_test/bwrap_coverage_gaps_test.go b/components/execd/pkg/runtime/bwrap_test/bwrap_coverage_gaps_test.go index c1ad05c7d..0105a1cf4 100644 --- a/components/execd/pkg/runtime/bwrap_test/bwrap_coverage_gaps_test.go +++ b/components/execd/pkg/runtime/bwrap_test/bwrap_coverage_gaps_test.go @@ -225,13 +225,12 @@ func TestDeleteThenRecreate(t *testing.T) { err = r.DeleteIsolatedSession(id) require.NoError(t, err, "delete %d", i) - // Verify it's gone. _, err = r.GetIsolatedSession(id) assert.Error(t, err, "session %d should be gone after delete", i) } } -// TestBashAliasAndBuiltins verifies bash builtins and functions work. +// TestBashBuiltinsAndFunctions verifies bash builtins and functions work. func TestBashBuiltinsAndFunctions(t *testing.T) { r := newRunner(t) @@ -433,7 +432,6 @@ func TestWorkspaceIsolationAcrossSessions(t *testing.T) { r := newRunner(t) ws := t.TempDir() - // Pre-create a file in workspace. require.NoError(t, os.WriteFile(ws+"/shared.txt", []byte("original"), 0644)) // Session 1: rw mode — write modifies workspace directly. diff --git a/components/execd/pkg/runtime/bwrap_test/bwrap_extra_writable_test.go b/components/execd/pkg/runtime/bwrap_test/bwrap_extra_writable_test.go index fdff92f8c..bcdea84c9 100644 --- a/components/execd/pkg/runtime/bwrap_test/bwrap_extra_writable_test.go +++ b/components/execd/pkg/runtime/bwrap_test/bwrap_extra_writable_test.go @@ -84,11 +84,9 @@ func TestExtraWritable_ReadWriteRoundTrip(t *testing.T) { testFile := filepath.Join(extraDir, "roundtrip.txt") - // Write. err = r.RunInIsolatedSession(ctx, id, "echo 'roundtrip-value' > "+testFile, nil, nil) require.NoError(t, err) - // Read back. var lines []string err = r.RunInIsolatedSession(ctx, id, "cat "+testFile, nil, func(line string) { lines = append(lines, line) }) diff --git a/components/execd/pkg/runtime/isolated_session_test.go b/components/execd/pkg/runtime/isolated_session_test.go index 72f649300..0cdbcc58c 100644 --- a/components/execd/pkg/runtime/isolated_session_test.go +++ b/components/execd/pkg/runtime/isolated_session_test.go @@ -258,7 +258,6 @@ func TestCreateIsolatedSession_HappyPath(t *testing.T) { t.Error("expected non-empty session ID") } - // Verify session is tracked. s := runner.lookup(id) if s == nil { t.Fatal("session not found after create") @@ -267,7 +266,6 @@ func TestCreateIsolatedSession_HappyPath(t *testing.T) { t.Errorf("profile = %q, want strict", s.opts.Profile) } - // Clean up. if err := runner.DeleteIsolatedSession(id); err != nil { t.Errorf("DeleteIsolatedSession: %v", err) } @@ -527,7 +525,6 @@ func TestDeleteIsolatedSession_Success(t *testing.T) { t.Fatal(err) } - // Verify removed. if s := runner.lookup(id); s != nil { t.Error("session should be removed after delete") } @@ -551,13 +548,11 @@ func TestRunInIsolatedSession_HappyPath(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - // echo should succeed (exit 0). err = runner.RunInIsolatedSession(ctx, id, "echo hello", nil, nil) if err != nil { t.Errorf("RunInIsolatedSession: %v", err) } - // Verify lastRunAt was updated. s := runner.lookup(id) if s == nil { t.Fatal("session disappeared") diff --git a/components/execd/pkg/util/glob/match_test.go b/components/execd/pkg/util/glob/match_test.go index 4b3ba4297..7207eeac2 100644 --- a/components/execd/pkg/util/glob/match_test.go +++ b/components/execd/pkg/util/glob/match_test.go @@ -233,7 +233,6 @@ func testValidatePatternWith(t *testing.T, idx int, tt MatchTest) { func TestPathMatch(t *testing.T) { for idx, tt := range matchTests { - // Even though we aren't actually matching paths on disk, we are using if tt.testOnDisk { testPathMatchWith(t, idx, tt) } @@ -263,13 +262,11 @@ func testPathMatchWith(t *testing.T, idx int, tt MatchTest) { } func TestPathMatchFake(t *testing.T) { - // This test fakes that our path separator is `\\` so we can test what it if onWindows { return } for idx, tt := range matchTests { - // Even though we aren't actually matching paths on disk, we are using if tt.testOnDisk && !strings.Contains(tt.pattern, "\\") { testPathMatchFakeWith(t, idx, tt) } diff --git a/components/execd/pkg/web/controller/codeinterpreting.go b/components/execd/pkg/web/controller/codeinterpreting.go index f95883e9b..959f9f1af 100644 --- a/components/execd/pkg/web/controller/codeinterpreting.go +++ b/components/execd/pkg/web/controller/codeinterpreting.go @@ -179,7 +179,7 @@ func (c *CodeInterpretingController) RunCode() { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, - fmt.Sprintf("error running codes %v", err), + fmt.Sprintf("error running code %v", err), ) return } @@ -504,7 +504,7 @@ func (c *CodeInterpretingController) interrupt() { c.RespondError( http.StatusInternalServerError, model.ErrorCodeRuntimeError, - fmt.Sprintf("error interruptting code context. %v", err), + fmt.Sprintf("error interrupting code context. %v", err), ) return } diff --git a/components/execd/pkg/web/controller/isolated_session.go b/components/execd/pkg/web/controller/isolated_session.go index 7f78e8bc6..98415ebc8 100644 --- a/components/execd/pkg/web/controller/isolated_session.go +++ b/components/execd/pkg/web/controller/isolated_session.go @@ -466,13 +466,10 @@ func (c *IsolatedSessionController) Capabilities() { Version: caps.Version, SetprivAvailable: caps.SetprivAvailable, UsernsAvailable: caps.UsernsAvailable, - CommitSupported: caps.CommitSupported, - DiffSupported: caps.DiffSupported, + // Diff and commit are Phase 2; do not advertise probe-derived overlay capability as implemented. + CommitSupported: false, + DiffSupported: false, } - // Probe results indicate overlay capability, not diff/commit implementation. - // Diff and commit are Phase 2; do not advertise them as supported. - resp.CommitSupported = false - resp.DiffSupported = false c.RespondSuccess(resp) } diff --git a/components/execd/pkg/web/controller/metric_test.go b/components/execd/pkg/web/controller/metric_test.go index 3974dae4b..3c6458dd7 100644 --- a/components/execd/pkg/web/controller/metric_test.go +++ b/components/execd/pkg/web/controller/metric_test.go @@ -41,19 +41,15 @@ func TestReadMetrics(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, metrics) - // Validate CPU count assert.Greater(t, metrics.CpuCount, 0.0) - // Validate CPU utilization assert.GreaterOrEqual(t, metrics.CpuUsedPct, 0.0) assert.Less(t, metrics.CpuUsedPct, 100.1) // CPU usage should be under 100% with small float tolerance - // Validate memory information assert.Greater(t, metrics.MemTotalMiB, 0.0) assert.GreaterOrEqual(t, metrics.MemUsedMiB, 0.0) assert.LessOrEqual(t, metrics.MemUsedMiB, metrics.MemTotalMiB) // Used memory should not exceed total - // Validate timestamps currentTime := time.Now().UnixMilli() oneMinuteAgo := currentTime - 60*1000 assert.GreaterOrEqual(t, metrics.Timestamp, oneMinuteAgo) // Should be within the last minute diff --git a/components/execd/pkg/web/controller/pty_ws.go b/components/execd/pkg/web/controller/pty_ws.go index d369884d6..ead8db345 100644 --- a/components/execd/pkg/web/controller/pty_ws.go +++ b/components/execd/pkg/web/controller/pty_ws.go @@ -44,7 +44,7 @@ const ( wsReadDeadline = 60 * time.Second wsWriteDeadline = 10 * time.Second // wsTakeoverTimeout bounds how long a ?takeover=1 request waits for the current - // holder to release after being evicted, before giving up with 409. + // holder to release after being evicted, before the takeover fails. wsTakeoverTimeout = 5 * time.Second // wsTakeoverCloseTimeout bounds the best-effort close-frame write sent to an // evicted holder, so a full/unresponsive client socket cannot stall the takeover. diff --git a/components/execd/pkg/web/controller/pty_ws_test.go b/components/execd/pkg/web/controller/pty_ws_test.go index 0cf5dae2c..6ed60527c 100644 --- a/components/execd/pkg/web/controller/pty_ws_test.go +++ b/components/execd/pkg/web/controller/pty_ws_test.go @@ -188,8 +188,6 @@ func ptyWriteStdin(t *testing.T, conn *websocket.Conn, text string) { require.NoError(t, conn.WriteMessage(websocket.BinaryMessage, frame)) } -// --- Tests --- - func TestPTYWS_UnknownSessionReturns404(t *testing.T) { srv := newPTYTestServer(t) defer srv.Close() @@ -466,7 +464,6 @@ func TestPTYWS_ReplayOnReconnect(t *testing.T) { require.NoError(t, json.NewDecoder(resp.Body).Decode(&status)) require.True(t, status.OutputOffset > 0) - // Disconnect. _ = conn1.Close() time.Sleep(100 * time.Millisecond) diff --git a/components/execd/pkg/web/model/isolated_session.go b/components/execd/pkg/web/model/isolated_session.go index 19a0a205e..d5d616e6a 100644 --- a/components/execd/pkg/web/model/isolated_session.go +++ b/components/execd/pkg/web/model/isolated_session.go @@ -158,7 +158,7 @@ type IsolatedRunStatus struct { // session ID (e.g. after a client restart). Older execd builds may omit // these fields; clients must tolerate them being absent. type SessionState struct { - Status string `json:"status"` // "active" | "dead" | "destroyed" + Status string `json:"status"` // "active" | "dead" CreatedAt time.Time `json:"created_at"` LastRunAt time.Time `json:"last_run_at"` IdleRemainingSeconds *int `json:"idle_remaining_seconds,omitempty"` diff --git a/components/ingress/DEVELOPMENT.md b/components/ingress/DEVELOPMENT.md index 279cec764..cfa70c69d 100644 --- a/components/ingress/DEVELOPMENT.md +++ b/components/ingress/DEVELOPMENT.md @@ -1,7 +1,7 @@ # Development Guide (Quick) ## Prerequisites -- Go 1.24+ +- Go 1.25+ - Docker (optional, for image build) - Access to a Kubernetes cluster with BatchSandbox CRD installed. @@ -13,8 +13,8 @@ go mod tidy && go mod vendor ## Build & Run ```bash -make build # binary at bin/ingress with ldflags version info -./bin/ingress \ +make build # binary at bin/router with ldflags version info +./bin/router \ --namespace \ --port 28888 \ --log-level info diff --git a/components/ingress/RELEASE_NOTES.md b/components/ingress/RELEASE_NOTES.md index 08830ca61..05da9319b 100644 --- a/components/ingress/RELEASE_NOTES.md +++ b/components/ingress/RELEASE_NOTES.md @@ -3,7 +3,7 @@ ## What's New ### ✨ Features -- **[EXPERIMENTAL]** publishing renew-intent to Redis for [OSEP-0009](https://github.com/alibaba/OpenSandbox/blob/main/oseps/0009-auto-renew-sandbox-on-ingress-access.md) (#480) +- **[EXPERIMENTAL]** publishing renew-intent to Redis for [OSEP-0009](https://github.com/opensandbox-group/OpenSandbox/blob/main/oseps/0009-auto-renew-sandbox-on-ingress-access.md) (#480) ### 🐛 Bug Fixes - use LoadOrStore for renew-intent MinInterval throttle (#529) diff --git a/components/ingress/pkg/proxy/header.go b/components/ingress/pkg/proxy/header.go index a44dd748d..14347b8a6 100644 --- a/components/ingress/pkg/proxy/header.go +++ b/components/ingress/pkg/proxy/header.go @@ -22,8 +22,7 @@ var ( XForwardedProto = http.CanonicalHeaderKey("X-Forwarded-Proto") SandboxIngress = http.CanonicalHeaderKey("OpenSandbox-Ingress-To") - // DeprecatedSandboxIngress is the deprecated header name - // Deprecated + // DeprecatedSandboxIngress is the deprecated header name. DeprecatedSandboxIngress = http.CanonicalHeaderKey("OPEN-SANDBOX-INGRESS") AccessControlAllowOrigin = http.CanonicalHeaderKey("Access-Control-Allow-Origin") diff --git a/components/ingress/pkg/proxy/http_test.go b/components/ingress/pkg/proxy/http_test.go index 777502a1c..f807671eb 100644 --- a/components/ingress/pkg/proxy/http_test.go +++ b/components/ingress/pkg/proxy/http_test.go @@ -85,7 +85,6 @@ func httpProxyWithHeaderMode(t *testing.T) { defer server.Close() serverPort := server.URL[len("http://127.0.0.1:"):] - // Create mock provider with sandbox endpoint provider := &mockProvider{ endpoints: map[string]string{ "test-sandbox": "127.0.0.1", @@ -107,7 +106,6 @@ func httpProxyWithHeaderMode(t *testing.T) { time.Sleep(2 * time.Second) - // no header request, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%v/hello", port), nil) assert.Nil(t, err) response, err := http.DefaultClient.Do(request) @@ -116,7 +114,6 @@ func httpProxyWithHeaderMode(t *testing.T) { bytes, _ := io.ReadAll(response.Body) t.Log(string(bytes)) - // no sandbox backend request, err = http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%v/hello", port), nil) request.Header.Set(SandboxIngress, fmt.Sprintf("non-existent-%v", port)) response, err = http.DefaultClient.Do(request) @@ -125,7 +122,6 @@ func httpProxyWithHeaderMode(t *testing.T) { bytes, _ = io.ReadAll(response.Body) t.Log(string(bytes)) - // valid sandbox request request, err = http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%v/hello?a=1&b=2", port), nil) assert.Nil(t, err) @@ -159,7 +155,6 @@ func httpProxyWithURIMode(t *testing.T) { defer server.Close() serverPort := server.URL[len("http://127.0.0.1:"):] - // Create mock provider with sandbox endpoint provider := &mockProvider{ endpoints: map[string]string{ "test-sandbox": "127.0.0.1", @@ -181,7 +176,6 @@ func httpProxyWithURIMode(t *testing.T) { time.Sleep(2 * time.Second) - // uri is empty request, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%v", port), nil) assert.Nil(t, err) response, err := http.DefaultClient.Do(request) @@ -190,7 +184,6 @@ func httpProxyWithURIMode(t *testing.T) { bytes, _ := io.ReadAll(response.Body) t.Log(string(bytes)) - // no sandbox backend request, err = http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%v/non-existent-xxx/80/hello", port), nil) response, err = http.DefaultClient.Do(request) assert.Nil(t, err) @@ -198,7 +191,6 @@ func httpProxyWithURIMode(t *testing.T) { bytes, _ = io.ReadAll(response.Body) t.Log(string(bytes)) - // valid sandbox request request, err = http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("http://127.0.0.1:%v/test-sandbox/%v/hello?a=1&b=2", port, serverPort), nil) assert.Nil(t, err) response, err = http.DefaultClient.Do(request) diff --git a/components/ingress/pkg/proxy/proxy_test.go b/components/ingress/pkg/proxy/proxy_test.go index 2b8ced1c7..49ed671cf 100644 --- a/components/ingress/pkg/proxy/proxy_test.go +++ b/components/ingress/pkg/proxy/proxy_test.go @@ -34,22 +34,17 @@ func (stubNoSecureProvider) GetEndpoint(string) (*sandbox.EndpointInfo, error) { func (stubNoSecureProvider) Start(context.Context) error { return nil } -// Test_WatchPods is removed as we now use BatchSandbox Provider instead of direct Pod watching - func TestIsWebSocketRequest(t *testing.T) { proxy := &Proxy{} - // Valid websocket request req := httptest.NewRequest(http.MethodGet, "/ws", nil) req.Header.Set("Upgrade", "websocket") req.Header.Set("Connection", "Upgrade") assert.True(t, proxy.isWebSocketRequest(req)) - // Missing upgrade headers req = httptest.NewRequest(http.MethodGet, "/ws", nil) assert.False(t, proxy.isWebSocketRequest(req)) - // Wrong method req = httptest.NewRequest(http.MethodPost, "/ws", nil) req.Header.Set("Upgrade", "websocket") req.Header.Set("Connection", "Upgrade") diff --git a/components/ingress/pkg/proxy/websocket_test.go b/components/ingress/pkg/proxy/websocket_test.go index 0eb0a401b..cc99d4a62 100644 --- a/components/ingress/pkg/proxy/websocket_test.go +++ b/components/ingress/pkg/proxy/websocket_test.go @@ -39,7 +39,6 @@ func Test_WebSocketProxy(t *testing.T) { } func webSocketProxyWithHeaderMode(t *testing.T) { - // Create mock provider provider := &mockProvider{ endpoints: map[string]string{ "test-sandbox": "127.0.0.1", @@ -65,7 +64,6 @@ func webSocketProxyWithHeaderMode(t *testing.T) { backendPort, err := findAvailablePort() assert.Nil(t, err) - // backend echo server go func() { mux2 := http.NewServeMux() mux2.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { @@ -100,8 +98,6 @@ func webSocketProxyWithHeaderMode(t *testing.T) { time.Sleep(time.Millisecond * 100) - // frontend server, dial now our proxy, which will reverse proxy our - // message to the backend websocket server. h := http.Header{} h.Set(SandboxIngress, "test-sandbox-"+strconv.Itoa(backendPort)) conn, _, err := websocket.DefaultDialer.Dial(proxyURL+"/ws", h) @@ -109,7 +105,6 @@ func webSocketProxyWithHeaderMode(t *testing.T) { t.Fatal(err) } - // write a message and send it to the backend server msg := "hello kite" err = conn.WriteMessage(websocket.TextMessage, []byte(msg)) if err != nil { @@ -131,7 +126,6 @@ func webSocketProxyWithHeaderMode(t *testing.T) { } func webSocketProxyWithURIMode(t *testing.T) { - // Create mock provider provider := &mockProvider{ endpoints: map[string]string{ "test-sandbox": "127.0.0.1", @@ -157,7 +151,6 @@ func webSocketProxyWithURIMode(t *testing.T) { backendPort, err := findAvailablePort() assert.Nil(t, err) - // backend echo server go func() { mux2 := http.NewServeMux() mux2.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { @@ -192,8 +185,6 @@ func webSocketProxyWithURIMode(t *testing.T) { time.Sleep(time.Millisecond * 100) - // frontend server, dial now our proxy, which will reverse proxy our - // message to the backend websocket server. h := http.Header{} h.Set(SandboxIngress, "test-sandbox-"+strconv.Itoa(backendPort)) conn, _, err := websocket.DefaultDialer.Dial(proxyURL+fmt.Sprintf("/test-sandbox/%v", backendPort)+"/ws", h) @@ -201,7 +192,6 @@ func webSocketProxyWithURIMode(t *testing.T) { t.Fatal(err) } - // write a message and send it to the backend server msg := "hello kite" err = conn.WriteMessage(websocket.TextMessage, []byte(msg)) if err != nil { diff --git a/components/ingress/pkg/sandbox/agent_sandbox_provider_test.go b/components/ingress/pkg/sandbox/agent_sandbox_provider_test.go index c32918edc..0a7235592 100644 --- a/components/ingress/pkg/sandbox/agent_sandbox_provider_test.go +++ b/components/ingress/pkg/sandbox/agent_sandbox_provider_test.go @@ -141,7 +141,6 @@ func TestAgentSandboxProvider_GetEndpoint_ServiceFQDN(t *testing.T) { err := provider.Start(ctx) assert.NoError(t, err) - // Seed store err = provider.informer.GetStore().Add(obj) assert.NoError(t, err) @@ -201,7 +200,6 @@ func TestAgentSandboxProvider_GetEndpoint_NoServiceFQDN(t *testing.T) { err := provider.Start(ctx) assert.NoError(t, err) - // Seed store err = provider.informer.GetStore().Add(obj) assert.NoError(t, err) @@ -245,7 +243,6 @@ func TestAgentSandboxProvider_GetEndpoint_NotReadyCondition(t *testing.T) { err := provider.Start(ctx) assert.NoError(t, err) - // Seed store err = provider.informer.GetStore().Add(obj) assert.NoError(t, err) diff --git a/components/ingress/pkg/sandbox/batchsandbox_provider_test.go b/components/ingress/pkg/sandbox/batchsandbox_provider_test.go index 1757375f0..cc81cdc39 100644 --- a/components/ingress/pkg/sandbox/batchsandbox_provider_test.go +++ b/components/ingress/pkg/sandbox/batchsandbox_provider_test.go @@ -37,7 +37,6 @@ import ( func TestBatchSandboxProvider_WithFakeInformer(t *testing.T) { namespace := "test-namespace" - // Create a ready BatchSandbox with valid endpoints readyBatchSandbox := &sandboxv1alpha1.BatchSandbox{ ObjectMeta: metav1.ObjectMeta{ Name: "ready-sandbox", @@ -55,7 +54,6 @@ func TestBatchSandboxProvider_WithFakeInformer(t *testing.T) { }, } - // Create a not ready BatchSandbox notReadyBatchSandbox := &sandboxv1alpha1.BatchSandbox{ ObjectMeta: metav1.ObjectMeta{ Name: "not-ready-sandbox", @@ -70,10 +68,8 @@ func TestBatchSandboxProvider_WithFakeInformer(t *testing.T) { }, } - // Create fake clientset with test objects fakeClient := fakeclientset.NewSimpleClientset(readyBatchSandbox, notReadyBatchSandbox) - // Create informer factory informerFactory := informers.NewSharedInformerFactoryWithOptions( fakeClient, time.Second*30, @@ -82,14 +78,12 @@ func TestBatchSandboxProvider_WithFakeInformer(t *testing.T) { batchSandboxInformer := informerFactory.Sandbox().V1alpha1().BatchSandboxes() - // Create provider provider := &BatchSandboxProvider{ informerFactory: informerFactory, lister: batchSandboxInformer.Lister(), informerSynced: batchSandboxInformer.Informer().HasSynced, } - // Start informer and wait for cache sync ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() @@ -102,7 +96,6 @@ func TestBatchSandboxProvider_WithFakeInformer(t *testing.T) { err = batchSandboxInformer.Informer().GetStore().Add(notReadyBatchSandbox) assert.NoError(t, err) - // Test 1: Get endpoint from ready sandbox t.Run("GetEndpoint from ready sandbox", func(t *testing.T) { endpoint, err := provider.GetEndpoint("ready-sandbox") assert.NoError(t, err) @@ -110,7 +103,6 @@ func TestBatchSandboxProvider_WithFakeInformer(t *testing.T) { assert.Equal(t, "", endpoint.SecureAccessToken) }) - // Test 2: Get endpoint from not ready sandbox t.Run("GetEndpoint from not ready sandbox", func(t *testing.T) { _, err := provider.GetEndpoint("not-ready-sandbox") assert.Error(t, err) @@ -118,7 +110,6 @@ func TestBatchSandboxProvider_WithFakeInformer(t *testing.T) { assert.Contains(t, err.Error(), "not ready") }) - // Test 3: Get endpoint from non-existent sandbox t.Run("GetEndpoint from non-existent sandbox", func(t *testing.T) { _, err := provider.GetEndpoint("non-existent") assert.Error(t, err) @@ -153,11 +144,9 @@ func TestBatchSandboxProvider_WithFakeInformer(t *testing.T) { }) } -// TestBatchSandboxProvider_MissingAnnotation tests sandbox without endpoints annotation func TestBatchSandboxProvider_MissingAnnotation(t *testing.T) { namespace := "test-namespace" - // Create BatchSandbox without endpoints annotation batchSandbox := &sandboxv1alpha1.BatchSandbox{ ObjectMeta: metav1.ObjectMeta{ Name: "no-annotation-sandbox", @@ -193,7 +182,6 @@ func TestBatchSandboxProvider_MissingAnnotation(t *testing.T) { err := provider.Start(ctx) assert.NoError(t, err) - // Manually add object to informer cache err = batchSandboxInformer.Informer().GetStore().Add(batchSandbox) assert.NoError(t, err) @@ -203,7 +191,6 @@ func TestBatchSandboxProvider_MissingAnnotation(t *testing.T) { assert.Contains(t, err.Error(), "has no annotations") } -// TestBatchSandboxProvider_InvalidAnnotation tests sandbox with invalid annotation format func TestBatchSandboxProvider_InvalidAnnotation(t *testing.T) { namespace := "test-namespace" @@ -245,7 +232,6 @@ func TestBatchSandboxProvider_InvalidAnnotation(t *testing.T) { err := provider.Start(ctx) assert.NoError(t, err) - // Manually add object to informer cache err = batchSandboxInformer.Informer().GetStore().Add(batchSandbox) assert.NoError(t, err) @@ -255,7 +241,6 @@ func TestBatchSandboxProvider_InvalidAnnotation(t *testing.T) { assert.Contains(t, err.Error(), "failed to parse") } -// TestBatchSandboxProvider_DynamicUpdate tests adding object after informer starts func TestBatchSandboxProvider_DynamicUpdate(t *testing.T) { namespace := "test-namespace" @@ -280,13 +265,11 @@ func TestBatchSandboxProvider_DynamicUpdate(t *testing.T) { err := provider.Start(ctx) assert.NoError(t, err) - // Initially no sandbox exists _, err = provider.GetEndpoint("dynamic-sandbox") assert.Error(t, err) assert.True(t, errors.Is(err, ErrSandboxNotFound), "Should return ErrSandboxNotFound") assert.Contains(t, err.Error(), "not found") - // Add a new BatchSandbox newBatchSandbox := &sandboxv1alpha1.BatchSandbox{ ObjectMeta: metav1.ObjectMeta{ Name: "dynamic-sandbox", @@ -308,14 +291,12 @@ func TestBatchSandboxProvider_DynamicUpdate(t *testing.T) { context.Background(), newBatchSandbox, metav1.CreateOptions{}) assert.NoError(t, err) - // Wait for informer to pick up the change assert.Eventually(t, func() bool { endpoint, err := provider.GetEndpoint("dynamic-sandbox") return err == nil && endpoint.Endpoint == "10.0.0.100" }, 3*time.Second, 100*time.Millisecond, "Informer should eventually sync the new object") } -// TestBatchSandboxProvider_StartCacheSyncFailure tests cache sync timeout func TestBatchSandboxProvider_StartCacheSyncFailure(t *testing.T) { namespace := "test-namespace" @@ -334,11 +315,9 @@ func TestBatchSandboxProvider_StartCacheSyncFailure(t *testing.T) { informerSynced: batchSandboxInformer.Informer().HasSynced, } - // Create a context that expires immediately ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) defer cancel() - // Wait for context to expire time.Sleep(10 * time.Millisecond) err := provider.Start(ctx) @@ -346,11 +325,9 @@ func TestBatchSandboxProvider_StartCacheSyncFailure(t *testing.T) { assert.Contains(t, err.Error(), "failed to sync") } -// TestBatchSandboxProvider_GetEndpointNonNotFoundError tests non-IsNotFound K8s errors func TestBatchSandboxProvider_GetEndpointNonNotFoundError(t *testing.T) { namespace := "test-namespace" - // Create a sandbox with Ready status but missing endpoint annotation batchSandbox := &sandboxv1alpha1.BatchSandbox{ ObjectMeta: metav1.ObjectMeta{ Name: "missing-endpoint-sandbox", @@ -389,11 +366,9 @@ func TestBatchSandboxProvider_GetEndpointNonNotFoundError(t *testing.T) { err := provider.Start(ctx) assert.NoError(t, err) - // Manually add object to informer cache err = batchSandboxInformer.Informer().GetStore().Add(batchSandbox) assert.NoError(t, err) - // Should successfully get endpoint endpoint, err := provider.GetEndpoint("missing-endpoint-sandbox") assert.NoError(t, err) assert.Equal(t, "10.0.0.1", endpoint.Endpoint) @@ -450,7 +425,6 @@ func TestBatchSandboxProvider_GetEndpoint_AmbiguousAcrossNamespaces(t *testing.T assert.True(t, strings.Contains(err.Error(), "ambiguous sandbox id")) } -// ptr is a helper function to create int32 pointer func ptr(i int32) *int32 { return &i } diff --git a/components/ingress/pkg/sandbox/provider.go b/components/ingress/pkg/sandbox/provider.go index 55201c0de..cdd0b4e18 100644 --- a/components/ingress/pkg/sandbox/provider.go +++ b/components/ingress/pkg/sandbox/provider.go @@ -27,7 +27,9 @@ const ( sandboxNameIndex string = "sandbox-name" - // AnnotationAccessToken marks a sandbox that requires signed ingress routes when non-empty. + // AnnotationAccessToken marks a sandbox that requires secure access when + // non-empty: requests must present the matching OpenSandbox-Secure-Access + // header token or a valid signed ingress route. AnnotationAccessToken = "opensandbox.io/secure-access-token" ) diff --git a/components/internal/cmd/supervisor/main_test.go b/components/internal/cmd/supervisor/main_test.go index d141a0ce7..20331fb7d 100644 --- a/components/internal/cmd/supervisor/main_test.go +++ b/components/internal/cmd/supervisor/main_test.go @@ -62,7 +62,7 @@ func TestSplitOnDoubleDash(t *testing.T) { { name: "empty input", in: []string{}, - wantSupArgs: nil, // append(nil, []string{}...) returns nil + wantSupArgs: nil, wantWorkerArgs: nil, }, } diff --git a/components/internal/logger/zap.go b/components/internal/logger/zap.go index 93f280dca..7793140f1 100644 --- a/components/internal/logger/zap.go +++ b/components/internal/logger/zap.go @@ -60,7 +60,8 @@ func (r *RotateConfig) applyDefaults() { } } -// Config is the minimal configuration to align execd/ingress defaults. +// Config is the minimal configuration shared by the runtime components +// (execd, egress, ingress, nodeagent). // - JSON encoding, ISO8601 time // - Caller/stacktrace disabled // - Stdout as default output diff --git a/components/internal/safego/safe.go b/components/internal/safego/safe.go index b2e8515dc..9da969dcf 100644 --- a/components/internal/safego/safe.go +++ b/components/internal/safego/safe.go @@ -23,6 +23,9 @@ import ( runtimeutil "k8s.io/apimachinery/pkg/util/runtime" ) +// InitPanicLogger installs a panic handler that logs the recovered panic with +// its stack trace instead of crashing. http.ErrAbortHandler is swallowed +// because it is a normal control-flow signal in net/http, not a bug. func InitPanicLogger(_ context.Context, log logger.Logger) { runtimeutil.PanicHandlers = []func(context.Context, any){ func(_ context.Context, r any) { @@ -46,6 +49,9 @@ func init() { runtimeutil.ReallyCrash = false } +// Go runs f in a new goroutine with the global panic handler (see +// InitPanicLogger) attached, so an unexpected panic is logged instead of +// taking down the process. func Go(f func()) { go func() { defer runtimeutil.HandleCrash() diff --git a/components/internal/supervisor/README.md b/components/internal/supervisor/README.md index 694e37c53..834a68b53 100644 --- a/components/internal/supervisor/README.md +++ b/components/internal/supervisor/README.md @@ -55,7 +55,7 @@ Each delay is perturbed by ±`backoff-jitter` (default ±10%) to avoid thunderin ### Crashloop Circuit Breaker -A sliding-window counter tracks launches. If more than `burst-max` (default 10) launches occur within `burst-window` (default 5 min), the supervisor either: +A sliding-window counter tracks launches. If `burst-max` (default 10) launches occur within `burst-window` (default 5 min), the supervisor either: - **Exits non-zero** (`--on-burst-exit=true`, default) — surfacing the crashloop via Kubernetes pod status instead of silently retrying. - **Continues retrying** (`--on-burst-exit=false`) — for environments without an outer restart supervisor. diff --git a/components/internal/supervisor/events.go b/components/internal/supervisor/events.go index 9d3762f44..f90e18219 100644 --- a/components/internal/supervisor/events.go +++ b/components/internal/supervisor/events.go @@ -33,8 +33,8 @@ const ( EventShutdown = "shutdown" ) -// Event is one structured record in the supervisor's event log. Only set -// fields are emitted (omitempty everywhere) so different kinds share one type. +// Event is one structured record in the supervisor's event log. Optional +// fields are emitted only when set (omitempty) so different kinds share one type. type Event struct { TS time.Time `json:"ts"` Name string `json:"name,omitempty"` @@ -69,8 +69,7 @@ func newEventWriter(w io.Writer, name string, now func() time.Time) *eventWriter } // emit fills TS/Name and writes the event followed by a newline. Errors are -// returned to the caller so the supervisor can surface them; callers may -// choose to ignore (event logging must not abort the main loop). +// returned so callers may ignore them (event logging must not abort the main loop). func (ew *eventWriter) emit(e Event) error { if ew == nil || ew.w == nil { return nil diff --git a/components/internal/supervisor/spec.go b/components/internal/supervisor/spec.go index c51944f12..7ddc66025 100644 --- a/components/internal/supervisor/spec.go +++ b/components/internal/supervisor/spec.go @@ -82,7 +82,8 @@ type Spec struct { PostExitTimeout time.Duration // default 30s // Backoff controls inter-restart sleep. Sleep grows exponentially from - // BackoffMin to BackoffMax with ±*BackoffJitter*prev jitter. After the + // BackoffMin to BackoffMax: the previous value is doubled, clamped, and + // perturbed by ±BackoffJitter of the doubled value. After the // worker has been alive at least StableAfter, the backoff resets. BackoffMin time.Duration // default 1s BackoffMax time.Duration // default 30s @@ -92,8 +93,8 @@ type Spec struct { BackoffJitter *float64 StableAfter time.Duration // default 60s - // Crashloop circuit breaker. If more than BurstMax launches occur - // within BurstWindow, the supervisor either returns (OnBurstExit=true, + // Crashloop circuit breaker. If BurstMax launches occur within + // BurstWindow, the supervisor either returns (OnBurstExit=true, // default) so the surrounding runtime can react, or continues looping. BurstWindow time.Duration // default 5m BurstMax int // default 10 diff --git a/components/internal/supervisor/supervisor.go b/components/internal/supervisor/supervisor.go index 325eaa573..7a4cdd2af 100644 --- a/components/internal/supervisor/supervisor.go +++ b/components/internal/supervisor/supervisor.go @@ -30,8 +30,9 @@ import ( var ErrBurstExceeded = errors.New("supervisor: crashloop budget exceeded") // Run supervises the worker described by spec until ctx is cancelled or the -// crashloop budget is exhausted. It returns ctx.Err() on graceful shutdown, -// ErrBurstExceeded on burst exit, or a setup error if Spec is invalid. +// crashloop budget is exhausted (only when Spec.OnBurstExit is true; with +// OnBurstExit=false it keeps looping). It returns ctx.Err() on graceful +// shutdown, ErrBurstExceeded on burst exit, or a setup error if Spec is invalid. func Run(ctx context.Context, spec Spec) error { if spec.Cmd == "" { return errors.New("supervisor: Spec.Cmd is required") diff --git a/components/internal/supervisor/supervisor_test.go b/components/internal/supervisor/supervisor_test.go index 0117c6665..d4515c8d3 100644 --- a/components/internal/supervisor/supervisor_test.go +++ b/components/internal/supervisor/supervisor_test.go @@ -381,8 +381,8 @@ func TestRun_RejectsEmptyCmd(t *testing.T) { } } -// Sanity: parse a duration into a string and back to verify the suite runs -// when triggered by the real go test entry (not the re-exec path). +// Sanity: verify the suite runs when triggered by the real go test entry +// (not the re-exec child path). func TestSanity(t *testing.T) { _, err := strconv.Atoi("1") if err != nil { diff --git a/components/internal/telemetry/init.go b/components/internal/telemetry/init.go index c341c509b..8c9b72f1f 100644 --- a/components/internal/telemetry/init.go +++ b/components/internal/telemetry/init.go @@ -201,9 +201,9 @@ func firstEndpoint(primary, fallback string) string { return strings.TrimSpace(fallback) } -// deltaTemporalitySelector returns delta temporality for monotonic instruments -// (Counter, Histogram, ObservableCounter). Gauges and UpDownCounters keep -// the default cumulative semantics. +// deltaTemporalitySelector returns delta temporality for Counter and +// Histogram. ObservableCounter deliberately keeps the default cumulative +// semantics; Gauges and UpDownCounters are cumulative by nature. func deltaTemporalitySelector(kind sdkmetric.InstrumentKind) metricdata.Temporality { switch kind { case sdkmetric.InstrumentKindCounter, diff --git a/docs/api/index.md b/docs/api/index.md index 919d52309..736c38d3b 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -18,7 +18,7 @@ Defines the complete lifecycle interfaces for creating, managing, and destroying **Core Features:** - **Sandbox Management**: Create, list, query, and delete sandbox instances with metadata filters and pagination - **State Control**: Pause and resume sandbox execution -- **Lifecycle States**: Supports transitions across Pending -> Running -> Pausing -> Paused -> Stopping -> Terminated, and error handling with `Failed` +- **Lifecycle States**: Supports transitions across Pending -> Running -> Pausing -> Paused -> Resuming -> Stopping -> Terminated, and error handling with `Failed` - **Resource & Runtime Configuration**: Specify CPU/memory/GPU resource limits, image startup `entrypoint`, optional `secureAccess`, environment variables, and opaque `extensions` - **Image Support**: Create sandboxes from public or private registries, including registry auth - **Timeout Management**: Optional `timeout` on creation (omit or set to `null` to disable automatic expiration) with explicit renewal via API @@ -39,6 +39,7 @@ Defines the complete lifecycle interfaces for creating, managing, and destroying - `POST /sandboxes/{sandboxId}/renew-expiration` - Renew sandbox expiration (TTL) - `PATCH /sandboxes/{sandboxId}/metadata` - Patch sandbox metadata (JSON Merge Patch, RFC 7396) - `GET /sandboxes/{sandboxId}/endpoints/{port}` - Get an access endpoint for a service port +- `POST /metrics/events` - Report SDK-side metrics events (e.g. sandbox creation latency) **Authentication:** - HTTP Header: `OPEN-SANDBOX-API-KEY: your-api-key` @@ -51,8 +52,8 @@ Defines the complete lifecycle interfaces for creating, managing, and destroying Defines best-effort troubleshooting descriptors for sandbox diagnostic logs and events. The descriptors either embed plain-text diagnostic content inline or return a download URL for the content. This spec does not define a structured audit or observability model. **Main Endpoints (base path `/v1`):** -- `GET /sandboxes/{sandboxId}/diagnostics/logs` - Retrieve a diagnostic log content descriptor for an optional scope -- `GET /sandboxes/{sandboxId}/diagnostics/events` - Retrieve a diagnostic event content descriptor for an optional scope +- `GET /sandboxes/{sandboxId}/diagnostics/logs` - Retrieve a diagnostic log content descriptor for the requested scope +- `GET /sandboxes/{sandboxId}/diagnostics/events` - Retrieve a diagnostic event content descriptor for the requested scope **Authentication:** - HTTP Header: `OPEN-SANDBOX-API-KEY: your-api-key` @@ -62,7 +63,7 @@ Defines best-effort troubleshooting descriptors for sandbox diagnostic logs and **Code Execution API Inside Sandbox** -Defines interfaces for executing code, commands, and file operations within sandbox environments, providing complete code interpreter and filesystem management capabilities. All endpoints require the `X-EXECD-ACCESS-TOKEN` header. +Defines interfaces for executing code, commands, and file operations within sandbox environments, providing complete code interpreter and filesystem management capabilities. Endpoints require the `X-EXECD-ACCESS-TOKEN` header when the server is started with `--access-token` (optional by default). **Core Features:** - **Code Execution**: Stateful code execution supporting Python, JavaScript, and other languages with context lifecycle management @@ -117,10 +118,13 @@ Defines interfaces for executing code, commands, and file operations within sand **Isolated Execution (base path `/v1/isolated`):** - `POST /session` - Create an isolated bash session +- `GET /sessions` - List isolated sessions - `GET /capabilities` - Get isolator capabilities - `GET /session/{sessionId}` - Get isolated session state - `DELETE /session/{sessionId}` - Delete an isolated session - `POST /session/{sessionId}/run` - Run code in an isolated session (SSE streaming) +- `GET /session/{sessionId}/runs/{runId}` - Get a background run result +- `GET /session/{sessionId}/runs/{runId}/logs` - Fetch accumulated run output - `GET /session/{sessionId}/diff` - Download upper directory diff - `POST /session/{sessionId}/commit` - Commit upper changes to workspace - `GET /session/{sessionId}/files/info` - Get file information @@ -151,8 +155,15 @@ the sandbox endpoint for the egress port and then calling the sidecar endpoint d **Main Endpoints:** - `GET /policy` - Get the current egress policy +- `POST` / `PUT` `/policy` - Replace the current egress policy (empty body resets to deny-all) - `PATCH /policy` - Merge new egress rules into the current policy - `DELETE /policy` - Remove specific egress rules from the current policy by target +- `POST /credential-vault` - Create a sandbox-local Credential Vault +- `GET /credential-vault` - Fetch the current vault state +- `PATCH /credential-vault` - Mutate vault credentials/bindings +- `DELETE /credential-vault` - Delete the vault +- `GET /credential-vault/credentials[/{name}]` - Read individual credentials +- `GET /credential-vault/bindings[/{name}]` - Read individual bindings ## Technical Features @@ -160,6 +171,7 @@ the sandbox endpoint for the egress port and then calling the sidecar endpoint d Code execution and command execution interfaces use SSE for real-time streaming output, supporting the following event types: - `init` - Initialization event +- `ping` - Keep-alive event - `status` - Status update - `stdout` / `stderr` - Standard output/error streams - `result` - Execution result diff --git a/docs/architecture/index.md b/docs/architecture/index.md index 0e34c1d89..a1935babb 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -115,7 +115,8 @@ Command and code execution use Server-Sent Events for streaming output. The curr `specs/egress-api.yaml` defines the runtime policy API exposed directly by the egress sidecar: - `GET /policy` -- `PATCH /policy` +- `POST` / `PUT` / `PATCH` / `DELETE` `/policy` +- `/credential-vault*` — sandbox-local Credential Vault management (create, inspect, mutate, delete) The API is reached by resolving the sandbox endpoint for the egress sidecar port. When sidecar authentication is enabled, callers must include the endpoint headers returned by the lifecycle endpoint resolution API. diff --git a/docs/cli/index.md b/docs/cli/index.md index b9331e0e9..da74b0b06 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -307,6 +307,7 @@ The main command groups are: - `osb command`: command execution and persistent sessions - `osb file`: file and directory operations - `osb egress`: runtime egress policy +- `osb credential-vault`: manage the Credential Vault - `osb diagnostics`: stable diagnostics logs and events - `osb devops`: experimental legacy diagnostics - `osb config`: local CLI configuration @@ -331,6 +332,7 @@ Bundled skills: - `command-execution` - `file-operations` - `network-egress` +- `credential-vault` - `sandbox-troubleshooting` Supported targets: diff --git a/docs/components/egress.md b/docs/components/egress.md index 1eebc3f6a..cf3191866 100644 --- a/docs/components/egress.md +++ b/docs/components/egress.md @@ -179,7 +179,7 @@ The credential vault provides automatic credential injection for outbound reques Prerequisites: transparent mitmproxy enabled (`OPENSANDBOX_EGRESS_MITMPROXY_TRANSPARENT=true`), egress API auth token set (`OPENSANDBOX_EGRESS_TOKEN`). -Supported auth types: `bearer`, `basic`, `apiKey`, `customHeaders`. +Supported auth types: `bearer`, `basic`, `apiKey`, `customHeaders`, `passthrough`. See [Credential Vault](/guides/credential-vault) for full API usage, binding rules, and security model. @@ -196,7 +196,7 @@ explicitly: 0.001 0.0025 0.005 0.01 0.025 0.05 0.1 0.25 0.5 1 2.5 5 10 15 30 60 120 300 600 ``` -The head resolves a cache hit up to one upstream timeout +The head spans a fast single-upstream exchange (sub-millisecond) up to one upstream timeout (`OPENSANDBOX_EGRESS_DNS_UPSTREAM_TIMEOUT`, 5s by default). The coarse tail is there because the recorded duration covers the **whole resolver chain** — forwarding walks the upstreams serially with the full timeout each, so a query can legitimately take diff --git a/docs/components/execd.md b/docs/components/execd.md index 23ecc4479..5cca8e505 100644 --- a/docs/components/execd.md +++ b/docs/components/execd.md @@ -239,6 +239,8 @@ OTLP metrics export is enabled when either endpoint is set: - `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` - `OTEL_EXPORTER_OTLP_ENDPOINT` +When neither is set, execd falls back to exporting insecurely to `:4318` when `HOST_IP` (or `/etc/hostinfo`) contains a valid IP. + ### Local Metrics Endpoints - `GET /metrics`: point-in-time host metrics snapshot diff --git a/docs/components/ingress.md b/docs/components/ingress.md index c297fe17f..1754cb347 100644 --- a/docs/components/ingress.md +++ b/docs/components/ingress.md @@ -47,9 +47,8 @@ curl -H "Host: my-sandbox-8080.example.com" https://ingress.opensandbox.io/api/u ``` **Parsing logic:** -- Extracts sandbox ID and port from the format `-` -- The last segment after the last `-` is treated as the port -- Everything before the last `-` is treated as the sandbox ID +- Header mode first attempts to parse the header/Host label as an OSEP-0011 signed route token (`---`); on failure it falls back to the legacy `-` format. +- For the legacy format: the last segment after the last `-` is treated as the port; everything before the last `-` is treated as the sandbox ID. ### URI Mode (`--mode uri`) @@ -69,9 +68,8 @@ wss://ingress.opensandbox.io/my-sandbox/8080/ws ``` **Parsing logic:** -- First path segment: sandbox ID -- Second path segment: sandbox port -- Remaining path: forwarded to the target sandbox as the request URI +- The path is first parsed as an OSEP-0011 signed route (`/////`); if that fails, the legacy format applies. +- Legacy format: first path segment = sandbox ID; second path segment = sandbox port; remaining path is forwarded to the target sandbox as the request URI. - If no remaining path is provided, defaults to `/` **Use cases:** @@ -79,6 +77,19 @@ wss://ingress.opensandbox.io/my-sandbox/8080/ws - When you need path-based routing - For simpler client configuration without custom headers +## Secure Access (OSEP-0011) + +Sandboxes created with `secureAccess` are annotated with a per-sandbox access token and require signed routes. The ingress enforces access when the target sandbox is secure: + +- If the request carries an `OpenSandbox-Secure-Access` header, its value is compared (constant-time) against the sandbox's annotation token; a mismatch returns **401** with no signature fallback. +- Otherwise the request must carry a valid signed route (host label or URI path with `expires` + `signature` segments); the signature is verified against the keys configured via `--secure-access-keys` and its expiry is checked. Requests without a signature return **401**; if no verifier keys are configured, the ingress returns **503**. + +| Flag | Default | Description | +|------|---------|-------------| +| `--secure-access-keys` | `` | OSEP-0011 verification keys as comma-separated `key_id=base64` entries (key_id is one char `[0-9a-z]`) | + +See [Secure Access](/guides/secure-access) for the full guide. + ## Auto-Renew on Ingress Access (OSEP-0009) When enabled, the ingress publishes **renew-intent** events to a Redis list on each proxied request (after resolving the sandbox). The OpenSandbox server consumes these events and may extend sandbox expiration for sandboxes that opted in at creation time. @@ -154,7 +165,7 @@ TAG=local VERSION=1.2.3 GIT_COMMIT=abc BUILD_TIME=2025-01-01T00:00:00Z bash buil - `ErrSandboxNotFound` (sandbox resource not exists) -> HTTP 404 - `ErrSandboxNotReady` (not enough replicas, missing endpoints, invalid config) -> HTTP 503 - Other errors (K8s API errors, etc.) -> HTTP 502 -- WebSocket path forwards essential headers and X-Forwarded-*; HTTP path strips `OpenSandbox-Ingress-To` before proxying (header mode only). +- WebSocket path forwards essential headers and X-Forwarded-*; both HTTP and WebSocket paths strip `OpenSandbox-Ingress-To` and `OpenSandbox-Secure-Access` before proxying (all routing modes). ## Development & Tests ```bash diff --git a/docs/components/server.md b/docs/components/server.md index 29ef27d06..eb478efde 100644 --- a/docs/components/server.md +++ b/docs/components/server.md @@ -101,7 +101,7 @@ Authentication is enforced only when `server.api_key` is set. If the value is em Strongly recommend enabling `server.api_key`. See [security report Issue #750](https://github.com/opensandbox-group/OpenSandbox/issues/750). ::: -All API endpoints (except `/health`, `/docs`, `/redoc`) require authentication via the `OPEN-SANDBOX-API-KEY` header when authentication is enabled: +All API endpoints (except the documented public routes `/health`, `/version`, `/docs`, `/redoc`, `/openapi.json`, and proxy-to-sandbox routes in single-tenant mode) require authentication via the `OPEN-SANDBOX-API-KEY` header when authentication is enabled: ```bash curl -H "OPEN-SANDBOX-API-KEY: your-secret-api-key" http://localhost:8080/v1/sandboxes @@ -252,13 +252,9 @@ restart, and Kubernetes keeps `spec.expireTime` on the workload. A newly created replacement receives its own ID and expiration time from its new create request. -## Experimental Features +## Auto-Renew on Access -Optional experimental behavior; off by default. See release notes before production. - -### Auto-Renew on Access - -Extends sandbox TTL when traffic is observed (lifecycle proxy and/or ingress + optional Redis queue). Per-sandbox: on create, set `extensions["access.renew.extend.seconds"]` (string integer 300-86400). Clients using the server proxy: request endpoints with `use_server_proxy=true` (REST) or SDK `ConnectionConfig(..., use_server_proxy=True)`. +Extends sandbox TTL when traffic is observed (lifecycle proxy and/or ingress + optional Redis queue). Per-sandbox: on create, set `extensions["access.renew.extend.seconds"]` (string integer 300-86400). Clients using the server proxy: request endpoints with `use_server_proxy=true` (REST) or SDK `ConnectionConfig(..., use_server_proxy=True)`. Off by default; see release notes before production. ## Development diff --git a/docs/examples/aio-sandbox.md b/docs/examples/aio-sandbox.md index a5ef609a0..c6c523fce 100644 --- a/docs/examples/aio-sandbox.md +++ b/docs/examples/aio-sandbox.md @@ -63,7 +63,7 @@ Run the example (it will create a sandbox via OpenSandbox, wait until it's Runni uv run python examples/aio-sandbox/main.py ``` -Subsequently, you will instantiate an AIO sandbox, navigate to Google, capture a screenshot, and download it to your local environment. +Subsequently, you will instantiate an AIO sandbox, run a shell command (`ls -la`), read a file (`.bashrc`), capture a screenshot of the portal's default page, and download it to your local environment. ```text Creating AIO sandbox with image=ghcr.io/agent-infra/sandbox:latest on OpenSandbox server http://localhost:8080... diff --git a/docs/examples/chrome.md b/docs/examples/chrome.md index a2eaeeb7d..8be139fec 100644 --- a/docs/examples/chrome.md +++ b/docs/examples/chrome.md @@ -41,9 +41,10 @@ opensandbox-server ## Create and access a Chrome sandbox -Build/pull the image above, then create a sandbox with image `opensandbox/chrome:latest` and an entrypoint that keeps it -alive (e.g., `["/bin/sh", "-c", "sleep infinity"]`), or reuse `tail -f /dev/null`. Make sure the runtime exposes ports -`5901` and `9222` for VNC/DevTools. +Build/pull the image above, then create a sandbox with image `opensandbox/chrome:latest`. The image ships an +`/entrypoint` supervisor that starts Xtigervnc and the Chrome launcher; the example passes +`entrypoint=["/entrypoint"]` explicitly, so the VNC (`5901`) and DevTools (`9222`) ports come up on their own. +(Using a plain `sleep infinity`/`tail -f /dev/null` entrypoint will NOT start the VNC/Chrome stack.) ```shell uv pip install opensandbox diff --git a/docs/examples/codex-cli.md b/docs/examples/codex-cli.md index 8a0a97049..147854068 100644 --- a/docs/examples/codex-cli.md +++ b/docs/examples/codex-cli.md @@ -36,7 +36,7 @@ uv pip install opensandbox uv run python examples/codex-cli/main.py ``` -The script installs the Codex CLI (`npm install -g @openai/codex@latest`) at runtime (Node.js is already in the code-interpreter image), then executes a simple request `codex exec "Compute 1+1 and return JSON with keys result and reasoning." --skip-git-repo-check`. Auth is passed via `OPENAI_API_KEY`; you can override endpoint/model with `OPENAI_BASE_URL` / `OPENAI_MODEL`. +The script installs the Codex CLI (`npm install -g @openai/codex@latest`) at runtime (Node.js is already in the code-interpreter image), then executes a simple request `codex exec "Compute 1+1=?." --skip-git-repo-check`. Auth is passed via `OPENAI_API_KEY`; you can override endpoint/model with `OPENAI_BASE_URL` / `OPENAI_MODEL`. ## Environment Variables diff --git a/docs/examples/desktop.md b/docs/examples/desktop.md index 84dd473e3..da84b052f 100644 --- a/docs/examples/desktop.md +++ b/docs/examples/desktop.md @@ -48,7 +48,7 @@ uv run python examples/desktop/main.py ``` The script starts the desktop stack (Xvfb + XFCE + x11vnc) and also launches noVNC/websockify. It prints: -- VNC endpoint (`endpoint.endpoint`) for native VNC clients when direct endpoint mode is enabled, password from `VNC_PASSWORD` (default: `opensandbox`) +- VNC endpoint (`endpoint.endpoint`) for native VNC clients when direct endpoint mode is enabled, password from `VNC_PASSWORD` (required, no default) - noVNC URL for browsers (`/vnc.html?host=...&port=...&path=...`) The sandbox stays alive for 5 minutes by default; interrupt sooner with Ctrl+C. Uses the prebuilt desktop image by default. @@ -142,7 +142,7 @@ clients, so the example omits the native VNC endpoint in this mode. | `SANDBOX_API_KEY` | _(optional for local)_ | Example-specific API key; takes precedence over `OPEN_SANDBOX_API_KEY` | | `OPEN_SANDBOX_API_KEY` | _(optional for local)_ | SDK-standard API key fallback when `SANDBOX_API_KEY` is unset | | `SANDBOX_IMAGE` | `opensandbox/desktop:latest` | Sandbox image to use | -| `VNC_PASSWORD` | `opensandbox` | Password for VNC access | +| `VNC_PASSWORD` | _(required)_ | Password for VNC access; the example fails fast if unset | ## References diff --git a/docs/examples/openclaw.md b/docs/examples/openclaw.md index c672db478..dda8c0283 100644 --- a/docs/examples/openclaw.md +++ b/docs/examples/openclaw.md @@ -21,10 +21,12 @@ uv run python examples/openclaw/main.py | Variable | Default | Description | |----------|---------|-------------| -| `OPENCLAW_SERVER` | `http://localhost:8080` | OpenSandbox server address | -| `OPENCLAW_TOKEN` | `dummy-token-for-sandbox` | Gateway authentication token | +| `OPEN_SANDBOX_SERVER` | `http://localhost:8080` | OpenSandbox server address | +| `OPEN_SANDBOX_API_KEY` | *(empty)* | API key for the OpenSandbox server | | `OPENCLAW_IMAGE` | `ghcr.io/openclaw/openclaw:latest` | Container image | | `OPENCLAW_TIMEOUT` | `3600` | Sandbox timeout in seconds | +| `OPENCLAW_PORT` | `18789` | OpenClaw gateway port | +| `OPENCLAW_GATEWAY_TOKEN` | value of `OPENCLAW_TOKEN` (default `dummy-token-for-sandbox`) | Gateway authentication token | ## Network Policy @@ -114,10 +116,10 @@ Openclaw started finished. Please refer to 127.0.0.1:56123 ## Advanced: Custom Gateway Port -To use a custom port, modify the `entrypoint` in `main.py`: +To use a custom port, modify the `entrypoint` in `main.py` (each argument as a separate list element): ```python -entrypoint=["node dist/index.js gateway --bind=lan --port 19999 --allow-unconfigured --verbose"], +entrypoint=["node", "dist/index.js", "gateway", "--bind=lan", "--port", "19999", "--allow-unconfigured", "--verbose"], ``` Then update the port in the `get_endpoint()` call: diff --git a/docs/examples/windows.md b/docs/examples/windows.md index 82697dad4..ba72bfd9c 100644 --- a/docs/examples/windows.md +++ b/docs/examples/windows.md @@ -109,7 +109,6 @@ Do not manually set `CPU_CORES`, `RAM_SIZE`, or `DISK_SIZE` -- they are derived | Port | Service | |------|---------| | 44772 | execd (sandbox execution API) | -| 8080 | HTTP service | | 3389 | RDP (native Remote Desktop) | | 8006 | Web console (noVNC) | diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index e4903abcf..be78a18dc 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -50,13 +50,13 @@ If `server.api_key` is empty, the server runs without authentication. In non-int | Section | Description | |---------|-------------| | `[server]` | Host, port, API key, and general server settings | -| `[docker]` | Docker runtime: `network_mode`, `host_ip`, image registry | +| `[docker]` | Docker runtime: `network_mode`, `host_ip`, port range, security options | | `[kubernetes]` | Kubernetes runtime: `workload_provider`, `batchsandbox_template_file` | | `[egress]` | Egress sidecar for `networkPolicy` enforcement | | `[ingress]` | Ingress gateway configuration | | `[secure_runtime]` | Secure container runtime (gVisor, Kata, Firecracker) | | `[store]` | Persistence backend (default: SQLite at `~/.opensandbox/opensandbox.db`) | -| `[renew_intent]` | Auto-renew on access (experimental) | +| `[renew_intent]` | Auto-renew sandbox expiration on ingress access | | `[agent_sandbox]` | Agent sandbox settings for Kubernetes | For the full configuration reference with all keys and defaults, see the [server configuration.md](https://github.com/opensandbox-group/OpenSandbox/blob/main/server/configuration.md). diff --git a/docs/guides/pause-resume.md b/docs/guides/pause-resume.md index 6bb6e891a..87abcf53e 100644 --- a/docs/guides/pause-resume.md +++ b/docs/guides/pause-resume.md @@ -60,7 +60,7 @@ The sandbox transitions through both stable and intermediate states: | `Resuming` | Intermediate | Resume operation in progress. The controller is rewriting the sandbox template to the latest snapshot image and recreating the runtime | | `Failed` | Stable | Operation failed (check `reason` and `message` for details) | -The Lifecycle API exposes only the coarse-grained sandbox states above. For detailed snapshot progress, inspect the internal `SandboxSnapshot` resource: +The Lifecycle API exposes the sandbox states defined in the lifecycle spec (`Pending`, `Running`, `Pausing`, `Paused`, `Resuming`, `Stopping`, `Terminated`, `Failed`). For detailed snapshot progress, inspect the internal `SandboxSnapshot` resource: - `Pending`: snapshot request accepted, waiting to resolve source Pod / create commit Job - `Committing`: commit Job is running and pushing snapshot images @@ -244,7 +244,7 @@ curl -X POST http://localhost:8080/v1/sandboxes/{sandbox_id}/pause \ **Response:** `202 Accepted` with an empty body. The pause is asynchronous. The sandbox transitions through: -`running` → `pausing` → `paused` +`Running` → `Pausing` → `Paused` ### Check pause status @@ -252,7 +252,7 @@ The pause is asynchronous. The sandbox transitions through: curl http://localhost:8080/v1/sandboxes/{sandbox_id} ``` -When `status` is `paused`, the filesystem has been committed and cluster resources have been released. +When `status` is `Paused`, the filesystem has been committed and cluster resources have been released. ### Resume a sandbox @@ -264,7 +264,7 @@ curl -X POST http://localhost:8080/v1/sandboxes/{sandbox_id}/resume \ **Response:** `202 Accepted` with an empty body. The sandbox transitions through: -`paused` → `resuming` → `running` +`Paused` → `Resuming` → `Running` ### Multiple pause/resume cycles diff --git a/docs/kubernetes/index.md b/docs/kubernetes/index.md index a1104c65a..010cc5566 100644 --- a/docs/kubernetes/index.md +++ b/docs/kubernetes/index.md @@ -220,7 +220,7 @@ This project requires two separate images - one for the controller and another f You can install OpenSandbox Controller directly from GitHub Releases. Check the [Releases page](https://github.com/opensandbox-group/OpenSandbox/releases?q=helm%2Fopensandbox-controller&expanded=true) for all available versions. ```sh -# Replace with the desired version (e.g., 0.1.0) +# Replace with the desired version (e.g., 0.2.0) helm install opensandbox-controller \ https://github.com/opensandbox-group/OpenSandbox/releases/download/helm/opensandbox-controller//opensandbox-controller-.tgz \ --namespace opensandbox-system \ @@ -233,7 +233,7 @@ Use `--set` flags to customize the configuration: ```sh helm install opensandbox-controller \ - https://github.com/opensandbox-group/OpenSandbox/releases/download/helm/opensandbox-controller/0.1.0/opensandbox-controller-0.1.0.tgz \ + https://github.com/opensandbox-group/OpenSandbox/releases/download/helm/opensandbox-controller/0.2.0/opensandbox-controller-0.2.0.tgz \ --namespace opensandbox-system \ --create-namespace \ --set controller.replicaCount=2 \ @@ -258,7 +258,7 @@ controller: EOF helm install opensandbox-controller \ - https://github.com/opensandbox-group/OpenSandbox/releases/download/helm/opensandbox-controller/0.1.0/opensandbox-controller-0.1.0.tgz \ + https://github.com/opensandbox-group/OpenSandbox/releases/download/helm/opensandbox-controller/0.2.0/opensandbox-controller-0.2.0.tgz \ --namespace opensandbox-system \ --create-namespace \ -f custom-values.yaml @@ -543,15 +543,15 @@ The time complexity of SIG Agent-Sandbox and BatchSandbox for batch delivery of ``` kubernetes/ - api/v1alpha1/ # Custom resource definitions (BatchSandbox, Pool) + apis/sandbox/v1alpha1/ # Custom resource types (BatchSandbox, Pool, SandboxSnapshot) cmd/controller/ # Main controller manager entry point cmd/task-executor/ # Task executor binary config/crd/ # CRD manifests config/manager/ # Controller manager configuration config/rbac/ # RBAC manifests config/samples/ # Sample YAML manifests - internal/controller/ # Core controller implementations - internal/scheduler/ # Resource allocation and scheduling logic + internal/controller/ # Core controller implementations (allocator, eviction, pause/resume) + internal/scheduler/ # In-process task scheduling (task-to-pod assignment) internal/task-executor/# Task execution engine internals pkg/task-executor/ # Shared task executor packages test/ # Test suites and utilities diff --git a/docs/sdks/csharp.md b/docs/sdks/csharp.md index e02b49407..a172a7814 100644 --- a/docs/sdks/csharp.md +++ b/docs/sdks/csharp.md @@ -411,7 +411,6 @@ await sandbox.CreateCredentialVaultAsync( Match = new CredentialMatch { Schemes = new[] { "https" }, - Ports = new[] { 443 }, Hosts = new[] { "api.example.com" }, Paths = new[] { "/v1/*" } }, diff --git a/docs/sdks/go.md b/docs/sdks/go.md index 884b0d8c3..bdc3df45f 100644 --- a/docs/sdks/go.md +++ b/docs/sdks/go.md @@ -150,7 +150,6 @@ _, err = sandbox.CreateCredentialVault(ctx, opensandbox.CredentialVaultCreateReq Name: "api-token", Match: opensandbox.CredentialMatch{ Schemes: []opensandbox.CredentialScheme{opensandbox.CredentialSchemeHTTPS}, - Ports: []int{443}, Hosts: []string{"api.example.com"}, Paths: []string{"/v1/*"}, }, diff --git a/docs/sdks/mcp.md b/docs/sdks/mcp.md index e241d253f..a989215af 100644 --- a/docs/sdks/mcp.md +++ b/docs/sdks/mcp.md @@ -48,6 +48,7 @@ Config fields: - `protocol`: `http` or `https` for API requests. - `request_timeout_seconds`: HTTP request timeout in seconds. - `transport`: `stdio` by default, or `streamable-http`. +- `use_server_proxy`: when present, forces the SDK client to use server proxy mode. ### Streamable HTTP @@ -105,7 +106,7 @@ claude mcp add opensandbox-sandbox --transport http http://localhost:8000/mcp ::: info - All tools operate on a `sandbox_id` returned by `sandbox_create` or `sandbox_connect`. -- `file_read`/`file_write` are text-only; use `encoding` and `range_header` for large files. +- `file_read`/`file_write` are text-only; `file_read` supports `encoding` and `range_header` for large files. ::: ### Sandbox diff --git a/examples/playwright/main.py b/examples/playwright/main.py index eee4699bd..53254e53b 100644 --- a/examples/playwright/main.py +++ b/examples/playwright/main.py @@ -52,8 +52,11 @@ async def main() -> None: request_timeout=timedelta(seconds=60), ) - # Inject Python version into container environment - env = {"PYTHON_VERSION": python_version} + # Inject Python version and target URL into the container environment + env = { + "PYTHON_VERSION": python_version, + "TARGET_URL": os.getenv("TARGET_URL", "https://example.com"), + } sandbox = await Sandbox.create( image, connection_config=config, diff --git a/kubernetes/AGENTS.md b/kubernetes/AGENTS.md index 2e73d2f2f..fa5187922 100644 --- a/kubernetes/AGENTS.md +++ b/kubernetes/AGENTS.md @@ -48,6 +48,7 @@ The controller communicates allocation state through annotations on BatchSandbox - `sandbox.opensandbox.io/alloc-status`: JSON `{"pods":["pod-1","pod-2"]}` — current pod allocation - `sandbox.opensandbox.io/alloc-release`: JSON `{"pods":["pod-3"]}` — pods released back to pool +- `sandbox.opensandbox.io/alloc-released`: JSON `{"pods":["pod-3"]}` — pods already released (written on release, read on recovery) - `sandbox.opensandbox.io/endpoints`: JSON endpoint list consumed by server-side endpoint resolution Do not change annotation keys or JSON shapes without updating both writers and all readers, including controller tests and any server-side Kubernetes integration that parses them. @@ -57,6 +58,8 @@ Do not change annotation keys or JSON shapes without updating both writers and a - `sandbox.opensandbox.io/pool-name`: labels pool-owned pods - `sandbox.opensandbox.io/pool-revision`: revision hash for rolling updates - `batch-sandbox.sandbox.opensandbox.io/pod-index`: pod index within a BatchSandbox +- `batch-sandbox.sandbox.opensandbox.io/name`: owning BatchSandbox name (used for pause/resume pod lookup) +- `sandbox.opensandbox.io/privileged-node-access`: marks pods eligible for privileged node access (snapshot commit Jobs) - `pool.opensandbox.io/evict`: marks idle pool pods for eviction - `pool.opensandbox.io/eviction-handler`: selects pool eviction handler implementation diff --git a/kubernetes/DEVELOPMENT.md b/kubernetes/DEVELOPMENT.md index 34479ecdc..775fbc04a 100644 --- a/kubernetes/DEVELOPMENT.md +++ b/kubernetes/DEVELOPMENT.md @@ -102,7 +102,7 @@ kubernetes/ │ ├── e2e/ # Core e2e tests (Kind-based) │ ├── e2e_task/ # Task-executor e2e tests │ ├── e2e_runtime/ # RuntimeClass e2e (gVisor) -│ └── kind/ # Kind cluster configs +│ └── utils/ # Shared e2e test helpers └── docs/ # Design documents ├── proposals/ # Design proposals for new features and significant changes │ ├── YYYYMMDD-template.md # Proposal template @@ -159,11 +159,9 @@ BatchSandboxReconciler.Reconcile └─ assigns tasks to pods via task-executor HTTP API ``` -The `task-executor` binary runs as a sidecar inside sandbox pods. It exposes an HTTP API on port 5758 for task lifecycle management (create, list, stop). It supports two runtime modes: -- **Process executor**: runs commands directly on the host -- **Container executor**: manages containers via CRI +The `task-executor` binary runs as a sidecar inside sandbox pods. It exposes an HTTP API on port 5758 for task lifecycle management (create, list, stop). The process executor runs task commands either locally in the task-executor container or inside the main sandbox container (via nsenter); a CRI-based container executor exists only as a placeholder and returns "not implemented yet". -The `compositeExecutor` dispatches to the appropriate runtime based on the task type (tasks with `Process` field use process executor, tasks with `PodTemplateSpec` use container executor). +The `compositeExecutor` dispatches to the appropriate runtime based on the task type (tasks with a `Process` field use the process executor; tasks without one are routed to the container executor, which is not implemented yet). ### Strategy Pattern @@ -410,6 +408,8 @@ The controller communicates allocation state through annotations on BatchSandbox |---|---|---|---| | `sandbox.opensandbox.io/alloc-status` | `{"pods":["pod-1"]}` | `allocator.go` via `apis.go` | `batchsandbox_controller.go` | | `sandbox.opensandbox.io/alloc-release` | `{"pods":["pod-3"]}` | `batchsandbox_controller.go` | `allocator.go` | +| `sandbox.opensandbox.io/alloc-released` | `{"pods":["pod-3"]}` | `allocator.go` | `allocator.go` (recovery) | +| `sandbox.opensandbox.io/endpoints` | JSON endpoint list | server-side K8s integration | endpoint resolution | When changing annotation shapes, update all readers and writers, and add migration logic if the change is not backward-compatible. diff --git a/kubernetes/apis/sandbox/v1alpha1/batchsandbox_types.go b/kubernetes/apis/sandbox/v1alpha1/batchsandbox_types.go index b4ee0560e..f5559c015 100644 --- a/kubernetes/apis/sandbox/v1alpha1/batchsandbox_types.go +++ b/kubernetes/apis/sandbox/v1alpha1/batchsandbox_types.go @@ -153,9 +153,9 @@ type BatchSandboxStatus struct { ObservedGeneration int64 `json:"observedGeneration,omitempty"` // Replicas is the number of actual Pods Replicas int32 `json:"replicas"` - // Allocated is the number of actual scheduled Pod + // Allocated is the number of scheduled Pods Allocated int32 `json:"allocated"` - // Ready is the number of actual Ready Pod + // Ready is the number of Ready Pods Ready int32 `json:"ready"` // TaskRunning is the number of Running task TaskRunning int32 `json:"taskRunning"` diff --git a/kubernetes/apis/sandbox/v1alpha1/pool_types.go b/kubernetes/apis/sandbox/v1alpha1/pool_types.go index d20d412f0..6ebbb1dc8 100644 --- a/kubernetes/apis/sandbox/v1alpha1/pool_types.go +++ b/kubernetes/apis/sandbox/v1alpha1/pool_types.go @@ -20,9 +20,6 @@ import ( "k8s.io/apimachinery/pkg/util/intstr" ) -// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! -// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. - // RecycleType defines the type of recycle policy. type RecycleType string @@ -107,8 +104,8 @@ type UpdateStrategy struct { // PoolStatus defines the observed state of Pool. type PoolStatus struct { - // ObservedGeneration is the most recent generation observed for this BatchSandbox. It corresponds to the - // BatchSandbox's generation, which is updated on mutation by the API Server. + // ObservedGeneration is the most recent generation observed for this Pool. + // It corresponds to the Pool's generation, which is updated on mutation by the API Server. ObservedGeneration int64 `json:"observedGeneration,omitempty"` // Revision is the latest version of pool Revision string `json:"revision"` diff --git a/kubernetes/charts/opensandbox-controller/templates/crds/pools.yaml b/kubernetes/charts/opensandbox-controller/templates/crds/pools.yaml index 148d137f7..0ba7ce47c 100644 --- a/kubernetes/charts/opensandbox-controller/templates/crds/pools.yaml +++ b/kubernetes/charts/opensandbox-controller/templates/crds/pools.yaml @@ -36,6 +36,13 @@ spec: jsonPath: .status.available name: AVAILABLE type: integer + - description: The number of nodes updated to the latest revision. + jsonPath: .status.updated + name: UPDATED + type: integer + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date name: v1alpha1 schema: openAPIV3Schema: @@ -158,8 +165,8 @@ spec: type: integer observedGeneration: description: |- - ObservedGeneration is the most recent generation observed for this BatchSandbox. It corresponds to the - BatchSandbox's generation, which is updated on mutation by the API Server. + ObservedGeneration is the most recent generation observed for this Pool. It corresponds to the + Pool's generation, which is updated on mutation by the API Server. format: int64 type: integer revision: @@ -169,6 +176,11 @@ spec: description: Total is the total number of nodes in the pool. format: int32 type: integer + updated: + description: Updated is the number of nodes that have been updated + to the latest revision. + format: int32 + type: integer required: - allocated - available diff --git a/kubernetes/cmd/controller/main.go b/kubernetes/cmd/controller/main.go index 35e7d426b..afbda0a93 100644 --- a/kubernetes/cmd/controller/main.go +++ b/kubernetes/cmd/controller/main.go @@ -350,11 +350,6 @@ func main() { // If the certificate is not specified, controller-runtime will automatically // generate self-signed certificates for the metrics server. While convenient for development and testing, // this setup is not recommended for production. - // - // TODO(user): If you enable certManager, uncomment the following lines: - // - [METRICS-WITH-CERTS] at config/default/kustomization.yaml to generate and use certificates - // managed by cert-manager for the metrics server. - // - [PROMETHEUS-WITH-CERTS] at config/prometheus/kustomization.yaml for TLS certification. if len(metricsCertPath) > 0 { metricsCertFile := filepath.Join(metricsCertPath, metricsCertName) metricsKeyFile := filepath.Join(metricsCertPath, metricsCertKey) diff --git a/kubernetes/config/crd/bases/sandbox.opensandbox.io_pools.yaml b/kubernetes/config/crd/bases/sandbox.opensandbox.io_pools.yaml index 04310c18d..c8cf17dac 100644 --- a/kubernetes/config/crd/bases/sandbox.opensandbox.io_pools.yaml +++ b/kubernetes/config/crd/bases/sandbox.opensandbox.io_pools.yaml @@ -156,8 +156,8 @@ spec: type: integer observedGeneration: description: |- - ObservedGeneration is the most recent generation observed for this BatchSandbox. It corresponds to the - BatchSandbox's generation, which is updated on mutation by the API Server. + ObservedGeneration is the most recent generation observed for this Pool. It corresponds to the + Pool's generation, which is updated on mutation by the API Server. format: int64 type: integer revision: diff --git a/kubernetes/docs/HELM-DEPLOYMENT.md b/kubernetes/docs/HELM-DEPLOYMENT.md index 78120f946..53bbf2382 100644 --- a/kubernetes/docs/HELM-DEPLOYMENT.md +++ b/kubernetes/docs/HELM-DEPLOYMENT.md @@ -15,9 +15,9 @@ This document describes how to deploy the OpenSandbox Controller using Helm Char Download and install the published chart package directly: ```bash -# Install the latest version (0.1.0) +# Install the latest version (0.2.0) helm install opensandbox-controller \ - https://github.com/opensandbox-group/OpenSandbox/releases/download/helm/opensandbox-controller/0.1.0/opensandbox-controller-0.1.0.tgz \ + https://github.com/opensandbox-group/OpenSandbox/releases/download/helm/opensandbox-controller/0.2.0/opensandbox-controller-0.2.0.tgz \ --namespace opensandbox-system \ --create-namespace ``` @@ -26,7 +26,7 @@ To use a custom image: ```bash helm install opensandbox-controller \ - https://github.com/opensandbox-group/OpenSandbox/releases/download/helm/opensandbox-controller/0.1.0/opensandbox-controller-0.1.0.tgz \ + https://github.com/opensandbox-group/OpenSandbox/releases/download/helm/opensandbox-controller/0.2.0/opensandbox-controller-0.2.0.tgz \ --set controller.image.repository=/controller \ --set controller.image.tag=v0.0.1 \ --namespace opensandbox-system \ @@ -91,7 +91,7 @@ helm list -n opensandbox-system Visit GitHub Releases to see all available versions: https://github.com/opensandbox-group/OpenSandbox/releases -Look for tags starting with `helm/opensandbox-controller/`, such as `helm/opensandbox-controller/0.1.0` +Look for tags starting with `helm/opensandbox-controller/`, such as `helm/opensandbox-controller/0.2.0` ### Upgrade to a Specific Version @@ -434,9 +434,9 @@ Publish Helm Charts automatically via GitHub Actions: #### Option 1: Trigger via Git Tag ```bash -# Publish opensandbox-controller chart version 0.1.0 -git tag helm/opensandbox-controller/0.1.0 -git push origin helm/opensandbox-controller/0.1.0 +# Publish opensandbox-controller chart version 0.2.0 +git tag helm/opensandbox-controller/0.2.0 +git push origin helm/opensandbox-controller/0.2.0 ``` Tag naming convention: `helm/{component}/{version}` @@ -483,7 +483,7 @@ https://github.com/opensandbox-group/OpenSandbox/releases/download/helm/{COMPONE Example: ``` -https://github.com/opensandbox-group/OpenSandbox/releases/download/helm/opensandbox-controller/0.1.0/opensandbox-controller-0.1.0.tgz +https://github.com/opensandbox-group/OpenSandbox/releases/download/helm/opensandbox-controller/0.2.0/opensandbox-controller-0.2.0.tgz ``` ### Adding a New Helm Chart Component diff --git a/kubernetes/docs/proposals/20260427-pool-auto-assign.md b/kubernetes/docs/proposals/20260427-pool-auto-assign.md index 2f6b36581..b0b1bc068 100644 --- a/kubernetes/docs/proposals/20260427-pool-auto-assign.md +++ b/kubernetes/docs/proposals/20260427-pool-auto-assign.md @@ -3,7 +3,7 @@ title: Pool Auto-Assign for BatchSandbox authors: - "@Spground" creation-date: 2026-04-27 -status: provisional +status: implemented --- # Pool Auto-Assign for BatchSandbox diff --git a/kubernetes/internal/controller/batchsandbox_controller.go b/kubernetes/internal/controller/batchsandbox_controller.go index faea7ac79..d443d08d2 100644 --- a/kubernetes/internal/controller/batchsandbox_controller.go +++ b/kubernetes/internal/controller/batchsandbox_controller.go @@ -85,10 +85,6 @@ type BatchSandboxReconciler struct { // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. -// TODO(user): Modify the Reconcile function to compare the state specified by -// the BatchSandbox object against the actual cluster state, and then -// perform operations to make the cluster state reflect the state specified by -// the user. // // For more details, check Reconcile and its Result here: // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.21.0/pkg/reconcile diff --git a/kubernetes/internal/controller/suite_test.go b/kubernetes/internal/controller/suite_test.go index 027de5781..7f2998cd0 100644 --- a/kubernetes/internal/controller/suite_test.go +++ b/kubernetes/internal/controller/suite_test.go @@ -108,7 +108,6 @@ var _ = BeforeSuite(func() { Allocator: NewDefaultAllocator(k8sManager.GetClient()), RestConfig: cfg, }).SetupWithManager(k8sManager, 128)).Should(Succeed()) - // TODO more reconciler goes HERE By("try to start manager") mgrStopped = startTestManager(ctx, k8sManager) diff --git a/kubernetes/internal/scheduler/default_scheduler_test.go b/kubernetes/internal/scheduler/default_scheduler_test.go index 274e0bc1e..55d3f1b2d 100644 --- a/kubernetes/internal/scheduler/default_scheduler_test.go +++ b/kubernetes/internal/scheduler/default_scheduler_test.go @@ -701,23 +701,19 @@ func Test_collectTaskStatus(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Create mock task status collector mockCollector := NewMocktaskStatusCollector(ctl) if len(tt.expectedCollectIPs) > 0 { mockCollector.EXPECT().Collect(gomock.Any(), tt.expectedCollectIPs).Return(tt.mockReturnTasks).Times(1) } - // Create scheduler with mock collector sch := &defaultTaskScheduler{ taskNodes: tt.taskNodes, taskStatusCollector: mockCollector, logger: testLogger, } - // Call collectTaskStatus sch.collectTaskStatus(tt.taskNodes) - // Verify results for i, expectedNode := range tt.expectedTaskNodes { actualNode := tt.taskNodes[i] @@ -749,7 +745,6 @@ func Test_collectTaskStatus(t *testing.T) { t.Errorf("taskNode[%d].tState = %v, want %v", i, actualNode.tState, expectedNode.tState) } - // Compare time pointers if expectedNode.tStateLastTransTime == nil { if actualNode.tStateLastTransTime != nil { t.Errorf("taskNode[%d].tStateLastTransTime = %v, want nil", i, actualNode.tStateLastTransTime) @@ -1141,10 +1136,8 @@ func Test_scheduleTaskNodes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Create mock task clients for each pod IP and task node mockClients := make(map[string]*MocktaskClient) - // Create task client creator function that returns mock clients taskClientCreator := func(ip string) taskClient { if mockClient, ok := mockClients[ip]; ok { return mockClient @@ -1154,7 +1147,6 @@ func Test_scheduleTaskNodes(t *testing.T) { return mockClient } - // Set expectations for Set calls for ip, expectedTask := range tt.expectedSetCalls { mockClient := mockClients[ip] if mockClient == nil { @@ -1164,7 +1156,6 @@ func Test_scheduleTaskNodes(t *testing.T) { mockClient.EXPECT().Set(gomock.Any(), expectedTask).Return(expectedTask, nil).Times(1) } - // Create scheduler sch := &defaultTaskScheduler{ taskNodes: tt.taskNodes, freePods: tt.freePods, @@ -1173,15 +1164,12 @@ func Test_scheduleTaskNodes(t *testing.T) { logger: testLogger, } - // Call scheduleTaskNodes err := sch.scheduleTaskNodes() - // Verify no error if err != nil { t.Errorf("scheduleTaskNodes() error = %v, want nil", err) } - // Verify results for i, expectedNode := range tt.expectedTaskNodes { actualNode := tt.taskNodes[i] @@ -1198,7 +1186,6 @@ func Test_scheduleTaskNodes(t *testing.T) { } } - // Verify remaining free pods if len(sch.freePods) != tt.expectedRemainingFreePods { t.Errorf("scheduleTaskNodes() remaining freePods length = %v, want %v", len(sch.freePods), tt.expectedRemainingFreePods) } diff --git a/kubernetes/internal/scheduler/recovery.go b/kubernetes/internal/scheduler/recovery.go index a1491011a..4b8a35078 100644 --- a/kubernetes/internal/scheduler/recovery.go +++ b/kubernetes/internal/scheduler/recovery.go @@ -62,9 +62,7 @@ func (sch *defaultTaskScheduler) recoverTaskNodesStatus() error { } if tNode := sch.taskNodeByNameIndex[task.Name]; tNode != nil { recoverOneTaskNode(tNode, task, pod.Status.PodIP, pod.Name, sch.logger) - } else { } - // TODO do we need to stop tasks not belong us? e.g users ScaleIn []*sandboxv1alpha1.Task } return nil } diff --git a/kubernetes/internal/scheduler/status_collector.go b/kubernetes/internal/scheduler/status_collector.go index adc8db364..6b172352b 100644 --- a/kubernetes/internal/scheduler/status_collector.go +++ b/kubernetes/internal/scheduler/status_collector.go @@ -30,12 +30,11 @@ func newTaskStatusCollector(creator taskClientCreator, logger logr.Logger) taskS return &defaultTaskStatusCollector{creator: creator, logger: logger} } -// TODO error +// taskStatusCollector gathers task state from the pods' task-executor endpoints. type taskStatusCollector interface { Collect(ctx context.Context, ipList []string) map[string]*api.Task /*ip<->task*/ } -// TODO maybe cache type defaultTaskStatusCollector struct { creator taskClientCreator logger logr.Logger diff --git a/kubernetes/internal/scheduler/types.go b/kubernetes/internal/scheduler/types.go index 45a02058a..5c1ccc2ee 100644 --- a/kubernetes/internal/scheduler/types.go +++ b/kubernetes/internal/scheduler/types.go @@ -18,8 +18,7 @@ type Task interface { GetName() string GetState() TaskState GetPodName() string - // IsResourceReleased task resource is released - // TODO func name is strange + // IsResourceReleased reports whether the task's pod resources have been released. IsResourceReleased() bool // GetTerminatedMessage returns a human-readable message when the task has // reached a terminal failure state (e.g., a lifecycle hook error with stderr). diff --git a/kubernetes/internal/task-executor/runtime/process.go b/kubernetes/internal/task-executor/runtime/process.go index 646c434a3..1e1904a8f 100644 --- a/kubernetes/internal/task-executor/runtime/process.go +++ b/kubernetes/internal/task-executor/runtime/process.go @@ -45,8 +45,10 @@ const ( lifecycleHookOutputMarker = "\n... output truncated; showing first 8 KiB and last 8 KiB ...\n" ) -// processExecutor handles both Host and Sidecar modes as they share the same -// shim-based process execution model. +// processExecutor handles both Local and Sidecar execution modes as they +// share the same shim-based process execution model: Local runs the process +// in the task-executor's own container, Sidecar enters the main sandbox +// container via nsenter. type processExecutor struct { config *config.Config rootDir string @@ -132,7 +134,7 @@ func (e *processExecutor) Start(ctx context.Context, task *types.Task) error { } else { cmd = exec.Command("/bin/sh", "-c", shimScript) cmd.Env = os.Environ() - klog.InfoS("Starting host task", "name", task.Name, "cmd", safeCmdStr, "exitPath", exitPath) + klog.InfoS("Starting local task", "name", task.Name, "cmd", safeCmdStr, "exitPath", exitPath) } cmd.SysProcAttr = &syscall.SysProcAttr{ @@ -195,7 +197,7 @@ func (e *processExecutor) executeCommand(task *types.Task, cmd *exec.Cmd, pidPat } } - // Write PID to file immediately (Host-side PID) + // Write PID to file immediately (task-executor-side PID) // This fixes the issue where sidecar tasks would write the container-internal PID pid := cmd.Process.Pid if err := os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0644); err != nil { diff --git a/kubernetes/internal/task-executor/server/handler_test.go b/kubernetes/internal/task-executor/server/handler_test.go index 3cadac5c1..59dad38db 100644 --- a/kubernetes/internal/task-executor/server/handler_test.go +++ b/kubernetes/internal/task-executor/server/handler_test.go @@ -300,7 +300,6 @@ func TestConvertInternalToAPITask(t *testing.T) { assert.False(t, apiTask.PodStatus.ContainerStatuses[1].Ready) assert.False(t, utils.IsPodReadyConditionTrue(*apiTask.PodStatus)) - // Conditions check var podReady, containersReady *corev1.PodCondition for i := range apiTask.PodStatus.Conditions { c := &apiTask.PodStatus.Conditions[i] @@ -340,7 +339,6 @@ func TestConvertInternalToAPITask(t *testing.T) { apiTask := convertInternalToAPITask(task) assert.NotNil(t, apiTask.PodStatus) - // Conditions check var podReady, containersReady *corev1.PodCondition for i := range apiTask.PodStatus.Conditions { c := &apiTask.PodStatus.Conditions[i] diff --git a/kubernetes/internal/task-executor/storage/file_store_test.go b/kubernetes/internal/task-executor/storage/file_store_test.go index ba850b77b..063e6ac64 100644 --- a/kubernetes/internal/task-executor/storage/file_store_test.go +++ b/kubernetes/internal/task-executor/storage/file_store_test.go @@ -63,7 +63,6 @@ func TestFileStore_CRUD(t *testing.T) { t.Fatalf("Create failed: %v", err) } - // Verify file exists taskDir := filepath.Join(tmpDir, task.Name) if _, err := os.Stat(taskDir); os.IsNotExist(err) { t.Error("Task directory was not created") @@ -111,7 +110,6 @@ func TestFileStore_CRUD(t *testing.T) { t.Fatalf("Delete failed: %v", err) } - // Verify deletion if _, err := store.Get(ctx, task.Name); err == nil { t.Error("Get should fail after delete") } @@ -124,7 +122,6 @@ func TestFileStore_CRUD(t *testing.T) { t.Errorf("List returned %d tasks after delete, want 0", len(tasks)) } - // Verify directory gone if _, err := os.Stat(taskDir); !os.IsNotExist(err) { t.Error("Task directory still exists after delete") } diff --git a/sdks/code-interpreter/csharp/README.md b/sdks/code-interpreter/csharp/README.md index 5d6049619..d1fc53142 100644 --- a/sdks/code-interpreter/csharp/README.md +++ b/sdks/code-interpreter/csharp/README.md @@ -322,7 +322,7 @@ Console.WriteLine($"CPU: {metrics.CpuUsedPercentage}%, Memory: {metrics.MemoryUs - **Lifecycle**: `CodeInterpreter` wraps an existing `Sandbox` and reuses its connection and services. - **Default context behavior**: `RunAsync(..., new RunCodeOptions { Language = ... })` uses the language default context. -- **Cleanup**: `DisposeAsync` only cleans local resources. Call `KillAsync()` to terminate the remote sandbox instance. +- **Cleanup**: `CodeInterpreter` has no resources of its own; dispose the wrapped `Sandbox`, and call `KillAsync()` on it to terminate the remote sandbox instance. ## License diff --git a/sdks/code-interpreter/python/src/code_interpreter/code_interpreter.py b/sdks/code-interpreter/python/src/code_interpreter/code_interpreter.py index 03a9d5125..25b555879 100644 --- a/sdks/code-interpreter/python/src/code_interpreter/code_interpreter.py +++ b/sdks/code-interpreter/python/src/code_interpreter/code_interpreter.py @@ -45,19 +45,21 @@ class CodeInterpreter: Key Features: - - Multi-language Code Execution: Support for Python, JavaScript, Bash, Java, Kotlin + - Multi-language Code Execution: Support for Python, JavaScript, TypeScript, Bash, Java, Go - Session Management: Persistent execution contexts with variable state - Sandbox Integration: Full access to underlying sandbox file system and command execution - Streaming Execution: Real-time code execution with output streaming - - Variable Inspection: Access to execution variables and state + - Variable Persistence: Variables and imports persist across executions in a context Usage Example: ```python - # First create a sandbox instance + # First create a sandbox instance. + # Use the opensandbox/code-interpreter image: it ships the Jupyter kernels + # the interpreter relies on (a plain python:3.11 image cannot run code). sandbox = await Sandbox.create( - "python:3.11", + "opensandbox/code-interpreter:v1.1.0", resource={"cpu": "1", "memory": "2Gi"} ) @@ -68,7 +70,7 @@ class CodeInterpreter: from code_interpreter.models.code import SupportedLanguage context = await interpreter.codes.create_context(SupportedLanguage.PYTHON) result = await interpreter.codes.run("print('Hello World')", context=context) - print(result.logs.stdout) # Output: Hello World + print(result.logs.stdout[0].text) # Output: Hello World # Access underlying sandbox for file operations await interpreter.sandbox.files.write_files([ diff --git a/sdks/code-interpreter/python/src/code_interpreter/models/code.py b/sdks/code-interpreter/python/src/code_interpreter/models/code.py index eed212bce..845ad7397 100644 --- a/sdks/code-interpreter/python/src/code_interpreter/models/code.py +++ b/sdks/code-interpreter/python/src/code_interpreter/models/code.py @@ -51,7 +51,7 @@ class CodeContext(BaseModel): 1. Creation: Context is created with language and working directory 2. Execution: Code runs within this context, building up state 3. Persistence: Variables, imports, and functions persist between executions - 4. Cleanup: Context can be explicitly destroyed or garbage collected + 4. Cleanup: Context is destroyed explicitly by the caller when no longer needed """ id: str | None = Field(default=None, description="Unique identifier for this execution context") diff --git a/sdks/code-interpreter/python/src/code_interpreter/models/code_sync.py b/sdks/code-interpreter/python/src/code_interpreter/models/code_sync.py index c9781d541..32102e4ba 100644 --- a/sdks/code-interpreter/python/src/code_interpreter/models/code_sync.py +++ b/sdks/code-interpreter/python/src/code_interpreter/models/code_sync.py @@ -21,7 +21,8 @@ class SupportedLanguageSync: - # kept for symmetry; values match SupportedLanguage + # Mirror of SupportedLanguage; note that JAVASCRIPT is intentionally + # absent because the sync flow only wires the languages listed below. PYTHON = "python" JAVA = "java" GO = "go" diff --git a/sdks/code-interpreter/python/src/code_interpreter/services/code.py b/sdks/code-interpreter/python/src/code_interpreter/services/code.py index 018d93ef0..91c04b44d 100644 --- a/sdks/code-interpreter/python/src/code_interpreter/services/code.py +++ b/sdks/code-interpreter/python/src/code_interpreter/services/code.py @@ -32,16 +32,16 @@ class Codes(Protocol): Code execution service for multi-language code interpretation. This service provides advanced code execution capabilities with context management, - session persistence, and multi-language support. It extends basic command execution - with interpreter-specific features like variable inspection and execution history. + session persistence, and multi-language support. Supported Languages: - Python: Full Python 3.x support with package management - JavaScript/Node.js: ES6+ with npm package support + - TypeScript: With Node.js runtime support - Bash: Shell scripting with full system access - Java: Compilation and execution with classpath management - - Kotlin: Script and compiled Kotlin execution + - Go: Script and compiled Go execution Key Features: diff --git a/sdks/code-interpreter/python/src/code_interpreter/sync/services/code.py b/sdks/code-interpreter/python/src/code_interpreter/sync/services/code.py index 7715333af..c68f2a332 100644 --- a/sdks/code-interpreter/python/src/code_interpreter/sync/services/code.py +++ b/sdks/code-interpreter/python/src/code_interpreter/sync/services/code.py @@ -42,7 +42,7 @@ class CodesSync(Protocol): - JavaScript / TypeScript - Bash - Java - - Kotlin (depending on server image) + - Go Key Features: - Execution Contexts: Isolated environments with persistent state diff --git a/sdks/code-interpreter/python/uv.lock b/sdks/code-interpreter/python/uv.lock index 41bfb4621..103ddab33 100644 --- a/sdks/code-interpreter/python/uv.lock +++ b/sdks/code-interpreter/python/uv.lock @@ -218,6 +218,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "idna" version = "3.15" @@ -251,6 +260,7 @@ source = { editable = "../../sandbox/python" } dependencies = [ { name = "attrs" }, { name = "httpx" }, + { name = "httpx-sse" }, { name = "pydantic" }, { name = "python-dateutil" }, ] @@ -259,6 +269,7 @@ dependencies = [ requires-dist = [ { name = "attrs", specifier = ">=21.3.0" }, { name = "httpx", specifier = ">=0.27.0,<1.0" }, + { name = "httpx-sse", specifier = ">=0.4.3,<0.5" }, { name = "pydantic", specifier = ">=2.4.2,<3.0" }, { name = "pyjwt", marker = "extra == 'pool-redis'", specifier = ">=2.13.0" }, { name = "python-dateutil", specifier = ">=2.8.2,<3.0" }, diff --git a/sdks/mcp/sandbox/python/README.md b/sdks/mcp/sandbox/python/README.md index 222c71447..dc2742752 100644 --- a/sdks/mcp/sandbox/python/README.md +++ b/sdks/mcp/sandbox/python/README.md @@ -101,7 +101,7 @@ claude mcp add opensandbox-sandbox --transport http http://localhost:8000/mcp Notes: - All tools operate on a `sandbox_id` returned by `sandbox_create` or `sandbox_connect`. -- `file_read`/`file_write` are text-only; use `encoding` and `range_header` for large files. +- `file_read`/`file_write` are text-only; `file_read` supports `encoding` and `range_header` for large files. ### Sandbox diff --git a/sdks/sandbox/csharp/README.md b/sdks/sandbox/csharp/README.md index cd37379ee..81aec73ef 100644 --- a/sdks/sandbox/csharp/README.md +++ b/sdks/sandbox/csharp/README.md @@ -401,7 +401,6 @@ await sandbox.CreateCredentialVaultAsync( Match = new CredentialMatch { Schemes = new[] { "https" }, - Ports = new[] { 443 }, Hosts = new[] { "api.example.com" }, Paths = new[] { "/v1/*" } }, diff --git a/sdks/sandbox/go/README.md b/sdks/sandbox/go/README.md index c7f3f74e4..a2baff9da 100644 --- a/sdks/sandbox/go/README.md +++ b/sdks/sandbox/go/README.md @@ -139,7 +139,6 @@ _, err = sandbox.CreateCredentialVault(ctx, opensandbox.CredentialVaultCreateReq Name: "api-token", Match: opensandbox.CredentialMatch{ Schemes: []opensandbox.CredentialScheme{opensandbox.CredentialSchemeHTTPS}, - Ports: []int{443}, Hosts: []string{"api.example.com"}, Paths: []string{"/v1/*"}, }, diff --git a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/Sandbox.kt b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/Sandbox.kt index 93e571f14..1f0d013c5 100644 --- a/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/Sandbox.kt +++ b/sdks/sandbox/kotlin/sandbox/src/main/kotlin/com/alibaba/opensandbox/sandbox/Sandbox.kt @@ -79,12 +79,13 @@ import java.time.OffsetDateTime * .build() * * // Use the sandbox - * sandbox.writeFile("script.py", "print('Hello World')") - * val result = sandbox.execute("python script.py") - * println(result.stdout) // Output: Hello World + * sandbox.files().writeFile("script.py", "print('Hello World')") + * val result = sandbox.commands().run("python script.py") + * println(result.logs.stdout.joinToString("") { it.text }) // Output: Hello World * * // Always clean up resources - * sandbox.terminate() + * sandbox.kill() + * sandbox.close() * ``` * */ diff --git a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/CommandsAdapterTest.kt b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/CommandsAdapterTest.kt index 3975609ef..ff70236b2 100644 --- a/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/CommandsAdapterTest.kt +++ b/sdks/sandbox/kotlin/sandbox/src/test/kotlin/com/alibaba/opensandbox/sandbox/infrastructure/adapters/service/CommandsAdapterTest.kt @@ -48,7 +48,6 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit class CommandsAdapterTest { - // CommandsAdapter unit tests private lateinit var mockWebServer: MockWebServer private lateinit var commandsAdapter: CommandsAdapter private lateinit var httpClientProvider: HttpClientProvider diff --git a/sdks/sandbox/python/src/opensandbox/models/execd.py b/sdks/sandbox/python/src/opensandbox/models/execd.py index 0ab218728..93d129235 100644 --- a/sdks/sandbox/python/src/opensandbox/models/execd.py +++ b/sdks/sandbox/python/src/opensandbox/models/execd.py @@ -233,9 +233,12 @@ async def handle_stdout(msg: OutputMessage): # Can perform async operations await log_to_database(msg.text) + async def handle_stderr(msg: OutputMessage): + print(f"Error: {msg.text}") + handlers = ExecutionHandlers( on_stdout=handle_stdout, - on_stderr=lambda msg: print(f"Error: {msg.text}"), + on_stderr=handle_stderr, ) ``` """ diff --git a/server/DEVELOPMENT.md b/server/DEVELOPMENT.md index d3dac1fb7..31330f87a 100644 --- a/server/DEVELOPMENT.md +++ b/server/DEVELOPMENT.md @@ -57,7 +57,7 @@ Layered architecture: Client → POST /sandboxes → Auth Middleware validates API key → lifecycle.create_sandbox() receives CreateSandboxRequest - → sandbox_service.create_sandbox_async(request) + → sandbox_service.create_sandbox(request) → Returns 202 Accepted with Pending status immediately → Background thread provisions the sandbox ``` @@ -73,10 +73,10 @@ In-memory timer tracking per sandbox. On timeout, sandbox is cleaned up automati ### Network Modes **Bridge (recommended):** isolated networks, HTTP proxy for routing. -- Endpoint: `http://{server}/route/{sandbox_id}/{port}/path` +- Endpoint: `{public_host}:{execd_host_port}/proxy/{port}` (e.g. `http://host:44772/proxy/8080`); direct HTTP access via the mapped host port `{public_host}:{http_host_port}` where available. **Host:** sandboxes share host network, direct port access. Not recommended — no network isolation. -- Endpoint: `http://{domain}/{sandbox_id}/{port}` +- Endpoint: `{public_host}:{port}` ```bash # Local Docker diff --git a/server/RELEASE_NOTES.md b/server/RELEASE_NOTES.md index ebf196135..0ccc9b6e3 100644 --- a/server/RELEASE_NOTES.md +++ b/server/RELEASE_NOTES.md @@ -272,7 +272,7 @@ Thanks to these contributors ❤️ - create kubernetes resource name with sandbox-id (#163) ### ⚠️ Breaking Changes -- extract egress configuration as an independent module, `[runtime].egress_image` is not accepted, you can refer it from [Configuration reference](https://github.com/alibaba/OpenSandbox/blob/main/server/README.md#configuration-reference) (#186) +- extract egress configuration as an independent module, `[runtime].egress_image` is not accepted, you can refer it from [Configuration reference](configuration.md) (#186) ### 📦 Misc - package server as PyPI artifact (#170) diff --git a/server/configuration.md b/server/configuration.md index c175979f5..565a5a1cf 100644 --- a/server/configuration.md +++ b/server/configuration.md @@ -30,11 +30,12 @@ Example files in this repository: 9. [`[egress]`](#egress) 10. [`[storage]`](#storage) 11. [`[store]`](#store) -12. [`[secure_runtime]`](#secure_runtime) -13. [`[renew_intent]`](#renew_intent) -14. [`[otel]`](#otel) -15. [Environment variables (outside TOML)](#environment-variables-outside-toml) -16. [Cross-field validation rules](#cross-field-validation-rules) +12. [`[tenants]`](#tenants) +13. [`[secure_runtime]`](#secure_runtime) +14. [`[renew_intent]`](#renew_intent) +15. [`[otel]`](#otel) +16. [Environment variables (outside TOML)](#environment-variables-outside-toml) +17. [Cross-field validation rules](#cross-field-validation-rules) --- @@ -52,6 +53,7 @@ Example files in this repository: | `[egress]` | No | Required values when clients use `networkPolicy` on create | | `[storage]` | No | Host bind mounts / OSSFS mount root | | `[store]` | No | Server-managed persistent metadata backend | +| `[tenants]` | No | Multi-tenant mode; see [`[tenants]`](#tenants) | | `[secure_runtime]` | No | gVisor / Kata / Firecracker | | `[renew_intent]` | No | Auto-renew on access | | `[otel]` | No | OTLP export for ingested SDK metrics | @@ -64,7 +66,7 @@ Example files in this repository: |-----|------|---------|-------------| | `host` | string | `"0.0.0.0"` | Bind address for the HTTP API. | | `port` | integer | `8080` | Listen port (1–65535). | -| `api_key` | string \| omitted | `null` | If set to a non-empty string, requests must send header `OPEN-SANDBOX-API-KEY` with this value (except documented public routes such as `/health`, `/docs`, `/redoc`). If omitted or empty, API key checks are skipped, but startup now requires explicit risk acknowledgment: interactive TTY confirmation (`YES`) or `OPENSANDBOX_INSECURE_SERVER=YES`. | +| `api_key` | string \| omitted | `null` | If set to a non-empty string, requests must send header `OPEN-SANDBOX-API-KEY` with this value, except documented public routes (`/health`, `/version`, `/docs`, `/redoc`, `/openapi.json`) and proxy-to-sandbox routes in single-tenant mode. If omitted or empty, API key checks are skipped, but startup now requires explicit risk acknowledgment: interactive TTY confirmation (`YES`) or `OPENSANDBOX_INSECURE_SERVER=YES`. | | `eip` | string \| omitted | `null` | Public IP or hostname used as the **host part** when the server returns sandbox endpoint URLs (notably Docker runtime). | | `max_sandbox_timeout_seconds` | integer \| omitted | `null` | Upper bound on sandbox TTL in seconds for **create** requests that specify `timeout`. Must be ≥ **60** if set. Omit to disable the server-side cap. | | `timeout_keep_alive` | integer | `30` | Idle keep-alive timeout (seconds) passed to uvicorn. | @@ -181,6 +183,8 @@ Controls how **ingress exposure** is described for sandbox endpoints (especially | Key | Type | Default | Description | |-----|------|---------|-------------| | `mode` | string | `"direct"` | **`direct`** — clients reach sandboxes without an L7 gateway configured here. **`gateway`** — use `[ingress.gateway]` for address and routing mode (Kubernetes-oriented deployments). | +| `secure_access.active_key` | string \| omitted | `null` | **OSEP-0011** secure access. Identifier of the active signing key, exactly one character `[0-9a-z]`, must reference a `key_id` present in `keys`. Required when `secure_access.keys` is set. | +| `secure_access.keys` | list of objects \| omitted | `null` | List of signing keys for signed ingress routes. Each entry has `key_id` (one character `[0-9a-z]`) and `key` (base64-encoded HMAC key). May also be injected via `OPENSANDBOX_SECURE_ACCESS_KEYS` / `OPENSANDBOX_SECURE_ACCESS_ACTIVE_KEY` (see [Environment variables](#environment-variables-outside-toml)). | ### When `mode = "gateway"` @@ -259,6 +263,21 @@ the same backend. --- +## `[tenants]` + +Optional multi-tenant mode. When the table is present, tenant resolution is enabled and API key checks apply per tenant instead of globally. Provider types: **`file`** (reads a `tenants.toml` at the path given by `SANDBOX_TENANTS_CONFIG_PATH`, default `~/.opensandbox/tenants.toml`) or **`http`** (fetches tenants from a remote endpoint with in-process caching). + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `provider` | string | `"file"` | Tenant provider type: **`file`** or **`http`**. | +| `endpoint` | string \| omitted | `null` | HTTP tenant provider endpoint URL. **Required** when `provider = "http"`. | +| `max_stale_seconds` | float | `300` | Maximum seconds to serve the stale tenant cache when the HTTP endpoint is unreachable. | +| `timeout_seconds` | float | `5` | HTTP request timeout in seconds. | +| `auth_header` | string \| omitted | `null` | Optional header name for provider-level authentication to the HTTP endpoint. | +| `auth_token` | string \| omitted | `null` | Optional token value for provider-level authentication to the HTTP endpoint. | + +--- + ## `[secure_runtime]` Optional **strong isolation** runtimes (gVisor, Kata, Firecracker). @@ -319,6 +338,10 @@ These are read by the server or runtime code in addition to the TOML file: |----------|------------|-------------| | `SANDBOX_CONFIG_PATH` | `config.py`, CLI | Path to the TOML file. Overrides the default `~/.sandbox.toml` when set. | | `OPENSANDBOX_SERVER_API_KEY` | `config.py` | Overrides the API key from the TOML file. | +| `OPENSANDBOX_INSECURE_SERVER` | `startup_guard.py` | Set to `YES` to acknowledge running without an API key in non-interactive environments. | +| `OPENSANDBOX_SECURE_ACCESS_KEYS` | `config.py` | Comma-separated key ring in `key_id=base64` form for `[ingress.secure_access]`. Requires `OPENSANDBOX_SECURE_ACCESS_ACTIVE_KEY` and `ingress.mode = "gateway"`. | +| `OPENSANDBOX_SECURE_ACCESS_ACTIVE_KEY` | `config.py` | Names the active key in the `OPENSANDBOX_SECURE_ACCESS_KEYS` ring. | +| `SANDBOX_TENANTS_CONFIG_PATH` | `tenants/file_provider.py` | Path to the `tenants.toml` file when `[tenants] provider = "file"`. Defaults to `~/.opensandbox/tenants.toml`. | | `DOCKER_HOST` | Docker service | Standard Docker daemon address (e.g. `unix:///var/run/docker.sock`). | | `OTEL_EXPORTER_OTLP_ENDPOINT` | OTEL exporter | Default OTLP endpoint when `[otel].endpoint` is omitted. | | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | OTEL exporter | Metrics-specific OTLP endpoint override. | diff --git a/server/docker-compose.example.yaml b/server/docker-compose.example.yaml index 17b0ea316..7de06493d 100644 --- a/server/docker-compose.example.yaml +++ b/server/docker-compose.example.yaml @@ -26,9 +26,7 @@ configs: port_range_max = 60000 drop_capabilities = ["AUDIT_WRITE", "MKNOD", "NET_ADMIN", "NET_RAW", "SYS_ADMIN", "SYS_MODULE", "SYS_PTRACE", "SYS_TIME", "SYS_TTY_CONFIG"] no_new_privileges = true - # TODO: For production environments, it is recommended to set this to '4096' or higher to avoid - # "can't start new thread" errors when multiple sandboxes are running concurrently. - # See: https://github.com/opensandbox-group/OpenSandbox/issues/447 + # 4096 is the default limit; set null to disable the per-container PID cap. pids_limit = 4096 [ingress] diff --git a/server/opensandbox_server/api/lifecycle.py b/server/opensandbox_server/api/lifecycle.py index 846622dce..38ce23f29 100644 --- a/server/opensandbox_server/api/lifecycle.py +++ b/server/opensandbox_server/api/lifecycle.py @@ -52,7 +52,6 @@ from opensandbox_server.services.factory import create_sandbox_service from opensandbox_server.services.snapshot_service import create_snapshot_service -# Initialize router router = APIRouter(tags=["Sandboxes"]) # Initialize service based on configuration from config.toml (defaults to docker) @@ -163,7 +162,6 @@ def list_sandboxes( logger = logging.getLogger(__name__) logger.info("ListSandboxes: %s", request.filter) - # Delegate to the service layer for filtering and pagination return sandbox_service.list_sandboxes(request) @@ -199,7 +197,6 @@ def get_sandbox( Raises: HTTPException: If sandbox not found or access denied """ - # Delegate to the service layer for sandbox lookup return sandbox_service.get_sandbox(sandbox_id) @@ -261,7 +258,6 @@ def delete_sandbox( Raises: HTTPException: If sandbox not found or deletion fails """ - # Delegate to the service layer for deletion sandbox_service.delete_sandbox(sandbox_id) return Response(status_code=status.HTTP_204_NO_CONTENT) @@ -302,7 +298,6 @@ def pause_sandbox( Raises: HTTPException: If sandbox not found or cannot be paused """ - # Delegate to the service layer for pause orchestration sandbox_service.pause_sandbox(sandbox_id) return Response(status_code=status.HTTP_202_ACCEPTED) @@ -339,7 +334,6 @@ def resume_sandbox( Raises: HTTPException: If sandbox not found or cannot be resumed """ - # Delegate to the service layer for resume orchestration sandbox_service.resume_sandbox(sandbox_id) return Response(status_code=status.HTTP_202_ACCEPTED) @@ -380,7 +374,6 @@ def renew_sandbox_expiration( Raises: HTTPException: If sandbox not found or renewal fails """ - # Delegate to the service layer for expiration updates return sandbox_service.renew_expiration(sandbox_id, request) @@ -564,7 +557,6 @@ def get_sandbox_endpoint( }, ) - # Delegate to the service layer for endpoint resolution endpoint = sandbox_service.get_endpoint(sandbox_id, port, expires=expires) if use_server_proxy: diff --git a/server/opensandbox_server/config.py b/server/opensandbox_server/config.py index 717bc3e7e..aea4c3482 100644 --- a/server/opensandbox_server/config.py +++ b/server/opensandbox_server/config.py @@ -419,7 +419,7 @@ class LogConfig(BaseModel): default=False, description=( "When true, logs are written to rotating files instead of stdout. " - "Uses default paths (/var/log/opensandbox/) unless file_path/access_file_path are set." + "Uses default paths (~/logs/opensandbox/) unless file_path/access_file_path are set." ), ) file_path: Optional[str] = Field( diff --git a/server/opensandbox_server/examples/example.config.k8s.toml b/server/opensandbox_server/examples/example.config.k8s.toml index c95563548..b948c2a8b 100644 --- a/server/opensandbox_server/examples/example.config.k8s.toml +++ b/server/opensandbox_server/examples/example.config.k8s.toml @@ -36,7 +36,7 @@ execd_image = "opensandbox/execd:v1.0.22" [storage] # Allowlist of host path prefixes permitted for bind mounts. -# If empty, all host paths are allowed (not recommended for production). +# If empty, host bind mounts are rejected. # Example: allowed_host_paths = ["/data/opensandbox", "/tmp/sandbox"] allowed_host_paths = [] @@ -80,7 +80,7 @@ mode = "dns" # Default is true (recommended for dual-stack CNI). Set false only if you need IPv6 in the netns (see server/configuration.md). # disable_ipv6 = false -# Renew-on-access. Off by default — see server/README.md. +# Renew-on-access. Off by default — see server/configuration.md. [renew_intent] enabled = false min_interval_seconds = 60 diff --git a/server/opensandbox_server/examples/example.config.k8s.zh.toml b/server/opensandbox_server/examples/example.config.k8s.zh.toml index c326649df..16fa56a0a 100644 --- a/server/opensandbox_server/examples/example.config.k8s.zh.toml +++ b/server/opensandbox_server/examples/example.config.k8s.zh.toml @@ -36,7 +36,7 @@ execd_image = "sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/execd [storage] # 允许进行 bind mount 的宿主机路径前缀白名单。 -# 如果为空,则允许所有路径(不建议在生产环境使用)。 +# 如果为空,则拒绝所有 host bind mount。 # 示例:allowed_host_paths = ["/data/opensandbox", "/tmp/sandbox"] allowed_host_paths = [] @@ -81,7 +81,7 @@ mode = "dns" # Default is true (recommended for dual-stack CNI). Set false only if you need IPv6 in the netns (see server/configuration.md). # disable_ipv6 = false -# 按访问续期。默认关闭 — 见 server/README_zh.md。 +# 按访问续期。默认关闭 — 见 server/configuration.md。 [renew_intent] enabled = false min_interval_seconds = 60 diff --git a/server/opensandbox_server/examples/example.config.toml b/server/opensandbox_server/examples/example.config.toml index 6a053058d..1571293f8 100644 --- a/server/opensandbox_server/examples/example.config.toml +++ b/server/opensandbox_server/examples/example.config.toml @@ -36,7 +36,7 @@ execd_image = "opensandbox/execd:v1.0.22" [storage] # Allowlist of host path prefixes permitted for bind mounts. -# If empty, all host paths are allowed (not recommended for production). +# If empty, host bind mounts are rejected. # Example: allowed_host_paths = ["/data/opensandbox", "/tmp/sandbox"] allowed_host_paths = [] @@ -71,7 +71,7 @@ mode = "direct" image = "opensandbox/egress:v1.1.6" mode = "dns" -# Renew-on-access. Off by default — see server/README.md. +# Renew-on-access. Off by default — see server/configuration.md. [renew_intent] enabled = false min_interval_seconds = 60 diff --git a/server/opensandbox_server/examples/example.config.zh.toml b/server/opensandbox_server/examples/example.config.zh.toml index 080681478..7f034a9b8 100644 --- a/server/opensandbox_server/examples/example.config.zh.toml +++ b/server/opensandbox_server/examples/example.config.zh.toml @@ -69,7 +69,7 @@ mode = "direct" image = "sandbox-registry.cn-zhangjiakou.cr.aliyuncs.com/opensandbox/egress:v1.1.6" mode = "dns" -# 按访问续期。默认关闭 — 见 server/README_zh.md。 +# 按访问续期。默认关闭 — 见 server/configuration.md。 [renew_intent] enabled = false min_interval_seconds = 60 diff --git a/server/opensandbox_server/services/docker/docker_service.py b/server/opensandbox_server/services/docker/docker_service.py index ab359e2bd..749fa6dd3 100644 --- a/server/opensandbox_server/services/docker/docker_service.py +++ b/server/opensandbox_server/services/docker/docker_service.py @@ -158,7 +158,6 @@ def __init__(self, config: Optional[AppConfig] = None): self._metadata_store = DockerMetadataStore() self._api_timeout = self._resolve_api_timeout() try: - # Initialize Docker service from environment variables client_kwargs = {} try: signature = inspect.signature(docker.from_env) diff --git a/server/opensandbox_server/services/docker/snapshot_runtime.py b/server/opensandbox_server/services/docker/snapshot_runtime.py index 5b2171b2a..71f3c8167 100644 --- a/server/opensandbox_server/services/docker/snapshot_runtime.py +++ b/server/opensandbox_server/services/docker/snapshot_runtime.py @@ -15,9 +15,9 @@ """ Docker-backed snapshot runtime. -This runtime performs ``docker commit`` inline and returns the final status to -the caller so the server can persist terminal snapshot state in the request -path. +This runtime performs ``docker commit`` on a background worker thread and +returns the committed image result to the snapshot service, which persists the +terminal snapshot state outside the request path. """ from __future__ import annotations diff --git a/server/opensandbox_server/services/sandbox_service.py b/server/opensandbox_server/services/sandbox_service.py index d90ed19aa..520f04b02 100644 --- a/server/opensandbox_server/services/sandbox_service.py +++ b/server/opensandbox_server/services/sandbox_service.py @@ -13,10 +13,10 @@ # limitations under the License. """ -Sandbox service layer for business logic. +Sandbox service layer: abstract interface for sandbox lifecycle management. -This module contains the business logic for sandbox lifecycle management. -This module defines the abstract interface for sandbox services. +Implementations live in the runtime-specific service modules (Docker and +Kubernetes); this module defines the interface they must satisfy. """ from abc import ABC, abstractmethod diff --git a/server/opensandbox_server/services/snapshot_service.py b/server/opensandbox_server/services/snapshot_service.py index 86c857b20..40f1975b8 100644 --- a/server/opensandbox_server/services/snapshot_service.py +++ b/server/opensandbox_server/services/snapshot_service.py @@ -15,9 +15,9 @@ """ Snapshot service orchestration for server-managed snapshot resources. -The preferred path is to persist the snapshot record and, when supported by the -runtime, complete snapshot creation inline so the repository reaches a terminal -state within the request lifecycle. +The preferred path is to persist the snapshot record immediately and create the +snapshot asynchronously on a worker thread, so the API returns a CREATING state +without blocking the request on the (potentially slow) runtime commit. """ from __future__ import annotations diff --git a/server/tests/k8s/test_agent_sandbox_provider.py b/server/tests/k8s/test_agent_sandbox_provider.py index 357288ef7..dcf683484 100644 --- a/server/tests/k8s/test_agent_sandbox_provider.py +++ b/server/tests/k8s/test_agent_sandbox_provider.py @@ -740,8 +740,6 @@ def test_get_endpoint_info_falls_back_to_service_fqdn(self, mock_k8s_client): class TestAgentSandboxProviderExecdInit: - """AgentSandboxProvider execd init container resource tests""" - def test_init_container_has_no_resources_when_not_configured(self, mock_k8s_client): provider = AgentSandboxProvider(mock_k8s_client) mock_k8s_client.create_custom_object.return_value = { @@ -803,8 +801,6 @@ def test_init_container_has_resources_when_configured(self, mock_k8s_client): class TestAgentSandboxProviderEgress: - """AgentSandboxProvider egress sidecar tests""" - def test_create_workload_without_network_policy_no_sidecar(self, mock_k8s_client): provider = AgentSandboxProvider(mock_k8s_client) mock_k8s_client.create_custom_object.return_value = { @@ -831,10 +827,8 @@ def test_create_workload_without_network_policy_no_sidecar(self, mock_k8s_client pod_spec = body["spec"]["podTemplate"]["spec"] containers = pod_spec["containers"] - # Should only have main container assert len(containers) == 1 assert containers[0]["name"] == "sandbox" - # Should not have securityContext with sysctls assert "securityContext" not in pod_spec or "sysctls" not in pod_spec.get( "securityContext", {} ) @@ -873,15 +867,12 @@ def test_create_workload_with_network_policy_adds_sidecar(self, mock_k8s_client) pod_spec = body["spec"]["podTemplate"]["spec"] containers = pod_spec["containers"] - # Should have both main container and sidecar assert len(containers) == 2 - # Find sidecar container sidecar = next((c for c in containers if c["name"] == "egress"), None) assert sidecar is not None assert sidecar["image"] == "opensandbox/egress:v1.1.6" - # Verify sidecar has environment variable env_vars = {e["name"]: e["value"] for e in sidecar.get("env", [])} assert "OPENSANDBOX_EGRESS_RULES" in env_vars assert env_vars["OPENSANDBOX_EGRESS_MODE"] == EGRESS_MODE_DNS @@ -1102,11 +1093,9 @@ def test_create_workload_with_network_policy_drops_net_admin_from_main_container pod_spec = body["spec"]["podTemplate"]["spec"] containers = pod_spec["containers"] - # Find main container main_container = next((c for c in containers if c["name"] == "sandbox"), None) assert main_container is not None - # Verify main container has securityContext assert "securityContext" in main_container assert "capabilities" in main_container["securityContext"] assert "drop" in main_container["securityContext"]["capabilities"] @@ -1142,7 +1131,6 @@ def test_create_workload_without_egress_image_no_sidecar(self, mock_k8s_client): pod_spec = body["spec"]["podTemplate"]["spec"] containers = pod_spec["containers"] - # Should only have main container assert len(containers) == 1 assert containers[0]["name"] == "sandbox" @@ -1185,7 +1173,6 @@ def test_egress_sidecar_contains_network_policy_in_env(self, mock_k8s_client): env_vars = {e["name"]: e["value"] for e in sidecar.get("env", [])} assert "OPENSANDBOX_EGRESS_RULES" in env_vars - # Verify the environment variable contains valid JSON with network policy import json policy_json = json.loads(env_vars["OPENSANDBOX_EGRESS_RULES"]) @@ -1221,5 +1208,4 @@ def test_main_container_no_security_context_without_network_policy(self, mock_k8 containers = pod_spec["containers"] main_container = containers[0] - # Main container should not have securityContext when no network policy assert "securityContext" not in main_container diff --git a/server/tests/k8s/test_batchsandbox_provider.py b/server/tests/k8s/test_batchsandbox_provider.py index 4d2358503..63832b153 100644 --- a/server/tests/k8s/test_batchsandbox_provider.py +++ b/server/tests/k8s/test_batchsandbox_provider.py @@ -143,7 +143,6 @@ def test_create_workload_builds_correct_manifest(self, mock_k8s_client): assert result == {"name": "test-id", "uid": "test-uid", "apiVersion": "sandbox.opensandbox.io/v1alpha1", "kind": "BatchSandbox"} - # Verify API call call_args = mock_k8s_client.create_custom_object.call_args body = call_args.kwargs["body"] @@ -258,7 +257,6 @@ def test_create_workload_windows_profile_default_entrypoint_uses_image_entrypoin body = mock_k8s_client.create_custom_object.call_args.kwargs["body"] pod_spec = body["spec"]["template"]["spec"] main_container = pod_spec["containers"][0] - # No command set - image default ENTRYPOINT will be used assert "command" not in main_container assert "args" not in main_container @@ -423,7 +421,6 @@ def test_create_workload_builds_execd_init_container(self, mock_k8s_client): ) in init_script assert "chmod 0555 /opt/opensandbox/opensandbox-session-gate" in init_script assert init_container["volumeMounts"][0]["name"] == "opensandbox-bin" - # No resources configured: resources field should be absent assert "resources" not in init_container def test_create_workload_init_container_with_configured_resources(self, mock_k8s_client): @@ -530,12 +527,10 @@ def test_create_workload_converts_env_to_list(self, mock_k8s_client): body = mock_k8s_client.create_custom_object.call_args.kwargs["body"] env_vars = body["spec"]["template"]["spec"]["containers"][0]["env"] - # Should have user env vars plus EXECD assert len(env_vars) == 3 env_dict = {e["name"]: e["value"] for e in env_vars} assert env_dict["FOO"] == "bar" assert env_dict["BAZ"] == "qux" - # Verify EXECD is automatically injected assert env_dict["EXECD"] == "/opt/opensandbox/execd" def test_create_workload_merges_template_volumes_and_mounts(self, mock_k8s_client, tmp_path): @@ -832,7 +827,6 @@ def test_get_workload_handles_404_gracefully(self, mock_k8s_client): def test_get_workload_reraises_non_404_exceptions(self, mock_k8s_client): provider = BatchSandboxProvider(mock_k8s_client) - # Mock 500 exception error = ApiException(status=500) mock_k8s_client.get_custom_object.side_effect = error @@ -971,7 +965,6 @@ def test_get_expiration_returns_none_on_invalid_format(self): provider = BatchSandboxProvider(MagicMock()) workload = {"spec": {"expireTime": "invalid-date"}} - # Should return None and not raise exception result = provider.get_expiration(workload) assert result is None @@ -1333,10 +1326,8 @@ def test_create_workload_poolref_ignores_image_spec(self, mock_k8s_client): extensions={"poolRef": "my-pool"}, ) - # Should succeed and return workload info assert result == {"name": "sandbox-test-id", "uid": "test-uid", "apiVersion": "sandbox.opensandbox.io/v1alpha1", "kind": "BatchSandbox"} - - # Verify poolRef is used + body = mock_k8s_client.create_custom_object.call_args.kwargs["body"] assert body["spec"]["poolRef"] == "my-pool" @@ -1365,10 +1356,8 @@ def test_create_workload_poolref_ignores_resource_limits(self, mock_k8s_client): extensions={"poolRef": "my-pool"}, ) - # Should succeed and return workload info assert result == {"name": "sandbox-test-id", "uid": "test-uid", "apiVersion": "sandbox.opensandbox.io/v1alpha1", "kind": "BatchSandbox"} - - # Verify poolRef is used + body = mock_k8s_client.create_custom_object.call_args.kwargs["body"] assert body["spec"]["poolRef"] == "my-pool" @@ -1438,19 +1427,15 @@ def test_create_workload_poolref_allows_entrypoint_and_env(self, mock_k8s_client assert result == {"name": "sandbox-test-id", "uid": "test-uid", "apiVersion": "sandbox.opensandbox.io/v1alpha1", "kind": "BatchSandbox"} - # Verify the call body = mock_k8s_client.create_custom_object.call_args.kwargs["body"] assert body["spec"]["poolRef"] == "my-pool" assert "taskTemplate" in body["spec"] - # Verify taskTemplate structure task_template = body["spec"]["taskTemplate"] - assert "spec" in task_template assert "process" in task_template["spec"] command = task_template["spec"]["process"]["command"] assert command[0] == "/bin/sh" assert command[1] == "-c" - # Command should contain bootstrap.sh execution # Example: /opt/opensandbox/bootstrap.sh python app.py & assert "/opt/opensandbox/bootstrap.sh python app.py" in command[2] assert command[2].endswith(" &") @@ -1478,18 +1463,14 @@ def test_build_task_template_with_env(self, mock_k8s_client): assert "process" in result["spec"] process_task = result["spec"]["process"] - # Verify command structure command = process_task["command"] assert command[0] == "/bin/sh" assert command[1] == "-c" - # Should execute via bootstrap.sh in background (&) assert "/opt/opensandbox/bootstrap.sh" in command[2] assert "/usr/bin/python" in command[2] assert "app.py" in command[2] - # Should end with & (run in background) assert command[2].endswith("&") - # Verify env list assert process_task["env"] == [ {"name": "KEY1", "value": "value1"}, {"name": "KEY2", "value": "value2"}, @@ -1512,11 +1493,9 @@ def test_build_task_template_without_env(self, mock_k8s_client): assert "process" in result["spec"] process_task = result["spec"]["process"] assert process_task["env"] == [] - # Without env, command directly calls bootstrap.sh in background command = process_task["command"] assert command[0] == "/bin/sh" assert command[1] == "-c" - # Check escaped entrypoint assert "/opt/opensandbox/bootstrap.sh" in command[2] assert "/usr/bin/python" in command[2] assert "app.py" in command[2] @@ -1537,7 +1516,6 @@ def test_build_task_template_uses_default_env_path(self, mock_k8s_client): ) command = result["spec"]["process"]["command"][2] - # Should execute bootstrap.sh in background assert "/opt/opensandbox/bootstrap.sh" in command assert "python" in command assert "app.py" in command @@ -1559,13 +1537,10 @@ def test_build_task_template_escapes_special_characters(self, mock_k8s_client): command = result["spec"]["process"]["command"][2] - # Verify entrypoint args are properly escaped assert "python" in command assert "-c" in command - # The python code with spaces and quotes should be properly escaped - assert "'print(" in command or '"print(' in command # Escaped + assert "'print(" in command or '"print(' in command - # Verify env is passed through env list, not in command env_list = result["spec"]["process"]["env"] assert {"name": "KEY", "value": "value with spaces"} in env_list assert {"name": "QUOTE", "value": "it's fine"} in env_list @@ -1601,19 +1576,16 @@ def test_create_workload_poolref_builds_correct_manifest(self, mock_k8s_client): body = mock_k8s_client.create_custom_object.call_args.kwargs["body"] - # Verify basic structure assert body["apiVersion"] == "sandbox.opensandbox.io/v1alpha1" assert body["kind"] == "BatchSandbox" assert body["metadata"]["name"] == "test-id" assert body["metadata"]["labels"] == {"test": "label"} - # Verify pool-specific fields assert body["spec"]["replicas"] == 1 assert body["spec"]["poolRef"] == "test-pool" assert body["spec"]["expireTime"] == "2025-12-31T10:00:00+00:00" assert "taskTemplate" in body["spec"] - # Verify no template field (pool-based doesn't use template) assert "template" not in body["spec"] def test_create_workload_poolref_default_entrypoint_no_env_omits_task_template( @@ -1728,10 +1700,8 @@ def test_create_workload_without_network_policy_no_sidecar(self, mock_k8s_client pod_spec = body["spec"]["template"]["spec"] containers = pod_spec["containers"] - # Should only have main container assert len(containers) == 1 assert containers[0]["name"] == "sandbox" - # Should not have securityContext with sysctls assert "securityContext" not in pod_spec or "sysctls" not in pod_spec.get( "securityContext", {} ) @@ -1770,15 +1740,12 @@ def test_create_workload_with_network_policy_adds_sidecar(self, mock_k8s_client) pod_spec = body["spec"]["template"]["spec"] containers = pod_spec["containers"] - # Should have both main container and sidecar assert len(containers) == 2 - # Find sidecar container sidecar = next((c for c in containers if c["name"] == "egress"), None) assert sidecar is not None assert sidecar["image"] == "opensandbox/egress:v1.1.6" - # Verify sidecar has environment variable env_vars = {e["name"]: e["value"] for e in sidecar.get("env", [])} assert "OPENSANDBOX_EGRESS_RULES" in env_vars assert env_vars["OPENSANDBOX_EGRESS_MODE"] == EGRESS_MODE_DNS @@ -2043,11 +2010,9 @@ def test_create_workload_with_network_policy_drops_net_admin_from_main_container pod_spec = body["spec"]["template"]["spec"] containers = pod_spec["containers"] - # Find main container main_container = next((c for c in containers if c["name"] == "sandbox"), None) assert main_container is not None - # Verify main container has securityContext assert "securityContext" in main_container assert "capabilities" in main_container["securityContext"] assert "drop" in main_container["securityContext"]["capabilities"] @@ -2083,7 +2048,6 @@ def test_create_workload_without_egress_image_no_sidecar(self, mock_k8s_client): pod_spec = body["spec"]["template"]["spec"] containers = pod_spec["containers"] - # Should only have main container assert len(containers) == 1 assert containers[0]["name"] == "sandbox" @@ -2126,7 +2090,6 @@ def test_egress_sidecar_contains_network_policy_in_env(self, mock_k8s_client): env_vars = {e["name"]: e["value"] for e in sidecar.get("env", [])} assert "OPENSANDBOX_EGRESS_RULES" in env_vars - # Verify the environment variable contains valid JSON with network policy import json policy_json = json.loads(env_vars["OPENSANDBOX_EGRESS_RULES"]) @@ -2162,7 +2125,6 @@ def test_main_container_no_security_context_without_network_policy(self, mock_k8 containers = pod_spec["containers"] main_container = containers[0] - # Main container should not have securityContext when no network policy assert "securityContext" not in main_container def test_create_workload_with_network_policy_works_with_template( @@ -2210,10 +2172,8 @@ def test_create_workload_with_network_policy_works_with_template( pod_spec = body["spec"]["template"]["spec"] containers = pod_spec["containers"] - # Should have both main container and sidecar assert len(containers) == 2 - # Verify sidecar exists sidecar = next((c for c in containers if c["name"] == "egress"), None) assert sidecar is not None @@ -2222,7 +2182,6 @@ def test_create_workload_with_network_policy_works_with_template( "securityContext", {} ) - # Verify template volumes are still merged volume_names = [v["name"] for v in pod_spec["volumes"]] assert "sandbox-shared-data" in volume_names assert "opensandbox-bin" in volume_names @@ -2833,14 +2792,12 @@ def test_create_workload_with_host_volume(self, mock_k8s_client): body = mock_k8s_client.create_custom_object.call_args.kwargs["body"] pod_spec = body["spec"]["template"]["spec"] - # Check volume definition volumes_list = pod_spec.get("volumes", []) host_volume = next((v for v in volumes_list if v["name"] == "host-volume"), None) assert host_volume is not None assert host_volume["hostPath"]["path"] == "/data/shared" assert host_volume["hostPath"]["type"] == "DirectoryOrCreate" - # Check volume mount main_container = pod_spec["containers"][0] mounts = main_container.get("volumeMounts", []) host_mount = next((m for m in mounts if m["name"] == "host-volume"), None) @@ -2890,11 +2847,9 @@ def test_create_workload_with_multiple_volumes(self, mock_k8s_client): body = mock_k8s_client.create_custom_object.call_args.kwargs["body"] pod_spec = body["spec"]["template"]["spec"] - # Check both volumes exist volumes_list = pod_spec.get("volumes", []) assert len([v for v in volumes_list if v["name"] in ("pvc-volume", "host-volume")]) == 2 - # Check both mounts exist main_container = pod_spec["containers"][0] mounts = main_container.get("volumeMounts", []) mount_names = {m["name"] for m in mounts} @@ -2943,7 +2898,6 @@ def test_apply_volumes_to_pod_spec_empty_volumes(self, mock_k8s_client): apply_volumes_to_pod_spec(pod_spec, []) - # Should not modify pod_spec assert pod_spec["volumes"] == [] assert pod_spec["containers"][0]["volumeMounts"] == [] @@ -2956,10 +2910,8 @@ def test_apply_volumes_to_pod_spec_no_containers(self, mock_k8s_client): pod_spec = {"volumes": []} volumes = [Volume(name="test", pvc=PVC(claim_name="pvc"), mount_path="/mnt")] - # Should not raise exception apply_volumes_to_pod_spec(pod_spec, volumes) - # Pod spec should remain unchanged (no containers to mount to) assert pod_spec["volumes"] == [] def test_apply_volumes_to_pod_spec_duplicate_internal_volume(self, mock_k8s_client): @@ -2974,7 +2926,6 @@ def test_apply_volumes_to_pod_spec_duplicate_internal_volume(self, mock_k8s_clie } volumes = [Volume(name="opensandbox-bin", pvc=PVC(claim_name="pvc"), mount_path="/mnt")] - # Should raise ValueError for duplicate volume name with pytest.raises(ValueError) as exc_info: apply_volumes_to_pod_spec(pod_spec, volumes) @@ -3019,7 +2970,6 @@ def test_apply_volumes_to_pod_spec_same_pvc_multiple_mounts(self, mock_k8s_clien assert shared_volume["persistentVolumeClaim"]["claimName"] == "oss-pvc-r" assert shared_volume["persistentVolumeClaim"]["readOnly"] is True - # Two volumeMounts, both referencing the same volume name mounts = pod_spec["containers"][0]["volumeMounts"] assert len(mounts) == 2 by_path = {m["mountPath"]: m for m in mounts} diff --git a/server/tests/k8s/test_batchsandbox_template.py b/server/tests/k8s/test_batchsandbox_template.py index e06e8731d..b10f2b12d 100644 --- a/server/tests/k8s/test_batchsandbox_template.py +++ b/server/tests/k8s/test_batchsandbox_template.py @@ -20,7 +20,6 @@ class TestBatchSandboxTemplateManager: def test_load_valid_yaml_template_successfully(self, tmp_path): - # Create valid template file template_file = tmp_path / "valid_template.yaml" template_content = { "metadata": {"annotations": {"test": "value"}}, @@ -34,29 +33,24 @@ def test_load_valid_yaml_template_successfully(self, tmp_path): assert manager.template_file_path == str(template_file) def test_load_nonexistent_file_raises_error(self): - # Should raise FileNotFoundError with pytest.raises(FileNotFoundError) as exc_info: BatchSandboxTemplateManager("/path/to/nonexistent.yaml") assert "not found" in str(exc_info.value) def test_load_invalid_yaml_raises_error(self, tmp_path): - # Create malformed YAML template_file = tmp_path / "invalid.yaml" template_file.write_text("invalid: yaml: [missing: bracket") - # Should raise RuntimeError with pytest.raises(RuntimeError) as exc_info: BatchSandboxTemplateManager(str(template_file)) assert "Failed to load" in str(exc_info.value) def test_load_non_dict_yaml_raises_error(self, tmp_path): - # Create YAML with list template_file = tmp_path / "list.yaml" template_file.write_text("- item1\n- item2") - # Should raise ValueError with pytest.raises(ValueError) as exc_info: BatchSandboxTemplateManager(str(template_file)) @@ -128,11 +122,9 @@ def test_deep_copy_creates_independent_copies(self): copy = BatchSandboxTemplateManager._deep_copy(original) - # Modify copy copy["nested"]["list"].append(4) copy["nested"]["dict"]["key"] = "new_value" - # Original should not be affected assert original["nested"]["list"] == [1, 2, 3] assert original["nested"]["dict"]["key"] == "value" @@ -146,7 +138,6 @@ def test_get_base_template_returns_copy(self, tmp_path): template1 = manager.get_base_template() template2 = manager.get_base_template() - # Same content but not same object assert template1 == template2 assert template1 is not template2 @@ -166,7 +157,6 @@ def test_merge_with_runtime_values_without_template(self): assert result == runtime_manifest def test_merge_with_runtime_values_with_template(self, tmp_path): - # Create template template_file = tmp_path / "template.yaml" template_content = { "spec": { @@ -182,7 +172,6 @@ def test_merge_with_runtime_values_with_template(self, tmp_path): manager = BatchSandboxTemplateManager(str(template_file)) - # Runtime manifest runtime_manifest = { "spec": { "replicas": 1, @@ -197,10 +186,8 @@ def test_merge_with_runtime_values_with_template(self, tmp_path): result = manager.merge_with_runtime_values(runtime_manifest) - # Verify template fields are preserved assert result["spec"]["template"]["spec"]["nodeSelector"] == {"workload": "sandbox"} assert result["spec"]["template"]["spec"]["tolerations"] == [{"operator": "Exists"}] - # Verify runtime fields are added assert result["spec"]["replicas"] == 1 assert result["spec"]["template"]["spec"]["containers"] == [{"name": "test"}] assert result["spec"]["template"]["spec"]["volumes"] == [{"name": "vol"}] diff --git a/server/tests/k8s/test_informer.py b/server/tests/k8s/test_informer.py index de53a1c43..f1bcd557a 100644 --- a/server/tests/k8s/test_informer.py +++ b/server/tests/k8s/test_informer.py @@ -124,7 +124,6 @@ def test_update_cache_ignores_object_without_name(self): """update_cache silently ignores objects that lack a metadata.name.""" informer = _make_informer() informer.update_cache({"metadata": {}}) - # Cache remains empty — no exception raised assert informer._cache == {} def test_update_cache_updates_resource_version(self): diff --git a/server/tests/k8s/test_kubernetes_service.py b/server/tests/k8s/test_kubernetes_service.py index a1fa02b58..a6360e215 100644 --- a/server/tests/k8s/test_kubernetes_service.py +++ b/server/tests/k8s/test_kubernetes_service.py @@ -110,7 +110,6 @@ def test_credential_proxy_requires_dns_nft_mode( async def test_create_sandbox_with_valid_request_succeeds( self, k8s_service, create_sandbox_request, mock_workload ): - # Mock workload provider k8s_service.workload_provider.create_workload.return_value = { "name": "test-sandbox-123", "uid": "abc-123", @@ -245,7 +244,6 @@ async def test_create_sandbox_uses_configured_timeout_and_poll_interval( "last_transition_at": datetime.now(timezone.utc), } - # Override config values k8s_service.app_config.kubernetes.sandbox_create_timeout_seconds = 120 k8s_service.app_config.kubernetes.sandbox_create_poll_interval_seconds = 0.5 @@ -292,7 +290,6 @@ async def test_create_sandbox_allows_image_auth_when_provider_supported( "last_transition_at": datetime.now(timezone.utc), } - # Should not raise await k8s_service.create_sandbox(create_sandbox_request) k8s_service.workload_provider.create_workload.assert_called_once() @@ -831,7 +828,6 @@ async def test_create_sandbox_pool_mode_image_auth_guard_no_error( k8s_service.workload_provider.get_endpoint_info.return_value = "10.244.0.6:8080" k8s_service.workload_provider.get_expiration.return_value = datetime.now(timezone.utc) + timedelta(hours=1) - # Should not raise AttributeError on None.auth response = await k8s_service.create_sandbox(pool_request) assert response.id is not None @@ -854,7 +850,6 @@ async def test_wait_for_running_pod_succeeds(self, k8s_service, mock_workload): @pytest.mark.asyncio async def test_wait_for_pending_then_running_succeeds(self, k8s_service, mock_workload): - # Mock state transition: Pending -> Allocated -> Running status_sequence = [ {"state": "Pending", "reason": "", "message": "Pending", "last_transition_at": datetime.now(timezone.utc)}, {"state": "Allocated", "reason": "IP_ASSIGNED", "message": "IP assigned", "last_transition_at": datetime.now(timezone.utc)}, @@ -987,7 +982,6 @@ def test_get_existing_sandbox_succeeds(self, k8s_service, mock_workload): k8s_service.workload_provider.get_endpoint_info.return_value = "10.0.0.1:8080" k8s_service.workload_provider.get_expiration.return_value = datetime.now(timezone.utc) + timedelta(hours=1) - # Use sandbox_id from mock_workload sandbox = k8s_service.get_sandbox("test-sandbox-123") # Sandbox uses 'id' field @@ -1151,7 +1145,6 @@ def test_delete_existing_sandbox_succeeds(self, k8s_service, mock_workload): ) def test_delete_nonexistent_sandbox_raises_404(self, k8s_service): - # Mock delete_workload to raise exception containing "not found" k8s_service.workload_provider.delete_workload.side_effect = Exception("Sandbox not found") with pytest.raises(HTTPException) as exc_info: @@ -1919,7 +1912,6 @@ def test_patch_failure_is_best_effort(self, k8s_service): # Patch failure must not propagate — the sandbox is already created. k8s_service.k8s_client.patch_pvc.side_effect = Exception("forbidden") - # No exception expected k8s_service._attach_pvc_owner_references( ["pvc-a"], {"name": "s", "uid": "u", "apiVersion": "g/v", "kind": "K"}, @@ -1959,7 +1951,6 @@ def test_list_all_sandboxes_succeeds(self, k8s_service, mock_workload): assert response.pagination.total_items == 1 def test_list_sandboxes_with_pagination(self, k8s_service, mock_workload): - # Create multiple mock workloads using mock_workload as template workloads = [] for i in range(10): workload = { @@ -1998,11 +1989,9 @@ def test_list_sandboxes_with_pagination(self, k8s_service, mock_workload): assert response.pagination.total_pages == 2 def test_list_sandboxes_sorted_by_creation_time(self, k8s_service, mock_workload): - # Create workloads with different creation times base_time = datetime.now(timezone.utc) workloads = [] - # Create sandboxes with specific creation times # We'll create them in random order to verify sorting works creation_times = [ base_time - timedelta(hours=5), # Oldest @@ -2042,18 +2031,14 @@ def test_list_sandboxes_sorted_by_creation_time(self, k8s_service, mock_workload request = ListSandboxesRequest(pagination=PaginationRequest(page=1, page_size=10)) response = k8s_service.list_sandboxes(request) - # Verify all items are returned assert len(response.items) == 5 - # Verify they are sorted by creation time (newest first) - # The order should be: index 4 (newest), 3, 2, 1, 0 (oldest) assert response.items[0].id == "sandbox-4" # Newest assert response.items[1].id == "sandbox-3" assert response.items[2].id == "sandbox-2" assert response.items[3].id == "sandbox-1" assert response.items[4].id == "sandbox-0" # Oldest - # Also verify the creation times are in descending order for i in range(len(response.items) - 1): assert response.items[i].created_at >= response.items[i + 1].created_at diff --git a/server/tests/k8s/test_provider_factory.py b/server/tests/k8s/test_provider_factory.py index 41e74e473..c59133f5e 100644 --- a/server/tests/k8s/test_provider_factory.py +++ b/server/tests/k8s/test_provider_factory.py @@ -87,7 +87,6 @@ def test_create_provider_case_insensitive(self, mock_k8s_client, k8s_app_config) def test_create_provider_with_none_type_uses_default(self, mock_k8s_client, k8s_app_config): provider = create_workload_provider(None, mock_k8s_client, k8s_app_config) - # Should use the first registered provider (batchsandbox) assert isinstance(provider, BatchSandboxProvider) def test_create_provider_with_invalid_type_raises_error(self, mock_k8s_client): @@ -112,7 +111,6 @@ def test_create_batchsandbox_with_template_file(self, mock_k8s_client, k8s_app_c with patch.object(BatchSandboxProvider, '__init__', return_value=None) as mock_init: create_workload_provider(PROVIDER_TYPE_BATCHSANDBOX, mock_k8s_client, k8s_app_config) - # Verify that app_config carrying the template path was passed mock_init.assert_called_once() call_kwargs = mock_init.call_args.kwargs assert call_kwargs['app_config'].kubernetes.batchsandbox_template_file == str(template_file) @@ -125,7 +123,6 @@ def test_list_available_providers(self): assert PROVIDER_TYPE_AGENT_SANDBOX in providers def test_register_custom_provider(self, mock_k8s_client, isolated_registry): - # Create a custom provider class class CustomProvider(WorkloadProvider): def __init__(self, k8s_client): self.k8s_client = k8s_client @@ -154,14 +151,11 @@ def get_status(self, *args, **kwargs): def get_endpoint_info(self, *args, **kwargs): pass - # Register custom provider register_provider("custom", CustomProvider) - # Verify that custom provider can be created provider = create_workload_provider("custom", mock_k8s_client) assert isinstance(provider, CustomProvider) - # Verify it's registered assert "custom" in list_available_providers() def test_create_batchsandbox_with_config(self, mock_k8s_client, k8s_app_config): @@ -173,9 +167,7 @@ def test_create_batchsandbox_with_config(self, mock_k8s_client, k8s_app_config): def test_create_provider_with_empty_registry_raises_error(self, mock_k8s_client, isolated_registry): from opensandbox_server.services.k8s import provider_factory - # Clear the registry to test empty registry scenario provider_factory._PROVIDER_REGISTRY.clear() - # Verify that ValueError is raised when registry is empty and type is None with pytest.raises(ValueError, match="No workload providers are registered"): create_workload_provider(None, mock_k8s_client) diff --git a/server/tests/k8s/test_workload_mapper.py b/server/tests/k8s/test_workload_mapper.py index 0bd32fb09..13be720dd 100644 --- a/server/tests/k8s/test_workload_mapper.py +++ b/server/tests/k8s/test_workload_mapper.py @@ -76,7 +76,6 @@ def test_pool_mode_workload_with_null_template_returns_none(self): }, "status": {"replicas": 1, "ready": 1, "allocated": 1}, } - # Should return None (no platform info), not raise. assert _extract_platform_from_workload(workload) is None def test_pool_mode_workload_without_template_key_returns_none(self): diff --git a/server/tests/test_config.py b/server/tests/test_config.py index a450c0a1c..eb75bb8b8 100644 --- a/server/tests/test_config.py +++ b/server/tests/test_config.py @@ -622,7 +622,6 @@ def test_docker_runtime_rejects_gateway_ingress(): ), ), ) - # direct remains valid app_cfg = AppConfig( server=server_cfg, runtime=runtime_cfg, @@ -911,26 +910,21 @@ def test_log_config_resolved_file_path(): cfg = LogConfig(file_enabled=False) assert cfg.resolved_file_path() is None - # file_enabled=True without file_path uses default cfg = LogConfig(file_enabled=True) assert cfg.resolved_file_path() == LogConfig.DEFAULT_FILE_PATH - # file_enabled=True with file_path uses custom path cfg = LogConfig(file_enabled=True, file_path="/custom/path.log") assert cfg.resolved_file_path() == "/custom/path.log" def test_log_config_resolved_access_file_path(): """resolved_access_file_path() should return default path when file_enabled.""" - # file_enabled=False always returns None cfg = LogConfig(file_enabled=False, access_file_path="/path/access.log") assert cfg.resolved_access_file_path() is None - # file_enabled=True without access_file_path returns default path cfg = LogConfig(file_enabled=True) assert cfg.resolved_access_file_path() == LogConfig.DEFAULT_ACCESS_FILE_PATH - # file_enabled=True with access_file_path returns the custom path cfg = LogConfig(file_enabled=True, access_file_path="/custom/access.log") assert cfg.resolved_access_file_path() == "/custom/access.log" @@ -1103,7 +1097,6 @@ def test_load_config_log_file_enabled(tmp_path, monkeypatch): assert loaded.log.file_enabled is True assert loaded.log.file_path is None # not set, uses default assert loaded.log.access_file_path is None - # resolved_* methods should return default paths assert loaded.log.resolved_file_path() == LogConfig.DEFAULT_FILE_PATH assert loaded.log.resolved_access_file_path() == LogConfig.DEFAULT_ACCESS_FILE_PATH diff --git a/server/tests/test_docker_endpoint.py b/server/tests/test_docker_endpoint.py index 07bd5fdfc..e496ad15e 100644 --- a/server/tests/test_docker_endpoint.py +++ b/server/tests/test_docker_endpoint.py @@ -26,7 +26,6 @@ @pytest.fixture def mock_docker_service(): """Create a DockerSandboxService with mocked docker client.""" - # Setup base config config = AppConfig( server=ServerConfig(port=8080, host="0.0.0.0"), runtime=RuntimeConfig(type="docker", execd_image="test/execd:latest"), @@ -38,7 +37,6 @@ def mock_docker_service(): mock_client = MagicMock() mock_docker.return_value = mock_client - # Initialize service service = DockerSandboxService(config=config) # Inject the mock client directly to ensure we control it service.docker_client = mock_client diff --git a/server/tests/test_docker_service.py b/server/tests/test_docker_service.py index fd8699213..1d3badf0e 100644 --- a/server/tests/test_docker_service.py +++ b/server/tests/test_docker_service.py @@ -115,9 +115,7 @@ def test_parse_timestamp_defaults_on_invalid(): assert future.year == 2024 def test_env_allows_empty_string_and_skips_none(): - # Use base config helper DockerSandboxService(config=_app_config()) - # Build request with mixed env values req = CreateSandboxRequest( image=ImageSpec(uri="python:3.11"), timeout=120, @@ -126,7 +124,6 @@ def test_env_allows_empty_string_and_skips_none(): metadata={}, entrypoint=["python"], ) - # Validate env handling env_dict = req.env or {} environment = [] for key, value in env_dict.items(): @@ -135,8 +132,7 @@ def test_env_allows_empty_string_and_skips_none(): environment.append(f"{key}={value}") assert "FOO=bar" in environment - assert "EMPTY=" in environment # empty string preserved - # None should be skipped + assert "EMPTY=" in environment assert all(not item.startswith("NONE=") for item in environment) @pytest.mark.asyncio @@ -843,17 +839,14 @@ def host_cfg_side_effect(**kwargs): sidecar_kwargs = sidecar_call.kwargs main_kwargs = main_call.kwargs - # Sidecar host config should have NET_ADMIN and port bindings assert "NET_ADMIN" in sidecar_kwargs["host_config"]["cap_add"] assert "44772" in sidecar_kwargs["host_config"]["port_bindings"] assert "8080" in sidecar_kwargs["host_config"]["port_bindings"] - # Main container should share sidecar netns, drop NET_ADMIN, and have no port bindings assert main_kwargs["host_config"]["network_mode"] == "container:sidecar-id" assert "NET_ADMIN" in set(main_kwargs["host_config"].get("cap_drop") or []) assert "port_bindings" not in main_kwargs["host_config"] - # Main container labels should carry host port info labels = main_kwargs["labels"] assert labels.get("opensandbox.io/embedding-proxy-port") assert labels.get("opensandbox.io/http-port") @@ -3172,7 +3165,6 @@ async def test_pvc_volume_binds_passed_to_docker(self, mock_docker): assert response.status.state == "Running" - # Verify named volume bind was passed to create_host_config host_config_call = mock_client.api.create_host_config.call_args assert "binds" in host_config_call.kwargs binds = host_config_call.kwargs["binds"] @@ -3483,7 +3475,6 @@ async def test_host_volume_binds_passed_to_docker(self, mock_docker): ): await service.create_sandbox(request) - # Verify binds were passed to create_host_config host_config_call = mock_client.api.create_host_config.call_args assert "binds" in host_config_call.kwargs binds = host_config_call.kwargs["binds"] @@ -3550,7 +3541,6 @@ async def test_host_volume_with_subpath_resolved_correctly(self, mock_docker): cfg = _app_config() cfg.storage = StorageConfig(allowed_host_paths=[tmpdir]) service = DockerSandboxService(config=cfg) - # Create the subPath directory sub_dir = os.path.join(tmpdir, "task-001") os.makedirs(sub_dir) @@ -3699,7 +3689,6 @@ async def test_empty_allowlist_rejects_host_path(self, mock_docker): mock_client.containers.get.return_value = MagicMock() mock_docker.from_env.return_value = mock_client - # Default config has storage.allowed_host_paths = [] cfg = _app_config() assert cfg.storage.allowed_host_paths == [] service = DockerSandboxService(config=cfg) diff --git a/server/tests/test_patch_metadata.py b/server/tests/test_patch_metadata.py index 6a55fa8fd..4cf017788 100644 --- a/server/tests/test_patch_metadata.py +++ b/server/tests/test_patch_metadata.py @@ -210,14 +210,12 @@ def test_reserved_prefix_rejected(self): def test_valid_metadata_accepted(self): from opensandbox_server.services.validators import ensure_metadata_labels - # Should not raise ensure_metadata_labels({"team": "platform", "version": "2.0"}) def test_null_values_not_validated(self): """Null values (deletions) do not need validation — they are removed before validate.""" from opensandbox_server.services.validators import ensure_metadata_labels - # This simulates what happens: null keys are popped before validation metadata = {"team": "valid", "bad-key": None} - metadata.pop("bad-key", None) # null removed + metadata.pop("bad-key", None) ensure_metadata_labels({"team": "valid"}) # only remaining keys validated diff --git a/server/tests/test_tenants.py b/server/tests/test_tenants.py index 78f8a8741..663a7f558 100644 --- a/server/tests/test_tenants.py +++ b/server/tests/test_tenants.py @@ -193,7 +193,6 @@ def test_file_provider_hot_reload(tmp_path): provider.start() try: assert provider.lookup("key-new") is None - # Modify file time.sleep(0.05) f.write_text("""\ [[tenants]] @@ -217,7 +216,6 @@ def test_file_provider_reload_bad_file_keeps_previous(tmp_path): try: f.write_text("invalid toml [[[") provider._reload() - # Should keep previous state assert provider.lookup("key-alpha-1") is not None finally: provider.close() @@ -271,7 +269,6 @@ def test_http_provider_lookup_cache_hit(): provider = HTTPTenantProvider(cfg) provider.start() try: - # Manually inject a cache entry from opensandbox_server.tenants.http_provider import _CacheEntry entry = TenantEntry(name="cached", namespace="ns-cached", api_keys=("key-c",)) diff --git a/server/tests/test_validators.py b/server/tests/test_validators.py index b87537476..b08752a1b 100644 --- a/server/tests/test_validators.py +++ b/server/tests/test_validators.py @@ -508,7 +508,6 @@ def test_invalid_volume_name_rejected_by_pydantic(self): """Invalid volume name should be rejected by Pydantic pattern validation.""" from pydantic import ValidationError - # Pydantic validates the pattern before our validators run with pytest.raises(ValidationError) as exc_info: Volume( name="Invalid_Name", # Invalid: uppercase and underscore @@ -522,7 +521,6 @@ def test_invalid_mount_path_rejected_by_pydantic(self): """Invalid mount path should be rejected by Pydantic pattern validation.""" from pydantic import ValidationError - # Pydantic validates the pattern before our validators run with pytest.raises(ValidationError) as exc_info: Volume( name="workdir", @@ -632,7 +630,6 @@ def test_invalid_pvc_name_rejected_by_pydantic(self): """Invalid PVC name should be rejected by Pydantic pattern validation.""" from pydantic import ValidationError - # Pydantic validates the pattern before our validators run with pytest.raises(ValidationError) as exc_info: PVC(claim_name="Invalid_PVC") # Invalid: uppercase and underscore assert "claim_name" in str(exc_info.value) diff --git a/specs/egress-api.yaml b/specs/egress-api.yaml index 08e488517..811203bb8 100644 --- a/specs/egress-api.yaml +++ b/specs/egress-api.yaml @@ -65,6 +65,53 @@ paths: $ref: '#/components/responses/Unauthorized' '500': $ref: '#/components/responses/InternalServerError' + post: + tags: [Policy] + summary: Replace the egress policy + description: | + Replace the currently enforced egress policy wholesale. + + - An empty request body resets the policy to deny-all. + - An object body is parsed as a `NetworkPolicy`; an empty object or + `null` also resets the policy to deny-all. + - `PUT` behaves identically to `POST`. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NetworkPolicy' + examples: + reset-to-deny-all: + summary: Reset policy to deny-all (empty body) + value: {} + allow-pypi: + summary: Allow pypi.org, deny everything else + value: + defaultAction: deny + egress: + - action: allow + target: pypi.org + responses: + '200': + description: Policy replaced successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/PolicyStatusResponse' + examples: + replaced: + summary: Policy replaced + value: + status: ok + mode: deny_all + enforcementMode: dns + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '500': + $ref: '#/components/responses/InternalServerError' patch: tags: [Policy] summary: Patch egress rules @@ -372,7 +419,7 @@ components: description: | Egress network policy matching the sidecar `/policy` request body. If `defaultAction` is omitted, the sidecar defaults to "deny"; passing an empty - object or null results in allow-all behavior at startup. + object or null resets the policy to deny-all at startup. properties: defaultAction: type: string @@ -394,8 +441,9 @@ components: target: type: string description: | - FQDN or wildcard domain (e.g., "example.com", "*.example.com"). - IP/CIDR not yet supported in the egress MVP. + FQDN, wildcard domain (e.g., "example.com", "*.example.com"), IPv4/IPv6 + address, or CIDR block (e.g., "10.96.0.0/12"). IP/CIDR targets are enforced + in nftables mode (`dns+nft`); in DNS-proxy mode they are not enforced. required: [action, target] additionalProperties: false CredentialVaultCreateRequest: diff --git a/specs/execd-api.yaml b/specs/execd-api.yaml index b62ea653f..3313d79cc 100644 --- a/specs/execd-api.yaml +++ b/specs/execd-api.yaml @@ -74,8 +74,8 @@ paths: parameters: - name: language in: query - required: true - description: Filter contexts by execution runtime (python, bash, java, etc.) + required: false + description: Filter contexts by execution runtime (python, bash, java, etc.). When omitted, all contexts are returned. schema: type: string example: python @@ -511,10 +511,12 @@ paths: Returns stdout and stderr for a background (detached) command by command ID. Foreground commands should be consumed via SSE; this endpoint is intended for polling logs of background commands. Supports incremental reads similar to a file seek: - pass a starting line via query to fetch output after that line and receive the latest - tail cursor for the next poll. When no starting line is provided, the full logs are returned. - Response body is plain text so it can be rendered directly in browsers; the latest line index - is provided via response header `EXECD-COMMANDS-TAIL-CURSOR` for subsequent incremental requests. + pass the byte offset returned by the previous response (via `EXECD-COMMANDS-TAIL-CURSOR`) + to fetch output after that position and receive the latest tail cursor for the next poll. + When no cursor is provided, the full logs are returned. + Response body is plain text so it can be rendered directly in browsers; the latest + byte offset is provided via response header `EXECD-COMMANDS-TAIL-CURSOR` for subsequent + incremental requests. operationId: getBackgroundCommandLogs tags: - Command @@ -530,10 +532,11 @@ paths: in: query required: false description: | - Optional 0-based line cursor (behaves like a file seek). When provided, only - stdout/stderr lines after this line are returned. The response includes the - latest line index (`cursor`) so the client can request incremental output - on subsequent calls. If omitted, the full log is returned. + Optional 0-based byte offset into the combined stdout/stderr output file + (behaves like a file seek). When provided, only output after this offset is + returned. The response header includes the latest byte offset (`cursor`) so the + client can request incremental output on subsequent calls. If omitted, the full + log is returned. schema: type: integer format: int64 @@ -552,7 +555,7 @@ paths: warn: something on stderr headers: EXECD-COMMANDS-TAIL-CURSOR: - description: Highest available 0-based line index after applying the request cursor (use as the next cursor for incremental reads) + description: Highest available byte offset after applying the request cursor (use as the next cursor for incremental reads) schema: type: integer format: int64 @@ -1808,8 +1811,9 @@ components: in: header name: X-EXECD-ACCESS-TOKEN description: | - Access token for API authentication. All requests must include this header - with a valid token. The token is configured during server initialization. + Optional shared access token for API authentication. Requests must include this + header with a valid token when the server is started with `--access-token`. + When no token is configured (the default), the header is not enforced. schemas: CreateSessionRequest: @@ -2170,7 +2174,7 @@ components: cpu_count: type: number format: float - description: Number of CPU cores + description: Number of CPU cores visible to the process (GOMAXPROCS, may reflect cgroup CPU quota) example: 4.0 cpu_used_pct: type: number @@ -2426,7 +2430,7 @@ components: properties: status: type: string - enum: ["active", "dead", "destroyed"] + enum: ["active", "dead"] created_at: type: string format: date-time @@ -2479,7 +2483,7 @@ components: format: uuid status: type: string - enum: ["active", "dead", "destroyed"] + enum: ["active", "dead"] created_at: type: string format: date-time diff --git a/specs/sandbox-lifecycle.yml b/specs/sandbox-lifecycle.yml index 8564db4f8..c9ca0bd7d 100644 --- a/specs/sandbox-lifecycle.yml +++ b/specs/sandbox-lifecycle.yml @@ -12,7 +12,7 @@ info: A sandbox follows this lifecycle: - 1. **Creation** → Sandbox is provisioned and enters `Running` state + 1. **Creation** → Sandbox is provisioned asynchronously (`Pending` → `Running`) 2. **Execution** → Sandbox is running and ready to accept requests 3. **Pause** (optional) → `Pausing` → `Paused` (asynchronous process) 4. **Resume** (optional) → `Resuming` → `Running` (asynchronous process) @@ -245,11 +245,11 @@ paths: responses: '202': description: | - Sandbox created and provisioned successfully. + Sandbox creation accepted and provisioning started asynchronously. The returned sandbox includes: - `id`: Unique sandbox identifier - - `status.state: "Running"` (provisioning completed synchronously) + - `status.state`: one of `Pending` (provisioning in progress) or `Running` (already provisioned) - `status.reason` and `status.message` indicating current state - `metadata`, `expiresAt`, `createdAt`: Core sandbox information @@ -1480,8 +1480,11 @@ components: type: string description: | Public URL to access the service from outside the sandbox. - Format: {endpoint-host}/sandboxes/{sandboxId}/port/{port} - Example: endpoint.opensandbox.io/sandboxes/abc123/port/8080 + The exact shape depends on the runtime and network mode, e.g. direct + `{endpoint-host}:{port}`, execd proxy + `{endpoint-host}:{hostProxyPort}/proxy/{port}`, or server proxy + `{endpoint-host}/sandboxes/{sandboxId}/proxy/{port}`. + Example: endpoint.opensandbox.io/sandboxes/abc123/proxy/8080 headers: type: object additionalProperties: @@ -1497,7 +1500,7 @@ components: description: | Egress network policy matching the sidecar `/policy` request body. If `defaultAction` is omitted, the sidecar defaults to "deny"; passing an empty - object or null results in allow-all behavior at startup. + object or null resets the policy to deny-all at startup. properties: defaultAction: type: string @@ -1540,8 +1543,9 @@ components: target: type: string description: | - FQDN or wildcard domain (e.g., "example.com", "*.example.com"). - IP/CIDR not yet supported in the egress MVP. + FQDN, wildcard domain (e.g., "example.com", "*.example.com"), IPv4/IPv6 + address, or CIDR block (e.g., "10.96.0.0/12"). IP/CIDR targets are enforced + in nftables mode (`dns+nft`); in DNS-proxy mode they are not enforced. required: [action, target] additionalProperties: false