diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dad9521..cf860ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,43 @@ jobs: - name: Run ruff formatting check run: uv run ruff format --check . + golang-checks: + name: Go Quality Checks and Tests + runs-on: ubuntu-latest + + defaults: + run: + working-directory: ./code-interpreter-go + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: code-interpreter-go/go.mod + + - name: Check formatting + run: test -z "$(gofmt -l .)" + + - name: Build + run: go build ./... + + - name: Run go vet + run: go vet ./... + + - name: Run tests + run: go test ./... + + - name: Pull executor Docker image + run: docker pull onyxdotapp/python-executor-sci:latest + + - name: Run Docker executor end-to-end test + run: go test ./internal/executor -run TestDockerExecutorEndToEnd -v + env: + CODE_INTERPRETER_GO_E2E: "1" + integration-tests: name: Integration Tests runs-on: ubuntu-latest diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index 141791d..70250a4 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -96,3 +96,116 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + + # Each platform builds on a NATIVE runner (no QEMU emulation) and pushes + # per-target images by digest; the merge job below assembles the multi-arch + # manifest lists. Targets are defined in code-interpreter-go/docker-bake.hcl. + build-code-interpreter-go: + name: Build Code Interpreter Go (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + needs: build-executor + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-latest + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ env.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to dhi.io for hardened base images + uses: docker/login-action@v3 + with: + registry: dhi.io + username: ${{ env.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push by digest + id: bake + uses: docker/bake-action@v5 + with: + workdir: ./code-interpreter-go + set: | + *.platform=${{ matrix.platform }} + *.output=type=image,name=${{ env.DOCKERHUB_USERNAME }}/code-interpreter-go,push-by-digest=true,name-canonical=true,push=true + full.cache-from=type=gha,scope=code-interpreter-go-full-${{ matrix.arch }} + full.cache-to=type=gha,scope=code-interpreter-go-full-${{ matrix.arch }},mode=max + slim.cache-from=type=gha,scope=code-interpreter-go-slim-${{ matrix.arch }} + slim.cache-to=type=gha,scope=code-interpreter-go-slim-${{ matrix.arch }},mode=max + env: + PUSH_BY_DIGEST: "1" + + - name: Export digests + run: | + mkdir -p /tmp/digests + cat <<'METADATA' > /tmp/digests/${{ matrix.arch }}.json + ${{ steps.bake.outputs.metadata }} + METADATA + + - name: Upload digests + uses: actions/upload-artifact@v4 + with: + name: code-interpreter-go-digests-${{ matrix.arch }} + path: /tmp/digests/${{ matrix.arch }}.json + retention-days: 1 + + merge-code-interpreter-go: + name: Merge Code Interpreter Go Manifests + runs-on: ubuntu-latest + needs: build-code-interpreter-go + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download digests + uses: actions/download-artifact@v4 + with: + pattern: code-interpreter-go-digests-* + path: /tmp/digests + merge-multiple: true + + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ env.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Read version from config + id: version + run: echo "version=$(grep -m1 'ServiceVersion = ' code-interpreter-go/internal/config/config.go | cut -d'"' -f2)" >> "$GITHUB_OUTPUT" + + - name: Create multi-arch manifests + run: | + REPO="${{ env.DOCKERHUB_USERNAME }}/code-interpreter-go" + VERSION="${{ steps.version.outputs.version }}" + digest() { jq -r --arg t "$1" '.[$t]["containerimage.digest"]' "/tmp/digests/$2.json"; } + + docker buildx imagetools create \ + -t "$REPO:$VERSION" -t "$REPO:latest" \ + "$REPO@$(digest full amd64)" "$REPO@$(digest full arm64)" + + docker buildx imagetools create \ + -t "$REPO:$VERSION-slim" -t "$REPO:latest-slim" \ + "$REPO@$(digest slim amd64)" "$REPO@$(digest slim arm64)" + + - name: Inspect published manifests + run: | + REPO="${{ env.DOCKERHUB_USERNAME }}/code-interpreter-go" + docker buildx imagetools inspect "$REPO:${{ steps.version.outputs.version }}" + docker buildx imagetools inspect "$REPO:${{ steps.version.outputs.version }}-slim" diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..773ebd0 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,94 @@ +# Python vs Go service benchmarks + +Measured comparison of the two code-interpreter implementations +(`code-interpreter/`, Python/FastAPI/uvicorn vs `code-interpreter-go/`, +Go/net/http) to inform the migration decision. + +## Methodology + +`harness/` spawns each service as a native process with identical +configuration (single instance, access logs suppressed on both, unique file +storage dir per run) and measures: + +- **Cold start**: process spawn → first `200` from `GET /v1/files`, polled + every 2 ms. 11 runs each, first discarded (page cache / `.pyc` warmup). +- **CPU-to-ready**: `utime+stime` from `/proc//stat` at readiness. +- **Memory**: `VmRSS`/`VmHWM` from `/proc//status` after a 3 s idle + settle, and again after the load phases. +- **API-layer latency/throughput**: identical HTTP client (keep-alive, + N workers, fixed wall-clock duration) driving storage and validation + endpoints, which exercise routing, (de)serialization, validation, and file + I/O without needing an executor backend. + +Environment: 24-CPU shared Linux host (noisy neighbors — absolute numbers +vary run to run; ratios are stable). Two full runs are reported as ranges. +The sandbox lacks `CAP_SYS_ADMIN`, so no Docker daemon could run: +**end-to-end `/v1/execute` latency (container spin-up dominated) is not +measured here** — the Go repo's CI runs a real-daemon e2e test, and both +implementations pay the same daemon-side container costs; they differ in +orchestration overhead (Python spawns ~6 docker-CLI subprocesses per +execution, Go makes Engine API calls over one socket), which this harness +cannot isolate without a daemon. + +Reproduce: + +```bash +(cd code-interpreter && uv sync --locked) +(cd code-interpreter-go && CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w" -o /tmp/code-interpreter-api ./cmd/code-interpreter-api) +cd benchmarks/harness && go run . +python3 benchmarks/imagesizes.py # registry-reported compressed image sizes +``` + +## Results (2026-07, two runs, median values shown as ranges) + +### Startup and memory + +| Metric | Python | Go | Go advantage | +|---|---|---|---| +| Cold start to first served request | 271–763 ms | 21–30 ms | **13–25× faster** | +| CPU time to readiness | 0.26–0.73 s | 0.02–0.03 s | **~13–24× less** | +| Idle RSS | 48 MiB | 31 MiB | **~36% less** | +| RSS after load burst | 52 MiB | 43 MiB | ~17% less | + +### API-layer throughput (single instance) + +| Endpoint (workers) | Python req/s | Go req/s | p50 latency (Py → Go) | Speedup | +|---|---|---|---|---| +| download 100 KiB (c=1) | 1 114–2 112 | 3 709–4 083 | 0.42–0.62 ms → 0.19 ms | ~2–3.5× | +| download 100 KiB (c=16) | 1 227–1 717 | 8 330–9 870 | 7.7–11.4 ms → 1.3–1.5 ms | **~6–8×** | +| list files (c=16) | 517–590 | 14 859–18 179 | 25–27 ms → 0.6–0.8 ms | **~25–35×** | +| execute validation path (c=1) | 880–1 160 | 9 694–11 355 | 0.6–0.7 ms → 0.07 ms | **~8–13×** | +| upload 100 KiB (c=4) | 292–477 | 1 711–2 269 | 7.7–11.7 ms → 1.6–2.0 ms | ~4–6× | + +The Python service plateaus under concurrency (GIL + threadpool: c=16 +download throughput barely exceeds c=1); the Go service scales with cores. +Matching Go's c=16 throughput with uvicorn workers would take roughly 6–8 +processes at ~48 MiB each (~300–400 MiB) vs one 43 MiB Go process. + +### Sizes + +Registry-reported compressed sizes (Docker Hub manifests, linux/amd64): + +| Artifact | Size | Source | +|---|---|---| +| `onyxdotapp/code-interpreter:latest` (Python, with Docker daemon) | **194 MB** | measured (registry) | +| — of which docker-ce layer | 104 MB | measured (largest layer) | +| — of which `python:3.11-slim` base | 45 MB | measured (registry) | +| — of which app deps/venv + uv layers | ~38 MB | measured (remaining layers) | +| `debian:bookworm-slim` / `trixie-slim` base | 28 / 30 MB | measured (registry) | +| Go service binary (stripped, gzipped) | 13 MB | measured | +| **Go `-slim` image (base + binary + ca-certs/passwd)** | **~45 MB** | derived | +| **Go default image (adds the same docker-ce layer)** | **~150 MB** | derived | + +On-disk application payload: Python needs the CPython runtime (84 MB) plus a +129 MB venv; the Go service is a single 46 MB static binary. + +## Not measured + +- End-to-end `/v1/execute` (needs a Docker daemon; dominated by container + create/start either way). Run the harness on a Docker-capable host to add + this; CI's `TestDockerExecutorEndToEnd` covers correctness. +- Kubernetes-backend performance. +- Uncompressed on-disk image sizes (needs a daemon to build; compressed + pull sizes above are what registries report and networks transfer). diff --git a/benchmarks/harness/go.mod b/benchmarks/harness/go.mod new file mode 100644 index 0000000..e35b97e --- /dev/null +++ b/benchmarks/harness/go.mod @@ -0,0 +1,3 @@ +module github.com/onyx-dot-app/python-sandbox/benchmarks/harness + +go 1.26.3 diff --git a/benchmarks/harness/main.go b/benchmarks/harness/main.go new file mode 100644 index 0000000..a6713d6 --- /dev/null +++ b/benchmarks/harness/main.go @@ -0,0 +1,449 @@ +// Benchmark harness comparing the Python and Go code-interpreter services. +// Runs each server as a native process (no Docker daemon required), +// measuring: +// - cold-start time to first served request (N runs, first discarded) +// - CPU time consumed to reach readiness +// - idle RSS after settle, RSS after load +// - API-layer latency/throughput on storage endpoints (no executor needed) +// +// Usage: +// +// (cd code-interpreter && uv sync --locked) +// (cd code-interpreter-go && CGO_ENABLED=0 go build -trimpath \ +// -ldflags="-s -w" -o /tmp/code-interpreter-api ./cmd/code-interpreter-api) +// cd benchmarks/harness && go run . +// +// Environment overrides: PYTHON_SERVICE_BIN, PYTHON_SERVICE_DIR, +// GO_SERVICE_BIN. Defaults assume running from benchmarks/harness. +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" +) + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// abs resolves a path against the harness's own working directory. Relative +// argv[0] paths would otherwise resolve against the child's Dir at exec time. +func abs(path string) string { + a, err := filepath.Abs(path) + if err != nil { + return path + } + return a +} + +type target struct { + name string + argv []string + dir string +} + +var targets = []target{ + { + name: "python", + argv: []string{abs(envOr( + "PYTHON_SERVICE_BIN", "../../code-interpreter/.venv/bin/code-interpreter-api", + ))}, + dir: abs(envOr("PYTHON_SERVICE_DIR", "../../code-interpreter")), + }, + { + name: "go", + argv: []string{abs(envOr("GO_SERVICE_BIN", "/tmp/code-interpreter-api"))}, + dir: "/tmp", + }, +} + +type proc struct { + cmd *exec.Cmd + port int + base string + stderr *bytes.Buffer +} + +func spawn(t target, port int, storageDir string) (*proc, error) { + cmd := exec.Command(t.argv[0], t.argv[1:]...) + cmd.Dir = t.dir + cmd.Env = append(os.Environ(), + "HOST=127.0.0.1", + "PORT="+strconv.Itoa(port), + "FILE_STORAGE_DIR="+storageDir, + "EXECUTOR_BACKEND=docker", + // No daemon in this environment: Go skips DinD bootstrap via the + // external-daemon branch; Python ignores this variable. + "DOCKER_HOST=unix:///nonexistent-bench.sock", + "LOG_LEVEL=WARNING", // suppress access logs on both for fair load tests + ) + var errBuf bytes.Buffer + cmd.Stdout = &errBuf + cmd.Stderr = &errBuf + if err := cmd.Start(); err != nil { + return nil, err + } + return &proc{ + cmd: cmd, + port: port, + base: "http://127.0.0.1:" + strconv.Itoa(port), + stderr: &errBuf, + }, nil +} + +func (p *proc) waitReady(client *http.Client, timeout time.Duration) (time.Duration, error) { + start := time.Now() + deadline := start.Add(timeout) + for time.Now().Before(deadline) { + resp, err := client.Get(p.base + "/v1/files") + if err == nil { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if resp.StatusCode == 200 { + return time.Since(start), nil + } + } + time.Sleep(2 * time.Millisecond) + } + return 0, fmt.Errorf("not ready within %s; stderr:\n%s", timeout, p.stderr.String()) +} + +func (p *proc) kill() { + _ = p.cmd.Process.Signal(syscall.SIGKILL) + _, _ = p.cmd.Process.Wait() +} + +// procMem returns VmRSS and VmHWM in KiB. +func procMem(pid int) (rss, hwm int64) { + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)) + if err != nil { + return 0, 0 + } + for _, line := range strings.Split(string(data), "\n") { + if v, ok := strings.CutPrefix(line, "VmRSS:"); ok { + rss, _ = strconv.ParseInt(strings.Fields(v)[0], 10, 64) + } + if v, ok := strings.CutPrefix(line, "VmHWM:"); ok { + hwm, _ = strconv.ParseInt(strings.Fields(v)[0], 10, 64) + } + } + return rss, hwm +} + +// procCPU returns utime+stime in seconds. +func procCPU(pid int) float64 { + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if err != nil { + return 0 + } + // Fields after the comm field (which may contain spaces inside parens). + idx := strings.LastIndexByte(string(data), ')') + fields := strings.Fields(string(data)[idx+2:]) + utime, _ := strconv.ParseFloat(fields[11], 64) // field 14 overall + stime, _ := strconv.ParseFloat(fields[12], 64) // field 15 overall + return (utime + stime) / 100.0 +} + +func newClient() *http.Client { + return &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 64, + MaxIdleConnsPerHost: 64, + }, + Timeout: 30 * time.Second, + } +} + +type startupResult struct { + ReadyMs []float64 + CPUSec []float64 +} + +func benchStartup(t target, runs int, basePort int) (startupResult, error) { + var res startupResult + client := newClient() + for i := 0; i < runs; i++ { + storage, _ := os.MkdirTemp("", "bench-"+t.name+"-*") + p, err := spawn(t, basePort+i, storage) + if err != nil { + return res, err + } + d, err := p.waitReady(client, 60*time.Second) + if err != nil { + p.kill() + return res, fmt.Errorf("%s run %d: %w", t.name, i, err) + } + cpu := procCPU(p.cmd.Process.Pid) + p.kill() + _ = os.RemoveAll(storage) + // Discard the first (warmup: page cache, .pyc compilation). + if i > 0 { + res.ReadyMs = append(res.ReadyMs, float64(d.Microseconds())/1000.0) + res.CPUSec = append(res.CPUSec, cpu) + } + } + return res, nil +} + +type latencyStats struct { + RPS float64 + P50 float64 + P90 float64 + P99 float64 + N int +} + +func percentile(sorted []float64, p float64) float64 { + if len(sorted) == 0 { + return 0 + } + idx := int(p * float64(len(sorted)-1)) + return sorted[idx] +} + +func runLoad( + client *http.Client, + concurrency int, + duration time.Duration, + do func() error, +) (latencyStats, error) { + var mu sync.Mutex + var all []float64 + var firstErr error + start := time.Now() + var wg sync.WaitGroup + for w := 0; w < concurrency; w++ { + wg.Add(1) + go func() { + defer wg.Done() + var local []float64 + for time.Since(start) < duration { + t0 := time.Now() + err := do() + if err != nil { + mu.Lock() + if firstErr == nil { + firstErr = err + } + mu.Unlock() + return + } + local = append(local, float64(time.Since(t0).Microseconds())/1000.0) + } + mu.Lock() + all = append(all, local...) + mu.Unlock() + }() + } + wg.Wait() + if firstErr != nil { + return latencyStats{}, firstErr + } + elapsed := time.Since(start).Seconds() + sort.Float64s(all) + return latencyStats{ + RPS: float64(len(all)) / elapsed, + P50: percentile(all, 0.50), + P90: percentile(all, 0.90), + P99: percentile(all, 0.99), + N: len(all), + }, nil +} + +func getOK(client *http.Client, url string) error { + resp, err := client.Get(url) + if err != nil { + return err + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("GET %s: status %d", url, resp.StatusCode) + } + return nil +} + +func multipartBody(field, filename string, content []byte) (*bytes.Reader, string) { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, _ := mw.CreateFormFile(field, filename) + _, _ = fw.Write(content) + _ = mw.Close() + return bytes.NewReader(buf.Bytes()), mw.FormDataContentType() +} + +func upload(client *http.Client, base string, content []byte) (string, error) { + body, ctype := multipartBody("file", "bench.bin", content) + resp, err := client.Post(base+"/v1/files", ctype, body) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != 201 { + return "", fmt.Errorf("upload status %d", resp.StatusCode) + } + var payload struct { + FileID string `json:"file_id"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", err + } + return payload.FileID, nil +} + +type residentResult struct { + IdleRSSKiB int64 + IdleHWMKiB int64 + AfterLoadRSS int64 + AfterLoadHWM int64 + Download1 latencyStats + Download16 latencyStats + List16 latencyStats + Upload4 latencyStats + ExecuteReject1 latencyStats // POST /v1/execute with over-limit timeout: full parse+validate path, 422 +} + +func benchResident(t target, port int) (residentResult, error) { + var res residentResult + storage, _ := os.MkdirTemp("", "bench-res-"+t.name+"-*") + defer func() { _ = os.RemoveAll(storage) }() + + p, err := spawn(t, port, storage) + if err != nil { + return res, err + } + defer p.kill() + + client := newClient() + if _, err := p.waitReady(client, 60*time.Second); err != nil { + return res, err + } + + time.Sleep(3 * time.Second) + res.IdleRSSKiB, res.IdleHWMKiB = procMem(p.cmd.Process.Pid) + + content := bytes.Repeat([]byte("x"), 100*1024) // 100 KiB + fileID, err := upload(client, p.base, content) + if err != nil { + return res, err + } + dlURL := p.base + "/v1/files/" + fileID + listURL := p.base + "/v1/files" + + warm, err := runLoad(client, 4, 2*time.Second, func() error { return getOK(client, dlURL) }) + _ = warm + if err != nil { + return res, err + } + + if res.Download1, err = runLoad(client, 1, 5*time.Second, func() error { + return getOK(client, dlURL) + }); err != nil { + return res, err + } + if res.Download16, err = runLoad(client, 16, 5*time.Second, func() error { + return getOK(client, dlURL) + }); err != nil { + return res, err + } + if res.List16, err = runLoad(client, 16, 5*time.Second, func() error { + return getOK(client, listURL) + }); err != nil { + return res, err + } + + // Full request-validation path without an executor: timeout_ms above the + // configured maximum returns 422 on both implementations. + execBody := []byte(`{"code": "print('hi')", "timeout_ms": 99999999}`) + if res.ExecuteReject1, err = runLoad(client, 1, 3*time.Second, func() error { + resp, err := client.Post( + p.base+"/v1/execute", "application/json", bytes.NewReader(execBody), + ) + if err != nil { + return err + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if resp.StatusCode != 422 { + return fmt.Errorf("execute status %d", resp.StatusCode) + } + return nil + }); err != nil { + return res, err + } + + if res.Upload4, err = runLoad(client, 4, 3*time.Second, func() error { + _, err := upload(client, p.base, content) + return err + }); err != nil { + return res, err + } + + time.Sleep(1 * time.Second) + res.AfterLoadRSS, res.AfterLoadHWM = procMem(p.cmd.Process.Pid) + return res, nil +} + +func stats(v []float64) (min, med, max float64) { + if len(v) == 0 { + return + } + s := append([]float64{}, v...) + sort.Float64s(s) + return s[0], s[len(s)/2], s[len(s)-1] +} + +func main() { + fmt.Printf("host: %d CPUs\n\n", runtime.NumCPU()) + for i, t := range targets { + fmt.Printf("=== %s ===\n", t.name) + + su, err := benchStartup(t, 11, 19000+i*100) + if err != nil { + fmt.Printf("startup bench failed: %v\n", err) + os.Exit(1) + } + minMs, medMs, maxMs := stats(su.ReadyMs) + minC, medC, maxC := stats(su.CPUSec) + fmt.Printf("startup_to_ready_ms: min=%.1f median=%.1f max=%.1f (n=%d)\n", + minMs, medMs, maxMs, len(su.ReadyMs)) + fmt.Printf("startup_cpu_sec: min=%.2f median=%.2f max=%.2f\n", minC, medC, maxC) + + res, err := benchResident(t, 19900+i) + if err != nil { + fmt.Printf("resident bench failed: %v\n", err) + os.Exit(1) + } + fmt.Printf("idle_rss_mib: %.1f (hwm %.1f)\n", + float64(res.IdleRSSKiB)/1024, float64(res.IdleHWMKiB)/1024) + fmt.Printf("after_load_rss_mib: %.1f (hwm %.1f)\n", + float64(res.AfterLoadRSS)/1024, float64(res.AfterLoadHWM)/1024) + pr := func(name string, s latencyStats) { + fmt.Printf("%-22s rps=%8.0f p50=%6.2fms p90=%6.2fms p99=%6.2fms (n=%d)\n", + name, s.RPS, s.P50, s.P90, s.P99, s.N) + } + pr("download_100k_c1:", res.Download1) + pr("download_100k_c16:", res.Download16) + pr("list_files_c16:", res.List16) + pr("execute_validate_c1:", res.ExecuteReject1) + pr("upload_100k_c4:", res.Upload4) + fmt.Println() + } +} diff --git a/benchmarks/imagesizes.py b/benchmarks/imagesizes.py new file mode 100644 index 0000000..a618c1b --- /dev/null +++ b/benchmarks/imagesizes.py @@ -0,0 +1,52 @@ +"""Fetch real compressed image sizes (pull size) from Docker Hub manifests.""" + +import json +import urllib.request + +ACCEPT = ", ".join( + [ + "application/vnd.docker.distribution.manifest.list.v2+json", + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", + "application/vnd.oci.image.manifest.v1+json", + ] +) + + +def fetch(url, headers=None): + req = urllib.request.Request(url, headers=headers or {}) + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read()) + + +def image_size(repo, tag): + token = fetch( + f"https://auth.docker.io/token?service=registry.docker.io&scope=repository:{repo}:pull" + )["token"] + headers = {"Authorization": f"Bearer {token}", "Accept": ACCEPT} + manifest = fetch(f"https://registry-1.docker.io/v2/{repo}/manifests/{tag}", headers) + + if "manifests" in manifest: # multi-arch index: pick linux/amd64 + digest = next( + m["digest"] + for m in manifest["manifests"] + if m["platform"]["os"] == "linux" + and m["platform"]["architecture"] == "amd64" + ) + manifest = fetch( + f"https://registry-1.docker.io/v2/{repo}/manifests/{digest}", headers + ) + + layers = [layer["size"] for layer in manifest["layers"]] + return sum(layers), layers + + +for repo, tag in [ + ("onyxdotapp/code-interpreter", "latest"), + ("library/python", "3.11-slim"), + ("library/debian", "bookworm-slim"), + ("library/debian", "trixie-slim"), +]: + total, layers = image_size(repo, tag) + layer_mb = ", ".join(f"{s / 1e6:.1f}" for s in sorted(layers, reverse=True)) + print(f"{repo}:{tag} total={total / 1e6:.1f} MB (compressed) layers MB: [{layer_mb}]") diff --git a/code-interpreter-go/.gitignore b/code-interpreter-go/.gitignore new file mode 100644 index 0000000..0bbdf65 --- /dev/null +++ b/code-interpreter-go/.gitignore @@ -0,0 +1,2 @@ +/code-interpreter-api +/dist/ diff --git a/code-interpreter-go/Dockerfile b/code-interpreter-go/Dockerfile new file mode 100644 index 0000000..abd1104 --- /dev/null +++ b/code-interpreter-go/Dockerfile @@ -0,0 +1,109 @@ +# Dockerfile for the Go API service (distinct from executor image) +# +# Base images default to Docker Hardened Images (DHI), pulled directly from +# Docker's dhi.io registry (requires a DHI subscription and `docker login +# dhi.io`); override the build args to use public images instead: +# +# docker build \ +# --build-arg BUILD_IMAGE=golang:1.26-bookworm \ +# --build-arg RUNTIME_IMAGE=debian:bookworm-slim \ +# -t code-interpreter-go . +# +# The -dev runtime variant is required for the default (Docker-in-Docker) +# build because apt installs the Docker daemon at build time. The service +# binary itself needs no shell — the entrypoint bootstrap (including starting +# dockerd for DinD) is built into it. +# +# Multi-platform builds: the runtime stage runs apt for the target +# architecture, so cross-building (e.g. arm64 on an amd64 host) needs QEMU +# binfmt registered once per host ("exec format error" means it is missing): +# +# docker run --privileged --rm tonistiigi/binfmt --install arm64 +# +# For local development, skip all of that and build only your native +# platform: +# docker buildx bake local +ARG BUILD_IMAGE=dhi.io/golang:1.26-debian13-dev +ARG RUNTIME_IMAGE=dhi.io/debian-base:trixie-dev + +# The build stage always runs on the build host's native architecture and +# cross-compiles for the target — Go needs no emulation for this, and it +# avoids running the toolchain under QEMU on multi-platform builds. +FROM --platform=${BUILDPLATFORM} ${BUILD_IMAGE} AS build + +USER root +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +ARG TARGETOS +ARG TARGETARCH + +COPY cmd ./cmd +COPY internal ./internal +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + go build -trimpath -ldflags="-s -w" \ + -o /out/code-interpreter-api ./cmd/code-interpreter-api + +FROM ${RUNTIME_IMAGE} + +# Toggle off to skip installing the Docker daemon. Unlike the Python image, +# a SKIP_NESTED_DOCKER=1 build still supports Docker-out-of-Docker (mounted +# socket) — the service uses the Engine API directly — so only +# Docker-in-Docker deployments need the default full build. +ARG SKIP_NESTED_DOCKER=0 + +USER root +WORKDIR /app + +# passwd provides groupadd/useradd for the build-time user setup below; both +# packages are present on stock debian images but not guaranteed in hardened +# ones. No shell is required at runtime. +RUN apt-get update \ + && apt-get install -y --no-install-recommends passwd ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Docker's apt signing key, fetched at build time so curl/gnupg are never +# installed in the image. +ADD https://download.docker.com/linux/debian/gpg /etc/apt/keyrings/docker.asc + +# Install the Docker daemon if not disabled via build arg. This is needed +# ONLY for Docker-in-Docker (--privileged with no socket mounted): the +# service talks to the daemon through the Engine API socket, so +# Docker-out-of-Docker and Kubernetes deployments need no docker packages at +# all. docker-ce-cli comes along as an apt dependency of docker-ce; the +# service itself never invokes it. +RUN if [ "${SKIP_NESTED_DOCKER}" != "1" ]; then \ + echo "Installing Docker daemon for Docker-in-Docker support"; \ + chmod a+r /etc/apt/keyrings/docker.asc && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "${VERSION_CODENAME}") stable" | \ + tee /etc/apt/sources.list.d/docker.list > /dev/null && \ + apt-get update && \ + apt-get install -y --no-install-recommends iptables docker-ce docker-ce-cli containerd.io && \ + rm -rf /var/lib/apt/lists/*; \ + which docker && docker --version || echo "Docker installation failed"; \ + else \ + rm -f /etc/apt/keyrings/docker.asc; \ + fi + +COPY --from=build /out/code-interpreter-api /usr/local/bin/code-interpreter-api + +# Create api user with UID 1000 to match Kubernetes security context and optionally add to docker group +RUN groupadd --gid 1000 api \ + && useradd --uid 1000 --gid api --create-home --home-dir /home/api api \ + && chown -R api:api /app \ + && if [ "${SKIP_NESTED_DOCKER}" != "1" ]; then \ + usermod -aG docker api || true; \ + fi + +ENV PYTHON_EXECUTOR_DOCKER_IMAGE=onyxdotapp/python-executor-sci + +# Don't switch to api user - let the container decide at runtime +# This allows running with --user root when Docker access is needed + +EXPOSE 8000 + +# The binary performs its own Docker bootstrap (mode detection, cgroup v2 +# nesting, and starting dockerd for Docker-in-Docker) — no shell entrypoint. +ENTRYPOINT ["/usr/local/bin/code-interpreter-api"] diff --git a/code-interpreter-go/README.md b/code-interpreter-go/README.md new file mode 100644 index 0000000..6a3e5e6 --- /dev/null +++ b/code-interpreter-go/README.md @@ -0,0 +1,156 @@ +# code-interpreter-go + +Go port of the `code-interpreter` FastAPI service. Manages execution of +arbitrary Python code; the server itself never runs user-defined Python — it +delegates to an isolated `executor` backend (Docker container or Kubernetes +pod). + +## Layout + +``` +cmd/code-interpreter-api/ entrypoint (port of app/main.py) +internal/api/ HTTP routes + schemas (app/api/routes.py, app/models/schemas.py) +internal/bootstrap/ Docker environment setup at startup (port of entrypoint.sh) +internal/executor/ backend abstraction, Docker and Kubernetes executors +internal/storage/ uploaded-file storage (app/services/file_storage.py) +internal/config/ environment-based settings (app/app_configs.py) +internal/logging/ plain/JSON logging (app/logging_config.py) +internal/imageref/ Docker image reference normalization (app/image_ref.py) +``` + +## Build & run + +```bash +go build ./... +go run ./cmd/code-interpreter-api # respects HOST/PORT, defaults 0.0.0.0:8000 +``` + +Docker image (same Docker-in-Docker / Docker-out-of-Docker support as the +Python image; the entrypoint.sh logic is built into the binary). Bases +default to Docker Hardened Images mirrored into the org namespace +(`onyxdotapp/dhi-golang`, `onyxdotapp/dhi-debian`); override the build args +to use public images: + +Image variants are defined in `docker-bake.hcl`; CI builds each platform on +a native runner from the same definition and merges the manifests. + +Local builds use the `local` targets, which build only your machine's native +platform (no QEMU needed) and load the result into `docker images`: + +```bash +docker buildx bake local # code-interpreter-go:local and :local-slim +docker buildx bake slim-local # just the slim variant +# Without a DHI subscription: +BUILD_IMAGE=golang:1.26-bookworm RUNTIME_IMAGE=debian:bookworm-slim \ + docker buildx bake local +# Plain docker build works too: +docker build . -t code-interpreter-go +``` + +## Published images + +Each release publishes two variants of `onyxdotapp/code-interpreter-go`: + +- **`:` (default)** — includes the Docker daemon, so every + deployment mode works out of the box, including Docker-in-Docker. Pick + this if unsure. +- **`:-slim`** — no docker packages at all (built with + `SKIP_NESTED_DOCKER=1`). Opt in when deploying with a mounted socket + (Docker-out-of-Docker) or on Kubernetes; because the service talks to the + Engine API socket directly, DooD needs no docker packages in the image — + the Python service still required the docker CLI for that. + +## Deployment modes + +In order of preference: + +1. **Kubernetes** (`EXECUTOR_BACKEND=kubernetes`): executors run as locked + down pods; no Docker anywhere. Use the `-slim` image. +2. **Docker-out-of-Docker**: mount a Docker socket + (`-v /var/run/docker.sock:/var/run/docker.sock`); executor containers are + created on that daemon. Use the `-slim` image. Prefer mounting a + **rootless** daemon's socket (`$XDG_RUNTIME_DIR/docker.sock`): a rootful + socket is root-equivalent on the host, while a rootless daemon caps the + blast radius of a service compromise at one unprivileged user and adds + user-namespace isolation around executors. Rootless requires cgroup v2 + with systemd delegation for the memory/pids limits to apply. +3. **Docker-in-Docker** (default image, `--privileged`, no socket mounted): + the service starts its own nested daemon. For environments where no + daemon can be shared. Note `--privileged` disables seccomp/AppArmor and + is effectively root on the host — rootless DooD is the safer choice when + available. + +## Test + +```bash +go vet ./... +go test ./... + +# Optional end-to-end test against a real Docker daemon: +CODE_INTERPRETER_GO_E2E=1 go test ./internal/executor -run TestDockerExecutorEndToEnd +``` + +## Configuration + +Same environment variables as the Python service: `EXECUTOR_BACKEND` +(docker|kubernetes), `PYTHON_EXECUTOR_DOCKER_IMAGE/RUN_ARGS/NETWORK`, +`KUBERNETES_EXECUTOR_NAMESPACE/IMAGE/SERVICE_ACCOUNT/NET_ADMIN_LOCKDOWN`, +`MAX_EXEC_TIMEOUT_MS`, `MAX_OUTPUT_BYTES`, `CPU_TIME_LIMIT_SEC`, +`MEMORY_LIMIT_MB`, `HOST`, `PORT`, `LOG_LEVEL`, `LOG_FORMAT`, +`FILE_STORAGE_DIR`, `MAX_FILE_SIZE_MB`, `FILE_TTL_SEC`. + +Docker-specific changes: + +- The service talks to the Docker Engine API directly (no docker CLI + subprocesses), so the daemon connection uses the standard `DOCKER_HOST` / + `DOCKER_API_VERSION` / `DOCKER_CERT_PATH` / `DOCKER_TLS_VERIFY` variables, + defaulting to the local socket. `PYTHON_EXECUTOR_DOCKER_BIN` is no longer + read. +- `PYTHON_EXECUTOR_DOCKER_RUN_ARGS` accepts a documented subset instead of + arbitrary `docker run` flags: `--label`, `--env`/`-e`, `--add-host`, + `--dns`, and `--ulimit` (space or `=` separated). Any other flag fails at + startup rather than being silently dropped. + +## API + +Same surface and payloads as the Python service: + +- `GET /health` +- `POST /v1/execute` and `POST /v1/execute/stream` (SSE: `output`, `result`, + `error` events) +- `POST/GET/DELETE /v1/files`, `GET /v1/files/{file_id}` +- `POST /v1/sessions`, `DELETE /v1/sessions/{session_id}`, + `POST /v1/sessions/{session_id}/bash` + +## Intentional differences from the Python implementation + +- **No shell entrypoint**: the binary itself performs entrypoint.sh's job at + startup — mode detection (Kubernetes / external `DOCKER_HOST` / mounted + socket / Docker-in-Docker), cgroup v2 nesting setup, launching and + supervising a nested `dockerd`, and waiting for readiness via an Engine + API ping instead of `docker info`. The image runs the binary directly and + needs no shell at runtime. +- **Docker Engine API instead of the docker CLI**: containers are managed via + `github.com/docker/docker/client` (create/start/kill/list/remove, exec + attach with demultiplexed streams, image pull), giving typed error handling + (e.g. not-found) instead of parsing subprocess stderr. File staging and the + workspace snapshot still run `tar` *inside* the container over the exec + API, because `/workspace` is a tmpfs mount that the engine's copy API + cannot see. See the configuration notes above for the + `PYTHON_EXECUTOR_DOCKER_BIN` and `RUN_ARGS` implications. +- **Validation error bodies**: request-validation failures return + `{"detail": ""}` (a string) with HTTP 422, rather than Pydantic's + array-of-objects `detail`. Status codes are unchanged. +- **Ephemeral container keepalive**: the idle `sleep` for one-shot executions + is `timeout_ms/1000 + 10` seconds. The Python version computed + `timeout_ms * 1000 + 10` (a unit slip yielding multi-day sleeps); the + container is killed after execution either way, but a crashed API now leaks + the container for seconds instead of days. +- **Kubernetes workspace snapshot**: streamed over SPDY, which is + binary-safe, so the `tar | base64` round-trip the websocket client needed + is gone — the pod-side command is plain `tar -c`. +- **Hardened base images**: the Dockerfile defaults to Docker Hardened Images + (DHI) `-dev` variants for both build and runtime stages, overridable via + `BUILD_IMAGE` / `RUNTIME_IMAGE` build args. +- **No OpenAPI docs endpoints**: `/docs`, `/redoc`, and `/openapi.json` are + not served. diff --git a/code-interpreter-go/cmd/code-interpreter-api/main.go b/code-interpreter-go/cmd/code-interpreter-api/main.go new file mode 100644 index 0000000..5dc16d0 --- /dev/null +++ b/code-interpreter-go/cmd/code-interpreter-api/main.go @@ -0,0 +1,126 @@ +// Command code-interpreter-api runs the code-interpreter HTTP service. It is +// the Go port of app/main.py. +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/api" + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/bootstrap" + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/config" + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/executor" + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/logging" + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/storage" +) + +const sessionReaperInterval = 30 * time.Second + +// reapExpiredSessionsOnce runs a single reap pass via the configured +// executor. Failures are logged, never fatal. +func reapExpiredSessionsOnce(exec executor.Executor, log *slog.Logger) { + count, err := exec.ReapExpiredSessions() + if err != nil { + log.Warn("Session reaper pass failed", "error", err.Error()) + return + } + if count > 0 { + log.Info(fmt.Sprintf("Reaped %d expired session(s)", count)) + } +} + +// sessionReaperLoop periodically deletes sessions whose TTL has elapsed, +// until ctx is cancelled. +func sessionReaperLoop(ctx context.Context, exec executor.Executor, log *slog.Logger) { + ticker := time.NewTicker(sessionReaperInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + reapExpiredSessionsOnce(exec, log) + } + } +} + +func run() error { + cfg := config.Load() + logging.Setup(cfg) + log := logging.Named("main") + + // Prepare the Docker environment (Docker-in-Docker daemon if required) + // before anything talks to the socket. This replaces entrypoint.sh. + stopDockerd, err := bootstrap.BootstrapDockerEnvironment(cfg) + if err != nil { + return err + } + defer stopDockerd() + + // Startup: ensure the Docker executor image is available before accepting + // requests. + if strings.ToLower(cfg.ExecutorBackend) == "docker" { + log.Info("Ensuring Docker executor image is available...") + if err := executor.EnsureDockerImageAvailable(cfg); err != nil { + return err + } + log.Info("Docker executor image is ready") + } + + exec, err := executor.New(cfg) + if err != nil { + return err + } + + store, err := storage.NewFileStorageService(cfg.FileStorageDir) + if err != nil { + return err + } + + // Reap any sessions whose TTL elapsed while the service was down, then + // keep reaping in the background. + reapExpiredSessionsOnce(exec, log) + reaperCtx, stopReaper := context.WithCancel(context.Background()) + defer stopReaper() + go sessionReaperLoop(reaperCtx, exec, log) + + server := &http.Server{ + Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port), + Handler: api.NewServer(cfg, exec, store), + } + + shutdownDone := make(chan struct{}) + go func() { + defer close(shutdownDone) + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + <-sigCh + log.Info("Shutting down...") + stopReaper() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + }() + + log.Info(fmt.Sprintf("Serving on http://%s:%d", cfg.Host, cfg.Port)) + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + <-shutdownDone + return nil +} + +func main() { + if err := run(); err != nil { + slog.Error(err.Error()) + os.Exit(1) + } +} diff --git a/code-interpreter-go/docker-bake.hcl b/code-interpreter-go/docker-bake.hcl new file mode 100644 index 0000000..eac2163 --- /dev/null +++ b/code-interpreter-go/docker-bake.hcl @@ -0,0 +1,107 @@ +# Build definitions for the code-interpreter-go images. +# +# docker buildx bake local # native-platform build of both +# # variants, loaded into `docker images` +# docker buildx bake slim-local # just the slim variant +# docker buildx bake # multi-platform build of both variants +# VERSION=1.2.3 docker buildx bake --push +# +# Multi-platform targets (`full`, `slim`) cross-build the runtime stage, which +# needs QEMU binfmt registered once per host for foreign architectures: +# docker run --privileged --rm tonistiigi/binfmt --install arm64 +# CI avoids that by building each platform on a native runner and merging the +# manifests (see .github/workflows/docker-build-push.yml). +# +# The default base images require a DHI subscription and `docker login dhi.io`. +# Without one, override the hardened base images: +# BUILD_IMAGE=golang:1.26-bookworm RUNTIME_IMAGE=debian:bookworm-slim \ +# docker buildx bake local + +variable "REGISTRY" { + default = "onyxdotapp" +} + +variable "IMAGE_NAME" { + default = "code-interpreter-go" +} + +variable "VERSION" { + default = "dev" +} + +# Base image overrides. Empty means "use the Dockerfile defaults" (the DHI +# hardened images), which stay the single source of truth. +variable "BUILD_IMAGE" { + default = "" +} + +variable "RUNTIME_IMAGE" { + default = "" +} + +# When non-empty, release targets drop their tags so CI can push per-platform +# images by digest (native runner per architecture) and merge the manifest +# lists afterwards with `docker buildx imagetools create`. +variable "PUSH_BY_DIGEST" { + default = "" +} + +group "default" { + targets = ["full", "slim"] +} + +group "local" { + targets = ["full-local", "slim-local"] +} + +target "_common" { + context = "." + dockerfile = "Dockerfile" + args = { + BUILD_IMAGE = BUILD_IMAGE != "" ? BUILD_IMAGE : null + RUNTIME_IMAGE = RUNTIME_IMAGE != "" ? RUNTIME_IMAGE : null + } +} + +# Default image: includes the Docker daemon so every deployment mode works +# out of the box, including Docker-in-Docker. +target "full" { + inherits = ["_common"] + platforms = ["linux/amd64", "linux/arm64"] + tags = PUSH_BY_DIGEST != "" ? [] : [ + "${REGISTRY}/${IMAGE_NAME}:${VERSION}", + "${REGISTRY}/${IMAGE_NAME}:latest", + ] +} + +# Slim image: no docker packages at all. Opt-in for Docker-out-of-Docker +# (mounted socket) and Kubernetes deployments. +target "slim" { + inherits = ["_common"] + platforms = ["linux/amd64", "linux/arm64"] + args = { + SKIP_NESTED_DOCKER = "1" + } + tags = PUSH_BY_DIGEST != "" ? [] : [ + "${REGISTRY}/${IMAGE_NAME}:${VERSION}-slim", + "${REGISTRY}/${IMAGE_NAME}:latest-slim", + ] +} + +# Local development builds: no `platforms` means the host's native platform, +# so no QEMU or cross toolchain is involved, and the result is loaded straight +# into the local image store. +target "full-local" { + inherits = ["_common"] + tags = ["${IMAGE_NAME}:local"] + output = ["type=docker"] +} + +target "slim-local" { + inherits = ["_common"] + args = { + SKIP_NESTED_DOCKER = "1" + } + tags = ["${IMAGE_NAME}:local-slim"] + output = ["type=docker"] +} diff --git a/code-interpreter-go/go.mod b/code-interpreter-go/go.mod new file mode 100644 index 0000000..84fbb51 --- /dev/null +++ b/code-interpreter-go/go.mod @@ -0,0 +1,79 @@ +module github.com/onyx-dot-app/python-sandbox/code-interpreter-go + +go 1.26 + +require ( + github.com/docker/docker v28.5.2+incompatible + github.com/docker/go-units v0.5.0 + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 + github.com/google/uuid v1.6.0 + github.com/opencontainers/image-spec v1.1.1 + golang.org/x/sys v0.45.0 + k8s.io/api v0.31.4 + k8s.io/apimachinery v0.31.4 + k8s.io/client-go v0.31.4 +) + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.22.4 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/gorilla/websocket v1.5.0 // indirect + github.com/imdario/mergo v0.3.6 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/spdystream v0.4.0 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/morikuni/aec v1.1.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/time v0.3.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.2 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect + k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) diff --git a/code-interpreter-go/go.sum b/code-interpreter-go/go.sum new file mode 100644 index 0000000..647850d --- /dev/null +++ b/code-interpreter-go/go.sum @@ -0,0 +1,239 @@ +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= +github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af h1:kmjWCqn2qkEml422C2Rrd27c3VGxi6a/6HNq8QmHRKM= +github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= +github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/spdystream v0.4.0 h1:Vy79D6mHeJJjiPdFEL2yku1kl0chZpJfZcPpb16BRl8= +github.com/moby/spdystream v0.4.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= +github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA= +github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To= +github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +k8s.io/api v0.31.4 h1:I2QNzitPVsPeLQvexMEsj945QumYraqv9m74isPDKhM= +k8s.io/api v0.31.4/go.mod h1:d+7vgXLvmcdT1BCo79VEgJxHHryww3V5np2OYTr6jdw= +k8s.io/apimachinery v0.31.4 h1:8xjE2C4CzhYVm9DGf60yohpNUh5AEBnPxCryPBECmlM= +k8s.io/apimachinery v0.31.4/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= +k8s.io/client-go v0.31.4 h1:t4QEXt4jgHIkKKlx06+W3+1JOwAFU/2OPiOo7H92eRQ= +k8s.io/client-go v0.31.4/go.mod h1:kvuMro4sFYIa8sulL5Gi5GFqUPvfH2O/dXuKstbaaeg= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= +k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= +k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/code-interpreter-go/internal/api/schemas.go b/code-interpreter-go/internal/api/schemas.go new file mode 100644 index 0000000..156c3db --- /dev/null +++ b/code-interpreter-go/internal/api/schemas.go @@ -0,0 +1,231 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/executor" +) + +// Session and bash defaults, mirroring app/models/schemas.py. +const ( + DefaultSessionTTLSec = 15 * 60 + MaxSessionTTLSec = 24 * 60 * 60 + DefaultBashTimeoutMs = 30_000 + defaultExecTimeoutMs = 2000 +) + +// ExecuteFile references a previously uploaded file to stage in the +// execution workspace. +type ExecuteFile struct { + Path *string `json:"path"` + FileID *string `json:"file_id"` +} + +// ExecuteRequest is the body of POST /v1/execute and /v1/execute/stream. +// Pointer fields distinguish "absent" from zero values so defaults can be +// applied after decoding. +type ExecuteRequest struct { + Code *string `json:"code"` + Stdin *string `json:"stdin"` + TimeoutMs *int `json:"timeout_ms"` + LastLineInteractive *bool `json:"last_line_interactive"` + Files []ExecuteFile `json:"files"` +} + +// validationError is mapped to HTTP 422 by the handlers, mirroring +// FastAPI/Pydantic request validation. +type validationError struct{ message string } + +func (e *validationError) Error() string { return e.message } + +// decodeJSONBody decodes a request body, translating malformed JSON and type +// mismatches into validation errors. +func decodeJSONBody(body io.Reader, dst any) error { + data, err := io.ReadAll(body) + if err != nil { + return &validationError{message: "Failed to read request body"} + } + if err := json.Unmarshal(data, dst); err != nil { + return &validationError{message: "Invalid request body: " + err.Error()} + } + return nil +} + +// validate applies defaults and enforces the same constraints as the Pydantic +// model (code required, timeout_ms >= 1). +func (r *ExecuteRequest) validate() error { + if r.Code == nil { + return &validationError{message: "Field 'code' is required."} + } + if r.TimeoutMs == nil { + v := defaultExecTimeoutMs + r.TimeoutMs = &v + } + if *r.TimeoutMs < 1 { + return &validationError{message: "timeout_ms must be greater than or equal to 1"} + } + if r.LastLineInteractive == nil { + v := true + r.LastLineInteractive = &v + } + for _, f := range r.Files { + if f.Path == nil || f.FileID == nil { + return &validationError{ + message: "Each file must include both 'path' and 'file_id'.", + } + } + } + return nil +} + +// WorkspaceFile is a saved output file in an execute response. +type WorkspaceFile struct { + Path string `json:"path"` + Kind executor.EntryKind `json:"kind"` + FileID *string `json:"file_id"` +} + +// ExecuteResponse is the body of a successful POST /v1/execute. +type ExecuteResponse struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode *int `json:"exit_code"` + TimedOut bool `json:"timed_out"` + DurationMs int64 `json:"duration_ms"` + Files []WorkspaceFile `json:"files"` +} + +// StreamOutputEvent is the payload of "output" SSE events. +type StreamOutputEvent struct { + Stream string `json:"stream"` + Data string `json:"data"` +} + +// StreamResultEvent is the payload of the final "result" SSE event. +type StreamResultEvent struct { + ExitCode *int `json:"exit_code"` + TimedOut bool `json:"timed_out"` + DurationMs int64 `json:"duration_ms"` + Files []WorkspaceFile `json:"files"` +} + +// StreamErrorEvent is the payload of "error" SSE events. +type StreamErrorEvent struct { + Message string `json:"message"` +} + +// jsonEncode writes v as JSON to w. +func jsonEncode(w io.Writer, v any) error { + return json.NewEncoder(w).Encode(v) +} + +// sseFrame formats a fully-formed SSE frame for an event payload. +func sseFrame(event string, payload any) string { + data, err := json.Marshal(payload) + if err != nil { + data = []byte("{}") + } + return fmt.Sprintf("event: %s\ndata: %s\n\n", event, data) +} + +// UploadFileResponse is the body of a successful POST /v1/files. +type UploadFileResponse struct { + FileID string `json:"file_id"` + Filename string `json:"filename"` + SizeBytes int64 `json:"size_bytes"` +} + +// FileMetadataResponse describes one stored file in a list response. +type FileMetadataResponse struct { + FileID string `json:"file_id"` + Filename string `json:"filename"` + SizeBytes int64 `json:"size_bytes"` + UploadTime float64 `json:"upload_time"` +} + +// ListFilesResponse is the body of GET /v1/files. +type ListFilesResponse struct { + Files []FileMetadataResponse `json:"files"` +} + +// HealthResponse is the body of GET /health. +type HealthResponse struct { + Status string `json:"status"` + Message *string `json:"message"` + Version string `json:"version"` +} + +// CreateSessionRequest is the body of POST /v1/sessions. +type CreateSessionRequest struct { + Files []ExecuteFile `json:"files"` + TTLSeconds *int `json:"ttl_seconds"` +} + +// validate applies defaults and enforces 1 <= ttl_seconds <= MaxSessionTTLSec. +func (r *CreateSessionRequest) validate() error { + if r.TTLSeconds == nil { + v := DefaultSessionTTLSec + r.TTLSeconds = &v + } + if *r.TTLSeconds < 1 { + return &validationError{message: "ttl_seconds must be greater than or equal to 1"} + } + if *r.TTLSeconds > MaxSessionTTLSec { + return &validationError{ + message: fmt.Sprintf( + "ttl_seconds must be less than or equal to %d", MaxSessionTTLSec, + ), + } + } + for _, f := range r.Files { + if f.Path == nil || f.FileID == nil { + return &validationError{ + message: "Each file must include both 'path' and 'file_id'.", + } + } + } + return nil +} + +// CreateSessionResponse is the body of a successful POST /v1/sessions. +type CreateSessionResponse struct { + SessionID string `json:"session_id"` + ExpiresAt float64 `json:"expires_at"` +} + +// BashExecRequest is the body of POST /v1/sessions/{session_id}/bash. +type BashExecRequest struct { + Cmd *string `json:"cmd"` + TimeoutMs *int `json:"timeout_ms"` +} + +// validate applies defaults and enforces cmd presence and timeout_ms >= 1. +func (r *BashExecRequest) validate() error { + if r.Cmd == nil { + return &validationError{message: "Field 'cmd' is required."} + } + if r.TimeoutMs == nil { + v := DefaultBashTimeoutMs + r.TimeoutMs = &v + } + if *r.TimeoutMs < 1 { + return &validationError{message: "timeout_ms must be greater than or equal to 1"} + } + return nil +} + +// BashExecResponse is the body of a successful bash execution. +type BashExecResponse struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode *int `json:"exit_code"` + TimedOut bool `json:"timed_out"` + DurationMs int64 `json:"duration_ms"` +} + +// ErrorResponse mirrors FastAPI's {"detail": ...} error body. +type ErrorResponse struct { + Detail string `json:"detail"` +} diff --git a/code-interpreter-go/internal/api/server.go b/code-interpreter-go/internal/api/server.go new file mode 100644 index 0000000..7be07ae --- /dev/null +++ b/code-interpreter-go/internal/api/server.go @@ -0,0 +1,480 @@ +// Package api implements the HTTP layer of the code-interpreter service. It +// is the Go port of app/api/routes.py and the app factory in app/main.py. +package api + +import ( + "bytes" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/config" + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/executor" + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/logging" + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/storage" +) + +// Server wires the executor backend and file storage into HTTP handlers. +type Server struct { + cfg config.Config + exec executor.Executor + storage *storage.FileStorageService + log *slog.Logger +} + +// NewServer returns the service's HTTP handler. +func NewServer( + cfg config.Config, + exec executor.Executor, + store *storage.FileStorageService, +) http.Handler { + s := &Server{ + cfg: cfg, + exec: exec, + storage: store, + log: logging.Named("api"), + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /health", s.handleHealth) + mux.HandleFunc("POST /v1/execute", s.handleExecute) + mux.HandleFunc("POST /v1/execute/stream", s.handleExecuteStream) + mux.HandleFunc("POST /v1/files", s.handleUploadFile) + mux.HandleFunc("GET /v1/files", s.handleListFiles) + mux.HandleFunc("GET /v1/files/{file_id}", s.handleDownloadFile) + mux.HandleFunc("DELETE /v1/files/{file_id}", s.handleDeleteFile) + mux.HandleFunc("POST /v1/sessions", s.handleCreateSession) + mux.HandleFunc("DELETE /v1/sessions/{session_id}", s.handleDeleteSession) + mux.HandleFunc("POST /v1/sessions/{session_id}/bash", s.handleSessionBash) + return mux +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = jsonEncode(w, payload) +} + +func writeDetail(w http.ResponseWriter, status int, detail string) { + writeJSON(w, status, ErrorResponse{Detail: detail}) +} + +// writeError maps executor/service errors onto the same HTTP statuses the +// Python service uses: validation → 422, missing session → 404, unsupported +// operation → 501, everything else → 500. +func (s *Server) writeError(w http.ResponseWriter, err error) { + var valErr *validationError + var execValErr *executor.ValidationError + var notFoundErr *executor.SessionNotFoundError + var notImplErr *executor.NotImplementedError + + switch { + case errors.As(err, &valErr): + writeDetail(w, http.StatusUnprocessableEntity, valErr.message) + case errors.As(err, &execValErr): + writeDetail(w, http.StatusUnprocessableEntity, execValErr.Message) + case errors.As(err, ¬FoundErr): + writeDetail(w, http.StatusNotFound, notFoundErr.Error()) + case errors.As(err, ¬ImplErr): + writeDetail(w, http.StatusNotImplemented, notImplErr.Message) + default: + s.log.Error("Internal server error", "error", err.Error()) + writeDetail(w, http.StatusInternalServerError, "Internal Server Error") + } +} + +func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { + result := s.exec.CheckHealth() + var message *string + if result.Message != "" { + message = &result.Message + } + writeJSON(w, http.StatusOK, HealthResponse{ + Status: result.Status, + Message: message, + Version: config.ServiceVersion, + }) +} + +// resolveUploadedFiles resolves uploaded file IDs into content for the +// executor. It returns the staged files and a path→content map used to +// filter unchanged inputs out of the workspace snapshot. +func (s *Server) resolveUploadedFiles( + files []ExecuteFile, +) ([]executor.StagedFile, map[string][]byte, error) { + staged := make([]executor.StagedFile, 0, len(files)) + inputFiles := map[string][]byte{} + for _, file := range files { + content, _, err := s.storage.GetFile(*file.FileID) + if err != nil { + if errors.Is(err, storage.ErrFileNotFound) { + return nil, nil, &httpError{ + status: http.StatusNotFound, + detail: fmt.Sprintf( + "File with ID '%s' not found for path '%s'.", *file.FileID, *file.Path, + ), + } + } + return nil, nil, err + } + staged = append(staged, executor.StagedFile{Path: *file.Path, Content: content}) + inputFiles[*file.Path] = content + } + return staged, inputFiles, nil +} + +// httpError carries an explicit status + detail through handler helpers. +type httpError struct { + status int + detail string +} + +func (e *httpError) Error() string { return e.detail } + +// saveWorkspaceFiles filters and saves new/modified workspace files to +// storage. +func (s *Server) saveWorkspaceFiles( + entries []executor.WorkspaceEntry, + inputFiles map[string][]byte, +) []WorkspaceFile { + workspaceFiles := []WorkspaceFile{} + for _, entry := range entries { + if entry.Kind != executor.EntryKindFile || entry.Content == nil { + continue + } + if original, ok := inputFiles[entry.Path]; ok && bytes.Equal(entry.Content, original) { + continue + } + fileID, err := s.storage.SaveFile(entry.Content, entry.Path) + if err != nil { + s.log.Warn("Failed to save workspace file", "path", entry.Path, "error", err) + continue + } + workspaceFiles = append(workspaceFiles, WorkspaceFile{ + Path: entry.Path, + Kind: entry.Kind, + FileID: &fileID, + }) + } + return workspaceFiles +} + +// parseExecuteRequest decodes, defaults, and validates an execute request, +// including the timeout ceiling shared by both execute routes. +func (s *Server) parseExecuteRequest(r *http.Request) (*ExecuteRequest, error) { + var req ExecuteRequest + if err := decodeJSONBody(r.Body, &req); err != nil { + return nil, err + } + if err := req.validate(); err != nil { + return nil, err + } + if *req.TimeoutMs > s.cfg.MaxExecTimeoutMs { + return nil, &validationError{ + message: fmt.Sprintf( + "timeout_ms exceeds maximum of %d ms", s.cfg.MaxExecTimeoutMs, + ), + } + } + return &req, nil +} + +func (s *Server) execOptions(req *ExecuteRequest, staged []executor.StagedFile) executor.ExecOptions { + cpu := s.cfg.CPUTimeLimitSec + mem := s.cfg.MemoryLimitMB + return executor.ExecOptions{ + Code: *req.Code, + Stdin: req.Stdin, + TimeoutMs: *req.TimeoutMs, + MaxOutputBytes: s.cfg.MaxOutputBytes, + CPUTimeLimitSec: &cpu, + MemoryLimitMB: &mem, + Files: staged, + LastLineInteractive: *req.LastLineInteractive, + } +} + +// handleExecute executes provided Python code synchronously within an +// isolated environment. +func (s *Server) handleExecute(w http.ResponseWriter, r *http.Request) { + req, err := s.parseExecuteRequest(r) + if err != nil { + s.writeRequestError(w, err) + return + } + + staged, inputFiles, err := s.resolveUploadedFiles(req.Files) + if err != nil { + s.writeRequestError(w, err) + return + } + + result, err := s.exec.ExecutePython(s.execOptions(req, staged)) + if err != nil { + s.writeError(w, err) + return + } + + writeJSON(w, http.StatusOK, ExecuteResponse{ + Stdout: result.Stdout, + Stderr: result.Stderr, + ExitCode: result.ExitCode, + TimedOut: result.TimedOut, + DurationMs: result.DurationMs, + Files: s.saveWorkspaceFiles(result.Files, inputFiles), + }) +} + +// handleExecuteStream executes Python code with streaming output via +// Server-Sent Events. +func (s *Server) handleExecuteStream(w http.ResponseWriter, r *http.Request) { + req, err := s.parseExecuteRequest(r) + if err != nil { + s.writeRequestError(w, err) + return + } + + staged, inputFiles, err := s.resolveUploadedFiles(req.Files) + if err != nil { + s.writeRequestError(w, err) + return + } + + w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + + flusher, _ := w.(http.Flusher) + emit := func(chunk executor.StreamChunk) { + _, _ = io.WriteString(w, sseFrame("output", StreamOutputEvent{ + Stream: chunk.Stream, + Data: chunk.Data, + })) + if flusher != nil { + flusher.Flush() + } + } + + result, err := s.exec.ExecutePythonStreaming(s.execOptions(req, staged), emit) + if err != nil { + _, _ = io.WriteString(w, sseFrame("error", StreamErrorEvent{Message: err.Error()})) + if flusher != nil { + flusher.Flush() + } + return + } + + _, _ = io.WriteString(w, sseFrame("result", StreamResultEvent{ + ExitCode: result.ExitCode, + TimedOut: result.TimedOut, + DurationMs: result.DurationMs, + Files: s.saveWorkspaceFiles(result.Files, inputFiles), + })) + if flusher != nil { + flusher.Flush() + } +} + +// writeRequestError handles pre-execution errors that may carry an explicit +// HTTP status (missing uploaded files) or be validation failures. +func (s *Server) writeRequestError(w http.ResponseWriter, err error) { + var httpErr *httpError + if errors.As(err, &httpErr) { + writeDetail(w, httpErr.status, httpErr.detail) + return + } + s.writeError(w, err) +} + +// handleUploadFile uploads a file for later use in code execution. +func (s *Server) handleUploadFile(w http.ResponseWriter, r *http.Request) { + file, header, err := r.FormFile("file") + if err != nil { + writeDetail(w, http.StatusUnprocessableEntity, "A 'file' form field is required.") + return + } + defer func() { _ = file.Close() }() + + maxSizeBytes := int64(s.cfg.MaxFileSizeMB) * 1024 * 1024 + content, err := io.ReadAll(io.LimitReader(file, maxSizeBytes+1)) + if err != nil { + s.writeError(w, err) + return + } + if int64(len(content)) > maxSizeBytes { + writeDetail(w, http.StatusRequestEntityTooLarge, fmt.Sprintf( + "File size exceeds maximum of %d MB", s.cfg.MaxFileSizeMB, + )) + return + } + + filename := header.Filename + if filename == "" { + filename = "unnamed" + } + fileID, err := s.storage.SaveFile(content, filename) + if err != nil { + s.writeError(w, err) + return + } + + writeJSON(w, http.StatusCreated, UploadFileResponse{ + FileID: fileID, + Filename: filename, + SizeBytes: int64(len(content)), + }) +} + +// handleDownloadFile downloads a previously uploaded file by its ID. +func (s *Server) handleDownloadFile(w http.ResponseWriter, r *http.Request) { + fileID := r.PathValue("file_id") + + content, metadata, err := s.storage.GetFile(fileID) + if err != nil { + if errors.Is(err, storage.ErrFileNotFound) { + writeDetail(w, http.StatusNotFound, fmt.Sprintf( + "File with ID '%s' not found", fileID, + )) + return + } + s.writeError(w, err) + return + } + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set( + "Content-Disposition", fmt.Sprintf("attachment; filename=%q", metadata.Filename), + ) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(content) +} + +// handleListFiles lists all uploaded files with their metadata. +func (s *Server) handleListFiles(w http.ResponseWriter, _ *http.Request) { + files, err := s.storage.ListFiles() + if err != nil { + s.writeError(w, err) + return + } + + response := ListFilesResponse{Files: []FileMetadataResponse{}} + for _, f := range files { + response.Files = append(response.Files, FileMetadataResponse{ + FileID: f.FileID, + Filename: f.Filename, + SizeBytes: f.SizeBytes, + UploadTime: f.UploadTime, + }) + } + writeJSON(w, http.StatusOK, response) +} + +// handleDeleteFile deletes a previously uploaded file by its ID. +func (s *Server) handleDeleteFile(w http.ResponseWriter, r *http.Request) { + fileID := r.PathValue("file_id") + + deleted, err := s.storage.DeleteFile(fileID) + if err != nil { + s.writeError(w, err) + return + } + if !deleted { + writeDetail(w, http.StatusNotFound, fmt.Sprintf("File with ID '%s' not found", fileID)) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// handleCreateSession creates a long-lived code-executor pod/container with +// the given TTL. The session is guaranteed to be torn down at or before the +// TTL expires, even if the API service crashes and restarts. +func (s *Server) handleCreateSession(w http.ResponseWriter, r *http.Request) { + var req CreateSessionRequest + if err := decodeJSONBody(r.Body, &req); err != nil { + s.writeRequestError(w, err) + return + } + if err := req.validate(); err != nil { + s.writeRequestError(w, err) + return + } + + staged, _, err := s.resolveUploadedFiles(req.Files) + if err != nil { + s.writeRequestError(w, err) + return + } + + cpu := s.cfg.CPUTimeLimitSec + mem := s.cfg.MemoryLimitMB + info, err := s.exec.CreateSession(*req.TTLSeconds, staged, &cpu, &mem) + if err != nil { + s.writeError(w, err) + return + } + + writeJSON(w, http.StatusCreated, CreateSessionResponse{ + SessionID: info.SessionID, + ExpiresAt: info.ExpiresAt, + }) +} + +// handleDeleteSession tears down a session pod/container by ID. +func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) { + sessionID := r.PathValue("session_id") + + deleted, err := s.exec.DeleteSession(sessionID) + if err != nil { + s.writeError(w, err) + return + } + if !deleted { + writeDetail(w, http.StatusNotFound, fmt.Sprintf("Session '%s' not found", sessionID)) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// handleSessionBash runs a bash command inside an existing session. +// +// The session pod has no network access (enforced at session creation), and +// that restriction continues to apply for every command run via this route. +func (s *Server) handleSessionBash(w http.ResponseWriter, r *http.Request) { + sessionID := r.PathValue("session_id") + + var req BashExecRequest + if err := decodeJSONBody(r.Body, &req); err != nil { + s.writeRequestError(w, err) + return + } + if err := req.validate(); err != nil { + s.writeRequestError(w, err) + return + } + if *req.TimeoutMs > s.cfg.MaxExecTimeoutMs { + writeDetail(w, http.StatusUnprocessableEntity, fmt.Sprintf( + "timeout_ms exceeds maximum of %d ms", s.cfg.MaxExecTimeoutMs, + )) + return + } + + result, err := s.exec.ExecuteBashInSession( + sessionID, *req.Cmd, *req.TimeoutMs, s.cfg.MaxOutputBytes, + ) + if err != nil { + s.writeError(w, err) + return + } + + writeJSON(w, http.StatusOK, BashExecResponse{ + Stdout: result.Stdout, + Stderr: result.Stderr, + ExitCode: result.ExitCode, + TimedOut: result.TimedOut, + DurationMs: result.DurationMs, + }) +} diff --git a/code-interpreter-go/internal/api/server_test.go b/code-interpreter-go/internal/api/server_test.go new file mode 100644 index 0000000..7305e0d --- /dev/null +++ b/code-interpreter-go/internal/api/server_test.go @@ -0,0 +1,634 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/config" + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/executor" + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/storage" +) + +// fakeExecutor is a configurable in-memory Executor for API tests, standing +// in for the dummy backends used by the Python integration tests. +type fakeExecutor struct { + executeFunc func(opts executor.ExecOptions) (executor.ExecutionResult, error) + streamFunc func(opts executor.ExecOptions, emit executor.EmitFunc) (executor.StreamResult, error) + createFunc func(ttl int) (executor.SessionInfo, error) + deleteFunc func(id string) (bool, error) + bashFunc func(id, cmd string) (executor.ExecutionResult, error) + lastExecOpts *executor.ExecOptions + lastSessionID string +} + +func intPtr(v int) *int { return &v } + +func (f *fakeExecutor) CheckHealth() executor.HealthCheck { + return executor.HealthCheck{Status: "ok"} +} + +func (f *fakeExecutor) ExecutePython(opts executor.ExecOptions) (executor.ExecutionResult, error) { + f.lastExecOpts = &opts + if f.executeFunc != nil { + return f.executeFunc(opts) + } + return executor.ExecutionResult{ + Stdout: "hello\n", Stderr: "", ExitCode: intPtr(0), DurationMs: 5, + }, nil +} + +func (f *fakeExecutor) ExecutePythonStreaming( + opts executor.ExecOptions, emit executor.EmitFunc, +) (executor.StreamResult, error) { + f.lastExecOpts = &opts + if f.streamFunc != nil { + return f.streamFunc(opts, emit) + } + emit(executor.StreamChunk{Stream: "stdout", Data: "hello\n"}) + return executor.StreamResult{ExitCode: intPtr(0), DurationMs: 5}, nil +} + +func (f *fakeExecutor) CreateSession( + ttlSeconds int, _ []executor.StagedFile, _ *int, _ *int, +) (executor.SessionInfo, error) { + if f.createFunc != nil { + return f.createFunc(ttlSeconds) + } + return executor.SessionInfo{ + SessionID: "code-session-abc123", ExpiresAt: 1700000000.5, + }, nil +} + +func (f *fakeExecutor) DeleteSession(sessionID string) (bool, error) { + f.lastSessionID = sessionID + if f.deleteFunc != nil { + return f.deleteFunc(sessionID) + } + return true, nil +} + +func (f *fakeExecutor) ReapExpiredSessions() (int, error) { return 0, nil } + +func (f *fakeExecutor) ExecuteBashInSession( + sessionID, cmd string, _ int, _ int, +) (executor.ExecutionResult, error) { + f.lastSessionID = sessionID + if f.bashFunc != nil { + return f.bashFunc(sessionID, cmd) + } + return executor.ExecutionResult{ + Stdout: "ok\n", ExitCode: intPtr(0), DurationMs: 3, + }, nil +} + +func testConfig() config.Config { + cfg := config.Load() + cfg.MaxExecTimeoutMs = 60_000 + cfg.MaxOutputBytes = 1_000_000 + cfg.MaxFileSizeMB = 1 + return cfg +} + +func newTestServer(t *testing.T, fake *fakeExecutor) (*httptest.Server, *storage.FileStorageService) { + t.Helper() + store, err := storage.NewFileStorageService(t.TempDir()) + if err != nil { + t.Fatalf("storage: %v", err) + } + srv := httptest.NewServer(NewServer(testConfig(), fake, store)) + t.Cleanup(srv.Close) + return srv, store +} + +func postJSON(t *testing.T, url string, body string) *http.Response { + t.Helper() + resp, err := http.Post(url, "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("POST %s: %v", url, err) + } + return resp +} + +func decodeBody[T any](t *testing.T, resp *http.Response) T { + t.Helper() + defer func() { _ = resp.Body.Close() }() + var v T + if err := json.NewDecoder(resp.Body).Decode(&v); err != nil { + t.Fatalf("decode response: %v", err) + } + return v +} + +// --- health -------------------------------------------------------------- + +func TestHealth(t *testing.T) { + srv, _ := newTestServer(t, &fakeExecutor{}) + resp, err := http.Get(srv.URL + "/health") + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + payload := decodeBody[map[string]any](t, resp) + if payload["status"] != "ok" { + t.Errorf("status = %v, want ok", payload["status"]) + } + if payload["version"] != config.ServiceVersion { + t.Errorf("version = %v, want %s", payload["version"], config.ServiceVersion) + } + if msg, present := payload["message"]; !present || msg != nil { + t.Errorf("message = %v, want explicit null", msg) + } +} + +// --- /v1/execute ---------------------------------------------------------- + +func TestExecuteReturnsExpectedPayload(t *testing.T) { + fake := &fakeExecutor{} + srv, _ := newTestServer(t, fake) + + resp := postJSON(t, srv.URL+"/v1/execute", + `{"code": "print('hello')", "stdin": null, "timeout_ms": 1000}`) + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + payload := decodeBody[map[string]any](t, resp) + if payload["stdout"] != "hello\n" { + t.Errorf("stdout = %v", payload["stdout"]) + } + if payload["stderr"] != "" { + t.Errorf("stderr = %v", payload["stderr"]) + } + if payload["exit_code"] != float64(0) { + t.Errorf("exit_code = %v", payload["exit_code"]) + } + if payload["timed_out"] != false { + t.Errorf("timed_out = %v", payload["timed_out"]) + } + if _, ok := payload["duration_ms"].(float64); !ok { + t.Errorf("duration_ms = %v", payload["duration_ms"]) + } + if files, ok := payload["files"].([]any); !ok || len(files) != 0 { + t.Errorf("files = %v, want []", payload["files"]) + } + + if fake.lastExecOpts.TimeoutMs != 1000 { + t.Errorf("executor received timeout %d", fake.lastExecOpts.TimeoutMs) + } + if !fake.lastExecOpts.LastLineInteractive { + t.Error("last_line_interactive should default to true") + } +} + +func TestExecuteValidation(t *testing.T) { + srv, _ := newTestServer(t, &fakeExecutor{}) + + cases := []struct { + name string + body string + want int + }{ + {"timeout above max", `{"code": "1", "timeout_ms": 60001}`, 422}, + {"timeout below one", `{"code": "1", "timeout_ms": 0}`, 422}, + {"missing code", `{"timeout_ms": 100}`, 422}, + {"code wrong type", `{"code": 42}`, 422}, + {"malformed json", `{"code": `, 422}, + {"defaults applied", `{"code": "1"}`, 200}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resp := postJSON(t, srv.URL+"/v1/execute", tc.body) + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != tc.want { + body, _ := io.ReadAll(resp.Body) + t.Errorf("status = %d, want %d (body: %s)", resp.StatusCode, tc.want, body) + } + }) + } +} + +func TestExecuteTimeoutDefaultIs2000(t *testing.T) { + fake := &fakeExecutor{} + srv, _ := newTestServer(t, fake) + resp := postJSON(t, srv.URL+"/v1/execute", `{"code": "1"}`) + _ = resp.Body.Close() + if fake.lastExecOpts.TimeoutMs != 2000 { + t.Errorf("default timeout = %d, want 2000", fake.lastExecOpts.TimeoutMs) + } +} + +func TestExecuteWithUnknownFileID(t *testing.T) { + srv, _ := newTestServer(t, &fakeExecutor{}) + resp := postJSON(t, srv.URL+"/v1/execute", + `{"code": "1", "files": [{"path": "in.txt", "file_id": "missing-id"}]}`) + if resp.StatusCode != 404 { + t.Fatalf("status = %d, want 404", resp.StatusCode) + } + payload := decodeBody[map[string]any](t, resp) + detail, _ := payload["detail"].(string) + if !strings.Contains(detail, "missing-id") || !strings.Contains(detail, "in.txt") { + t.Errorf("detail = %q", detail) + } +} + +func TestExecuteStagesUploadedFiles(t *testing.T) { + fake := &fakeExecutor{} + srv, store := newTestServer(t, fake) + + fileID, err := store.SaveFile([]byte("col1,col2\n"), "data.csv") + if err != nil { + t.Fatal(err) + } + + resp := postJSON(t, srv.URL+"/v1/execute", fmt.Sprintf( + `{"code": "1", "files": [{"path": "input/data.csv", "file_id": %q}]}`, fileID)) + _ = resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if len(fake.lastExecOpts.Files) != 1 { + t.Fatalf("staged files = %d, want 1", len(fake.lastExecOpts.Files)) + } + staged := fake.lastExecOpts.Files[0] + if staged.Path != "input/data.csv" || string(staged.Content) != "col1,col2\n" { + t.Errorf("staged = %+v", staged) + } +} + +func TestExecuteSavesNewWorkspaceFilesOnly(t *testing.T) { + fake := &fakeExecutor{} + srv, store := newTestServer(t, fake) + + inputContent := []byte("unchanged") + fileID, _ := store.SaveFile(inputContent, "in.txt") + + fake.executeFunc = func(opts executor.ExecOptions) (executor.ExecutionResult, error) { + return executor.ExecutionResult{ + Stdout: "", ExitCode: intPtr(0), DurationMs: 1, + Files: []executor.WorkspaceEntry{ + {Path: "outdir", Kind: executor.EntryKindDirectory}, + {Path: "in.txt", Kind: executor.EntryKindFile, Content: inputContent}, + {Path: "out.txt", Kind: executor.EntryKindFile, Content: []byte("fresh")}, + }, + }, nil + } + + resp := postJSON(t, srv.URL+"/v1/execute", fmt.Sprintf( + `{"code": "1", "files": [{"path": "in.txt", "file_id": %q}]}`, fileID)) + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + payload := decodeBody[ExecuteResponse](t, resp) + + if len(payload.Files) != 1 { + t.Fatalf("files = %+v, want only the new out.txt", payload.Files) + } + saved := payload.Files[0] + if saved.Path != "out.txt" || saved.Kind != executor.EntryKindFile || saved.FileID == nil { + t.Fatalf("saved = %+v", saved) + } + + content, _, err := store.GetFile(*saved.FileID) + if err != nil || string(content) != "fresh" { + t.Errorf("stored output file = %q, %v", content, err) + } +} + +// --- /v1/execute/stream ---------------------------------------------------- + +type sseEvent struct { + event string + data map[string]any +} + +func parseSSE(t *testing.T, raw string) []sseEvent { + t.Helper() + var events []sseEvent + var current string + var data string + for _, line := range strings.Split(raw, "\n") { + switch { + case strings.HasPrefix(line, "event: "): + current = line[7:] + case strings.HasPrefix(line, "data: "): + data = line[6:] + case line == "" && current != "": + var payload map[string]any + if err := json.Unmarshal([]byte(data), &payload); err != nil { + t.Fatalf("bad SSE data %q: %v", data, err) + } + events = append(events, sseEvent{event: current, data: payload}) + current, data = "", "" + } + } + return events +} + +func TestStreamingBasicOutput(t *testing.T) { + srv, _ := newTestServer(t, &fakeExecutor{}) + + resp := postJSON(t, srv.URL+"/v1/execute/stream", + `{"code": "print('hello')", "timeout_ms": 5000}`) + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/event-stream") { + t.Fatalf("content-type = %q", ct) + } + + body, _ := io.ReadAll(resp.Body) + events := parseSSE(t, string(body)) + + var outputs, results []sseEvent + for _, e := range events { + switch e.event { + case "output": + outputs = append(outputs, e) + case "result": + results = append(results, e) + } + } + if len(results) != 1 { + t.Fatalf("result events = %d, want 1", len(results)) + } + result := results[0].data + if result["exit_code"] != float64(0) || result["timed_out"] != false { + t.Errorf("result = %v", result) + } + + var stdout string + for _, e := range outputs { + if e.data["stream"] == "stdout" { + stdout += e.data["data"].(string) + } + } + if stdout != "hello\n" { + t.Errorf("stdout = %q", stdout) + } +} + +func TestStreamingEmitsErrorEvent(t *testing.T) { + fake := &fakeExecutor{ + streamFunc: func(_ executor.ExecOptions, emit executor.EmitFunc) (executor.StreamResult, error) { + emit(executor.StreamChunk{Stream: "stdout", Data: "partial"}) + return executor.StreamResult{}, fmt.Errorf("backend exploded") + }, + } + srv, _ := newTestServer(t, fake) + + resp := postJSON(t, srv.URL+"/v1/execute/stream", `{"code": "1"}`) + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(resp.Body) + events := parseSSE(t, string(body)) + + var sawError bool + for _, e := range events { + if e.event == "error" { + sawError = true + if !strings.Contains(e.data["message"].(string), "backend exploded") { + t.Errorf("error message = %v", e.data["message"]) + } + } + if e.event == "result" { + t.Error("result event must not follow an error") + } + } + if !sawError { + t.Error("no error event emitted") + } +} + +func TestStreamingValidationRejectedBeforeStream(t *testing.T) { + srv, _ := newTestServer(t, &fakeExecutor{}) + resp := postJSON(t, srv.URL+"/v1/execute/stream", `{"code": "1", "timeout_ms": 999999}`) + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != 422 { + t.Errorf("status = %d, want 422", resp.StatusCode) + } +} + +// --- /v1/files --------------------------------------------------------------- + +func uploadFile(t *testing.T, url, filename string, content []byte) *http.Response { + t.Helper() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, err := mw.CreateFormFile("file", filename) + if err != nil { + t.Fatal(err) + } + if _, err := fw.Write(content); err != nil { + t.Fatal(err) + } + _ = mw.Close() + + resp, err := http.Post(url+"/v1/files", mw.FormDataContentType(), &buf) + if err != nil { + t.Fatal(err) + } + return resp +} + +func TestFileUploadDownloadRoundTrip(t *testing.T) { + srv, _ := newTestServer(t, &fakeExecutor{}) + content := []byte{0x00, 0x01, 0xFF, 0xFE, 'a', 'b'} + + resp := uploadFile(t, srv.URL, "blob.bin", content) + if resp.StatusCode != 201 { + t.Fatalf("upload status = %d, want 201", resp.StatusCode) + } + uploaded := decodeBody[UploadFileResponse](t, resp) + if uploaded.Filename != "blob.bin" || uploaded.SizeBytes != int64(len(content)) { + t.Errorf("upload payload = %+v", uploaded) + } + + dl, err := http.Get(srv.URL + "/v1/files/" + uploaded.FileID) + if err != nil { + t.Fatal(err) + } + defer func() { _ = dl.Body.Close() }() + if dl.StatusCode != 200 { + t.Fatalf("download status = %d", dl.StatusCode) + } + if cd := dl.Header.Get("Content-Disposition"); !strings.Contains(cd, "blob.bin") { + t.Errorf("content-disposition = %q", cd) + } + got, _ := io.ReadAll(dl.Body) + if !bytes.Equal(got, content) { + t.Error("binary content corrupted through upload/download") + } +} + +func TestFileUploadTooLarge(t *testing.T) { + srv, _ := newTestServer(t, &fakeExecutor{}) // MaxFileSizeMB = 1 + big := bytes.Repeat([]byte("x"), 1024*1024+1) + resp := uploadFile(t, srv.URL, "big.bin", big) + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != 413 { + t.Errorf("status = %d, want 413", resp.StatusCode) + } +} + +func TestFileListAndDelete(t *testing.T) { + srv, _ := newTestServer(t, &fakeExecutor{}) + + resp := uploadFile(t, srv.URL, "a.txt", []byte("a")) + uploaded := decodeBody[UploadFileResponse](t, resp) + + listResp, err := http.Get(srv.URL + "/v1/files") + if err != nil { + t.Fatal(err) + } + listed := decodeBody[ListFilesResponse](t, listResp) + if len(listed.Files) != 1 || listed.Files[0].FileID != uploaded.FileID { + t.Errorf("list = %+v", listed) + } + + req, _ := http.NewRequest(http.MethodDelete, srv.URL+"/v1/files/"+uploaded.FileID, nil) + delResp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + _ = delResp.Body.Close() + if delResp.StatusCode != 204 { + t.Errorf("delete status = %d, want 204", delResp.StatusCode) + } + + req, _ = http.NewRequest(http.MethodDelete, srv.URL+"/v1/files/"+uploaded.FileID, nil) + delResp, _ = http.DefaultClient.Do(req) + _ = delResp.Body.Close() + if delResp.StatusCode != 404 { + t.Errorf("second delete status = %d, want 404", delResp.StatusCode) + } + + dl, _ := http.Get(srv.URL + "/v1/files/" + uploaded.FileID) + _ = dl.Body.Close() + if dl.StatusCode != 404 { + t.Errorf("download after delete = %d, want 404", dl.StatusCode) + } +} + +// --- /v1/sessions ------------------------------------------------------------- + +func TestCreateSession(t *testing.T) { + srv, _ := newTestServer(t, &fakeExecutor{}) + + resp := postJSON(t, srv.URL+"/v1/sessions", `{"ttl_seconds": 300}`) + if resp.StatusCode != 201 { + t.Fatalf("status = %d, want 201", resp.StatusCode) + } + payload := decodeBody[CreateSessionResponse](t, resp) + if payload.SessionID != "code-session-abc123" || payload.ExpiresAt != 1700000000.5 { + t.Errorf("payload = %+v", payload) + } +} + +func TestCreateSessionTTLValidation(t *testing.T) { + srv, _ := newTestServer(t, &fakeExecutor{}) + for _, body := range []string{ + `{"ttl_seconds": 0}`, + `{"ttl_seconds": 86401}`, + } { + resp := postJSON(t, srv.URL+"/v1/sessions", body) + _ = resp.Body.Close() + if resp.StatusCode != 422 { + t.Errorf("body %s: status = %d, want 422", body, resp.StatusCode) + } + } +} + +func TestCreateSessionNotImplemented(t *testing.T) { + fake := &fakeExecutor{ + createFunc: func(int) (executor.SessionInfo, error) { + return executor.SessionInfo{}, &executor.NotImplementedError{ + Message: "fakeExecutor does not support sessions", + } + }, + } + srv, _ := newTestServer(t, fake) + resp := postJSON(t, srv.URL+"/v1/sessions", `{}`) + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != 501 { + t.Errorf("status = %d, want 501", resp.StatusCode) + } +} + +func TestDeleteSession(t *testing.T) { + fake := &fakeExecutor{} + srv, _ := newTestServer(t, fake) + + req, _ := http.NewRequest(http.MethodDelete, srv.URL+"/v1/sessions/code-session-abc123", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if resp.StatusCode != 204 { + t.Errorf("status = %d, want 204", resp.StatusCode) + } + if fake.lastSessionID != "code-session-abc123" { + t.Errorf("executor got session %q", fake.lastSessionID) + } + + fake.deleteFunc = func(string) (bool, error) { return false, nil } + req, _ = http.NewRequest(http.MethodDelete, srv.URL+"/v1/sessions/code-session-missing", nil) + resp, _ = http.DefaultClient.Do(req) + _ = resp.Body.Close() + if resp.StatusCode != 404 { + t.Errorf("missing session status = %d, want 404", resp.StatusCode) + } +} + +// --- /v1/sessions/{id}/bash ----------------------------------------------------- + +func TestSessionBash(t *testing.T) { + srv, _ := newTestServer(t, &fakeExecutor{}) + + resp := postJSON(t, srv.URL+"/v1/sessions/code-session-abc123/bash", `{"cmd": "echo ok"}`) + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + payload := decodeBody[BashExecResponse](t, resp) + if payload.Stdout != "ok\n" || payload.ExitCode == nil || *payload.ExitCode != 0 { + t.Errorf("payload = %+v", payload) + } +} + +func TestSessionBashValidationAndErrors(t *testing.T) { + fake := &fakeExecutor{} + srv, _ := newTestServer(t, fake) + url := srv.URL + "/v1/sessions/code-session-abc123/bash" + + resp := postJSON(t, url, `{"cmd": "echo", "timeout_ms": 60001}`) + _ = resp.Body.Close() + if resp.StatusCode != 422 { + t.Errorf("timeout above max: status = %d, want 422", resp.StatusCode) + } + + resp = postJSON(t, url, `{"timeout_ms": 100}`) + _ = resp.Body.Close() + if resp.StatusCode != 422 { + t.Errorf("missing cmd: status = %d, want 422", resp.StatusCode) + } + + fake.bashFunc = func(id, _ string) (executor.ExecutionResult, error) { + return executor.ExecutionResult{}, &executor.SessionNotFoundError{SessionID: id} + } + resp = postJSON(t, url, `{"cmd": "echo"}`) + if resp.StatusCode != 404 { + t.Fatalf("missing session: status = %d, want 404", resp.StatusCode) + } + payload := decodeBody[map[string]any](t, resp) + if detail, _ := payload["detail"].(string); !strings.Contains(detail, "not found") { + t.Errorf("detail = %v", payload["detail"]) + } +} diff --git a/code-interpreter-go/internal/bootstrap/docker.go b/code-interpreter-go/internal/bootstrap/docker.go new file mode 100644 index 0000000..e438f9e --- /dev/null +++ b/code-interpreter-go/internal/bootstrap/docker.go @@ -0,0 +1,249 @@ +// Package bootstrap prepares the Docker environment before the service +// starts. It is the Go port of entrypoint.sh: it decides between +// Docker-out-of-Docker (a mounted socket), Docker-in-Docker (starting a +// nested dockerd, which requires --privileged), and no Docker at all, so the +// image needs no shell entrypoint. +package bootstrap + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/docker/docker/client" + "golang.org/x/sys/unix" + + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/config" + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/logging" +) + +const ( + dockerSocketPath = "/var/run/docker.sock" + dockerdLogPath = "/var/log/dockerd.log" + cgroupRoot = "/sys/fs/cgroup" + daemonReadyRetries = 30 + daemonReadyDelay = 1 * time.Second +) + +// Mode is the Docker setup decision for this process. +type Mode int + +const ( + // ModeSkipKubernetes: the Kubernetes backend is configured; Docker is + // not used at all. + ModeSkipKubernetes Mode = iota + // ModeExternalDaemon: DOCKER_HOST points at a daemon elsewhere; nothing + // to set up locally. + ModeExternalDaemon + // ModeSocketFound: the host's socket is mounted (Docker-out-of-Docker). + ModeSocketFound + // ModeStartDaemon: no socket but we have privileges — start a nested + // dockerd (Docker-in-Docker). + ModeStartDaemon + // ModeNoDocker: no socket and no privileges; warn and continue so + // health checks surface the misconfiguration. + ModeNoDocker +) + +// DecideMode picks the Docker setup mode from the environment, mirroring the +// branch structure of entrypoint.sh (plus an explicit DOCKER_HOST short +// circuit the script lacked). +func DecideMode(executorBackend string, dockerHostSet, socketExists, varRunWritable bool) Mode { + if strings.ToLower(executorBackend) == "kubernetes" { + return ModeSkipKubernetes + } + if dockerHostSet { + return ModeExternalDaemon + } + if socketExists { + return ModeSocketFound + } + if varRunWritable { + return ModeStartDaemon + } + return ModeNoDocker +} + +// socketExists reports whether path exists and is a unix socket (the -S test +// in entrypoint.sh). +func socketExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode()&os.ModeSocket != 0 +} + +// BootstrapDockerEnvironment applies the decided mode. When it starts a +// nested dockerd it returns a stop function that signals the daemon; +// otherwise the returned function is a no-op. The only error case is a +// Docker-in-Docker daemon that fails to start or become ready. +func BootstrapDockerEnvironment(cfg config.Config) (func(), error) { + log := logging.Named("bootstrap") + noop := func() {} + + mode := DecideMode( + cfg.ExecutorBackend, + os.Getenv("DOCKER_HOST") != "", + socketExists(dockerSocketPath), + unix.Access("/var/run", unix.W_OK) == nil, + ) + + switch mode { + case ModeSkipKubernetes: + log.Info("Using Kubernetes executor backend - skipping Docker setup") + return noop, nil + case ModeExternalDaemon: + log.Info("DOCKER_HOST is set - using the configured external Docker daemon") + return noop, nil + case ModeSocketFound: + log.Info("Docker socket found at " + dockerSocketPath + + " - using Docker-out-of-Docker mode") + return noop, nil + case ModeNoDocker: + log.Warn("No Docker socket found and insufficient privileges for Docker-in-Docker") + log.Warn("This container needs either:") + log.Warn(" 1. Docker-out-of-Docker: -v /var/run/docker.sock:/var/run/docker.sock --user root") + log.Warn(" 2. Docker-in-Docker: --privileged") + return noop, nil + } + + log.Info("No Docker socket found but running with privileges - enabling Docker-in-Docker mode") + return startDockerd() +} + +// startDockerd launches a nested Docker daemon and waits for it to become +// ready, mirroring entrypoint.sh's start_dockerd. +func startDockerd() (func(), error) { + log := logging.Named("bootstrap") + log.Info("Starting Docker daemon for Docker-in-Docker mode...") + + // Enable cgroup v2 nesting before dockerd starts. + if err := enableCgroupNesting(); err != nil { + log.Warn("Failed to enable cgroup v2 nesting", "error", err.Error()) + } + + logFile, err := os.OpenFile(dockerdLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return nil, fmt.Errorf("failed to open dockerd log file: %w", err) + } + + cmd := exec.Command( + "dockerd", + "--host=unix://"+dockerSocketPath, + "--storage-driver=vfs", + ) + cmd.Stdout = logFile + cmd.Stderr = logFile + if err := cmd.Start(); err != nil { + _ = logFile.Close() + return nil, fmt.Errorf("failed to start dockerd: %w", err) + } + _ = logFile.Close() + + // Reap dockerd if it exits so it cannot linger as a zombie under PID 1. + daemonExited := make(chan error, 1) + go func() { daemonExited <- cmd.Wait() }() + + stop := func() { _ = cmd.Process.Signal(syscall.SIGTERM) } + + log.Info("Waiting for Docker daemon to be ready...") + if err := waitForDaemonReady(daemonExited); err != nil { + stop() + return nil, err + } + + log.Info("Docker daemon is ready") + return stop, nil +} + +// waitForDaemonReady pings the daemon socket until it responds, the retry +// budget is exhausted, or the daemon process exits. +func waitForDaemonReady(daemonExited <-chan error) error { + cli, err := client.NewClientWithOpts( + client.WithHost("unix://"+dockerSocketPath), + client.WithAPIVersionNegotiation(), + ) + if err != nil { + return fmt.Errorf("failed to create Docker client: %w", err) + } + defer func() { _ = cli.Close() }() + + for attempt := 0; attempt < daemonReadyRetries; attempt++ { + select { + case waitErr := <-daemonExited: + return fmt.Errorf( + "dockerd exited before becoming ready: %v\n%s", waitErr, dockerdLogTail(), + ) + case <-time.After(daemonReadyDelay): + } + + ctx, cancel := context.WithTimeout(context.Background(), daemonReadyDelay) + _, pingErr := cli.Ping(ctx) + cancel() + if pingErr == nil { + return nil + } + } + + return errors.New( + "ERROR: Docker daemon failed to start within timeout\n" + dockerdLogTail(), + ) +} + +// dockerdLogTail returns the last part of the dockerd log for error +// messages, standing in for entrypoint.sh's `cat /var/log/dockerd.log`. +func dockerdLogTail() string { + data, err := os.ReadFile(dockerdLogPath) + if err != nil { + return "(dockerd log unavailable: " + err.Error() + ")" + } + const tailBytes = 4096 + if len(data) > tailBytes { + data = data[len(data)-tailBytes:] + } + return string(data) +} + +// enableCgroupNesting moves all processes out of the root cgroup and turns +// on all controllers for children, the cgroup v2 dance from entrypoint.sh +// required before running a nested dockerd. +func enableCgroupNesting() error { + controllersPath := filepath.Join(cgroupRoot, "cgroup.controllers") + controllersRaw, err := os.ReadFile(controllersPath) + if err != nil { + // Not cgroup v2; nothing to do (the script's `if [ -f ... ]` guard). + return nil + } + + initDir := filepath.Join(cgroupRoot, "init") + if err := os.MkdirAll(initDir, 0o755); err != nil { + return err + } + + // Move all current processes out of the root cgroup. Individual writes + // may fail for kernel threads or already-exited pids; ignore them like + // the script's `|| true`. + procsRaw, err := os.ReadFile(filepath.Join(cgroupRoot, "cgroup.procs")) + if err != nil { + return err + } + initProcs := filepath.Join(initDir, "cgroup.procs") + for _, pid := range strings.Fields(string(procsRaw)) { + _ = os.WriteFile(initProcs, []byte(pid), 0o644) + } + + // Turn on all available controllers for children. + controllers := strings.Fields(string(controllersRaw)) + for i, c := range controllers { + controllers[i] = "+" + c + } + return os.WriteFile( + filepath.Join(cgroupRoot, "cgroup.subtree_control"), + []byte(strings.Join(controllers, " ")), + 0o644, + ) +} diff --git a/code-interpreter-go/internal/bootstrap/docker_test.go b/code-interpreter-go/internal/bootstrap/docker_test.go new file mode 100644 index 0000000..3bacc6b --- /dev/null +++ b/code-interpreter-go/internal/bootstrap/docker_test.go @@ -0,0 +1,48 @@ +package bootstrap + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDecideMode(t *testing.T) { + cases := []struct { + name string + backend string + dockerHostSet bool + socketExists bool + varRunWritable bool + want Mode + }{ + {"kubernetes backend skips docker", "kubernetes", false, false, false, ModeSkipKubernetes}, + {"kubernetes wins over socket", "kubernetes", true, true, true, ModeSkipKubernetes}, + {"docker host set uses external daemon", "docker", true, false, true, ModeExternalDaemon}, + {"mounted socket is DooD", "docker", false, true, true, ModeSocketFound}, + {"privileged without socket is DinD", "docker", false, false, true, ModeStartDaemon}, + {"no socket no privileges warns", "docker", false, false, false, ModeNoDocker}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := DecideMode(tc.backend, tc.dockerHostSet, tc.socketExists, tc.varRunWritable) + if got != tc.want { + t.Errorf("DecideMode = %v, want %v", got, tc.want) + } + }) + } +} + +func TestSocketExists(t *testing.T) { + if socketExists(filepath.Join(t.TempDir(), "nope.sock")) { + t.Error("nonexistent path reported as socket") + } + + // A regular file is not a socket. + regular := filepath.Join(t.TempDir(), "regular") + if err := os.WriteFile(regular, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if socketExists(regular) { + t.Error("regular file reported as socket") + } +} diff --git a/code-interpreter-go/internal/config/config.go b/code-interpreter-go/internal/config/config.go new file mode 100644 index 0000000..f76de58 --- /dev/null +++ b/code-interpreter-go/internal/config/config.go @@ -0,0 +1,129 @@ +// Package config holds environment-based settings for the code-interpreter +// service. It is the Go port of app/app_configs.py. +package config + +import ( + "os" + "strconv" + "strings" +) + +// ServiceVersion is the semver of the running service. Clients can compare +// against a required minimum to detect whether new functionality is available. +const ServiceVersion = "0.4.4" + +// Config carries every runtime setting the service reads from the environment. +type Config struct { + // Executor backend selection ("docker" or "kubernetes"). + ExecutorBackend string + + // Docker executor configuration. The daemon connection comes from the + // standard DOCKER_HOST / DOCKER_API_VERSION / DOCKER_CERT_PATH environment + // variables, defaulting to the local socket. + DockerImage string + DockerRunArgs string + // Docker network for spawned executor containers. Defaults to "none" (no + // network access) for maximum isolation. Set to a Docker network name + // (e.g. "onyx_default", "traefik") to allow executor containers to reach + // services on that network. + DockerNetwork string + + // Kubernetes executor configuration. + KubernetesNamespace string + KubernetesImage string + KubernetesServiceAccount string + // When true, executor pods run a privileged (NET_ADMIN) init container + // that uses iptables to drop all outbound traffic before the executor + // container starts. This avoids the race where a pod can reach the + // network before the CNI enforces a NetworkPolicy. Environments whose CNI + // applies NetworkPolicies without that race (or that disallow NET_ADMIN) + // can set this to false and rely on a NetworkPolicy. + KubernetesNetAdminLockdown bool + + // Execution limits. + MaxExecTimeoutMs int + MaxOutputBytes int + CPUTimeLimitSec int + MemoryLimitMB int + + // API server configuration. + Host string + Port int + + // Logging configuration. LogLevel controls verbosity (e.g. DEBUG, INFO, + // WARNING). LogFormat selects the output style: "plain" (default + // human-readable text) or "json" (structured single-line JSON suitable + // for container log aggregators). + LogLevel string + LogFormat string + + // File storage configuration. + FileStorageDir string + MaxFileSizeMB int + FileTTLSec int +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func envIntOr(key string, fallback int) int { + v := os.Getenv(key) + if v == "" { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil { + return fallback + } + return n +} + +func envBoolDefaultTrue(key string) bool { + v := strings.ToLower(envOr(key, "true")) + switch v { + case "false", "0", "no": + return false + } + return true +} + +// Load reads every setting from the environment, applying the same defaults +// as the Python service. +func Load() Config { + return Config{ + ExecutorBackend: envOr("EXECUTOR_BACKEND", "docker"), + + DockerImage: envOr("PYTHON_EXECUTOR_DOCKER_IMAGE", "onyxdotapp/python-executor-sci"), + DockerRunArgs: envOr("PYTHON_EXECUTOR_DOCKER_RUN_ARGS", ""), + DockerNetwork: envOr("PYTHON_EXECUTOR_DOCKER_NETWORK", "none"), + + KubernetesNamespace: envOr("KUBERNETES_EXECUTOR_NAMESPACE", "default"), + KubernetesImage: envOr("KUBERNETES_EXECUTOR_IMAGE", "onyxdotapp/python-executor-sci"), + KubernetesServiceAccount: envOr("KUBERNETES_EXECUTOR_SERVICE_ACCOUNT", ""), + KubernetesNetAdminLockdown: envBoolDefaultTrue("KUBERNETES_EXECUTOR_NET_ADMIN_LOCKDOWN"), + + MaxExecTimeoutMs: envIntOr("MAX_EXEC_TIMEOUT_MS", 60_000), + MaxOutputBytes: envIntOr("MAX_OUTPUT_BYTES", 1_000_000), + CPUTimeLimitSec: envIntOr("CPU_TIME_LIMIT_SEC", 5), + MemoryLimitMB: envIntOr("MEMORY_LIMIT_MB", 256), + + Host: envOr("HOST", "0.0.0.0"), + Port: envIntOr("PORT", 8000), + + LogLevel: strings.ToUpper(envOr("LOG_LEVEL", "INFO")), + LogFormat: strings.ToLower(envOr("LOG_FORMAT", "plain")), + + FileStorageDir: envOr("FILE_STORAGE_DIR", "/tmp/code-interpreter-files"), + MaxFileSizeMB: envIntOr("MAX_FILE_SIZE_MB", 100), + FileTTLSec: envIntOr("FILE_TTL_SEC", 3600), + } +} + +// JSONLogging reports whether structured JSON logging is enabled. +func (c Config) JSONLogging() bool { + return c.LogFormat == "json" +} diff --git a/code-interpreter-go/internal/executor/decode.go b/code-interpreter-go/internal/executor/decode.go new file mode 100644 index 0000000..a18a7a6 --- /dev/null +++ b/code-interpreter-go/internal/executor/decode.go @@ -0,0 +1,109 @@ +package executor + +import ( + "strings" + "sync" + "unicode/utf8" +) + +// incrementalUTF8Decoder decodes a byte stream into valid UTF-8 text across +// arbitrary chunk boundaries. Bytes that could be the start of a rune split +// across chunks are held back until the rune completes; invalid sequences are +// replaced with U+FFFD. It is the analogue of Python's incremental UTF-8 +// decoder with errors="replace". +type incrementalUTF8Decoder struct { + pending []byte +} + +// Decode appends p to any held-back bytes and returns the longest decodable +// prefix as valid UTF-8 text. When final is true, everything (including an +// incomplete trailing rune) is flushed with replacement characters. +func (d *incrementalUTF8Decoder) Decode(p []byte, final bool) string { + d.pending = append(d.pending, p...) + + keep := 0 + if !final { + // Scan at most the last UTFMax-1 bytes for an incomplete rune start. + limit := len(d.pending) - (utf8.UTFMax - 1) + for i := len(d.pending) - 1; i >= 0 && i >= limit; i-- { + b := d.pending[i] + if b < 0x80 { + break // ASCII byte: everything up to the end is complete. + } + if b >= 0xC0 { + // Start byte of a multibyte rune: hold it back only if the + // rune is still incomplete. + if !utf8.FullRune(d.pending[i:]) { + keep = len(d.pending) - i + } + break + } + // Continuation byte: keep scanning backwards. + } + } + + emit := d.pending[:len(d.pending)-keep] + rest := append([]byte(nil), d.pending[len(d.pending)-keep:]...) + d.pending = rest + + if len(emit) == 0 { + return "" + } + return strings.ToValidUTF8(string(emit), "�") +} + +// streamTracker enforces a per-stream output cap while incrementally decoding +// chunks, mirroring Python's _StreamTracker. +type streamTracker struct { + stream string + decoder incrementalUTF8Decoder + bytesSent int + maxBytes int +} + +func newStreamTracker(stream string, maxBytes int) *streamTracker { + return &streamTracker{stream: stream, maxBytes: maxBytes} +} + +// decodeChunk decodes a raw chunk and returns a StreamChunk if within limits. +// Bytes beyond the cap are counted but never decoded or emitted. +func (t *streamTracker) decodeChunk(data []byte) *StreamChunk { + var chunk *StreamChunk + if t.bytesSent < t.maxBytes { + allowed := t.maxBytes - t.bytesSent + if allowed > len(data) { + allowed = len(data) + } + if text := t.decoder.Decode(data[:allowed], false); text != "" { + chunk = &StreamChunk{Stream: t.stream, Data: text} + } + } + t.bytesSent += len(data) + return chunk +} + +// flush drains the decoder and returns a final chunk if any bytes remain. +func (t *streamTracker) flush() *StreamChunk { + if text := t.decoder.Decode(nil, true); text != "" { + return &StreamChunk{Stream: t.stream, Data: text} + } + return nil +} + +// trackerWriter adapts a streamTracker + emit callback into an io.Writer for +// backend stream-copy goroutines. mu serializes emits across the stdout and +// stderr writers. +type trackerWriter struct { + tracker *streamTracker + emit EmitFunc + mu *sync.Mutex +} + +func (w *trackerWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + if chunk := w.tracker.decodeChunk(p); chunk != nil { + w.emit(*chunk) + } + return len(p), nil +} diff --git a/code-interpreter-go/internal/executor/decode_test.go b/code-interpreter-go/internal/executor/decode_test.go new file mode 100644 index 0000000..15d894b --- /dev/null +++ b/code-interpreter-go/internal/executor/decode_test.go @@ -0,0 +1,89 @@ +package executor + +import ( + "strings" + "testing" +) + +func TestIncrementalUTF8DecoderSplitRune(t *testing.T) { + // "héllo" with the é (0xC3 0xA9) split across two chunks. + var d incrementalUTF8Decoder + part1 := d.Decode([]byte{'h', 0xC3}, false) + if part1 != "h" { + t.Errorf("first chunk = %q, want %q (0xC3 must be held back)", part1, "h") + } + part2 := d.Decode([]byte{0xA9, 'l', 'l', 'o'}, false) + if part2 != "éllo" { + t.Errorf("second chunk = %q, want %q", part2, "éllo") + } + if tail := d.Decode(nil, true); tail != "" { + t.Errorf("flush = %q, want empty", tail) + } +} + +func TestIncrementalUTF8DecoderInvalidBytes(t *testing.T) { + var d incrementalUTF8Decoder + out := d.Decode([]byte{0xFF, 'a'}, false) + if !strings.Contains(out, "�") || !strings.Contains(out, "a") { + t.Errorf("invalid byte not replaced: %q", out) + } +} + +func TestIncrementalUTF8DecoderFlushIncomplete(t *testing.T) { + var d incrementalUTF8Decoder + if out := d.Decode([]byte{0xE2, 0x82}, false); out != "" { + t.Errorf("incomplete rune emitted early: %q", out) + } + // Final flush must emit the truncated rune as replacement char(s). + if out := d.Decode(nil, true); !strings.Contains(out, "�") { + t.Errorf("flush of incomplete rune = %q, want replacement char", out) + } +} + +func TestStreamTrackerCapsOutput(t *testing.T) { + tr := newStreamTracker("stdout", 5) + + chunk := tr.decodeChunk([]byte("abc")) + if chunk == nil || chunk.Data != "abc" { + t.Fatalf("first chunk = %+v, want abc", chunk) + } + + // Only 2 more bytes are allowed; the rest must be counted but dropped. + chunk = tr.decodeChunk([]byte("defgh")) + if chunk == nil || chunk.Data != "de" { + t.Fatalf("second chunk = %+v, want de", chunk) + } + + if chunk = tr.decodeChunk([]byte("ijk")); chunk != nil { + t.Fatalf("after cap, chunk = %+v, want nil", chunk) + } + if tr.bytesSent != 11 { + t.Errorf("bytesSent = %d, want 11 (dropped bytes still counted)", tr.bytesSent) + } +} + +func TestTruncateOutput(t *testing.T) { + if got := TruncateOutput([]byte("hello"), 100); got != "hello" { + t.Errorf("under limit = %q", got) + } + + long := strings.Repeat("x", 200) + got := TruncateOutput([]byte(long), 100) + if !strings.HasSuffix(got, "\n...[truncated]") { + t.Errorf("truncated output missing marker: %q", got) + } + // head is max(0, 100-32) = 68 bytes, plus the 15-byte marker. + if len(got) != 68+len("\n...[truncated]") { + t.Errorf("truncated length = %d, want %d", len(got), 68+len("\n...[truncated]")) + } + + // Tiny limit: head is empty, only the marker remains. + if got := TruncateOutput([]byte(long), 10); got != "\n...[truncated]" { + t.Errorf("tiny limit = %q", got) + } + + // Invalid UTF-8 replaced. + if got := TruncateOutput([]byte{0xFF, 'a'}, 100); !strings.Contains(got, "�") { + t.Errorf("invalid UTF-8 not replaced: %q", got) + } +} diff --git a/code-interpreter-go/internal/executor/docker.go b/code-interpreter-go/internal/executor/docker.go new file mode 100644 index 0000000..1f31321 --- /dev/null +++ b/code-interpreter-go/internal/executor/docker.go @@ -0,0 +1,833 @@ +package executor + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "strconv" + "strings" + "sync" + "time" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/client" + "github.com/docker/docker/errdefs" + "github.com/docker/docker/pkg/stdcopy" + units "github.com/docker/go-units" + "github.com/google/shlex" + "github.com/google/uuid" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/config" + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/imageref" + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/logging" +) + +const executorUser = "65532:65532" + +// runArgsOptions is the parsed, supported subset of +// PYTHON_EXECUTOR_DOCKER_RUN_ARGS. The Python service passed the raw string +// through to the `docker run` CLI; the Engine API takes structured fields, so +// only these documented flags are accepted. +type runArgsOptions struct { + labels map[string]string + env []string + extraHosts []string + dns []string + ulimits []*units.Ulimit +} + +// parseDockerRunArgs parses PYTHON_EXECUTOR_DOCKER_RUN_ARGS. Supported flags: +// --label, --env/-e, --add-host, --dns, --ulimit (each as "--flag value" or +// "--flag=value"). Any other flag is rejected at startup so a silently +// dropped hardening or connectivity flag cannot go unnoticed. +func parseDockerRunArgs(raw string) (runArgsOptions, error) { + opts := runArgsOptions{labels: map[string]string{}} + if raw == "" { + return opts, nil + } + + tokens, err := shlex.Split(raw) + if err != nil { + return opts, fmt.Errorf("invalid PYTHON_EXECUTOR_DOCKER_RUN_ARGS: %w", err) + } + + for i := 0; i < len(tokens); i++ { + flag := tokens[i] + var value string + if name, inline, ok := strings.Cut(flag, "="); ok { + flag = name + value = inline + } else { + i++ + if i >= len(tokens) { + return opts, fmt.Errorf( + "invalid PYTHON_EXECUTOR_DOCKER_RUN_ARGS: flag %s is missing a value", flag, + ) + } + value = tokens[i] + } + + switch flag { + case "--label", "-l": + key, labelValue, _ := strings.Cut(value, "=") + opts.labels[key] = labelValue + case "--env", "-e": + opts.env = append(opts.env, value) + case "--add-host": + opts.extraHosts = append(opts.extraHosts, value) + case "--dns": + opts.dns = append(opts.dns, value) + case "--ulimit": + ulimit, err := units.ParseUlimit(value) + if err != nil { + return opts, fmt.Errorf( + "invalid PYTHON_EXECUTOR_DOCKER_RUN_ARGS ulimit %q: %w", value, err, + ) + } + opts.ulimits = append(opts.ulimits, ulimit) + default: + return opts, fmt.Errorf( + "unsupported flag %q in PYTHON_EXECUTOR_DOCKER_RUN_ARGS: supported flags are "+ + "--label, --env, --add-host, --dns, --ulimit", flag, + ) + } + } + return opts, nil +} + +// dockerAPI is the subset of the Docker Engine client used by the executor, +// extracted for testability. +type dockerAPI interface { + Ping(ctx context.Context) (types.Ping, error) + ImageInspect( + ctx context.Context, imageID string, opts ...client.ImageInspectOption, + ) (image.InspectResponse, error) + ImagePull( + ctx context.Context, refStr string, options image.PullOptions, + ) (io.ReadCloser, error) + ContainerCreate( + ctx context.Context, + config *container.Config, + hostConfig *container.HostConfig, + networkingConfig *network.NetworkingConfig, + platform *ocispec.Platform, + containerName string, + ) (container.CreateResponse, error) + ContainerStart(ctx context.Context, containerID string, options container.StartOptions) error + ContainerKill(ctx context.Context, containerID, signal string) error + ContainerRemove(ctx context.Context, containerID string, options container.RemoveOptions) error + ContainerList( + ctx context.Context, options container.ListOptions, + ) ([]container.Summary, error) + ContainerExecCreate( + ctx context.Context, containerID string, options container.ExecOptions, + ) (container.ExecCreateResponse, error) + ContainerExecAttach( + ctx context.Context, execID string, config container.ExecAttachOptions, + ) (types.HijackedResponse, error) + ContainerExecStart( + ctx context.Context, execID string, config container.ExecStartOptions, + ) error + ContainerExecInspect(ctx context.Context, execID string) (container.ExecInspect, error) +} + +// DockerExecutor runs code inside ephemeral Docker containers with no network +// access, talking directly to the Docker Engine API (no docker CLI binary +// required). It is the Go port of app/services/executor_docker.py. +type DockerExecutor struct { + cli dockerAPI + image string + network string + extra runArgsOptions + log *slog.Logger +} + +// NewDockerExecutor builds an Engine API client from the environment +// (DOCKER_HOST etc., defaulting to the local socket) and validates the +// configured run-args subset. +func NewDockerExecutor(cfg config.Config) (*DockerExecutor, error) { + extra, err := parseDockerRunArgs(cfg.DockerRunArgs) + if err != nil { + return nil, err + } + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return nil, fmt.Errorf("failed to create Docker client: %w", err) + } + return &DockerExecutor{ + cli: cli, + image: cfg.DockerImage, + network: cfg.DockerNetwork, + extra: extra, + log: logging.Named("executor.docker"), + }, nil +} + +// CheckHealth verifies the Docker daemon is reachable and the executor image +// is available locally. +func (d *DockerExecutor) CheckHealth() HealthCheck { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if _, err := d.cli.Ping(ctx); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return HealthCheck{Status: "error", Message: "Docker daemon not responding"} + } + return HealthCheck{ + Status: "error", + Message: "Docker daemon not reachable: " + err.Error(), + } + } + + imageWithTag := imageref.Normalize(d.image) + imgCtx, cancelImg := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelImg() + if _, err := d.cli.ImageInspect(imgCtx, imageWithTag); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return HealthCheck{ + Status: "error", + Message: "Timeout checking image " + imageWithTag, + } + } + return HealthCheck{ + Status: "error", + Message: "Executor image " + imageWithTag + " not available locally", + } + } + + return HealthCheck{Status: "ok"} +} + +// containerSpecs builds the create-container configuration, the SDK analogue +// of the Python service's `docker run` argument list. +// +// sleepSeconds controls how long the container's idle `sleep` lasts; callers +// must ensure it exceeds their work duration. labels are attached for later +// filtering (e.g. by the session reaper). +func (d *DockerExecutor) containerSpecs( + cpuTimeLimitSec *int, + memoryLimitMB *int, + sleepSeconds int, + labels map[string]string, +) (*container.Config, *container.HostConfig) { + mergedLabels := map[string]string{} + for k, v := range labels { + mergedLabels[k] = v + } + for k, v := range d.extra.labels { + mergedLabels[k] = v + } + + env := []string{ + "PYTHONUNBUFFERED=1", + "PYTHONDONTWRITEBYTECODE=1", + "PYTHONIOENCODING=utf-8", + "MPLCONFIGDIR=/tmp/matplotlib", + } + env = append(env, d.extra.env...) + + cfg := &container.Config{ + Image: d.image, + Cmd: []string{"sleep", strconv.Itoa(sleepSeconds)}, + WorkingDir: "/workspace", + Env: env, + Labels: mergedLabels, + } + + pidsLimit := int64(64) + resources := container.Resources{ + PidsLimit: &pidsLimit, + Ulimits: append([]*units.Ulimit{}, d.extra.ulimits...), + } + if cpuTimeLimitSec != nil { + cpuLimit := int64(max(*cpuTimeLimitSec, 1)) + resources.Ulimits = append(resources.Ulimits, &units.Ulimit{ + Name: "cpu", Soft: cpuLimit, Hard: cpuLimit, + }) + } + if memoryLimitMB != nil { + memoryBytes := int64(max(*memoryLimitMB, 16)) * 1024 * 1024 + resources.Memory = memoryBytes + resources.MemorySwap = memoryBytes + } + + // We need CAP_CHOWN to set up the workspace, but drop privileges for + // execution (exec runs as the unprivileged executor user). + hostCfg := &container.HostConfig{ + AutoRemove: true, + NetworkMode: container.NetworkMode(d.network), + // Use the host cgroup namespace to avoid cgroup v2 issues in DinD. + CgroupnsMode: container.CgroupnsModeHost, + SecurityOpt: []string{"no-new-privileges"}, + CapDrop: []string{"ALL"}, + CapAdd: []string{"CHOWN"}, + Tmpfs: map[string]string{ + "/tmp": "rw,size=64m", + // Create the workspace as a tmpfs owned by the executor user. + "/workspace": "rw,uid=65532,gid=65532", + }, + Resources: resources, + ExtraHosts: append([]string{}, d.extra.extraHosts...), + DNS: append([]string{}, d.extra.dns...), + } + + return cfg, hostCfg +} + +// startContainer creates and starts an executor container. +func (d *DockerExecutor) startContainer( + name string, + cfg *container.Config, + hostCfg *container.HostConfig, +) error { + ctx := context.Background() + if _, err := d.cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, name); err != nil { + return fmt.Errorf("Failed to start container: %v", err) + } + if err := d.cli.ContainerStart(ctx, name, container.StartOptions{}); err != nil { + return fmt.Errorf("Failed to start container: %v", err) + } + return nil +} + +// killContainer force-kills a container, ignoring failures (it may already be +// gone; AutoRemove cleans it up afterwards). +func (d *DockerExecutor) killContainer(name string) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = d.cli.ContainerKill(ctx, name, "KILL") +} + +// killProcessInContainer best-effort SIGKILLs all processes with the given +// name inside the container (as root, to ensure we can kill it). +func (d *DockerExecutor) killProcessInContainer(containerName, processName string) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + resp, err := d.cli.ContainerExecCreate(ctx, containerName, container.ExecOptions{ + Cmd: []string{"pkill", "-9", processName}, + }) + if err != nil { + return + } + _ = d.cli.ContainerExecStart(ctx, resp.ID, container.ExecStartOptions{Detach: true}) +} + +// execParams describes one exec-in-container invocation. +type execParams struct { + container string + cmd []string + user string + stdin io.Reader + stdout io.Writer + stderr io.Writer + // timeout of 0 means wait indefinitely. + timeout time.Duration + // killProcess, when non-empty, is pkill'ed inside the container if the + // timeout elapses. + killProcess string +} + +// isMissingContainerErr reports whether an Engine API error means the target +// container is gone or stopped — the typed replacement for the Python +// service's stderr-string heuristic. +func isMissingContainerErr(err error) bool { + return errdefs.IsNotFound(err) || errdefs.IsConflict(err) +} + +// execInContainer runs a command via the exec API, streaming demultiplexed +// stdout/stderr into the provided writers. Returns the exit code (nil when +// the run timed out or the daemon could not report one) and whether the +// timeout elapsed. +func (d *DockerExecutor) execInContainer(p execParams) (exitCode *int, timedOut bool, err error) { + ctx := context.Background() + + createResp, err := d.cli.ContainerExecCreate(ctx, p.container, container.ExecOptions{ + User: p.user, + AttachStdin: p.stdin != nil, + AttachStdout: true, + AttachStderr: true, + Cmd: p.cmd, + }) + if err != nil { + return nil, false, err + } + + attach, err := d.cli.ContainerExecAttach(ctx, createResp.ID, container.ExecAttachOptions{}) + if err != nil { + return nil, false, err + } + defer attach.Close() + + // Feed stdin from its own goroutine (mirroring communicate()) so a child + // that never reads cannot deadlock the copy loop, then half-close to + // signal EOF. + if p.stdin != nil { + go func() { + _, _ = io.Copy(attach.Conn, p.stdin) + _ = attach.CloseWrite() + }() + } else { + _ = attach.CloseWrite() + } + + stdout := p.stdout + if stdout == nil { + stdout = io.Discard + } + stderr := p.stderr + if stderr == nil { + stderr = io.Discard + } + + done := make(chan error, 1) + go func() { + _, copyErr := stdcopy.StdCopy(stdout, stderr, attach.Reader) + done <- copyErr + }() + + var timer <-chan time.Time + if p.timeout > 0 { + t := time.NewTimer(p.timeout) + defer t.Stop() + timer = t.C + } + + select { + case <-done: + case <-timer: + timedOut = true + if p.killProcess != "" { + d.killProcessInContainer(p.container, p.killProcess) + } + // Closing the attach connection unblocks the copy goroutine; wait for + // it so the writers are never touched after we return. + attach.Close() + <-done + } + + if timedOut { + return nil, true, nil + } + + // The exec process may not be reaped the instant the stream closes; poll + // briefly until the daemon reports completion. + inspectDeadline := time.Now().Add(2 * time.Second) + for { + inspect, inspectErr := d.cli.ContainerExecInspect(ctx, createResp.ID) + if inspectErr != nil { + return nil, false, inspectErr + } + if !inspect.Running { + code := inspect.ExitCode + return &code, false, nil + } + if time.Now().After(inspectDeadline) { + return nil, false, nil + } + time.Sleep(50 * time.Millisecond) + } +} + +// uploadTarToContainer streams a tar archive into the container workspace. +// +// The workspace is a tmpfs mount, which exists only in the container's mount +// namespace — the Engine's CopyToContainer API writes through the rootfs and +// would be invisible there — so extraction runs inside the container. +func (d *DockerExecutor) uploadTarToContainer(containerName string, tarArchive []byte) error { + var stderr bytes.Buffer + exitCode, _, err := d.execInContainer(execParams{ + container: containerName, + cmd: []string{"tar", "-x", "-C", "/workspace"}, + user: executorUser, + stdin: bytes.NewReader(tarArchive), + stderr: &stderr, + }) + if err != nil { + return fmt.Errorf("Failed to extract files: %v", err) + } + if exitCode == nil || *exitCode != 0 { + return fmt.Errorf( + "Failed to extract files: %s", strings.ToValidUTF8(stderr.String(), "�"), + ) + } + return nil +} + +// extractWorkspaceSnapshot extracts files from the container workspace after +// execution using in-container tar (see uploadTarToContainer for why). +// Failures yield an empty snapshot, never an error. +func (d *DockerExecutor) extractWorkspaceSnapshot(containerName string) []WorkspaceEntry { + var stdout bytes.Buffer + exitCode, _, err := d.execInContainer(execParams{ + container: containerName, + cmd: []string{"tar", "-c", "--exclude=__main__.py", "-C", "/workspace", "."}, + stdout: &stdout, + timeout: 10 * time.Second, + }) + if err != nil || exitCode == nil || *exitCode != 0 { + return nil + } + entries, err := ParseWorkspaceSnapshot(stdout.Bytes()) + if err != nil { + return nil + } + return entries +} + +// prepareContainer creates an ephemeral executor container and stages the +// code + files. The returned cleanup function kills the container and must be +// deferred by the caller. +func (d *DockerExecutor) prepareContainer(opts ExecOptions) (string, func(), error) { + containerName := "code-exec-" + strings.ReplaceAll(uuid.NewString(), "-", "") + + cfg, hostCfg := d.containerSpecs( + opts.CPUTimeLimitSec, opts.MemoryLimitMB, opts.TimeoutMs/1000+10, nil, + ) + if err := d.startContainer(containerName, cfg, hostCfg); err != nil { + return "", func() {}, err + } + cleanup := func() { d.killContainer(containerName) } + + tarArchive, err := CreateTarArchive(&opts.Code, opts.Files, opts.LastLineInteractive, nil) + if err != nil { + cleanup() + return "", func() {}, err + } + if err := d.uploadTarToContainer(containerName, tarArchive); err != nil { + cleanup() + return "", func() {}, err + } + return containerName, cleanup, nil +} + +// ExecutePython executes Python code inside an ephemeral Docker container. +func (d *DockerExecutor) ExecutePython(opts ExecOptions) (ExecutionResult, error) { + containerName, cleanup, err := d.prepareContainer(opts) + if err != nil { + return ExecutionResult{}, err + } + defer cleanup() + + d.log.Debug("Executing code", "code", opts.Code) + + var stdin io.Reader + if opts.Stdin != nil { + stdin = strings.NewReader(*opts.Stdin) + } + var stdoutBuf, stderrBuf bytes.Buffer + + start := time.Now() + exitCode, timedOut, err := d.execInContainer(execParams{ + container: containerName, + cmd: []string{"python", "/workspace/__main__.py"}, + user: executorUser, + stdin: stdin, + stdout: &stdoutBuf, + stderr: &stderrBuf, + timeout: time.Duration(opts.TimeoutMs) * time.Millisecond, + killProcess: "python", + }) + if err != nil { + return ExecutionResult{}, fmt.Errorf("Failed to execute code: %v", err) + } + + workspaceSnapshot := d.extractWorkspaceSnapshot(containerName) + durationMs := time.Since(start).Milliseconds() + + stdout := TruncateOutput(stdoutBuf.Bytes(), opts.MaxOutputBytes) + d.log.Debug("execution stdout", "stdout", stdout) + stderr := TruncateOutput(stderrBuf.Bytes(), opts.MaxOutputBytes) + d.log.Debug("execution stderr", "stderr", stderr) + + return ExecutionResult{ + Stdout: stdout, + Stderr: stderr, + ExitCode: exitCode, + TimedOut: timedOut, + DurationMs: durationMs, + Files: workspaceSnapshot, + }, nil +} + +// ExecutePythonStreaming executes Python code, emitting output chunks as they +// arrive, and returns the final result. +func (d *DockerExecutor) ExecutePythonStreaming( + opts ExecOptions, + emit EmitFunc, +) (StreamResult, error) { + containerName, cleanup, err := d.prepareContainer(opts) + if err != nil { + return StreamResult{}, err + } + defer cleanup() + + var stdin io.Reader + if opts.Stdin != nil { + stdin = strings.NewReader(*opts.Stdin) + } + + var mu sync.Mutex + stdoutWriter := &trackerWriter{ + tracker: newStreamTracker("stdout", opts.MaxOutputBytes), emit: emit, mu: &mu, + } + stderrWriter := &trackerWriter{ + tracker: newStreamTracker("stderr", opts.MaxOutputBytes), emit: emit, mu: &mu, + } + + start := time.Now() + exitCode, timedOut, err := d.execInContainer(execParams{ + container: containerName, + cmd: []string{"python", "/workspace/__main__.py"}, + user: executorUser, + stdin: stdin, + stdout: stdoutWriter, + stderr: stderrWriter, + timeout: time.Duration(opts.TimeoutMs) * time.Millisecond, + killProcess: "python", + }) + if err != nil { + return StreamResult{}, fmt.Errorf("Failed to execute code: %v", err) + } + + for _, w := range []*trackerWriter{stdoutWriter, stderrWriter} { + mu.Lock() + if chunk := w.tracker.flush(); chunk != nil { + emit(*chunk) + } + mu.Unlock() + } + + workspaceSnapshot := d.extractWorkspaceSnapshot(containerName) + durationMs := time.Since(start).Milliseconds() + + return StreamResult{ + ExitCode: exitCode, + TimedOut: timedOut, + DurationMs: durationMs, + Files: workspaceSnapshot, + }, nil +} + +// CreateSession starts a long-lived session container whose idle `sleep` +// enforces the TTL even if this process crashes. +func (d *DockerExecutor) CreateSession( + ttlSeconds int, + files []StagedFile, + cpuTimeLimitSec *int, + memoryLimitMB *int, +) (SessionInfo, error) { + containerName := SessionNamePrefix + strings.ReplaceAll(uuid.NewString(), "-", "") + expiresAt := float64(time.Now().UnixNano())/1e9 + float64(ttlSeconds) + + cfg, hostCfg := d.containerSpecs( + cpuTimeLimitSec, memoryLimitMB, ttlSeconds, + map[string]string{ + "app": SessionAppLabel, + "component": SessionComponentLabel, + SessionExpiresAtKey: strconv.FormatFloat(expiresAt, 'f', -1, 64), + }, + ) + if err := d.startContainer(containerName, cfg, hostCfg); err != nil { + return SessionInfo{}, fmt.Errorf("Failed to start session container: %v", err) + } + + if len(files) > 0 { + tarArchive, err := CreateTarArchive(nil, files, true, nil) + if err != nil { + d.killContainer(containerName) + return SessionInfo{}, err + } + if err := d.uploadTarToContainer(containerName, tarArchive); err != nil { + d.killContainer(containerName) + return SessionInfo{}, err + } + } + + d.log.Info("Created session container", + "session_id", containerName, "expires_at", expiresAt) + return SessionInfo{SessionID: containerName, ExpiresAt: expiresAt}, nil +} + +// DeleteSession force-removes a session container by ID. Returns false when +// the container does not exist. +func (d *DockerExecutor) DeleteSession(sessionID string) (bool, error) { + if !strings.HasPrefix(sessionID, SessionNamePrefix) { + return false, nil + } + err := d.cli.ContainerRemove( + context.Background(), sessionID, container.RemoveOptions{Force: true}, + ) + if err != nil { + if errdefs.IsNotFound(err) { + return false, nil + } + return false, fmt.Errorf("Failed to delete session %s: %v", sessionID, err) + } + return true, nil +} + +// ReapExpiredSessions deletes session containers whose expires-at label has +// elapsed. Returns the number reaped. +func (d *DockerExecutor) ReapExpiredSessions() (int, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + containers, err := d.cli.ContainerList(ctx, container.ListOptions{ + All: true, + Filters: filters.NewArgs( + filters.Arg("label", "app="+SessionAppLabel), + filters.Arg("label", "component="+SessionComponentLabel), + ), + }) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + d.log.Warn("Timed out listing session containers for reap") + } else { + d.log.Warn("Failed to list session containers", "error", err.Error()) + } + return 0, nil + } + + now := float64(time.Now().UnixNano()) / 1e9 + reaped := 0 + for _, c := range containers { + name := c.ID + if len(c.Names) > 0 { + name = strings.TrimPrefix(c.Names[0], "/") + } + expiresStr := c.Labels[SessionExpiresAtKey] + if name == "" || expiresStr == "" { + continue + } + expiresAt, err := strconv.ParseFloat(expiresStr, 64) + if err != nil { + continue + } + if expiresAt >= now { + continue + } + rmErr := d.cli.ContainerRemove( + context.Background(), name, container.RemoveOptions{Force: true}, + ) + if rmErr == nil || errdefs.IsNotFound(rmErr) { + reaped++ + d.log.Info("Reaped expired session container", "name", name) + } else { + d.log.Warn("Failed to reap session container", + "name", name, "error", rmErr.Error()) + } + } + return reaped, nil +} + +// ExecuteBashInSession runs a bash command inside an existing session +// container. +// +// The container was created with the "none" network at session-create time +// and that network namespace is what the exec inherits — no additional +// settings are needed (or accepted) for exec. +func (d *DockerExecutor) ExecuteBashInSession( + sessionID string, + cmd string, + timeoutMs int, + maxOutputBytes int, +) (ExecutionResult, error) { + if !strings.HasPrefix(sessionID, SessionNamePrefix) { + return ExecutionResult{}, &SessionNotFoundError{SessionID: sessionID} + } + + var stdoutBuf, stderrBuf bytes.Buffer + start := time.Now() + + // Kill bash inside the container on timeout; pkill matches all bash procs + // in the container — acceptable since the agent runs commands + // sequentially. + exitCode, timedOut, err := d.execInContainer(execParams{ + container: sessionID, + cmd: []string{"bash", "-c", cmd}, + user: executorUser, + stdout: &stdoutBuf, + stderr: &stderrBuf, + timeout: time.Duration(timeoutMs) * time.Millisecond, + killProcess: "bash", + }) + if err != nil { + if isMissingContainerErr(err) { + return ExecutionResult{}, &SessionNotFoundError{SessionID: sessionID} + } + return ExecutionResult{}, fmt.Errorf("Failed to execute bash command: %v", err) + } + + durationMs := time.Since(start).Milliseconds() + return ExecutionResult{ + Stdout: TruncateOutput(stdoutBuf.Bytes(), maxOutputBytes), + Stderr: TruncateOutput(stderrBuf.Bytes(), maxOutputBytes), + ExitCode: exitCode, + TimedOut: timedOut, + DurationMs: durationMs, + }, nil +} + +// EnsureDockerImageAvailable checks that the executor image exists locally +// and attempts to pull it if not. It runs during application startup so the +// image is ready before the service accepts requests. An unreachable daemon +// is a warning, not an error, so Kubernetes-only images can share the binary. +func EnsureDockerImageAvailable(cfg config.Config) error { + log := logging.Named("main") + + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return fmt.Errorf("failed to create Docker client: %w", err) + } + defer func() { _ = cli.Close() }() + + pingCtx, cancelPing := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelPing() + if _, err := cli.Ping(pingCtx); err != nil { + log.Warn("Docker daemon not reachable, skipping image check") + return nil + } + + imageWithTag := imageref.Normalize(cfg.DockerImage) + + log.Info("Checking for Docker image: " + imageWithTag) + checkCtx, cancelCheck := context.WithTimeout(context.Background(), 10*time.Second) + defer cancelCheck() + if _, err := cli.ImageInspect(checkCtx, imageWithTag); err == nil { + log.Info("Docker image " + imageWithTag + " is already available locally") + return nil + } + + log.Info("Docker image " + imageWithTag + " not found locally, attempting to pull...") + pullCtx, cancelPull := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancelPull() + reader, err := cli.ImagePull(pullCtx, imageWithTag, image.PullOptions{}) + if err == nil { + _, err = io.Copy(io.Discard, reader) + _ = reader.Close() + } + if pullCtx.Err() == context.DeadlineExceeded { + return fmt.Errorf( + "Timeout while pulling Docker image %s. "+ + "This may indicate network issues or the image is very large.", + imageWithTag, + ) + } + if err != nil { + log.Error("Failed to pull " + imageWithTag + ": " + err.Error()) + return fmt.Errorf( + "Docker executor image %s is not available locally "+ + "and could not be pulled. Error: %v", + imageWithTag, err, + ) + } + log.Info("Successfully pulled " + imageWithTag) + return nil +} diff --git a/code-interpreter-go/internal/executor/docker_test.go b/code-interpreter-go/internal/executor/docker_test.go new file mode 100644 index 0000000..a5eade6 --- /dev/null +++ b/code-interpreter-go/internal/executor/docker_test.go @@ -0,0 +1,258 @@ +package executor + +import ( + "archive/tar" + "bytes" + "log/slog" + "os" + "slices" + "strings" + "testing" + + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/config" +) + +func newTestDockerExecutor(t *testing.T, runArgs string) *DockerExecutor { + t.Helper() + extra, err := parseDockerRunArgs(runArgs) + if err != nil { + t.Fatalf("parseDockerRunArgs(%q): %v", runArgs, err) + } + return &DockerExecutor{ + image: "onyxdotapp/python-executor-sci", + network: "none", + extra: extra, + log: slog.Default(), + } +} + +func TestContainerSpecsBasics(t *testing.T) { + d := newTestDockerExecutor(t, "") + cpu, mem := 5, 256 + cfg, hostCfg := d.containerSpecs(&cpu, &mem, 12, nil) + + if !slices.Equal(cfg.Cmd, []string{"sleep", "12"}) { + t.Errorf("cmd = %v", cfg.Cmd) + } + if cfg.WorkingDir != "/workspace" || cfg.Image != d.image { + t.Errorf("config = %+v", cfg) + } + if !slices.Contains(cfg.Env, "PYTHONUNBUFFERED=1") || + !slices.Contains(cfg.Env, "MPLCONFIGDIR=/tmp/matplotlib") { + t.Errorf("env = %v", cfg.Env) + } + + if !hostCfg.AutoRemove { + t.Error("AutoRemove not set") + } + if string(hostCfg.NetworkMode) != "none" { + t.Errorf("network = %s", hostCfg.NetworkMode) + } + if string(hostCfg.CgroupnsMode) != "host" { + t.Errorf("cgroupns = %s", hostCfg.CgroupnsMode) + } + if !slices.Contains(hostCfg.SecurityOpt, "no-new-privileges") { + t.Errorf("security opts = %v", hostCfg.SecurityOpt) + } + if !slices.Contains([]string(hostCfg.CapDrop), "ALL") || + !slices.Contains([]string(hostCfg.CapAdd), "CHOWN") { + t.Errorf("caps = drop %v add %v", hostCfg.CapDrop, hostCfg.CapAdd) + } + if hostCfg.Tmpfs["/workspace"] != "rw,uid=65532,gid=65532" || + hostCfg.Tmpfs["/tmp"] != "rw,size=64m" { + t.Errorf("tmpfs = %v", hostCfg.Tmpfs) + } + if hostCfg.Resources.PidsLimit == nil || *hostCfg.Resources.PidsLimit != 64 { + t.Errorf("pids limit = %v", hostCfg.Resources.PidsLimit) + } + if hostCfg.Resources.Memory != 256*1024*1024 || + hostCfg.Resources.MemorySwap != 256*1024*1024 { + t.Errorf("memory = %d swap %d", hostCfg.Resources.Memory, hostCfg.Resources.MemorySwap) + } + + var foundCPU bool + for _, u := range hostCfg.Resources.Ulimits { + if u.Name == "cpu" && u.Soft == 5 && u.Hard == 5 { + foundCPU = true + } + } + if !foundCPU { + t.Errorf("cpu ulimit missing: %v", hostCfg.Resources.Ulimits) + } +} + +func TestContainerSpecsClampsLimits(t *testing.T) { + d := newTestDockerExecutor(t, "") + cpu, mem := 0, 4 + _, hostCfg := d.containerSpecs(&cpu, &mem, 1, nil) + + if hostCfg.Resources.Memory != 16*1024*1024 { + t.Errorf("memory not clamped to 16 MiB: %d", hostCfg.Resources.Memory) + } + for _, u := range hostCfg.Resources.Ulimits { + if u.Name == "cpu" && (u.Soft != 1 || u.Hard != 1) { + t.Errorf("cpu ulimit not clamped: %+v", u) + } + } +} + +func TestContainerSpecsOmitsLimitsWhenNil(t *testing.T) { + d := newTestDockerExecutor(t, "") + _, hostCfg := d.containerSpecs(nil, nil, 1, nil) + + if hostCfg.Resources.Memory != 0 { + t.Errorf("memory set despite nil: %d", hostCfg.Resources.Memory) + } + for _, u := range hostCfg.Resources.Ulimits { + if u.Name == "cpu" { + t.Errorf("cpu ulimit set despite nil: %+v", u) + } + } +} + +func TestContainerSpecsLabels(t *testing.T) { + d := newTestDockerExecutor(t, "") + cfg, _ := d.containerSpecs(nil, nil, 60, map[string]string{ + "app": SessionAppLabel, + "component": SessionComponentLabel, + }) + if cfg.Labels["app"] != "code-interpreter" || cfg.Labels["component"] != "session" { + t.Errorf("labels = %v", cfg.Labels) + } +} + +func TestParseDockerRunArgs(t *testing.T) { + opts, err := parseDockerRunArgs( + `--label team=infra --env "FOO=bar baz" --add-host example.com:10.0.0.1 ` + + `--dns 1.1.1.1 --ulimit nofile=1024:2048`, + ) + if err != nil { + t.Fatal(err) + } + if opts.labels["team"] != "infra" { + t.Errorf("labels = %v", opts.labels) + } + if !slices.Contains(opts.env, "FOO=bar baz") { + t.Errorf("env = %v", opts.env) + } + if !slices.Contains(opts.extraHosts, "example.com:10.0.0.1") { + t.Errorf("extraHosts = %v", opts.extraHosts) + } + if !slices.Contains(opts.dns, "1.1.1.1") { + t.Errorf("dns = %v", opts.dns) + } + if len(opts.ulimits) != 1 || opts.ulimits[0].Name != "nofile" || + opts.ulimits[0].Soft != 1024 || opts.ulimits[0].Hard != 2048 { + t.Errorf("ulimits = %+v", opts.ulimits) + } +} + +func TestParseDockerRunArgsEqualsForm(t *testing.T) { + opts, err := parseDockerRunArgs(`--env=FOO=bar --label=a=b`) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(opts.env, "FOO=bar") || opts.labels["a"] != "b" { + t.Errorf("opts = %+v", opts) + } +} + +func TestParseDockerRunArgsRejectsUnsupported(t *testing.T) { + for _, raw := range []string{ + "--privileged true", + "--network host", + "--volume /:/host", + "--env", + } { + if _, err := parseDockerRunArgs(raw); err == nil { + t.Errorf("parseDockerRunArgs(%q) expected error", raw) + } + } +} + +func TestParseDockerRunArgsEmpty(t *testing.T) { + opts, err := parseDockerRunArgs("") + if err != nil { + t.Fatal(err) + } + if len(opts.env) != 0 || len(opts.labels) != 0 { + t.Errorf("opts = %+v", opts) + } +} + +func TestParseWorkspaceSnapshotFromInContainerTar(t *testing.T) { + // Simulate the archive produced by `tar -c -C /workspace .` inside the + // container: "./"-rooted entries. + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for _, hdr := range []*tar.Header{ + {Name: "./", Typeflag: tar.TypeDir, Mode: 0o755}, + {Name: "./out", Typeflag: tar.TypeDir, Mode: 0o755}, + {Name: "./out/result.txt", Typeflag: tar.TypeReg, Mode: 0o644, Size: 2}, + } { + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if hdr.Typeflag == tar.TypeReg { + if _, err := tw.Write([]byte("42")); err != nil { + t.Fatal(err) + } + } + } + _ = tw.Close() + + entries, err := ParseWorkspaceSnapshot(buf.Bytes()) + if err != nil { + t.Fatal(err) + } + byPath := map[string]WorkspaceEntry{} + for _, e := range entries { + byPath[e.Path] = e + } + if len(entries) != 2 { + t.Fatalf("entries = %+v", entries) + } + if e, ok := byPath["out"]; !ok || e.Kind != EntryKindDirectory { + t.Errorf("out dir entry = %+v", e) + } + if e, ok := byPath["out/result.txt"]; !ok || string(e.Content) != "42" { + t.Errorf("out/result.txt = %+v", e) + } +} + +// TestDockerExecutorEndToEnd exercises a real docker daemon. It is skipped +// unless docker (and the executor image) are available, mirroring the Python +// e2e tests that require a running environment. +func TestDockerExecutorEndToEnd(t *testing.T) { + if os.Getenv("CODE_INTERPRETER_GO_E2E") == "" { + t.Skip("set CODE_INTERPRETER_GO_E2E=1 to run docker end-to-end tests") + } + + cfg := config.Load() + d, err := NewDockerExecutor(cfg) + if err != nil { + t.Fatal(err) + } + if health := d.CheckHealth(); health.Status != "ok" { + t.Skipf("docker backend not healthy: %s", health.Message) + } + + stdin := "fed via stdin" + result, err := d.ExecutePython(ExecOptions{ + Code: "import sys\nprint('hello from go port')\nprint(sys.stdin.read())", + Stdin: &stdin, + TimeoutMs: 30_000, + MaxOutputBytes: 1_000_000, + LastLineInteractive: true, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(result.Stdout, "hello from go port") || + !strings.Contains(result.Stdout, "fed via stdin") { + t.Errorf("stdout = %q", result.Stdout) + } + if result.ExitCode == nil || *result.ExitCode != 0 { + t.Errorf("exit code = %v", result.ExitCode) + } +} diff --git a/code-interpreter-go/internal/executor/factory.go b/code-interpreter-go/internal/executor/factory.go new file mode 100644 index 0000000..345d740 --- /dev/null +++ b/code-interpreter-go/internal/executor/factory.go @@ -0,0 +1,21 @@ +package executor + +import ( + "fmt" + "strings" + + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/config" +) + +// New returns the executor selected by cfg.ExecutorBackend ("docker" or +// "kubernetes"). It is the Go port of app/services/executor_factory.py. +func New(cfg config.Config) (Executor, error) { + switch strings.ToLower(cfg.ExecutorBackend) { + case "docker": + return NewDockerExecutor(cfg) + case "kubernetes": + return NewKubernetesExecutor(cfg) + default: + return nil, fmt.Errorf("Unknown executor backend: %s", strings.ToLower(cfg.ExecutorBackend)) + } +} diff --git a/code-interpreter-go/internal/executor/kubernetes.go b/code-interpreter-go/internal/executor/kubernetes.go new file mode 100644 index 0000000..990c0df --- /dev/null +++ b/code-interpreter-go/internal/executor/kubernetes.go @@ -0,0 +1,841 @@ +package executor + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "strconv" + "strings" + "sync" + "time" + + "github.com/google/uuid" + authorizationv1 "k8s.io/api/authorization/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/tools/remotecommand" + utilexec "k8s.io/client-go/util/exec" + + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/config" + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/logging" +) + +const ( + podDeleteRetries = 3 + podDeleteRetryDelay = 200 * time.Millisecond + podDeleteConfirmTimeout = 2 * time.Second + podReadyTimeout = 30 * time.Second + sessionLabelSelector = "app=" + SessionAppLabel + ",component=" + SessionComponentLabel + executorUID int64 = 65532 + executorContainerName string = "executor" +) + +// KubernetesExecutor runs code inside isolated Kubernetes pods. It is the Go +// port of app/services/executor_kubernetes.py. +type KubernetesExecutor struct { + clientset kubernetes.Interface + restConfig *rest.Config + namespace string + image string + serviceAccount string + netAdminLockdown bool + log *slog.Logger +} + +// NewKubernetesExecutor loads in-cluster configuration, falling back to the +// local kubeconfig, and returns a ready executor. +func NewKubernetesExecutor(cfg config.Config) (*KubernetesExecutor, error) { + restConfig, err := rest.InClusterConfig() + if err != nil { + restConfig, err = clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + clientcmd.NewDefaultClientConfigLoadingRules(), + &clientcmd.ConfigOverrides{}, + ).ClientConfig() + if err != nil { + return nil, fmt.Errorf("failed to load Kubernetes configuration: %w", err) + } + } + + clientset, err := kubernetes.NewForConfig(restConfig) + if err != nil { + return nil, fmt.Errorf("failed to create Kubernetes client: %w", err) + } + + return &KubernetesExecutor{ + clientset: clientset, + restConfig: restConfig, + namespace: cfg.KubernetesNamespace, + image: cfg.KubernetesImage, + serviceAccount: cfg.KubernetesServiceAccount, + netAdminLockdown: cfg.KubernetesNetAdminLockdown, + log: logging.Named("executor.kubernetes"), + }, nil +} + +// CheckHealth verifies the Kubernetes API is reachable and we can create pods +// in the namespace. +func (k *KubernetesExecutor) CheckHealth() HealthCheck { + review, err := k.clientset.AuthorizationV1().SelfSubjectAccessReviews().Create( + context.Background(), + &authorizationv1.SelfSubjectAccessReview{ + Spec: authorizationv1.SelfSubjectAccessReviewSpec{ + ResourceAttributes: &authorizationv1.ResourceAttributes{ + Namespace: k.namespace, + Verb: "create", + Resource: "pods", + }, + }, + }, + metav1.CreateOptions{}, + ) + if err != nil { + var statusErr *apierrors.StatusError + if errors.As(err, &statusErr) { + return HealthCheck{ + Status: "error", + Message: fmt.Sprintf( + "Kubernetes API error (namespace=%s): %s", + k.namespace, statusErr.ErrStatus.Reason, + ), + } + } + return HealthCheck{ + Status: "error", + Message: fmt.Sprintf("Kubernetes API not reachable: %v", err), + } + } + if !review.Status.Allowed { + reason := review.Status.Reason + if reason == "" { + reason = "no reason provided" + } + k.log.Warn(fmt.Sprintf( + "Health check failed: cannot create pods in namespace=%s (reason=%s)", + k.namespace, reason, + )) + return HealthCheck{ + Status: "error", + Message: fmt.Sprintf( + "Service account lacks permission to create pods in namespace=%s", k.namespace, + ), + } + } + return HealthCheck{Status: "ok"} +} + +// createPodManifest builds a pod manifest for an isolated executor container. +// +// command is the executor container's command (e.g. ["sleep", "3600"]). +// activeDeadlineSeconds, when set, instructs kubelet to stop the pod at that +// age — used by sessions to enforce TTL even if the API is down. +func (k *KubernetesExecutor) createPodManifest( + podName string, + command []string, + labels map[string]string, + annotations map[string]string, + activeDeadlineSeconds *int64, + memoryLimitMB *int, + cpuTimeLimitSec *int, +) *corev1.Pod { + limits := corev1.ResourceList{} + requests := corev1.ResourceList{} + + if memoryLimitMB != nil { + memoryLimit := max(*memoryLimitMB, 16) + limits[corev1.ResourceMemory] = resource.MustParse(fmt.Sprintf("%dMi", memoryLimit)) + requests[corev1.ResourceMemory] = resource.MustParse( + fmt.Sprintf("%dMi", min(memoryLimit, 64)), + ) + } + if cpuTimeLimitSec != nil { + cpuLimit := max(*cpuTimeLimitSec, 1) + limits[corev1.ResourceCPU] = resource.MustParse(strconv.Itoa(cpuLimit)) + requests[corev1.ResourceCPU] = resource.MustParse("100m") + } + + var resources corev1.ResourceRequirements + if len(limits) > 0 { + resources = corev1.ResourceRequirements{Limits: limits, Requests: requests} + } + + uid := executorUID + falseVal := false + trueVal := true + + container := corev1.Container{ + Name: executorContainerName, + Image: k.image, + Command: command, + WorkingDir: "/workspace", + Resources: resources, + SecurityContext: &corev1.SecurityContext{ + RunAsUser: &uid, + RunAsGroup: &uid, + AllowPrivilegeEscalation: &falseVal, + ReadOnlyRootFilesystem: &falseVal, + Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}}, + }, + Env: []corev1.EnvVar{ + {Name: "PYTHONUNBUFFERED", Value: "1"}, + {Name: "PYTHONDONTWRITEBYTECODE", Value: "1"}, + {Name: "PYTHONIOENCODING", Value: "utf-8"}, + {Name: "MPLCONFIGDIR", Value: "/tmp/matplotlib"}, + }, + VolumeMounts: []corev1.VolumeMount{ + {Name: "workspace", MountPath: "/workspace"}, + {Name: "tmp", MountPath: "/tmp"}, + }, + } + + // Use iptables in an init container to drop all outbound traffic before + // the main executor container starts. Since all containers in a pod share + // a network namespace, rules set here apply to the executor container as + // well. This eliminates the race condition where the pod can send network + // requests before the Kubernetes NetworkPolicy is enforced by the CNI. + // + // This requires the NET_ADMIN capability. Environments whose CNI enforces + // NetworkPolicies without that race (or that disallow NET_ADMIN) can + // disable this and rely on a NetworkPolicy instead. + var initContainers []corev1.Container + if k.netAdminLockdown { + rootUID := int64(0) + iptablesScript := "set -e && iptables -A OUTPUT -j DROP && ip6tables -A OUTPUT -j DROP" + initContainers = append(initContainers, corev1.Container{ + Name: "network-lockdown", + Image: k.image, + Command: []string{"sh", "-c", iptablesScript}, + SecurityContext: &corev1.SecurityContext{ + RunAsUser: &rootUID, + RunAsNonRoot: &falseVal, + AllowPrivilegeEscalation: &falseVal, + ReadOnlyRootFilesystem: &trueVal, + Capabilities: &corev1.Capabilities{ + Drop: []corev1.Capability{"ALL"}, + Add: []corev1.Capability{"NET_ADMIN"}, + }, + }, + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + corev1.ResourceMemory: resource.MustParse("32Mi"), + }, + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("10m"), + corev1.ResourceMemory: resource.MustParse("16Mi"), + }, + }, + }) + } + + workspaceSize := resource.MustParse("100Mi") + tmpSize := resource.MustParse("64Mi") + fsGroup := executorUID + + spec := corev1.PodSpec{ + InitContainers: initContainers, + Containers: []corev1.Container{container}, + RestartPolicy: corev1.RestartPolicyNever, + ActiveDeadlineSeconds: activeDeadlineSeconds, + ServiceAccountName: k.serviceAccount, + Volumes: []corev1.Volume{ + { + Name: "workspace", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{SizeLimit: &workspaceSize}, + }, + }, + { + Name: "tmp", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{SizeLimit: &tmpSize}, + }, + }, + }, + SecurityContext: &corev1.PodSecurityContext{ + RunAsNonRoot: &trueVal, + FSGroup: &fsGroup, + }, + } + + return &corev1.Pod{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Pod"}, + ObjectMeta: metav1.ObjectMeta{ + Name: podName, + Namespace: k.namespace, + Labels: labels, + Annotations: annotations, + }, + Spec: spec, + } +} + +// waitForPodReady polls until the pod reaches the Running phase. +func (k *KubernetesExecutor) waitForPodReady(podName string, timeout time.Duration) error { + k.log.Info("Waiting for pod " + podName + " to be ready") + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + pod, err := k.clientset.CoreV1().Pods(k.namespace).Get( + context.Background(), podName, metav1.GetOptions{}, + ) + if err != nil { + return err + } + if pod.Status.Phase == corev1.PodRunning { + k.log.Info("Pod " + podName + " is running") + return nil + } + time.Sleep(100 * time.Millisecond) + } + return fmt.Errorf( + "Pod %s did not become ready in %d seconds", podName, int(timeout.Seconds()), + ) +} + +// podExec runs a command in the executor container over SPDY. Streams are +// wired directly; a nil reader/writer disables that stream. The returned +// error is nil on exit code 0, a *utilexec.CodeExitError on non-zero exit, +// or the transport/context error otherwise. +func (k *KubernetesExecutor) podExec( + ctx context.Context, + podName string, + command []string, + stdin io.Reader, + stdout io.Writer, + stderr io.Writer, +) error { + req := k.clientset.CoreV1().RESTClient().Post(). + Resource("pods"). + Name(podName). + Namespace(k.namespace). + SubResource("exec"). + VersionedParams(&corev1.PodExecOptions{ + Container: executorContainerName, + Command: command, + Stdin: stdin != nil, + Stdout: stdout != nil, + Stderr: stderr != nil, + TTY: false, + }, scheme.ParameterCodec) + + executor, err := remotecommand.NewSPDYExecutor(k.restConfig, "POST", req.URL()) + if err != nil { + return err + } + return executor.StreamWithContext(ctx, remotecommand.StreamOptions{ + Stdin: stdin, + Stdout: stdout, + Stderr: stderr, + }) +} + +// exitCodeFromExecError translates a podExec error into an exit code: +// nil → 0, CodeExitError → its code, anything else → nil (unknown). +func exitCodeFromExecError(err error) *int { + if err == nil { + zero := 0 + return &zero + } + var codeErr utilexec.CodeExitError + if errors.As(err, &codeErr) { + code := codeErr.Code + return &code + } + return nil +} + +// uploadTarToPod uploads and extracts a tar archive into the pod's workspace. +func (k *KubernetesExecutor) uploadTarToPod(podName string, tarArchive []byte) error { + k.log.Info(fmt.Sprintf( + "Uploading tar archive (%d bytes) to pod %s", len(tarArchive), podName, + )) + var stderr bytes.Buffer + err := k.podExec( + context.Background(), + podName, + []string{"tar", "-x", "-C", "/workspace"}, + bytes.NewReader(tarArchive), + io.Discard, + &stderr, + ) + if err != nil { + return fmt.Errorf( + "Tar extraction failed: %v. stderr: %s", + err, strings.ToValidUTF8(stderr.String(), "�"), + ) + } + k.log.Info("Tar extraction completed for pod " + podName) + return nil +} + +// killProcessesInPod best-effort SIGKILLs all processes with the given name +// in the pod. +func (k *KubernetesExecutor) killProcessesInPod(podName, processName string) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := k.podExec( + ctx, podName, []string{"pkill", "-9", processName}, nil, io.Discard, io.Discard, + ); err != nil { + var codeErr utilexec.CodeExitError + if !errors.As(err, &codeErr) { + k.log.Warn(fmt.Sprintf( + "Failed to kill %s process in pod %s: %v", processName, podName, err, + )) + } + } +} + +// extractWorkspaceSnapshot extracts files from the pod workspace after +// execution using tar. SPDY streams are binary-safe, so the tar bytes are +// captured directly. Failures yield an empty snapshot, never an error. +func (k *KubernetesExecutor) extractWorkspaceSnapshot(podName string) []WorkspaceEntry { + var stdout, stderr bytes.Buffer + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + k.log.Info("Starting tar extraction from pod " + podName) + err := k.podExec( + ctx, + podName, + []string{"tar", "-c", "--exclude=__main__.py", "-C", "/workspace", "."}, + nil, + &stdout, + &stderr, + ) + if err != nil { + k.log.Error(fmt.Sprintf( + "Failed to extract workspace snapshot from pod %s: %v (stderr: %s)", + podName, err, stderr.String(), + )) + return nil + } + + entries, err := ParseWorkspaceSnapshot(stdout.Bytes()) + if err != nil { + k.log.Error(fmt.Sprintf("Failed to parse workspace snapshot: %v", err)) + return nil + } + k.log.Info(fmt.Sprintf("Extracted %d workspace entries", len(entries))) + return entries +} + +// waitForPodDeleted polls until the pod is gone, returning true on success. +func (k *KubernetesExecutor) waitForPodDeleted(podName string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + _, err := k.clientset.CoreV1().Pods(k.namespace).Get( + context.Background(), podName, metav1.GetOptions{}, + ) + if err != nil { + if apierrors.IsNotFound(err) { + return true + } + k.log.Warn(fmt.Sprintf( + "Error while checking pod deletion for %s in namespace %s: %v", + podName, k.namespace, err, + )) + return false + } + time.Sleep(100 * time.Millisecond) + } + return false +} + +// cleanupPod deletes a pod with retries and logs any cleanup failures. +func (k *KubernetesExecutor) cleanupPod(podName string) { + gracePeriod := int64(0) + deleteOpts := metav1.DeleteOptions{GracePeriodSeconds: &gracePeriod} + + for attempt := 1; attempt <= podDeleteRetries; attempt++ { + err := k.clientset.CoreV1().Pods(k.namespace).Delete( + context.Background(), podName, deleteOpts, + ) + if err != nil { + if apierrors.IsNotFound(err) { + return + } + k.log.Warn(fmt.Sprintf( + "Failed to delete pod %s in namespace %s on attempt %d/%d: %v", + podName, k.namespace, attempt, podDeleteRetries, err, + )) + } else { + if k.waitForPodDeleted(podName, podDeleteConfirmTimeout) { + return + } + k.log.Warn(fmt.Sprintf( + "Pod %s still exists after delete request on attempt %d/%d", + podName, attempt, podDeleteRetries, + )) + } + + if attempt < podDeleteRetries { + time.Sleep(podDeleteRetryDelay * time.Duration(attempt)) + } + } + + k.log.Error(fmt.Sprintf( + "Failed to confirm deletion of pod %s in namespace %s after %d attempts", + podName, k.namespace, podDeleteRetries, + )) +} + +// preparePod creates an ephemeral executor pod, waits for it to run, and +// stages the code + files. The returned cleanup function deletes the pod. +func (k *KubernetesExecutor) preparePod(opts ExecOptions) (string, func(), error) { + podName := "code-exec-" + strings.ReplaceAll(uuid.NewString(), "-", "") + k.log.Info("Starting execution in pod " + podName) + + manifest := k.createPodManifest( + podName, + []string{"sleep", "3600"}, + map[string]string{"app": "code-interpreter", "component": "executor"}, + nil, + nil, + opts.MemoryLimitMB, + opts.CPUTimeLimitSec, + ) + + cleanup := func() { + k.log.Info("Cleaning up pod " + podName) + k.cleanupPod(podName) + } + + k.log.Info(fmt.Sprintf("Creating pod %s in namespace %s", podName, k.namespace)) + if _, err := k.clientset.CoreV1().Pods(k.namespace).Create( + context.Background(), manifest, metav1.CreateOptions{}, + ); err != nil { + return "", func() {}, fmt.Errorf("failed to create pod %s: %w", podName, err) + } + + if err := k.waitForPodReady(podName, podReadyTimeout); err != nil { + cleanup() + return "", func() {}, err + } + + owner := &TarOwner{UID: int(executorUID), GID: int(executorUID)} + tarArchive, err := CreateTarArchive(&opts.Code, opts.Files, opts.LastLineInteractive, owner) + if err != nil { + cleanup() + return "", func() {}, err + } + if err := k.uploadTarToPod(podName, tarArchive); err != nil { + cleanup() + return "", func() {}, err + } + + return podName, cleanup, nil +} + +// ExecutePython executes Python code inside a Kubernetes pod. +func (k *KubernetesExecutor) ExecutePython(opts ExecOptions) (ExecutionResult, error) { + podName, cleanup, err := k.preparePod(opts) + if err != nil { + return ExecutionResult{}, err + } + defer cleanup() + + k.log.Info("Executing Python code in pod " + podName) + start := time.Now() + + var stdin io.Reader + if opts.Stdin != nil { + stdin = strings.NewReader(*opts.Stdin) + } + var stdoutBuf, stderrBuf bytes.Buffer + + ctx, cancel := context.WithTimeout( + context.Background(), time.Duration(opts.TimeoutMs)*time.Millisecond, + ) + defer cancel() + + execErr := k.podExec( + ctx, podName, []string{"python", "/workspace/__main__.py"}, + stdin, &stdoutBuf, &stderrBuf, + ) + + timedOut := ctx.Err() == context.DeadlineExceeded + if timedOut { + k.killProcessesInPod(podName, "python") + } else if execErr != nil && exitCodeFromExecError(execErr) == nil { + return ExecutionResult{}, fmt.Errorf( + "error during execution in pod %s: %w", podName, execErr, + ) + } + + exitCode := exitCodeFromExecError(execErr) + k.log.Info(fmt.Sprintf( + "Python execution completed. Exit code: %v, Timed out: %v", exitCode, timedOut, + )) + + k.log.Info("Extracting workspace snapshot from pod " + podName) + workspaceSnapshot := k.extractWorkspaceSnapshot(podName) + + durationMs := time.Since(start).Milliseconds() + if timedOut { + exitCode = nil + } + + k.log.Info(fmt.Sprintf("Execution completed in %dms", durationMs)) + return ExecutionResult{ + Stdout: TruncateOutput(stdoutBuf.Bytes(), opts.MaxOutputBytes), + Stderr: TruncateOutput(stderrBuf.Bytes(), opts.MaxOutputBytes), + ExitCode: exitCode, + TimedOut: timedOut, + DurationMs: durationMs, + Files: workspaceSnapshot, + }, nil +} + +// ExecutePythonStreaming executes Python code, emitting output chunks as they +// arrive, and returns the final result. +func (k *KubernetesExecutor) ExecutePythonStreaming( + opts ExecOptions, + emit EmitFunc, +) (StreamResult, error) { + podName, cleanup, err := k.preparePod(opts) + if err != nil { + return StreamResult{}, err + } + defer cleanup() + + start := time.Now() + + var stdin io.Reader + if opts.Stdin != nil { + stdin = strings.NewReader(*opts.Stdin) + } + + var mu sync.Mutex + stdoutWriter := &trackerWriter{ + tracker: newStreamTracker("stdout", opts.MaxOutputBytes), emit: emit, mu: &mu, + } + stderrWriter := &trackerWriter{ + tracker: newStreamTracker("stderr", opts.MaxOutputBytes), emit: emit, mu: &mu, + } + + ctx, cancel := context.WithTimeout( + context.Background(), time.Duration(opts.TimeoutMs)*time.Millisecond, + ) + defer cancel() + + execErr := k.podExec( + ctx, podName, []string{"python", "/workspace/__main__.py"}, + stdin, stdoutWriter, stderrWriter, + ) + + timedOut := ctx.Err() == context.DeadlineExceeded + if timedOut { + k.killProcessesInPod(podName, "python") + } else if execErr != nil && exitCodeFromExecError(execErr) == nil { + return StreamResult{}, fmt.Errorf( + "error during execution in pod %s: %w", podName, execErr, + ) + } + + for _, w := range []*trackerWriter{stdoutWriter, stderrWriter} { + mu.Lock() + if chunk := w.tracker.flush(); chunk != nil { + emit(*chunk) + } + mu.Unlock() + } + + exitCode := exitCodeFromExecError(execErr) + workspaceSnapshot := k.extractWorkspaceSnapshot(podName) + durationMs := time.Since(start).Milliseconds() + if timedOut { + exitCode = nil + } + + return StreamResult{ + ExitCode: exitCode, + TimedOut: timedOut, + DurationMs: durationMs, + Files: workspaceSnapshot, + }, nil +} + +// CreateSession creates a long-lived session pod. activeDeadlineSeconds and +// the idle `sleep` both enforce the TTL even if this process crashes. +func (k *KubernetesExecutor) CreateSession( + ttlSeconds int, + files []StagedFile, + cpuTimeLimitSec *int, + memoryLimitMB *int, +) (SessionInfo, error) { + podName := SessionNamePrefix + strings.ReplaceAll(uuid.NewString(), "-", "") + expiresAt := float64(time.Now().UnixNano())/1e9 + float64(ttlSeconds) + deadline := int64(ttlSeconds) + + manifest := k.createPodManifest( + podName, + []string{"sleep", strconv.Itoa(ttlSeconds)}, + map[string]string{"app": SessionAppLabel, "component": SessionComponentLabel}, + map[string]string{ + SessionExpiresAtKey: strconv.FormatFloat(expiresAt, 'f', -1, 64), + }, + &deadline, + memoryLimitMB, + cpuTimeLimitSec, + ) + + k.log.Info(fmt.Sprintf( + "Creating session pod %s in namespace %s (ttl=%ds)", podName, k.namespace, ttlSeconds, + )) + if _, err := k.clientset.CoreV1().Pods(k.namespace).Create( + context.Background(), manifest, metav1.CreateOptions{}, + ); err != nil { + return SessionInfo{}, fmt.Errorf("failed to create session pod %s: %w", podName, err) + } + + if err := k.waitForPodReady(podName, podReadyTimeout); err != nil { + k.cleanupPod(podName) + return SessionInfo{}, err + } + if len(files) > 0 { + owner := &TarOwner{UID: int(executorUID), GID: int(executorUID)} + tarArchive, err := CreateTarArchive(nil, files, true, owner) + if err != nil { + k.cleanupPod(podName) + return SessionInfo{}, err + } + if err := k.uploadTarToPod(podName, tarArchive); err != nil { + k.cleanupPod(podName) + return SessionInfo{}, err + } + } + + return SessionInfo{SessionID: podName, ExpiresAt: expiresAt}, nil +} + +// DeleteSession deletes a session pod by ID. Returns false when it does not +// exist. +func (k *KubernetesExecutor) DeleteSession(sessionID string) (bool, error) { + if !strings.HasPrefix(sessionID, SessionNamePrefix) { + return false, nil + } + gracePeriod := int64(0) + err := k.clientset.CoreV1().Pods(k.namespace).Delete( + context.Background(), sessionID, metav1.DeleteOptions{GracePeriodSeconds: &gracePeriod}, + ) + if err != nil { + if apierrors.IsNotFound(err) { + return false, nil + } + return false, err + } + return true, nil +} + +// ReapExpiredSessions deletes session pods whose expires-at annotation has +// elapsed. Returns the number reaped. +func (k *KubernetesExecutor) ReapExpiredSessions() (int, error) { + pods, err := k.clientset.CoreV1().Pods(k.namespace).List( + context.Background(), metav1.ListOptions{LabelSelector: sessionLabelSelector}, + ) + if err != nil { + k.log.Warn(fmt.Sprintf("Failed to list session pods for reap: %v", err)) + return 0, nil + } + + now := float64(time.Now().UnixNano()) / 1e9 + gracePeriod := int64(0) + reaped := 0 + for _, pod := range pods.Items { + expiresStr, ok := pod.Annotations[SessionExpiresAtKey] + if !ok { + continue + } + expiresAt, err := strconv.ParseFloat(expiresStr, 64) + if err != nil { + k.log.Warn(fmt.Sprintf( + "Session pod %s has invalid expires-at annotation %q", pod.Name, expiresStr, + )) + continue + } + if expiresAt >= now { + continue + } + err = k.clientset.CoreV1().Pods(k.namespace).Delete( + context.Background(), pod.Name, + metav1.DeleteOptions{GracePeriodSeconds: &gracePeriod}, + ) + if err != nil { + if apierrors.IsNotFound(err) { + continue + } + k.log.Warn(fmt.Sprintf("Failed to reap session pod %s: %v", pod.Name, err)) + continue + } + reaped++ + k.log.Info("Reaped expired session pod " + pod.Name) + } + return reaped, nil +} + +// ExecuteBashInSession runs a bash command inside an existing session pod. +// +// Network restrictions established at pod creation (the iptables init +// container) remain in force — exec inherits the pod's network namespace. +func (k *KubernetesExecutor) ExecuteBashInSession( + sessionID string, + cmd string, + timeoutMs int, + maxOutputBytes int, +) (ExecutionResult, error) { + if !strings.HasPrefix(sessionID, SessionNamePrefix) { + return ExecutionResult{}, &SessionNotFoundError{SessionID: sessionID} + } + + if _, err := k.clientset.CoreV1().Pods(k.namespace).Get( + context.Background(), sessionID, metav1.GetOptions{}, + ); err != nil { + if apierrors.IsNotFound(err) { + return ExecutionResult{}, &SessionNotFoundError{SessionID: sessionID} + } + return ExecutionResult{}, err + } + + start := time.Now() + var stdoutBuf, stderrBuf bytes.Buffer + + ctx, cancel := context.WithTimeout( + context.Background(), time.Duration(timeoutMs)*time.Millisecond, + ) + defer cancel() + + execErr := k.podExec( + ctx, sessionID, []string{"bash", "-c", cmd}, nil, &stdoutBuf, &stderrBuf, + ) + + timedOut := ctx.Err() == context.DeadlineExceeded + if timedOut { + k.killProcessesInPod(sessionID, "bash") + } else if execErr != nil && exitCodeFromExecError(execErr) == nil { + return ExecutionResult{}, fmt.Errorf( + "error during bash execution in session %s: %w", sessionID, execErr, + ) + } + + exitCode := exitCodeFromExecError(execErr) + if timedOut { + exitCode = nil + } + durationMs := time.Since(start).Milliseconds() + + return ExecutionResult{ + Stdout: TruncateOutput(stdoutBuf.Bytes(), maxOutputBytes), + Stderr: TruncateOutput(stderrBuf.Bytes(), maxOutputBytes), + ExitCode: exitCode, + TimedOut: timedOut, + DurationMs: durationMs, + }, nil +} diff --git a/code-interpreter-go/internal/executor/tar.go b/code-interpreter-go/internal/executor/tar.go new file mode 100644 index 0000000..dfede40 --- /dev/null +++ b/code-interpreter-go/internal/executor/tar.go @@ -0,0 +1,184 @@ +package executor + +import ( + "archive/tar" + "bytes" + "errors" + "io" + "strings" +) + +// EntrypointFileName is the reserved name for the staged user program. +const EntrypointFileName = "__main__.py" + +// ValidateRelativePath sanitizes a workspace-relative path: it must not be +// absolute, must not contain "..", and must be non-empty after removing "." +// and empty segments. Returns the cleaned path joined with "/". +func ValidateRelativePath(pathStr string) (string, error) { + if strings.HasPrefix(pathStr, "/") { + return "", &ValidationError{Message: "File paths must be relative."} + } + + var sanitized []string + for _, part := range strings.Split(pathStr, "/") { + if part == "" || part == "." { + continue + } + if part == ".." { + return "", &ValidationError{Message: "File paths must not contain '..'."} + } + sanitized = append(sanitized, part) + } + + if len(sanitized) == 0 { + return "", &ValidationError{Message: "File path must not be empty."} + } + + return strings.Join(sanitized, "/"), nil +} + +// TarOwner optionally assigns a uid/gid to archive entries (used by the +// Kubernetes backend, whose extraction runs without a chown-capable tar). +type TarOwner struct { + UID int + GID int +} + +// CreateTarArchive builds a tar archive optionally containing an entrypoint +// and staged files. +// +// If code is non-nil it is written as __main__.py at the archive root; when +// lastLineInteractive is also true the code is wrapped so the last line +// prints its value if it is a bare expression. Parent directories of staged +// files are materialized as explicit directory entries. +func CreateTarArchive( + code *string, + files []StagedFile, + lastLineInteractive bool, + owner *TarOwner, +) ([]byte, error) { + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + + applyOwner := func(h *tar.Header) { + if owner != nil { + h.Uid = owner.UID + h.Gid = owner.GID + } + } + + if code != nil { + codeToExecute := *code + if lastLineInteractive { + codeToExecute = WrapLastLineInteractive(codeToExecute) + } + hdr := &tar.Header{ + Name: EntrypointFileName, + Mode: 0o644, + Size: int64(len(codeToExecute)), + } + applyOwner(hdr) + if err := tw.WriteHeader(hdr); err != nil { + return nil, err + } + if _, err := tw.Write([]byte(codeToExecute)); err != nil { + return nil, err + } + } + + createdDirs := map[string]bool{} + for _, file := range files { + validated, err := ValidateRelativePath(file.Path) + if err != nil { + return nil, err + } + if code != nil && validated == EntrypointFileName { + return nil, &ValidationError{ + Message: "File path '__main__.py' is reserved for the execution entrypoint.", + } + } + + parts := strings.Split(validated, "/") + for i := 1; i < len(parts); i++ { + dirPath := strings.Join(parts[:i], "/") + if createdDirs[dirPath] { + continue + } + hdr := &tar.Header{ + Name: dirPath + "/", + Typeflag: tar.TypeDir, + Mode: 0o755, + } + applyOwner(hdr) + if err := tw.WriteHeader(hdr); err != nil { + return nil, err + } + createdDirs[dirPath] = true + } + + hdr := &tar.Header{ + Name: validated, + Mode: 0o644, + Size: int64(len(file.Content)), + } + applyOwner(hdr) + if err := tw.WriteHeader(hdr); err != nil { + return nil, err + } + if _, err := tw.Write(file.Content); err != nil { + return nil, err + } + } + + if err := tw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// ParseWorkspaceSnapshot reads a tar archive produced by `tar -c -C +// /workspace .` and returns the workspace entries it contains. The root "." +// entry is skipped and leading "./" prefixes are removed. +func ParseWorkspaceSnapshot(tarData []byte) ([]WorkspaceEntry, error) { + tr := tar.NewReader(bytes.NewReader(tarData)) + var entries []WorkspaceEntry + + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + + if hdr.Name == "." || hdr.Name == "./" { + continue + } + cleanPath := strings.TrimLeft(hdr.Name, "./") + if cleanPath == "" { + continue + } + cleanPath = strings.TrimSuffix(cleanPath, "/") + + switch hdr.Typeflag { + case tar.TypeDir: + entries = append(entries, WorkspaceEntry{ + Path: cleanPath, + Kind: EntryKindDirectory, + }) + case tar.TypeReg: + content, err := io.ReadAll(tr) + if err != nil { + return nil, err + } + entries = append(entries, WorkspaceEntry{ + Path: cleanPath, + Kind: EntryKindFile, + Content: content, + }) + } + } + + return entries, nil +} diff --git a/code-interpreter-go/internal/executor/tar_test.go b/code-interpreter-go/internal/executor/tar_test.go new file mode 100644 index 0000000..999899d --- /dev/null +++ b/code-interpreter-go/internal/executor/tar_test.go @@ -0,0 +1,152 @@ +package executor + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +func TestValidateRelativePath(t *testing.T) { + valid := []struct { + in string + want string + }{ + {"data.csv", "data.csv"}, + {"dir/data.csv", "dir/data.csv"}, + {"./data.csv", "data.csv"}, + {"a//b", "a/b"}, + {"a/./b", "a/b"}, + } + for _, tc := range valid { + got, err := ValidateRelativePath(tc.in) + if err != nil { + t.Errorf("ValidateRelativePath(%q) unexpected error: %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("ValidateRelativePath(%q) = %q, want %q", tc.in, got, tc.want) + } + } + + invalid := []string{"/abs/path.txt", "../escape.txt", "a/../b", "", ".", "./"} + for _, in := range invalid { + if _, err := ValidateRelativePath(in); err == nil { + t.Errorf("ValidateRelativePath(%q) expected error, got nil", in) + } else { + var valErr *ValidationError + if !errors.As(err, &valErr) { + t.Errorf("ValidateRelativePath(%q) error type = %T, want *ValidationError", in, err) + } + } + } +} + +func TestCreateTarArchiveWithCodeAndFiles(t *testing.T) { + code := "print('hi')" + archive, err := CreateTarArchive( + &code, + []StagedFile{ + {Path: "data/input.csv", Content: []byte("a,b\n1,2\n")}, + {Path: "top.txt", Content: []byte("x")}, + }, + false, + nil, + ) + if err != nil { + t.Fatalf("CreateTarArchive: %v", err) + } + + entries, err := ParseWorkspaceSnapshot(archive) + if err != nil { + t.Fatalf("ParseWorkspaceSnapshot: %v", err) + } + + byPath := map[string]WorkspaceEntry{} + for _, e := range entries { + byPath[e.Path] = e + } + + main, ok := byPath["__main__.py"] + if !ok { + t.Fatal("archive missing __main__.py") + } + if string(main.Content) != code { + t.Errorf("__main__.py content = %q, want %q (interactive wrapping disabled)", + main.Content, code) + } + if dir, ok := byPath["data"]; !ok || dir.Kind != EntryKindDirectory { + t.Errorf("expected directory entry for 'data', got %+v", dir) + } + if f, ok := byPath["data/input.csv"]; !ok || !bytes.Equal(f.Content, []byte("a,b\n1,2\n")) { + t.Errorf("data/input.csv content mismatch: %+v", f) + } + if _, ok := byPath["top.txt"]; !ok { + t.Error("archive missing top.txt") + } +} + +func TestCreateTarArchiveWrapsInteractiveCode(t *testing.T) { + code := "1 + 1" + archive, err := CreateTarArchive(&code, nil, true, nil) + if err != nil { + t.Fatalf("CreateTarArchive: %v", err) + } + entries, err := ParseWorkspaceSnapshot(archive) + if err != nil { + t.Fatalf("ParseWorkspaceSnapshot: %v", err) + } + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + content := string(entries[0].Content) + if !strings.Contains(content, "ast.parse") || !strings.Contains(content, "1 + 1") { + t.Errorf("wrapped code missing expected fragments:\n%s", content) + } +} + +func TestCreateTarArchiveRejectsReservedEntrypoint(t *testing.T) { + code := "print('hi')" + _, err := CreateTarArchive( + &code, []StagedFile{{Path: "__main__.py", Content: []byte("evil")}}, true, nil, + ) + var valErr *ValidationError + if !errors.As(err, &valErr) { + t.Fatalf("expected *ValidationError for reserved __main__.py, got %v", err) + } + + // Without code staged, __main__.py is an ordinary path. + if _, err := CreateTarArchive( + nil, []StagedFile{{Path: "__main__.py", Content: []byte("ok")}}, true, nil, + ); err != nil { + t.Fatalf("expected __main__.py to be allowed without code, got %v", err) + } +} + +func TestParseWorkspaceSnapshotStripsDotSlash(t *testing.T) { + archive, err := CreateTarArchive( + nil, []StagedFile{{Path: "./out/result.txt", Content: []byte("42")}}, true, nil, + ) + if err != nil { + t.Fatalf("CreateTarArchive: %v", err) + } + entries, err := ParseWorkspaceSnapshot(archive) + if err != nil { + t.Fatalf("ParseWorkspaceSnapshot: %v", err) + } + for _, e := range entries { + if strings.HasPrefix(e.Path, "./") || strings.HasPrefix(e.Path, "/") { + t.Errorf("entry path not cleaned: %q", e.Path) + } + } +} + +func TestWrapLastLineInteractiveEscapesQuotes(t *testing.T) { + wrapped := WrapLastLineInteractive(`print('it\'s')`) + if !strings.Contains(wrapped, `\'`) { + t.Error("expected single quotes to be escaped in wrapped code") + } + if !strings.Contains(wrapped, "compile(interactive, '', 'single')") { + t.Error("wrapped code missing 'single' compile mode") + } +} diff --git a/code-interpreter-go/internal/executor/types.go b/code-interpreter-go/internal/executor/types.go new file mode 100644 index 0000000..01c66d8 --- /dev/null +++ b/code-interpreter-go/internal/executor/types.go @@ -0,0 +1,175 @@ +// Package executor defines the execution-backend abstraction and its shared +// types. It is the Go port of app/services/executor_base.py. +package executor + +import ( + "fmt" + "strings" +) + +// EntryKind distinguishes files from directories in a workspace snapshot. +type EntryKind string + +const ( + EntryKindFile EntryKind = "file" + EntryKindDirectory EntryKind = "directory" +) + +// WorkspaceEntry is a single file or directory captured from the execution +// workspace after a run. Content is nil for directories. +type WorkspaceEntry struct { + Path string + Kind EntryKind + Content []byte +} + +// StagedFile is a file to place into the execution workspace before running. +type StagedFile struct { + Path string + Content []byte +} + +// ExecutionResult is the outcome of a completed (or timed-out) execution. +// ExitCode is nil when the run timed out. +type ExecutionResult struct { + Stdout string + Stderr string + ExitCode *int + TimedOut bool + DurationMs int64 + Files []WorkspaceEntry +} + +// StreamChunk is a chunk of output emitted during a streaming execution. +// Stream is either "stdout" or "stderr". +type StreamChunk struct { + Stream string + Data string +} + +// StreamResult is the final event of a streaming execution. +type StreamResult struct { + ExitCode *int + TimedOut bool + DurationMs int64 + Files []WorkspaceEntry +} + +// HealthCheck is the result of an executor health check. Status is "ok" or +// "error"; Message carries detail for errors. +type HealthCheck struct { + Status string + Message string +} + +// SessionInfo identifies a long-lived session. ExpiresAt is a Unix timestamp +// (fractional seconds). +type SessionInfo struct { + SessionID string + ExpiresAt float64 +} + +// Session naming/labeling shared by all backends and the reaper. +const ( + SessionNamePrefix = "code-session-" + SessionAppLabel = "code-interpreter" + SessionComponentLabel = "session" + SessionExpiresAtKey = "code-interpreter.expires-at" +) + +// SessionNotFoundError is returned when a session ID does not refer to an +// existing session. +type SessionNotFoundError struct { + SessionID string +} + +func (e *SessionNotFoundError) Error() string { + return fmt.Sprintf("Session '%s' not found", e.SessionID) +} + +// NotImplementedError is returned by backends that do not support an +// operation (streaming, sessions). The API layer maps it to HTTP 501. +type NotImplementedError struct { + Message string +} + +func (e *NotImplementedError) Error() string { return e.Message } + +// ValidationError marks caller mistakes (bad file paths, reserved names). +// The API layer maps it to HTTP 422, mirroring Python's ValueError handling. +type ValidationError struct { + Message string +} + +func (e *ValidationError) Error() string { return e.Message } + +// ExecOptions carries the parameters of a single Python execution. +type ExecOptions struct { + Code string + Stdin *string + TimeoutMs int + MaxOutputBytes int + // Nil means "no limit" for the two resource limits below. + CPUTimeLimitSec *int + MemoryLimitMB *int + Files []StagedFile + // If true, the last line of code will print its value to stdout when it + // is a bare expression (like Jupyter notebooks or the Python REPL). Only + // the last line is affected. + LastLineInteractive bool +} + +// EmitFunc receives output chunks during a streaming execution. +type EmitFunc func(StreamChunk) + +// Executor is the pluggable execution backend interface. +type Executor interface { + // CheckHealth reports whether the backend is operational. + CheckHealth() HealthCheck + + // ExecutePython runs Python code in an isolated environment. + ExecutePython(opts ExecOptions) (ExecutionResult, error) + + // ExecutePythonStreaming runs Python code, invoking emit for each output + // chunk as it arrives, and returns the final result. + ExecutePythonStreaming(opts ExecOptions, emit EmitFunc) (StreamResult, error) + + // CreateSession creates a long-lived execution environment. The session + // is guaranteed to be torn down at or before ExpiresAt even if this + // process crashes. + CreateSession( + ttlSeconds int, + files []StagedFile, + cpuTimeLimitSec *int, + memoryLimitMB *int, + ) (SessionInfo, error) + + // DeleteSession tears down a session by ID. Returns true if it was found + // and deleted. + DeleteSession(sessionID string) (bool, error) + + // ReapExpiredSessions deletes sessions whose TTL has elapsed and returns + // the number reaped. + ReapExpiredSessions() (int, error) + + // ExecuteBashInSession runs a bash command inside an existing session. + // Returns *SessionNotFoundError when the session does not exist. Network + // restrictions established at session creation remain in force. + ExecuteBashInSession( + sessionID string, + cmd string, + timeoutMs int, + maxOutputBytes int, + ) (ExecutionResult, error) +} + +// TruncateOutput decodes an output stream as UTF-8 (invalid sequences are +// replaced) and truncates it to maxBytes with a trailing marker. +func TruncateOutput(stream []byte, maxBytes int) string { + if len(stream) <= maxBytes { + return strings.ToValidUTF8(string(stream), "�") + } + head := stream[:max(0, maxBytes-32)] + truncated := append(append([]byte{}, head...), []byte("\n...[truncated]")...) + return strings.ToValidUTF8(string(truncated), "�") +} diff --git a/code-interpreter-go/internal/executor/wrap.go b/code-interpreter-go/internal/executor/wrap.go new file mode 100644 index 0000000..3895d3e --- /dev/null +++ b/code-interpreter-go/internal/executor/wrap.go @@ -0,0 +1,45 @@ +package executor + +import "strings" + +// WrapLastLineInteractive wraps user code to execute in last-line-interactive +// mode. +// +// The generated wrapper uses Python's 'single' compilation mode for the last +// expression only, which automatically prints the value to stdout, mimicking +// Jupyter notebook behavior. Only the last line is affected; earlier +// expressions are not printed. +func WrapLastLineInteractive(code string) string { + // Escape the code string for embedding in Python source. + escaped := strings.ReplaceAll(code, `\`, `\\`) + escaped = strings.ReplaceAll(escaped, `'`, `\'`) + + return `import ast +import sys + +# User code +code = '''` + escaped + `''' + +# Parse the code +tree = ast.parse(code) + +# Execute all statements except the last one normally +if len(tree.body) > 0: + for node in tree.body[:-1]: + code_obj = compile(ast.Module(body=[node], type_ignores=[]), '', 'exec') + exec(code_obj) + + # For the last statement, check if it's an expression + last_node = tree.body[-1] + if isinstance(last_node, ast.Expr): + # Execute in 'single' mode to print the result + interactive = ast.Interactive(body=[last_node]) + ast.fix_missing_locations(interactive) + code_obj = compile(interactive, '', 'single') + exec(code_obj) + else: + # Not an expression, execute normally + code_obj = compile(ast.Module(body=[last_node], type_ignores=[]), '', 'exec') + exec(code_obj) +` +} diff --git a/code-interpreter-go/internal/imageref/imageref.go b/code-interpreter-go/internal/imageref/imageref.go new file mode 100644 index 0000000..312f822 --- /dev/null +++ b/code-interpreter-go/internal/imageref/imageref.go @@ -0,0 +1,29 @@ +// Package imageref provides helpers for normalizing Docker image references. +package imageref + +import "strings" + +// Normalize returns ref with an explicit ":latest" tag if it has neither tag +// nor digest. +// +// Docker image references follow the grammar +// [registry[:port]/]repo[:tag|@digest]. A bare repository (repo, owner/repo, +// registry.io/owner/repo) needs an explicit tag for some operations such as +// "docker image inspect". References that already carry a tag (repo:v1) or a +// digest (repo@sha256:…) must be returned unchanged — appending ":latest" to +// either produces an invalid reference. +// +// Registry ports require care: in registry.io:443/owner/repo, the ":" is a +// port separator, not a tag separator. The rule we apply is that ":" is only +// a tag separator when it appears after the rightmost "/". +func Normalize(ref string) string { + if strings.Contains(ref, "@") { + return ref + } + lastSlash := strings.LastIndex(ref, "/") + lastColon := strings.LastIndex(ref, ":") + if lastColon > lastSlash { + return ref + } + return ref + ":latest" +} diff --git a/code-interpreter-go/internal/imageref/imageref_test.go b/code-interpreter-go/internal/imageref/imageref_test.go new file mode 100644 index 0000000..ba6db06 --- /dev/null +++ b/code-interpreter-go/internal/imageref/imageref_test.go @@ -0,0 +1,56 @@ +// Tests for imageref.Normalize, ported from +// tests/integration_tests/test_image_ref.py. +// +// These exercise every shape of Docker image reference we expect operators to +// set in PYTHON_EXECUTOR_DOCKER_IMAGE: bare repositories (":latest" must be +// appended), tagged references and digests (must be unchanged), and +// registry-with-port variants where a ":" before the last "/" is a port +// separator, not a tag separator. +package imageref + +import "testing" + +func TestNormalize(t *testing.T) { + digest := "onyxdotapp/python-executor-sci" + + "@sha256:462c2fb0ed8998b75418d7a3f9d7fb75f61ce4c4605a1468436d5af09b9971b8" + + cases := []struct { + name string + in string + want string + }{ + {"bare repo gets latest", "python-executor-sci", "python-executor-sci:latest"}, + { + "namespaced bare repo gets latest", + "onyxdotapp/python-executor-sci", + "onyxdotapp/python-executor-sci:latest", + }, + {"registry bare repo gets latest", "ghcr.io/owner/repo", "ghcr.io/owner/repo:latest"}, + {"tagged reference unchanged", "python-executor-sci:0.4.0", "python-executor-sci:0.4.0"}, + { + "namespaced tagged unchanged", + "onyxdotapp/python-executor-sci:0.4.0", + "onyxdotapp/python-executor-sci:0.4.0", + }, + {"registry tagged unchanged", "ghcr.io/owner/repo:v1", "ghcr.io/owner/repo:v1"}, + {"digest reference unchanged", digest, digest}, + { + "registry port is not a tag", + "registry.io:443/owner/repo", + "registry.io:443/owner/repo:latest", + }, + { + "registry port with tag unchanged", + "registry.io:443/owner/repo:v2", + "registry.io:443/owner/repo:v2", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := Normalize(tc.in); got != tc.want { + t.Errorf("Normalize(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} diff --git a/code-interpreter-go/internal/logging/logging.go b/code-interpreter-go/internal/logging/logging.go new file mode 100644 index 0000000..890cf2a --- /dev/null +++ b/code-interpreter-go/internal/logging/logging.go @@ -0,0 +1,174 @@ +// Package logging configures process-wide structured logging. It is the Go +// port of app/logging_config.py: a single handler on the default logger, with +// either a plain human-readable format or single-line JSON for container log +// aggregators. +package logging + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "sync" + + "github.com/onyx-dot-app/python-sandbox/code-interpreter-go/internal/config" +) + +// LoggerKey is the attribute used to carry the logger name (the analogue of +// Python's logging.getLogger(__name__)). +const LoggerKey = "logger" + +// Named returns a logger carrying a component name, mirroring the per-module +// loggers of the Python service. +func Named(name string) *slog.Logger { + return slog.Default().With(slog.String(LoggerKey, name)) +} + +// resolveLevel maps a Python-style level name to a slog level. It returns +// (level, wasValid); unknown names fall back to INFO so a typo'd, +// operator-supplied LOG_LEVEL degrades gracefully instead of crashing the +// service at startup. +func resolveLevel(name string) (slog.Level, bool) { + switch name { + case "DEBUG": + return slog.LevelDebug, true + case "INFO": + return slog.LevelInfo, true + case "WARNING", "WARN": + return slog.LevelWarn, true + case "ERROR": + return slog.LevelError, true + case "CRITICAL", "FATAL": + return slog.LevelError + 4, true + } + return slog.LevelInfo, false +} + +// levelName renders slog levels using Python's level names so log output stays +// consistent across the two implementations. +func levelName(l slog.Level) string { + switch { + case l >= slog.LevelError+4: + return "CRITICAL" + case l >= slog.LevelError: + return "ERROR" + case l >= slog.LevelWarn: + return "WARNING" + case l >= slog.LevelInfo: + return "INFO" + default: + return "DEBUG" + } +} + +// Setup installs the configured handler on slog's default logger and returns +// the resolved level. Idempotent: the default logger is replaced, not +// stacked, so repeated calls do not duplicate output. +func Setup(cfg config.Config) slog.Level { + level, levelValid := resolveLevel(cfg.LogLevel) + + var handler slog.Handler + if cfg.JSONLogging() { + handler = NewJSONHandler(os.Stderr, level) + } else { + handler = NewPlainHandler(os.Stderr, level) + } + slog.SetDefault(slog.New(handler)) + + if !levelValid { + slog.Warn(fmt.Sprintf("Unknown LOG_LEVEL %q; falling back to INFO", cfg.LogLevel)) + } + return level +} + +// NewJSONHandler emits structured single-line JSON records with the same +// field names as the Python service (timestamp, level, logger, filename, +// lineno, message). +func NewJSONHandler(w io.Writer, level slog.Level) slog.Handler { + return slog.NewJSONHandler(w, &slog.HandlerOptions{ + Level: level, + AddSource: true, + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if len(groups) > 0 { + return a + } + switch a.Key { + case slog.TimeKey: + a.Key = "timestamp" + a.Value = slog.StringValue(a.Value.Time().Format("2006-01-02T15:04:05-0700")) + case slog.LevelKey: + a.Key = "level" + a.Value = slog.StringValue(levelName(a.Value.Any().(slog.Level))) + case slog.MessageKey: + a.Key = "message" + case slog.SourceKey: + if src, ok := a.Value.Any().(*slog.Source); ok { + // An empty-keyed group is inlined by the handler, which + // yields discrete top-level filename/lineno fields. + return slog.Attr{Key: "", Value: slog.GroupValue( + slog.String("filename", filepath.Base(src.File)), + slog.Int("lineno", src.Line), + )} + } + } + return a + }, + }) +} + +// NewPlainHandler renders records as +// "2006-01-02 15:04:05,000 - logger - LEVEL - message", matching Python's +// "%(asctime)s - %(name)s - %(levelname)s - %(message)s" format. +func NewPlainHandler(w io.Writer, level slog.Level) slog.Handler { + return &plainHandler{w: w, level: level, mu: &sync.Mutex{}} +} + +type plainHandler struct { + w io.Writer + level slog.Level + attrs []slog.Attr + mu *sync.Mutex +} + +func (h *plainHandler) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.level +} + +func (h *plainHandler) Handle(_ context.Context, r slog.Record) error { + name := "app" + extras := "" + collect := func(a slog.Attr) bool { + if a.Key == LoggerKey { + name = a.Value.String() + } else { + extras += fmt.Sprintf(" %s=%v", a.Key, a.Value.Any()) + } + return true + } + for _, a := range h.attrs { + collect(a) + } + r.Attrs(collect) + + ts := r.Time.Format("2006-01-02 15:04:05,000") + h.mu.Lock() + defer h.mu.Unlock() + _, err := fmt.Fprintf( + h.w, "%s - %s - %s - %s%s\n", ts, name, levelName(r.Level), r.Message, extras, + ) + return err +} + +func (h *plainHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + merged := make([]slog.Attr, 0, len(h.attrs)+len(attrs)) + merged = append(merged, h.attrs...) + merged = append(merged, attrs...) + return &plainHandler{w: h.w, level: h.level, attrs: merged, mu: h.mu} +} + +func (h *plainHandler) WithGroup(_ string) slog.Handler { + // Groups are not used by this service; flatten them. + return h +} diff --git a/code-interpreter-go/internal/storage/storage.go b/code-interpreter-go/internal/storage/storage.go new file mode 100644 index 0000000..cbd9dfd --- /dev/null +++ b/code-interpreter-go/internal/storage/storage.go @@ -0,0 +1,179 @@ +// Package storage manages uploaded files with UUID-based storage. It is the +// Go port of app/services/file_storage.py. +package storage + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" +) + +// ErrFileNotFound is returned when a file ID does not exist in storage. +var ErrFileNotFound = errors.New("file not found") + +const metadataSuffix = ".meta.json" + +// FileMetadata describes a stored file. UploadTime is a Unix timestamp with +// fractional seconds, matching the Python service's JSON representation. +type FileMetadata struct { + FileID string `json:"file_id"` + Filename string `json:"filename"` + SizeBytes int64 `json:"size_bytes"` + UploadTime float64 `json:"upload_time"` +} + +// FileStorageService stores file content and metadata side by side in a +// single directory, keyed by UUID. +type FileStorageService struct { + storageDir string +} + +// NewFileStorageService creates the storage directory if needed. +func NewFileStorageService(storageDir string) (*FileStorageService, error) { + if err := os.MkdirAll(storageDir, 0o755); err != nil { + return nil, fmt.Errorf("failed to create storage directory: %w", err) + } + return &FileStorageService{storageDir: storageDir}, nil +} + +func (s *FileStorageService) filePath(fileID string) string { + return filepath.Join(s.storageDir, fileID) +} + +func (s *FileStorageService) metadataPath(fileID string) string { + return filepath.Join(s.storageDir, fileID+metadataSuffix) +} + +// SaveFile stores content under a fresh UUID and returns it. +func (s *FileStorageService) SaveFile(content []byte, filename string) (string, error) { + fileID := uuid.NewString() + + if err := os.WriteFile(s.filePath(fileID), content, 0o644); err != nil { + return "", err + } + + metadata := FileMetadata{ + FileID: fileID, + Filename: filename, + SizeBytes: int64(len(content)), + UploadTime: float64(time.Now().UnixNano()) / 1e9, + } + metaBytes, err := json.Marshal(metadata) + if err != nil { + return "", err + } + if err := os.WriteFile(s.metadataPath(fileID), metaBytes, 0o644); err != nil { + return "", err + } + + return fileID, nil +} + +// GetFile retrieves file content and metadata by ID. Returns ErrFileNotFound +// when the ID does not exist. +func (s *FileStorageService) GetFile(fileID string) ([]byte, FileMetadata, error) { + content, err := os.ReadFile(s.filePath(fileID)) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, FileMetadata{}, fmt.Errorf( + "File with ID '%s' not found: %w", fileID, ErrFileNotFound, + ) + } + return nil, FileMetadata{}, err + } + + metaBytes, err := os.ReadFile(s.metadataPath(fileID)) + if err == nil { + var metadata FileMetadata + if err := json.Unmarshal(metaBytes, &metadata); err == nil { + return content, metadata, nil + } + } + + // Fallback for files without (or with corrupt) metadata. + info, statErr := os.Stat(s.filePath(fileID)) + uploadTime := float64(time.Now().UnixNano()) / 1e9 + if statErr == nil { + uploadTime = float64(info.ModTime().UnixNano()) / 1e9 + } + return content, FileMetadata{ + FileID: fileID, + Filename: "unknown", + SizeBytes: int64(len(content)), + UploadTime: uploadTime, + }, nil +} + +// DeleteFile removes a file and its metadata by ID. Returns true if the file +// existed. +func (s *FileStorageService) DeleteFile(fileID string) (bool, error) { + existed := true + if err := os.Remove(s.filePath(fileID)); err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return false, err + } + existed = false + } + if err := os.Remove(s.metadataPath(fileID)); err != nil && !errors.Is(err, fs.ErrNotExist) { + return existed, err + } + return existed, nil +} + +// ListFiles returns metadata for every stored file, skipping unreadable or +// invalid metadata entries. +func (s *FileStorageService) ListFiles() ([]FileMetadata, error) { + entries, err := os.ReadDir(s.storageDir) + if err != nil { + return nil, err + } + + result := []FileMetadata{} + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), metadataSuffix) { + continue + } + metaBytes, err := os.ReadFile(filepath.Join(s.storageDir, entry.Name())) + if err != nil { + continue + } + var metadata FileMetadata + if err := json.Unmarshal(metaBytes, &metadata); err != nil { + continue + } + result = append(result, metadata) + } + return result, nil +} + +// CleanupExpiredFiles removes files older than maxAge and returns the number +// deleted. +func (s *FileStorageService) CleanupExpiredFiles(maxAge time.Duration) (int, error) { + files, err := s.ListFiles() + if err != nil { + return 0, err + } + + now := float64(time.Now().UnixNano()) / 1e9 + deleted := 0 + for _, metadata := range files { + if now-metadata.UploadTime <= maxAge.Seconds() { + continue + } + existed, err := s.DeleteFile(metadata.FileID) + if err != nil { + continue + } + if existed { + deleted++ + } + } + return deleted, nil +} diff --git a/code-interpreter-go/internal/storage/storage_test.go b/code-interpreter-go/internal/storage/storage_test.go new file mode 100644 index 0000000..534bede --- /dev/null +++ b/code-interpreter-go/internal/storage/storage_test.go @@ -0,0 +1,141 @@ +package storage + +import ( + "bytes" + "errors" + "testing" + "time" +) + +func newTestStorage(t *testing.T) *FileStorageService { + t.Helper() + s, err := NewFileStorageService(t.TempDir()) + if err != nil { + t.Fatalf("NewFileStorageService: %v", err) + } + return s +} + +func TestSaveAndGetFile(t *testing.T) { + s := newTestStorage(t) + content := []byte("hello world") + + fileID, err := s.SaveFile(content, "greeting.txt") + if err != nil { + t.Fatalf("SaveFile: %v", err) + } + + got, metadata, err := s.GetFile(fileID) + if err != nil { + t.Fatalf("GetFile: %v", err) + } + if !bytes.Equal(got, content) { + t.Errorf("content = %q, want %q", got, content) + } + if metadata.Filename != "greeting.txt" { + t.Errorf("filename = %q, want greeting.txt", metadata.Filename) + } + if metadata.SizeBytes != int64(len(content)) { + t.Errorf("size = %d, want %d", metadata.SizeBytes, len(content)) + } + if metadata.UploadTime <= 0 { + t.Errorf("upload_time = %f, want > 0", metadata.UploadTime) + } +} + +func TestSaveBinaryFileIntegrity(t *testing.T) { + s := newTestStorage(t) + content := make([]byte, 4096) + for i := range content { + content[i] = byte(i % 251) + } + + fileID, err := s.SaveFile(content, "blob.bin") + if err != nil { + t.Fatalf("SaveFile: %v", err) + } + got, _, err := s.GetFile(fileID) + if err != nil { + t.Fatalf("GetFile: %v", err) + } + if !bytes.Equal(got, content) { + t.Error("binary content corrupted on round-trip") + } +} + +func TestGetFileNotFound(t *testing.T) { + s := newTestStorage(t) + _, _, err := s.GetFile("nonexistent-id") + if !errors.Is(err, ErrFileNotFound) { + t.Errorf("err = %v, want ErrFileNotFound", err) + } +} + +func TestDeleteFile(t *testing.T) { + s := newTestStorage(t) + fileID, err := s.SaveFile([]byte("x"), "f.txt") + if err != nil { + t.Fatalf("SaveFile: %v", err) + } + + deleted, err := s.DeleteFile(fileID) + if err != nil || !deleted { + t.Fatalf("DeleteFile = (%v, %v), want (true, nil)", deleted, err) + } + if _, _, err := s.GetFile(fileID); !errors.Is(err, ErrFileNotFound) { + t.Error("file still readable after delete") + } + + deleted, err = s.DeleteFile(fileID) + if err != nil || deleted { + t.Errorf("second DeleteFile = (%v, %v), want (false, nil)", deleted, err) + } +} + +func TestListFiles(t *testing.T) { + s := newTestStorage(t) + if files, err := s.ListFiles(); err != nil || len(files) != 0 { + t.Fatalf("empty ListFiles = (%v, %v)", files, err) + } + + id1, _ := s.SaveFile([]byte("a"), "a.txt") + id2, _ := s.SaveFile([]byte("bb"), "b.txt") + + files, err := s.ListFiles() + if err != nil { + t.Fatalf("ListFiles: %v", err) + } + if len(files) != 2 { + t.Fatalf("len = %d, want 2", len(files)) + } + seen := map[string]bool{} + for _, f := range files { + seen[f.FileID] = true + } + if !seen[id1] || !seen[id2] { + t.Errorf("ListFiles missing ids: %v", seen) + } +} + +func TestCleanupExpiredFiles(t *testing.T) { + s := newTestStorage(t) + _, _ = s.SaveFile([]byte("old"), "old.txt") + + // Nothing is older than an hour. + deleted, err := s.CleanupExpiredFiles(time.Hour) + if err != nil || deleted != 0 { + t.Fatalf("CleanupExpiredFiles(1h) = (%d, %v), want (0, nil)", deleted, err) + } + + // With a zero max age everything already stored has expired. + time.Sleep(10 * time.Millisecond) + deleted, err = s.CleanupExpiredFiles(0) + if err != nil || deleted != 1 { + t.Fatalf("CleanupExpiredFiles(0) = (%d, %v), want (1, nil)", deleted, err) + } + + files, _ := s.ListFiles() + if len(files) != 0 { + t.Errorf("files remain after cleanup: %v", files) + } +}