mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
feat: Sandboxed function execution via FUNCTIONS_DRIVER=docker
Each invoke runs in an ephemeral locked-down container — no network, read-only rootfs, 128MiB/0.5CPU/64-pid caps, non-root, cap-drop ALL. Source travels as base64 in the container env and materializes in the tmpfs, so no bind mount is needed (Docker Desktop / remote daemons work). Timeout force-removes the named container. exec driver stays the default; the trust-model docs now describe both. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
edef002beb
commit
9ae1c22af0
8 files changed
+246
-9
No files matched your search
@@ -65,6 +65,16 @@ BACKEND_STORAGE_DRIVER=local
|
|||||||
# can't reach. Presigned URLs are valid only at the host they were minted for.
|
# can't reach. Presigned URLs are valid only at the host they were minted for.
|
||||||
#S3_PUBLIC_ENDPOINT=https://objects.example.com
|
#S3_PUBLIC_ENDPOINT=https://objects.example.com
|
||||||
|
|
||||||
|
# Functions execution. "exec" runs the runtime binary (bun/deno) directly on
|
||||||
|
# the backend host — simple, but function code inherits backend privileges.
|
||||||
|
# "docker" runs each invoke in an ephemeral locked-down container (no
|
||||||
|
# network, read-only fs, 128 MiB mem, non-root) and requires the Docker
|
||||||
|
# socket mounted into the backend plus the runtime images pulled.
|
||||||
|
#FUNCTIONS_DRIVER=exec
|
||||||
|
#FUNCTIONS_RUNTIME=auto
|
||||||
|
#FUNCTIONS_TIMEOUT_SECONDS=30
|
||||||
|
#FUNCTIONS_MAX_OUTPUT_BYTES=65536
|
||||||
|
|
||||||
# Mail defaults — each is overridable in-app (Settings → Instance). In-app
|
# Mail defaults — each is overridable in-app (Settings → Instance). In-app
|
||||||
# values win over these env vars; deleting the in-app value reverts here.
|
# values win over these env vars; deleting the in-app value reverts here.
|
||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac
|
|||||||
- **Presigned object URLs** — `POST /buckets/:bucketID/object-presigns` mints SigV4 query-signed `GET`/`PUT` URLs (60 s–1 h TTL) so clients transfer large objects directly against S3 without proxying through the backend. Read role for downloads (object must exist — no key probing), write role for uploads. `S3_PUBLIC_ENDPOINT` rewrites the client-facing host for internal `S3_ENDPOINT`s; signatures bind the host so URLs are valid only where they were minted. The local driver returns a clear error — a presigned URL needs a real object API.
|
- **Presigned object URLs** — `POST /buckets/:bucketID/object-presigns` mints SigV4 query-signed `GET`/`PUT` URLs (60 s–1 h TTL) so clients transfer large objects directly against S3 without proxying through the backend. Read role for downloads (object must exist — no key probing), write role for uploads. `S3_PUBLIC_ENDPOINT` rewrites the client-facing host for internal `S3_ENDPOINT`s; signatures bind the host so URLs are valid only where they were minted. The local driver returns a clear error — a presigned URL needs a real object API.
|
||||||
- **Desktop auto-updater** — `tauri-plugin-updater` wired end to end: minisign keypair, `createUpdaterArtifacts`, `latest.json` manifest built from signed artifacts on every release, and a silent background check on the connect screen offering "Update & restart" (download → verify → install → relaunch). Unsigned artifacts can never install — missing signature means no manifest, not a bypass. CI signs via `TAURI_SIGNING_PRIVATE_KEY` secrets; OS-level signing (notarization/Authenticode) remains open.
|
- **Desktop auto-updater** — `tauri-plugin-updater` wired end to end: minisign keypair, `createUpdaterArtifacts`, `latest.json` manifest built from signed artifacts on every release, and a silent background check on the connect screen offering "Update & restart" (download → verify → install → relaunch). Unsigned artifacts can never install — missing signature means no manifest, not a bypass. CI signs via `TAURI_SIGNING_PRIVATE_KEY` secrets; OS-level signing (notarization/Authenticode) remains open.
|
||||||
- **Release SBOMs** — CycloneDX SBOM for each published GHCR image (backend/auth/frontend) plus one for the source tree, attached to every GitHub Release via `anchore/sbom-action`.
|
- **Release SBOMs** — CycloneDX SBOM for each published GHCR image (backend/auth/frontend) plus one for the source tree, attached to every GitHub Release via `anchore/sbom-action`.
|
||||||
|
- **Sandboxed function driver** — `FUNCTIONS_DRIVER=docker` runs every invoke in an ephemeral locked-down container: no network, read-only rootfs, 128 MiB memory, 0.5 CPU, 64 pids, non-root, all capabilities dropped. Source is injected via base64 env (no bind mount — works on Docker Desktop and remote daemons); timeout force-removes the container so nothing lingers. Verified live: code runs, network is unreachable inside, and deadline kills land.
|
||||||
- **Function triggers** (migration 00016) — three new ways to run a function, all through the same executor and run log. Scheduled jobs accept `function_id` instead of `url` (run status mirrors the exit code); inbound hooks gain `mode: "function"` — the received body becomes the function's stdin payload, acked immediately with the run recorded async; and `event_pattern` on a function (`document.*`, `*`, or an exact type) fires it detached whenever a matching domain event publishes — `{event, project_id, occurred_at, data}` as payload. Runs record trigger source: `manual`/`schedule`/`hook`/`event`. Dashboard: function event-trigger field, job target picker (URL or function), and inbound-hook function mode.
|
- **Function triggers** (migration 00016) — three new ways to run a function, all through the same executor and run log. Scheduled jobs accept `function_id` instead of `url` (run status mirrors the exit code); inbound hooks gain `mode: "function"` — the received body becomes the function's stdin payload, acked immediately with the run recorded async; and `event_pattern` on a function (`document.*`, `*`, or an exact type) fires it detached whenever a matching domain event publishes — `{event, project_id, occurred_at, data}` as payload. Runs record trigger source: `manual`/`schedule`/`hook`/`event`. Dashboard: function event-trigger field, job target picker (URL or function), and inbound-hook function mode.
|
||||||
|
|
||||||
## [0.7.1] - 2026-09-23
|
## [0.7.1] - 2026-09-23
|
||||||
|
|||||||
+16
-6
@@ -47,7 +47,8 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
|||||||
| `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | S3 credentials | with `s3` |
|
| `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | S3 credentials | with `s3` |
|
||||||
| `S3_PREFIX` / `S3_PATH_STYLE` | optional key prefix; path-style URLs (default `true`, needed by MinIO/Garage — set `false` for AWS virtual-hosted) | no |
|
| `S3_PREFIX` / `S3_PATH_STYLE` | optional key prefix; path-style URLs (default `true`, needed by MinIO/Garage — set `false` for AWS virtual-hosted) | no |
|
||||||
| `S3_PUBLIC_ENDPOINT` | client-facing S3 endpoint used for presigned URLs when `S3_ENDPOINT` is internal-only (e.g. `http://minio:9000`); signatures bind the public host | no |
|
| `S3_PUBLIC_ENDPOINT` | client-facing S3 endpoint used for presigned URLs when `S3_ENDPOINT` is internal-only (e.g. `http://minio:9000`); signatures bind the public host | no |
|
||||||
| `FUNCTIONS_RUNTIME` / `FUNCTIONS_TIMEOUT_SECONDS` / `FUNCTIONS_MAX_OUTPUT_BYTES` | Functions: runtime binary (`auto` resolves `bun`/`deno` from the backend's PATH — the backend image must ship one, or set this to its path), per-run timeout (default 30 s), captured-output cap (default 64 KiB) | no |
|
| `FUNCTIONS_DRIVER` | Function execution driver: `exec` (default — runtime binary on the backend host; code runs with backend privileges) or `docker` — each invoke runs in an ephemeral locked-down container (no network, read-only fs, 128 MiB, non-root). Docker mode needs `/var/run/docker.sock` mounted into the backend (see the commented volume in `docker-compose.yml`) and the runtime images pulled (`oven/bun`, `denoland/deno`). Source code is capped at 256 KiB under the docker driver | no |
|
||||||
|
| `FUNCTIONS_RUNTIME` / `FUNCTIONS_TIMEOUT_SECONDS` / `FUNCTIONS_MAX_OUTPUT_BYTES` | Functions: runtime binary (`auto` resolves `bun`/`deno` — exec driver only; docker driver uses the matching official image), per-run timeout (default 30 s), captured-output cap (default 64 KiB) | no |
|
||||||
| `AUTH_ADMIN_EMAILS` | comma-separated emails promoted to auth admin on boot | no |
|
| `AUTH_ADMIN_EMAILS` | comma-separated emails promoted to auth admin on boot | no |
|
||||||
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | per-identity API rate limits (defaults 240 / 600) | no |
|
| `USER_RATE_LIMIT_PER_MINUTE` / `API_KEY_RATE_LIMIT_PER_MINUTE` | per-identity API rate limits (defaults 240 / 600) | no |
|
||||||
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` / `SMTP_SECURE` / `MAIL_FROM` | transactional mail; without it verification/reset emails go nowhere | production |
|
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` / `SMTP_SECURE` / `MAIL_FROM` | transactional mail; without it verification/reset emails go nowhere | production |
|
||||||
@@ -140,11 +141,20 @@ variables above (AWS, MinIO, Garage, R2 all work).
|
|||||||
|
|
||||||
## Functions trust model
|
## Functions trust model
|
||||||
|
|
||||||
Function code runs as a child process of the backend — same host, same
|
Two drivers, two trust levels:
|
||||||
privileges, no sandbox. Treat every project member who can edit a function
|
|
||||||
as holding a shell on the backend host. If that is unacceptable for your
|
- **`exec` (default)** — function code runs as a child process of the
|
||||||
deployment, do not enable untrusted editors; isolated runtimes are on the
|
backend: same host, same privileges, no sandbox. Treat every project
|
||||||
roadmap.
|
member who can edit a function as holding a shell on the backend host.
|
||||||
|
Appropriate for single-team self-hosted deployments.
|
||||||
|
- **`docker`** — each invoke runs in an ephemeral container: no network,
|
||||||
|
read-only rootfs, 128 MiB memory, half a CPU, 64 pids, non-root, all
|
||||||
|
capabilities dropped. Requires the Docker socket mounted into the
|
||||||
|
backend (commented volume in `docker-compose.yml`) — note the socket is
|
||||||
|
itself root-equivalent on the host, so this isolates *function code*,
|
||||||
|
not operators. Source code capped at 256 KiB. For hostile multi-tenant
|
||||||
|
workloads, a dedicated microVM runtime (Firecracker/gVisor) remains the
|
||||||
|
roadmap item.
|
||||||
|
|
||||||
## Observability
|
## Observability
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,13 @@ func Bootstrap(ctx context.Context) (*App, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("encryption key: %w", err)
|
return nil, fmt.Errorf("encryption key: %w", err)
|
||||||
}
|
}
|
||||||
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger, services.NewExecRunner(cfg.FunctionsRuntime, cfg.FunctionsTimeout, cfg.FunctionsMaxBytes))
|
var fnRunner services.FunctionRunner
|
||||||
|
if cfg.FunctionsDriver == "docker" {
|
||||||
|
fnRunner = services.NewDockerRunner(cfg.FunctionsTimeout, cfg.FunctionsMaxBytes)
|
||||||
|
} else {
|
||||||
|
fnRunner = services.NewExecRunner(cfg.FunctionsRuntime, cfg.FunctionsTimeout, cfg.FunctionsMaxBytes)
|
||||||
|
}
|
||||||
|
platform := services.NewPlatformService(repo, store, services.NewMailer(cfg), os.Getenv("VITE_APP_URL"), dbxClient, encryptor, logger, fnRunner)
|
||||||
settings := services.NewSettingsService(repo, encryptor)
|
settings := services.NewSettingsService(repo, encryptor)
|
||||||
|
|
||||||
if cfg.Env == "production" {
|
if cfg.Env == "production" {
|
||||||
|
|||||||
@@ -48,8 +48,11 @@ type Config struct {
|
|||||||
S3Prefix string
|
S3Prefix string
|
||||||
S3PathStyle bool
|
S3PathStyle bool
|
||||||
|
|
||||||
// Functions runtime: binary name/path ("auto" resolves per-function
|
// Functions runtime: driver "exec" (runtime binary on the backend host)
|
||||||
// runtime from PATH — "bun" or "deno"), timeout and output cap.
|
// or "docker" (ephemeral locked-down container), binary name/path for
|
||||||
|
// exec ("auto" resolves per-function runtime from PATH), timeout and
|
||||||
|
// output cap.
|
||||||
|
FunctionsDriver string
|
||||||
FunctionsRuntime string
|
FunctionsRuntime string
|
||||||
FunctionsTimeout time.Duration
|
FunctionsTimeout time.Duration
|
||||||
FunctionsMaxBytes int64
|
FunctionsMaxBytes int64
|
||||||
@@ -89,6 +92,7 @@ func Load() (Config, error) {
|
|||||||
S3SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"),
|
S3SecretAccessKey: os.Getenv("S3_SECRET_ACCESS_KEY"),
|
||||||
S3PublicEndpoint: os.Getenv("S3_PUBLIC_ENDPOINT"),
|
S3PublicEndpoint: os.Getenv("S3_PUBLIC_ENDPOINT"),
|
||||||
S3Prefix: os.Getenv("S3_PREFIX"),
|
S3Prefix: os.Getenv("S3_PREFIX"),
|
||||||
|
FunctionsDriver: getenv("FUNCTIONS_DRIVER", "exec"),
|
||||||
FunctionsRuntime: getenv("FUNCTIONS_RUNTIME", "auto"),
|
FunctionsRuntime: getenv("FUNCTIONS_RUNTIME", "auto"),
|
||||||
FunctionsMaxBytes: 65536,
|
FunctionsMaxBytes: 65536,
|
||||||
}
|
}
|
||||||
@@ -135,6 +139,12 @@ func Load() (Config, error) {
|
|||||||
}
|
}
|
||||||
cfg.FunctionsTimeout = time.Duration(fnTimeout) * time.Second
|
cfg.FunctionsTimeout = time.Duration(fnTimeout) * time.Second
|
||||||
|
|
||||||
|
switch cfg.FunctionsDriver {
|
||||||
|
case "exec", "docker":
|
||||||
|
default:
|
||||||
|
return Config{}, fmt.Errorf("FUNCTIONS_DRIVER %q must be exec or docker", cfg.FunctionsDriver)
|
||||||
|
}
|
||||||
|
|
||||||
var missing []string
|
var missing []string
|
||||||
if cfg.DatabaseURL == "" {
|
if cfg.DatabaseURL == "" {
|
||||||
missing = append(missing, "DATABASE_URL")
|
missing = append(missing, "DATABASE_URL")
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dockerRunner executes functions in an ephemeral, locked-down container:
|
||||||
|
// no network, read-only rootfs, memory/CPU/pid caps, non-root, all
|
||||||
|
// capabilities dropped. The source is passed through the environment as
|
||||||
|
// base64 and materialized inside the container's tmpfs — no bind mount,
|
||||||
|
// so this works on Docker Desktop (VM daemon) and remote daemons where
|
||||||
|
// host paths aren't shared. The payload arrives on stdin — same contract
|
||||||
|
// as execRunner, but user code no longer inherits backend privileges.
|
||||||
|
// Requires the Docker socket (compose: mount /var/run/docker.sock) — the
|
||||||
|
// documented trade-off: socket access is root-equivalent on the host, so
|
||||||
|
// this driver isolates function code, not operators.
|
||||||
|
type dockerRunner struct {
|
||||||
|
timeout time.Duration
|
||||||
|
maxOutput int64
|
||||||
|
images map[string]string // runtime -> image ref
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxFnSourceBytes caps function source for the docker driver — the source
|
||||||
|
// travels in the container spec (env), which kernels limit to ~2 MB total;
|
||||||
|
// 256 KiB keeps argv+env comfortably inside it.
|
||||||
|
const maxFnSourceBytes = 256 << 10
|
||||||
|
|
||||||
|
func NewDockerRunner(timeout time.Duration, maxOutput int64) *dockerRunner {
|
||||||
|
return &dockerRunner{
|
||||||
|
timeout: timeout,
|
||||||
|
maxOutput: maxOutput,
|
||||||
|
images: map[string]string{
|
||||||
|
"bun": "oven/bun:1.3.14",
|
||||||
|
"deno": "denoland/deno:2.5.6",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *dockerRunner) Run(ctx context.Context, runtime, code string, payload json.RawMessage) FunctionResult {
|
||||||
|
image, ok := r.images[runtime]
|
||||||
|
if !ok {
|
||||||
|
return FunctionResult{Status: "error", Stderr: "no container image configured for runtime " + runtime}
|
||||||
|
}
|
||||||
|
if len(code) > maxFnSourceBytes {
|
||||||
|
return FunctionResult{Status: "error", Stderr: fmt.Sprintf("function source exceeds %d KiB docker-driver limit", maxFnSourceBytes>>10)}
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("docker"); err != nil {
|
||||||
|
return FunctionResult{Status: "error", Stderr: "FUNCTIONS_DRIVER=docker but docker CLI not found"}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, r.timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Named container so a timed-out client doesn't orphan a running fn.
|
||||||
|
var suffix [4]byte
|
||||||
|
_, _ = rand.Read(suffix[:])
|
||||||
|
name := "primora-fn-" + hex.EncodeToString(suffix[:])
|
||||||
|
|
||||||
|
// Write source inside the container, then run it. base64 keeps the
|
||||||
|
// code inert data — it is decoded by the shell, never interpreted.
|
||||||
|
entrypoint := fmt.Sprintf(
|
||||||
|
`echo "$PRIMORA_CODE" | base64 -d > /tmp/fn.ts && %s /tmp/fn.ts`,
|
||||||
|
map[bool]string{true: "deno run --no-prompt", false: "bun run"}[runtime == "deno"],
|
||||||
|
)
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"run", "--rm", "-i", "--name", name,
|
||||||
|
"--network", "none",
|
||||||
|
"--memory", "128m", "--memory-swap", "128m",
|
||||||
|
"--cpus", "0.5",
|
||||||
|
"--pids-limit", "64",
|
||||||
|
"--read-only",
|
||||||
|
"--tmpfs", "/tmp:rw,noexec,nosuid,size=16m",
|
||||||
|
"--cap-drop", "ALL",
|
||||||
|
"--security-opt", "no-new-privileges",
|
||||||
|
"--user", "65534:65534",
|
||||||
|
"-e", "PRIMORA_CODE=" + base64.StdEncoding.EncodeToString([]byte(code)),
|
||||||
|
"-e", "PRIMORA_PAYLOAD=" + string(payload),
|
||||||
|
"-e", "PRIMORA_FUNCTION_RUNTIME=" + runtime,
|
||||||
|
"--entrypoint", "sh",
|
||||||
|
image, "-c", entrypoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||||
|
cmd.Stdin = bytes.NewReader(payload)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &limitedWriter{w: &stdout, max: r.maxOutput}
|
||||||
|
cmd.Stderr = &limitedWriter{w: &stderr, max: r.maxOutput}
|
||||||
|
|
||||||
|
// ctx cancel kills the docker CLI but the daemon keeps the container
|
||||||
|
// running — force-remove it so a stuck function can't linger.
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = exec.Command("docker", "rm", "-f", name).Run()
|
||||||
|
case <-done:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
defer close(done)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
runErr := cmd.Run()
|
||||||
|
duration := time.Since(start).Milliseconds()
|
||||||
|
|
||||||
|
res := FunctionResult{
|
||||||
|
Status: "success",
|
||||||
|
Stdout: stdout.String(),
|
||||||
|
Stderr: stderr.String(),
|
||||||
|
DurationMs: duration,
|
||||||
|
}
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
res.Status = "timeout"
|
||||||
|
res.Stderr = strings.TrimSpace(res.Stderr + "\nexecution timed out")
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
if runErr != nil {
|
||||||
|
res.Status = "error"
|
||||||
|
if exitErr, ok := runErr.(*exec.ExitError); ok {
|
||||||
|
code := int32(exitErr.ExitCode())
|
||||||
|
res.ExitCode = &code
|
||||||
|
} else {
|
||||||
|
res.Stderr = strings.TrimSpace(fmt.Sprintf("%v\n%s", runErr, res.Stderr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func dockerAvailable(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := exec.LookPath("docker"); err != nil {
|
||||||
|
t.Skip("docker CLI not installed")
|
||||||
|
}
|
||||||
|
if err := exec.Command("docker", "info").Run(); err != nil {
|
||||||
|
t.Skip("docker daemon not reachable")
|
||||||
|
}
|
||||||
|
// The test pulls the runtime image on first run.
|
||||||
|
if err := exec.Command("docker", "image", "inspect", "oven/bun:1.3.14").Run(); err != nil {
|
||||||
|
t.Skip("oven/bun:1.3.14 image not pulled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerRunnerRunsCode(t *testing.T) {
|
||||||
|
dockerAvailable(t)
|
||||||
|
r := NewDockerRunner(30*time.Second, 65536)
|
||||||
|
res := r.Run(context.Background(), "bun",
|
||||||
|
`const p = JSON.parse(await new Response(Bun.stdin.stream()).text()); console.log("hi " + p.name);`,
|
||||||
|
json.RawMessage(`{"name":"sandbox"}`))
|
||||||
|
if res.Status != "success" {
|
||||||
|
t.Fatalf("status = %q stderr = %q", res.Status, res.Stderr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.Stdout, "hi sandbox") {
|
||||||
|
t.Fatalf("stdout = %q", res.Stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerRunnerIsolatesNetwork(t *testing.T) {
|
||||||
|
dockerAvailable(t)
|
||||||
|
r := NewDockerRunner(30*time.Second, 65536)
|
||||||
|
// --network none: even a DNS/connect attempt must fail inside.
|
||||||
|
res := r.Run(context.Background(), "bun",
|
||||||
|
`try { await fetch("http://169.254.169.254/"); console.log("NET-OPEN") } catch { console.log("NET-CLOSED") }`,
|
||||||
|
json.RawMessage(`{}`))
|
||||||
|
if !strings.Contains(res.Stdout, "NET-CLOSED") {
|
||||||
|
t.Fatalf("network should be unreachable, stdout = %q stderr = %q status = %q", res.Stdout, res.Stderr, res.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDockerRunnerTimesOut(t *testing.T) {
|
||||||
|
dockerAvailable(t)
|
||||||
|
r := NewDockerRunner(2*time.Second, 65536)
|
||||||
|
res := r.Run(context.Background(), "bun", `while (true) {}`, json.RawMessage(`{}`))
|
||||||
|
if res.Status != "timeout" {
|
||||||
|
t.Fatalf("status = %q", res.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,10 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
- backend_storage:/data/storage
|
- backend_storage:/data/storage
|
||||||
|
# Uncomment for FUNCTIONS_DRIVER=docker — sibling containers for
|
||||||
|
# sandboxed function runs. Socket access is root-equivalent on the
|
||||||
|
# host; leave commented unless you run untrusted function code.
|
||||||
|
# - /var/run/docker.sock:/var/run/docker.sock
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/v1/health/liveness || exit 1"]
|
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/v1/health/liveness || exit 1"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
|
|||||||
Reference in new issue
Block a user