Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e824b97894 | |||
| af69ed3442 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -56,3 +56,6 @@ internal/dashboard/static/*
|
||||
# Added by code-review-graph
|
||||
.code-review-graph/
|
||||
.mcp.json
|
||||
|
||||
# tokensave
|
||||
.tokensave/
|
||||
|
||||
20
CLAUDE.md
20
CLAUDE.md
@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
Wrenn is an open-source, self-hosted dev environment platform. Users spin up isolated sandboxes (Cloud Hypervisor microVMs), run code inside them, and get output back via SDKs. Fast boot, persistent state, and a single agent binary on each host you own.
|
||||
Wrenn is the runtime where AI engineers live — an open-source platform for running AI coding agents. Each project gets a persistent, isolated workspace (Cloud Hypervisor microVM) where agents edit code, run tests, debug, and ship continuously, with humans supervising via SDKs and chat surfaces. Fast boot, persistent state, available hosted or fully self-hosted with a single agent binary on each host you own. (The underlying primitive is still an isolated microVM — "sandbox" in the API/backend, "capsule" in the dashboard.)
|
||||
|
||||
## Build & Development Commands
|
||||
|
||||
@ -83,7 +83,7 @@ It can optionally implement any of these hook interfaces (the OSS server type-as
|
||||
|
||||
**Auth helpers for extensions** (`pkg/auth/session/middleware/`, re-exported via `cpextension`): `RequireSession(sctx)`, `RequireSessionOrAPIKey(sctx)`, `RequireAdmin(sctx)`, `RequireCSRF()`, `IssueSession(w, r, sctx, userID, teamID)`, `ClearSessionCookies(w, r)`. Cookie/header names are exported as `SessionCookieName`, `CSRFCookieName`, `CSRFHeaderName`. OSS handlers (`internal/api/middleware_session.go`) are thin shims over this package — single source of truth.
|
||||
|
||||
**pkg/ vs internal/ decision rule:** A package belongs in `pkg/` only if the cloud repo needs to import it directly. Everything else stays in `internal/`. New OSS services (e.g. email, notifications) go in `internal/` — the cloud repo accesses them through `ServerContext`, not by importing the package. Do not put a service in `pkg/` just because the cloud repo uses it.
|
||||
**pkg/ vs internal/ decision rule:** A package belongs in `pkg/` only if an external module needs to import it directly — the cloud repo, or standalone consumers of the sandbox runtime (e.g. the `wr` CLI, which embeds the host agent's orchestration packages to run sandboxes without a control plane). Everything else stays in `internal/`. New OSS services (e.g. email, notifications) go in `internal/` — the cloud repo accesses them through `ServerContext`, not by importing the package. Do not put a service in `pkg/` just because the cloud repo uses it.
|
||||
|
||||
Startup (`cmd/control-plane/main.go`) is a thin wrapper: `cpserver.Run(cpserver.WithVersion(...))`. All 20 initialization steps live in `pkg/cpserver/run.go`: config → pgxpool → `db.Queries` → Redis → mTLS CA → host client pool → scheduler → OAuth → channels → audit logger → `api.New()` → background workers → HTTP server. Everything flows through constructor injection.
|
||||
|
||||
@ -95,18 +95,20 @@ Startup (`cmd/control-plane/main.go`) is a thin wrapper: `cpserver.Run(cpserver.
|
||||
|
||||
### Host Agent
|
||||
|
||||
**Packages:** `internal/hostagent/`, `internal/sandbox/`, `internal/vm/`, `internal/network/`, `internal/devicemapper/`, `internal/envdclient/`, `internal/snapshot/`
|
||||
**Packages:** `internal/hostagent/` (CP-facing: RPC server, registration, heartbeat, mTLS, proxy), plus the public sandbox runtime importable by external consumers such as the `wr` CLI: `pkg/sandbox/`, `pkg/vm/`, `pkg/network/`, `pkg/devicemapper/`, `pkg/envdclient/`, `pkg/layout/`, `pkg/models/`. `internal/snapshot/` stays internal (only used by `pkg/sandbox`, which may import it intra-module).
|
||||
|
||||
**Standalone/library use:** `pkg/sandbox` exposes the full startup ritual (`CheckPrivileges`, `EnsureIPForward`, `Setup(SetupOptions)`) so a CLI can wire a `sandbox.Manager` without the RPC server. A library consumer that re-attaches to sandboxes created by earlier processes must set `SetupOptions.SkipCleanup` — the default stale-cleanup kills every cloud-hypervisor process on the host. Running sandboxes persist re-attach state to `{wrennDir}/sandboxes/{id}/sandbox.json`; `Manager.RestoreRunningSandboxes()` (call before `RestorePausedSandboxes()`) re-attaches live VMs across processes. Network slots are claimed cross-process-safely via O_EXCL files in `{wrennDir}/slots/`.
|
||||
|
||||
**Production deployment:** `make setup-host` (→ `scripts/setup-host.sh`) prepares the host: creates the `wrenn` system user, sets Linux capabilities (setcap) on wrenn-agent and all child binaries (iptables, losetup, dmsetup, etc.), installs an apt hook to restore capabilities after package updates, configures udev rules for `/dev/net/tun`, and loads required kernel modules. No sudo grants — all privilege is via capabilities. `make install` then copies the binaries to `/usr/local/bin` and installs the systemd units from `deploy/systemd/`.
|
||||
|
||||
Startup (`cmd/host-agent/main.go`) wires: root/capabilities check → enable IP forwarding → clean up stale dm devices → `sandbox.Manager` (containing `vm.Manager` + `network.SlotAllocator` + `devicemapper.LoopRegistry`) → `hostagent.Server` (Connect RPC handler) → HTTP server.
|
||||
|
||||
- **RPC Server** (`internal/hostagent/server.go`): implements `hostagentv1connect.HostAgentServiceHandler`. Thin wrapper — every method delegates to `sandbox.Manager`. Maps Connect error codes on return.
|
||||
- **Sandbox Manager** (`internal/sandbox/manager.go`): the core orchestration layer. Maintains in-memory state in `boxes map[string]*sandboxState` (protected by `sync.RWMutex`). Each `sandboxState` holds a `models.Sandbox`, a `*network.Slot`, and an `*envdclient.Client`. Runs a TTL reaper (every 10s) that auto-destroys timed-out sandboxes.
|
||||
- **VM Manager** (`internal/vm/manager.go`, `ch.go`, `config.go`): manages Cloud Hypervisor processes. Uses raw HTTP API over Unix socket (`/tmp/ch-{sandboxID}.sock`). Launches Cloud Hypervisor via `unshare -m` + `ip netns exec` with `--api-socket path=...`. Configures and boots VM via `PUT /vm.create` + `PUT /vm.boot`. Snapshot restore uses `--restore source_url=file://...`.
|
||||
- **Network** (`internal/network/setup.go`, `allocator.go`): per-sandbox network namespace with veth pair + TAP device. See Networking section below.
|
||||
- **Device Mapper** (`internal/devicemapper/devicemapper.go`): CoW rootfs via device-mapper snapshots. Shared read-only loop devices per base template (refcounted `LoopRegistry`), per-sandbox sparse CoW files, dm-snapshot create/restore/remove/flatten operations.
|
||||
- **envd Client** (`internal/envdclient/client.go`, `health.go`): dual interface to the guest agent. Connect RPC for streaming process exec (`process.Start()` bidirectional stream). Plain HTTP for file operations (POST/GET `/files?path=...&username=root`). Health check polls `GET /health` every 100ms until ready (30s timeout).
|
||||
- **Sandbox Manager** (`pkg/sandbox/manager.go`): the core orchestration layer. Maintains in-memory state in `boxes map[string]*sandboxState` (protected by `sync.RWMutex`). Each `sandboxState` holds a `models.Sandbox`, a `*network.Slot`, and an `*envdclient.Client`. Runs a TTL reaper (every 10s) that auto-destroys timed-out sandboxes.
|
||||
- **VM Manager** (`pkg/vm/manager.go`, `ch.go`, `config.go`): manages Cloud Hypervisor processes. Uses raw HTTP API over Unix socket (`/tmp/ch-{sandboxID}.sock`). Launches Cloud Hypervisor via `unshare -m` + `ip netns exec` with `--api-socket path=...`. Configures and boots VM via `PUT /vm.create` + `PUT /vm.boot`. Snapshot restore uses `--restore source_url=file://...`. `Reattach()` adopts a still-live CH process started by an earlier process (exit detection via kill-0 polling).
|
||||
- **Network** (`pkg/network/setup.go`, `allocator.go`): per-sandbox network namespace with veth pair + TAP device. Slot indices claimed cross-process via O_EXCL files. See Networking section below.
|
||||
- **Device Mapper** (`pkg/devicemapper/devicemapper.go`): CoW rootfs via device-mapper snapshots. Shared read-only loop devices per base template (refcounted `LoopRegistry`), per-sandbox sparse CoW files, dm-snapshot create/restore/remove/flatten/reattach operations.
|
||||
- **envd Client** (`pkg/envdclient/client.go`, `health.go`): dual interface to the guest agent. Connect RPC for streaming process exec (`process.Start()` bidirectional stream). Plain HTTP for file operations (POST/GET `/files?path=...&username=root`). Health check polls `GET /health` every 100ms until ready (30s timeout).
|
||||
|
||||
### envd (Guest Agent)
|
||||
|
||||
@ -156,7 +158,7 @@ veth-{idx} ←──── veth pair ────→ eth0
|
||||
- **Outbound NAT**: guest (169.254.0.21) → SNAT to vpeerIP inside namespace → MASQUERADE on host to default interface
|
||||
- **Inbound NAT**: host traffic to 10.11.0.{idx} → DNAT to 169.254.0.21 inside namespace
|
||||
- IP forwarding enabled inside each namespace
|
||||
- All details in `internal/network/setup.go`
|
||||
- All details in `pkg/network/setup.go`
|
||||
|
||||
### Sandbox State Machine
|
||||
```
|
||||
|
||||
2
Makefile
2
Makefile
@ -115,7 +115,7 @@ vet:
|
||||
go vet ./...
|
||||
|
||||
test:
|
||||
go test -race -v ./internal/...
|
||||
go test -race -v ./internal/... ./pkg/...
|
||||
cd envd-rs && cargo test
|
||||
|
||||
test-integration:
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# Wrenn
|
||||
|
||||
Secure infrastructure for AI
|
||||
Runtime where AI engineers live
|
||||
|
||||
Wrenn is an open-source self-hosted dev environment platform. Each capsule is a fully isolated virtual machine — booted in seconds, persistent across sessions. Run the control plane anywhere, deploy a single agent binary on each compute host.
|
||||
Wrenn is an open-source platform for running AI coding agents. Each project gets a persistent, isolated microVM workspace — booted in seconds, stateful across sessions — where agents edit code, run tests, debug, and ship continuously while humans supervise via SDKs and chat surfaces. Available hosted or fully self-hosted: run the control plane anywhere, deploy a single agent binary on each compute host you own.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@ -1 +1 @@
|
||||
0.2.1
|
||||
0.3.1
|
||||
|
||||
@ -1 +1 @@
|
||||
0.2.1
|
||||
0.3.1
|
||||
|
||||
@ -1,32 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/devicemapper"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/hostagent"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/layout"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/network"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/sandbox"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/vm"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/logging"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/network"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/sandbox"
|
||||
"git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect"
|
||||
)
|
||||
|
||||
@ -48,29 +42,26 @@ func main() {
|
||||
cleanupLog := logging.Setup(filepath.Join(rootDir, "logs"), "host-agent")
|
||||
defer cleanupLog()
|
||||
|
||||
if err := checkPrivileges(); err != nil {
|
||||
if err := sandbox.CheckPrivileges(); err != nil {
|
||||
slog.Error("insufficient privileges", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Enable IP forwarding (required for NAT). The write may fail if running
|
||||
// as non-root without DAC_OVERRIDE on this path — that's OK if the systemd
|
||||
// unit's ExecStartPre already set it. We verify the value regardless.
|
||||
if err := os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1"), 0644); err != nil {
|
||||
slog.Warn("failed to enable ip_forward (may have been set by systemd unit)", "error", err)
|
||||
}
|
||||
if b, err := os.ReadFile("/proc/sys/net/ipv4/ip_forward"); err != nil || strings.TrimSpace(string(b)) != "1" {
|
||||
// unit's ExecStartPre already set it.
|
||||
if err := sandbox.EnsureIPForward(); err != nil {
|
||||
slog.Error("ip_forward is not enabled — sandbox networking will be broken", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Clean up stale resources from a previous crash. Order matters:
|
||||
// kill stale CH processes first — they hold dm-snapshot devices open and
|
||||
// would otherwise cause "Device or resource busy" on dmsetup remove.
|
||||
vm.CleanupStaleProcesses()
|
||||
devicemapper.CleanupStaleDevices()
|
||||
devicemapper.LogLoopState()
|
||||
network.CleanupStaleNamespaces()
|
||||
// Install the egress guard: block capsule traffic to private ranges from
|
||||
// leaving the public NIC (which would leak private-destined packets onto the
|
||||
// upstream network and read as a port scan) and deny cross-capsule reach.
|
||||
if err := network.EnsureEgressGuard(); err != nil {
|
||||
slog.Error("failed to install network egress guard", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
listenAddr := envOrDefault("WRENN_HOST_LISTEN_ADDR", ":50051")
|
||||
cpURL := os.Getenv("WRENN_CP_URL")
|
||||
@ -116,37 +107,27 @@ func main() {
|
||||
slog.Info("using custom rootfs size", "size_mb", defaultRootfsSizeMB)
|
||||
}
|
||||
|
||||
// Expand base images to the configured disk size (sparse, no extra physical
|
||||
// disk). This ensures dm-snapshot sandboxes see the full size from boot.
|
||||
if err := sandbox.EnsureImageSizes(rootDir, defaultRootfsSizeMB); err != nil {
|
||||
slog.Error("failed to expand base images", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Resolve latest kernel version.
|
||||
kernelPath, kernelVersion, err := layout.LatestKernel(rootDir)
|
||||
// Run the startup ritual: stale-resource cleanup, base image expansion,
|
||||
// kernel resolution, cloud-hypervisor detection, orphan pause-dir GC.
|
||||
env, err := sandbox.Setup(sandbox.SetupOptions{
|
||||
WrennDir: rootDir,
|
||||
CHBin: envOrDefault("WRENN_CH_BIN", sandbox.DefaultCHBin),
|
||||
DefaultRootfsSizeMB: defaultRootfsSizeMB,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("failed to find kernel", "error", err)
|
||||
slog.Error("host setup failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("resolved kernel", "version", kernelVersion, "path", kernelPath)
|
||||
|
||||
// Detect cloud-hypervisor version.
|
||||
chBin := envOrDefault("WRENN_CH_BIN", "/usr/local/bin/cloud-hypervisor")
|
||||
chVersion, err := sandbox.DetectCHVersion(chBin)
|
||||
if err != nil {
|
||||
slog.Error("failed to detect cloud-hypervisor version", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("resolved cloud-hypervisor", "version", chVersion, "path", chBin)
|
||||
slog.Info("resolved kernel", "version", env.KernelVersion, "path", env.KernelPath)
|
||||
slog.Info("resolved cloud-hypervisor", "version", env.CHVersion, "path", env.CHBin)
|
||||
|
||||
cfg := sandbox.Config{
|
||||
WrennDir: rootDir,
|
||||
DefaultRootfsSizeMB: defaultRootfsSizeMB,
|
||||
KernelPath: kernelPath,
|
||||
KernelVersion: kernelVersion,
|
||||
VMMBin: chBin,
|
||||
VMMVersion: chVersion,
|
||||
KernelPath: env.KernelPath,
|
||||
KernelVersion: env.KernelVersion,
|
||||
VMMBin: env.CHBin,
|
||||
VMMVersion: env.CHVersion,
|
||||
AgentVersion: version,
|
||||
ProxyDomain: envOrDefault("WRENN_PROXY_DOMAIN", "wrenn.dev"),
|
||||
|
||||
@ -157,11 +138,6 @@ func main() {
|
||||
DiskFloorBps: envUint64("WRENN_DISK_FLOOR_BPS"),
|
||||
}
|
||||
|
||||
// Remove any *.staging-* / *.trash-* directories left behind by a
|
||||
// previous Pause that crashed before completing the atomic swap. Must
|
||||
// run before any Resume so we don't race with a sandbox restoration.
|
||||
sandbox.CleanupOrphanPauseDirs(rootDir)
|
||||
|
||||
mgr := sandbox.New(cfg)
|
||||
|
||||
// Set up lifecycle event callback sender so autonomous events
|
||||
@ -169,12 +145,15 @@ func main() {
|
||||
cb := hostagent.NewCallbackSender(cpURL, credsFile, creds.HostID)
|
||||
mgr.SetEventSender(hostagent.NewEventSender(cb))
|
||||
|
||||
// Restore paused sandboxes from disk so ListSandboxes reports them as
|
||||
// 'paused' immediately. Without this, the CP's HostMonitor would mark
|
||||
// every paused-on-disk sandbox 'stopped' via the missing→stopped
|
||||
// reconcile path on the first ListSandboxes after agent restart.
|
||||
// Must run before HTTP server starts serving (an early Create would
|
||||
// race the slot reservation).
|
||||
// Sweep stale running-state files first (Setup's stale cleanup killed
|
||||
// every CH process, so nothing can actually be re-attached), then restore
|
||||
// paused sandboxes from disk so ListSandboxes reports them as 'paused'
|
||||
// immediately. Without the latter, the CP's HostMonitor would mark every
|
||||
// paused-on-disk sandbox 'stopped' via the missing→stopped reconcile path
|
||||
// on the first ListSandboxes after agent restart. Must run before the
|
||||
// HTTP server starts serving (an early Create would race the slot
|
||||
// reservation).
|
||||
mgr.RestoreRunningSandboxes()
|
||||
mgr.RestorePausedSandboxes()
|
||||
|
||||
mgr.StartTTLReaper(ctx)
|
||||
@ -361,63 +340,3 @@ func envUint64(key string) uint64 {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// checkPrivileges verifies the process has the required Linux capabilities.
|
||||
// Always reads CapEff — even for root — because a root process inside a
|
||||
// restricted container (e.g. docker --cap-drop=all) may not have all caps.
|
||||
func checkPrivileges() error {
|
||||
capEff, err := readEffectiveCaps()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read capabilities: %w", err)
|
||||
}
|
||||
|
||||
// All capabilities required by the host agent at runtime.
|
||||
required := []struct {
|
||||
bit uint
|
||||
name string
|
||||
}{
|
||||
{1, "CAP_DAC_OVERRIDE"}, // /dev/loop*, /dev/mapper/*, /dev/net/tun
|
||||
{5, "CAP_KILL"}, // SIGTERM/SIGKILL to cloud-hypervisor processes
|
||||
{12, "CAP_NET_ADMIN"}, // netlink, iptables, routing, TAP/veth
|
||||
{13, "CAP_NET_RAW"}, // raw sockets (iptables)
|
||||
{19, "CAP_SYS_PTRACE"}, // reading /proc/self/ns/net (netns.Get)
|
||||
{21, "CAP_SYS_ADMIN"}, // netns, mount ns, losetup, dmsetup
|
||||
{27, "CAP_MKNOD"}, // device-mapper node creation
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, cap := range required {
|
||||
if capEff&(1<<cap.bit) == 0 {
|
||||
missing = append(missing, cap.name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("missing capabilities: %s — run as root or apply setcap to the binary",
|
||||
strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readEffectiveCaps parses the CapEff bitmask from /proc/self/status.
|
||||
func readEffectiveCaps() (uint64, error) {
|
||||
f, err := os.Open("/proc/self/status")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if hexStr, ok := strings.CutPrefix(line, "CapEff:"); ok {
|
||||
return strconv.ParseUint(strings.TrimSpace(hexStr), 16, 64)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return 0, fmt.Errorf("read /proc/self/status: %w", err)
|
||||
}
|
||||
return 0, fmt.Errorf("CapEff not found in /proc/self/status")
|
||||
}
|
||||
|
||||
25
db/migrations/20260706122825_bump_default_vcpus_memory.sql
Normal file
25
db/migrations/20260706122825_bump_default_vcpus_memory.sql
Normal file
@ -0,0 +1,25 @@
|
||||
-- +goose Up
|
||||
|
||||
-- Bump the default sandbox specs from 1 vCPU / 512 MB to 2 vCPUs / 2048 MB.
|
||||
-- Applies to any row inserted without explicit vcpus/memory_mb across the
|
||||
-- sandboxes, templates, and template_builds tables.
|
||||
|
||||
ALTER TABLE sandboxes ALTER COLUMN vcpus SET DEFAULT 2;
|
||||
ALTER TABLE sandboxes ALTER COLUMN memory_mb SET DEFAULT 2048;
|
||||
|
||||
ALTER TABLE templates ALTER COLUMN vcpus SET DEFAULT 2;
|
||||
ALTER TABLE templates ALTER COLUMN memory_mb SET DEFAULT 2048;
|
||||
|
||||
ALTER TABLE template_builds ALTER COLUMN vcpus SET DEFAULT 2;
|
||||
ALTER TABLE template_builds ALTER COLUMN memory_mb SET DEFAULT 2048;
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER TABLE sandboxes ALTER COLUMN vcpus SET DEFAULT 1;
|
||||
ALTER TABLE sandboxes ALTER COLUMN memory_mb SET DEFAULT 512;
|
||||
|
||||
ALTER TABLE templates ALTER COLUMN vcpus SET DEFAULT 1;
|
||||
ALTER TABLE templates ALTER COLUMN memory_mb SET DEFAULT 512;
|
||||
|
||||
ALTER TABLE template_builds ALTER COLUMN vcpus SET DEFAULT 1;
|
||||
ALTER TABLE template_builds ALTER COLUMN memory_mb SET DEFAULT 512;
|
||||
@ -28,3 +28,6 @@ DELETE FROM team_api_keys WHERE team_id = $1;
|
||||
|
||||
-- name: DeleteAPIKeysByCreator :exec
|
||||
DELETE FROM team_api_keys WHERE created_by = $1;
|
||||
|
||||
-- name: DeleteAPIKeysByTeamAndCreator :exec
|
||||
DELETE FROM team_api_keys WHERE team_id = $1 AND created_by = $2;
|
||||
|
||||
432
envd-rs/Cargo.lock
generated
432
envd-rs/Cargo.lock
generated
@ -124,9 +124,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
@ -195,9 +195,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
@ -258,24 +258,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.61"
|
||||
version = "1.2.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
|
||||
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
@ -297,9 +297,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"num-traits",
|
||||
@ -489,9 +489,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.1.0"
|
||||
version = "6.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
|
||||
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
@ -514,9 +514,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
@ -529,7 +529,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "envd"
|
||||
version = "0.4.0"
|
||||
version = "0.6.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"axum",
|
||||
@ -542,7 +542,6 @@ dependencies = [
|
||||
"connectrpc",
|
||||
"connectrpc-build",
|
||||
"dashmap",
|
||||
"flate2",
|
||||
"futures",
|
||||
"hex",
|
||||
"hmac",
|
||||
@ -550,7 +549,6 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"libc",
|
||||
"mime_guess",
|
||||
"nix",
|
||||
"notify",
|
||||
"serde",
|
||||
@ -594,13 +592,12 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.27"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"libredox",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -761,22 +758,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@ -809,9 +804,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.0"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@ -836,9 +831,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
@ -887,9 +882,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.9.0"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@ -933,7 +928,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -945,12 +940,6 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@ -958,9 +947,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.0",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1016,21 +1003,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.98"
|
||||
version = "0.3.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08"
|
||||
checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
|
||||
checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5"
|
||||
dependencies = [
|
||||
"kqueue-sys",
|
||||
"libc",
|
||||
@ -1038,11 +1024,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "kqueue-sys"
|
||||
version = "1.0.4"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
|
||||
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"bitflags 2.13.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@ -1052,30 +1038,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"libc",
|
||||
"plain",
|
||||
"redox_syscall 0.7.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@ -1093,9 +1061,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
@ -1114,9 +1082,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
@ -1146,9 +1114,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
@ -1179,7 +1147,7 @@ version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@ -1191,7 +1159,7 @@ version = "7.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"filetime",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
@ -1270,7 +1238,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall 0.5.18",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
@ -1283,18 +1251,18 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.11"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
|
||||
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.11"
|
||||
version = "1.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
|
||||
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -1313,12 +1281,6 @@ version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "plain"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
@ -1340,9 +1302,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
version = "1.0.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
@ -1385,16 +1347,7 @@ version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1410,9 +1363,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
@ -1420,7 +1373,7 @@ version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
@ -1454,12 +1407,6 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
@ -1492,9 +1439,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@ -1548,9 +1495,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
@ -1576,15 +1523,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
version = "1.15.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
@ -1610,9 +1557,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
version = "2.0.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -1646,7 +1593,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
@ -1683,9 +1630,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.1"
|
||||
version = "1.52.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@ -1741,11 +1688,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.6.8"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bitflags 2.13.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
@ -1762,7 +1709,6 @@ dependencies = [
|
||||
"tokio-util",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1854,9 +1800,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
@ -1870,12 +1816,6 @@ version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
@ -1912,27 +1852,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
|
||||
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.57.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip3"
|
||||
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
|
||||
dependencies = [
|
||||
"wit-bindgen 0.51.0",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.121"
|
||||
version = "0.2.125"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790"
|
||||
checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@ -1943,9 +1874,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.121"
|
||||
version = "0.2.125"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578"
|
||||
checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@ -1953,9 +1884,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.121"
|
||||
version = "0.2.125"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2"
|
||||
checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@ -1966,47 +1897,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.121"
|
||||
version = "0.2.125"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441"
|
||||
checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
@ -2044,7 +1941,7 @@ version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-core 0.57.0",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
@ -2054,12 +1951,25 @@ version = "0.57.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-result",
|
||||
"windows-implement 0.57.0",
|
||||
"windows-interface 0.57.0",
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement 0.60.2",
|
||||
"windows-interface 0.59.3",
|
||||
"windows-link",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.57.0"
|
||||
@ -2071,6 +1981,17 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.57.0"
|
||||
@ -2082,6 +2003,17 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
@ -2097,6 +2029,24 @@ dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
@ -2179,114 +2129,26 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.11.1",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.4.3"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
|
||||
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "envd"
|
||||
version = "0.4.0"
|
||||
version = "0.6.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
|
||||
@ -50,9 +50,6 @@ zeroize = { version = "1", features = ["derive"] }
|
||||
# File watching
|
||||
notify = "7"
|
||||
|
||||
# Compression
|
||||
flate2 = "1"
|
||||
|
||||
# Directory walking
|
||||
walkdir = "2"
|
||||
|
||||
@ -70,7 +67,6 @@ subtle = "2"
|
||||
http-body = "1.0.1"
|
||||
buffa = "0.3"
|
||||
async-stream = "0.3.6"
|
||||
mime_guess = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@ -81,8 +81,8 @@ make build-envd-go # Go version (for comparison)
|
||||
| GET | `/envs` | Current environment variables |
|
||||
| POST | `/init` | Host agent init (token, env, mounts) |
|
||||
| POST | `/snapshot/prepare` | Quiesce before Cloud Hypervisor snapshot |
|
||||
| GET | `/files` | Download file (gzip, range support) |
|
||||
| POST | `/files` | Upload file(s) via multipart |
|
||||
| GET | `/files` | Download file (streamed from disk) |
|
||||
| PUT | `/files` | Upload file (raw body, streamed, atomic) |
|
||||
|
||||
### Connect RPC (same port)
|
||||
|
||||
|
||||
@ -30,8 +30,5 @@ pub async fn get_activity(State(state): State<Arc<AppState>>) -> impl IntoRespon
|
||||
disk_bps: state.disk_bps(),
|
||||
};
|
||||
|
||||
(
|
||||
[(header::CACHE_CONTROL, "no-store")],
|
||||
Json(body),
|
||||
)
|
||||
([(header::CACHE_CONTROL, "no-store")], Json(body))
|
||||
}
|
||||
|
||||
@ -1,376 +0,0 @@
|
||||
use axum::http::Request;
|
||||
|
||||
const ENCODING_GZIP: &str = "gzip";
|
||||
const ENCODING_IDENTITY: &str = "identity";
|
||||
const ENCODING_WILDCARD: &str = "*";
|
||||
|
||||
const SUPPORTED_ENCODINGS: &[&str] = &[ENCODING_GZIP];
|
||||
|
||||
struct EncodingWithQuality {
|
||||
encoding: String,
|
||||
quality: f64,
|
||||
}
|
||||
|
||||
fn parse_encoding_with_quality(value: &str) -> EncodingWithQuality {
|
||||
let value = value.trim();
|
||||
let mut quality = 1.0;
|
||||
|
||||
if let Some(idx) = value.find(';') {
|
||||
let params = &value[idx + 1..];
|
||||
let enc = value[..idx].trim();
|
||||
for param in params.split(';') {
|
||||
let param = param.trim();
|
||||
if let Some(stripped) = param
|
||||
.strip_prefix("q=")
|
||||
.or_else(|| param.strip_prefix("Q="))
|
||||
{
|
||||
if let Ok(q) = stripped.parse::<f64>() {
|
||||
quality = q;
|
||||
}
|
||||
}
|
||||
}
|
||||
return EncodingWithQuality {
|
||||
encoding: enc.to_ascii_lowercase(),
|
||||
quality,
|
||||
};
|
||||
}
|
||||
|
||||
EncodingWithQuality {
|
||||
encoding: value.to_ascii_lowercase(),
|
||||
quality,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_accept_encoding_header(header: &str) -> (Vec<EncodingWithQuality>, bool) {
|
||||
if header.is_empty() {
|
||||
return (Vec::new(), false);
|
||||
}
|
||||
|
||||
let encodings: Vec<EncodingWithQuality> = header
|
||||
.split(',')
|
||||
.map(|v| parse_encoding_with_quality(v))
|
||||
.collect();
|
||||
|
||||
let mut identity_rejected = false;
|
||||
let mut identity_explicitly_accepted = false;
|
||||
let mut wildcard_rejected = false;
|
||||
|
||||
for eq in &encodings {
|
||||
match eq.encoding.as_str() {
|
||||
ENCODING_IDENTITY => {
|
||||
if eq.quality == 0.0 {
|
||||
identity_rejected = true;
|
||||
} else {
|
||||
identity_explicitly_accepted = true;
|
||||
}
|
||||
}
|
||||
ENCODING_WILDCARD => {
|
||||
if eq.quality == 0.0 {
|
||||
wildcard_rejected = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if wildcard_rejected && !identity_explicitly_accepted {
|
||||
identity_rejected = true;
|
||||
}
|
||||
|
||||
(encodings, identity_rejected)
|
||||
}
|
||||
|
||||
pub fn is_identity_acceptable<B>(r: &Request<B>) -> bool {
|
||||
let header = r
|
||||
.headers()
|
||||
.get("accept-encoding")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let (_, rejected) = parse_accept_encoding_header(header);
|
||||
!rejected
|
||||
}
|
||||
|
||||
pub fn parse_accept_encoding<B>(r: &Request<B>) -> Result<&'static str, String> {
|
||||
let header = r
|
||||
.headers()
|
||||
.get("accept-encoding")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
if header.is_empty() {
|
||||
return Ok(ENCODING_IDENTITY);
|
||||
}
|
||||
|
||||
let (mut encodings, identity_rejected) = parse_accept_encoding_header(header);
|
||||
encodings.sort_by(|a, b| {
|
||||
b.quality
|
||||
.partial_cmp(&a.quality)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
for eq in &encodings {
|
||||
if eq.quality == 0.0 {
|
||||
continue;
|
||||
}
|
||||
if eq.encoding == ENCODING_IDENTITY {
|
||||
return Ok(ENCODING_IDENTITY);
|
||||
}
|
||||
if eq.encoding == ENCODING_WILDCARD {
|
||||
if identity_rejected && !SUPPORTED_ENCODINGS.is_empty() {
|
||||
return Ok(SUPPORTED_ENCODINGS[0]);
|
||||
}
|
||||
return Ok(ENCODING_IDENTITY);
|
||||
}
|
||||
if eq.encoding == ENCODING_GZIP {
|
||||
return Ok(ENCODING_GZIP);
|
||||
}
|
||||
}
|
||||
|
||||
if !identity_rejected {
|
||||
return Ok(ENCODING_IDENTITY);
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"no acceptable encoding found, supported: {SUPPORTED_ENCODINGS:?}"
|
||||
))
|
||||
}
|
||||
|
||||
pub fn parse_content_encoding<B>(r: &Request<B>) -> Result<&'static str, String> {
|
||||
let header = r
|
||||
.headers()
|
||||
.get("content-encoding")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
if header.is_empty() {
|
||||
return Ok(ENCODING_IDENTITY);
|
||||
}
|
||||
|
||||
let encoding = header.trim().to_ascii_lowercase();
|
||||
if encoding == ENCODING_IDENTITY {
|
||||
return Ok(ENCODING_IDENTITY);
|
||||
}
|
||||
if SUPPORTED_ENCODINGS.contains(&encoding.as_str()) {
|
||||
return Ok(ENCODING_GZIP);
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"unsupported Content-Encoding: {header}, supported: {SUPPORTED_ENCODINGS:?}"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::Request;
|
||||
|
||||
fn req_with_accept(v: &str) -> Request<()> {
|
||||
Request::builder()
|
||||
.header("accept-encoding", v)
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn req_with_content(v: &str) -> Request<()> {
|
||||
Request::builder()
|
||||
.header("content-encoding", v)
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn req_no_headers() -> Request<()> {
|
||||
Request::builder().body(()).unwrap()
|
||||
}
|
||||
|
||||
// parse_encoding_with_quality
|
||||
|
||||
#[test]
|
||||
fn encoding_quality_default_1() {
|
||||
let eq = parse_encoding_with_quality("gzip");
|
||||
assert_eq!(eq.encoding, "gzip");
|
||||
assert_eq!(eq.quality, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoding_quality_explicit() {
|
||||
let eq = parse_encoding_with_quality("gzip;q=0.8");
|
||||
assert_eq!(eq.encoding, "gzip");
|
||||
assert_eq!(eq.quality, 0.8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoding_quality_case_insensitive() {
|
||||
let eq = parse_encoding_with_quality("GZIP;Q=0.5");
|
||||
assert_eq!(eq.encoding, "gzip");
|
||||
assert_eq!(eq.quality, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoding_quality_zero() {
|
||||
let eq = parse_encoding_with_quality("gzip;q=0");
|
||||
assert_eq!(eq.quality, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoding_quality_whitespace_trimmed() {
|
||||
let eq = parse_encoding_with_quality(" gzip ; q=0.9 ");
|
||||
assert_eq!(eq.encoding, "gzip");
|
||||
assert_eq!(eq.quality, 0.9);
|
||||
}
|
||||
|
||||
// parse_accept_encoding_header
|
||||
|
||||
#[test]
|
||||
fn accept_header_empty() {
|
||||
let (encs, rejected) = parse_accept_encoding_header("");
|
||||
assert!(encs.is_empty());
|
||||
assert!(!rejected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_header_identity_q0_rejects() {
|
||||
let (_, rejected) = parse_accept_encoding_header("identity;q=0");
|
||||
assert!(rejected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_header_wildcard_q0_rejects_identity() {
|
||||
let (_, rejected) = parse_accept_encoding_header("*;q=0");
|
||||
assert!(rejected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_header_wildcard_q0_but_identity_explicit_accepted() {
|
||||
let (_, rejected) = parse_accept_encoding_header("*;q=0, identity");
|
||||
assert!(!rejected);
|
||||
}
|
||||
|
||||
// parse_accept_encoding (full)
|
||||
|
||||
#[test]
|
||||
fn accept_encoding_no_header_returns_identity() {
|
||||
assert_eq!(
|
||||
parse_accept_encoding(&req_no_headers()).unwrap(),
|
||||
"identity"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_encoding_gzip() {
|
||||
assert_eq!(
|
||||
parse_accept_encoding(&req_with_accept("gzip")).unwrap(),
|
||||
"gzip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_encoding_identity_explicit() {
|
||||
assert_eq!(
|
||||
parse_accept_encoding(&req_with_accept("identity")).unwrap(),
|
||||
"identity"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_encoding_gzip_higher_quality() {
|
||||
assert_eq!(
|
||||
parse_accept_encoding(&req_with_accept("identity;q=0.1, gzip;q=0.9")).unwrap(),
|
||||
"gzip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_encoding_wildcard_returns_identity() {
|
||||
assert_eq!(
|
||||
parse_accept_encoding(&req_with_accept("*")).unwrap(),
|
||||
"identity"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_encoding_wildcard_identity_rejected_returns_gzip() {
|
||||
assert_eq!(
|
||||
parse_accept_encoding(&req_with_accept("identity;q=0, *")).unwrap(),
|
||||
"gzip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_encoding_all_rejected_errors() {
|
||||
assert!(parse_accept_encoding(&req_with_accept("identity;q=0, *;q=0")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_encoding_unsupported_only_falls_to_identity() {
|
||||
assert_eq!(
|
||||
parse_accept_encoding(&req_with_accept("br")).unwrap(),
|
||||
"identity"
|
||||
);
|
||||
}
|
||||
|
||||
// is_identity_acceptable
|
||||
|
||||
#[test]
|
||||
fn identity_acceptable_no_header() {
|
||||
assert!(is_identity_acceptable(&req_no_headers()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_acceptable_gzip_only() {
|
||||
assert!(is_identity_acceptable(&req_with_accept("gzip")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_not_acceptable_identity_q0() {
|
||||
assert!(!is_identity_acceptable(&req_with_accept("identity;q=0")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_not_acceptable_wildcard_q0() {
|
||||
assert!(!is_identity_acceptable(&req_with_accept("*;q=0")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_acceptable_wildcard_q0_but_identity_explicit() {
|
||||
assert!(is_identity_acceptable(&req_with_accept("*;q=0, identity")));
|
||||
}
|
||||
|
||||
// parse_content_encoding
|
||||
|
||||
#[test]
|
||||
fn content_encoding_empty_returns_identity() {
|
||||
assert_eq!(
|
||||
parse_content_encoding(&req_no_headers()).unwrap(),
|
||||
"identity"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_encoding_gzip() {
|
||||
assert_eq!(
|
||||
parse_content_encoding(&req_with_content("gzip")).unwrap(),
|
||||
"gzip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_encoding_identity_explicit() {
|
||||
assert_eq!(
|
||||
parse_content_encoding(&req_with_content("identity")).unwrap(),
|
||||
"identity"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_encoding_unsupported_errors() {
|
||||
assert!(parse_content_encoding(&req_with_content("br")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_encoding_case_insensitive() {
|
||||
assert_eq!(
|
||||
parse_content_encoding(&req_with_content("GZIP")).unwrap(),
|
||||
"gzip"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,22 +1,30 @@
|
||||
use std::io::Write as _;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{FromRequest, Query, Request, State};
|
||||
use axum::extract::{Query, Request, State};
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use futures::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
use crate::auth::signing;
|
||||
use crate::execcontext;
|
||||
use crate::http::encoding;
|
||||
use crate::permissions::path::{ensure_dirs, expand_and_resolve};
|
||||
use crate::permissions::user::lookup_user;
|
||||
use crate::state::AppState;
|
||||
|
||||
const ACCESS_TOKEN_HEADER: &str = "x-access-token";
|
||||
|
||||
/// Monotonic counter for unique temp-file names within this process. Combined
|
||||
/// with the pid it guarantees concurrent uploads never collide on the staging
|
||||
/// path before the atomic rename.
|
||||
static UPLOAD_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct FileParams {
|
||||
pub path: Option<String>,
|
||||
@ -62,7 +70,10 @@ fn validate_file_signing(
|
||||
)
|
||||
}
|
||||
|
||||
/// GET /files — download a file
|
||||
/// GET /files — download a file, streamed from disk.
|
||||
///
|
||||
/// The body is streamed straight off the filesystem so large files never get
|
||||
/// buffered into memory. Identity encoding only — no gzip, no range support.
|
||||
pub async fn get_files(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<FileParams>,
|
||||
@ -101,7 +112,7 @@ pub async fn get_files(
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, &e),
|
||||
};
|
||||
|
||||
let meta = match std::fs::metadata(&resolved) {
|
||||
let meta = match tokio::fs::metadata(&resolved).await {
|
||||
Ok(m) => m,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return json_error(
|
||||
@ -131,34 +142,12 @@ pub async fn get_files(
|
||||
);
|
||||
}
|
||||
|
||||
let accept_enc = match encoding::parse_accept_encoding(&req) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return json_error(StatusCode::NOT_ACCEPTABLE, &e),
|
||||
};
|
||||
|
||||
let has_range_or_conditional = req.headers().get("range").is_some()
|
||||
|| req.headers().get("if-modified-since").is_some()
|
||||
|| req.headers().get("if-none-match").is_some()
|
||||
|| req.headers().get("if-range").is_some();
|
||||
|
||||
let use_encoding = if has_range_or_conditional {
|
||||
if !encoding::is_identity_acceptable(&req) {
|
||||
return json_error(
|
||||
StatusCode::NOT_ACCEPTABLE,
|
||||
"identity encoding not acceptable for Range or conditional request",
|
||||
);
|
||||
}
|
||||
"identity"
|
||||
} else {
|
||||
accept_enc
|
||||
};
|
||||
|
||||
let file_data = match std::fs::read(&resolved) {
|
||||
Ok(d) => d,
|
||||
let file = match tokio::fs::File::open(&resolved).await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("error reading file: {e}"),
|
||||
&format!("error opening file: {e}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -167,57 +156,34 @@ pub async fn get_files(
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let content_disposition = format!("inline; filename=\"{}\"", filename);
|
||||
let content_type = mime_guess::from_path(&resolved)
|
||||
.first_raw()
|
||||
.unwrap_or("application/octet-stream");
|
||||
|
||||
if use_encoding == "gzip" {
|
||||
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
if let Err(e) = encoder.write_all(&file_data) {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("gzip encoding error: {e}"),
|
||||
);
|
||||
}
|
||||
let compressed = match encoder.finish() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("gzip finish error: {e}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CONTENT_ENCODING, "gzip")
|
||||
.header(header::CONTENT_DISPOSITION, content_disposition)
|
||||
.header(header::VARY, "Accept-Encoding")
|
||||
.body(Body::from(compressed))
|
||||
.unwrap();
|
||||
}
|
||||
let body = Body::from_stream(ReaderStream::new(file));
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(header::CONTENT_DISPOSITION, content_disposition)
|
||||
.header(header::VARY, "Accept-Encoding")
|
||||
.header(header::CONTENT_LENGTH, file_data.len())
|
||||
.body(Body::from(file_data))
|
||||
.header(header::CONTENT_LENGTH, meta.len())
|
||||
.body(body)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// POST /files — upload file(s) via multipart
|
||||
pub async fn post_files(
|
||||
/// PUT /files — upload a single file, streamed to disk.
|
||||
///
|
||||
/// The request body is the raw file content (no multipart, no encoding). It is
|
||||
/// streamed to a temporary staging file in the destination directory, then
|
||||
/// atomically renamed into place — concurrent writers to the same path never
|
||||
/// observe a torn file, and the last rename wins.
|
||||
pub async fn put_files(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<FileParams>,
|
||||
req: Request,
|
||||
) -> Response {
|
||||
let path_str = params.path.as_deref().unwrap_or("");
|
||||
if path_str.is_empty() {
|
||||
return json_error(StatusCode::BAD_REQUEST, "missing required 'path' parameter");
|
||||
}
|
||||
let header_token = extract_header_token(&req);
|
||||
|
||||
let default_user = state.defaults.user();
|
||||
@ -246,105 +212,42 @@ pub async fn post_files(
|
||||
let home_dir = user.dir.to_string_lossy().to_string();
|
||||
let uid = user.uid;
|
||||
let gid = user.gid;
|
||||
let default_workdir = state.defaults.workdir();
|
||||
|
||||
let content_enc = match encoding::parse_content_encoding(&req) {
|
||||
Ok(e) => e,
|
||||
let file_path = match expand_and_resolve(path_str, &home_dir, default_workdir.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, &e),
|
||||
};
|
||||
|
||||
let mut multipart = match axum::extract::Multipart::from_request(req, &()).await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("error parsing multipart: {e}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let mut uploaded: Vec<EntryInfo> = Vec::new();
|
||||
let default_workdir = state.defaults.workdir();
|
||||
|
||||
while let Ok(Some(field)) = multipart.next_field().await {
|
||||
let field_name = field.name().unwrap_or("").to_string();
|
||||
if field_name != "file" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_path = if !path_str.is_empty() {
|
||||
match expand_and_resolve(path_str, &home_dir, default_workdir.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, &e),
|
||||
}
|
||||
} else {
|
||||
let fname = field.file_name().unwrap_or("upload").to_string();
|
||||
match expand_and_resolve(&fname, &home_dir, default_workdir.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, &e),
|
||||
}
|
||||
};
|
||||
|
||||
if uploaded.iter().any(|e| e.path == file_path) {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("cannot upload multiple files to same path '{}'", file_path),
|
||||
);
|
||||
}
|
||||
|
||||
let raw_bytes = match field.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("error reading field: {e}"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let data = if content_enc == "gzip" {
|
||||
use std::io::Read;
|
||||
let mut decoder = flate2::read::GzDecoder::new(&raw_bytes[..]);
|
||||
let mut buf = Vec::new();
|
||||
match decoder.read_to_end(&mut buf) {
|
||||
Ok(_) => buf,
|
||||
Err(e) => {
|
||||
return json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
&format!("gzip decompression failed: {e}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
raw_bytes.to_vec()
|
||||
};
|
||||
|
||||
if let Err(e) = process_file(&file_path, &data, uid, gid) {
|
||||
let (status, msg) = e;
|
||||
return json_error(status, &msg);
|
||||
}
|
||||
|
||||
let name = Path::new(&file_path)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
uploaded.push(EntryInfo {
|
||||
path: file_path,
|
||||
name,
|
||||
r#type: "file",
|
||||
});
|
||||
if let Err((status, msg)) = stream_to_file(req.into_body(), &file_path, uid, gid).await {
|
||||
return json_error(status, &msg);
|
||||
}
|
||||
|
||||
axum::Json(uploaded).into_response()
|
||||
let name = Path::new(&file_path)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
axum::Json(EntryInfo {
|
||||
path: file_path,
|
||||
name,
|
||||
r#type: "file",
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn process_file(
|
||||
/// Stream a request body to `path` via a temp file + atomic rename. The staging
|
||||
/// file is created with mode 0o666 and chowned to (uid, gid) before the rename,
|
||||
/// so the destination appears atomically with the correct owner.
|
||||
async fn stream_to_file(
|
||||
body: Body,
|
||||
path: &str,
|
||||
data: &[u8],
|
||||
uid: nix::unistd::Uid,
|
||||
gid: nix::unistd::Gid,
|
||||
) -> Result<(), (StatusCode, String)> {
|
||||
let dir = Path::new(path)
|
||||
let target = Path::new(path);
|
||||
|
||||
let dir = target
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
@ -358,68 +261,34 @@ fn process_file(
|
||||
})?;
|
||||
}
|
||||
|
||||
let can_pre_chown = match std::fs::metadata(path) {
|
||||
Ok(meta) => {
|
||||
if meta.is_dir() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("path is a directory: {path}"),
|
||||
));
|
||||
}
|
||||
true
|
||||
// Reject writing over an existing directory before staging anything.
|
||||
match std::fs::metadata(path) {
|
||||
Ok(meta) if meta.is_dir() => {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("path is a directory: {path}"),
|
||||
));
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error getting file info: {e}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Stage in the destination directory so the rename stays on one filesystem.
|
||||
let seq = UPLOAD_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let tmp_name = format!(".envd-upload.{}.{}", std::process::id(), seq);
|
||||
let tmp_path = if dir.is_empty() {
|
||||
tmp_name
|
||||
} else {
|
||||
format!("{dir}/{tmp_name}")
|
||||
};
|
||||
|
||||
let mut chowned = false;
|
||||
if can_pre_chown {
|
||||
match std::os::unix::fs::chown(path, Some(uid.as_raw()), Some(gid.as_raw())) {
|
||||
Ok(()) => chowned = true,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => {
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error changing ownership: {e}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o666)
|
||||
.open(path)
|
||||
.map_err(|e| {
|
||||
if e.raw_os_error() == Some(libc::ENOSPC) {
|
||||
return (
|
||||
StatusCode::INSUFFICIENT_STORAGE,
|
||||
"not enough disk space available".to_string(),
|
||||
);
|
||||
}
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error opening file: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !chowned {
|
||||
std::os::unix::fs::chown(path, Some(uid.as_raw()), Some(gid.as_raw())).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error changing ownership: {e}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
file.write_all(data).map_err(|e| {
|
||||
let map_open_err = |e: std::io::Error| {
|
||||
if e.raw_os_error() == Some(libc::ENOSPC) {
|
||||
return (
|
||||
StatusCode::INSUFFICIENT_STORAGE,
|
||||
@ -428,11 +297,81 @@ fn process_file(
|
||||
}
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error writing file: {e}"),
|
||||
format!("error opening file: {e}"),
|
||||
)
|
||||
};
|
||||
|
||||
let std_file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o666)
|
||||
.open(&tmp_path)
|
||||
.map_err(map_open_err)?;
|
||||
|
||||
// From here on, any failure must clean up the staging file.
|
||||
let result = write_body_to_tmp(body, std_file, &tmp_path, uid, gid).await;
|
||||
if result.is_err() {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
return result.map(|_| ());
|
||||
}
|
||||
|
||||
std::fs::rename(&tmp_path, path).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp_path);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error finalizing file: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
async fn write_body_to_tmp(
|
||||
body: Body,
|
||||
std_file: std::fs::File,
|
||||
tmp_path: &str,
|
||||
uid: nix::unistd::Uid,
|
||||
gid: nix::unistd::Gid,
|
||||
) -> Result<(), (StatusCode, String)> {
|
||||
let mut file = tokio::fs::File::from_std(std_file);
|
||||
|
||||
let mut stream = body.into_data_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let bytes = chunk.map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("error reading request body: {e}"),
|
||||
)
|
||||
})?;
|
||||
file.write_all(&bytes).await.map_err(|e| {
|
||||
if e.raw_os_error() == Some(libc::ENOSPC) {
|
||||
return (
|
||||
StatusCode::INSUFFICIENT_STORAGE,
|
||||
"not enough disk space available".to_string(),
|
||||
);
|
||||
}
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error writing file: {e}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
file.flush().await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error flushing file: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// chown the staging file so it lands at the destination already owned by
|
||||
// the target user.
|
||||
std::os::unix::fs::chown(tmp_path, Some(uid.as_raw()), Some(gid.as_raw())).map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error changing ownership: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -71,25 +71,41 @@ pub async fn post_init(
|
||||
// back in the source memory-ranges file as the host re-restored them
|
||||
// lazily. Reset the flags so the next POST /memory/preload kicks off
|
||||
// a new loader instead of returning the stale "already-done".
|
||||
//
|
||||
// The generation bump + reset happen under the error mutex — the same
|
||||
// critical section a loader thread publishes its result in. A loader
|
||||
// from the PREVIOUS lifecycle can be frozen mid-walk by the pause and
|
||||
// thaw here; the bump makes it stop and discard its result instead of
|
||||
// storing a stale done=true that the host would trust for the next
|
||||
// snapshot. cancel is deliberately cleared (not set): the stale thread
|
||||
// stops on generation mismatch, and the new run must start uncancelled.
|
||||
use std::sync::atomic::Ordering;
|
||||
state.mem_preload_cancel.store(false, Ordering::SeqCst);
|
||||
state.mem_preload_done.store(false, Ordering::SeqCst);
|
||||
state.mem_preload_started.store(false, Ordering::SeqCst);
|
||||
state.mem_preload_regions.store(0, Ordering::SeqCst);
|
||||
state.mem_preload_pages.store(0, Ordering::SeqCst);
|
||||
state.mem_preload_bytes.store(0, Ordering::SeqCst);
|
||||
state.mem_preload_elapsed_us.store(0, Ordering::SeqCst);
|
||||
state.mem_preload_source.store(0, Ordering::SeqCst);
|
||||
*state.mem_preload_error.lock().unwrap() = None;
|
||||
{
|
||||
let mut err = state.mem_preload_error.lock().unwrap();
|
||||
state.mem_preload_generation.fetch_add(1, Ordering::SeqCst);
|
||||
state.mem_preload_cancel.store(false, Ordering::SeqCst);
|
||||
state.mem_preload_started.store(false, Ordering::SeqCst);
|
||||
state.reset_preload_run(&mut err);
|
||||
}
|
||||
|
||||
if let Some(ref port_sub) = state.port_subsystem {
|
||||
tracing::info!("lifecycle changed, restarting port subsystem");
|
||||
port_sub.restart();
|
||||
}
|
||||
// Force chrony to step the clock immediately. chronyd is launched by
|
||||
// wrenn-init.sh and disciplines against PHC (/dev/ptp0), so the host
|
||||
// wall time is already available — `makestep` just bypasses chrony's
|
||||
// normal slewing and snaps the clock in one go. Best effort.
|
||||
// Instant wall-clock step on resume. The host wall time arrives in
|
||||
// init_req.timestamp; chrony's PHC refclock needs several poll cycles
|
||||
// (poll 2 = 4s) before it has a valid offset to step to, so makestep
|
||||
// alone leaves the clock stale for seconds after a resume. Set
|
||||
// CLOCK_REALTIME directly here for an immediate jump, then let chronyd
|
||||
// keep disciplining drift against /dev/ptp0.
|
||||
if let Some(ref ts_str) = init_req.timestamp {
|
||||
if let Ok(nanos) = parse_timestamp_to_nanos(ts_str) {
|
||||
step_realtime_clock(nanos);
|
||||
}
|
||||
}
|
||||
// Also nudge chrony to re-sync its internal offset against the
|
||||
// now-correct clock + PHC immediately, bypassing its slew period.
|
||||
// Best effort — the direct step above already corrected wall time.
|
||||
tokio::spawn(async {
|
||||
match tokio::process::Command::new("chronyc")
|
||||
.args(["makestep"])
|
||||
@ -112,7 +128,8 @@ pub async fn post_init(
|
||||
|
||||
// Idempotent timestamp check. Run after lifecycle handling so a
|
||||
// stale-timestamp /init still gets to refresh ports + step clock.
|
||||
// No userspace clock_settime here — chrony owns time discipline.
|
||||
// The actual clock step happens in the lifecycle block above; this
|
||||
// only gates the rest of the apply path on monotonic timestamps.
|
||||
if let Some(ref ts_str) = init_req.timestamp {
|
||||
if let Ok(ts) = parse_timestamp_to_nanos(ts_str) {
|
||||
if !state.last_set_time.set_to_greater(ts) {
|
||||
@ -180,11 +197,15 @@ pub async fn post_init(
|
||||
futures::future::join_all(futs).await;
|
||||
}
|
||||
|
||||
// Set sandbox/template metadata from request body.
|
||||
// Set sandbox/template metadata from request body. Deliberately NOT
|
||||
// written into envd's own process environment: std::env::set_var is
|
||||
// undefined behavior with the multi-threaded runtime live (concurrent
|
||||
// getenv from any thread races the environ rewrite), and nothing needs
|
||||
// it there — spawned processes get these via defaults.env_vars, and
|
||||
// out-of-band consumers (e.g. the `envd ports` subcommand's
|
||||
// read_identity) fall back to the run files.
|
||||
if let Some(ref id) = init_req.sandbox_id {
|
||||
tracing::debug!(sandbox_id = %id, "setting sandbox ID from init request");
|
||||
// SAFETY: envd is single-threaded at init time; no concurrent env reads.
|
||||
unsafe { std::env::set_var("WRENN_SANDBOX_ID", id) };
|
||||
write_run_file(".WRENN_SANDBOX_ID", id);
|
||||
state
|
||||
.defaults
|
||||
@ -193,8 +214,6 @@ pub async fn post_init(
|
||||
}
|
||||
if let Some(ref id) = init_req.template_id {
|
||||
tracing::debug!(template_id = %id, "setting template ID from init request");
|
||||
// SAFETY: envd is single-threaded at init time; no concurrent env reads.
|
||||
unsafe { std::env::set_var("WRENN_TEMPLATE_ID", id) };
|
||||
write_run_file(".WRENN_TEMPLATE_ID", id);
|
||||
state
|
||||
.defaults
|
||||
@ -204,8 +223,6 @@ pub async fn post_init(
|
||||
if let Some(ref domain) = init_req.proxy_domain {
|
||||
if !domain.is_empty() {
|
||||
tracing::debug!(proxy_domain = %domain, "setting proxy domain from init request");
|
||||
// SAFETY: envd is single-threaded at init time; no concurrent env reads.
|
||||
unsafe { std::env::set_var("WRENN_PROXY_DOMAIN", domain) };
|
||||
write_run_file(".WRENN_PROXY_DOMAIN", domain);
|
||||
state
|
||||
.defaults
|
||||
@ -243,6 +260,14 @@ async fn validate_init_access_token(state: &AppState, request_token: &str) -> Re
|
||||
}
|
||||
|
||||
async fn setup_hyperloop(address: &str, env_vars: &dashmap::DashMap<String, String>) {
|
||||
// Reject anything that is not a bare IP address before it reaches
|
||||
// /etc/hosts. Without this, a newline in `address` would inject arbitrary
|
||||
// additional host entries into the file.
|
||||
if address.parse::<std::net::IpAddr>().is_err() {
|
||||
tracing::error!(%address, "hyperloop address is not a valid IP; skipping /etc/hosts entry");
|
||||
return;
|
||||
}
|
||||
|
||||
// Write to /etc/hosts: events.wrenn.local → address
|
||||
let entry = format!("{address} events.wrenn.local\n");
|
||||
|
||||
@ -318,6 +343,30 @@ fn write_run_file(name: &str, value: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Hard-steps CLOCK_REALTIME to `nanos` since the Unix epoch. Requires
|
||||
/// CAP_SYS_TIME, which envd has as PID 1 in the guest. Best effort — on
|
||||
/// failure the clock is left for chrony to discipline against the PHC.
|
||||
// libc::time_t is deprecated pending musl 1.2's 64-bit switch, but the
|
||||
// timespec.tv_sec field is still typed as time_t on this target.
|
||||
#[allow(deprecated)]
|
||||
fn step_realtime_clock(nanos: i64) {
|
||||
let ts = libc::timespec {
|
||||
tv_sec: (nanos / 1_000_000_000) as libc::time_t,
|
||||
tv_nsec: (nanos % 1_000_000_000) as libc::c_long,
|
||||
};
|
||||
// SAFETY: ts is a valid timespec; CLOCK_REALTIME is settable as root.
|
||||
let rc = unsafe { libc::clock_settime(libc::CLOCK_REALTIME, &ts) };
|
||||
if rc != 0 {
|
||||
tracing::warn!(error = %std::io::Error::last_os_error(),
|
||||
"clock_settime(CLOCK_REALTIME) failed");
|
||||
} else {
|
||||
tracing::info!(
|
||||
nanos,
|
||||
"stepped CLOCK_REALTIME from host timestamp on resume"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a host-provided timestamp into nanoseconds since the Unix epoch.
|
||||
/// Accepts either RFC3339 (`2026-05-17T16:13:03.123456Z`) or a float-seconds
|
||||
/// string (legacy callers).
|
||||
|
||||
@ -10,8 +10,11 @@
|
||||
// handler fills the page from the source memory-ranges file.
|
||||
//
|
||||
// Wire protocol:
|
||||
// POST /memory/preload — starts the loader (idempotent) and returns
|
||||
// the current status JSON immediately
|
||||
// POST /memory/preload — starts the loader (idempotent while a run
|
||||
// is in flight or completed successfully;
|
||||
// restarts a run that ended failed/cancelled)
|
||||
// and returns the current status JSON
|
||||
// immediately
|
||||
// GET /memory/preload — returns the current status JSON
|
||||
// POST /memory/preload/cancel — signals the loader to stop early
|
||||
//
|
||||
@ -50,21 +53,64 @@ pub struct PreloadStatus {
|
||||
|
||||
pub async fn post_memory_preload(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
// First caller wins the CAS and spawns the loader; subsequent callers
|
||||
// just report the existing status.
|
||||
let we_start = state
|
||||
.mem_preload_started
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_ok();
|
||||
// just report the existing status — EXCEPT when the previous run finished
|
||||
// in failed/cancelled state, which may be retried. Without the retry, one
|
||||
// transient failure would block the host's pause/snapshot gate until the
|
||||
// next /init (which never comes while the sandbox stays running).
|
||||
//
|
||||
// Both the fresh-run reset and the retry check run under the error mutex
|
||||
// — the same critical section /init's reset and the loader's result
|
||||
// publication use — so concurrent POSTs cannot both claim a retry and a
|
||||
// frozen /init cannot interleave.
|
||||
// The generation MUST be captured inside the same mutex-held block as the
|
||||
// CAS/reset: loading it after the lock drops lets an interleaving /init
|
||||
// (bump + started=false) tag this loader with the NEW generation, and a
|
||||
// follow-up POST would then spawn a second concurrent full-RAM walk.
|
||||
let start_generation = {
|
||||
let mut err = state.mem_preload_error.lock().unwrap();
|
||||
let fresh = state
|
||||
.mem_preload_started
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_ok();
|
||||
let retry = !fresh
|
||||
&& state.mem_preload_done.load(Ordering::SeqCst)
|
||||
&& (err.is_some() || state.mem_preload_cancel.load(Ordering::SeqCst));
|
||||
if fresh || retry {
|
||||
// Clear leftovers (previous lifecycle's or failed run's result) so
|
||||
// a stale done=true can't masquerade as this run's completion.
|
||||
state.reset_preload_run(&mut err);
|
||||
state.mem_preload_cancel.store(false, Ordering::SeqCst);
|
||||
Some(state.mem_preload_generation.load(Ordering::SeqCst))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if we_start {
|
||||
if let Some(generation) = start_generation {
|
||||
let state_clone = Arc::clone(&state);
|
||||
// Detached blocking thread — no axum task lifetime ties it to the
|
||||
// request, so the connection can close immediately without aborting
|
||||
// materialisation. Lifecycle bump on the next /init clears the flags
|
||||
// for a fresh run after a restore.
|
||||
// for a fresh run after a restore; this thread stops (and refuses to
|
||||
// publish) once the generation it captured is no longer current — a
|
||||
// pause can freeze it mid-walk and thaw it in the NEXT lifecycle.
|
||||
std::thread::spawn(move || {
|
||||
let started = Instant::now();
|
||||
match preload_blocking(&state_clone) {
|
||||
let outcome = preload_blocking(&state_clone, generation);
|
||||
|
||||
// Publish under the error mutex so a concurrent /init bump can't
|
||||
// interleave: either this store lands before the bump (and /init
|
||||
// resets it), or the generation no longer matches and the result
|
||||
// is discarded.
|
||||
let mut err = state_clone.mem_preload_error.lock().unwrap();
|
||||
if state_clone.mem_preload_generation.load(Ordering::SeqCst) != generation {
|
||||
tracing::info!(
|
||||
generation,
|
||||
"memory preload result discarded: stale lifecycle"
|
||||
);
|
||||
return;
|
||||
}
|
||||
match outcome {
|
||||
Ok((source, regions, pages, bytes)) => {
|
||||
let elapsed = started.elapsed().as_secs_f64();
|
||||
state_clone
|
||||
@ -76,7 +122,7 @@ pub async fn post_memory_preload(State(state): State<Arc<AppState>>) -> impl Int
|
||||
.mem_preload_elapsed_us
|
||||
.store((elapsed * 1_000_000.0) as u64, Ordering::SeqCst);
|
||||
set_source(&state_clone, source);
|
||||
*state_clone.mem_preload_error.lock().unwrap() = None;
|
||||
*err = None;
|
||||
state_clone.mem_preload_done.store(true, Ordering::SeqCst);
|
||||
tracing::info!(
|
||||
regions,
|
||||
@ -92,7 +138,7 @@ pub async fn post_memory_preload(State(state): State<Arc<AppState>>) -> impl Int
|
||||
state_clone
|
||||
.mem_preload_elapsed_us
|
||||
.store((elapsed * 1_000_000.0) as u64, Ordering::SeqCst);
|
||||
*state_clone.mem_preload_error.lock().unwrap() = Some(e.clone());
|
||||
*err = Some(e.clone());
|
||||
state_clone.mem_preload_done.store(true, Ordering::SeqCst);
|
||||
tracing::warn!(error = %e, "memory preload failed");
|
||||
}
|
||||
@ -159,7 +205,10 @@ fn get_source(state: &AppState) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn preload_blocking(state: &AppState) -> Result<(&'static str, u64, u64, u64), String> {
|
||||
fn preload_blocking(
|
||||
state: &AppState,
|
||||
generation: u64,
|
||||
) -> Result<(&'static str, u64, u64, u64), String> {
|
||||
let ranges = parse_system_ram_ranges().map_err(|e| format!("iomem: {e}"))?;
|
||||
if ranges.is_empty() {
|
||||
return Err("no System RAM ranges found in /proc/iomem".into());
|
||||
@ -168,7 +217,7 @@ fn preload_blocking(state: &AppState) -> Result<(&'static str, u64, u64, u64), S
|
||||
let mut pages: u64 = 0;
|
||||
let mut bytes: u64 = 0;
|
||||
|
||||
match preload_via_devmem(&ranges, state, &mut pages, &mut bytes) {
|
||||
match preload_via_devmem(&ranges, state, generation, &mut pages, &mut bytes) {
|
||||
Ok(()) => Ok(("/dev/mem", ranges.len() as u64, pages, bytes)),
|
||||
Err(devmem_err) => {
|
||||
tracing::warn!(
|
||||
@ -177,13 +226,22 @@ fn preload_blocking(state: &AppState) -> Result<(&'static str, u64, u64, u64), S
|
||||
);
|
||||
pages = 0;
|
||||
bytes = 0;
|
||||
preload_via_kcore(state, &mut pages, &mut bytes)
|
||||
preload_via_kcore(state, generation, &mut pages, &mut bytes)
|
||||
.map_err(|e| format!("/dev/mem: {devmem_err}; /proc/kcore: {e}"))?;
|
||||
Ok(("/proc/kcore", ranges.len() as u64, pages, bytes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// should_stop reports whether the walk must abort: an explicit cancel, or the
|
||||
// lifecycle moved on (a pause froze this thread and a resume /init bumped the
|
||||
// generation — continuing would fault pages nobody waits for and eventually
|
||||
// publish a stale result).
|
||||
fn should_stop(state: &AppState, generation: u64) -> bool {
|
||||
state.mem_preload_cancel.load(Ordering::SeqCst)
|
||||
|| state.mem_preload_generation.load(Ordering::SeqCst) != generation
|
||||
}
|
||||
|
||||
fn parse_system_ram_ranges() -> std::io::Result<Vec<(u64, u64)>> {
|
||||
let data = fs::read_to_string("/proc/iomem")?;
|
||||
let mut out = Vec::new();
|
||||
@ -212,6 +270,7 @@ fn parse_system_ram_ranges() -> std::io::Result<Vec<(u64, u64)>> {
|
||||
fn preload_via_devmem(
|
||||
ranges: &[(u64, u64)],
|
||||
state: &AppState,
|
||||
generation: u64,
|
||||
pages: &mut u64,
|
||||
bytes: &mut u64,
|
||||
) -> std::io::Result<()> {
|
||||
@ -220,7 +279,7 @@ fn preload_via_devmem(
|
||||
for (start, end) in ranges {
|
||||
let mut off = *start;
|
||||
while off < *end {
|
||||
if state.mem_preload_cancel.load(Ordering::SeqCst) {
|
||||
if should_stop(state, generation) {
|
||||
return Ok(());
|
||||
}
|
||||
f.read_at(&mut buf, off)?;
|
||||
@ -238,13 +297,23 @@ fn preload_via_devmem(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Read /proc/kcore's direct-map segment to materialise physical RAM. The
|
||||
// direct map's PT_LOAD covers the kernel's *maximum* possible direct-map
|
||||
// region (64TB on x86_64), not just the present physical RAM — iterating the
|
||||
// whole segment would loop for billions of pages. Bound the walk to the sum
|
||||
// of System RAM ranges from /proc/iomem; sequential reads through the
|
||||
// segment touch consecutive physical pages 1:1, which is what we need.
|
||||
fn preload_via_kcore(state: &AppState, pages: &mut u64, bytes: &mut u64) -> std::io::Result<()> {
|
||||
// Read physical RAM through /proc/kcore's per-range direct-map segments.
|
||||
//
|
||||
// The kernel emits one KCORE_RAM PT_LOAD *per contiguous System RAM range*
|
||||
// (walk_system_ram_range), each with vaddr = direct_map_base + phys_start and
|
||||
// file_size = range size — there is NO single segment covering all of RAM.
|
||||
// Segments must therefore be matched range-by-range against /proc/iomem;
|
||||
// picking "a big kernel-space segment" instead lands on the vmalloc or
|
||||
// vmemmap region, which the kernel zero-fills for unmapped addresses without
|
||||
// ever touching a physical page — the reads "succeed" instantly and nothing
|
||||
// is materialised. Matching failure is a hard error for the same reason:
|
||||
// reading the wrong segment is indistinguishable from success.
|
||||
fn preload_via_kcore(
|
||||
state: &AppState,
|
||||
generation: u64,
|
||||
pages: &mut u64,
|
||||
bytes: &mut u64,
|
||||
) -> std::io::Result<()> {
|
||||
let ram_ranges = parse_system_ram_ranges()?;
|
||||
if ram_ranges.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
@ -252,7 +321,6 @@ fn preload_via_kcore(state: &AppState, pages: &mut u64, bytes: &mut u64) -> std:
|
||||
"no System RAM ranges to bound kcore walk",
|
||||
));
|
||||
}
|
||||
let total_ram_bytes: u64 = ram_ranges.iter().map(|(s, e)| e - s).sum();
|
||||
|
||||
let mut f = fs::File::open("/proc/kcore")?;
|
||||
let segments = parse_kcore_pt_load(&mut f)?;
|
||||
@ -263,55 +331,253 @@ fn preload_via_kcore(state: &AppState, pages: &mut u64, bytes: &mut u64) -> std:
|
||||
));
|
||||
}
|
||||
|
||||
// Pick the direct map: highest-vaddr kernel-space segment large enough
|
||||
// to plausibly cover RAM. KASLR randomises the base, so don't hardcode it.
|
||||
// Kernel virtual addresses start at 0xffff800000000000 on x86_64; vmalloc
|
||||
// / modules sit above the direct map and are usually smaller.
|
||||
const KERNEL_SPACE_MIN: u64 = 0xffff_8000_0000_0000;
|
||||
let direct_map = segments
|
||||
.iter()
|
||||
.filter(|s| s.vaddr >= KERNEL_SPACE_MIN && s.file_size >= total_ram_bytes)
|
||||
.min_by_key(|s| s.vaddr)
|
||||
.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"no PT_LOAD segment large enough for {} bytes of RAM in /proc/kcore",
|
||||
total_ram_bytes
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let ram_segments = match_ram_segments(&ram_ranges, &segments)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
let mut buf = [0u8; 1];
|
||||
let read_bytes = total_ram_bytes.min(direct_map.file_size);
|
||||
let start = direct_map.file_offset;
|
||||
let end = start.saturating_add(read_bytes);
|
||||
let mut off = start;
|
||||
while off < end {
|
||||
if state.mem_preload_cancel.load(Ordering::SeqCst) {
|
||||
return Ok(());
|
||||
}
|
||||
// Reads into MMIO holes within the direct map can fail; ignore so the
|
||||
// loop keeps making progress over the present RAM ranges either side.
|
||||
if f.read_at(&mut buf, off).is_ok() {
|
||||
*pages += 1;
|
||||
*bytes += PAGE_SIZE;
|
||||
if *pages % 256 == 0 {
|
||||
state.mem_preload_pages.store(*pages, Ordering::SeqCst);
|
||||
state.mem_preload_bytes.store(*bytes, Ordering::SeqCst);
|
||||
let mut read_errors: u64 = 0;
|
||||
for seg in &ram_segments {
|
||||
let end = seg.file_offset.saturating_add(seg.len);
|
||||
let mut off = seg.file_offset;
|
||||
while off < end {
|
||||
if should_stop(state, generation) {
|
||||
return Ok(());
|
||||
}
|
||||
match f.read_at(&mut buf, off) {
|
||||
Ok(_) => {
|
||||
*pages += 1;
|
||||
*bytes += PAGE_SIZE;
|
||||
if *pages % 256 == 0 {
|
||||
state.mem_preload_pages.store(*pages, Ordering::SeqCst);
|
||||
state.mem_preload_bytes.store(*bytes, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
Err(_) => read_errors += 1,
|
||||
}
|
||||
off = off.saturating_add(PAGE_SIZE);
|
||||
}
|
||||
off = off.saturating_add(PAGE_SIZE);
|
||||
}
|
||||
|
||||
// A few failed reads are tolerable (hwpoison, offline pages); a walk where
|
||||
// nothing was read means the segments were wrong — report it rather than
|
||||
// letting the host trust an empty "done".
|
||||
if *pages == 0 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("kcore walk read no pages ({read_errors} read errors)"),
|
||||
));
|
||||
}
|
||||
if read_errors > 0 {
|
||||
tracing::warn!(
|
||||
read_errors,
|
||||
pages = *pages,
|
||||
"kcore preload skipped unreadable pages"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// A resolved (file_offset, length) window in /proc/kcore corresponding to one
|
||||
// System RAM range.
|
||||
struct RamSegment {
|
||||
file_offset: u64,
|
||||
len: u64,
|
||||
}
|
||||
|
||||
// Match every System RAM range from /proc/iomem to its KCORE_RAM PT_LOAD
|
||||
// segment. RAM segments share a single direct-map base (vaddr = base +
|
||||
// phys_start; base is KASLR-randomised, so it must be derived, not assumed).
|
||||
// Candidate bases come from size-matched (segment, range) pairs; a base is
|
||||
// accepted only if EVERY RAM range has a segment at its expected vaddr with
|
||||
// the expected size. This can never select vmalloc/vmemmap segments: their
|
||||
// sizes (tens of TB) match no iomem range.
|
||||
//
|
||||
// Sizes are compared after clamping the iomem range to page boundaries:
|
||||
// kcore's segments are PFN-granular (walk_system_ram_range uses
|
||||
// PFN_UP/PFN_DOWN) while iomem ranges are byte-granular — the classic
|
||||
// 0x1000-0x9fbff low-RAM range is 0x9ec00 bytes in iomem but 0x9e000 in
|
||||
// kcore. Exact byte matching would reject every kernel that has such a range.
|
||||
fn match_ram_segments(
|
||||
ranges: &[(u64, u64)],
|
||||
segments: &[KcoreSegment],
|
||||
) -> Result<Vec<RamSegment>, String> {
|
||||
const KERNEL_SPACE_MIN: u64 = 0xffff_8000_0000_0000;
|
||||
|
||||
// Page-clamp: [PFN_UP(start), PFN_DOWN(end)). Ranges smaller than one
|
||||
// page after clamping have no kcore segment and nothing to preload.
|
||||
let clamped: Vec<(u64, u64)> = ranges
|
||||
.iter()
|
||||
.map(|(start, end)| (start.next_multiple_of(PAGE_SIZE), end & !(PAGE_SIZE - 1)))
|
||||
.filter(|(start, end)| end > start)
|
||||
.collect();
|
||||
if clamped.is_empty() {
|
||||
return Err("no page-sized System RAM ranges after clamping".into());
|
||||
}
|
||||
|
||||
let mut candidates: Vec<u64> = Vec::new();
|
||||
for s in segments.iter().filter(|s| s.vaddr >= KERNEL_SPACE_MIN) {
|
||||
for (start, end) in &clamped {
|
||||
if s.file_size == end - start {
|
||||
if let Some(base) = s.vaddr.checked_sub(*start) {
|
||||
candidates.push(base);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.sort_unstable();
|
||||
candidates.dedup();
|
||||
|
||||
let try_base = |base: u64| -> Option<Vec<RamSegment>> {
|
||||
clamped
|
||||
.iter()
|
||||
.map(|(start, end)| {
|
||||
segments
|
||||
.iter()
|
||||
.find(|s| s.vaddr == base + start && s.file_size == end - start)
|
||||
.map(|s| RamSegment {
|
||||
file_offset: s.file_offset,
|
||||
len: end - start,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
for base in &candidates {
|
||||
if let Some(out) = try_base(*base) {
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"no consistent direct-map base for {} RAM ranges across {} PT_LOAD segments \
|
||||
({} candidate bases tried)",
|
||||
clamped.len(),
|
||||
segments.len(),
|
||||
candidates.len(),
|
||||
))
|
||||
}
|
||||
|
||||
struct KcoreSegment {
|
||||
file_offset: u64,
|
||||
file_size: u64,
|
||||
vaddr: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const BASE: u64 = 0xffff_8880_0000_0000; // typical direct-map base, no KASLR
|
||||
const GIB: u64 = 1 << 30;
|
||||
const TIB: u64 = 1 << 40;
|
||||
|
||||
fn seg(vaddr: u64, file_size: u64, file_offset: u64) -> KcoreSegment {
|
||||
KcoreSegment {
|
||||
file_offset,
|
||||
file_size,
|
||||
vaddr,
|
||||
}
|
||||
}
|
||||
|
||||
// Typical CH guest: low RAM hole below 1MB, main range, plus the huge
|
||||
// vmalloc/vmemmap segments that the old heuristic used to pick.
|
||||
fn typical_segments() -> Vec<KcoreSegment> {
|
||||
vec![
|
||||
seg(0xffff_c900_0000_0000, 32 * TIB, 0x1000), // vmalloc
|
||||
seg(0xffff_ea00_0000_0000, TIB, 0x2000), // vmemmap
|
||||
seg(BASE + 0x1000, 0x9e000, 0x10000), // RAM 0x1000-0x9efff
|
||||
seg(BASE + 0x100000, 2 * GIB - 0x100000, 0x20000), // RAM 1MB-2GB
|
||||
]
|
||||
}
|
||||
|
||||
// Byte-granular, as /proc/iomem reports them: the low range ends at
|
||||
// 0x9fbff (parse adds 1 → 0x9fc00), NOT page-aligned. kcore's segment for
|
||||
// it is PFN-clamped to 0x9e000 bytes — matching must tolerate that.
|
||||
fn typical_ranges() -> Vec<(u64, u64)> {
|
||||
vec![(0x1000, 0x9fc00), (0x100000, 2 * GIB)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_all_ranges_and_skips_vmalloc() {
|
||||
let out = match_ram_segments(&typical_ranges(), &typical_segments()).unwrap();
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(out[0].file_offset, 0x10000);
|
||||
assert_eq!(out[0].len, 0x9e000);
|
||||
assert_eq!(out[1].file_offset, 0x20000);
|
||||
assert_eq!(out[1].len, 2 * GIB - 0x100000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kaslr_base_is_derived_not_assumed() {
|
||||
let kaslr = BASE + 37 * GIB; // PUD-granular randomisation
|
||||
let segments = vec![
|
||||
seg(0xffff_c900_0000_0000, 32 * TIB, 0x1000),
|
||||
seg(kaslr + 0x1000, 0x9e000, 0x10000),
|
||||
seg(kaslr + 0x100000, 2 * GIB - 0x100000, 0x20000),
|
||||
];
|
||||
let out = match_ram_segments(&typical_ranges(), &segments).unwrap();
|
||||
assert_eq!(out[1].file_offset, 0x20000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_when_a_range_has_no_segment() {
|
||||
// Second RAM range's segment missing → no base covers all ranges.
|
||||
let segments = vec![
|
||||
seg(0xffff_c900_0000_0000, 32 * TIB, 0x1000),
|
||||
seg(BASE + 0x1000, 0x9e000, 0x10000),
|
||||
];
|
||||
assert!(match_ram_segments(&typical_ranges(), &segments).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_instead_of_falling_back_to_vmalloc() {
|
||||
// Only huge kernel segments present (the exact shape that fooled the
|
||||
// old `file_size >= total_ram` heuristic).
|
||||
let segments = vec![
|
||||
seg(0xffff_c900_0000_0000, 32 * TIB, 0x1000),
|
||||
seg(0xffff_ea00_0000_0000, TIB, 0x2000),
|
||||
];
|
||||
assert!(match_ram_segments(&typical_ranges(), &segments).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unaligned_range_is_page_clamped() {
|
||||
// Range with unaligned start AND end: kcore clamps to
|
||||
// [PFN_UP(start), PFN_DOWN(end)).
|
||||
let ranges = vec![(0x2400, 0x9fbff + 1)];
|
||||
let segments = vec![
|
||||
seg(0xffff_c900_0000_0000, 32 * TIB, 0x1000),
|
||||
seg(BASE + 0x3000, 0x9c000, 0x10000), // 0x3000..0x9f000
|
||||
];
|
||||
let out = match_ram_segments(&ranges, &segments).unwrap();
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].file_offset, 0x10000);
|
||||
assert_eq!(out[0].len, 0x9c000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_page_range_is_skipped() {
|
||||
// A range smaller than one page after clamping (e.g. 0x9fc00-0x9ffff)
|
||||
// has no kcore segment; it must be dropped, not fail the match.
|
||||
let ranges = vec![(0x9fc00, 0xa0000), (0x100000, 2 * GIB)];
|
||||
let segments = vec![seg(BASE + 0x100000, 2 * GIB - 0x100000, 0x20000)];
|
||||
let out = match_ram_segments(&ranges, &segments).unwrap();
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].file_offset, 0x20000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_same_size_ranges_still_resolve() {
|
||||
// Two RAM ranges of identical size: candidate bases from cross pairs
|
||||
// must be rejected; only the true base matches both ranges.
|
||||
let ranges = vec![(0x0, GIB), (2 * GIB, 3 * GIB)];
|
||||
let segments = vec![seg(BASE, GIB, 0x10000), seg(BASE + 2 * GIB, GIB, 0x20000)];
|
||||
let out = match_ram_segments(&ranges, &segments).unwrap();
|
||||
assert_eq!(out[0].file_offset, 0x10000);
|
||||
assert_eq!(out[1].file_offset, 0x20000);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_kcore_pt_load(f: &mut fs::File) -> std::io::Result<Vec<KcoreSegment>> {
|
||||
let mut hdr = [0u8; 64];
|
||||
f.seek(SeekFrom::Start(0))?;
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
pub mod activity;
|
||||
pub mod encoding;
|
||||
pub mod envs;
|
||||
pub mod error;
|
||||
pub mod files;
|
||||
@ -61,7 +60,7 @@ pub fn router(state: Arc<AppState>) -> Router {
|
||||
"/memory/preload/cancel",
|
||||
post(memory::post_memory_preload_cancel),
|
||||
)
|
||||
.route("/files", get(files::get_files).post(files::post_files))
|
||||
.route("/files", get(files::get_files).put(files::put_files))
|
||||
.layer(cors)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@ -20,15 +20,11 @@ pub async fn post_snapshot_prepare(State(state): State<Arc<AppState>>) -> impl I
|
||||
port_sub.stop();
|
||||
}
|
||||
|
||||
// sync(2) flushes the in-memory FS state. Done before drop_caches so the
|
||||
// pages we drop are clean.
|
||||
sync();
|
||||
|
||||
// Drop the VFS page cache + dentries/inodes. Reduces snapshot size by
|
||||
// ensuring CH only persists memory pages that the guest actually needs.
|
||||
if let Err(e) = std::fs::write("/proc/sys/vm/drop_caches", "3") {
|
||||
tracing::warn!(error = %e, "drop_caches (first pass) failed (continuing)");
|
||||
}
|
||||
// Flush in-memory FS state, then drop the VFS page cache +
|
||||
// dentries/inodes. sync first so the pages we drop are clean; dropping
|
||||
// reduces snapshot size by ensuring CH only persists memory pages the
|
||||
// guest actually needs.
|
||||
flush_and_drop_caches("first pass").await;
|
||||
|
||||
// Best-effort fstrim on the rootfs so unused blocks are returned to the
|
||||
// dm-snapshot, keeping CoW size minimal.
|
||||
@ -37,20 +33,17 @@ pub async fn post_snapshot_prepare(State(state): State<Arc<AppState>>) -> impl I
|
||||
.output()
|
||||
.await;
|
||||
|
||||
// Second drop_caches pass after fstrim: fstrim re-reads superblock /
|
||||
// group descriptor pages that we just evicted, putting them back in the
|
||||
// page cache. A second pass drops those and any other late readers (e.g.
|
||||
// sync flushers).
|
||||
sync();
|
||||
if let Err(e) = std::fs::write("/proc/sys/vm/drop_caches", "3") {
|
||||
tracing::warn!(error = %e, "drop_caches (second pass) failed (continuing)");
|
||||
}
|
||||
// Second pass after fstrim: fstrim re-reads superblock / group descriptor
|
||||
// pages that we just evicted, putting them back in the page cache. This
|
||||
// drops those and any other late readers (e.g. sync flushers).
|
||||
flush_and_drop_caches("second pass").await;
|
||||
|
||||
// Free-page reporting drains asynchronously: the balloon driver hands
|
||||
// freed pages to the host in batches and CH punches holes in the backing
|
||||
// memfile. Without a brief settle window most of the pages freed by the
|
||||
// drop_caches passes above would still be present in the snapshot.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
// No balloon settle window here: free-page reporting drains asynchronously,
|
||||
// so any pages not yet hole-punched by the host at snapshot time are written
|
||||
// verbatim — but with init_on_free=1 the guest zeroes them on free, and the
|
||||
// host-side background zero-page punch reclaims them off the pause critical
|
||||
// path. Trading a fixed ~1s of pause latency for a slightly larger artifact
|
||||
// that the async punch later shrinks anyway.
|
||||
|
||||
tracing::info!("snapshot/prepare: quiesced");
|
||||
(
|
||||
@ -58,3 +51,17 @@ pub async fn post_snapshot_prepare(State(state): State<Arc<AppState>>) -> impl I
|
||||
[(header::CACHE_CONTROL, "no-store")],
|
||||
)
|
||||
}
|
||||
|
||||
// sync(2) can block for seconds flushing dirty pages, and the drop_caches
|
||||
// write blocks while the kernel evicts — both run on the blocking pool or
|
||||
// they starve every async task sharing the worker thread (health probes,
|
||||
// exec streams) for the whole quiesce window.
|
||||
async fn flush_and_drop_caches(pass: &'static str) {
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
sync();
|
||||
if let Err(e) = std::fs::write("/proc/sys/vm/drop_caches", "3") {
|
||||
tracing::warn!(error = %e, pass, "drop_caches failed (continuing)");
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
@ -57,10 +57,18 @@ pub fn ensure_dirs(path: &str, uid: Uid, gid: Gid) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
fs::create_dir(¤t)
|
||||
.map_err(|e| format!("failed to create directory {current_str}: {e}"))?;
|
||||
chown(¤t, Some(uid.as_raw()), Some(gid.as_raw()))
|
||||
.map_err(|e| format!("failed to chown directory {current_str}: {e}"))?;
|
||||
if let Err(ce) = fs::create_dir(¤t) {
|
||||
// Concurrent request may have created it between the stat
|
||||
// and here — fine as long as it's a directory now. The
|
||||
// winner did its own chown; don't re-chown their dir.
|
||||
let now_dir = fs::metadata(¤t).map(|m| m.is_dir()).unwrap_or(false);
|
||||
if !now_dir {
|
||||
return Err(format!("failed to create directory {current_str}: {ce}"));
|
||||
}
|
||||
} else {
|
||||
chown(¤t, Some(uid.as_raw()), Some(gid.as_raw()))
|
||||
.map_err(|e| format!("failed to chown directory {current_str}: {e}"))?;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("failed to stat directory {current_str}: {e}"));
|
||||
|
||||
@ -159,26 +159,33 @@ impl Filesystem for FilesystemServiceImpl {
|
||||
|
||||
let path = self.resolve_path(request.path, &ctx)?;
|
||||
|
||||
let resolved = std::fs::canonicalize(&path).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
ConnectError::new(ErrorCode::NotFound, format!("path not found: {e}"))
|
||||
} else {
|
||||
ConnectError::new(ErrorCode::Internal, format!("error resolving path: {e}"))
|
||||
// The recursive walk stats every entry (plus uid/gid lookups) — on a
|
||||
// large tree that is seconds of blocking syscalls, so it runs on the
|
||||
// blocking pool instead of a runtime worker thread.
|
||||
let entries = tokio::task::spawn_blocking(move || {
|
||||
let resolved = std::fs::canonicalize(&path).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
ConnectError::new(ErrorCode::NotFound, format!("path not found: {e}"))
|
||||
} else {
|
||||
ConnectError::new(ErrorCode::Internal, format!("error resolving path: {e}"))
|
||||
}
|
||||
})?;
|
||||
let resolved_str = resolved.to_string_lossy().to_string();
|
||||
|
||||
let meta = std::fs::metadata(&resolved).map_err(|e| {
|
||||
ConnectError::new(ErrorCode::Internal, format!("error getting file info: {e}"))
|
||||
})?;
|
||||
if !meta.is_dir() {
|
||||
return Err(ConnectError::new(
|
||||
ErrorCode::InvalidArgument,
|
||||
format!("path is not a directory: {path}"),
|
||||
));
|
||||
}
|
||||
})?;
|
||||
let resolved_str = resolved.to_string_lossy().to_string();
|
||||
|
||||
let meta = std::fs::metadata(&resolved).map_err(|e| {
|
||||
ConnectError::new(ErrorCode::Internal, format!("error getting file info: {e}"))
|
||||
})?;
|
||||
if !meta.is_dir() {
|
||||
return Err(ConnectError::new(
|
||||
ErrorCode::InvalidArgument,
|
||||
format!("path is not a directory: {path}"),
|
||||
));
|
||||
}
|
||||
|
||||
let entries = walk_dir(&path, &resolved_str, depth)?;
|
||||
walk_dir(&path, &resolved_str, depth)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ConnectError::new(ErrorCode::Internal, format!("list_dir task: {e}")))??;
|
||||
Ok((
|
||||
ListDirResponse {
|
||||
entries,
|
||||
@ -195,14 +202,21 @@ impl Filesystem for FilesystemServiceImpl {
|
||||
) -> Result<(RemoveResponse, Context), ConnectError> {
|
||||
let path = self.resolve_path(request.path, &ctx)?;
|
||||
|
||||
if let Err(e1) = std::fs::remove_dir_all(&path) {
|
||||
if let Err(e2) = std::fs::remove_file(&path) {
|
||||
return Err(ConnectError::new(
|
||||
ErrorCode::Internal,
|
||||
format!("error removing: {e1}; also tried as file: {e2}"),
|
||||
));
|
||||
// remove_dir_all recurses through the whole tree — blocking pool, not
|
||||
// a runtime worker thread.
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if let Err(e1) = std::fs::remove_dir_all(&path) {
|
||||
if let Err(e2) = std::fs::remove_file(&path) {
|
||||
return Err(ConnectError::new(
|
||||
ErrorCode::Internal,
|
||||
format!("error removing: {e1}; also tried as file: {e2}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ConnectError::new(ErrorCode::Internal, format!("remove task: {e}")))??;
|
||||
|
||||
Ok((
|
||||
RemoveResponse {
|
||||
|
||||
@ -1,13 +1,18 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::io::Read;
|
||||
use std::os::fd::{AsFd, OwnedFd};
|
||||
use std::os::unix::io::{AsRawFd, RawFd};
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use connectrpc::{ConnectError, ErrorCode};
|
||||
use nix::pty::{Winsize, openpty};
|
||||
use nix::sys::signal::{self, Signal};
|
||||
use nix::unistd::Pid;
|
||||
use tokio::io::Interest;
|
||||
use tokio::io::unix::AsyncFd;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::rpc::pb::process::*;
|
||||
@ -16,11 +21,28 @@ const STD_CHUNK_SIZE: usize = 32768;
|
||||
const PTY_CHUNK_SIZE: usize = 16384;
|
||||
const BROADCAST_CAPACITY: usize = 4096;
|
||||
|
||||
// Coalescing window for output reads. After the first read of a burst we keep
|
||||
// draining whatever is already available on the fd for up to COALESCE_FLUSH_MS
|
||||
// (or until COALESCE_CAP bytes accumulate), then publish one merged chunk. This
|
||||
// collapses a full-screen TUI redraw — which the app emits as many small writes
|
||||
// — into a single stream message, cutting per-message framing/encoding overhead
|
||||
// across the whole path. Total added latency per flush is bounded by the window.
|
||||
const COALESCE_FLUSH_MS: u64 = 4;
|
||||
const COALESCE_CAP: usize = 64 * 1024;
|
||||
|
||||
// Upper bound on the per-process output kept for replay. A late Connect gets
|
||||
// the most recent OUTPUT_LOG_CAPACITY bytes (older output is evicted) so the
|
||||
// buffer can never grow without bound for a chatty long-running process.
|
||||
const OUTPUT_LOG_CAPACITY: usize = 256 * 1024;
|
||||
|
||||
// Bound on how long one input write may wait for the target process to drain
|
||||
// its buffer. The stdin pipe / pty master is O_NONBLOCK, so a full buffer
|
||||
// parks the writing task (never an OS thread); within this window a
|
||||
// briefly-full buffer recovers transparently, past it the write fails with
|
||||
// ResourceExhausted instead of hanging forever on a process that stopped
|
||||
// reading its input.
|
||||
const INPUT_WRITE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum DataEvent {
|
||||
Stdout(Vec<u8>),
|
||||
@ -67,6 +89,90 @@ fn ev_len(ev: &DataEvent) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Blocking read loop that coalesces bursts: once a read returns, keep draining
|
||||
/// whatever is already available on the fd (bounded by [`COALESCE_FLUSH_MS`] and
|
||||
/// [`COALESCE_CAP`]) before handing the accumulated bytes to `publish`. A full
|
||||
/// TUI redraw — emitted by the app as many small writes — collapses into one
|
||||
/// output message instead of dozens. Latency added per flush is bounded by the
|
||||
/// window, so interactive feel is preserved even for a slow trickle of output.
|
||||
fn coalesce_read_loop<R, F>(mut reader: R, chunk_size: usize, mut publish: F)
|
||||
where
|
||||
R: Read + AsRawFd,
|
||||
F: FnMut(Vec<u8>),
|
||||
{
|
||||
let fd = reader.as_raw_fd();
|
||||
let mut rbuf = vec![0u8; chunk_size];
|
||||
let mut acc: Vec<u8> = Vec::new();
|
||||
loop {
|
||||
match reader.read(&mut rbuf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
acc.extend_from_slice(&rbuf[..n]);
|
||||
let deadline = Instant::now() + Duration::from_millis(COALESCE_FLUSH_MS);
|
||||
while acc.len() < COALESCE_CAP {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
break;
|
||||
}
|
||||
let mut pfd = libc::pollfd {
|
||||
fd,
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
let timeout_ms = remaining.as_millis().min(i32::MAX as u128) as i32;
|
||||
let r = unsafe { libc::poll(&mut pfd, 1, timeout_ms) };
|
||||
if r < 0 {
|
||||
// Interrupted by a signal (envd runs as PID 1, so SIGCHLD
|
||||
// et al. land here): retry within the remaining window
|
||||
// instead of cutting the coalesce burst short.
|
||||
let errno = std::io::Error::last_os_error().raw_os_error();
|
||||
if errno == Some(libc::EINTR) {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if r == 0 || (pfd.revents & libc::POLLIN) == 0 {
|
||||
break;
|
||||
}
|
||||
match reader.read(&mut rbuf) {
|
||||
Ok(0) => {
|
||||
if !acc.is_empty() {
|
||||
publish(std::mem::take(&mut acc));
|
||||
}
|
||||
return;
|
||||
}
|
||||
Ok(m) => acc.extend_from_slice(&rbuf[..m]),
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
publish(std::mem::take(&mut acc));
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
// The pty master is O_NONBLOCK for the input write path and
|
||||
// shares its file description with this reader. Wait for
|
||||
// readability and retry; on POLLHUP/POLLERR the retried read
|
||||
// returns 0/EIO and ends the loop.
|
||||
let mut pfd = libc::pollfd {
|
||||
fd,
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
if unsafe { libc::poll(&mut pfd, 1, -1) } < 0 {
|
||||
let errno = std::io::Error::last_os_error().raw_os_error();
|
||||
if errno != Some(libc::EINTR) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
if !acc.is_empty() {
|
||||
publish(acc);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProcessHandle {
|
||||
pub config: ProcessConfig,
|
||||
pub tag: Option<String>,
|
||||
@ -79,6 +185,10 @@ pub struct ProcessHandle {
|
||||
|
||||
stdin: Mutex<Option<std::process::ChildStdin>>,
|
||||
pty_master: Mutex<Option<std::fs::File>>,
|
||||
// Serializes input writes so concurrent chunks land in order. A tokio
|
||||
// mutex: a writer parked on a full buffer holds it across an await, and
|
||||
// the next writer waits as a suspended task — no OS thread is pinned.
|
||||
input_gate: tokio::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
impl ProcessHandle {
|
||||
@ -124,32 +234,41 @@ impl ProcessHandle {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write_stdin(&self, data: &[u8]) -> Result<(), ConnectError> {
|
||||
use std::io::Write;
|
||||
let mut guard = self.stdin.lock().unwrap();
|
||||
match guard.as_mut() {
|
||||
Some(stdin) => stdin.write_all(data).map_err(|e| {
|
||||
ConnectError::new(ErrorCode::Internal, format!("error writing to stdin: {e}"))
|
||||
}),
|
||||
None => Err(ConnectError::new(
|
||||
ErrorCode::FailedPrecondition,
|
||||
"stdin not enabled or closed",
|
||||
)),
|
||||
}
|
||||
pub async fn write_stdin(&self, data: &[u8]) -> Result<(), ConnectError> {
|
||||
let _ordered = self.input_gate.lock().await;
|
||||
// Dup the fd under the std mutex, then release it before awaiting:
|
||||
// close_stdin stays responsive while a write is parked, and the fd
|
||||
// stays valid for this write even if stdin is closed concurrently.
|
||||
let fd = {
|
||||
let guard = self.stdin.lock().unwrap();
|
||||
match guard.as_ref() {
|
||||
Some(stdin) => dup_writer_fd(stdin, "stdin")?,
|
||||
None => {
|
||||
return Err(ConnectError::new(
|
||||
ErrorCode::FailedPrecondition,
|
||||
"stdin not enabled or closed",
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
write_nonblocking(fd, data, "stdin").await
|
||||
}
|
||||
|
||||
pub fn write_pty(&self, data: &[u8]) -> Result<(), ConnectError> {
|
||||
use std::io::Write;
|
||||
let mut guard = self.pty_master.lock().unwrap();
|
||||
match guard.as_mut() {
|
||||
Some(master) => master.write_all(data).map_err(|e| {
|
||||
ConnectError::new(ErrorCode::Internal, format!("error writing to pty: {e}"))
|
||||
}),
|
||||
None => Err(ConnectError::new(
|
||||
ErrorCode::FailedPrecondition,
|
||||
"pty not assigned to process",
|
||||
)),
|
||||
}
|
||||
pub async fn write_pty(&self, data: &[u8]) -> Result<(), ConnectError> {
|
||||
let _ordered = self.input_gate.lock().await;
|
||||
let fd = {
|
||||
let guard = self.pty_master.lock().unwrap();
|
||||
match guard.as_ref() {
|
||||
Some(master) => dup_writer_fd(master, "pty")?,
|
||||
None => {
|
||||
return Err(ConnectError::new(
|
||||
ErrorCode::FailedPrecondition,
|
||||
"pty not assigned to process",
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
write_nonblocking(fd, data, "pty").await
|
||||
}
|
||||
|
||||
pub fn close_stdin(&self) -> Result<(), ConnectError> {
|
||||
@ -195,12 +314,109 @@ impl ProcessHandle {
|
||||
}
|
||||
}
|
||||
|
||||
fn dup_writer_fd(f: &impl AsFd, what: &str) -> Result<OwnedFd, ConnectError> {
|
||||
f.as_fd()
|
||||
.try_clone_to_owned()
|
||||
.map_err(|e| ConnectError::new(ErrorCode::Internal, format!("dup {what} fd: {e}")))
|
||||
}
|
||||
|
||||
/// Write `data` to a non-blocking fd from async context. Small interactive
|
||||
/// writes complete inline; a full buffer surfaces as WouldBlock and we await
|
||||
/// writability with a deadline, so a process that stopped reading its input
|
||||
/// costs a parked task — never a pinned OS thread.
|
||||
async fn write_nonblocking(fd: OwnedFd, data: &[u8], what: &str) -> Result<(), ConnectError> {
|
||||
let afd = AsyncFd::with_interest(fd, Interest::WRITABLE)
|
||||
.map_err(|e| ConnectError::new(ErrorCode::Internal, format!("register {what} fd: {e}")))?;
|
||||
let deadline = tokio::time::Instant::now() + INPUT_WRITE_TIMEOUT;
|
||||
let mut written = 0usize;
|
||||
while written < data.len() {
|
||||
let rest = &data[written..];
|
||||
let n = unsafe {
|
||||
libc::write(
|
||||
afd.get_ref().as_raw_fd(),
|
||||
rest.as_ptr() as *const libc::c_void,
|
||||
rest.len(),
|
||||
)
|
||||
};
|
||||
if n >= 0 {
|
||||
written += n as usize;
|
||||
continue;
|
||||
}
|
||||
let err = std::io::Error::last_os_error();
|
||||
match err.kind() {
|
||||
std::io::ErrorKind::WouldBlock => {
|
||||
match tokio::time::timeout_at(deadline, afd.writable()).await {
|
||||
Ok(Ok(mut ready)) => ready.clear_ready(),
|
||||
Ok(Err(e)) => {
|
||||
return Err(ConnectError::new(
|
||||
ErrorCode::Internal,
|
||||
format!("error waiting for {what} to become writable: {e}"),
|
||||
));
|
||||
}
|
||||
Err(_) => {
|
||||
return Err(ConnectError::new(
|
||||
ErrorCode::ResourceExhausted,
|
||||
format!(
|
||||
"{what} input buffer full ({written} of {} bytes written): process is not reading its input",
|
||||
data.len()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
std::io::ErrorKind::Interrupted => {}
|
||||
_ => {
|
||||
return Err(ConnectError::new(
|
||||
ErrorCode::Internal,
|
||||
format!("error writing to {what}: {err}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_nonblocking(fd: RawFd) -> std::io::Result<()> {
|
||||
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
|
||||
if flags < 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
if unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct SpawnedProcess {
|
||||
pub handle: Arc<ProcessHandle>,
|
||||
pub data_rx: broadcast::Receiver<DataEvent>,
|
||||
pub end_rx: broadcast::Receiver<EndEvent>,
|
||||
}
|
||||
|
||||
/// Switch the child to the target gid/uid, dropping supplementary groups first
|
||||
/// and checking every step. Order is load-bearing: supplementary groups must be
|
||||
/// cleared while still privileged (setgroups after setuid fails), otherwise a
|
||||
/// non-root child inherits PID 1's root supplementary groups. Checking the
|
||||
/// return values turns a failed drop into a failed spawn instead of silently
|
||||
/// executing the command as root.
|
||||
///
|
||||
/// Runs inside `pre_exec`, so it must stay async-signal-safe: raw libc calls
|
||||
/// only, no allocation.
|
||||
unsafe fn switch_user(uid: libc::uid_t, gid: libc::gid_t) -> std::io::Result<()> {
|
||||
unsafe {
|
||||
if libc::setgroups(0, std::ptr::null()) != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
if libc::setgid(gid) != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
if libc::setuid(uid) != 0 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn spawn_process(
|
||||
cmd_str: &str,
|
||||
args: &[String],
|
||||
@ -259,7 +475,12 @@ test -f "${HOME}/.bashrc" && . "${HOME}/.bashrc""#;
|
||||
// ($1 is cmd_str; $0 of the inner shell is the shell path).
|
||||
// - with args: exec the program + args directly, no shell interpretation
|
||||
// (backward-compatible program/argv form).
|
||||
let target = if args.is_empty() {
|
||||
let target = if cmd_str.is_empty() && args.is_empty() {
|
||||
// No command at all (e.g. an interactive PTY session with no explicit
|
||||
// command): launch the user's login shell directly. Under a pty its
|
||||
// stdin is a tty, so it starts interactively.
|
||||
format!(r#""{shell}""#)
|
||||
} else if args.is_empty() {
|
||||
format!(r#""{shell}" -c "$1" "{shell}""#)
|
||||
} else {
|
||||
r#""$@""#.to_string()
|
||||
@ -311,7 +532,7 @@ exec {nice_prefix}{target}"#
|
||||
let master_fd = pty_result.master;
|
||||
let slave_fd = pty_result.slave;
|
||||
|
||||
let mut command = std::process::Command::new("/bin/bash");
|
||||
let mut command = std::process::Command::new(&shell);
|
||||
command
|
||||
.args(&wrapper_args)
|
||||
.env_clear()
|
||||
@ -333,8 +554,7 @@ exec {nice_prefix}{target}"#
|
||||
if slave_raw > 2 {
|
||||
libc::close(slave_raw);
|
||||
}
|
||||
libc::setgid(gid);
|
||||
libc::setuid(uid);
|
||||
switch_user(uid, gid)?;
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@ -354,6 +574,19 @@ exec {nice_prefix}{target}"#
|
||||
|
||||
let pid = child.id();
|
||||
let master_file: std::fs::File = master_fd.into();
|
||||
// O_NONBLOCK is per file description, so it covers the write path and
|
||||
// the reader clone alike; coalesce_read_loop handles the resulting
|
||||
// WouldBlock by polling. Without it a write would block an OS thread,
|
||||
// so treat failure as a failed spawn.
|
||||
if let Err(e) = set_nonblocking(master_file.as_raw_fd()) {
|
||||
let mut child = child;
|
||||
let _ = signal::kill(Pid::from_raw(-(pid as i32)), Signal::SIGKILL);
|
||||
let _ = child.wait();
|
||||
return Err(ConnectError::new(
|
||||
ErrorCode::Internal,
|
||||
format!("set pty master non-blocking: {e}"),
|
||||
));
|
||||
}
|
||||
let master_clone = master_file.try_clone().unwrap();
|
||||
|
||||
let handle = Arc::new(ProcessHandle {
|
||||
@ -366,6 +599,7 @@ exec {nice_prefix}{target}"#
|
||||
output_log: Mutex::new(OutputLog::default()),
|
||||
stdin: Mutex::new(None),
|
||||
pty_master: Mutex::new(Some(master_file)),
|
||||
input_gate: tokio::sync::Mutex::new(()),
|
||||
});
|
||||
|
||||
let data_rx = handle.subscribe_data();
|
||||
@ -373,17 +607,9 @@ exec {nice_prefix}{target}"#
|
||||
|
||||
let handle_for_reader = Arc::clone(&handle);
|
||||
let pty_reader = std::thread::spawn(move || {
|
||||
let mut master = master_clone;
|
||||
let mut buf = vec![0u8; PTY_CHUNK_SIZE];
|
||||
loop {
|
||||
match master.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
handle_for_reader.publish_data(DataEvent::Pty(buf[..n].to_vec()));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
coalesce_read_loop(master_clone, PTY_CHUNK_SIZE, |chunk| {
|
||||
handle_for_reader.publish_data(DataEvent::Pty(chunk));
|
||||
});
|
||||
});
|
||||
|
||||
let end_tx_clone = end_tx.clone();
|
||||
@ -419,7 +645,7 @@ exec {nice_prefix}{target}"#
|
||||
end_rx,
|
||||
})
|
||||
} else {
|
||||
let mut command = std::process::Command::new("/bin/bash");
|
||||
let mut command = std::process::Command::new(&shell);
|
||||
command
|
||||
.args(&wrapper_args)
|
||||
.env_clear()
|
||||
@ -441,8 +667,7 @@ exec {nice_prefix}{target}"#
|
||||
// for free via setsid().
|
||||
nix::unistd::setpgid(Pid::from_raw(0), Pid::from_raw(0))
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
|
||||
libc::setgid(gid);
|
||||
libc::setuid(uid);
|
||||
switch_user(uid, gid)?;
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@ -456,6 +681,21 @@ exec {nice_prefix}{target}"#
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
|
||||
// Non-blocking stdin is what lets write_stdin fail fast instead of
|
||||
// pinning an OS thread when the process stops draining its input.
|
||||
// Only the pipe's write end is affected — the child's read end is a
|
||||
// separate file description.
|
||||
if let Some(s) = stdin.as_ref() {
|
||||
if let Err(e) = set_nonblocking(s.as_raw_fd()) {
|
||||
let _ = signal::kill(Pid::from_raw(-(pid as i32)), Signal::SIGKILL);
|
||||
let _ = child.wait();
|
||||
return Err(ConnectError::new(
|
||||
ErrorCode::Internal,
|
||||
format!("set stdin non-blocking: {e}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let handle = Arc::new(ProcessHandle {
|
||||
config,
|
||||
tag,
|
||||
@ -466,6 +706,7 @@ exec {nice_prefix}{target}"#
|
||||
output_log: Mutex::new(OutputLog::default()),
|
||||
stdin: Mutex::new(stdin),
|
||||
pty_master: Mutex::new(None),
|
||||
input_gate: tokio::sync::Mutex::new(()),
|
||||
});
|
||||
|
||||
let data_rx = handle.subscribe_data();
|
||||
@ -473,35 +714,21 @@ exec {nice_prefix}{target}"#
|
||||
|
||||
let mut output_readers: Vec<std::thread::JoinHandle<()>> = Vec::new();
|
||||
|
||||
if let Some(mut out) = stdout {
|
||||
if let Some(out) = stdout {
|
||||
let handle_for_reader = Arc::clone(&handle);
|
||||
output_readers.push(std::thread::spawn(move || {
|
||||
let mut buf = vec![0u8; STD_CHUNK_SIZE];
|
||||
loop {
|
||||
match out.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
handle_for_reader.publish_data(DataEvent::Stdout(buf[..n].to_vec()));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
coalesce_read_loop(out, STD_CHUNK_SIZE, |chunk| {
|
||||
handle_for_reader.publish_data(DataEvent::Stdout(chunk));
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(mut err_pipe) = stderr {
|
||||
if let Some(err_pipe) = stderr {
|
||||
let handle_for_reader = Arc::clone(&handle);
|
||||
output_readers.push(std::thread::spawn(move || {
|
||||
let mut buf = vec![0u8; STD_CHUNK_SIZE];
|
||||
loop {
|
||||
match err_pipe.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
handle_for_reader.publish_data(DataEvent::Stderr(buf[..n].to_vec()));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
coalesce_read_loop(err_pipe, STD_CHUNK_SIZE, |chunk| {
|
||||
handle_for_reader.publish_data(DataEvent::Stderr(chunk));
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
@ -553,3 +780,112 @@ fn current_nice() -> i32 {
|
||||
prio
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn spawn(cmd: &str, enable_stdin: bool, pty: Option<(u16, u16)>) -> SpawnedProcess {
|
||||
let user = nix::unistd::User::from_uid(nix::unistd::getuid())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
spawn_process(
|
||||
cmd,
|
||||
&[],
|
||||
&HashMap::new(),
|
||||
"/tmp",
|
||||
pty,
|
||||
enable_stdin,
|
||||
None,
|
||||
&user,
|
||||
&dashmap::DashMap::new(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Wait until the accumulated bytes from `recv` contain `needle`.
|
||||
async fn recv_until_contains(
|
||||
rx: &mut broadcast::Receiver<DataEvent>,
|
||||
needle: &[u8],
|
||||
what: &str,
|
||||
) -> Vec<u8> {
|
||||
let mut acc: Vec<u8> = Vec::new();
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(DataEvent::Stdout(d)) | Ok(DataEvent::Pty(d)) => {
|
||||
acc.extend_from_slice(&d);
|
||||
if acc.windows(needle.len()).any(|w| w == needle) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(DataEvent::Stderr(_)) => {}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {}
|
||||
Err(e) => panic!("{what}: data stream closed early: {e}"),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("{what}: output never contained expected bytes"));
|
||||
acc
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn write_to_stuck_stdin_errors_instead_of_hanging() {
|
||||
// sleep never reads stdin: the 64K pipe buffer fills and the write
|
||||
// must fail with ResourceExhausted after the bounded wait — the old
|
||||
// write_all here blocked a pool thread forever.
|
||||
let spawned = spawn("sleep 60", true, None);
|
||||
let handle = Arc::clone(&spawned.handle);
|
||||
let big = vec![b'x'; 1024 * 1024];
|
||||
let writer = tokio::spawn(async move { handle.write_stdin(&big).await });
|
||||
|
||||
// While that write is parked, the agent must stay fully responsive:
|
||||
// a fresh process spawns, runs, and reports its exit.
|
||||
let mut quick = spawn("true", false, None);
|
||||
let end = tokio::time::timeout(Duration::from_secs(10), quick.end_rx.recv())
|
||||
.await
|
||||
.expect("agent unresponsive while stdin write pending")
|
||||
.expect("end event");
|
||||
assert!(end.exited);
|
||||
|
||||
let err = tokio::time::timeout(INPUT_WRITE_TIMEOUT + Duration::from_secs(10), writer)
|
||||
.await
|
||||
.expect("stdin write hung past its deadline")
|
||||
.expect("writer task panicked")
|
||||
.expect_err("write into a full pipe nobody reads must error");
|
||||
assert!(
|
||||
matches!(err.code, ErrorCode::ResourceExhausted),
|
||||
"expected ResourceExhausted, got {:?}",
|
||||
err.code
|
||||
);
|
||||
|
||||
let _ = spawned.handle.send_signal(Signal::SIGKILL);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn stdin_write_and_eof_still_work() {
|
||||
let mut spawned = spawn("cat", true, None);
|
||||
spawned.handle.write_stdin(b"hello\n").await.unwrap();
|
||||
recv_until_contains(&mut spawned.data_rx, b"hello\n", "cat stdout").await;
|
||||
|
||||
// close_stdin must still deliver EOF with the non-blocking pipe.
|
||||
spawned.handle.close_stdin().unwrap();
|
||||
let end = tokio::time::timeout(Duration::from_secs(10), spawned.end_rx.recv())
|
||||
.await
|
||||
.expect("cat did not exit after stdin EOF")
|
||||
.expect("end event");
|
||||
assert_eq!(end.exit_code, 0);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn pty_write_and_echo_still_work() {
|
||||
// O_NONBLOCK on the pty master is shared with the output reader;
|
||||
// this covers both directions surviving it.
|
||||
let mut spawned = spawn("cat", false, Some((80, 24)));
|
||||
spawned.handle.write_pty(b"hello\r").await.unwrap();
|
||||
recv_until_contains(&mut spawned.data_rx, b"hello", "pty echo").await;
|
||||
let _ = spawned.handle.send_signal(Signal::SIGKILL);
|
||||
}
|
||||
}
|
||||
|
||||
@ -72,7 +72,12 @@ impl ProcessServiceImpl {
|
||||
ConnectError::new(ErrorCode::InvalidArgument, "process config required")
|
||||
})?;
|
||||
|
||||
let username = self.state.defaults.user();
|
||||
// Per-request user overrides the sandbox default when provided.
|
||||
let username = if proc_config.user.is_empty() {
|
||||
self.state.defaults.user()
|
||||
} else {
|
||||
proc_config.user.to_string()
|
||||
};
|
||||
let user = lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
||||
|
||||
let cmd_raw: &str = proc_config.cmd;
|
||||
@ -300,7 +305,7 @@ impl Process for ProcessServiceImpl {
|
||||
ConnectError::new(ErrorCode::FailedPrecondition, "no start event received")
|
||||
})?;
|
||||
if let Some(input) = data.input.as_option() {
|
||||
write_input(h, input)?;
|
||||
write_input(h, input).await?;
|
||||
}
|
||||
}
|
||||
Some(stream_input_request::EventView::Keepalive(_)) => {}
|
||||
@ -327,7 +332,7 @@ impl Process for ProcessServiceImpl {
|
||||
let handle = self.get_process_by_selector(selector)?;
|
||||
|
||||
if let Some(input) = request.input.as_option() {
|
||||
write_input(&handle, input)?;
|
||||
write_input(&handle, input).await?;
|
||||
}
|
||||
|
||||
Ok((
|
||||
@ -387,10 +392,18 @@ impl Process for ProcessServiceImpl {
|
||||
}
|
||||
}
|
||||
|
||||
fn write_input(handle: &ProcessHandle, input: &ProcessInputView) -> Result<(), ConnectError> {
|
||||
// Input writes go straight to the fd, which is O_NONBLOCK (set at spawn):
|
||||
// small interactive writes complete inline on the async worker, and a full
|
||||
// buffer parks the task awaiting writability with a bounded deadline inside
|
||||
// write_stdin / write_pty — no blocking-pool thread is ever pinned by a
|
||||
// process that stopped reading its input.
|
||||
async fn write_input(
|
||||
handle: &Arc<ProcessHandle>,
|
||||
input: &ProcessInputView<'_>,
|
||||
) -> Result<(), ConnectError> {
|
||||
match &input.input {
|
||||
Some(process_input::InputView::Pty(d)) => handle.write_pty(d),
|
||||
Some(process_input::InputView::Stdin(d)) => handle.write_stdin(d),
|
||||
Some(process_input::InputView::Pty(d)) => handle.write_pty(d).await,
|
||||
Some(process_input::InputView::Stdin(d)) => handle.write_stdin(d).await,
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,6 +31,17 @@ pub struct AppState {
|
||||
pub mem_preload_started: AtomicBool,
|
||||
pub mem_preload_done: AtomicBool,
|
||||
pub mem_preload_cancel: AtomicBool,
|
||||
/// See `reset_preload_run` for the per-run reset shared by /init and the
|
||||
/// POST /memory/preload start/retry paths.
|
||||
///
|
||||
/// Bumped by every /init lifecycle change. A loader thread captures the
|
||||
/// value at spawn and refuses to run — or to publish results — once it no
|
||||
/// longer matches, so a thread that survived a pause/resume (the VM can be
|
||||
/// frozen mid-walk) cannot store a stale `done=true` for the NEXT
|
||||
/// lifecycle's preload. Publication happens under `mem_preload_error`'s
|
||||
/// mutex, which /init also holds while bumping, closing the
|
||||
/// freeze-between-check-and-store window.
|
||||
pub mem_preload_generation: AtomicU64,
|
||||
pub mem_preload_regions: AtomicU64,
|
||||
pub mem_preload_pages: AtomicU64,
|
||||
pub mem_preload_bytes: AtomicU64,
|
||||
@ -66,6 +77,7 @@ impl AppState {
|
||||
mem_preload_started: AtomicBool::new(false),
|
||||
mem_preload_done: AtomicBool::new(false),
|
||||
mem_preload_cancel: AtomicBool::new(false),
|
||||
mem_preload_generation: AtomicU64::new(0),
|
||||
mem_preload_regions: AtomicU64::new(0),
|
||||
mem_preload_pages: AtomicU64::new(0),
|
||||
mem_preload_bytes: AtomicU64::new(0),
|
||||
@ -101,6 +113,22 @@ impl AppState {
|
||||
|
||||
/// Records a new lifecycle ID, returning true if it changed (i.e. this
|
||||
/// is the first /init since a resume). First-ever call returns false:
|
||||
/// Clears the per-run memory-preload result fields (done flag, counters,
|
||||
/// source, error). Shared by /init's lifecycle reset and POST
|
||||
/// /memory/preload's start/retry paths so the two field lists cannot
|
||||
/// drift. The caller MUST hold the `mem_preload_error` mutex and pass its
|
||||
/// guard's contents in — that mutex is the critical section that also
|
||||
/// serializes the loader thread's result publication.
|
||||
pub fn reset_preload_run(&self, err: &mut Option<String>) {
|
||||
*err = None;
|
||||
self.mem_preload_done.store(false, Ordering::SeqCst);
|
||||
self.mem_preload_regions.store(0, Ordering::SeqCst);
|
||||
self.mem_preload_pages.store(0, Ordering::SeqCst);
|
||||
self.mem_preload_bytes.store(0, Ordering::SeqCst);
|
||||
self.mem_preload_elapsed_us.store(0, Ordering::SeqCst);
|
||||
self.mem_preload_source.store(0, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// boot-time /init doesn't need port-subsystem restart since the
|
||||
/// subsystem hasn't been started yet by anything else.
|
||||
pub fn bump_lifecycle(&self, new_id: &str) -> bool {
|
||||
|
||||
@ -2,7 +2,7 @@ import { apiFetch, apiFetchMultipart, type ApiResult } from '$lib/api/client';
|
||||
|
||||
export type BuildLogEntry = {
|
||||
step: number;
|
||||
phase: string; // "pre-build", "recipe", or "post-build"
|
||||
phase: string; // "pre-build", "recipe", "post-build", or "healthcheck"
|
||||
cmd: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
|
||||
@ -103,7 +103,11 @@ export function createBuildConsole(buildId: string) {
|
||||
exit: ev.exit ?? 0,
|
||||
elapsedMs: ev.elapsed_ms ?? 0
|
||||
});
|
||||
if (typeof ev.step === 'number' && ev.step > currentStep) currentStep = ev.step;
|
||||
// The healthcheck is shown as a trailing pseudo-step but is not
|
||||
// counted in total_steps, so it must not advance the counter.
|
||||
if (ev.phase !== 'healthcheck' && typeof ev.step === 'number' && ev.step > currentStep) {
|
||||
currentStep = ev.step;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'build-status':
|
||||
|
||||
@ -27,6 +27,8 @@
|
||||
return 'var(--color-text-bright)';
|
||||
case 'WORKDIR':
|
||||
return 'var(--color-text-tertiary)';
|
||||
case 'HEALTHCHECK':
|
||||
return 'var(--color-accent-bright)';
|
||||
default:
|
||||
return 'var(--color-text-muted)';
|
||||
}
|
||||
@ -111,9 +113,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
<code class="mt-1.5 block truncate font-mono text-meta">
|
||||
<span style="color: {keywordColor(kw)}">{kw}</span>{#if rest}
|
||||
<span class="text-[var(--color-text-secondary)]">{rest}</span>
|
||||
{/if}
|
||||
<span style="color: {keywordColor(kw)}">{kw}</span>{#if rest}{' '}<span
|
||||
class="text-[var(--color-text-secondary)]">{rest}</span>{/if}
|
||||
</code>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
};
|
||||
let { open, onclose, oncreated, templateSource = 'team' }: Props = $props();
|
||||
|
||||
let createForm = $state<CreateCapsuleParams>({ template: 'minimal-ubuntu', vcpus: 1, memory_mb: 512, timeout_sec: 0 });
|
||||
let createForm = $state<CreateCapsuleParams>({ template: 'minimal-ubuntu', vcpus: 2, memory_mb: 2048, timeout_sec: 0 });
|
||||
let creating = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
|
||||
@ -120,7 +120,7 @@
|
||||
const creator = templateSource === 'platform' ? createAdminCapsule : createCapsule;
|
||||
const result = await creator(createForm);
|
||||
if (result.ok) {
|
||||
createForm = { template: 'minimal-ubuntu', vcpus: 1, memory_mb: 512, timeout_sec: 0 };
|
||||
createForm = { template: 'minimal-ubuntu', vcpus: 2, memory_mb: 2048, timeout_sec: 0 };
|
||||
templateQuery = 'minimal-ubuntu';
|
||||
onclose();
|
||||
oncreated?.(result.data);
|
||||
|
||||
@ -78,19 +78,6 @@
|
||||
brightWhite: '#eae7e2',
|
||||
};
|
||||
|
||||
// Binary-safe base64 encode (handles multi-byte UTF-8 from xterm onData)
|
||||
function toBase64(str: string): string {
|
||||
return btoa(
|
||||
Array.from(new TextEncoder().encode(str), (b) => String.fromCharCode(b)).join('')
|
||||
);
|
||||
}
|
||||
|
||||
// Binary-safe base64 decode (handles raw PTY bytes)
|
||||
function fromBase64(b64: string): string {
|
||||
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
function getWsUrl(): string {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${proto}//${window.location.host}${apiBasePath}/${capsuleId}/pty`;
|
||||
@ -104,6 +91,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Raw keystrokes go as binary frames (no base64/JSON wrapper); control
|
||||
// messages stay JSON text. Mirrors the binary output path.
|
||||
function wsSendBinary(ws: WebSocket | null, data: Uint8Array) {
|
||||
try {
|
||||
if (ws?.readyState === WebSocket.OPEN) ws.send(data);
|
||||
} catch {
|
||||
// Connection closing — ignore
|
||||
}
|
||||
}
|
||||
|
||||
function updateSession(id: number, updates: Partial<SessionDisplay>) {
|
||||
const idx = sessions.findIndex(s => s.id === id);
|
||||
if (idx === -1) return;
|
||||
@ -230,7 +227,7 @@
|
||||
if (!int) return;
|
||||
int.inputFlushTimer = null;
|
||||
if (!int.inputBuffer) return;
|
||||
wsSend(int.ws, JSON.stringify({ type: 'input', data: toBase64(int.inputBuffer) }));
|
||||
wsSendBinary(int.ws, new TextEncoder().encode(int.inputBuffer));
|
||||
int.inputBuffer = '';
|
||||
}
|
||||
|
||||
@ -265,6 +262,9 @@
|
||||
|
||||
// Browser sends wrenn_sid cookie on the WS upgrade automatically (same-origin).
|
||||
const ws = new WebSocket(getWsUrl());
|
||||
// PTY output arrives as raw binary frames (permessage-deflate compressed
|
||||
// by the server); control messages stay JSON text.
|
||||
ws.binaryType = 'arraybuffer';
|
||||
int.ws = ws;
|
||||
updateSession(id, { state: 'connecting', errorMessage: null });
|
||||
|
||||
@ -278,13 +278,18 @@
|
||||
if (tag) {
|
||||
msg.tag = tag;
|
||||
} else {
|
||||
msg.cmd = '/bin/bash';
|
||||
// No cmd: the server launches the user's default login shell.
|
||||
msg.envs = { TERM: 'xterm-256color' };
|
||||
}
|
||||
wsSend(ws, JSON.stringify(msg));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// Binary frames are raw PTY output — write straight to the terminal.
|
||||
if (typeof event.data !== 'string') {
|
||||
int.term.write(new Uint8Array(event.data));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
switch (msg.type) {
|
||||
@ -296,9 +301,6 @@
|
||||
});
|
||||
if (activeSessionId === id) int.term.focus();
|
||||
break;
|
||||
case 'output':
|
||||
if (msg.data) int.term.write(fromBase64(msg.data));
|
||||
break;
|
||||
case 'exit':
|
||||
closeSession(id);
|
||||
break;
|
||||
|
||||
@ -45,8 +45,8 @@
|
||||
let createForm = $state({
|
||||
name: '',
|
||||
base_template: 'minimal-ubuntu',
|
||||
vcpus: 1,
|
||||
memory_mb: 512,
|
||||
vcpus: 2,
|
||||
memory_mb: 2048,
|
||||
recipe: '',
|
||||
healthcheck: '',
|
||||
skip_pre_post: false,
|
||||
@ -152,7 +152,7 @@
|
||||
|
||||
if (result.ok) {
|
||||
showCreate = false;
|
||||
createForm = { name: '', base_template: 'minimal-ubuntu', vcpus: 1, memory_mb: 512, recipe: '', healthcheck: '', skip_pre_post: false, run_as_root: false, archive: null };
|
||||
createForm = { name: '', base_template: 'minimal-ubuntu', vcpus: 2, memory_mb: 2048, recipe: '', healthcheck: '', skip_pre_post: false, run_as_root: false, archive: null };
|
||||
toast.success('Build queued');
|
||||
goto(`/admin/templates/builds/${result.data.id}`);
|
||||
} else {
|
||||
@ -246,7 +246,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => { showCreate = true; createError = null; createForm = { name: '', base_template: 'minimal-ubuntu', vcpus: 1, memory_mb: 512, recipe: '', healthcheck: '', skip_pre_post: false, run_as_root: false, archive: null }; }}
|
||||
onclick={() => { showCreate = true; createError = null; createForm = { name: '', base_template: 'minimal-ubuntu', vcpus: 2, memory_mb: 2048, recipe: '', healthcheck: '', skip_pre_post: false, run_as_root: false, archive: null }; }}
|
||||
class="group flex items-center gap-2.5 rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2.5 text-ui font-semibold text-white shadow-sm transition-all duration-200 hover:shadow-[0_0_20px_var(--color-accent-glow-mid)] hover:brightness-115 hover:-translate-y-px active:translate-y-0"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" class="transition-transform duration-200 group-hover:rotate-90"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
@ -397,7 +397,7 @@
|
||||
</p>
|
||||
{#if type === 'templates'}
|
||||
<button
|
||||
onclick={() => { showCreate = true; createError = null; createForm = { name: '', base_template: 'minimal-ubuntu', vcpus: 1, memory_mb: 512, recipe: '', healthcheck: '', skip_pre_post: false, run_as_root: false, archive: null }; }}
|
||||
onclick={() => { showCreate = true; createError = null; createForm = { name: '', base_template: 'minimal-ubuntu', vcpus: 2, memory_mb: 2048, recipe: '', healthcheck: '', skip_pre_post: false, run_as_root: false, archive: null }; }}
|
||||
class="mt-6 flex items-center gap-2 rounded-[var(--radius-button)] border border-[var(--color-accent)]/30 bg-[var(--color-accent)]/10 px-4 py-2 text-ui font-medium text-[var(--color-accent-bright)] transition-all duration-200 hover:bg-[var(--color-accent)]/20 hover:border-[var(--color-accent)]/50"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
|
||||
@ -225,7 +225,7 @@
|
||||
}
|
||||
}
|
||||
capsules = capsules;
|
||||
} else if (event.event === 'capsule.create') {
|
||||
} else if (event.event === 'capsule.create' || event.event === 'capsule.state.changed') {
|
||||
capsules = [event.sandbox, ...capsules];
|
||||
newCapsuleId = sandboxId;
|
||||
setTimeout(() => { newCapsuleId = null; }, 1600);
|
||||
|
||||
@ -35,8 +35,8 @@
|
||||
|
||||
// Launch state
|
||||
let launchTarget = $state<Snapshot | null>(null);
|
||||
let launchVcpus = $state(1);
|
||||
let launchMemoryMb = $state(512);
|
||||
let launchVcpus = $state(2);
|
||||
let launchMemoryMb = $state(2048);
|
||||
let launchTimeoutSec = $state(0);
|
||||
let launching = $state(false);
|
||||
let launchError = $state<string | null>(null);
|
||||
@ -81,8 +81,8 @@
|
||||
|
||||
function openLaunch(snapshot: Snapshot) {
|
||||
launchTarget = snapshot;
|
||||
launchVcpus = snapshot.vcpus ?? 1;
|
||||
launchMemoryMb = snapshot.memory_mb ?? 512;
|
||||
launchVcpus = snapshot.vcpus ?? 2;
|
||||
launchMemoryMb = snapshot.memory_mb ?? 2048;
|
||||
launchTimeoutSec = 0;
|
||||
launchError = null;
|
||||
}
|
||||
@ -637,7 +637,7 @@
|
||||
</label>
|
||||
{#if launchTarget.type === 'snapshot'}
|
||||
<div class="rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-0)] px-3 py-2 font-mono text-ui text-[var(--color-text-muted)]">
|
||||
{launchTarget.memory_mb ?? 512}
|
||||
{launchTarget.memory_mb ?? 2048}
|
||||
</div>
|
||||
{:else}
|
||||
<input
|
||||
|
||||
@ -205,7 +205,7 @@
|
||||
class="relative z-10 mt-10 font-mono text-ui uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]"
|
||||
style="animation: fadeUp 0.35s ease 0.2s both"
|
||||
>
|
||||
Isolated VMs. Milliseconds to live.
|
||||
Runtime where AI engineers live.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -62,12 +62,19 @@ func requireRunningSandbox(w http.ResponseWriter, r *http.Request, queries *db.Q
|
||||
// SDK clients via X-API-Key. Requests without an auth context are rejected
|
||||
// with a 401 before the upgrade.
|
||||
func upgradeAndAuthenticate(w http.ResponseWriter, r *http.Request) (*websocket.Conn, auth.AuthContext, error) {
|
||||
return upgradeAndAuthenticateWith(w, r, &upgrader)
|
||||
}
|
||||
|
||||
// upgradeAndAuthenticateWith is upgradeAndAuthenticate with a caller-supplied
|
||||
// upgrader, used by the PTY handler to negotiate per-message compression on that
|
||||
// endpoint alone without affecting the other WebSocket routes.
|
||||
func upgradeAndAuthenticateWith(w http.ResponseWriter, r *http.Request, up *websocket.Upgrader) (*websocket.Conn, auth.AuthContext, error) {
|
||||
ac, hasAuth := auth.FromContext(r.Context())
|
||||
if !hasAuth {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "session cookie or X-API-Key required")
|
||||
return nil, auth.AuthContext{}, fmt.Errorf("unauthenticated")
|
||||
}
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
conn, err := up.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return nil, auth.AuthContext{}, fmt.Errorf("websocket upgrade: %w", err)
|
||||
}
|
||||
|
||||
@ -11,11 +11,11 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/layout"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/audit"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/layout"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/service"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/validate"
|
||||
|
||||
@ -2,7 +2,6 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@ -24,11 +23,19 @@ import (
|
||||
|
||||
const (
|
||||
ptyKeepaliveInterval = 30 * time.Second
|
||||
ptyDefaultCmd = "/bin/bash"
|
||||
ptyDefaultCols = 80
|
||||
ptyDefaultRows = 24
|
||||
)
|
||||
|
||||
// ptyUpgrader enables permessage-deflate (RFC 7692) for the PTY WebSocket only.
|
||||
// TUI output is highly compressible (repeated escape sequences, runs of spaces,
|
||||
// repeated SGR color codes); the browser negotiates compression automatically.
|
||||
// The other WebSocket endpoints keep the default uncompressed upgrader.
|
||||
var ptyUpgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
EnableCompression: true,
|
||||
}
|
||||
|
||||
type ptyHandler struct {
|
||||
db *db.Queries
|
||||
pool *lifecycle.HostClientPool
|
||||
@ -40,9 +47,12 @@ func newPtyHandler(db *db.Queries, pool *lifecycle.HostClientPool) *ptyHandler {
|
||||
|
||||
// --- WebSocket message types ---
|
||||
|
||||
// wsPtyIn is the inbound message from the client.
|
||||
// wsPtyIn is an inbound message from the client. Control messages (start,
|
||||
// connect, resize, kill) arrive as JSON text frames. Keystroke input arrives as
|
||||
// raw binary frames and is represented here with Type "input" and the bytes in
|
||||
// inputBytes (never JSON-encoded).
|
||||
type wsPtyIn struct {
|
||||
Type string `json:"type"` // "start", "connect", "input", "resize", "kill"
|
||||
Type string `json:"type"` // "start", "connect", "resize", "kill"
|
||||
Cmd string `json:"cmd,omitempty"` // for "start"
|
||||
Args []string `json:"args,omitempty"` // for "start"
|
||||
Cols uint32 `json:"cols,omitempty"` // for "start", "resize"
|
||||
@ -51,15 +61,17 @@ type wsPtyIn struct {
|
||||
Cwd string `json:"cwd,omitempty"` // for "start"
|
||||
User string `json:"user,omitempty"` // for "start"
|
||||
Tag string `json:"tag,omitempty"` // for "connect"
|
||||
Data string `json:"data,omitempty"` // for "input" (base64)
|
||||
|
||||
inputBytes []byte // raw keystrokes from a binary frame (Type == "input")
|
||||
}
|
||||
|
||||
// wsPtyOut is the outbound message to the client.
|
||||
// wsPtyOut is an outbound control message to the client (JSON text frame). PTY
|
||||
// output is sent separately as raw binary frames, not through this struct.
|
||||
type wsPtyOut struct {
|
||||
Type string `json:"type"` // "started", "output", "exit", "error"
|
||||
Type string `json:"type"` // "started", "exit", "error", "ping"
|
||||
Tag string `json:"tag,omitempty"` // for "started"
|
||||
PID uint32 `json:"pid,omitempty"` // for "started"
|
||||
Data string `json:"data,omitempty"` // for "output" (base64), "error"
|
||||
Data string `json:"data,omitempty"` // for "error"
|
||||
ExitCode *int32 `json:"exit_code,omitempty"` // for "exit"
|
||||
Fatal bool `json:"fatal,omitempty"` // for "error"
|
||||
}
|
||||
@ -78,6 +90,17 @@ func (w *wsWriter) writeJSON(v any) {
|
||||
}
|
||||
}
|
||||
|
||||
// writeBinary sends raw PTY output as a binary WebSocket frame. Binary avoids
|
||||
// the ~33% base64 inflation of the JSON path; the negotiated permessage-deflate
|
||||
// compression then runs on the raw bytes for maximum reduction.
|
||||
func (w *wsWriter) writeBinary(data []byte) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if err := w.conn.WriteMessage(websocket.BinaryMessage, data); err != nil {
|
||||
slog.Debug("pty websocket binary write error", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// PtySession handles WS /v1/capsules/{id}/pty.
|
||||
func (h *ptyHandler) PtySession(w http.ResponseWriter, r *http.Request) {
|
||||
sandboxIDStr := chi.URLParam(r, "id")
|
||||
@ -89,7 +112,7 @@ func (h *ptyHandler) PtySession(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
conn, ac, err := upgradeAndAuthenticate(w, r)
|
||||
conn, ac, err := upgradeAndAuthenticateWith(w, r, &ptyUpgrader)
|
||||
if err != nil {
|
||||
slog.Error("pty websocket upgrade/auth failed", "error", err)
|
||||
return
|
||||
@ -147,10 +170,9 @@ func (h *ptyHandler) handleStart(
|
||||
sandboxIDStr string,
|
||||
msg wsPtyIn,
|
||||
) {
|
||||
// An empty cmd is intentional: envd launches the user's default login shell
|
||||
// (resolved from /etc/passwd) when no command is given.
|
||||
cmd := msg.Cmd
|
||||
if cmd == "" {
|
||||
cmd = ptyDefaultCmd
|
||||
}
|
||||
cols := msg.Cols
|
||||
if cols == 0 {
|
||||
cols = ptyDefaultCols
|
||||
@ -213,6 +235,7 @@ func (h *ptyHandler) handleConnect(
|
||||
stream, err := agent.PtyAttach(ctx, connect.NewRequest(&pb.PtyAttachRequest{
|
||||
SandboxId: sandboxIDStr,
|
||||
Tag: msg.Tag,
|
||||
Reconnect: true,
|
||||
}))
|
||||
if err != nil {
|
||||
ws.writeJSON(wsPtyOut{Type: "error", Data: "failed to connect to pty: " + err.Error(), Fatal: true})
|
||||
@ -251,10 +274,7 @@ func runPtyLoop(
|
||||
ws.writeJSON(wsPtyOut{Type: "started", Tag: ev.Started.Tag, PID: ev.Started.Pid})
|
||||
|
||||
case *pb.PtyAttachResponse_Output:
|
||||
ws.writeJSON(wsPtyOut{
|
||||
Type: "output",
|
||||
Data: base64.StdEncoding.EncodeToString(ev.Output.Data),
|
||||
})
|
||||
ws.writeBinary(ev.Output.Data)
|
||||
|
||||
case *pb.PtyAttachResponse_Exited:
|
||||
exitCode := ev.Exited.ExitCode
|
||||
@ -282,13 +302,16 @@ func runPtyLoop(
|
||||
defer cancel()
|
||||
|
||||
for {
|
||||
_, raw, err := ws.conn.ReadMessage()
|
||||
mt, raw, err := ws.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var msg wsPtyIn
|
||||
if json.Unmarshal(raw, &msg) != nil {
|
||||
if mt == websocket.BinaryMessage {
|
||||
// Raw keystrokes — forward bytes verbatim.
|
||||
msg = wsPtyIn{Type: "input", inputBytes: raw}
|
||||
} else if json.Unmarshal(raw, &msg) != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@ -328,15 +351,14 @@ func runPtyLoop(
|
||||
|
||||
switch msg.Type {
|
||||
case "input":
|
||||
data, err := base64.StdEncoding.DecodeString(msg.Data)
|
||||
if err != nil {
|
||||
// Coalesce: drain any queued input messages into a single RPC.
|
||||
var data []byte
|
||||
data, pending = coalescePtyInput(inputCh, msg.inputBytes)
|
||||
if len(data) == 0 {
|
||||
rpcCancel()
|
||||
continue
|
||||
}
|
||||
|
||||
// Coalesce: drain any queued input messages into a single RPC.
|
||||
data, pending = coalescePtyInput(inputCh, data)
|
||||
|
||||
if _, err := agent.PtySendInput(rpcCtx, connect.NewRequest(&pb.PtySendInputRequest{
|
||||
SandboxId: sandboxID,
|
||||
Tag: tag,
|
||||
@ -413,11 +435,7 @@ func coalescePtyInput(ch <-chan wsPtyIn, buf []byte) ([]byte, *wsPtyIn) {
|
||||
if msg.Type != "input" {
|
||||
return buf, &msg
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(msg.Data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
buf = append(buf, data...)
|
||||
buf = append(buf, msg.inputBytes...)
|
||||
default:
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
@ -24,10 +24,11 @@ func newSandboxHandler(svc *service.SandboxService, al *audit.AuditLogger) *sand
|
||||
}
|
||||
|
||||
type createSandboxRequest struct {
|
||||
Template string `json:"template"`
|
||||
VCPUs int32 `json:"vcpus"`
|
||||
MemoryMB int32 `json:"memory_mb"`
|
||||
TimeoutSec int32 `json:"timeout_sec"`
|
||||
Template string `json:"template"`
|
||||
VCPUs int32 `json:"vcpus"`
|
||||
MemoryMB int32 `json:"memory_mb"`
|
||||
TimeoutSec int32 `json:"timeout_sec"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
type sandboxResponse struct {
|
||||
@ -99,6 +100,7 @@ func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
VCPUs: req.VCPUs,
|
||||
MemoryMB: req.MemoryMB,
|
||||
TimeoutSec: req.TimeoutSec,
|
||||
Metadata: req.Metadata,
|
||||
})
|
||||
h.audit.LogSandboxCreate(r.Context(), ac, sb.ID, req.Template, err)
|
||||
if err != nil {
|
||||
|
||||
@ -12,11 +12,11 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/layout"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/audit"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/layout"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/service"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/validate"
|
||||
|
||||
@ -119,6 +119,8 @@ func serviceErrToHTTP(err error) (int, string, string) {
|
||||
case strings.Contains(msg, "no online") && strings.Contains(msg, "hosts available"),
|
||||
strings.Contains(msg, "no host has sufficient resources"):
|
||||
return http.StatusServiceUnavailable, "no_hosts_available", "no servers available — try again later"
|
||||
case strings.HasPrefix(msg, "invalid metadata: "):
|
||||
return http.StatusBadRequest, "invalid_metadata", strings.TrimPrefix(msg, "invalid metadata: ")
|
||||
case strings.Contains(msg, "invalid"):
|
||||
return http.StatusBadRequest, "invalid_request", "invalid request"
|
||||
default:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -95,7 +95,7 @@ func New(
|
||||
apiKeySvc := &service.APIKeyService{DB: queries}
|
||||
templateSvc := &service.TemplateService{DB: queries}
|
||||
hostSvc := &service.HostService{DB: queries, Redis: rdb, JWT: jwtSecret, Pool: pool, CA: ca}
|
||||
teamSvc := &service.TeamService{DB: queries, Pool: pgPool, HostPool: pool}
|
||||
teamSvc := &service.TeamService{DB: queries, Pool: pgPool, HostPool: pool, Sessions: sessionSvc}
|
||||
userSvc := &service.UserService{DB: queries, SandboxSvc: sandboxSvc}
|
||||
auditSvc := &service.AuditService{DB: queries}
|
||||
statsSvc := &service.StatsService{DB: queries, Pool: pgPool}
|
||||
|
||||
@ -3,7 +3,7 @@ package hostagent
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/sandbox"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/sandbox"
|
||||
)
|
||||
|
||||
// callbackAdapter adapts CallbackSender to satisfy sandbox.EventSender.
|
||||
|
||||
@ -13,7 +13,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/sandbox"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/sandbox"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@ -20,8 +19,8 @@ import (
|
||||
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
|
||||
"git.omukk.dev/wrenn/wrenn/proto/hostagent/gen/hostagentv1connect"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/envdclient"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/sandbox"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/envdclient"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/sandbox"
|
||||
)
|
||||
|
||||
// Server implements the HostAgentService Connect RPC handler.
|
||||
@ -77,8 +76,11 @@ func (s *Server) CreateSandbox(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// disk_size_mb in the request is deprecated and never set by the control
|
||||
// plane; passing 0 lets the manager pick the size (DefaultRootfsSizeMB,
|
||||
// floored at the origin image size).
|
||||
sb, diskSizeBytes, err := s.mgr.Create(ctx, msg.SandboxId, teamID, templateID,
|
||||
int(msg.Vcpus), int(msg.MemoryMb), int(msg.TimeoutSec), int(msg.DiskSizeMb),
|
||||
int(msg.Vcpus), int(msg.MemoryMb), int(msg.TimeoutSec), 0,
|
||||
msg.DefaultUser, msg.DefaultEnv)
|
||||
if err != nil {
|
||||
if errors.Is(err, sandbox.ErrDraining) {
|
||||
@ -456,52 +458,46 @@ func (s *Server) WriteFileStream(
|
||||
return nil, connect.NewError(connect.CodeNotFound, err)
|
||||
}
|
||||
|
||||
// Use io.Pipe to stream chunks into a multipart body for envd's REST endpoint.
|
||||
// Use io.Pipe to stream raw chunks into envd's REST endpoint body.
|
||||
pr, pw := io.Pipe()
|
||||
mpWriter := multipart.NewWriter(pw)
|
||||
|
||||
// Write multipart data in a goroutine.
|
||||
// Pump chunks from the Connect stream into the request body in a goroutine.
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
defer pw.Close()
|
||||
part, err := mpWriter.CreateFormFile("file", "upload")
|
||||
if err != nil {
|
||||
errCh <- fmt.Errorf("create multipart: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
for stream.Receive() {
|
||||
chunk := stream.Msg().GetChunk()
|
||||
if len(chunk) == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := part.Write(chunk); err != nil {
|
||||
if _, err := pw.Write(chunk); err != nil {
|
||||
pw.CloseWithError(err)
|
||||
errCh <- fmt.Errorf("write chunk: %w", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := stream.Err(); err != nil {
|
||||
pw.CloseWithError(err)
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
mpWriter.Close()
|
||||
pw.Close()
|
||||
errCh <- nil
|
||||
}()
|
||||
|
||||
// Send the streaming multipart body to envd.
|
||||
// Send the raw streaming body to envd.
|
||||
base := client.BaseURL()
|
||||
u := fmt.Sprintf("%s/files?%s", base, url.Values{
|
||||
"path": {meta.Path},
|
||||
"username": {"root"},
|
||||
}.Encode())
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, u, pr)
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPut, u, pr)
|
||||
if err != nil {
|
||||
pw.CloseWithError(err)
|
||||
<-errCh
|
||||
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("create request: %w", err))
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", mpWriter.FormDataContentType())
|
||||
httpReq.Header.Set("Content-Type", "application/octet-stream")
|
||||
|
||||
resp, err := client.StreamingHTTPClient().Do(httpReq)
|
||||
if err != nil {
|
||||
@ -689,7 +685,7 @@ func (s *Server) PtyAttach(
|
||||
) error {
|
||||
msg := req.Msg
|
||||
|
||||
events, err := s.mgr.PtyAttach(ctx, msg.SandboxId, msg.Tag, msg.Cmd, msg.Args, msg.Cols, msg.Rows, msg.Envs, msg.Cwd)
|
||||
events, err := s.mgr.PtyAttach(ctx, msg.SandboxId, msg.Tag, msg.Cmd, msg.Args, msg.Cols, msg.Rows, msg.Envs, msg.Cwd, msg.User, msg.Reconnect)
|
||||
if err != nil {
|
||||
return connect.NewError(connect.CodeInternal, fmt.Errorf("pty attach: %w", err))
|
||||
}
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SlotAllocator manages network slot indices for sandboxes.
|
||||
// Each sandbox needs a unique slot index for its network addressing.
|
||||
type SlotAllocator struct {
|
||||
mu sync.Mutex
|
||||
inUse map[int]bool
|
||||
}
|
||||
|
||||
// NewSlotAllocator creates a new slot allocator.
|
||||
func NewSlotAllocator() *SlotAllocator {
|
||||
return &SlotAllocator{
|
||||
inUse: make(map[int]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// Allocate returns the next available slot index (1-based).
|
||||
func (a *SlotAllocator) Allocate() (int, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
for i := 1; i <= 32767; i++ {
|
||||
if !a.inUse[i] {
|
||||
a.inUse[i] = true
|
||||
return i, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("no free network slots")
|
||||
}
|
||||
|
||||
// Release frees a slot index for reuse.
|
||||
func (a *SlotAllocator) Release(index int) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
delete(a.inUse, index)
|
||||
}
|
||||
|
||||
// Reserve marks a specific slot index as in use. Returns an error if the
|
||||
// index is out of range or already taken. Used on resume to re-acquire the
|
||||
// slot a sandbox previously held so its host-reachable IP stays stable.
|
||||
func (a *SlotAllocator) Reserve(index int) error {
|
||||
if index < 1 || index > 32767 {
|
||||
return fmt.Errorf("slot index out of range: %d", index)
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.inUse[index] {
|
||||
return fmt.Errorf("slot %d already in use", index)
|
||||
}
|
||||
a.inUse[index] = true
|
||||
return nil
|
||||
}
|
||||
@ -21,30 +21,33 @@ type ExecContext struct {
|
||||
var envRegex = regexp.MustCompile(`\$\$|\$\{([a-zA-Z0-9_]*)\}|\$([a-zA-Z0-9_]+)`)
|
||||
|
||||
// WrappedCommand returns the full shell command for a RUN step with context
|
||||
// applied. The result is passed as the argument to /bin/sh -c.
|
||||
// applied. The result is handed to the exec layer as a bare command (no
|
||||
// "-c" wrapper), so the user's default login shell — resolved by envd from
|
||||
// /etc/passwd inside the VM — interprets it.
|
||||
//
|
||||
// If WORKDIR and/or ENV are set, they are prepended as a shell preamble:
|
||||
//
|
||||
// cd '/the/dir' && KEY='val' /bin/sh -c 'original command'
|
||||
// cd '/the/dir' && export KEY='val' && original command
|
||||
//
|
||||
// If USER is set to a non-root user, the entire command is wrapped with su:
|
||||
// If USER is set to a non-root user, the entire command is wrapped with su.
|
||||
// Dropping `-s` lets su run it under that user's login shell rather than
|
||||
// forcing /bin/sh:
|
||||
//
|
||||
// su <user> -s /bin/sh -c '<preamble + command>'
|
||||
// su <user> -c '<preamble + command>'
|
||||
func (c *ExecContext) WrappedCommand(cmd string) string {
|
||||
inner := c.innerCommand(cmd)
|
||||
if c.User != "" && c.User != "root" {
|
||||
return "su " + shellescape(c.User) + " -s /bin/sh -c " + shellescape(inner)
|
||||
return "su " + shellescape(c.User) + " -c " + shellescape(inner)
|
||||
}
|
||||
return inner
|
||||
}
|
||||
|
||||
// innerCommand builds the command with workdir/env preamble but without user wrapping.
|
||||
// innerCommand applies the workdir/env preamble to cmd without user wrapping.
|
||||
// The preamble cds into WORKDIR and exports ENV vars, then the command runs in
|
||||
// the same (login) shell — no nested shell is named, so the user's default
|
||||
// shell interprets the command and any pipes/operators within it.
|
||||
func (c *ExecContext) innerCommand(cmd string) string {
|
||||
prefix := c.shellPrefix()
|
||||
if prefix == "" {
|
||||
return cmd
|
||||
}
|
||||
return prefix + "/bin/sh -c " + shellescape(cmd)
|
||||
return c.shellPrefix() + cmd
|
||||
}
|
||||
|
||||
// StartCommand returns the shell command for a START step. The process is
|
||||
@ -55,16 +58,22 @@ func (c *ExecContext) innerCommand(cmd string) string {
|
||||
// Multiple START steps can be issued to run several background processes
|
||||
// simultaneously before a healthcheck is evaluated.
|
||||
func (c *ExecContext) StartCommand(cmd string) string {
|
||||
// Launch the background process under the user's login shell. $SHELL is set
|
||||
// to that shell by su (non-root) or by envd (root), with /bin/sh as a safe
|
||||
// fallback if it is somehow unset.
|
||||
prefix := c.shellPrefix()
|
||||
inner := prefix + "nohup /bin/sh -c " + shellescape(cmd) + " >/dev/null 2>&1 &"
|
||||
inner := prefix + `nohup "${SHELL:-/bin/sh}" -c ` + shellescape(cmd) + " >/dev/null 2>&1 &"
|
||||
if c.User != "" && c.User != "root" {
|
||||
return "su " + shellescape(c.User) + " -s /bin/sh -c " + shellescape(inner)
|
||||
return "su " + shellescape(c.User) + " -c " + shellescape(inner)
|
||||
}
|
||||
return inner
|
||||
}
|
||||
|
||||
// shellPrefix builds the "cd ... && KEY=val " preamble for a shell command.
|
||||
// Returns an empty string when no context is set.
|
||||
// shellPrefix builds the "cd '/dir' && export KEY='val' && " preamble for a
|
||||
// shell command. ENV vars are exported (not just assignment-prefixed) so they
|
||||
// apply to the whole command — including any pipes or && chains — and to child
|
||||
// processes, matching Dockerfile ENV semantics. Returns an empty string when no
|
||||
// context is set.
|
||||
func (c *ExecContext) shellPrefix() string {
|
||||
if c.WorkDir == "" && len(c.EnvVars) == 0 {
|
||||
return ""
|
||||
@ -75,16 +84,20 @@ func (c *ExecContext) shellPrefix() string {
|
||||
sb.WriteString(shellescape(c.WorkDir))
|
||||
sb.WriteString(" && ")
|
||||
}
|
||||
keys := make([]string, 0, len(c.EnvVars))
|
||||
for k := range c.EnvVars {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
for _, k := range keys {
|
||||
sb.WriteString(k)
|
||||
sb.WriteByte('=')
|
||||
sb.WriteString(shellescape(c.EnvVars[k]))
|
||||
sb.WriteByte(' ')
|
||||
if len(c.EnvVars) > 0 {
|
||||
keys := make([]string, 0, len(c.EnvVars))
|
||||
for k := range c.EnvVars {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
sb.WriteString("export")
|
||||
for _, k := range keys {
|
||||
sb.WriteByte(' ')
|
||||
sb.WriteString(k)
|
||||
sb.WriteByte('=')
|
||||
sb.WriteString(shellescape(c.EnvVars[k]))
|
||||
}
|
||||
sb.WriteString(" && ")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@ -20,31 +20,31 @@ func TestExecContext_WrappedCommand(t *testing.T) {
|
||||
name: "workdir only",
|
||||
ctx: ExecContext{WorkDir: "/app"},
|
||||
cmd: "npm install",
|
||||
want: "cd '/app' && /bin/sh -c 'npm install'",
|
||||
want: "cd '/app' && npm install",
|
||||
},
|
||||
{
|
||||
name: "env only",
|
||||
ctx: ExecContext{EnvVars: map[string]string{"PORT": "8080"}},
|
||||
cmd: "node server.js",
|
||||
want: "PORT='8080' /bin/sh -c 'node server.js'",
|
||||
want: "export PORT='8080' && node server.js",
|
||||
},
|
||||
{
|
||||
name: "workdir with space",
|
||||
ctx: ExecContext{WorkDir: "/my project"},
|
||||
cmd: "make build",
|
||||
want: "cd '/my project' && /bin/sh -c 'make build'",
|
||||
want: "cd '/my project' && make build",
|
||||
},
|
||||
{
|
||||
name: "command with single quotes",
|
||||
ctx: ExecContext{WorkDir: "/app"},
|
||||
cmd: "echo 'hello'",
|
||||
want: "cd '/app' && /bin/sh -c 'echo '\\''hello'\\'''",
|
||||
want: "cd '/app' && echo 'hello'",
|
||||
},
|
||||
{
|
||||
name: "env value with single quotes",
|
||||
ctx: ExecContext{EnvVars: map[string]string{"MSG": "it's fine"}},
|
||||
cmd: "echo $MSG",
|
||||
want: "MSG='it'\\''s fine' /bin/sh -c 'echo $MSG'",
|
||||
want: "export MSG='it'\\''s fine' && echo $MSG",
|
||||
},
|
||||
{
|
||||
name: "env expansion with pre-expanded PATH",
|
||||
@ -52,7 +52,25 @@ func TestExecContext_WrappedCommand(t *testing.T) {
|
||||
EnvVars: map[string]string{"PATH": "/usr/bin", "FOO": "/opt/venv/bin:/usr/bin"},
|
||||
},
|
||||
cmd: "make build",
|
||||
want: "FOO='/opt/venv/bin:/usr/bin' PATH='/usr/bin' /bin/sh -c 'make build'",
|
||||
want: "export FOO='/opt/venv/bin:/usr/bin' PATH='/usr/bin' && make build",
|
||||
},
|
||||
{
|
||||
name: "non-root user wraps with su login shell",
|
||||
ctx: ExecContext{User: "wrenn-user"},
|
||||
cmd: "whoami",
|
||||
want: "su 'wrenn-user' -c 'whoami'",
|
||||
},
|
||||
{
|
||||
name: "non-root user with workdir and env",
|
||||
ctx: ExecContext{User: "wrenn-user", WorkDir: "/app", EnvVars: map[string]string{"PORT": "8080"}},
|
||||
cmd: "node server.js",
|
||||
want: "su 'wrenn-user' -c 'cd '\\''/app'\\'' && export PORT='\\''8080'\\'' && node server.js'",
|
||||
},
|
||||
{
|
||||
name: "root user is not su-wrapped",
|
||||
ctx: ExecContext{User: "root", WorkDir: "/app"},
|
||||
cmd: "ls",
|
||||
want: "cd '/app' && ls",
|
||||
},
|
||||
}
|
||||
|
||||
@ -88,19 +106,25 @@ func TestExecContext_StartCommand(t *testing.T) {
|
||||
name: "no context",
|
||||
ctx: ExecContext{},
|
||||
cmd: "python3 app.py",
|
||||
want: "nohup /bin/sh -c 'python3 app.py' >/dev/null 2>&1 &",
|
||||
want: `nohup "${SHELL:-/bin/sh}" -c 'python3 app.py' >/dev/null 2>&1 &`,
|
||||
},
|
||||
{
|
||||
name: "with workdir",
|
||||
ctx: ExecContext{WorkDir: "/app"},
|
||||
cmd: "python3 server.py",
|
||||
want: "cd '/app' && nohup /bin/sh -c 'python3 server.py' >/dev/null 2>&1 &",
|
||||
want: `cd '/app' && nohup "${SHELL:-/bin/sh}" -c 'python3 server.py' >/dev/null 2>&1 &`,
|
||||
},
|
||||
{
|
||||
name: "with env",
|
||||
ctx: ExecContext{EnvVars: map[string]string{"PORT": "9000"}},
|
||||
cmd: "node index.js",
|
||||
want: "PORT='9000' nohup /bin/sh -c 'node index.js' >/dev/null 2>&1 &",
|
||||
want: `export PORT='9000' && nohup "${SHELL:-/bin/sh}" -c 'node index.js' >/dev/null 2>&1 &`,
|
||||
},
|
||||
{
|
||||
name: "non-root user wraps start with su",
|
||||
ctx: ExecContext{User: "wrenn-user"},
|
||||
cmd: "python3 app.py",
|
||||
want: `su 'wrenn-user' -c 'nohup "${SHELL:-/bin/sh}" -c '\''python3 app.py'\'' >/dev/null 2>&1 &'`,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -183,8 +183,7 @@ func execRun(
|
||||
start := time.Now()
|
||||
resp, err := execFn(execCtx, connect.NewRequest(&pb.ExecRequest{
|
||||
SandboxId: sandboxID,
|
||||
Cmd: "/bin/sh",
|
||||
Args: []string{"-c", bctx.WrappedCommand(st.Shell)},
|
||||
Cmd: bctx.WrappedCommand(st.Shell),
|
||||
TimeoutSec: int32(timeout.Seconds()),
|
||||
}))
|
||||
|
||||
@ -360,8 +359,7 @@ func execRawShell(
|
||||
start := time.Now()
|
||||
resp, err := execFn(execCtx, connect.NewRequest(&pb.ExecRequest{
|
||||
SandboxId: sandboxID,
|
||||
Cmd: "/bin/sh",
|
||||
Args: []string{"-c", shellCmd},
|
||||
Cmd: shellCmd,
|
||||
TimeoutSec: int32(timeout.Seconds()),
|
||||
}))
|
||||
|
||||
@ -398,8 +396,7 @@ func execStart(
|
||||
start := time.Now()
|
||||
resp, err := execFn(execCtx, connect.NewRequest(&pb.ExecRequest{
|
||||
SandboxId: sandboxID,
|
||||
Cmd: "/bin/sh",
|
||||
Args: []string{"-c", bctx.StartCommand(st.Shell)},
|
||||
Cmd: bctx.StartCommand(st.Shell),
|
||||
TimeoutSec: 10,
|
||||
}))
|
||||
|
||||
|
||||
@ -116,7 +116,13 @@ func hydrateFromDB(queries *db.Queries) func(context.Context, *session.Session)
|
||||
role := ""
|
||||
if err == nil {
|
||||
role = membership.Role
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
} else if errors.Is(err, pgx.ErrNoRows) {
|
||||
// The session's active team no longer lists this user as a member
|
||||
// (e.g. they were removed from the team). Strip the team binding so
|
||||
// the stale session can no longer authorize against team-scoped
|
||||
// resources such as GetSandboxByTeam.
|
||||
sess.TeamID = pgtype.UUID{}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
sess.Email = user.Email
|
||||
|
||||
@ -43,6 +43,20 @@ func (q *Queries) DeleteAPIKeysByTeam(ctx context.Context, teamID pgtype.UUID) e
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteAPIKeysByTeamAndCreator = `-- name: DeleteAPIKeysByTeamAndCreator :exec
|
||||
DELETE FROM team_api_keys WHERE team_id = $1 AND created_by = $2
|
||||
`
|
||||
|
||||
type DeleteAPIKeysByTeamAndCreatorParams struct {
|
||||
TeamID pgtype.UUID `json:"team_id"`
|
||||
CreatedBy pgtype.UUID `json:"created_by"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteAPIKeysByTeamAndCreator(ctx context.Context, arg DeleteAPIKeysByTeamAndCreatorParams) error {
|
||||
_, err := q.db.Exec(ctx, deleteAPIKeysByTeamAndCreator, arg.TeamID, arg.CreatedBy)
|
||||
return err
|
||||
}
|
||||
|
||||
const getAPIKeyByHash = `-- name: GetAPIKeyByHash :one
|
||||
SELECT id, team_id, name, key_hash, key_prefix, created_by, created_at, last_used FROM team_api_keys WHERE key_hash = $1
|
||||
`
|
||||
|
||||
@ -192,8 +192,19 @@ func RestoreSnapshot(ctx context.Context, name, originLoopDev, cowPath string, o
|
||||
|
||||
// RemoveSnapshot tears down a dm-snapshot device and its CoW loop device.
|
||||
// The CoW file is NOT deleted — the caller decides whether to keep or remove it.
|
||||
//
|
||||
// The CoW loop is detached even when the dm removal fails (busy device):
|
||||
// callers treat a failed removal as fatal teardown and typically delete the
|
||||
// CoW backing file next, which would orphan a still-attached loop forever
|
||||
// (losetup can no longer find it by file). Detaching first is always safe —
|
||||
// while dm still references the loop the kernel just defers the release via
|
||||
// autoclear until that reference drops.
|
||||
func RemoveSnapshot(ctx context.Context, dev *SnapshotDevice) error {
|
||||
if err := dmsetupRemove(ctx, dev.Name); err != nil {
|
||||
if derr := losetupDetachRetry(dev.CowLoopDev); derr != nil {
|
||||
slog.Warn("cow loop detach after failed dm remove",
|
||||
"device", dev.CowLoopDev, "name", dev.Name, "error", derr)
|
||||
}
|
||||
return fmt.Errorf("dmsetup remove %s: %w", dev.Name, err)
|
||||
}
|
||||
|
||||
@ -205,6 +216,27 @@ func RemoveSnapshot(ctx context.Context, dev *SnapshotDevice) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReattachSnapshot reconstructs the SnapshotDevice handle for a dm-snapshot
|
||||
// that was created by an earlier process and is still live in the kernel. No
|
||||
// device-mapper state is modified — the existing dm device is verified and
|
||||
// the CoW loop device is rediscovered from its backing file, so a later
|
||||
// RemoveSnapshot can detach it as usual.
|
||||
func ReattachSnapshot(name, cowPath string) (*SnapshotDevice, error) {
|
||||
if !dmDeviceExists(name) {
|
||||
return nil, fmt.Errorf("dm device %s does not exist", name)
|
||||
}
|
||||
cowLoopDev, err := losetupFindByFile(cowPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find cow loop for %s: %w", cowPath, err)
|
||||
}
|
||||
return &SnapshotDevice{
|
||||
Name: name,
|
||||
DevicePath: "/dev/mapper/" + name,
|
||||
CowPath: cowPath,
|
||||
CowLoopDev: cowLoopDev,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FlattenSnapshot reads the full contents of a dm-snapshot device and writes
|
||||
// it to a new file. This merges the base image + CoW changes into a standalone
|
||||
// rootfs image suitable for use as a new template.
|
||||
@ -284,10 +316,55 @@ func LogLoopState() {
|
||||
}
|
||||
}
|
||||
|
||||
// DetachLoopsByFile best-effort detaches every loop device backed by path.
|
||||
//
|
||||
// Safety net for teardown paths that are about to delete a backing file whose
|
||||
// loop may still be attached (e.g. RemoveSnapshot failed on a busy dm device,
|
||||
// or startup cleanup removed the dm device before the CoW loop was found).
|
||||
// Deleting the file first orphans the loop permanently — losetup can no
|
||||
// longer find it by file, so no later sweep can reclaim it. Detaching first
|
||||
// always works: if device-mapper still references the loop, the kernel defers
|
||||
// the release via autoclear until that reference drops.
|
||||
func DetachLoopsByFile(path string) {
|
||||
out, err := exec.Command("losetup", "-j", path, "--output", "NAME", "--noheadings").Output()
|
||||
if err != nil {
|
||||
slog.Debug("losetup -j failed", "path", path, "error", err)
|
||||
return
|
||||
}
|
||||
for _, dev := range strings.Fields(strings.TrimSpace(string(out))) {
|
||||
if err := losetupDetachRetry(dev); err != nil {
|
||||
slog.Warn("loop detach by file failed", "device", dev, "path", path, "error", err)
|
||||
} else {
|
||||
slog.Info("detached leftover loop device", "device", dev, "path", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- low-level helpers ---
|
||||
|
||||
// losetupCreate attaches a file as a read-only loop device.
|
||||
// losetupCreate attaches a file as a read-only loop device, reusing an existing
|
||||
// attachment for the same backing file when one is already present.
|
||||
//
|
||||
// `losetup --find` always allocates a NEW device even when the file is already
|
||||
// mapped. Within a single long-lived process the LoopRegistry hides this by
|
||||
// caching the device per image path, but a daemonless caller runs many
|
||||
// short-lived processes, each with its own registry: when a later process
|
||||
// re-attaches a sandbox created by an earlier one, a naive --find would attach a
|
||||
// second loop to the same origin image while the dm-snapshot still references
|
||||
// the first. That later process then balances its refcount by detaching only
|
||||
// its own (second) loop, orphaning the original — a loop leak that accumulates
|
||||
// one device per template per re-attach/destroy cycle. Reusing the existing
|
||||
// attachment keeps the kernel's loop set 1:1 with backing files, so the device a
|
||||
// caller releases is the same one the dm origin depends on. Mirrors how
|
||||
// ReattachSnapshot already rediscovers the CoW loop via losetupFindByFile.
|
||||
func losetupCreate(imagePath string) (string, error) {
|
||||
// Reuse when exactly one loop already backs the image. losetupFindByFile
|
||||
// errors on both "none" and "more than one"; either way fall through to
|
||||
// attach a fresh device (the multi-attachment case only arises from loops
|
||||
// leaked before this reuse logic existed, and is reclaimed separately).
|
||||
if dev, err := losetupFindByFile(imagePath); err == nil {
|
||||
return dev, nil
|
||||
}
|
||||
out, err := exec.Command("losetup", "--read-only", "--find", "--show", imagePath).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("losetup --read-only: %w", err)
|
||||
@ -304,6 +381,23 @@ func losetupCreateRW(path string) (string, error) {
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// losetupFindByFile returns the loop device backed by the given file.
|
||||
// Errors if none or more than one is attached.
|
||||
func losetupFindByFile(path string) (string, error) {
|
||||
out, err := exec.Command("losetup", "-j", path, "--output", "NAME", "--noheadings").Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("losetup -j: %w", err)
|
||||
}
|
||||
devs := strings.Fields(strings.TrimSpace(string(out)))
|
||||
if len(devs) == 0 {
|
||||
return "", fmt.Errorf("no loop device attached to %s", path)
|
||||
}
|
||||
if len(devs) > 1 {
|
||||
return "", fmt.Errorf("multiple loop devices attached to %s: %v", path, devs)
|
||||
}
|
||||
return devs[0], nil
|
||||
}
|
||||
|
||||
// losetupDetach detaches a loop device.
|
||||
func losetupDetach(dev string) error {
|
||||
return exec.Command("losetup", "-d", dev).Run()
|
||||
@ -7,7 +7,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
@ -35,7 +34,23 @@ type Client struct {
|
||||
|
||||
// New creates a new envd client that connects to the given host IP.
|
||||
func New(hostIP string) *Client {
|
||||
base := baseURL(hostIP)
|
||||
return newWithBase(hostIP, baseURL(hostIP))
|
||||
}
|
||||
|
||||
// NewWithBaseURL creates an envd client against an explicit base URL instead
|
||||
// of the standard http://{hostIP}:{envdPort}. Intended for tests (httptest
|
||||
// servers) and nonstandard listeners.
|
||||
func NewWithBaseURL(base string) *Client {
|
||||
host := base
|
||||
// Hostname() is empty for scheme-less inputs ("127.0.0.1:8080" parses
|
||||
// with "127.0.0.1" as the scheme) — keep the raw base then.
|
||||
if u, err := url.Parse(base); err == nil && u.Hostname() != "" {
|
||||
host = u.Hostname()
|
||||
}
|
||||
return newWithBase(host, base)
|
||||
}
|
||||
|
||||
func newWithBase(hostIP, base string) *Client {
|
||||
httpClient := newHTTPClient()
|
||||
streamingClient := newStreamingHTTPClient()
|
||||
|
||||
@ -243,30 +258,18 @@ func (c *Client) ExecStream(ctx context.Context, cmd string, args ...string) (<-
|
||||
}
|
||||
|
||||
// WriteFile writes content to a file inside the sandbox via envd's REST endpoint.
|
||||
// envd expects POST /files?path=...&username=root with multipart/form-data (field name "file").
|
||||
// envd expects PUT /files?path=...&username=root with the raw file content as the body.
|
||||
func (c *Client) WriteFile(ctx context.Context, path string, content []byte) error {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
part, err := writer.CreateFormFile("file", "upload")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create multipart: %w", err)
|
||||
}
|
||||
if _, err := part.Write(content); err != nil {
|
||||
return fmt.Errorf("write multipart: %w", err)
|
||||
}
|
||||
writer.Close()
|
||||
|
||||
u := fmt.Sprintf("%s/files?%s", c.base, url.Values{
|
||||
"path": {path},
|
||||
"username": {"root"},
|
||||
}.Encode())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, &body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, u, bytes.NewReader(content))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@ -23,14 +23,14 @@ type PtyEvent struct {
|
||||
// PtyStart starts a new PTY process in the guest and returns a channel of events.
|
||||
// The tag is the stable identifier used to reconnect via PtyConnect.
|
||||
// The channel is closed when the process ends or ctx is cancelled.
|
||||
// NOTE: The user parameter from PtyAttachRequest is not yet supported by envd's
|
||||
// ProcessConfig proto. When envd adds user support, thread it through here.
|
||||
func (c *Client) PtyStart(ctx context.Context, tag, cmd string, args []string, cols, rows uint32, envs map[string]string, cwd string) (<-chan PtyEvent, error) {
|
||||
// An empty user runs as the sandbox default user.
|
||||
func (c *Client) PtyStart(ctx context.Context, tag, cmd string, args []string, cols, rows uint32, envs map[string]string, cwd, user string) (<-chan PtyEvent, error) {
|
||||
stdin := true
|
||||
cfg := &envdpb.ProcessConfig{
|
||||
Cmd: cmd,
|
||||
Args: args,
|
||||
Envs: envs,
|
||||
User: user,
|
||||
}
|
||||
if cwd != "" {
|
||||
cfg.Cwd = &cwd
|
||||
@ -169,6 +169,13 @@ func compareSemver(a, b string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// SlotsDir returns the directory holding network slot claim files. Each
|
||||
// allocated slot index is an empty file named after the index, created with
|
||||
// O_EXCL so concurrent processes can never claim the same slot.
|
||||
func SlotsDir(wrennDir string) string {
|
||||
return filepath.Join(wrennDir, "slots")
|
||||
}
|
||||
|
||||
// ImagesRoot returns the root images directory.
|
||||
func ImagesRoot(wrennDir string) string {
|
||||
return filepath.Join(wrennDir, "images")
|
||||
143
pkg/network/allocator.go
Normal file
143
pkg/network/allocator.go
Normal file
@ -0,0 +1,143 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SlotAllocator manages network slot indices for sandboxes.
|
||||
// Each sandbox needs a unique slot index for its network addressing.
|
||||
//
|
||||
// When slotsDir is set, every claim is additionally recorded as a file
|
||||
// {slotsDir}/{index} created with O_CREAT|O_EXCL, which is atomic on local
|
||||
// filesystems. That makes allocation safe across processes: two concurrent
|
||||
// short-lived CLI invocations (each with its own in-memory allocator) can
|
||||
// never claim the same slot, which would otherwise silently give two VMs the
|
||||
// same host-reachable IP.
|
||||
type SlotAllocator struct {
|
||||
mu sync.Mutex
|
||||
inUse map[int]bool
|
||||
// seeded marks slots that have a claim file on disk but no owner in this
|
||||
// process yet (populated by SeedFromDir). Allocate never hands them out;
|
||||
// Reserve may adopt one — the caller restoring a paused or running
|
||||
// sandbox owns the on-disk state that justifies the claim.
|
||||
seeded map[int]bool
|
||||
slotsDir string // empty → in-memory only (single-process mode)
|
||||
}
|
||||
|
||||
// NewSlotAllocator creates a new slot allocator. slotsDir enables
|
||||
// cross-process claim files; pass "" for in-memory-only allocation.
|
||||
func NewSlotAllocator(slotsDir string) *SlotAllocator {
|
||||
return &SlotAllocator{
|
||||
inUse: make(map[int]bool),
|
||||
seeded: make(map[int]bool),
|
||||
slotsDir: slotsDir,
|
||||
}
|
||||
}
|
||||
|
||||
// Allocate returns the next available slot index (1-based).
|
||||
func (a *SlotAllocator) Allocate() (int, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
for i := 1; i <= 32767; i++ {
|
||||
if a.inUse[i] {
|
||||
continue
|
||||
}
|
||||
if err := a.claimFile(i); err != nil {
|
||||
if os.IsExist(err) {
|
||||
// Another process holds this slot; remember and move on.
|
||||
a.inUse[i] = true
|
||||
continue
|
||||
}
|
||||
return 0, fmt.Errorf("claim slot %d: %w", i, err)
|
||||
}
|
||||
a.inUse[i] = true
|
||||
return i, nil
|
||||
}
|
||||
return 0, fmt.Errorf("no free network slots")
|
||||
}
|
||||
|
||||
// Release frees a slot index for reuse.
|
||||
func (a *SlotAllocator) Release(index int) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
delete(a.inUse, index)
|
||||
delete(a.seeded, index)
|
||||
if a.slotsDir != "" {
|
||||
_ = os.Remove(a.slotFile(index))
|
||||
}
|
||||
}
|
||||
|
||||
// Reserve marks a specific slot index as in use. Returns an error if the
|
||||
// index is out of range or already taken in this process. Used on resume and
|
||||
// re-attach to re-acquire the slot a sandbox previously held so its
|
||||
// host-reachable IP stays stable. The claim file may legitimately already
|
||||
// exist (written by the process that created the sandbox), so EEXIST is not
|
||||
// an error here — on-disk sandbox state is what proves ownership.
|
||||
func (a *SlotAllocator) Reserve(index int) error {
|
||||
if index < 1 || index > 32767 {
|
||||
return fmt.Errorf("slot index out of range: %d", index)
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.inUse[index] && !a.seeded[index] {
|
||||
return fmt.Errorf("slot %d already in use", index)
|
||||
}
|
||||
if err := a.claimFile(index); err != nil && !os.IsExist(err) {
|
||||
return fmt.Errorf("claim slot %d: %w", index, err)
|
||||
}
|
||||
a.inUse[index] = true
|
||||
delete(a.seeded, index)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SeedFromDir marks every slot that has a claim file as in-use in this
|
||||
// process. Called once at startup (before any Allocate) so slots held by
|
||||
// concurrently running processes — including creates that have not yet
|
||||
// persisted their sandbox state — are never handed out.
|
||||
func (a *SlotAllocator) SeedFromDir() error {
|
||||
if a.slotsDir == "" {
|
||||
return nil
|
||||
}
|
||||
entries, err := os.ReadDir(a.slotsDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read slots dir: %w", err)
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
for _, e := range entries {
|
||||
if idx, err := strconv.Atoi(e.Name()); err == nil && idx >= 1 && idx <= 32767 {
|
||||
a.inUse[idx] = true
|
||||
a.seeded[idx] = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// claimFile atomically creates the claim file for a slot. Returns an error
|
||||
// satisfying os.IsExist when another process already holds the slot. No-op
|
||||
// (nil) when slotsDir is unset.
|
||||
func (a *SlotAllocator) claimFile(index int) error {
|
||||
if a.slotsDir == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(a.slotsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(a.slotFile(index), os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
func (a *SlotAllocator) slotFile(index int) string {
|
||||
return filepath.Join(a.slotsDir, strconv.Itoa(index))
|
||||
}
|
||||
125
pkg/network/allocator_test.go
Normal file
125
pkg/network/allocator_test.go
Normal file
@ -0,0 +1,125 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSlotAllocator_InMemory(t *testing.T) {
|
||||
a := NewSlotAllocator("")
|
||||
|
||||
idx, err := a.Allocate()
|
||||
if err != nil {
|
||||
t.Fatalf("Allocate: %v", err)
|
||||
}
|
||||
if idx != 1 {
|
||||
t.Fatalf("first slot = %d, want 1", idx)
|
||||
}
|
||||
|
||||
if err := a.Reserve(idx); err == nil {
|
||||
t.Fatal("Reserve of allocated slot should fail")
|
||||
}
|
||||
|
||||
a.Release(idx)
|
||||
if err := a.Reserve(idx); err != nil {
|
||||
t.Fatalf("Reserve after Release: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotAllocator_ClaimFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := NewSlotAllocator(dir)
|
||||
|
||||
idx, err := a.Allocate()
|
||||
if err != nil {
|
||||
t.Fatalf("Allocate: %v", err)
|
||||
}
|
||||
claim := filepath.Join(dir, strconv.Itoa(idx))
|
||||
if _, err := os.Stat(claim); err != nil {
|
||||
t.Fatalf("claim file missing after Allocate: %v", err)
|
||||
}
|
||||
|
||||
a.Release(idx)
|
||||
if _, err := os.Stat(claim); !os.IsNotExist(err) {
|
||||
t.Fatalf("claim file still present after Release (err=%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotAllocator_AllocateSkipsExternalClaims(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// Simulate another process holding slots 1 and 2.
|
||||
for _, n := range []string{"1", "2"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, n), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
a := NewSlotAllocator(dir)
|
||||
idx, err := a.Allocate()
|
||||
if err != nil {
|
||||
t.Fatalf("Allocate: %v", err)
|
||||
}
|
||||
if idx != 3 {
|
||||
t.Fatalf("Allocate = %d, want 3 (slots 1-2 externally claimed)", idx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotAllocator_SeedAndReserveAdopt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "5"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a := NewSlotAllocator(dir)
|
||||
if err := a.SeedFromDir(); err != nil {
|
||||
t.Fatalf("SeedFromDir: %v", err)
|
||||
}
|
||||
|
||||
// Seeded slots are never handed out by Allocate.
|
||||
idx, err := a.Allocate()
|
||||
if err != nil {
|
||||
t.Fatalf("Allocate: %v", err)
|
||||
}
|
||||
if idx == 5 {
|
||||
t.Fatal("Allocate handed out a seeded slot")
|
||||
}
|
||||
|
||||
// A seeded slot can be adopted exactly once via Reserve (restore path)...
|
||||
if err := a.Reserve(5); err != nil {
|
||||
t.Fatalf("Reserve of seeded slot: %v", err)
|
||||
}
|
||||
// ...after which it is owned and a second Reserve fails.
|
||||
if err := a.Reserve(5); err == nil {
|
||||
t.Fatal("second Reserve of adopted slot should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotAllocator_ReserveCreatesClaimFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := NewSlotAllocator(dir)
|
||||
|
||||
if err := a.Reserve(7); err != nil {
|
||||
t.Fatalf("Reserve: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "7")); err != nil {
|
||||
t.Fatalf("claim file missing after Reserve: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotAllocator_ReserveOutOfRange(t *testing.T) {
|
||||
a := NewSlotAllocator("")
|
||||
for _, idx := range []int{0, -1, 32768} {
|
||||
if err := a.Reserve(idx); err == nil {
|
||||
t.Fatalf("Reserve(%d) should fail", idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlotAllocator_SeedFromMissingDir(t *testing.T) {
|
||||
a := NewSlotAllocator(filepath.Join(t.TempDir(), "does-not-exist"))
|
||||
if err := a.SeedFromDir(); err != nil {
|
||||
t.Fatalf("SeedFromDir on missing dir: %v", err)
|
||||
}
|
||||
}
|
||||
@ -545,6 +545,107 @@ func RemoveNetwork(slot *Slot) error {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
var privateEgressGuardNets = []string{
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"169.254.0.0/16",
|
||||
"100.64.0.0/10",
|
||||
}
|
||||
|
||||
var sandboxSuperNets = []string{"10.11.0.0/16", "10.12.0.0/16"}
|
||||
|
||||
const vethMatch = "wrenn-veth+"
|
||||
|
||||
// Installs host-level protections that stop capsule from (a) leaking traffic
|
||||
// destined to private ranges onto the upstream network via the public NIC
|
||||
// and (b) reaching another capsule's host-reachable IP.
|
||||
func EnsureEgressGuard() error {
|
||||
defaultIface, err := getDefaultInterface()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve default interface: %w", err)
|
||||
}
|
||||
|
||||
// Drop forwarded capsule traffic destined to private ranges out the public
|
||||
// NIC. Insert at the head of FORWARD so it precedes the per-sandbox ACCEPT
|
||||
// rules appended when a sandbox is created.
|
||||
for _, n := range privateEgressGuardNets {
|
||||
if err := ensureHostRule(
|
||||
[]string{"-C", "FORWARD", "-o", defaultIface, "-d", n, "-j", "DROP"},
|
||||
[]string{"-I", "FORWARD", "1", "-o", defaultIface, "-d", n, "-j", "DROP"},
|
||||
); err != nil {
|
||||
return fmt.Errorf("install egress guard for %s: %w", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Deny capsule-to-capsule forwarding outright: a veth may reach the uplink,
|
||||
// never another sandbox's veth. This closes cross-tenant reach to the
|
||||
// unauthenticated guest agent even for allocated slots.
|
||||
if err := ensureHostRule(
|
||||
[]string{"-C", "FORWARD", "-i", vethMatch, "-o", vethMatch, "-j", "DROP"},
|
||||
[]string{"-I", "FORWARD", "1", "-i", vethMatch, "-o", vethMatch, "-j", "DROP"},
|
||||
); err != nil {
|
||||
return fmt.Errorf("install inter-sandbox guard: %w", err)
|
||||
}
|
||||
|
||||
// Deny capsule-initiated connections to the host's own services. The
|
||||
// FORWARD guard above only covers transit traffic; packets addressed to
|
||||
// one of the host's own IPs are delivered locally via INPUT and would
|
||||
// otherwise reach anything the host binds on all interfaces (control
|
||||
// plane, host agent, SSH). Replies to host-initiated connections
|
||||
// (e.g. envd health checks / exec, where the host is the client) arrive
|
||||
// on the same veth and MUST still be accepted, so allow ESTABLISHED first
|
||||
// and drop only NEW capsule-sourced flows. Guest DNS points at public
|
||||
// resolvers (routed out the uplink, not INPUT), so this does not affect
|
||||
// name resolution.
|
||||
// Insert the DROP first, then prepend the ESTABLISHED accept ahead of it
|
||||
// (each "-I INPUT 1" prepends), so the final order is always accept-then-
|
||||
// drop without hardcoding a chain position that other host rules could
|
||||
// shift.
|
||||
if err := ensureHostRule(
|
||||
[]string{"-C", "INPUT", "-i", vethMatch, "-j", "DROP"},
|
||||
[]string{"-I", "INPUT", "1", "-i", vethMatch, "-j", "DROP"},
|
||||
); err != nil {
|
||||
return fmt.Errorf("install host input guard: %w", err)
|
||||
}
|
||||
if err := ensureHostRule(
|
||||
[]string{"-C", "INPUT", "-i", vethMatch, "-m", "conntrack", "--ctstate", "ESTABLISHED,RELATED", "-j", "ACCEPT"},
|
||||
[]string{"-I", "INPUT", "1", "-i", vethMatch, "-m", "conntrack", "--ctstate", "ESTABLISHED,RELATED", "-j", "ACCEPT"},
|
||||
); err != nil {
|
||||
return fmt.Errorf("install host input allow-established: %w", err)
|
||||
}
|
||||
|
||||
// Blackhole the sandbox supernets so packets to unallocated slot IPs are
|
||||
// dropped rather than routed to the default gateway.
|
||||
for _, cidr := range sandboxSuperNets {
|
||||
if err := ensureBlackholeRoute(cidr); err != nil {
|
||||
return fmt.Errorf("blackhole %s: %w", cidr, err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("egress guard installed", "default_iface", defaultIface)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureHostRule inserts a host iptables rule only when an identical rule is
|
||||
// not already present, keeping EnsureEgressGuard idempotent across restarts.
|
||||
func ensureHostRule(checkArgs, insertArgs []string) error {
|
||||
if exec.Command("iptables", checkArgs...).Run() == nil {
|
||||
return nil // already present
|
||||
}
|
||||
return iptablesHost(insertArgs...)
|
||||
}
|
||||
|
||||
// ensureBlackholeRoute idempotently installs a blackhole route via `ip route
|
||||
// replace` (a no-op if the route already matches).
|
||||
func ensureBlackholeRoute(cidr string) error {
|
||||
cmd := exec.Command("ip", "route", "replace", "blackhole", cidr)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("ip route replace blackhole %s: %s: %w", cidr, string(out), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nsExec runs a command inside a network namespace.
|
||||
func nsExec(nsName string, command string, args ...string) error {
|
||||
cmdArgs := append([]string{"netns", "exec", nsName, command}, args...)
|
||||
@ -3,7 +3,7 @@ package sandbox
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/envdclient"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/envdclient"
|
||||
)
|
||||
|
||||
func TestIsBusy(t *testing.T) {
|
||||
@ -11,8 +11,8 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/layout"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/layout"
|
||||
)
|
||||
|
||||
// DefaultDiskSizeMB is the standard disk size for base images. Images smaller
|
||||
@ -17,11 +17,11 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/devicemapper"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/layout"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/models"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/network"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/devicemapper"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/layout"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/models"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/network"
|
||||
)
|
||||
|
||||
// createFromSnapshotTemplate launches a new sandbox from a snapshot-template
|
||||
@ -147,6 +147,7 @@ func (m *Manager) createFromSnapshotTemplate(
|
||||
dmDevice: dmDev,
|
||||
baseImagePath: baseRootfs,
|
||||
sandboxDirOverride: meta.SandboxDir,
|
||||
lazyRestore: true,
|
||||
}
|
||||
sb.client.Store(client)
|
||||
|
||||
@ -162,6 +163,7 @@ func (m *Manager) createFromSnapshotTemplate(
|
||||
|
||||
m.startSampler(sb)
|
||||
m.startCrashWatcher(sb)
|
||||
m.writeRunningState(sb)
|
||||
|
||||
slog.Info("sandbox launched from snapshot template",
|
||||
"id", sandboxID,
|
||||
@ -15,13 +15,13 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/devicemapper"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/envdclient"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/layout"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/models"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/network"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/vm"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/devicemapper"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/envdclient"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/layout"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/models"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/network"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/vm"
|
||||
envdpb "git.omukk.dev/wrenn/wrenn/proto/envd/gen"
|
||||
)
|
||||
|
||||
@ -211,12 +211,29 @@ type sandboxState struct {
|
||||
// find rootfs.ext4 in the new mount namespace.
|
||||
sandboxDirOverride string
|
||||
|
||||
// lazyRestore records that this sandbox's CH process was launched with
|
||||
// --restore ...,memory_restore_mode=ondemand (Resume or snapshot-template
|
||||
// launch): guest pages fault in lazily from the source memory-ranges, so
|
||||
// any vm.snapshot taken before the guest-side preload completes would emit
|
||||
// holes for never-faulted pages — silent corruption on the next restore.
|
||||
// Pause/CreateSnapshot consult this via ensureMemoryMaterialized. Persisted
|
||||
// in the running-state file so re-attached sandboxes keep the guarantee.
|
||||
lazyRestore bool
|
||||
|
||||
// Background memory loading state (set during Resume for UFFD sandboxes).
|
||||
// nil for freshly-created sandboxes. For resumed sandboxes, memLoadDone
|
||||
// is closed when the background loader finishes (success or failure).
|
||||
memLoadDone chan struct{} // closed when background memory loader exits
|
||||
memLoadCancel context.CancelFunc // cancels the background loader goroutine
|
||||
|
||||
// Background zero-page punch state (set by Pause for paused sandboxes).
|
||||
// punchZeroPagesInDir runs off the pause critical path: the VM is already
|
||||
// destroyed and the snapshot dir swapped in, so the read-back-and-punch
|
||||
// pass need not block the user-perceived pause. Resume/Destroy cancel and
|
||||
// wait via waitForPunch before relaunching CH or removing the snapshot dir.
|
||||
punchDone chan struct{} // closed when the background punch exits
|
||||
punchCancel context.CancelFunc // cancels the background punch goroutine
|
||||
|
||||
// Metrics sampling state.
|
||||
vmmPID int // VMM process PID (child of unshare wrapper)
|
||||
ring *metricsRing // tiered ring buffers for CPU/mem/disk metrics
|
||||
@ -256,10 +273,16 @@ func New(cfg Config) *Manager {
|
||||
if cfg.EnvdTimeout == 0 {
|
||||
cfg.EnvdTimeout = 30 * time.Second
|
||||
}
|
||||
slots := network.NewSlotAllocator(layout.SlotsDir(cfg.WrennDir))
|
||||
// Honor slots claimed by other live processes (e.g. a concurrent CLI
|
||||
// invocation mid-Create) before handing any out ourselves.
|
||||
if err := slots.SeedFromDir(); err != nil {
|
||||
slog.Warn("failed to seed slot allocator from disk", "error", err)
|
||||
}
|
||||
return &Manager{
|
||||
cfg: cfg,
|
||||
vm: vm.NewManager(),
|
||||
slots: network.NewSlotAllocator(),
|
||||
slots: slots,
|
||||
loops: devicemapper.NewLoopRegistry(),
|
||||
boxes: make(map[string]*sandboxState),
|
||||
creates: make(map[string]*createHandle),
|
||||
@ -306,10 +329,10 @@ func (m *Manager) Create(
|
||||
}
|
||||
|
||||
if vcpus <= 0 {
|
||||
vcpus = 1
|
||||
vcpus = 2
|
||||
}
|
||||
if memoryMB <= 0 {
|
||||
memoryMB = 512
|
||||
memoryMB = 2048
|
||||
}
|
||||
if diskSizeMB <= 0 {
|
||||
diskSizeMB = m.cfg.DefaultRootfsSizeMB
|
||||
@ -497,6 +520,7 @@ func (m *Manager) Create(
|
||||
|
||||
m.startSampler(sb)
|
||||
m.startCrashWatcher(sb)
|
||||
m.writeRunningState(sb)
|
||||
|
||||
slog.Info("sandbox created",
|
||||
"id", sandboxID,
|
||||
@ -579,6 +603,9 @@ func (m *Manager) Destroy(ctx context.Context, sandboxID string) error {
|
||||
|
||||
// cleanup tears down all resources for a sandbox.
|
||||
func (m *Manager) cleanup(ctx context.Context, sb *sandboxState) {
|
||||
// Stop any background zero-page punch before removing the snapshot dir,
|
||||
// so a lingering goroutine can't keep writing to files we're deleting.
|
||||
m.waitForPunch(sb)
|
||||
if sb.memLoadCancel != nil {
|
||||
sb.memLoadCancel()
|
||||
if sb.memLoadDone != nil {
|
||||
@ -595,6 +622,8 @@ func (m *Manager) cleanup(ctx context.Context, sb *sandboxState) {
|
||||
m.slots.Release(sb.SlotIndex)
|
||||
|
||||
// Tear down dm-snapshot and release the base image loop device.
|
||||
// RemoveSnapshot detaches the CoW loop even on failure, so deleting the
|
||||
// CoW file below cannot orphan it.
|
||||
if sb.dmDevice != nil {
|
||||
if err := devicemapper.RemoveSnapshot(context.Background(), sb.dmDevice); err != nil {
|
||||
slog.Warn("dm-snapshot remove error", "id", sb.ID, "error", err)
|
||||
@ -602,8 +631,10 @@ func (m *Manager) cleanup(ctx context.Context, sb *sandboxState) {
|
||||
os.Remove(sb.dmDevice.CowPath)
|
||||
}
|
||||
// Paused branch: dm-snapshot and loop were already released by
|
||||
// releaseRuntime; the CoW file inside the sandbox dir is removed by
|
||||
// Destroy's os.RemoveAll(SandboxDir) below.
|
||||
// releaseRuntime, which also cleared dmDevice and baseImagePath — both
|
||||
// guards below are nil/empty for a paused sandbox, so nothing double-
|
||||
// releases. The CoW file inside the sandbox dir is removed by Destroy's
|
||||
// os.RemoveAll(SandboxDir) below.
|
||||
if sb.baseImagePath != "" {
|
||||
m.loops.Release(sb.baseImagePath)
|
||||
}
|
||||
@ -710,16 +741,18 @@ func (m *Manager) SetDefaults(ctx context.Context, sandboxID, defaultUser string
|
||||
}
|
||||
|
||||
// PtyAttach starts a new PTY process or reconnects to an existing one.
|
||||
// If cmd is non-empty, starts a new process. If empty, reconnects using tag.
|
||||
func (m *Manager) PtyAttach(ctx context.Context, sandboxID, tag, cmd string, args []string, cols, rows uint32, envs map[string]string, cwd string) (<-chan envdclient.PtyEvent, error) {
|
||||
// When reconnect is true it reattaches to the process identified by tag;
|
||||
// otherwise it starts a new process (cmd may be empty to launch the user's
|
||||
// default login shell). user empty means the sandbox default user.
|
||||
func (m *Manager) PtyAttach(ctx context.Context, sandboxID, tag, cmd string, args []string, cols, rows uint32, envs map[string]string, cwd, user string, reconnect bool) (<-chan envdclient.PtyEvent, error) {
|
||||
c, err := m.activeClient(sandboxID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cmd != "" {
|
||||
return c.PtyStart(ctx, tag, cmd, args, cols, rows, envs, cwd)
|
||||
if reconnect {
|
||||
return c.PtyConnect(ctx, tag)
|
||||
}
|
||||
return c.PtyConnect(ctx, tag)
|
||||
return c.PtyStart(ctx, tag, cmd, args, cols, rows, envs, cwd, user)
|
||||
}
|
||||
|
||||
// PtySendInput sends raw bytes to a PTY process in a sandbox.
|
||||
@ -1249,6 +1282,8 @@ func (m *Manager) cleanupAfterCrash(sb *sandboxState) {
|
||||
}
|
||||
m.slots.Release(sb.SlotIndex)
|
||||
|
||||
// RemoveSnapshot detaches the CoW loop even on failure, so the RemoveAll
|
||||
// below (which deletes the CoW backing file) cannot orphan it.
|
||||
if sb.dmDevice != nil {
|
||||
if err := devicemapper.RemoveSnapshot(context.Background(), sb.dmDevice); err != nil {
|
||||
slog.Warn("crash cleanup: dm-snapshot error", "id", sb.ID, "error", err)
|
||||
@ -41,13 +41,13 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/devicemapper"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/layout"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/models"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/network"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/snapshot"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/vm"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/devicemapper"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/layout"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/models"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/network"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/vm"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -56,8 +56,11 @@ const (
|
||||
snapshotMetaFile = "wrenn-snapshot.json"
|
||||
|
||||
// drainTimeout is how long pause waits for in-flight proxy connections
|
||||
// to release before forcibly cancelling them.
|
||||
drainTimeout = 5 * time.Second
|
||||
// to release before forcibly cancelling them. Kept tight: an idle capsule
|
||||
// has no in-flight connections so Drain returns immediately, and a busy
|
||||
// one is force-closed straight after — a long cap only adds dead latency
|
||||
// to the user-perceived pause.
|
||||
drainTimeout = 2 * time.Second
|
||||
|
||||
// prepareSnapshotTimeout bounds the in-guest /snapshot/prepare call.
|
||||
// Short on purpose: envd PrepareSnapshot is best-effort, and a wedged
|
||||
@ -165,13 +168,25 @@ func (m *Manager) Pause(ctx context.Context, sandboxID string) error {
|
||||
return fmt.Errorf("%w: %s (status: %s)", ErrNotRunning, sandboxID, sb.Status)
|
||||
}
|
||||
|
||||
// Wait for the post-resume memory loader to finish before snapshotting.
|
||||
// Without this, ch.snapshot's SEEK_DATA/SEEK_HOLE writer would emit holes
|
||||
// for any page not yet faulted in, which read back as zero on the next
|
||||
// restore — silent corruption across pause/resume chains.
|
||||
if err := m.waitForMemoryLoader(ctx, sb); err != nil {
|
||||
// Per-phase timing so the slow step in a pause is visible in logs without
|
||||
// a profiler. phase(name) logs the elapsed time since the previous phase.
|
||||
phaseStart := time.Now()
|
||||
lastPhase := phaseStart
|
||||
phase := func(name string) {
|
||||
now := time.Now()
|
||||
slog.Debug("pause phase", "id", sandboxID, "phase", name, "ms", now.Sub(lastPhase).Milliseconds())
|
||||
lastPhase = now
|
||||
}
|
||||
|
||||
// Ensure every guest page is resident before snapshotting. Without this,
|
||||
// ch.snapshot's SEEK_DATA/SEEK_HOLE writer would emit holes for any page
|
||||
// not yet faulted in, which read back as zero on the next restore —
|
||||
// silent corruption across pause/resume chains. Verified against envd's
|
||||
// preload status, not just the in-process loader goroutine.
|
||||
if err := m.ensureMemoryMaterialized(ctx, sb); err != nil {
|
||||
return fmt.Errorf("pause %s: %w", sandboxID, err)
|
||||
}
|
||||
phase("memory_loader_wait")
|
||||
|
||||
m.mu.Lock()
|
||||
sb.Status = models.StatusPausing
|
||||
@ -190,6 +205,26 @@ func (m *Manager) Pause(ctx context.Context, sandboxID string) error {
|
||||
sb.Status = models.StatusError
|
||||
m.mu.Unlock()
|
||||
sb.connTracker.Reset()
|
||||
// The VM is dead (typically it crashed mid-quiesce — the crash
|
||||
// watcher saw StatusPausing and deliberately stood down). Nothing
|
||||
// will ever resume this sandbox, so release its runtime now:
|
||||
// otherwise the network slot, dm-snapshot, loop refcount, and
|
||||
// sampler goroutine stay held until an explicit Destroy or agent
|
||||
// shutdown. Zero SlotIndex so a later Destroy's cleanup() cannot
|
||||
// release the (possibly re-allocated) slot a second time.
|
||||
m.releaseRuntime(sb, dropCow)
|
||||
m.mu.Lock()
|
||||
sb.SlotIndex = 0
|
||||
m.mu.Unlock()
|
||||
// Remove the sandbox dir wholesale, mirroring cleanupAfterCrash.
|
||||
// This drops the running-state file — its recorded slot is freed
|
||||
// and may be re-claimed, so a later restart's sweep must not tear
|
||||
// down the new owner's network — and any previous pause
|
||||
// generation, which is unusable anyway: the CoW has advanced past
|
||||
// the memory image it was captured with.
|
||||
if err := os.RemoveAll(layout.SandboxDir(m.cfg.WrennDir, sandboxID)); err != nil {
|
||||
slog.Warn("pause rollback: sandbox dir remove failed", "id", sandboxID, "error", err)
|
||||
}
|
||||
return fmt.Errorf("pause %s: %s: %w (and resume failed: %v)",
|
||||
sandboxID, stage, cause, rerr)
|
||||
}
|
||||
@ -203,9 +238,10 @@ func (m *Manager) Pause(ctx context.Context, sandboxID string) error {
|
||||
if err := m.quiesceAndPauseCH(ctx, sb); err != nil {
|
||||
return rollbackToRunning(err, "quiesce")
|
||||
}
|
||||
phase("quiesce_and_pause")
|
||||
|
||||
// Memory materialisation is handled out-of-band by the background loader
|
||||
// kicked off by Resume after /init. We blocked on it above (waitForMemoryLoader)
|
||||
// kicked off by Resume after /init. We blocked on it above (ensureMemoryMaterialized)
|
||||
// so by the time we reach ch.snapshot every guest page is resident in CH's
|
||||
// memfile and SEEK_DATA/SEEK_HOLE produces a self-contained snapshot.
|
||||
|
||||
@ -215,11 +251,12 @@ func (m *Manager) Pause(ctx context.Context, sandboxID string) error {
|
||||
if err := m.vm.Snapshot(ctx, sandboxID, stageDir); err != nil {
|
||||
return rollbackToRunning(err, "snapshot")
|
||||
}
|
||||
phase("ch_snapshot")
|
||||
|
||||
// Punch zero pages CH wrote verbatim (guest had them dirty-then-free
|
||||
// without notifying the balloon driver). Best-effort; failures only
|
||||
// cost disk space.
|
||||
punchZeroPagesInDir(stageDir)
|
||||
// Zero-page punching is deferred to a background goroutine after the swap
|
||||
// (see startAsyncPunch below). It reads the whole memory-ranges file back
|
||||
// to reclaim zeros CH wrote verbatim — a full IO pass we keep off the
|
||||
// user-perceived pause critical path.
|
||||
|
||||
meta := &snapshotMeta{
|
||||
TeamID: id.UUIDString(pgtype.UUID{Bytes: sb.TemplateTeamID, Valid: true}),
|
||||
@ -269,11 +306,66 @@ func (m *Manager) Pause(ctx context.Context, sandboxID string) error {
|
||||
m.mu.Lock()
|
||||
sb.Status = models.StatusPaused
|
||||
m.mu.Unlock()
|
||||
phase("release_and_swap")
|
||||
|
||||
slog.Info("sandbox paused", "id", sandboxID, "snapshot_dir", finalDir)
|
||||
// Reclaim zero pages CH wrote verbatim (guest dirtied-then-freed them
|
||||
// without notifying the balloon driver). Deferred off the critical path:
|
||||
// the VM is destroyed and finalDir is in place, so nothing holds the file
|
||||
// open. Resume/Destroy cancel-and-wait via waitForPunch before reusing or
|
||||
// removing the dir; a punched all-zero block reads back as zero, so the
|
||||
// pass is safe to interrupt at any point.
|
||||
m.startAsyncPunch(sb, finalDir)
|
||||
|
||||
slog.Info("sandbox paused",
|
||||
"id", sandboxID,
|
||||
"snapshot_dir", finalDir,
|
||||
"total_ms", time.Since(phaseStart).Milliseconds())
|
||||
return nil
|
||||
}
|
||||
|
||||
// startAsyncPunch launches the background zero-page punch for a just-paused
|
||||
// sandbox. Cancellable + joinable via sb.punchCancel / sb.punchDone, which
|
||||
// waitForPunch consumes. Best-effort: a failed or cancelled punch only leaves
|
||||
// the snapshot larger on disk, never incorrect.
|
||||
func (m *Manager) startAsyncPunch(sb *sandboxState, dir string) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
|
||||
m.mu.Lock()
|
||||
sb.punchCancel = cancel
|
||||
sb.punchDone = done
|
||||
m.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
started := time.Now()
|
||||
punchZeroPagesInDir(ctx, dir)
|
||||
if ctx.Err() == nil {
|
||||
slog.Info("async zero-page punch complete",
|
||||
"id", sb.ID, "elapsed_ms", time.Since(started).Milliseconds())
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// waitForPunch cancels and joins any in-flight background punch for sb, then
|
||||
// clears the handles. Idempotent and safe when no punch is running. Must be
|
||||
// called before Resume relaunches CH on the snapshot or Destroy removes it.
|
||||
func (m *Manager) waitForPunch(sb *sandboxState) {
|
||||
m.mu.Lock()
|
||||
cancel := sb.punchCancel
|
||||
done := sb.punchDone
|
||||
sb.punchCancel = nil
|
||||
sb.punchDone = nil
|
||||
m.mu.Unlock()
|
||||
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// swapDir atomically replaces final with stage. Any existing final dir is
|
||||
// moved aside to a uniquely-named trash dir before the swap so the rename
|
||||
// can succeed, then the trash is removed.
|
||||
@ -428,6 +520,8 @@ func (m *Manager) releaseRuntime(sb *sandboxState, cow cowDisposition) {
|
||||
m.slots.Release(sb.SlotIndex)
|
||||
}
|
||||
|
||||
// RemoveSnapshot detaches the CoW loop even on failure, so dropCow's file
|
||||
// deletion cannot orphan it and keepCow's Resume cannot double-attach.
|
||||
if sb.dmDevice != nil {
|
||||
if err := devicemapper.RemoveSnapshot(context.Background(), sb.dmDevice); err != nil {
|
||||
slog.Warn("dm-snapshot remove on pause", "id", sb.ID, "error", err)
|
||||
@ -440,10 +534,16 @@ func (m *Manager) releaseRuntime(sb *sandboxState, cow cowDisposition) {
|
||||
m.loops.Release(sb.baseImagePath)
|
||||
}
|
||||
|
||||
// Clear runtime references; they're rebuilt on resume.
|
||||
// Clear runtime references; they're rebuilt on resume. baseImagePath MUST
|
||||
// be cleared along with the rest: it pairs 1:1 with the loop refcount just
|
||||
// released, and a later Destroy-of-paused runs cleanup(), which releases
|
||||
// the loop again whenever baseImagePath is set — underflowing the shared
|
||||
// registry and detaching the base image loop out from under every other
|
||||
// sandbox running on the same template.
|
||||
sb.slot = nil
|
||||
sb.client.Store(nil)
|
||||
sb.dmDevice = nil
|
||||
sb.baseImagePath = ""
|
||||
}
|
||||
|
||||
// Resume re-launches a paused sandbox from its on-disk snapshot. The same
|
||||
@ -477,6 +577,11 @@ func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int,
|
||||
return nil, fmt.Errorf("%w: %s (status: %s)", ErrNotPaused, sandboxID, sb.Status)
|
||||
}
|
||||
|
||||
// Stop any background zero-page punch still scanning the snapshot before
|
||||
// CH reopens memory-ranges for --restore. Punching is safe to interrupt;
|
||||
// whatever it reclaimed stays valid, and the rest just stays on disk.
|
||||
m.waitForPunch(sb)
|
||||
|
||||
snapDir := layout.PauseSnapshotDir(m.cfg.WrennDir, sandboxID)
|
||||
meta, err := readSnapshotMeta(snapDir)
|
||||
if err != nil {
|
||||
@ -591,6 +696,7 @@ func (m *Manager) resumeFromMeta(ctx context.Context, sb *sandboxState, meta *sn
|
||||
// baseImagePath is populated — the restored entry intentionally leaves
|
||||
// it empty so a Destroy-before-Resume cannot underflow the registry.
|
||||
sb.baseImagePath = meta.BaseTemplate
|
||||
sb.lazyRestore = true
|
||||
sb.connTracker.Reset()
|
||||
sb.HostIP = slot.HostIP
|
||||
sb.RootfsPath = dmDev.DevicePath
|
||||
@ -600,6 +706,7 @@ func (m *Manager) resumeFromMeta(ctx context.Context, sb *sandboxState, meta *sn
|
||||
|
||||
m.startSampler(sb)
|
||||
m.startCrashWatcher(sb)
|
||||
m.writeRunningState(sb)
|
||||
|
||||
// Background memory loader is started by the outer Resume AFTER /init
|
||||
// completes — see comment there for the race rationale.
|
||||
@ -676,23 +783,65 @@ func (m *Manager) startMemoryLoader(sb *sandboxState) {
|
||||
}()
|
||||
}
|
||||
|
||||
// waitForMemoryLoader blocks until the background memory loader finishes, or
|
||||
// until ctx is cancelled. Returns nil if the loader is already done or not
|
||||
// running. A pause must wait on this before ch.snapshot so the resulting
|
||||
// memory-ranges is self-contained.
|
||||
func (m *Manager) waitForMemoryLoader(ctx context.Context, sb *sandboxState) error {
|
||||
// ensureMemoryMaterialized guarantees that every guest page of a
|
||||
// lazily-restored sandbox is resident in CH's memfile before a vm.snapshot.
|
||||
// For cold-booted sandboxes (lazyRestore unset) it is a no-op.
|
||||
//
|
||||
// envd's /memory/preload status is the source of truth — not the in-process
|
||||
// loader goroutine, which does not survive a host-agent restart and whose
|
||||
// completion only means "polling ended", not "preload succeeded". The
|
||||
// in-process channel is still awaited first so a running loader isn't raced;
|
||||
// the verdict then comes from the guest:
|
||||
//
|
||||
// done → safe to snapshot
|
||||
// idle / running → (re)start and wait: idle happens when the agent died
|
||||
// between restore and the loader's POST, or a previous
|
||||
// start attempt failed on the wire
|
||||
// failed / cancelled → hard error; snapshotting now would silently zero all
|
||||
// never-faulted guest memory on the next restore
|
||||
func (m *Manager) ensureMemoryMaterialized(ctx context.Context, sb *sandboxState) error {
|
||||
m.mu.RLock()
|
||||
lazy := sb.lazyRestore
|
||||
done := sb.memLoadDone
|
||||
m.mu.RUnlock()
|
||||
if done == nil {
|
||||
if !lazy {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for memory loader: %w", ctx.Err())
|
||||
|
||||
if done != nil {
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for memory loader: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
client := sb.client.Load()
|
||||
if client == nil {
|
||||
return fmt.Errorf("memory preload verify: envd client unavailable")
|
||||
}
|
||||
|
||||
// One POST covers every state: "done" reports immediately, "running"
|
||||
// reports the in-flight run, "idle" starts one (agent died between
|
||||
// restore and the loader's POST, or an earlier start failed on the wire),
|
||||
// and a previous "failed"/"cancelled" run is re-armed — so a transient
|
||||
// guest-side failure blocks only this attempt, not every future pause.
|
||||
status, err := client.StartMemoryPreload(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("memory preload start: %w", err)
|
||||
}
|
||||
if status.State != "done" {
|
||||
status, err = client.WaitMemoryPreload(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("memory preload wait: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if status.State != "done" {
|
||||
return fmt.Errorf("memory preload %s: %s (source %q, %d pages, %d bytes)",
|
||||
status.State, status.Error, status.Source, status.Pages, status.Bytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateSnapshot writes a self-contained template snapshot to
|
||||
@ -736,10 +885,10 @@ func (m *Manager) CreateSnapshot(ctx context.Context, sandboxID string, teamID,
|
||||
func (m *Manager) snapshotRunningToTemplate(ctx context.Context, sb *sandboxState, teamID, templateID pgtype.UUID, name string) (int64, error) {
|
||||
sandboxID := sb.ID
|
||||
|
||||
// Same rationale as Pause: wait for the background memory loader so the
|
||||
// resulting memory-ranges is self-contained when this sandbox itself was
|
||||
// previously restored from an ondemand snapshot.
|
||||
if err := m.waitForMemoryLoader(ctx, sb); err != nil {
|
||||
// Same rationale as Pause: ensure guest memory is fully materialised so
|
||||
// the resulting memory-ranges is self-contained when this sandbox itself
|
||||
// was previously restored from an ondemand snapshot.
|
||||
if err := m.ensureMemoryMaterialized(ctx, sb); err != nil {
|
||||
return 0, fmt.Errorf("create snapshot %s: %w", sandboxID, err)
|
||||
}
|
||||
|
||||
@ -765,7 +914,10 @@ func (m *Manager) snapshotRunningToTemplate(ctx context.Context, sb *sandboxStat
|
||||
sb.connTracker.Reset()
|
||||
return 0, fmt.Errorf("vm.snapshot: %w", err)
|
||||
}
|
||||
punchZeroPagesInDir(stageDir)
|
||||
// Template snapshots punch synchronously: this path resumes the VM right
|
||||
// after and produces a reusable artefact, so size matters more than the
|
||||
// few seconds saved, and there is no paused-sandbox handle to defer onto.
|
||||
punchZeroPagesInDir(ctx, stageDir)
|
||||
|
||||
// Flatten dm-snapshot → rootfs.ext4. Reads through the dm device which is
|
||||
// stable while CH is paused.
|
||||
@ -841,6 +993,12 @@ func (m *Manager) snapshotRunningToTemplate(ctx context.Context, sb *sandboxStat
|
||||
// loader before snapshotting), so we copy those memory files verbatim and
|
||||
// flatten the persistent CoW into rootfs.ext4. The sandbox stays Paused.
|
||||
func (m *Manager) snapshotPausedToTemplate(ctx context.Context, sb *sandboxState, teamID, templateID pgtype.UUID, name string) (int64, error) {
|
||||
// Join the pause's background zero-page punch before copying memory-ranges.
|
||||
// Without this the template would link the un-punched file and report its
|
||||
// full (~2GB) size instead of the reclaimed (~650MB) one — the punch
|
||||
// shrinks the very file we hardlink here.
|
||||
m.waitForPunch(sb)
|
||||
|
||||
snapDir := layout.PauseSnapshotDir(m.cfg.WrennDir, sb.ID)
|
||||
meta, err := readSnapshotMeta(snapDir)
|
||||
if err != nil {
|
||||
152
pkg/sandbox/pause_test.go
Normal file
152
pkg/sandbox/pause_test.go
Normal file
@ -0,0 +1,152 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/envdclient"
|
||||
)
|
||||
|
||||
// preloadServer fakes envd's /memory/preload endpoint. GET returns the states
|
||||
// in sequence (last one repeats); POST marks the loader started and returns
|
||||
// the current state.
|
||||
type preloadServer struct {
|
||||
states []string
|
||||
idx atomic.Int64
|
||||
posts atomic.Int64
|
||||
gets atomic.Int64
|
||||
}
|
||||
|
||||
func (s *preloadServer) handler(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasSuffix(r.URL.Path, "/memory/preload") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
s.posts.Add(1)
|
||||
case http.MethodGet:
|
||||
s.gets.Add(1)
|
||||
}
|
||||
i := s.idx.Load()
|
||||
if int(i) >= len(s.states) {
|
||||
i = int64(len(s.states) - 1)
|
||||
} else {
|
||||
s.idx.Add(1)
|
||||
}
|
||||
state := s.states[i]
|
||||
resp := map[string]any{"state": state}
|
||||
if state == "failed" {
|
||||
resp["error"] = "kcore walk read no pages"
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func TestEnsureMemoryMaterialized(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
lazy bool
|
||||
nilClient bool
|
||||
states []string // envd responses in order
|
||||
wantErr string // substring; "" = expect nil
|
||||
wantPosts int64 // exact POST count; -1 = don't check
|
||||
}{
|
||||
{
|
||||
name: "cold boot is a no-op",
|
||||
lazy: false,
|
||||
states: []string{"idle"},
|
||||
wantErr: "",
|
||||
wantPosts: 0,
|
||||
},
|
||||
{
|
||||
name: "lazy and already done",
|
||||
lazy: true,
|
||||
states: []string{"done"},
|
||||
wantErr: "",
|
||||
wantPosts: 1, // single POST reports done, no polling
|
||||
},
|
||||
{
|
||||
name: "lazy idle self-heals via start",
|
||||
lazy: true,
|
||||
states: []string{"idle", "running", "done", "done"},
|
||||
wantErr: "",
|
||||
wantPosts: 1,
|
||||
},
|
||||
{
|
||||
name: "lazy failed after wait is a hard error",
|
||||
lazy: true,
|
||||
states: []string{"idle", "failed"},
|
||||
wantErr: "memory preload failed",
|
||||
wantPosts: 1,
|
||||
},
|
||||
{
|
||||
name: "lazy cancelled is a hard error",
|
||||
lazy: true,
|
||||
states: []string{"running", "cancelled"},
|
||||
wantErr: "memory preload cancelled",
|
||||
wantPosts: 1,
|
||||
},
|
||||
{
|
||||
name: "lazy without client fails",
|
||||
lazy: true,
|
||||
nilClient: true,
|
||||
wantErr: "envd client unavailable",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
srv := &preloadServer{states: tt.states}
|
||||
ts := httptest.NewServer(http.HandlerFunc(srv.handler))
|
||||
defer ts.Close()
|
||||
|
||||
m := &Manager{}
|
||||
sb := &sandboxState{lazyRestore: tt.lazy}
|
||||
if !tt.nilClient {
|
||||
sb.client.Store(envdclient.NewWithBaseURL(ts.URL))
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
err := m.ensureMemoryMaterialized(ctx, sb)
|
||||
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("ensureMemoryMaterialized: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("want error containing %q, got %v", tt.wantErr, err)
|
||||
}
|
||||
}
|
||||
if tt.wantPosts >= 0 && srv.posts.Load() != tt.wantPosts {
|
||||
t.Fatalf("POST count = %d, want %d", srv.posts.Load(), tt.wantPosts)
|
||||
}
|
||||
if !tt.lazy && srv.gets.Load() != 0 {
|
||||
t.Fatalf("cold boot must not query envd, got %d GETs", srv.gets.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ensureMemoryMaterialized must first drain an in-process loader goroutine
|
||||
// before consulting envd, and must honor ctx cancellation while blocked on it.
|
||||
func TestEnsureMemoryMaterializedWaitsForLoader(t *testing.T) {
|
||||
m := &Manager{}
|
||||
sb := &sandboxState{lazyRestore: true}
|
||||
sb.memLoadDone = make(chan struct{}) // never closed
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
err := m.ensureMemoryMaterialized(ctx, sb)
|
||||
if err == nil || !strings.Contains(err.Error(), "wait for memory loader") {
|
||||
t.Fatalf("want loader-wait timeout error, got %v", err)
|
||||
}
|
||||
}
|
||||
@ -10,7 +10,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/envdclient"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/envdclient"
|
||||
)
|
||||
|
||||
// cpuStat holds raw CPU jiffies read from /proc/{pid}/stat.
|
||||
@ -9,6 +9,7 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@ -38,7 +39,12 @@ const (
|
||||
// CH writes its memory dump as one or more files prefixed "memory" inside
|
||||
// the snapshot directory; everything else (config.json, state.json) is
|
||||
// metadata and untouched.
|
||||
func punchZeroPagesInDir(dir string) {
|
||||
//
|
||||
// ctx cancellation aborts the scan between IO chunks so a Resume/Destroy that
|
||||
// supersedes a background punch returns promptly. A cancelled scan leaves the
|
||||
// file valid — already-punched holes read back identically to the zeros they
|
||||
// replaced.
|
||||
func punchZeroPagesInDir(ctx context.Context, dir string) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
slog.Warn("punch: read snapshot dir", "dir", dir, "error", err)
|
||||
@ -48,8 +54,14 @@ func punchZeroPagesInDir(dir string) {
|
||||
if e.IsDir() || !strings.HasPrefix(e.Name(), "memory") {
|
||||
continue
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
before, after, err := punchZeroPages(path)
|
||||
before, after, err := punchZeroPages(ctx, path)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("punch: zero-page scan failed", "path", path, "error", err)
|
||||
continue
|
||||
@ -67,7 +79,7 @@ func punchZeroPagesInDir(dir string) {
|
||||
// skipped via SEEK_DATA so a partially-sparse input stays cheap to scan.
|
||||
//
|
||||
// Returns the file's disk allocation (st_blocks * 512) before and after.
|
||||
func punchZeroPages(path string) (int64, int64, error) {
|
||||
func punchZeroPages(ctx context.Context, path string) (int64, int64, error) {
|
||||
f, err := os.OpenFile(path, os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
@ -111,6 +123,17 @@ func punchZeroPages(path string) (int64, int64, error) {
|
||||
zeroStart := int64(-1)
|
||||
cur := off
|
||||
for cur < endData {
|
||||
if ctx.Err() != nil {
|
||||
// Flush any pending zero run before bailing so the partial
|
||||
// scan still reclaims what it found.
|
||||
if zeroStart >= 0 {
|
||||
if err := punch(f, zeroStart, cur-zeroStart); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
}
|
||||
stAfter, _ := statBlocks(f)
|
||||
return stBefore, stAfter, ctx.Err()
|
||||
}
|
||||
toRead := min(int64(len(buf)), endData-cur)
|
||||
n, err := readAt(f, buf[:toRead], cur)
|
||||
if err != nil {
|
||||
@ -17,9 +17,9 @@ import (
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/envdclient"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/network"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/vm"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/envdclient"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/network"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/vm"
|
||||
)
|
||||
|
||||
// restoreInputs is the common set of fields needed to build a restore VMConfig.
|
||||
@ -11,8 +11,8 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/layout"
|
||||
"git.omukk.dev/wrenn/wrenn/internal/models"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/layout"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/models"
|
||||
)
|
||||
|
||||
// RestorePausedSandboxes scans WRENN_DIR/sandboxes/ for paused-sandbox
|
||||
@ -68,6 +68,29 @@ func (m *Manager) RestorePausedSandboxes() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip sandboxes already registered — a resumed-then-still-running
|
||||
// sandbox re-attached by RestoreRunningSandboxes keeps its previous
|
||||
// pause generation on disk, but the live VM wins.
|
||||
m.mu.RLock()
|
||||
_, known := m.boxes[name]
|
||||
m.mu.RUnlock()
|
||||
if known {
|
||||
continue
|
||||
}
|
||||
|
||||
// A readable running-state file that survived RestoreRunningSandboxes
|
||||
// means the adoption of a verified-LIVE CH process failed (probe
|
||||
// timeout etc.) and its slot/loop were deliberately left held. The
|
||||
// stale pause generation in the same dir must not be registered — and
|
||||
// above all not trashed: trashing renames the whole sandbox dir,
|
||||
// carrying away the live VM's CoW file and the state file the next
|
||||
// restart needs to retry the adoption.
|
||||
if _, err := readRunningState(m.cfg.WrennDir, name); err == nil {
|
||||
slog.Warn("restore: skipping paused candidate — dir owned by live un-adopted VM",
|
||||
"id", name)
|
||||
continue
|
||||
}
|
||||
|
||||
snapDir := layout.PauseSnapshotDir(m.cfg.WrennDir, name)
|
||||
meta, err := readSnapshotMeta(snapDir)
|
||||
if err != nil {
|
||||
269
pkg/sandbox/restore_running.go
Normal file
269
pkg/sandbox/restore_running.go
Normal file
@ -0,0 +1,269 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/devicemapper"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/envdclient"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/layout"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/models"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/network"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/vm"
|
||||
)
|
||||
|
||||
// reattachProbeTimeout bounds the CH API probe issued while re-attaching to
|
||||
// a live VM. Local unix-socket call — kept tight so a dead socket fails fast.
|
||||
const reattachProbeTimeout = 3 * time.Second
|
||||
|
||||
// RestoreRunningSandboxes scans WRENN_DIR/sandboxes/ for sandboxes whose
|
||||
// creating process has exited but whose VMs are still running, and re-attaches
|
||||
// them to this manager. This is what lets a short-lived CLI process Exec into
|
||||
// or Destroy a sandbox created by an earlier invocation.
|
||||
//
|
||||
// A sandbox qualifies when its dir contains a sandbox.json running-state file
|
||||
// (written on create/resume, removed by every teardown path) AND its CH
|
||||
// process is verifiably alive: the recorded PID exists, its cmdline still
|
||||
// references this sandbox (PID-recycling guard), and the CH API socket
|
||||
// answers. Anything else is a stale record from a crash — its leftover
|
||||
// resources are swept, but a pause snapshot in the same dir is preserved so
|
||||
// RestorePausedSandboxes can still recover it.
|
||||
//
|
||||
// Must be called once at startup, BEFORE RestorePausedSandboxes: a resumed
|
||||
// sandbox's dir contains both the running-state file and the previous
|
||||
// generation's pause snapshot, and the running state must win while the VM
|
||||
// is alive.
|
||||
//
|
||||
// Never returns an error — a single unrecoverable sandbox must not block
|
||||
// startup. Mirrors RestorePausedSandboxes.
|
||||
func (m *Manager) RestoreRunningSandboxes() {
|
||||
sandboxesDir := layout.SandboxesDir(m.cfg.WrennDir)
|
||||
entries, err := os.ReadDir(sandboxesDir)
|
||||
if err != nil {
|
||||
// Directory does not exist yet — fresh install, nothing to restore.
|
||||
return
|
||||
}
|
||||
|
||||
restored, swept := 0, 0
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.Contains(name, ".staging-") || strings.Contains(name, ".trash-") {
|
||||
continue
|
||||
}
|
||||
|
||||
st, err := readRunningState(m.cfg.WrennDir, name)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
slog.Warn("restore running: unreadable state file, removing",
|
||||
"id", name, "error", err)
|
||||
deleteRunningState(m.cfg.WrennDir, name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
_, known := m.boxes[name]
|
||||
m.mu.RUnlock()
|
||||
if known {
|
||||
continue
|
||||
}
|
||||
|
||||
if !chProcessAlive(st) {
|
||||
m.sweepDeadRunning(st)
|
||||
swept++
|
||||
continue
|
||||
}
|
||||
|
||||
if err := m.reattachRunning(st); err != nil {
|
||||
slog.Warn("restore running: re-attach failed", "id", name, "error", err)
|
||||
continue
|
||||
}
|
||||
restored++
|
||||
}
|
||||
|
||||
if restored > 0 || swept > 0 {
|
||||
slog.Info("running sandbox restore complete", "restored", restored, "swept", swept)
|
||||
}
|
||||
}
|
||||
|
||||
// chProcessAlive reports whether the recorded CH process is still the CH
|
||||
// process for this sandbox: PID alive, cmdline still references the sandbox
|
||||
// (guards against PID recycling), API socket present.
|
||||
func chProcessAlive(st *runningState) bool {
|
||||
if st.CHPID <= 0 {
|
||||
return false
|
||||
}
|
||||
if err := syscall.Kill(st.CHPID, 0); err != nil {
|
||||
return false
|
||||
}
|
||||
// The PID is the unshare wrapper whose bash script embeds the sandbox's
|
||||
// socket path and dm device path — both contain the sandbox ID.
|
||||
cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", st.CHPID))
|
||||
if err != nil || !strings.Contains(string(cmdline), st.ID) {
|
||||
return false
|
||||
}
|
||||
if _, err := os.Stat(st.CHSocket); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// reattachRunning rebuilds the in-memory sandboxState for a verified-live
|
||||
// sandbox and registers it in m.boxes.
|
||||
func (m *Manager) reattachRunning(st *runningState) error {
|
||||
teamBytes, err := parsePlainUUID(st.TeamID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad team_id: %w", err)
|
||||
}
|
||||
templateBytes, err := parsePlainUUID(st.TemplateID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad template_id: %w", err)
|
||||
}
|
||||
if st.SlotIndex == 0 {
|
||||
return fmt.Errorf("running state has no slot_index")
|
||||
}
|
||||
|
||||
// Adopt the slot (its claim file already exists — written by the creating
|
||||
// process) so a concurrent Create cannot be handed this sandbox's IP.
|
||||
if err := m.slots.Reserve(st.SlotIndex); err != nil {
|
||||
return fmt.Errorf("reserve slot %d: %w", st.SlotIndex, err)
|
||||
}
|
||||
slot := network.NewSlot(st.SlotIndex)
|
||||
|
||||
// NO rollback past this point. chProcessAlive just verified a live CH
|
||||
// process that depends on this slot's netns/IP, the dm device, and the
|
||||
// loop attachments. Releasing any of it on a failed adoption would pull
|
||||
// resources out from under a running VM: freeing the slot lets a new
|
||||
// Create collide with the live netns, and dropping the loop refcount can
|
||||
// detach the origin loop the dm table references (LoopRegistry adopts the
|
||||
// previous process's loop, so refcount 1 here IS that live attachment).
|
||||
// On failure, leave everything held and unmanaged — the next agent
|
||||
// restart retries the adoption, or sweeps properly once CH is dead.
|
||||
|
||||
// Rediscover the live dm-snapshot; no device-mapper state is modified.
|
||||
dmDev, err := devicemapper.ReattachSnapshot(st.DMName, st.CowPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reattach dm-snapshot (slot %d left reserved for live CH): %w",
|
||||
st.SlotIndex, err)
|
||||
}
|
||||
|
||||
// Re-acquire the base image in this process's loop registry so the
|
||||
// refcounting stays balanced with the Release in cleanup()/releaseRuntime.
|
||||
// (The dm device keeps referencing the creating process's origin loop by
|
||||
// device number; this Acquire only matters for bookkeeping and for other
|
||||
// sandboxes sharing the base in this process.)
|
||||
if _, err := m.loops.Acquire(st.BaseImagePath); err != nil {
|
||||
return fmt.Errorf("acquire base image loop (slot %d left reserved for live CH): %w",
|
||||
st.SlotIndex, err)
|
||||
}
|
||||
|
||||
// Register the live CH process; probes the API socket before committing.
|
||||
probeCtx, cancel := context.WithTimeout(context.Background(), reattachProbeTimeout)
|
||||
defer cancel()
|
||||
_, err = m.vm.Reattach(probeCtx, vm.VMConfig{
|
||||
SandboxID: st.ID,
|
||||
SocketPath: st.CHSocket,
|
||||
SandboxDir: st.SandboxDir,
|
||||
VCPUs: st.VCPUs,
|
||||
MemoryMB: st.MemoryMB,
|
||||
}, st.CHPID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reattach VM (slot %d and loop refcount left held for live CH): %w",
|
||||
st.SlotIndex, err)
|
||||
}
|
||||
|
||||
sb := &sandboxState{
|
||||
Sandbox: models.Sandbox{
|
||||
ID: st.ID,
|
||||
Status: models.StatusRunning,
|
||||
TemplateTeamID: teamBytes,
|
||||
TemplateID: templateBytes,
|
||||
VCPUs: st.VCPUs,
|
||||
MemoryMB: st.MemoryMB,
|
||||
TimeoutSec: st.TimeoutSec,
|
||||
SlotIndex: st.SlotIndex,
|
||||
HostIP: slot.HostIP,
|
||||
RootfsPath: dmDev.DevicePath,
|
||||
CreatedAt: st.CreatedAt,
|
||||
LastActiveAt: time.Now(),
|
||||
Metadata: st.Metadata,
|
||||
},
|
||||
slot: slot,
|
||||
connTracker: &ConnTracker{},
|
||||
dmDevice: dmDev,
|
||||
baseImagePath: st.BaseImagePath,
|
||||
sandboxDirOverride: st.SandboxDirOverride,
|
||||
lazyRestore: st.LazyRestore,
|
||||
}
|
||||
sb.client.Store(envdclient.New(slot.HostIP.String()))
|
||||
|
||||
m.mu.Lock()
|
||||
m.boxes[st.ID] = sb
|
||||
m.mu.Unlock()
|
||||
|
||||
// A lazily-restored VM may still be materialising guest memory when the
|
||||
// creating process died. Re-arm the loader: envd's POST is CAS-idempotent
|
||||
// (a completed preload answers "done" immediately), and having memLoadDone
|
||||
// set again restores the TTL-reaper/activity-sampler guards. Pause remains
|
||||
// safe either way — ensureMemoryMaterialized verifies against envd itself.
|
||||
if sb.lazyRestore {
|
||||
m.startMemoryLoader(sb)
|
||||
}
|
||||
|
||||
m.startSampler(sb)
|
||||
m.startCrashWatcher(sb)
|
||||
|
||||
slog.Info("re-attached running sandbox",
|
||||
"id", st.ID, "slot", st.SlotIndex, "pid", st.CHPID, "host_ip", slot.HostIP.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
// sweepDeadRunning cleans up after a sandbox whose running-state file exists
|
||||
// but whose VM is gone (creating process crashed, host rebooted, VM killed).
|
||||
// Best-effort: every resource may or may not still exist.
|
||||
//
|
||||
// A pause snapshot in the same dir is preserved — the sandbox may have been
|
||||
// resumed and then died, in which case the previous pause generation is still
|
||||
// the best available recovery point and RestorePausedSandboxes (called after
|
||||
// this) will register it. Without a snapshot the dir is removed entirely.
|
||||
func (m *Manager) sweepDeadRunning(st *runningState) {
|
||||
slog.Info("restore running: sweeping dead sandbox", "id", st.ID)
|
||||
|
||||
if dmDev, err := devicemapper.ReattachSnapshot(st.DMName, st.CowPath); err == nil {
|
||||
if err := devicemapper.RemoveSnapshot(context.Background(), dmDev); err != nil {
|
||||
slog.Warn("sweep: dm-snapshot remove failed", "id", st.ID, "error", err)
|
||||
}
|
||||
}
|
||||
// ReattachSnapshot usually fails here: Setup's CleanupStaleDevices already
|
||||
// removed the dead sandbox's dm device (it was not held open by any live
|
||||
// CH), so RemoveSnapshot above never runs and the CoW loop from the
|
||||
// previous agent stays attached. Detach it by backing file before the
|
||||
// os.RemoveAll below deletes that file and orphans the loop for good.
|
||||
devicemapper.DetachLoopsByFile(st.CowPath)
|
||||
if err := network.RemoveNetwork(network.NewSlot(st.SlotIndex)); err != nil {
|
||||
slog.Warn("sweep: network remove failed", "id", st.ID, "error", err)
|
||||
}
|
||||
_ = os.Remove(st.CHSocket)
|
||||
deleteRunningState(m.cfg.WrennDir, st.ID)
|
||||
|
||||
snapDir := layout.PauseSnapshotDir(m.cfg.WrennDir, st.ID)
|
||||
if _, err := readSnapshotMeta(snapDir); err == nil {
|
||||
// Recoverable pause snapshot present: keep the dir, the CoW file, and
|
||||
// the slot claim so RestorePausedSandboxes can adopt them.
|
||||
return
|
||||
}
|
||||
if err := os.RemoveAll(layout.SandboxDir(m.cfg.WrennDir, st.ID)); err != nil {
|
||||
slog.Warn("sweep: sandbox dir remove failed", "id", st.ID, "error", err)
|
||||
}
|
||||
if st.SlotIndex > 0 {
|
||||
m.slots.Release(st.SlotIndex)
|
||||
}
|
||||
}
|
||||
182
pkg/sandbox/setup.go
Normal file
182
pkg/sandbox/setup.go
Normal file
@ -0,0 +1,182 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/devicemapper"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/layout"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/network"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/vm"
|
||||
)
|
||||
|
||||
// DefaultCHBin is the conventional install path for the Cloud Hypervisor
|
||||
// binary, used when SetupOptions.CHBin is empty.
|
||||
const DefaultCHBin = "/usr/local/bin/cloud-hypervisor"
|
||||
|
||||
// CheckPrivileges verifies the process has all Linux capabilities required to
|
||||
// run sandboxes (DAC_OVERRIDE, KILL, NET_ADMIN, NET_RAW, SYS_PTRACE,
|
||||
// SYS_ADMIN, MKNOD). It always reads CapEff — even for root — because a root
|
||||
// process inside a restricted container (e.g. docker --cap-drop=all) may not
|
||||
// have all caps.
|
||||
func CheckPrivileges() error {
|
||||
capEff, err := readEffectiveCaps()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read capabilities: %w", err)
|
||||
}
|
||||
|
||||
required := []struct {
|
||||
bit uint
|
||||
name string
|
||||
}{
|
||||
{1, "CAP_DAC_OVERRIDE"}, // /dev/loop*, /dev/mapper/*, /dev/net/tun
|
||||
{5, "CAP_KILL"}, // SIGTERM/SIGKILL to cloud-hypervisor processes
|
||||
{12, "CAP_NET_ADMIN"}, // netlink, iptables, routing, TAP/veth
|
||||
{13, "CAP_NET_RAW"}, // raw sockets (iptables)
|
||||
{19, "CAP_SYS_PTRACE"}, // reading /proc/self/ns/net (netns.Get)
|
||||
{21, "CAP_SYS_ADMIN"}, // netns, mount ns, losetup, dmsetup
|
||||
{27, "CAP_MKNOD"}, // device-mapper node creation
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, c := range required {
|
||||
if capEff&(1<<c.bit) == 0 {
|
||||
missing = append(missing, c.name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("missing capabilities: %s — run as root or apply setcap to the binary",
|
||||
strings.Join(missing, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// readEffectiveCaps parses the CapEff bitmask from /proc/self/status.
|
||||
func readEffectiveCaps() (uint64, error) {
|
||||
f, err := os.Open("/proc/self/status")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if hexStr, ok := strings.CutPrefix(line, "CapEff:"); ok {
|
||||
return strconv.ParseUint(strings.TrimSpace(hexStr), 16, 64)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return 0, fmt.Errorf("read /proc/self/status: %w", err)
|
||||
}
|
||||
return 0, fmt.Errorf("CapEff not found in /proc/self/status")
|
||||
}
|
||||
|
||||
// EnsureIPForward enables IPv4 forwarding (required for sandbox NAT) and
|
||||
// verifies the resulting value. The write may fail when the caller lacks
|
||||
// DAC_OVERRIDE on /proc/sys — that is tolerated as long as forwarding was
|
||||
// already enabled externally (e.g. by a systemd unit's ExecStartPre).
|
||||
func EnsureIPForward() error {
|
||||
_ = os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1"), 0644)
|
||||
b, err := os.ReadFile("/proc/sys/net/ipv4/ip_forward")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read ip_forward: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(string(b)) != "1" {
|
||||
return fmt.Errorf("ip_forward is not enabled — sandbox networking will be broken")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetupOptions controls which phases of the host startup ritual run.
|
||||
type SetupOptions struct {
|
||||
// WrennDir is the root data directory (e.g. /var/lib/wrenn).
|
||||
WrennDir string
|
||||
// CHBin is the cloud-hypervisor binary path; empty → DefaultCHBin.
|
||||
CHBin string
|
||||
// DefaultRootfsSizeMB is forwarded to EnsureImageSizes; 0 → DefaultDiskSizeMB.
|
||||
DefaultRootfsSizeMB int
|
||||
// SkipCleanup skips the stale-resource sweep (kill orphaned CH processes,
|
||||
// remove stale dm devices and network namespaces).
|
||||
//
|
||||
// The sweep assumes this process is the only sandbox owner on the host —
|
||||
// it kills EVERY cloud-hypervisor process not tracked by this process.
|
||||
// A short-lived CLI re-attaching to sandboxes created by earlier
|
||||
// invocations MUST set this, or it will destroy its own running VMs.
|
||||
SkipCleanup bool
|
||||
// SkipEnsureImages skips expanding base images to the configured disk size.
|
||||
SkipEnsureImages bool
|
||||
}
|
||||
|
||||
// SetupResult holds environment facts resolved by Setup, ready to be copied
|
||||
// into Config.
|
||||
type SetupResult struct {
|
||||
KernelPath string
|
||||
KernelVersion string
|
||||
CHBin string
|
||||
CHVersion string
|
||||
}
|
||||
|
||||
// Setup performs the host startup ritual shared by the host agent and
|
||||
// standalone library consumers: stale-resource cleanup, base-image expansion,
|
||||
// kernel resolution, and cloud-hypervisor version detection.
|
||||
//
|
||||
// Call CheckPrivileges and EnsureIPForward first; they are separate because
|
||||
// callers surface their failures differently.
|
||||
func Setup(opts SetupOptions) (*SetupResult, error) {
|
||||
if opts.CHBin == "" {
|
||||
opts.CHBin = DefaultCHBin
|
||||
}
|
||||
|
||||
if !opts.SkipCleanup {
|
||||
// Order matters: kill stale CH processes first — they hold dm-snapshot
|
||||
// devices open and would otherwise cause "Device or resource busy" on
|
||||
// dmsetup remove.
|
||||
vm.CleanupStaleProcesses()
|
||||
devicemapper.CleanupStaleDevices()
|
||||
devicemapper.LogLoopState()
|
||||
network.CleanupStaleNamespaces()
|
||||
// The sweep above killed every sandbox process on the host, so all
|
||||
// slot claim files are stale. Remove them; live claims are
|
||||
// re-established when paused/running sandboxes are restored.
|
||||
if err := os.RemoveAll(layout.SlotsDir(opts.WrennDir)); err != nil {
|
||||
return nil, fmt.Errorf("reset slot claims: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !opts.SkipEnsureImages {
|
||||
// Expand base images to the configured disk size (sparse, no extra
|
||||
// physical disk) so dm-snapshot sandboxes see the full size from boot.
|
||||
if err := EnsureImageSizes(opts.WrennDir, opts.DefaultRootfsSizeMB); err != nil {
|
||||
return nil, fmt.Errorf("expand base images: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
kernelPath, kernelVersion, err := layout.LatestKernel(opts.WrennDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find kernel: %w", err)
|
||||
}
|
||||
|
||||
chVersion, err := DetectCHVersion(opts.CHBin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("detect cloud-hypervisor version: %w", err)
|
||||
}
|
||||
|
||||
// Remove any *.staging-* / *.trash-* directories left behind by a
|
||||
// previous Pause that crashed before completing the atomic swap. Must
|
||||
// run before any Resume so we don't race a sandbox restoration.
|
||||
CleanupOrphanPauseDirs(opts.WrennDir)
|
||||
|
||||
return &SetupResult{
|
||||
KernelPath: kernelPath,
|
||||
KernelVersion: kernelVersion,
|
||||
CHBin: opts.CHBin,
|
||||
CHVersion: chVersion,
|
||||
}, nil
|
||||
}
|
||||
152
pkg/sandbox/state_file.go
Normal file
152
pkg/sandbox/state_file.go
Normal file
@ -0,0 +1,152 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/layout"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/vm"
|
||||
)
|
||||
|
||||
// uuidString16 renders a raw 16-byte UUID as the standard hyphenated string.
|
||||
func uuidString16(b [16]byte) string {
|
||||
return id.UUIDString(pgtype.UUID{Bytes: b, Valid: true})
|
||||
}
|
||||
|
||||
// runningStateFile is the per-sandbox state file that records everything a
|
||||
// fresh process needs to re-attach to a RUNNING sandbox created by an earlier
|
||||
// process (see RestoreRunningSandboxes). It lives in the sandbox dir alongside
|
||||
// rootfs.cow, so every existing teardown path already removes it:
|
||||
//
|
||||
// - Pause's swapDir replaces the dir with a staging dir that lacks it
|
||||
// - Destroy's os.RemoveAll(SandboxDir) wipes it with the dir
|
||||
// - cleanupAfterCrash's os.RemoveAll(SandboxDir) likewise
|
||||
//
|
||||
// It is therefore only present while the sandbox is (or last was) running.
|
||||
const runningStateFile = "sandbox.json"
|
||||
|
||||
// runningStateVersion is bumped on breaking schema changes; readers refuse
|
||||
// unknown versions rather than mis-restoring.
|
||||
const runningStateVersion = 1
|
||||
|
||||
// runningState is the on-disk schema of runningStateFile.
|
||||
type runningState struct {
|
||||
Version int `json:"version"`
|
||||
ID string `json:"id"`
|
||||
TeamID string `json:"team_id"`
|
||||
TemplateID string `json:"template_id"`
|
||||
VCPUs int `json:"vcpus"`
|
||||
MemoryMB int `json:"memory_mb"`
|
||||
TimeoutSec int `json:"timeout_sec"`
|
||||
SlotIndex int `json:"slot_index"`
|
||||
// BaseImagePath is the base template rootfs backing the dm-snapshot.
|
||||
BaseImagePath string `json:"base_image_path"`
|
||||
CowPath string `json:"cow_path"`
|
||||
DMName string `json:"dm_name"`
|
||||
// SandboxDir is the tmpfs path baked into CH's config (effectiveSandboxDir) —
|
||||
// differs from vm.SandboxTmpDir(ID) for sandboxes launched from snapshot
|
||||
// templates.
|
||||
SandboxDir string `json:"sandbox_dir"`
|
||||
// CHPID is the PID of the unshare wrapper; the exec chain keeps the same
|
||||
// PID, so it is also the cloud-hypervisor PID.
|
||||
CHPID int `json:"ch_pid"`
|
||||
CHSocket string `json:"ch_socket"`
|
||||
// SandboxDirOverride is non-empty when the sandbox was launched from a
|
||||
// snapshot template (see sandboxState.sandboxDirOverride).
|
||||
SandboxDirOverride string `json:"sandbox_dir_override,omitempty"`
|
||||
// LazyRestore mirrors sandboxState.lazyRestore: the VM was restored with
|
||||
// memory_restore_mode=ondemand, so a re-attaching process must verify the
|
||||
// guest memory preload before any pause/snapshot.
|
||||
LazyRestore bool `json:"lazy_restore,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// writeRunningState persists sb's re-attach state next to its CoW file.
|
||||
// Best-effort by design: the sandbox is already running and functional, so a
|
||||
// failed write only means a later process cannot re-attach it. Must be called
|
||||
// after the VM is registered in m.vm (the PID is read from there).
|
||||
func (m *Manager) writeRunningState(sb *sandboxState) {
|
||||
v, ok := m.vm.Get(sb.ID)
|
||||
if !ok {
|
||||
slog.Warn("running state: VM not found, skipping persist", "id", sb.ID)
|
||||
return
|
||||
}
|
||||
|
||||
st := runningState{
|
||||
Version: runningStateVersion,
|
||||
ID: sb.ID,
|
||||
TeamID: uuidString16(sb.TemplateTeamID),
|
||||
TemplateID: uuidString16(sb.TemplateID),
|
||||
VCPUs: sb.VCPUs,
|
||||
MemoryMB: sb.MemoryMB,
|
||||
TimeoutSec: sb.TimeoutSec,
|
||||
SlotIndex: sb.SlotIndex,
|
||||
CowPath: sb.dmDevice.CowPath,
|
||||
DMName: sb.dmDevice.Name,
|
||||
BaseImagePath: sb.baseImagePath,
|
||||
SandboxDir: effectiveSandboxDir(sb),
|
||||
CHPID: v.PID(),
|
||||
CHSocket: vm.SandboxSocketPath(sb.ID),
|
||||
SandboxDirOverride: sb.sandboxDirOverride,
|
||||
LazyRestore: sb.lazyRestore,
|
||||
CreatedAt: sb.CreatedAt,
|
||||
Metadata: sb.Metadata,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(st, "", " ")
|
||||
if err != nil {
|
||||
slog.Warn("running state: marshal failed", "id", sb.ID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Atomic write (tmp + rename) so a concurrent reader never sees a torn file.
|
||||
path := runningStatePath(m.cfg.WrennDir, sb.ID)
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
slog.Warn("running state: write failed", "id", sb.ID, "error", err)
|
||||
return
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
slog.Warn("running state: rename failed", "id", sb.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// readRunningState loads and validates the running-state file for a sandbox.
|
||||
func readRunningState(wrennDir, sandboxID string) (*runningState, error) {
|
||||
data, err := os.ReadFile(runningStatePath(wrennDir, sandboxID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var st runningState
|
||||
if err := json.Unmarshal(data, &st); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal running state: %w", err)
|
||||
}
|
||||
if st.Version != runningStateVersion {
|
||||
return nil, fmt.Errorf("unsupported running state version %d (want %d)", st.Version, runningStateVersion)
|
||||
}
|
||||
if st.ID != sandboxID {
|
||||
return nil, fmt.Errorf("running state id mismatch: file says %q, dir is %q", st.ID, sandboxID)
|
||||
}
|
||||
return &st, nil
|
||||
}
|
||||
|
||||
// deleteRunningState removes the running-state file. Most teardown paths do
|
||||
// not need this (they remove or swap the whole sandbox dir); it exists for
|
||||
// the dead-sandbox path in RestoreRunningSandboxes, which must invalidate the
|
||||
// file while preserving a possible pause snapshot in the same dir.
|
||||
func deleteRunningState(wrennDir, sandboxID string) {
|
||||
_ = os.Remove(runningStatePath(wrennDir, sandboxID))
|
||||
}
|
||||
|
||||
func runningStatePath(wrennDir, sandboxID string) string {
|
||||
return filepath.Join(layout.SandboxDir(wrennDir, sandboxID), runningStateFile)
|
||||
}
|
||||
111
pkg/sandbox/state_file_test.go
Normal file
111
pkg/sandbox/state_file_test.go
Normal file
@ -0,0 +1,111 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/layout"
|
||||
)
|
||||
|
||||
func writeTestRunningState(t *testing.T, wrennDir string, st *runningState) {
|
||||
t.Helper()
|
||||
dir := layout.SandboxDir(wrennDir, st.ID)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, runningStateFile), data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunningStateRoundtrip(t *testing.T) {
|
||||
wrennDir := t.TempDir()
|
||||
want := &runningState{
|
||||
Version: runningStateVersion,
|
||||
ID: "sb-cafe0123",
|
||||
TeamID: "00000000-0000-0000-0000-000000000000",
|
||||
TemplateID: "00000000-0000-0000-0000-000000000001",
|
||||
VCPUs: 2,
|
||||
MemoryMB: 1024,
|
||||
TimeoutSec: 300,
|
||||
SlotIndex: 7,
|
||||
BaseImagePath: "/var/lib/wrenn/images/teams/x/y/rootfs.ext4",
|
||||
CowPath: "/var/lib/wrenn/sandboxes/sb-cafe0123/rootfs.cow",
|
||||
DMName: "wrenn-sb-cafe0123",
|
||||
SandboxDir: "/tmp/ch-vm-sb-cafe0123",
|
||||
CHPID: 4242,
|
||||
CHSocket: "/tmp/ch-sb-cafe0123.sock",
|
||||
SandboxDirOverride: "/tmp/ch-vm-sb-original",
|
||||
LazyRestore: true,
|
||||
CreatedAt: time.Now().Truncate(0),
|
||||
Metadata: map[string]string{"kernel_version": "6.1.102"},
|
||||
}
|
||||
writeTestRunningState(t, wrennDir, want)
|
||||
|
||||
got, err := readRunningState(wrennDir, want.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("readRunningState: %v", err)
|
||||
}
|
||||
if got.ID != want.ID || got.SlotIndex != want.SlotIndex ||
|
||||
got.CHPID != want.CHPID || got.DMName != want.DMName ||
|
||||
got.SandboxDirOverride != want.SandboxDirOverride ||
|
||||
got.LazyRestore != want.LazyRestore ||
|
||||
!got.CreatedAt.Equal(want.CreatedAt) ||
|
||||
got.Metadata["kernel_version"] != want.Metadata["kernel_version"] {
|
||||
t.Fatalf("roundtrip mismatch:\ngot %+v\nwant %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunningStateVersionMismatch(t *testing.T) {
|
||||
wrennDir := t.TempDir()
|
||||
st := &runningState{Version: runningStateVersion + 1, ID: "sb-deadbeef", SlotIndex: 1}
|
||||
writeTestRunningState(t, wrennDir, st)
|
||||
|
||||
if _, err := readRunningState(wrennDir, st.ID); err == nil {
|
||||
t.Fatal("readRunningState should reject unknown version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunningStateIDMismatch(t *testing.T) {
|
||||
wrennDir := t.TempDir()
|
||||
st := &runningState{Version: runningStateVersion, ID: "sb-other", SlotIndex: 1}
|
||||
// Write the file under a directory whose name does not match st.ID.
|
||||
dir := layout.SandboxDir(wrennDir, "sb-dirname")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := json.Marshal(st)
|
||||
if err := os.WriteFile(filepath.Join(dir, runningStateFile), data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := readRunningState(wrennDir, "sb-dirname"); err == nil {
|
||||
t.Fatal("readRunningState should reject id mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunningStateMissingFile(t *testing.T) {
|
||||
if _, err := readRunningState(t.TempDir(), "sb-none"); !os.IsNotExist(err) {
|
||||
t.Fatalf("want os.IsNotExist error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRunningState(t *testing.T) {
|
||||
wrennDir := t.TempDir()
|
||||
st := &runningState{Version: runningStateVersion, ID: "sb-gone", SlotIndex: 1}
|
||||
writeTestRunningState(t, wrennDir, st)
|
||||
|
||||
deleteRunningState(wrennDir, st.ID)
|
||||
if _, err := readRunningState(wrennDir, st.ID); !os.IsNotExist(err) {
|
||||
t.Fatalf("state file should be gone, got %v", err)
|
||||
}
|
||||
// Idempotent.
|
||||
deleteRunningState(wrennDir, st.ID)
|
||||
}
|
||||
@ -109,10 +109,10 @@ func (s *BuildService) Create(ctx context.Context, p BuildCreateParams) (db.Temp
|
||||
p.BaseTemplate = "minimal-ubuntu"
|
||||
}
|
||||
if p.VCPUs <= 0 {
|
||||
p.VCPUs = 1
|
||||
p.VCPUs = 2
|
||||
}
|
||||
if p.MemoryMB <= 0 {
|
||||
p.MemoryMB = 512
|
||||
p.MemoryMB = 2048
|
||||
}
|
||||
|
||||
// Assemble the recipe. Unless run_as_root is set, the non-root build user
|
||||
@ -410,7 +410,15 @@ func (s *BuildService) executeBuild(ctx context.Context, buildIDStr string) {
|
||||
templateDefaultUser := bctx.User
|
||||
templateDefaultEnv := filterBuildEnv(bctx.EnvVars)
|
||||
|
||||
// Phase 3: Post-build (as root) — cleanup.
|
||||
// Phase 3: Healthcheck — runs before cleanup so a failing healthcheck aborts
|
||||
// immediately and skips post-build (the sandbox is destroyed regardless). It
|
||||
// runs as the template's default user, against the app the recipe started.
|
||||
if !s.runHealthcheck(buildCtx, buildID, build, agent, sandboxIDStr, &logs, streamFn, templateDefaultUser, log) {
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 4: Post-build (as root) — cleanup. Only reached once the healthcheck
|
||||
// has passed, so we never clean up an image that fails verification.
|
||||
bctx.User = "root"
|
||||
if !build.SkipPrePost {
|
||||
if !runPhase("post-build", postBuildSteps, 0) {
|
||||
@ -418,7 +426,7 @@ func (s *BuildService) executeBuild(ctx context.Context, buildIDStr string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize: healthcheck/snapshot/flatten → persist template → mark success.
|
||||
// Finalize: snapshot/flatten → persist template → mark success.
|
||||
s.finalizeBuild(buildCtx, buildID, build, agent, sandboxIDStr, templateDefaultUser, templateDefaultEnv, sandboxMetadata, log)
|
||||
}
|
||||
|
||||
@ -517,8 +525,9 @@ func (s *BuildService) provisionBuildSandbox(
|
||||
return agent, sandboxIDStr, sandboxMetadata, nil
|
||||
}
|
||||
|
||||
// finalizeBuild handles the healthcheck/snapshot/flatten step and persists the
|
||||
// template record. Called after all recipe phases complete successfully.
|
||||
// finalizeBuild snapshots (or flattens) the verified rootfs and persists the
|
||||
// template record. Called after the recipe, healthcheck, and post-build phases
|
||||
// all complete successfully.
|
||||
func (s *BuildService) finalizeBuild(
|
||||
ctx context.Context,
|
||||
buildID pgtype.UUID,
|
||||
@ -532,23 +541,7 @@ func (s *BuildService) finalizeBuild(
|
||||
) {
|
||||
var sizeBytes int64
|
||||
if build.Healthcheck != "" {
|
||||
hc, err := recipe.ParseHealthcheck(build.Healthcheck)
|
||||
if err != nil {
|
||||
s.destroySandbox(ctx, agent, sandboxIDStr)
|
||||
s.failBuild(ctx, buildID, fmt.Sprintf("invalid healthcheck: %v", err))
|
||||
return
|
||||
}
|
||||
log.Info("running healthcheck", "cmd", hc.Cmd, "interval", hc.Interval, "timeout", hc.Timeout, "start_period", hc.StartPeriod, "retries", hc.Retries)
|
||||
if err := s.waitForHealthcheck(ctx, agent, sandboxIDStr, hc, defaultUser); err != nil {
|
||||
s.destroySandbox(ctx, agent, sandboxIDStr)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
s.failBuild(ctx, buildID, fmt.Sprintf("healthcheck failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("healthcheck passed, creating snapshot")
|
||||
log.Info("creating snapshot")
|
||||
snapResp, err := agent.CreateSnapshot(ctx, connect.NewRequest(&pb.CreateSnapshotRequest{
|
||||
SandboxId: sandboxIDStr,
|
||||
Name: build.Name,
|
||||
@ -630,19 +623,115 @@ func (s *BuildService) finalizeBuild(
|
||||
log.Info("template build completed successfully", "name", build.Name)
|
||||
}
|
||||
|
||||
// waitForHealthcheck repeatedly executes the healthcheck command inside the
|
||||
// healthcheckResult captures what a healthcheck run produced, for recording as
|
||||
// a build pseudo-step. Output is the merged per-attempt console log (capped to
|
||||
// hcMaxOutputBytes); Exit is the final attempt's exit code.
|
||||
type healthcheckResult struct {
|
||||
Output string
|
||||
Exit int32
|
||||
Attempts int
|
||||
Elapsed int64 // total wall time in milliseconds
|
||||
}
|
||||
|
||||
// hcMaxOutputBytes bounds how much healthcheck output is retained for the cold
|
||||
// log/replay, so a long-polling check can't grow the persisted log unbounded.
|
||||
// The live stream is never truncated — this only caps what we store.
|
||||
const hcMaxOutputBytes = 32 * 1024
|
||||
|
||||
// runHealthcheck executes the configured healthcheck (if any) as a streamed
|
||||
// pseudo-step, before the post-build cleanup phase. It emits step-start /
|
||||
// output / step-end events live, appends the resulting log entry to *logs, and
|
||||
// persists it. The healthcheck is numbered after every recipe/post-build step
|
||||
// (total_steps+1) and deliberately does not advance current_step.
|
||||
//
|
||||
// Returns true when the build should continue (healthcheck passed or none was
|
||||
// configured), false when it must stop (invalid/failed healthcheck, or the
|
||||
// build was cancelled) — in which case the sandbox has been destroyed and the
|
||||
// build marked failed.
|
||||
func (s *BuildService) runHealthcheck(
|
||||
ctx context.Context,
|
||||
buildID pgtype.UUID,
|
||||
build db.TemplateBuild,
|
||||
agent buildAgentClient,
|
||||
sandboxIDStr string,
|
||||
logs *[]recipe.BuildLogEntry,
|
||||
streamFn recipe.StreamExecFunc,
|
||||
user string,
|
||||
log *slog.Logger,
|
||||
) bool {
|
||||
if build.Healthcheck == "" {
|
||||
return true
|
||||
}
|
||||
hc, err := recipe.ParseHealthcheck(build.Healthcheck)
|
||||
if err != nil {
|
||||
s.destroySandbox(ctx, agent, sandboxIDStr)
|
||||
s.failBuild(ctx, buildID, fmt.Sprintf("invalid healthcheck: %v", err))
|
||||
return false
|
||||
}
|
||||
log.Info("running healthcheck", "cmd", hc.Cmd, "interval", hc.Interval, "timeout", hc.Timeout, "start_period", hc.StartPeriod, "retries", hc.Retries)
|
||||
|
||||
hcStep := int(build.TotalSteps) + 1
|
||||
buildIDStr := id.FormatBuildID(buildID)
|
||||
hcCmd := "HEALTHCHECK " + hc.Cmd
|
||||
publishBuildEvent(ctx, s.Redis, buildIDStr, BuildStreamEvent{
|
||||
Type: "step-start", Step: hcStep, Phase: "healthcheck", Cmd: hcCmd,
|
||||
})
|
||||
|
||||
// Forward each output chunk to the live console as it arrives.
|
||||
onChunk := func(data []byte) {
|
||||
publishBuildEvent(ctx, s.Redis, buildIDStr, BuildStreamEvent{
|
||||
Type: "output", Step: hcStep, Data: base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
}
|
||||
|
||||
res, hcErr := s.streamHealthcheck(ctx, sandboxIDStr, hc, user, streamFn, onChunk)
|
||||
entry := recipe.BuildLogEntry{
|
||||
Step: hcStep, Phase: "healthcheck", Cmd: hcCmd,
|
||||
Stdout: res.Output, Exit: res.Exit, Elapsed: res.Elapsed, Ok: hcErr == nil,
|
||||
}
|
||||
*logs = append(*logs, entry)
|
||||
// Persist while pinning current_step to total_steps so the progress counter
|
||||
// doesn't overshoot — the healthcheck is shown but not counted.
|
||||
s.updateLogs(ctx, buildID, int(build.TotalSteps), *logs)
|
||||
publishBuildEvent(ctx, s.Redis, buildIDStr, BuildStreamEvent{
|
||||
Type: "step-end", Step: hcStep, Phase: "healthcheck", Cmd: hcCmd,
|
||||
Exit: entry.Exit, Ok: entry.Ok, ElapsedMs: entry.Elapsed,
|
||||
})
|
||||
|
||||
if hcErr != nil {
|
||||
s.destroySandbox(ctx, agent, sandboxIDStr)
|
||||
if ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
s.failBuild(ctx, buildID, fmt.Sprintf("healthcheck failed: %v", hcErr))
|
||||
return false
|
||||
}
|
||||
log.Info("healthcheck passed")
|
||||
return true
|
||||
}
|
||||
|
||||
// streamHealthcheck repeatedly runs the healthcheck command in a PTY inside the
|
||||
// sandbox according to the config's interval, timeout, start-period, and
|
||||
// retries.
|
||||
// During the start period, failures are not counted toward the retry budget.
|
||||
// Returns nil on the first successful check, or an error if retries are
|
||||
// exhausted, the deadline passes, or the context is cancelled.
|
||||
func (s *BuildService) waitForHealthcheck(ctx context.Context, agent buildAgentClient, sandboxIDStr string, hc recipe.HealthcheckConfig, user string) error {
|
||||
// retries, forwarding output live via onChunk. During the start period,
|
||||
// failures are not counted toward the retry budget. Returns a nil error on the
|
||||
// first successful check, or an error if retries are exhausted, the deadline
|
||||
// passes, or the context is cancelled. The returned healthcheckResult always
|
||||
// carries the captured per-attempt output regardless of outcome.
|
||||
func (s *BuildService) streamHealthcheck(
|
||||
ctx context.Context,
|
||||
sandboxIDStr string,
|
||||
hc recipe.HealthcheckConfig,
|
||||
user string,
|
||||
streamFn recipe.StreamExecFunc,
|
||||
onChunk func(data []byte),
|
||||
) (result healthcheckResult, err error) {
|
||||
// Wrap the healthcheck command with su when a non-root user is set, so that
|
||||
// ~ expands to the correct home directory and the process runs with the
|
||||
// right UID (matching the template's default user).
|
||||
cmd := hc.Cmd
|
||||
if user != "" && user != "root" {
|
||||
cmd = "su " + recipe.Shellescape(user) + " -s /bin/sh -c " + recipe.Shellescape(hc.Cmd)
|
||||
// Drop `-s` so su runs the check under the user's login shell.
|
||||
cmd = "su " + recipe.Shellescape(user) + " -c " + recipe.Shellescape(hc.Cmd)
|
||||
}
|
||||
ticker := time.NewTicker(hc.Interval)
|
||||
defer ticker.Stop()
|
||||
@ -658,41 +747,82 @@ func (s *BuildService) waitForHealthcheck(ctx context.Context, agent buildAgentC
|
||||
|
||||
startedAt := time.Now()
|
||||
failCount := 0
|
||||
attempt := 0
|
||||
var output strings.Builder
|
||||
// emit sends a line to the live console and accumulates it (capped) for the
|
||||
// cold log used on reconnect/replay.
|
||||
emit := func(line string) {
|
||||
onChunk([]byte(line))
|
||||
if output.Len() < hcMaxOutputBytes {
|
||||
output.WriteString(line)
|
||||
}
|
||||
}
|
||||
// Populate the result on every return path.
|
||||
defer func() {
|
||||
result.Attempts = attempt
|
||||
result.Elapsed = time.Since(startedAt).Milliseconds()
|
||||
result.Output = strings.TrimRight(output.String(), "\r\n")
|
||||
}()
|
||||
|
||||
// runAttempt streams one healthcheck invocation, forwarding output, and
|
||||
// returns its exit code (or an error if the exec never completed).
|
||||
runAttempt := func() (int32, error) {
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, hc.Timeout)
|
||||
defer cancel()
|
||||
ch, err := streamFn(attemptCtx, sandboxIDStr, cmd)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
var exit int32
|
||||
gotDone := false
|
||||
for chunk := range ch {
|
||||
if chunk.Err != nil {
|
||||
return -1, chunk.Err
|
||||
}
|
||||
if chunk.Done {
|
||||
exit, gotDone = chunk.Exit, true
|
||||
continue
|
||||
}
|
||||
emit(string(chunk.Data))
|
||||
}
|
||||
if !gotDone {
|
||||
return -1, fmt.Errorf("healthcheck stream ended without completion")
|
||||
}
|
||||
return exit, nil
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
return result, ctx.Err()
|
||||
case <-deadlineCh:
|
||||
return fmt.Errorf("healthcheck timed out: exceeded %d attempts over %s", failCount, time.Since(startedAt))
|
||||
return result, fmt.Errorf("healthcheck timed out: exceeded %d attempts over %s", failCount, time.Since(startedAt))
|
||||
case <-ticker.C:
|
||||
execCtx, cancel := context.WithTimeout(ctx, hc.Timeout)
|
||||
resp, err := agent.Exec(execCtx, connect.NewRequest(&pb.ExecRequest{
|
||||
SandboxId: sandboxIDStr,
|
||||
Cmd: "/bin/sh",
|
||||
Args: []string{"-c", cmd},
|
||||
TimeoutSec: int32(hc.Timeout.Seconds()),
|
||||
}))
|
||||
cancel()
|
||||
attempt++
|
||||
emit(fmt.Sprintf("\x1b[2m── attempt %d ──\x1b[0m\r\n", attempt))
|
||||
exit, attemptErr := runAttempt()
|
||||
result.Exit = exit
|
||||
|
||||
if err != nil {
|
||||
slog.Debug("healthcheck exec error (retrying)", "error", err)
|
||||
if attemptErr != nil {
|
||||
emit(fmt.Sprintf("exec error: %v\r\n", attemptErr))
|
||||
slog.Debug("healthcheck exec error (retrying)", "error", attemptErr)
|
||||
if time.Since(startedAt) >= hc.StartPeriod {
|
||||
failCount++
|
||||
if hc.Retries > 0 && failCount >= hc.Retries {
|
||||
return fmt.Errorf("healthcheck failed after %d retries: exec error: %w", failCount, err)
|
||||
return result, fmt.Errorf("healthcheck failed after %d retries: exec error: %w", failCount, attemptErr)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if resp.Msg.ExitCode == 0 {
|
||||
return nil
|
||||
emit(fmt.Sprintf("\x1b[2m→ exit %d\x1b[0m\r\n", exit))
|
||||
if exit == 0 {
|
||||
return result, nil
|
||||
}
|
||||
slog.Debug("healthcheck failed (retrying)", "exit_code", resp.Msg.ExitCode)
|
||||
slog.Debug("healthcheck failed (retrying)", "exit_code", exit)
|
||||
if time.Since(startedAt) >= hc.StartPeriod {
|
||||
failCount++
|
||||
if hc.Retries > 0 && failCount >= hc.Retries {
|
||||
return fmt.Errorf("healthcheck failed after %d retries: exit code %d", failCount, resp.Msg.ExitCode)
|
||||
return result, fmt.Errorf("healthcheck failed after %d retries: exit code %d", failCount, exit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -755,10 +885,12 @@ func (s *BuildService) ptyStreamExec(agent buildAgentClient) recipe.StreamExecFu
|
||||
stream, err := agent.PtyAttach(ctx, connect.NewRequest(&pb.PtyAttachRequest{
|
||||
SandboxId: sandboxID,
|
||||
Tag: tag,
|
||||
Cmd: "/bin/sh",
|
||||
Args: []string{"-c", shellCmd},
|
||||
Cols: buildPtyCols,
|
||||
Rows: buildPtyRows,
|
||||
// Bare command: envd wraps it in the user's login shell (resolved
|
||||
// from /etc/passwd), so build steps run under the image's default
|
||||
// shell instead of a forced /bin/sh.
|
||||
Cmd: shellCmd,
|
||||
Cols: buildPtyCols,
|
||||
Rows: buildPtyRows,
|
||||
}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -836,8 +968,7 @@ func (s *BuildService) fetchSandboxEnv(ctx context.Context,
|
||||
agent buildAgentClient, sandboxIDStr string) (map[string]string, error) {
|
||||
resp, err := agent.Exec(ctx, connect.NewRequest(&pb.ExecRequest{
|
||||
SandboxId: sandboxIDStr,
|
||||
Cmd: "/bin/sh",
|
||||
Args: []string{"-c", "env"},
|
||||
Cmd: "env",
|
||||
TimeoutSec: 10,
|
||||
}))
|
||||
if err != nil {
|
||||
@ -922,8 +1053,7 @@ func (s *BuildService) uploadAndExtractArchive(
|
||||
|
||||
resp, err := agent.Exec(ctx, connect.NewRequest(&pb.ExecRequest{
|
||||
SandboxId: sandboxID,
|
||||
Cmd: "/bin/sh",
|
||||
Args: []string{"-c", fullCmd},
|
||||
Cmd: fullCmd,
|
||||
TimeoutSec: 120,
|
||||
}))
|
||||
if err != nil {
|
||||
|
||||
@ -49,16 +49,19 @@ type SandboxCreateParams struct {
|
||||
VCPUs int32
|
||||
MemoryMB int32
|
||||
TimeoutSec int32
|
||||
// Metadata holds user-supplied key/value labels attached at create-time.
|
||||
// Reserved system keys (kernel_version, etc.) are rejected by validation.
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
// MinTimeoutSec mirrors internal/sandbox.MinTimeoutSec. Sub-minute TTLs race
|
||||
// MinTimeoutSec mirrors pkg/sandbox.MinTimeoutSec. Sub-minute TTLs race
|
||||
// the post-create startup window (DB insert → /init → memory loader); the
|
||||
// agent silently clamps anyway, but the CP must clamp too so the DB record
|
||||
// agrees with what the agent runs. 0 is preserved (no TTL).
|
||||
const MinTimeoutSec int32 = 60
|
||||
|
||||
// clampTimeout normalises a caller-supplied TTL the same way the host agent
|
||||
// does. Keep in sync with internal/sandbox.clampTimeout.
|
||||
// does. Keep in sync with pkg/sandbox.clampTimeout.
|
||||
func clampTimeout(timeoutSec int32) int32 {
|
||||
if timeoutSec <= 0 {
|
||||
return 0
|
||||
@ -125,11 +128,14 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
|
||||
if err := validate.SafeName(p.Template); err != nil {
|
||||
return db.Sandbox{}, fmt.Errorf("invalid template name: %w", err)
|
||||
}
|
||||
if err := validate.Metadata(p.Metadata); err != nil {
|
||||
return db.Sandbox{}, fmt.Errorf("invalid metadata: %w", err)
|
||||
}
|
||||
if p.VCPUs <= 0 {
|
||||
p.VCPUs = 1
|
||||
p.VCPUs = 2
|
||||
}
|
||||
if p.MemoryMB <= 0 {
|
||||
p.MemoryMB = 512
|
||||
p.MemoryMB = 2048
|
||||
}
|
||||
p.TimeoutSec = clampTimeout(p.TimeoutSec)
|
||||
|
||||
@ -175,6 +181,13 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
|
||||
sandboxIDStr := id.FormatSandboxID(sandboxID)
|
||||
hostIDStr := id.FormatHostID(host.ID)
|
||||
|
||||
metaJSON := []byte("{}")
|
||||
if len(p.Metadata) > 0 {
|
||||
if metaJSON, err = json.Marshal(p.Metadata); err != nil {
|
||||
return db.Sandbox{}, fmt.Errorf("marshal metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
sb, err := s.DB.InsertSandbox(ctx, db.InsertSandboxParams{
|
||||
ID: sandboxID,
|
||||
TeamID: p.TeamID,
|
||||
@ -187,13 +200,14 @@ func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.
|
||||
DiskSizeMb: 0,
|
||||
TemplateID: templateID,
|
||||
TemplateTeamID: templateTeamID,
|
||||
Metadata: []byte("{}"),
|
||||
Metadata: metaJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return db.Sandbox{}, fmt.Errorf("insert sandbox: %w", err)
|
||||
}
|
||||
|
||||
teamIDStr := id.FormatTeamID(p.TeamID)
|
||||
s.publishStateChanged(ctx, sandboxIDStr, teamIDStr, hostIDStr, "", "starting")
|
||||
go s.createInBackground(sandboxID, sandboxIDStr, hostIDStr, teamIDStr, agent, p, templateTeamID, templateID, templateDefaultUser, templateDefaultEnv)
|
||||
|
||||
return sb, nil
|
||||
@ -258,14 +272,7 @@ func (s *SandboxService) createInBackground(
|
||||
slog.Warn("failed to update sandbox running after create", "id", sandboxIDStr, "error", dbErr)
|
||||
}
|
||||
|
||||
if meta := resp.Msg.Metadata; len(meta) > 0 {
|
||||
metaJSON, _ := json.Marshal(meta)
|
||||
if err := s.DB.UpdateSandboxMetadata(bgCtx, db.UpdateSandboxMetadataParams{
|
||||
ID: sandboxID, Metadata: metaJSON,
|
||||
}); err != nil {
|
||||
slog.Warn("failed to store sandbox metadata", "id", sandboxIDStr, "error", err)
|
||||
}
|
||||
}
|
||||
s.persistSystemMetadata(bgCtx, sandboxID, sandboxIDStr, p.Metadata, resp.Msg.Metadata)
|
||||
|
||||
s.publishEvent(bgCtx, SandboxStateEvent{
|
||||
Event: "sandbox.started", SandboxID: sandboxIDStr, TeamID: teamIDStr, HostID: hostIDStr,
|
||||
@ -274,6 +281,33 @@ func (s *SandboxService) createInBackground(
|
||||
})
|
||||
}
|
||||
|
||||
// persistSystemMetadata merges agent-provided system metadata over the given
|
||||
// user labels and writes the result to the sandbox row. System keys
|
||||
// (kernel_version, etc.) are applied last so they always win — users can never
|
||||
// override protected fields even if create-time validation were bypassed.
|
||||
// userMeta may be nil. No-op when the agent returned no system metadata.
|
||||
func (s *SandboxService) persistSystemMetadata(
|
||||
ctx context.Context, sandboxID pgtype.UUID, sandboxIDStr string,
|
||||
userMeta, systemMeta map[string]string,
|
||||
) {
|
||||
if len(systemMeta) == 0 {
|
||||
return
|
||||
}
|
||||
merged := make(map[string]string, len(userMeta)+len(systemMeta))
|
||||
for k, v := range userMeta {
|
||||
merged[k] = v
|
||||
}
|
||||
for k, v := range systemMeta {
|
||||
merged[k] = v
|
||||
}
|
||||
metaJSON, _ := json.Marshal(merged)
|
||||
if err := s.DB.UpdateSandboxMetadata(ctx, db.UpdateSandboxMetadataParams{
|
||||
ID: sandboxID, Metadata: metaJSON,
|
||||
}); err != nil {
|
||||
slog.Warn("failed to store sandbox metadata", "id", sandboxIDStr, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// List returns active sandboxes (excludes stopped/error) belonging to the given team.
|
||||
func (s *SandboxService) List(ctx context.Context, teamID pgtype.UUID) ([]db.Sandbox, error) {
|
||||
return s.DB.ListSandboxesByTeam(ctx, teamID)
|
||||
@ -447,13 +481,14 @@ func (s *SandboxService) resumeInBackground(
|
||||
slog.Warn("failed to update sandbox to running after resume", "id", sandboxIDStr, "error", err)
|
||||
}
|
||||
|
||||
if meta := resp.Msg.Metadata; len(meta) > 0 {
|
||||
metaJSON, _ := json.Marshal(meta)
|
||||
if err := s.DB.UpdateSandboxMetadata(bgCtx, db.UpdateSandboxMetadataParams{
|
||||
ID: sandboxID, Metadata: metaJSON,
|
||||
}); err != nil {
|
||||
slog.Warn("failed to store sandbox metadata after resume", "id", sandboxIDStr, "error", err)
|
||||
// Refresh system metadata but preserve the user's labels — read the current
|
||||
// row and merge agent keys over it rather than overwriting wholesale.
|
||||
if len(resp.Msg.Metadata) > 0 {
|
||||
var existing map[string]string
|
||||
if cur, err := s.DB.GetSandbox(bgCtx, sandboxID); err == nil && len(cur.Metadata) > 0 {
|
||||
_ = json.Unmarshal(cur.Metadata, &existing)
|
||||
}
|
||||
s.persistSystemMetadata(bgCtx, sandboxID, sandboxIDStr, existing, resp.Msg.Metadata)
|
||||
}
|
||||
|
||||
s.publishEvent(bgCtx, SandboxStateEvent{
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth/session"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/lifecycle"
|
||||
@ -25,6 +26,10 @@ type TeamService struct {
|
||||
DB *db.Queries
|
||||
Pool *pgxpool.Pool
|
||||
HostPool *lifecycle.HostClientPool
|
||||
// Sessions drops cached sessions when a member is removed so their access
|
||||
// is stripped at the next request. Optional: nil in contexts without a
|
||||
// session store (revocation is then skipped).
|
||||
Sessions *session.Service
|
||||
}
|
||||
|
||||
// TeamWithRole pairs a team with the calling user's role in it.
|
||||
@ -378,6 +383,26 @@ func (s *TeamService) RemoveMember(ctx context.Context, teamID, callerUserID, ta
|
||||
}); err != nil {
|
||||
return fmt.Errorf("delete member: %w", err)
|
||||
}
|
||||
|
||||
// Revoke the removed member's standing access to this team immediately.
|
||||
// Deleting the membership row alone leaves two credentials live: the
|
||||
// team-scoped API keys they created, and any cached session still holding
|
||||
// this team_id. Purge the keys, and drop the session cache so the next
|
||||
// request rehydrates from Postgres (where the membership is now gone) —
|
||||
// hydrateFromDB then clears the stale team binding.
|
||||
if err := s.DB.DeleteAPIKeysByTeamAndCreator(ctx, db.DeleteAPIKeysByTeamAndCreatorParams{
|
||||
TeamID: teamID,
|
||||
CreatedBy: targetUserID,
|
||||
}); err != nil {
|
||||
slog.Warn("failed to delete API keys for removed member",
|
||||
"team_id", teamID, "user_id", targetUserID, "error", err)
|
||||
}
|
||||
if s.Sessions != nil {
|
||||
if err := s.Sessions.InvalidateCacheForUser(ctx, targetUserID); err != nil {
|
||||
slog.Warn("failed to invalidate session cache for removed member",
|
||||
"user_id", targetUserID, "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
54
pkg/validate/metadata.go
Normal file
54
pkg/validate/metadata.go
Normal file
@ -0,0 +1,54 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// metadataKeyRe matches user metadata keys: alphanumeric start, then
|
||||
// alphanumeric, dash, underscore, or dot. Max 64 characters. Mirrors the
|
||||
// SafeName allowlist so keys stay predictable across the API and UI.
|
||||
var metadataKeyRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
|
||||
|
||||
const (
|
||||
// MaxMetadataKeys caps how many user labels a sandbox may carry.
|
||||
MaxMetadataKeys = 20
|
||||
// MaxMetadataValueLen caps the length (in characters) of a label value.
|
||||
MaxMetadataValueLen = 64
|
||||
)
|
||||
|
||||
// reservedMetadataKeys are written by the host agent after a VM boots and must
|
||||
// not be set by users — they carry the immutable system facts about the VM.
|
||||
// Keep in sync with (*sandbox.Manager).buildMetadata: if a key is added there
|
||||
// but not here, a user could set it at create-time and watch it silently get
|
||||
// overwritten by the agent on boot. (validate deliberately does not import
|
||||
// pkg/sandbox — it would drag the whole host runtime into the control plane's
|
||||
// validation layer — hence the duplication; same pattern as service.MinTimeoutSec.)
|
||||
var reservedMetadataKeys = map[string]struct{}{
|
||||
"kernel_version": {},
|
||||
"vmm_version": {},
|
||||
"agent_version": {},
|
||||
"envd_version": {},
|
||||
}
|
||||
|
||||
// Metadata validates a user-supplied sandbox metadata map. It rejects reserved
|
||||
// system keys, enforces a key count limit, and constrains key/value shape so
|
||||
// the data stays safe to render and store. A nil/empty map is valid.
|
||||
func Metadata(meta map[string]string) error {
|
||||
if len(meta) > MaxMetadataKeys {
|
||||
return fmt.Errorf("too many metadata keys: %d (max %d)", len(meta), MaxMetadataKeys)
|
||||
}
|
||||
for k, v := range meta {
|
||||
if _, reserved := reservedMetadataKeys[k]; reserved {
|
||||
return fmt.Errorf("metadata key %q is reserved for system use", k)
|
||||
}
|
||||
if !metadataKeyRe.MatchString(k) {
|
||||
return fmt.Errorf("metadata key %q is invalid (max 64 chars, must match %s)", k, metadataKeyRe.String())
|
||||
}
|
||||
if utf8.RuneCountInString(v) > MaxMetadataValueLen {
|
||||
return fmt.Errorf("metadata value for key %q is too long (max %d chars)", k, MaxMetadataValueLen)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
48
pkg/validate/metadata_test.go
Normal file
48
pkg/validate/metadata_test.go
Normal file
@ -0,0 +1,48 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMetadata(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input map[string]string
|
||||
wantErr bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"empty", map[string]string{}, false},
|
||||
{"simple", map[string]string{"env": "prod", "owner": "alice"}, false},
|
||||
{"dotted-key", map[string]string{"team.name": "infra"}, false},
|
||||
{"empty-value", map[string]string{"flag": ""}, false},
|
||||
{"max-keys", makeKeys(MaxMetadataKeys), false},
|
||||
|
||||
{"reserved-kernel", map[string]string{"kernel_version": "6.1.0"}, true},
|
||||
{"reserved-vmm", map[string]string{"vmm_version": "x"}, true},
|
||||
{"reserved-agent", map[string]string{"agent_version": "x"}, true},
|
||||
{"reserved-envd", map[string]string{"envd_version": "x"}, true},
|
||||
{"too-many-keys", makeKeys(MaxMetadataKeys + 1), true},
|
||||
{"bad-key-leading-dot", map[string]string{".hidden": "v"}, true},
|
||||
{"bad-key-space", map[string]string{"my key": "v"}, true},
|
||||
{"key-too-long", map[string]string{strings.Repeat("a", 65): "v"}, true},
|
||||
{"value-too-long", map[string]string{"k": strings.Repeat("a", MaxMetadataValueLen+1)}, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := Metadata(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Metadata(%v) error = %v, wantErr %v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func makeKeys(n int) map[string]string {
|
||||
m := make(map[string]string, n)
|
||||
for i := range n {
|
||||
m[fmt.Sprintf("key%d", i)] = "v"
|
||||
}
|
||||
return m
|
||||
}
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -250,7 +251,7 @@ func (m *Manager) CreateFromSnapshot(ctx context.Context, cfg VMConfig) (*VM, er
|
||||
|
||||
// PID returns the process ID of the unshare wrapper process.
|
||||
func (v *VM) PID() int {
|
||||
return v.process.cmd.Process.Pid
|
||||
return v.process.pid()
|
||||
}
|
||||
|
||||
// Exited returns a channel that is closed when the VM process exits.
|
||||
@ -258,6 +259,56 @@ func (v *VM) Exited() <-chan struct{} {
|
||||
return v.process.exited()
|
||||
}
|
||||
|
||||
// reattachPollInterval is how often a re-attached (non-child) CH process is
|
||||
// probed for liveness. A non-child cannot be Wait()ed, so exit detection is
|
||||
// kill(pid, 0) polling.
|
||||
const reattachPollInterval = 2 * time.Second
|
||||
|
||||
// Reattach registers a Cloud Hypervisor process that was started by an
|
||||
// earlier process (e.g. a previous CLI invocation) so this Manager can
|
||||
// operate on it — Destroy, Pause, Snapshot, Info all work as usual through
|
||||
// the still-live API socket. The caller must have verified the PID belongs
|
||||
// to the expected CH process; Reattach only confirms the API socket answers.
|
||||
func (m *Manager) Reattach(ctx context.Context, cfg VMConfig, pid int) (*VM, error) {
|
||||
cfg.applyDefaults()
|
||||
|
||||
proc := &process{
|
||||
attachedPID: pid,
|
||||
exitCh: make(chan struct{}),
|
||||
}
|
||||
client := newCHClient(cfg.SocketPath)
|
||||
|
||||
// Probe the CH API before registering — a dead socket means the state
|
||||
// file is stale and the caller should clean up instead.
|
||||
if _, err := client.vmInfo(ctx); err != nil {
|
||||
return nil, fmt.Errorf("vm.info probe: %w", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(reattachPollInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
if err := syscall.Kill(pid, 0); err != nil {
|
||||
close(proc.exitCh)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
vm := &VM{
|
||||
Config: cfg,
|
||||
process: proc,
|
||||
client: client,
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.vms[cfg.SandboxID] = vm
|
||||
m.mu.Unlock()
|
||||
|
||||
slog.Info("re-attached to running VM", "sandbox", cfg.SandboxID, "pid", pid)
|
||||
return vm, nil
|
||||
}
|
||||
|
||||
// Get returns a running VM by sandbox ID.
|
||||
func (m *Manager) Get(sandboxID string) (*VM, bool) {
|
||||
m.mu.RLock()
|
||||
@ -20,6 +20,19 @@ type process struct {
|
||||
exitCh chan struct{}
|
||||
exitErr error
|
||||
logFile *os.File
|
||||
|
||||
// attachedPID is set instead of cmd when the CH process was started by an
|
||||
// earlier process and re-attached via Manager.Reattach. A non-child cannot
|
||||
// be cmd.Wait()ed; exit detection falls back to kill(pid, 0) polling.
|
||||
attachedPID int
|
||||
}
|
||||
|
||||
// pid returns the process ID regardless of how the process was obtained.
|
||||
func (p *process) pid() int {
|
||||
if p.cmd != nil && p.cmd.Process != nil {
|
||||
return p.cmd.Process.Pid
|
||||
}
|
||||
return p.attachedPID
|
||||
}
|
||||
|
||||
// startProcess launches the Cloud Hypervisor binary inside an isolated mount
|
||||
@ -145,13 +158,15 @@ exec %[4]s
|
||||
}
|
||||
|
||||
// stop sends SIGTERM and waits for the process to exit. If it doesn't exit
|
||||
// within 10 seconds, SIGKILL is sent.
|
||||
// within 10 seconds, SIGKILL is sent. Signals target the process group
|
||||
// (Setsid at launch), which works for both child and re-attached processes.
|
||||
func (p *process) stop() error {
|
||||
if p.cmd.Process == nil {
|
||||
pid := p.pid()
|
||||
if pid == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := syscall.Kill(-p.cmd.Process.Pid, syscall.SIGTERM); err != nil {
|
||||
if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil {
|
||||
slog.Debug("sigterm failed, process may have exited", "error", err)
|
||||
}
|
||||
|
||||
@ -160,7 +175,7 @@ func (p *process) stop() error {
|
||||
return nil
|
||||
case <-time.After(10 * time.Second):
|
||||
slog.Warn("cloud-hypervisor did not exit after SIGTERM, sending SIGKILL")
|
||||
if err := syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL); err != nil {
|
||||
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
|
||||
slog.Debug("sigkill failed", "error", err)
|
||||
}
|
||||
<-p.exitCh
|
||||
@ -117,11 +117,13 @@ func (x *PTY) GetSize() *PTY_Size {
|
||||
}
|
||||
|
||||
type ProcessConfig struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Cmd string `protobuf:"bytes,1,opt,name=cmd,proto3" json:"cmd,omitempty"`
|
||||
Args []string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"`
|
||||
Envs map[string]string `protobuf:"bytes,3,rep,name=envs,proto3" json:"envs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
Cwd *string `protobuf:"bytes,4,opt,name=cwd,proto3,oneof" json:"cwd,omitempty"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Cmd string `protobuf:"bytes,1,opt,name=cmd,proto3" json:"cmd,omitempty"`
|
||||
Args []string `protobuf:"bytes,2,rep,name=args,proto3" json:"args,omitempty"`
|
||||
Envs map[string]string `protobuf:"bytes,3,rep,name=envs,proto3" json:"envs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
Cwd *string `protobuf:"bytes,4,opt,name=cwd,proto3,oneof" json:"cwd,omitempty"`
|
||||
// User to run the process as. Empty means the sandbox default user.
|
||||
User string `protobuf:"bytes,5,opt,name=user,proto3" json:"user,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -184,6 +186,13 @@ func (x *ProcessConfig) GetCwd() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *ProcessConfig) GetUser() string {
|
||||
if x != nil {
|
||||
return x.User
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ListRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
@ -1713,12 +1722,13 @@ const file_process_proto_rawDesc = "" +
|
||||
"\x04size\x18\x01 \x01(\v2\x11.process.PTY.SizeR\x04size\x1a.\n" +
|
||||
"\x04Size\x12\x12\n" +
|
||||
"\x04cols\x18\x01 \x01(\rR\x04cols\x12\x12\n" +
|
||||
"\x04rows\x18\x02 \x01(\rR\x04rows\"\xc3\x01\n" +
|
||||
"\x04rows\x18\x02 \x01(\rR\x04rows\"\xd7\x01\n" +
|
||||
"\rProcessConfig\x12\x10\n" +
|
||||
"\x03cmd\x18\x01 \x01(\tR\x03cmd\x12\x12\n" +
|
||||
"\x04args\x18\x02 \x03(\tR\x04args\x124\n" +
|
||||
"\x04envs\x18\x03 \x03(\v2 .process.ProcessConfig.EnvsEntryR\x04envs\x12\x15\n" +
|
||||
"\x03cwd\x18\x04 \x01(\tH\x00R\x03cwd\x88\x01\x01\x1a7\n" +
|
||||
"\x03cwd\x18\x04 \x01(\tH\x00R\x03cwd\x88\x01\x01\x12\x12\n" +
|
||||
"\x04user\x18\x05 \x01(\tR\x04user\x1a7\n" +
|
||||
"\tEnvsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x06\n" +
|
||||
|
||||
@ -37,6 +37,9 @@ message ProcessConfig {
|
||||
|
||||
map<string, string> envs = 3;
|
||||
optional string cwd = 4;
|
||||
|
||||
// User to run the process as. Empty means the sandbox default user.
|
||||
string user = 5;
|
||||
}
|
||||
|
||||
message ListRequest {}
|
||||
|
||||
@ -2985,8 +2985,8 @@ type PtyAttachRequest struct {
|
||||
// Tag is the stable identifier for this PTY session (e.g. "pty-abc123de").
|
||||
// Chosen by the caller and used to reconnect later.
|
||||
Tag string `protobuf:"bytes,2,opt,name=tag,proto3" json:"tag,omitempty"`
|
||||
// If cmd is non-empty, a new process is started. If empty, reconnects to
|
||||
// the existing process identified by tag.
|
||||
// Command to run for a new session. May be empty to launch the user's
|
||||
// default login shell. Ignored when reconnect is true.
|
||||
Cmd string `protobuf:"bytes,3,opt,name=cmd,proto3" json:"cmd,omitempty"`
|
||||
Args []string `protobuf:"bytes,4,rep,name=args,proto3" json:"args,omitempty"`
|
||||
Cols uint32 `protobuf:"varint,5,opt,name=cols,proto3" json:"cols,omitempty"`
|
||||
@ -2996,7 +2996,11 @@ type PtyAttachRequest struct {
|
||||
// Working directory. Empty means default.
|
||||
Cwd string `protobuf:"bytes,8,opt,name=cwd,proto3" json:"cwd,omitempty"`
|
||||
// User to run as. Empty means default (root).
|
||||
User string `protobuf:"bytes,9,opt,name=user,proto3" json:"user,omitempty"`
|
||||
User string `protobuf:"bytes,9,opt,name=user,proto3" json:"user,omitempty"`
|
||||
// If true, reconnect to the existing process identified by tag instead of
|
||||
// starting a new one. Distinguishes reconnect from a start whose cmd is
|
||||
// empty (login-shell default).
|
||||
Reconnect bool `protobuf:"varint,10,opt,name=reconnect,proto3" json:"reconnect,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -3094,6 +3098,13 @@ func (x *PtyAttachRequest) GetUser() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *PtyAttachRequest) GetReconnect() bool {
|
||||
if x != nil {
|
||||
return x.Reconnect
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type PtyAttachResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Types that are valid to be assigned to Event:
|
||||
@ -4490,7 +4501,7 @@ const file_hostagent_proto_rawDesc = "" +
|
||||
"templateId\"8\n" +
|
||||
"\x17GetTemplateSizeResponse\x12\x1d\n" +
|
||||
"\n" +
|
||||
"size_bytes\x18\x01 \x01(\x03R\tsizeBytes\"\xae\x02\n" +
|
||||
"size_bytes\x18\x01 \x01(\x03R\tsizeBytes\"\xcc\x02\n" +
|
||||
"\x10PtyAttachRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x10\n" +
|
||||
@ -4501,7 +4512,9 @@ const file_hostagent_proto_rawDesc = "" +
|
||||
"\x04rows\x18\x06 \x01(\rR\x04rows\x12<\n" +
|
||||
"\x04envs\x18\a \x03(\v2(.hostagent.v1.PtyAttachRequest.EnvsEntryR\x04envs\x12\x10\n" +
|
||||
"\x03cwd\x18\b \x01(\tR\x03cwd\x12\x12\n" +
|
||||
"\x04user\x18\t \x01(\tR\x04user\x1a7\n" +
|
||||
"\x04user\x18\t \x01(\tR\x04user\x12\x1c\n" +
|
||||
"\treconnect\x18\n" +
|
||||
" \x01(\bR\treconnect\x1a7\n" +
|
||||
"\tEnvsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xb8\x01\n" +
|
||||
|
||||
@ -471,8 +471,8 @@ message PtyAttachRequest {
|
||||
// Tag is the stable identifier for this PTY session (e.g. "pty-abc123de").
|
||||
// Chosen by the caller and used to reconnect later.
|
||||
string tag = 2;
|
||||
// If cmd is non-empty, a new process is started. If empty, reconnects to
|
||||
// the existing process identified by tag.
|
||||
// Command to run for a new session. May be empty to launch the user's
|
||||
// default login shell. Ignored when reconnect is true.
|
||||
string cmd = 3;
|
||||
repeated string args = 4;
|
||||
uint32 cols = 5;
|
||||
@ -483,6 +483,10 @@ message PtyAttachRequest {
|
||||
string cwd = 8;
|
||||
// User to run as. Empty means default (root).
|
||||
string user = 9;
|
||||
// If true, reconnect to the existing process identified by tag instead of
|
||||
// starting a new one. Distinguishes reconnect from a start whose cmd is
|
||||
// empty (login-shell default).
|
||||
bool reconnect = 10;
|
||||
}
|
||||
|
||||
message PtyAttachResponse {
|
||||
|
||||
@ -1,169 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
try:
|
||||
import websocket
|
||||
except ImportError:
|
||||
print("websocket-client is required: pip install websocket-client")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def create_kernel(base_url: str, token: str) -> str:
|
||||
url = f"{base_url}/api/kernels"
|
||||
headers = {}
|
||||
if token:
|
||||
headers["X-API-Key"] = token
|
||||
|
||||
req = urllib.request.Request(url, method="POST", data=b"", headers=headers)
|
||||
resp = urllib.request.urlopen(req)
|
||||
data = json.loads(resp.read())
|
||||
kernel_id = data["id"]
|
||||
print(f"Created kernel: {kernel_id}")
|
||||
return kernel_id
|
||||
|
||||
|
||||
def execute_code(ws: websocket.WebSocket, code: str) -> dict:
|
||||
msg_id = str(uuid.uuid4())
|
||||
session_id = str(uuid.uuid4())
|
||||
msg = {
|
||||
"header": {
|
||||
"msg_type": "execute_request",
|
||||
"msg_id": msg_id,
|
||||
"username": "",
|
||||
"session": session_id,
|
||||
"version": "5.3",
|
||||
"date": "",
|
||||
},
|
||||
"parent_header": {},
|
||||
"metadata": {},
|
||||
"content": {
|
||||
"code": code,
|
||||
"silent": False,
|
||||
"store_history": True,
|
||||
"user_expressions": {},
|
||||
},
|
||||
"buffers": [],
|
||||
"channel": "shell",
|
||||
}
|
||||
ws.send(json.dumps(msg))
|
||||
|
||||
result = {"stdout": "", "stderr": "", "output": None, "error": None}
|
||||
|
||||
while True:
|
||||
resp = json.loads(ws.recv())
|
||||
|
||||
# Filter out messages from other executions by matching msg_id
|
||||
parent_id = resp.get("parent_header", {}).get("msg_id")
|
||||
if parent_id != msg_id:
|
||||
continue
|
||||
|
||||
msg_type = resp.get("msg_type", "")
|
||||
|
||||
if msg_type == "stream":
|
||||
result["stdout"] += resp["content"]["text"]
|
||||
elif msg_type == "error":
|
||||
result["error"] = "\n".join(resp["content"].get("traceback", []))
|
||||
elif msg_type == "execute_result":
|
||||
result["output"] = resp["content"]["data"]
|
||||
elif msg_type == "status":
|
||||
if resp["content"]["execution_state"] == "idle":
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Test Jupyter kernel state management in a sandbox"
|
||||
)
|
||||
parser.add_argument(
|
||||
"sandbox_id",
|
||||
help="Sandbox ID (e.g. cl-8nxizn9ygtczplsnn9jve38be)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--domain",
|
||||
default="localhost:8080",
|
||||
help="Proxy domain (default: localhost:8080)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
default="8888",
|
||||
help="Jupyter port inside the sandbox (default: 8888)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--key",
|
||||
default="",
|
||||
help="Wrenn API Token",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
base_url = f"http://{args.port}-{args.sandbox_id}.{args.domain}"
|
||||
ws_base = base_url.replace("http", "ws", 1)
|
||||
|
||||
print(f"Testing Jupyter kernel at {base_url}")
|
||||
print()
|
||||
|
||||
kernel_id = create_kernel(base_url, args.key)
|
||||
|
||||
ws_url = f"{ws_base}/api/kernels/{kernel_id}/channels"
|
||||
|
||||
# Pass auth headers to the WebSocket if a token was provided
|
||||
ws_headers = {}
|
||||
if args.key:
|
||||
ws_headers["X-API-Key"] = args.key
|
||||
|
||||
ws = websocket.create_connection(ws_url, header=ws_headers)
|
||||
print("Connected to kernel WebSocket")
|
||||
print()
|
||||
|
||||
tests = [
|
||||
("variable assignment", "x = 42", None),
|
||||
("read variable", "x * 2", "84"),
|
||||
("import", "import math", None),
|
||||
("use import", "math.sqrt(144)", "12.0"),
|
||||
("function definition", "def greet(name): return f'hello {name}'", None),
|
||||
# Fixed: Jupyter 'execute_result' strings include the literal single quotes
|
||||
("call function", "greet('sandbox')", "'hello sandbox'"),
|
||||
("list mutation", "items = [1, 2, 3]; items.append(4); items", "[1, 2, 3, 4]"),
|
||||
]
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
|
||||
for name, code, expected in tests:
|
||||
print(f" {name}: {code}")
|
||||
result = execute_code(ws, code)
|
||||
|
||||
if result["error"]:
|
||||
print(f" ERROR: {result['error']}")
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
output = result["stdout"].strip()
|
||||
if not output and result["output"]:
|
||||
if "text/plain" in result["output"]:
|
||||
output = result["output"]["text/plain"].strip()
|
||||
|
||||
if expected is not None:
|
||||
if output == expected:
|
||||
print(f" PASS (got: {output})")
|
||||
passed += 1
|
||||
else:
|
||||
print(f" FAIL (expected: {expected}, got: {output})")
|
||||
failed += 1
|
||||
else:
|
||||
print(" OK")
|
||||
passed += 1
|
||||
|
||||
ws.close()
|
||||
print()
|
||||
print(f"Results: {passed} passed, {failed} failed")
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user