diff --git a/.env.example b/.env.example index 5464fde..f128de7 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,18 @@ # Database DATABASE_URL=postgres://wrenn:wrenn@localhost:5432/wrenn?sslmode=disable +# Redis +REDIS_URL=redis://localhost:6379/0 + # Control Plane CP_LISTEN_ADDR=:8000 CP_HOST_AGENT_ADDR=localhost:50051 # Host Agent AGENT_LISTEN_ADDR=:50051 -AGENT_KERNEL_PATH=/var/lib/wrenn/kernels/vmlinux -AGENT_IMAGES_PATH=/var/lib/wrenn/images -AGENT_SANDBOXES_PATH=/var/lib/wrenn/sandboxes +AGENT_FILES_ROOTDIR=/var/lib/wrenn AGENT_HOST_INTERFACE=eth0 +AGENT_CP_URL=http://localhost:8000 # Lago (billing — external service) LAGO_API_URL=http://localhost:3000 @@ -22,3 +24,12 @@ S3_REGION=fsn1 S3_ENDPOINT=https://fsn1.your-objectstorage.com AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= + +# Auth +JWT_SECRET= + +# OAuth +OAUTH_GITHUB_CLIENT_ID= +OAUTH_GITHUB_CLIENT_SECRET= +OAUTH_REDIRECT_URL=https://app.wrenn.dev +CP_PUBLIC_URL=https://api.wrenn.dev diff --git a/.gitignore b/.gitignore index 85b3fc2..c7fff43 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,13 @@ go.work.sum e2b/ ## Builds -builds/ \ No newline at end of file +builds/ + +## Frontend +frontend/node_modules/ +frontend/.svelte-kit/ +frontend/build/ + +## Dashboard embedded static (built from frontend, not committed) +internal/dashboard/static/* +!internal/dashboard/static/.gitkeep \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 8b5f75e..34a6bbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,848 +1,415 @@ -# CLAUDE.md — Wrenn Sandbox +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview -Wrenn Sandbox is a microVM-based code execution platform. Users create isolated sandboxes (Firecracker microVMs), run code inside them, and get output back via SDKs (Python, TypeScript, Go). Think E2B but with persistent sandboxes, pool-based pricing, and a single-binary deployment story. +Wrenn Sandbox is a microVM-based code execution platform. Users create isolated sandboxes (Firecracker microVMs), run code inside them, and get output back via SDKs. Think E2B but with persistent sandboxes, pool-based pricing, and a single-binary deployment story. + +## Build & Development Commands + +All commands go through the Makefile. Never use raw `go build` or `go run`. + +```bash +make build # Build all binaries → builds/ +make build-cp # Control plane only (builds frontend first) +make build-agent # Host agent only +make build-envd # envd static binary (verified statically linked) +make build-frontend # SvelteKit dashboard → internal/dashboard/static/ + +make dev # Full local dev: infra + migrate + control plane +make dev-infra # Start PostgreSQL + Prometheus + Grafana (Docker) +make dev-down # Stop dev infra +make dev-cp # Control plane with hot reload (if air installed) +make dev-frontend # Vite dev server with HMR (port 5173) +make dev-agent # Host agent (sudo required) +make dev-envd # envd in TCP debug mode + +make check # fmt + vet + lint + test (CI order) +make test # Unit tests: go test -race -v ./internal/... +make test-integration # Integration tests (require host agent + Firecracker) +make fmt # gofmt both modules +make vet # go vet both modules +make lint # golangci-lint + +make migrate-up # Apply pending migrations +make migrate-down # Rollback last migration +make migrate-create name=xxx # Scaffold new goose migration (never create manually) +make migrate-reset # Drop + re-apply all + +make generate # Proto (buf) + sqlc codegen +make proto # buf generate for all proto dirs +make tidy # go mod tidy both modules +``` + +Run a single test: `go test -race -v -run TestName ./internal/path/...` ## Architecture ``` -User SDK (Python/TS/Go) - │ - │ HTTPS / WebSocket - ▼ -Control Plane (Go binary, single process) - ├── REST API (chi router) - ├── Admin UI (htmx + Go templates) - ├── Scheduler (picks host for new sandboxes) - ├── State DB (PostgreSQL via pgx + goose migrations) - ├── Lifecycle Manager (background goroutine) - └── gRPC client → Host Agent - │ - │ gRPC (mTLS) - ▼ -Host Agent (Go binary, one per physical machine) - ├── VM Manager (Firecracker Go SDK + jailer) - ├── Network Manager (TAP devices, NAT, IP allocator) - ├── Filesystem Manager (CoW rootfs clones) - ├── Envd Client (vsock gRPC to guest agent) - ├── Snapshot Manager (pause/hibernate/resume) - ├── Metrics Exporter (Prometheus) - └── gRPC server (listens for control plane) - │ - │ vsock (AF_VSOCK, through Firecracker) - ▼ -envd (Go binary, runs inside each microVM as PID 1) - ├── ProcessService (exec commands, stream stdout/stderr) - ├── FilesystemService (read/write/list files) - └── Terminal (PTY handling for interactive sessions) +User SDK → HTTPS/WS → Control Plane → Connect RPC → Host Agent → HTTP/Connect RPC over TAP → envd (inside VM) ``` -## Key Decisions +**Three binaries, two Go modules:** -- **Language**: Everything is Go. No Python, no Node.js, no separate frontend. -- **Guest agent**: envd is extracted from E2B's open-source repo (e2b-dev/infra, Apache 2.0). The orchestrator VM management code is also adapted from E2B. -- **Database**: PostgreSQL. Migrations via goose (plain SQL files). -- **Admin UI**: htmx + Go html/template + chi router, served from the control plane binary. No SPA, no React, no build step. -- **API framework**: chi router for HTTP. Standard grpc-go for gRPC. -- **Billing**: Lago (external service, integrated via API). Not part of this codebase — we send usage events to Lago. -- **No separate reverse proxy binary**. Port forwarding is handled within the control plane or host agent directly if needed later. +| Binary | Module | Entry point | Runs as | +|--------|--------|-------------|---------| +| wrenn-cp | `git.omukk.dev/wrenn/sandbox` | `cmd/control-plane/main.go` | Unprivileged | +| wrenn-agent | `git.omukk.dev/wrenn/sandbox` | `cmd/host-agent/main.go` | Root (NET_ADMIN + /dev/kvm) | +| envd | `git.omukk.dev/wrenn/sandbox/envd` (standalone `envd/go.mod`) | `envd/main.go` | PID 1 inside guest VM | -## Directory Structure +envd is a **completely independent Go module**. It is never imported by the main module. The only connection is the protobuf contract. It compiles to a static binary baked into rootfs images. + +**Key architectural invariant:** The host agent is **stateful** (in-memory `boxes` map is the source of truth for running VMs). The control plane is **stateless** (all persistent state in PostgreSQL). The reconciler (`internal/api/reconciler.go`) bridges the gap — it periodically compares DB records against the host agent's live state and marks orphaned sandboxes as "stopped". + +### Control Plane + +**Packages:** `internal/api/`, `internal/dashboard/`, `internal/auth/`, `internal/scheduler/`, `internal/lifecycle/`, `internal/config/`, `internal/db/` + +Startup (`cmd/control-plane/main.go`) wires: config (env vars) → pgxpool → `db.Queries` (sqlc-generated) → Connect RPC client to host agent → `api.Server`. Everything flows through constructor injection. + +- **API Server** (`internal/api/server.go`): chi router with middleware. Creates handler structs (`sandboxHandler`, `execHandler`, `filesHandler`, etc.) injected with `db.Queries` and the host agent Connect RPC client. Routes under `/v1/sandboxes/*`. +- **Reconciler** (`internal/api/reconciler.go`): background goroutine (every 30s) that compares DB records against `agent.ListSandboxes()` RPC. Marks orphaned DB entries as "stopped". +- **Dashboard** (SvelteKit + Tailwind + Bits UI, statically built and embedded via `go:embed`, served as catch-all at root) +- **Database**: PostgreSQL via pgx/v5. Queries generated by sqlc from `db/queries/sandboxes.sql`. Migrations in `db/migrations/` (goose, plain SQL). +- **Config** (`internal/config/config.go`): purely environment variables (`DATABASE_URL`, `CP_LISTEN_ADDR`, `CP_HOST_AGENT_ADDR`), no YAML/file config. + +### Host Agent + +**Packages:** `internal/hostagent/`, `internal/sandbox/`, `internal/vm/`, `internal/network/`, `internal/devicemapper/`, `internal/envdclient/`, `internal/snapshot/` + +Startup (`cmd/host-agent/main.go`) wires: root 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`, `fc.go`, `config.go`): manages Firecracker processes. Uses raw HTTP API over Unix socket (`/tmp/fc-{sandboxID}.sock`), not the firecracker-go-sdk Machine type. Launches Firecracker via `unshare -m` + `ip netns exec`. Configures VM via PUT to `/boot-source`, `/drives/rootfs`, `/network-interfaces/eth0`, `/machine-config`, then starts with PUT `/actions`. +- **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). + +### envd (Guest Agent) + +**Module:** `envd/` with its own `go.mod` (`git.omukk.dev/wrenn/sandbox/envd`) + +Runs as PID 1 inside the microVM via `wrenn-init.sh` (mounts procfs/sysfs/dev, sets hostname, writes resolv.conf, then execs envd). Extracted from E2B (Apache 2.0), with shared packages internalized into `envd/internal/shared/`. Listens on TCP `0.0.0.0:49983`. + +- **ProcessService**: start processes, stream stdout/stderr, signal handling, PTY support +- **FilesystemService**: stat/list/mkdir/move/remove/watch files +- **Health**: GET `/health` + +### Dashboard (Frontend) + +**Directory:** `frontend/` — standalone SvelteKit app (Svelte 5, runes mode) + +- **Stack**: SvelteKit + `adapter-static` + Tailwind CSS v4 + Bits UI (headless accessible components) +- **Package manager**: pnpm +- **Routing**: SvelteKit file-based routing under `frontend/src/routes/` +- **Routing layout**: `/login` and `/signup` at root, authenticated pages under `/dashboard/*` (e.g. `/dashboard/capsules`, `/dashboard/keys`) +- **Build output**: `frontend/build/` → copied to `internal/dashboard/static/` → embedded via `go:embed` into the control plane binary +- **Serving**: `internal/dashboard/dashboard.go` registers a `NotFound` catch-all SPA handler with fallback to `index.html`. API routes (`/v1/*`, `/openapi.yaml`, `/docs`) are registered first and take priority +- **Dev workflow**: `make dev-frontend` runs Vite dev server on port 5173 with HMR. API calls proxy to `http://localhost:8000` +- **Fonts**: Manrope (UI), Instrument Serif (headings), JetBrains Mono (code), Alice (brand wordmark) — all self-hosted via `@fontsource` +- **Dark mode**: class-based (`.dark` on ``) with system preference detection + localStorage persistence + +To add a new page: create `frontend/src/routes/your-page/+page.svelte`. + +### Networking (per sandbox) + +Each sandbox gets its own Linux network namespace (`ns-{idx}`). Slot index (1-based, up to 65534) determines all addressing: ``` -wrenn-sandbox/ -├── CLAUDE.md # This file -├── Makefile # Build all binaries, run migrations, generate proto -├── go.mod # github.com/wrenn-dev/wrenn-sandbox -├── go.sum -├── .env.example -│ -├── cmd/ -│ ├── control-plane/ -│ │ └── main.go # Entry: HTTP server + gRPC client + lifecycle manager -│ └── host-agent/ -│ └── main.go # Entry: gRPC server + VM management -│ -├── envd/ # Guest agent (extracted from E2B, separate go.mod) -│ ├── go.mod -│ ├── main.go -│ ├── Makefile -│ └── internal/ # Process exec, filesystem, PTY, PID 1 handling -│ -├── proto/ -│ ├── envd/ # From E2B: ProcessService, FilesystemService -│ │ ├── process.proto -│ │ ├── filesystem.proto -│ │ └── gen/ # Generated Go stubs -│ └── hostagent/ # Our definition: control plane ↔ host agent -│ ├── hostagent.proto -│ └── gen/ -│ -├── internal/ -│ │ -│ │ ── CONTROL PLANE ── -│ ├── api/ -│ │ ├── server.go # chi router setup, middleware -│ │ ├── middleware.go # Auth, rate limiting, request logging -│ │ ├── handlers_sandbox.go # CRUD for sandboxes -│ │ ├── handlers_exec.go # Execute commands in sandboxes -│ │ ├── handlers_files.go # Upload/download files -│ │ └── handlers_terminal.go # WebSocket terminal sessions -│ │ -│ ├── admin/ # Admin UI (htmx + Go templates) -│ │ ├── handlers.go # Page handlers (dashboard, sandbox detail, etc.) -│ │ ├── templates/ -│ │ │ ├── layout.html # Base layout with htmx, navigation -│ │ │ ├── dashboard.html # Overview: active sandboxes, resource usage -│ │ │ ├── sandboxes.html # List all sandboxes with status -│ │ │ ├── sandbox_detail.html # Single sandbox: logs, metrics, audit trail -│ │ │ └── partials/ # htmx partial templates for dynamic updates -│ │ │ ├── sandbox_row.html -│ │ │ ├── metrics_card.html -│ │ │ └── audit_log.html -│ │ └── static/ # Minimal CSS (no build step) -│ │ └── style.css -│ │ -│ ├── auth/ -│ │ ├── apikey.go # API key validation -│ │ └── ratelimit.go -│ │ -│ ├── scheduler/ -│ │ ├── scheduler.go # Interface definition -│ │ ├── single_host.go # Default: always picks the one registered host -│ │ └── least_loaded.go # Multi-host: picks host with most available capacity -│ │ -│ ├── lifecycle/ -│ │ └── manager.go # Background goroutine: auto-pause, auto-hibernate, auto-destroy -│ │ -│ │ ── HOST AGENT ── -│ ├── vm/ -│ │ ├── manager.go # CreateVM, DestroyVM (wraps Firecracker Go SDK) -│ │ ├── config.go # Build Firecracker config from sandbox request -│ │ └── jailer.go # Jailer configuration for production -│ │ -│ ├── network/ -│ │ ├── manager.go # SetupNetwork, TeardownNetwork (TAP + NAT) -│ │ ├── allocator.go # IP pool allocator (/30 subnets from 10.0.0.0/16) -│ │ └── nat.go # iptables/nftables rule management -│ │ -│ ├── filesystem/ -│ │ ├── images.go # Base image registry -│ │ └── clone.go # CoW rootfs clones (cp --reflink) -│ │ -│ ├── envdclient/ -│ │ ├── client.go # gRPC client wrapper for envd -│ │ ├── dialer.go # vsock CONNECT handshake dialer -│ │ └── health.go # Health check with retry -│ │ -│ ├── snapshot/ -│ │ ├── manager.go # Pause/resume coordination -│ │ ├── local.go # Local disk snapshot storage -│ │ └── remote.go # S3/GCS upload/download for hibernate -│ │ -│ ├── metrics/ -│ │ ├── collector.go # Read cgroup stats per sandbox -│ │ └── exporter.go # Prometheus /metrics endpoint -│ │ -│ │ ── SHARED ── -│ ├── models/ -│ │ ├── sandbox.go # Sandbox struct, status enum, state machine -│ │ └── host.go # Host struct, capacity tracking -│ │ -│ ├── id/ -│ │ └── id.go # Generate sandbox IDs: "sb-" + 8 hex chars -│ │ -│ └── config/ -│ └── config.go # Configuration loading (env vars + YAML) -│ -├── db/ -│ ├── migrations/ # goose SQL migrations -│ │ ├── 00001_initial.sql -│ │ ├── 00002_add_persistence.sql -│ │ └── 00003_add_audit_events.sql -│ └── queries/ # SQL queries (used with sqlc or raw pgx) -│ ├── sandboxes.sql -│ ├── hosts.sql -│ └── audit.sql -│ -├── images/ # Rootfs build scripts -│ ├── build-rootfs.sh -│ ├── docker-to-rootfs.sh -│ └── templates/ -│ ├── minimal/build.sh -│ ├── python311/build.sh -│ └── node20/build.sh -| -├── deploy/ -│ ├── systemd/ -│ │ ├── wrenn-control-plane.service -│ │ └── wrenn-host-agent.service -│ └── ansible/ -│ └── playbook.yml -│ -├── scripts/ -│ ├── setup-host.sh -│ ├── generate-proto.sh -│ └── dev.sh -│ -└── tests/ - ├── integration/ - │ ├── sandbox_lifecycle_test.go - │ ├── networking_test.go - │ └── snapshot_test.go - └── load/ - └── concurrent_test.go +Host Namespace Namespace "ns-{idx}" Guest VM +────────────────────────────────────────────────────────────────────────────────────── +veth-{idx} ←──── veth pair ────→ eth0 +10.12.0.{idx*2}/31 10.12.0.{idx*2+1}/31 + │ + tap0 (169.254.0.22/30) ←── TAP ──→ eth0 (169.254.0.21) + ↑ kernel ip= boot arg ``` -## Database - -### Tech Stack -- PostgreSQL (via pgx/v5 driver, no ORM) -- goose for migrations (plain SQL, up/down) -- sqlc for type-safe query generation (optional, can use raw pgx) - -### Migration Convention -``` -db/migrations/ -├── 00001_initial.sql -├── 00002_add_persistence.sql -└── ... -``` - -Each migration file uses goose format: -```sql --- +goose Up -CREATE TABLE sandboxes (...); - --- +goose Down -DROP TABLE sandboxes; -``` - -Run migrations: -```bash -# Apply all pending migrations -goose -dir db/migrations postgres "$DATABASE_URL" up - -# Rollback last migration -goose -dir db/migrations postgres "$DATABASE_URL" down - -# Check current status -goose -dir db/migrations postgres "$DATABASE_URL" status - -# Create a new migration -goose -dir db/migrations create add_new_table sql -``` - -### Core Tables - -**sandboxes** — Every sandbox created on the platform -```sql -CREATE TABLE sandboxes ( - id TEXT PRIMARY KEY, - owner_id TEXT NOT NULL, - host_id TEXT NOT NULL, - template TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - vcpus INTEGER DEFAULT 1, - memory_mb INTEGER DEFAULT 512, - timeout_sec INTEGER DEFAULT 300, - guest_ip TEXT, - vsock_cid INTEGER, - snapshot_path TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - started_at TIMESTAMPTZ, - paused_at TIMESTAMPTZ, - last_active_at TIMESTAMPTZ, - metadata JSONB DEFAULT '{}' -); - -CREATE INDEX idx_sandboxes_owner ON sandboxes(owner_id); -CREATE INDEX idx_sandboxes_status ON sandboxes(status); -CREATE INDEX idx_sandboxes_host ON sandboxes(host_id); -``` - -**hosts** — Registered host agents -```sql -CREATE TABLE hosts ( - id TEXT PRIMARY KEY, - grpc_endpoint TEXT NOT NULL, - total_vcpus INTEGER, - total_memory_mb INTEGER, - used_vcpus INTEGER DEFAULT 0, - used_memory_mb INTEGER DEFAULT 0, - sandbox_count INTEGER DEFAULT 0, - status TEXT DEFAULT 'healthy', - last_heartbeat TIMESTAMPTZ -); -``` - -**audit_events** — Every exec/file operation -```sql -CREATE TABLE audit_events ( - id BIGSERIAL PRIMARY KEY, - sandbox_id TEXT NOT NULL REFERENCES sandboxes(id), - owner_id TEXT NOT NULL, - event_type TEXT NOT NULL, - command TEXT, - exit_code INTEGER, - duration_ms INTEGER, - stdout_bytes INTEGER, - stderr_bytes INTEGER, - created_at TIMESTAMPTZ DEFAULT NOW() -); - -CREATE INDEX idx_audit_sandbox ON audit_events(sandbox_id); -CREATE INDEX idx_audit_owner ON audit_events(owner_id); -CREATE INDEX idx_audit_created ON audit_events(created_at); -``` - -**api_keys** — Authentication -```sql -CREATE TABLE api_keys ( - id TEXT PRIMARY KEY, - key_hash TEXT NOT NULL UNIQUE, - owner_id TEXT NOT NULL, - plan TEXT DEFAULT 'hobby', - pool_vcpus INTEGER DEFAULT 2, - pool_memory_mb INTEGER DEFAULT 8192, - pool_storage_mb INTEGER DEFAULT 20480, - is_active BOOLEAN DEFAULT true, - created_at TIMESTAMPTZ DEFAULT NOW() -); -``` +- **Host-reachable IP**: `10.11.0.{idx}/32` — routed through veth to namespace, DNAT'd to guest +- **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` ### Sandbox State Machine ``` PENDING → STARTING → RUNNING → PAUSED → HIBERNATED │ │ ↓ ↓ - STOPPED STOPPED - │ - ↓ - (destroyed/cleaned up) + STOPPED STOPPED → (destroyed) -Also: any state → ERROR (on crash/failure) -PAUSED → RUNNING (resume from warm snapshot) -HIBERNATED → RUNNING (resume from cold snapshot, slower) +Any state → ERROR (on crash/failure) +PAUSED → RUNNING (warm snapshot resume) +HIBERNATED → RUNNING (cold snapshot resume, slower) ``` -## Admin UI (htmx) +### Key Request Flows -The control plane serves an admin dashboard at `/admin/`. It uses: -- Go `html/template` for server-side rendering -- htmx for dynamic updates (no JavaScript framework) -- Minimal custom CSS — no Tailwind, no build step +**Sandbox creation** (`POST /v1/sandboxes`): +1. API handler generates sandbox ID, inserts into DB as "pending" +2. RPC `CreateSandbox` → host agent → `sandbox.Manager.Create()` +3. Manager: resolve base rootfs → acquire shared loop device → create dm-snapshot (sparse CoW file) → allocate network slot → `CreateNetwork()` (netns + veth + tap + NAT) → `vm.Create()` (start Firecracker with `/dev/mapper/wrenn-{id}`, configure via HTTP API, boot) → `envdclient.WaitUntilReady()` (poll /health) → store in-memory state +4. API handler updates DB to "running" with host_ip -### Pages -- `/admin/` — Dashboard: active sandbox count, resource pool usage, recent activity -- `/admin/sandboxes` — List all sandboxes (filterable by status, owner, template) -- `/admin/sandboxes/{id}` — Sandbox detail: status, metrics, audit log, actions (pause/resume/destroy) -- `/admin/hosts` — Host agent list with capacity and health -- `/admin/keys` — API key management +**Command execution** (`POST /v1/sandboxes/{id}/exec`): +1. API handler verifies sandbox is "running" in DB +2. RPC `Exec` → host agent → `sandbox.Manager.Exec()` → `envdclient.Exec()` +3. envd client opens bidirectional Connect RPC stream (`process.Start`), collects stdout/stderr/exit_code +4. API handler checks UTF-8 validity (base64-encodes if binary), updates last_active_at, returns result -### htmx Patterns -- Sandbox list auto-refreshes via `hx-trigger="every 5s"` -- Actions (pause, resume, destroy) use `hx-post` with `hx-swap="outerHTML"` to update the row -- Audit log on sandbox detail uses `hx-get` with infinite scroll -- Metrics cards use `hx-trigger="every 10s"` for live updates +**Streaming exec** (`WS /v1/sandboxes/{id}/exec/stream`): +1. WebSocket upgrade, read first message for cmd/args +2. RPC `ExecStream` → host agent → `sandbox.Manager.ExecStream()` → `envdclient.ExecStream()` +3. envd client returns a channel of events; host agent forwards events through the RPC stream +4. API handler forwards stream events to WebSocket as JSON messages (`{type: "stdout"|"stderr"|"exit", ...}`) -### Styling -Wrenn brand colors: -- Background: obsidian (#0c0c0c, #131313, #1a1a1a for raised surfaces) -- Text: warm off-white (#e8e6e3), dim (#9a9890) -- Accent: sage green (#8fbc8f) -- Borders: #2a2a2a -- Font: system monospace for data, system sans-serif for prose -- Minimal, developer-tool aesthetic. Dense, functional, sharp edges. - -## Proto Definitions - -### hostagent.proto (control plane ↔ host agent) -```protobuf -syntax = "proto3"; -package hostagent; -option go_package = "github.com/wrenn-dev/wrenn-sandbox/proto/hostagent/gen"; - -service HostAgentService { - rpc CreateSandbox(CreateSandboxRequest) returns (CreateSandboxResponse); - rpc DestroySandbox(DestroySandboxRequest) returns (DestroySandboxResponse); - rpc PauseSandbox(PauseSandboxRequest) returns (PauseSandboxResponse); - rpc ResumeSandbox(ResumeSandboxRequest) returns (ResumeSandboxResponse); - rpc Exec(ExecRequest) returns (stream ExecOutput); - rpc WriteFile(WriteFileRequest) returns (WriteFileResponse); - rpc ReadFile(ReadFileRequest) returns (ReadFileResponse); - rpc Heartbeat(HeartbeatRequest) returns (HeartbeatResponse); -} -``` - -### envd protos (host agent ↔ guest agent) -Extracted from E2B's spec/ directory. ProcessService and FilesystemService. Do not modify these unless you also modify envd. +**File transfer**: Write uses multipart POST to envd `/files`; read uses GET. Streaming variants chunk in 64KB pieces through the RPC stream. ## REST API -All endpoints under `/v1/`. JSON request/response. API key auth via `X-API-Key` header. +Routes defined in `internal/api/server.go`, handlers in `internal/api/handlers_*.go`. OpenAPI spec embedded via `//go:embed` and served at `/openapi.yaml` (Swagger UI at `/docs`). JSON request/response. API key auth via `X-API-Key` header. Error responses: `{"error": {"code": "...", "message": "..."}}`. -``` -POST /v1/sandboxes Create sandbox -GET /v1/sandboxes List sandboxes -GET /v1/sandboxes/{id} Get sandbox status -POST /v1/sandboxes/{id}/exec Execute command -PUT /v1/sandboxes/{id}/files Upload file -GET /v1/sandboxes/{id}/files/{path} Download file -POST /v1/sandboxes/{id}/pause Pause sandbox -POST /v1/sandboxes/{id}/resume Resume sandbox -DELETE /v1/sandboxes/{id} Destroy sandbox -WS /v1/sandboxes/{id}/terminal Interactive terminal +## Code Generation -GET /v1/hosts List hosts (admin) -GET /v1/keys List API keys (admin) -POST /v1/keys Create API key (admin) -``` +### Proto (Connect RPC) + +Proto source of truth is `proto/envd/*.proto` and `proto/hostagent/*.proto`. Run `make proto` to regenerate. Three `buf.gen.yaml` files control output: + +| buf.gen.yaml location | Generates to | Used by | +|---|---|---| +| `proto/envd/buf.gen.yaml` | `proto/envd/gen/` | Main module (host agent's envd client) | +| `proto/hostagent/buf.gen.yaml` | `proto/hostagent/gen/` | Main module (control plane ↔ host agent) | +| `envd/spec/buf.gen.yaml` | `envd/internal/services/spec/` | envd module (guest agent server) | + +The envd `buf.gen.yaml` reads from `../../proto/envd/` (same source protos) but generates into envd's own module. This means the same `.proto` files produce two independent sets of Go stubs — one for each Go module. + +To add a new RPC method: edit the `.proto` file → `make proto` → implement the handler on both sides. + +### sqlc + +Config: `sqlc.yaml` (project root). Reads queries from `db/queries/*.sql`, reads schema from `db/migrations/`, outputs to `internal/db/`. + +To add a new query: add it to the appropriate `.sql` file in `db/queries/` → `make generate` → use the new method on `*db.Queries`. + +## Key Technical Decisions + +- **Connect RPC** (not gRPC) for all RPC communication between components +- **Buf + protoc-gen-connect-go** for code generation (not protoc-gen-go-grpc) +- **Raw Firecracker HTTP API** via Unix socket (not firecracker-go-sdk Machine type) +- **TAP networking** (not vsock) for host-to-envd communication +- **Device-mapper snapshots** for rootfs CoW — shared read-only loop device per base template, per-sandbox sparse CoW file, Firecracker gets `/dev/mapper/wrenn-{id}` +- **PostgreSQL** via pgx/v5 + sqlc (type-safe query generation). Goose for migrations (plain SQL, up/down) +- **Dashboard**: SvelteKit (Svelte 5, adapter-static) + Tailwind CSS v4 + Bits UI. Built to static files, embedded into the Go binary via `go:embed`, served as catch-all at root +- **Lago** for billing (external service, not in this codebase) ## Coding Conventions -### Go Style -- Follow standard Go conventions. Run `gofmt` and `go vet`. -- Use `context.Context` everywhere. Pass it through the full call chain. -- Error handling: wrap errors with `fmt.Errorf("create sandbox: %w", err)`. No bare returns. -- Logging: use `slog` (Go 1.21+ structured logging). No third-party loggers. -- No global state. Everything injected via constructors. - -### Naming -- Sandbox IDs: `sb-` prefix + 8 hex chars (e.g., `sb-a1b2c3d4`) -- API keys: `wrn_` prefix + 32 random chars -- Host IDs: hostname or `host-` prefix + 8 hex chars -- TAP devices: `tap-` + first 8 chars of sandbox ID -- vsock CIDs: allocated from pool starting at 3 - -### Error Responses -```json -{ - "error": { - "code": "pool_exhausted", - "message": "Your vCPU pool is fully allocated. Upgrade your plan or destroy idle sandboxes." - } -} -``` - -### Testing -- Unit tests: `go test ./internal/...` -- Integration tests: `go test ./tests/integration/...` (require running host agent + Firecracker) -- Table-driven tests for handlers and state machine transitions - - -## envd — Standalone Binary - -envd is a **completely independent Go project**. It has its own `go.mod`, its own dependencies, and its own build. It is never imported by the control plane or host agent as a Go package. The only connection is the protobuf contract — both envd and the host agent generate code from the same `.proto` files. - -**Why standalone:** envd runs inside microVMs. It gets compiled once as a static binary, baked into rootfs images, and then used across thousands of sandboxes. It has zero runtime dependency on the rest of the Wrenn codebase. The host agent talks to it over vsock gRPC — same as talking to any remote service. - -**envd's own structure:** -``` -envd/ -├── go.mod # module github.com/wrenn-dev/envd (NOT the parent module) -├── go.sum -├── Makefile # self-contained build -├── main.go # Entry point, boots as PID 1 -└── internal/ - ├── server/ # gRPC service implementations - ├── process/ # Process exec, PTY, signal handling - ├── filesystem/ # File read/write/list/watch - └── network/ # Guest-side network config on boot/resume -``` - -**Building envd:** -```bash -cd envd -CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags='-s -w' -o envd . -file envd # MUST say "statically linked" -# Binary goes into rootfs images at /usr/local/bin/envd -``` - -**Versioning:** envd has its own version, independent of the control plane or host agent. When you update envd, you rebuild rootfs images. Existing sandboxes keep the old envd. - -## Build Commands (Makefile) - -```makefile -# ═══════════════════════════════════════════════════ -# Variables -# ═══════════════════════════════════════════════════ -DATABASE_URL ?= postgres://wrenn:wrenn@localhost:5432/wrenn?sslmode=disable -GOBIN := $(shell pwd)/bin -ENVD_DIR := envd -LDFLAGS := -s -w - -# ═══════════════════════════════════════════════════ -# Build -# ═══════════════════════════════════════════════════ -.PHONY: build build-cp build-agent build-envd - -build: build-cp build-agent build-envd - -build-cp: - go build -v -ldflags="$(LDFLAGS)" -o $(GOBIN)/wrenn-cp ./cmd/control-plane - -build-agent: - go build -v -ldflags="$(LDFLAGS)" -o $(GOBIN)/wrenn-agent ./cmd/host-agent - -build-envd: - cd $(ENVD_DIR) && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ - go build -ldflags="$(LDFLAGS)" -o ../$(GOBIN)/envd . - @file $(GOBIN)/envd | grep -q "statically linked" || \ - (echo "ERROR: envd is not statically linked!" && exit 1) - -# ═══════════════════════════════════════════════════ -# Development -# ═══════════════════════════════════════════════════ -.PHONY: dev dev-cp dev-agent dev-envd dev-infra dev-down dev-seed - -## One command to start everything for local dev -dev: dev-infra migrate-up dev-seed dev-cp - -dev-infra: - docker compose -f deploy/docker-compose.dev.yml up -d - @echo "Waiting for PostgreSQL..." - @until pg_isready -h localhost -p 5432 -q; do sleep 0.5; done - @echo "Dev infrastructure ready." - -dev-down: - docker compose -f deploy/docker-compose.dev.yml down -v - -dev-cp: - @if command -v air > /dev/null; then air -c .air.cp.toml; \ - else go run ./cmd/control-plane; fi - -dev-agent: - sudo go run ./cmd/host-agent - -dev-envd: - cd $(ENVD_DIR) && go run . --debug --listen-tcp :3002 - -dev-seed: - go run ./scripts/seed.go - -# ═══════════════════════════════════════════════════ -# Database (goose) -# ═══════════════════════════════════════════════════ -.PHONY: migrate-up migrate-down migrate-status migrate-create migrate-reset - -migrate-up: - goose -dir db/migrations postgres "$(DATABASE_URL)" up - -migrate-down: - goose -dir db/migrations postgres "$(DATABASE_URL)" down - -migrate-status: - goose -dir db/migrations postgres "$(DATABASE_URL)" status - -migrate-create: - goose -dir db/migrations create $(name) sql - -migrate-reset: - goose -dir db/migrations postgres "$(DATABASE_URL)" reset - goose -dir db/migrations postgres "$(DATABASE_URL)" up - -# ═══════════════════════════════════════════════════ -# Code Generation -# ═══════════════════════════════════════════════════ -.PHONY: generate proto sqlc - -generate: proto sqlc - -proto: - protoc --go_out=. --go_opt=paths=source_relative \ - --go-grpc_out=. --go-grpc_opt=paths=source_relative \ - proto/hostagent/hostagent.proto - protoc --go_out=. --go_opt=paths=source_relative \ - --go-grpc_out=. --go-grpc_opt=paths=source_relative \ - proto/envd/process.proto proto/envd/filesystem.proto - -sqlc: - @if command -v sqlc > /dev/null; then sqlc generate; \ - else echo "sqlc not installed, skipping"; fi - -# ═══════════════════════════════════════════════════ -# Quality & Testing -# ═══════════════════════════════════════════════════ -.PHONY: fmt lint vet test test-integration test-all tidy check - -fmt: - gofmt -w . - cd $(ENVD_DIR) && gofmt -w . - -lint: - golangci-lint run ./... - -vet: - go vet ./... - cd $(ENVD_DIR) && go vet ./... - -test: - go test -race -v ./internal/... - -test-integration: - go test -race -v -tags=integration ./tests/integration/... - -test-all: test test-integration - -tidy: - go mod tidy - cd $(ENVD_DIR) && go mod tidy - -## Run all quality checks in CI order -check: fmt vet lint test - -# ═══════════════════════════════════════════════════ -# Rootfs Images -# ═══════════════════════════════════════════════════ -.PHONY: images image-minimal image-python image-node - -images: build-envd image-minimal image-python image-node - -image-minimal: - sudo bash images/templates/minimal/build.sh - -image-python: - sudo bash images/templates/python311/build.sh - -image-node: - sudo bash images/templates/node20/build.sh - -# ═══════════════════════════════════════════════════ -# Deployment -# ═══════════════════════════════════════════════════ -.PHONY: setup-host install - -setup-host: - sudo bash scripts/setup-host.sh - -install: build - sudo cp $(GOBIN)/wrenn-cp /usr/local/bin/ - sudo cp $(GOBIN)/wrenn-agent /usr/local/bin/ - sudo cp deploy/systemd/*.service /etc/systemd/system/ - sudo systemctl daemon-reload - -# ═══════════════════════════════════════════════════ -# Clean -# ═══════════════════════════════════════════════════ -.PHONY: clean - -clean: - rm -rf bin/ - cd $(ENVD_DIR) && rm -f envd - -# ═══════════════════════════════════════════════════ -# Help -# ═══════════════════════════════════════════════════ -.DEFAULT_GOAL := help -.PHONY: help -help: - @echo "Wrenn Sandbox" - @echo "" - @echo " make dev Full local dev (infra + migrate + seed + control plane)" - @echo " make dev-infra Start PostgreSQL + Prometheus + Grafana" - @echo " make dev-down Stop dev infra" - @echo " make dev-cp Control plane (hot reload if air installed)" - @echo " make dev-agent Host agent (sudo required)" - @echo " make dev-envd envd in TCP debug mode" - @echo "" - @echo " make build Build all binaries → bin/" - @echo " make build-envd Build envd static binary" - @echo "" - @echo " make migrate-up Apply migrations" - @echo " make migrate-create name=xxx New migration" - @echo " make migrate-reset Drop + re-apply all" - @echo "" - @echo " make generate Proto + sqlc codegen" - @echo " make check fmt + vet + lint + test" - @echo " make test-all Unit + integration tests" - @echo "" - @echo " make images Build all rootfs images" - @echo " make setup-host One-time host setup" - @echo " make install Install binaries + systemd units" -``` - -### docker-compose.dev.yml - -```yaml -# deploy/docker-compose.dev.yml -services: - postgres: - image: postgres:16-alpine - environment: - POSTGRES_USER: wrenn - POSTGRES_PASSWORD: wrenn - POSTGRES_DB: wrenn - ports: - - "5432:5432" - volumes: - - pgdata:/var/lib/postgresql/data - - prometheus: - image: prom/prometheus:latest - ports: - - "9090:9090" - volumes: - - ./deploy/prometheus.yml:/etc/prometheus/prometheus.yml - - grafana: - image: grafana/grafana:latest - ports: - - "3001:3000" - environment: - GF_SECURITY_ADMIN_PASSWORD: admin - -volumes: - pgdata: -``` - -### .env.example - -```bash -# Database -DATABASE_URL=postgres://wrenn:wrenn@localhost:5432/wrenn?sslmode=disable - -# Control Plane -CP_LISTEN_ADDR=:8000 -CP_HOST_AGENT_ADDR=localhost:50051 - -# Host Agent -AGENT_LISTEN_ADDR=:50051 -AGENT_KERNEL_PATH=/var/lib/wrenn/kernels/vmlinux -AGENT_IMAGES_PATH=/var/lib/wrenn/images -AGENT_SANDBOXES_PATH=/var/lib/wrenn/sandboxes -AGENT_HOST_INTERFACE=eth0 - -# Lago (billing — external service) -LAGO_API_URL=http://localhost:3000 -LAGO_API_KEY= - -# Object Storage (hibernate snapshots — Hetzner Object Storage, S3-compatible) -# Hetzner Object Storage uses the S3-compatible API, so we use standard AWS SDK environment variables -S3_BUCKET=wrenn-snapshots -S3_REGION=fsn1 -S3_ENDPOINT=https://fsn1.your-objectstorage.com -AWS_ACCESS_KEY_ID= # Hetzner Object Storage access key (S3-compatible) -AWS_SECRET_ACCESS_KEY= # Hetzner Object Storage secret key (S3-compatible) -``` - -### Development Workflow - -```bash -# First time -git clone https://github.com/wrenn-dev/wrenn-sandbox && cd wrenn-sandbox -make tidy - -# Install tools -go install github.com/pressly/goose/v3/cmd/goose@latest -go install google.golang.org/protobuf/cmd/protoc-gen-go@latest -go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest -go install github.com/air-verse/air@latest -go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest - -# Start everything -make dev-infra # PostgreSQL + monitoring -make migrate-up # Create tables -make dev-seed # Test API key - -# Terminal 1 -make dev-cp # → http://localhost:8000 (API + admin UI) - -# Terminal 2 -make dev-agent # → gRPC on :50051 - -# Terminal 3 -curl http://localhost:8000/v1/sandboxes -open http://localhost:8000/admin/ -``` - -## Implementation Priority - -### Phase 1: Boot a VM -1. Build envd static binary -2. Create minimal rootfs with envd baked in -3. Write `internal/vm/` — boot Firecracker -4. Write `internal/envdclient/` — connect to envd over vsock -5. Test: boot VM, run "echo hello", get output back - -### Phase 2: Host Agent -1. Write `internal/network/` — TAP + NAT per sandbox -2. Write `internal/filesystem/` — CoW rootfs clones -3. Define hostagent.proto, generate stubs -4. Write host agent gRPC server -5. Test: grpcurl to create/exec/destroy - -### Phase 3: Control Plane -1. Set up PostgreSQL, write goose migrations -2. Write `internal/api/` — REST handlers -3. Write `internal/auth/` — API key validation -4. Write `internal/scheduler/` — SingleHostScheduler -5. Test: curl to create/exec/destroy via REST - -### Phase 4: Admin UI -1. Write `internal/admin/` — htmx templates -2. Dashboard, sandbox list, sandbox detail -3. Host status, API key management -4. Test: browser, see sandboxes, perform actions - -### Phase 5: Persistence -1. Write `internal/snapshot/` — Firecracker snapshots -2. Add pause/hibernate/resume states -3. Write `internal/lifecycle/` — auto-pause idle sandboxes -4. Test: pause, resume, verify state intact - -### Phase 6: SDKs -1. Python SDK -2. TypeScript SDK -3. Go SDK -4. Test: end-to-end from SDK - -### Phase 7: Hardening -1. Jailer integration -2. cgroup resource limits -3. Egress filtering -4. Prometheus metrics -5. Stress testing - -## Dependencies - -### Go modules (main project) -``` -github.com/go-chi/chi/v5 -github.com/jackc/pgx/v5 -github.com/pressly/goose/v3 -github.com/firecracker-microvm/firecracker-go-sdk -github.com/mdlayher/vsock -google.golang.org/grpc -google.golang.org/protobuf -github.com/prometheus/client_golang -github.com/gorilla/websocket -github.com/rs/cors -golang.org/x/crypto -``` - -### envd Go modules (separate go.mod — minimal deps only) -``` -google.golang.org/grpc -google.golang.org/protobuf -github.com/mdlayher/vsock -``` - -### External services -- PostgreSQL (local Docker or managed) -- Lago (billing, HTTP API only) -- S3/GCS (hibernate snapshot storage) - -### Dev tools -``` -goose, protoc, protoc-gen-go, protoc-gen-go-grpc, air, golangci-lint, grpcurl, sqlc -``` - -## Important Notes - -- Host agent MUST run as root (NET_ADMIN + /dev/kvm). -- Control plane does NOT need root. -- envd is a **standalone Go module** (`envd/go.mod`). Never imported by other Go code. Static binary. Baked into rootfs images. -- `make dev` is the one command for local development. -- For dev without Firecracker, `make dev-envd` runs envd in TCP mode. \ No newline at end of file +- **Go style**: `gofmt`, `go vet`, `context.Context` everywhere, errors wrapped with `fmt.Errorf("action: %w", err)`, `slog` for logging, no global state +- **Naming**: Sandbox IDs `sb-` + 8 hex, API keys `wrn_` + 32 chars, Host IDs `host-` + 8 hex +- **Dependencies**: Use `go get` to add deps, never hand-edit go.mod. For envd deps: `cd envd && go get ...` (separate module) +- **Generated code**: Always commit generated code (proto stubs, sqlc). Never add generated code to .gitignore +- **Migrations**: Always use `make migrate-create name=xxx`, never create migration files manually +- **Testing**: Table-driven tests for handlers and state machine transitions + +### Two-module gotcha + +The main module (`go.mod`) and envd (`envd/go.mod`) are fully independent. `make tidy`, `make fmt`, `make vet` already operate on both. But when adding dependencies manually, remember to target the correct module (`cd envd && go get ...` for envd deps). `make proto` also generates stubs for both modules from the same proto sources. + +## Rootfs & Guest Init + +- **wrenn-init** (`images/wrenn-init.sh`): the PID 1 init script baked into every rootfs. Mounts virtual filesystems, sets hostname, writes `/etc/resolv.conf`, then execs envd. +- **Updating the rootfs** after changing envd or wrenn-init: `bash scripts/update-debug-rootfs.sh [rootfs_path]`. This builds envd via `make build-envd`, mounts the rootfs image, copies in the new binaries, and unmounts. Defaults to `/var/lib/wrenn/images/minimal.ext4`. +- Rootfs images are minimal debootstrap — no systemd, no coreutils beyond busybox. Use `/bin/sh -c` for shell builtins inside the guest. + +## Fixed Paths (on host machine) + +- Kernel: `/var/lib/wrenn/kernels/vmlinux` +- Base rootfs images: `/var/lib/wrenn/images/{template}.ext4` +- Sandbox clones: `/var/lib/wrenn/sandboxes/` +- Firecracker: `/usr/local/bin/firecracker` (e2b's fork of firecracker) + +## Web UI Styling + +### Identity + +Warm, confident developer tool with industrial precision and crafted organic character. The feel is sharp and data-forward — not cold or sterile, but not soft either. Think: an engineer's favorite tool, built with care. + +--- + +### Color Palette (Dark Mode) + +**Background scale (6 steps, near-black-green):** +`#0a0c0b` (bg-0, page base) → `#0f1211` (bg-1, sidebar/topbar) → `#141817` (bg-2, cards/surfaces) → `#1a1e1c` (bg-3, hover states/elevated) → `#212624` (bg-4, inputs/avatars) → `#2a302d` (bg-5, active controls) + +**Text hierarchy (5 levels):** +- Bright `#eae7e2` — page titles, metric values, active states +- Primary `#d0cdc6` — body text, nav labels, readable content +- Secondary `#9b9790` — supporting text, inactive nav, descriptions +- Tertiary `#6b6862` — labels, section headers, timestamps +- Muted `#454340` — ghost text, disabled states, grid labels + +**Sage green brand accent (3 tiers + 2 glows):** +- Solid `#5e8c58` — primary accent, buttons, borders, active indicators +- Mid `#89a785` — badges, chart lines, secondary accent +- Bright `#a4c89f` — active nav text, live counts, chart dots +- Glow `rgba(94,140,88,0.07)` — active nav backgrounds, subtle highlights +- Glow Mid `rgba(94,140,88,0.14)` — live badges, status badge backgrounds + +**Borders (2 levels):** +- Default `#1f2321` — card edges, dividers, sidebar borders +- Mid `#2a2f2c` — hover states, interactive borders, stronger separation + +**Semantic status colors:** +- Amber `#d4a73c` — warning, building, countdown timers +- Red `#cf8172` — error, failed, destructive actions +- Blue `#5a9fd4` — info, stopped (use sparingly) + +**Light mode:** (TBD — follow same warm-tinted approach. Background scale from `#f8f6f1` → `#dedbd5`. Text hierarchy inverts. Accent stays `#5e8c58` for solid.) + +--- + +### Typography + +Four fonts, each with a clear role: + +| Font | Role | Weights | Where | +|------|------|---------|-------| +| **Manrope** (variable) | Body, UI | 400–700 | All body text, nav labels, buttons, descriptions, section headers | +| **Instrument Serif** | Display, metrics | 400 | Page titles (h1), large metric values, empty-state headings only | +| **JetBrains Mono** | Code, data | 400–600 | Status bar, time range buttons, search inputs, IDs, commit SHAs, countdown timers, log viewer, URL paths, code blocks | +| **Alice** | Brand wordmark | 400 | Sidebar wordmark only — never used elsewhere | + +**Sizing:** +- Base body: `14px` +- Page title (h1): `24px` serif, `letter-spacing: -0.02em` +- Card metric values: `36px` serif, `letter-spacing: -0.04em` +- Chart inline metric: `30px` serif, `letter-spacing: -0.04em` +- Nav items: `13px` body, weight 500 +- Section/group labels: `11px` body, uppercase, `letter-spacing: 0.06em`, weight 600 +- Chart section labels: `12px` body, uppercase, `letter-spacing: 0.05em`, weight 600 +- Stat cell labels: `11px` body, uppercase, `letter-spacing: 0.05em`, weight 600 +- Badge text: `10px`, uppercase, `letter-spacing: 0.04em`, weight 600 +- Status bar / footer links: `11–12px` mono +- Table headers: `11px` body, uppercase, `letter-spacing: 0.05em`, weight 600, color muted +- Table body cells: `13px` + +**Key rule:** Instrument Serif is reserved exclusively for page-level titles and large numeric values. It provides warmth and character without softness. Everything else uses Manrope (UI) or JetBrains Mono (data/code). + +--- + +### Spacing + +4px base unit (Tailwind scale). Moderate density — functional and confident, never cramped. + +- Page content padding: `24–28px` +- Card/surface internal padding: `18–20px` +- Sidebar width: `230px` +- Sidebar nav item padding: `8px 10px` +- Sidebar brand area: `18px 16px 16px` +- Tab bar items: `10px 16px` +- Topbar: `16px 28px` +- Metric strip cell: `18px 20px` +- Chart header: `18px 20px` +- Chart canvas: `14px 20px 12px` +- Table header cells: `11px 16px` +- Table body cells: `12px 16px` +- Status bar: `6px 28px` +- Between sections (cards): `20–24px` margin-bottom + +--- + +### Borders & Depth + +**Flat aesthetic — no drop shadows.** Depth comes from background color stepping (bg-0 → bg-1 → bg-2 → bg-3), not shadows. `--shadow-sm: 0 0 #0000`. + +- All borders: `1px solid` in warm muted tones +- Corner radii: cards/surfaces `8px`, inputs/buttons `5px`, logo mark `6px`, avatars `5px`, dots `50%` +- Connected metric cells use shared border container with `border-left: 1px solid` between cells (no gap/grid trick) — creates the industrial panel look +- Tables wrapped in `border-radius: 8px` container with overflow hidden + +--- + +### Components + +**Sidebar navigation:** +- Active items use `3px left-border` in sage solid (`#5e8c58`) with accent glow background (`rgba(94,140,88,0.07)`) +- Active text color: accent-bright (`#a4c89f`) +- Icons at `16px`, opacity 0.5 default, 1.0 on active +- Group labels: `11px` uppercase with `0.06em` tracking, muted color + +**Status chip (live indicator):** +- Rounded `8px` border, `bg-2` background, `border-mid` border +- Pulsing dot: `7px`, accent-solid fill, `box-shadow: 0 0 8px rgba(94,140,88,0.5)` with glow animation +- Count in mono at `14px` accent-bright, label in secondary text + +**Live badges (inline):** +- `10px` text, uppercase, `3px` border-radius +- Background: accent-glow-mid (`rgba(94,140,88,0.14)`), text: accent mid +- Includes `5px` pulsing dot with box-shadow + +**Metric strip:** +- 3-column grid, connected cells (single outer border, inner dividers) +- Hover: background steps from bg-2 to bg-3 +- Value: `36px` serif, bright text +- Label: `11px` uppercase, tertiary +- Sub-metadata row with `1px` divider between items + +**Chart cards:** +- `8px` border-radius, bg-2 background, default border +- Header: section label (12px uppercase) + large serif metric + live badge +- Range group: segmented buttons with `1px` borders, mono text, active state uses bg-5 +- Chart area: SVG with `0.5px` grid lines in border color, `10px` mono axis labels in muted +- Data line: `1.5px` accent-solid stroke, `stroke-linejoin: round` +- Area fill: gradient from `rgba(94,140,88,0.28)` → transparent +- Data dot: accent-bright fill, `2.5px` bg-2 stroke, `4px` radius + +**Buttons hierarchy:** +1. Ghost (icon-btn): transparent bg, default border, tertiary color → border-mid + secondary on hover +2. Outline: no bg, border-mid border → accent-solid border + primary text on hover +3. Tool: bg-2 background, default border → border-mid + primary on hover +4. Filled/CTA: accent-solid background, white text → lighter green on hover, subtle `translateY(-1px)` lift + +**Tables:** +- Container: `8px` border-radius, border, overflow hidden +- Header: bg-3 background, `11px` uppercase muted text +- Body: default bg, `1px` border-bottom between rows +- Row hover: bg-3 + +**Empty states:** +- Centered, `72px` vertical padding +- Icon container: `56px` square, bg-3, border-mid border, `8px` radius +- Heading: `20px` serif, bright text +- Description: `13px` body, tertiary text +- CTA button below + +**Inputs:** +- bg-2 background, default border, `5px` radius +- Mono font for search/filter inputs +- Focus: `border-color: accent-solid` (clean single ring, no double-ring) +- Placeholder: muted color + +**Focus rings:** Single accent-solid border-color change on focus. Clean and minimal — no double-ring outlines. + +--- + +### Animation + +- **All interactive transitions:** `150ms ease` +- **Page load / section entrance:** `fadeUp` — `opacity: 0, translateY(6px)` → visible, `0.35s ease`, staggered with `60–80ms` delays between elements +- **Chart data animation:** SVG `` on path `d`, polyline `points`, and circle `cy` — `0.5–0.6s` duration, `0.2–0.35s` begin delay, `fill: freeze` +- **Live status dot:** `glow` keyframe — `2.5s ease infinite` box-shadow bloom from `0 0 6px rgba(94,140,88,0.5)` → `0 0 14px rgba(94,140,88,0.2)` +- **CTA buttons:** subtle `translateY(-1px)` on hover for lift feel + +--- + +### Dark Mode + +Primary and default mode. Very dark near-black-green backgrounds (`#0a0c0b` base) with warm off-white text and desaturated sage accent. Completely flat — no card shadows anywhere. System preference detection + localStorage persistence. + +--- + +### Overall Feel + +Sharp, warm, industrial-confident. Avoids cold grays entirely — palette leans slightly warm/brown-tinted throughout. The serif display type provides organic character and warmth on titles and metrics, while Manrope handles readable UI text and JetBrains Mono anchors the data-forward, developer-tool identity. Connected metric panels, tight chart cards, and uppercase section labels create engineering density without sacrificing readability. The result is a tool that feels crafted and precise — designed by someone who uses developer tools daily. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ca87729 --- /dev/null +++ b/LICENSE @@ -0,0 +1,97 @@ +Wrenn Sandbox License + +Business Source License 1.1 + +Copyright (c) 2026 M/S Omukk, Bangladesh + +--- + +Licensor + +M/S Omukk, Bangladesh + +Contact: [contact@omukk.dev](mailto:contact@omukk.dev) + +--- + +Licensed Work + +The Licensed Work is the software project known as "Wrenn Sandbox", including all source code and associated files in this repository, except the directory `envd/`, which is licensed separately under the Apache License Version 2.0. + +Initial development of the Licensed Work began in March 2026. + +--- + +Change Date + +January 1, 2030 + +--- + +Change License + +On the Change Date, the Licensed Work will automatically become available under the terms of the GNU General Public License, Version 3 (GPL-3.0). + +--- + +Additional Use Grant (SaaS Restriction) + +The Licensor grants you the right to copy, modify, create derivative works, redistribute, and make non-production use of the Licensed Work, provided that you comply with the limitations of this License. + +You may: + +* Use the software for personal use +* Use the software internally within your organization +* Modify the source code +* Experiment, test, and evaluate the software +* Distribute unmodified copies of the source code for evaluation + +You may not: + +Provide the Licensed Work to third parties as a managed service, hosted service, software-as-a-service (SaaS), platform service, or any similar commercial offering where the primary value of the service derives from the Licensed Work. + +You may not sell the Licensed Work or offer paid services primarily based on the Licensed Work without a commercial license from M/S Omukk. + +Commercial licenses may be obtained by contacting: + +[contact@omukk.dev](mailto:contact@omukk.dev) + +--- + +Contributions + +Unless otherwise stated, any Contribution intentionally submitted for inclusion in the Licensed Work shall be licensed under the terms of this Business Source License 1.1. + +--- + +Business Source License Terms + +Use of the Licensed Work is governed by the Business Source License included in this file. + +The Business Source License is not an Open Source license. However, the Licensed Work will automatically become available under the Change License on the Change Date. + +Licensor grants you a non-exclusive, worldwide, royalty-free license to use, copy, modify, create derivative works, redistribute, and make non-production use of the Licensed Work, provided that you comply with the limitations stated in this License. + +All copies of the Licensed Work must include this License file. + +Any use of the Licensed Work in violation of this License will automatically terminate your rights under this License. + +--- + +Disclaimer of Warranty + +THE LICENSED WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. + +--- + +Limitation of Liability + +IN NO EVENT SHALL THE LICENSOR OR CONTRIBUTORS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM THE USE OF THE LICENSED WORK. + +--- + +Third-Party Components + +Portions of this project include software licensed under separate open-source licenses. + +See the NOTICE file and THIRD_PARTY_LICENSES directory for details. diff --git a/Makefile b/Makefile index ce54bdb..4a2e0b6 100644 --- a/Makefile +++ b/Makefile @@ -9,10 +9,13 @@ LDFLAGS := -s -w # ═══════════════════════════════════════════════════ # Build # ═══════════════════════════════════════════════════ -.PHONY: build build-cp build-agent build-envd +.PHONY: build build-cp build-agent build-envd build-frontend build: build-cp build-agent build-envd +build-frontend: + cd frontend && pnpm install --frozen-lockfile && pnpm build + build-cp: go build -v -ldflags="$(LDFLAGS)" -o $(GOBIN)/wrenn-cp ./cmd/control-plane @@ -21,17 +24,17 @@ build-agent: build-envd: cd $(ENVD_DIR) && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ - go build -ldflags="$(LDFLAGS)" -o ../$(GOBIN)/envd . + go build -ldflags="$(LDFLAGS)" -o $(GOBIN)/envd . @file $(GOBIN)/envd | grep -q "statically linked" || \ (echo "ERROR: envd is not statically linked!" && exit 1) # ═══════════════════════════════════════════════════ # Development # ═══════════════════════════════════════════════════ -.PHONY: dev dev-cp dev-agent dev-envd dev-infra dev-down dev-seed +.PHONY: dev dev-cp dev-agent dev-envd dev-frontend dev-infra dev-down ## One command to start everything for local dev -dev: dev-infra migrate-up dev-seed dev-cp +dev: dev-infra migrate-up dev-cp dev-infra: docker compose -f deploy/docker-compose.dev.yml up -d @@ -49,11 +52,12 @@ dev-cp: dev-agent: sudo go run ./cmd/host-agent +dev-frontend: + cd frontend && pnpm dev --port 5173 + dev-envd: cd $(ENVD_DIR) && go run . --debug --listen-tcp :3002 -dev-seed: - go run ./scripts/seed.go # ═══════════════════════════════════════════════════ # Database (goose) @@ -84,16 +88,12 @@ migrate-reset: generate: proto sqlc proto: - protoc --go_out=. --go_opt=paths=source_relative \ - --go-grpc_out=. --go-grpc_opt=paths=source_relative \ - proto/hostagent/hostagent.proto - protoc --go_out=. --go_opt=paths=source_relative \ - --go-grpc_out=. --go-grpc_opt=paths=source_relative \ - proto/envd/process.proto proto/envd/filesystem.proto + cd proto/envd && buf generate + cd proto/hostagent && buf generate + cd $(ENVD_DIR)/spec && buf generate sqlc: - @if command -v sqlc > /dev/null; then sqlc generate; \ - else echo "sqlc not installed, skipping"; fi + sqlc generate # ═══════════════════════════════════════════════════ # Quality & Testing @@ -177,10 +177,12 @@ help: @echo " make dev-infra Start PostgreSQL + Prometheus + Grafana" @echo " make dev-down Stop dev infra" @echo " make dev-cp Control plane (hot reload if air installed)" + @echo " make dev-frontend Vite dev server with HMR (port 5173)" @echo " make dev-agent Host agent (sudo required)" @echo " make dev-envd envd in TCP debug mode" @echo "" @echo " make build Build all binaries → builds/" + @echo " make build-frontend Build SvelteKit dashboard → frontend/build/" @echo " make build-envd Build envd static binary" @echo "" @echo " make migrate-up Apply migrations" diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..c6c4e38 --- /dev/null +++ b/NOTICE @@ -0,0 +1,19 @@ +Wrenn Sandbox +Copyright (c) 2026 M/S Omukk, Bangladesh + +This project includes software derived from the following project: + +Project: e2b infra +Repository: https://github.com/e2b-dev/infra + +The following files and directories in this repository contain code derived from the above project and are licensed under the Apache License Version 2.0: + +- envd/ +- proto/envd/*.proto +- internal/snapshot/ +- internal/uffd/ + +Modifications to this code were made by M/S Omukk. + +Copyright (c) 2023 FoundryLabs, Inc. +Modifications Copyright (c) 2026 M/S Omukk, Bangladesh diff --git a/README.md b/README.md index 28ae648..dff1932 100644 --- a/README.md +++ b/README.md @@ -2,211 +2,128 @@ MicroVM-based code execution platform. Firecracker VMs, not containers. Pool-based pricing, persistent sandboxes, Python/TS/Go SDKs. -## Stack +## Deployment -| Component | Tech | -|---|---| -| Control plane | Go, chi, pgx, goose, htmx | -| Host agent | Go, Firecracker Go SDK, vsock | -| Guest agent (envd) | Go (extracted from E2B, standalone binary) | -| Database | PostgreSQL | -| Cache | Redis | -| Billing | Lago (external) | -| Snapshot storage | S3 (Seaweedfs for dev) | -| Monitoring | Prometheus + Grafana | -| Admin UI | htmx + Go html/template | +### Prerequisites -## Architecture +- Linux host with `/dev/kvm` access (bare metal or nested virt) +- Firecracker binary at `/usr/local/bin/firecracker` +- PostgreSQL +- Go 1.25+ -``` -SDK → HTTPS → Control Plane → gRPC → Host Agent → vsock → envd (inside VM) - │ │ - ├── PostgreSQL ├── Firecracker - ├── Redis ├── TAP/NAT networking - └── Lago (billing) ├── CoW rootfs clones - └── Prometheus /metrics -``` - -Control plane is stateless (state in Postgres + Redis). Host agent is stateful (manages VMs on the local machine). envd is a static binary baked into rootfs images — separate Go module, separate build, never imported by anything. - -## Prerequisites - -- Linux with `/dev/kvm` (bare metal or nested virt) -- Go 1.22+ -- Docker (for dev infra) -- Firecracker + jailer installed at `/usr/local/bin/` -- `protoc` + Go plugins for proto generation +### Build ```bash -# Firecracker -ARCH=$(uname -m) VERSION="v1.6.0" -curl -L "https://github.com/firecracker-microvm/firecracker/releases/download/${VERSION}/firecracker-${VERSION}-${ARCH}.tgz" | tar xz -sudo mv release-*/firecracker-* /usr/local/bin/firecracker -sudo mv release-*/jailer-* /usr/local/bin/jailer - -# Go tools -go install github.com/pressly/goose/v3/cmd/goose@latest -go install google.golang.org/protobuf/cmd/protoc-gen-go@latest -go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest -go install github.com/air-verse/air@latest -go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest - -# KVM -ls /dev/kvm && sudo setfacl -m u:${USER}:rw /dev/kvm +make build # outputs to builds/ ``` -## Quick Start +Produces three binaries: `wrenn-cp` (control plane), `wrenn-agent` (host agent), `envd` (guest agent). + +### Host setup + +The host agent machine needs: ```bash -cp .env.example .env -make tidy -make dev-infra # Postgres, Redis, Prometheus, Grafana +# Kernel for guest VMs +mkdir -p /var/lib/wrenn/kernels +# Place a vmlinux kernel at /var/lib/wrenn/kernels/vmlinux + +# Rootfs images +mkdir -p /var/lib/wrenn/images +# Build or place .ext4 rootfs images (e.g., minimal.ext4) + +# Sandbox working directory +mkdir -p /var/lib/wrenn/sandboxes + +# Snapshots directory +mkdir -p /var/lib/wrenn/snapshots + +# Enable IP forwarding +sysctl -w net.ipv4.ip_forward=1 +``` + +### Configure + +Copy `.env.example` to `.env` and edit: + +```bash +# Required +DATABASE_URL=postgres://wrenn:wrenn@localhost:5432/wrenn?sslmode=disable + +# Control plane +CP_LISTEN_ADDR=:8000 +CP_HOST_AGENT_ADDR=http://localhost:50051 + +# Host agent +AGENT_LISTEN_ADDR=:50051 +AGENT_FILES_ROOTDIR=/var/lib/wrenn +``` + +### Run + +```bash +# Apply database migrations make migrate-up -make dev-seed -# Terminal 1 -make dev-cp # :8000 - -# Terminal 2 -make dev-agent # :50051 (sudo) +# Start control plane +./builds/wrenn-cp ``` -- API: `http://localhost:8000/v1/sandboxes` -- Admin: `http://localhost:8000/admin/` -- Grafana: `http://localhost:3001` (admin/admin) -- Prometheus: `http://localhost:9090` +Control plane listens on `CP_LISTEN_ADDR` (default `:8000`). -## Layout +### Host registration -``` -cmd/ - control-plane/ REST API + admin UI + gRPC client + lifecycle manager - host-agent/ gRPC server + Firecracker + networking + metrics +Hosts must be registered with the control plane before they can serve sandboxes. -envd/ standalone Go module — separate go.mod, static binary - extracted from e2b-dev/infra, talks gRPC over vsock +1. **Create a host record** (via API or dashboard): + ```bash + # As an admin (JWT auth) + curl -X POST http://localhost:8000/v1/hosts \ + -H "Authorization: Bearer $JWT_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"type": "regular"}' + ``` + This returns a `registration_token` (valid for 1 hour). -proto/ - hostagent/ control plane ↔ host agent - envd/ host agent ↔ guest agent (from E2B spec/) +2. **Start the host agent** with the registration token and its externally-reachable address: + ```bash + sudo AGENT_CP_URL=http://cp-host:8000 \ + ./builds/wrenn-agent \ + --register \ + --address 10.0.1.5:50051 + ``` + On first startup the agent sends its specs (arch, CPU, memory, disk) to the control plane, receives a long-lived host JWT, and saves it to `$AGENT_FILES_ROOTDIR/host-token`. -internal/ - api/ chi handlers - admin/ htmx + Go templates - auth/ API key + rate limiting - scheduler/ SingleHost → LeastLoaded - lifecycle/ auto-pause, auto-hibernate, auto-destroy - vm/ Firecracker config, boot, stop, jailer - network/ TAP, NAT, IP allocator (/30 subnets) - filesystem/ base images, CoW clones (cp --reflink) - envdclient/ vsock dialer + gRPC client to envd - snapshot/ pause/resume + S3 offload - metrics/ cgroup stats + Prometheus exporter - models/ Sandbox, Host structs - config/ env + YAML loading - id/ sb-xxxxxxxx generation +3. **Subsequent startups** don't need `--register` — the agent loads the saved JWT automatically: + ```bash + sudo AGENT_CP_URL=http://cp-host:8000 \ + ./builds/wrenn-agent --address 10.0.1.5:50051 + ``` -db/migrations/ goose SQL (00001_initial.sql, ...) -db/queries/ raw SQL or sqlc +4. **If registration fails** (e.g., network error after token was consumed), regenerate a token: + ```bash + curl -X POST http://localhost:8000/v1/hosts/$HOST_ID/token \ + -H "Authorization: Bearer $JWT_TOKEN" + ``` + Then restart the agent with the new token. -images/templates/ rootfs build scripts (minimal, python311, node20) -sdk/ Python, TypeScript, Go client SDKs -deploy/ systemd units, ansible, docker-compose.dev.yml -``` +The agent sends heartbeats to the control plane every 30 seconds. Host agent listens on `AGENT_LISTEN_ADDR` (default `:50051`). -## Commands +### Rootfs images + +envd must be baked into every rootfs image. After building: ```bash -# Dev -make dev # everything: infra + migrate + seed + control plane -make dev-infra # just Postgres/Redis/Prometheus/Grafana -make dev-down # tear down -make dev-cp # control plane (hot reload with air) -make dev-agent # host agent (sudo) -make dev-envd # envd in TCP debug mode (no Firecracker) -make dev-seed # test API key + data - -# Build -make build # all → bin/ -make build-envd # static binary, verified - -# DB -make migrate-up -make migrate-down -make migrate-create name=xxx -make migrate-reset # drop + re-apply - -# Codegen -make generate # proto + sqlc -make proto - -# Quality -make check # fmt + vet + lint + test -make test # unit -make test-all # unit + integration -make tidy # go mod tidy (both modules) - -# Images -make images # all rootfs (needs sudo + envd) - -# Deploy -make setup-host # one-time KVM/networking setup -make install # binaries + systemd +make build-envd +bash scripts/update-debug-rootfs.sh /var/lib/wrenn/images/minimal.ext4 ``` -## Database +## Development -Postgres via pgx. No ORM. Migrations via goose (plain SQL). - -Tables: `sandboxes`, `hosts`, `audit_events`, `api_keys`. - -States: `pending → starting → running → paused → hibernated → stopped`. Any → `error`. - -## envd - -From [e2b-dev/infra](https://github.com/e2b-dev/infra) (Apache 2.0). PID 1 inside every VM. Exposes ProcessService + FilesystemService over gRPC on vsock. - -Own `go.mod`. Must be `CGO_ENABLED=0`. Baked into rootfs at `/usr/local/bin/envd`. Kernel args: `init=/usr/local/bin/envd`. - -Host agent connects via Firecracker vsock UDS using `CONNECT \n` handshake. - -## Networking - -Each sandbox: `/30` from `10.0.0.0/16` (~16K per host). - -``` -Host: tap-sb-a1b2c3d4 (10.0.0.1/30) ↔ Guest eth0 (10.0.0.2/30) -NAT: iptables MASQUERADE via host internet interface +```bash +make dev # Start PostgreSQL (Docker), run migrations, start control plane +make dev-agent # Start host agent (separate terminal, sudo) +make check # fmt + vet + lint + test ``` -## Snapshots - -- **Warm pause**: Firecracker snapshot on local NVMe. Resume <1s. -- **Cold hibernate**: zstd compressed, uploaded to S3/MinIO. Resume 5-10s. - -## API - -``` -POST /v1/sandboxes create -GET /v1/sandboxes list -GET /v1/sandboxes/{id} status -POST /v1/sandboxes/{id}/exec exec -PUT /v1/sandboxes/{id}/files upload -GET /v1/sandboxes/{id}/files/* download -POST /v1/sandboxes/{id}/pause pause -POST /v1/sandboxes/{id}/resume resume -DELETE /v1/sandboxes/{id} destroy -WS /v1/sandboxes/{id}/terminal shell -``` - -Auth: `X-API-Key` header. Prefix: `wrn_`. - -## Phases - -1. Boot VM + exec via vsock (W1) -2. Host agent + networking (W2) -3. Control plane + DB + REST (W3) -4. Admin UI / htmx (W4) -5. Pause / hibernate / resume (W5) -6. SDKs (W6) -7. Jailer, cgroups, egress, metrics (W7-8) \ No newline at end of file +See `CLAUDE.md` for full architecture documentation. diff --git a/cmd/control-plane/main.go b/cmd/control-plane/main.go index e69de29..3f52b41 100644 --- a/cmd/control-plane/main.go +++ b/cmd/control-plane/main.go @@ -0,0 +1,124 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" + + "git.omukk.dev/wrenn/sandbox/internal/api" + "git.omukk.dev/wrenn/sandbox/internal/auth/oauth" + "git.omukk.dev/wrenn/sandbox/internal/config" + "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" +) + +func main() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }))) + + cfg := config.Load() + + if len(cfg.JWTSecret) < 32 { + slog.Error("JWT_SECRET must be at least 32 characters") + os.Exit(1) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Database connection pool. + pool, err := pgxpool.New(ctx, cfg.DatabaseURL) + if err != nil { + slog.Error("failed to connect to database", "error", err) + os.Exit(1) + } + defer pool.Close() + + if err := pool.Ping(ctx); err != nil { + slog.Error("failed to ping database", "error", err) + os.Exit(1) + } + slog.Info("connected to database") + + queries := db.New(pool) + + // Redis client. + redisOpts, err := redis.ParseURL(cfg.RedisURL) + if err != nil { + slog.Error("failed to parse REDIS_URL", "error", err) + os.Exit(1) + } + rdb := redis.NewClient(redisOpts) + defer rdb.Close() + + if err := rdb.Ping(ctx).Err(); err != nil { + slog.Error("failed to ping redis", "error", err) + os.Exit(1) + } + slog.Info("connected to redis") + + // Connect RPC client for the host agent. + agentHTTP := &http.Client{Timeout: 10 * time.Minute} + agentClient := hostagentv1connect.NewHostAgentServiceClient( + agentHTTP, + cfg.HostAgentAddr, + ) + + // OAuth provider registry. + oauthRegistry := oauth.NewRegistry() + if cfg.OAuthGitHubClientID != "" && cfg.OAuthGitHubClientSecret != "" { + if cfg.CPPublicURL == "" { + slog.Error("CP_PUBLIC_URL must be set when OAuth providers are configured") + os.Exit(1) + } + callbackURL := strings.TrimRight(cfg.CPPublicURL, "/") + "/auth/oauth/github/callback" + ghProvider := oauth.NewGitHubProvider(cfg.OAuthGitHubClientID, cfg.OAuthGitHubClientSecret, callbackURL) + oauthRegistry.Register(ghProvider) + slog.Info("registered OAuth provider", "provider", "github") + } + + // API server. + srv := api.New(queries, agentClient, pool, rdb, []byte(cfg.JWTSecret), oauthRegistry, cfg.OAuthRedirectURL) + + // Start reconciler. + reconciler := api.NewReconciler(queries, agentClient, "default", 5*time.Second) + reconciler.Start(ctx) + + httpServer := &http.Server{ + Addr: cfg.ListenAddr, + Handler: srv.Handler(), + } + + // Graceful shutdown on signal. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-sigCh + slog.Info("received signal, shutting down", "signal", sig) + cancel() + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer shutdownCancel() + + if err := httpServer.Shutdown(shutdownCtx); err != nil { + slog.Error("http server shutdown error", "error", err) + } + }() + + slog.Info("control plane starting", "addr", cfg.ListenAddr, "agent", cfg.HostAgentAddr) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("http server error", "error", err) + os.Exit(1) + } + + slog.Info("control plane stopped") +} diff --git a/cmd/host-agent/main.go b/cmd/host-agent/main.go index e69de29..c426a81 100644 --- a/cmd/host-agent/main.go +++ b/cmd/host-agent/main.go @@ -0,0 +1,132 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "git.omukk.dev/wrenn/sandbox/internal/devicemapper" + "git.omukk.dev/wrenn/sandbox/internal/hostagent" + "git.omukk.dev/wrenn/sandbox/internal/sandbox" + "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" +) + +func main() { + registrationToken := flag.String("register", "", "One-time registration token from the control plane") + advertiseAddr := flag.String("address", "", "Externally-reachable address (ip:port) for this host agent") + flag.Parse() + + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }))) + + if os.Geteuid() != 0 { + slog.Error("host agent must run as root") + os.Exit(1) + } + + // Enable IP forwarding (required for NAT). + if err := os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1"), 0644); err != nil { + slog.Warn("failed to enable ip_forward", "error", err) + } + + // Clean up any stale dm-snapshot devices from a previous crash. + devicemapper.CleanupStaleDevices() + + listenAddr := envOrDefault("AGENT_LISTEN_ADDR", ":50051") + rootDir := envOrDefault("AGENT_FILES_ROOTDIR", "/var/lib/wrenn") + cpURL := os.Getenv("AGENT_CP_URL") + tokenFile := filepath.Join(rootDir, "host-token") + + cfg := sandbox.Config{ + KernelPath: filepath.Join(rootDir, "kernels", "vmlinux"), + ImagesDir: filepath.Join(rootDir, "images"), + SandboxesDir: filepath.Join(rootDir, "sandboxes"), + SnapshotsDir: filepath.Join(rootDir, "snapshots"), + } + + mgr := sandbox.New(cfg) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + mgr.StartTTLReaper(ctx) + + if *advertiseAddr == "" { + slog.Error("--address flag is required (externally-reachable ip:port)") + os.Exit(1) + } + + // Register with the control plane (if configured). + if cpURL != "" { + hostToken, err := hostagent.Register(ctx, hostagent.RegistrationConfig{ + CPURL: cpURL, + RegistrationToken: *registrationToken, + TokenFile: tokenFile, + Address: *advertiseAddr, + }) + if err != nil { + slog.Error("host registration failed", "error", err) + os.Exit(1) + } + + hostID, err := hostagent.HostIDFromToken(hostToken) + if err != nil { + slog.Error("failed to extract host ID from token", "error", err) + os.Exit(1) + } + + slog.Info("host registered", "host_id", hostID) + hostagent.StartHeartbeat(ctx, cpURL, hostID, hostToken, 30*time.Second) + } + + srv := hostagent.NewServer(mgr) + path, handler := hostagentv1connect.NewHostAgentServiceHandler(srv) + + mux := http.NewServeMux() + mux.Handle(path, handler) + + httpServer := &http.Server{ + Addr: listenAddr, + Handler: mux, + } + + // Graceful shutdown on signal. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-sigCh + slog.Info("received signal, shutting down", "signal", sig) + cancel() + + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer shutdownCancel() + + mgr.Shutdown(shutdownCtx) + + if err := httpServer.Shutdown(shutdownCtx); err != nil { + slog.Error("http server shutdown error", "error", err) + } + }() + + slog.Info("host agent starting", "addr", listenAddr) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("http server error", "error", err) + os.Exit(1) + } + + slog.Info("host agent stopped") +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/db/migrations/20260310094104_initial.sql b/db/migrations/20260310094104_initial.sql new file mode 100644 index 0000000..c291815 --- /dev/null +++ b/db/migrations/20260310094104_initial.sql @@ -0,0 +1,25 @@ +-- +goose Up + +CREATE TABLE sandboxes ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL DEFAULT '', + host_id TEXT NOT NULL DEFAULT 'default', + template TEXT NOT NULL DEFAULT 'minimal', + status TEXT NOT NULL DEFAULT 'pending', + vcpus INTEGER NOT NULL DEFAULT 1, + memory_mb INTEGER NOT NULL DEFAULT 512, + timeout_sec INTEGER NOT NULL DEFAULT 0, + guest_ip TEXT NOT NULL DEFAULT '', + host_ip TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + started_at TIMESTAMPTZ, + last_active_at TIMESTAMPTZ, + last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_sandboxes_status ON sandboxes(status); +CREATE INDEX idx_sandboxes_host_status ON sandboxes(host_id, status); + +-- +goose Down + +DROP TABLE sandboxes; diff --git a/db/migrations/20260311224925_snapshots.sql b/db/migrations/20260311224925_snapshots.sql new file mode 100644 index 0000000..8a0427c --- /dev/null +++ b/db/migrations/20260311224925_snapshots.sql @@ -0,0 +1,14 @@ +-- +goose Up + +CREATE TABLE templates ( + name TEXT PRIMARY KEY, + type TEXT NOT NULL DEFAULT 'base', -- 'base' or 'snapshot' + vcpus INTEGER, + memory_mb INTEGER, + size_bytes BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- +goose Down + +DROP TABLE templates; diff --git a/db/migrations/20260313210608_auth.sql b/db/migrations/20260313210608_auth.sql new file mode 100644 index 0000000..03970a8 --- /dev/null +++ b/db/migrations/20260313210608_auth.sql @@ -0,0 +1,46 @@ +-- +goose Up + +CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE teams ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE users_teams ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + is_default BOOLEAN NOT NULL DEFAULT TRUE, + role TEXT NOT NULL DEFAULT 'owner', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (team_id, user_id) +); + +CREATE INDEX idx_users_teams_user ON users_teams(user_id); + +CREATE TABLE team_api_keys ( + id TEXT PRIMARY KEY, + team_id TEXT NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT '', + key_hash TEXT NOT NULL UNIQUE, + key_prefix TEXT NOT NULL DEFAULT '', + created_by TEXT NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used TIMESTAMPTZ +); + +CREATE INDEX idx_team_api_keys_team ON team_api_keys(team_id); + +-- +goose Down + +DROP TABLE team_api_keys; +DROP TABLE users_teams; +DROP TABLE teams; +DROP TABLE users; diff --git a/db/migrations/20260313210611_team_ownership.sql b/db/migrations/20260313210611_team_ownership.sql new file mode 100644 index 0000000..849e781 --- /dev/null +++ b/db/migrations/20260313210611_team_ownership.sql @@ -0,0 +1,31 @@ +-- +goose Up + +ALTER TABLE sandboxes + ADD COLUMN team_id TEXT NOT NULL DEFAULT ''; + +UPDATE sandboxes SET team_id = owner_id WHERE owner_id != ''; + +ALTER TABLE sandboxes + DROP COLUMN owner_id; + +ALTER TABLE templates + ADD COLUMN team_id TEXT NOT NULL DEFAULT ''; + +CREATE INDEX idx_sandboxes_team ON sandboxes(team_id); +CREATE INDEX idx_templates_team ON templates(team_id); + +-- +goose Down + +ALTER TABLE sandboxes + ADD COLUMN owner_id TEXT NOT NULL DEFAULT ''; + +UPDATE sandboxes SET owner_id = team_id WHERE team_id != ''; + +ALTER TABLE sandboxes + DROP COLUMN team_id; + +ALTER TABLE templates + DROP COLUMN team_id; + +DROP INDEX IF EXISTS idx_sandboxes_team; +DROP INDEX IF EXISTS idx_templates_team; diff --git a/db/migrations/20260315001514_oauth.sql b/db/migrations/20260315001514_oauth.sql new file mode 100644 index 0000000..c3c33e9 --- /dev/null +++ b/db/migrations/20260315001514_oauth.sql @@ -0,0 +1,22 @@ +-- +goose Up + +ALTER TABLE users + ALTER COLUMN password_hash DROP NOT NULL; + +CREATE TABLE oauth_providers ( + provider TEXT NOT NULL, + provider_id TEXT NOT NULL, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + email TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (provider, provider_id) +); + +CREATE INDEX idx_oauth_providers_user ON oauth_providers(user_id); + +-- +goose Down + +DROP TABLE oauth_providers; + +UPDATE users SET password_hash = '' WHERE password_hash IS NULL; +ALTER TABLE users ALTER COLUMN password_hash SET NOT NULL; diff --git a/db/migrations/20260316203135_admin_users.sql b/db/migrations/20260316203135_admin_users.sql new file mode 100644 index 0000000..eff669b --- /dev/null +++ b/db/migrations/20260316203135_admin_users.sql @@ -0,0 +1,21 @@ +-- +goose Up + +ALTER TABLE users + ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE TABLE admin_permissions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + permission TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, permission) +); + +CREATE INDEX idx_admin_permissions_user ON admin_permissions(user_id); + +-- +goose Down + +DROP TABLE admin_permissions; + +ALTER TABLE users + DROP COLUMN is_admin; diff --git a/db/migrations/20260316203138_byoc_teams.sql b/db/migrations/20260316203138_byoc_teams.sql new file mode 100644 index 0000000..bb2c8ec --- /dev/null +++ b/db/migrations/20260316203138_byoc_teams.sql @@ -0,0 +1,9 @@ +-- +goose Up + +ALTER TABLE teams + ADD COLUMN is_byoc BOOLEAN NOT NULL DEFAULT FALSE; + +-- +goose Down + +ALTER TABLE teams + DROP COLUMN is_byoc; diff --git a/db/migrations/20260316203142_hosts.sql b/db/migrations/20260316203142_hosts.sql new file mode 100644 index 0000000..372b380 --- /dev/null +++ b/db/migrations/20260316203142_hosts.sql @@ -0,0 +1,47 @@ +-- +goose Up + +CREATE TABLE hosts ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL DEFAULT 'regular', -- 'regular' or 'byoc' + team_id TEXT REFERENCES teams(id) ON DELETE SET NULL, + provider TEXT, + availability_zone TEXT, + arch TEXT, + cpu_cores INTEGER, + memory_mb INTEGER, + disk_gb INTEGER, + address TEXT, -- ip:port of host agent + status TEXT NOT NULL DEFAULT 'pending', -- 'pending', 'online', 'offline', 'draining' + last_heartbeat_at TIMESTAMPTZ, + metadata JSONB NOT NULL DEFAULT '{}', + created_by TEXT NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE host_tokens ( + id TEXT PRIMARY KEY, + host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, + created_by TEXT NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ +); + +CREATE TABLE host_tags ( + host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (host_id, tag) +); + +CREATE INDEX idx_hosts_type ON hosts(type); +CREATE INDEX idx_hosts_team ON hosts(team_id); +CREATE INDEX idx_hosts_status ON hosts(status); +CREATE INDEX idx_host_tokens_host ON host_tokens(host_id); +CREATE INDEX idx_host_tags_tag ON host_tags(tag); + +-- +goose Down + +DROP TABLE host_tags; +DROP TABLE host_tokens; +DROP TABLE hosts; diff --git a/db/migrations/20260316223629_host_mtls.sql b/db/migrations/20260316223629_host_mtls.sql new file mode 100644 index 0000000..f56b923 --- /dev/null +++ b/db/migrations/20260316223629_host_mtls.sql @@ -0,0 +1,11 @@ +-- +goose Up + +ALTER TABLE hosts + ADD COLUMN cert_fingerprint TEXT, + ADD COLUMN mtls_enabled BOOLEAN NOT NULL DEFAULT FALSE; + +-- +goose Down + +ALTER TABLE hosts + DROP COLUMN cert_fingerprint, + DROP COLUMN mtls_enabled; diff --git a/db/queries/api_keys.sql b/db/queries/api_keys.sql new file mode 100644 index 0000000..7ea9645 --- /dev/null +++ b/db/queries/api_keys.sql @@ -0,0 +1,24 @@ +-- name: InsertAPIKey :one +INSERT INTO team_api_keys (id, team_id, name, key_hash, key_prefix, created_by) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING *; + +-- name: GetAPIKeyByHash :one +SELECT * FROM team_api_keys WHERE key_hash = $1; + +-- name: ListAPIKeysByTeam :many +SELECT * FROM team_api_keys WHERE team_id = $1 ORDER BY created_at DESC; + +-- name: ListAPIKeysByTeamWithCreator :many +SELECT k.id, k.team_id, k.name, k.key_hash, k.key_prefix, k.created_by, k.created_at, k.last_used, + u.email AS creator_email +FROM team_api_keys k +JOIN users u ON u.id = k.created_by +WHERE k.team_id = $1 +ORDER BY k.created_at DESC; + +-- name: DeleteAPIKey :exec +DELETE FROM team_api_keys WHERE id = $1 AND team_id = $2; + +-- name: UpdateAPIKeyLastUsed :exec +UPDATE team_api_keys SET last_used = NOW() WHERE id = $1; diff --git a/db/queries/hosts.sql b/db/queries/hosts.sql index e69de29..7f8c9e4 100644 --- a/db/queries/hosts.sql +++ b/db/queries/hosts.sql @@ -0,0 +1,69 @@ +-- name: InsertHost :one +INSERT INTO hosts (id, type, team_id, provider, availability_zone, created_by) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING *; + +-- name: GetHost :one +SELECT * FROM hosts WHERE id = $1; + +-- name: ListHosts :many +SELECT * FROM hosts ORDER BY created_at DESC; + +-- name: ListHostsByType :many +SELECT * FROM hosts WHERE type = $1 ORDER BY created_at DESC; + +-- name: ListHostsByTeam :many +SELECT * FROM hosts WHERE team_id = $1 AND type = 'byoc' ORDER BY created_at DESC; + +-- name: ListHostsByStatus :many +SELECT * FROM hosts WHERE status = $1 ORDER BY created_at DESC; + +-- name: RegisterHost :execrows +UPDATE hosts +SET arch = $2, + cpu_cores = $3, + memory_mb = $4, + disk_gb = $5, + address = $6, + status = 'online', + last_heartbeat_at = NOW(), + updated_at = NOW() +WHERE id = $1 AND status = 'pending'; + +-- name: UpdateHostStatus :exec +UPDATE hosts SET status = $2, updated_at = NOW() WHERE id = $1; + +-- name: UpdateHostHeartbeat :exec +UPDATE hosts SET last_heartbeat_at = NOW(), updated_at = NOW() WHERE id = $1; + +-- name: DeleteHost :exec +DELETE FROM hosts WHERE id = $1; + +-- name: AddHostTag :exec +INSERT INTO host_tags (host_id, tag) VALUES ($1, $2) ON CONFLICT DO NOTHING; + +-- name: RemoveHostTag :exec +DELETE FROM host_tags WHERE host_id = $1 AND tag = $2; + +-- name: GetHostTags :many +SELECT tag FROM host_tags WHERE host_id = $1 ORDER BY tag; + +-- name: ListHostsByTag :many +SELECT h.* FROM hosts h +JOIN host_tags ht ON ht.host_id = h.id +WHERE ht.tag = $1 +ORDER BY h.created_at DESC; + +-- name: InsertHostToken :one +INSERT INTO host_tokens (id, host_id, created_by, expires_at) +VALUES ($1, $2, $3, $4) +RETURNING *; + +-- name: MarkHostTokenUsed :exec +UPDATE host_tokens SET used_at = NOW() WHERE id = $1; + +-- name: GetHostTokensByHost :many +SELECT * FROM host_tokens WHERE host_id = $1 ORDER BY created_at DESC; + +-- name: GetHostByTeam :one +SELECT * FROM hosts WHERE id = $1 AND team_id = $2; diff --git a/db/queries/oauth.sql b/db/queries/oauth.sql new file mode 100644 index 0000000..31b1ff8 --- /dev/null +++ b/db/queries/oauth.sql @@ -0,0 +1,7 @@ +-- name: InsertOAuthProvider :exec +INSERT INTO oauth_providers (provider, provider_id, user_id, email) +VALUES ($1, $2, $3, $4); + +-- name: GetOAuthProvider :one +SELECT * FROM oauth_providers +WHERE provider = $1 AND provider_id = $2; diff --git a/db/queries/sandboxes.sql b/db/queries/sandboxes.sql index e69de29..f2a5d51 100644 --- a/db/queries/sandboxes.sql +++ b/db/queries/sandboxes.sql @@ -0,0 +1,53 @@ +-- name: InsertSandbox :one +INSERT INTO sandboxes (id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING *; + +-- name: GetSandbox :one +SELECT * FROM sandboxes WHERE id = $1; + +-- name: GetSandboxByTeam :one +SELECT * FROM sandboxes WHERE id = $1 AND team_id = $2; + +-- name: ListSandboxes :many +SELECT * FROM sandboxes ORDER BY created_at DESC; + +-- name: ListSandboxesByTeam :many +SELECT * FROM sandboxes +WHERE team_id = $1 AND status NOT IN ('stopped', 'error') +ORDER BY created_at DESC; + +-- name: ListSandboxesByHostAndStatus :many +SELECT * FROM sandboxes +WHERE host_id = $1 AND status = ANY($2::text[]) +ORDER BY created_at DESC; + +-- name: UpdateSandboxRunning :one +UPDATE sandboxes +SET status = 'running', + host_ip = $2, + guest_ip = $3, + started_at = $4, + last_active_at = $4, + last_updated = NOW() +WHERE id = $1 +RETURNING *; + +-- name: UpdateSandboxStatus :one +UPDATE sandboxes +SET status = $2, + last_updated = NOW() +WHERE id = $1 +RETURNING *; + +-- name: UpdateLastActive :exec +UPDATE sandboxes +SET last_active_at = $2, + last_updated = NOW() +WHERE id = $1; + +-- name: BulkUpdateStatusByIDs :exec +UPDATE sandboxes +SET status = $2, + last_updated = NOW() +WHERE id = ANY($1::text[]); diff --git a/db/queries/teams.sql b/db/queries/teams.sql new file mode 100644 index 0000000..58985ab --- /dev/null +++ b/db/queries/teams.sql @@ -0,0 +1,26 @@ +-- name: InsertTeam :one +INSERT INTO teams (id, name) +VALUES ($1, $2) +RETURNING *; + +-- name: GetTeam :one +SELECT * FROM teams WHERE id = $1; + +-- name: InsertTeamMember :exec +INSERT INTO users_teams (user_id, team_id, is_default, role) +VALUES ($1, $2, $3, $4); + +-- name: GetDefaultTeamForUser :one +SELECT t.* FROM teams t +JOIN users_teams ut ON ut.team_id = t.id +WHERE ut.user_id = $1 AND ut.is_default = TRUE +LIMIT 1; + +-- name: SetTeamBYOC :exec +UPDATE teams SET is_byoc = $2 WHERE id = $1; + +-- name: GetBYOCTeams :many +SELECT * FROM teams WHERE is_byoc = TRUE ORDER BY created_at; + +-- name: GetTeamMembership :one +SELECT * FROM users_teams WHERE user_id = $1 AND team_id = $2; diff --git a/db/queries/templates.sql b/db/queries/templates.sql new file mode 100644 index 0000000..b17abc3 --- /dev/null +++ b/db/queries/templates.sql @@ -0,0 +1,28 @@ +-- name: InsertTemplate :one +INSERT INTO templates (name, type, vcpus, memory_mb, size_bytes, team_id) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING *; + +-- name: GetTemplate :one +SELECT * FROM templates WHERE name = $1; + +-- name: GetTemplateByTeam :one +SELECT * FROM templates WHERE name = $1 AND team_id = $2; + +-- name: ListTemplates :many +SELECT * FROM templates ORDER BY created_at DESC; + +-- name: ListTemplatesByType :many +SELECT * FROM templates WHERE type = $1 ORDER BY created_at DESC; + +-- name: ListTemplatesByTeam :many +SELECT * FROM templates WHERE team_id = $1 ORDER BY created_at DESC; + +-- name: ListTemplatesByTeamAndType :many +SELECT * FROM templates WHERE team_id = $1 AND type = $2 ORDER BY created_at DESC; + +-- name: DeleteTemplate :exec +DELETE FROM templates WHERE name = $1; + +-- name: DeleteTemplateByTeam :exec +DELETE FROM templates WHERE name = $1 AND team_id = $2; diff --git a/db/queries/users.sql b/db/queries/users.sql new file mode 100644 index 0000000..3c2f4f0 --- /dev/null +++ b/db/queries/users.sql @@ -0,0 +1,36 @@ +-- name: InsertUser :one +INSERT INTO users (id, email, password_hash) +VALUES ($1, $2, $3) +RETURNING *; + +-- name: GetUserByEmail :one +SELECT * FROM users WHERE email = $1; + +-- name: GetUserByID :one +SELECT * FROM users WHERE id = $1; + +-- name: InsertUserOAuth :one +INSERT INTO users (id, email) +VALUES ($1, $2) +RETURNING *; + +-- name: SetUserAdmin :exec +UPDATE users SET is_admin = $2, updated_at = NOW() WHERE id = $1; + +-- name: GetAdminUsers :many +SELECT * FROM users WHERE is_admin = TRUE ORDER BY created_at; + +-- name: InsertAdminPermission :exec +INSERT INTO admin_permissions (id, user_id, permission) +VALUES ($1, $2, $3); + +-- name: DeleteAdminPermission :exec +DELETE FROM admin_permissions WHERE user_id = $1 AND permission = $2; + +-- name: GetAdminPermissions :many +SELECT * FROM admin_permissions WHERE user_id = $1 ORDER BY permission; + +-- name: HasAdminPermission :one +SELECT EXISTS( + SELECT 1 FROM admin_permissions WHERE user_id = $1 AND permission = $2 +) AS has_permission; diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 5a32c91..ebcd308 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -10,6 +10,11 @@ services: volumes: - pgdata:/var/lib/postgresql/data + redis: + image: redis:7-alpine + ports: + - "6379:6379" + prometheus: image: prom/prometheus:latest ports: diff --git a/envd/LICENSE b/envd/LICENSE new file mode 100644 index 0000000..ec47fef --- /dev/null +++ b/envd/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 FoundryLabs, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/envd/Makefile b/envd/Makefile index 7b861c3..b3af722 100644 --- a/envd/Makefile +++ b/envd/Makefile @@ -1,17 +1,62 @@ -LDFLAGS := -s -w +BUILD := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +LDFLAGS := -s -w -X=main.commitSHA=$(BUILD) +BUILDS := ../builds -.PHONY: build clean fmt vet +# ═══════════════════════════════════════════════════ +# Build +# ═══════════════════════════════════════════════════ +.PHONY: build build-debug build: - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o envd . - @file envd | grep -q "statically linked" || \ + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(BUILDS)/envd . + @file $(BUILDS)/envd | grep -q "statically linked" || \ (echo "ERROR: envd is not statically linked!" && exit 1) -clean: - rm -f envd +build-debug: + CGO_ENABLED=1 go build -race -gcflags=all="-N -l" -ldflags="-X=main.commitSHA=$(BUILD)" -o $(BUILDS)/debug/envd . + +# ═══════════════════════════════════════════════════ +# Run (debug mode, not inside a VM) +# ═══════════════════════════════════════════════════ +.PHONY: run-debug + +run-debug: build-debug + $(BUILDS)/debug/envd -isnotfc -port 49983 + +# ═══════════════════════════════════════════════════ +# Code Generation +# ═══════════════════════════════════════════════════ +.PHONY: generate proto openapi + +generate: proto openapi + +proto: + cd spec && buf generate --template buf.gen.yaml + +openapi: + go generate ./internal/api/... + +# ═══════════════════════════════════════════════════ +# Quality +# ═══════════════════════════════════════════════════ +.PHONY: fmt vet test tidy fmt: gofmt -w . vet: go vet ./... + +test: + go test -race -v ./... + +tidy: + go mod tidy + +# ═══════════════════════════════════════════════════ +# Clean +# ═══════════════════════════════════════════════════ +.PHONY: clean + +clean: + rm -f $(BUILDS)/envd $(BUILDS)/debug/envd diff --git a/envd/go.mod b/envd/go.mod index da104fe..be2c95a 100644 --- a/envd/go.mod +++ b/envd/go.mod @@ -1,9 +1,42 @@ -module github.com/wrenn-dev/envd +module git.omukk.dev/wrenn/sandbox/envd -go 1.23.0 +go 1.25.5 require ( - github.com/mdlayher/vsock v1.2.1 - google.golang.org/grpc v1.71.0 - google.golang.org/protobuf v1.36.5 + connectrpc.com/authn v0.1.0 + connectrpc.com/connect v1.19.1 + connectrpc.com/cors v0.1.0 + github.com/awnumar/memguard v0.23.0 + github.com/creack/pty v1.1.24 + github.com/dchest/uniuri v1.2.0 + github.com/e2b-dev/fsnotify v0.0.1 + github.com/go-chi/chi/v5 v5.2.5 + github.com/google/uuid v1.6.0 + github.com/oapi-codegen/runtime v1.2.0 + github.com/orcaman/concurrent-map/v2 v2.0.1 + github.com/rs/cors v1.11.1 + github.com/rs/zerolog v1.34.0 + github.com/shirou/gopsutil/v4 v4.26.2 + github.com/stretchr/testify v1.11.1 + github.com/txn2/txeh v1.8.0 + golang.org/x/sys v0.42.0 + google.golang.org/protobuf v1.36.11 +) + +require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/awnumar/memcall v0.4.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/crypto v0.41.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/envd/go.sum b/envd/go.sum new file mode 100644 index 0000000..a051cf5 --- /dev/null +++ b/envd/go.sum @@ -0,0 +1,92 @@ +connectrpc.com/authn v0.1.0 h1:m5weACjLWwgwcjttvUDyTPICJKw74+p2obBVrf8hT9E= +connectrpc.com/authn v0.1.0/go.mod h1:AwNZK/KYbqaJzRYadTuAaoz6sYQSPdORPqh1TOPIkgY= +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +connectrpc.com/cors v0.1.0 h1:f3gTXJyDZPrDIZCQ567jxfD9PAIpopHiRDnJRt3QuOQ= +connectrpc.com/cors v0.1.0/go.mod h1:v8SJZCPfHtGH1zsm+Ttajpozd4cYIUryl4dFB6QEpfg= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/awnumar/memcall v0.4.0 h1:B7hgZYdfH6Ot1Goaz8jGne/7i8xD4taZie/PNSFZ29g= +github.com/awnumar/memcall v0.4.0/go.mod h1:8xOx1YbfyuCg3Fy6TO8DK0kZUua3V42/goA5Ru47E8w= +github.com/awnumar/memguard v0.23.0 h1:sJ3a1/SWlcuKIQ7MV+R9p0Pvo9CWsMbGZvcZQtmc68A= +github.com/awnumar/memguard v0.23.0/go.mod h1:olVofBrsPdITtJ2HgxQKrEYEMyIBAIciVG4wNnZhW9M= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g= +github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY= +github.com/e2b-dev/fsnotify v0.0.1 h1:7j0I98HD6VehAuK/bcslvW4QDynAULtOuMZtImihjVk= +github.com/e2b-dev/fsnotify v0.0.1/go.mod h1:jAuDjregRrUixKneTRQwPI847nNuPFg3+n5QM/ku/JM= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/oapi-codegen/runtime v1.2.0 h1:RvKc1CVS1QeKSNzO97FBQbSMZyQ8s6rZd+LpmzwHMP4= +github.com/oapi-codegen/runtime v1.2.0/go.mod h1:Y7ZhmmlE8ikZOmuHRRndiIm7nf3xcVv+YMweKgG1DT0= +github.com/orcaman/concurrent-map/v2 v2.0.1 h1:jOJ5Pg2w1oeB6PeDurIYf6k9PQ+aTITr/6lP/L/zp6c= +github.com/orcaman/concurrent-map/v2 v2.0.1/go.mod h1:9Eq3TG2oBe5FirmYWQfYO5iH1q0Jv47PLaNK++uCdOM= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= +github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/txn2/txeh v1.8.0 h1:G1vZgom6+P/xWwU53AMOpcZgC5ni382ukcPP1TDVYHk= +github.com/txn2/txeh v1.8.0/go.mod h1:rRI3Egi3+AFmEXQjft051YdYbxeCT3nFmBLsNCZZaxM= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/envd/internal/api/api.gen.go b/envd/internal/api/api.gen.go new file mode 100644 index 0000000..512747b --- /dev/null +++ b/envd/internal/api/api.gen.go @@ -0,0 +1,568 @@ +// Package api provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.1 DO NOT EDIT. +package api + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/oapi-codegen/runtime" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +const ( + AccessTokenAuthScopes = "AccessTokenAuth.Scopes" +) + +// Defines values for EntryInfoType. +const ( + File EntryInfoType = "file" +) + +// EntryInfo defines model for EntryInfo. +type EntryInfo struct { + // Name Name of the file + Name string `json:"name"` + + // Path Path to the file + Path string `json:"path"` + + // Type Type of the file + Type EntryInfoType `json:"type"` +} + +// EntryInfoType Type of the file +type EntryInfoType string + +// EnvVars Environment variables to set +type EnvVars map[string]string + +// Error defines model for Error. +type Error struct { + // Code Error code + Code int `json:"code"` + + // Message Error message + Message string `json:"message"` +} + +// Metrics Resource usage metrics +type Metrics struct { + // CpuCount Number of CPU cores + CpuCount *int `json:"cpu_count,omitempty"` + + // CpuUsedPct CPU usage percentage + CpuUsedPct *float32 `json:"cpu_used_pct,omitempty"` + + // DiskTotal Total disk space in bytes + DiskTotal *int `json:"disk_total,omitempty"` + + // DiskUsed Used disk space in bytes + DiskUsed *int `json:"disk_used,omitempty"` + + // MemTotal Total virtual memory in bytes + MemTotal *int `json:"mem_total,omitempty"` + + // MemUsed Used virtual memory in bytes + MemUsed *int `json:"mem_used,omitempty"` + + // Ts Unix timestamp in UTC for current sandbox time + Ts *int64 `json:"ts,omitempty"` +} + +// VolumeMount Volume +type VolumeMount struct { + NfsTarget string `json:"nfs_target"` + Path string `json:"path"` +} + +// FilePath defines model for FilePath. +type FilePath = string + +// Signature defines model for Signature. +type Signature = string + +// SignatureExpiration defines model for SignatureExpiration. +type SignatureExpiration = int + +// User defines model for User. +type User = string + +// FileNotFound defines model for FileNotFound. +type FileNotFound = Error + +// InternalServerError defines model for InternalServerError. +type InternalServerError = Error + +// InvalidPath defines model for InvalidPath. +type InvalidPath = Error + +// InvalidUser defines model for InvalidUser. +type InvalidUser = Error + +// NotEnoughDiskSpace defines model for NotEnoughDiskSpace. +type NotEnoughDiskSpace = Error + +// UploadSuccess defines model for UploadSuccess. +type UploadSuccess = []EntryInfo + +// GetFilesParams defines parameters for GetFiles. +type GetFilesParams struct { + // Path Path to the file, URL encoded. Can be relative to user's home directory. + Path *FilePath `form:"path,omitempty" json:"path,omitempty"` + + // Username User used for setting the owner, or resolving relative paths. + Username *User `form:"username,omitempty" json:"username,omitempty"` + + // Signature Signature used for file access permission verification. + Signature *Signature `form:"signature,omitempty" json:"signature,omitempty"` + + // SignatureExpiration Signature expiration used for defining the expiration time of the signature. + SignatureExpiration *SignatureExpiration `form:"signature_expiration,omitempty" json:"signature_expiration,omitempty"` +} + +// PostFilesMultipartBody defines parameters for PostFiles. +type PostFilesMultipartBody struct { + File *openapi_types.File `json:"file,omitempty"` +} + +// PostFilesParams defines parameters for PostFiles. +type PostFilesParams struct { + // Path Path to the file, URL encoded. Can be relative to user's home directory. + Path *FilePath `form:"path,omitempty" json:"path,omitempty"` + + // Username User used for setting the owner, or resolving relative paths. + Username *User `form:"username,omitempty" json:"username,omitempty"` + + // Signature Signature used for file access permission verification. + Signature *Signature `form:"signature,omitempty" json:"signature,omitempty"` + + // SignatureExpiration Signature expiration used for defining the expiration time of the signature. + SignatureExpiration *SignatureExpiration `form:"signature_expiration,omitempty" json:"signature_expiration,omitempty"` +} + +// PostInitJSONBody defines parameters for PostInit. +type PostInitJSONBody struct { + // AccessToken Access token for secure access to envd service + AccessToken *SecureToken `json:"accessToken,omitempty"` + + // DefaultUser The default user to use for operations + DefaultUser *string `json:"defaultUser,omitempty"` + + // DefaultWorkdir The default working directory to use for operations + DefaultWorkdir *string `json:"defaultWorkdir,omitempty"` + + // EnvVars Environment variables to set + EnvVars *EnvVars `json:"envVars,omitempty"` + + // HyperloopIP IP address of the hyperloop server to connect to + HyperloopIP *string `json:"hyperloopIP,omitempty"` + + // Timestamp The current timestamp in RFC3339 format + Timestamp *time.Time `json:"timestamp,omitempty"` + VolumeMounts *[]VolumeMount `json:"volumeMounts,omitempty"` +} + +// PostFilesMultipartRequestBody defines body for PostFiles for multipart/form-data ContentType. +type PostFilesMultipartRequestBody PostFilesMultipartBody + +// PostInitJSONRequestBody defines body for PostInit for application/json ContentType. +type PostInitJSONRequestBody PostInitJSONBody + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Get the environment variables + // (GET /envs) + GetEnvs(w http.ResponseWriter, r *http.Request) + // Download a file + // (GET /files) + GetFiles(w http.ResponseWriter, r *http.Request, params GetFilesParams) + // Upload a file and ensure the parent directories exist. If the file exists, it will be overwritten. + // (POST /files) + PostFiles(w http.ResponseWriter, r *http.Request, params PostFilesParams) + // Check the health of the service + // (GET /health) + GetHealth(w http.ResponseWriter, r *http.Request) + // Set initial vars, ensure the time and metadata is synced with the host + // (POST /init) + PostInit(w http.ResponseWriter, r *http.Request) + // Get the stats of the service + // (GET /metrics) + GetMetrics(w http.ResponseWriter, r *http.Request) +} + +// Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. + +type Unimplemented struct{} + +// Get the environment variables +// (GET /envs) +func (_ Unimplemented) GetEnvs(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Download a file +// (GET /files) +func (_ Unimplemented) GetFiles(w http.ResponseWriter, r *http.Request, params GetFilesParams) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Upload a file and ensure the parent directories exist. If the file exists, it will be overwritten. +// (POST /files) +func (_ Unimplemented) PostFiles(w http.ResponseWriter, r *http.Request, params PostFilesParams) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Check the health of the service +// (GET /health) +func (_ Unimplemented) GetHealth(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Set initial vars, ensure the time and metadata is synced with the host +// (POST /init) +func (_ Unimplemented) PostInit(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Get the stats of the service +// (GET /metrics) +func (_ Unimplemented) GetMetrics(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// GetEnvs operation middleware +func (siw *ServerInterfaceWrapper) GetEnvs(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, AccessTokenAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetEnvs(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetFiles operation middleware +func (siw *ServerInterfaceWrapper) GetFiles(w http.ResponseWriter, r *http.Request) { + + var err error + + ctx := r.Context() + + ctx = context.WithValue(ctx, AccessTokenAuthScopes, []string{}) + + r = r.WithContext(ctx) + + // Parameter object where we will unmarshal all parameters from the context + var params GetFilesParams + + // ------------- Optional query parameter "path" ------------- + + err = runtime.BindQueryParameter("form", true, false, "path", r.URL.Query(), ¶ms.Path) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "path", Err: err}) + return + } + + // ------------- Optional query parameter "username" ------------- + + err = runtime.BindQueryParameter("form", true, false, "username", r.URL.Query(), ¶ms.Username) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "username", Err: err}) + return + } + + // ------------- Optional query parameter "signature" ------------- + + err = runtime.BindQueryParameter("form", true, false, "signature", r.URL.Query(), ¶ms.Signature) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "signature", Err: err}) + return + } + + // ------------- Optional query parameter "signature_expiration" ------------- + + err = runtime.BindQueryParameter("form", true, false, "signature_expiration", r.URL.Query(), ¶ms.SignatureExpiration) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "signature_expiration", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetFiles(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// PostFiles operation middleware +func (siw *ServerInterfaceWrapper) PostFiles(w http.ResponseWriter, r *http.Request) { + + var err error + + ctx := r.Context() + + ctx = context.WithValue(ctx, AccessTokenAuthScopes, []string{}) + + r = r.WithContext(ctx) + + // Parameter object where we will unmarshal all parameters from the context + var params PostFilesParams + + // ------------- Optional query parameter "path" ------------- + + err = runtime.BindQueryParameter("form", true, false, "path", r.URL.Query(), ¶ms.Path) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "path", Err: err}) + return + } + + // ------------- Optional query parameter "username" ------------- + + err = runtime.BindQueryParameter("form", true, false, "username", r.URL.Query(), ¶ms.Username) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "username", Err: err}) + return + } + + // ------------- Optional query parameter "signature" ------------- + + err = runtime.BindQueryParameter("form", true, false, "signature", r.URL.Query(), ¶ms.Signature) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "signature", Err: err}) + return + } + + // ------------- Optional query parameter "signature_expiration" ------------- + + err = runtime.BindQueryParameter("form", true, false, "signature_expiration", r.URL.Query(), ¶ms.SignatureExpiration) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "signature_expiration", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PostFiles(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetHealth operation middleware +func (siw *ServerInterfaceWrapper) GetHealth(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetHealth(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// PostInit operation middleware +func (siw *ServerInterfaceWrapper) PostInit(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, AccessTokenAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PostInit(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// GetMetrics operation middleware +func (siw *ServerInterfaceWrapper) GetMetrics(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, AccessTokenAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetMetrics(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{}) +} + +type ChiServerOptions struct { + BaseURL string + BaseRouter chi.Router + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseRouter: r, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, r chi.Router, baseURL string) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseURL: baseURL, + BaseRouter: r, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handler { + r := options.BaseRouter + + if r == nil { + r = chi.NewRouter() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/envs", wrapper.GetEnvs) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/files", wrapper.GetFiles) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/files", wrapper.PostFiles) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/health", wrapper.GetHealth) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/init", wrapper.PostInit) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/metrics", wrapper.GetMetrics) + }) + + return r +} diff --git a/envd/internal/api/auth.go b/envd/internal/api/auth.go new file mode 100644 index 0000000..b626f5a --- /dev/null +++ b/envd/internal/api/auth.go @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "errors" + "fmt" + "net/http" + "slices" + "strconv" + "strings" + "time" + + "github.com/awnumar/memguard" + + "git.omukk.dev/wrenn/sandbox/envd/internal/shared/keys" +) + +const ( + SigningReadOperation = "read" + SigningWriteOperation = "write" + + accessTokenHeader = "X-Access-Token" +) + +// paths that are always allowed without general authentication +// POST/init is secured via MMDS hash validation instead +var authExcludedPaths = []string{ + "GET/health", + "GET/files", + "POST/files", + "POST/init", +} + +func (a *API) WithAuthorization(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if a.accessToken.IsSet() { + authHeader := req.Header.Get(accessTokenHeader) + + // check if this path is allowed without authentication (e.g., health check, endpoints supporting signing) + allowedPath := slices.Contains(authExcludedPaths, req.Method+req.URL.Path) + + if !a.accessToken.Equals(authHeader) && !allowedPath { + a.logger.Error().Msg("Trying to access secured envd without correct access token") + + err := fmt.Errorf("unauthorized access, please provide a valid access token or method signing if supported") + jsonError(w, http.StatusUnauthorized, err) + + return + } + } + + handler.ServeHTTP(w, req) + }) +} + +func (a *API) generateSignature(path string, username string, operation string, signatureExpiration *int64) (string, error) { + tokenBytes, err := a.accessToken.Bytes() + if err != nil { + return "", fmt.Errorf("access token is not set: %w", err) + } + defer memguard.WipeBytes(tokenBytes) + + var signature string + hasher := keys.NewSHA256Hashing() + + if signatureExpiration == nil { + signature = strings.Join([]string{path, operation, username, string(tokenBytes)}, ":") + } else { + signature = strings.Join([]string{path, operation, username, string(tokenBytes), strconv.FormatInt(*signatureExpiration, 10)}, ":") + } + + return fmt.Sprintf("v1_%s", hasher.HashWithoutPrefix([]byte(signature))), nil +} + +func (a *API) validateSigning(r *http.Request, signature *string, signatureExpiration *int, username *string, path string, operation string) (err error) { + var expectedSignature string + + // no need to validate signing key if access token is not set + if !a.accessToken.IsSet() { + return nil + } + + // check if access token is sent in the header + tokenFromHeader := r.Header.Get(accessTokenHeader) + if tokenFromHeader != "" { + if !a.accessToken.Equals(tokenFromHeader) { + return fmt.Errorf("access token present in header but does not match") + } + + return nil + } + + if signature == nil { + return fmt.Errorf("missing signature query parameter") + } + + // Empty string is used when no username is provided and the default user should be used + signatureUsername := "" + if username != nil { + signatureUsername = *username + } + + if signatureExpiration == nil { + expectedSignature, err = a.generateSignature(path, signatureUsername, operation, nil) + } else { + exp := int64(*signatureExpiration) + expectedSignature, err = a.generateSignature(path, signatureUsername, operation, &exp) + } + + if err != nil { + a.logger.Error().Err(err).Msg("error generating signing key") + + return errors.New("invalid signature") + } + + // signature validation + if expectedSignature != *signature { + return fmt.Errorf("invalid signature") + } + + // signature expiration + if signatureExpiration != nil { + exp := int64(*signatureExpiration) + if exp < time.Now().Unix() { + return fmt.Errorf("signature is already expired") + } + } + + return nil +} diff --git a/envd/internal/api/auth_test.go b/envd/internal/api/auth_test.go new file mode 100644 index 0000000..4e80ec7 --- /dev/null +++ b/envd/internal/api/auth_test.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "fmt" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.omukk.dev/wrenn/sandbox/envd/internal/shared/keys" +) + +func TestKeyGenerationAlgorithmIsStable(t *testing.T) { + t.Parallel() + apiToken := "secret-access-token" + secureToken := &SecureToken{} + err := secureToken.Set([]byte(apiToken)) + require.NoError(t, err) + api := &API{accessToken: secureToken} + + path := "/path/to/demo.txt" + username := "root" + operation := "write" + timestamp := time.Now().Unix() + + signature, err := api.generateSignature(path, username, operation, ×tamp) + require.NoError(t, err) + assert.NotEmpty(t, signature) + + // locally generated signature + hasher := keys.NewSHA256Hashing() + localSignatureTmp := fmt.Sprintf("%s:%s:%s:%s:%s", path, operation, username, apiToken, strconv.FormatInt(timestamp, 10)) + localSignature := fmt.Sprintf("v1_%s", hasher.HashWithoutPrefix([]byte(localSignatureTmp))) + + assert.Equal(t, localSignature, signature) +} + +func TestKeyGenerationAlgorithmWithoutExpirationIsStable(t *testing.T) { + t.Parallel() + apiToken := "secret-access-token" + secureToken := &SecureToken{} + err := secureToken.Set([]byte(apiToken)) + require.NoError(t, err) + api := &API{accessToken: secureToken} + + path := "/path/to/resource.txt" + username := "user" + operation := "read" + + signature, err := api.generateSignature(path, username, operation, nil) + require.NoError(t, err) + assert.NotEmpty(t, signature) + + // locally generated signature + hasher := keys.NewSHA256Hashing() + localSignatureTmp := fmt.Sprintf("%s:%s:%s:%s", path, operation, username, apiToken) + localSignature := fmt.Sprintf("v1_%s", hasher.HashWithoutPrefix([]byte(localSignatureTmp))) + + assert.Equal(t, localSignature, signature) +} diff --git a/envd/internal/api/cfg.yaml b/envd/internal/api/cfg.yaml new file mode 100644 index 0000000..f72ca5e --- /dev/null +++ b/envd/internal/api/cfg.yaml @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: Apache-2.0 + +# yaml-language-server: $schema=https://raw.githubusercontent.com/deepmap/oapi-codegen/HEAD/configuration-schema.json + +package: api +output: api.gen.go +generate: + models: true + chi-server: true + client: false diff --git a/envd/internal/api/download.go b/envd/internal/api/download.go new file mode 100644 index 0000000..b90a8ac --- /dev/null +++ b/envd/internal/api/download.go @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "compress/gzip" + "errors" + "fmt" + "io" + "mime" + "net/http" + "os" + "os/user" + "path/filepath" + + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" +) + +func (a *API) GetFiles(w http.ResponseWriter, r *http.Request, params GetFilesParams) { + defer r.Body.Close() + + var errorCode int + var errMsg error + + var path string + if params.Path != nil { + path = *params.Path + } + + operationID := logs.AssignOperationID() + + // signing authorization if needed + err := a.validateSigning(r, params.Signature, params.SignatureExpiration, params.Username, path, SigningReadOperation) + if err != nil { + a.logger.Error().Err(err).Str(string(logs.OperationIDKey), operationID).Msg("error during auth validation") + jsonError(w, http.StatusUnauthorized, err) + + return + } + + username, err := execcontext.ResolveDefaultUsername(params.Username, a.defaults.User) + if err != nil { + a.logger.Error().Err(err).Str(string(logs.OperationIDKey), operationID).Msg("no user specified") + jsonError(w, http.StatusBadRequest, err) + + return + } + + defer func() { + l := a.logger. + Err(errMsg). + Str("method", r.Method+" "+r.URL.Path). + Str(string(logs.OperationIDKey), operationID). + Str("path", path). + Str("username", username) + + if errMsg != nil { + l = l.Int("error_code", errorCode) + } + + l.Msg("File read") + }() + + u, err := user.Lookup(username) + if err != nil { + errMsg = fmt.Errorf("error looking up user '%s': %w", username, err) + errorCode = http.StatusUnauthorized + jsonError(w, errorCode, errMsg) + + return + } + + resolvedPath, err := permissions.ExpandAndResolve(path, u, a.defaults.Workdir) + if err != nil { + errMsg = fmt.Errorf("error expanding and resolving path '%s': %w", path, err) + errorCode = http.StatusBadRequest + jsonError(w, errorCode, errMsg) + + return + } + + stat, err := os.Stat(resolvedPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + errMsg = fmt.Errorf("path '%s' does not exist", resolvedPath) + errorCode = http.StatusNotFound + jsonError(w, errorCode, errMsg) + + return + } + + errMsg = fmt.Errorf("error checking if path exists '%s': %w", resolvedPath, err) + errorCode = http.StatusInternalServerError + jsonError(w, errorCode, errMsg) + + return + } + + if stat.IsDir() { + errMsg = fmt.Errorf("path '%s' is a directory", resolvedPath) + errorCode = http.StatusBadRequest + jsonError(w, errorCode, errMsg) + + return + } + + // Validate Accept-Encoding header + encoding, err := parseAcceptEncoding(r) + if err != nil { + errMsg = fmt.Errorf("error parsing Accept-Encoding: %w", err) + errorCode = http.StatusNotAcceptable + jsonError(w, errorCode, errMsg) + + return + } + + // Tell caches to store separate variants for different Accept-Encoding values + w.Header().Set("Vary", "Accept-Encoding") + + // Fall back to identity for Range or conditional requests to preserve http.ServeContent + // behavior (206 Partial Content, 304 Not Modified). However, we must check if identity + // is acceptable per the Accept-Encoding header. + hasRangeOrConditional := r.Header.Get("Range") != "" || + r.Header.Get("If-Modified-Since") != "" || + r.Header.Get("If-None-Match") != "" || + r.Header.Get("If-Range") != "" + if hasRangeOrConditional { + if !isIdentityAcceptable(r) { + errMsg = fmt.Errorf("identity encoding not acceptable for Range or conditional request") + errorCode = http.StatusNotAcceptable + jsonError(w, errorCode, errMsg) + + return + } + encoding = EncodingIdentity + } + + file, err := os.Open(resolvedPath) + if err != nil { + errMsg = fmt.Errorf("error opening file '%s': %w", resolvedPath, err) + errorCode = http.StatusInternalServerError + jsonError(w, errorCode, errMsg) + + return + } + defer file.Close() + + w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": filepath.Base(resolvedPath)})) + + // Serve with gzip encoding if requested. + if encoding == EncodingGzip { + w.Header().Set("Content-Encoding", EncodingGzip) + + // Set Content-Type based on file extension, preserving the original type + contentType := mime.TypeByExtension(filepath.Ext(path)) + if contentType == "" { + contentType = "application/octet-stream" + } + w.Header().Set("Content-Type", contentType) + + gw := gzip.NewWriter(w) + defer gw.Close() + + _, err = io.Copy(gw, file) + if err != nil { + a.logger.Error().Err(err).Str(string(logs.OperationIDKey), operationID).Msg("error writing gzip response") + } + + return + } + + http.ServeContent(w, r, path, stat.ModTime(), file) +} diff --git a/envd/internal/api/download_test.go b/envd/internal/api/download_test.go new file mode 100644 index 0000000..235a613 --- /dev/null +++ b/envd/internal/api/download_test.go @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "bytes" + "compress/gzip" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/user" + "path/filepath" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" +) + +func TestGetFilesContentDisposition(t *testing.T) { + t.Parallel() + + currentUser, err := user.Current() + require.NoError(t, err) + + tests := []struct { + name string + filename string + expectedHeader string + }{ + { + name: "simple filename", + filename: "test.txt", + expectedHeader: `inline; filename=test.txt`, + }, + { + name: "filename with extension", + filename: "presentation.pptx", + expectedHeader: `inline; filename=presentation.pptx`, + }, + { + name: "filename with multiple dots", + filename: "archive.tar.gz", + expectedHeader: `inline; filename=archive.tar.gz`, + }, + { + name: "filename with spaces", + filename: "my document.pdf", + expectedHeader: `inline; filename="my document.pdf"`, + }, + { + name: "filename with quotes", + filename: `file"name.txt`, + expectedHeader: `inline; filename="file\"name.txt"`, + }, + { + name: "filename with backslash", + filename: `file\name.txt`, + expectedHeader: `inline; filename="file\\name.txt"`, + }, + { + name: "unicode filename", + filename: "\u6587\u6863.pdf", // 文档.pdf in Chinese + expectedHeader: "inline; filename*=utf-8''%E6%96%87%E6%A1%A3.pdf", + }, + { + name: "dotfile preserved", + filename: ".env", + expectedHeader: `inline; filename=.env`, + }, + { + name: "dotfile with extension preserved", + filename: ".gitignore", + expectedHeader: `inline; filename=.gitignore`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Create a temp directory and file + tempDir := t.TempDir() + tempFile := filepath.Join(tempDir, tt.filename) + err := os.WriteFile(tempFile, []byte("test content"), 0o644) + require.NoError(t, err) + + // Create test API + logger := zerolog.Nop() + defaults := &execcontext.Defaults{ + EnvVars: utils.NewMap[string, string](), + User: currentUser.Username, + } + api := New(&logger, defaults, nil, false) + + // Create request and response recorder + req := httptest.NewRequest(http.MethodGet, "/files?path="+url.QueryEscape(tempFile), nil) + w := httptest.NewRecorder() + + // Call the handler + params := GetFilesParams{ + Path: &tempFile, + Username: ¤tUser.Username, + } + api.GetFiles(w, req, params) + + // Check response + resp := w.Result() + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify Content-Disposition header + contentDisposition := resp.Header.Get("Content-Disposition") + assert.Equal(t, tt.expectedHeader, contentDisposition, "Content-Disposition header should be set with correct filename") + }) + } +} + +func TestGetFilesContentDispositionWithNestedPath(t *testing.T) { + t.Parallel() + + currentUser, err := user.Current() + require.NoError(t, err) + + // Create a temp directory with nested structure + tempDir := t.TempDir() + nestedDir := filepath.Join(tempDir, "subdir", "another") + err = os.MkdirAll(nestedDir, 0o755) + require.NoError(t, err) + + filename := "document.pdf" + tempFile := filepath.Join(nestedDir, filename) + err = os.WriteFile(tempFile, []byte("test content"), 0o644) + require.NoError(t, err) + + // Create test API + logger := zerolog.Nop() + defaults := &execcontext.Defaults{ + EnvVars: utils.NewMap[string, string](), + User: currentUser.Username, + } + api := New(&logger, defaults, nil, false) + + // Create request and response recorder + req := httptest.NewRequest(http.MethodGet, "/files?path="+url.QueryEscape(tempFile), nil) + w := httptest.NewRecorder() + + // Call the handler + params := GetFilesParams{ + Path: &tempFile, + Username: ¤tUser.Username, + } + api.GetFiles(w, req, params) + + // Check response + resp := w.Result() + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify Content-Disposition header uses only the base filename, not the full path + contentDisposition := resp.Header.Get("Content-Disposition") + assert.Equal(t, `inline; filename=document.pdf`, contentDisposition, "Content-Disposition should contain only the filename, not the path") +} + +func TestGetFiles_GzipEncoding_ExplicitIdentityOffWithRange(t *testing.T) { + t.Parallel() + + currentUser, err := user.Current() + require.NoError(t, err) + + // Create a temp directory with a test file + tempDir := t.TempDir() + filename := "document.pdf" + tempFile := filepath.Join(tempDir, filename) + err = os.WriteFile(tempFile, []byte("test content"), 0o644) + require.NoError(t, err) + + // Create test API + logger := zerolog.Nop() + defaults := &execcontext.Defaults{ + EnvVars: utils.NewMap[string, string](), + User: currentUser.Username, + } + api := New(&logger, defaults, nil, false) + + // Create request and response recorder + req := httptest.NewRequest(http.MethodGet, "/files?path="+url.QueryEscape(tempFile), nil) + req.Header.Set("Accept-Encoding", "gzip; q=1,*; q=0") + req.Header.Set("Range", "bytes=0-4") // Request first 5 bytes + w := httptest.NewRecorder() + + // Call the handler + params := GetFilesParams{ + Path: &tempFile, + Username: ¤tUser.Username, + } + api.GetFiles(w, req, params) + + // Check response + resp := w.Result() + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotAcceptable, resp.StatusCode) +} + +func TestGetFiles_GzipDownload(t *testing.T) { + t.Parallel() + + currentUser, err := user.Current() + require.NoError(t, err) + + originalContent := []byte("hello world, this is a test file for gzip compression") + + // Create a temp file with known content + tempDir := t.TempDir() + tempFile := filepath.Join(tempDir, "test.txt") + err = os.WriteFile(tempFile, originalContent, 0o644) + require.NoError(t, err) + + logger := zerolog.Nop() + defaults := &execcontext.Defaults{ + EnvVars: utils.NewMap[string, string](), + User: currentUser.Username, + } + api := New(&logger, defaults, nil, false) + + req := httptest.NewRequest(http.MethodGet, "/files?path="+url.QueryEscape(tempFile), nil) + req.Header.Set("Accept-Encoding", "gzip") + w := httptest.NewRecorder() + + params := GetFilesParams{ + Path: &tempFile, + Username: ¤tUser.Username, + } + api.GetFiles(w, req, params) + + resp := w.Result() + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "gzip", resp.Header.Get("Content-Encoding")) + assert.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type")) + + // Decompress the gzip response body + gzReader, err := gzip.NewReader(resp.Body) + require.NoError(t, err) + defer gzReader.Close() + + decompressed, err := io.ReadAll(gzReader) + require.NoError(t, err) + + assert.Equal(t, originalContent, decompressed) +} + +func TestPostFiles_GzipUpload(t *testing.T) { + t.Parallel() + + currentUser, err := user.Current() + require.NoError(t, err) + + originalContent := []byte("hello world, this is a test file uploaded with gzip") + + // Build a multipart body + var multipartBuf bytes.Buffer + mpWriter := multipart.NewWriter(&multipartBuf) + part, err := mpWriter.CreateFormFile("file", "uploaded.txt") + require.NoError(t, err) + _, err = part.Write(originalContent) + require.NoError(t, err) + err = mpWriter.Close() + require.NoError(t, err) + + // Gzip-compress the entire multipart body + var gzBuf bytes.Buffer + gzWriter := gzip.NewWriter(&gzBuf) + _, err = gzWriter.Write(multipartBuf.Bytes()) + require.NoError(t, err) + err = gzWriter.Close() + require.NoError(t, err) + + // Create test API + tempDir := t.TempDir() + destPath := filepath.Join(tempDir, "uploaded.txt") + + logger := zerolog.Nop() + defaults := &execcontext.Defaults{ + EnvVars: utils.NewMap[string, string](), + User: currentUser.Username, + } + api := New(&logger, defaults, nil, false) + + req := httptest.NewRequest(http.MethodPost, "/files?path="+url.QueryEscape(destPath), &gzBuf) + req.Header.Set("Content-Type", mpWriter.FormDataContentType()) + req.Header.Set("Content-Encoding", "gzip") + w := httptest.NewRecorder() + + params := PostFilesParams{ + Path: &destPath, + Username: ¤tUser.Username, + } + api.PostFiles(w, req, params) + + resp := w.Result() + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify the file was written with the original (decompressed) content + data, err := os.ReadFile(destPath) + require.NoError(t, err) + assert.Equal(t, originalContent, data) +} + +func TestGzipUploadThenGzipDownload(t *testing.T) { + t.Parallel() + + currentUser, err := user.Current() + require.NoError(t, err) + + originalContent := []byte("round-trip gzip test: upload compressed, download compressed, verify match") + + // --- Upload with gzip --- + + // Build a multipart body + var multipartBuf bytes.Buffer + mpWriter := multipart.NewWriter(&multipartBuf) + part, err := mpWriter.CreateFormFile("file", "roundtrip.txt") + require.NoError(t, err) + _, err = part.Write(originalContent) + require.NoError(t, err) + err = mpWriter.Close() + require.NoError(t, err) + + // Gzip-compress the entire multipart body + var gzBuf bytes.Buffer + gzWriter := gzip.NewWriter(&gzBuf) + _, err = gzWriter.Write(multipartBuf.Bytes()) + require.NoError(t, err) + err = gzWriter.Close() + require.NoError(t, err) + + tempDir := t.TempDir() + destPath := filepath.Join(tempDir, "roundtrip.txt") + + logger := zerolog.Nop() + defaults := &execcontext.Defaults{ + EnvVars: utils.NewMap[string, string](), + User: currentUser.Username, + } + api := New(&logger, defaults, nil, false) + + uploadReq := httptest.NewRequest(http.MethodPost, "/files?path="+url.QueryEscape(destPath), &gzBuf) + uploadReq.Header.Set("Content-Type", mpWriter.FormDataContentType()) + uploadReq.Header.Set("Content-Encoding", "gzip") + uploadW := httptest.NewRecorder() + + uploadParams := PostFilesParams{ + Path: &destPath, + Username: ¤tUser.Username, + } + api.PostFiles(uploadW, uploadReq, uploadParams) + + uploadResp := uploadW.Result() + defer uploadResp.Body.Close() + + require.Equal(t, http.StatusOK, uploadResp.StatusCode) + + // --- Download with gzip --- + + downloadReq := httptest.NewRequest(http.MethodGet, "/files?path="+url.QueryEscape(destPath), nil) + downloadReq.Header.Set("Accept-Encoding", "gzip") + downloadW := httptest.NewRecorder() + + downloadParams := GetFilesParams{ + Path: &destPath, + Username: ¤tUser.Username, + } + api.GetFiles(downloadW, downloadReq, downloadParams) + + downloadResp := downloadW.Result() + defer downloadResp.Body.Close() + + require.Equal(t, http.StatusOK, downloadResp.StatusCode) + assert.Equal(t, "gzip", downloadResp.Header.Get("Content-Encoding")) + + // Decompress and verify content matches original + gzReader, err := gzip.NewReader(downloadResp.Body) + require.NoError(t, err) + defer gzReader.Close() + + decompressed, err := io.ReadAll(gzReader) + require.NoError(t, err) + + assert.Equal(t, originalContent, decompressed) +} diff --git a/envd/internal/api/encoding.go b/envd/internal/api/encoding.go new file mode 100644 index 0000000..d324c1c --- /dev/null +++ b/envd/internal/api/encoding.go @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "compress/gzip" + "fmt" + "io" + "net/http" + "slices" + "sort" + "strconv" + "strings" +) + +const ( + // EncodingGzip is the gzip content encoding. + EncodingGzip = "gzip" + // EncodingIdentity means no encoding (passthrough). + EncodingIdentity = "identity" + // EncodingWildcard means any encoding is acceptable. + EncodingWildcard = "*" +) + +// SupportedEncodings lists the content encodings supported for file transfer. +// The order matters - encodings are checked in order of preference. +var SupportedEncodings = []string{ + EncodingGzip, +} + +// encodingWithQuality holds an encoding name and its quality value. +type encodingWithQuality struct { + encoding string + quality float64 +} + +// isSupportedEncoding checks if the given encoding is in the supported list. +// Per RFC 7231, content-coding values are case-insensitive. +func isSupportedEncoding(encoding string) bool { + return slices.Contains(SupportedEncodings, strings.ToLower(encoding)) +} + +// parseEncodingWithQuality parses an encoding value and extracts the quality. +// Returns the encoding name (lowercased) and quality value (default 1.0 if not specified). +// Per RFC 7231, content-coding values are case-insensitive. +func parseEncodingWithQuality(value string) encodingWithQuality { + value = strings.TrimSpace(value) + quality := 1.0 + + if idx := strings.Index(value, ";"); idx != -1 { + params := value[idx+1:] + value = strings.TrimSpace(value[:idx]) + + // Parse q=X.X parameter + for param := range strings.SplitSeq(params, ";") { + param = strings.TrimSpace(param) + if strings.HasPrefix(strings.ToLower(param), "q=") { + if q, err := strconv.ParseFloat(param[2:], 64); err == nil { + quality = q + } + } + } + } + + // Normalize encoding to lowercase per RFC 7231 + return encodingWithQuality{encoding: strings.ToLower(value), quality: quality} +} + +// parseEncoding extracts the encoding name from a header value, stripping quality. +func parseEncoding(value string) string { + return parseEncodingWithQuality(value).encoding +} + +// parseContentEncoding parses the Content-Encoding header and returns the encoding. +// Returns an error if an unsupported encoding is specified. +// If no Content-Encoding header is present, returns empty string. +func parseContentEncoding(r *http.Request) (string, error) { + header := r.Header.Get("Content-Encoding") + if header == "" { + return EncodingIdentity, nil + } + + encoding := parseEncoding(header) + + if encoding == EncodingIdentity { + return EncodingIdentity, nil + } + + if !isSupportedEncoding(encoding) { + return "", fmt.Errorf("unsupported Content-Encoding: %s, supported: %v", header, SupportedEncodings) + } + + return encoding, nil +} + +// parseAcceptEncodingHeader parses the Accept-Encoding header and returns +// the parsed encodings along with the identity rejection state. +// Per RFC 7231 Section 5.3.4, identity is acceptable unless excluded by +// "identity;q=0" or "*;q=0" without a more specific entry for identity with q>0. +func parseAcceptEncodingHeader(header string) ([]encodingWithQuality, bool) { + if header == "" { + return nil, false // identity not rejected when header is empty + } + + // Parse all encodings with their quality values + var encodings []encodingWithQuality + for value := range strings.SplitSeq(header, ",") { + eq := parseEncodingWithQuality(value) + encodings = append(encodings, eq) + } + + // Check if identity is rejected per RFC 7231 Section 5.3.4: + // identity is acceptable unless excluded by "identity;q=0" or "*;q=0" + // without a more specific entry for identity with q>0. + identityRejected := false + identityExplicitlyAccepted := false + wildcardRejected := false + + for _, eq := range encodings { + switch eq.encoding { + case EncodingIdentity: + if eq.quality == 0 { + identityRejected = true + } else { + identityExplicitlyAccepted = true + } + case EncodingWildcard: + if eq.quality == 0 { + wildcardRejected = true + } + } + } + + if wildcardRejected && !identityExplicitlyAccepted { + identityRejected = true + } + + return encodings, identityRejected +} + +// isIdentityAcceptable checks if identity encoding is acceptable based on the +// Accept-Encoding header. Per RFC 7231 section 5.3.4, identity is always +// implicitly acceptable unless explicitly rejected with q=0. +func isIdentityAcceptable(r *http.Request) bool { + header := r.Header.Get("Accept-Encoding") + _, identityRejected := parseAcceptEncodingHeader(header) + + return !identityRejected +} + +// parseAcceptEncoding parses the Accept-Encoding header and returns the best +// supported encoding based on quality values. Per RFC 7231 section 5.3.4, +// identity is always implicitly acceptable unless explicitly rejected with q=0. +// If no Accept-Encoding header is present, returns empty string (identity). +func parseAcceptEncoding(r *http.Request) (string, error) { + header := r.Header.Get("Accept-Encoding") + if header == "" { + return EncodingIdentity, nil + } + + encodings, identityRejected := parseAcceptEncodingHeader(header) + + // Sort by quality value (highest first) + sort.Slice(encodings, func(i, j int) bool { + return encodings[i].quality > encodings[j].quality + }) + + // Find the best supported encoding + for _, eq := range encodings { + // Skip encodings with q=0 (explicitly rejected) + if eq.quality == 0 { + continue + } + + if eq.encoding == EncodingIdentity { + return EncodingIdentity, nil + } + + // Wildcard means any encoding is acceptable - return a supported encoding if identity is rejected + if eq.encoding == EncodingWildcard { + if identityRejected && len(SupportedEncodings) > 0 { + return SupportedEncodings[0], nil + } + + return EncodingIdentity, nil + } + + if isSupportedEncoding(eq.encoding) { + return eq.encoding, nil + } + } + + // Per RFC 7231, identity is implicitly acceptable unless rejected + if !identityRejected { + return EncodingIdentity, nil + } + + // Identity rejected and no supported encodings found + return "", fmt.Errorf("no acceptable encoding found, supported: %v", SupportedEncodings) +} + +// getDecompressedBody returns a reader that decompresses the request body based on +// Content-Encoding header. Returns the original body if no encoding is specified. +// Returns an error if an unsupported encoding is specified. +// The caller is responsible for closing both the returned ReadCloser and the +// original request body (r.Body) separately. +func getDecompressedBody(r *http.Request) (io.ReadCloser, error) { + encoding, err := parseContentEncoding(r) + if err != nil { + return nil, err + } + + if encoding == EncodingIdentity { + return r.Body, nil + } + + switch encoding { + case EncodingGzip: + gzReader, err := gzip.NewReader(r.Body) + if err != nil { + return nil, fmt.Errorf("failed to create gzip reader: %w", err) + } + + return gzReader, nil + default: + // This shouldn't happen if isSupportedEncoding is correct + return nil, fmt.Errorf("encoding %s is supported but not implemented", encoding) + } +} diff --git a/envd/internal/api/encoding_test.go b/envd/internal/api/encoding_test.go new file mode 100644 index 0000000..6cb311b --- /dev/null +++ b/envd/internal/api/encoding_test.go @@ -0,0 +1,496 @@ +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "bytes" + "compress/gzip" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsSupportedEncoding(t *testing.T) { + t.Parallel() + + t.Run("gzip is supported", func(t *testing.T) { + t.Parallel() + assert.True(t, isSupportedEncoding("gzip")) + }) + + t.Run("GZIP is supported (case-insensitive)", func(t *testing.T) { + t.Parallel() + assert.True(t, isSupportedEncoding("GZIP")) + }) + + t.Run("Gzip is supported (case-insensitive)", func(t *testing.T) { + t.Parallel() + assert.True(t, isSupportedEncoding("Gzip")) + }) + + t.Run("br is not supported", func(t *testing.T) { + t.Parallel() + assert.False(t, isSupportedEncoding("br")) + }) + + t.Run("deflate is not supported", func(t *testing.T) { + t.Parallel() + assert.False(t, isSupportedEncoding("deflate")) + }) +} + +func TestParseEncodingWithQuality(t *testing.T) { + t.Parallel() + + t.Run("returns encoding with default quality 1.0", func(t *testing.T) { + t.Parallel() + eq := parseEncodingWithQuality("gzip") + assert.Equal(t, "gzip", eq.encoding) + assert.InDelta(t, 1.0, eq.quality, 0.001) + }) + + t.Run("parses quality value", func(t *testing.T) { + t.Parallel() + eq := parseEncodingWithQuality("gzip;q=0.5") + assert.Equal(t, "gzip", eq.encoding) + assert.InDelta(t, 0.5, eq.quality, 0.001) + }) + + t.Run("parses quality value with whitespace", func(t *testing.T) { + t.Parallel() + eq := parseEncodingWithQuality("gzip ; q=0.8") + assert.Equal(t, "gzip", eq.encoding) + assert.InDelta(t, 0.8, eq.quality, 0.001) + }) + + t.Run("handles q=0", func(t *testing.T) { + t.Parallel() + eq := parseEncodingWithQuality("gzip;q=0") + assert.Equal(t, "gzip", eq.encoding) + assert.InDelta(t, 0.0, eq.quality, 0.001) + }) + + t.Run("handles invalid quality value", func(t *testing.T) { + t.Parallel() + eq := parseEncodingWithQuality("gzip;q=invalid") + assert.Equal(t, "gzip", eq.encoding) + assert.InDelta(t, 1.0, eq.quality, 0.001) // defaults to 1.0 on parse error + }) + + t.Run("trims whitespace from encoding", func(t *testing.T) { + t.Parallel() + eq := parseEncodingWithQuality(" gzip ") + assert.Equal(t, "gzip", eq.encoding) + assert.InDelta(t, 1.0, eq.quality, 0.001) + }) + + t.Run("normalizes encoding to lowercase", func(t *testing.T) { + t.Parallel() + eq := parseEncodingWithQuality("GZIP") + assert.Equal(t, "gzip", eq.encoding) + }) + + t.Run("normalizes mixed case encoding", func(t *testing.T) { + t.Parallel() + eq := parseEncodingWithQuality("Gzip;q=0.5") + assert.Equal(t, "gzip", eq.encoding) + assert.InDelta(t, 0.5, eq.quality, 0.001) + }) +} + +func TestParseEncoding(t *testing.T) { + t.Parallel() + + t.Run("returns encoding as-is", func(t *testing.T) { + t.Parallel() + assert.Equal(t, "gzip", parseEncoding("gzip")) + }) + + t.Run("trims whitespace", func(t *testing.T) { + t.Parallel() + assert.Equal(t, "gzip", parseEncoding(" gzip ")) + }) + + t.Run("strips quality value", func(t *testing.T) { + t.Parallel() + assert.Equal(t, "gzip", parseEncoding("gzip;q=1.0")) + }) + + t.Run("strips quality value with whitespace", func(t *testing.T) { + t.Parallel() + assert.Equal(t, "gzip", parseEncoding("gzip ; q=0.5")) + }) +} + +func TestParseContentEncoding(t *testing.T) { + t.Parallel() + + t.Run("returns identity when no header", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", nil) + + encoding, err := parseContentEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingIdentity, encoding) + }) + + t.Run("returns gzip when Content-Encoding is gzip", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", nil) + req.Header.Set("Content-Encoding", "gzip") + + encoding, err := parseContentEncoding(req) + require.NoError(t, err) + assert.Equal(t, "gzip", encoding) + }) + + t.Run("returns gzip when Content-Encoding is GZIP (case-insensitive)", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", nil) + req.Header.Set("Content-Encoding", "GZIP") + + encoding, err := parseContentEncoding(req) + require.NoError(t, err) + assert.Equal(t, "gzip", encoding) + }) + + t.Run("returns gzip when Content-Encoding is Gzip (case-insensitive)", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", nil) + req.Header.Set("Content-Encoding", "Gzip") + + encoding, err := parseContentEncoding(req) + require.NoError(t, err) + assert.Equal(t, "gzip", encoding) + }) + + t.Run("returns identity for identity encoding", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", nil) + req.Header.Set("Content-Encoding", "identity") + + encoding, err := parseContentEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingIdentity, encoding) + }) + + t.Run("returns error for unsupported encoding", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", nil) + req.Header.Set("Content-Encoding", "br") + + _, err := parseContentEncoding(req) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported Content-Encoding") + assert.Contains(t, err.Error(), "supported: [gzip]") + }) + + t.Run("handles gzip with quality value", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", nil) + req.Header.Set("Content-Encoding", "gzip;q=1.0") + + encoding, err := parseContentEncoding(req) + require.NoError(t, err) + assert.Equal(t, "gzip", encoding) + }) +} + +func TestParseAcceptEncoding(t *testing.T) { + t.Parallel() + + t.Run("returns identity when no header", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingIdentity, encoding) + }) + + t.Run("returns gzip when Accept-Encoding is gzip", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "gzip") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, "gzip", encoding) + }) + + t.Run("returns gzip when Accept-Encoding is GZIP (case-insensitive)", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "GZIP") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, "gzip", encoding) + }) + + t.Run("returns gzip when gzip is among multiple encodings", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "deflate, gzip, br") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, "gzip", encoding) + }) + + t.Run("returns gzip with quality value", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "gzip;q=1.0") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, "gzip", encoding) + }) + + t.Run("returns identity for identity encoding", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "identity") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingIdentity, encoding) + }) + + t.Run("returns identity for wildcard encoding", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "*") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingIdentity, encoding) + }) + + t.Run("falls back to identity for unsupported encoding only", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "br") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingIdentity, encoding) + }) + + t.Run("falls back to identity when only unsupported encodings", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "deflate, br") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingIdentity, encoding) + }) + + t.Run("selects gzip when it has highest quality", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "br;q=0.5, gzip;q=1.0, deflate;q=0.8") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, "gzip", encoding) + }) + + t.Run("selects gzip even with lower quality when others unsupported", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "br;q=1.0, gzip;q=0.5") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, "gzip", encoding) + }) + + t.Run("returns identity when it has higher quality than gzip", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "gzip;q=0.5, identity;q=1.0") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingIdentity, encoding) + }) + + t.Run("skips encoding with q=0", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "gzip;q=0, identity") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingIdentity, encoding) + }) + + t.Run("falls back to identity when gzip rejected and no other supported", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "gzip;q=0, br") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingIdentity, encoding) + }) + + t.Run("returns error when identity explicitly rejected and no supported encoding", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "br, identity;q=0") + + _, err := parseAcceptEncoding(req) + require.Error(t, err) + assert.Contains(t, err.Error(), "no acceptable encoding found") + }) + + t.Run("returns gzip for wildcard when identity rejected", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "*, identity;q=0") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, "gzip", encoding) // wildcard with identity rejected returns supported encoding + }) + + t.Run("returns error when wildcard rejected and no explicit identity", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "*;q=0") + + _, err := parseAcceptEncoding(req) + require.Error(t, err) + assert.Contains(t, err.Error(), "no acceptable encoding found") + }) + + t.Run("returns identity when wildcard rejected but identity explicitly accepted", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "*;q=0, identity") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingIdentity, encoding) + }) + + t.Run("returns gzip when wildcard rejected but gzip explicitly accepted", func(t *testing.T) { + t.Parallel() + req, _ := http.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil) + req.Header.Set("Accept-Encoding", "*;q=0, gzip") + + encoding, err := parseAcceptEncoding(req) + require.NoError(t, err) + assert.Equal(t, EncodingGzip, encoding) + }) +} + +func TestGetDecompressedBody(t *testing.T) { + t.Parallel() + + t.Run("returns original body when no Content-Encoding header", func(t *testing.T) { + t.Parallel() + content := []byte("test content") + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", bytes.NewReader(content)) + + body, err := getDecompressedBody(req) + require.NoError(t, err) + assert.Equal(t, req.Body, body, "should return original body") + + data, err := io.ReadAll(body) + require.NoError(t, err) + assert.Equal(t, content, data) + }) + + t.Run("decompresses gzip body when Content-Encoding is gzip", func(t *testing.T) { + t.Parallel() + originalContent := []byte("test content to compress") + + var compressed bytes.Buffer + gw := gzip.NewWriter(&compressed) + _, err := gw.Write(originalContent) + require.NoError(t, err) + err = gw.Close() + require.NoError(t, err) + + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", bytes.NewReader(compressed.Bytes())) + req.Header.Set("Content-Encoding", "gzip") + + body, err := getDecompressedBody(req) + require.NoError(t, err) + defer body.Close() + + assert.NotEqual(t, req.Body, body, "should return a new gzip reader") + + data, err := io.ReadAll(body) + require.NoError(t, err) + assert.Equal(t, originalContent, data) + }) + + t.Run("returns error for invalid gzip data", func(t *testing.T) { + t.Parallel() + invalidGzip := []byte("this is not gzip data") + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", bytes.NewReader(invalidGzip)) + req.Header.Set("Content-Encoding", "gzip") + + _, err := getDecompressedBody(req) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to create gzip reader") + }) + + t.Run("returns original body for identity encoding", func(t *testing.T) { + t.Parallel() + content := []byte("test content") + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", bytes.NewReader(content)) + req.Header.Set("Content-Encoding", "identity") + + body, err := getDecompressedBody(req) + require.NoError(t, err) + assert.Equal(t, req.Body, body, "should return original body") + + data, err := io.ReadAll(body) + require.NoError(t, err) + assert.Equal(t, content, data) + }) + + t.Run("returns error for unsupported encoding", func(t *testing.T) { + t.Parallel() + content := []byte("test content") + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", bytes.NewReader(content)) + req.Header.Set("Content-Encoding", "br") + + _, err := getDecompressedBody(req) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported Content-Encoding") + }) + + t.Run("handles gzip with quality value", func(t *testing.T) { + t.Parallel() + originalContent := []byte("test content to compress") + + var compressed bytes.Buffer + gw := gzip.NewWriter(&compressed) + _, err := gw.Write(originalContent) + require.NoError(t, err) + err = gw.Close() + require.NoError(t, err) + + req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost, "/test", bytes.NewReader(compressed.Bytes())) + req.Header.Set("Content-Encoding", "gzip;q=1.0") + + body, err := getDecompressedBody(req) + require.NoError(t, err) + defer body.Close() + + data, err := io.ReadAll(body) + require.NoError(t, err) + assert.Equal(t, originalContent, data) + }) +} diff --git a/envd/internal/api/envs.go b/envd/internal/api/envs.go new file mode 100644 index 0000000..6de61b7 --- /dev/null +++ b/envd/internal/api/envs.go @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "encoding/json" + "net/http" + + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" +) + +func (a *API) GetEnvs(w http.ResponseWriter, _ *http.Request) { + operationID := logs.AssignOperationID() + + a.logger.Debug().Str(string(logs.OperationIDKey), operationID).Msg("Getting env vars") + + envs := make(EnvVars) + a.defaults.EnvVars.Range(func(key, value string) bool { + envs[key] = value + + return true + }) + + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json") + + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(envs); err != nil { + a.logger.Error().Err(err).Str(string(logs.OperationIDKey), operationID).Msg("Failed to encode env vars") + } +} diff --git a/envd/internal/api/error.go b/envd/internal/api/error.go new file mode 100644 index 0000000..f3362b0 --- /dev/null +++ b/envd/internal/api/error.go @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "encoding/json" + "errors" + "net/http" +) + +func jsonError(w http.ResponseWriter, code int, err error) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("X-Content-Type-Options", "nosniff") + + w.WriteHeader(code) + encodeErr := json.NewEncoder(w).Encode(Error{ + Code: code, + Message: err.Error(), + }) + if encodeErr != nil { + http.Error(w, errors.Join(encodeErr, err).Error(), http.StatusInternalServerError) + } +} diff --git a/envd/internal/api/generate.go b/envd/internal/api/generate.go new file mode 100644 index 0000000..8d906a6 --- /dev/null +++ b/envd/internal/api/generate.go @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: Apache-2.0 + +package api + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -config cfg.yaml ../../spec/envd.yaml diff --git a/envd/internal/api/init.go b/envd/internal/api/init.go new file mode 100644 index 0000000..a489459 --- /dev/null +++ b/envd/internal/api/init.go @@ -0,0 +1,317 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modifications by M/S Omukk + +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/netip" + "os/exec" + "time" + + "github.com/awnumar/memguard" + "github.com/rs/zerolog" + "github.com/txn2/txeh" + "golang.org/x/sys/unix" + + "git.omukk.dev/wrenn/sandbox/envd/internal/host" + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + "git.omukk.dev/wrenn/sandbox/envd/internal/shared/keys" +) + +var ( + ErrAccessTokenMismatch = errors.New("access token validation failed") + ErrAccessTokenResetNotAuthorized = errors.New("access token reset not authorized") +) + +const ( + maxTimeInPast = 50 * time.Millisecond + maxTimeInFuture = 5 * time.Second +) + +// validateInitAccessToken validates the access token for /init requests. +// Token is valid if it matches the existing token OR the MMDS hash. +// If neither exists, first-time setup is allowed. +func (a *API) validateInitAccessToken(ctx context.Context, requestToken *SecureToken) error { + requestTokenSet := requestToken.IsSet() + + // Fast path: token matches existing + if a.accessToken.IsSet() && requestTokenSet && a.accessToken.EqualsSecure(requestToken) { + return nil + } + + // Check MMDS only if token didn't match existing + matchesMMDS, mmdsExists := a.checkMMDSHash(ctx, requestToken) + + switch { + case matchesMMDS: + return nil + case !a.accessToken.IsSet() && !mmdsExists: + return nil // first-time setup + case !requestTokenSet: + return ErrAccessTokenResetNotAuthorized + default: + return ErrAccessTokenMismatch + } +} + +// checkMMDSHash checks if the request token matches the MMDS hash. +// Returns (matches, mmdsExists). +// +// The MMDS hash is set by the orchestrator during Resume: +// - hash(token): requires this specific token +// - hash(""): explicitly allows nil token (token reset authorized) +// - "": MMDS not properly configured, no authorization granted +func (a *API) checkMMDSHash(ctx context.Context, requestToken *SecureToken) (bool, bool) { + if a.isNotFC { + return false, false + } + + mmdsHash, err := a.mmdsClient.GetAccessTokenHash(ctx) + if err != nil { + return false, false + } + + if mmdsHash == "" { + return false, false + } + + if !requestToken.IsSet() { + return mmdsHash == keys.HashAccessToken(""), true + } + + tokenBytes, err := requestToken.Bytes() + if err != nil { + return false, true + } + defer memguard.WipeBytes(tokenBytes) + + return keys.HashAccessTokenBytes(tokenBytes) == mmdsHash, true +} + +func (a *API) PostInit(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + ctx := r.Context() + + operationID := logs.AssignOperationID() + logger := a.logger.With().Str(string(logs.OperationIDKey), operationID).Logger() + + if r.Body != nil { + // Read raw body so we can wipe it after parsing + body, err := io.ReadAll(r.Body) + // Ensure body is wiped after we're done + defer memguard.WipeBytes(body) + if err != nil { + logger.Error().Msgf("Failed to read request body: %v", err) + w.WriteHeader(http.StatusBadRequest) + + return + } + + var initRequest PostInitJSONBody + if len(body) > 0 { + err = json.Unmarshal(body, &initRequest) + if err != nil { + logger.Error().Msgf("Failed to decode request: %v", err) + w.WriteHeader(http.StatusBadRequest) + + return + } + } + + // Ensure request token is destroyed if not transferred via TakeFrom. + // This handles: validation failures, timestamp-based skips, and any early returns. + // Safe because Destroy() is nil-safe and TakeFrom clears the source. + defer initRequest.AccessToken.Destroy() + + a.initLock.Lock() + defer a.initLock.Unlock() + + // Update data only if the request is newer or if there's no timestamp at all + if initRequest.Timestamp == nil || a.lastSetTime.SetToGreater(initRequest.Timestamp.UnixNano()) { + err = a.SetData(ctx, logger, initRequest) + if err != nil { + switch { + case errors.Is(err, ErrAccessTokenMismatch), errors.Is(err, ErrAccessTokenResetNotAuthorized): + w.WriteHeader(http.StatusUnauthorized) + default: + logger.Error().Msgf("Failed to set data: %v", err) + w.WriteHeader(http.StatusBadRequest) + } + w.Write([]byte(err.Error())) + + return + } + } + } + + go func() { //nolint:contextcheck // TODO: fix this later + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + host.PollForMMDSOpts(ctx, a.mmdsChan, a.defaults.EnvVars) + }() + + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "") + + w.WriteHeader(http.StatusNoContent) +} + +func (a *API) SetData(ctx context.Context, logger zerolog.Logger, data PostInitJSONBody) error { + // Validate access token before proceeding with any action + // The request must provide a token that is either: + // 1. Matches the existing access token (if set), OR + // 2. Matches the MMDS hash (for token change during resume) + if err := a.validateInitAccessToken(ctx, data.AccessToken); err != nil { + return err + } + + if data.Timestamp != nil { + // Check if current time differs significantly from the received timestamp + if shouldSetSystemTime(time.Now(), *data.Timestamp) { + logger.Debug().Msgf("Setting sandbox start time to: %v", *data.Timestamp) + ts := unix.NsecToTimespec(data.Timestamp.UnixNano()) + err := unix.ClockSettime(unix.CLOCK_REALTIME, &ts) + if err != nil { + logger.Error().Msgf("Failed to set system time: %v", err) + } + } else { + logger.Debug().Msgf("Current time is within acceptable range of timestamp %v, not setting system time", *data.Timestamp) + } + } + + if data.EnvVars != nil { + logger.Debug().Msg(fmt.Sprintf("Setting %d env vars", len(*data.EnvVars))) + + for key, value := range *data.EnvVars { + logger.Debug().Msgf("Setting env var for %s", key) + a.defaults.EnvVars.Store(key, value) + } + } + + if data.AccessToken.IsSet() { + logger.Debug().Msg("Setting access token") + a.accessToken.TakeFrom(data.AccessToken) + } else if a.accessToken.IsSet() { + logger.Debug().Msg("Clearing access token") + a.accessToken.Destroy() + } + + if data.HyperloopIP != nil { + go a.SetupHyperloop(*data.HyperloopIP) + } + + if data.DefaultUser != nil && *data.DefaultUser != "" { + logger.Debug().Msgf("Setting default user to: %s", *data.DefaultUser) + a.defaults.User = *data.DefaultUser + } + + if data.DefaultWorkdir != nil && *data.DefaultWorkdir != "" { + logger.Debug().Msgf("Setting default workdir to: %s", *data.DefaultWorkdir) + a.defaults.Workdir = data.DefaultWorkdir + } + + if data.VolumeMounts != nil { + for _, volume := range *data.VolumeMounts { + logger.Debug().Msgf("Mounting %s at %q", volume.NfsTarget, volume.Path) + + go a.setupNfs(context.WithoutCancel(ctx), volume.NfsTarget, volume.Path) + } + } + + return nil +} + +func (a *API) setupNfs(ctx context.Context, nfsTarget, path string) { + commands := [][]string{ + {"mkdir", "-p", path}, + {"mount", "-v", "-t", "nfs", "-o", "mountproto=tcp,mountport=2049,proto=tcp,port=2049,nfsvers=3,noacl", nfsTarget, path}, + } + + for _, command := range commands { + data, err := exec.CommandContext(ctx, command[0], command[1:]...).CombinedOutput() + + logger := a.getLogger(err) + + logger. + Strs("command", command). + Str("output", string(data)). + Msg("Mount NFS") + + if err != nil { + return + } + } +} + +func (a *API) SetupHyperloop(address string) { + a.hyperloopLock.Lock() + defer a.hyperloopLock.Unlock() + + if err := rewriteHostsFile(address, "/etc/hosts"); err != nil { + a.logger.Error().Err(err).Msg("failed to modify hosts file") + } else { + a.defaults.EnvVars.Store("WRENN_EVENTS_ADDRESS", fmt.Sprintf("http://%s", address)) + } +} + +const eventsHost = "events.wrenn.local" + +func rewriteHostsFile(address, path string) error { + hosts, err := txeh.NewHosts(&txeh.HostsConfig{ + ReadFilePath: path, + WriteFilePath: path, + }) + if err != nil { + return fmt.Errorf("failed to create hosts: %w", err) + } + + // Update /etc/hosts to point events.wrenn.local to the hyperloop IP + // This will remove any existing entries for events.wrenn.local first + ipFamily, err := getIPFamily(address) + if err != nil { + return fmt.Errorf("failed to get ip family: %w", err) + } + + if ok, current, _ := hosts.HostAddressLookup(eventsHost, ipFamily); ok && current == address { + return nil // nothing to be done + } + + hosts.AddHost(address, eventsHost) + + return hosts.Save() +} + +var ( + ErrInvalidAddress = errors.New("invalid IP address") + ErrUnknownAddressFormat = errors.New("unknown IP address format") +) + +func getIPFamily(address string) (txeh.IPFamily, error) { + addressIP, err := netip.ParseAddr(address) + if err != nil { + return txeh.IPFamilyV4, fmt.Errorf("failed to parse IP address: %w", err) + } + + switch { + case addressIP.Is4(): + return txeh.IPFamilyV4, nil + case addressIP.Is6(): + return txeh.IPFamilyV6, nil + default: + return txeh.IPFamilyV4, fmt.Errorf("%w: %s", ErrUnknownAddressFormat, address) + } +} + +// shouldSetSystemTime returns true if the current time differs significantly from the received timestamp, +// indicating the system clock should be adjusted. Returns true when the sandboxTime is more than +// maxTimeInPast before the hostTime or more than maxTimeInFuture after the hostTime. +func shouldSetSystemTime(sandboxTime, hostTime time.Time) bool { + return sandboxTime.Before(hostTime.Add(-maxTimeInPast)) || sandboxTime.After(hostTime.Add(maxTimeInFuture)) +} diff --git a/envd/internal/api/init_test.go b/envd/internal/api/init_test.go new file mode 100644 index 0000000..c4b6f4b --- /dev/null +++ b/envd/internal/api/init_test.go @@ -0,0 +1,590 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modifications by M/S Omukk + +package api + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" + "git.omukk.dev/wrenn/sandbox/envd/internal/shared/keys" + utilsShared "git.omukk.dev/wrenn/sandbox/envd/internal/shared/utils" + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" +) + +func TestSimpleCases(t *testing.T) { + t.Parallel() + testCases := map[string]func(string) string{ + "both newlines": func(s string) string { return s }, + "no newline prefix": func(s string) string { return strings.TrimPrefix(s, "\n") }, + "no newline suffix": func(s string) string { return strings.TrimSuffix(s, "\n") }, + "no newline prefix or suffix": strings.TrimSpace, + } + + for name, preprocessor := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + + value := ` +# comment +127.0.0.1 one.host +127.0.0.2 two.host +` + value = preprocessor(value) + inputPath := filepath.Join(tempDir, "hosts") + err := os.WriteFile(inputPath, []byte(value), 0o644) + require.NoError(t, err) + + err = rewriteHostsFile("127.0.0.3", inputPath) + require.NoError(t, err) + + data, err := os.ReadFile(inputPath) + require.NoError(t, err) + + assert.Equal(t, `# comment +127.0.0.1 one.host +127.0.0.2 two.host +127.0.0.3 events.wrenn.local`, strings.TrimSpace(string(data))) + }) + } +} + +func TestShouldSetSystemTime(t *testing.T) { + t.Parallel() + sandboxTime := time.Now() + + tests := []struct { + name string + hostTime time.Time + want bool + }{ + { + name: "sandbox time far ahead of host time (should set)", + hostTime: sandboxTime.Add(-10 * time.Second), + want: true, + }, + { + name: "sandbox time at maxTimeInPast boundary ahead of host time (should not set)", + hostTime: sandboxTime.Add(-50 * time.Millisecond), + want: false, + }, + { + name: "sandbox time just within maxTimeInPast ahead of host time (should not set)", + hostTime: sandboxTime.Add(-40 * time.Millisecond), + want: false, + }, + { + name: "sandbox time slightly ahead of host time (should not set)", + hostTime: sandboxTime.Add(-10 * time.Millisecond), + want: false, + }, + { + name: "sandbox time equals host time (should not set)", + hostTime: sandboxTime, + want: false, + }, + { + name: "sandbox time slightly behind host time (should not set)", + hostTime: sandboxTime.Add(1 * time.Second), + want: false, + }, + { + name: "sandbox time just within maxTimeInFuture behind host time (should not set)", + hostTime: sandboxTime.Add(4 * time.Second), + want: false, + }, + { + name: "sandbox time at maxTimeInFuture boundary behind host time (should not set)", + hostTime: sandboxTime.Add(5 * time.Second), + want: false, + }, + { + name: "sandbox time far behind host time (should set)", + hostTime: sandboxTime.Add(1 * time.Minute), + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := shouldSetSystemTime(tt.hostTime, sandboxTime) + assert.Equal(t, tt.want, got) + }) + } +} + +func secureTokenPtr(s string) *SecureToken { + token := &SecureToken{} + _ = token.Set([]byte(s)) + + return token +} + +type mockMMDSClient struct { + hash string + err error +} + +func (m *mockMMDSClient) GetAccessTokenHash(_ context.Context) (string, error) { + return m.hash, m.err +} + +func newTestAPI(accessToken *SecureToken, mmdsClient MMDSClient) *API { + logger := zerolog.Nop() + defaults := &execcontext.Defaults{ + EnvVars: utils.NewMap[string, string](), + } + api := New(&logger, defaults, nil, false) + if accessToken != nil { + api.accessToken.TakeFrom(accessToken) + } + api.mmdsClient = mmdsClient + + return api +} + +func TestValidateInitAccessToken(t *testing.T) { + t.Parallel() + ctx := t.Context() + + tests := []struct { + name string + accessToken *SecureToken + requestToken *SecureToken + mmdsHash string + mmdsErr error + wantErr error + }{ + { + name: "fast path: token matches existing", + accessToken: secureTokenPtr("secret-token"), + requestToken: secureTokenPtr("secret-token"), + mmdsHash: "", + mmdsErr: nil, + wantErr: nil, + }, + { + name: "MMDS match: token hash matches MMDS hash", + accessToken: secureTokenPtr("old-token"), + requestToken: secureTokenPtr("new-token"), + mmdsHash: keys.HashAccessToken("new-token"), + mmdsErr: nil, + wantErr: nil, + }, + { + name: "first-time setup: no existing token, MMDS error", + accessToken: nil, + requestToken: secureTokenPtr("new-token"), + mmdsHash: "", + mmdsErr: assert.AnError, + wantErr: nil, + }, + { + name: "first-time setup: no existing token, empty MMDS hash", + accessToken: nil, + requestToken: secureTokenPtr("new-token"), + mmdsHash: "", + mmdsErr: nil, + wantErr: nil, + }, + { + name: "first-time setup: both tokens nil, no MMDS", + accessToken: nil, + requestToken: nil, + mmdsHash: "", + mmdsErr: assert.AnError, + wantErr: nil, + }, + { + name: "mismatch: existing token differs from request, no MMDS", + accessToken: secureTokenPtr("existing-token"), + requestToken: secureTokenPtr("wrong-token"), + mmdsHash: "", + mmdsErr: assert.AnError, + wantErr: ErrAccessTokenMismatch, + }, + { + name: "mismatch: existing token differs from request, MMDS hash mismatch", + accessToken: secureTokenPtr("existing-token"), + requestToken: secureTokenPtr("wrong-token"), + mmdsHash: keys.HashAccessToken("different-token"), + mmdsErr: nil, + wantErr: ErrAccessTokenMismatch, + }, + { + name: "conflict: existing token, nil request, MMDS exists", + accessToken: secureTokenPtr("existing-token"), + requestToken: nil, + mmdsHash: keys.HashAccessToken("some-token"), + mmdsErr: nil, + wantErr: ErrAccessTokenResetNotAuthorized, + }, + { + name: "conflict: existing token, nil request, no MMDS", + accessToken: secureTokenPtr("existing-token"), + requestToken: nil, + mmdsHash: "", + mmdsErr: assert.AnError, + wantErr: ErrAccessTokenResetNotAuthorized, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: tt.mmdsHash, err: tt.mmdsErr} + api := newTestAPI(tt.accessToken, mmdsClient) + + err := api.validateInitAccessToken(ctx, tt.requestToken) + + if tt.wantErr != nil { + require.Error(t, err) + assert.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestCheckMMDSHash(t *testing.T) { + t.Parallel() + ctx := t.Context() + + t.Run("returns match when token hash equals MMDS hash", func(t *testing.T) { + t.Parallel() + token := "my-secret-token" + mmdsClient := &mockMMDSClient{hash: keys.HashAccessToken(token), err: nil} + api := newTestAPI(nil, mmdsClient) + + matches, exists := api.checkMMDSHash(ctx, secureTokenPtr(token)) + + assert.True(t, matches) + assert.True(t, exists) + }) + + t.Run("returns no match when token hash differs from MMDS hash", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: keys.HashAccessToken("different-token"), err: nil} + api := newTestAPI(nil, mmdsClient) + + matches, exists := api.checkMMDSHash(ctx, secureTokenPtr("my-token")) + + assert.False(t, matches) + assert.True(t, exists) + }) + + t.Run("returns exists but no match when request token is nil", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: keys.HashAccessToken("some-token"), err: nil} + api := newTestAPI(nil, mmdsClient) + + matches, exists := api.checkMMDSHash(ctx, nil) + + assert.False(t, matches) + assert.True(t, exists) + }) + + t.Run("returns false, false when MMDS returns error", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: "", err: assert.AnError} + api := newTestAPI(nil, mmdsClient) + + matches, exists := api.checkMMDSHash(ctx, secureTokenPtr("any-token")) + + assert.False(t, matches) + assert.False(t, exists) + }) + + t.Run("returns false, false when MMDS returns empty hash with non-nil request", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: "", err: nil} + api := newTestAPI(nil, mmdsClient) + + matches, exists := api.checkMMDSHash(ctx, secureTokenPtr("any-token")) + + assert.False(t, matches) + assert.False(t, exists) + }) + + t.Run("returns false, false when MMDS returns empty hash with nil request", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: "", err: nil} + api := newTestAPI(nil, mmdsClient) + + matches, exists := api.checkMMDSHash(ctx, nil) + + assert.False(t, matches) + assert.False(t, exists) + }) + + t.Run("returns true, true when MMDS returns hash of empty string with nil request (explicit reset)", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: keys.HashAccessToken(""), err: nil} + api := newTestAPI(nil, mmdsClient) + + matches, exists := api.checkMMDSHash(ctx, nil) + + assert.True(t, matches) + assert.True(t, exists) + }) +} + +func TestSetData(t *testing.T) { + t.Parallel() + ctx := context.Background() + logger := zerolog.Nop() + + t.Run("access token updates", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + existingToken *SecureToken + requestToken *SecureToken + mmdsHash string + mmdsErr error + wantErr error + wantFinalToken *SecureToken + }{ + { + name: "first-time setup: sets initial token", + existingToken: nil, + requestToken: secureTokenPtr("initial-token"), + mmdsHash: "", + mmdsErr: assert.AnError, + wantErr: nil, + wantFinalToken: secureTokenPtr("initial-token"), + }, + { + name: "first-time setup: nil request token leaves token unset", + existingToken: nil, + requestToken: nil, + mmdsHash: "", + mmdsErr: assert.AnError, + wantErr: nil, + wantFinalToken: nil, + }, + { + name: "re-init with same token: token unchanged", + existingToken: secureTokenPtr("same-token"), + requestToken: secureTokenPtr("same-token"), + mmdsHash: "", + mmdsErr: assert.AnError, + wantErr: nil, + wantFinalToken: secureTokenPtr("same-token"), + }, + { + name: "resume with MMDS: updates token when hash matches", + existingToken: secureTokenPtr("old-token"), + requestToken: secureTokenPtr("new-token"), + mmdsHash: keys.HashAccessToken("new-token"), + mmdsErr: nil, + wantErr: nil, + wantFinalToken: secureTokenPtr("new-token"), + }, + { + name: "resume with MMDS: fails when hash doesn't match", + existingToken: secureTokenPtr("old-token"), + requestToken: secureTokenPtr("new-token"), + mmdsHash: keys.HashAccessToken("different-token"), + mmdsErr: nil, + wantErr: ErrAccessTokenMismatch, + wantFinalToken: secureTokenPtr("old-token"), + }, + { + name: "fails when existing token and request token mismatch without MMDS", + existingToken: secureTokenPtr("existing-token"), + requestToken: secureTokenPtr("wrong-token"), + mmdsHash: "", + mmdsErr: assert.AnError, + wantErr: ErrAccessTokenMismatch, + wantFinalToken: secureTokenPtr("existing-token"), + }, + { + name: "conflict when existing token but nil request token", + existingToken: secureTokenPtr("existing-token"), + requestToken: nil, + mmdsHash: "", + mmdsErr: assert.AnError, + wantErr: ErrAccessTokenResetNotAuthorized, + wantFinalToken: secureTokenPtr("existing-token"), + }, + { + name: "conflict when existing token but nil request with MMDS present", + existingToken: secureTokenPtr("existing-token"), + requestToken: nil, + mmdsHash: keys.HashAccessToken("some-token"), + mmdsErr: nil, + wantErr: ErrAccessTokenResetNotAuthorized, + wantFinalToken: secureTokenPtr("existing-token"), + }, + { + name: "conflict when MMDS returns empty hash and request is nil (prevents unauthorized reset)", + existingToken: secureTokenPtr("existing-token"), + requestToken: nil, + mmdsHash: "", + mmdsErr: nil, + wantErr: ErrAccessTokenResetNotAuthorized, + wantFinalToken: secureTokenPtr("existing-token"), + }, + { + name: "resets token when MMDS returns hash of empty string and request is nil (explicit reset)", + existingToken: secureTokenPtr("existing-token"), + requestToken: nil, + mmdsHash: keys.HashAccessToken(""), + mmdsErr: nil, + wantErr: nil, + wantFinalToken: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: tt.mmdsHash, err: tt.mmdsErr} + api := newTestAPI(tt.existingToken, mmdsClient) + + data := PostInitJSONBody{ + AccessToken: tt.requestToken, + } + + err := api.SetData(ctx, logger, data) + + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + + if tt.wantFinalToken == nil { + assert.False(t, api.accessToken.IsSet(), "expected token to not be set") + } else { + require.True(t, api.accessToken.IsSet(), "expected token to be set") + assert.True(t, api.accessToken.EqualsSecure(tt.wantFinalToken), "expected token to match") + } + }) + } + }) + + t.Run("sets environment variables", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: "", err: assert.AnError} + api := newTestAPI(nil, mmdsClient) + + envVars := EnvVars{"FOO": "bar", "BAZ": "qux"} + data := PostInitJSONBody{ + EnvVars: &envVars, + } + + err := api.SetData(ctx, logger, data) + + require.NoError(t, err) + val, ok := api.defaults.EnvVars.Load("FOO") + assert.True(t, ok) + assert.Equal(t, "bar", val) + val, ok = api.defaults.EnvVars.Load("BAZ") + assert.True(t, ok) + assert.Equal(t, "qux", val) + }) + + t.Run("sets default user", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: "", err: assert.AnError} + api := newTestAPI(nil, mmdsClient) + + data := PostInitJSONBody{ + DefaultUser: utilsShared.ToPtr("testuser"), + } + + err := api.SetData(ctx, logger, data) + + require.NoError(t, err) + assert.Equal(t, "testuser", api.defaults.User) + }) + + t.Run("does not set default user when empty", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: "", err: assert.AnError} + api := newTestAPI(nil, mmdsClient) + api.defaults.User = "original" + + data := PostInitJSONBody{ + DefaultUser: utilsShared.ToPtr(""), + } + + err := api.SetData(ctx, logger, data) + + require.NoError(t, err) + assert.Equal(t, "original", api.defaults.User) + }) + + t.Run("sets default workdir", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: "", err: assert.AnError} + api := newTestAPI(nil, mmdsClient) + + data := PostInitJSONBody{ + DefaultWorkdir: utilsShared.ToPtr("/home/user"), + } + + err := api.SetData(ctx, logger, data) + + require.NoError(t, err) + require.NotNil(t, api.defaults.Workdir) + assert.Equal(t, "/home/user", *api.defaults.Workdir) + }) + + t.Run("does not set default workdir when empty", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: "", err: assert.AnError} + api := newTestAPI(nil, mmdsClient) + originalWorkdir := "/original" + api.defaults.Workdir = &originalWorkdir + + data := PostInitJSONBody{ + DefaultWorkdir: utilsShared.ToPtr(""), + } + + err := api.SetData(ctx, logger, data) + + require.NoError(t, err) + require.NotNil(t, api.defaults.Workdir) + assert.Equal(t, "/original", *api.defaults.Workdir) + }) + + t.Run("sets multiple fields at once", func(t *testing.T) { + t.Parallel() + mmdsClient := &mockMMDSClient{hash: "", err: assert.AnError} + api := newTestAPI(nil, mmdsClient) + + envVars := EnvVars{"KEY": "value"} + data := PostInitJSONBody{ + AccessToken: secureTokenPtr("token"), + DefaultUser: utilsShared.ToPtr("user"), + DefaultWorkdir: utilsShared.ToPtr("/workdir"), + EnvVars: &envVars, + } + + err := api.SetData(ctx, logger, data) + + require.NoError(t, err) + assert.True(t, api.accessToken.Equals("token"), "expected token to match") + assert.Equal(t, "user", api.defaults.User) + assert.Equal(t, "/workdir", *api.defaults.Workdir) + val, ok := api.defaults.EnvVars.Load("KEY") + assert.True(t, ok) + assert.Equal(t, "value", val) + }) +} diff --git a/envd/internal/api/secure_token.go b/envd/internal/api/secure_token.go new file mode 100644 index 0000000..bdb6fab --- /dev/null +++ b/envd/internal/api/secure_token.go @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "bytes" + "errors" + "sync" + + "github.com/awnumar/memguard" +) + +var ( + ErrTokenNotSet = errors.New("access token not set") + ErrTokenEmpty = errors.New("empty token not allowed") +) + +// SecureToken wraps memguard for secure token storage. +// It uses LockedBuffer which provides memory locking, guard pages, +// and secure zeroing on destroy. +type SecureToken struct { + mu sync.RWMutex + buffer *memguard.LockedBuffer +} + +// Set securely replaces the token, destroying the old one first. +// The old token memory is zeroed before the new token is stored. +// The input byte slice is wiped after copying to secure memory. +// Returns ErrTokenEmpty if token is empty - use Destroy() to clear the token instead. +func (s *SecureToken) Set(token []byte) error { + if len(token) == 0 { + return ErrTokenEmpty + } + + s.mu.Lock() + defer s.mu.Unlock() + + // Destroy old token first (zeros memory) + if s.buffer != nil { + s.buffer.Destroy() + s.buffer = nil + } + + // Create new LockedBuffer from bytes (source slice is wiped by memguard) + s.buffer = memguard.NewBufferFromBytes(token) + + return nil +} + +// UnmarshalJSON implements json.Unmarshaler to securely parse a JSON string +// directly into memguard, wiping the input bytes after copying. +// +// Access tokens are hex-encoded HMAC-SHA256 hashes (64 chars of [0-9a-f]), +// so they never contain JSON escape sequences. +func (s *SecureToken) UnmarshalJSON(data []byte) error { + // JSON strings are quoted, so minimum valid is `""` (2 bytes). + if len(data) < 2 || data[0] != '"' || data[len(data)-1] != '"' { + memguard.WipeBytes(data) + + return errors.New("invalid secure token JSON string") + } + + content := data[1 : len(data)-1] + + // Access tokens are hex strings - reject if contains backslash + if bytes.ContainsRune(content, '\\') { + memguard.WipeBytes(data) + + return errors.New("invalid secure token: unexpected escape sequence") + } + + if len(content) == 0 { + memguard.WipeBytes(data) + + return ErrTokenEmpty + } + + s.mu.Lock() + defer s.mu.Unlock() + + if s.buffer != nil { + s.buffer.Destroy() + s.buffer = nil + } + + // Allocate secure buffer and copy directly into it + s.buffer = memguard.NewBuffer(len(content)) + copy(s.buffer.Bytes(), content) + + // Wipe the input data + memguard.WipeBytes(data) + + return nil +} + +// TakeFrom transfers the token from src to this SecureToken, destroying any +// existing token. The source token is cleared after transfer. +// This avoids copying the underlying bytes. +func (s *SecureToken) TakeFrom(src *SecureToken) { + if src == nil || s == src { + return + } + + // Extract buffer from source + src.mu.Lock() + buffer := src.buffer + src.buffer = nil + src.mu.Unlock() + + // Install buffer in destination + s.mu.Lock() + if s.buffer != nil { + s.buffer.Destroy() + } + s.buffer = buffer + s.mu.Unlock() +} + +// Equals checks if token matches using constant-time comparison. +// Returns false if the receiver is nil. +func (s *SecureToken) Equals(token string) bool { + if s == nil { + return false + } + + s.mu.RLock() + defer s.mu.RUnlock() + + if s.buffer == nil || !s.buffer.IsAlive() { + return false + } + + return s.buffer.EqualTo([]byte(token)) +} + +// EqualsSecure compares this token with another SecureToken using constant-time comparison. +// Returns false if either receiver or other is nil. +func (s *SecureToken) EqualsSecure(other *SecureToken) bool { + if s == nil || other == nil { + return false + } + + if s == other { + return s.IsSet() + } + + // Get a copy of other's bytes (avoids holding two locks simultaneously) + otherBytes, err := other.Bytes() + if err != nil { + return false + } + defer memguard.WipeBytes(otherBytes) + + s.mu.RLock() + defer s.mu.RUnlock() + + if s.buffer == nil || !s.buffer.IsAlive() { + return false + } + + return s.buffer.EqualTo(otherBytes) +} + +// IsSet returns true if a token is stored. +// Returns false if the receiver is nil. +func (s *SecureToken) IsSet() bool { + if s == nil { + return false + } + + s.mu.RLock() + defer s.mu.RUnlock() + + return s.buffer != nil && s.buffer.IsAlive() +} + +// Bytes returns a copy of the token bytes (for signature generation). +// The caller should zero the returned slice after use. +// Returns ErrTokenNotSet if the receiver is nil. +func (s *SecureToken) Bytes() ([]byte, error) { + if s == nil { + return nil, ErrTokenNotSet + } + + s.mu.RLock() + defer s.mu.RUnlock() + + if s.buffer == nil || !s.buffer.IsAlive() { + return nil, ErrTokenNotSet + } + + // Return a copy (unavoidable for signature generation) + src := s.buffer.Bytes() + result := make([]byte, len(src)) + copy(result, src) + + return result, nil +} + +// Destroy securely wipes the token from memory. +// No-op if the receiver is nil. +func (s *SecureToken) Destroy() { + if s == nil { + return + } + + s.mu.Lock() + defer s.mu.Unlock() + + if s.buffer != nil { + s.buffer.Destroy() + s.buffer = nil + } +} diff --git a/envd/internal/api/secure_token_test.go b/envd/internal/api/secure_token_test.go new file mode 100644 index 0000000..ccb5a78 --- /dev/null +++ b/envd/internal/api/secure_token_test.go @@ -0,0 +1,463 @@ +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "sync" + "testing" + + "github.com/awnumar/memguard" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSecureTokenSetAndEquals(t *testing.T) { + t.Parallel() + + st := &SecureToken{} + + // Initially not set + assert.False(t, st.IsSet(), "token should not be set initially") + assert.False(t, st.Equals("any-token"), "equals should return false when not set") + + // Set token + err := st.Set([]byte("test-token")) + require.NoError(t, err) + assert.True(t, st.IsSet(), "token should be set after Set()") + assert.True(t, st.Equals("test-token"), "equals should return true for correct token") + assert.False(t, st.Equals("wrong-token"), "equals should return false for wrong token") + assert.False(t, st.Equals(""), "equals should return false for empty token") +} + +func TestSecureTokenReplace(t *testing.T) { + t.Parallel() + + st := &SecureToken{} + + // Set initial token + err := st.Set([]byte("first-token")) + require.NoError(t, err) + assert.True(t, st.Equals("first-token")) + + // Replace with new token (old one should be destroyed) + err = st.Set([]byte("second-token")) + require.NoError(t, err) + assert.True(t, st.Equals("second-token"), "should match new token") + assert.False(t, st.Equals("first-token"), "should not match old token") +} + +func TestSecureTokenDestroy(t *testing.T) { + t.Parallel() + + st := &SecureToken{} + + // Set and then destroy + err := st.Set([]byte("test-token")) + require.NoError(t, err) + assert.True(t, st.IsSet()) + + st.Destroy() + assert.False(t, st.IsSet(), "token should not be set after Destroy()") + assert.False(t, st.Equals("test-token"), "equals should return false after Destroy()") + + // Destroy on already destroyed should be safe + st.Destroy() + assert.False(t, st.IsSet()) + + // Nil receiver should be safe + var nilToken *SecureToken + assert.False(t, nilToken.IsSet(), "nil receiver should return false for IsSet()") + assert.False(t, nilToken.Equals("anything"), "nil receiver should return false for Equals()") + assert.False(t, nilToken.EqualsSecure(st), "nil receiver should return false for EqualsSecure()") + nilToken.Destroy() // should not panic + + _, err = nilToken.Bytes() + require.ErrorIs(t, err, ErrTokenNotSet, "nil receiver should return ErrTokenNotSet for Bytes()") +} + +func TestSecureTokenBytes(t *testing.T) { + t.Parallel() + + st := &SecureToken{} + + // Bytes should return error when not set + _, err := st.Bytes() + require.ErrorIs(t, err, ErrTokenNotSet) + + // Set token and get bytes + err = st.Set([]byte("test-token")) + require.NoError(t, err) + + bytes, err := st.Bytes() + require.NoError(t, err) + assert.Equal(t, []byte("test-token"), bytes) + + // Zero out the bytes (as caller should do) + memguard.WipeBytes(bytes) + + // Original should still be intact + assert.True(t, st.Equals("test-token"), "original token should still work after zeroing copy") + + // After destroy, bytes should fail + st.Destroy() + _, err = st.Bytes() + assert.ErrorIs(t, err, ErrTokenNotSet) +} + +func TestSecureTokenConcurrentAccess(t *testing.T) { + t.Parallel() + + st := &SecureToken{} + err := st.Set([]byte("initial-token")) + require.NoError(t, err) + + var wg sync.WaitGroup + const numGoroutines = 100 + + // Concurrent reads + for range numGoroutines { + wg.Go(func() { + st.IsSet() + st.Equals("initial-token") + }) + } + + // Concurrent writes + for i := range 10 { + wg.Add(1) + go func(idx int) { + defer wg.Done() + st.Set([]byte("token-" + string(rune('a'+idx)))) + }(i) + } + + wg.Wait() + + // Should still be in a valid state + assert.True(t, st.IsSet()) +} + +func TestSecureTokenEmptyToken(t *testing.T) { + t.Parallel() + + st := &SecureToken{} + + // Setting empty token should return an error + err := st.Set([]byte{}) + require.ErrorIs(t, err, ErrTokenEmpty) + assert.False(t, st.IsSet(), "token should not be set after empty token error") + + // Setting nil should also return an error + err = st.Set(nil) + require.ErrorIs(t, err, ErrTokenEmpty) + assert.False(t, st.IsSet(), "token should not be set after nil token error") +} + +func TestSecureTokenEmptyTokenDoesNotClearExisting(t *testing.T) { + t.Parallel() + + st := &SecureToken{} + + // Set a valid token first + err := st.Set([]byte("valid-token")) + require.NoError(t, err) + assert.True(t, st.IsSet()) + + // Attempting to set empty token should fail and preserve existing token + err = st.Set([]byte{}) + require.ErrorIs(t, err, ErrTokenEmpty) + assert.True(t, st.IsSet(), "existing token should be preserved after empty token error") + assert.True(t, st.Equals("valid-token"), "existing token value should be unchanged") +} + +func TestSecureTokenUnmarshalJSON(t *testing.T) { + t.Parallel() + + t.Run("unmarshals valid JSON string", func(t *testing.T) { + t.Parallel() + st := &SecureToken{} + err := st.UnmarshalJSON([]byte(`"my-secret-token"`)) + require.NoError(t, err) + assert.True(t, st.IsSet()) + assert.True(t, st.Equals("my-secret-token")) + }) + + t.Run("returns error for empty string", func(t *testing.T) { + t.Parallel() + st := &SecureToken{} + err := st.UnmarshalJSON([]byte(`""`)) + require.ErrorIs(t, err, ErrTokenEmpty) + assert.False(t, st.IsSet()) + }) + + t.Run("returns error for invalid JSON", func(t *testing.T) { + t.Parallel() + st := &SecureToken{} + err := st.UnmarshalJSON([]byte(`not-valid-json`)) + require.Error(t, err) + assert.False(t, st.IsSet()) + }) + + t.Run("replaces existing token", func(t *testing.T) { + t.Parallel() + st := &SecureToken{} + err := st.Set([]byte("old-token")) + require.NoError(t, err) + + err = st.UnmarshalJSON([]byte(`"new-token"`)) + require.NoError(t, err) + assert.True(t, st.Equals("new-token")) + assert.False(t, st.Equals("old-token")) + }) + + t.Run("wipes input buffer after parsing", func(t *testing.T) { + t.Parallel() + // Create a buffer with a known token + input := []byte(`"secret-token-12345"`) + original := make([]byte, len(input)) + copy(original, input) + + st := &SecureToken{} + err := st.UnmarshalJSON(input) + require.NoError(t, err) + + // Verify the token was stored correctly + assert.True(t, st.Equals("secret-token-12345")) + + // Verify the input buffer was wiped (all zeros) + for i, b := range input { + assert.Equal(t, byte(0), b, "byte at position %d should be zero, got %d", i, b) + } + }) + + t.Run("wipes input buffer on error", func(t *testing.T) { + t.Parallel() + // Create a buffer with an empty token (will error) + input := []byte(`""`) + + st := &SecureToken{} + err := st.UnmarshalJSON(input) + require.Error(t, err) + + // Verify the input buffer was still wiped + for i, b := range input { + assert.Equal(t, byte(0), b, "byte at position %d should be zero, got %d", i, b) + } + }) + + t.Run("rejects escape sequences", func(t *testing.T) { + t.Parallel() + st := &SecureToken{} + err := st.UnmarshalJSON([]byte(`"token\nwith\nnewlines"`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "escape sequence") + assert.False(t, st.IsSet()) + }) +} + +func TestSecureTokenSetWipesInput(t *testing.T) { + t.Parallel() + + t.Run("wipes input buffer after storing", func(t *testing.T) { + t.Parallel() + // Create a buffer with a known token + input := []byte("my-secret-token") + original := make([]byte, len(input)) + copy(original, input) + + st := &SecureToken{} + err := st.Set(input) + require.NoError(t, err) + + // Verify the token was stored correctly + assert.True(t, st.Equals("my-secret-token")) + + // Verify the input buffer was wiped (all zeros) + for i, b := range input { + assert.Equal(t, byte(0), b, "byte at position %d should be zero, got %d", i, b) + } + }) +} + +func TestSecureTokenTakeFrom(t *testing.T) { + t.Parallel() + + t.Run("transfers token from source to destination", func(t *testing.T) { + t.Parallel() + src := &SecureToken{} + err := src.Set([]byte("source-token")) + require.NoError(t, err) + + dst := &SecureToken{} + dst.TakeFrom(src) + + assert.True(t, dst.IsSet()) + assert.True(t, dst.Equals("source-token")) + assert.False(t, src.IsSet(), "source should be empty after transfer") + }) + + t.Run("replaces existing destination token", func(t *testing.T) { + t.Parallel() + src := &SecureToken{} + err := src.Set([]byte("new-token")) + require.NoError(t, err) + + dst := &SecureToken{} + err = dst.Set([]byte("old-token")) + require.NoError(t, err) + + dst.TakeFrom(src) + + assert.True(t, dst.Equals("new-token")) + assert.False(t, dst.Equals("old-token")) + assert.False(t, src.IsSet()) + }) + + t.Run("handles nil source", func(t *testing.T) { + t.Parallel() + dst := &SecureToken{} + err := dst.Set([]byte("existing-token")) + require.NoError(t, err) + + dst.TakeFrom(nil) + + assert.True(t, dst.IsSet(), "destination should be unchanged with nil source") + assert.True(t, dst.Equals("existing-token")) + }) + + t.Run("handles empty source", func(t *testing.T) { + t.Parallel() + src := &SecureToken{} + dst := &SecureToken{} + err := dst.Set([]byte("existing-token")) + require.NoError(t, err) + + dst.TakeFrom(src) + + assert.False(t, dst.IsSet(), "destination should be cleared when source is empty") + }) + + t.Run("self-transfer is no-op and does not deadlock", func(t *testing.T) { + t.Parallel() + st := &SecureToken{} + err := st.Set([]byte("token")) + require.NoError(t, err) + + st.TakeFrom(st) + + assert.True(t, st.IsSet(), "token should remain set after self-transfer") + assert.True(t, st.Equals("token"), "token value should be unchanged") + }) +} + +func TestSecureTokenEqualsSecure(t *testing.T) { + t.Parallel() + + t.Run("returns true for matching tokens", func(t *testing.T) { + t.Parallel() + st1 := &SecureToken{} + err := st1.Set([]byte("same-token")) + require.NoError(t, err) + + st2 := &SecureToken{} + err = st2.Set([]byte("same-token")) + require.NoError(t, err) + + assert.True(t, st1.EqualsSecure(st2)) + assert.True(t, st2.EqualsSecure(st1)) + }) + + t.Run("concurrent TakeFrom and EqualsSecure do not deadlock", func(t *testing.T) { + t.Parallel() + // This test verifies the fix for the lock ordering deadlock bug. + + const iterations = 100 + + for range iterations { + a := &SecureToken{} + err := a.Set([]byte("token-a")) + require.NoError(t, err) + + b := &SecureToken{} + err = b.Set([]byte("token-b")) + require.NoError(t, err) + + var wg sync.WaitGroup + wg.Add(2) + + // Goroutine 1: a.TakeFrom(b) + go func() { + defer wg.Done() + a.TakeFrom(b) + }() + + // Goroutine 2: b.EqualsSecure(a) + go func() { + defer wg.Done() + b.EqualsSecure(a) + }() + + wg.Wait() + } + }) + + t.Run("returns false for different tokens", func(t *testing.T) { + t.Parallel() + st1 := &SecureToken{} + err := st1.Set([]byte("token-a")) + require.NoError(t, err) + + st2 := &SecureToken{} + err = st2.Set([]byte("token-b")) + require.NoError(t, err) + + assert.False(t, st1.EqualsSecure(st2)) + }) + + t.Run("returns false when comparing with nil", func(t *testing.T) { + t.Parallel() + st := &SecureToken{} + err := st.Set([]byte("token")) + require.NoError(t, err) + + assert.False(t, st.EqualsSecure(nil)) + }) + + t.Run("returns false when other is not set", func(t *testing.T) { + t.Parallel() + st1 := &SecureToken{} + err := st1.Set([]byte("token")) + require.NoError(t, err) + + st2 := &SecureToken{} + + assert.False(t, st1.EqualsSecure(st2)) + }) + + t.Run("returns false when self is not set", func(t *testing.T) { + t.Parallel() + st1 := &SecureToken{} + + st2 := &SecureToken{} + err := st2.Set([]byte("token")) + require.NoError(t, err) + + assert.False(t, st1.EqualsSecure(st2)) + }) + + t.Run("self-comparison returns true when set", func(t *testing.T) { + t.Parallel() + st := &SecureToken{} + err := st.Set([]byte("token")) + require.NoError(t, err) + + assert.True(t, st.EqualsSecure(st), "self-comparison should return true and not deadlock") + }) + + t.Run("self-comparison returns false when not set", func(t *testing.T) { + t.Parallel() + st := &SecureToken{} + + assert.False(t, st.EqualsSecure(st), "self-comparison on unset token should return false") + }) +} diff --git a/envd/internal/api/store.go b/envd/internal/api/store.go new file mode 100644 index 0000000..088222a --- /dev/null +++ b/envd/internal/api/store.go @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "context" + "encoding/json" + "net/http" + "sync" + + "github.com/rs/zerolog" + + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" + "git.omukk.dev/wrenn/sandbox/envd/internal/host" + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" +) + +// MMDSClient provides access to MMDS metadata. +type MMDSClient interface { + GetAccessTokenHash(ctx context.Context) (string, error) +} + +// DefaultMMDSClient is the production implementation that calls the real MMDS endpoint. +type DefaultMMDSClient struct{} + +func (c *DefaultMMDSClient) GetAccessTokenHash(ctx context.Context) (string, error) { + return host.GetAccessTokenHashFromMMDS(ctx) +} + +type API struct { + isNotFC bool + logger *zerolog.Logger + accessToken *SecureToken + defaults *execcontext.Defaults + + mmdsChan chan *host.MMDSOpts + hyperloopLock sync.Mutex + mmdsClient MMDSClient + + lastSetTime *utils.AtomicMax + initLock sync.Mutex +} + +func New(l *zerolog.Logger, defaults *execcontext.Defaults, mmdsChan chan *host.MMDSOpts, isNotFC bool) *API { + return &API{ + logger: l, + defaults: defaults, + mmdsChan: mmdsChan, + isNotFC: isNotFC, + mmdsClient: &DefaultMMDSClient{}, + lastSetTime: utils.NewAtomicMax(), + accessToken: &SecureToken{}, + } +} + +func (a *API) GetHealth(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + a.logger.Trace().Msg("Health check") + + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "") + + w.WriteHeader(http.StatusNoContent) +} + +func (a *API) GetMetrics(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + a.logger.Trace().Msg("Get metrics") + + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", "application/json") + + metrics, err := host.GetMetrics() + if err != nil { + a.logger.Error().Err(err).Msg("Failed to get metrics") + w.WriteHeader(http.StatusInternalServerError) + + return + } + + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(metrics); err != nil { + a.logger.Error().Err(err).Msg("Failed to encode metrics") + } +} + +func (a *API) getLogger(err error) *zerolog.Event { + if err != nil { + return a.logger.Error().Err(err) //nolint:zerologlint // this is only prep + } + + return a.logger.Info() //nolint:zerologlint // this is only prep +} diff --git a/envd/internal/api/upload.go b/envd/internal/api/upload.go new file mode 100644 index 0000000..e42e0b5 --- /dev/null +++ b/envd/internal/api/upload.go @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "os/user" + "path/filepath" + "strings" + "syscall" + + "github.com/rs/zerolog" + + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" +) + +var ErrNoDiskSpace = fmt.Errorf("not enough disk space available") + +func processFile(r *http.Request, path string, part io.Reader, uid, gid int, logger zerolog.Logger) (int, error) { + logger.Debug(). + Str("path", path). + Msg("File processing") + + err := permissions.EnsureDirs(filepath.Dir(path), uid, gid) + if err != nil { + err := fmt.Errorf("error ensuring directories: %w", err) + + return http.StatusInternalServerError, err + } + + canBePreChowned := false + stat, err := os.Stat(path) + if err != nil && !os.IsNotExist(err) { + errMsg := fmt.Errorf("error getting file info: %w", err) + + return http.StatusInternalServerError, errMsg + } else if err == nil { + if stat.IsDir() { + err := fmt.Errorf("path is a directory: %s", path) + + return http.StatusBadRequest, err + } + canBePreChowned = true + } + + hasBeenChowned := false + if canBePreChowned { + err = os.Chown(path, uid, gid) + if err != nil { + if !os.IsNotExist(err) { + err = fmt.Errorf("error changing file ownership: %w", err) + + return http.StatusInternalServerError, err + } + } else { + hasBeenChowned = true + } + } + + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o666) + if err != nil { + if errors.Is(err, syscall.ENOSPC) { + err = fmt.Errorf("not enough inodes available: %w", err) + + return http.StatusInsufficientStorage, err + } + + err := fmt.Errorf("error opening file: %w", err) + + return http.StatusInternalServerError, err + } + + defer file.Close() + + if !hasBeenChowned { + err = os.Chown(path, uid, gid) + if err != nil { + err := fmt.Errorf("error changing file ownership: %w", err) + + return http.StatusInternalServerError, err + } + } + + _, err = file.ReadFrom(part) + if err != nil { + if errors.Is(err, syscall.ENOSPC) { + err = ErrNoDiskSpace + if r.ContentLength > 0 { + err = fmt.Errorf("attempted to write %d bytes: %w", r.ContentLength, err) + } + + return http.StatusInsufficientStorage, err + } + + err = fmt.Errorf("error writing file: %w", err) + + return http.StatusInternalServerError, err + } + + return http.StatusNoContent, nil +} + +func resolvePath(part *multipart.Part, paths *UploadSuccess, u *user.User, defaultPath *string, params PostFilesParams) (string, error) { + var pathToResolve string + + if params.Path != nil { + pathToResolve = *params.Path + } else { + var err error + customPart := utils.NewCustomPart(part) + pathToResolve, err = customPart.FileNameWithPath() + if err != nil { + return "", fmt.Errorf("error getting multipart custom part file name: %w", err) + } + } + + filePath, err := permissions.ExpandAndResolve(pathToResolve, u, defaultPath) + if err != nil { + return "", fmt.Errorf("error resolving path: %w", err) + } + + for _, entry := range *paths { + if entry.Path == filePath { + var alreadyUploaded []string + for _, uploadedFile := range *paths { + if uploadedFile.Path != filePath { + alreadyUploaded = append(alreadyUploaded, uploadedFile.Path) + } + } + + errMsg := fmt.Errorf("you cannot upload multiple files to the same path '%s' in one upload request, only the first specified file was uploaded", filePath) + + if len(alreadyUploaded) > 1 { + errMsg = fmt.Errorf("%w, also the following files were uploaded: %v", errMsg, strings.Join(alreadyUploaded, ", ")) + } + + return "", errMsg + } + } + + return filePath, nil +} + +func (a *API) handlePart(r *http.Request, part *multipart.Part, paths UploadSuccess, u *user.User, uid, gid int, operationID string, params PostFilesParams) (*EntryInfo, int, error) { + defer part.Close() + + if part.FormName() != "file" { + return nil, http.StatusOK, nil + } + + filePath, err := resolvePath(part, &paths, u, a.defaults.Workdir, params) + if err != nil { + return nil, http.StatusBadRequest, err + } + + logger := a.logger. + With(). + Str(string(logs.OperationIDKey), operationID). + Str("event_type", "file_processing"). + Logger() + + status, err := processFile(r, filePath, part, uid, gid, logger) + if err != nil { + return nil, status, err + } + + return &EntryInfo{ + Path: filePath, + Name: filepath.Base(filePath), + Type: File, + }, http.StatusOK, nil +} + +func (a *API) PostFiles(w http.ResponseWriter, r *http.Request, params PostFilesParams) { + // Capture original body to ensure it's always closed + originalBody := r.Body + defer originalBody.Close() + + var errorCode int + var errMsg error + + var path string + if params.Path != nil { + path = *params.Path + } + + operationID := logs.AssignOperationID() + + // signing authorization if needed + err := a.validateSigning(r, params.Signature, params.SignatureExpiration, params.Username, path, SigningWriteOperation) + if err != nil { + a.logger.Error().Err(err).Str(string(logs.OperationIDKey), operationID).Msg("error during auth validation") + jsonError(w, http.StatusUnauthorized, err) + + return + } + + username, err := execcontext.ResolveDefaultUsername(params.Username, a.defaults.User) + if err != nil { + a.logger.Error().Err(err).Str(string(logs.OperationIDKey), operationID).Msg("no user specified") + jsonError(w, http.StatusBadRequest, err) + + return + } + + defer func() { + l := a.logger. + Err(errMsg). + Str("method", r.Method+" "+r.URL.Path). + Str(string(logs.OperationIDKey), operationID). + Str("path", path). + Str("username", username) + + if errMsg != nil { + l = l.Int("error_code", errorCode) + } + + l.Msg("File write") + }() + + // Handle gzip-encoded request body + body, err := getDecompressedBody(r) + if err != nil { + errMsg = fmt.Errorf("error decompressing request body: %w", err) + errorCode = http.StatusBadRequest + jsonError(w, errorCode, errMsg) + + return + } + defer body.Close() + r.Body = body + + f, err := r.MultipartReader() + if err != nil { + errMsg = fmt.Errorf("error parsing multipart form: %w", err) + errorCode = http.StatusInternalServerError + jsonError(w, errorCode, errMsg) + + return + } + + u, err := user.Lookup(username) + if err != nil { + errMsg = fmt.Errorf("error looking up user '%s': %w", username, err) + errorCode = http.StatusUnauthorized + + jsonError(w, errorCode, errMsg) + + return + } + + uid, gid, err := permissions.GetUserIdInts(u) + if err != nil { + errMsg = fmt.Errorf("error getting user ids: %w", err) + + jsonError(w, http.StatusInternalServerError, errMsg) + + return + } + + paths := UploadSuccess{} + + for { + part, partErr := f.NextPart() + + if partErr == io.EOF { + // We're done reading the parts. + break + } else if partErr != nil { + errMsg = fmt.Errorf("error reading form: %w", partErr) + errorCode = http.StatusInternalServerError + jsonError(w, errorCode, errMsg) + + break + } + + entry, status, err := a.handlePart(r, part, paths, u, uid, gid, operationID, params) + if err != nil { + errorCode = status + errMsg = err + jsonError(w, errorCode, errMsg) + + return + } + + if entry != nil { + paths = append(paths, *entry) + } + } + + data, err := json.Marshal(paths) + if err != nil { + errMsg = fmt.Errorf("error marshaling response: %w", err) + errorCode = http.StatusInternalServerError + jsonError(w, errorCode, errMsg) + + return + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) +} diff --git a/envd/internal/api/upload_test.go b/envd/internal/api/upload_test.go new file mode 100644 index 0000000..9a142cb --- /dev/null +++ b/envd/internal/api/upload_test.go @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProcessFile(t *testing.T) { + t.Parallel() + uid := os.Getuid() + gid := os.Getgid() + + newRequest := func(content []byte) (*http.Request, io.Reader) { + request := &http.Request{ + ContentLength: int64(len(content)), + } + buffer := bytes.NewBuffer(content) + + return request, buffer + } + + var emptyReq http.Request + var emptyPart *bytes.Buffer + var emptyLogger zerolog.Logger + + t.Run("failed to ensure directories", func(t *testing.T) { + t.Parallel() + httpStatus, err := processFile(&emptyReq, "/proc/invalid/not-real", emptyPart, uid, gid, emptyLogger) + require.Error(t, err) + assert.Equal(t, http.StatusInternalServerError, httpStatus) + assert.ErrorContains(t, err, "error ensuring directories: ") + }) + + t.Run("attempt to replace directory with a file", func(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + + httpStatus, err := processFile(&emptyReq, tempDir, emptyPart, uid, gid, emptyLogger) + require.Error(t, err) + assert.Equal(t, http.StatusBadRequest, httpStatus, err.Error()) + assert.ErrorContains(t, err, "path is a directory: ") + }) + + t.Run("fail to create file", func(t *testing.T) { + t.Parallel() + httpStatus, err := processFile(&emptyReq, "/proc/invalid-filename", emptyPart, uid, gid, emptyLogger) + require.Error(t, err) + assert.Equal(t, http.StatusInternalServerError, httpStatus) + assert.ErrorContains(t, err, "error opening file: ") + }) + + t.Run("out of disk space", func(t *testing.T) { + t.Parallel() + // make a tiny tmpfs mount + mountSize := 1024 + tempDir := createTmpfsMount(t, mountSize) + + // create test file + firstFileSize := mountSize / 2 + tempFile1 := filepath.Join(tempDir, "test-file-1") + + // fill it up + cmd := exec.CommandContext(t.Context(), + "dd", "if=/dev/zero", "of="+tempFile1, fmt.Sprintf("bs=%d", firstFileSize), "count=1") + err := cmd.Run() + require.NoError(t, err) + + // create a new file that would fill up the + secondFileContents := make([]byte, mountSize*2) + for index := range secondFileContents { + secondFileContents[index] = 'a' + } + + // try to replace it + request, buffer := newRequest(secondFileContents) + tempFile2 := filepath.Join(tempDir, "test-file-2") + httpStatus, err := processFile(request, tempFile2, buffer, uid, gid, emptyLogger) + require.Error(t, err) + assert.Equal(t, http.StatusInsufficientStorage, httpStatus) + assert.ErrorContains(t, err, "attempted to write 2048 bytes: not enough disk space") + }) + + t.Run("happy path", func(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + tempFile := filepath.Join(tempDir, "test-file") + + content := []byte("test-file-contents") + request, buffer := newRequest(content) + + httpStatus, err := processFile(request, tempFile, buffer, uid, gid, emptyLogger) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, httpStatus) + + data, err := os.ReadFile(tempFile) + require.NoError(t, err) + assert.Equal(t, content, data) + }) + + t.Run("overwrite file on full disk", func(t *testing.T) { + t.Parallel() + // make a tiny tmpfs mount + sizeInBytes := 1024 + tempDir := createTmpfsMount(t, 1024) + + // create test file + tempFile := filepath.Join(tempDir, "test-file") + + // fill it up + cmd := exec.CommandContext(t.Context(), "dd", "if=/dev/zero", "of="+tempFile, fmt.Sprintf("bs=%d", sizeInBytes), "count=1") + err := cmd.Run() + require.NoError(t, err) + + // try to replace it + content := []byte("test-file-contents") + request, buffer := newRequest(content) + httpStatus, err := processFile(request, tempFile, buffer, uid, gid, emptyLogger) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, httpStatus) + }) + + t.Run("write new file on full disk", func(t *testing.T) { + t.Parallel() + // make a tiny tmpfs mount + sizeInBytes := 1024 + tempDir := createTmpfsMount(t, 1024) + + // create test file + tempFile1 := filepath.Join(tempDir, "test-file") + + // fill it up + cmd := exec.CommandContext(t.Context(), "dd", "if=/dev/zero", "of="+tempFile1, fmt.Sprintf("bs=%d", sizeInBytes), "count=1") + err := cmd.Run() + require.NoError(t, err) + + // try to write a new file + tempFile2 := filepath.Join(tempDir, "test-file-2") + content := []byte("test-file-contents") + request, buffer := newRequest(content) + httpStatus, err := processFile(request, tempFile2, buffer, uid, gid, emptyLogger) + require.ErrorContains(t, err, "not enough disk space available") + assert.Equal(t, http.StatusInsufficientStorage, httpStatus) + }) + + t.Run("write new file with no inodes available", func(t *testing.T) { + t.Parallel() + // make a tiny tmpfs mount + tempDir := createTmpfsMountWithInodes(t, 1024, 2) + + // create test file + tempFile1 := filepath.Join(tempDir, "test-file") + + // fill it up + cmd := exec.CommandContext(t.Context(), "dd", "if=/dev/zero", "of="+tempFile1, fmt.Sprintf("bs=%d", 100), "count=1") + err := cmd.Run() + require.NoError(t, err) + + // try to write a new file + tempFile2 := filepath.Join(tempDir, "test-file-2") + content := []byte("test-file-contents") + request, buffer := newRequest(content) + httpStatus, err := processFile(request, tempFile2, buffer, uid, gid, emptyLogger) + require.ErrorContains(t, err, "not enough inodes available") + assert.Equal(t, http.StatusInsufficientStorage, httpStatus) + }) + + t.Run("update sysfs or other virtual fs", func(t *testing.T) { + t.Parallel() + if os.Geteuid() != 0 { + t.Skip("skipping sysfs updates: Operation not permitted with non-root user") + } + + filePath := "/sys/fs/cgroup/user.slice/cpu.weight" + newContent := []byte("102\n") + request, buffer := newRequest(newContent) + + httpStatus, err := processFile(request, filePath, buffer, uid, gid, emptyLogger) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, httpStatus) + + data, err := os.ReadFile(filePath) + require.NoError(t, err) + assert.Equal(t, newContent, data) + }) + + t.Run("replace file", func(t *testing.T) { + t.Parallel() + tempDir := t.TempDir() + tempFile := filepath.Join(tempDir, "test-file") + + err := os.WriteFile(tempFile, []byte("old-contents"), 0o644) + require.NoError(t, err) + + newContent := []byte("new-file-contents") + request, buffer := newRequest(newContent) + + httpStatus, err := processFile(request, tempFile, buffer, uid, gid, emptyLogger) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, httpStatus) + + data, err := os.ReadFile(tempFile) + require.NoError(t, err) + assert.Equal(t, newContent, data) + }) +} + +func createTmpfsMount(t *testing.T, sizeInBytes int) string { + t.Helper() + + return createTmpfsMountWithInodes(t, sizeInBytes, 5) +} + +func createTmpfsMountWithInodes(t *testing.T, sizeInBytes, inodesCount int) string { + t.Helper() + + if os.Geteuid() != 0 { + t.Skip("skipping sysfs updates: Operation not permitted with non-root user") + } + + tempDir := t.TempDir() + + cmd := exec.CommandContext(t.Context(), + "mount", + "tmpfs", + tempDir, + "-t", "tmpfs", + "-o", fmt.Sprintf("size=%d,nr_inodes=%d", sizeInBytes, inodesCount)) + err := cmd.Run() + require.NoError(t, err) + t.Cleanup(func() { + ctx := context.WithoutCancel(t.Context()) + cmd := exec.CommandContext(ctx, "umount", tempDir) + err := cmd.Run() + require.NoError(t, err) + }) + + return tempDir +} diff --git a/envd/internal/execcontext/context.go b/envd/internal/execcontext/context.go new file mode 100644 index 0000000..f150d61 --- /dev/null +++ b/envd/internal/execcontext/context.go @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 + +package execcontext + +import ( + "errors" + + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" +) + +type Defaults struct { + EnvVars *utils.Map[string, string] + User string + Workdir *string +} + +func ResolveDefaultWorkdir(workdir string, defaultWorkdir *string) string { + if workdir != "" { + return workdir + } + + if defaultWorkdir != nil { + return *defaultWorkdir + } + + return "" +} + +func ResolveDefaultUsername(username *string, defaultUsername string) (string, error) { + if username != nil { + return *username, nil + } + + if defaultUsername != "" { + return defaultUsername, nil + } + + return "", errors.New("username not provided") +} diff --git a/envd/internal/filesystem/.gitkeep b/envd/internal/filesystem/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/envd/internal/host/metrics.go b/envd/internal/host/metrics.go new file mode 100644 index 0000000..3e80518 --- /dev/null +++ b/envd/internal/host/metrics.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modifications by M/S Omukk + +package host + +import ( + "math" + "time" + + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/mem" + "golang.org/x/sys/unix" +) + +type Metrics struct { + Timestamp int64 `json:"ts"` // Unix Timestamp in UTC + + CPUCount uint32 `json:"cpu_count"` // Total CPU cores + CPUUsedPercent float32 `json:"cpu_used_pct"` // Percent rounded to 2 decimal places + + // Deprecated: kept for backwards compatibility with older orchestrators. + MemTotalMiB uint64 `json:"mem_total_mib"` // Total virtual memory in MiB + + // Deprecated: kept for backwards compatibility with older orchestrators. + MemUsedMiB uint64 `json:"mem_used_mib"` // Used virtual memory in MiB + + MemTotal uint64 `json:"mem_total"` // Total virtual memory in bytes + MemUsed uint64 `json:"mem_used"` // Used virtual memory in bytes + + DiskUsed uint64 `json:"disk_used"` // Used disk space in bytes + DiskTotal uint64 `json:"disk_total"` // Total disk space in bytes +} + +func GetMetrics() (*Metrics, error) { + v, err := mem.VirtualMemory() + if err != nil { + return nil, err + } + + memUsedMiB := v.Used / 1024 / 1024 + memTotalMiB := v.Total / 1024 / 1024 + + cpuTotal, err := cpu.Counts(true) + if err != nil { + return nil, err + } + + cpuUsedPcts, err := cpu.Percent(0, false) + if err != nil { + return nil, err + } + + cpuUsedPct := cpuUsedPcts[0] + cpuUsedPctRounded := float32(cpuUsedPct) + if cpuUsedPct > 0 { + cpuUsedPctRounded = float32(math.Round(cpuUsedPct*100) / 100) + } + + diskMetrics, err := diskStats("/") + if err != nil { + return nil, err + } + + return &Metrics{ + Timestamp: time.Now().UTC().Unix(), + CPUCount: uint32(cpuTotal), + CPUUsedPercent: cpuUsedPctRounded, + MemUsedMiB: memUsedMiB, + MemTotalMiB: memTotalMiB, + MemTotal: v.Total, + MemUsed: v.Used, + DiskUsed: diskMetrics.Total - diskMetrics.Available, + DiskTotal: diskMetrics.Total, + }, nil +} + +type diskSpace struct { + Total uint64 + Available uint64 +} + +func diskStats(path string) (diskSpace, error) { + var st unix.Statfs_t + if err := unix.Statfs(path, &st); err != nil { + return diskSpace{}, err + } + + block := uint64(st.Bsize) + + // all data blocks + total := st.Blocks * block + // blocks available + available := st.Bavail * block + + return diskSpace{Total: total, Available: available}, nil +} diff --git a/envd/internal/host/mmds.go b/envd/internal/host/mmds.go new file mode 100644 index 0000000..99efcf2 --- /dev/null +++ b/envd/internal/host/mmds.go @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modifications by M/S Omukk + +package host + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" +) + +const ( + WrennRunDir = "/run/wrenn" // store sandbox metadata files here + + mmdsDefaultAddress = "169.254.169.254" + mmdsTokenExpiration = 60 * time.Second + + mmdsAccessTokenRequestClientTimeout = 10 * time.Second +) + +var mmdsAccessTokenClient = &http.Client{ + Timeout: mmdsAccessTokenRequestClientTimeout, + Transport: &http.Transport{ + DisableKeepAlives: true, + }, +} + +type MMDSOpts struct { + SandboxID string `json:"instanceID"` + TemplateID string `json:"envID"` + LogsCollectorAddress string `json:"address"` + AccessTokenHash string `json:"accessTokenHash"` +} + +func (opts *MMDSOpts) Update(sandboxID, templateID, collectorAddress string) { + opts.SandboxID = sandboxID + opts.TemplateID = templateID + opts.LogsCollectorAddress = collectorAddress +} + +func (opts *MMDSOpts) AddOptsToJSON(jsonLogs []byte) ([]byte, error) { + parsed := make(map[string]any) + + err := json.Unmarshal(jsonLogs, &parsed) + if err != nil { + return nil, err + } + + parsed["instanceID"] = opts.SandboxID + parsed["envID"] = opts.TemplateID + + data, err := json.Marshal(parsed) + + return data, err +} + +func getMMDSToken(ctx context.Context, client *http.Client) (string, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodPut, "http://"+mmdsDefaultAddress+"/latest/api/token", &bytes.Buffer{}) + if err != nil { + return "", err + } + + request.Header["X-metadata-token-ttl-seconds"] = []string{fmt.Sprint(mmdsTokenExpiration.Seconds())} + + response, err := client.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return "", err + } + + token := string(body) + + if len(token) == 0 { + return "", fmt.Errorf("mmds token is an empty string") + } + + return token, nil +} + +func getMMDSOpts(ctx context.Context, client *http.Client, token string) (*MMDSOpts, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+mmdsDefaultAddress, &bytes.Buffer{}) + if err != nil { + return nil, err + } + + request.Header["X-metadata-token"] = []string{token} + request.Header["Accept"] = []string{"application/json"} + + response, err := client.Do(request) + if err != nil { + return nil, err + } + + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + var opts MMDSOpts + + err = json.Unmarshal(body, &opts) + if err != nil { + return nil, err + } + + return &opts, nil +} + +// GetAccessTokenHashFromMMDS reads the access token hash from MMDS. +// This is used to validate that /init requests come from the orchestrator. +func GetAccessTokenHashFromMMDS(ctx context.Context) (string, error) { + token, err := getMMDSToken(ctx, mmdsAccessTokenClient) + if err != nil { + return "", fmt.Errorf("failed to get MMDS token: %w", err) + } + + opts, err := getMMDSOpts(ctx, mmdsAccessTokenClient, token) + if err != nil { + return "", fmt.Errorf("failed to get MMDS opts: %w", err) + } + + return opts.AccessTokenHash, nil +} + +func PollForMMDSOpts(ctx context.Context, mmdsChan chan<- *MMDSOpts, envVars *utils.Map[string, string]) { + httpClient := &http.Client{} + defer httpClient.CloseIdleConnections() + + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + fmt.Fprintf(os.Stderr, "context cancelled while waiting for mmds opts") + + return + case <-ticker.C: + token, err := getMMDSToken(ctx, httpClient) + if err != nil { + fmt.Fprintf(os.Stderr, "error getting mmds token: %v\n", err) + + continue + } + + mmdsOpts, err := getMMDSOpts(ctx, httpClient, token) + if err != nil { + fmt.Fprintf(os.Stderr, "error getting mmds opts: %v\n", err) + + continue + } + + envVars.Store("WRENN_SANDBOX_ID", mmdsOpts.SandboxID) + envVars.Store("WRENN_TEMPLATE_ID", mmdsOpts.TemplateID) + + if err := os.WriteFile(filepath.Join(WrennRunDir, ".WRENN_SANDBOX_ID"), []byte(mmdsOpts.SandboxID), 0o666); err != nil { + fmt.Fprintf(os.Stderr, "error writing sandbox ID file: %v\n", err) + } + if err := os.WriteFile(filepath.Join(WrennRunDir, ".WRENN_TEMPLATE_ID"), []byte(mmdsOpts.TemplateID), 0o666); err != nil { + fmt.Fprintf(os.Stderr, "error writing template ID file: %v\n", err) + } + + if mmdsOpts.LogsCollectorAddress != "" { + mmdsChan <- mmdsOpts + } + + return + } + } +} diff --git a/envd/internal/logs/bufferedEvents.go b/envd/internal/logs/bufferedEvents.go new file mode 100644 index 0000000..24d5158 --- /dev/null +++ b/envd/internal/logs/bufferedEvents.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 + +package logs + +import ( + "time" + + "github.com/rs/zerolog" +) + +const ( + defaultMaxBufferSize = 2 << 15 + defaultTimeout = 2 * time.Second +) + +func LogBufferedDataEvents(dataCh <-chan []byte, logger *zerolog.Logger, eventType string) { + timer := time.NewTicker(defaultTimeout) + defer timer.Stop() + + var buffer []byte + defer func() { + if len(buffer) > 0 { + logger.Info().Str(eventType, string(buffer)).Msg("Streaming process event (flush)") + } + }() + + for { + select { + case <-timer.C: + if len(buffer) > 0 { + logger.Info().Str(eventType, string(buffer)).Msg("Streaming process event") + buffer = nil + } + case data, ok := <-dataCh: + if !ok { + return + } + + buffer = append(buffer, data...) + + if len(buffer) >= defaultMaxBufferSize { + logger.Info().Str(eventType, string(buffer)).Msg("Streaming process event") + buffer = nil + + continue + } + } + } +} diff --git a/envd/internal/logs/exporter/exporter.go b/envd/internal/logs/exporter/exporter.go new file mode 100644 index 0000000..038ef51 --- /dev/null +++ b/envd/internal/logs/exporter/exporter.go @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: Apache-2.0 + +package exporter + +import ( + "bytes" + "context" + "fmt" + "log" + "net/http" + "os" + "sync" + "time" + + "git.omukk.dev/wrenn/sandbox/envd/internal/host" +) + +const ExporterTimeout = 10 * time.Second + +type HTTPExporter struct { + client http.Client + logs [][]byte + isNotFC bool + mmdsOpts *host.MMDSOpts + + // Concurrency coordination + triggers chan struct{} + logLock sync.RWMutex + mmdsLock sync.RWMutex + startOnce sync.Once +} + +func NewHTTPLogsExporter(ctx context.Context, isNotFC bool, mmdsChan <-chan *host.MMDSOpts) *HTTPExporter { + exporter := &HTTPExporter{ + client: http.Client{ + Timeout: ExporterTimeout, + }, + triggers: make(chan struct{}, 1), + isNotFC: isNotFC, + startOnce: sync.Once{}, + mmdsOpts: &host.MMDSOpts{ + SandboxID: "unknown", + TemplateID: "unknown", + LogsCollectorAddress: "", + }, + } + + go exporter.listenForMMDSOptsAndStart(ctx, mmdsChan) + + return exporter +} + +func (w *HTTPExporter) sendInstanceLogs(ctx context.Context, logs []byte, address string) error { + if address == "" { + return nil + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPost, address, bytes.NewBuffer(logs)) + if err != nil { + return err + } + + request.Header.Set("Content-Type", "application/json") + + response, err := w.client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + + return nil +} + +func printLog(logs []byte) { + fmt.Fprintf(os.Stdout, "%v", string(logs)) +} + +func (w *HTTPExporter) listenForMMDSOptsAndStart(ctx context.Context, mmdsChan <-chan *host.MMDSOpts) { + for { + select { + case <-ctx.Done(): + return + case mmdsOpts, ok := <-mmdsChan: + if !ok { + return + } + + w.mmdsLock.Lock() + w.mmdsOpts.Update(mmdsOpts.SandboxID, mmdsOpts.TemplateID, mmdsOpts.LogsCollectorAddress) + w.mmdsLock.Unlock() + + w.startOnce.Do(func() { + go w.start(ctx) + }) + } + } +} + +func (w *HTTPExporter) start(ctx context.Context) { + for range w.triggers { + logs := w.getAllLogs() + + if len(logs) == 0 { + continue + } + + if w.isNotFC { + for _, log := range logs { + fmt.Fprintf(os.Stdout, "%v", string(log)) + } + + continue + } + + for _, logLine := range logs { + w.mmdsLock.RLock() + logLineWithOpts, err := w.mmdsOpts.AddOptsToJSON(logLine) + w.mmdsLock.RUnlock() + if err != nil { + log.Printf("error adding instance logging options (%+v) to JSON (%+v) with logs : %v\n", w.mmdsOpts, logLine, err) + + printLog(logLine) + + continue + } + + err = w.sendInstanceLogs(ctx, logLineWithOpts, w.mmdsOpts.LogsCollectorAddress) + if err != nil { + log.Printf("error sending instance logs: %+v", err) + + printLog(logLine) + + continue + } + } + } +} + +func (w *HTTPExporter) resumeProcessing() { + select { + case w.triggers <- struct{}{}: + default: + // Exporter processing already triggered + // This is expected behavior if the exporter is already processing logs + } +} + +func (w *HTTPExporter) Write(logs []byte) (int, error) { + logsCopy := make([]byte, len(logs)) + copy(logsCopy, logs) + + go w.addLogs(logsCopy) + + return len(logs), nil +} + +func (w *HTTPExporter) getAllLogs() [][]byte { + w.logLock.Lock() + defer w.logLock.Unlock() + + logs := w.logs + w.logs = nil + + return logs +} + +func (w *HTTPExporter) addLogs(logs []byte) { + w.logLock.Lock() + defer w.logLock.Unlock() + + w.logs = append(w.logs, logs) + + w.resumeProcessing() +} diff --git a/envd/internal/logs/interceptor.go b/envd/internal/logs/interceptor.go new file mode 100644 index 0000000..2aa7c83 --- /dev/null +++ b/envd/internal/logs/interceptor.go @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: Apache-2.0 + +package logs + +import ( + "context" + "fmt" + "strconv" + "strings" + "sync/atomic" + + "connectrpc.com/connect" + "github.com/rs/zerolog" +) + +type OperationID string + +const ( + OperationIDKey OperationID = "operation_id" + DefaultHTTPMethod string = "POST" +) + +var operationID = atomic.Int32{} + +func AssignOperationID() string { + id := operationID.Add(1) + + return strconv.Itoa(int(id)) +} + +func AddRequestIDToContext(ctx context.Context) context.Context { + return context.WithValue(ctx, OperationIDKey, AssignOperationID()) +} + +func formatMethod(method string) string { + parts := strings.Split(method, ".") + if len(parts) < 2 { + return method + } + + split := strings.Split(parts[1], "/") + if len(split) < 2 { + return method + } + + servicePart := split[0] + servicePart = strings.ToUpper(servicePart[:1]) + servicePart[1:] + + methodPart := split[1] + methodPart = strings.ToLower(methodPart[:1]) + methodPart[1:] + + return fmt.Sprintf("%s %s", servicePart, methodPart) +} + +func NewUnaryLogInterceptor(logger *zerolog.Logger) connect.UnaryInterceptorFunc { + interceptor := func(next connect.UnaryFunc) connect.UnaryFunc { + return connect.UnaryFunc(func( + ctx context.Context, + req connect.AnyRequest, + ) (connect.AnyResponse, error) { + ctx = AddRequestIDToContext(ctx) + + res, err := next(ctx, req) + + l := logger. + Err(err). + Str("method", DefaultHTTPMethod+" "+req.Spec().Procedure). + Str(string(OperationIDKey), ctx.Value(OperationIDKey).(string)) + + if err != nil { + l = l.Int("error_code", int(connect.CodeOf(err))) + } + + if req != nil { + l = l.Interface("request", req.Any()) + } + + if res != nil && err == nil { + l = l.Interface("response", res.Any()) + } + + if res == nil && err == nil { + l = l.Interface("response", nil) + } + + l.Msg(formatMethod(req.Spec().Procedure)) + + return res, err + }) + } + + return connect.UnaryInterceptorFunc(interceptor) +} + +func LogServerStreamWithoutEvents[T any, R any]( + ctx context.Context, + logger *zerolog.Logger, + req *connect.Request[R], + stream *connect.ServerStream[T], + handler func(ctx context.Context, req *connect.Request[R], stream *connect.ServerStream[T]) error, +) error { + ctx = AddRequestIDToContext(ctx) + + l := logger.Debug(). + Str("method", DefaultHTTPMethod+" "+req.Spec().Procedure). + Str(string(OperationIDKey), ctx.Value(OperationIDKey).(string)) + + if req != nil { + l = l.Interface("request", req.Any()) + } + + l.Msg(fmt.Sprintf("%s (server stream start)", formatMethod(req.Spec().Procedure))) + + err := handler(ctx, req, stream) + + logEvent := getErrDebugLogEvent(logger, err). + Str("method", DefaultHTTPMethod+" "+req.Spec().Procedure). + Str(string(OperationIDKey), ctx.Value(OperationIDKey).(string)) + + if err != nil { + logEvent = logEvent.Int("error_code", int(connect.CodeOf(err))) + } else { + logEvent = logEvent.Interface("response", nil) + } + + logEvent.Msg(fmt.Sprintf("%s (server stream end)", formatMethod(req.Spec().Procedure))) + + return err +} + +func LogClientStreamWithoutEvents[T any, R any]( + ctx context.Context, + logger *zerolog.Logger, + stream *connect.ClientStream[T], + handler func(ctx context.Context, stream *connect.ClientStream[T]) (*connect.Response[R], error), +) (*connect.Response[R], error) { + ctx = AddRequestIDToContext(ctx) + + logger.Debug(). + Str("method", DefaultHTTPMethod+" "+stream.Spec().Procedure). + Str(string(OperationIDKey), ctx.Value(OperationIDKey).(string)). + Msg(fmt.Sprintf("%s (client stream start)", formatMethod(stream.Spec().Procedure))) + + res, err := handler(ctx, stream) + + logEvent := getErrDebugLogEvent(logger, err). + Str("method", DefaultHTTPMethod+" "+stream.Spec().Procedure). + Str(string(OperationIDKey), ctx.Value(OperationIDKey).(string)) + + if err != nil { + logEvent = logEvent.Int("error_code", int(connect.CodeOf(err))) + } + + if res != nil && err == nil { + logEvent = logEvent.Interface("response", res.Any()) + } + + if res == nil && err == nil { + logEvent = logEvent.Interface("response", nil) + } + + logEvent.Msg(fmt.Sprintf("%s (client stream end)", formatMethod(stream.Spec().Procedure))) + + return res, err +} + +// Return logger with error level if err is not nil, otherwise return logger with debug level +func getErrDebugLogEvent(logger *zerolog.Logger, err error) *zerolog.Event { + if err != nil { + return logger.Error().Err(err) //nolint:zerologlint // this builds an event, it is not expected to return it + } + + return logger.Debug() //nolint:zerologlint // this builds an event, it is not expected to return it +} diff --git a/envd/internal/logs/logger.go b/envd/internal/logs/logger.go new file mode 100644 index 0000000..ff17b0b --- /dev/null +++ b/envd/internal/logs/logger.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 + +package logs + +import ( + "context" + "io" + "os" + "time" + + "github.com/rs/zerolog" + + "git.omukk.dev/wrenn/sandbox/envd/internal/host" + "git.omukk.dev/wrenn/sandbox/envd/internal/logs/exporter" +) + +func NewLogger(ctx context.Context, isNotFC bool, mmdsChan <-chan *host.MMDSOpts) *zerolog.Logger { + zerolog.TimestampFieldName = "timestamp" + zerolog.TimeFieldFormat = time.RFC3339Nano + + exporters := []io.Writer{} + + if isNotFC { + exporters = append(exporters, os.Stdout) + } else { + exporters = append(exporters, exporter.NewHTTPLogsExporter(ctx, isNotFC, mmdsChan), os.Stdout) + } + + l := zerolog. + New(io.MultiWriter(exporters...)). + With(). + Timestamp(). + Logger(). + Level(zerolog.DebugLevel) + + return &l +} diff --git a/envd/internal/network/.gitkeep b/envd/internal/network/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/envd/internal/permissions/authenticate.go b/envd/internal/permissions/authenticate.go new file mode 100644 index 0000000..1b799ad --- /dev/null +++ b/envd/internal/permissions/authenticate.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 + +package permissions + +import ( + "context" + "fmt" + "os/user" + + "connectrpc.com/authn" + "connectrpc.com/connect" + + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" +) + +func AuthenticateUsername(_ context.Context, req authn.Request) (any, error) { + username, _, ok := req.BasicAuth() + if !ok { + // When no username is provided, ignore the authentication method (not all endpoints require it) + // Missing user is then handled in the GetAuthUser function + return nil, nil + } + + u, err := GetUser(username) + if err != nil { + return nil, authn.Errorf("invalid username: '%s'", username) + } + + return u, nil +} + +func GetAuthUser(ctx context.Context, defaultUser string) (*user.User, error) { + u, ok := authn.GetInfo(ctx).(*user.User) + if !ok { + username, err := execcontext.ResolveDefaultUsername(nil, defaultUser) + if err != nil { + return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("no user specified")) + } + + u, err := GetUser(username) + if err != nil { + return nil, authn.Errorf("invalid default user: '%s'", username) + } + + return u, nil + } + + return u, nil +} diff --git a/envd/internal/permissions/keepalive.go b/envd/internal/permissions/keepalive.go new file mode 100644 index 0000000..e39b38b --- /dev/null +++ b/envd/internal/permissions/keepalive.go @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 + +package permissions + +import ( + "strconv" + "time" + + "connectrpc.com/connect" +) + +const defaultKeepAliveInterval = 90 * time.Second + +func GetKeepAliveTicker[T any](req *connect.Request[T]) (*time.Ticker, func()) { + keepAliveIntervalHeader := req.Header().Get("Keepalive-Ping-Interval") + + var interval time.Duration + + keepAliveIntervalInt, err := strconv.Atoi(keepAliveIntervalHeader) + if err != nil { + interval = defaultKeepAliveInterval + } else { + interval = time.Duration(keepAliveIntervalInt) * time.Second + } + + ticker := time.NewTicker(interval) + + return ticker, func() { + ticker.Reset(interval) + } +} diff --git a/envd/internal/permissions/path.go b/envd/internal/permissions/path.go new file mode 100644 index 0000000..9a15495 --- /dev/null +++ b/envd/internal/permissions/path.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 + +package permissions + +import ( + "errors" + "fmt" + "os" + "os/user" + "path/filepath" + "slices" + + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" +) + +func expand(path, homedir string) (string, error) { + if len(path) == 0 { + return path, nil + } + + if path[0] != '~' { + return path, nil + } + + if len(path) > 1 && path[1] != '/' && path[1] != '\\' { + return "", errors.New("cannot expand user-specific home dir") + } + + return filepath.Join(homedir, path[1:]), nil +} + +func ExpandAndResolve(path string, user *user.User, defaultPath *string) (string, error) { + path = execcontext.ResolveDefaultWorkdir(path, defaultPath) + + path, err := expand(path, user.HomeDir) + if err != nil { + return "", fmt.Errorf("failed to expand path '%s' for user '%s': %w", path, user.Username, err) + } + + if filepath.IsAbs(path) { + return path, nil + } + + // The filepath.Abs can correctly resolve paths like /home/user/../file + path = filepath.Join(user.HomeDir, path) + + abs, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("failed to resolve path '%s' for user '%s' with home dir '%s': %w", path, user.Username, user.HomeDir, err) + } + + return abs, nil +} + +func getSubpaths(path string) (subpaths []string) { + for { + subpaths = append(subpaths, path) + + path = filepath.Dir(path) + if path == "/" { + break + } + } + + slices.Reverse(subpaths) + + return subpaths +} + +func EnsureDirs(path string, uid, gid int) error { + subpaths := getSubpaths(path) + for _, subpath := range subpaths { + info, err := os.Stat(subpath) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to stat directory: %w", err) + } + + if err != nil && os.IsNotExist(err) { + err = os.Mkdir(subpath, 0o755) + if err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + err = os.Chown(subpath, uid, gid) + if err != nil { + return fmt.Errorf("failed to chown directory: %w", err) + } + + continue + } + + if !info.IsDir() { + return fmt.Errorf("path is a file: %s", subpath) + } + } + + return nil +} diff --git a/envd/internal/permissions/user.go b/envd/internal/permissions/user.go new file mode 100644 index 0000000..e2d3ffd --- /dev/null +++ b/envd/internal/permissions/user.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 + +package permissions + +import ( + "fmt" + "os/user" + "strconv" +) + +func GetUserIdUints(u *user.User) (uid, gid uint32, err error) { + newUID, err := strconv.ParseUint(u.Uid, 10, 32) + if err != nil { + return 0, 0, fmt.Errorf("error parsing uid '%s': %w", u.Uid, err) + } + + newGID, err := strconv.ParseUint(u.Gid, 10, 32) + if err != nil { + return 0, 0, fmt.Errorf("error parsing gid '%s': %w", u.Gid, err) + } + + return uint32(newUID), uint32(newGID), nil +} + +func GetUserIdInts(u *user.User) (uid, gid int, err error) { + newUID, err := strconv.ParseInt(u.Uid, 10, strconv.IntSize) + if err != nil { + return 0, 0, fmt.Errorf("error parsing uid '%s': %w", u.Uid, err) + } + + newGID, err := strconv.ParseInt(u.Gid, 10, strconv.IntSize) + if err != nil { + return 0, 0, fmt.Errorf("error parsing gid '%s': %w", u.Gid, err) + } + + return int(newUID), int(newGID), nil +} + +func GetUser(username string) (u *user.User, err error) { + u, err = user.Lookup(username) + if err != nil { + return nil, fmt.Errorf("error looking up user '%s': %w", username, err) + } + + return u, nil +} diff --git a/envd/internal/port/forward.go b/envd/internal/port/forward.go new file mode 100644 index 0000000..e836519 --- /dev/null +++ b/envd/internal/port/forward.go @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: Apache-2.0 + +// portf (port forward) periodaically scans opened TCP ports on the 127.0.0.1 (or localhost) +// and launches `socat` process for every such port in the background. +// socat forward traffic from `sourceIP`:port to the 127.0.0.1:port. + +// WARNING: portf isn't thread safe! + +package port + +import ( + "context" + "fmt" + "net" + "os/exec" + "syscall" + + "github.com/rs/zerolog" + + "git.omukk.dev/wrenn/sandbox/envd/internal/services/cgroups" +) + +type PortState string + +const ( + PortStateForward PortState = "FORWARD" + PortStateDelete PortState = "DELETE" +) + +var defaultGatewayIP = net.IPv4(169, 254, 0, 21) + +type PortToForward struct { + socat *exec.Cmd + // Process ID of the process that's listening on port. + pid int32 + // family version of the ip. + family uint32 + state PortState + port uint32 +} + +type Forwarder struct { + logger *zerolog.Logger + cgroupManager cgroups.Manager + // Map of ports that are being currently forwarded. + ports map[string]*PortToForward + scannerSubscriber *ScannerSubscriber + sourceIP net.IP +} + +func NewForwarder( + logger *zerolog.Logger, + scanner *Scanner, + cgroupManager cgroups.Manager, +) *Forwarder { + scannerSub := scanner.AddSubscriber( + logger, + "port-forwarder", + // We only want to forward ports that are actively listening on localhost. + &ScannerFilter{ + IPs: []string{"127.0.0.1", "localhost", "::1"}, + State: "LISTEN", + }, + ) + + return &Forwarder{ + logger: logger, + sourceIP: defaultGatewayIP, + ports: make(map[string]*PortToForward), + scannerSubscriber: scannerSub, + cgroupManager: cgroupManager, + } +} + +func (f *Forwarder) StartForwarding(ctx context.Context) { + if f.scannerSubscriber == nil { + f.logger.Error().Msg("Cannot start forwarding because scanner subscriber is nil") + + return + } + + for { + // procs is an array of currently opened ports. + if procs, ok := <-f.scannerSubscriber.Messages; ok { + // Now we are going to refresh all ports that are being forwarded in the `ports` map. Maybe add new ones + // and maybe remove some. + + // Go through the ports that are currently being forwarded and set all of them + // to the `DELETE` state. We don't know yet if they will be there after refresh. + for _, v := range f.ports { + v.state = PortStateDelete + } + + // Let's refresh our map of currently forwarded ports and mark the currently opened ones with the "FORWARD" state. + // This will make sure we won't delete them later. + for _, p := range procs { + key := fmt.Sprintf("%d-%d", p.Pid, p.Laddr.Port) + + // We check if the opened port is in our map of forwarded ports. + val, portOk := f.ports[key] + if portOk { + // Just mark the port as being forwarded so we don't delete it. + // The actual socat process that handles forwarding should be running from the last iteration. + val.state = PortStateForward + } else { + f.logger.Debug(). + Str("ip", p.Laddr.IP). + Uint32("port", p.Laddr.Port). + Uint32("family", familyToIPVersion(p.Family)). + Str("state", p.Status). + Msg("Detected new opened port on localhost that is not forwarded") + + // The opened port wasn't in the map so we create a new PortToForward and start forwarding. + ptf := &PortToForward{ + pid: p.Pid, + port: p.Laddr.Port, + state: PortStateForward, + family: familyToIPVersion(p.Family), + } + f.ports[key] = ptf + f.startPortForwarding(ctx, ptf) + } + } + + // We go through the ports map one more time and stop forwarding all ports + // that stayed marked as "DELETE". + for _, v := range f.ports { + if v.state == PortStateDelete { + f.stopPortForwarding(v) + } + } + } + } +} + +func (f *Forwarder) startPortForwarding(ctx context.Context, p *PortToForward) { + // https://unix.stackexchange.com/questions/311492/redirect-application-listening-on-localhost-to-listening-on-external-interface + // socat -d -d TCP4-LISTEN:4000,bind=169.254.0.21,fork TCP4:localhost:4000 + // reuseaddr is used to fix the "Address already in use" error when restarting socat quickly. + cmd := exec.CommandContext(ctx, + "socat", "-d", "-d", "-d", + fmt.Sprintf("TCP4-LISTEN:%v,bind=%s,reuseaddr,fork", p.port, f.sourceIP.To4()), + fmt.Sprintf("TCP%d:localhost:%v", p.family, p.port), + ) + + cgroupFD, ok := f.cgroupManager.GetFileDescriptor(cgroups.ProcessTypeSocat) + + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + CgroupFD: cgroupFD, + UseCgroupFD: ok, + } + + f.logger.Debug(). + Str("socatCmd", cmd.String()). + Int32("pid", p.pid). + Uint32("family", p.family). + IPAddr("sourceIP", f.sourceIP.To4()). + Uint32("port", p.port). + Msg("About to start port forwarding") + + if err := cmd.Start(); err != nil { + f.logger. + Error(). + Str("socatCmd", cmd.String()). + Err(err). + Msg("Failed to start port forwarding - failed to start socat") + + return + } + + go func() { + if err := cmd.Wait(); err != nil { + f.logger. + Debug(). + Str("socatCmd", cmd.String()). + Err(err). + Msg("Port forwarding socat process exited") + } + }() + + p.socat = cmd +} + +func (f *Forwarder) stopPortForwarding(p *PortToForward) { + if p.socat == nil { + return + } + + defer func() { p.socat = nil }() + + logger := f.logger.With(). + Str("socatCmd", p.socat.String()). + Int32("pid", p.pid). + Uint32("family", p.family). + IPAddr("sourceIP", f.sourceIP.To4()). + Uint32("port", p.port). + Logger() + + logger.Debug().Msg("Stopping port forwarding") + + if err := syscall.Kill(-p.socat.Process.Pid, syscall.SIGKILL); err != nil { + logger.Error().Err(err).Msg("Failed to kill process group") + + return + } + + logger.Debug().Msg("Stopped port forwarding") +} + +func familyToIPVersion(family uint32) uint32 { + switch family { + case syscall.AF_INET: + return 4 + case syscall.AF_INET6: + return 6 + default: + return 0 // Unknown or unsupported family + } +} diff --git a/envd/internal/port/scan.go b/envd/internal/port/scan.go new file mode 100644 index 0000000..766202a --- /dev/null +++ b/envd/internal/port/scan.go @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 + +package port + +import ( + "time" + + "github.com/rs/zerolog" + "github.com/shirou/gopsutil/v4/net" + + "git.omukk.dev/wrenn/sandbox/envd/internal/shared/smap" +) + +type Scanner struct { + Processes chan net.ConnectionStat + scanExit chan struct{} + subs *smap.Map[*ScannerSubscriber] + period time.Duration +} + +func (s *Scanner) Destroy() { + close(s.scanExit) +} + +func NewScanner(period time.Duration) *Scanner { + return &Scanner{ + period: period, + subs: smap.New[*ScannerSubscriber](), + scanExit: make(chan struct{}), + Processes: make(chan net.ConnectionStat), + } +} + +func (s *Scanner) AddSubscriber(logger *zerolog.Logger, id string, filter *ScannerFilter) *ScannerSubscriber { + subscriber := NewScannerSubscriber(logger, id, filter) + s.subs.Insert(id, subscriber) + + return subscriber +} + +func (s *Scanner) Unsubscribe(sub *ScannerSubscriber) { + s.subs.Remove(sub.ID()) + sub.Destroy() +} + +// ScanAndBroadcast starts scanning open TCP ports and broadcasts every open port to all subscribers. +func (s *Scanner) ScanAndBroadcast() { + for { + // tcp monitors both ipv4 and ipv6 connections. + processes, _ := net.Connections("tcp") + for _, sub := range s.subs.Items() { + sub.Signal(processes) + } + select { + case <-s.scanExit: + return + default: + time.Sleep(s.period) + } + } +} diff --git a/envd/internal/port/scanSubscriber.go b/envd/internal/port/scanSubscriber.go new file mode 100644 index 0000000..6a4f5b0 --- /dev/null +++ b/envd/internal/port/scanSubscriber.go @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 + +package port + +import ( + "github.com/rs/zerolog" + "github.com/shirou/gopsutil/v4/net" +) + +// If we want to create a listener/subscriber pattern somewhere else we should move +// from a concrete implementation to combination of generics and interfaces. + +type ScannerSubscriber struct { + logger *zerolog.Logger + filter *ScannerFilter + Messages chan ([]net.ConnectionStat) + id string +} + +func NewScannerSubscriber(logger *zerolog.Logger, id string, filter *ScannerFilter) *ScannerSubscriber { + return &ScannerSubscriber{ + logger: logger, + id: id, + filter: filter, + Messages: make(chan []net.ConnectionStat), + } +} + +func (ss *ScannerSubscriber) ID() string { + return ss.id +} + +func (ss *ScannerSubscriber) Destroy() { + close(ss.Messages) +} + +func (ss *ScannerSubscriber) Signal(proc []net.ConnectionStat) { + // Filter isn't specified. Accept everything. + if ss.filter == nil { + ss.Messages <- proc + } else { + filtered := []net.ConnectionStat{} + for i := range proc { + // We need to access the list directly otherwise there will be implicit memory aliasing + // If the filter matched a process, we will send it to a channel. + if ss.filter.Match(&proc[i]) { + filtered = append(filtered, proc[i]) + } + } + ss.Messages <- filtered + } +} diff --git a/envd/internal/port/scanfilter.go b/envd/internal/port/scanfilter.go new file mode 100644 index 0000000..941023d --- /dev/null +++ b/envd/internal/port/scanfilter.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 + +package port + +import ( + "slices" + + "github.com/shirou/gopsutil/v4/net" +) + +type ScannerFilter struct { + State string + IPs []string +} + +func (sf *ScannerFilter) Match(proc *net.ConnectionStat) bool { + // Filter is an empty struct. + if sf.State == "" && len(sf.IPs) == 0 { + return false + } + + ipMatch := slices.Contains(sf.IPs, proc.Laddr.IP) + + if ipMatch && sf.State == proc.Status { + return true + } + + return false +} diff --git a/envd/internal/process/.gitkeep b/envd/internal/process/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/envd/internal/server/.gitkeep b/envd/internal/server/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/envd/internal/services/cgroups/cgroup2.go b/envd/internal/services/cgroups/cgroup2.go new file mode 100644 index 0000000..b60251e --- /dev/null +++ b/envd/internal/services/cgroups/cgroup2.go @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cgroups + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "golang.org/x/sys/unix" +) + +type Cgroup2Manager struct { + cgroupFDs map[ProcessType]int +} + +var _ Manager = (*Cgroup2Manager)(nil) + +type cgroup2Config struct { + rootPath string + processTypes map[ProcessType]Cgroup2Config +} + +type Cgroup2ManagerOption func(*cgroup2Config) + +func WithCgroup2RootSysFSPath(path string) Cgroup2ManagerOption { + return func(config *cgroup2Config) { + config.rootPath = path + } +} + +func WithCgroup2ProcessType(processType ProcessType, path string, properties map[string]string) Cgroup2ManagerOption { + return func(config *cgroup2Config) { + if config.processTypes == nil { + config.processTypes = make(map[ProcessType]Cgroup2Config) + } + config.processTypes[processType] = Cgroup2Config{Path: path, Properties: properties} + } +} + +type Cgroup2Config struct { + Path string + Properties map[string]string +} + +func NewCgroup2Manager(opts ...Cgroup2ManagerOption) (*Cgroup2Manager, error) { + config := cgroup2Config{ + rootPath: "/sys/fs/cgroup", + } + + for _, opt := range opts { + opt(&config) + } + + cgroupFDs, err := createCgroups(config) + if err != nil { + return nil, fmt.Errorf("failed to create cgroups: %w", err) + } + + return &Cgroup2Manager{cgroupFDs: cgroupFDs}, nil +} + +func createCgroups(configs cgroup2Config) (map[ProcessType]int, error) { + var ( + results = make(map[ProcessType]int) + errs []error + ) + + for procType, config := range configs.processTypes { + fullPath := filepath.Join(configs.rootPath, config.Path) + fd, err := createCgroup(fullPath, config.Properties) + if err != nil { + errs = append(errs, fmt.Errorf("failed to create %s cgroup: %w", procType, err)) + + continue + } + results[procType] = fd + } + + if len(errs) > 0 { + for procType, fd := range results { + err := unix.Close(fd) + if err != nil { + errs = append(errs, fmt.Errorf("failed to close cgroup fd for %s: %w", procType, err)) + } + } + + return nil, errors.Join(errs...) + } + + return results, nil +} + +func createCgroup(fullPath string, properties map[string]string) (int, error) { + if err := os.MkdirAll(fullPath, 0o755); err != nil { + return -1, fmt.Errorf("failed to create cgroup root: %w", err) + } + + var errs []error + for name, value := range properties { + if err := os.WriteFile(filepath.Join(fullPath, name), []byte(value), 0o644); err != nil { + errs = append(errs, fmt.Errorf("failed to write cgroup property: %w", err)) + } + } + if len(errs) > 0 { + return -1, errors.Join(errs...) + } + + return unix.Open(fullPath, unix.O_RDONLY, 0) +} + +func (c Cgroup2Manager) GetFileDescriptor(procType ProcessType) (int, bool) { + fd, ok := c.cgroupFDs[procType] + + return fd, ok +} + +func (c Cgroup2Manager) Close() error { + var errs []error + for procType, fd := range c.cgroupFDs { + if err := unix.Close(fd); err != nil { + errs = append(errs, fmt.Errorf("failed to close cgroup fd for %s: %w", procType, err)) + } + delete(c.cgroupFDs, procType) + } + + return errors.Join(errs...) +} diff --git a/envd/internal/services/cgroups/cgroup2_test.go b/envd/internal/services/cgroups/cgroup2_test.go new file mode 100644 index 0000000..ff16787 --- /dev/null +++ b/envd/internal/services/cgroups/cgroup2_test.go @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cgroups + +import ( + "context" + "fmt" + "math/rand" + "os" + "os/exec" + "strconv" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + oneByte = 1 + kilobyte = 1024 * oneByte + megabyte = 1024 * kilobyte +) + +func TestCgroupRoundTrip(t *testing.T) { + t.Parallel() + + if os.Geteuid() != 0 { + t.Skip("must run as root") + + return + } + + maxTimeout := time.Second * 5 + + t.Run("process does not die without cgroups", func(t *testing.T) { + t.Parallel() + + // create manager + m, err := NewCgroup2Manager() + require.NoError(t, err) + + // create new child process + cmd := startProcess(t, m, "not-a-real-one") + + // wait for child process to die + err = waitForProcess(t, cmd, maxTimeout) + + require.ErrorIs(t, err, context.DeadlineExceeded) + }) + + t.Run("process dies with cgroups", func(t *testing.T) { + t.Parallel() + + cgroupPath := createCgroupPath(t, "real-one") + + // create manager + m, err := NewCgroup2Manager( + WithCgroup2ProcessType(ProcessTypePTY, cgroupPath, map[string]string{ + "memory.max": strconv.Itoa(1 * megabyte), + }), + ) + require.NoError(t, err) + + t.Cleanup(func() { + err := m.Close() + assert.NoError(t, err) + }) + + // create new child process + cmd := startProcess(t, m, ProcessTypePTY) + + // wait for child process to die + err = waitForProcess(t, cmd, maxTimeout) + + // verify process exited correctly + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, "signal: killed", exitErr.Error()) + assert.False(t, exitErr.Exited()) + assert.False(t, exitErr.Success()) + assert.Equal(t, -1, exitErr.ExitCode()) + + // dig a little deeper + ws, ok := exitErr.Sys().(syscall.WaitStatus) + require.True(t, ok) + assert.Equal(t, syscall.SIGKILL, ws.Signal()) + assert.True(t, ws.Signaled()) + assert.False(t, ws.Stopped()) + assert.False(t, ws.Continued()) + assert.False(t, ws.CoreDump()) + assert.False(t, ws.Exited()) + assert.Equal(t, -1, ws.ExitStatus()) + }) + + t.Run("process cannot be spawned because memory limit is too low", func(t *testing.T) { + t.Parallel() + + cgroupPath := createCgroupPath(t, "real-one") + + // create manager + m, err := NewCgroup2Manager( + WithCgroup2ProcessType(ProcessTypeSocat, cgroupPath, map[string]string{ + "memory.max": strconv.Itoa(1 * kilobyte), + }), + ) + require.NoError(t, err) + + t.Cleanup(func() { + err := m.Close() + assert.NoError(t, err) + }) + + // create new child process + cmd := startProcess(t, m, ProcessTypeSocat) + + // wait for child process to die + err = waitForProcess(t, cmd, maxTimeout) + + // verify process exited correctly + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, "exit status 253", exitErr.Error()) + assert.True(t, exitErr.Exited()) + assert.False(t, exitErr.Success()) + assert.Equal(t, 253, exitErr.ExitCode()) + + // dig a little deeper + ws, ok := exitErr.Sys().(syscall.WaitStatus) + require.True(t, ok) + assert.Equal(t, syscall.Signal(-1), ws.Signal()) + assert.False(t, ws.Signaled()) + assert.False(t, ws.Stopped()) + assert.False(t, ws.Continued()) + assert.False(t, ws.CoreDump()) + assert.True(t, ws.Exited()) + assert.Equal(t, 253, ws.ExitStatus()) + }) +} + +func createCgroupPath(t *testing.T, s string) string { + t.Helper() + + randPart := rand.Int() + + return fmt.Sprintf("envd-test-%s-%d", s, randPart) +} + +func startProcess(t *testing.T, m *Cgroup2Manager, pt ProcessType) *exec.Cmd { + t.Helper() + + cmdName, args := "bash", []string{"-c", `sleep 1 && tail /dev/zero`} + cmd := exec.CommandContext(t.Context(), cmdName, args...) + + fd, ok := m.GetFileDescriptor(pt) + cmd.SysProcAttr = &syscall.SysProcAttr{ + UseCgroupFD: ok, + CgroupFD: fd, + } + + err := cmd.Start() + require.NoError(t, err) + + return cmd +} + +func waitForProcess(t *testing.T, cmd *exec.Cmd, timeout time.Duration) error { + t.Helper() + + done := make(chan error, 1) + + go func() { + defer close(done) + done <- cmd.Wait() + }() + + ctx, cancel := context.WithTimeout(t.Context(), timeout) + t.Cleanup(cancel) + + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-done: + return err + } +} diff --git a/envd/internal/services/cgroups/iface.go b/envd/internal/services/cgroups/iface.go new file mode 100644 index 0000000..04bbfa0 --- /dev/null +++ b/envd/internal/services/cgroups/iface.go @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cgroups + +type ProcessType string + +const ( + ProcessTypePTY ProcessType = "pty" + ProcessTypeUser ProcessType = "user" + ProcessTypeSocat ProcessType = "socat" +) + +type Manager interface { + GetFileDescriptor(procType ProcessType) (int, bool) + Close() error +} diff --git a/envd/internal/services/cgroups/noop.go b/envd/internal/services/cgroups/noop.go new file mode 100644 index 0000000..3b5f076 --- /dev/null +++ b/envd/internal/services/cgroups/noop.go @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cgroups + +type NoopManager struct{} + +var _ Manager = (*NoopManager)(nil) + +func NewNoopManager() *NoopManager { + return &NoopManager{} +} + +func (n NoopManager) GetFileDescriptor(ProcessType) (int, bool) { + return 0, false +} + +func (n NoopManager) Close() error { + return nil +} diff --git a/envd/internal/services/filesystem/dir.go b/envd/internal/services/filesystem/dir.go new file mode 100644 index 0000000..c3ee752 --- /dev/null +++ b/envd/internal/services/filesystem/dir.go @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: Apache-2.0 + +package filesystem + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "connectrpc.com/connect" + + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem" +) + +func (s Service) ListDir(ctx context.Context, req *connect.Request[rpc.ListDirRequest]) (*connect.Response[rpc.ListDirResponse], error) { + depth := req.Msg.GetDepth() + if depth == 0 { + depth = 1 // default depth to current directory + } + + u, err := permissions.GetAuthUser(ctx, s.defaults.User) + if err != nil { + return nil, err + } + + requestedPath := req.Msg.GetPath() + + // Expand the path so we can return absolute paths in the response. + requestedPath, err = permissions.ExpandAndResolve(requestedPath, u, s.defaults.Workdir) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + resolvedPath, err := followSymlink(requestedPath) + if err != nil { + return nil, err + } + + err = checkIfDirectory(resolvedPath) + if err != nil { + return nil, err + } + + entries, err := walkDir(requestedPath, resolvedPath, int(depth)) + if err != nil { + return nil, err + } + + return connect.NewResponse(&rpc.ListDirResponse{ + Entries: entries, + }), nil +} + +func (s Service) MakeDir(ctx context.Context, req *connect.Request[rpc.MakeDirRequest]) (*connect.Response[rpc.MakeDirResponse], error) { + u, err := permissions.GetAuthUser(ctx, s.defaults.User) + if err != nil { + return nil, err + } + + dirPath, err := permissions.ExpandAndResolve(req.Msg.GetPath(), u, s.defaults.Workdir) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + stat, err := os.Stat(dirPath) + if err != nil && !os.IsNotExist(err) { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error getting file info: %w", err)) + } + + if err == nil { + if stat.IsDir() { + return nil, connect.NewError(connect.CodeAlreadyExists, fmt.Errorf("directory already exists: %s", dirPath)) + } + + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("path already exists but it is not a directory: %s", dirPath)) + } + + uid, gid, userErr := permissions.GetUserIdInts(u) + if userErr != nil { + return nil, connect.NewError(connect.CodeInternal, userErr) + } + + userErr = permissions.EnsureDirs(dirPath, uid, gid) + if userErr != nil { + return nil, connect.NewError(connect.CodeInternal, userErr) + } + + entry, err := entryInfo(dirPath) + if err != nil { + return nil, err + } + + return connect.NewResponse(&rpc.MakeDirResponse{ + Entry: entry, + }), nil +} + +// followSymlink resolves a symbolic link to its target path. +func followSymlink(path string) (string, error) { + // Resolve symlinks + resolvedPath, err := filepath.EvalSymlinks(path) + if err != nil { + if os.IsNotExist(err) { + return "", connect.NewError(connect.CodeNotFound, fmt.Errorf("path not found: %w", err)) + } + + if strings.Contains(err.Error(), "too many links") { + return "", connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf("cyclic symlink or chain >255 links at %q", path)) + } + + return "", connect.NewError(connect.CodeInternal, fmt.Errorf("error resolving symlink: %w", err)) + } + + return resolvedPath, nil +} + +// checkIfDirectory checks if the given path is a directory. +func checkIfDirectory(path string) error { + stat, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return connect.NewError(connect.CodeNotFound, fmt.Errorf("directory not found: %w", err)) + } + + return connect.NewError(connect.CodeInternal, fmt.Errorf("error getting file info: %w", err)) + } + + if !stat.IsDir() { + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("path is not a directory: %s", path)) + } + + return nil +} + +// walkDir walks the directory tree starting from dirPath up to the specified depth (doesn't follow symlinks). +func walkDir(requestedPath string, dirPath string, depth int) (entries []*rpc.EntryInfo, err error) { + err = filepath.WalkDir(dirPath, func(path string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip the root directory itself + if path == dirPath { + return nil + } + + // Calculate current depth + relPath, err := filepath.Rel(dirPath, path) + if err != nil { + return err + } + currentDepth := len(strings.Split(relPath, string(os.PathSeparator))) + + if currentDepth > depth { + return filepath.SkipDir + } + + entryInfo, err := entryInfo(path) + if err != nil { + var connectErr *connect.Error + if errors.As(err, &connectErr) && connectErr.Code() == connect.CodeNotFound { + // Skip entries that don't exist anymore + return nil + } + + return err + } + + // Return the requested path as the base path instead of the symlink-resolved path + path = filepath.Join(requestedPath, relPath) + entryInfo.Path = path + + entries = append(entries, entryInfo) + + return nil + }) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error reading directory %s: %w", dirPath, err)) + } + + return entries, nil +} diff --git a/envd/internal/services/filesystem/dir_test.go b/envd/internal/services/filesystem/dir_test.go new file mode 100644 index 0000000..5dba82e --- /dev/null +++ b/envd/internal/services/filesystem/dir_test.go @@ -0,0 +1,407 @@ +// SPDX-License-Identifier: Apache-2.0 + +package filesystem + +import ( + "context" + "errors" + "fmt" + "os" + "os/user" + "path/filepath" + "testing" + + "connectrpc.com/authn" + "connectrpc.com/connect" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem" +) + +func TestListDir(t *testing.T) { + t.Parallel() + + // Setup temp root and user + root := t.TempDir() + u, err := user.Current() + require.NoError(t, err) + + // Setup directory structure + testFolder := filepath.Join(root, "test") + require.NoError(t, os.MkdirAll(filepath.Join(testFolder, "test-dir", "sub-dir-1"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(testFolder, "test-dir", "sub-dir-2"), 0o755)) + filePath := filepath.Join(testFolder, "test-dir", "sub-dir-1", "file.txt") + require.NoError(t, os.WriteFile(filePath, []byte("Hello, World!"), 0o644)) + + // Service instance + svc := mockService() + + // Helper to inject user into context + injectUser := func(ctx context.Context, u *user.User) context.Context { + return authn.SetInfo(ctx, u) + } + + tests := []struct { + name string + depth uint32 + expectedPaths []string + }{ + { + name: "depth 0 lists only root directory", + depth: 0, + expectedPaths: []string{ + filepath.Join(testFolder, "test-dir"), + }, + }, + { + name: "depth 1 lists root directory", + depth: 1, + expectedPaths: []string{ + filepath.Join(testFolder, "test-dir"), + }, + }, + { + name: "depth 2 lists first level of subdirectories (in this case the root directory)", + depth: 2, + expectedPaths: []string{ + filepath.Join(testFolder, "test-dir"), + filepath.Join(testFolder, "test-dir", "sub-dir-1"), + filepath.Join(testFolder, "test-dir", "sub-dir-2"), + }, + }, + { + name: "depth 3 lists all directories and files", + depth: 3, + expectedPaths: []string{ + filepath.Join(testFolder, "test-dir"), + filepath.Join(testFolder, "test-dir", "sub-dir-1"), + filepath.Join(testFolder, "test-dir", "sub-dir-2"), + filepath.Join(testFolder, "test-dir", "sub-dir-1", "file.txt"), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := injectUser(t.Context(), u) + req := connect.NewRequest(&filesystem.ListDirRequest{ + Path: testFolder, + Depth: tt.depth, + }) + resp, err := svc.ListDir(ctx, req) + require.NoError(t, err) + assert.NotEmpty(t, resp.Msg) + assert.Len(t, resp.Msg.GetEntries(), len(tt.expectedPaths)) + actualPaths := make([]string, len(resp.Msg.GetEntries())) + for i, entry := range resp.Msg.GetEntries() { + actualPaths[i] = entry.GetPath() + } + assert.ElementsMatch(t, tt.expectedPaths, actualPaths) + }) + } +} + +func TestListDirNonExistingPath(t *testing.T) { + t.Parallel() + + svc := mockService() + u, err := user.Current() + require.NoError(t, err) + ctx := authn.SetInfo(t.Context(), u) + + req := connect.NewRequest(&filesystem.ListDirRequest{ + Path: "/non-existing-path", + Depth: 1, + }) + _, err = svc.ListDir(ctx, req) + require.Error(t, err) + var connectErr *connect.Error + ok := errors.As(err, &connectErr) + assert.True(t, ok, "expected error to be of type *connect.Error") + assert.Equal(t, connect.CodeNotFound, connectErr.Code()) +} + +func TestListDirRelativePath(t *testing.T) { + t.Parallel() + + // Setup temp root and user + u, err := user.Current() + require.NoError(t, err) + + // Setup directory structure + testRelativePath := fmt.Sprintf("test-%s", uuid.New()) + testFolderPath := filepath.Join(u.HomeDir, testRelativePath) + filePath := filepath.Join(testFolderPath, "file.txt") + require.NoError(t, os.MkdirAll(testFolderPath, 0o755)) + require.NoError(t, os.WriteFile(filePath, []byte("Hello, World!"), 0o644)) + + // Service instance + svc := mockService() + ctx := authn.SetInfo(t.Context(), u) + + req := connect.NewRequest(&filesystem.ListDirRequest{ + Path: testRelativePath, + Depth: 1, + }) + resp, err := svc.ListDir(ctx, req) + require.NoError(t, err) + assert.NotEmpty(t, resp.Msg) + + expectedPaths := []string{ + filepath.Join(testFolderPath, "file.txt"), + } + assert.Len(t, resp.Msg.GetEntries(), len(expectedPaths)) + + actualPaths := make([]string, len(resp.Msg.GetEntries())) + for i, entry := range resp.Msg.GetEntries() { + actualPaths[i] = entry.GetPath() + } + assert.ElementsMatch(t, expectedPaths, actualPaths) +} + +func TestListDir_Symlinks(t *testing.T) { + t.Parallel() + + root := t.TempDir() + u, err := user.Current() + require.NoError(t, err) + ctx := authn.SetInfo(t.Context(), u) + + symlinkRoot := filepath.Join(root, "test-symlinks") + require.NoError(t, os.MkdirAll(symlinkRoot, 0o755)) + + // 1. Prepare a real directory + file that a symlink will point to + realDir := filepath.Join(symlinkRoot, "real-dir") + require.NoError(t, os.MkdirAll(realDir, 0o755)) + filePath := filepath.Join(realDir, "file.txt") + require.NoError(t, os.WriteFile(filePath, []byte("hello via symlink"), 0o644)) + + // 2. Prepare a standalone real file (points-to-file scenario) + realFile := filepath.Join(symlinkRoot, "real-file.txt") + require.NoError(t, os.WriteFile(realFile, []byte("i am a plain file"), 0o644)) + + // 3. Create the three symlinks + linkToDir := filepath.Join(symlinkRoot, "link-dir") // → directory + linkToFile := filepath.Join(symlinkRoot, "link-file") // → file + cyclicLink := filepath.Join(symlinkRoot, "cyclic") // → itself + require.NoError(t, os.Symlink(realDir, linkToDir)) + require.NoError(t, os.Symlink(realFile, linkToFile)) + require.NoError(t, os.Symlink(cyclicLink, cyclicLink)) + + svc := mockService() + + t.Run("symlink to directory behaves like directory and the content looks like inside the directory", func(t *testing.T) { + t.Parallel() + + req := connect.NewRequest(&filesystem.ListDirRequest{ + Path: linkToDir, + Depth: 1, + }) + resp, err := svc.ListDir(ctx, req) + require.NoError(t, err) + expected := []string{ + filepath.Join(linkToDir, "file.txt"), + } + actual := make([]string, len(resp.Msg.GetEntries())) + for i, e := range resp.Msg.GetEntries() { + actual[i] = e.GetPath() + } + assert.ElementsMatch(t, expected, actual) + }) + + t.Run("link to file", func(t *testing.T) { + t.Parallel() + + req := connect.NewRequest(&filesystem.ListDirRequest{ + Path: linkToFile, + Depth: 1, + }) + _, err := svc.ListDir(ctx, req) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a directory") + }) + + t.Run("cyclic symlink surfaces 'too many links' → invalid-argument", func(t *testing.T) { + t.Parallel() + + req := connect.NewRequest(&filesystem.ListDirRequest{ + Path: cyclicLink, + }) + _, err := svc.ListDir(ctx, req) + require.Error(t, err) + var connectErr *connect.Error + ok := errors.As(err, &connectErr) + assert.True(t, ok, "expected error to be of type *connect.Error") + assert.Equal(t, connect.CodeFailedPrecondition, connectErr.Code()) + assert.Contains(t, connectErr.Error(), "cyclic symlink") + }) + + t.Run("symlink not resolved if not root", func(t *testing.T) { + t.Parallel() + + req := connect.NewRequest(&filesystem.ListDirRequest{ + Path: symlinkRoot, + Depth: 3, + }) + res, err := svc.ListDir(ctx, req) + require.NoError(t, err) + expected := []string{ + filepath.Join(symlinkRoot, "cyclic"), + filepath.Join(symlinkRoot, "link-dir"), + filepath.Join(symlinkRoot, "link-file"), + filepath.Join(symlinkRoot, "real-dir"), + filepath.Join(symlinkRoot, "real-dir", "file.txt"), + filepath.Join(symlinkRoot, "real-file.txt"), + } + actual := make([]string, len(res.Msg.GetEntries())) + for i, e := range res.Msg.GetEntries() { + actual[i] = e.GetPath() + } + assert.ElementsMatch(t, expected, actual, "symlinks should not be resolved when listing the symlink root directory") + }) +} + +// TestFollowSymlink_Success makes sure that followSymlink resolves symlinks, +// while also being robust to the /var → /private/var indirection that exists on macOS. +func TestFollowSymlink_Success(t *testing.T) { + t.Parallel() + + // Base temporary directory. On macOS this lives under /var/folders/… + // which itself is a symlink to /private/var/folders/…. + base := t.TempDir() + + // Create a real directory that we ultimately want to resolve to. + target := filepath.Join(base, "target") + require.NoError(t, os.MkdirAll(target, 0o755)) + + // Create a symlink pointing at the real directory so we can verify that + // followSymlink follows it. + link := filepath.Join(base, "link") + require.NoError(t, os.Symlink(target, link)) + + got, err := followSymlink(link) + require.NoError(t, err) + + // Canonicalise the expected path too, so that /var → /private/var (macOS) + // or any other benign symlink indirections don’t cause flaky tests. + want, err := filepath.EvalSymlinks(link) + require.NoError(t, err) + + require.Equal(t, want, got, "followSymlink should resolve and canonicalise symlinks") +} + +// TestFollowSymlink_MultiSymlinkChain verifies that followSymlink follows a chain +// of several symlinks (non‑cyclic) correctly. +func TestFollowSymlink_MultiSymlinkChain(t *testing.T) { + t.Parallel() + + base := t.TempDir() + + // Final destination directory. + target := filepath.Join(base, "target") + require.NoError(t, os.MkdirAll(target, 0o755)) + + // Build a 3‑link chain: link1 → link2 → link3 → target. + link3 := filepath.Join(base, "link3") + require.NoError(t, os.Symlink(target, link3)) + + link2 := filepath.Join(base, "link2") + require.NoError(t, os.Symlink(link3, link2)) + + link1 := filepath.Join(base, "link1") + require.NoError(t, os.Symlink(link2, link1)) + + got, err := followSymlink(link1) + require.NoError(t, err) + + want, err := filepath.EvalSymlinks(link1) + require.NoError(t, err) + + require.Equal(t, want, got, "followSymlink should resolve an arbitrary symlink chain") +} + +func TestFollowSymlink_NotFound(t *testing.T) { + t.Parallel() + + _, err := followSymlink("/definitely/does/not/exist") + require.Error(t, err) + + var cerr *connect.Error + require.ErrorAs(t, err, &cerr) + require.Equal(t, connect.CodeNotFound, cerr.Code()) +} + +func TestFollowSymlink_CyclicSymlink(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + a := filepath.Join(dir, "a") + b := filepath.Join(dir, "b") + require.NoError(t, os.MkdirAll(a, 0o755)) + require.NoError(t, os.MkdirAll(b, 0o755)) + + // Create a two‑node loop: a/loop → b/loop, b/loop → a/loop. + require.NoError(t, os.Symlink(filepath.Join(b, "loop"), filepath.Join(a, "loop"))) + require.NoError(t, os.Symlink(filepath.Join(a, "loop"), filepath.Join(b, "loop"))) + + _, err := followSymlink(filepath.Join(a, "loop")) + require.Error(t, err) + + var cerr *connect.Error + require.ErrorAs(t, err, &cerr) + require.Equal(t, connect.CodeFailedPrecondition, cerr.Code()) + require.Contains(t, cerr.Message(), "cyclic") +} + +func TestCheckIfDirectory(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, checkIfDirectory(dir)) + + file := filepath.Join(dir, "file.txt") + require.NoError(t, os.WriteFile(file, []byte("hello"), 0o644)) + + err := checkIfDirectory(file) + require.Error(t, err) + + var cerr *connect.Error + require.ErrorAs(t, err, &cerr) + require.Equal(t, connect.CodeInvalidArgument, cerr.Code()) +} + +func TestWalkDir_Depth(t *testing.T) { + t.Parallel() + + root := t.TempDir() + sub := filepath.Join(root, "sub") + subsub := filepath.Join(sub, "subsub") + require.NoError(t, os.MkdirAll(subsub, 0o755)) + + entries, err := walkDir(root, root, 1) + require.NoError(t, err) + + // Collect the names for easier assertions. + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.GetName()) + } + + require.Contains(t, names, "sub") + require.NotContains(t, names, "subsub", "entries beyond depth should be excluded") +} + +func TestWalkDir_Error(t *testing.T) { + t.Parallel() + + _, err := walkDir("/does/not/exist", "/does/not/exist", 1) + require.Error(t, err) + + var cerr *connect.Error + require.ErrorAs(t, err, &cerr) + require.Equal(t, connect.CodeInternal, cerr.Code()) +} diff --git a/envd/internal/services/filesystem/move.go b/envd/internal/services/filesystem/move.go new file mode 100644 index 0000000..adf5b7e --- /dev/null +++ b/envd/internal/services/filesystem/move.go @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 + +package filesystem + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "connectrpc.com/connect" + + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem" +) + +func (s Service) Move(ctx context.Context, req *connect.Request[rpc.MoveRequest]) (*connect.Response[rpc.MoveResponse], error) { + u, err := permissions.GetAuthUser(ctx, s.defaults.User) + if err != nil { + return nil, err + } + + source, err := permissions.ExpandAndResolve(req.Msg.GetSource(), u, s.defaults.Workdir) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + destination, err := permissions.ExpandAndResolve(req.Msg.GetDestination(), u, s.defaults.Workdir) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + uid, gid, userErr := permissions.GetUserIdInts(u) + if userErr != nil { + return nil, connect.NewError(connect.CodeInternal, userErr) + } + + userErr = permissions.EnsureDirs(filepath.Dir(destination), uid, gid) + if userErr != nil { + return nil, connect.NewError(connect.CodeInternal, userErr) + } + + err = os.Rename(source, destination) + if err != nil { + if os.IsNotExist(err) { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("source file not found: %w", err)) + } + + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error renaming: %w", err)) + } + + entry, err := entryInfo(destination) + if err != nil { + return nil, err + } + + return connect.NewResponse(&rpc.MoveResponse{ + Entry: entry, + }), nil +} diff --git a/envd/internal/services/filesystem/move_test.go b/envd/internal/services/filesystem/move_test.go new file mode 100644 index 0000000..f094e9b --- /dev/null +++ b/envd/internal/services/filesystem/move_test.go @@ -0,0 +1,366 @@ +// SPDX-License-Identifier: Apache-2.0 + +package filesystem + +import ( + "errors" + "fmt" + "os" + "os/user" + "path/filepath" + "testing" + + "connectrpc.com/authn" + "connectrpc.com/connect" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem" +) + +func TestMove(t *testing.T) { + t.Parallel() + + // Setup temp root and user + root := t.TempDir() + u, err := user.Current() + require.NoError(t, err) + + // Setup source and destination directories + sourceDir := filepath.Join(root, "source") + destDir := filepath.Join(root, "destination") + require.NoError(t, os.MkdirAll(sourceDir, 0o755)) + require.NoError(t, os.MkdirAll(destDir, 0o755)) + + // Create a test file to move + sourceFile := filepath.Join(sourceDir, "test-file.txt") + testContent := []byte("Hello, World!") + require.NoError(t, os.WriteFile(sourceFile, testContent, 0o644)) + + // Destination file path + destFile := filepath.Join(destDir, "test-file.txt") + + // Service instance + svc := mockService() + + // Call the Move function + ctx := authn.SetInfo(t.Context(), u) + req := connect.NewRequest(&filesystem.MoveRequest{ + Source: sourceFile, + Destination: destFile, + }) + resp, err := svc.Move(ctx, req) + + // Verify the move was successful + require.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, destFile, resp.Msg.GetEntry().GetPath()) + + // Verify the file exists at the destination + _, err = os.Stat(destFile) + require.NoError(t, err) + + // Verify the file no longer exists at the source + _, err = os.Stat(sourceFile) + assert.True(t, os.IsNotExist(err)) + + // Verify the content of the moved file + content, err := os.ReadFile(destFile) + require.NoError(t, err) + assert.Equal(t, testContent, content) +} + +func TestMoveDirectory(t *testing.T) { + t.Parallel() + + // Setup temp root and user + root := t.TempDir() + u, err := user.Current() + require.NoError(t, err) + + // Setup source and destination directories + sourceParent := filepath.Join(root, "source-parent") + destParent := filepath.Join(root, "dest-parent") + require.NoError(t, os.MkdirAll(sourceParent, 0o755)) + require.NoError(t, os.MkdirAll(destParent, 0o755)) + + // Create a test directory with files to move + sourceDir := filepath.Join(sourceParent, "test-dir") + require.NoError(t, os.MkdirAll(filepath.Join(sourceDir, "subdir"), 0o755)) + + // Create some files in the directory + file1 := filepath.Join(sourceDir, "file1.txt") + file2 := filepath.Join(sourceDir, "subdir", "file2.txt") + require.NoError(t, os.WriteFile(file1, []byte("File 1 content"), 0o644)) + require.NoError(t, os.WriteFile(file2, []byte("File 2 content"), 0o644)) + + // Destination directory path + destDir := filepath.Join(destParent, "test-dir") + + // Service instance + svc := mockService() + + // Call the Move function + ctx := authn.SetInfo(t.Context(), u) + req := connect.NewRequest(&filesystem.MoveRequest{ + Source: sourceDir, + Destination: destDir, + }) + resp, err := svc.Move(ctx, req) + + // Verify the move was successful + require.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, destDir, resp.Msg.GetEntry().GetPath()) + + // Verify the directory exists at the destination + _, err = os.Stat(destDir) + require.NoError(t, err) + + // Verify the files exist at the destination + destFile1 := filepath.Join(destDir, "file1.txt") + destFile2 := filepath.Join(destDir, "subdir", "file2.txt") + _, err = os.Stat(destFile1) + require.NoError(t, err) + _, err = os.Stat(destFile2) + require.NoError(t, err) + + // Verify the directory no longer exists at the source + _, err = os.Stat(sourceDir) + assert.True(t, os.IsNotExist(err)) + + // Verify the content of the moved files + content1, err := os.ReadFile(destFile1) + require.NoError(t, err) + assert.Equal(t, []byte("File 1 content"), content1) + + content2, err := os.ReadFile(destFile2) + require.NoError(t, err) + assert.Equal(t, []byte("File 2 content"), content2) +} + +func TestMoveNonExistingFile(t *testing.T) { + t.Parallel() + + // Setup temp root and user + root := t.TempDir() + u, err := user.Current() + require.NoError(t, err) + + // Setup destination directory + destDir := filepath.Join(root, "destination") + require.NoError(t, os.MkdirAll(destDir, 0o755)) + + // Non-existing source file + sourceFile := filepath.Join(root, "non-existing-file.txt") + + // Destination file path + destFile := filepath.Join(destDir, "moved-file.txt") + + // Service instance + svc := mockService() + + // Call the Move function + ctx := authn.SetInfo(t.Context(), u) + req := connect.NewRequest(&filesystem.MoveRequest{ + Source: sourceFile, + Destination: destFile, + }) + _, err = svc.Move(ctx, req) + + // Verify the correct error is returned + require.Error(t, err) + + var connectErr *connect.Error + ok := errors.As(err, &connectErr) + assert.True(t, ok, "expected error to be of type *connect.Error") + assert.Equal(t, connect.CodeNotFound, connectErr.Code()) + assert.Contains(t, connectErr.Message(), "source file not found") +} + +func TestMoveRelativePath(t *testing.T) { + t.Parallel() + + // Setup user + u, err := user.Current() + require.NoError(t, err) + + // Setup directory structure with unique name to avoid conflicts + testRelativePath := fmt.Sprintf("test-move-%s", uuid.New()) + testFolderPath := filepath.Join(u.HomeDir, testRelativePath) + require.NoError(t, os.MkdirAll(testFolderPath, 0o755)) + + // Create a test file to move + sourceFile := filepath.Join(testFolderPath, "source-file.txt") + testContent := []byte("Hello from relative path!") + require.NoError(t, os.WriteFile(sourceFile, testContent, 0o644)) + + // Destination file path (also relative) + destRelativePath := fmt.Sprintf("test-move-dest-%s", uuid.New()) + destFolderPath := filepath.Join(u.HomeDir, destRelativePath) + require.NoError(t, os.MkdirAll(destFolderPath, 0o755)) + destFile := filepath.Join(destFolderPath, "moved-file.txt") + + // Service instance + svc := mockService() + + // Call the Move function with relative paths + ctx := authn.SetInfo(t.Context(), u) + req := connect.NewRequest(&filesystem.MoveRequest{ + Source: filepath.Join(testRelativePath, "source-file.txt"), // Relative path + Destination: filepath.Join(destRelativePath, "moved-file.txt"), // Relative path + }) + resp, err := svc.Move(ctx, req) + + // Verify the move was successful + require.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, destFile, resp.Msg.GetEntry().GetPath()) + + // Verify the file exists at the destination + _, err = os.Stat(destFile) + require.NoError(t, err) + + // Verify the file no longer exists at the source + _, err = os.Stat(sourceFile) + assert.True(t, os.IsNotExist(err)) + + // Verify the content of the moved file + content, err := os.ReadFile(destFile) + require.NoError(t, err) + assert.Equal(t, testContent, content) + + // Clean up + os.RemoveAll(testFolderPath) + os.RemoveAll(destFolderPath) +} + +func TestMove_Symlinks(t *testing.T) { //nolint:tparallel // this test cannot be executed in parallel + root := t.TempDir() + u, err := user.Current() + require.NoError(t, err) + ctx := authn.SetInfo(t.Context(), u) + + // Setup source and destination directories + sourceRoot := filepath.Join(root, "source") + destRoot := filepath.Join(root, "destination") + require.NoError(t, os.MkdirAll(sourceRoot, 0o755)) + require.NoError(t, os.MkdirAll(destRoot, 0o755)) + + // 1. Prepare a real directory + file that a symlink will point to + realDir := filepath.Join(sourceRoot, "real-dir") + require.NoError(t, os.MkdirAll(realDir, 0o755)) + filePath := filepath.Join(realDir, "file.txt") + require.NoError(t, os.WriteFile(filePath, []byte("hello via symlink"), 0o644)) + + // 2. Prepare a standalone real file (points-to-file scenario) + realFile := filepath.Join(sourceRoot, "real-file.txt") + require.NoError(t, os.WriteFile(realFile, []byte("i am a plain file"), 0o644)) + + // 3. Create symlinks + linkToDir := filepath.Join(sourceRoot, "link-dir") // → directory + linkToFile := filepath.Join(sourceRoot, "link-file") // → file + require.NoError(t, os.Symlink(realDir, linkToDir)) + require.NoError(t, os.Symlink(realFile, linkToFile)) + + svc := mockService() + + t.Run("move symlink to directory", func(t *testing.T) { + t.Parallel() + destPath := filepath.Join(destRoot, "moved-link-dir") + + req := connect.NewRequest(&filesystem.MoveRequest{ + Source: linkToDir, + Destination: destPath, + }) + resp, err := svc.Move(ctx, req) + require.NoError(t, err) + assert.Equal(t, destPath, resp.Msg.GetEntry().GetPath()) + + // Verify the symlink was moved + _, err = os.Stat(destPath) + require.NoError(t, err) + + // Verify it's still a symlink + info, err := os.Lstat(destPath) + require.NoError(t, err) + assert.NotEqual(t, 0, info.Mode()&os.ModeSymlink, "expected a symlink") + + // Verify the symlink target is still correct + target, err := os.Readlink(destPath) + require.NoError(t, err) + assert.Equal(t, realDir, target) + + // Verify the original symlink is gone + _, err = os.Stat(linkToDir) + assert.True(t, os.IsNotExist(err)) + + // Verify the real directory still exists + _, err = os.Stat(realDir) + assert.NoError(t, err) + }) + + t.Run("move symlink to file", func(t *testing.T) { //nolint:paralleltest + destPath := filepath.Join(destRoot, "moved-link-file") + + req := connect.NewRequest(&filesystem.MoveRequest{ + Source: linkToFile, + Destination: destPath, + }) + resp, err := svc.Move(ctx, req) + require.NoError(t, err) + assert.Equal(t, destPath, resp.Msg.GetEntry().GetPath()) + + // Verify the symlink was moved + _, err = os.Stat(destPath) + require.NoError(t, err) + + // Verify it's still a symlink + info, err := os.Lstat(destPath) + require.NoError(t, err) + assert.NotEqual(t, 0, info.Mode()&os.ModeSymlink, "expected a symlink") + + // Verify the symlink target is still correct + target, err := os.Readlink(destPath) + require.NoError(t, err) + assert.Equal(t, realFile, target) + + // Verify the original symlink is gone + _, err = os.Stat(linkToFile) + assert.True(t, os.IsNotExist(err)) + + // Verify the real file still exists + _, err = os.Stat(realFile) + assert.NoError(t, err) + }) + + t.Run("move real file that is target of symlink", func(t *testing.T) { + t.Parallel() + // Create a new symlink to the real file + newLinkToFile := filepath.Join(sourceRoot, "new-link-file") + require.NoError(t, os.Symlink(realFile, newLinkToFile)) + + destPath := filepath.Join(destRoot, "moved-real-file.txt") + + req := connect.NewRequest(&filesystem.MoveRequest{ + Source: realFile, + Destination: destPath, + }) + resp, err := svc.Move(ctx, req) + require.NoError(t, err) + assert.Equal(t, destPath, resp.Msg.GetEntry().GetPath()) + + // Verify the real file was moved + _, err = os.Stat(destPath) + require.NoError(t, err) + + // Verify the original file is gone + _, err = os.Stat(realFile) + assert.True(t, os.IsNotExist(err)) + + // Verify the symlink still exists but now points to a non-existent file + _, err = os.Stat(newLinkToFile) + require.Error(t, err, "symlink should point to non-existent file") + }) +} diff --git a/envd/internal/services/filesystem/remove.go b/envd/internal/services/filesystem/remove.go new file mode 100644 index 0000000..fd8ce62 --- /dev/null +++ b/envd/internal/services/filesystem/remove.go @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 + +package filesystem + +import ( + "context" + "fmt" + "os" + + "connectrpc.com/connect" + + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem" +) + +func (s Service) Remove(ctx context.Context, req *connect.Request[rpc.RemoveRequest]) (*connect.Response[rpc.RemoveResponse], error) { + u, err := permissions.GetAuthUser(ctx, s.defaults.User) + if err != nil { + return nil, err + } + + path, err := permissions.ExpandAndResolve(req.Msg.GetPath(), u, s.defaults.Workdir) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + err = os.RemoveAll(path) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error removing file or directory: %w", err)) + } + + return connect.NewResponse(&rpc.RemoveResponse{}), nil +} diff --git a/envd/internal/services/filesystem/service.go b/envd/internal/services/filesystem/service.go new file mode 100644 index 0000000..51f948d --- /dev/null +++ b/envd/internal/services/filesystem/service.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modifications by M/S Omukk + +package filesystem + +import ( + "connectrpc.com/connect" + "github.com/go-chi/chi/v5" + "github.com/rs/zerolog" + + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + spec "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem/filesystemconnect" + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" +) + +type Service struct { + logger *zerolog.Logger + watchers *utils.Map[string, *FileWatcher] + defaults *execcontext.Defaults +} + +func Handle(server *chi.Mux, l *zerolog.Logger, defaults *execcontext.Defaults) { + service := Service{ + logger: l, + watchers: utils.NewMap[string, *FileWatcher](), + defaults: defaults, + } + + interceptors := connect.WithInterceptors( + logs.NewUnaryLogInterceptor(l), + ) + + path, handler := spec.NewFilesystemHandler(service, interceptors) + + server.Mount(path, handler) +} diff --git a/envd/internal/services/filesystem/service_test.go b/envd/internal/services/filesystem/service_test.go new file mode 100644 index 0000000..3e6db01 --- /dev/null +++ b/envd/internal/services/filesystem/service_test.go @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: Apache-2.0 + +package filesystem + +import ( + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" +) + +func mockService() Service { + return Service{ + defaults: &execcontext.Defaults{ + EnvVars: utils.NewMap[string, string](), + }, + } +} diff --git a/envd/internal/services/filesystem/stat.go b/envd/internal/services/filesystem/stat.go new file mode 100644 index 0000000..d3bc4f5 --- /dev/null +++ b/envd/internal/services/filesystem/stat.go @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Apache-2.0 + +package filesystem + +import ( + "context" + + "connectrpc.com/connect" + + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem" +) + +func (s Service) Stat(ctx context.Context, req *connect.Request[rpc.StatRequest]) (*connect.Response[rpc.StatResponse], error) { + u, err := permissions.GetAuthUser(ctx, s.defaults.User) + if err != nil { + return nil, err + } + + path, err := permissions.ExpandAndResolve(req.Msg.GetPath(), u, s.defaults.Workdir) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + entry, err := entryInfo(path) + if err != nil { + return nil, err + } + + return connect.NewResponse(&rpc.StatResponse{Entry: entry}), nil +} diff --git a/envd/internal/services/filesystem/stat_test.go b/envd/internal/services/filesystem/stat_test.go new file mode 100644 index 0000000..56d4af7 --- /dev/null +++ b/envd/internal/services/filesystem/stat_test.go @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 + +package filesystem + +import ( + "context" + "os" + "os/user" + "path/filepath" + "testing" + + "connectrpc.com/authn" + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem" +) + +func TestStat(t *testing.T) { + t.Parallel() + + // Setup temp root and user + root := t.TempDir() + // Get the actual path to the temp directory (symlinks can cause issues) + root, err := filepath.EvalSymlinks(root) + require.NoError(t, err) + + u, err := user.Current() + require.NoError(t, err) + + group, err := user.LookupGroupId(u.Gid) + require.NoError(t, err) + + // Setup directory structure + testFolder := filepath.Join(root, "test") + err = os.MkdirAll(testFolder, 0o755) + require.NoError(t, err) + + testFile := filepath.Join(testFolder, "file.txt") + err = os.WriteFile(testFile, []byte("Hello, World!"), 0o644) + require.NoError(t, err) + + linkedFile := filepath.Join(testFolder, "linked-file.txt") + err = os.Symlink(testFile, linkedFile) + require.NoError(t, err) + + // Service instance + svc := mockService() + + // Helper to inject user into context + injectUser := func(ctx context.Context, u *user.User) context.Context { + return authn.SetInfo(ctx, u) + } + + tests := []struct { + name string + path string + }{ + { + name: "Stat file directory", + path: testFile, + }, + { + name: "Stat symlink to file", + path: linkedFile, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := injectUser(t.Context(), u) + req := connect.NewRequest(&filesystem.StatRequest{ + Path: tt.path, + }) + resp, err := svc.Stat(ctx, req) + require.NoError(t, err) + require.NotEmpty(t, resp.Msg) + require.NotNil(t, resp.Msg.GetEntry()) + assert.Equal(t, tt.path, resp.Msg.GetEntry().GetPath()) + assert.Equal(t, filesystem.FileType_FILE_TYPE_FILE, resp.Msg.GetEntry().GetType()) + assert.Equal(t, u.Username, resp.Msg.GetEntry().GetOwner()) + assert.Equal(t, group.Name, resp.Msg.GetEntry().GetGroup()) + assert.Equal(t, uint32(0o644), resp.Msg.GetEntry().GetMode()) + if tt.path == linkedFile { + require.NotNil(t, resp.Msg.GetEntry().GetSymlinkTarget()) + assert.Equal(t, testFile, resp.Msg.GetEntry().GetSymlinkTarget()) + } else { + assert.Empty(t, resp.Msg.GetEntry().GetSymlinkTarget()) + } + }) + } +} + +func TestStatMissingPathReturnsNotFound(t *testing.T) { + t.Parallel() + + u, err := user.Current() + require.NoError(t, err) + + svc := mockService() + ctx := authn.SetInfo(t.Context(), u) + + req := connect.NewRequest(&filesystem.StatRequest{ + Path: filepath.Join(t.TempDir(), "missing.txt"), + }) + + _, err = svc.Stat(ctx, req) + require.Error(t, err) + + var connectErr *connect.Error + require.ErrorAs(t, err, &connectErr) + assert.Equal(t, connect.CodeNotFound, connectErr.Code()) +} diff --git a/envd/internal/services/filesystem/utils.go b/envd/internal/services/filesystem/utils.go new file mode 100644 index 0000000..6e94ce0 --- /dev/null +++ b/envd/internal/services/filesystem/utils.go @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 + +package filesystem + +import ( + "fmt" + "os" + "os/user" + "syscall" + "time" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem" + "git.omukk.dev/wrenn/sandbox/envd/internal/shared/filesystem" +) + +// Filesystem magic numbers from Linux kernel (include/uapi/linux/magic.h) +const ( + nfsSuperMagic = 0x6969 + cifsMagic = 0xFF534D42 + smbSuperMagic = 0x517B + smb2MagicNumber = 0xFE534D42 + fuseSuperMagic = 0x65735546 +) + +// IsPathOnNetworkMount checks if the given path is on a network filesystem mount. +// Returns true if the path is on NFS, CIFS, SMB, or FUSE filesystem. +func IsPathOnNetworkMount(path string) (bool, error) { + var statfs syscall.Statfs_t + if err := syscall.Statfs(path, &statfs); err != nil { + return false, fmt.Errorf("failed to statfs %s: %w", path, err) + } + + switch statfs.Type { + case nfsSuperMagic, cifsMagic, smbSuperMagic, smb2MagicNumber, fuseSuperMagic: + return true, nil + default: + return false, nil + } +} + +func entryInfo(path string) (*rpc.EntryInfo, error) { + info, err := filesystem.GetEntryFromPath(path) + if err != nil { + if os.IsNotExist(err) { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("file not found: %w", err)) + } + + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error getting file info: %w", err)) + } + + owner, group := getFileOwnership(info) + + return &rpc.EntryInfo{ + Name: info.Name, + Type: getEntryType(info.Type), + Path: info.Path, + Size: info.Size, + Mode: uint32(info.Mode), + Permissions: info.Permissions, + Owner: owner, + Group: group, + ModifiedTime: toTimestamp(info.ModifiedTime), + SymlinkTarget: info.SymlinkTarget, + }, nil +} + +func toTimestamp(time time.Time) *timestamppb.Timestamp { + if time.IsZero() { + return nil + } + + return timestamppb.New(time) +} + +// getFileOwnership returns the owner and group names for a file. +// If the lookup fails, it returns the numeric UID and GID as strings. +func getFileOwnership(fileInfo filesystem.EntryInfo) (owner, group string) { + // Look up username + owner = fmt.Sprintf("%d", fileInfo.UID) + if u, err := user.LookupId(owner); err == nil { + owner = u.Username + } + + // Look up group name + group = fmt.Sprintf("%d", fileInfo.GID) + if g, err := user.LookupGroupId(group); err == nil { + group = g.Name + } + + return owner, group +} + +// getEntryType determines the type of file entry based on its mode and path. +// If the file is a symlink, it follows the symlink to determine the actual type. +func getEntryType(fileType filesystem.FileType) rpc.FileType { + switch fileType { + case filesystem.FileFileType: + return rpc.FileType_FILE_TYPE_FILE + case filesystem.DirectoryFileType: + return rpc.FileType_FILE_TYPE_DIRECTORY + case filesystem.SymlinkFileType: + return rpc.FileType_FILE_TYPE_SYMLINK + default: + return rpc.FileType_FILE_TYPE_UNSPECIFIED + } +} diff --git a/envd/internal/services/filesystem/utils_test.go b/envd/internal/services/filesystem/utils_test.go new file mode 100644 index 0000000..0f0f9ad --- /dev/null +++ b/envd/internal/services/filesystem/utils_test.go @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: Apache-2.0 + +package filesystem + +import ( + "context" + "os/exec" + osuser "os/user" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + fsmodel "git.omukk.dev/wrenn/sandbox/envd/internal/shared/filesystem" +) + +func TestIsPathOnNetworkMount(t *testing.T) { + t.Parallel() + + // Test with a regular directory (should not be on network mount) + tempDir := t.TempDir() + isNetwork, err := IsPathOnNetworkMount(tempDir) + require.NoError(t, err) + assert.False(t, isNetwork, "temp directory should not be on a network mount") +} + +func TestIsPathOnNetworkMount_FuseMount(t *testing.T) { + t.Parallel() + + // Require bindfs to be available + _, err := exec.LookPath("bindfs") + require.NoError(t, err, "bindfs must be installed for this test") + + // Require fusermount to be available (needed for unmounting) + _, err = exec.LookPath("fusermount") + require.NoError(t, err, "fusermount must be installed for this test") + + // Create source and mount directories + sourceDir := t.TempDir() + mountDir := t.TempDir() + + // Mount sourceDir onto mountDir using bindfs (FUSE) + ctx := context.Background() + cmd := exec.CommandContext(ctx, "bindfs", sourceDir, mountDir) + require.NoError(t, cmd.Run(), "failed to mount bindfs") + + // Ensure we unmount on cleanup + t.Cleanup(func() { + _ = exec.CommandContext(context.Background(), "fusermount", "-u", mountDir).Run() + }) + + // Test that the FUSE mount is detected + isNetwork, err := IsPathOnNetworkMount(mountDir) + require.NoError(t, err) + assert.True(t, isNetwork, "FUSE mount should be detected as network filesystem") + + // Test that the source directory is NOT detected as network mount + isNetworkSource, err := IsPathOnNetworkMount(sourceDir) + require.NoError(t, err) + assert.False(t, isNetworkSource, "source directory should not be detected as network filesystem") +} + +func TestGetFileOwnership_CurrentUser(t *testing.T) { + t.Parallel() + + t.Run("current user", func(t *testing.T) { + t.Parallel() + + // Get current user running the tests + cur, err := osuser.Current() + if err != nil { + t.Skipf("unable to determine current user: %v", err) + } + + // Determine expected owner/group using the same lookup logic + expectedOwner := cur.Uid + if u, err := osuser.LookupId(cur.Uid); err == nil { + expectedOwner = u.Username + } + + expectedGroup := cur.Gid + if g, err := osuser.LookupGroupId(cur.Gid); err == nil { + expectedGroup = g.Name + } + + // Parse UID/GID strings to uint32 for EntryInfo + uid64, err := strconv.ParseUint(cur.Uid, 10, 32) + require.NoError(t, err) + gid64, err := strconv.ParseUint(cur.Gid, 10, 32) + require.NoError(t, err) + + // Build a minimal EntryInfo with current UID/GID + info := fsmodel.EntryInfo{ // from shared pkg + UID: uint32(uid64), + GID: uint32(gid64), + } + + owner, group := getFileOwnership(info) + assert.Equal(t, expectedOwner, owner) + assert.Equal(t, expectedGroup, group) + }) + + t.Run("no user", func(t *testing.T) { + t.Parallel() + + // Find a UID that does not exist on this system + var unknownUIDStr string + for i := 60001; i < 70000; i++ { // search a high range typically unused + idStr := strconv.Itoa(i) + if _, err := osuser.LookupId(idStr); err != nil { + unknownUIDStr = idStr + + break + } + } + if unknownUIDStr == "" { + t.Skip("could not find a non-existent UID in the probed range") + } + + // Find a GID that does not exist on this system + var unknownGIDStr string + for i := 60001; i < 70000; i++ { // search a high range typically unused + idStr := strconv.Itoa(i) + if _, err := osuser.LookupGroupId(idStr); err != nil { + unknownGIDStr = idStr + + break + } + } + if unknownGIDStr == "" { + t.Skip("could not find a non-existent GID in the probed range") + } + + // Parse to uint32 for EntryInfo construction + uid64, err := strconv.ParseUint(unknownUIDStr, 10, 32) + require.NoError(t, err) + gid64, err := strconv.ParseUint(unknownGIDStr, 10, 32) + require.NoError(t, err) + + info := fsmodel.EntryInfo{ + UID: uint32(uid64), + GID: uint32(gid64), + } + + owner, group := getFileOwnership(info) + // Expect numeric fallbacks because lookups should fail for unknown IDs + assert.Equal(t, unknownUIDStr, owner) + assert.Equal(t, unknownGIDStr, group) + }) +} diff --git a/envd/internal/services/filesystem/watch.go b/envd/internal/services/filesystem/watch.go new file mode 100644 index 0000000..0ad0105 --- /dev/null +++ b/envd/internal/services/filesystem/watch.go @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: Apache-2.0 + +package filesystem + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "connectrpc.com/connect" + "github.com/e2b-dev/fsnotify" + + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem" + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" +) + +func (s Service) WatchDir(ctx context.Context, req *connect.Request[rpc.WatchDirRequest], stream *connect.ServerStream[rpc.WatchDirResponse]) error { + return logs.LogServerStreamWithoutEvents(ctx, s.logger, req, stream, s.watchHandler) +} + +func (s Service) watchHandler(ctx context.Context, req *connect.Request[rpc.WatchDirRequest], stream *connect.ServerStream[rpc.WatchDirResponse]) error { + u, err := permissions.GetAuthUser(ctx, s.defaults.User) + if err != nil { + return err + } + + watchPath, err := permissions.ExpandAndResolve(req.Msg.GetPath(), u, s.defaults.Workdir) + if err != nil { + return connect.NewError(connect.CodeInvalidArgument, err) + } + + info, err := os.Stat(watchPath) + if err != nil { + if os.IsNotExist(err) { + return connect.NewError(connect.CodeNotFound, fmt.Errorf("path %s not found: %w", watchPath, err)) + } + + return connect.NewError(connect.CodeInternal, fmt.Errorf("error statting path %s: %w", watchPath, err)) + } + + if !info.IsDir() { + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("path %s not a directory: %w", watchPath, err)) + } + + // Check if path is on a network filesystem mount + isNetworkMount, err := IsPathOnNetworkMount(watchPath) + if err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("error checking mount status: %w", err)) + } + if isNetworkMount { + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("cannot watch path on network filesystem: %s", watchPath)) + } + + w, err := fsnotify.NewWatcher() + if err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("error creating watcher: %w", err)) + } + defer w.Close() + + err = w.Add(utils.FsnotifyPath(watchPath, req.Msg.GetRecursive())) + if err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("error adding path %s to watcher: %w", watchPath, err)) + } + + err = stream.Send(&rpc.WatchDirResponse{ + Event: &rpc.WatchDirResponse_Start{ + Start: &rpc.WatchDirResponse_StartEvent{}, + }, + }) + if err != nil { + return connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending start event: %w", err)) + } + + keepaliveTicker, resetKeepalive := permissions.GetKeepAliveTicker(req) + defer keepaliveTicker.Stop() + + for { + select { + case <-keepaliveTicker.C: + streamErr := stream.Send(&rpc.WatchDirResponse{ + Event: &rpc.WatchDirResponse_Keepalive{ + Keepalive: &rpc.WatchDirResponse_KeepAlive{}, + }, + }) + if streamErr != nil { + return connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending keepalive: %w", streamErr)) + } + case <-ctx.Done(): + return ctx.Err() + case chErr, ok := <-w.Errors: + if !ok { + return connect.NewError(connect.CodeInternal, fmt.Errorf("watcher error channel closed")) + } + + return connect.NewError(connect.CodeInternal, fmt.Errorf("watcher error: %w", chErr)) + case e, ok := <-w.Events: + if !ok { + return connect.NewError(connect.CodeInternal, fmt.Errorf("watcher event channel closed")) + } + + // One event can have multiple operations. + ops := []rpc.EventType{} + + if fsnotify.Create.Has(e.Op) { + ops = append(ops, rpc.EventType_EVENT_TYPE_CREATE) + } + + if fsnotify.Rename.Has(e.Op) { + ops = append(ops, rpc.EventType_EVENT_TYPE_RENAME) + } + + if fsnotify.Chmod.Has(e.Op) { + ops = append(ops, rpc.EventType_EVENT_TYPE_CHMOD) + } + + if fsnotify.Write.Has(e.Op) { + ops = append(ops, rpc.EventType_EVENT_TYPE_WRITE) + } + + if fsnotify.Remove.Has(e.Op) { + ops = append(ops, rpc.EventType_EVENT_TYPE_REMOVE) + } + + for _, op := range ops { + name, nameErr := filepath.Rel(watchPath, e.Name) + if nameErr != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("error getting relative path: %w", nameErr)) + } + + filesystemEvent := &rpc.WatchDirResponse_Filesystem{ + Filesystem: &rpc.FilesystemEvent{ + Name: name, + Type: op, + }, + } + + event := &rpc.WatchDirResponse{ + Event: filesystemEvent, + } + + streamErr := stream.Send(event) + + s.logger. + Debug(). + Str("event_type", "filesystem_event"). + Str(string(logs.OperationIDKey), ctx.Value(logs.OperationIDKey).(string)). + Interface("filesystem_event", event). + Msg("Streaming filesystem event") + + if streamErr != nil { + return connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending filesystem event: %w", streamErr)) + } + + resetKeepalive() + } + } + } +} diff --git a/envd/internal/services/filesystem/watch_sync.go b/envd/internal/services/filesystem/watch_sync.go new file mode 100644 index 0000000..fb5c407 --- /dev/null +++ b/envd/internal/services/filesystem/watch_sync.go @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: Apache-2.0 + +package filesystem + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sync" + + "connectrpc.com/connect" + "github.com/e2b-dev/fsnotify" + "github.com/rs/zerolog" + + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem" + "git.omukk.dev/wrenn/sandbox/envd/internal/shared/id" + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" +) + +type FileWatcher struct { + watcher *fsnotify.Watcher + Events []*rpc.FilesystemEvent + cancel func() + Error error + + Lock sync.Mutex +} + +func CreateFileWatcher(ctx context.Context, watchPath string, recursive bool, operationID string, logger *zerolog.Logger) (*FileWatcher, error) { + w, err := fsnotify.NewWatcher() + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error creating watcher: %w", err)) + } + + // We don't want to cancel the context when the request is finished + ctx, cancel := context.WithCancel(context.WithoutCancel(ctx)) + + err = w.Add(utils.FsnotifyPath(watchPath, recursive)) + if err != nil { + _ = w.Close() + cancel() + + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error adding path %s to watcher: %w", watchPath, err)) + } + fw := &FileWatcher{ + watcher: w, + cancel: cancel, + Events: []*rpc.FilesystemEvent{}, + Error: nil, + } + + go func() { + for { + select { + case <-ctx.Done(): + return + case chErr, ok := <-w.Errors: + if !ok { + fw.Error = connect.NewError(connect.CodeInternal, fmt.Errorf("watcher error channel closed")) + + return + } + + fw.Error = connect.NewError(connect.CodeInternal, fmt.Errorf("watcher error: %w", chErr)) + + return + case e, ok := <-w.Events: + if !ok { + fw.Error = connect.NewError(connect.CodeInternal, fmt.Errorf("watcher event channel closed")) + + return + } + + // One event can have multiple operations. + ops := []rpc.EventType{} + + if fsnotify.Create.Has(e.Op) { + ops = append(ops, rpc.EventType_EVENT_TYPE_CREATE) + } + + if fsnotify.Rename.Has(e.Op) { + ops = append(ops, rpc.EventType_EVENT_TYPE_RENAME) + } + + if fsnotify.Chmod.Has(e.Op) { + ops = append(ops, rpc.EventType_EVENT_TYPE_CHMOD) + } + + if fsnotify.Write.Has(e.Op) { + ops = append(ops, rpc.EventType_EVENT_TYPE_WRITE) + } + + if fsnotify.Remove.Has(e.Op) { + ops = append(ops, rpc.EventType_EVENT_TYPE_REMOVE) + } + + for _, op := range ops { + name, nameErr := filepath.Rel(watchPath, e.Name) + if nameErr != nil { + fw.Error = connect.NewError(connect.CodeInternal, fmt.Errorf("error getting relative path: %w", nameErr)) + + return + } + + fw.Lock.Lock() + fw.Events = append(fw.Events, &rpc.FilesystemEvent{ + Name: name, + Type: op, + }) + fw.Lock.Unlock() + + // these are only used for logging + filesystemEvent := &rpc.WatchDirResponse_Filesystem{ + Filesystem: &rpc.FilesystemEvent{ + Name: name, + Type: op, + }, + } + event := &rpc.WatchDirResponse{ + Event: filesystemEvent, + } + + logger. + Debug(). + Str("event_type", "filesystem_event"). + Str(string(logs.OperationIDKey), operationID). + Interface("filesystem_event", event). + Msg("Streaming filesystem event") + } + } + } + }() + + return fw, nil +} + +func (fw *FileWatcher) Close() { + _ = fw.watcher.Close() + fw.cancel() +} + +func (s Service) CreateWatcher(ctx context.Context, req *connect.Request[rpc.CreateWatcherRequest]) (*connect.Response[rpc.CreateWatcherResponse], error) { + u, err := permissions.GetAuthUser(ctx, s.defaults.User) + if err != nil { + return nil, err + } + + watchPath, err := permissions.ExpandAndResolve(req.Msg.GetPath(), u, s.defaults.Workdir) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + info, err := os.Stat(watchPath) + if err != nil { + if os.IsNotExist(err) { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("path %s not found: %w", watchPath, err)) + } + + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error statting path %s: %w", watchPath, err)) + } + + if !info.IsDir() { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("path %s not a directory: %w", watchPath, err)) + } + + // Check if path is on a network filesystem mount + isNetworkMount, err := IsPathOnNetworkMount(watchPath) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error checking mount status: %w", err)) + } + if isNetworkMount { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("cannot watch path on network filesystem: %s", watchPath)) + } + + watcherId := "w" + id.Generate() + + w, err := CreateFileWatcher(ctx, watchPath, req.Msg.GetRecursive(), watcherId, s.logger) + if err != nil { + return nil, err + } + + s.watchers.Store(watcherId, w) + + return connect.NewResponse(&rpc.CreateWatcherResponse{ + WatcherId: watcherId, + }), nil +} + +func (s Service) GetWatcherEvents(_ context.Context, req *connect.Request[rpc.GetWatcherEventsRequest]) (*connect.Response[rpc.GetWatcherEventsResponse], error) { + watcherId := req.Msg.GetWatcherId() + + w, ok := s.watchers.Load(watcherId) + if !ok { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("watcher with id %s not found", watcherId)) + } + + if w.Error != nil { + return nil, w.Error + } + + w.Lock.Lock() + defer w.Lock.Unlock() + events := w.Events + w.Events = []*rpc.FilesystemEvent{} + + return connect.NewResponse(&rpc.GetWatcherEventsResponse{ + Events: events, + }), nil +} + +func (s Service) RemoveWatcher(_ context.Context, req *connect.Request[rpc.RemoveWatcherRequest]) (*connect.Response[rpc.RemoveWatcherResponse], error) { + watcherId := req.Msg.GetWatcherId() + + w, ok := s.watchers.Load(watcherId) + if !ok { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("watcher with id %s not found", watcherId)) + } + + w.Close() + s.watchers.Delete(watcherId) + + return connect.NewResponse(&rpc.RemoveWatcherResponse{}), nil +} diff --git a/envd/internal/services/process/connect.go b/envd/internal/services/process/connect.go new file mode 100644 index 0000000..6d900ef --- /dev/null +++ b/envd/internal/services/process/connect.go @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 + +package process + +import ( + "context" + "errors" + "fmt" + + "connectrpc.com/connect" + + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process" +) + +func (s *Service) Connect(ctx context.Context, req *connect.Request[rpc.ConnectRequest], stream *connect.ServerStream[rpc.ConnectResponse]) error { + return logs.LogServerStreamWithoutEvents(ctx, s.logger, req, stream, s.handleConnect) +} + +func (s *Service) handleConnect(ctx context.Context, req *connect.Request[rpc.ConnectRequest], stream *connect.ServerStream[rpc.ConnectResponse]) error { + ctx, cancel := context.WithCancelCause(ctx) + defer cancel(nil) + + proc, err := s.getProcess(req.Msg.GetProcess()) + if err != nil { + return err + } + + exitChan := make(chan struct{}) + + data, dataCancel := proc.DataEvent.Fork() + defer dataCancel() + + end, endCancel := proc.EndEvent.Fork() + defer endCancel() + + streamErr := stream.Send(&rpc.ConnectResponse{ + Event: &rpc.ProcessEvent{ + Event: &rpc.ProcessEvent_Start{ + Start: &rpc.ProcessEvent_StartEvent{ + Pid: proc.Pid(), + }, + }, + }, + }) + if streamErr != nil { + return connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending start event: %w", streamErr)) + } + + go func() { + defer close(exitChan) + + keepaliveTicker, resetKeepalive := permissions.GetKeepAliveTicker(req) + defer keepaliveTicker.Stop() + + dataLoop: + for { + select { + case <-keepaliveTicker.C: + streamErr := stream.Send(&rpc.ConnectResponse{ + Event: &rpc.ProcessEvent{ + Event: &rpc.ProcessEvent_Keepalive{ + Keepalive: &rpc.ProcessEvent_KeepAlive{}, + }, + }, + }) + if streamErr != nil { + cancel(connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending keepalive: %w", streamErr))) + + return + } + case <-ctx.Done(): + cancel(ctx.Err()) + + return + case event, ok := <-data: + if !ok { + break dataLoop + } + + streamErr := stream.Send(&rpc.ConnectResponse{ + Event: &rpc.ProcessEvent{ + Event: &event, + }, + }) + if streamErr != nil { + cancel(connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending data event: %w", streamErr))) + + return + } + + resetKeepalive() + } + } + + select { + case <-ctx.Done(): + cancel(ctx.Err()) + + return + case event, ok := <-end: + if !ok { + cancel(connect.NewError(connect.CodeUnknown, errors.New("end event channel closed before sending end event"))) + + return + } + + streamErr := stream.Send(&rpc.ConnectResponse{ + Event: &rpc.ProcessEvent{ + Event: &event, + }, + }) + if streamErr != nil { + cancel(connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending end event: %w", streamErr))) + + return + } + } + }() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-exitChan: + return nil + } +} diff --git a/envd/internal/services/process/handler/handler.go b/envd/internal/services/process/handler/handler.go new file mode 100644 index 0000000..dc5a8dd --- /dev/null +++ b/envd/internal/services/process/handler/handler.go @@ -0,0 +1,480 @@ +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "os/user" + "strconv" + "strings" + "sync" + "syscall" + + "connectrpc.com/connect" + "github.com/creack/pty" + "github.com/rs/zerolog" + + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" + "git.omukk.dev/wrenn/sandbox/envd/internal/services/cgroups" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process" +) + +const ( + defaultNice = 0 + defaultOomScore = 100 + outputBufferSize = 64 + stdChunkSize = 2 << 14 + ptyChunkSize = 2 << 13 +) + +type ProcessExit struct { + Error *string + Status string + Exited bool + Code int32 +} + +type Handler struct { + Config *rpc.ProcessConfig + + logger *zerolog.Logger + + Tag *string + cmd *exec.Cmd + tty *os.File + + cancel context.CancelFunc + + outCtx context.Context //nolint:containedctx // todo: refactor so this can be removed + outCancel context.CancelFunc + + stdinMu sync.Mutex + stdin io.WriteCloser + + DataEvent *MultiplexedChannel[rpc.ProcessEvent_Data] + EndEvent *MultiplexedChannel[rpc.ProcessEvent_End] +} + +// This method must be called only after the process has been started +func (p *Handler) Pid() uint32 { + return uint32(p.cmd.Process.Pid) +} + +// userCommand returns a human-readable representation of the user's original command, +// without the internal OOM/nice wrapper that is prepended to the actual exec. +func (p *Handler) userCommand() string { + return strings.Join(append([]string{p.Config.GetCmd()}, p.Config.GetArgs()...), " ") +} + +// currentNice returns the nice value of the current process. +func currentNice() int { + prio, err := syscall.Getpriority(syscall.PRIO_PROCESS, 0) + if err != nil { + return 0 + } + + // Getpriority returns 20 - nice on Linux. + return 20 - prio +} + +func New( + ctx context.Context, + user *user.User, + req *rpc.StartRequest, + logger *zerolog.Logger, + defaults *execcontext.Defaults, + cgroupManager cgroups.Manager, + cancel context.CancelFunc, +) (*Handler, error) { + // User command string for logging (without the internal wrapper details). + userCmd := strings.Join(append([]string{req.GetProcess().GetCmd()}, req.GetProcess().GetArgs()...), " ") + + // Wrap the command in a shell that sets the OOM score and nice value before exec-ing the actual command. + // This eliminates the race window where grandchildren could inherit the parent's protected OOM score (-1000) + // or high CPU priority (nice -20) before the post-start calls had a chance to correct them. + // nice(1) applies a relative adjustment, so we compute the delta from the current (inherited) nice to the target. + niceDelta := defaultNice - currentNice() + oomWrapperScript := fmt.Sprintf(`echo %d > /proc/$$/oom_score_adj && exec /usr/bin/nice -n %d "${@}"`, defaultOomScore, niceDelta) + wrapperArgs := append([]string{"-c", oomWrapperScript, "--", req.GetProcess().GetCmd()}, req.GetProcess().GetArgs()...) + cmd := exec.CommandContext(ctx, "/bin/sh", wrapperArgs...) + + uid, gid, err := permissions.GetUserIdUints(user) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + groups := []uint32{gid} + if gids, err := user.GroupIds(); err != nil { + logger.Warn().Err(err).Str("user", user.Username).Msg("failed to get supplementary groups") + } else { + for _, g := range gids { + if parsed, err := strconv.ParseUint(g, 10, 32); err == nil { + groups = append(groups, uint32(parsed)) + } + } + } + + cgroupFD, ok := cgroupManager.GetFileDescriptor(getProcType(req)) + + cmd.SysProcAttr = &syscall.SysProcAttr{ + UseCgroupFD: ok, + CgroupFD: cgroupFD, + Credential: &syscall.Credential{ + Uid: uid, + Gid: gid, + Groups: groups, + }, + } + + resolvedPath, err := permissions.ExpandAndResolve(req.GetProcess().GetCwd(), user, defaults.Workdir) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + // Check if the cwd resolved path exists + if _, err := os.Stat(resolvedPath); errors.Is(err, os.ErrNotExist) { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("cwd '%s' does not exist", resolvedPath)) + } + + cmd.Dir = resolvedPath + + var formattedVars []string + + // Take only 'PATH' variable from the current environment + // The 'PATH' should ideally be set in the environment + formattedVars = append(formattedVars, "PATH="+os.Getenv("PATH")) + formattedVars = append(formattedVars, "HOME="+user.HomeDir) + formattedVars = append(formattedVars, "USER="+user.Username) + formattedVars = append(formattedVars, "LOGNAME="+user.Username) + + // Add the environment variables from the global environment + if defaults.EnvVars != nil { + defaults.EnvVars.Range(func(key string, value string) bool { + formattedVars = append(formattedVars, key+"="+value) + + return true + }) + } + + // Only the last values of the env vars are used - this allows for overwriting defaults + for key, value := range req.GetProcess().GetEnvs() { + formattedVars = append(formattedVars, key+"="+value) + } + + cmd.Env = formattedVars + + outMultiplex := NewMultiplexedChannel[rpc.ProcessEvent_Data](outputBufferSize) + + var outWg sync.WaitGroup + + // Create a context for waiting for and cancelling output pipes. + // Cancellation of the process via timeout will propagate and cancel this context too. + outCtx, outCancel := context.WithCancel(ctx) + + h := &Handler{ + Config: req.GetProcess(), + cmd: cmd, + Tag: req.Tag, + DataEvent: outMultiplex, + cancel: cancel, + outCtx: outCtx, + outCancel: outCancel, + EndEvent: NewMultiplexedChannel[rpc.ProcessEvent_End](0), + logger: logger, + } + + if req.GetPty() != nil { + // The pty should ideally start only in the Start method, but the package does not support that and we would have to code it manually. + // The output of the pty should correctly be passed though. + tty, err := pty.StartWithSize(cmd, &pty.Winsize{ + Cols: uint16(req.GetPty().GetSize().GetCols()), + Rows: uint16(req.GetPty().GetSize().GetRows()), + }) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("error starting pty with command '%s' in dir '%s' with '%d' cols and '%d' rows: %w", userCmd, cmd.Dir, req.GetPty().GetSize().GetCols(), req.GetPty().GetSize().GetRows(), err)) + } + + outWg.Go(func() { + for { + buf := make([]byte, ptyChunkSize) + + n, readErr := tty.Read(buf) + + if n > 0 { + outMultiplex.Source <- rpc.ProcessEvent_Data{ + Data: &rpc.ProcessEvent_DataEvent{ + Output: &rpc.ProcessEvent_DataEvent_Pty{ + Pty: buf[:n], + }, + }, + } + } + + if errors.Is(readErr, io.EOF) { + break + } + + if readErr != nil { + fmt.Fprintf(os.Stderr, "error reading from pty: %s\n", readErr) + + break + } + } + }) + + h.tty = tty + } else { + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error creating stdout pipe for command '%s': %w", userCmd, err)) + } + + outWg.Go(func() { + stdoutLogs := make(chan []byte, outputBufferSize) + defer close(stdoutLogs) + + stdoutLogger := logger.With().Str("event_type", "stdout").Logger() + + go logs.LogBufferedDataEvents(stdoutLogs, &stdoutLogger, "data") + + for { + buf := make([]byte, stdChunkSize) + + n, readErr := stdout.Read(buf) + + if n > 0 { + outMultiplex.Source <- rpc.ProcessEvent_Data{ + Data: &rpc.ProcessEvent_DataEvent{ + Output: &rpc.ProcessEvent_DataEvent_Stdout{ + Stdout: buf[:n], + }, + }, + } + + stdoutLogs <- buf[:n] + } + + if errors.Is(readErr, io.EOF) { + break + } + + if readErr != nil { + fmt.Fprintf(os.Stderr, "error reading from stdout: %s\n", readErr) + + break + } + } + }) + + stderr, err := cmd.StderrPipe() + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error creating stderr pipe for command '%s': %w", userCmd, err)) + } + + outWg.Go(func() { + stderrLogs := make(chan []byte, outputBufferSize) + defer close(stderrLogs) + + stderrLogger := logger.With().Str("event_type", "stderr").Logger() + + go logs.LogBufferedDataEvents(stderrLogs, &stderrLogger, "data") + + for { + buf := make([]byte, stdChunkSize) + + n, readErr := stderr.Read(buf) + + if n > 0 { + outMultiplex.Source <- rpc.ProcessEvent_Data{ + Data: &rpc.ProcessEvent_DataEvent{ + Output: &rpc.ProcessEvent_DataEvent_Stderr{ + Stderr: buf[:n], + }, + }, + } + + stderrLogs <- buf[:n] + } + + if errors.Is(readErr, io.EOF) { + break + } + + if readErr != nil { + fmt.Fprintf(os.Stderr, "error reading from stderr: %s\n", readErr) + + break + } + } + }) + + // For backwards compatibility we still set the stdin if not explicitly disabled + // If stdin is disabled, the process will use /dev/null as stdin + if req.Stdin == nil || req.GetStdin() == true { + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error creating stdin pipe for command '%s': %w", userCmd, err)) + } + + h.stdin = stdin + } + } + + go func() { + outWg.Wait() + + close(outMultiplex.Source) + + outCancel() + }() + + return h, nil +} + +func getProcType(req *rpc.StartRequest) cgroups.ProcessType { + if req != nil && req.GetPty() != nil { + return cgroups.ProcessTypePTY + } + + return cgroups.ProcessTypeUser +} + +func (p *Handler) SendSignal(signal syscall.Signal) error { + if p.cmd.Process == nil { + return fmt.Errorf("process not started") + } + + if signal == syscall.SIGKILL || signal == syscall.SIGTERM { + p.outCancel() + } + + return p.cmd.Process.Signal(signal) +} + +func (p *Handler) ResizeTty(size *pty.Winsize) error { + if p.tty == nil { + return fmt.Errorf("tty not assigned to process") + } + + return pty.Setsize(p.tty, size) +} + +func (p *Handler) WriteStdin(data []byte) error { + if p.tty != nil { + return fmt.Errorf("tty assigned to process — input should be written to the pty, not the stdin") + } + + p.stdinMu.Lock() + defer p.stdinMu.Unlock() + + if p.stdin == nil { + return fmt.Errorf("stdin not enabled or closed") + } + + _, err := p.stdin.Write(data) + if err != nil { + return fmt.Errorf("error writing to stdin of process '%d': %w", p.cmd.Process.Pid, err) + } + + return nil +} + +// CloseStdin closes the stdin pipe to signal EOF to the process. +// Only works for non-PTY processes. +func (p *Handler) CloseStdin() error { + if p.tty != nil { + return fmt.Errorf("cannot close stdin for PTY process — send Ctrl+D (0x04) instead") + } + + p.stdinMu.Lock() + defer p.stdinMu.Unlock() + + if p.stdin == nil { + return nil + } + + err := p.stdin.Close() + // We still set the stdin to nil even on error as there are no errors, + // for which it is really safe to retry close across all distributions. + p.stdin = nil + + return err +} + +func (p *Handler) WriteTty(data []byte) error { + if p.tty == nil { + return fmt.Errorf("tty not assigned to process — input should be written to the stdin, not the tty") + } + + _, err := p.tty.Write(data) + if err != nil { + return fmt.Errorf("error writing to tty of process '%d': %w", p.cmd.Process.Pid, err) + } + + return nil +} + +func (p *Handler) Start() (uint32, error) { + // Pty is already started in the New method + if p.tty == nil { + err := p.cmd.Start() + if err != nil { + return 0, fmt.Errorf("error starting process '%s': %w", p.userCommand(), err) + } + } + + p.logger. + Info(). + Str("event_type", "process_start"). + Int("pid", p.cmd.Process.Pid). + Str("command", p.userCommand()). + Msg(fmt.Sprintf("Process with pid %d started", p.cmd.Process.Pid)) + + return uint32(p.cmd.Process.Pid), nil +} + +func (p *Handler) Wait() { + // Wait for the output pipes to be closed or cancelled. + <-p.outCtx.Done() + + err := p.cmd.Wait() + + p.tty.Close() + + var errMsg *string + + if err != nil { + msg := err.Error() + errMsg = &msg + } + + endEvent := &rpc.ProcessEvent_EndEvent{ + Error: errMsg, + ExitCode: int32(p.cmd.ProcessState.ExitCode()), + Exited: p.cmd.ProcessState.Exited(), + Status: p.cmd.ProcessState.String(), + } + + event := rpc.ProcessEvent_End{ + End: endEvent, + } + + p.EndEvent.Source <- event + + p.logger. + Info(). + Str("event_type", "process_end"). + Interface("process_result", endEvent). + Msg(fmt.Sprintf("Process with pid %d ended", p.cmd.Process.Pid)) + + // Ensure the process cancel is called to cleanup resources. + // As it is called after end event and Wait, it should not affect command execution or returned events. + p.cancel() +} diff --git a/envd/internal/services/process/handler/multiplex.go b/envd/internal/services/process/handler/multiplex.go new file mode 100644 index 0000000..4fe696e --- /dev/null +++ b/envd/internal/services/process/handler/multiplex.go @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 + +package handler + +import ( + "sync" + "sync/atomic" +) + +type MultiplexedChannel[T any] struct { + Source chan T + channels []chan T + mu sync.RWMutex + exited atomic.Bool +} + +func NewMultiplexedChannel[T any](buffer int) *MultiplexedChannel[T] { + c := &MultiplexedChannel[T]{ + channels: nil, + Source: make(chan T, buffer), + } + + go func() { + for v := range c.Source { + c.mu.RLock() + + for _, cons := range c.channels { + cons <- v + } + + c.mu.RUnlock() + } + + c.exited.Store(true) + + for _, cons := range c.channels { + close(cons) + } + }() + + return c +} + +func (m *MultiplexedChannel[T]) Fork() (chan T, func()) { + if m.exited.Load() { + ch := make(chan T) + close(ch) + + return ch, func() {} + } + + m.mu.Lock() + defer m.mu.Unlock() + + consumer := make(chan T) + + m.channels = append(m.channels, consumer) + + return consumer, func() { + m.remove(consumer) + } +} + +func (m *MultiplexedChannel[T]) remove(consumer chan T) { + m.mu.Lock() + defer m.mu.Unlock() + + for i, ch := range m.channels { + if ch == consumer { + m.channels = append(m.channels[:i], m.channels[i+1:]...) + + return + } + } +} diff --git a/envd/internal/services/process/input.go b/envd/internal/services/process/input.go new file mode 100644 index 0000000..da82d37 --- /dev/null +++ b/envd/internal/services/process/input.go @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 + +package process + +import ( + "context" + "fmt" + + "connectrpc.com/connect" + "github.com/rs/zerolog" + + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + "git.omukk.dev/wrenn/sandbox/envd/internal/services/process/handler" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process" +) + +func handleInput(ctx context.Context, process *handler.Handler, in *rpc.ProcessInput, logger *zerolog.Logger) error { + switch in.GetInput().(type) { + case *rpc.ProcessInput_Pty: + err := process.WriteTty(in.GetPty()) + if err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("error writing to tty: %w", err)) + } + + case *rpc.ProcessInput_Stdin: + err := process.WriteStdin(in.GetStdin()) + if err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("error writing to stdin: %w", err)) + } + + logger.Debug(). + Str("event_type", "stdin"). + Interface("stdin", in.GetStdin()). + Str(string(logs.OperationIDKey), ctx.Value(logs.OperationIDKey).(string)). + Msg("Streaming input to process") + + default: + return connect.NewError(connect.CodeUnimplemented, fmt.Errorf("invalid input type %T", in.GetInput())) + } + + return nil +} + +func (s *Service) SendInput(ctx context.Context, req *connect.Request[rpc.SendInputRequest]) (*connect.Response[rpc.SendInputResponse], error) { + proc, err := s.getProcess(req.Msg.GetProcess()) + if err != nil { + return nil, err + } + + err = handleInput(ctx, proc, req.Msg.GetInput(), s.logger) + if err != nil { + return nil, err + } + + return connect.NewResponse(&rpc.SendInputResponse{}), nil +} + +func (s *Service) StreamInput(ctx context.Context, stream *connect.ClientStream[rpc.StreamInputRequest]) (*connect.Response[rpc.StreamInputResponse], error) { + return logs.LogClientStreamWithoutEvents(ctx, s.logger, stream, s.streamInputHandler) +} + +func (s *Service) streamInputHandler(ctx context.Context, stream *connect.ClientStream[rpc.StreamInputRequest]) (*connect.Response[rpc.StreamInputResponse], error) { + var proc *handler.Handler + + for stream.Receive() { + req := stream.Msg() + + switch req.GetEvent().(type) { + case *rpc.StreamInputRequest_Start: + p, err := s.getProcess(req.GetStart().GetProcess()) + if err != nil { + return nil, err + } + + proc = p + case *rpc.StreamInputRequest_Data: + err := handleInput(ctx, proc, req.GetData().GetInput(), s.logger) + if err != nil { + return nil, err + } + case *rpc.StreamInputRequest_Keepalive: + default: + return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("invalid event type %T", req.GetEvent())) + } + } + + err := stream.Err() + if err != nil { + return nil, connect.NewError(connect.CodeUnknown, fmt.Errorf("error streaming input: %w", err)) + } + + return connect.NewResponse(&rpc.StreamInputResponse{}), nil +} + +func (s *Service) CloseStdin( + _ context.Context, + req *connect.Request[rpc.CloseStdinRequest], +) (*connect.Response[rpc.CloseStdinResponse], error) { + handler, err := s.getProcess(req.Msg.GetProcess()) + if err != nil { + return nil, err + } + + if err := handler.CloseStdin(); err != nil { + return nil, connect.NewError(connect.CodeUnknown, fmt.Errorf("error closing stdin: %w", err)) + } + + return connect.NewResponse(&rpc.CloseStdinResponse{}), nil +} diff --git a/envd/internal/services/process/list.go b/envd/internal/services/process/list.go new file mode 100644 index 0000000..3b42655 --- /dev/null +++ b/envd/internal/services/process/list.go @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 + +package process + +import ( + "context" + + "connectrpc.com/connect" + + "git.omukk.dev/wrenn/sandbox/envd/internal/services/process/handler" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process" +) + +func (s *Service) List(context.Context, *connect.Request[rpc.ListRequest]) (*connect.Response[rpc.ListResponse], error) { + processes := make([]*rpc.ProcessInfo, 0) + + s.processes.Range(func(pid uint32, value *handler.Handler) bool { + processes = append(processes, &rpc.ProcessInfo{ + Pid: pid, + Tag: value.Tag, + Config: value.Config, + }) + + return true + }) + + return connect.NewResponse(&rpc.ListResponse{ + Processes: processes, + }), nil +} diff --git a/envd/internal/services/process/service.go b/envd/internal/services/process/service.go new file mode 100644 index 0000000..e00f345 --- /dev/null +++ b/envd/internal/services/process/service.go @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 + +package process + +import ( + "fmt" + + "connectrpc.com/connect" + "github.com/go-chi/chi/v5" + "github.com/rs/zerolog" + + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + "git.omukk.dev/wrenn/sandbox/envd/internal/services/cgroups" + "git.omukk.dev/wrenn/sandbox/envd/internal/services/process/handler" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process" + spec "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process/processconnect" + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" +) + +type Service struct { + processes *utils.Map[uint32, *handler.Handler] + logger *zerolog.Logger + defaults *execcontext.Defaults + cgroupManager cgroups.Manager +} + +func newService(l *zerolog.Logger, defaults *execcontext.Defaults, cgroupManager cgroups.Manager) *Service { + return &Service{ + logger: l, + processes: utils.NewMap[uint32, *handler.Handler](), + defaults: defaults, + cgroupManager: cgroupManager, + } +} + +func Handle(server *chi.Mux, l *zerolog.Logger, defaults *execcontext.Defaults, cgroupManager cgroups.Manager) *Service { + service := newService(l, defaults, cgroupManager) + + interceptors := connect.WithInterceptors(logs.NewUnaryLogInterceptor(l)) + + path, h := spec.NewProcessHandler(service, interceptors) + + server.Mount(path, h) + + return service +} + +func (s *Service) getProcess(selector *rpc.ProcessSelector) (*handler.Handler, error) { + var proc *handler.Handler + + switch selector.GetSelector().(type) { + case *rpc.ProcessSelector_Pid: + p, ok := s.processes.Load(selector.GetPid()) + if !ok { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("process with pid %d not found", selector.GetPid())) + } + + proc = p + case *rpc.ProcessSelector_Tag: + tag := selector.GetTag() + + s.processes.Range(func(_ uint32, value *handler.Handler) bool { + if value.Tag == nil { + return true + } + + if *value.Tag == tag { + proc = value + + return true + } + + return false + }) + + if proc == nil { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("process with tag %s not found", tag)) + } + + default: + return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("invalid input type %T", selector)) + } + + return proc, nil +} diff --git a/envd/internal/services/process/signal.go b/envd/internal/services/process/signal.go new file mode 100644 index 0000000..23795da --- /dev/null +++ b/envd/internal/services/process/signal.go @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 + +package process + +import ( + "context" + "fmt" + "syscall" + + "connectrpc.com/connect" + + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process" +) + +func (s *Service) SendSignal( + _ context.Context, + req *connect.Request[rpc.SendSignalRequest], +) (*connect.Response[rpc.SendSignalResponse], error) { + handler, err := s.getProcess(req.Msg.GetProcess()) + if err != nil { + return nil, err + } + + var signal syscall.Signal + switch req.Msg.GetSignal() { + case rpc.Signal_SIGNAL_SIGKILL: + signal = syscall.SIGKILL + case rpc.Signal_SIGNAL_SIGTERM: + signal = syscall.SIGTERM + default: + return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("invalid signal: %s", req.Msg.GetSignal())) + } + + err = handler.SendSignal(signal) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error sending signal: %w", err)) + } + + return connect.NewResponse(&rpc.SendSignalResponse{}), nil +} diff --git a/envd/internal/services/process/start.go b/envd/internal/services/process/start.go new file mode 100644 index 0000000..b9a61b1 --- /dev/null +++ b/envd/internal/services/process/start.go @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: Apache-2.0 + +package process + +import ( + "context" + "errors" + "fmt" + "net/http" + "os/user" + "strconv" + "time" + + "connectrpc.com/connect" + + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" + "git.omukk.dev/wrenn/sandbox/envd/internal/services/process/handler" + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process" +) + +func (s *Service) InitializeStartProcess(ctx context.Context, user *user.User, req *rpc.StartRequest) error { + var err error + + ctx = logs.AddRequestIDToContext(ctx) + + defer s.logger. + Err(err). + Interface("request", req). + Str(string(logs.OperationIDKey), ctx.Value(logs.OperationIDKey).(string)). + Msg("Initialized startCmd") + + handlerL := s.logger.With().Str(string(logs.OperationIDKey), ctx.Value(logs.OperationIDKey).(string)).Logger() + + startProcCtx, startProcCancel := context.WithCancel(ctx) + proc, err := handler.New(startProcCtx, user, req, &handlerL, s.defaults, s.cgroupManager, startProcCancel) + if err != nil { + return err + } + + pid, err := proc.Start() + if err != nil { + return err + } + + s.processes.Store(pid, proc) + + go func() { + defer s.processes.Delete(pid) + + proc.Wait() + }() + + return nil +} + +func (s *Service) Start(ctx context.Context, req *connect.Request[rpc.StartRequest], stream *connect.ServerStream[rpc.StartResponse]) error { + return logs.LogServerStreamWithoutEvents(ctx, s.logger, req, stream, s.handleStart) +} + +func (s *Service) handleStart(ctx context.Context, req *connect.Request[rpc.StartRequest], stream *connect.ServerStream[rpc.StartResponse]) error { + ctx, cancel := context.WithCancelCause(ctx) + defer cancel(nil) + + handlerL := s.logger.With().Str(string(logs.OperationIDKey), ctx.Value(logs.OperationIDKey).(string)).Logger() + + u, err := permissions.GetAuthUser(ctx, s.defaults.User) + if err != nil { + return err + } + + timeout, err := determineTimeoutFromHeader(stream.Conn().RequestHeader()) + if err != nil { + return connect.NewError(connect.CodeInvalidArgument, err) + } + + // Create a new context with a timeout if provided. + // We do not want the command to be killed if the request context is cancelled + procCtx, cancelProc := context.Background(), func() {} + if timeout > 0 { // zero timeout means no timeout + procCtx, cancelProc = context.WithTimeout(procCtx, timeout) + } + + proc, err := handler.New( //nolint:contextcheck // TODO: fix this later + procCtx, + u, + req.Msg, + &handlerL, + s.defaults, + s.cgroupManager, + cancelProc, + ) + if err != nil { + // Ensure the process cancel is called to cleanup resources. + cancelProc() + + return err + } + + exitChan := make(chan struct{}) + + startMultiplexer := handler.NewMultiplexedChannel[rpc.ProcessEvent_Start](0) + defer close(startMultiplexer.Source) + + start, startCancel := startMultiplexer.Fork() + defer startCancel() + + data, dataCancel := proc.DataEvent.Fork() + defer dataCancel() + + end, endCancel := proc.EndEvent.Fork() + defer endCancel() + + go func() { + defer close(exitChan) + + select { + case <-ctx.Done(): + cancel(ctx.Err()) + + return + case event, ok := <-start: + if !ok { + cancel(connect.NewError(connect.CodeUnknown, errors.New("start event channel closed before sending start event"))) + + return + } + + streamErr := stream.Send(&rpc.StartResponse{ + Event: &rpc.ProcessEvent{ + Event: &event, + }, + }) + if streamErr != nil { + cancel(connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending start event: %w", streamErr))) + + return + } + } + + keepaliveTicker, resetKeepalive := permissions.GetKeepAliveTicker(req) + defer keepaliveTicker.Stop() + + dataLoop: + for { + select { + case <-keepaliveTicker.C: + streamErr := stream.Send(&rpc.StartResponse{ + Event: &rpc.ProcessEvent{ + Event: &rpc.ProcessEvent_Keepalive{ + Keepalive: &rpc.ProcessEvent_KeepAlive{}, + }, + }, + }) + if streamErr != nil { + cancel(connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending keepalive: %w", streamErr))) + + return + } + case <-ctx.Done(): + cancel(ctx.Err()) + + return + case event, ok := <-data: + if !ok { + break dataLoop + } + + streamErr := stream.Send(&rpc.StartResponse{ + Event: &rpc.ProcessEvent{ + Event: &event, + }, + }) + if streamErr != nil { + cancel(connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending data event: %w", streamErr))) + + return + } + + resetKeepalive() + } + } + + select { + case <-ctx.Done(): + cancel(ctx.Err()) + + return + case event, ok := <-end: + if !ok { + cancel(connect.NewError(connect.CodeUnknown, errors.New("end event channel closed before sending end event"))) + + return + } + + streamErr := stream.Send(&rpc.StartResponse{ + Event: &rpc.ProcessEvent{ + Event: &event, + }, + }) + if streamErr != nil { + cancel(connect.NewError(connect.CodeUnknown, fmt.Errorf("error sending end event: %w", streamErr))) + + return + } + } + }() + + pid, err := proc.Start() + if err != nil { + return connect.NewError(connect.CodeInvalidArgument, err) + } + + s.processes.Store(pid, proc) + + start <- rpc.ProcessEvent_Start{ + Start: &rpc.ProcessEvent_StartEvent{ + Pid: pid, + }, + } + + go func() { + defer s.processes.Delete(pid) + + proc.Wait() + }() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-exitChan: + return nil + } +} + +func determineTimeoutFromHeader(header http.Header) (time.Duration, error) { + timeoutHeader := header.Get("Connect-Timeout-Ms") + + if timeoutHeader == "" { + return 0, nil + } + + timeout, err := strconv.Atoi(timeoutHeader) + if err != nil { + return 0, err + } + + return time.Duration(timeout) * time.Millisecond, nil +} diff --git a/envd/internal/services/process/update.go b/envd/internal/services/process/update.go new file mode 100644 index 0000000..1778f89 --- /dev/null +++ b/envd/internal/services/process/update.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 + +package process + +import ( + "context" + "fmt" + + "connectrpc.com/connect" + "github.com/creack/pty" + + rpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process" +) + +func (s *Service) Update(_ context.Context, req *connect.Request[rpc.UpdateRequest]) (*connect.Response[rpc.UpdateResponse], error) { + proc, err := s.getProcess(req.Msg.GetProcess()) + if err != nil { + return nil, err + } + + if req.Msg.GetPty() != nil { + err := proc.ResizeTty(&pty.Winsize{ + Rows: uint16(req.Msg.GetPty().GetSize().GetRows()), + Cols: uint16(req.Msg.GetPty().GetSize().GetCols()), + }) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error resizing tty: %w", err)) + } + } + + return connect.NewResponse(&rpc.UpdateResponse{}), nil +} diff --git a/envd/internal/services/spec/filesystem.pb.go b/envd/internal/services/spec/filesystem.pb.go new file mode 100644 index 0000000..9d3e537 --- /dev/null +++ b/envd/internal/services/spec/filesystem.pb.go @@ -0,0 +1,1446 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: filesystem.proto + +package spec + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type FileType int32 + +const ( + FileType_FILE_TYPE_UNSPECIFIED FileType = 0 + FileType_FILE_TYPE_FILE FileType = 1 + FileType_FILE_TYPE_DIRECTORY FileType = 2 + FileType_FILE_TYPE_SYMLINK FileType = 3 +) + +// Enum value maps for FileType. +var ( + FileType_name = map[int32]string{ + 0: "FILE_TYPE_UNSPECIFIED", + 1: "FILE_TYPE_FILE", + 2: "FILE_TYPE_DIRECTORY", + 3: "FILE_TYPE_SYMLINK", + } + FileType_value = map[string]int32{ + "FILE_TYPE_UNSPECIFIED": 0, + "FILE_TYPE_FILE": 1, + "FILE_TYPE_DIRECTORY": 2, + "FILE_TYPE_SYMLINK": 3, + } +) + +func (x FileType) Enum() *FileType { + p := new(FileType) + *p = x + return p +} + +func (x FileType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (FileType) Descriptor() protoreflect.EnumDescriptor { + return file_filesystem_proto_enumTypes[0].Descriptor() +} + +func (FileType) Type() protoreflect.EnumType { + return &file_filesystem_proto_enumTypes[0] +} + +func (x FileType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use FileType.Descriptor instead. +func (FileType) EnumDescriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{0} +} + +type EventType int32 + +const ( + EventType_EVENT_TYPE_UNSPECIFIED EventType = 0 + EventType_EVENT_TYPE_CREATE EventType = 1 + EventType_EVENT_TYPE_WRITE EventType = 2 + EventType_EVENT_TYPE_REMOVE EventType = 3 + EventType_EVENT_TYPE_RENAME EventType = 4 + EventType_EVENT_TYPE_CHMOD EventType = 5 +) + +// Enum value maps for EventType. +var ( + EventType_name = map[int32]string{ + 0: "EVENT_TYPE_UNSPECIFIED", + 1: "EVENT_TYPE_CREATE", + 2: "EVENT_TYPE_WRITE", + 3: "EVENT_TYPE_REMOVE", + 4: "EVENT_TYPE_RENAME", + 5: "EVENT_TYPE_CHMOD", + } + EventType_value = map[string]int32{ + "EVENT_TYPE_UNSPECIFIED": 0, + "EVENT_TYPE_CREATE": 1, + "EVENT_TYPE_WRITE": 2, + "EVENT_TYPE_REMOVE": 3, + "EVENT_TYPE_RENAME": 4, + "EVENT_TYPE_CHMOD": 5, + } +) + +func (x EventType) Enum() *EventType { + p := new(EventType) + *p = x + return p +} + +func (x EventType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (EventType) Descriptor() protoreflect.EnumDescriptor { + return file_filesystem_proto_enumTypes[1].Descriptor() +} + +func (EventType) Type() protoreflect.EnumType { + return &file_filesystem_proto_enumTypes[1] +} + +func (x EventType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use EventType.Descriptor instead. +func (EventType) EnumDescriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{1} +} + +type MoveRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Source string `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"` + Destination string `protobuf:"bytes,2,opt,name=destination,proto3" json:"destination,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MoveRequest) Reset() { + *x = MoveRequest{} + mi := &file_filesystem_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MoveRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MoveRequest) ProtoMessage() {} + +func (x *MoveRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MoveRequest.ProtoReflect.Descriptor instead. +func (*MoveRequest) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{0} +} + +func (x *MoveRequest) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *MoveRequest) GetDestination() string { + if x != nil { + return x.Destination + } + return "" +} + +type MoveResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *EntryInfo `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MoveResponse) Reset() { + *x = MoveResponse{} + mi := &file_filesystem_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MoveResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MoveResponse) ProtoMessage() {} + +func (x *MoveResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MoveResponse.ProtoReflect.Descriptor instead. +func (*MoveResponse) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{1} +} + +func (x *MoveResponse) GetEntry() *EntryInfo { + if x != nil { + return x.Entry + } + return nil +} + +type MakeDirRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MakeDirRequest) Reset() { + *x = MakeDirRequest{} + mi := &file_filesystem_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MakeDirRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MakeDirRequest) ProtoMessage() {} + +func (x *MakeDirRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MakeDirRequest.ProtoReflect.Descriptor instead. +func (*MakeDirRequest) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{2} +} + +func (x *MakeDirRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type MakeDirResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *EntryInfo `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MakeDirResponse) Reset() { + *x = MakeDirResponse{} + mi := &file_filesystem_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MakeDirResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MakeDirResponse) ProtoMessage() {} + +func (x *MakeDirResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MakeDirResponse.ProtoReflect.Descriptor instead. +func (*MakeDirResponse) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{3} +} + +func (x *MakeDirResponse) GetEntry() *EntryInfo { + if x != nil { + return x.Entry + } + return nil +} + +type RemoveRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveRequest) Reset() { + *x = RemoveRequest{} + mi := &file_filesystem_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveRequest) ProtoMessage() {} + +func (x *RemoveRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveRequest.ProtoReflect.Descriptor instead. +func (*RemoveRequest) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{4} +} + +func (x *RemoveRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type RemoveResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveResponse) Reset() { + *x = RemoveResponse{} + mi := &file_filesystem_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveResponse) ProtoMessage() {} + +func (x *RemoveResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveResponse.ProtoReflect.Descriptor instead. +func (*RemoveResponse) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{5} +} + +type StatRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatRequest) Reset() { + *x = StatRequest{} + mi := &file_filesystem_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatRequest) ProtoMessage() {} + +func (x *StatRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatRequest.ProtoReflect.Descriptor instead. +func (*StatRequest) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{6} +} + +func (x *StatRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type StatResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *EntryInfo `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatResponse) Reset() { + *x = StatResponse{} + mi := &file_filesystem_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatResponse) ProtoMessage() {} + +func (x *StatResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatResponse.ProtoReflect.Descriptor instead. +func (*StatResponse) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{7} +} + +func (x *StatResponse) GetEntry() *EntryInfo { + if x != nil { + return x.Entry + } + return nil +} + +type EntryInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Type FileType `protobuf:"varint,2,opt,name=type,proto3,enum=filesystem.FileType" json:"type,omitempty"` + Path string `protobuf:"bytes,3,opt,name=path,proto3" json:"path,omitempty"` + Size int64 `protobuf:"varint,4,opt,name=size,proto3" json:"size,omitempty"` + Mode uint32 `protobuf:"varint,5,opt,name=mode,proto3" json:"mode,omitempty"` + Permissions string `protobuf:"bytes,6,opt,name=permissions,proto3" json:"permissions,omitempty"` + Owner string `protobuf:"bytes,7,opt,name=owner,proto3" json:"owner,omitempty"` + Group string `protobuf:"bytes,8,opt,name=group,proto3" json:"group,omitempty"` + ModifiedTime *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=modified_time,json=modifiedTime,proto3" json:"modified_time,omitempty"` + // If the entry is a symlink, this field contains the target of the symlink. + SymlinkTarget *string `protobuf:"bytes,10,opt,name=symlink_target,json=symlinkTarget,proto3,oneof" json:"symlink_target,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EntryInfo) Reset() { + *x = EntryInfo{} + mi := &file_filesystem_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EntryInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EntryInfo) ProtoMessage() {} + +func (x *EntryInfo) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EntryInfo.ProtoReflect.Descriptor instead. +func (*EntryInfo) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{8} +} + +func (x *EntryInfo) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *EntryInfo) GetType() FileType { + if x != nil { + return x.Type + } + return FileType_FILE_TYPE_UNSPECIFIED +} + +func (x *EntryInfo) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *EntryInfo) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +func (x *EntryInfo) GetMode() uint32 { + if x != nil { + return x.Mode + } + return 0 +} + +func (x *EntryInfo) GetPermissions() string { + if x != nil { + return x.Permissions + } + return "" +} + +func (x *EntryInfo) GetOwner() string { + if x != nil { + return x.Owner + } + return "" +} + +func (x *EntryInfo) GetGroup() string { + if x != nil { + return x.Group + } + return "" +} + +func (x *EntryInfo) GetModifiedTime() *timestamppb.Timestamp { + if x != nil { + return x.ModifiedTime + } + return nil +} + +func (x *EntryInfo) GetSymlinkTarget() string { + if x != nil && x.SymlinkTarget != nil { + return *x.SymlinkTarget + } + return "" +} + +type ListDirRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Depth uint32 `protobuf:"varint,2,opt,name=depth,proto3" json:"depth,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListDirRequest) Reset() { + *x = ListDirRequest{} + mi := &file_filesystem_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListDirRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListDirRequest) ProtoMessage() {} + +func (x *ListDirRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListDirRequest.ProtoReflect.Descriptor instead. +func (*ListDirRequest) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{9} +} + +func (x *ListDirRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *ListDirRequest) GetDepth() uint32 { + if x != nil { + return x.Depth + } + return 0 +} + +type ListDirResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entries []*EntryInfo `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListDirResponse) Reset() { + *x = ListDirResponse{} + mi := &file_filesystem_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListDirResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListDirResponse) ProtoMessage() {} + +func (x *ListDirResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListDirResponse.ProtoReflect.Descriptor instead. +func (*ListDirResponse) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{10} +} + +func (x *ListDirResponse) GetEntries() []*EntryInfo { + if x != nil { + return x.Entries + } + return nil +} + +type WatchDirRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Recursive bool `protobuf:"varint,2,opt,name=recursive,proto3" json:"recursive,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchDirRequest) Reset() { + *x = WatchDirRequest{} + mi := &file_filesystem_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchDirRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchDirRequest) ProtoMessage() {} + +func (x *WatchDirRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchDirRequest.ProtoReflect.Descriptor instead. +func (*WatchDirRequest) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{11} +} + +func (x *WatchDirRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *WatchDirRequest) GetRecursive() bool { + if x != nil { + return x.Recursive + } + return false +} + +type FilesystemEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Type EventType `protobuf:"varint,2,opt,name=type,proto3,enum=filesystem.EventType" json:"type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FilesystemEvent) Reset() { + *x = FilesystemEvent{} + mi := &file_filesystem_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FilesystemEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FilesystemEvent) ProtoMessage() {} + +func (x *FilesystemEvent) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FilesystemEvent.ProtoReflect.Descriptor instead. +func (*FilesystemEvent) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{12} +} + +func (x *FilesystemEvent) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *FilesystemEvent) GetType() EventType { + if x != nil { + return x.Type + } + return EventType_EVENT_TYPE_UNSPECIFIED +} + +type WatchDirResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *WatchDirResponse_Start + // *WatchDirResponse_Filesystem + // *WatchDirResponse_Keepalive + Event isWatchDirResponse_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchDirResponse) Reset() { + *x = WatchDirResponse{} + mi := &file_filesystem_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchDirResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchDirResponse) ProtoMessage() {} + +func (x *WatchDirResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchDirResponse.ProtoReflect.Descriptor instead. +func (*WatchDirResponse) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{13} +} + +func (x *WatchDirResponse) GetEvent() isWatchDirResponse_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *WatchDirResponse) GetStart() *WatchDirResponse_StartEvent { + if x != nil { + if x, ok := x.Event.(*WatchDirResponse_Start); ok { + return x.Start + } + } + return nil +} + +func (x *WatchDirResponse) GetFilesystem() *FilesystemEvent { + if x != nil { + if x, ok := x.Event.(*WatchDirResponse_Filesystem); ok { + return x.Filesystem + } + } + return nil +} + +func (x *WatchDirResponse) GetKeepalive() *WatchDirResponse_KeepAlive { + if x != nil { + if x, ok := x.Event.(*WatchDirResponse_Keepalive); ok { + return x.Keepalive + } + } + return nil +} + +type isWatchDirResponse_Event interface { + isWatchDirResponse_Event() +} + +type WatchDirResponse_Start struct { + Start *WatchDirResponse_StartEvent `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type WatchDirResponse_Filesystem struct { + Filesystem *FilesystemEvent `protobuf:"bytes,2,opt,name=filesystem,proto3,oneof"` +} + +type WatchDirResponse_Keepalive struct { + Keepalive *WatchDirResponse_KeepAlive `protobuf:"bytes,3,opt,name=keepalive,proto3,oneof"` +} + +func (*WatchDirResponse_Start) isWatchDirResponse_Event() {} + +func (*WatchDirResponse_Filesystem) isWatchDirResponse_Event() {} + +func (*WatchDirResponse_Keepalive) isWatchDirResponse_Event() {} + +type CreateWatcherRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Recursive bool `protobuf:"varint,2,opt,name=recursive,proto3" json:"recursive,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateWatcherRequest) Reset() { + *x = CreateWatcherRequest{} + mi := &file_filesystem_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateWatcherRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateWatcherRequest) ProtoMessage() {} + +func (x *CreateWatcherRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateWatcherRequest.ProtoReflect.Descriptor instead. +func (*CreateWatcherRequest) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{14} +} + +func (x *CreateWatcherRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *CreateWatcherRequest) GetRecursive() bool { + if x != nil { + return x.Recursive + } + return false +} + +type CreateWatcherResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + WatcherId string `protobuf:"bytes,1,opt,name=watcher_id,json=watcherId,proto3" json:"watcher_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateWatcherResponse) Reset() { + *x = CreateWatcherResponse{} + mi := &file_filesystem_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateWatcherResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateWatcherResponse) ProtoMessage() {} + +func (x *CreateWatcherResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateWatcherResponse.ProtoReflect.Descriptor instead. +func (*CreateWatcherResponse) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{15} +} + +func (x *CreateWatcherResponse) GetWatcherId() string { + if x != nil { + return x.WatcherId + } + return "" +} + +type GetWatcherEventsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + WatcherId string `protobuf:"bytes,1,opt,name=watcher_id,json=watcherId,proto3" json:"watcher_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetWatcherEventsRequest) Reset() { + *x = GetWatcherEventsRequest{} + mi := &file_filesystem_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetWatcherEventsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetWatcherEventsRequest) ProtoMessage() {} + +func (x *GetWatcherEventsRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetWatcherEventsRequest.ProtoReflect.Descriptor instead. +func (*GetWatcherEventsRequest) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{16} +} + +func (x *GetWatcherEventsRequest) GetWatcherId() string { + if x != nil { + return x.WatcherId + } + return "" +} + +type GetWatcherEventsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Events []*FilesystemEvent `protobuf:"bytes,1,rep,name=events,proto3" json:"events,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetWatcherEventsResponse) Reset() { + *x = GetWatcherEventsResponse{} + mi := &file_filesystem_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetWatcherEventsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetWatcherEventsResponse) ProtoMessage() {} + +func (x *GetWatcherEventsResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetWatcherEventsResponse.ProtoReflect.Descriptor instead. +func (*GetWatcherEventsResponse) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{17} +} + +func (x *GetWatcherEventsResponse) GetEvents() []*FilesystemEvent { + if x != nil { + return x.Events + } + return nil +} + +type RemoveWatcherRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + WatcherId string `protobuf:"bytes,1,opt,name=watcher_id,json=watcherId,proto3" json:"watcher_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveWatcherRequest) Reset() { + *x = RemoveWatcherRequest{} + mi := &file_filesystem_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveWatcherRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveWatcherRequest) ProtoMessage() {} + +func (x *RemoveWatcherRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveWatcherRequest.ProtoReflect.Descriptor instead. +func (*RemoveWatcherRequest) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{18} +} + +func (x *RemoveWatcherRequest) GetWatcherId() string { + if x != nil { + return x.WatcherId + } + return "" +} + +type RemoveWatcherResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveWatcherResponse) Reset() { + *x = RemoveWatcherResponse{} + mi := &file_filesystem_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveWatcherResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveWatcherResponse) ProtoMessage() {} + +func (x *RemoveWatcherResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveWatcherResponse.ProtoReflect.Descriptor instead. +func (*RemoveWatcherResponse) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{19} +} + +type WatchDirResponse_StartEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchDirResponse_StartEvent) Reset() { + *x = WatchDirResponse_StartEvent{} + mi := &file_filesystem_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchDirResponse_StartEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchDirResponse_StartEvent) ProtoMessage() {} + +func (x *WatchDirResponse_StartEvent) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchDirResponse_StartEvent.ProtoReflect.Descriptor instead. +func (*WatchDirResponse_StartEvent) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{13, 0} +} + +type WatchDirResponse_KeepAlive struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchDirResponse_KeepAlive) Reset() { + *x = WatchDirResponse_KeepAlive{} + mi := &file_filesystem_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchDirResponse_KeepAlive) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchDirResponse_KeepAlive) ProtoMessage() {} + +func (x *WatchDirResponse_KeepAlive) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchDirResponse_KeepAlive.ProtoReflect.Descriptor instead. +func (*WatchDirResponse_KeepAlive) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{13, 1} +} + +var File_filesystem_proto protoreflect.FileDescriptor + +const file_filesystem_proto_rawDesc = "" + + "\n" + + "\x10filesystem.proto\x12\n" + + "filesystem\x1a\x1fgoogle/protobuf/timestamp.proto\"G\n" + + "\vMoveRequest\x12\x16\n" + + "\x06source\x18\x01 \x01(\tR\x06source\x12 \n" + + "\vdestination\x18\x02 \x01(\tR\vdestination\";\n" + + "\fMoveResponse\x12+\n" + + "\x05entry\x18\x01 \x01(\v2\x15.filesystem.EntryInfoR\x05entry\"$\n" + + "\x0eMakeDirRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\">\n" + + "\x0fMakeDirResponse\x12+\n" + + "\x05entry\x18\x01 \x01(\v2\x15.filesystem.EntryInfoR\x05entry\"#\n" + + "\rRemoveRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\"\x10\n" + + "\x0eRemoveResponse\"!\n" + + "\vStatRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\";\n" + + "\fStatResponse\x12+\n" + + "\x05entry\x18\x01 \x01(\v2\x15.filesystem.EntryInfoR\x05entry\"\xd3\x02\n" + + "\tEntryInfo\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12(\n" + + "\x04type\x18\x02 \x01(\x0e2\x14.filesystem.FileTypeR\x04type\x12\x12\n" + + "\x04path\x18\x03 \x01(\tR\x04path\x12\x12\n" + + "\x04size\x18\x04 \x01(\x03R\x04size\x12\x12\n" + + "\x04mode\x18\x05 \x01(\rR\x04mode\x12 \n" + + "\vpermissions\x18\x06 \x01(\tR\vpermissions\x12\x14\n" + + "\x05owner\x18\a \x01(\tR\x05owner\x12\x14\n" + + "\x05group\x18\b \x01(\tR\x05group\x12?\n" + + "\rmodified_time\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\fmodifiedTime\x12*\n" + + "\x0esymlink_target\x18\n" + + " \x01(\tH\x00R\rsymlinkTarget\x88\x01\x01B\x11\n" + + "\x0f_symlink_target\":\n" + + "\x0eListDirRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x14\n" + + "\x05depth\x18\x02 \x01(\rR\x05depth\"B\n" + + "\x0fListDirResponse\x12/\n" + + "\aentries\x18\x01 \x03(\v2\x15.filesystem.EntryInfoR\aentries\"C\n" + + "\x0fWatchDirRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n" + + "\trecursive\x18\x02 \x01(\bR\trecursive\"P\n" + + "\x0fFilesystemEvent\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12)\n" + + "\x04type\x18\x02 \x01(\x0e2\x15.filesystem.EventTypeR\x04type\"\xfe\x01\n" + + "\x10WatchDirResponse\x12?\n" + + "\x05start\x18\x01 \x01(\v2'.filesystem.WatchDirResponse.StartEventH\x00R\x05start\x12=\n" + + "\n" + + "filesystem\x18\x02 \x01(\v2\x1b.filesystem.FilesystemEventH\x00R\n" + + "filesystem\x12F\n" + + "\tkeepalive\x18\x03 \x01(\v2&.filesystem.WatchDirResponse.KeepAliveH\x00R\tkeepalive\x1a\f\n" + + "\n" + + "StartEvent\x1a\v\n" + + "\tKeepAliveB\a\n" + + "\x05event\"H\n" + + "\x14CreateWatcherRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n" + + "\trecursive\x18\x02 \x01(\bR\trecursive\"6\n" + + "\x15CreateWatcherResponse\x12\x1d\n" + + "\n" + + "watcher_id\x18\x01 \x01(\tR\twatcherId\"8\n" + + "\x17GetWatcherEventsRequest\x12\x1d\n" + + "\n" + + "watcher_id\x18\x01 \x01(\tR\twatcherId\"O\n" + + "\x18GetWatcherEventsResponse\x123\n" + + "\x06events\x18\x01 \x03(\v2\x1b.filesystem.FilesystemEventR\x06events\"5\n" + + "\x14RemoveWatcherRequest\x12\x1d\n" + + "\n" + + "watcher_id\x18\x01 \x01(\tR\twatcherId\"\x17\n" + + "\x15RemoveWatcherResponse*i\n" + + "\bFileType\x12\x19\n" + + "\x15FILE_TYPE_UNSPECIFIED\x10\x00\x12\x12\n" + + "\x0eFILE_TYPE_FILE\x10\x01\x12\x17\n" + + "\x13FILE_TYPE_DIRECTORY\x10\x02\x12\x15\n" + + "\x11FILE_TYPE_SYMLINK\x10\x03*\x98\x01\n" + + "\tEventType\x12\x1a\n" + + "\x16EVENT_TYPE_UNSPECIFIED\x10\x00\x12\x15\n" + + "\x11EVENT_TYPE_CREATE\x10\x01\x12\x14\n" + + "\x10EVENT_TYPE_WRITE\x10\x02\x12\x15\n" + + "\x11EVENT_TYPE_REMOVE\x10\x03\x12\x15\n" + + "\x11EVENT_TYPE_RENAME\x10\x04\x12\x14\n" + + "\x10EVENT_TYPE_CHMOD\x10\x052\x9f\x05\n" + + "\n" + + "Filesystem\x129\n" + + "\x04Stat\x12\x17.filesystem.StatRequest\x1a\x18.filesystem.StatResponse\x12B\n" + + "\aMakeDir\x12\x1a.filesystem.MakeDirRequest\x1a\x1b.filesystem.MakeDirResponse\x129\n" + + "\x04Move\x12\x17.filesystem.MoveRequest\x1a\x18.filesystem.MoveResponse\x12B\n" + + "\aListDir\x12\x1a.filesystem.ListDirRequest\x1a\x1b.filesystem.ListDirResponse\x12?\n" + + "\x06Remove\x12\x19.filesystem.RemoveRequest\x1a\x1a.filesystem.RemoveResponse\x12G\n" + + "\bWatchDir\x12\x1b.filesystem.WatchDirRequest\x1a\x1c.filesystem.WatchDirResponse0\x01\x12T\n" + + "\rCreateWatcher\x12 .filesystem.CreateWatcherRequest\x1a!.filesystem.CreateWatcherResponse\x12]\n" + + "\x10GetWatcherEvents\x12#.filesystem.GetWatcherEventsRequest\x1a$.filesystem.GetWatcherEventsResponse\x12T\n" + + "\rRemoveWatcher\x12 .filesystem.RemoveWatcherRequest\x1a!.filesystem.RemoveWatcherResponseB\xa2\x01\n" + + "\x0ecom.filesystemB\x0fFilesystemProtoP\x01Z7git.omukk.dev/wrenn/sandbox/envd/internal/services/spec\xa2\x02\x03FXX\xaa\x02\n" + + "Filesystem\xca\x02\n" + + "Filesystem\xe2\x02\x16Filesystem\\GPBMetadata\xea\x02\n" + + "Filesystemb\x06proto3" + +var ( + file_filesystem_proto_rawDescOnce sync.Once + file_filesystem_proto_rawDescData []byte +) + +func file_filesystem_proto_rawDescGZIP() []byte { + file_filesystem_proto_rawDescOnce.Do(func() { + file_filesystem_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_filesystem_proto_rawDesc), len(file_filesystem_proto_rawDesc))) + }) + return file_filesystem_proto_rawDescData +} + +var file_filesystem_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_filesystem_proto_msgTypes = make([]protoimpl.MessageInfo, 22) +var file_filesystem_proto_goTypes = []any{ + (FileType)(0), // 0: filesystem.FileType + (EventType)(0), // 1: filesystem.EventType + (*MoveRequest)(nil), // 2: filesystem.MoveRequest + (*MoveResponse)(nil), // 3: filesystem.MoveResponse + (*MakeDirRequest)(nil), // 4: filesystem.MakeDirRequest + (*MakeDirResponse)(nil), // 5: filesystem.MakeDirResponse + (*RemoveRequest)(nil), // 6: filesystem.RemoveRequest + (*RemoveResponse)(nil), // 7: filesystem.RemoveResponse + (*StatRequest)(nil), // 8: filesystem.StatRequest + (*StatResponse)(nil), // 9: filesystem.StatResponse + (*EntryInfo)(nil), // 10: filesystem.EntryInfo + (*ListDirRequest)(nil), // 11: filesystem.ListDirRequest + (*ListDirResponse)(nil), // 12: filesystem.ListDirResponse + (*WatchDirRequest)(nil), // 13: filesystem.WatchDirRequest + (*FilesystemEvent)(nil), // 14: filesystem.FilesystemEvent + (*WatchDirResponse)(nil), // 15: filesystem.WatchDirResponse + (*CreateWatcherRequest)(nil), // 16: filesystem.CreateWatcherRequest + (*CreateWatcherResponse)(nil), // 17: filesystem.CreateWatcherResponse + (*GetWatcherEventsRequest)(nil), // 18: filesystem.GetWatcherEventsRequest + (*GetWatcherEventsResponse)(nil), // 19: filesystem.GetWatcherEventsResponse + (*RemoveWatcherRequest)(nil), // 20: filesystem.RemoveWatcherRequest + (*RemoveWatcherResponse)(nil), // 21: filesystem.RemoveWatcherResponse + (*WatchDirResponse_StartEvent)(nil), // 22: filesystem.WatchDirResponse.StartEvent + (*WatchDirResponse_KeepAlive)(nil), // 23: filesystem.WatchDirResponse.KeepAlive + (*timestamppb.Timestamp)(nil), // 24: google.protobuf.Timestamp +} +var file_filesystem_proto_depIdxs = []int32{ + 10, // 0: filesystem.MoveResponse.entry:type_name -> filesystem.EntryInfo + 10, // 1: filesystem.MakeDirResponse.entry:type_name -> filesystem.EntryInfo + 10, // 2: filesystem.StatResponse.entry:type_name -> filesystem.EntryInfo + 0, // 3: filesystem.EntryInfo.type:type_name -> filesystem.FileType + 24, // 4: filesystem.EntryInfo.modified_time:type_name -> google.protobuf.Timestamp + 10, // 5: filesystem.ListDirResponse.entries:type_name -> filesystem.EntryInfo + 1, // 6: filesystem.FilesystemEvent.type:type_name -> filesystem.EventType + 22, // 7: filesystem.WatchDirResponse.start:type_name -> filesystem.WatchDirResponse.StartEvent + 14, // 8: filesystem.WatchDirResponse.filesystem:type_name -> filesystem.FilesystemEvent + 23, // 9: filesystem.WatchDirResponse.keepalive:type_name -> filesystem.WatchDirResponse.KeepAlive + 14, // 10: filesystem.GetWatcherEventsResponse.events:type_name -> filesystem.FilesystemEvent + 8, // 11: filesystem.Filesystem.Stat:input_type -> filesystem.StatRequest + 4, // 12: filesystem.Filesystem.MakeDir:input_type -> filesystem.MakeDirRequest + 2, // 13: filesystem.Filesystem.Move:input_type -> filesystem.MoveRequest + 11, // 14: filesystem.Filesystem.ListDir:input_type -> filesystem.ListDirRequest + 6, // 15: filesystem.Filesystem.Remove:input_type -> filesystem.RemoveRequest + 13, // 16: filesystem.Filesystem.WatchDir:input_type -> filesystem.WatchDirRequest + 16, // 17: filesystem.Filesystem.CreateWatcher:input_type -> filesystem.CreateWatcherRequest + 18, // 18: filesystem.Filesystem.GetWatcherEvents:input_type -> filesystem.GetWatcherEventsRequest + 20, // 19: filesystem.Filesystem.RemoveWatcher:input_type -> filesystem.RemoveWatcherRequest + 9, // 20: filesystem.Filesystem.Stat:output_type -> filesystem.StatResponse + 5, // 21: filesystem.Filesystem.MakeDir:output_type -> filesystem.MakeDirResponse + 3, // 22: filesystem.Filesystem.Move:output_type -> filesystem.MoveResponse + 12, // 23: filesystem.Filesystem.ListDir:output_type -> filesystem.ListDirResponse + 7, // 24: filesystem.Filesystem.Remove:output_type -> filesystem.RemoveResponse + 15, // 25: filesystem.Filesystem.WatchDir:output_type -> filesystem.WatchDirResponse + 17, // 26: filesystem.Filesystem.CreateWatcher:output_type -> filesystem.CreateWatcherResponse + 19, // 27: filesystem.Filesystem.GetWatcherEvents:output_type -> filesystem.GetWatcherEventsResponse + 21, // 28: filesystem.Filesystem.RemoveWatcher:output_type -> filesystem.RemoveWatcherResponse + 20, // [20:29] is the sub-list for method output_type + 11, // [11:20] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name +} + +func init() { file_filesystem_proto_init() } +func file_filesystem_proto_init() { + if File_filesystem_proto != nil { + return + } + file_filesystem_proto_msgTypes[8].OneofWrappers = []any{} + file_filesystem_proto_msgTypes[13].OneofWrappers = []any{ + (*WatchDirResponse_Start)(nil), + (*WatchDirResponse_Filesystem)(nil), + (*WatchDirResponse_Keepalive)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_filesystem_proto_rawDesc), len(file_filesystem_proto_rawDesc)), + NumEnums: 2, + NumMessages: 22, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_filesystem_proto_goTypes, + DependencyIndexes: file_filesystem_proto_depIdxs, + EnumInfos: file_filesystem_proto_enumTypes, + MessageInfos: file_filesystem_proto_msgTypes, + }.Build() + File_filesystem_proto = out.File + file_filesystem_proto_goTypes = nil + file_filesystem_proto_depIdxs = nil +} diff --git a/envd/internal/services/spec/filesystem/filesystem.pb.go b/envd/internal/services/spec/filesystem/filesystem.pb.go new file mode 100644 index 0000000..d8a03bd --- /dev/null +++ b/envd/internal/services/spec/filesystem/filesystem.pb.go @@ -0,0 +1,1444 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: filesystem/filesystem.proto + +package filesystem + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type FileType int32 + +const ( + FileType_FILE_TYPE_UNSPECIFIED FileType = 0 + FileType_FILE_TYPE_FILE FileType = 1 + FileType_FILE_TYPE_DIRECTORY FileType = 2 + FileType_FILE_TYPE_SYMLINK FileType = 3 +) + +// Enum value maps for FileType. +var ( + FileType_name = map[int32]string{ + 0: "FILE_TYPE_UNSPECIFIED", + 1: "FILE_TYPE_FILE", + 2: "FILE_TYPE_DIRECTORY", + 3: "FILE_TYPE_SYMLINK", + } + FileType_value = map[string]int32{ + "FILE_TYPE_UNSPECIFIED": 0, + "FILE_TYPE_FILE": 1, + "FILE_TYPE_DIRECTORY": 2, + "FILE_TYPE_SYMLINK": 3, + } +) + +func (x FileType) Enum() *FileType { + p := new(FileType) + *p = x + return p +} + +func (x FileType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (FileType) Descriptor() protoreflect.EnumDescriptor { + return file_filesystem_filesystem_proto_enumTypes[0].Descriptor() +} + +func (FileType) Type() protoreflect.EnumType { + return &file_filesystem_filesystem_proto_enumTypes[0] +} + +func (x FileType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use FileType.Descriptor instead. +func (FileType) EnumDescriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{0} +} + +type EventType int32 + +const ( + EventType_EVENT_TYPE_UNSPECIFIED EventType = 0 + EventType_EVENT_TYPE_CREATE EventType = 1 + EventType_EVENT_TYPE_WRITE EventType = 2 + EventType_EVENT_TYPE_REMOVE EventType = 3 + EventType_EVENT_TYPE_RENAME EventType = 4 + EventType_EVENT_TYPE_CHMOD EventType = 5 +) + +// Enum value maps for EventType. +var ( + EventType_name = map[int32]string{ + 0: "EVENT_TYPE_UNSPECIFIED", + 1: "EVENT_TYPE_CREATE", + 2: "EVENT_TYPE_WRITE", + 3: "EVENT_TYPE_REMOVE", + 4: "EVENT_TYPE_RENAME", + 5: "EVENT_TYPE_CHMOD", + } + EventType_value = map[string]int32{ + "EVENT_TYPE_UNSPECIFIED": 0, + "EVENT_TYPE_CREATE": 1, + "EVENT_TYPE_WRITE": 2, + "EVENT_TYPE_REMOVE": 3, + "EVENT_TYPE_RENAME": 4, + "EVENT_TYPE_CHMOD": 5, + } +) + +func (x EventType) Enum() *EventType { + p := new(EventType) + *p = x + return p +} + +func (x EventType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (EventType) Descriptor() protoreflect.EnumDescriptor { + return file_filesystem_filesystem_proto_enumTypes[1].Descriptor() +} + +func (EventType) Type() protoreflect.EnumType { + return &file_filesystem_filesystem_proto_enumTypes[1] +} + +func (x EventType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use EventType.Descriptor instead. +func (EventType) EnumDescriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{1} +} + +type MoveRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Source string `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"` + Destination string `protobuf:"bytes,2,opt,name=destination,proto3" json:"destination,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MoveRequest) Reset() { + *x = MoveRequest{} + mi := &file_filesystem_filesystem_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MoveRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MoveRequest) ProtoMessage() {} + +func (x *MoveRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MoveRequest.ProtoReflect.Descriptor instead. +func (*MoveRequest) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{0} +} + +func (x *MoveRequest) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *MoveRequest) GetDestination() string { + if x != nil { + return x.Destination + } + return "" +} + +type MoveResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *EntryInfo `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MoveResponse) Reset() { + *x = MoveResponse{} + mi := &file_filesystem_filesystem_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MoveResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MoveResponse) ProtoMessage() {} + +func (x *MoveResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MoveResponse.ProtoReflect.Descriptor instead. +func (*MoveResponse) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{1} +} + +func (x *MoveResponse) GetEntry() *EntryInfo { + if x != nil { + return x.Entry + } + return nil +} + +type MakeDirRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MakeDirRequest) Reset() { + *x = MakeDirRequest{} + mi := &file_filesystem_filesystem_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MakeDirRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MakeDirRequest) ProtoMessage() {} + +func (x *MakeDirRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MakeDirRequest.ProtoReflect.Descriptor instead. +func (*MakeDirRequest) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{2} +} + +func (x *MakeDirRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type MakeDirResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *EntryInfo `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MakeDirResponse) Reset() { + *x = MakeDirResponse{} + mi := &file_filesystem_filesystem_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MakeDirResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MakeDirResponse) ProtoMessage() {} + +func (x *MakeDirResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MakeDirResponse.ProtoReflect.Descriptor instead. +func (*MakeDirResponse) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{3} +} + +func (x *MakeDirResponse) GetEntry() *EntryInfo { + if x != nil { + return x.Entry + } + return nil +} + +type RemoveRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveRequest) Reset() { + *x = RemoveRequest{} + mi := &file_filesystem_filesystem_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveRequest) ProtoMessage() {} + +func (x *RemoveRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveRequest.ProtoReflect.Descriptor instead. +func (*RemoveRequest) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{4} +} + +func (x *RemoveRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type RemoveResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveResponse) Reset() { + *x = RemoveResponse{} + mi := &file_filesystem_filesystem_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveResponse) ProtoMessage() {} + +func (x *RemoveResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveResponse.ProtoReflect.Descriptor instead. +func (*RemoveResponse) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{5} +} + +type StatRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatRequest) Reset() { + *x = StatRequest{} + mi := &file_filesystem_filesystem_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatRequest) ProtoMessage() {} + +func (x *StatRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatRequest.ProtoReflect.Descriptor instead. +func (*StatRequest) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{6} +} + +func (x *StatRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type StatResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *EntryInfo `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatResponse) Reset() { + *x = StatResponse{} + mi := &file_filesystem_filesystem_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatResponse) ProtoMessage() {} + +func (x *StatResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatResponse.ProtoReflect.Descriptor instead. +func (*StatResponse) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{7} +} + +func (x *StatResponse) GetEntry() *EntryInfo { + if x != nil { + return x.Entry + } + return nil +} + +type EntryInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Type FileType `protobuf:"varint,2,opt,name=type,proto3,enum=filesystem.FileType" json:"type,omitempty"` + Path string `protobuf:"bytes,3,opt,name=path,proto3" json:"path,omitempty"` + Size int64 `protobuf:"varint,4,opt,name=size,proto3" json:"size,omitempty"` + Mode uint32 `protobuf:"varint,5,opt,name=mode,proto3" json:"mode,omitempty"` + Permissions string `protobuf:"bytes,6,opt,name=permissions,proto3" json:"permissions,omitempty"` + Owner string `protobuf:"bytes,7,opt,name=owner,proto3" json:"owner,omitempty"` + Group string `protobuf:"bytes,8,opt,name=group,proto3" json:"group,omitempty"` + ModifiedTime *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=modified_time,json=modifiedTime,proto3" json:"modified_time,omitempty"` + // If the entry is a symlink, this field contains the target of the symlink. + SymlinkTarget *string `protobuf:"bytes,10,opt,name=symlink_target,json=symlinkTarget,proto3,oneof" json:"symlink_target,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EntryInfo) Reset() { + *x = EntryInfo{} + mi := &file_filesystem_filesystem_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EntryInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EntryInfo) ProtoMessage() {} + +func (x *EntryInfo) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EntryInfo.ProtoReflect.Descriptor instead. +func (*EntryInfo) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{8} +} + +func (x *EntryInfo) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *EntryInfo) GetType() FileType { + if x != nil { + return x.Type + } + return FileType_FILE_TYPE_UNSPECIFIED +} + +func (x *EntryInfo) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *EntryInfo) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +func (x *EntryInfo) GetMode() uint32 { + if x != nil { + return x.Mode + } + return 0 +} + +func (x *EntryInfo) GetPermissions() string { + if x != nil { + return x.Permissions + } + return "" +} + +func (x *EntryInfo) GetOwner() string { + if x != nil { + return x.Owner + } + return "" +} + +func (x *EntryInfo) GetGroup() string { + if x != nil { + return x.Group + } + return "" +} + +func (x *EntryInfo) GetModifiedTime() *timestamppb.Timestamp { + if x != nil { + return x.ModifiedTime + } + return nil +} + +func (x *EntryInfo) GetSymlinkTarget() string { + if x != nil && x.SymlinkTarget != nil { + return *x.SymlinkTarget + } + return "" +} + +type ListDirRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Depth uint32 `protobuf:"varint,2,opt,name=depth,proto3" json:"depth,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListDirRequest) Reset() { + *x = ListDirRequest{} + mi := &file_filesystem_filesystem_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListDirRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListDirRequest) ProtoMessage() {} + +func (x *ListDirRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListDirRequest.ProtoReflect.Descriptor instead. +func (*ListDirRequest) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{9} +} + +func (x *ListDirRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *ListDirRequest) GetDepth() uint32 { + if x != nil { + return x.Depth + } + return 0 +} + +type ListDirResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entries []*EntryInfo `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListDirResponse) Reset() { + *x = ListDirResponse{} + mi := &file_filesystem_filesystem_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListDirResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListDirResponse) ProtoMessage() {} + +func (x *ListDirResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListDirResponse.ProtoReflect.Descriptor instead. +func (*ListDirResponse) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{10} +} + +func (x *ListDirResponse) GetEntries() []*EntryInfo { + if x != nil { + return x.Entries + } + return nil +} + +type WatchDirRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Recursive bool `protobuf:"varint,2,opt,name=recursive,proto3" json:"recursive,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchDirRequest) Reset() { + *x = WatchDirRequest{} + mi := &file_filesystem_filesystem_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchDirRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchDirRequest) ProtoMessage() {} + +func (x *WatchDirRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchDirRequest.ProtoReflect.Descriptor instead. +func (*WatchDirRequest) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{11} +} + +func (x *WatchDirRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *WatchDirRequest) GetRecursive() bool { + if x != nil { + return x.Recursive + } + return false +} + +type FilesystemEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Type EventType `protobuf:"varint,2,opt,name=type,proto3,enum=filesystem.EventType" json:"type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FilesystemEvent) Reset() { + *x = FilesystemEvent{} + mi := &file_filesystem_filesystem_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FilesystemEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FilesystemEvent) ProtoMessage() {} + +func (x *FilesystemEvent) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FilesystemEvent.ProtoReflect.Descriptor instead. +func (*FilesystemEvent) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{12} +} + +func (x *FilesystemEvent) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *FilesystemEvent) GetType() EventType { + if x != nil { + return x.Type + } + return EventType_EVENT_TYPE_UNSPECIFIED +} + +type WatchDirResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *WatchDirResponse_Start + // *WatchDirResponse_Filesystem + // *WatchDirResponse_Keepalive + Event isWatchDirResponse_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchDirResponse) Reset() { + *x = WatchDirResponse{} + mi := &file_filesystem_filesystem_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchDirResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchDirResponse) ProtoMessage() {} + +func (x *WatchDirResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchDirResponse.ProtoReflect.Descriptor instead. +func (*WatchDirResponse) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{13} +} + +func (x *WatchDirResponse) GetEvent() isWatchDirResponse_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *WatchDirResponse) GetStart() *WatchDirResponse_StartEvent { + if x != nil { + if x, ok := x.Event.(*WatchDirResponse_Start); ok { + return x.Start + } + } + return nil +} + +func (x *WatchDirResponse) GetFilesystem() *FilesystemEvent { + if x != nil { + if x, ok := x.Event.(*WatchDirResponse_Filesystem); ok { + return x.Filesystem + } + } + return nil +} + +func (x *WatchDirResponse) GetKeepalive() *WatchDirResponse_KeepAlive { + if x != nil { + if x, ok := x.Event.(*WatchDirResponse_Keepalive); ok { + return x.Keepalive + } + } + return nil +} + +type isWatchDirResponse_Event interface { + isWatchDirResponse_Event() +} + +type WatchDirResponse_Start struct { + Start *WatchDirResponse_StartEvent `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type WatchDirResponse_Filesystem struct { + Filesystem *FilesystemEvent `protobuf:"bytes,2,opt,name=filesystem,proto3,oneof"` +} + +type WatchDirResponse_Keepalive struct { + Keepalive *WatchDirResponse_KeepAlive `protobuf:"bytes,3,opt,name=keepalive,proto3,oneof"` +} + +func (*WatchDirResponse_Start) isWatchDirResponse_Event() {} + +func (*WatchDirResponse_Filesystem) isWatchDirResponse_Event() {} + +func (*WatchDirResponse_Keepalive) isWatchDirResponse_Event() {} + +type CreateWatcherRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Recursive bool `protobuf:"varint,2,opt,name=recursive,proto3" json:"recursive,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateWatcherRequest) Reset() { + *x = CreateWatcherRequest{} + mi := &file_filesystem_filesystem_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateWatcherRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateWatcherRequest) ProtoMessage() {} + +func (x *CreateWatcherRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateWatcherRequest.ProtoReflect.Descriptor instead. +func (*CreateWatcherRequest) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{14} +} + +func (x *CreateWatcherRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *CreateWatcherRequest) GetRecursive() bool { + if x != nil { + return x.Recursive + } + return false +} + +type CreateWatcherResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + WatcherId string `protobuf:"bytes,1,opt,name=watcher_id,json=watcherId,proto3" json:"watcher_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateWatcherResponse) Reset() { + *x = CreateWatcherResponse{} + mi := &file_filesystem_filesystem_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateWatcherResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateWatcherResponse) ProtoMessage() {} + +func (x *CreateWatcherResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateWatcherResponse.ProtoReflect.Descriptor instead. +func (*CreateWatcherResponse) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{15} +} + +func (x *CreateWatcherResponse) GetWatcherId() string { + if x != nil { + return x.WatcherId + } + return "" +} + +type GetWatcherEventsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + WatcherId string `protobuf:"bytes,1,opt,name=watcher_id,json=watcherId,proto3" json:"watcher_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetWatcherEventsRequest) Reset() { + *x = GetWatcherEventsRequest{} + mi := &file_filesystem_filesystem_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetWatcherEventsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetWatcherEventsRequest) ProtoMessage() {} + +func (x *GetWatcherEventsRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetWatcherEventsRequest.ProtoReflect.Descriptor instead. +func (*GetWatcherEventsRequest) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{16} +} + +func (x *GetWatcherEventsRequest) GetWatcherId() string { + if x != nil { + return x.WatcherId + } + return "" +} + +type GetWatcherEventsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Events []*FilesystemEvent `protobuf:"bytes,1,rep,name=events,proto3" json:"events,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetWatcherEventsResponse) Reset() { + *x = GetWatcherEventsResponse{} + mi := &file_filesystem_filesystem_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetWatcherEventsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetWatcherEventsResponse) ProtoMessage() {} + +func (x *GetWatcherEventsResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetWatcherEventsResponse.ProtoReflect.Descriptor instead. +func (*GetWatcherEventsResponse) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{17} +} + +func (x *GetWatcherEventsResponse) GetEvents() []*FilesystemEvent { + if x != nil { + return x.Events + } + return nil +} + +type RemoveWatcherRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + WatcherId string `protobuf:"bytes,1,opt,name=watcher_id,json=watcherId,proto3" json:"watcher_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveWatcherRequest) Reset() { + *x = RemoveWatcherRequest{} + mi := &file_filesystem_filesystem_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveWatcherRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveWatcherRequest) ProtoMessage() {} + +func (x *RemoveWatcherRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveWatcherRequest.ProtoReflect.Descriptor instead. +func (*RemoveWatcherRequest) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{18} +} + +func (x *RemoveWatcherRequest) GetWatcherId() string { + if x != nil { + return x.WatcherId + } + return "" +} + +type RemoveWatcherResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveWatcherResponse) Reset() { + *x = RemoveWatcherResponse{} + mi := &file_filesystem_filesystem_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveWatcherResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveWatcherResponse) ProtoMessage() {} + +func (x *RemoveWatcherResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveWatcherResponse.ProtoReflect.Descriptor instead. +func (*RemoveWatcherResponse) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{19} +} + +type WatchDirResponse_StartEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchDirResponse_StartEvent) Reset() { + *x = WatchDirResponse_StartEvent{} + mi := &file_filesystem_filesystem_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchDirResponse_StartEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchDirResponse_StartEvent) ProtoMessage() {} + +func (x *WatchDirResponse_StartEvent) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchDirResponse_StartEvent.ProtoReflect.Descriptor instead. +func (*WatchDirResponse_StartEvent) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{13, 0} +} + +type WatchDirResponse_KeepAlive struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchDirResponse_KeepAlive) Reset() { + *x = WatchDirResponse_KeepAlive{} + mi := &file_filesystem_filesystem_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchDirResponse_KeepAlive) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchDirResponse_KeepAlive) ProtoMessage() {} + +func (x *WatchDirResponse_KeepAlive) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_filesystem_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchDirResponse_KeepAlive.ProtoReflect.Descriptor instead. +func (*WatchDirResponse_KeepAlive) Descriptor() ([]byte, []int) { + return file_filesystem_filesystem_proto_rawDescGZIP(), []int{13, 1} +} + +var File_filesystem_filesystem_proto protoreflect.FileDescriptor + +const file_filesystem_filesystem_proto_rawDesc = "" + + "\n" + + "\x1bfilesystem/filesystem.proto\x12\n" + + "filesystem\x1a\x1fgoogle/protobuf/timestamp.proto\"G\n" + + "\vMoveRequest\x12\x16\n" + + "\x06source\x18\x01 \x01(\tR\x06source\x12 \n" + + "\vdestination\x18\x02 \x01(\tR\vdestination\";\n" + + "\fMoveResponse\x12+\n" + + "\x05entry\x18\x01 \x01(\v2\x15.filesystem.EntryInfoR\x05entry\"$\n" + + "\x0eMakeDirRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\">\n" + + "\x0fMakeDirResponse\x12+\n" + + "\x05entry\x18\x01 \x01(\v2\x15.filesystem.EntryInfoR\x05entry\"#\n" + + "\rRemoveRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\"\x10\n" + + "\x0eRemoveResponse\"!\n" + + "\vStatRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\";\n" + + "\fStatResponse\x12+\n" + + "\x05entry\x18\x01 \x01(\v2\x15.filesystem.EntryInfoR\x05entry\"\xd3\x02\n" + + "\tEntryInfo\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12(\n" + + "\x04type\x18\x02 \x01(\x0e2\x14.filesystem.FileTypeR\x04type\x12\x12\n" + + "\x04path\x18\x03 \x01(\tR\x04path\x12\x12\n" + + "\x04size\x18\x04 \x01(\x03R\x04size\x12\x12\n" + + "\x04mode\x18\x05 \x01(\rR\x04mode\x12 \n" + + "\vpermissions\x18\x06 \x01(\tR\vpermissions\x12\x14\n" + + "\x05owner\x18\a \x01(\tR\x05owner\x12\x14\n" + + "\x05group\x18\b \x01(\tR\x05group\x12?\n" + + "\rmodified_time\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\fmodifiedTime\x12*\n" + + "\x0esymlink_target\x18\n" + + " \x01(\tH\x00R\rsymlinkTarget\x88\x01\x01B\x11\n" + + "\x0f_symlink_target\":\n" + + "\x0eListDirRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x14\n" + + "\x05depth\x18\x02 \x01(\rR\x05depth\"B\n" + + "\x0fListDirResponse\x12/\n" + + "\aentries\x18\x01 \x03(\v2\x15.filesystem.EntryInfoR\aentries\"C\n" + + "\x0fWatchDirRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n" + + "\trecursive\x18\x02 \x01(\bR\trecursive\"P\n" + + "\x0fFilesystemEvent\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12)\n" + + "\x04type\x18\x02 \x01(\x0e2\x15.filesystem.EventTypeR\x04type\"\xfe\x01\n" + + "\x10WatchDirResponse\x12?\n" + + "\x05start\x18\x01 \x01(\v2'.filesystem.WatchDirResponse.StartEventH\x00R\x05start\x12=\n" + + "\n" + + "filesystem\x18\x02 \x01(\v2\x1b.filesystem.FilesystemEventH\x00R\n" + + "filesystem\x12F\n" + + "\tkeepalive\x18\x03 \x01(\v2&.filesystem.WatchDirResponse.KeepAliveH\x00R\tkeepalive\x1a\f\n" + + "\n" + + "StartEvent\x1a\v\n" + + "\tKeepAliveB\a\n" + + "\x05event\"H\n" + + "\x14CreateWatcherRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n" + + "\trecursive\x18\x02 \x01(\bR\trecursive\"6\n" + + "\x15CreateWatcherResponse\x12\x1d\n" + + "\n" + + "watcher_id\x18\x01 \x01(\tR\twatcherId\"8\n" + + "\x17GetWatcherEventsRequest\x12\x1d\n" + + "\n" + + "watcher_id\x18\x01 \x01(\tR\twatcherId\"O\n" + + "\x18GetWatcherEventsResponse\x123\n" + + "\x06events\x18\x01 \x03(\v2\x1b.filesystem.FilesystemEventR\x06events\"5\n" + + "\x14RemoveWatcherRequest\x12\x1d\n" + + "\n" + + "watcher_id\x18\x01 \x01(\tR\twatcherId\"\x17\n" + + "\x15RemoveWatcherResponse*i\n" + + "\bFileType\x12\x19\n" + + "\x15FILE_TYPE_UNSPECIFIED\x10\x00\x12\x12\n" + + "\x0eFILE_TYPE_FILE\x10\x01\x12\x17\n" + + "\x13FILE_TYPE_DIRECTORY\x10\x02\x12\x15\n" + + "\x11FILE_TYPE_SYMLINK\x10\x03*\x98\x01\n" + + "\tEventType\x12\x1a\n" + + "\x16EVENT_TYPE_UNSPECIFIED\x10\x00\x12\x15\n" + + "\x11EVENT_TYPE_CREATE\x10\x01\x12\x14\n" + + "\x10EVENT_TYPE_WRITE\x10\x02\x12\x15\n" + + "\x11EVENT_TYPE_REMOVE\x10\x03\x12\x15\n" + + "\x11EVENT_TYPE_RENAME\x10\x04\x12\x14\n" + + "\x10EVENT_TYPE_CHMOD\x10\x052\x9f\x05\n" + + "\n" + + "Filesystem\x129\n" + + "\x04Stat\x12\x17.filesystem.StatRequest\x1a\x18.filesystem.StatResponse\x12B\n" + + "\aMakeDir\x12\x1a.filesystem.MakeDirRequest\x1a\x1b.filesystem.MakeDirResponse\x129\n" + + "\x04Move\x12\x17.filesystem.MoveRequest\x1a\x18.filesystem.MoveResponse\x12B\n" + + "\aListDir\x12\x1a.filesystem.ListDirRequest\x1a\x1b.filesystem.ListDirResponse\x12?\n" + + "\x06Remove\x12\x19.filesystem.RemoveRequest\x1a\x1a.filesystem.RemoveResponse\x12G\n" + + "\bWatchDir\x12\x1b.filesystem.WatchDirRequest\x1a\x1c.filesystem.WatchDirResponse0\x01\x12T\n" + + "\rCreateWatcher\x12 .filesystem.CreateWatcherRequest\x1a!.filesystem.CreateWatcherResponse\x12]\n" + + "\x10GetWatcherEvents\x12#.filesystem.GetWatcherEventsRequest\x1a$.filesystem.GetWatcherEventsResponse\x12T\n" + + "\rRemoveWatcher\x12 .filesystem.RemoveWatcherRequest\x1a!.filesystem.RemoveWatcherResponseB\xad\x01\n" + + "\x0ecom.filesystemB\x0fFilesystemProtoP\x01ZBgit.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem\xa2\x02\x03FXX\xaa\x02\n" + + "Filesystem\xca\x02\n" + + "Filesystem\xe2\x02\x16Filesystem\\GPBMetadata\xea\x02\n" + + "Filesystemb\x06proto3" + +var ( + file_filesystem_filesystem_proto_rawDescOnce sync.Once + file_filesystem_filesystem_proto_rawDescData []byte +) + +func file_filesystem_filesystem_proto_rawDescGZIP() []byte { + file_filesystem_filesystem_proto_rawDescOnce.Do(func() { + file_filesystem_filesystem_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_filesystem_filesystem_proto_rawDesc), len(file_filesystem_filesystem_proto_rawDesc))) + }) + return file_filesystem_filesystem_proto_rawDescData +} + +var file_filesystem_filesystem_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_filesystem_filesystem_proto_msgTypes = make([]protoimpl.MessageInfo, 22) +var file_filesystem_filesystem_proto_goTypes = []any{ + (FileType)(0), // 0: filesystem.FileType + (EventType)(0), // 1: filesystem.EventType + (*MoveRequest)(nil), // 2: filesystem.MoveRequest + (*MoveResponse)(nil), // 3: filesystem.MoveResponse + (*MakeDirRequest)(nil), // 4: filesystem.MakeDirRequest + (*MakeDirResponse)(nil), // 5: filesystem.MakeDirResponse + (*RemoveRequest)(nil), // 6: filesystem.RemoveRequest + (*RemoveResponse)(nil), // 7: filesystem.RemoveResponse + (*StatRequest)(nil), // 8: filesystem.StatRequest + (*StatResponse)(nil), // 9: filesystem.StatResponse + (*EntryInfo)(nil), // 10: filesystem.EntryInfo + (*ListDirRequest)(nil), // 11: filesystem.ListDirRequest + (*ListDirResponse)(nil), // 12: filesystem.ListDirResponse + (*WatchDirRequest)(nil), // 13: filesystem.WatchDirRequest + (*FilesystemEvent)(nil), // 14: filesystem.FilesystemEvent + (*WatchDirResponse)(nil), // 15: filesystem.WatchDirResponse + (*CreateWatcherRequest)(nil), // 16: filesystem.CreateWatcherRequest + (*CreateWatcherResponse)(nil), // 17: filesystem.CreateWatcherResponse + (*GetWatcherEventsRequest)(nil), // 18: filesystem.GetWatcherEventsRequest + (*GetWatcherEventsResponse)(nil), // 19: filesystem.GetWatcherEventsResponse + (*RemoveWatcherRequest)(nil), // 20: filesystem.RemoveWatcherRequest + (*RemoveWatcherResponse)(nil), // 21: filesystem.RemoveWatcherResponse + (*WatchDirResponse_StartEvent)(nil), // 22: filesystem.WatchDirResponse.StartEvent + (*WatchDirResponse_KeepAlive)(nil), // 23: filesystem.WatchDirResponse.KeepAlive + (*timestamppb.Timestamp)(nil), // 24: google.protobuf.Timestamp +} +var file_filesystem_filesystem_proto_depIdxs = []int32{ + 10, // 0: filesystem.MoveResponse.entry:type_name -> filesystem.EntryInfo + 10, // 1: filesystem.MakeDirResponse.entry:type_name -> filesystem.EntryInfo + 10, // 2: filesystem.StatResponse.entry:type_name -> filesystem.EntryInfo + 0, // 3: filesystem.EntryInfo.type:type_name -> filesystem.FileType + 24, // 4: filesystem.EntryInfo.modified_time:type_name -> google.protobuf.Timestamp + 10, // 5: filesystem.ListDirResponse.entries:type_name -> filesystem.EntryInfo + 1, // 6: filesystem.FilesystemEvent.type:type_name -> filesystem.EventType + 22, // 7: filesystem.WatchDirResponse.start:type_name -> filesystem.WatchDirResponse.StartEvent + 14, // 8: filesystem.WatchDirResponse.filesystem:type_name -> filesystem.FilesystemEvent + 23, // 9: filesystem.WatchDirResponse.keepalive:type_name -> filesystem.WatchDirResponse.KeepAlive + 14, // 10: filesystem.GetWatcherEventsResponse.events:type_name -> filesystem.FilesystemEvent + 8, // 11: filesystem.Filesystem.Stat:input_type -> filesystem.StatRequest + 4, // 12: filesystem.Filesystem.MakeDir:input_type -> filesystem.MakeDirRequest + 2, // 13: filesystem.Filesystem.Move:input_type -> filesystem.MoveRequest + 11, // 14: filesystem.Filesystem.ListDir:input_type -> filesystem.ListDirRequest + 6, // 15: filesystem.Filesystem.Remove:input_type -> filesystem.RemoveRequest + 13, // 16: filesystem.Filesystem.WatchDir:input_type -> filesystem.WatchDirRequest + 16, // 17: filesystem.Filesystem.CreateWatcher:input_type -> filesystem.CreateWatcherRequest + 18, // 18: filesystem.Filesystem.GetWatcherEvents:input_type -> filesystem.GetWatcherEventsRequest + 20, // 19: filesystem.Filesystem.RemoveWatcher:input_type -> filesystem.RemoveWatcherRequest + 9, // 20: filesystem.Filesystem.Stat:output_type -> filesystem.StatResponse + 5, // 21: filesystem.Filesystem.MakeDir:output_type -> filesystem.MakeDirResponse + 3, // 22: filesystem.Filesystem.Move:output_type -> filesystem.MoveResponse + 12, // 23: filesystem.Filesystem.ListDir:output_type -> filesystem.ListDirResponse + 7, // 24: filesystem.Filesystem.Remove:output_type -> filesystem.RemoveResponse + 15, // 25: filesystem.Filesystem.WatchDir:output_type -> filesystem.WatchDirResponse + 17, // 26: filesystem.Filesystem.CreateWatcher:output_type -> filesystem.CreateWatcherResponse + 19, // 27: filesystem.Filesystem.GetWatcherEvents:output_type -> filesystem.GetWatcherEventsResponse + 21, // 28: filesystem.Filesystem.RemoveWatcher:output_type -> filesystem.RemoveWatcherResponse + 20, // [20:29] is the sub-list for method output_type + 11, // [11:20] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name +} + +func init() { file_filesystem_filesystem_proto_init() } +func file_filesystem_filesystem_proto_init() { + if File_filesystem_filesystem_proto != nil { + return + } + file_filesystem_filesystem_proto_msgTypes[8].OneofWrappers = []any{} + file_filesystem_filesystem_proto_msgTypes[13].OneofWrappers = []any{ + (*WatchDirResponse_Start)(nil), + (*WatchDirResponse_Filesystem)(nil), + (*WatchDirResponse_Keepalive)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_filesystem_filesystem_proto_rawDesc), len(file_filesystem_filesystem_proto_rawDesc)), + NumEnums: 2, + NumMessages: 22, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_filesystem_filesystem_proto_goTypes, + DependencyIndexes: file_filesystem_filesystem_proto_depIdxs, + EnumInfos: file_filesystem_filesystem_proto_enumTypes, + MessageInfos: file_filesystem_filesystem_proto_msgTypes, + }.Build() + File_filesystem_filesystem_proto = out.File + file_filesystem_filesystem_proto_goTypes = nil + file_filesystem_filesystem_proto_depIdxs = nil +} diff --git a/envd/internal/services/spec/filesystem/filesystemconnect/filesystem.connect.go b/envd/internal/services/spec/filesystem/filesystemconnect/filesystem.connect.go new file mode 100644 index 0000000..05893f2 --- /dev/null +++ b/envd/internal/services/spec/filesystem/filesystemconnect/filesystem.connect.go @@ -0,0 +1,337 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: filesystem/filesystem.proto + +package filesystemconnect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + filesystem "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/filesystem" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // FilesystemName is the fully-qualified name of the Filesystem service. + FilesystemName = "filesystem.Filesystem" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // FilesystemStatProcedure is the fully-qualified name of the Filesystem's Stat RPC. + FilesystemStatProcedure = "/filesystem.Filesystem/Stat" + // FilesystemMakeDirProcedure is the fully-qualified name of the Filesystem's MakeDir RPC. + FilesystemMakeDirProcedure = "/filesystem.Filesystem/MakeDir" + // FilesystemMoveProcedure is the fully-qualified name of the Filesystem's Move RPC. + FilesystemMoveProcedure = "/filesystem.Filesystem/Move" + // FilesystemListDirProcedure is the fully-qualified name of the Filesystem's ListDir RPC. + FilesystemListDirProcedure = "/filesystem.Filesystem/ListDir" + // FilesystemRemoveProcedure is the fully-qualified name of the Filesystem's Remove RPC. + FilesystemRemoveProcedure = "/filesystem.Filesystem/Remove" + // FilesystemWatchDirProcedure is the fully-qualified name of the Filesystem's WatchDir RPC. + FilesystemWatchDirProcedure = "/filesystem.Filesystem/WatchDir" + // FilesystemCreateWatcherProcedure is the fully-qualified name of the Filesystem's CreateWatcher + // RPC. + FilesystemCreateWatcherProcedure = "/filesystem.Filesystem/CreateWatcher" + // FilesystemGetWatcherEventsProcedure is the fully-qualified name of the Filesystem's + // GetWatcherEvents RPC. + FilesystemGetWatcherEventsProcedure = "/filesystem.Filesystem/GetWatcherEvents" + // FilesystemRemoveWatcherProcedure is the fully-qualified name of the Filesystem's RemoveWatcher + // RPC. + FilesystemRemoveWatcherProcedure = "/filesystem.Filesystem/RemoveWatcher" +) + +// FilesystemClient is a client for the filesystem.Filesystem service. +type FilesystemClient interface { + Stat(context.Context, *connect.Request[filesystem.StatRequest]) (*connect.Response[filesystem.StatResponse], error) + MakeDir(context.Context, *connect.Request[filesystem.MakeDirRequest]) (*connect.Response[filesystem.MakeDirResponse], error) + Move(context.Context, *connect.Request[filesystem.MoveRequest]) (*connect.Response[filesystem.MoveResponse], error) + ListDir(context.Context, *connect.Request[filesystem.ListDirRequest]) (*connect.Response[filesystem.ListDirResponse], error) + Remove(context.Context, *connect.Request[filesystem.RemoveRequest]) (*connect.Response[filesystem.RemoveResponse], error) + WatchDir(context.Context, *connect.Request[filesystem.WatchDirRequest]) (*connect.ServerStreamForClient[filesystem.WatchDirResponse], error) + // Non-streaming versions of WatchDir + CreateWatcher(context.Context, *connect.Request[filesystem.CreateWatcherRequest]) (*connect.Response[filesystem.CreateWatcherResponse], error) + GetWatcherEvents(context.Context, *connect.Request[filesystem.GetWatcherEventsRequest]) (*connect.Response[filesystem.GetWatcherEventsResponse], error) + RemoveWatcher(context.Context, *connect.Request[filesystem.RemoveWatcherRequest]) (*connect.Response[filesystem.RemoveWatcherResponse], error) +} + +// NewFilesystemClient constructs a client for the filesystem.Filesystem service. By default, it +// uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends +// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or +// connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewFilesystemClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) FilesystemClient { + baseURL = strings.TrimRight(baseURL, "/") + filesystemMethods := filesystem.File_filesystem_filesystem_proto.Services().ByName("Filesystem").Methods() + return &filesystemClient{ + stat: connect.NewClient[filesystem.StatRequest, filesystem.StatResponse]( + httpClient, + baseURL+FilesystemStatProcedure, + connect.WithSchema(filesystemMethods.ByName("Stat")), + connect.WithClientOptions(opts...), + ), + makeDir: connect.NewClient[filesystem.MakeDirRequest, filesystem.MakeDirResponse]( + httpClient, + baseURL+FilesystemMakeDirProcedure, + connect.WithSchema(filesystemMethods.ByName("MakeDir")), + connect.WithClientOptions(opts...), + ), + move: connect.NewClient[filesystem.MoveRequest, filesystem.MoveResponse]( + httpClient, + baseURL+FilesystemMoveProcedure, + connect.WithSchema(filesystemMethods.ByName("Move")), + connect.WithClientOptions(opts...), + ), + listDir: connect.NewClient[filesystem.ListDirRequest, filesystem.ListDirResponse]( + httpClient, + baseURL+FilesystemListDirProcedure, + connect.WithSchema(filesystemMethods.ByName("ListDir")), + connect.WithClientOptions(opts...), + ), + remove: connect.NewClient[filesystem.RemoveRequest, filesystem.RemoveResponse]( + httpClient, + baseURL+FilesystemRemoveProcedure, + connect.WithSchema(filesystemMethods.ByName("Remove")), + connect.WithClientOptions(opts...), + ), + watchDir: connect.NewClient[filesystem.WatchDirRequest, filesystem.WatchDirResponse]( + httpClient, + baseURL+FilesystemWatchDirProcedure, + connect.WithSchema(filesystemMethods.ByName("WatchDir")), + connect.WithClientOptions(opts...), + ), + createWatcher: connect.NewClient[filesystem.CreateWatcherRequest, filesystem.CreateWatcherResponse]( + httpClient, + baseURL+FilesystemCreateWatcherProcedure, + connect.WithSchema(filesystemMethods.ByName("CreateWatcher")), + connect.WithClientOptions(opts...), + ), + getWatcherEvents: connect.NewClient[filesystem.GetWatcherEventsRequest, filesystem.GetWatcherEventsResponse]( + httpClient, + baseURL+FilesystemGetWatcherEventsProcedure, + connect.WithSchema(filesystemMethods.ByName("GetWatcherEvents")), + connect.WithClientOptions(opts...), + ), + removeWatcher: connect.NewClient[filesystem.RemoveWatcherRequest, filesystem.RemoveWatcherResponse]( + httpClient, + baseURL+FilesystemRemoveWatcherProcedure, + connect.WithSchema(filesystemMethods.ByName("RemoveWatcher")), + connect.WithClientOptions(opts...), + ), + } +} + +// filesystemClient implements FilesystemClient. +type filesystemClient struct { + stat *connect.Client[filesystem.StatRequest, filesystem.StatResponse] + makeDir *connect.Client[filesystem.MakeDirRequest, filesystem.MakeDirResponse] + move *connect.Client[filesystem.MoveRequest, filesystem.MoveResponse] + listDir *connect.Client[filesystem.ListDirRequest, filesystem.ListDirResponse] + remove *connect.Client[filesystem.RemoveRequest, filesystem.RemoveResponse] + watchDir *connect.Client[filesystem.WatchDirRequest, filesystem.WatchDirResponse] + createWatcher *connect.Client[filesystem.CreateWatcherRequest, filesystem.CreateWatcherResponse] + getWatcherEvents *connect.Client[filesystem.GetWatcherEventsRequest, filesystem.GetWatcherEventsResponse] + removeWatcher *connect.Client[filesystem.RemoveWatcherRequest, filesystem.RemoveWatcherResponse] +} + +// Stat calls filesystem.Filesystem.Stat. +func (c *filesystemClient) Stat(ctx context.Context, req *connect.Request[filesystem.StatRequest]) (*connect.Response[filesystem.StatResponse], error) { + return c.stat.CallUnary(ctx, req) +} + +// MakeDir calls filesystem.Filesystem.MakeDir. +func (c *filesystemClient) MakeDir(ctx context.Context, req *connect.Request[filesystem.MakeDirRequest]) (*connect.Response[filesystem.MakeDirResponse], error) { + return c.makeDir.CallUnary(ctx, req) +} + +// Move calls filesystem.Filesystem.Move. +func (c *filesystemClient) Move(ctx context.Context, req *connect.Request[filesystem.MoveRequest]) (*connect.Response[filesystem.MoveResponse], error) { + return c.move.CallUnary(ctx, req) +} + +// ListDir calls filesystem.Filesystem.ListDir. +func (c *filesystemClient) ListDir(ctx context.Context, req *connect.Request[filesystem.ListDirRequest]) (*connect.Response[filesystem.ListDirResponse], error) { + return c.listDir.CallUnary(ctx, req) +} + +// Remove calls filesystem.Filesystem.Remove. +func (c *filesystemClient) Remove(ctx context.Context, req *connect.Request[filesystem.RemoveRequest]) (*connect.Response[filesystem.RemoveResponse], error) { + return c.remove.CallUnary(ctx, req) +} + +// WatchDir calls filesystem.Filesystem.WatchDir. +func (c *filesystemClient) WatchDir(ctx context.Context, req *connect.Request[filesystem.WatchDirRequest]) (*connect.ServerStreamForClient[filesystem.WatchDirResponse], error) { + return c.watchDir.CallServerStream(ctx, req) +} + +// CreateWatcher calls filesystem.Filesystem.CreateWatcher. +func (c *filesystemClient) CreateWatcher(ctx context.Context, req *connect.Request[filesystem.CreateWatcherRequest]) (*connect.Response[filesystem.CreateWatcherResponse], error) { + return c.createWatcher.CallUnary(ctx, req) +} + +// GetWatcherEvents calls filesystem.Filesystem.GetWatcherEvents. +func (c *filesystemClient) GetWatcherEvents(ctx context.Context, req *connect.Request[filesystem.GetWatcherEventsRequest]) (*connect.Response[filesystem.GetWatcherEventsResponse], error) { + return c.getWatcherEvents.CallUnary(ctx, req) +} + +// RemoveWatcher calls filesystem.Filesystem.RemoveWatcher. +func (c *filesystemClient) RemoveWatcher(ctx context.Context, req *connect.Request[filesystem.RemoveWatcherRequest]) (*connect.Response[filesystem.RemoveWatcherResponse], error) { + return c.removeWatcher.CallUnary(ctx, req) +} + +// FilesystemHandler is an implementation of the filesystem.Filesystem service. +type FilesystemHandler interface { + Stat(context.Context, *connect.Request[filesystem.StatRequest]) (*connect.Response[filesystem.StatResponse], error) + MakeDir(context.Context, *connect.Request[filesystem.MakeDirRequest]) (*connect.Response[filesystem.MakeDirResponse], error) + Move(context.Context, *connect.Request[filesystem.MoveRequest]) (*connect.Response[filesystem.MoveResponse], error) + ListDir(context.Context, *connect.Request[filesystem.ListDirRequest]) (*connect.Response[filesystem.ListDirResponse], error) + Remove(context.Context, *connect.Request[filesystem.RemoveRequest]) (*connect.Response[filesystem.RemoveResponse], error) + WatchDir(context.Context, *connect.Request[filesystem.WatchDirRequest], *connect.ServerStream[filesystem.WatchDirResponse]) error + // Non-streaming versions of WatchDir + CreateWatcher(context.Context, *connect.Request[filesystem.CreateWatcherRequest]) (*connect.Response[filesystem.CreateWatcherResponse], error) + GetWatcherEvents(context.Context, *connect.Request[filesystem.GetWatcherEventsRequest]) (*connect.Response[filesystem.GetWatcherEventsResponse], error) + RemoveWatcher(context.Context, *connect.Request[filesystem.RemoveWatcherRequest]) (*connect.Response[filesystem.RemoveWatcherResponse], error) +} + +// NewFilesystemHandler builds an HTTP handler from the service implementation. It returns the path +// on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewFilesystemHandler(svc FilesystemHandler, opts ...connect.HandlerOption) (string, http.Handler) { + filesystemMethods := filesystem.File_filesystem_filesystem_proto.Services().ByName("Filesystem").Methods() + filesystemStatHandler := connect.NewUnaryHandler( + FilesystemStatProcedure, + svc.Stat, + connect.WithSchema(filesystemMethods.ByName("Stat")), + connect.WithHandlerOptions(opts...), + ) + filesystemMakeDirHandler := connect.NewUnaryHandler( + FilesystemMakeDirProcedure, + svc.MakeDir, + connect.WithSchema(filesystemMethods.ByName("MakeDir")), + connect.WithHandlerOptions(opts...), + ) + filesystemMoveHandler := connect.NewUnaryHandler( + FilesystemMoveProcedure, + svc.Move, + connect.WithSchema(filesystemMethods.ByName("Move")), + connect.WithHandlerOptions(opts...), + ) + filesystemListDirHandler := connect.NewUnaryHandler( + FilesystemListDirProcedure, + svc.ListDir, + connect.WithSchema(filesystemMethods.ByName("ListDir")), + connect.WithHandlerOptions(opts...), + ) + filesystemRemoveHandler := connect.NewUnaryHandler( + FilesystemRemoveProcedure, + svc.Remove, + connect.WithSchema(filesystemMethods.ByName("Remove")), + connect.WithHandlerOptions(opts...), + ) + filesystemWatchDirHandler := connect.NewServerStreamHandler( + FilesystemWatchDirProcedure, + svc.WatchDir, + connect.WithSchema(filesystemMethods.ByName("WatchDir")), + connect.WithHandlerOptions(opts...), + ) + filesystemCreateWatcherHandler := connect.NewUnaryHandler( + FilesystemCreateWatcherProcedure, + svc.CreateWatcher, + connect.WithSchema(filesystemMethods.ByName("CreateWatcher")), + connect.WithHandlerOptions(opts...), + ) + filesystemGetWatcherEventsHandler := connect.NewUnaryHandler( + FilesystemGetWatcherEventsProcedure, + svc.GetWatcherEvents, + connect.WithSchema(filesystemMethods.ByName("GetWatcherEvents")), + connect.WithHandlerOptions(opts...), + ) + filesystemRemoveWatcherHandler := connect.NewUnaryHandler( + FilesystemRemoveWatcherProcedure, + svc.RemoveWatcher, + connect.WithSchema(filesystemMethods.ByName("RemoveWatcher")), + connect.WithHandlerOptions(opts...), + ) + return "/filesystem.Filesystem/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case FilesystemStatProcedure: + filesystemStatHandler.ServeHTTP(w, r) + case FilesystemMakeDirProcedure: + filesystemMakeDirHandler.ServeHTTP(w, r) + case FilesystemMoveProcedure: + filesystemMoveHandler.ServeHTTP(w, r) + case FilesystemListDirProcedure: + filesystemListDirHandler.ServeHTTP(w, r) + case FilesystemRemoveProcedure: + filesystemRemoveHandler.ServeHTTP(w, r) + case FilesystemWatchDirProcedure: + filesystemWatchDirHandler.ServeHTTP(w, r) + case FilesystemCreateWatcherProcedure: + filesystemCreateWatcherHandler.ServeHTTP(w, r) + case FilesystemGetWatcherEventsProcedure: + filesystemGetWatcherEventsHandler.ServeHTTP(w, r) + case FilesystemRemoveWatcherProcedure: + filesystemRemoveWatcherHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedFilesystemHandler returns CodeUnimplemented from all methods. +type UnimplementedFilesystemHandler struct{} + +func (UnimplementedFilesystemHandler) Stat(context.Context, *connect.Request[filesystem.StatRequest]) (*connect.Response[filesystem.StatResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.Stat is not implemented")) +} + +func (UnimplementedFilesystemHandler) MakeDir(context.Context, *connect.Request[filesystem.MakeDirRequest]) (*connect.Response[filesystem.MakeDirResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.MakeDir is not implemented")) +} + +func (UnimplementedFilesystemHandler) Move(context.Context, *connect.Request[filesystem.MoveRequest]) (*connect.Response[filesystem.MoveResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.Move is not implemented")) +} + +func (UnimplementedFilesystemHandler) ListDir(context.Context, *connect.Request[filesystem.ListDirRequest]) (*connect.Response[filesystem.ListDirResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.ListDir is not implemented")) +} + +func (UnimplementedFilesystemHandler) Remove(context.Context, *connect.Request[filesystem.RemoveRequest]) (*connect.Response[filesystem.RemoveResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.Remove is not implemented")) +} + +func (UnimplementedFilesystemHandler) WatchDir(context.Context, *connect.Request[filesystem.WatchDirRequest], *connect.ServerStream[filesystem.WatchDirResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.WatchDir is not implemented")) +} + +func (UnimplementedFilesystemHandler) CreateWatcher(context.Context, *connect.Request[filesystem.CreateWatcherRequest]) (*connect.Response[filesystem.CreateWatcherResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.CreateWatcher is not implemented")) +} + +func (UnimplementedFilesystemHandler) GetWatcherEvents(context.Context, *connect.Request[filesystem.GetWatcherEventsRequest]) (*connect.Response[filesystem.GetWatcherEventsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.GetWatcherEvents is not implemented")) +} + +func (UnimplementedFilesystemHandler) RemoveWatcher(context.Context, *connect.Request[filesystem.RemoveWatcherRequest]) (*connect.Response[filesystem.RemoveWatcherResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.RemoveWatcher is not implemented")) +} diff --git a/envd/internal/services/spec/process.pb.go b/envd/internal/services/spec/process.pb.go new file mode 100644 index 0000000..6877dca --- /dev/null +++ b/envd/internal/services/spec/process.pb.go @@ -0,0 +1,1972 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: process.proto + +package spec + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Signal int32 + +const ( + Signal_SIGNAL_UNSPECIFIED Signal = 0 + Signal_SIGNAL_SIGTERM Signal = 15 + Signal_SIGNAL_SIGKILL Signal = 9 +) + +// Enum value maps for Signal. +var ( + Signal_name = map[int32]string{ + 0: "SIGNAL_UNSPECIFIED", + 15: "SIGNAL_SIGTERM", + 9: "SIGNAL_SIGKILL", + } + Signal_value = map[string]int32{ + "SIGNAL_UNSPECIFIED": 0, + "SIGNAL_SIGTERM": 15, + "SIGNAL_SIGKILL": 9, + } +) + +func (x Signal) Enum() *Signal { + p := new(Signal) + *p = x + return p +} + +func (x Signal) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Signal) Descriptor() protoreflect.EnumDescriptor { + return file_process_proto_enumTypes[0].Descriptor() +} + +func (Signal) Type() protoreflect.EnumType { + return &file_process_proto_enumTypes[0] +} + +func (x Signal) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Signal.Descriptor instead. +func (Signal) EnumDescriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{0} +} + +type PTY struct { + state protoimpl.MessageState `protogen:"open.v1"` + Size *PTY_Size `protobuf:"bytes,1,opt,name=size,proto3" json:"size,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PTY) Reset() { + *x = PTY{} + mi := &file_process_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PTY) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PTY) ProtoMessage() {} + +func (x *PTY) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PTY.ProtoReflect.Descriptor instead. +func (*PTY) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{0} +} + +func (x *PTY) GetSize() *PTY_Size { + if x != nil { + return x.Size + } + return nil +} + +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"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessConfig) Reset() { + *x = ProcessConfig{} + mi := &file_process_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessConfig) ProtoMessage() {} + +func (x *ProcessConfig) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessConfig.ProtoReflect.Descriptor instead. +func (*ProcessConfig) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{1} +} + +func (x *ProcessConfig) GetCmd() string { + if x != nil { + return x.Cmd + } + return "" +} + +func (x *ProcessConfig) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + +func (x *ProcessConfig) GetEnvs() map[string]string { + if x != nil { + return x.Envs + } + return nil +} + +func (x *ProcessConfig) GetCwd() string { + if x != nil && x.Cwd != nil { + return *x.Cwd + } + return "" +} + +type ListRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListRequest) Reset() { + *x = ListRequest{} + mi := &file_process_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRequest) ProtoMessage() {} + +func (x *ListRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListRequest.ProtoReflect.Descriptor instead. +func (*ListRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{2} +} + +type ProcessInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Config *ProcessConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` + Pid uint32 `protobuf:"varint,2,opt,name=pid,proto3" json:"pid,omitempty"` + Tag *string `protobuf:"bytes,3,opt,name=tag,proto3,oneof" json:"tag,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessInfo) Reset() { + *x = ProcessInfo{} + mi := &file_process_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessInfo) ProtoMessage() {} + +func (x *ProcessInfo) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessInfo.ProtoReflect.Descriptor instead. +func (*ProcessInfo) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{3} +} + +func (x *ProcessInfo) GetConfig() *ProcessConfig { + if x != nil { + return x.Config + } + return nil +} + +func (x *ProcessInfo) GetPid() uint32 { + if x != nil { + return x.Pid + } + return 0 +} + +func (x *ProcessInfo) GetTag() string { + if x != nil && x.Tag != nil { + return *x.Tag + } + return "" +} + +type ListResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Processes []*ProcessInfo `protobuf:"bytes,1,rep,name=processes,proto3" json:"processes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListResponse) Reset() { + *x = ListResponse{} + mi := &file_process_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListResponse) ProtoMessage() {} + +func (x *ListResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListResponse.ProtoReflect.Descriptor instead. +func (*ListResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{4} +} + +func (x *ListResponse) GetProcesses() []*ProcessInfo { + if x != nil { + return x.Processes + } + return nil +} + +type StartRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessConfig `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Pty *PTY `protobuf:"bytes,2,opt,name=pty,proto3,oneof" json:"pty,omitempty"` + Tag *string `protobuf:"bytes,3,opt,name=tag,proto3,oneof" json:"tag,omitempty"` + // This is optional for backwards compatibility. + // We default to true. New SDK versions will set this to false by default. + Stdin *bool `protobuf:"varint,4,opt,name=stdin,proto3,oneof" json:"stdin,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartRequest) Reset() { + *x = StartRequest{} + mi := &file_process_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartRequest) ProtoMessage() {} + +func (x *StartRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StartRequest.ProtoReflect.Descriptor instead. +func (*StartRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{5} +} + +func (x *StartRequest) GetProcess() *ProcessConfig { + if x != nil { + return x.Process + } + return nil +} + +func (x *StartRequest) GetPty() *PTY { + if x != nil { + return x.Pty + } + return nil +} + +func (x *StartRequest) GetTag() string { + if x != nil && x.Tag != nil { + return *x.Tag + } + return "" +} + +func (x *StartRequest) GetStdin() bool { + if x != nil && x.Stdin != nil { + return *x.Stdin + } + return false +} + +type UpdateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Pty *PTY `protobuf:"bytes,2,opt,name=pty,proto3,oneof" json:"pty,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateRequest) Reset() { + *x = UpdateRequest{} + mi := &file_process_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateRequest) ProtoMessage() {} + +func (x *UpdateRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateRequest.ProtoReflect.Descriptor instead. +func (*UpdateRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{6} +} + +func (x *UpdateRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +func (x *UpdateRequest) GetPty() *PTY { + if x != nil { + return x.Pty + } + return nil +} + +type UpdateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateResponse) Reset() { + *x = UpdateResponse{} + mi := &file_process_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateResponse) ProtoMessage() {} + +func (x *UpdateResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateResponse.ProtoReflect.Descriptor instead. +func (*UpdateResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{7} +} + +type ProcessEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *ProcessEvent_Start + // *ProcessEvent_Data + // *ProcessEvent_End + // *ProcessEvent_Keepalive + Event isProcessEvent_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent) Reset() { + *x = ProcessEvent{} + mi := &file_process_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent) ProtoMessage() {} + +func (x *ProcessEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{8} +} + +func (x *ProcessEvent) GetEvent() isProcessEvent_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *ProcessEvent) GetStart() *ProcessEvent_StartEvent { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_Start); ok { + return x.Start + } + } + return nil +} + +func (x *ProcessEvent) GetData() *ProcessEvent_DataEvent { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_Data); ok { + return x.Data + } + } + return nil +} + +func (x *ProcessEvent) GetEnd() *ProcessEvent_EndEvent { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_End); ok { + return x.End + } + } + return nil +} + +func (x *ProcessEvent) GetKeepalive() *ProcessEvent_KeepAlive { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_Keepalive); ok { + return x.Keepalive + } + } + return nil +} + +type isProcessEvent_Event interface { + isProcessEvent_Event() +} + +type ProcessEvent_Start struct { + Start *ProcessEvent_StartEvent `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type ProcessEvent_Data struct { + Data *ProcessEvent_DataEvent `protobuf:"bytes,2,opt,name=data,proto3,oneof"` +} + +type ProcessEvent_End struct { + End *ProcessEvent_EndEvent `protobuf:"bytes,3,opt,name=end,proto3,oneof"` +} + +type ProcessEvent_Keepalive struct { + Keepalive *ProcessEvent_KeepAlive `protobuf:"bytes,4,opt,name=keepalive,proto3,oneof"` +} + +func (*ProcessEvent_Start) isProcessEvent_Event() {} + +func (*ProcessEvent_Data) isProcessEvent_Event() {} + +func (*ProcessEvent_End) isProcessEvent_Event() {} + +func (*ProcessEvent_Keepalive) isProcessEvent_Event() {} + +type StartResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Event *ProcessEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartResponse) Reset() { + *x = StartResponse{} + mi := &file_process_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartResponse) ProtoMessage() {} + +func (x *StartResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StartResponse.ProtoReflect.Descriptor instead. +func (*StartResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{9} +} + +func (x *StartResponse) GetEvent() *ProcessEvent { + if x != nil { + return x.Event + } + return nil +} + +type ConnectResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Event *ProcessEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectResponse) Reset() { + *x = ConnectResponse{} + mi := &file_process_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectResponse) ProtoMessage() {} + +func (x *ConnectResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectResponse.ProtoReflect.Descriptor instead. +func (*ConnectResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{10} +} + +func (x *ConnectResponse) GetEvent() *ProcessEvent { + if x != nil { + return x.Event + } + return nil +} + +type SendInputRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Input *ProcessInput `protobuf:"bytes,2,opt,name=input,proto3" json:"input,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendInputRequest) Reset() { + *x = SendInputRequest{} + mi := &file_process_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendInputRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendInputRequest) ProtoMessage() {} + +func (x *SendInputRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendInputRequest.ProtoReflect.Descriptor instead. +func (*SendInputRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{11} +} + +func (x *SendInputRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +func (x *SendInputRequest) GetInput() *ProcessInput { + if x != nil { + return x.Input + } + return nil +} + +type SendInputResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendInputResponse) Reset() { + *x = SendInputResponse{} + mi := &file_process_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendInputResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendInputResponse) ProtoMessage() {} + +func (x *SendInputResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendInputResponse.ProtoReflect.Descriptor instead. +func (*SendInputResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{12} +} + +type ProcessInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Input: + // + // *ProcessInput_Stdin + // *ProcessInput_Pty + Input isProcessInput_Input `protobuf_oneof:"input"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessInput) Reset() { + *x = ProcessInput{} + mi := &file_process_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessInput) ProtoMessage() {} + +func (x *ProcessInput) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessInput.ProtoReflect.Descriptor instead. +func (*ProcessInput) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{13} +} + +func (x *ProcessInput) GetInput() isProcessInput_Input { + if x != nil { + return x.Input + } + return nil +} + +func (x *ProcessInput) GetStdin() []byte { + if x != nil { + if x, ok := x.Input.(*ProcessInput_Stdin); ok { + return x.Stdin + } + } + return nil +} + +func (x *ProcessInput) GetPty() []byte { + if x != nil { + if x, ok := x.Input.(*ProcessInput_Pty); ok { + return x.Pty + } + } + return nil +} + +type isProcessInput_Input interface { + isProcessInput_Input() +} + +type ProcessInput_Stdin struct { + Stdin []byte `protobuf:"bytes,1,opt,name=stdin,proto3,oneof"` +} + +type ProcessInput_Pty struct { + Pty []byte `protobuf:"bytes,2,opt,name=pty,proto3,oneof"` +} + +func (*ProcessInput_Stdin) isProcessInput_Input() {} + +func (*ProcessInput_Pty) isProcessInput_Input() {} + +type StreamInputRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *StreamInputRequest_Start + // *StreamInputRequest_Data + // *StreamInputRequest_Keepalive + Event isStreamInputRequest_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest) Reset() { + *x = StreamInputRequest{} + mi := &file_process_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest) ProtoMessage() {} + +func (x *StreamInputRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest.ProtoReflect.Descriptor instead. +func (*StreamInputRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{14} +} + +func (x *StreamInputRequest) GetEvent() isStreamInputRequest_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *StreamInputRequest) GetStart() *StreamInputRequest_StartEvent { + if x != nil { + if x, ok := x.Event.(*StreamInputRequest_Start); ok { + return x.Start + } + } + return nil +} + +func (x *StreamInputRequest) GetData() *StreamInputRequest_DataEvent { + if x != nil { + if x, ok := x.Event.(*StreamInputRequest_Data); ok { + return x.Data + } + } + return nil +} + +func (x *StreamInputRequest) GetKeepalive() *StreamInputRequest_KeepAlive { + if x != nil { + if x, ok := x.Event.(*StreamInputRequest_Keepalive); ok { + return x.Keepalive + } + } + return nil +} + +type isStreamInputRequest_Event interface { + isStreamInputRequest_Event() +} + +type StreamInputRequest_Start struct { + Start *StreamInputRequest_StartEvent `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type StreamInputRequest_Data struct { + Data *StreamInputRequest_DataEvent `protobuf:"bytes,2,opt,name=data,proto3,oneof"` +} + +type StreamInputRequest_Keepalive struct { + Keepalive *StreamInputRequest_KeepAlive `protobuf:"bytes,3,opt,name=keepalive,proto3,oneof"` +} + +func (*StreamInputRequest_Start) isStreamInputRequest_Event() {} + +func (*StreamInputRequest_Data) isStreamInputRequest_Event() {} + +func (*StreamInputRequest_Keepalive) isStreamInputRequest_Event() {} + +type StreamInputResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputResponse) Reset() { + *x = StreamInputResponse{} + mi := &file_process_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputResponse) ProtoMessage() {} + +func (x *StreamInputResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputResponse.ProtoReflect.Descriptor instead. +func (*StreamInputResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{15} +} + +type SendSignalRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Signal Signal `protobuf:"varint,2,opt,name=signal,proto3,enum=process.Signal" json:"signal,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendSignalRequest) Reset() { + *x = SendSignalRequest{} + mi := &file_process_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendSignalRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendSignalRequest) ProtoMessage() {} + +func (x *SendSignalRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendSignalRequest.ProtoReflect.Descriptor instead. +func (*SendSignalRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{16} +} + +func (x *SendSignalRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +func (x *SendSignalRequest) GetSignal() Signal { + if x != nil { + return x.Signal + } + return Signal_SIGNAL_UNSPECIFIED +} + +type SendSignalResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendSignalResponse) Reset() { + *x = SendSignalResponse{} + mi := &file_process_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendSignalResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendSignalResponse) ProtoMessage() {} + +func (x *SendSignalResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendSignalResponse.ProtoReflect.Descriptor instead. +func (*SendSignalResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{17} +} + +type CloseStdinRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CloseStdinRequest) Reset() { + *x = CloseStdinRequest{} + mi := &file_process_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CloseStdinRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CloseStdinRequest) ProtoMessage() {} + +func (x *CloseStdinRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CloseStdinRequest.ProtoReflect.Descriptor instead. +func (*CloseStdinRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{18} +} + +func (x *CloseStdinRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +type CloseStdinResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CloseStdinResponse) Reset() { + *x = CloseStdinResponse{} + mi := &file_process_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CloseStdinResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CloseStdinResponse) ProtoMessage() {} + +func (x *CloseStdinResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CloseStdinResponse.ProtoReflect.Descriptor instead. +func (*CloseStdinResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{19} +} + +type ConnectRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectRequest) Reset() { + *x = ConnectRequest{} + mi := &file_process_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectRequest) ProtoMessage() {} + +func (x *ConnectRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectRequest.ProtoReflect.Descriptor instead. +func (*ConnectRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{20} +} + +func (x *ConnectRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +type ProcessSelector struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Selector: + // + // *ProcessSelector_Pid + // *ProcessSelector_Tag + Selector isProcessSelector_Selector `protobuf_oneof:"selector"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessSelector) Reset() { + *x = ProcessSelector{} + mi := &file_process_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessSelector) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessSelector) ProtoMessage() {} + +func (x *ProcessSelector) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessSelector.ProtoReflect.Descriptor instead. +func (*ProcessSelector) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{21} +} + +func (x *ProcessSelector) GetSelector() isProcessSelector_Selector { + if x != nil { + return x.Selector + } + return nil +} + +func (x *ProcessSelector) GetPid() uint32 { + if x != nil { + if x, ok := x.Selector.(*ProcessSelector_Pid); ok { + return x.Pid + } + } + return 0 +} + +func (x *ProcessSelector) GetTag() string { + if x != nil { + if x, ok := x.Selector.(*ProcessSelector_Tag); ok { + return x.Tag + } + } + return "" +} + +type isProcessSelector_Selector interface { + isProcessSelector_Selector() +} + +type ProcessSelector_Pid struct { + Pid uint32 `protobuf:"varint,1,opt,name=pid,proto3,oneof"` +} + +type ProcessSelector_Tag struct { + Tag string `protobuf:"bytes,2,opt,name=tag,proto3,oneof"` +} + +func (*ProcessSelector_Pid) isProcessSelector_Selector() {} + +func (*ProcessSelector_Tag) isProcessSelector_Selector() {} + +type PTY_Size struct { + state protoimpl.MessageState `protogen:"open.v1"` + Cols uint32 `protobuf:"varint,1,opt,name=cols,proto3" json:"cols,omitempty"` + Rows uint32 `protobuf:"varint,2,opt,name=rows,proto3" json:"rows,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PTY_Size) Reset() { + *x = PTY_Size{} + mi := &file_process_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PTY_Size) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PTY_Size) ProtoMessage() {} + +func (x *PTY_Size) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PTY_Size.ProtoReflect.Descriptor instead. +func (*PTY_Size) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{0, 0} +} + +func (x *PTY_Size) GetCols() uint32 { + if x != nil { + return x.Cols + } + return 0 +} + +func (x *PTY_Size) GetRows() uint32 { + if x != nil { + return x.Rows + } + return 0 +} + +type ProcessEvent_StartEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Pid uint32 `protobuf:"varint,1,opt,name=pid,proto3" json:"pid,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_StartEvent) Reset() { + *x = ProcessEvent_StartEvent{} + mi := &file_process_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_StartEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_StartEvent) ProtoMessage() {} + +func (x *ProcessEvent_StartEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_StartEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent_StartEvent) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{8, 0} +} + +func (x *ProcessEvent_StartEvent) GetPid() uint32 { + if x != nil { + return x.Pid + } + return 0 +} + +type ProcessEvent_DataEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Output: + // + // *ProcessEvent_DataEvent_Stdout + // *ProcessEvent_DataEvent_Stderr + // *ProcessEvent_DataEvent_Pty + Output isProcessEvent_DataEvent_Output `protobuf_oneof:"output"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_DataEvent) Reset() { + *x = ProcessEvent_DataEvent{} + mi := &file_process_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_DataEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_DataEvent) ProtoMessage() {} + +func (x *ProcessEvent_DataEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_DataEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent_DataEvent) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{8, 1} +} + +func (x *ProcessEvent_DataEvent) GetOutput() isProcessEvent_DataEvent_Output { + if x != nil { + return x.Output + } + return nil +} + +func (x *ProcessEvent_DataEvent) GetStdout() []byte { + if x != nil { + if x, ok := x.Output.(*ProcessEvent_DataEvent_Stdout); ok { + return x.Stdout + } + } + return nil +} + +func (x *ProcessEvent_DataEvent) GetStderr() []byte { + if x != nil { + if x, ok := x.Output.(*ProcessEvent_DataEvent_Stderr); ok { + return x.Stderr + } + } + return nil +} + +func (x *ProcessEvent_DataEvent) GetPty() []byte { + if x != nil { + if x, ok := x.Output.(*ProcessEvent_DataEvent_Pty); ok { + return x.Pty + } + } + return nil +} + +type isProcessEvent_DataEvent_Output interface { + isProcessEvent_DataEvent_Output() +} + +type ProcessEvent_DataEvent_Stdout struct { + Stdout []byte `protobuf:"bytes,1,opt,name=stdout,proto3,oneof"` +} + +type ProcessEvent_DataEvent_Stderr struct { + Stderr []byte `protobuf:"bytes,2,opt,name=stderr,proto3,oneof"` +} + +type ProcessEvent_DataEvent_Pty struct { + Pty []byte `protobuf:"bytes,3,opt,name=pty,proto3,oneof"` +} + +func (*ProcessEvent_DataEvent_Stdout) isProcessEvent_DataEvent_Output() {} + +func (*ProcessEvent_DataEvent_Stderr) isProcessEvent_DataEvent_Output() {} + +func (*ProcessEvent_DataEvent_Pty) isProcessEvent_DataEvent_Output() {} + +type ProcessEvent_EndEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + ExitCode int32 `protobuf:"zigzag32,1,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + Exited bool `protobuf:"varint,2,opt,name=exited,proto3" json:"exited,omitempty"` + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + Error *string `protobuf:"bytes,4,opt,name=error,proto3,oneof" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_EndEvent) Reset() { + *x = ProcessEvent_EndEvent{} + mi := &file_process_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_EndEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_EndEvent) ProtoMessage() {} + +func (x *ProcessEvent_EndEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_EndEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent_EndEvent) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{8, 2} +} + +func (x *ProcessEvent_EndEvent) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +func (x *ProcessEvent_EndEvent) GetExited() bool { + if x != nil { + return x.Exited + } + return false +} + +func (x *ProcessEvent_EndEvent) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *ProcessEvent_EndEvent) GetError() string { + if x != nil && x.Error != nil { + return *x.Error + } + return "" +} + +type ProcessEvent_KeepAlive struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_KeepAlive) Reset() { + *x = ProcessEvent_KeepAlive{} + mi := &file_process_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_KeepAlive) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_KeepAlive) ProtoMessage() {} + +func (x *ProcessEvent_KeepAlive) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_KeepAlive.ProtoReflect.Descriptor instead. +func (*ProcessEvent_KeepAlive) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{8, 3} +} + +type StreamInputRequest_StartEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest_StartEvent) Reset() { + *x = StreamInputRequest_StartEvent{} + mi := &file_process_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest_StartEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest_StartEvent) ProtoMessage() {} + +func (x *StreamInputRequest_StartEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest_StartEvent.ProtoReflect.Descriptor instead. +func (*StreamInputRequest_StartEvent) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{14, 0} +} + +func (x *StreamInputRequest_StartEvent) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +type StreamInputRequest_DataEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Input *ProcessInput `protobuf:"bytes,2,opt,name=input,proto3" json:"input,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest_DataEvent) Reset() { + *x = StreamInputRequest_DataEvent{} + mi := &file_process_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest_DataEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest_DataEvent) ProtoMessage() {} + +func (x *StreamInputRequest_DataEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest_DataEvent.ProtoReflect.Descriptor instead. +func (*StreamInputRequest_DataEvent) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{14, 1} +} + +func (x *StreamInputRequest_DataEvent) GetInput() *ProcessInput { + if x != nil { + return x.Input + } + return nil +} + +type StreamInputRequest_KeepAlive struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest_KeepAlive) Reset() { + *x = StreamInputRequest_KeepAlive{} + mi := &file_process_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest_KeepAlive) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest_KeepAlive) ProtoMessage() {} + +func (x *StreamInputRequest_KeepAlive) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest_KeepAlive.ProtoReflect.Descriptor instead. +func (*StreamInputRequest_KeepAlive) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{14, 2} +} + +var File_process_proto protoreflect.FileDescriptor + +const file_process_proto_rawDesc = "" + + "\n" + + "\rprocess.proto\x12\aprocess\"\\\n" + + "\x03PTY\x12%\n" + + "\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" + + "\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" + + "\tEnvsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x06\n" + + "\x04_cwd\"\r\n" + + "\vListRequest\"n\n" + + "\vProcessInfo\x12.\n" + + "\x06config\x18\x01 \x01(\v2\x16.process.ProcessConfigR\x06config\x12\x10\n" + + "\x03pid\x18\x02 \x01(\rR\x03pid\x12\x15\n" + + "\x03tag\x18\x03 \x01(\tH\x00R\x03tag\x88\x01\x01B\x06\n" + + "\x04_tag\"B\n" + + "\fListResponse\x122\n" + + "\tprocesses\x18\x01 \x03(\v2\x14.process.ProcessInfoR\tprocesses\"\xb1\x01\n" + + "\fStartRequest\x120\n" + + "\aprocess\x18\x01 \x01(\v2\x16.process.ProcessConfigR\aprocess\x12#\n" + + "\x03pty\x18\x02 \x01(\v2\f.process.PTYH\x00R\x03pty\x88\x01\x01\x12\x15\n" + + "\x03tag\x18\x03 \x01(\tH\x01R\x03tag\x88\x01\x01\x12\x19\n" + + "\x05stdin\x18\x04 \x01(\bH\x02R\x05stdin\x88\x01\x01B\x06\n" + + "\x04_ptyB\x06\n" + + "\x04_tagB\b\n" + + "\x06_stdin\"p\n" + + "\rUpdateRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x12#\n" + + "\x03pty\x18\x02 \x01(\v2\f.process.PTYH\x00R\x03pty\x88\x01\x01B\x06\n" + + "\x04_pty\"\x10\n" + + "\x0eUpdateResponse\"\x87\x04\n" + + "\fProcessEvent\x128\n" + + "\x05start\x18\x01 \x01(\v2 .process.ProcessEvent.StartEventH\x00R\x05start\x125\n" + + "\x04data\x18\x02 \x01(\v2\x1f.process.ProcessEvent.DataEventH\x00R\x04data\x122\n" + + "\x03end\x18\x03 \x01(\v2\x1e.process.ProcessEvent.EndEventH\x00R\x03end\x12?\n" + + "\tkeepalive\x18\x04 \x01(\v2\x1f.process.ProcessEvent.KeepAliveH\x00R\tkeepalive\x1a\x1e\n" + + "\n" + + "StartEvent\x12\x10\n" + + "\x03pid\x18\x01 \x01(\rR\x03pid\x1a]\n" + + "\tDataEvent\x12\x18\n" + + "\x06stdout\x18\x01 \x01(\fH\x00R\x06stdout\x12\x18\n" + + "\x06stderr\x18\x02 \x01(\fH\x00R\x06stderr\x12\x12\n" + + "\x03pty\x18\x03 \x01(\fH\x00R\x03ptyB\b\n" + + "\x06output\x1a|\n" + + "\bEndEvent\x12\x1b\n" + + "\texit_code\x18\x01 \x01(\x11R\bexitCode\x12\x16\n" + + "\x06exited\x18\x02 \x01(\bR\x06exited\x12\x16\n" + + "\x06status\x18\x03 \x01(\tR\x06status\x12\x19\n" + + "\x05error\x18\x04 \x01(\tH\x00R\x05error\x88\x01\x01B\b\n" + + "\x06_error\x1a\v\n" + + "\tKeepAliveB\a\n" + + "\x05event\"<\n" + + "\rStartResponse\x12+\n" + + "\x05event\x18\x01 \x01(\v2\x15.process.ProcessEventR\x05event\">\n" + + "\x0fConnectResponse\x12+\n" + + "\x05event\x18\x01 \x01(\v2\x15.process.ProcessEventR\x05event\"s\n" + + "\x10SendInputRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x12+\n" + + "\x05input\x18\x02 \x01(\v2\x15.process.ProcessInputR\x05input\"\x13\n" + + "\x11SendInputResponse\"C\n" + + "\fProcessInput\x12\x16\n" + + "\x05stdin\x18\x01 \x01(\fH\x00R\x05stdin\x12\x12\n" + + "\x03pty\x18\x02 \x01(\fH\x00R\x03ptyB\a\n" + + "\x05input\"\xea\x02\n" + + "\x12StreamInputRequest\x12>\n" + + "\x05start\x18\x01 \x01(\v2&.process.StreamInputRequest.StartEventH\x00R\x05start\x12;\n" + + "\x04data\x18\x02 \x01(\v2%.process.StreamInputRequest.DataEventH\x00R\x04data\x12E\n" + + "\tkeepalive\x18\x03 \x01(\v2%.process.StreamInputRequest.KeepAliveH\x00R\tkeepalive\x1a@\n" + + "\n" + + "StartEvent\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x1a8\n" + + "\tDataEvent\x12+\n" + + "\x05input\x18\x02 \x01(\v2\x15.process.ProcessInputR\x05input\x1a\v\n" + + "\tKeepAliveB\a\n" + + "\x05event\"\x15\n" + + "\x13StreamInputResponse\"p\n" + + "\x11SendSignalRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x12'\n" + + "\x06signal\x18\x02 \x01(\x0e2\x0f.process.SignalR\x06signal\"\x14\n" + + "\x12SendSignalResponse\"G\n" + + "\x11CloseStdinRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\"\x14\n" + + "\x12CloseStdinResponse\"D\n" + + "\x0eConnectRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\"E\n" + + "\x0fProcessSelector\x12\x12\n" + + "\x03pid\x18\x01 \x01(\rH\x00R\x03pid\x12\x12\n" + + "\x03tag\x18\x02 \x01(\tH\x00R\x03tagB\n" + + "\n" + + "\bselector*H\n" + + "\x06Signal\x12\x16\n" + + "\x12SIGNAL_UNSPECIFIED\x10\x00\x12\x12\n" + + "\x0eSIGNAL_SIGTERM\x10\x0f\x12\x12\n" + + "\x0eSIGNAL_SIGKILL\x10\t2\x91\x04\n" + + "\aProcess\x123\n" + + "\x04List\x12\x14.process.ListRequest\x1a\x15.process.ListResponse\x12>\n" + + "\aConnect\x12\x17.process.ConnectRequest\x1a\x18.process.ConnectResponse0\x01\x128\n" + + "\x05Start\x12\x15.process.StartRequest\x1a\x16.process.StartResponse0\x01\x129\n" + + "\x06Update\x12\x16.process.UpdateRequest\x1a\x17.process.UpdateResponse\x12J\n" + + "\vStreamInput\x12\x1b.process.StreamInputRequest\x1a\x1c.process.StreamInputResponse(\x01\x12B\n" + + "\tSendInput\x12\x19.process.SendInputRequest\x1a\x1a.process.SendInputResponse\x12E\n" + + "\n" + + "SendSignal\x12\x1a.process.SendSignalRequest\x1a\x1b.process.SendSignalResponse\x12E\n" + + "\n" + + "CloseStdin\x12\x1a.process.CloseStdinRequest\x1a\x1b.process.CloseStdinResponseB\x90\x01\n" + + "\vcom.processB\fProcessProtoP\x01Z7git.omukk.dev/wrenn/sandbox/envd/internal/services/spec\xa2\x02\x03PXX\xaa\x02\aProcess\xca\x02\aProcess\xe2\x02\x13Process\\GPBMetadata\xea\x02\aProcessb\x06proto3" + +var ( + file_process_proto_rawDescOnce sync.Once + file_process_proto_rawDescData []byte +) + +func file_process_proto_rawDescGZIP() []byte { + file_process_proto_rawDescOnce.Do(func() { + file_process_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_process_proto_rawDesc), len(file_process_proto_rawDesc))) + }) + return file_process_proto_rawDescData +} + +var file_process_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_process_proto_msgTypes = make([]protoimpl.MessageInfo, 31) +var file_process_proto_goTypes = []any{ + (Signal)(0), // 0: process.Signal + (*PTY)(nil), // 1: process.PTY + (*ProcessConfig)(nil), // 2: process.ProcessConfig + (*ListRequest)(nil), // 3: process.ListRequest + (*ProcessInfo)(nil), // 4: process.ProcessInfo + (*ListResponse)(nil), // 5: process.ListResponse + (*StartRequest)(nil), // 6: process.StartRequest + (*UpdateRequest)(nil), // 7: process.UpdateRequest + (*UpdateResponse)(nil), // 8: process.UpdateResponse + (*ProcessEvent)(nil), // 9: process.ProcessEvent + (*StartResponse)(nil), // 10: process.StartResponse + (*ConnectResponse)(nil), // 11: process.ConnectResponse + (*SendInputRequest)(nil), // 12: process.SendInputRequest + (*SendInputResponse)(nil), // 13: process.SendInputResponse + (*ProcessInput)(nil), // 14: process.ProcessInput + (*StreamInputRequest)(nil), // 15: process.StreamInputRequest + (*StreamInputResponse)(nil), // 16: process.StreamInputResponse + (*SendSignalRequest)(nil), // 17: process.SendSignalRequest + (*SendSignalResponse)(nil), // 18: process.SendSignalResponse + (*CloseStdinRequest)(nil), // 19: process.CloseStdinRequest + (*CloseStdinResponse)(nil), // 20: process.CloseStdinResponse + (*ConnectRequest)(nil), // 21: process.ConnectRequest + (*ProcessSelector)(nil), // 22: process.ProcessSelector + (*PTY_Size)(nil), // 23: process.PTY.Size + nil, // 24: process.ProcessConfig.EnvsEntry + (*ProcessEvent_StartEvent)(nil), // 25: process.ProcessEvent.StartEvent + (*ProcessEvent_DataEvent)(nil), // 26: process.ProcessEvent.DataEvent + (*ProcessEvent_EndEvent)(nil), // 27: process.ProcessEvent.EndEvent + (*ProcessEvent_KeepAlive)(nil), // 28: process.ProcessEvent.KeepAlive + (*StreamInputRequest_StartEvent)(nil), // 29: process.StreamInputRequest.StartEvent + (*StreamInputRequest_DataEvent)(nil), // 30: process.StreamInputRequest.DataEvent + (*StreamInputRequest_KeepAlive)(nil), // 31: process.StreamInputRequest.KeepAlive +} +var file_process_proto_depIdxs = []int32{ + 23, // 0: process.PTY.size:type_name -> process.PTY.Size + 24, // 1: process.ProcessConfig.envs:type_name -> process.ProcessConfig.EnvsEntry + 2, // 2: process.ProcessInfo.config:type_name -> process.ProcessConfig + 4, // 3: process.ListResponse.processes:type_name -> process.ProcessInfo + 2, // 4: process.StartRequest.process:type_name -> process.ProcessConfig + 1, // 5: process.StartRequest.pty:type_name -> process.PTY + 22, // 6: process.UpdateRequest.process:type_name -> process.ProcessSelector + 1, // 7: process.UpdateRequest.pty:type_name -> process.PTY + 25, // 8: process.ProcessEvent.start:type_name -> process.ProcessEvent.StartEvent + 26, // 9: process.ProcessEvent.data:type_name -> process.ProcessEvent.DataEvent + 27, // 10: process.ProcessEvent.end:type_name -> process.ProcessEvent.EndEvent + 28, // 11: process.ProcessEvent.keepalive:type_name -> process.ProcessEvent.KeepAlive + 9, // 12: process.StartResponse.event:type_name -> process.ProcessEvent + 9, // 13: process.ConnectResponse.event:type_name -> process.ProcessEvent + 22, // 14: process.SendInputRequest.process:type_name -> process.ProcessSelector + 14, // 15: process.SendInputRequest.input:type_name -> process.ProcessInput + 29, // 16: process.StreamInputRequest.start:type_name -> process.StreamInputRequest.StartEvent + 30, // 17: process.StreamInputRequest.data:type_name -> process.StreamInputRequest.DataEvent + 31, // 18: process.StreamInputRequest.keepalive:type_name -> process.StreamInputRequest.KeepAlive + 22, // 19: process.SendSignalRequest.process:type_name -> process.ProcessSelector + 0, // 20: process.SendSignalRequest.signal:type_name -> process.Signal + 22, // 21: process.CloseStdinRequest.process:type_name -> process.ProcessSelector + 22, // 22: process.ConnectRequest.process:type_name -> process.ProcessSelector + 22, // 23: process.StreamInputRequest.StartEvent.process:type_name -> process.ProcessSelector + 14, // 24: process.StreamInputRequest.DataEvent.input:type_name -> process.ProcessInput + 3, // 25: process.Process.List:input_type -> process.ListRequest + 21, // 26: process.Process.Connect:input_type -> process.ConnectRequest + 6, // 27: process.Process.Start:input_type -> process.StartRequest + 7, // 28: process.Process.Update:input_type -> process.UpdateRequest + 15, // 29: process.Process.StreamInput:input_type -> process.StreamInputRequest + 12, // 30: process.Process.SendInput:input_type -> process.SendInputRequest + 17, // 31: process.Process.SendSignal:input_type -> process.SendSignalRequest + 19, // 32: process.Process.CloseStdin:input_type -> process.CloseStdinRequest + 5, // 33: process.Process.List:output_type -> process.ListResponse + 11, // 34: process.Process.Connect:output_type -> process.ConnectResponse + 10, // 35: process.Process.Start:output_type -> process.StartResponse + 8, // 36: process.Process.Update:output_type -> process.UpdateResponse + 16, // 37: process.Process.StreamInput:output_type -> process.StreamInputResponse + 13, // 38: process.Process.SendInput:output_type -> process.SendInputResponse + 18, // 39: process.Process.SendSignal:output_type -> process.SendSignalResponse + 20, // 40: process.Process.CloseStdin:output_type -> process.CloseStdinResponse + 33, // [33:41] is the sub-list for method output_type + 25, // [25:33] is the sub-list for method input_type + 25, // [25:25] is the sub-list for extension type_name + 25, // [25:25] is the sub-list for extension extendee + 0, // [0:25] is the sub-list for field type_name +} + +func init() { file_process_proto_init() } +func file_process_proto_init() { + if File_process_proto != nil { + return + } + file_process_proto_msgTypes[1].OneofWrappers = []any{} + file_process_proto_msgTypes[3].OneofWrappers = []any{} + file_process_proto_msgTypes[5].OneofWrappers = []any{} + file_process_proto_msgTypes[6].OneofWrappers = []any{} + file_process_proto_msgTypes[8].OneofWrappers = []any{ + (*ProcessEvent_Start)(nil), + (*ProcessEvent_Data)(nil), + (*ProcessEvent_End)(nil), + (*ProcessEvent_Keepalive)(nil), + } + file_process_proto_msgTypes[13].OneofWrappers = []any{ + (*ProcessInput_Stdin)(nil), + (*ProcessInput_Pty)(nil), + } + file_process_proto_msgTypes[14].OneofWrappers = []any{ + (*StreamInputRequest_Start)(nil), + (*StreamInputRequest_Data)(nil), + (*StreamInputRequest_Keepalive)(nil), + } + file_process_proto_msgTypes[21].OneofWrappers = []any{ + (*ProcessSelector_Pid)(nil), + (*ProcessSelector_Tag)(nil), + } + file_process_proto_msgTypes[25].OneofWrappers = []any{ + (*ProcessEvent_DataEvent_Stdout)(nil), + (*ProcessEvent_DataEvent_Stderr)(nil), + (*ProcessEvent_DataEvent_Pty)(nil), + } + file_process_proto_msgTypes[26].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_process_proto_rawDesc), len(file_process_proto_rawDesc)), + NumEnums: 1, + NumMessages: 31, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_process_proto_goTypes, + DependencyIndexes: file_process_proto_depIdxs, + EnumInfos: file_process_proto_enumTypes, + MessageInfos: file_process_proto_msgTypes, + }.Build() + File_process_proto = out.File + file_process_proto_goTypes = nil + file_process_proto_depIdxs = nil +} diff --git a/envd/internal/services/spec/process/process.pb.go b/envd/internal/services/spec/process/process.pb.go new file mode 100644 index 0000000..c7c0ee0 --- /dev/null +++ b/envd/internal/services/spec/process/process.pb.go @@ -0,0 +1,1970 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: process/process.proto + +package process + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Signal int32 + +const ( + Signal_SIGNAL_UNSPECIFIED Signal = 0 + Signal_SIGNAL_SIGTERM Signal = 15 + Signal_SIGNAL_SIGKILL Signal = 9 +) + +// Enum value maps for Signal. +var ( + Signal_name = map[int32]string{ + 0: "SIGNAL_UNSPECIFIED", + 15: "SIGNAL_SIGTERM", + 9: "SIGNAL_SIGKILL", + } + Signal_value = map[string]int32{ + "SIGNAL_UNSPECIFIED": 0, + "SIGNAL_SIGTERM": 15, + "SIGNAL_SIGKILL": 9, + } +) + +func (x Signal) Enum() *Signal { + p := new(Signal) + *p = x + return p +} + +func (x Signal) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Signal) Descriptor() protoreflect.EnumDescriptor { + return file_process_process_proto_enumTypes[0].Descriptor() +} + +func (Signal) Type() protoreflect.EnumType { + return &file_process_process_proto_enumTypes[0] +} + +func (x Signal) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Signal.Descriptor instead. +func (Signal) EnumDescriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{0} +} + +type PTY struct { + state protoimpl.MessageState `protogen:"open.v1"` + Size *PTY_Size `protobuf:"bytes,1,opt,name=size,proto3" json:"size,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PTY) Reset() { + *x = PTY{} + mi := &file_process_process_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PTY) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PTY) ProtoMessage() {} + +func (x *PTY) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PTY.ProtoReflect.Descriptor instead. +func (*PTY) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{0} +} + +func (x *PTY) GetSize() *PTY_Size { + if x != nil { + return x.Size + } + return nil +} + +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"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessConfig) Reset() { + *x = ProcessConfig{} + mi := &file_process_process_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessConfig) ProtoMessage() {} + +func (x *ProcessConfig) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessConfig.ProtoReflect.Descriptor instead. +func (*ProcessConfig) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{1} +} + +func (x *ProcessConfig) GetCmd() string { + if x != nil { + return x.Cmd + } + return "" +} + +func (x *ProcessConfig) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + +func (x *ProcessConfig) GetEnvs() map[string]string { + if x != nil { + return x.Envs + } + return nil +} + +func (x *ProcessConfig) GetCwd() string { + if x != nil && x.Cwd != nil { + return *x.Cwd + } + return "" +} + +type ListRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListRequest) Reset() { + *x = ListRequest{} + mi := &file_process_process_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRequest) ProtoMessage() {} + +func (x *ListRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListRequest.ProtoReflect.Descriptor instead. +func (*ListRequest) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{2} +} + +type ProcessInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Config *ProcessConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` + Pid uint32 `protobuf:"varint,2,opt,name=pid,proto3" json:"pid,omitempty"` + Tag *string `protobuf:"bytes,3,opt,name=tag,proto3,oneof" json:"tag,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessInfo) Reset() { + *x = ProcessInfo{} + mi := &file_process_process_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessInfo) ProtoMessage() {} + +func (x *ProcessInfo) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessInfo.ProtoReflect.Descriptor instead. +func (*ProcessInfo) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{3} +} + +func (x *ProcessInfo) GetConfig() *ProcessConfig { + if x != nil { + return x.Config + } + return nil +} + +func (x *ProcessInfo) GetPid() uint32 { + if x != nil { + return x.Pid + } + return 0 +} + +func (x *ProcessInfo) GetTag() string { + if x != nil && x.Tag != nil { + return *x.Tag + } + return "" +} + +type ListResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Processes []*ProcessInfo `protobuf:"bytes,1,rep,name=processes,proto3" json:"processes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListResponse) Reset() { + *x = ListResponse{} + mi := &file_process_process_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListResponse) ProtoMessage() {} + +func (x *ListResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListResponse.ProtoReflect.Descriptor instead. +func (*ListResponse) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{4} +} + +func (x *ListResponse) GetProcesses() []*ProcessInfo { + if x != nil { + return x.Processes + } + return nil +} + +type StartRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessConfig `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Pty *PTY `protobuf:"bytes,2,opt,name=pty,proto3,oneof" json:"pty,omitempty"` + Tag *string `protobuf:"bytes,3,opt,name=tag,proto3,oneof" json:"tag,omitempty"` + // This is optional for backwards compatibility. + // We default to true. New SDK versions will set this to false by default. + Stdin *bool `protobuf:"varint,4,opt,name=stdin,proto3,oneof" json:"stdin,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartRequest) Reset() { + *x = StartRequest{} + mi := &file_process_process_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartRequest) ProtoMessage() {} + +func (x *StartRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StartRequest.ProtoReflect.Descriptor instead. +func (*StartRequest) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{5} +} + +func (x *StartRequest) GetProcess() *ProcessConfig { + if x != nil { + return x.Process + } + return nil +} + +func (x *StartRequest) GetPty() *PTY { + if x != nil { + return x.Pty + } + return nil +} + +func (x *StartRequest) GetTag() string { + if x != nil && x.Tag != nil { + return *x.Tag + } + return "" +} + +func (x *StartRequest) GetStdin() bool { + if x != nil && x.Stdin != nil { + return *x.Stdin + } + return false +} + +type UpdateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Pty *PTY `protobuf:"bytes,2,opt,name=pty,proto3,oneof" json:"pty,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateRequest) Reset() { + *x = UpdateRequest{} + mi := &file_process_process_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateRequest) ProtoMessage() {} + +func (x *UpdateRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateRequest.ProtoReflect.Descriptor instead. +func (*UpdateRequest) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{6} +} + +func (x *UpdateRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +func (x *UpdateRequest) GetPty() *PTY { + if x != nil { + return x.Pty + } + return nil +} + +type UpdateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateResponse) Reset() { + *x = UpdateResponse{} + mi := &file_process_process_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateResponse) ProtoMessage() {} + +func (x *UpdateResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateResponse.ProtoReflect.Descriptor instead. +func (*UpdateResponse) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{7} +} + +type ProcessEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *ProcessEvent_Start + // *ProcessEvent_Data + // *ProcessEvent_End + // *ProcessEvent_Keepalive + Event isProcessEvent_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent) Reset() { + *x = ProcessEvent{} + mi := &file_process_process_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent) ProtoMessage() {} + +func (x *ProcessEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{8} +} + +func (x *ProcessEvent) GetEvent() isProcessEvent_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *ProcessEvent) GetStart() *ProcessEvent_StartEvent { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_Start); ok { + return x.Start + } + } + return nil +} + +func (x *ProcessEvent) GetData() *ProcessEvent_DataEvent { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_Data); ok { + return x.Data + } + } + return nil +} + +func (x *ProcessEvent) GetEnd() *ProcessEvent_EndEvent { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_End); ok { + return x.End + } + } + return nil +} + +func (x *ProcessEvent) GetKeepalive() *ProcessEvent_KeepAlive { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_Keepalive); ok { + return x.Keepalive + } + } + return nil +} + +type isProcessEvent_Event interface { + isProcessEvent_Event() +} + +type ProcessEvent_Start struct { + Start *ProcessEvent_StartEvent `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type ProcessEvent_Data struct { + Data *ProcessEvent_DataEvent `protobuf:"bytes,2,opt,name=data,proto3,oneof"` +} + +type ProcessEvent_End struct { + End *ProcessEvent_EndEvent `protobuf:"bytes,3,opt,name=end,proto3,oneof"` +} + +type ProcessEvent_Keepalive struct { + Keepalive *ProcessEvent_KeepAlive `protobuf:"bytes,4,opt,name=keepalive,proto3,oneof"` +} + +func (*ProcessEvent_Start) isProcessEvent_Event() {} + +func (*ProcessEvent_Data) isProcessEvent_Event() {} + +func (*ProcessEvent_End) isProcessEvent_Event() {} + +func (*ProcessEvent_Keepalive) isProcessEvent_Event() {} + +type StartResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Event *ProcessEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartResponse) Reset() { + *x = StartResponse{} + mi := &file_process_process_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartResponse) ProtoMessage() {} + +func (x *StartResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StartResponse.ProtoReflect.Descriptor instead. +func (*StartResponse) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{9} +} + +func (x *StartResponse) GetEvent() *ProcessEvent { + if x != nil { + return x.Event + } + return nil +} + +type ConnectResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Event *ProcessEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectResponse) Reset() { + *x = ConnectResponse{} + mi := &file_process_process_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectResponse) ProtoMessage() {} + +func (x *ConnectResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectResponse.ProtoReflect.Descriptor instead. +func (*ConnectResponse) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{10} +} + +func (x *ConnectResponse) GetEvent() *ProcessEvent { + if x != nil { + return x.Event + } + return nil +} + +type SendInputRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Input *ProcessInput `protobuf:"bytes,2,opt,name=input,proto3" json:"input,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendInputRequest) Reset() { + *x = SendInputRequest{} + mi := &file_process_process_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendInputRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendInputRequest) ProtoMessage() {} + +func (x *SendInputRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendInputRequest.ProtoReflect.Descriptor instead. +func (*SendInputRequest) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{11} +} + +func (x *SendInputRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +func (x *SendInputRequest) GetInput() *ProcessInput { + if x != nil { + return x.Input + } + return nil +} + +type SendInputResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendInputResponse) Reset() { + *x = SendInputResponse{} + mi := &file_process_process_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendInputResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendInputResponse) ProtoMessage() {} + +func (x *SendInputResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendInputResponse.ProtoReflect.Descriptor instead. +func (*SendInputResponse) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{12} +} + +type ProcessInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Input: + // + // *ProcessInput_Stdin + // *ProcessInput_Pty + Input isProcessInput_Input `protobuf_oneof:"input"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessInput) Reset() { + *x = ProcessInput{} + mi := &file_process_process_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessInput) ProtoMessage() {} + +func (x *ProcessInput) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessInput.ProtoReflect.Descriptor instead. +func (*ProcessInput) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{13} +} + +func (x *ProcessInput) GetInput() isProcessInput_Input { + if x != nil { + return x.Input + } + return nil +} + +func (x *ProcessInput) GetStdin() []byte { + if x != nil { + if x, ok := x.Input.(*ProcessInput_Stdin); ok { + return x.Stdin + } + } + return nil +} + +func (x *ProcessInput) GetPty() []byte { + if x != nil { + if x, ok := x.Input.(*ProcessInput_Pty); ok { + return x.Pty + } + } + return nil +} + +type isProcessInput_Input interface { + isProcessInput_Input() +} + +type ProcessInput_Stdin struct { + Stdin []byte `protobuf:"bytes,1,opt,name=stdin,proto3,oneof"` +} + +type ProcessInput_Pty struct { + Pty []byte `protobuf:"bytes,2,opt,name=pty,proto3,oneof"` +} + +func (*ProcessInput_Stdin) isProcessInput_Input() {} + +func (*ProcessInput_Pty) isProcessInput_Input() {} + +type StreamInputRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *StreamInputRequest_Start + // *StreamInputRequest_Data + // *StreamInputRequest_Keepalive + Event isStreamInputRequest_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest) Reset() { + *x = StreamInputRequest{} + mi := &file_process_process_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest) ProtoMessage() {} + +func (x *StreamInputRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest.ProtoReflect.Descriptor instead. +func (*StreamInputRequest) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{14} +} + +func (x *StreamInputRequest) GetEvent() isStreamInputRequest_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *StreamInputRequest) GetStart() *StreamInputRequest_StartEvent { + if x != nil { + if x, ok := x.Event.(*StreamInputRequest_Start); ok { + return x.Start + } + } + return nil +} + +func (x *StreamInputRequest) GetData() *StreamInputRequest_DataEvent { + if x != nil { + if x, ok := x.Event.(*StreamInputRequest_Data); ok { + return x.Data + } + } + return nil +} + +func (x *StreamInputRequest) GetKeepalive() *StreamInputRequest_KeepAlive { + if x != nil { + if x, ok := x.Event.(*StreamInputRequest_Keepalive); ok { + return x.Keepalive + } + } + return nil +} + +type isStreamInputRequest_Event interface { + isStreamInputRequest_Event() +} + +type StreamInputRequest_Start struct { + Start *StreamInputRequest_StartEvent `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type StreamInputRequest_Data struct { + Data *StreamInputRequest_DataEvent `protobuf:"bytes,2,opt,name=data,proto3,oneof"` +} + +type StreamInputRequest_Keepalive struct { + Keepalive *StreamInputRequest_KeepAlive `protobuf:"bytes,3,opt,name=keepalive,proto3,oneof"` +} + +func (*StreamInputRequest_Start) isStreamInputRequest_Event() {} + +func (*StreamInputRequest_Data) isStreamInputRequest_Event() {} + +func (*StreamInputRequest_Keepalive) isStreamInputRequest_Event() {} + +type StreamInputResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputResponse) Reset() { + *x = StreamInputResponse{} + mi := &file_process_process_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputResponse) ProtoMessage() {} + +func (x *StreamInputResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputResponse.ProtoReflect.Descriptor instead. +func (*StreamInputResponse) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{15} +} + +type SendSignalRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Signal Signal `protobuf:"varint,2,opt,name=signal,proto3,enum=process.Signal" json:"signal,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendSignalRequest) Reset() { + *x = SendSignalRequest{} + mi := &file_process_process_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendSignalRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendSignalRequest) ProtoMessage() {} + +func (x *SendSignalRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendSignalRequest.ProtoReflect.Descriptor instead. +func (*SendSignalRequest) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{16} +} + +func (x *SendSignalRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +func (x *SendSignalRequest) GetSignal() Signal { + if x != nil { + return x.Signal + } + return Signal_SIGNAL_UNSPECIFIED +} + +type SendSignalResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendSignalResponse) Reset() { + *x = SendSignalResponse{} + mi := &file_process_process_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendSignalResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendSignalResponse) ProtoMessage() {} + +func (x *SendSignalResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendSignalResponse.ProtoReflect.Descriptor instead. +func (*SendSignalResponse) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{17} +} + +type CloseStdinRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CloseStdinRequest) Reset() { + *x = CloseStdinRequest{} + mi := &file_process_process_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CloseStdinRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CloseStdinRequest) ProtoMessage() {} + +func (x *CloseStdinRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CloseStdinRequest.ProtoReflect.Descriptor instead. +func (*CloseStdinRequest) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{18} +} + +func (x *CloseStdinRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +type CloseStdinResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CloseStdinResponse) Reset() { + *x = CloseStdinResponse{} + mi := &file_process_process_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CloseStdinResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CloseStdinResponse) ProtoMessage() {} + +func (x *CloseStdinResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CloseStdinResponse.ProtoReflect.Descriptor instead. +func (*CloseStdinResponse) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{19} +} + +type ConnectRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectRequest) Reset() { + *x = ConnectRequest{} + mi := &file_process_process_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectRequest) ProtoMessage() {} + +func (x *ConnectRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectRequest.ProtoReflect.Descriptor instead. +func (*ConnectRequest) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{20} +} + +func (x *ConnectRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +type ProcessSelector struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Selector: + // + // *ProcessSelector_Pid + // *ProcessSelector_Tag + Selector isProcessSelector_Selector `protobuf_oneof:"selector"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessSelector) Reset() { + *x = ProcessSelector{} + mi := &file_process_process_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessSelector) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessSelector) ProtoMessage() {} + +func (x *ProcessSelector) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessSelector.ProtoReflect.Descriptor instead. +func (*ProcessSelector) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{21} +} + +func (x *ProcessSelector) GetSelector() isProcessSelector_Selector { + if x != nil { + return x.Selector + } + return nil +} + +func (x *ProcessSelector) GetPid() uint32 { + if x != nil { + if x, ok := x.Selector.(*ProcessSelector_Pid); ok { + return x.Pid + } + } + return 0 +} + +func (x *ProcessSelector) GetTag() string { + if x != nil { + if x, ok := x.Selector.(*ProcessSelector_Tag); ok { + return x.Tag + } + } + return "" +} + +type isProcessSelector_Selector interface { + isProcessSelector_Selector() +} + +type ProcessSelector_Pid struct { + Pid uint32 `protobuf:"varint,1,opt,name=pid,proto3,oneof"` +} + +type ProcessSelector_Tag struct { + Tag string `protobuf:"bytes,2,opt,name=tag,proto3,oneof"` +} + +func (*ProcessSelector_Pid) isProcessSelector_Selector() {} + +func (*ProcessSelector_Tag) isProcessSelector_Selector() {} + +type PTY_Size struct { + state protoimpl.MessageState `protogen:"open.v1"` + Cols uint32 `protobuf:"varint,1,opt,name=cols,proto3" json:"cols,omitempty"` + Rows uint32 `protobuf:"varint,2,opt,name=rows,proto3" json:"rows,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PTY_Size) Reset() { + *x = PTY_Size{} + mi := &file_process_process_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PTY_Size) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PTY_Size) ProtoMessage() {} + +func (x *PTY_Size) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PTY_Size.ProtoReflect.Descriptor instead. +func (*PTY_Size) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{0, 0} +} + +func (x *PTY_Size) GetCols() uint32 { + if x != nil { + return x.Cols + } + return 0 +} + +func (x *PTY_Size) GetRows() uint32 { + if x != nil { + return x.Rows + } + return 0 +} + +type ProcessEvent_StartEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Pid uint32 `protobuf:"varint,1,opt,name=pid,proto3" json:"pid,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_StartEvent) Reset() { + *x = ProcessEvent_StartEvent{} + mi := &file_process_process_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_StartEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_StartEvent) ProtoMessage() {} + +func (x *ProcessEvent_StartEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_StartEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent_StartEvent) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{8, 0} +} + +func (x *ProcessEvent_StartEvent) GetPid() uint32 { + if x != nil { + return x.Pid + } + return 0 +} + +type ProcessEvent_DataEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Output: + // + // *ProcessEvent_DataEvent_Stdout + // *ProcessEvent_DataEvent_Stderr + // *ProcessEvent_DataEvent_Pty + Output isProcessEvent_DataEvent_Output `protobuf_oneof:"output"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_DataEvent) Reset() { + *x = ProcessEvent_DataEvent{} + mi := &file_process_process_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_DataEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_DataEvent) ProtoMessage() {} + +func (x *ProcessEvent_DataEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_DataEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent_DataEvent) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{8, 1} +} + +func (x *ProcessEvent_DataEvent) GetOutput() isProcessEvent_DataEvent_Output { + if x != nil { + return x.Output + } + return nil +} + +func (x *ProcessEvent_DataEvent) GetStdout() []byte { + if x != nil { + if x, ok := x.Output.(*ProcessEvent_DataEvent_Stdout); ok { + return x.Stdout + } + } + return nil +} + +func (x *ProcessEvent_DataEvent) GetStderr() []byte { + if x != nil { + if x, ok := x.Output.(*ProcessEvent_DataEvent_Stderr); ok { + return x.Stderr + } + } + return nil +} + +func (x *ProcessEvent_DataEvent) GetPty() []byte { + if x != nil { + if x, ok := x.Output.(*ProcessEvent_DataEvent_Pty); ok { + return x.Pty + } + } + return nil +} + +type isProcessEvent_DataEvent_Output interface { + isProcessEvent_DataEvent_Output() +} + +type ProcessEvent_DataEvent_Stdout struct { + Stdout []byte `protobuf:"bytes,1,opt,name=stdout,proto3,oneof"` +} + +type ProcessEvent_DataEvent_Stderr struct { + Stderr []byte `protobuf:"bytes,2,opt,name=stderr,proto3,oneof"` +} + +type ProcessEvent_DataEvent_Pty struct { + Pty []byte `protobuf:"bytes,3,opt,name=pty,proto3,oneof"` +} + +func (*ProcessEvent_DataEvent_Stdout) isProcessEvent_DataEvent_Output() {} + +func (*ProcessEvent_DataEvent_Stderr) isProcessEvent_DataEvent_Output() {} + +func (*ProcessEvent_DataEvent_Pty) isProcessEvent_DataEvent_Output() {} + +type ProcessEvent_EndEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + ExitCode int32 `protobuf:"zigzag32,1,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + Exited bool `protobuf:"varint,2,opt,name=exited,proto3" json:"exited,omitempty"` + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + Error *string `protobuf:"bytes,4,opt,name=error,proto3,oneof" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_EndEvent) Reset() { + *x = ProcessEvent_EndEvent{} + mi := &file_process_process_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_EndEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_EndEvent) ProtoMessage() {} + +func (x *ProcessEvent_EndEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_EndEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent_EndEvent) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{8, 2} +} + +func (x *ProcessEvent_EndEvent) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +func (x *ProcessEvent_EndEvent) GetExited() bool { + if x != nil { + return x.Exited + } + return false +} + +func (x *ProcessEvent_EndEvent) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *ProcessEvent_EndEvent) GetError() string { + if x != nil && x.Error != nil { + return *x.Error + } + return "" +} + +type ProcessEvent_KeepAlive struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_KeepAlive) Reset() { + *x = ProcessEvent_KeepAlive{} + mi := &file_process_process_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_KeepAlive) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_KeepAlive) ProtoMessage() {} + +func (x *ProcessEvent_KeepAlive) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_KeepAlive.ProtoReflect.Descriptor instead. +func (*ProcessEvent_KeepAlive) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{8, 3} +} + +type StreamInputRequest_StartEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest_StartEvent) Reset() { + *x = StreamInputRequest_StartEvent{} + mi := &file_process_process_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest_StartEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest_StartEvent) ProtoMessage() {} + +func (x *StreamInputRequest_StartEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest_StartEvent.ProtoReflect.Descriptor instead. +func (*StreamInputRequest_StartEvent) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{14, 0} +} + +func (x *StreamInputRequest_StartEvent) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +type StreamInputRequest_DataEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Input *ProcessInput `protobuf:"bytes,2,opt,name=input,proto3" json:"input,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest_DataEvent) Reset() { + *x = StreamInputRequest_DataEvent{} + mi := &file_process_process_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest_DataEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest_DataEvent) ProtoMessage() {} + +func (x *StreamInputRequest_DataEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest_DataEvent.ProtoReflect.Descriptor instead. +func (*StreamInputRequest_DataEvent) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{14, 1} +} + +func (x *StreamInputRequest_DataEvent) GetInput() *ProcessInput { + if x != nil { + return x.Input + } + return nil +} + +type StreamInputRequest_KeepAlive struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest_KeepAlive) Reset() { + *x = StreamInputRequest_KeepAlive{} + mi := &file_process_process_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest_KeepAlive) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest_KeepAlive) ProtoMessage() {} + +func (x *StreamInputRequest_KeepAlive) ProtoReflect() protoreflect.Message { + mi := &file_process_process_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest_KeepAlive.ProtoReflect.Descriptor instead. +func (*StreamInputRequest_KeepAlive) Descriptor() ([]byte, []int) { + return file_process_process_proto_rawDescGZIP(), []int{14, 2} +} + +var File_process_process_proto protoreflect.FileDescriptor + +const file_process_process_proto_rawDesc = "" + + "\n" + + "\x15process/process.proto\x12\aprocess\"\\\n" + + "\x03PTY\x12%\n" + + "\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" + + "\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" + + "\tEnvsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x06\n" + + "\x04_cwd\"\r\n" + + "\vListRequest\"n\n" + + "\vProcessInfo\x12.\n" + + "\x06config\x18\x01 \x01(\v2\x16.process.ProcessConfigR\x06config\x12\x10\n" + + "\x03pid\x18\x02 \x01(\rR\x03pid\x12\x15\n" + + "\x03tag\x18\x03 \x01(\tH\x00R\x03tag\x88\x01\x01B\x06\n" + + "\x04_tag\"B\n" + + "\fListResponse\x122\n" + + "\tprocesses\x18\x01 \x03(\v2\x14.process.ProcessInfoR\tprocesses\"\xb1\x01\n" + + "\fStartRequest\x120\n" + + "\aprocess\x18\x01 \x01(\v2\x16.process.ProcessConfigR\aprocess\x12#\n" + + "\x03pty\x18\x02 \x01(\v2\f.process.PTYH\x00R\x03pty\x88\x01\x01\x12\x15\n" + + "\x03tag\x18\x03 \x01(\tH\x01R\x03tag\x88\x01\x01\x12\x19\n" + + "\x05stdin\x18\x04 \x01(\bH\x02R\x05stdin\x88\x01\x01B\x06\n" + + "\x04_ptyB\x06\n" + + "\x04_tagB\b\n" + + "\x06_stdin\"p\n" + + "\rUpdateRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x12#\n" + + "\x03pty\x18\x02 \x01(\v2\f.process.PTYH\x00R\x03pty\x88\x01\x01B\x06\n" + + "\x04_pty\"\x10\n" + + "\x0eUpdateResponse\"\x87\x04\n" + + "\fProcessEvent\x128\n" + + "\x05start\x18\x01 \x01(\v2 .process.ProcessEvent.StartEventH\x00R\x05start\x125\n" + + "\x04data\x18\x02 \x01(\v2\x1f.process.ProcessEvent.DataEventH\x00R\x04data\x122\n" + + "\x03end\x18\x03 \x01(\v2\x1e.process.ProcessEvent.EndEventH\x00R\x03end\x12?\n" + + "\tkeepalive\x18\x04 \x01(\v2\x1f.process.ProcessEvent.KeepAliveH\x00R\tkeepalive\x1a\x1e\n" + + "\n" + + "StartEvent\x12\x10\n" + + "\x03pid\x18\x01 \x01(\rR\x03pid\x1a]\n" + + "\tDataEvent\x12\x18\n" + + "\x06stdout\x18\x01 \x01(\fH\x00R\x06stdout\x12\x18\n" + + "\x06stderr\x18\x02 \x01(\fH\x00R\x06stderr\x12\x12\n" + + "\x03pty\x18\x03 \x01(\fH\x00R\x03ptyB\b\n" + + "\x06output\x1a|\n" + + "\bEndEvent\x12\x1b\n" + + "\texit_code\x18\x01 \x01(\x11R\bexitCode\x12\x16\n" + + "\x06exited\x18\x02 \x01(\bR\x06exited\x12\x16\n" + + "\x06status\x18\x03 \x01(\tR\x06status\x12\x19\n" + + "\x05error\x18\x04 \x01(\tH\x00R\x05error\x88\x01\x01B\b\n" + + "\x06_error\x1a\v\n" + + "\tKeepAliveB\a\n" + + "\x05event\"<\n" + + "\rStartResponse\x12+\n" + + "\x05event\x18\x01 \x01(\v2\x15.process.ProcessEventR\x05event\">\n" + + "\x0fConnectResponse\x12+\n" + + "\x05event\x18\x01 \x01(\v2\x15.process.ProcessEventR\x05event\"s\n" + + "\x10SendInputRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x12+\n" + + "\x05input\x18\x02 \x01(\v2\x15.process.ProcessInputR\x05input\"\x13\n" + + "\x11SendInputResponse\"C\n" + + "\fProcessInput\x12\x16\n" + + "\x05stdin\x18\x01 \x01(\fH\x00R\x05stdin\x12\x12\n" + + "\x03pty\x18\x02 \x01(\fH\x00R\x03ptyB\a\n" + + "\x05input\"\xea\x02\n" + + "\x12StreamInputRequest\x12>\n" + + "\x05start\x18\x01 \x01(\v2&.process.StreamInputRequest.StartEventH\x00R\x05start\x12;\n" + + "\x04data\x18\x02 \x01(\v2%.process.StreamInputRequest.DataEventH\x00R\x04data\x12E\n" + + "\tkeepalive\x18\x03 \x01(\v2%.process.StreamInputRequest.KeepAliveH\x00R\tkeepalive\x1a@\n" + + "\n" + + "StartEvent\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x1a8\n" + + "\tDataEvent\x12+\n" + + "\x05input\x18\x02 \x01(\v2\x15.process.ProcessInputR\x05input\x1a\v\n" + + "\tKeepAliveB\a\n" + + "\x05event\"\x15\n" + + "\x13StreamInputResponse\"p\n" + + "\x11SendSignalRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x12'\n" + + "\x06signal\x18\x02 \x01(\x0e2\x0f.process.SignalR\x06signal\"\x14\n" + + "\x12SendSignalResponse\"G\n" + + "\x11CloseStdinRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\"\x14\n" + + "\x12CloseStdinResponse\"D\n" + + "\x0eConnectRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\"E\n" + + "\x0fProcessSelector\x12\x12\n" + + "\x03pid\x18\x01 \x01(\rH\x00R\x03pid\x12\x12\n" + + "\x03tag\x18\x02 \x01(\tH\x00R\x03tagB\n" + + "\n" + + "\bselector*H\n" + + "\x06Signal\x12\x16\n" + + "\x12SIGNAL_UNSPECIFIED\x10\x00\x12\x12\n" + + "\x0eSIGNAL_SIGTERM\x10\x0f\x12\x12\n" + + "\x0eSIGNAL_SIGKILL\x10\t2\x91\x04\n" + + "\aProcess\x123\n" + + "\x04List\x12\x14.process.ListRequest\x1a\x15.process.ListResponse\x12>\n" + + "\aConnect\x12\x17.process.ConnectRequest\x1a\x18.process.ConnectResponse0\x01\x128\n" + + "\x05Start\x12\x15.process.StartRequest\x1a\x16.process.StartResponse0\x01\x129\n" + + "\x06Update\x12\x16.process.UpdateRequest\x1a\x17.process.UpdateResponse\x12J\n" + + "\vStreamInput\x12\x1b.process.StreamInputRequest\x1a\x1c.process.StreamInputResponse(\x01\x12B\n" + + "\tSendInput\x12\x19.process.SendInputRequest\x1a\x1a.process.SendInputResponse\x12E\n" + + "\n" + + "SendSignal\x12\x1a.process.SendSignalRequest\x1a\x1b.process.SendSignalResponse\x12E\n" + + "\n" + + "CloseStdin\x12\x1a.process.CloseStdinRequest\x1a\x1b.process.CloseStdinResponseB\x98\x01\n" + + "\vcom.processB\fProcessProtoP\x01Z?git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process\xa2\x02\x03PXX\xaa\x02\aProcess\xca\x02\aProcess\xe2\x02\x13Process\\GPBMetadata\xea\x02\aProcessb\x06proto3" + +var ( + file_process_process_proto_rawDescOnce sync.Once + file_process_process_proto_rawDescData []byte +) + +func file_process_process_proto_rawDescGZIP() []byte { + file_process_process_proto_rawDescOnce.Do(func() { + file_process_process_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_process_process_proto_rawDesc), len(file_process_process_proto_rawDesc))) + }) + return file_process_process_proto_rawDescData +} + +var file_process_process_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_process_process_proto_msgTypes = make([]protoimpl.MessageInfo, 31) +var file_process_process_proto_goTypes = []any{ + (Signal)(0), // 0: process.Signal + (*PTY)(nil), // 1: process.PTY + (*ProcessConfig)(nil), // 2: process.ProcessConfig + (*ListRequest)(nil), // 3: process.ListRequest + (*ProcessInfo)(nil), // 4: process.ProcessInfo + (*ListResponse)(nil), // 5: process.ListResponse + (*StartRequest)(nil), // 6: process.StartRequest + (*UpdateRequest)(nil), // 7: process.UpdateRequest + (*UpdateResponse)(nil), // 8: process.UpdateResponse + (*ProcessEvent)(nil), // 9: process.ProcessEvent + (*StartResponse)(nil), // 10: process.StartResponse + (*ConnectResponse)(nil), // 11: process.ConnectResponse + (*SendInputRequest)(nil), // 12: process.SendInputRequest + (*SendInputResponse)(nil), // 13: process.SendInputResponse + (*ProcessInput)(nil), // 14: process.ProcessInput + (*StreamInputRequest)(nil), // 15: process.StreamInputRequest + (*StreamInputResponse)(nil), // 16: process.StreamInputResponse + (*SendSignalRequest)(nil), // 17: process.SendSignalRequest + (*SendSignalResponse)(nil), // 18: process.SendSignalResponse + (*CloseStdinRequest)(nil), // 19: process.CloseStdinRequest + (*CloseStdinResponse)(nil), // 20: process.CloseStdinResponse + (*ConnectRequest)(nil), // 21: process.ConnectRequest + (*ProcessSelector)(nil), // 22: process.ProcessSelector + (*PTY_Size)(nil), // 23: process.PTY.Size + nil, // 24: process.ProcessConfig.EnvsEntry + (*ProcessEvent_StartEvent)(nil), // 25: process.ProcessEvent.StartEvent + (*ProcessEvent_DataEvent)(nil), // 26: process.ProcessEvent.DataEvent + (*ProcessEvent_EndEvent)(nil), // 27: process.ProcessEvent.EndEvent + (*ProcessEvent_KeepAlive)(nil), // 28: process.ProcessEvent.KeepAlive + (*StreamInputRequest_StartEvent)(nil), // 29: process.StreamInputRequest.StartEvent + (*StreamInputRequest_DataEvent)(nil), // 30: process.StreamInputRequest.DataEvent + (*StreamInputRequest_KeepAlive)(nil), // 31: process.StreamInputRequest.KeepAlive +} +var file_process_process_proto_depIdxs = []int32{ + 23, // 0: process.PTY.size:type_name -> process.PTY.Size + 24, // 1: process.ProcessConfig.envs:type_name -> process.ProcessConfig.EnvsEntry + 2, // 2: process.ProcessInfo.config:type_name -> process.ProcessConfig + 4, // 3: process.ListResponse.processes:type_name -> process.ProcessInfo + 2, // 4: process.StartRequest.process:type_name -> process.ProcessConfig + 1, // 5: process.StartRequest.pty:type_name -> process.PTY + 22, // 6: process.UpdateRequest.process:type_name -> process.ProcessSelector + 1, // 7: process.UpdateRequest.pty:type_name -> process.PTY + 25, // 8: process.ProcessEvent.start:type_name -> process.ProcessEvent.StartEvent + 26, // 9: process.ProcessEvent.data:type_name -> process.ProcessEvent.DataEvent + 27, // 10: process.ProcessEvent.end:type_name -> process.ProcessEvent.EndEvent + 28, // 11: process.ProcessEvent.keepalive:type_name -> process.ProcessEvent.KeepAlive + 9, // 12: process.StartResponse.event:type_name -> process.ProcessEvent + 9, // 13: process.ConnectResponse.event:type_name -> process.ProcessEvent + 22, // 14: process.SendInputRequest.process:type_name -> process.ProcessSelector + 14, // 15: process.SendInputRequest.input:type_name -> process.ProcessInput + 29, // 16: process.StreamInputRequest.start:type_name -> process.StreamInputRequest.StartEvent + 30, // 17: process.StreamInputRequest.data:type_name -> process.StreamInputRequest.DataEvent + 31, // 18: process.StreamInputRequest.keepalive:type_name -> process.StreamInputRequest.KeepAlive + 22, // 19: process.SendSignalRequest.process:type_name -> process.ProcessSelector + 0, // 20: process.SendSignalRequest.signal:type_name -> process.Signal + 22, // 21: process.CloseStdinRequest.process:type_name -> process.ProcessSelector + 22, // 22: process.ConnectRequest.process:type_name -> process.ProcessSelector + 22, // 23: process.StreamInputRequest.StartEvent.process:type_name -> process.ProcessSelector + 14, // 24: process.StreamInputRequest.DataEvent.input:type_name -> process.ProcessInput + 3, // 25: process.Process.List:input_type -> process.ListRequest + 21, // 26: process.Process.Connect:input_type -> process.ConnectRequest + 6, // 27: process.Process.Start:input_type -> process.StartRequest + 7, // 28: process.Process.Update:input_type -> process.UpdateRequest + 15, // 29: process.Process.StreamInput:input_type -> process.StreamInputRequest + 12, // 30: process.Process.SendInput:input_type -> process.SendInputRequest + 17, // 31: process.Process.SendSignal:input_type -> process.SendSignalRequest + 19, // 32: process.Process.CloseStdin:input_type -> process.CloseStdinRequest + 5, // 33: process.Process.List:output_type -> process.ListResponse + 11, // 34: process.Process.Connect:output_type -> process.ConnectResponse + 10, // 35: process.Process.Start:output_type -> process.StartResponse + 8, // 36: process.Process.Update:output_type -> process.UpdateResponse + 16, // 37: process.Process.StreamInput:output_type -> process.StreamInputResponse + 13, // 38: process.Process.SendInput:output_type -> process.SendInputResponse + 18, // 39: process.Process.SendSignal:output_type -> process.SendSignalResponse + 20, // 40: process.Process.CloseStdin:output_type -> process.CloseStdinResponse + 33, // [33:41] is the sub-list for method output_type + 25, // [25:33] is the sub-list for method input_type + 25, // [25:25] is the sub-list for extension type_name + 25, // [25:25] is the sub-list for extension extendee + 0, // [0:25] is the sub-list for field type_name +} + +func init() { file_process_process_proto_init() } +func file_process_process_proto_init() { + if File_process_process_proto != nil { + return + } + file_process_process_proto_msgTypes[1].OneofWrappers = []any{} + file_process_process_proto_msgTypes[3].OneofWrappers = []any{} + file_process_process_proto_msgTypes[5].OneofWrappers = []any{} + file_process_process_proto_msgTypes[6].OneofWrappers = []any{} + file_process_process_proto_msgTypes[8].OneofWrappers = []any{ + (*ProcessEvent_Start)(nil), + (*ProcessEvent_Data)(nil), + (*ProcessEvent_End)(nil), + (*ProcessEvent_Keepalive)(nil), + } + file_process_process_proto_msgTypes[13].OneofWrappers = []any{ + (*ProcessInput_Stdin)(nil), + (*ProcessInput_Pty)(nil), + } + file_process_process_proto_msgTypes[14].OneofWrappers = []any{ + (*StreamInputRequest_Start)(nil), + (*StreamInputRequest_Data)(nil), + (*StreamInputRequest_Keepalive)(nil), + } + file_process_process_proto_msgTypes[21].OneofWrappers = []any{ + (*ProcessSelector_Pid)(nil), + (*ProcessSelector_Tag)(nil), + } + file_process_process_proto_msgTypes[25].OneofWrappers = []any{ + (*ProcessEvent_DataEvent_Stdout)(nil), + (*ProcessEvent_DataEvent_Stderr)(nil), + (*ProcessEvent_DataEvent_Pty)(nil), + } + file_process_process_proto_msgTypes[26].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_process_process_proto_rawDesc), len(file_process_process_proto_rawDesc)), + NumEnums: 1, + NumMessages: 31, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_process_process_proto_goTypes, + DependencyIndexes: file_process_process_proto_depIdxs, + EnumInfos: file_process_process_proto_enumTypes, + MessageInfos: file_process_process_proto_msgTypes, + }.Build() + File_process_process_proto = out.File + file_process_process_proto_goTypes = nil + file_process_process_proto_depIdxs = nil +} diff --git a/envd/internal/services/spec/process/processconnect/process.connect.go b/envd/internal/services/spec/process/processconnect/process.connect.go new file mode 100644 index 0000000..7a4f3f8 --- /dev/null +++ b/envd/internal/services/spec/process/processconnect/process.connect.go @@ -0,0 +1,310 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: process/process.proto + +package processconnect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + process "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // ProcessName is the fully-qualified name of the Process service. + ProcessName = "process.Process" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // ProcessListProcedure is the fully-qualified name of the Process's List RPC. + ProcessListProcedure = "/process.Process/List" + // ProcessConnectProcedure is the fully-qualified name of the Process's Connect RPC. + ProcessConnectProcedure = "/process.Process/Connect" + // ProcessStartProcedure is the fully-qualified name of the Process's Start RPC. + ProcessStartProcedure = "/process.Process/Start" + // ProcessUpdateProcedure is the fully-qualified name of the Process's Update RPC. + ProcessUpdateProcedure = "/process.Process/Update" + // ProcessStreamInputProcedure is the fully-qualified name of the Process's StreamInput RPC. + ProcessStreamInputProcedure = "/process.Process/StreamInput" + // ProcessSendInputProcedure is the fully-qualified name of the Process's SendInput RPC. + ProcessSendInputProcedure = "/process.Process/SendInput" + // ProcessSendSignalProcedure is the fully-qualified name of the Process's SendSignal RPC. + ProcessSendSignalProcedure = "/process.Process/SendSignal" + // ProcessCloseStdinProcedure is the fully-qualified name of the Process's CloseStdin RPC. + ProcessCloseStdinProcedure = "/process.Process/CloseStdin" +) + +// ProcessClient is a client for the process.Process service. +type ProcessClient interface { + List(context.Context, *connect.Request[process.ListRequest]) (*connect.Response[process.ListResponse], error) + Connect(context.Context, *connect.Request[process.ConnectRequest]) (*connect.ServerStreamForClient[process.ConnectResponse], error) + Start(context.Context, *connect.Request[process.StartRequest]) (*connect.ServerStreamForClient[process.StartResponse], error) + Update(context.Context, *connect.Request[process.UpdateRequest]) (*connect.Response[process.UpdateResponse], error) + // Client input stream ensures ordering of messages + StreamInput(context.Context) *connect.ClientStreamForClient[process.StreamInputRequest, process.StreamInputResponse] + SendInput(context.Context, *connect.Request[process.SendInputRequest]) (*connect.Response[process.SendInputResponse], error) + SendSignal(context.Context, *connect.Request[process.SendSignalRequest]) (*connect.Response[process.SendSignalResponse], error) + // Close stdin to signal EOF to the process. + // Only works for non-PTY processes. For PTY, send Ctrl+D (0x04) instead. + CloseStdin(context.Context, *connect.Request[process.CloseStdinRequest]) (*connect.Response[process.CloseStdinResponse], error) +} + +// NewProcessClient constructs a client for the process.Process service. By default, it uses the +// Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends +// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or +// connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewProcessClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ProcessClient { + baseURL = strings.TrimRight(baseURL, "/") + processMethods := process.File_process_process_proto.Services().ByName("Process").Methods() + return &processClient{ + list: connect.NewClient[process.ListRequest, process.ListResponse]( + httpClient, + baseURL+ProcessListProcedure, + connect.WithSchema(processMethods.ByName("List")), + connect.WithClientOptions(opts...), + ), + connect: connect.NewClient[process.ConnectRequest, process.ConnectResponse]( + httpClient, + baseURL+ProcessConnectProcedure, + connect.WithSchema(processMethods.ByName("Connect")), + connect.WithClientOptions(opts...), + ), + start: connect.NewClient[process.StartRequest, process.StartResponse]( + httpClient, + baseURL+ProcessStartProcedure, + connect.WithSchema(processMethods.ByName("Start")), + connect.WithClientOptions(opts...), + ), + update: connect.NewClient[process.UpdateRequest, process.UpdateResponse]( + httpClient, + baseURL+ProcessUpdateProcedure, + connect.WithSchema(processMethods.ByName("Update")), + connect.WithClientOptions(opts...), + ), + streamInput: connect.NewClient[process.StreamInputRequest, process.StreamInputResponse]( + httpClient, + baseURL+ProcessStreamInputProcedure, + connect.WithSchema(processMethods.ByName("StreamInput")), + connect.WithClientOptions(opts...), + ), + sendInput: connect.NewClient[process.SendInputRequest, process.SendInputResponse]( + httpClient, + baseURL+ProcessSendInputProcedure, + connect.WithSchema(processMethods.ByName("SendInput")), + connect.WithClientOptions(opts...), + ), + sendSignal: connect.NewClient[process.SendSignalRequest, process.SendSignalResponse]( + httpClient, + baseURL+ProcessSendSignalProcedure, + connect.WithSchema(processMethods.ByName("SendSignal")), + connect.WithClientOptions(opts...), + ), + closeStdin: connect.NewClient[process.CloseStdinRequest, process.CloseStdinResponse]( + httpClient, + baseURL+ProcessCloseStdinProcedure, + connect.WithSchema(processMethods.ByName("CloseStdin")), + connect.WithClientOptions(opts...), + ), + } +} + +// processClient implements ProcessClient. +type processClient struct { + list *connect.Client[process.ListRequest, process.ListResponse] + connect *connect.Client[process.ConnectRequest, process.ConnectResponse] + start *connect.Client[process.StartRequest, process.StartResponse] + update *connect.Client[process.UpdateRequest, process.UpdateResponse] + streamInput *connect.Client[process.StreamInputRequest, process.StreamInputResponse] + sendInput *connect.Client[process.SendInputRequest, process.SendInputResponse] + sendSignal *connect.Client[process.SendSignalRequest, process.SendSignalResponse] + closeStdin *connect.Client[process.CloseStdinRequest, process.CloseStdinResponse] +} + +// List calls process.Process.List. +func (c *processClient) List(ctx context.Context, req *connect.Request[process.ListRequest]) (*connect.Response[process.ListResponse], error) { + return c.list.CallUnary(ctx, req) +} + +// Connect calls process.Process.Connect. +func (c *processClient) Connect(ctx context.Context, req *connect.Request[process.ConnectRequest]) (*connect.ServerStreamForClient[process.ConnectResponse], error) { + return c.connect.CallServerStream(ctx, req) +} + +// Start calls process.Process.Start. +func (c *processClient) Start(ctx context.Context, req *connect.Request[process.StartRequest]) (*connect.ServerStreamForClient[process.StartResponse], error) { + return c.start.CallServerStream(ctx, req) +} + +// Update calls process.Process.Update. +func (c *processClient) Update(ctx context.Context, req *connect.Request[process.UpdateRequest]) (*connect.Response[process.UpdateResponse], error) { + return c.update.CallUnary(ctx, req) +} + +// StreamInput calls process.Process.StreamInput. +func (c *processClient) StreamInput(ctx context.Context) *connect.ClientStreamForClient[process.StreamInputRequest, process.StreamInputResponse] { + return c.streamInput.CallClientStream(ctx) +} + +// SendInput calls process.Process.SendInput. +func (c *processClient) SendInput(ctx context.Context, req *connect.Request[process.SendInputRequest]) (*connect.Response[process.SendInputResponse], error) { + return c.sendInput.CallUnary(ctx, req) +} + +// SendSignal calls process.Process.SendSignal. +func (c *processClient) SendSignal(ctx context.Context, req *connect.Request[process.SendSignalRequest]) (*connect.Response[process.SendSignalResponse], error) { + return c.sendSignal.CallUnary(ctx, req) +} + +// CloseStdin calls process.Process.CloseStdin. +func (c *processClient) CloseStdin(ctx context.Context, req *connect.Request[process.CloseStdinRequest]) (*connect.Response[process.CloseStdinResponse], error) { + return c.closeStdin.CallUnary(ctx, req) +} + +// ProcessHandler is an implementation of the process.Process service. +type ProcessHandler interface { + List(context.Context, *connect.Request[process.ListRequest]) (*connect.Response[process.ListResponse], error) + Connect(context.Context, *connect.Request[process.ConnectRequest], *connect.ServerStream[process.ConnectResponse]) error + Start(context.Context, *connect.Request[process.StartRequest], *connect.ServerStream[process.StartResponse]) error + Update(context.Context, *connect.Request[process.UpdateRequest]) (*connect.Response[process.UpdateResponse], error) + // Client input stream ensures ordering of messages + StreamInput(context.Context, *connect.ClientStream[process.StreamInputRequest]) (*connect.Response[process.StreamInputResponse], error) + SendInput(context.Context, *connect.Request[process.SendInputRequest]) (*connect.Response[process.SendInputResponse], error) + SendSignal(context.Context, *connect.Request[process.SendSignalRequest]) (*connect.Response[process.SendSignalResponse], error) + // Close stdin to signal EOF to the process. + // Only works for non-PTY processes. For PTY, send Ctrl+D (0x04) instead. + CloseStdin(context.Context, *connect.Request[process.CloseStdinRequest]) (*connect.Response[process.CloseStdinResponse], error) +} + +// NewProcessHandler builds an HTTP handler from the service implementation. It returns the path on +// which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewProcessHandler(svc ProcessHandler, opts ...connect.HandlerOption) (string, http.Handler) { + processMethods := process.File_process_process_proto.Services().ByName("Process").Methods() + processListHandler := connect.NewUnaryHandler( + ProcessListProcedure, + svc.List, + connect.WithSchema(processMethods.ByName("List")), + connect.WithHandlerOptions(opts...), + ) + processConnectHandler := connect.NewServerStreamHandler( + ProcessConnectProcedure, + svc.Connect, + connect.WithSchema(processMethods.ByName("Connect")), + connect.WithHandlerOptions(opts...), + ) + processStartHandler := connect.NewServerStreamHandler( + ProcessStartProcedure, + svc.Start, + connect.WithSchema(processMethods.ByName("Start")), + connect.WithHandlerOptions(opts...), + ) + processUpdateHandler := connect.NewUnaryHandler( + ProcessUpdateProcedure, + svc.Update, + connect.WithSchema(processMethods.ByName("Update")), + connect.WithHandlerOptions(opts...), + ) + processStreamInputHandler := connect.NewClientStreamHandler( + ProcessStreamInputProcedure, + svc.StreamInput, + connect.WithSchema(processMethods.ByName("StreamInput")), + connect.WithHandlerOptions(opts...), + ) + processSendInputHandler := connect.NewUnaryHandler( + ProcessSendInputProcedure, + svc.SendInput, + connect.WithSchema(processMethods.ByName("SendInput")), + connect.WithHandlerOptions(opts...), + ) + processSendSignalHandler := connect.NewUnaryHandler( + ProcessSendSignalProcedure, + svc.SendSignal, + connect.WithSchema(processMethods.ByName("SendSignal")), + connect.WithHandlerOptions(opts...), + ) + processCloseStdinHandler := connect.NewUnaryHandler( + ProcessCloseStdinProcedure, + svc.CloseStdin, + connect.WithSchema(processMethods.ByName("CloseStdin")), + connect.WithHandlerOptions(opts...), + ) + return "/process.Process/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case ProcessListProcedure: + processListHandler.ServeHTTP(w, r) + case ProcessConnectProcedure: + processConnectHandler.ServeHTTP(w, r) + case ProcessStartProcedure: + processStartHandler.ServeHTTP(w, r) + case ProcessUpdateProcedure: + processUpdateHandler.ServeHTTP(w, r) + case ProcessStreamInputProcedure: + processStreamInputHandler.ServeHTTP(w, r) + case ProcessSendInputProcedure: + processSendInputHandler.ServeHTTP(w, r) + case ProcessSendSignalProcedure: + processSendSignalHandler.ServeHTTP(w, r) + case ProcessCloseStdinProcedure: + processCloseStdinHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedProcessHandler returns CodeUnimplemented from all methods. +type UnimplementedProcessHandler struct{} + +func (UnimplementedProcessHandler) List(context.Context, *connect.Request[process.ListRequest]) (*connect.Response[process.ListResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.List is not implemented")) +} + +func (UnimplementedProcessHandler) Connect(context.Context, *connect.Request[process.ConnectRequest], *connect.ServerStream[process.ConnectResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.Connect is not implemented")) +} + +func (UnimplementedProcessHandler) Start(context.Context, *connect.Request[process.StartRequest], *connect.ServerStream[process.StartResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.Start is not implemented")) +} + +func (UnimplementedProcessHandler) Update(context.Context, *connect.Request[process.UpdateRequest]) (*connect.Response[process.UpdateResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.Update is not implemented")) +} + +func (UnimplementedProcessHandler) StreamInput(context.Context, *connect.ClientStream[process.StreamInputRequest]) (*connect.Response[process.StreamInputResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.StreamInput is not implemented")) +} + +func (UnimplementedProcessHandler) SendInput(context.Context, *connect.Request[process.SendInputRequest]) (*connect.Response[process.SendInputResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.SendInput is not implemented")) +} + +func (UnimplementedProcessHandler) SendSignal(context.Context, *connect.Request[process.SendSignalRequest]) (*connect.Response[process.SendSignalResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.SendSignal is not implemented")) +} + +func (UnimplementedProcessHandler) CloseStdin(context.Context, *connect.Request[process.CloseStdinRequest]) (*connect.Response[process.CloseStdinResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.CloseStdin is not implemented")) +} diff --git a/envd/internal/services/spec/specconnect/filesystem.connect.go b/envd/internal/services/spec/specconnect/filesystem.connect.go new file mode 100644 index 0000000..b06df5f --- /dev/null +++ b/envd/internal/services/spec/specconnect/filesystem.connect.go @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: filesystem.proto + +package specconnect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + spec "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // FilesystemName is the fully-qualified name of the Filesystem service. + FilesystemName = "filesystem.Filesystem" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // FilesystemStatProcedure is the fully-qualified name of the Filesystem's Stat RPC. + FilesystemStatProcedure = "/filesystem.Filesystem/Stat" + // FilesystemMakeDirProcedure is the fully-qualified name of the Filesystem's MakeDir RPC. + FilesystemMakeDirProcedure = "/filesystem.Filesystem/MakeDir" + // FilesystemMoveProcedure is the fully-qualified name of the Filesystem's Move RPC. + FilesystemMoveProcedure = "/filesystem.Filesystem/Move" + // FilesystemListDirProcedure is the fully-qualified name of the Filesystem's ListDir RPC. + FilesystemListDirProcedure = "/filesystem.Filesystem/ListDir" + // FilesystemRemoveProcedure is the fully-qualified name of the Filesystem's Remove RPC. + FilesystemRemoveProcedure = "/filesystem.Filesystem/Remove" + // FilesystemWatchDirProcedure is the fully-qualified name of the Filesystem's WatchDir RPC. + FilesystemWatchDirProcedure = "/filesystem.Filesystem/WatchDir" + // FilesystemCreateWatcherProcedure is the fully-qualified name of the Filesystem's CreateWatcher + // RPC. + FilesystemCreateWatcherProcedure = "/filesystem.Filesystem/CreateWatcher" + // FilesystemGetWatcherEventsProcedure is the fully-qualified name of the Filesystem's + // GetWatcherEvents RPC. + FilesystemGetWatcherEventsProcedure = "/filesystem.Filesystem/GetWatcherEvents" + // FilesystemRemoveWatcherProcedure is the fully-qualified name of the Filesystem's RemoveWatcher + // RPC. + FilesystemRemoveWatcherProcedure = "/filesystem.Filesystem/RemoveWatcher" +) + +// FilesystemClient is a client for the filesystem.Filesystem service. +type FilesystemClient interface { + Stat(context.Context, *connect.Request[spec.StatRequest]) (*connect.Response[spec.StatResponse], error) + MakeDir(context.Context, *connect.Request[spec.MakeDirRequest]) (*connect.Response[spec.MakeDirResponse], error) + Move(context.Context, *connect.Request[spec.MoveRequest]) (*connect.Response[spec.MoveResponse], error) + ListDir(context.Context, *connect.Request[spec.ListDirRequest]) (*connect.Response[spec.ListDirResponse], error) + Remove(context.Context, *connect.Request[spec.RemoveRequest]) (*connect.Response[spec.RemoveResponse], error) + WatchDir(context.Context, *connect.Request[spec.WatchDirRequest]) (*connect.ServerStreamForClient[spec.WatchDirResponse], error) + // Non-streaming versions of WatchDir + CreateWatcher(context.Context, *connect.Request[spec.CreateWatcherRequest]) (*connect.Response[spec.CreateWatcherResponse], error) + GetWatcherEvents(context.Context, *connect.Request[spec.GetWatcherEventsRequest]) (*connect.Response[spec.GetWatcherEventsResponse], error) + RemoveWatcher(context.Context, *connect.Request[spec.RemoveWatcherRequest]) (*connect.Response[spec.RemoveWatcherResponse], error) +} + +// NewFilesystemClient constructs a client for the filesystem.Filesystem service. By default, it +// uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends +// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or +// connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewFilesystemClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) FilesystemClient { + baseURL = strings.TrimRight(baseURL, "/") + filesystemMethods := spec.File_filesystem_proto.Services().ByName("Filesystem").Methods() + return &filesystemClient{ + stat: connect.NewClient[spec.StatRequest, spec.StatResponse]( + httpClient, + baseURL+FilesystemStatProcedure, + connect.WithSchema(filesystemMethods.ByName("Stat")), + connect.WithClientOptions(opts...), + ), + makeDir: connect.NewClient[spec.MakeDirRequest, spec.MakeDirResponse]( + httpClient, + baseURL+FilesystemMakeDirProcedure, + connect.WithSchema(filesystemMethods.ByName("MakeDir")), + connect.WithClientOptions(opts...), + ), + move: connect.NewClient[spec.MoveRequest, spec.MoveResponse]( + httpClient, + baseURL+FilesystemMoveProcedure, + connect.WithSchema(filesystemMethods.ByName("Move")), + connect.WithClientOptions(opts...), + ), + listDir: connect.NewClient[spec.ListDirRequest, spec.ListDirResponse]( + httpClient, + baseURL+FilesystemListDirProcedure, + connect.WithSchema(filesystemMethods.ByName("ListDir")), + connect.WithClientOptions(opts...), + ), + remove: connect.NewClient[spec.RemoveRequest, spec.RemoveResponse]( + httpClient, + baseURL+FilesystemRemoveProcedure, + connect.WithSchema(filesystemMethods.ByName("Remove")), + connect.WithClientOptions(opts...), + ), + watchDir: connect.NewClient[spec.WatchDirRequest, spec.WatchDirResponse]( + httpClient, + baseURL+FilesystemWatchDirProcedure, + connect.WithSchema(filesystemMethods.ByName("WatchDir")), + connect.WithClientOptions(opts...), + ), + createWatcher: connect.NewClient[spec.CreateWatcherRequest, spec.CreateWatcherResponse]( + httpClient, + baseURL+FilesystemCreateWatcherProcedure, + connect.WithSchema(filesystemMethods.ByName("CreateWatcher")), + connect.WithClientOptions(opts...), + ), + getWatcherEvents: connect.NewClient[spec.GetWatcherEventsRequest, spec.GetWatcherEventsResponse]( + httpClient, + baseURL+FilesystemGetWatcherEventsProcedure, + connect.WithSchema(filesystemMethods.ByName("GetWatcherEvents")), + connect.WithClientOptions(opts...), + ), + removeWatcher: connect.NewClient[spec.RemoveWatcherRequest, spec.RemoveWatcherResponse]( + httpClient, + baseURL+FilesystemRemoveWatcherProcedure, + connect.WithSchema(filesystemMethods.ByName("RemoveWatcher")), + connect.WithClientOptions(opts...), + ), + } +} + +// filesystemClient implements FilesystemClient. +type filesystemClient struct { + stat *connect.Client[spec.StatRequest, spec.StatResponse] + makeDir *connect.Client[spec.MakeDirRequest, spec.MakeDirResponse] + move *connect.Client[spec.MoveRequest, spec.MoveResponse] + listDir *connect.Client[spec.ListDirRequest, spec.ListDirResponse] + remove *connect.Client[spec.RemoveRequest, spec.RemoveResponse] + watchDir *connect.Client[spec.WatchDirRequest, spec.WatchDirResponse] + createWatcher *connect.Client[spec.CreateWatcherRequest, spec.CreateWatcherResponse] + getWatcherEvents *connect.Client[spec.GetWatcherEventsRequest, spec.GetWatcherEventsResponse] + removeWatcher *connect.Client[spec.RemoveWatcherRequest, spec.RemoveWatcherResponse] +} + +// Stat calls filesystem.Filesystem.Stat. +func (c *filesystemClient) Stat(ctx context.Context, req *connect.Request[spec.StatRequest]) (*connect.Response[spec.StatResponse], error) { + return c.stat.CallUnary(ctx, req) +} + +// MakeDir calls filesystem.Filesystem.MakeDir. +func (c *filesystemClient) MakeDir(ctx context.Context, req *connect.Request[spec.MakeDirRequest]) (*connect.Response[spec.MakeDirResponse], error) { + return c.makeDir.CallUnary(ctx, req) +} + +// Move calls filesystem.Filesystem.Move. +func (c *filesystemClient) Move(ctx context.Context, req *connect.Request[spec.MoveRequest]) (*connect.Response[spec.MoveResponse], error) { + return c.move.CallUnary(ctx, req) +} + +// ListDir calls filesystem.Filesystem.ListDir. +func (c *filesystemClient) ListDir(ctx context.Context, req *connect.Request[spec.ListDirRequest]) (*connect.Response[spec.ListDirResponse], error) { + return c.listDir.CallUnary(ctx, req) +} + +// Remove calls filesystem.Filesystem.Remove. +func (c *filesystemClient) Remove(ctx context.Context, req *connect.Request[spec.RemoveRequest]) (*connect.Response[spec.RemoveResponse], error) { + return c.remove.CallUnary(ctx, req) +} + +// WatchDir calls filesystem.Filesystem.WatchDir. +func (c *filesystemClient) WatchDir(ctx context.Context, req *connect.Request[spec.WatchDirRequest]) (*connect.ServerStreamForClient[spec.WatchDirResponse], error) { + return c.watchDir.CallServerStream(ctx, req) +} + +// CreateWatcher calls filesystem.Filesystem.CreateWatcher. +func (c *filesystemClient) CreateWatcher(ctx context.Context, req *connect.Request[spec.CreateWatcherRequest]) (*connect.Response[spec.CreateWatcherResponse], error) { + return c.createWatcher.CallUnary(ctx, req) +} + +// GetWatcherEvents calls filesystem.Filesystem.GetWatcherEvents. +func (c *filesystemClient) GetWatcherEvents(ctx context.Context, req *connect.Request[spec.GetWatcherEventsRequest]) (*connect.Response[spec.GetWatcherEventsResponse], error) { + return c.getWatcherEvents.CallUnary(ctx, req) +} + +// RemoveWatcher calls filesystem.Filesystem.RemoveWatcher. +func (c *filesystemClient) RemoveWatcher(ctx context.Context, req *connect.Request[spec.RemoveWatcherRequest]) (*connect.Response[spec.RemoveWatcherResponse], error) { + return c.removeWatcher.CallUnary(ctx, req) +} + +// FilesystemHandler is an implementation of the filesystem.Filesystem service. +type FilesystemHandler interface { + Stat(context.Context, *connect.Request[spec.StatRequest]) (*connect.Response[spec.StatResponse], error) + MakeDir(context.Context, *connect.Request[spec.MakeDirRequest]) (*connect.Response[spec.MakeDirResponse], error) + Move(context.Context, *connect.Request[spec.MoveRequest]) (*connect.Response[spec.MoveResponse], error) + ListDir(context.Context, *connect.Request[spec.ListDirRequest]) (*connect.Response[spec.ListDirResponse], error) + Remove(context.Context, *connect.Request[spec.RemoveRequest]) (*connect.Response[spec.RemoveResponse], error) + WatchDir(context.Context, *connect.Request[spec.WatchDirRequest], *connect.ServerStream[spec.WatchDirResponse]) error + // Non-streaming versions of WatchDir + CreateWatcher(context.Context, *connect.Request[spec.CreateWatcherRequest]) (*connect.Response[spec.CreateWatcherResponse], error) + GetWatcherEvents(context.Context, *connect.Request[spec.GetWatcherEventsRequest]) (*connect.Response[spec.GetWatcherEventsResponse], error) + RemoveWatcher(context.Context, *connect.Request[spec.RemoveWatcherRequest]) (*connect.Response[spec.RemoveWatcherResponse], error) +} + +// NewFilesystemHandler builds an HTTP handler from the service implementation. It returns the path +// on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewFilesystemHandler(svc FilesystemHandler, opts ...connect.HandlerOption) (string, http.Handler) { + filesystemMethods := spec.File_filesystem_proto.Services().ByName("Filesystem").Methods() + filesystemStatHandler := connect.NewUnaryHandler( + FilesystemStatProcedure, + svc.Stat, + connect.WithSchema(filesystemMethods.ByName("Stat")), + connect.WithHandlerOptions(opts...), + ) + filesystemMakeDirHandler := connect.NewUnaryHandler( + FilesystemMakeDirProcedure, + svc.MakeDir, + connect.WithSchema(filesystemMethods.ByName("MakeDir")), + connect.WithHandlerOptions(opts...), + ) + filesystemMoveHandler := connect.NewUnaryHandler( + FilesystemMoveProcedure, + svc.Move, + connect.WithSchema(filesystemMethods.ByName("Move")), + connect.WithHandlerOptions(opts...), + ) + filesystemListDirHandler := connect.NewUnaryHandler( + FilesystemListDirProcedure, + svc.ListDir, + connect.WithSchema(filesystemMethods.ByName("ListDir")), + connect.WithHandlerOptions(opts...), + ) + filesystemRemoveHandler := connect.NewUnaryHandler( + FilesystemRemoveProcedure, + svc.Remove, + connect.WithSchema(filesystemMethods.ByName("Remove")), + connect.WithHandlerOptions(opts...), + ) + filesystemWatchDirHandler := connect.NewServerStreamHandler( + FilesystemWatchDirProcedure, + svc.WatchDir, + connect.WithSchema(filesystemMethods.ByName("WatchDir")), + connect.WithHandlerOptions(opts...), + ) + filesystemCreateWatcherHandler := connect.NewUnaryHandler( + FilesystemCreateWatcherProcedure, + svc.CreateWatcher, + connect.WithSchema(filesystemMethods.ByName("CreateWatcher")), + connect.WithHandlerOptions(opts...), + ) + filesystemGetWatcherEventsHandler := connect.NewUnaryHandler( + FilesystemGetWatcherEventsProcedure, + svc.GetWatcherEvents, + connect.WithSchema(filesystemMethods.ByName("GetWatcherEvents")), + connect.WithHandlerOptions(opts...), + ) + filesystemRemoveWatcherHandler := connect.NewUnaryHandler( + FilesystemRemoveWatcherProcedure, + svc.RemoveWatcher, + connect.WithSchema(filesystemMethods.ByName("RemoveWatcher")), + connect.WithHandlerOptions(opts...), + ) + return "/filesystem.Filesystem/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case FilesystemStatProcedure: + filesystemStatHandler.ServeHTTP(w, r) + case FilesystemMakeDirProcedure: + filesystemMakeDirHandler.ServeHTTP(w, r) + case FilesystemMoveProcedure: + filesystemMoveHandler.ServeHTTP(w, r) + case FilesystemListDirProcedure: + filesystemListDirHandler.ServeHTTP(w, r) + case FilesystemRemoveProcedure: + filesystemRemoveHandler.ServeHTTP(w, r) + case FilesystemWatchDirProcedure: + filesystemWatchDirHandler.ServeHTTP(w, r) + case FilesystemCreateWatcherProcedure: + filesystemCreateWatcherHandler.ServeHTTP(w, r) + case FilesystemGetWatcherEventsProcedure: + filesystemGetWatcherEventsHandler.ServeHTTP(w, r) + case FilesystemRemoveWatcherProcedure: + filesystemRemoveWatcherHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedFilesystemHandler returns CodeUnimplemented from all methods. +type UnimplementedFilesystemHandler struct{} + +func (UnimplementedFilesystemHandler) Stat(context.Context, *connect.Request[spec.StatRequest]) (*connect.Response[spec.StatResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.Stat is not implemented")) +} + +func (UnimplementedFilesystemHandler) MakeDir(context.Context, *connect.Request[spec.MakeDirRequest]) (*connect.Response[spec.MakeDirResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.MakeDir is not implemented")) +} + +func (UnimplementedFilesystemHandler) Move(context.Context, *connect.Request[spec.MoveRequest]) (*connect.Response[spec.MoveResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.Move is not implemented")) +} + +func (UnimplementedFilesystemHandler) ListDir(context.Context, *connect.Request[spec.ListDirRequest]) (*connect.Response[spec.ListDirResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.ListDir is not implemented")) +} + +func (UnimplementedFilesystemHandler) Remove(context.Context, *connect.Request[spec.RemoveRequest]) (*connect.Response[spec.RemoveResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.Remove is not implemented")) +} + +func (UnimplementedFilesystemHandler) WatchDir(context.Context, *connect.Request[spec.WatchDirRequest], *connect.ServerStream[spec.WatchDirResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.WatchDir is not implemented")) +} + +func (UnimplementedFilesystemHandler) CreateWatcher(context.Context, *connect.Request[spec.CreateWatcherRequest]) (*connect.Response[spec.CreateWatcherResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.CreateWatcher is not implemented")) +} + +func (UnimplementedFilesystemHandler) GetWatcherEvents(context.Context, *connect.Request[spec.GetWatcherEventsRequest]) (*connect.Response[spec.GetWatcherEventsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.GetWatcherEvents is not implemented")) +} + +func (UnimplementedFilesystemHandler) RemoveWatcher(context.Context, *connect.Request[spec.RemoveWatcherRequest]) (*connect.Response[spec.RemoveWatcherResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.RemoveWatcher is not implemented")) +} diff --git a/envd/internal/services/spec/specconnect/process.connect.go b/envd/internal/services/spec/specconnect/process.connect.go new file mode 100644 index 0000000..57f49d5 --- /dev/null +++ b/envd/internal/services/spec/specconnect/process.connect.go @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: process.proto + +package specconnect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + spec "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // ProcessName is the fully-qualified name of the Process service. + ProcessName = "process.Process" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // ProcessListProcedure is the fully-qualified name of the Process's List RPC. + ProcessListProcedure = "/process.Process/List" + // ProcessConnectProcedure is the fully-qualified name of the Process's Connect RPC. + ProcessConnectProcedure = "/process.Process/Connect" + // ProcessStartProcedure is the fully-qualified name of the Process's Start RPC. + ProcessStartProcedure = "/process.Process/Start" + // ProcessUpdateProcedure is the fully-qualified name of the Process's Update RPC. + ProcessUpdateProcedure = "/process.Process/Update" + // ProcessStreamInputProcedure is the fully-qualified name of the Process's StreamInput RPC. + ProcessStreamInputProcedure = "/process.Process/StreamInput" + // ProcessSendInputProcedure is the fully-qualified name of the Process's SendInput RPC. + ProcessSendInputProcedure = "/process.Process/SendInput" + // ProcessSendSignalProcedure is the fully-qualified name of the Process's SendSignal RPC. + ProcessSendSignalProcedure = "/process.Process/SendSignal" + // ProcessCloseStdinProcedure is the fully-qualified name of the Process's CloseStdin RPC. + ProcessCloseStdinProcedure = "/process.Process/CloseStdin" +) + +// ProcessClient is a client for the process.Process service. +type ProcessClient interface { + List(context.Context, *connect.Request[spec.ListRequest]) (*connect.Response[spec.ListResponse], error) + Connect(context.Context, *connect.Request[spec.ConnectRequest]) (*connect.ServerStreamForClient[spec.ConnectResponse], error) + Start(context.Context, *connect.Request[spec.StartRequest]) (*connect.ServerStreamForClient[spec.StartResponse], error) + Update(context.Context, *connect.Request[spec.UpdateRequest]) (*connect.Response[spec.UpdateResponse], error) + // Client input stream ensures ordering of messages + StreamInput(context.Context) *connect.ClientStreamForClient[spec.StreamInputRequest, spec.StreamInputResponse] + SendInput(context.Context, *connect.Request[spec.SendInputRequest]) (*connect.Response[spec.SendInputResponse], error) + SendSignal(context.Context, *connect.Request[spec.SendSignalRequest]) (*connect.Response[spec.SendSignalResponse], error) + // Close stdin to signal EOF to the process. + // Only works for non-PTY processes. For PTY, send Ctrl+D (0x04) instead. + CloseStdin(context.Context, *connect.Request[spec.CloseStdinRequest]) (*connect.Response[spec.CloseStdinResponse], error) +} + +// NewProcessClient constructs a client for the process.Process service. By default, it uses the +// Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends +// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or +// connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewProcessClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ProcessClient { + baseURL = strings.TrimRight(baseURL, "/") + processMethods := spec.File_process_proto.Services().ByName("Process").Methods() + return &processClient{ + list: connect.NewClient[spec.ListRequest, spec.ListResponse]( + httpClient, + baseURL+ProcessListProcedure, + connect.WithSchema(processMethods.ByName("List")), + connect.WithClientOptions(opts...), + ), + connect: connect.NewClient[spec.ConnectRequest, spec.ConnectResponse]( + httpClient, + baseURL+ProcessConnectProcedure, + connect.WithSchema(processMethods.ByName("Connect")), + connect.WithClientOptions(opts...), + ), + start: connect.NewClient[spec.StartRequest, spec.StartResponse]( + httpClient, + baseURL+ProcessStartProcedure, + connect.WithSchema(processMethods.ByName("Start")), + connect.WithClientOptions(opts...), + ), + update: connect.NewClient[spec.UpdateRequest, spec.UpdateResponse]( + httpClient, + baseURL+ProcessUpdateProcedure, + connect.WithSchema(processMethods.ByName("Update")), + connect.WithClientOptions(opts...), + ), + streamInput: connect.NewClient[spec.StreamInputRequest, spec.StreamInputResponse]( + httpClient, + baseURL+ProcessStreamInputProcedure, + connect.WithSchema(processMethods.ByName("StreamInput")), + connect.WithClientOptions(opts...), + ), + sendInput: connect.NewClient[spec.SendInputRequest, spec.SendInputResponse]( + httpClient, + baseURL+ProcessSendInputProcedure, + connect.WithSchema(processMethods.ByName("SendInput")), + connect.WithClientOptions(opts...), + ), + sendSignal: connect.NewClient[spec.SendSignalRequest, spec.SendSignalResponse]( + httpClient, + baseURL+ProcessSendSignalProcedure, + connect.WithSchema(processMethods.ByName("SendSignal")), + connect.WithClientOptions(opts...), + ), + closeStdin: connect.NewClient[spec.CloseStdinRequest, spec.CloseStdinResponse]( + httpClient, + baseURL+ProcessCloseStdinProcedure, + connect.WithSchema(processMethods.ByName("CloseStdin")), + connect.WithClientOptions(opts...), + ), + } +} + +// processClient implements ProcessClient. +type processClient struct { + list *connect.Client[spec.ListRequest, spec.ListResponse] + connect *connect.Client[spec.ConnectRequest, spec.ConnectResponse] + start *connect.Client[spec.StartRequest, spec.StartResponse] + update *connect.Client[spec.UpdateRequest, spec.UpdateResponse] + streamInput *connect.Client[spec.StreamInputRequest, spec.StreamInputResponse] + sendInput *connect.Client[spec.SendInputRequest, spec.SendInputResponse] + sendSignal *connect.Client[spec.SendSignalRequest, spec.SendSignalResponse] + closeStdin *connect.Client[spec.CloseStdinRequest, spec.CloseStdinResponse] +} + +// List calls process.Process.List. +func (c *processClient) List(ctx context.Context, req *connect.Request[spec.ListRequest]) (*connect.Response[spec.ListResponse], error) { + return c.list.CallUnary(ctx, req) +} + +// Connect calls process.Process.Connect. +func (c *processClient) Connect(ctx context.Context, req *connect.Request[spec.ConnectRequest]) (*connect.ServerStreamForClient[spec.ConnectResponse], error) { + return c.connect.CallServerStream(ctx, req) +} + +// Start calls process.Process.Start. +func (c *processClient) Start(ctx context.Context, req *connect.Request[spec.StartRequest]) (*connect.ServerStreamForClient[spec.StartResponse], error) { + return c.start.CallServerStream(ctx, req) +} + +// Update calls process.Process.Update. +func (c *processClient) Update(ctx context.Context, req *connect.Request[spec.UpdateRequest]) (*connect.Response[spec.UpdateResponse], error) { + return c.update.CallUnary(ctx, req) +} + +// StreamInput calls process.Process.StreamInput. +func (c *processClient) StreamInput(ctx context.Context) *connect.ClientStreamForClient[spec.StreamInputRequest, spec.StreamInputResponse] { + return c.streamInput.CallClientStream(ctx) +} + +// SendInput calls process.Process.SendInput. +func (c *processClient) SendInput(ctx context.Context, req *connect.Request[spec.SendInputRequest]) (*connect.Response[spec.SendInputResponse], error) { + return c.sendInput.CallUnary(ctx, req) +} + +// SendSignal calls process.Process.SendSignal. +func (c *processClient) SendSignal(ctx context.Context, req *connect.Request[spec.SendSignalRequest]) (*connect.Response[spec.SendSignalResponse], error) { + return c.sendSignal.CallUnary(ctx, req) +} + +// CloseStdin calls process.Process.CloseStdin. +func (c *processClient) CloseStdin(ctx context.Context, req *connect.Request[spec.CloseStdinRequest]) (*connect.Response[spec.CloseStdinResponse], error) { + return c.closeStdin.CallUnary(ctx, req) +} + +// ProcessHandler is an implementation of the process.Process service. +type ProcessHandler interface { + List(context.Context, *connect.Request[spec.ListRequest]) (*connect.Response[spec.ListResponse], error) + Connect(context.Context, *connect.Request[spec.ConnectRequest], *connect.ServerStream[spec.ConnectResponse]) error + Start(context.Context, *connect.Request[spec.StartRequest], *connect.ServerStream[spec.StartResponse]) error + Update(context.Context, *connect.Request[spec.UpdateRequest]) (*connect.Response[spec.UpdateResponse], error) + // Client input stream ensures ordering of messages + StreamInput(context.Context, *connect.ClientStream[spec.StreamInputRequest]) (*connect.Response[spec.StreamInputResponse], error) + SendInput(context.Context, *connect.Request[spec.SendInputRequest]) (*connect.Response[spec.SendInputResponse], error) + SendSignal(context.Context, *connect.Request[spec.SendSignalRequest]) (*connect.Response[spec.SendSignalResponse], error) + // Close stdin to signal EOF to the process. + // Only works for non-PTY processes. For PTY, send Ctrl+D (0x04) instead. + CloseStdin(context.Context, *connect.Request[spec.CloseStdinRequest]) (*connect.Response[spec.CloseStdinResponse], error) +} + +// NewProcessHandler builds an HTTP handler from the service implementation. It returns the path on +// which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewProcessHandler(svc ProcessHandler, opts ...connect.HandlerOption) (string, http.Handler) { + processMethods := spec.File_process_proto.Services().ByName("Process").Methods() + processListHandler := connect.NewUnaryHandler( + ProcessListProcedure, + svc.List, + connect.WithSchema(processMethods.ByName("List")), + connect.WithHandlerOptions(opts...), + ) + processConnectHandler := connect.NewServerStreamHandler( + ProcessConnectProcedure, + svc.Connect, + connect.WithSchema(processMethods.ByName("Connect")), + connect.WithHandlerOptions(opts...), + ) + processStartHandler := connect.NewServerStreamHandler( + ProcessStartProcedure, + svc.Start, + connect.WithSchema(processMethods.ByName("Start")), + connect.WithHandlerOptions(opts...), + ) + processUpdateHandler := connect.NewUnaryHandler( + ProcessUpdateProcedure, + svc.Update, + connect.WithSchema(processMethods.ByName("Update")), + connect.WithHandlerOptions(opts...), + ) + processStreamInputHandler := connect.NewClientStreamHandler( + ProcessStreamInputProcedure, + svc.StreamInput, + connect.WithSchema(processMethods.ByName("StreamInput")), + connect.WithHandlerOptions(opts...), + ) + processSendInputHandler := connect.NewUnaryHandler( + ProcessSendInputProcedure, + svc.SendInput, + connect.WithSchema(processMethods.ByName("SendInput")), + connect.WithHandlerOptions(opts...), + ) + processSendSignalHandler := connect.NewUnaryHandler( + ProcessSendSignalProcedure, + svc.SendSignal, + connect.WithSchema(processMethods.ByName("SendSignal")), + connect.WithHandlerOptions(opts...), + ) + processCloseStdinHandler := connect.NewUnaryHandler( + ProcessCloseStdinProcedure, + svc.CloseStdin, + connect.WithSchema(processMethods.ByName("CloseStdin")), + connect.WithHandlerOptions(opts...), + ) + return "/process.Process/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case ProcessListProcedure: + processListHandler.ServeHTTP(w, r) + case ProcessConnectProcedure: + processConnectHandler.ServeHTTP(w, r) + case ProcessStartProcedure: + processStartHandler.ServeHTTP(w, r) + case ProcessUpdateProcedure: + processUpdateHandler.ServeHTTP(w, r) + case ProcessStreamInputProcedure: + processStreamInputHandler.ServeHTTP(w, r) + case ProcessSendInputProcedure: + processSendInputHandler.ServeHTTP(w, r) + case ProcessSendSignalProcedure: + processSendSignalHandler.ServeHTTP(w, r) + case ProcessCloseStdinProcedure: + processCloseStdinHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedProcessHandler returns CodeUnimplemented from all methods. +type UnimplementedProcessHandler struct{} + +func (UnimplementedProcessHandler) List(context.Context, *connect.Request[spec.ListRequest]) (*connect.Response[spec.ListResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.List is not implemented")) +} + +func (UnimplementedProcessHandler) Connect(context.Context, *connect.Request[spec.ConnectRequest], *connect.ServerStream[spec.ConnectResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.Connect is not implemented")) +} + +func (UnimplementedProcessHandler) Start(context.Context, *connect.Request[spec.StartRequest], *connect.ServerStream[spec.StartResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.Start is not implemented")) +} + +func (UnimplementedProcessHandler) Update(context.Context, *connect.Request[spec.UpdateRequest]) (*connect.Response[spec.UpdateResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.Update is not implemented")) +} + +func (UnimplementedProcessHandler) StreamInput(context.Context, *connect.ClientStream[spec.StreamInputRequest]) (*connect.Response[spec.StreamInputResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.StreamInput is not implemented")) +} + +func (UnimplementedProcessHandler) SendInput(context.Context, *connect.Request[spec.SendInputRequest]) (*connect.Response[spec.SendInputResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.SendInput is not implemented")) +} + +func (UnimplementedProcessHandler) SendSignal(context.Context, *connect.Request[spec.SendSignalRequest]) (*connect.Response[spec.SendSignalResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.SendSignal is not implemented")) +} + +func (UnimplementedProcessHandler) CloseStdin(context.Context, *connect.Request[spec.CloseStdinRequest]) (*connect.Response[spec.CloseStdinResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.CloseStdin is not implemented")) +} diff --git a/envd/internal/shared/filesystem/entry.go b/envd/internal/shared/filesystem/entry.go new file mode 100644 index 0000000..fb1cc2d --- /dev/null +++ b/envd/internal/shared/filesystem/entry.go @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 + +package filesystem + +import ( + "os" + "path/filepath" + "syscall" + "time" +) + +func GetEntryFromPath(path string) (EntryInfo, error) { + fileInfo, err := os.Lstat(path) + if err != nil { + return EntryInfo{}, err + } + + return GetEntryInfo(path, fileInfo), nil +} + +func GetEntryInfo(path string, fileInfo os.FileInfo) EntryInfo { + fileMode := fileInfo.Mode() + + var symlinkTarget *string + if fileMode&os.ModeSymlink != 0 { + // If we can't resolve the symlink target, we won't set the target + target := followSymlink(path) + symlinkTarget = &target + } + + var entryType FileType + var mode os.FileMode + + if symlinkTarget == nil { + entryType = getEntryType(fileMode) + mode = fileMode.Perm() + } else { + // If it's a symlink, we need to determine the type of the target + targetInfo, err := os.Stat(*symlinkTarget) + if err != nil { + entryType = UnknownFileType + } else { + entryType = getEntryType(targetInfo.Mode()) + mode = targetInfo.Mode().Perm() + } + } + + entry := EntryInfo{ + Name: fileInfo.Name(), + Path: path, + Type: entryType, + Size: fileInfo.Size(), + Mode: mode, + Permissions: fileMode.String(), + ModifiedTime: fileInfo.ModTime(), + SymlinkTarget: symlinkTarget, + } + + if base := getBase(fileInfo.Sys()); base != nil { + entry.AccessedTime = toTimestamp(base.Atim) + entry.CreatedTime = toTimestamp(base.Ctim) + entry.ModifiedTime = toTimestamp(base.Mtim) + entry.UID = base.Uid + entry.GID = base.Gid + } else if !fileInfo.ModTime().IsZero() { + entry.ModifiedTime = fileInfo.ModTime() + } + + return entry +} + +// getEntryType determines the type of file entry based on its mode and path. +// If the file is a symlink, it follows the symlink to determine the actual type. +func getEntryType(mode os.FileMode) FileType { + switch { + case mode.IsRegular(): + return FileFileType + case mode.IsDir(): + return DirectoryFileType + case mode&os.ModeSymlink == os.ModeSymlink: + return SymlinkFileType + default: + return UnknownFileType + } +} + +// followSymlink resolves a symbolic link to its target path. +func followSymlink(path string) string { + // Resolve symlinks + resolvedPath, err := filepath.EvalSymlinks(path) + if err != nil { + return path + } + + return resolvedPath +} + +func toTimestamp(spec syscall.Timespec) time.Time { + if spec.Sec == 0 && spec.Nsec == 0 { + return time.Time{} + } + + return time.Unix(spec.Sec, spec.Nsec) +} + +func getBase(sys any) *syscall.Stat_t { + st, _ := sys.(*syscall.Stat_t) + + return st +} diff --git a/envd/internal/shared/filesystem/entry_test.go b/envd/internal/shared/filesystem/entry_test.go new file mode 100644 index 0000000..537d6e0 --- /dev/null +++ b/envd/internal/shared/filesystem/entry_test.go @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: Apache-2.0 + +package filesystem + +import ( + "os" + "os/user" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetEntryType(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + // Create test files + regularFile := filepath.Join(tempDir, "regular.txt") + require.NoError(t, os.WriteFile(regularFile, []byte("test content"), 0o644)) + + testDir := filepath.Join(tempDir, "testdir") + require.NoError(t, os.MkdirAll(testDir, 0o755)) + + symlink := filepath.Join(tempDir, "symlink") + require.NoError(t, os.Symlink(regularFile, symlink)) + + tests := []struct { + name string + path string + expected FileType + }{ + { + name: "regular file", + path: regularFile, + expected: FileFileType, + }, + { + name: "directory", + path: testDir, + expected: DirectoryFileType, + }, + { + name: "symlink to file", + path: symlink, + expected: SymlinkFileType, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + info, err := os.Lstat(tt.path) + require.NoError(t, err) + + result := getEntryType(info.Mode()) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestEntryInfoFromFileInfo_SymlinkChain(t *testing.T) { + t.Parallel() + + // Base temporary directory. On macOS this lives under /var/folders/… + // which itself is a symlink to /private/var/folders/…. + tempDir := t.TempDir() + + // Create final target + target := filepath.Join(tempDir, "target") + require.NoError(t, os.MkdirAll(target, 0o755)) + + // Create a chain: link1 → link2 → target + link2 := filepath.Join(tempDir, "link2") + require.NoError(t, os.Symlink(target, link2)) + + link1 := filepath.Join(tempDir, "link1") + require.NoError(t, os.Symlink(link2, link1)) + + // run the test + result, err := GetEntryFromPath(link1) + require.NoError(t, err) + + // verify the results + assert.Equal(t, "link1", result.Name) + assert.Equal(t, link1, result.Path) + assert.Equal(t, DirectoryFileType, result.Type) // Should resolve to final target type + assert.Contains(t, result.Permissions, "L") + + // Canonicalize the expected target path to handle macOS symlink indirections + expectedTarget, err := filepath.EvalSymlinks(link1) + require.NoError(t, err) + assert.Equal(t, expectedTarget, *result.SymlinkTarget) +} + +func TestEntryInfoFromFileInfo_DifferentPermissions(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + testCases := []struct { + name string + permissions os.FileMode + expectedMode os.FileMode + expectedString string + }{ + {"read-only", 0o444, 0o444, "-r--r--r--"}, + {"executable", 0o755, 0o755, "-rwxr-xr-x"}, + {"write-only", 0o200, 0o200, "--w-------"}, + {"no permissions", 0o000, 0o000, "----------"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + testFile := filepath.Join(tempDir, tc.name+".txt") + require.NoError(t, os.WriteFile(testFile, []byte("test"), tc.permissions)) + + result, err := GetEntryFromPath(testFile) + require.NoError(t, err) + assert.Equal(t, tc.expectedMode, result.Mode) + assert.Equal(t, tc.expectedString, result.Permissions) + }) + } +} + +func TestEntryInfoFromFileInfo_EmptyFile(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + emptyFile := filepath.Join(tempDir, "empty.txt") + require.NoError(t, os.WriteFile(emptyFile, []byte{}, 0o600)) + + result, err := GetEntryFromPath(emptyFile) + require.NoError(t, err) + + assert.Equal(t, "empty.txt", result.Name) + assert.Equal(t, int64(0), result.Size) + assert.Equal(t, os.FileMode(0o600), result.Mode) + assert.Equal(t, FileFileType, result.Type) +} + +func TestEntryInfoFromFileInfo_CyclicSymlink(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + // Create cyclic symlink + cyclicSymlink := filepath.Join(tempDir, "cyclic") + require.NoError(t, os.Symlink(cyclicSymlink, cyclicSymlink)) + + result, err := GetEntryFromPath(cyclicSymlink) + require.NoError(t, err) + + assert.Equal(t, "cyclic", result.Name) + assert.Equal(t, cyclicSymlink, result.Path) + assert.Equal(t, UnknownFileType, result.Type) + assert.Contains(t, result.Permissions, "L") +} + +func TestEntryInfoFromFileInfo_BrokenSymlink(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + // Create broken symlink + brokenSymlink := filepath.Join(tempDir, "broken") + require.NoError(t, os.Symlink("/nonexistent", brokenSymlink)) + + result, err := GetEntryFromPath(brokenSymlink) + require.NoError(t, err) + + assert.Equal(t, "broken", result.Name) + assert.Equal(t, brokenSymlink, result.Path) + assert.Equal(t, UnknownFileType, result.Type) + assert.Contains(t, result.Permissions, "L") + // SymlinkTarget might be empty if followSymlink fails +} + +func TestEntryInfoFromFileInfo(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + + // Create a regular file with known content and permissions + testFile := filepath.Join(tempDir, "test.txt") + testContent := []byte("Hello, World!") + require.NoError(t, os.WriteFile(testFile, testContent, 0o644)) + + // Get current user for ownership comparison + currentUser, err := user.Current() + require.NoError(t, err) + + result, err := GetEntryFromPath(testFile) + require.NoError(t, err) + + // Basic assertions + assert.Equal(t, "test.txt", result.Name) + assert.Equal(t, testFile, result.Path) + assert.Equal(t, int64(len(testContent)), result.Size) + assert.Equal(t, FileFileType, result.Type) + assert.Equal(t, os.FileMode(0o644), result.Mode) + assert.Contains(t, result.Permissions, "-rw-r--r--") + assert.Equal(t, currentUser.Uid, strconv.Itoa(int(result.UID))) + assert.Equal(t, currentUser.Gid, strconv.Itoa(int(result.GID))) + assert.NotNil(t, result.ModifiedTime) + assert.Empty(t, result.SymlinkTarget) + + // Check that modified time is reasonable (within last minute) + modTime := result.ModifiedTime + assert.WithinDuration(t, time.Now(), modTime, time.Minute) +} + +func TestEntryInfoFromFileInfo_Directory(t *testing.T) { + t.Parallel() + + tempDir := t.TempDir() + testDir := filepath.Join(tempDir, "testdir") + require.NoError(t, os.MkdirAll(testDir, 0o755)) + + result, err := GetEntryFromPath(testDir) + require.NoError(t, err) + + assert.Equal(t, "testdir", result.Name) + assert.Equal(t, testDir, result.Path) + assert.Equal(t, DirectoryFileType, result.Type) + assert.Equal(t, os.FileMode(0o755), result.Mode) + assert.Equal(t, "drwxr-xr-x", result.Permissions) + assert.Empty(t, result.SymlinkTarget) +} + +func TestEntryInfoFromFileInfo_Symlink(t *testing.T) { + t.Parallel() + + // Base temporary directory. On macOS this lives under /var/folders/… + // which itself is a symlink to /private/var/folders/…. + tempDir := t.TempDir() + + // Create target file + targetFile := filepath.Join(tempDir, "target.txt") + require.NoError(t, os.WriteFile(targetFile, []byte("target content"), 0o644)) + + // Create symlink + symlinkPath := filepath.Join(tempDir, "symlink") + require.NoError(t, os.Symlink(targetFile, symlinkPath)) + + // Use Lstat to get symlink info (not the target) + result, err := GetEntryFromPath(symlinkPath) + require.NoError(t, err) + + assert.Equal(t, "symlink", result.Name) + assert.Equal(t, symlinkPath, result.Path) + assert.Equal(t, FileFileType, result.Type) // Should resolve to target type + assert.Contains(t, result.Permissions, "L") // Should show as symlink in permissions + + // Canonicalize the expected target path to handle macOS /var → /private/var symlink + expectedTarget, err := filepath.EvalSymlinks(symlinkPath) + require.NoError(t, err) + assert.Equal(t, expectedTarget, *result.SymlinkTarget) +} diff --git a/envd/internal/shared/filesystem/model.go b/envd/internal/shared/filesystem/model.go new file mode 100644 index 0000000..6024cce --- /dev/null +++ b/envd/internal/shared/filesystem/model.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 + +package filesystem + +import ( + "os" + "time" +) + +type EntryInfo struct { + Name string + Type FileType + Path string + Size int64 + Mode os.FileMode + Permissions string + UID uint32 + GID uint32 + AccessedTime time.Time + CreatedTime time.Time + ModifiedTime time.Time + SymlinkTarget *string +} + +type FileType int32 + +const ( + UnknownFileType FileType = 0 + FileFileType FileType = 1 + DirectoryFileType FileType = 2 + SymlinkFileType FileType = 3 +) diff --git a/envd/internal/shared/id/id.go b/envd/internal/shared/id/id.go new file mode 100644 index 0000000..45f68fe --- /dev/null +++ b/envd/internal/shared/id/id.go @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: Apache-2.0 + +package id + +import ( + "errors" + "fmt" + "maps" + "regexp" + "slices" + "strings" + + "github.com/dchest/uniuri" + "github.com/google/uuid" +) + +var ( + caseInsensitiveAlphabet = []byte("abcdefghijklmnopqrstuvwxyz1234567890") + identifierRegex = regexp.MustCompile(`^[a-z0-9-_]+$`) + tagRegex = regexp.MustCompile(`^[a-z0-9-_.]+$`) + sandboxIDRegex = regexp.MustCompile(`^[a-z0-9]+$`) +) + +const ( + DefaultTag = "default" + TagSeparator = ":" + NamespaceSeparator = "/" +) + +func Generate() string { + return uniuri.NewLenChars(uniuri.UUIDLen, caseInsensitiveAlphabet) +} + +// ValidateSandboxID checks that a sandbox ID contains only lowercase alphanumeric characters. +func ValidateSandboxID(sandboxID string) error { + if !sandboxIDRegex.MatchString(sandboxID) { + return fmt.Errorf("invalid sandbox ID: %q", sandboxID) + } + + return nil +} + +func cleanAndValidate(value, name string, re *regexp.Regexp) (string, error) { + cleaned := strings.ToLower(strings.TrimSpace(value)) + if !re.MatchString(cleaned) { + return "", fmt.Errorf("invalid %s: %s", name, value) + } + + return cleaned, nil +} + +func validateTag(tag string) (string, error) { + cleanedTag, err := cleanAndValidate(tag, "tag", tagRegex) + if err != nil { + return "", err + } + + // Prevent tags from being a UUID + _, err = uuid.Parse(cleanedTag) + if err == nil { + return "", errors.New("tag cannot be a UUID") + } + + return cleanedTag, nil +} + +func ValidateAndDeduplicateTags(tags []string) ([]string, error) { + seen := make(map[string]struct{}) + + for _, tag := range tags { + cleanedTag, err := validateTag(tag) + if err != nil { + return nil, fmt.Errorf("invalid tag '%s': %w", tag, err) + } + + seen[cleanedTag] = struct{}{} + } + + return slices.Collect(maps.Keys(seen)), nil +} + +// SplitIdentifier splits "namespace/alias" into its parts. +// Returns nil namespace for bare aliases, pointer for explicit namespace. +func SplitIdentifier(identifier string) (namespace *string, alias string) { + before, after, found := strings.Cut(identifier, NamespaceSeparator) + if !found { + return nil, before + } + + return &before, after +} + +// ParseName parses and validates "namespace/alias:tag" or "alias:tag". +// Returns the cleaned identifier (namespace/alias or alias) and optional tag. +// All components are validated and normalized (lowercase, trimmed). +func ParseName(input string) (identifier string, tag *string, err error) { + input = strings.TrimSpace(input) + + // Extract raw parts + identifierPart, tagPart, hasTag := strings.Cut(input, TagSeparator) + namespacePart, aliasPart := SplitIdentifier(identifierPart) + + // Validate tag + if hasTag { + validated, err := cleanAndValidate(tagPart, "tag", tagRegex) + if err != nil { + return "", nil, err + } + if !strings.EqualFold(validated, DefaultTag) { + tag = &validated + } + } + + // Validate namespace + if namespacePart != nil { + validated, err := cleanAndValidate(*namespacePart, "namespace", identifierRegex) + if err != nil { + return "", nil, err + } + namespacePart = &validated + } + + // Validate alias + aliasPart, err = cleanAndValidate(aliasPart, "template ID", identifierRegex) + if err != nil { + return "", nil, err + } + + // Build identifier + if namespacePart != nil { + identifier = WithNamespace(*namespacePart, aliasPart) + } else { + identifier = aliasPart + } + + return identifier, tag, nil +} + +// WithTag returns the identifier with the given tag appended (e.g. "templateID:tag"). +func WithTag(identifier, tag string) string { + return identifier + TagSeparator + tag +} + +// WithNamespace returns identifier with the given namespace prefix. +func WithNamespace(namespace, alias string) string { + return namespace + NamespaceSeparator + alias +} + +// ExtractAlias returns just the alias portion from an identifier (namespace/alias or alias). +func ExtractAlias(identifier string) string { + _, alias := SplitIdentifier(identifier) + + return alias +} + +// ValidateNamespaceMatchesTeam checks if an explicit namespace in the identifier matches the team's slug. +// Returns an error if the namespace doesn't match. +// If the identifier has no explicit namespace, returns nil (valid). +func ValidateNamespaceMatchesTeam(identifier, teamSlug string) error { + namespace, _ := SplitIdentifier(identifier) + if namespace != nil && *namespace != teamSlug { + return fmt.Errorf("namespace '%s' must match your team '%s'", *namespace, teamSlug) + } + + return nil +} diff --git a/envd/internal/shared/id/id_test.go b/envd/internal/shared/id/id_test.go new file mode 100644 index 0000000..38de0a7 --- /dev/null +++ b/envd/internal/shared/id/id_test.go @@ -0,0 +1,382 @@ +// SPDX-License-Identifier: Apache-2.0 + +package id + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.omukk.dev/wrenn/sandbox/envd/internal/shared/utils" +) + +func TestParseName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantIdentifier string + wantTag *string + wantErr bool + }{ + { + name: "bare alias only", + input: "my-template", + wantIdentifier: "my-template", + wantTag: nil, + }, + { + name: "alias with tag", + input: "my-template:v1", + wantIdentifier: "my-template", + wantTag: utils.ToPtr("v1"), + }, + { + name: "namespace and alias", + input: "acme/my-template", + wantIdentifier: "acme/my-template", + wantTag: nil, + }, + { + name: "namespace, alias and tag", + input: "acme/my-template:v1", + wantIdentifier: "acme/my-template", + wantTag: utils.ToPtr("v1"), + }, + { + name: "namespace with hyphens", + input: "my-team/my-template:prod", + wantIdentifier: "my-team/my-template", + wantTag: utils.ToPtr("prod"), + }, + { + name: "default tag normalized to nil", + input: "my-template:default", + wantIdentifier: "my-template", + wantTag: nil, + }, + { + name: "uppercase converted to lowercase", + input: "MyTemplate:Prod", + wantIdentifier: "mytemplate", + wantTag: utils.ToPtr("prod"), + }, + { + name: "whitespace trimmed", + input: " my-template : v1 ", + wantIdentifier: "my-template", + wantTag: utils.ToPtr("v1"), + }, + { + name: "invalid - empty namespace", + input: "/my-template", + wantErr: true, + }, + { + name: "invalid - empty tag after colon", + input: "my-template:", + wantErr: true, + }, + { + name: "invalid - special characters in alias", + input: "my template!", + wantErr: true, + }, + { + name: "invalid - special characters in namespace", + input: "my team!/my-template", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + gotIdentifier, gotTag, err := ParseName(tt.input) + + if tt.wantErr { + require.Error(t, err, "Expected ParseName() to return error, got") + + return + } + + require.NoError(t, err, "Expected ParseName() not to return error, got: %v", err) + assert.Equal(t, tt.wantIdentifier, gotIdentifier, "ParseName() identifier = %v, want %v", gotIdentifier, tt.wantIdentifier) + assert.Equal(t, tt.wantTag, gotTag, "ParseName() tag = %v, want %v", utils.Sprintp(gotTag), utils.Sprintp(tt.wantTag)) + }) + } +} + +func TestWithNamespace(t *testing.T) { + t.Parallel() + + got := WithNamespace("acme", "my-template") + want := "acme/my-template" + assert.Equal(t, want, got, "WithNamespace() = %q, want %q", got, want) +} + +func TestSplitIdentifier(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + identifier string + wantNamespace *string + wantAlias string + }{ + { + name: "bare alias", + identifier: "my-template", + wantNamespace: nil, + wantAlias: "my-template", + }, + { + name: "with namespace", + identifier: "acme/my-template", + wantNamespace: ptrStr("acme"), + wantAlias: "my-template", + }, + { + name: "empty namespace prefix", + identifier: "/my-template", + wantNamespace: ptrStr(""), + wantAlias: "my-template", + }, + { + name: "multiple slashes - only first split", + identifier: "a/b/c", + wantNamespace: ptrStr("a"), + wantAlias: "b/c", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + gotNamespace, gotAlias := SplitIdentifier(tt.identifier) + + if tt.wantNamespace == nil { + assert.Nil(t, gotNamespace) + } else { + require.NotNil(t, gotNamespace) + assert.Equal(t, *tt.wantNamespace, *gotNamespace) + } + + assert.Equal(t, tt.wantAlias, gotAlias) + }) + } +} + +func ptrStr(s string) *string { + return &s +} + +func TestValidateAndDeduplicateTags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tags []string + want []string + wantErr bool + }{ + { + name: "single valid tag", + tags: []string{"v1"}, + want: []string{"v1"}, + wantErr: false, + }, + { + name: "multiple unique tags", + tags: []string{"v1", "prod", "latest"}, + want: []string{"v1", "prod", "latest"}, + wantErr: false, + }, + { + name: "duplicate tags deduplicated", + tags: []string{"v1", "V1", "v1"}, + want: []string{"v1"}, + wantErr: false, + }, + { + name: "tags with dots and underscores", + tags: []string{"v1.0", "v1_1"}, + want: []string{"v1.0", "v1_1"}, + wantErr: false, + }, + { + name: "invalid - UUID tag rejected", + tags: []string{"550e8400-e29b-41d4-a716-446655440000"}, + wantErr: true, + }, + { + name: "invalid - special characters", + tags: []string{"v1!", "v2@"}, + wantErr: true, + }, + { + name: "empty list returns empty", + tags: []string{}, + want: []string{}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := ValidateAndDeduplicateTags(tt.tags) + + if tt.wantErr { + require.Error(t, err) + + return + } + + require.NoError(t, err) + assert.ElementsMatch(t, tt.want, got) + }) + } +} + +func TestValidateSandboxID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr bool + }{ + { + name: "canonical sandbox ID", + input: "i1a2b3c4d5e6f7g8h9j0k", + wantErr: false, + }, + { + name: "short alphanumeric", + input: "abc123", + wantErr: false, + }, + { + name: "all digits", + input: "1234567890", + wantErr: false, + }, + { + name: "all lowercase letters", + input: "abcdefghijklmnopqrst", + wantErr: false, + }, + { + name: "invalid - empty", + input: "", + wantErr: true, + }, + { + name: "invalid - contains colon (Redis separator)", + input: "abc:def", + wantErr: true, + }, + { + name: "invalid - contains open brace (Redis hash slot)", + input: "abc{def", + wantErr: true, + }, + { + name: "invalid - contains close brace (Redis hash slot)", + input: "abc}def", + wantErr: true, + }, + { + name: "invalid - contains newline", + input: "abc\ndef", + wantErr: true, + }, + { + name: "invalid - contains space", + input: "abc def", + wantErr: true, + }, + { + name: "invalid - contains hyphen", + input: "abc-def", + wantErr: true, + }, + { + name: "invalid - contains uppercase", + input: "abcDEF", + wantErr: true, + }, + { + name: "invalid - contains slash", + input: "abc/def", + wantErr: true, + }, + { + name: "invalid - contains null byte", + input: "abc\x00def", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ValidateSandboxID(tt.input) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidateNamespaceMatchesTeam(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + identifier string + teamSlug string + wantErr bool + }{ + { + name: "bare alias - no namespace", + identifier: "my-template", + teamSlug: "acme", + wantErr: false, + }, + { + name: "matching namespace", + identifier: "acme/my-template", + teamSlug: "acme", + wantErr: false, + }, + { + name: "mismatched namespace", + identifier: "other-team/my-template", + teamSlug: "acme", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := ValidateNamespaceMatchesTeam(tt.identifier, tt.teamSlug) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/envd/internal/shared/keys/constants.go b/envd/internal/shared/keys/constants.go new file mode 100644 index 0000000..72e8d68 --- /dev/null +++ b/envd/internal/shared/keys/constants.go @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modifications by M/S Omukk + +package keys + +const ( + ApiKeyPrefix = "wrn_" + AccessTokenPrefix = "sk_wrn_" +) diff --git a/envd/internal/shared/keys/hashing.go b/envd/internal/shared/keys/hashing.go new file mode 100644 index 0000000..4826637 --- /dev/null +++ b/envd/internal/shared/keys/hashing.go @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 + +package keys + +type Hasher interface { + Hash(key []byte) string +} diff --git a/envd/internal/shared/keys/hmac_sha256.go b/envd/internal/shared/keys/hmac_sha256.go new file mode 100644 index 0000000..de7e04c --- /dev/null +++ b/envd/internal/shared/keys/hmac_sha256.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 + +package keys + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" +) + +type HMACSha256Hashing struct { + key []byte +} + +func NewHMACSHA256Hashing(key []byte) *HMACSha256Hashing { + return &HMACSha256Hashing{key: key} +} + +func (h *HMACSha256Hashing) Hash(content []byte) (string, error) { + mac := hmac.New(sha256.New, h.key) + _, err := mac.Write(content) + if err != nil { + return "", err + } + + return hex.EncodeToString(mac.Sum(nil)), nil +} diff --git a/envd/internal/shared/keys/hmac_sha256_test.go b/envd/internal/shared/keys/hmac_sha256_test.go new file mode 100644 index 0000000..22bd49e --- /dev/null +++ b/envd/internal/shared/keys/hmac_sha256_test.go @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 + +package keys + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHMACSha256Hashing_ValidHash(t *testing.T) { + t.Parallel() + key := []byte("test-key") + hasher := NewHMACSHA256Hashing(key) + content := []byte("hello world") + expectedHash := "18c4b268f0bbf8471eda56af3e70b1d4613d734dc538b4940b59931c412a1591" + actualHash, err := hasher.Hash(content) + require.NoError(t, err) + + if actualHash != expectedHash { + t.Errorf("expected %s, got %s", expectedHash, actualHash) + } +} + +func TestHMACSha256Hashing_EmptyContent(t *testing.T) { + t.Parallel() + key := []byte("test-key") + hasher := NewHMACSHA256Hashing(key) + content := []byte("") + expectedHash := "2711cc23e9ab1b8a9bc0fe991238da92671624a9ebdaf1c1abec06e7e9a14f9b" + actualHash, err := hasher.Hash(content) + require.NoError(t, err) + + if actualHash != expectedHash { + t.Errorf("expected %s, got %s", expectedHash, actualHash) + } +} + +func TestHMACSha256Hashing_DifferentKey(t *testing.T) { + t.Parallel() + key := []byte("test-key") + hasher := NewHMACSHA256Hashing(key) + differentKeyHasher := NewHMACSHA256Hashing([]byte("different-key")) + content := []byte("hello world") + + hashWithOriginalKey, err := hasher.Hash(content) + require.NoError(t, err) + + hashWithDifferentKey, err := differentKeyHasher.Hash(content) + require.NoError(t, err) + + if hashWithOriginalKey == hashWithDifferentKey { + t.Errorf("hashes with different keys should not match") + } +} + +func TestHMACSha256Hashing_IdenticalResult(t *testing.T) { + t.Parallel() + key := []byte("placeholder-hashing-key") + content := []byte("test content for hashing") + + mac := hmac.New(sha256.New, key) + mac.Write(content) + expectedResult := hex.EncodeToString(mac.Sum(nil)) + + hasher := NewHMACSHA256Hashing(key) + actualResult, err := hasher.Hash(content) + require.NoError(t, err) + + if actualResult != expectedResult { + t.Errorf("expected %s, got %s", expectedResult, actualResult) + } +} diff --git a/envd/internal/shared/keys/key.go b/envd/internal/shared/keys/key.go new file mode 100644 index 0000000..c73c830 --- /dev/null +++ b/envd/internal/shared/keys/key.go @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 + +package keys + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "strings" +) + +const ( + identifierValueSuffixLength = 4 + identifierValuePrefixLength = 2 + + keyLength = 20 +) + +var hasher Hasher = NewSHA256Hashing() + +type Key struct { + PrefixedRawValue string + HashedValue string + Masked MaskedIdentifier +} + +type MaskedIdentifier struct { + Prefix string + ValueLength int + MaskedValuePrefix string + MaskedValueSuffix string +} + +// MaskKey returns identifier masking properties in accordance to the OpenAPI response spec +func MaskKey(prefix, value string) (MaskedIdentifier, error) { + valueLength := len(value) + + suffixOffset := valueLength - identifierValueSuffixLength + prefixOffset := identifierValuePrefixLength + + if suffixOffset < 0 { + return MaskedIdentifier{}, fmt.Errorf("mask value length is less than identifier suffix length (%d)", identifierValueSuffixLength) + } + + if suffixOffset == 0 { + return MaskedIdentifier{}, fmt.Errorf("mask value length is equal to identifier suffix length (%d), which would expose the entire identifier in the mask", identifierValueSuffixLength) + } + + // cap prefixOffset by suffixOffset to prevent overlap with the suffix. + if prefixOffset > suffixOffset { + prefixOffset = suffixOffset + } + + maskPrefix := value[:prefixOffset] + maskSuffix := value[suffixOffset:] + + maskedIdentifierProperties := MaskedIdentifier{ + Prefix: prefix, + ValueLength: valueLength, + MaskedValuePrefix: maskPrefix, + MaskedValueSuffix: maskSuffix, + } + + return maskedIdentifierProperties, nil +} + +func GenerateKey(prefix string) (Key, error) { + keyBytes := make([]byte, keyLength) + + _, err := rand.Read(keyBytes) + if err != nil { + return Key{}, err + } + + generatedIdentifier := hex.EncodeToString(keyBytes) + + mask, err := MaskKey(prefix, generatedIdentifier) + if err != nil { + return Key{}, err + } + + return Key{ + PrefixedRawValue: prefix + generatedIdentifier, + HashedValue: hasher.Hash(keyBytes), + Masked: mask, + }, nil +} + +func VerifyKey(prefix string, key string) (string, error) { + if !strings.HasPrefix(key, prefix) { + return "", fmt.Errorf("invalid key prefix") + } + + keyValue := key[len(prefix):] + keyBytes, err := hex.DecodeString(keyValue) + if err != nil { + return "", fmt.Errorf("invalid key") + } + + return hasher.Hash(keyBytes), nil +} diff --git a/envd/internal/shared/keys/key_test.go b/envd/internal/shared/keys/key_test.go new file mode 100644 index 0000000..50dcfb2 --- /dev/null +++ b/envd/internal/shared/keys/key_test.go @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: Apache-2.0 + +package keys + +import ( + "fmt" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMaskKey(t *testing.T) { + t.Parallel() + t.Run("succeeds: value longer than suffix length", func(t *testing.T) { + t.Parallel() + masked, err := MaskKey("test_", "1234567890") + require.NoError(t, err) + assert.Equal(t, "test_", masked.Prefix) + assert.Equal(t, "12", masked.MaskedValuePrefix) + assert.Equal(t, "7890", masked.MaskedValueSuffix) + }) + + t.Run("succeeds: empty prefix, value longer than suffix length", func(t *testing.T) { + t.Parallel() + masked, err := MaskKey("", "1234567890") + require.NoError(t, err) + assert.Empty(t, masked.Prefix) + assert.Equal(t, "12", masked.MaskedValuePrefix) + assert.Equal(t, "7890", masked.MaskedValueSuffix) + }) + + t.Run("error: value length less than suffix length", func(t *testing.T) { + t.Parallel() + _, err := MaskKey("test", "123") + require.Error(t, err) + assert.EqualError(t, err, fmt.Sprintf("mask value length is less than identifier suffix length (%d)", identifierValueSuffixLength)) + }) + + t.Run("error: value length equals suffix length", func(t *testing.T) { + t.Parallel() + _, err := MaskKey("test", "1234") + require.Error(t, err) + assert.EqualError(t, err, fmt.Sprintf("mask value length is equal to identifier suffix length (%d), which would expose the entire identifier in the mask", identifierValueSuffixLength)) + }) +} + +func TestGenerateKey(t *testing.T) { + t.Parallel() + keyLength := 40 + + t.Run("succeeds", func(t *testing.T) { + t.Parallel() + key, err := GenerateKey("test_") + require.NoError(t, err) + assert.Regexp(t, "^test_.*", key.PrefixedRawValue) + assert.Equal(t, "test_", key.Masked.Prefix) + assert.Equal(t, keyLength, key.Masked.ValueLength) + assert.Regexp(t, "^[0-9a-f]{"+strconv.Itoa(identifierValuePrefixLength)+"}$", key.Masked.MaskedValuePrefix) + assert.Regexp(t, "^[0-9a-f]{"+strconv.Itoa(identifierValueSuffixLength)+"}$", key.Masked.MaskedValueSuffix) + assert.Regexp(t, "^\\$sha256\\$.*", key.HashedValue) + }) + + t.Run("no prefix", func(t *testing.T) { + t.Parallel() + key, err := GenerateKey("") + require.NoError(t, err) + assert.Regexp(t, "^[0-9a-f]{"+strconv.Itoa(keyLength)+"}$", key.PrefixedRawValue) + assert.Empty(t, key.Masked.Prefix) + assert.Equal(t, keyLength, key.Masked.ValueLength) + assert.Regexp(t, "^[0-9a-f]{"+strconv.Itoa(identifierValuePrefixLength)+"}$", key.Masked.MaskedValuePrefix) + assert.Regexp(t, "^[0-9a-f]{"+strconv.Itoa(identifierValueSuffixLength)+"}$", key.Masked.MaskedValueSuffix) + assert.Regexp(t, "^\\$sha256\\$.*", key.HashedValue) + }) +} + +func TestGetMaskedIdentifierProperties(t *testing.T) { + t.Parallel() + type testCase struct { + name string + prefix string + value string + expectedResult MaskedIdentifier + expectedErrString string + } + + testCases := []testCase{ + // --- ERROR CASES (value's length <= identifierValueSuffixLength) --- + { + name: "error: value length < suffix length (3 vs 4)", + prefix: "pk_", + value: "abc", + expectedResult: MaskedIdentifier{}, + expectedErrString: fmt.Sprintf("mask value length is less than identifier suffix length (%d)", identifierValueSuffixLength), + }, + { + name: "error: value length == suffix length (4 vs 4)", + prefix: "sk_", + value: "abcd", + expectedResult: MaskedIdentifier{}, + expectedErrString: fmt.Sprintf("mask value length is equal to identifier suffix length (%d), which would expose the entire identifier in the mask", identifierValueSuffixLength), + }, + { + name: "error: value length < suffix length (0 vs 4, empty value)", + prefix: "err_", + value: "", + expectedResult: MaskedIdentifier{}, + expectedErrString: fmt.Sprintf("mask value length is less than identifier suffix length (%d)", identifierValueSuffixLength), + }, + + // --- SUCCESS CASES (value's length > identifierValueSuffixLength) --- + { + name: "success: value long (10), prefix val len fully used", + prefix: "pk_", + value: "abcdefghij", + expectedResult: MaskedIdentifier{ + Prefix: "pk_", + ValueLength: 10, + MaskedValuePrefix: "ab", + MaskedValueSuffix: "ghij", + }, + }, + { + name: "success: value medium (5), prefix val len truncated by overlap", + prefix: "", + value: "abcde", + expectedResult: MaskedIdentifier{ + Prefix: "", + ValueLength: 5, + MaskedValuePrefix: "a", + MaskedValueSuffix: "bcde", + }, + }, + { + name: "success: value medium (6), prefix val len fits exactly", + prefix: "pk_", + value: "abcdef", + expectedResult: MaskedIdentifier{ + Prefix: "pk_", + ValueLength: 6, + MaskedValuePrefix: "ab", + MaskedValueSuffix: "cdef", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result, err := MaskKey(tc.prefix, tc.value) + + if tc.expectedErrString != "" { + require.EqualError(t, err, tc.expectedErrString) + assert.Equal(t, tc.expectedResult, result) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expectedResult, result) + } + }) + } +} diff --git a/envd/internal/shared/keys/sha256.go b/envd/internal/shared/keys/sha256.go new file mode 100644 index 0000000..879bb10 --- /dev/null +++ b/envd/internal/shared/keys/sha256.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 + +package keys + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" +) + +type Sha256Hashing struct{} + +func NewSHA256Hashing() *Sha256Hashing { + return &Sha256Hashing{} +} + +func (h *Sha256Hashing) Hash(key []byte) string { + hashBytes := sha256.Sum256(key) + + hash64 := base64.RawStdEncoding.EncodeToString(hashBytes[:]) + + return fmt.Sprintf( + "$sha256$%s", + hash64, + ) +} + +func (h *Sha256Hashing) HashWithoutPrefix(key []byte) string { + hashBytes := sha256.Sum256(key) + + return base64.RawStdEncoding.EncodeToString(hashBytes[:]) +} diff --git a/envd/internal/shared/keys/sha256_test.go b/envd/internal/shared/keys/sha256_test.go new file mode 100644 index 0000000..9722fbe --- /dev/null +++ b/envd/internal/shared/keys/sha256_test.go @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 + +package keys + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSHA256Hashing(t *testing.T) { + t.Parallel() + hasher := NewSHA256Hashing() + + hashed := hasher.Hash([]byte("test")) + assert.Regexp(t, "^\\$sha256\\$.*", hashed) +} diff --git a/envd/internal/shared/keys/sha512.go b/envd/internal/shared/keys/sha512.go new file mode 100644 index 0000000..3bc3039 --- /dev/null +++ b/envd/internal/shared/keys/sha512.go @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 + +package keys + +import ( + "crypto/sha512" + "encoding/hex" +) + +// HashAccessToken computes the SHA-512 hash of an access token. +func HashAccessToken(token string) string { + h := sha512.Sum512([]byte(token)) + + return hex.EncodeToString(h[:]) +} + +// HashAccessTokenBytes computes the SHA-512 hash of an access token from bytes. +func HashAccessTokenBytes(token []byte) string { + h := sha512.Sum512(token) + + return hex.EncodeToString(h[:]) +} diff --git a/envd/internal/shared/smap/smap.go b/envd/internal/shared/smap/smap.go new file mode 100644 index 0000000..fc1f816 --- /dev/null +++ b/envd/internal/shared/smap/smap.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 + +package smap + +import ( + cmap "github.com/orcaman/concurrent-map/v2" +) + +type Map[V any] struct { + m cmap.ConcurrentMap[string, V] +} + +func New[V any]() *Map[V] { + return &Map[V]{ + m: cmap.New[V](), + } +} + +func (m *Map[V]) Remove(key string) { + m.m.Remove(key) +} + +func (m *Map[V]) Get(key string) (V, bool) { + return m.m.Get(key) +} + +func (m *Map[V]) Insert(key string, value V) { + m.m.Set(key, value) +} + +func (m *Map[V]) Upsert(key string, value V, cb cmap.UpsertCb[V]) V { + return m.m.Upsert(key, value, cb) +} + +func (m *Map[V]) InsertIfAbsent(key string, value V) bool { + return m.m.SetIfAbsent(key, value) +} + +func (m *Map[V]) Items() map[string]V { + return m.m.Items() +} + +func (m *Map[V]) RemoveCb(key string, cb func(key string, v V, exists bool) bool) bool { + return m.m.RemoveCb(key, cb) +} + +func (m *Map[V]) Count() int { + return m.m.Count() +} diff --git a/envd/internal/shared/utils/ptr.go b/envd/internal/shared/utils/ptr.go new file mode 100644 index 0000000..08c4090 --- /dev/null +++ b/envd/internal/shared/utils/ptr.go @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 + +package utils + +import "fmt" + +func ToPtr[T any](v T) *T { + return &v +} + +func FromPtr[T any](s *T) T { + if s == nil { + var zero T + + return zero + } + + return *s +} + +func Sprintp[T any](s *T) string { + if s == nil { + return "" + } + + return fmt.Sprintf("%v", *s) +} + +func DerefOrDefault[T any](s *T, defaultValue T) T { + if s == nil { + return defaultValue + } + + return *s +} + +func CastPtr[S any, T any](s *S, castFunc func(S) T) *T { + if s == nil { + return nil + } + + t := castFunc(*s) + + return &t +} diff --git a/envd/internal/utils/atomic.go b/envd/internal/utils/atomic.go new file mode 100644 index 0000000..6daa190 --- /dev/null +++ b/envd/internal/utils/atomic.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: Apache-2.0 + +package utils + +import ( + "sync" +) + +type AtomicMax struct { + val int64 + mu sync.Mutex +} + +func NewAtomicMax() *AtomicMax { + return &AtomicMax{} +} + +func (a *AtomicMax) SetToGreater(newValue int64) bool { + a.mu.Lock() + defer a.mu.Unlock() + + if a.val > newValue { + return false + } + + a.val = newValue + + return true +} diff --git a/envd/internal/utils/atomic_test.go b/envd/internal/utils/atomic_test.go new file mode 100644 index 0000000..5a01ddd --- /dev/null +++ b/envd/internal/utils/atomic_test.go @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 + +package utils + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAtomicMax_NewAtomicMax(t *testing.T) { + t.Parallel() + am := NewAtomicMax() + require.NotNil(t, am) + require.Equal(t, int64(0), am.val) +} + +func TestAtomicMax_SetToGreater_InitialValue(t *testing.T) { + t.Parallel() + am := NewAtomicMax() + + // Should succeed when newValue > current + assert.True(t, am.SetToGreater(10)) + assert.Equal(t, int64(10), am.val) +} + +func TestAtomicMax_SetToGreater_EqualValue(t *testing.T) { + t.Parallel() + am := NewAtomicMax() + am.val = 10 + + // Should succeed when newValue > current + assert.True(t, am.SetToGreater(20)) + assert.Equal(t, int64(20), am.val) +} + +func TestAtomicMax_SetToGreater_GreaterValue(t *testing.T) { + t.Parallel() + am := NewAtomicMax() + am.val = 10 + + // Should fail when newValue < current, keeping the max value + assert.False(t, am.SetToGreater(5)) + assert.Equal(t, int64(10), am.val) +} + +func TestAtomicMax_SetToGreater_NegativeValues(t *testing.T) { + t.Parallel() + am := NewAtomicMax() + am.val = -5 + + assert.True(t, am.SetToGreater(-2)) + assert.Equal(t, int64(-2), am.val) +} + +func TestAtomicMax_SetToGreater_Concurrent(t *testing.T) { + t.Parallel() + am := NewAtomicMax() + var wg sync.WaitGroup + + // Run 100 goroutines trying to update the value concurrently + numGoroutines := 100 + wg.Add(numGoroutines) + + for i := range numGoroutines { + go func(val int64) { + defer wg.Done() + am.SetToGreater(val) + }(int64(i)) + } + + wg.Wait() + + // The final value should be 99 (the maximum value) + assert.Equal(t, int64(99), am.val) +} diff --git a/envd/internal/utils/map.go b/envd/internal/utils/map.go new file mode 100644 index 0000000..b1e522b --- /dev/null +++ b/envd/internal/utils/map.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 + +package utils + +import "sync" + +type Map[K comparable, V any] struct { + m sync.Map +} + +func NewMap[K comparable, V any]() *Map[K, V] { + return &Map[K, V]{ + m: sync.Map{}, + } +} + +func (m *Map[K, V]) Delete(key K) { + m.m.Delete(key) +} + +func (m *Map[K, V]) Load(key K) (value V, ok bool) { + v, ok := m.m.Load(key) + if !ok { + return value, ok + } + + return v.(V), ok +} + +func (m *Map[K, V]) LoadAndDelete(key K) (value V, loaded bool) { + v, loaded := m.m.LoadAndDelete(key) + if !loaded { + return value, loaded + } + + return v.(V), loaded +} + +func (m *Map[K, V]) LoadOrStore(key K, value V) (actual V, loaded bool) { + a, loaded := m.m.LoadOrStore(key, value) + + return a.(V), loaded +} + +func (m *Map[K, V]) Range(f func(key K, value V) bool) { + m.m.Range(func(key, value any) bool { + return f(key.(K), value.(V)) + }) +} + +func (m *Map[K, V]) Store(key K, value V) { + m.m.Store(key, value) +} diff --git a/envd/internal/utils/multipart.go b/envd/internal/utils/multipart.go new file mode 100644 index 0000000..64dce42 --- /dev/null +++ b/envd/internal/utils/multipart.go @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 + +package utils + +import ( + "errors" + "mime" + "mime/multipart" +) + +// CustomPart is a wrapper around multipart.Part that overloads the FileName method +type CustomPart struct { + *multipart.Part +} + +// FileNameWithPath returns the filename parameter of the Part's Content-Disposition header. +// This method borrows from the original FileName method implementation but returns the full +// filename without using `filepath.Base`. +func (p *CustomPart) FileNameWithPath() (string, error) { + dispositionParams, err := p.parseContentDisposition() + if err != nil { + return "", err + } + filename, ok := dispositionParams["filename"] + if !ok { + return "", errors.New("filename not found in Content-Disposition header") + } + + return filename, nil +} + +func (p *CustomPart) parseContentDisposition() (map[string]string, error) { + v := p.Header.Get("Content-Disposition") + _, dispositionParams, err := mime.ParseMediaType(v) + if err != nil { + return nil, err + } + + return dispositionParams, nil +} + +// NewCustomPart creates a new CustomPart from a multipart.Part +func NewCustomPart(part *multipart.Part) *CustomPart { + return &CustomPart{Part: part} +} diff --git a/envd/internal/utils/rfsnotify.go b/envd/internal/utils/rfsnotify.go new file mode 100644 index 0000000..68918f6 --- /dev/null +++ b/envd/internal/utils/rfsnotify.go @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: Apache-2.0 + +package utils + +import "path/filepath" + +// FsnotifyPath creates an optionally recursive path for fsnotify/fsnotify internal implementation +func FsnotifyPath(path string, recursive bool) string { + if recursive { + return filepath.Join(path, "...") + } + + return path +} diff --git a/envd/main.go b/envd/main.go index e69de29..751788d 100644 --- a/envd/main.go +++ b/envd/main.go @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modifications by M/S Omukk + +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "time" + + "connectrpc.com/authn" + connectcors "connectrpc.com/cors" + "github.com/go-chi/chi/v5" + "github.com/rs/cors" + + "git.omukk.dev/wrenn/sandbox/envd/internal/api" + "git.omukk.dev/wrenn/sandbox/envd/internal/execcontext" + "git.omukk.dev/wrenn/sandbox/envd/internal/host" + "git.omukk.dev/wrenn/sandbox/envd/internal/logs" + "git.omukk.dev/wrenn/sandbox/envd/internal/permissions" + publicport "git.omukk.dev/wrenn/sandbox/envd/internal/port" + "git.omukk.dev/wrenn/sandbox/envd/internal/services/cgroups" + filesystemRpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/filesystem" + processRpc "git.omukk.dev/wrenn/sandbox/envd/internal/services/process" + processSpec "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec/process" + "git.omukk.dev/wrenn/sandbox/envd/internal/utils" +) + +const ( + // Downstream timeout should be greater than upstream (in orchestrator proxy). + idleTimeout = 640 * time.Second + maxAge = 2 * time.Hour + + defaultPort = 49983 + + portScannerInterval = 1000 * time.Millisecond + + // This is the default user used in the container if not specified otherwise. + // It should be always overridden by the user in /init when building the template. + defaultUser = "root" + + kilobyte = 1024 + megabyte = 1024 * kilobyte +) + +var ( + Version = "0.5.4" + + commitSHA string + + isNotFC bool + port int64 + + versionFlag bool + commitFlag bool + startCmdFlag string + cgroupRoot string +) + +func parseFlags() { + flag.BoolVar( + &isNotFC, + "isnotfc", + false, + "isNotFCmode prints all logs to stdout", + ) + + flag.BoolVar( + &versionFlag, + "version", + false, + "print envd version", + ) + + flag.BoolVar( + &commitFlag, + "commit", + false, + "print envd source commit", + ) + + flag.Int64Var( + &port, + "port", + defaultPort, + "a port on which the daemon should run", + ) + + flag.StringVar( + &startCmdFlag, + "cmd", + "", + "a command to run on the daemon start", + ) + + flag.StringVar( + &cgroupRoot, + "cgroup-root", + "/sys/fs/cgroup", + "cgroup root directory", + ) + + flag.Parse() +} + +func withCORS(h http.Handler) http.Handler { + middleware := cors.New(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{ + http.MethodHead, + http.MethodGet, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + }, + AllowedHeaders: []string{"*"}, + ExposedHeaders: append( + connectcors.ExposedHeaders(), + "Location", + "Cache-Control", + "X-Content-Type-Options", + ), + MaxAge: int(maxAge.Seconds()), + }) + + return middleware.Handler(h) +} + +func main() { + parseFlags() + + if versionFlag { + fmt.Printf("%s\n", Version) + + return + } + + if commitFlag { + fmt.Printf("%s\n", commitSHA) + + return + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := os.MkdirAll(host.WrennRunDir, 0o755); err != nil { + fmt.Fprintf(os.Stderr, "error creating wrenn run directory: %v\n", err) + } + + defaults := &execcontext.Defaults{ + User: defaultUser, + EnvVars: utils.NewMap[string, string](), + } + isFCBoolStr := strconv.FormatBool(!isNotFC) + defaults.EnvVars.Store("WRENN_SANDBOX", isFCBoolStr) + if err := os.WriteFile(filepath.Join(host.WrennRunDir, ".WRENN_SANDBOX"), []byte(isFCBoolStr), 0o444); err != nil { + fmt.Fprintf(os.Stderr, "error writing sandbox file: %v\n", err) + } + + mmdsChan := make(chan *host.MMDSOpts, 1) + defer close(mmdsChan) + if !isNotFC { + go host.PollForMMDSOpts(ctx, mmdsChan, defaults.EnvVars) + } + + l := logs.NewLogger(ctx, isNotFC, mmdsChan) + + m := chi.NewRouter() + + envLogger := l.With().Str("logger", "envd").Logger() + fsLogger := l.With().Str("logger", "filesystem").Logger() + filesystemRpc.Handle(m, &fsLogger, defaults) + + cgroupManager := createCgroupManager() + defer func() { + err := cgroupManager.Close() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to close cgroup manager: %v\n", err) + } + }() + + processLogger := l.With().Str("logger", "process").Logger() + processService := processRpc.Handle(m, &processLogger, defaults, cgroupManager) + + service := api.New(&envLogger, defaults, mmdsChan, isNotFC) + handler := api.HandlerFromMux(service, m) + middleware := authn.NewMiddleware(permissions.AuthenticateUsername) + + s := &http.Server{ + Handler: withCORS( + service.WithAuthorization( + middleware.Wrap(handler), + ), + ), + Addr: fmt.Sprintf("0.0.0.0:%d", port), + // We remove the timeouts as the connection is terminated by closing of the sandbox and keepalive close. + ReadTimeout: 0, + WriteTimeout: 0, + IdleTimeout: idleTimeout, + } + + // TODO: Not used anymore in template build, replaced by direct envd command call. + if startCmdFlag != "" { + tag := "startCmd" + cwd := "/home/user" + user, err := permissions.GetUser("root") + if err != nil { + log.Fatalf("error getting user: %v", err) //nolint:gocritic // probably fine to bail if we're done? + } + + if err = processService.InitializeStartProcess(ctx, user, &processSpec.StartRequest{ + Tag: &tag, + Process: &processSpec.ProcessConfig{ + Envs: make(map[string]string), + Cmd: "/bin/bash", + Args: []string{"-l", "-c", startCmdFlag}, + Cwd: &cwd, + }, + }); err != nil { + log.Fatalf("error starting process: %v", err) + } + } + + // Bind all open ports on 127.0.0.1 and localhost to the eth0 interface + portScanner := publicport.NewScanner(portScannerInterval) + defer portScanner.Destroy() + + portLogger := l.With().Str("logger", "port-forwarder").Logger() + portForwarder := publicport.NewForwarder(&portLogger, portScanner, cgroupManager) + go portForwarder.StartForwarding(ctx) + + go portScanner.ScanAndBroadcast() + + err := s.ListenAndServe() + if err != nil { + log.Fatalf("error starting server: %v", err) + } +} + +func createCgroupManager() (m cgroups.Manager) { + defer func() { + if m == nil { + fmt.Fprintf(os.Stderr, "falling back to no-op cgroup manager\n") + m = cgroups.NewNoopManager() + } + }() + + metrics, err := host.GetMetrics() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to calculate host metrics: %v\n", err) + + return nil + } + + // try to keep 1/8 of the memory free, but no more than 128 MB + maxMemoryReserved := uint64(float64(metrics.MemTotal) * .125) + maxMemoryReserved = min(maxMemoryReserved, uint64(128)*megabyte) + + opts := []cgroups.Cgroup2ManagerOption{ + cgroups.WithCgroup2ProcessType(cgroups.ProcessTypePTY, "ptys", map[string]string{ + "cpu.weight": "200", // gets much preferred cpu access, to help keep these real time + }), + cgroups.WithCgroup2ProcessType(cgroups.ProcessTypeSocat, "socats", map[string]string{ + "cpu.weight": "150", // gets slightly preferred cpu access + "memory.min": fmt.Sprintf("%d", 5*megabyte), + "memory.low": fmt.Sprintf("%d", 8*megabyte), + }), + cgroups.WithCgroup2ProcessType(cgroups.ProcessTypeUser, "user", map[string]string{ + "memory.high": fmt.Sprintf("%d", metrics.MemTotal-maxMemoryReserved), + "cpu.weight": "50", // less than envd, and less than core processes that default to 100 + }), + } + if cgroupRoot != "" { + opts = append(opts, cgroups.WithCgroup2RootSysFSPath(cgroupRoot)) + } + + mgr, err := cgroups.NewCgroup2Manager(opts...) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create cgroup2 manager: %v\n", err) + + return nil + } + + return mgr +} diff --git a/envd/spec/buf.gen.yaml b/envd/spec/buf.gen.yaml new file mode 100644 index 0000000..51282c2 --- /dev/null +++ b/envd/spec/buf.gen.yaml @@ -0,0 +1,15 @@ +version: v2 +plugins: + - protoc_builtin: go + out: ../internal/services/spec + opt: paths=source_relative + - local: protoc-gen-connect-go + out: ../internal/services/spec + opt: paths=source_relative +inputs: + - directory: ../../proto/envd +managed: + enabled: true + override: + - file_option: go_package_prefix + value: git.omukk.dev/wrenn/sandbox/envd/internal/services/spec diff --git a/envd/spec/envd.yaml b/envd/spec/envd.yaml new file mode 100644 index 0000000..b86d563 --- /dev/null +++ b/envd/spec/envd.yaml @@ -0,0 +1,305 @@ +# SPDX-License-Identifier: Apache-2.0 + +openapi: 3.0.0 +info: + title: envd + version: 0.1.1 + description: API for managing files' content and controlling envd + +tags: + - name: files + +paths: + /health: + get: + summary: Check the health of the service + responses: + "204": + description: The service is healthy + + /metrics: + get: + summary: Get the stats of the service + security: + - AccessTokenAuth: [] + - {} + responses: + "200": + description: The resource usage metrics of the service + content: + application/json: + schema: + $ref: "#/components/schemas/Metrics" + + /init: + post: + summary: Set initial vars, ensure the time and metadata is synced with the host + security: + - AccessTokenAuth: [] + - {} + requestBody: + content: + application/json: + schema: + type: object + properties: + volumeMounts: + type: array + items: + $ref: "#/components/schemas/VolumeMount" + hyperloopIP: + type: string + description: IP address of the hyperloop server to connect to + envVars: + $ref: "#/components/schemas/EnvVars" + accessToken: + type: string + description: Access token for secure access to envd service + x-go-type: SecureToken + timestamp: + type: string + format: date-time + description: The current timestamp in RFC3339 format + defaultUser: + type: string + description: The default user to use for operations + defaultWorkdir: + type: string + description: The default working directory to use for operations + responses: + "204": + description: Env vars set, the time and metadata is synced with the host + + /envs: + get: + summary: Get the environment variables + security: + - AccessTokenAuth: [] + - {} + responses: + "200": + description: Environment variables + content: + application/json: + schema: + $ref: "#/components/schemas/EnvVars" + + /files: + get: + summary: Download a file + tags: [files] + security: + - AccessTokenAuth: [] + - {} + parameters: + - $ref: "#/components/parameters/FilePath" + - $ref: "#/components/parameters/User" + - $ref: "#/components/parameters/Signature" + - $ref: "#/components/parameters/SignatureExpiration" + responses: + "200": + $ref: "#/components/responses/DownloadSuccess" + "401": + $ref: "#/components/responses/InvalidUser" + "400": + $ref: "#/components/responses/InvalidPath" + "404": + $ref: "#/components/responses/FileNotFound" + "500": + $ref: "#/components/responses/InternalServerError" + post: + summary: Upload a file and ensure the parent directories exist. If the file exists, it will be overwritten. + tags: [files] + security: + - AccessTokenAuth: [] + - {} + parameters: + - $ref: "#/components/parameters/FilePath" + - $ref: "#/components/parameters/User" + - $ref: "#/components/parameters/Signature" + - $ref: "#/components/parameters/SignatureExpiration" + requestBody: + $ref: "#/components/requestBodies/File" + responses: + "200": + $ref: "#/components/responses/UploadSuccess" + "400": + $ref: "#/components/responses/InvalidPath" + "401": + $ref: "#/components/responses/InvalidUser" + "500": + $ref: "#/components/responses/InternalServerError" + "507": + $ref: "#/components/responses/NotEnoughDiskSpace" + +components: + securitySchemes: + AccessTokenAuth: + type: apiKey + in: header + name: X-Access-Token + + parameters: + FilePath: + name: path + in: query + required: false + description: Path to the file, URL encoded. Can be relative to user's home directory. + schema: + type: string + User: + name: username + in: query + required: false + description: User used for setting the owner, or resolving relative paths. + schema: + type: string + Signature: + name: signature + in: query + required: false + description: Signature used for file access permission verification. + schema: + type: string + SignatureExpiration: + name: signature_expiration + in: query + required: false + description: Signature expiration used for defining the expiration time of the signature. + schema: + type: integer + + requestBodies: + File: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + + responses: + UploadSuccess: + description: The file was uploaded successfully. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/EntryInfo" + + DownloadSuccess: + description: Entire file downloaded successfully. + content: + application/octet-stream: + schema: + type: string + format: binary + description: The file content + InvalidPath: + description: Invalid path + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + InternalServerError: + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + FileNotFound: + description: File not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + InvalidUser: + description: Invalid user + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + NotEnoughDiskSpace: + description: Not enough disk space + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + schemas: + Error: + required: + - message + - code + properties: + message: + type: string + description: Error message + code: + type: integer + description: Error code + EntryInfo: + required: + - path + - name + - type + properties: + path: + type: string + description: Path to the file + name: + type: string + description: Name of the file + type: + type: string + description: Type of the file + enum: + - file + EnvVars: + type: object + description: Environment variables to set + additionalProperties: + type: string + Metrics: + type: object + description: Resource usage metrics + properties: + ts: + type: integer + format: int64 + description: Unix timestamp in UTC for current sandbox time + cpu_count: + type: integer + description: Number of CPU cores + cpu_used_pct: + type: number + format: float + description: CPU usage percentage + mem_total: + type: integer + description: Total virtual memory in bytes + mem_used: + type: integer + description: Used virtual memory in bytes + disk_used: + type: integer + description: Used disk space in bytes + disk_total: + type: integer + description: Total disk space in bytes + VolumeMount: + type: object + description: Volume + additionalProperties: false + properties: + nfs_target: + type: string + path: + type: string + required: + - nfs_target + - path diff --git a/envd/spec/generate.go b/envd/spec/generate.go new file mode 100644 index 0000000..60bb0d1 --- /dev/null +++ b/envd/spec/generate.go @@ -0,0 +1,3 @@ +package spec + +//go:generate buf generate diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f694403 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@fontsource-variable/jetbrains-mono": "^5.2.8", + "@fontsource-variable/manrope": "^5.2.8", + "@fontsource/alice": "^5.2.8", + "@fontsource/instrument-serif": "^5.2.8", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.2.1", + "bits-ui": "^2.16.3", + "svelte": "^5.51.0", + "svelte-check": "^4.4.2", + "tailwindcss": "^4.2.1", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..9f0353e --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,1491 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@fontsource-variable/jetbrains-mono': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource-variable/manrope': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource/alice': + specifier: ^5.2.8 + version: 5.2.8 + '@fontsource/instrument-serif': + specifier: ^5.2.8 + version: 5.2.8 + '@sveltejs/adapter-static': + specifier: ^3.0.10 + version: 3.0.10(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1))) + '@sveltejs/kit': + specifier: ^2.50.2 + version: 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)) + '@sveltejs/vite-plugin-svelte': + specifier: ^6.2.4 + version: 6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)) + '@tailwindcss/vite': + specifier: ^4.2.1 + version: 4.2.1(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)) + bits-ui: + specifier: ^2.16.3 + version: 2.16.3(@internationalized/date@3.12.0)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12) + svelte: + specifier: ^5.51.0 + version: 5.53.12 + svelte-check: + specifier: ^4.4.2 + version: 4.4.5(picomatch@4.0.3)(svelte@5.53.12)(typescript@5.9.3) + tailwindcss: + specifier: ^4.2.1 + version: 4.2.1 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^7.3.1 + version: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1) + +packages: + + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@fontsource-variable/jetbrains-mono@5.2.8': + resolution: {integrity: sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==} + + '@fontsource-variable/manrope@5.2.8': + resolution: {integrity: sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==} + + '@fontsource/alice@5.2.8': + resolution: {integrity: sha512-EDpK9aFXsaRKdyZpgFu8d5+zmE07yIaFxqVeKrYQJjdQpEhWDZA+naLflHwQQmMbLMJK3a4X/RAm5MCScT93NA==} + + '@fontsource/instrument-serif@5.2.8': + resolution: {integrity: sha512-s+bkz+syj2rO00Rmq9g0P+PwuLig33DR1xDR8pTWmovH1pUjwnncrFk++q9mmOex8fUQ7oW80gPpPDaw7V1MMw==} + + '@internationalized/date@3.12.0': + resolution: {integrity: sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@sveltejs/acorn-typescript@1.0.9': + resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/adapter-static@3.0.10': + resolution: {integrity: sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==} + peerDependencies: + '@sveltejs/kit': ^2.0.0 + + '@sveltejs/kit@2.55.0': + resolution: {integrity: sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==} + engines: {node: '>=18.13'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0 + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.3.3 + vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + typescript: + optional: true + + '@sveltejs/vite-plugin-svelte-inspector@5.0.2': + resolution: {integrity: sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^6.0.0-next.0 + svelte: ^5.0.0 + vite: ^6.3.0 || ^7.0.0 + + '@sveltejs/vite-plugin-svelte@6.2.4': + resolution: {integrity: sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + svelte: ^5.0.0 + vite: ^6.3.0 || ^7.0.0 + + '@swc/helpers@0.5.19': + resolution: {integrity: sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==} + + '@tailwindcss/node@4.2.1': + resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} + + '@tailwindcss/oxide-android-arm64@4.2.1': + resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.1': + resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.1': + resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.1': + resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.2.1': + resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 + + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@typescript-eslint/types@8.57.1': + resolution: {integrity: sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + bits-ui@2.16.3: + resolution: {integrity: sha512-5hJ5dEhf5yPzkRFcxzgQHScGodeo0gK0MUUXrdLlRHWaBOBGZiacWLG96j/wwFatKwZvouw7q+sn14i0fx3RIg==} + engines: {node: '>=20'} + peerDependencies: + '@internationalized/date': ^3.8.1 + svelte: ^5.33.0 + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@5.6.4: + resolution: {integrity: sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==} + + enhanced-resolve@5.20.1: + resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} + engines: {node: '>=10.13.0'} + + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + engines: {node: '>=18'} + hasBin: true + + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + esrap@2.2.4: + resolution: {integrity: sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + lightningcss-android-arm64@1.31.1: + resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.31.1: + resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.31.1: + resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.31.1: + resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.31.1: + resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.31.1: + resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.31.1: + resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.31.1: + resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.31.1: + resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.31.1: + resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.31.1: + resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.31.1: + resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} + engines: {node: '>= 12.0.0'} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + runed@0.35.1: + resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==} + peerDependencies: + '@sveltejs/kit': ^2.21.0 + svelte: ^5.7.0 + peerDependenciesMeta: + '@sveltejs/kit': + optional: true + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + set-cookie-parser@3.0.1: + resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + svelte-check@4.4.5: + resolution: {integrity: sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: '>=5.0.0' + + svelte-toolbelt@0.10.6: + resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==} + engines: {node: '>=18', pnpm: '>=8.7.0'} + peerDependencies: + svelte: ^5.30.2 + + svelte@5.53.12: + resolution: {integrity: sha512-4x/uk4rQe/d7RhfvS8wemTfNjQ0bJbKvamIzRBfTe2eHHjzBZ7PZicUQrC2ryj83xxEacfA1zHKd1ephD1tAxA==} + engines: {node: '>=18'} + + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + tailwindcss@4.2.1: + resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.2: + resolution: {integrity: sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0 + peerDependenciesMeta: + vite: + optional: true + + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.27.4': + optional: true + + '@esbuild/android-arm64@0.27.4': + optional: true + + '@esbuild/android-arm@0.27.4': + optional: true + + '@esbuild/android-x64@0.27.4': + optional: true + + '@esbuild/darwin-arm64@0.27.4': + optional: true + + '@esbuild/darwin-x64@0.27.4': + optional: true + + '@esbuild/freebsd-arm64@0.27.4': + optional: true + + '@esbuild/freebsd-x64@0.27.4': + optional: true + + '@esbuild/linux-arm64@0.27.4': + optional: true + + '@esbuild/linux-arm@0.27.4': + optional: true + + '@esbuild/linux-ia32@0.27.4': + optional: true + + '@esbuild/linux-loong64@0.27.4': + optional: true + + '@esbuild/linux-mips64el@0.27.4': + optional: true + + '@esbuild/linux-ppc64@0.27.4': + optional: true + + '@esbuild/linux-riscv64@0.27.4': + optional: true + + '@esbuild/linux-s390x@0.27.4': + optional: true + + '@esbuild/linux-x64@0.27.4': + optional: true + + '@esbuild/netbsd-arm64@0.27.4': + optional: true + + '@esbuild/netbsd-x64@0.27.4': + optional: true + + '@esbuild/openbsd-arm64@0.27.4': + optional: true + + '@esbuild/openbsd-x64@0.27.4': + optional: true + + '@esbuild/openharmony-arm64@0.27.4': + optional: true + + '@esbuild/sunos-x64@0.27.4': + optional: true + + '@esbuild/win32-arm64@0.27.4': + optional: true + + '@esbuild/win32-ia32@0.27.4': + optional: true + + '@esbuild/win32-x64@0.27.4': + optional: true + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/utils@0.2.11': {} + + '@fontsource-variable/jetbrains-mono@5.2.8': {} + + '@fontsource-variable/manrope@5.2.8': {} + + '@fontsource/alice@5.2.8': {} + + '@fontsource/instrument-serif@5.2.8': {} + + '@internationalized/date@3.12.0': + dependencies: + '@swc/helpers': 0.5.19 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@polka/url@1.0.0-next.29': {} + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)': + dependencies: + acorn: 8.16.0 + + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))': + dependencies: + '@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)) + + '@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1))': + dependencies: + '@standard-schema/spec': 1.1.0 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)) + '@types/cookie': 0.6.0 + acorn: 8.16.0 + cookie: 0.6.0 + devalue: 5.6.4 + esm-env: 1.2.2 + kleur: 4.1.5 + magic-string: 0.30.21 + mrmime: 2.0.1 + set-cookie-parser: 3.0.1 + sirv: 3.0.2 + svelte: 5.53.12 + vite: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1) + optionalDependencies: + typescript: 5.9.3 + + '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1))': + dependencies: + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)) + obug: 2.1.1 + svelte: 5.53.12 + vite: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1) + + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)) + deepmerge: 4.3.1 + magic-string: 0.30.21 + obug: 2.1.1 + svelte: 5.53.12 + vite: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1) + vitefu: 1.1.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)) + + '@swc/helpers@0.5.19': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.2.1': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.1 + jiti: 2.6.1 + lightningcss: 1.31.1 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.1 + + '@tailwindcss/oxide-android-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide@4.2.1': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-x64': 4.2.1 + '@tailwindcss/oxide-freebsd-x64': 4.2.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-x64-musl': 4.2.1 + '@tailwindcss/oxide-wasm32-wasi': 4.2.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 + + '@tailwindcss/vite@4.2.1(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1))': + dependencies: + '@tailwindcss/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 + tailwindcss: 4.2.1 + vite: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1) + + '@types/cookie@0.6.0': {} + + '@types/estree@1.0.8': {} + + '@types/trusted-types@2.0.7': {} + + '@typescript-eslint/types@8.57.1': {} + + acorn@8.16.0: {} + + aria-query@5.3.1: {} + + axobject-query@4.1.0: {} + + bits-ui@2.16.3(@internationalized/date@3.12.0)(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12): + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/dom': 1.7.6 + '@internationalized/date': 3.12.0 + esm-env: 1.2.2 + runed: 0.35.1(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12) + svelte: 5.53.12 + svelte-toolbelt: 0.10.6(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12) + tabbable: 6.4.0 + transitivePeerDependencies: + - '@sveltejs/kit' + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + clsx@2.1.1: {} + + cookie@0.6.0: {} + + deepmerge@4.3.1: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + devalue@5.6.4: {} + + enhanced-resolve@5.20.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + + esm-env@1.2.2: {} + + esrap@2.2.4: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@typescript-eslint/types': 8.57.1 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fsevents@2.3.3: + optional: true + + graceful-fs@4.2.11: {} + + inline-style-parser@0.2.7: {} + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + jiti@2.6.1: {} + + kleur@4.1.5: {} + + lightningcss-android-arm64@1.31.1: + optional: true + + lightningcss-darwin-arm64@1.31.1: + optional: true + + lightningcss-darwin-x64@1.31.1: + optional: true + + lightningcss-freebsd-x64@1.31.1: + optional: true + + lightningcss-linux-arm-gnueabihf@1.31.1: + optional: true + + lightningcss-linux-arm64-gnu@1.31.1: + optional: true + + lightningcss-linux-arm64-musl@1.31.1: + optional: true + + lightningcss-linux-x64-gnu@1.31.1: + optional: true + + lightningcss-linux-x64-musl@1.31.1: + optional: true + + lightningcss-win32-arm64-msvc@1.31.1: + optional: true + + lightningcss-win32-x64-msvc@1.31.1: + optional: true + + lightningcss@1.31.1: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.31.1 + lightningcss-darwin-arm64: 1.31.1 + lightningcss-darwin-x64: 1.31.1 + lightningcss-freebsd-x64: 1.31.1 + lightningcss-linux-arm-gnueabihf: 1.31.1 + lightningcss-linux-arm64-gnu: 1.31.1 + lightningcss-linux-arm64-musl: 1.31.1 + lightningcss-linux-x64-gnu: 1.31.1 + lightningcss-linux-x64-musl: 1.31.1 + lightningcss-win32-arm64-msvc: 1.31.1 + lightningcss-win32-x64-msvc: 1.31.1 + + locate-character@3.0.0: {} + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mri@1.2.0: {} + + mrmime@2.0.1: {} + + nanoid@3.3.11: {} + + obug@2.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + readdirp@4.1.2: {} + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + runed@0.35.1(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12): + dependencies: + dequal: 2.0.3 + esm-env: 1.2.2 + lz-string: 1.5.0 + svelte: 5.53.12 + optionalDependencies: + '@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)) + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + set-cookie-parser@3.0.1: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + svelte-check@4.4.5(picomatch@4.0.3)(svelte@5.53.12)(typescript@5.9.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.3) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.53.12 + typescript: 5.9.3 + transitivePeerDependencies: + - picomatch + + svelte-toolbelt@0.10.6(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12): + dependencies: + clsx: 2.1.1 + runed: 0.35.1(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.12)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12)(typescript@5.9.3)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)))(svelte@5.53.12) + style-to-object: 1.0.14 + svelte: 5.53.12 + transitivePeerDependencies: + - '@sveltejs/kit' + + svelte@5.53.12: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@types/estree': 1.0.8 + '@types/trusted-types': 2.0.7 + acorn: 8.16.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.6.4 + esm-env: 1.2.2 + esrap: 2.2.4 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + + tabbable@6.4.0: {} + + tailwindcss@4.2.1: {} + + tapable@2.3.0: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + totalist@3.0.1: {} + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1): + dependencies: + esbuild: 0.27.4 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.8 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.31.1 + + vitefu@1.1.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.31.1)): + optionalDependencies: + vite: 7.3.1(jiti@2.6.1)(lightningcss@1.31.1) + + zimmerframe@1.1.4: {} diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..72fd49b --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,121 @@ +@import 'tailwindcss'; + +@import '@fontsource-variable/manrope'; +@import '@fontsource/instrument-serif/400.css'; +@import '@fontsource-variable/jetbrains-mono'; +@import '@fontsource/alice/400.css'; + +/* + * Wrenn Design Tokens + * Sharp, warm, industrial-confident. Dark-first. + */ + +@theme { + /* Background scale (6 steps, near-black-green) */ + --color-bg-0: #0a0c0b; + --color-bg-1: #0f1211; + --color-bg-2: #141817; + --color-bg-3: #1a1e1c; + --color-bg-4: #212624; + --color-bg-5: #2a302d; + + /* Text hierarchy (5 levels) */ + --color-text-bright: #eae7e2; + --color-text-primary: #d0cdc6; + --color-text-secondary: #9b9790; + --color-text-tertiary: #6b6862; + --color-text-muted: #454340; + + /* Sage green brand accent (3 tiers + 2 glows) */ + --color-accent: #5e8c58; + --color-accent-mid: #89a785; + --color-accent-bright: #a4c89f; + --color-accent-glow: rgba(94, 140, 88, 0.07); + --color-accent-glow-mid: rgba(94, 140, 88, 0.14); + + /* Borders (2 levels) */ + --color-border: #1f2321; + --color-border-mid: #2a2f2c; + + /* Semantic status */ + --color-amber: #d4a73c; + --color-red: #cf8172; + --color-blue: #5a9fd4; + + /* Fonts */ + --font-sans: 'Manrope Variable', system-ui, sans-serif; + --font-serif: 'Instrument Serif', serif; + --font-mono: 'JetBrains Mono Variable', monospace; + --font-brand: 'Alice', serif; + + /* Radii */ + --radius-card: 8px; + --radius-input: 5px; + --radius-button: 5px; + --radius-avatar: 5px; + --radius-logo: 6px; + + /* Shadows — flat aesthetic */ + --shadow-sm: 0 0 #0000; +} + +/* Base styles */ +html { + font-family: var(--font-sans); + font-size: 14px; + color: var(--color-text-primary); + background-color: var(--color-bg-0); +} + +body { + margin: 0; + min-height: 100vh; +} + +/* Selection */ +::selection { + background: rgba(94, 140, 88, 0.25); + color: var(--color-text-bright); +} + +/* Scrollbar — thin, matches dark theme */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--color-bg-4); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-bg-5); +} + +/* Live status dot glow animation */ +@keyframes wrenn-glow { + 0%, + 100% { + box-shadow: 0 0 6px rgba(94, 140, 88, 0.5); + } + 50% { + box-shadow: 0 0 14px rgba(94, 140, 88, 0.2); + } +} + +/* Fade-up entrance animation */ +@keyframes fadeUp { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/frontend/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..82dd861 --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,15 @@ + + + + + + + + + Wrenn + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts new file mode 100644 index 0000000..51b987a --- /dev/null +++ b/frontend/src/lib/api/auth.ts @@ -0,0 +1,37 @@ +export type AuthResponse = { + token: string; + user_id: string; + team_id: string; + email: string; +}; + +export type AuthResult = { ok: true; data: AuthResponse } | { ok: false; error: string }; + +export async function apiLogin(email: string, password: string): Promise { + return authFetch('/api/v1/auth/login', { email, password }); +} + +export async function apiSignup(email: string, password: string): Promise { + return authFetch('/api/v1/auth/signup', { email, password }); +} + +async function authFetch(url: string, body: Record): Promise { + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + const data = await res.json(); + + if (!res.ok) { + const message = data?.error?.message ?? 'Something went wrong'; + return { ok: false, error: message }; + } + + return { ok: true, data: data as AuthResponse }; + } catch { + return { ok: false, error: 'Unable to connect to the server' }; + } +} diff --git a/frontend/src/lib/api/capsules.ts b/frontend/src/lib/api/capsules.ts new file mode 100644 index 0000000..c51737a --- /dev/null +++ b/frontend/src/lib/api/capsules.ts @@ -0,0 +1,66 @@ +import { apiFetch, type ApiResult } from '$lib/api/client'; + +export type Capsule = { + id: string; + status: string; + template: string; + vcpus: number; + memory_mb: number; + timeout_sec: number; + guest_ip?: string; + host_ip?: string; + created_at: string; + started_at?: string; + last_active_at?: string; + last_updated: string; +}; + + +export async function listCapsules(): Promise> { + return apiFetch('GET', '/api/v1/sandboxes'); +} + +export type CreateCapsuleParams = { + template?: string; + vcpus?: number; + memory_mb?: number; + timeout_sec?: number; +}; + +export async function createCapsule(params: CreateCapsuleParams): Promise> { + return apiFetch('POST', '/api/v1/sandboxes', params); +} + +export async function pauseCapsule(id: string): Promise> { + return apiFetch('POST', `/api/v1/sandboxes/${id}/pause`); +} + +export async function resumeCapsule(id: string): Promise> { + return apiFetch('POST', `/api/v1/sandboxes/${id}/resume`); +} + +export async function destroyCapsule(id: string): Promise> { + return apiFetch('DELETE', `/api/v1/sandboxes/${id}`); +} + +export type Snapshot = { + name: string; + type: string; + vcpus?: number; + memory_mb?: number; + size_bytes: number; + created_at: string; +}; + +export async function createSnapshot(sandboxId: string, name?: string): Promise> { + return apiFetch('POST', '/api/v1/snapshots', { sandbox_id: sandboxId, name }); +} + +export async function listSnapshots(typeFilter?: string): Promise> { + const url = typeFilter ? `/api/v1/snapshots?type=${typeFilter}` : '/api/v1/snapshots'; + return apiFetch('GET', url); +} + +export async function deleteSnapshot(name: string): Promise> { + return apiFetch('DELETE', `/api/v1/snapshots/${name}`); +} diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts new file mode 100644 index 0000000..00fa381 --- /dev/null +++ b/frontend/src/lib/api/client.ts @@ -0,0 +1,24 @@ +import { auth } from '$lib/auth.svelte'; + +export type ApiResult = { ok: true; data: T } | { ok: false; error: string }; + +export async function apiFetch(method: string, path: string, body?: unknown): Promise> { + try { + const headers: Record = { 'Content-Type': 'application/json' }; + if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`; + + const res = await fetch(path, { + method, + headers, + body: body ? JSON.stringify(body) : undefined + }); + + if (res.status === 204) return { ok: true, data: undefined as T }; + + const data = await res.json(); + if (!res.ok) return { ok: false, error: data?.error?.message ?? 'Something went wrong' }; + return { ok: true, data: data as T }; + } catch { + return { ok: false, error: 'Unable to connect to the server' }; + } +} diff --git a/frontend/src/lib/api/keys.ts b/frontend/src/lib/api/keys.ts new file mode 100644 index 0000000..55fcae4 --- /dev/null +++ b/frontend/src/lib/api/keys.ts @@ -0,0 +1,26 @@ +import { apiFetch, type ApiResult } from '$lib/api/client'; + +export type APIKey = { + id: string; + team_id: string; + name: string; + key_prefix: string; + created_by: string; + creator_email?: string; + created_at: string; + last_used?: string; + key?: string; // only present immediately after creation +}; + + +export async function listKeys(): Promise> { + return apiFetch('GET', '/api/v1/api-keys'); +} + +export async function createKey(name: string): Promise> { + return apiFetch('POST', '/api/v1/api-keys', { name }); +} + +export async function revokeKey(id: string): Promise> { + return apiFetch('DELETE', `/api/v1/api-keys/${id}`); +} diff --git a/frontend/src/lib/auth.svelte.ts b/frontend/src/lib/auth.svelte.ts new file mode 100644 index 0000000..86325df --- /dev/null +++ b/frontend/src/lib/auth.svelte.ts @@ -0,0 +1,94 @@ +import { goto } from '$app/navigation'; + +const STORAGE_KEYS = { + token: 'wrenn_token', + userId: 'wrenn_user_id', + teamId: 'wrenn_team_id', + email: 'wrenn_email' +} as const; + +function isTokenExpired(token: string): boolean { + try { + const payload = token.split('.')[1]; + const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/')); + const { exp } = JSON.parse(decoded); + return Date.now() / 1000 >= exp; + } catch { + return true; + } +} + +function createAuth() { + let token = $state(null); + let userId = $state(null); + let teamId = $state(null); + let email = $state(null); + let initialized = $state(false); + + // Initialize from localStorage synchronously at module load. + if (typeof window !== 'undefined') { + const stored = localStorage.getItem(STORAGE_KEYS.token); + if (stored && !isTokenExpired(stored)) { + token = stored; + userId = localStorage.getItem(STORAGE_KEYS.userId); + teamId = localStorage.getItem(STORAGE_KEYS.teamId); + email = localStorage.getItem(STORAGE_KEYS.email); + } else if (stored) { + // Expired — clean up. + for (const key of Object.values(STORAGE_KEYS)) { + localStorage.removeItem(key); + } + } + initialized = true; + } + + const isAuthenticated = $derived(token !== null && !isTokenExpired(token)); + + return { + get token() { + return token; + }, + get userId() { + return userId; + }, + get teamId() { + return teamId; + }, + get email() { + return email; + }, + get isAuthenticated() { + return isAuthenticated; + }, + get initialized() { + return initialized; + }, + + login(data: { token: string; user_id: string; team_id: string; email: string }) { + token = data.token; + userId = data.user_id; + teamId = data.team_id; + email = data.email; + + localStorage.setItem(STORAGE_KEYS.token, data.token); + localStorage.setItem(STORAGE_KEYS.userId, data.user_id); + localStorage.setItem(STORAGE_KEYS.teamId, data.team_id); + localStorage.setItem(STORAGE_KEYS.email, data.email); + }, + + logout() { + token = null; + userId = null; + teamId = null; + email = null; + + for (const key of Object.values(STORAGE_KEYS)) { + localStorage.removeItem(key); + } + + goto('/login'); + } + }; +} + +export const auth = createAuth(); diff --git a/frontend/src/lib/components/AuthModal.svelte b/frontend/src/lib/components/AuthModal.svelte new file mode 100644 index 0000000..3ab217f --- /dev/null +++ b/frontend/src/lib/components/AuthModal.svelte @@ -0,0 +1,210 @@ + + + + + + + + + + + +
+ +
+ + {title} + + + {subtitle} + +
+ + + + + +
+
+ or +
+
+ + +
+ {#if mode === 'signup'} +
+
+ +
+ +
+ {/if} + +
+
+ +
+ +
+ +
+
+ +
+ + +
+ + {#if mode === 'signin'} +
+ +
+ {/if} + + +
+ + +

+ {switchText} + +

+
+
+
+
+ + diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte new file mode 100644 index 0000000..0c165e2 --- /dev/null +++ b/frontend/src/lib/components/Sidebar.svelte @@ -0,0 +1,308 @@ + + + + +{#snippet navSection(label: string, items: NavItem[])} +
+ {#if collapsed} + {#if label !== 'Platform'} +
+ {/if} + {:else} +
+ {label} +
+ {/if} + {#each items as item} + {#if isActive(item.href)} + + {#if !collapsed} +
+ {/if} + + {#if !collapsed} + + {item.label} + + {/if} +
+ {:else} + + + {#if !collapsed} + + {item.label} + + {/if} + + {/if} + {/each} +
+{/snippet} + + + diff --git a/frontend/src/lib/components/Toaster.svelte b/frontend/src/lib/components/Toaster.svelte new file mode 100644 index 0000000..1416ec0 --- /dev/null +++ b/frontend/src/lib/components/Toaster.svelte @@ -0,0 +1,24 @@ + + +
+ {#each toast.list as t (t.id)} +
+ {t.message} + +
+ {/each} +
diff --git a/frontend/src/lib/components/icons/IconAudit.svelte b/frontend/src/lib/components/icons/IconAudit.svelte new file mode 100644 index 0000000..dac2ef2 --- /dev/null +++ b/frontend/src/lib/components/icons/IconAudit.svelte @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/lib/components/icons/IconBell.svelte b/frontend/src/lib/components/icons/IconBell.svelte new file mode 100644 index 0000000..9321951 --- /dev/null +++ b/frontend/src/lib/components/icons/IconBell.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/icons/IconBilling.svelte b/frontend/src/lib/components/icons/IconBilling.svelte new file mode 100644 index 0000000..9dd0d9c --- /dev/null +++ b/frontend/src/lib/components/icons/IconBilling.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/icons/IconBox.svelte b/frontend/src/lib/components/icons/IconBox.svelte new file mode 100644 index 0000000..f96495d --- /dev/null +++ b/frontend/src/lib/components/icons/IconBox.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/icons/IconCapsule.svelte b/frontend/src/lib/components/icons/IconCapsule.svelte new file mode 100644 index 0000000..cacbae9 --- /dev/null +++ b/frontend/src/lib/components/icons/IconCapsule.svelte @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/lib/components/icons/IconChevron.svelte b/frontend/src/lib/components/icons/IconChevron.svelte new file mode 100644 index 0000000..71076d3 --- /dev/null +++ b/frontend/src/lib/components/icons/IconChevron.svelte @@ -0,0 +1,30 @@ + + + diff --git a/frontend/src/lib/components/icons/IconDocs.svelte b/frontend/src/lib/components/icons/IconDocs.svelte new file mode 100644 index 0000000..e91f07c --- /dev/null +++ b/frontend/src/lib/components/icons/IconDocs.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/icons/IconEye.svelte b/frontend/src/lib/components/icons/IconEye.svelte new file mode 100644 index 0000000..6016257 --- /dev/null +++ b/frontend/src/lib/components/icons/IconEye.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/icons/IconEyeOff.svelte b/frontend/src/lib/components/icons/IconEyeOff.svelte new file mode 100644 index 0000000..9543dda --- /dev/null +++ b/frontend/src/lib/components/icons/IconEyeOff.svelte @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/lib/components/icons/IconGithub.svelte b/frontend/src/lib/components/icons/IconGithub.svelte new file mode 100644 index 0000000..0f5668e --- /dev/null +++ b/frontend/src/lib/components/icons/IconGithub.svelte @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/lib/components/icons/IconKey.svelte b/frontend/src/lib/components/icons/IconKey.svelte new file mode 100644 index 0000000..3183073 --- /dev/null +++ b/frontend/src/lib/components/icons/IconKey.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/icons/IconLock.svelte b/frontend/src/lib/components/icons/IconLock.svelte new file mode 100644 index 0000000..2a17bec --- /dev/null +++ b/frontend/src/lib/components/icons/IconLock.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/icons/IconLogout.svelte b/frontend/src/lib/components/icons/IconLogout.svelte new file mode 100644 index 0000000..bd39919 --- /dev/null +++ b/frontend/src/lib/components/icons/IconLogout.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/icons/IconMail.svelte b/frontend/src/lib/components/icons/IconMail.svelte new file mode 100644 index 0000000..13e24f2 --- /dev/null +++ b/frontend/src/lib/components/icons/IconMail.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/icons/IconMembers.svelte b/frontend/src/lib/components/icons/IconMembers.svelte new file mode 100644 index 0000000..0b21afa --- /dev/null +++ b/frontend/src/lib/components/icons/IconMembers.svelte @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/lib/components/icons/IconMonitor.svelte b/frontend/src/lib/components/icons/IconMonitor.svelte new file mode 100644 index 0000000..3b709e8 --- /dev/null +++ b/frontend/src/lib/components/icons/IconMonitor.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/icons/IconPlus.svelte b/frontend/src/lib/components/icons/IconPlus.svelte new file mode 100644 index 0000000..bacc1ab --- /dev/null +++ b/frontend/src/lib/components/icons/IconPlus.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/icons/IconSettings.svelte b/frontend/src/lib/components/icons/IconSettings.svelte new file mode 100644 index 0000000..cce4485 --- /dev/null +++ b/frontend/src/lib/components/icons/IconSettings.svelte @@ -0,0 +1,21 @@ + + + diff --git a/frontend/src/lib/components/icons/IconSidebar.svelte b/frontend/src/lib/components/icons/IconSidebar.svelte new file mode 100644 index 0000000..7b8e137 --- /dev/null +++ b/frontend/src/lib/components/icons/IconSidebar.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/icons/IconTemplate.svelte b/frontend/src/lib/components/icons/IconTemplate.svelte new file mode 100644 index 0000000..e7c612e --- /dev/null +++ b/frontend/src/lib/components/icons/IconTemplate.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/icons/IconTool.svelte b/frontend/src/lib/components/icons/IconTool.svelte new file mode 100644 index 0000000..0951b19 --- /dev/null +++ b/frontend/src/lib/components/icons/IconTool.svelte @@ -0,0 +1,20 @@ + + + diff --git a/frontend/src/lib/components/icons/IconUsage.svelte b/frontend/src/lib/components/icons/IconUsage.svelte new file mode 100644 index 0000000..b596f59 --- /dev/null +++ b/frontend/src/lib/components/icons/IconUsage.svelte @@ -0,0 +1,18 @@ + + + diff --git a/frontend/src/lib/components/icons/IconUser.svelte b/frontend/src/lib/components/icons/IconUser.svelte new file mode 100644 index 0000000..e5c026a --- /dev/null +++ b/frontend/src/lib/components/icons/IconUser.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/icons/IconX.svelte b/frontend/src/lib/components/icons/IconX.svelte new file mode 100644 index 0000000..b9214f2 --- /dev/null +++ b/frontend/src/lib/components/icons/IconX.svelte @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/lib/components/icons/index.ts b/frontend/src/lib/components/icons/index.ts new file mode 100644 index 0000000..6296641 --- /dev/null +++ b/frontend/src/lib/components/icons/index.ts @@ -0,0 +1,25 @@ +// Re-export all icon components +export { default as IconGithub } from './IconGithub.svelte'; +export { default as IconCapsule } from './IconCapsule.svelte'; +export { default as IconMonitor } from './IconMonitor.svelte'; +export { default as IconTemplate } from './IconTemplate.svelte'; +export { default as IconTool } from './IconTool.svelte'; +export { default as IconKey } from './IconKey.svelte'; +export { default as IconMembers } from './IconMembers.svelte'; +export { default as IconUsage } from './IconUsage.svelte'; +export { default as IconBilling } from './IconBilling.svelte'; +export { default as IconSettings } from './IconSettings.svelte'; +export { default as IconLogout } from './IconLogout.svelte'; +export { default as IconChevron } from './IconChevron.svelte'; +export { default as IconPlus } from './IconPlus.svelte'; +export { default as IconSidebar } from './IconSidebar.svelte'; +export { default as IconMail } from './IconMail.svelte'; +export { default as IconLock } from './IconLock.svelte'; +export { default as IconUser } from './IconUser.svelte'; +export { default as IconX } from './IconX.svelte'; +export { default as IconEye } from './IconEye.svelte'; +export { default as IconEyeOff } from './IconEyeOff.svelte'; +export { default as IconBell } from './IconBell.svelte'; +export { default as IconDocs } from './IconDocs.svelte'; +export { default as IconAudit } from './IconAudit.svelte'; +export { default as IconBox } from './IconBox.svelte'; diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/frontend/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/frontend/src/lib/sidebar.ts b/frontend/src/lib/sidebar.ts new file mode 100644 index 0000000..b034952 --- /dev/null +++ b/frontend/src/lib/sidebar.ts @@ -0,0 +1,5 @@ +export function getInitialCollapsed(): boolean { + return typeof window !== 'undefined' + ? localStorage.getItem('wrenn_sidebar_collapsed') === 'true' + : false; +} diff --git a/frontend/src/lib/toast.svelte.ts b/frontend/src/lib/toast.svelte.ts new file mode 100644 index 0000000..022e73b --- /dev/null +++ b/frontend/src/lib/toast.svelte.ts @@ -0,0 +1,22 @@ +type Toast = { id: string; message: string; type: 'error' | 'success' }; + +let toasts = $state([]); + +export const toast = { + get list() { + return toasts; + }, + error(message: string, duration = 4000) { + const id = Math.random().toString(36).slice(2); + toasts = [...toasts, { id, message, type: 'error' }]; + setTimeout(() => this.dismiss(id), duration); + }, + success(message: string, duration = 3000) { + const id = Math.random().toString(36).slice(2); + toasts = [...toasts, { id, message, type: 'success' }]; + setTimeout(() => this.dismiss(id), duration); + }, + dismiss(id: string) { + toasts = toasts.filter((t) => t.id !== id); + } +}; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..4991f4d --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,8 @@ + + +{@render children()} diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts new file mode 100644 index 0000000..72b205e --- /dev/null +++ b/frontend/src/routes/+layout.ts @@ -0,0 +1,3 @@ +// Static site generation — all pages prerendered +export const prerender = true; +export const ssr = false; diff --git a/frontend/src/routes/+page.ts b/frontend/src/routes/+page.ts new file mode 100644 index 0000000..081978d --- /dev/null +++ b/frontend/src/routes/+page.ts @@ -0,0 +1,11 @@ +import { redirect } from '@sveltejs/kit'; +import { browser } from '$app/environment'; +import { auth } from '$lib/auth.svelte'; + +export function load() { + if (!browser) return; + if (auth.isAuthenticated) { + redirect(302, '/dashboard'); + } + redirect(302, '/login'); +} diff --git a/frontend/src/routes/auth/github/callback/+page.svelte b/frontend/src/routes/auth/github/callback/+page.svelte new file mode 100644 index 0000000..8383718 --- /dev/null +++ b/frontend/src/routes/auth/github/callback/+page.svelte @@ -0,0 +1,28 @@ + + +
+

Signing you in...

+
diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte new file mode 100644 index 0000000..404b561 --- /dev/null +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -0,0 +1,7 @@ + + +{@render children()} + diff --git a/frontend/src/routes/dashboard/+layout.ts b/frontend/src/routes/dashboard/+layout.ts new file mode 100644 index 0000000..60264bd --- /dev/null +++ b/frontend/src/routes/dashboard/+layout.ts @@ -0,0 +1,10 @@ +import { redirect } from '@sveltejs/kit'; +import { browser } from '$app/environment'; +import { auth } from '$lib/auth.svelte'; + +export function load() { + if (!browser) return; + if (!auth.isAuthenticated) { + redirect(302, '/login'); + } +} diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte new file mode 100644 index 0000000..9078d85 --- /dev/null +++ b/frontend/src/routes/dashboard/+page.svelte @@ -0,0 +1,5 @@ + diff --git a/frontend/src/routes/dashboard/capsules/+page.svelte b/frontend/src/routes/dashboard/capsules/+page.svelte new file mode 100644 index 0000000..edde71b --- /dev/null +++ b/frontend/src/routes/dashboard/capsules/+page.svelte @@ -0,0 +1,874 @@ + + + + + + { if (e.key === 'Escape') openMenuId = null; }} /> + + + Wrenn - Capsules + + +
+ + +
+
+ +
+ +
+
+

+ Capsules +

+

+ Isolated VMs you can start, pause, and snapshot on demand. +

+
+ +
+ +
+ + + + + {runningCount} + concurrent capsules +
+
+
+ + +
+ + +
+
+ + + {#if activeTab === 'stats'} +
+
+ {@render metricCell('Concurrent Capsules', String(runningCount), '5-sec avg', 'limit: 20', true)} + {@render metricCell('Start Rate / Second', '0.000', '5-sec avg', null, true)} + {@render metricCell('Peak Concurrent', String(runningCount), '30-day max', 'limit: 20', false)} +
+ + {@render chartCard('Concurrent Capsules', String(runningCount), 'average')} + {@render chartCard('Start Rate Per Second', '0.000', 'average')} +
+ {:else} +
+ +
+
+ + + + +
+ {filteredCapsules.length} total + +
+ + + + + + + + +
+ + {#if error} +
+ {error} +
+ {/if} + + +
+ +
+
ID
+
Template
+ {@render sortableHeader('CPU', 'vcpus')} + {@render sortableHeader('Memory', 'memory_mb')} + {@render sortableHeader('Idle Timeout', 'timeout_sec')} + {@render sortableHeader('Started', 'started_at')} + {@render sortableHeader('Status', 'status')} +
+ + {#if loading && capsules.length === 0} +
+
+ + + + Loading capsules... +
+
+ {:else if filteredCapsules.length === 0} +
+
+ + + + + +
+

+ No capsules yet +

+

+ Active capsules will appear here. +

+ +
+ {:else} + {#each filteredCapsules as capsule, i (capsule.id)} +
+ +
+ {#if capsule.status === 'running'} + + + + + {:else if capsule.status === 'paused'} + + {:else} + + {/if} + {capsule.id} +
+ + +
+ {capsule.template} +
+ + +
+ {capsule.vcpus} +
+ + +
+ {capsule.memory_mb}MB +
+ + +
+ {capsule.timeout_sec ? `${capsule.timeout_sec}s` : '—'} +
+ + +
+ {formatTime(capsule.started_at)} + {#if capsule.last_active_at} + {timeAgo(capsule.last_active_at)} + {/if} +
+ + +
+ {#if actionLoading === capsule.id} + + + + + + {:else} + + + {/if} +
+
+ {/each} + {/if} +
+
+ {/if} +
+ + +
+
+ + All systems operational +
+
+
+
+ + +{#if openMenuId} + {@const openCapsule = capsules.find((c) => c.id === openMenuId)} + {#if openCapsule} +
+ {#if openCapsule.status === 'running'} + + + {:else if openCapsule.status === 'paused'} + + + {/if} +
+ +
+ {/if} +{/if} + + +{#if showCreateDialog} +
+ +
{ if (!creating) showCreateDialog = false; }} + onkeydown={(e) => { if (e.key === 'Escape' && !creating) showCreateDialog = false; }} + >
+ +
+

Launch Capsule

+

Launch a new isolated VM.

+ + {#if createError} +
+ {createError} +
+ {/if} + +
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+
+{/if} + + +{#if destroyTarget} +
+ +
{ if (!destroying) destroyTarget = null; }} + onkeydown={(e) => { if (e.key === 'Escape' && !destroying) destroyTarget = null; }} + >
+ +
+

Destroy Capsule

+

+ This will permanently destroy {destroyTarget.id}. This action cannot be undone. +

+ + {#if destroyError} +
+ {destroyError} +
+ {/if} + +
+ + +
+
+
+{/if} + + +{#snippet sortableHeader(label: string, key: SortKey)} + +{/snippet} + +{#snippet metricCell(label: string, value: string, sublabel: string, extra: string | null, hasBorderRight: boolean)} +
+
+ {label} + + + Live + +
+
{value}
+
+ {sublabel} + {#if extra} + | + {extra} + {/if} +
+
+{/snippet} + +{#snippet chartCard(label: string, value: string, sublabel: string)} +
+
+
+
{label}
+
+ {value} + {sublabel} + + + Live + +
+
+ +
+ {#each ['5m', '1H', '6H', '24H', '30D'] as range, i} + + {/each} +
+
+ +
+
+ 4 + 3 + 2 + 1 + 0 +
+ + + {#each [0, 45, 90, 135, 180] as y} + + {/each} + + + +
+ {#each ['03:01', '03:02', '03:03', '03:04', '03:05'] as t} + {t} + {/each} +
+
+
+{/snippet} diff --git a/frontend/src/routes/dashboard/keys/+page.svelte b/frontend/src/routes/dashboard/keys/+page.svelte new file mode 100644 index 0000000..b13af5c --- /dev/null +++ b/frontend/src/routes/dashboard/keys/+page.svelte @@ -0,0 +1,440 @@ + + + + Wrenn - API Keys + + +
+ + +
+
+ +
+
+
+

+ API Keys +

+

+ Keys authenticate SDK and direct API requests. Treat them like passwords. +

+
+ + +
+ +
+
+ + +
+ {#if error} +
+ {error} +
+ {/if} + + {#if loading} +
+
+ + + + Loading keys... +
+
+ {:else if keys.length === 0} +
+
+ + + +
+

No API keys yet

+

Create a key to authenticate SDK and API requests.

+ +
+ {:else} +
+ +
+
Name / Key
+
Created By
+
Created
+
Last Used
+
+
+ + {#each keys as key, i (key.id)} +
+ +
+ {key.name || '—'} + {key.key_prefix}... +
+ + +
+ {key.creator_email ?? key.created_by} +
+ + +
+ {formatDate(key.created_at)} +
+ + +
+ {#if key.last_used} + + {timeAgo(key.last_used)} + + {:else} + Never + {/if} +
+ + +
+ +
+
+ {/each} +
+ +

+ {keys.length} {keys.length === 1 ? 'key' : 'keys'} total +

+ {/if} +
+
+ + +
+
+ + All systems operational +
+
+
+
+ + +{#if showCreate} +
+ +
{ if (!creating) showCreate = false; }} + onkeydown={(e) => { if (e.key === 'Escape' && !creating) showCreate = false; }} + >
+ +
+

New API Key

+

Give your key a name to identify it later.

+ + {#if createError} +
+ {createError} +
+ {/if} + +
+ + { if (e.key === 'Enter' && !creating) handleCreate(); }} + class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 text-[13px] text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)]" + /> +
+ +
+ + +
+
+
+{/if} + + +{#if newKey} +
+ +
{ newKey = null; }} + onkeydown={(e) => { if (e.key === 'Escape') newKey = null; }} + >
+ +
+ +
+ + + + + + Key created successfully +
+ +

{newKey.name || 'API Key'}

+

+ Copy this key now — it won't be shown again. +

+ + +
+
+ + {newKey.key ?? ''} + + +
+
+ + +
+ + + + +

+ Store this key securely. For security reasons, we only show it once and cannot retrieve it later. +

+
+ +
+ +
+
+
+{/if} + + +{#if revokeTarget} +
+ +
{ if (!revoking) revokeTarget = null; }} + onkeydown={(e) => { if (e.key === 'Escape' && !revoking) revokeTarget = null; }} + >
+ +
+

Revoke Key

+

+ Revoke {revokeTarget.name || revokeTarget.id}? + Any request using it will stop working immediately. +

+

{revokeTarget.key_prefix}...

+ + {#if revokeError} +
+ {revokeError} +
+ {/if} + +
+ + +
+
+
+{/if} diff --git a/frontend/src/routes/dashboard/snapshots/+page.svelte b/frontend/src/routes/dashboard/snapshots/+page.svelte new file mode 100644 index 0000000..ab595c3 --- /dev/null +++ b/frontend/src/routes/dashboard/snapshots/+page.svelte @@ -0,0 +1,629 @@ + + + + Wrenn - Templates + + + + { + if (e.key === 'Escape') { + if (openDropdownName) { openDropdownName = null; return; } + if (deleting || launching) return; + deleteTarget = null; + launchTarget = null; + } + }} + onclick={(e) => { + if (openDropdownName && !(e.target as Element)?.closest('.split-btn-container')) { + openDropdownName = null; + } + }} +/> + +
+ + +
+
+ +
+
+
+

+ Templates +

+

+ Point-in-time captures and base environments for launching capsules. +

+
+
+ + +
+ + + + + +
+
+ + + {#if pageTab === 'snapshots'} +
+ {#if error} +
+ {error} +
+ {/if} + + {#if loading} +
+
+ + + + Loading snapshots... +
+
+ {:else} + +
+
+ {#each ([['all', 'All'], ['snapshot', 'Snapshots'], ['base', 'Images']] as const) as [val, label]} + + {/each} +
+ + {filteredSnapshots.length} + {filteredSnapshots.length === 1 ? 'snapshot' : 'snapshots'} + +
+ + {#if filteredSnapshots.length === 0} + +
+
+ + + + +
+

+ {emptyHeading(typeFilter)} +

+

+ {emptyDescription(typeFilter)} +

+ {#if typeFilter === 'all' || typeFilter === 'snapshot'} + + Go to Capsules + + + + + + {/if} +
+ {:else} + +
+ +
+
Name
+
Type
+
vCPUs
+
Memory
+
Size
+
Created
+
Actions
+
+ + + {#each filteredSnapshots as snapshot, i (snapshot.name)} +
+ +
+ {snapshot.name} +
+ + +
+ {#if snapshot.type === 'snapshot'} + + + Snapshot + + {:else} + + + Image + + {/if} +
+ + +
+ {#if snapshot.type === 'snapshot' && snapshot.vcpus != null} + {snapshot.vcpus} + {:else} + + {/if} +
+ + +
+ {#if snapshot.type === 'snapshot' && snapshot.memory_mb != null} + {snapshot.memory_mb} MB + {:else} + + {/if} +
+ + +
+ {formatBytes(snapshot.size_bytes)} +
+ + +
+ {timeAgo(snapshot.created_at)} +
+ + +
+
+ + + +
+ + +
+
+
+ {/each} +
+ +

+ {filteredSnapshots.length} {filteredSnapshots.length === 1 ? 'snapshot' : 'snapshots'} + {typeFilter !== 'all' ? `· filtered` : '· total'} +

+ {/if} + {/if} +
+ {/if} +
+ + +
+
+ + All systems operational +
+
+
+
+ + +{#if openDropdownName} + {@const dropdownSnapshot = snapshots.find((s) => s.name === openDropdownName)} + {#if dropdownSnapshot} +
+ +
+ {/if} +{/if} + + +{#if deleteTarget} +
+
{ if (!deleting) deleteTarget = null; }} + onkeydown={(e) => { if (e.key === 'Escape' && !deleting) deleteTarget = null; }} + >
+ +
+

Delete Snapshot

+

+ Delete {deleteTarget.name}? + This action cannot be undone. +

+ + {#if deleteTarget.type === 'snapshot'} +
+ + + + +

+ This live capture includes saved memory state. Any capsule relying on it will be unable to resume. +

+
+ {/if} + + {#if deleteError} +
+ {deleteError} +
+ {/if} + +
+ + +
+
+
+{/if} + + +{#if launchTarget} +
+ +
{ if (!launching) launchTarget = null; }} + onkeydown={(e) => { if (e.key === 'Escape' && !launching) launchTarget = null; }} + >
+ +
+

Launch Capsule

+

+ Start a new capsule from this template. +

+ + {#if launchError} +
+ {launchError} +
+ {/if} + + +
+ +
+ {#if launchTarget.type === 'snapshot'} + + {:else} + + {/if} + {launchTarget.name} + + {launchTarget.type === 'snapshot' ? 'Snapshot' : 'Image'} + +
+
+ + +
+
+ + {#if launchTarget.type === 'snapshot'} +
+ {launchTarget.vcpus ?? 1} +
+ {:else} + + {/if} +
+ +
+ + {#if launchTarget.type === 'snapshot'} +
+ {launchTarget.memory_mb ?? 512} +
+ {:else} + + {/if} +
+
+ + +
+ + +
+ +
+ + +
+
+
+{/if} diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..1383440 --- /dev/null +++ b/frontend/src/routes/login/+page.svelte @@ -0,0 +1,287 @@ + + + + Wrenn — {mode === 'signin' ? 'Sign in' : 'Sign up'} + + +
+ + + + + +
+ +
+ Wrenn + + Wrenn + +
+ +
+ +
+

+ {title} +

+

+ {subtitle} +

+
+ + + + + Continue with GitHub + + + +
+
+ or +
+
+ + +
+
+
+ +
+ +
+ +
+
+ +
+ + +
+ + {#if mode === 'signin'} +
+ +
+ {/if} + + {#if error} +

{error}

+ {/if} + + +
+ + +

+ {switchText} + +

+
+
+
diff --git a/frontend/static/apple-touch-icon.png b/frontend/static/apple-touch-icon.png new file mode 100644 index 0000000..e25ce97 Binary files /dev/null and b/frontend/static/apple-touch-icon.png differ diff --git a/frontend/static/logo.svg b/frontend/static/logo.svg new file mode 100644 index 0000000..26f2ab1 --- /dev/null +++ b/frontend/static/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/static/robots.txt b/frontend/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/frontend/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..935e080 --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,19 @@ +import adapter from '@sveltejs/adapter-static'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter({ + pages: 'build', + assets: 'build', + fallback: 'index.html', + precompress: false + }) + }, + vitePlugin: { + dynamicCompileOptions: ({ filename }) => + filename.includes('node_modules') ? undefined : { runes: true } + } +}; + +export default config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..89afaba --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,15 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + server: { + proxy: { + '/api': { + target: 'http://localhost:8000', + rewrite: (path) => path.replace(/^\/api/, '') + } + } + } +}); diff --git a/go.mod b/go.mod index a9ed4c4..aaa473d 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,31 @@ -module github.com/wrenn-dev/wrenn-sandbox +module git.omukk.dev/wrenn/sandbox -go 1.23.0 +go 1.25.0 require ( - github.com/firecracker-microvm/firecracker-go-sdk v1.1.1 - github.com/go-chi/chi/v5 v5.2.1 + connectrpc.com/connect v1.19.1 + github.com/go-chi/chi/v5 v5.2.5 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 - github.com/jackc/pgx/v5 v5.7.4 - github.com/mdlayher/vsock v1.2.1 - github.com/pressly/goose/v3 v3.24.3 - github.com/prometheus/client_golang v1.21.1 - github.com/rs/cors v1.11.1 - golang.org/x/crypto v0.36.0 - google.golang.org/grpc v1.71.0 - google.golang.org/protobuf v1.36.5 + github.com/jackc/pgx/v5 v5.8.0 + github.com/joho/godotenv v1.5.1 + github.com/redis/go-redis/v9 v9.18.0 + github.com/vishvananda/netlink v1.1.1-0.20210330154013-f5de75959ad5 + github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f + golang.org/x/crypto v0.49.0 + golang.org/x/oauth2 v0.36.0 + golang.org/x/sys v0.42.0 + google.golang.org/protobuf v1.36.11 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.35.0 // indirect ) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..752cbd6 --- /dev/null +++ b/go.sum @@ -0,0 +1,71 @@ +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/vishvananda/netlink v1.1.1-0.20210330154013-f5de75959ad5 h1:+UB2BJA852UkGH42H+Oee69djmxS3ANzl2b/JtT1YiA= +github.com/vishvananda/netlink v1.1.1-0.20210330154013-f5de75959ad5/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= +github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f h1:p4VB7kIXpOQvVn1ZaTIVp+3vuYAXFe3OJEvjbUYJLaA= +github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/images/wrenn-init.sh b/images/wrenn-init.sh new file mode 100644 index 0000000..bec7731 --- /dev/null +++ b/images/wrenn-init.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# wrenn-init: minimal PID 1 init for Firecracker microVMs. +# Mounts virtual filesystems then execs envd. + +set -e + +# Mount essential virtual filesystems if not already mounted. +mount -t proc proc /proc 2>/dev/null || true +mount -t sysfs sysfs /sys 2>/dev/null || true +mount -t devtmpfs devtmpfs /dev 2>/dev/null || true +mkdir -p /dev/pts /dev/shm +mount -t devpts devpts /dev/pts 2>/dev/null || true +mount -t tmpfs tmpfs /dev/shm 2>/dev/null || true +mount -t tmpfs tmpfs /tmp 2>/dev/null || true +mount -t tmpfs tmpfs /run 2>/dev/null || true +mkdir -p /sys/fs/cgroup +mount -t cgroup2 cgroup2 /sys/fs/cgroup 2>/dev/null || true + +# Set hostname +hostname sandbox + +# Configure DNS resolver. +echo "nameserver 8.8.8.8" > /etc/resolv.conf +echo "nameserver 8.8.4.4" >> /etc/resolv.conf + +# Set a standard PATH so envd and all child processes can find common binaries. +export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +# Exec tini as PID 1 — it reaps zombie processes and forwards signals to envd. +exec /sbin/tini -- /usr/local/bin/envd diff --git a/internal/admin/handlers.go b/internal/admin/handlers.go deleted file mode 100644 index e69de29..0000000 diff --git a/internal/admin/static/style.css b/internal/admin/static/style.css deleted file mode 100644 index e69de29..0000000 diff --git a/internal/admin/templates/dashboard.html b/internal/admin/templates/dashboard.html deleted file mode 100644 index e69de29..0000000 diff --git a/internal/admin/templates/layout.html b/internal/admin/templates/layout.html deleted file mode 100644 index e69de29..0000000 diff --git a/internal/admin/templates/partials/audit_log.html b/internal/admin/templates/partials/audit_log.html deleted file mode 100644 index e69de29..0000000 diff --git a/internal/admin/templates/partials/metrics_card.html b/internal/admin/templates/partials/metrics_card.html deleted file mode 100644 index e69de29..0000000 diff --git a/internal/admin/templates/partials/sandbox_row.html b/internal/admin/templates/partials/sandbox_row.html deleted file mode 100644 index e69de29..0000000 diff --git a/internal/admin/templates/sandbox_detail.html b/internal/admin/templates/sandbox_detail.html deleted file mode 100644 index e69de29..0000000 diff --git a/internal/admin/templates/sandboxes.html b/internal/admin/templates/sandboxes.html deleted file mode 100644 index e69de29..0000000 diff --git a/internal/api/handlers_apikeys.go b/internal/api/handlers_apikeys.go new file mode 100644 index 0000000..47f65ed --- /dev/null +++ b/internal/api/handlers_apikeys.go @@ -0,0 +1,126 @@ +package api + +import ( + "net/http" + "time" + + "github.com/go-chi/chi/v5" + + "git.omukk.dev/wrenn/sandbox/internal/auth" + "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/service" +) + +type apiKeyHandler struct { + svc *service.APIKeyService +} + +func newAPIKeyHandler(svc *service.APIKeyService) *apiKeyHandler { + return &apiKeyHandler{svc: svc} +} + +type createAPIKeyRequest struct { + Name string `json:"name"` +} + +type apiKeyResponse struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + Name string `json:"name"` + KeyPrefix string `json:"key_prefix"` + CreatedBy string `json:"created_by"` + CreatorEmail string `json:"creator_email,omitempty"` + CreatedAt string `json:"created_at"` + LastUsed *string `json:"last_used,omitempty"` + Key *string `json:"key,omitempty"` // only populated on Create +} + +func apiKeyToResponse(k db.TeamApiKey) apiKeyResponse { + resp := apiKeyResponse{ + ID: k.ID, + TeamID: k.TeamID, + Name: k.Name, + KeyPrefix: k.KeyPrefix, + CreatedBy: k.CreatedBy, + } + if k.CreatedAt.Valid { + resp.CreatedAt = k.CreatedAt.Time.Format(time.RFC3339) + } + if k.LastUsed.Valid { + s := k.LastUsed.Time.Format(time.RFC3339) + resp.LastUsed = &s + } + return resp +} + +func apiKeyWithCreatorToResponse(k db.ListAPIKeysByTeamWithCreatorRow) apiKeyResponse { + resp := apiKeyResponse{ + ID: k.ID, + TeamID: k.TeamID, + Name: k.Name, + KeyPrefix: k.KeyPrefix, + CreatedBy: k.CreatedBy, + CreatorEmail: k.CreatorEmail, + } + if k.CreatedAt.Valid { + resp.CreatedAt = k.CreatedAt.Time.Format(time.RFC3339) + } + if k.LastUsed.Valid { + s := k.LastUsed.Time.Format(time.RFC3339) + resp.LastUsed = &s + } + return resp +} + +// Create handles POST /v1/api-keys. +func (h *apiKeyHandler) Create(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + + var req createAPIKeyRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return + } + + result, err := h.svc.Create(r.Context(), ac.TeamID, ac.UserID, req.Name) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "failed to create API key") + return + } + + resp := apiKeyToResponse(result.Row) + resp.Key = &result.Plaintext + + writeJSON(w, http.StatusCreated, resp) +} + +// List handles GET /v1/api-keys. +func (h *apiKeyHandler) List(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + + keys, err := h.svc.ListWithCreator(r.Context(), ac.TeamID) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to list API keys") + return + } + + resp := make([]apiKeyResponse, len(keys)) + for i, k := range keys { + resp[i] = apiKeyWithCreatorToResponse(k) + } + + writeJSON(w, http.StatusOK, resp) +} + +// Delete handles DELETE /v1/api-keys/{id}. +func (h *apiKeyHandler) Delete(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + keyID := chi.URLParam(r, "id") + + if err := h.svc.Delete(r.Context(), keyID, ac.TeamID); err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to delete API key") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go new file mode 100644 index 0000000..ba90982 --- /dev/null +++ b/internal/api/handlers_auth.go @@ -0,0 +1,189 @@ +package api + +import ( + "errors" + "net/http" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.omukk.dev/wrenn/sandbox/internal/auth" + "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/id" +) + +type authHandler struct { + db *db.Queries + pool *pgxpool.Pool + jwtSecret []byte +} + +func newAuthHandler(db *db.Queries, pool *pgxpool.Pool, jwtSecret []byte) *authHandler { + return &authHandler{db: db, pool: pool, jwtSecret: jwtSecret} +} + +type signupRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type authResponse struct { + Token string `json:"token"` + UserID string `json:"user_id"` + TeamID string `json:"team_id"` + Email string `json:"email"` +} + +// Signup handles POST /v1/auth/signup. +func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) { + var req signupRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return + } + + req.Email = strings.TrimSpace(strings.ToLower(req.Email)) + if !strings.Contains(req.Email, "@") || len(req.Email) < 3 { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid email address") + return + } + if len(req.Password) < 8 { + writeError(w, http.StatusBadRequest, "invalid_request", "password must be at least 8 characters") + return + } + + ctx := r.Context() + + passwordHash, err := auth.HashPassword(req.Password) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "failed to hash password") + return + } + + // Use a transaction to atomically create user + team + membership. + tx, err := h.pool.Begin(ctx) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to begin transaction") + return + } + defer tx.Rollback(ctx) //nolint:errcheck + + qtx := h.db.WithTx(tx) + + userID := id.NewUserID() + _, err = qtx.InsertUser(ctx, db.InsertUserParams{ + ID: userID, + Email: req.Email, + PasswordHash: pgtype.Text{String: passwordHash, Valid: true}, + }) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + writeError(w, http.StatusConflict, "email_taken", "an account with this email already exists") + return + } + writeError(w, http.StatusInternalServerError, "db_error", "failed to create user") + return + } + + // Create default team. + teamID := id.NewTeamID() + if _, err := qtx.InsertTeam(ctx, db.InsertTeamParams{ + ID: teamID, + Name: req.Email + "'s Team", + }); err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to create team") + return + } + + if err := qtx.InsertTeamMember(ctx, db.InsertTeamMemberParams{ + UserID: userID, + TeamID: teamID, + IsDefault: true, + Role: "owner", + }); err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to add user to team") + return + } + + if err := tx.Commit(ctx); err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to commit signup") + return + } + + token, err := auth.SignJWT(h.jwtSecret, userID, teamID, req.Email) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token") + return + } + + writeJSON(w, http.StatusCreated, authResponse{ + Token: token, + UserID: userID, + TeamID: teamID, + Email: req.Email, + }) +} + +// Login handles POST /v1/auth/login. +func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return + } + + req.Email = strings.TrimSpace(strings.ToLower(req.Email)) + if req.Email == "" || req.Password == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "email and password are required") + return + } + + ctx := r.Context() + + user, err := h.db.GetUserByEmail(ctx, req.Email) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password") + return + } + writeError(w, http.StatusInternalServerError, "db_error", "failed to look up user") + return + } + + if !user.PasswordHash.Valid { + writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password") + return + } + if err := auth.CheckPassword(user.PasswordHash.String, req.Password); err != nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password") + return + } + + team, err := h.db.GetDefaultTeamForUser(ctx, user.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to look up team") + return + } + + token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token") + return + } + + writeJSON(w, http.StatusOK, authResponse{ + Token: token, + UserID: user.ID, + TeamID: team.ID, + Email: user.Email, + }) +} diff --git a/internal/api/handlers_exec.go b/internal/api/handlers_exec.go index e69de29..9307a67 100644 --- a/internal/api/handlers_exec.go +++ b/internal/api/handlers_exec.go @@ -0,0 +1,129 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + "log/slog" + "net/http" + "time" + "unicode/utf8" + + "connectrpc.com/connect" + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" + + "git.omukk.dev/wrenn/sandbox/internal/auth" + "git.omukk.dev/wrenn/sandbox/internal/db" + pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" + "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" +) + +type execHandler struct { + db *db.Queries + agent hostagentv1connect.HostAgentServiceClient +} + +func newExecHandler(db *db.Queries, agent hostagentv1connect.HostAgentServiceClient) *execHandler { + return &execHandler{db: db, agent: agent} +} + +type execRequest struct { + Cmd string `json:"cmd"` + Args []string `json:"args"` + TimeoutSec int32 `json:"timeout_sec"` +} + +type execResponse struct { + SandboxID string `json:"sandbox_id"` + Cmd string `json:"cmd"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int32 `json:"exit_code"` + DurationMs int64 `json:"duration_ms"` + // Encoding is "utf-8" for text output, "base64" for binary output. + Encoding string `json:"encoding"` +} + +// Exec handles POST /v1/sandboxes/{id}/exec. +func (h *execHandler) Exec(w http.ResponseWriter, r *http.Request) { + sandboxID := chi.URLParam(r, "id") + ctx := r.Context() + ac := auth.MustFromContext(ctx) + + sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "sandbox not found") + return + } + if sb.Status != "running" { + writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running (status: "+sb.Status+")") + return + } + + var req execRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return + } + + if req.Cmd == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "cmd is required") + return + } + + start := time.Now() + + resp, err := h.agent.Exec(ctx, connect.NewRequest(&pb.ExecRequest{ + SandboxId: sandboxID, + Cmd: req.Cmd, + Args: req.Args, + TimeoutSec: req.TimeoutSec, + })) + if err != nil { + status, code, msg := agentErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + duration := time.Since(start) + + // Update last active. + if err := h.db.UpdateLastActive(ctx, db.UpdateLastActiveParams{ + ID: sandboxID, + LastActiveAt: pgtype.Timestamptz{ + Time: time.Now(), + Valid: true, + }, + }); err != nil { + slog.Warn("failed to update last_active_at", "id", sandboxID, "error", err) + } + + // Use base64 encoding if output contains non-UTF-8 bytes. + stdout := resp.Msg.Stdout + stderr := resp.Msg.Stderr + encoding := "utf-8" + + if !utf8.Valid(stdout) || !utf8.Valid(stderr) { + encoding = "base64" + writeJSON(w, http.StatusOK, execResponse{ + SandboxID: sandboxID, + Cmd: req.Cmd, + Stdout: base64.StdEncoding.EncodeToString(stdout), + Stderr: base64.StdEncoding.EncodeToString(stderr), + ExitCode: resp.Msg.ExitCode, + DurationMs: duration.Milliseconds(), + Encoding: encoding, + }) + return + } + + writeJSON(w, http.StatusOK, execResponse{ + SandboxID: sandboxID, + Cmd: req.Cmd, + Stdout: string(stdout), + Stderr: string(stderr), + ExitCode: resp.Msg.ExitCode, + DurationMs: duration.Milliseconds(), + Encoding: encoding, + }) +} diff --git a/internal/api/handlers_exec_stream.go b/internal/api/handlers_exec_stream.go new file mode 100644 index 0000000..009f41b --- /dev/null +++ b/internal/api/handlers_exec_stream.go @@ -0,0 +1,166 @@ +package api + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "time" + + "connectrpc.com/connect" + "github.com/go-chi/chi/v5" + "github.com/gorilla/websocket" + "github.com/jackc/pgx/v5/pgtype" + + "git.omukk.dev/wrenn/sandbox/internal/auth" + "git.omukk.dev/wrenn/sandbox/internal/db" + pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" + "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" +) + +type execStreamHandler struct { + db *db.Queries + agent hostagentv1connect.HostAgentServiceClient +} + +func newExecStreamHandler(db *db.Queries, agent hostagentv1connect.HostAgentServiceClient) *execStreamHandler { + return &execStreamHandler{db: db, agent: agent} +} + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// wsStartMsg is the first message the client sends to start a process. +type wsStartMsg struct { + Type string `json:"type"` // "start" + Cmd string `json:"cmd"` + Args []string `json:"args"` +} + +// wsOutMsg is sent by the server for process events. +type wsOutMsg struct { + Type string `json:"type"` // "start", "stdout", "stderr", "exit", "error" + PID uint32 `json:"pid,omitempty"` // only for "start" + Data string `json:"data,omitempty"` // only for "stdout", "stderr", "error" + ExitCode *int32 `json:"exit_code,omitempty"` // only for "exit" +} + +// ExecStream handles WS /v1/sandboxes/{id}/exec/stream. +func (h *execStreamHandler) ExecStream(w http.ResponseWriter, r *http.Request) { + sandboxID := chi.URLParam(r, "id") + ctx := r.Context() + ac := auth.MustFromContext(ctx) + + sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "sandbox not found") + return + } + if sb.Status != "running" { + writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running (status: "+sb.Status+")") + return + } + + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + slog.Error("websocket upgrade failed", "error", err) + return + } + defer conn.Close() + + // Read the start message. + var startMsg wsStartMsg + if err := conn.ReadJSON(&startMsg); err != nil { + sendWSError(conn, "failed to read start message: "+err.Error()) + return + } + if startMsg.Type != "start" || startMsg.Cmd == "" { + sendWSError(conn, "first message must be type 'start' with a 'cmd' field") + return + } + + // Open streaming exec to host agent. + streamCtx, cancel := context.WithCancel(ctx) + defer cancel() + + stream, err := h.agent.ExecStream(streamCtx, connect.NewRequest(&pb.ExecStreamRequest{ + SandboxId: sandboxID, + Cmd: startMsg.Cmd, + Args: startMsg.Args, + })) + if err != nil { + sendWSError(conn, "failed to start exec stream: "+err.Error()) + return + } + defer stream.Close() + + // Listen for stop messages from the client in a goroutine. + go func() { + for { + _, msg, err := conn.ReadMessage() + if err != nil { + cancel() + return + } + var parsed struct { + Type string `json:"type"` + } + if json.Unmarshal(msg, &parsed) == nil && parsed.Type == "stop" { + cancel() + return + } + } + }() + + // Forward stream events to WebSocket. + for stream.Receive() { + resp := stream.Msg() + switch ev := resp.Event.(type) { + case *pb.ExecStreamResponse_Start: + writeWSJSON(conn, wsOutMsg{Type: "start", PID: ev.Start.Pid}) + + case *pb.ExecStreamResponse_Data: + switch o := ev.Data.Output.(type) { + case *pb.ExecStreamData_Stdout: + writeWSJSON(conn, wsOutMsg{Type: "stdout", Data: string(o.Stdout)}) + case *pb.ExecStreamData_Stderr: + writeWSJSON(conn, wsOutMsg{Type: "stderr", Data: string(o.Stderr)}) + } + + case *pb.ExecStreamResponse_End: + exitCode := ev.End.ExitCode + writeWSJSON(conn, wsOutMsg{Type: "exit", ExitCode: &exitCode}) + } + } + + if err := stream.Err(); err != nil { + // Only send if the connection is still alive (not a normal close). + if streamCtx.Err() == nil { + sendWSError(conn, err.Error()) + } + } + + // Update last active using a fresh context (the request context may be cancelled). + updateCtx, updateCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer updateCancel() + if err := h.db.UpdateLastActive(updateCtx, db.UpdateLastActiveParams{ + ID: sandboxID, + LastActiveAt: pgtype.Timestamptz{ + Time: time.Now(), + Valid: true, + }, + }); err != nil { + slog.Warn("failed to update last active after stream exec", "sandbox_id", sandboxID, "error", err) + } +} + +func sendWSError(conn *websocket.Conn, msg string) { + writeWSJSON(conn, wsOutMsg{Type: "error", Data: msg}) +} + +func writeWSJSON(conn *websocket.Conn, v any) { + if err := conn.WriteJSON(v); err != nil { + slog.Debug("websocket write error", "error", err) + } +} diff --git a/internal/api/handlers_files.go b/internal/api/handlers_files.go index e69de29..c1c0291 100644 --- a/internal/api/handlers_files.go +++ b/internal/api/handlers_files.go @@ -0,0 +1,135 @@ +package api + +import ( + "encoding/json" + "errors" + "io" + "net/http" + + "connectrpc.com/connect" + "github.com/go-chi/chi/v5" + + "git.omukk.dev/wrenn/sandbox/internal/auth" + "git.omukk.dev/wrenn/sandbox/internal/db" + pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" + "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" +) + +type filesHandler struct { + db *db.Queries + agent hostagentv1connect.HostAgentServiceClient +} + +func newFilesHandler(db *db.Queries, agent hostagentv1connect.HostAgentServiceClient) *filesHandler { + return &filesHandler{db: db, agent: agent} +} + +// Upload handles POST /v1/sandboxes/{id}/files/write. +// Expects multipart/form-data with: +// - "path" text field: absolute destination path inside the sandbox +// - "file" file field: binary content to write +func (h *filesHandler) Upload(w http.ResponseWriter, r *http.Request) { + sandboxID := chi.URLParam(r, "id") + ctx := r.Context() + ac := auth.MustFromContext(ctx) + + sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "sandbox not found") + return + } + if sb.Status != "running" { + writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running") + return + } + + // Limit to 100 MB. + r.Body = http.MaxBytesReader(w, r.Body, 100<<20) + + if err := r.ParseMultipartForm(100 << 20); err != nil { + var maxErr *http.MaxBytesError + if errors.As(err, &maxErr) { + writeError(w, http.StatusRequestEntityTooLarge, "too_large", "file exceeds 100 MB limit") + return + } + writeError(w, http.StatusBadRequest, "invalid_request", "expected multipart/form-data") + return + } + + filePath := r.FormValue("path") + if filePath == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "path field is required") + return + } + + file, _, err := r.FormFile("file") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "file field is required") + return + } + defer file.Close() + + content, err := io.ReadAll(file) + if err != nil { + writeError(w, http.StatusInternalServerError, "read_error", "failed to read uploaded file") + return + } + + if _, err := h.agent.WriteFile(ctx, connect.NewRequest(&pb.WriteFileRequest{ + SandboxId: sandboxID, + Path: filePath, + Content: content, + })); err != nil { + status, code, msg := agentErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +type readFileRequest struct { + Path string `json:"path"` +} + +// Download handles POST /v1/sandboxes/{id}/files/read. +// Accepts JSON body with path, returns raw file content with Content-Disposition. +func (h *filesHandler) Download(w http.ResponseWriter, r *http.Request) { + sandboxID := chi.URLParam(r, "id") + ctx := r.Context() + ac := auth.MustFromContext(ctx) + + sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "sandbox not found") + return + } + if sb.Status != "running" { + writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running") + return + } + + var req readFileRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return + } + + if req.Path == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "path is required") + return + } + + resp, err := h.agent.ReadFile(ctx, connect.NewRequest(&pb.ReadFileRequest{ + SandboxId: sandboxID, + Path: req.Path, + })) + if err != nil { + status, code, msg := agentErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(resp.Msg.Content) +} diff --git a/internal/api/handlers_files_stream.go b/internal/api/handlers_files_stream.go new file mode 100644 index 0000000..66a3c5b --- /dev/null +++ b/internal/api/handlers_files_stream.go @@ -0,0 +1,198 @@ +package api + +import ( + "io" + "log/slog" + "mime" + "mime/multipart" + "net/http" + + "connectrpc.com/connect" + "github.com/go-chi/chi/v5" + + "git.omukk.dev/wrenn/sandbox/internal/auth" + "git.omukk.dev/wrenn/sandbox/internal/db" + pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" + "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" +) + +type filesStreamHandler struct { + db *db.Queries + agent hostagentv1connect.HostAgentServiceClient +} + +func newFilesStreamHandler(db *db.Queries, agent hostagentv1connect.HostAgentServiceClient) *filesStreamHandler { + return &filesStreamHandler{db: db, agent: agent} +} + +// StreamUpload handles POST /v1/sandboxes/{id}/files/stream/write. +// Expects multipart/form-data with "path" text field and "file" file field. +// Streams file content directly from the request body to the host agent without buffering. +func (h *filesStreamHandler) StreamUpload(w http.ResponseWriter, r *http.Request) { + sandboxID := chi.URLParam(r, "id") + ctx := r.Context() + ac := auth.MustFromContext(ctx) + + sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "sandbox not found") + return + } + if sb.Status != "running" { + writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running") + return + } + + // Parse boundary from Content-Type without buffering the body. + contentType := r.Header.Get("Content-Type") + _, params, err := mime.ParseMediaType(contentType) + if err != nil || params["boundary"] == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "expected multipart/form-data with boundary") + return + } + + // Read parts manually from the multipart stream. + mr := multipart.NewReader(r.Body, params["boundary"]) + + var filePath string + var filePart *multipart.Part + + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "failed to parse multipart") + return + } + switch part.FormName() { + case "path": + data, _ := io.ReadAll(part) + filePath = string(data) + case "file": + filePart = part + } + if filePath != "" && filePart != nil { + break + } + } + + if filePath == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "path field is required") + return + } + if filePart == nil { + writeError(w, http.StatusBadRequest, "invalid_request", "file field is required") + return + } + defer filePart.Close() + + // Open client-streaming RPC to host agent. + stream := h.agent.WriteFileStream(ctx) + + // Send metadata first. + if err := stream.Send(&pb.WriteFileStreamRequest{ + Content: &pb.WriteFileStreamRequest_Meta{ + Meta: &pb.WriteFileStreamMeta{ + SandboxId: sandboxID, + Path: filePath, + }, + }, + }); err != nil { + writeError(w, http.StatusBadGateway, "agent_error", "failed to send file metadata") + return + } + + // Stream file content in 64KB chunks directly from the multipart part. + buf := make([]byte, 64*1024) + for { + n, err := filePart.Read(buf) + if n > 0 { + chunk := make([]byte, n) + copy(chunk, buf[:n]) + if sendErr := stream.Send(&pb.WriteFileStreamRequest{ + Content: &pb.WriteFileStreamRequest_Chunk{Chunk: chunk}, + }); sendErr != nil { + writeError(w, http.StatusBadGateway, "agent_error", "failed to stream file chunk") + return + } + } + if err == io.EOF { + break + } + if err != nil { + writeError(w, http.StatusInternalServerError, "read_error", "failed to read uploaded file") + return + } + } + + // Close and receive response. + if _, err := stream.CloseAndReceive(); err != nil { + status, code, msg := agentErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// StreamDownload handles POST /v1/sandboxes/{id}/files/stream/read. +// Accepts JSON body with path, streams file content back without buffering. +func (h *filesStreamHandler) StreamDownload(w http.ResponseWriter, r *http.Request) { + sandboxID := chi.URLParam(r, "id") + ctx := r.Context() + ac := auth.MustFromContext(ctx) + + sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: ac.TeamID}) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "sandbox not found") + return + } + if sb.Status != "running" { + writeError(w, http.StatusConflict, "invalid_state", "sandbox is not running") + return + } + + var req readFileRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return + } + if req.Path == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "path is required") + return + } + + // Open server-streaming RPC to host agent. + stream, err := h.agent.ReadFileStream(ctx, connect.NewRequest(&pb.ReadFileStreamRequest{ + SandboxId: sandboxID, + Path: req.Path, + })) + if err != nil { + status, code, msg := agentErrToHTTP(err) + writeError(w, status, code, msg) + return + } + defer stream.Close() + + w.Header().Set("Content-Type", "application/octet-stream") + + flusher, canFlush := w.(http.Flusher) + for stream.Receive() { + chunk := stream.Msg().Chunk + if len(chunk) > 0 { + if _, err := w.Write(chunk); err != nil { + return + } + if canFlush { + flusher.Flush() + } + } + } + + if err := stream.Err(); err != nil { + // Headers already sent, nothing we can do but log. + slog.Warn("file stream error after headers sent", "error", err) + } +} diff --git a/internal/api/handlers_hosts.go b/internal/api/handlers_hosts.go new file mode 100644 index 0000000..a6484a3 --- /dev/null +++ b/internal/api/handlers_hosts.go @@ -0,0 +1,327 @@ +package api + +import ( + "net/http" + "time" + + "github.com/go-chi/chi/v5" + + "git.omukk.dev/wrenn/sandbox/internal/auth" + "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/service" +) + +type hostHandler struct { + svc *service.HostService + queries *db.Queries +} + +func newHostHandler(svc *service.HostService, queries *db.Queries) *hostHandler { + return &hostHandler{svc: svc, queries: queries} +} + +// Request/response types. + +type createHostRequest struct { + Type string `json:"type"` + TeamID string `json:"team_id,omitempty"` + Provider string `json:"provider,omitempty"` + AvailabilityZone string `json:"availability_zone,omitempty"` +} + +type createHostResponse struct { + Host hostResponse `json:"host"` + RegistrationToken string `json:"registration_token"` +} + +type registerHostRequest struct { + Token string `json:"token"` + Arch string `json:"arch,omitempty"` + CPUCores int32 `json:"cpu_cores,omitempty"` + MemoryMB int32 `json:"memory_mb,omitempty"` + DiskGB int32 `json:"disk_gb,omitempty"` + Address string `json:"address"` +} + +type registerHostResponse struct { + Host hostResponse `json:"host"` + Token string `json:"token"` +} + +type addTagRequest struct { + Tag string `json:"tag"` +} + +type hostResponse struct { + ID string `json:"id"` + Type string `json:"type"` + TeamID *string `json:"team_id,omitempty"` + Provider *string `json:"provider,omitempty"` + AvailabilityZone *string `json:"availability_zone,omitempty"` + Arch *string `json:"arch,omitempty"` + CPUCores *int32 `json:"cpu_cores,omitempty"` + MemoryMB *int32 `json:"memory_mb,omitempty"` + DiskGB *int32 `json:"disk_gb,omitempty"` + Address *string `json:"address,omitempty"` + Status string `json:"status"` + LastHeartbeatAt *string `json:"last_heartbeat_at,omitempty"` + CreatedBy string `json:"created_by"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func hostToResponse(h db.Host) hostResponse { + resp := hostResponse{ + ID: h.ID, + Type: h.Type, + Status: h.Status, + CreatedBy: h.CreatedBy, + } + if h.TeamID.Valid { + resp.TeamID = &h.TeamID.String + } + if h.Provider.Valid { + resp.Provider = &h.Provider.String + } + if h.AvailabilityZone.Valid { + resp.AvailabilityZone = &h.AvailabilityZone.String + } + if h.Arch.Valid { + resp.Arch = &h.Arch.String + } + if h.CpuCores.Valid { + resp.CPUCores = &h.CpuCores.Int32 + } + if h.MemoryMb.Valid { + resp.MemoryMB = &h.MemoryMb.Int32 + } + if h.DiskGb.Valid { + resp.DiskGB = &h.DiskGb.Int32 + } + if h.Address.Valid { + resp.Address = &h.Address.String + } + if h.LastHeartbeatAt.Valid { + s := h.LastHeartbeatAt.Time.Format(time.RFC3339) + resp.LastHeartbeatAt = &s + } + // created_at and updated_at are NOT NULL DEFAULT NOW(), always valid. + resp.CreatedAt = h.CreatedAt.Time.Format(time.RFC3339) + resp.UpdatedAt = h.UpdatedAt.Time.Format(time.RFC3339) + return resp +} + +// isAdmin fetches the user record and returns whether they are an admin. +func (h *hostHandler) isAdmin(r *http.Request, userID string) bool { + user, err := h.queries.GetUserByID(r.Context(), userID) + if err != nil { + return false + } + return user.IsAdmin +} + +// Create handles POST /v1/hosts. +func (h *hostHandler) Create(w http.ResponseWriter, r *http.Request) { + var req createHostRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return + } + + ac := auth.MustFromContext(r.Context()) + + result, err := h.svc.Create(r.Context(), service.HostCreateParams{ + Type: req.Type, + TeamID: req.TeamID, + Provider: req.Provider, + AvailabilityZone: req.AvailabilityZone, + RequestingUserID: ac.UserID, + IsRequestorAdmin: h.isAdmin(r, ac.UserID), + }) + if err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + writeJSON(w, http.StatusCreated, createHostResponse{ + Host: hostToResponse(result.Host), + RegistrationToken: result.RegistrationToken, + }) +} + +// List handles GET /v1/hosts. +func (h *hostHandler) List(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + + hosts, err := h.svc.List(r.Context(), ac.TeamID, h.isAdmin(r, ac.UserID)) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to list hosts") + return + } + + resp := make([]hostResponse, len(hosts)) + for i, host := range hosts { + resp[i] = hostToResponse(host) + } + + writeJSON(w, http.StatusOK, resp) +} + +// Get handles GET /v1/hosts/{id}. +func (h *hostHandler) Get(w http.ResponseWriter, r *http.Request) { + hostID := chi.URLParam(r, "id") + ac := auth.MustFromContext(r.Context()) + + host, err := h.svc.Get(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID)) + if err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + writeJSON(w, http.StatusOK, hostToResponse(host)) +} + +// Delete handles DELETE /v1/hosts/{id}. +func (h *hostHandler) Delete(w http.ResponseWriter, r *http.Request) { + hostID := chi.URLParam(r, "id") + ac := auth.MustFromContext(r.Context()) + + if err := h.svc.Delete(r.Context(), hostID, ac.UserID, ac.TeamID, h.isAdmin(r, ac.UserID)); err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// RegenerateToken handles POST /v1/hosts/{id}/token. +func (h *hostHandler) RegenerateToken(w http.ResponseWriter, r *http.Request) { + hostID := chi.URLParam(r, "id") + ac := auth.MustFromContext(r.Context()) + + result, err := h.svc.RegenerateToken(r.Context(), hostID, ac.UserID, ac.TeamID, h.isAdmin(r, ac.UserID)) + if err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + writeJSON(w, http.StatusCreated, createHostResponse{ + Host: hostToResponse(result.Host), + RegistrationToken: result.RegistrationToken, + }) +} + +// Register handles POST /v1/hosts/register (unauthenticated). +func (h *hostHandler) Register(w http.ResponseWriter, r *http.Request) { + var req registerHostRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return + } + + if req.Token == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "token is required") + return + } + if req.Address == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "address is required") + return + } + + result, err := h.svc.Register(r.Context(), service.HostRegisterParams{ + Token: req.Token, + Arch: req.Arch, + CPUCores: req.CPUCores, + MemoryMB: req.MemoryMB, + DiskGB: req.DiskGB, + Address: req.Address, + }) + if err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + writeJSON(w, http.StatusCreated, registerHostResponse{ + Host: hostToResponse(result.Host), + Token: result.JWT, + }) +} + +// Heartbeat handles POST /v1/hosts/{id}/heartbeat (host-token-authenticated). +func (h *hostHandler) Heartbeat(w http.ResponseWriter, r *http.Request) { + hostID := chi.URLParam(r, "id") + hc := auth.MustHostFromContext(r.Context()) + + // Prevent a host from heartbeating for a different host. + if hostID != hc.HostID { + writeError(w, http.StatusForbidden, "forbidden", "host ID mismatch") + return + } + + if err := h.svc.Heartbeat(r.Context(), hc.HostID); err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to update heartbeat") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// AddTag handles POST /v1/hosts/{id}/tags. +func (h *hostHandler) AddTag(w http.ResponseWriter, r *http.Request) { + hostID := chi.URLParam(r, "id") + ac := auth.MustFromContext(r.Context()) + admin := h.isAdmin(r, ac.UserID) + + var req addTagRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return + } + if req.Tag == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "tag is required") + return + } + + if err := h.svc.AddTag(r.Context(), hostID, ac.TeamID, admin, req.Tag); err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// RemoveTag handles DELETE /v1/hosts/{id}/tags/{tag}. +func (h *hostHandler) RemoveTag(w http.ResponseWriter, r *http.Request) { + hostID := chi.URLParam(r, "id") + tag := chi.URLParam(r, "tag") + ac := auth.MustFromContext(r.Context()) + + if err := h.svc.RemoveTag(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID), tag); err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// ListTags handles GET /v1/hosts/{id}/tags. +func (h *hostHandler) ListTags(w http.ResponseWriter, r *http.Request) { + hostID := chi.URLParam(r, "id") + ac := auth.MustFromContext(r.Context()) + + tags, err := h.svc.ListTags(r.Context(), hostID, ac.TeamID, h.isAdmin(r, ac.UserID)) + if err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + writeJSON(w, http.StatusOK, tags) +} diff --git a/internal/api/handlers_oauth.go b/internal/api/handlers_oauth.go new file mode 100644 index 0000000..ab30617 --- /dev/null +++ b/internal/api/handlers_oauth.go @@ -0,0 +1,330 @@ +package api + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "log/slog" + "net/http" + "net/url" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "git.omukk.dev/wrenn/sandbox/internal/auth" + "git.omukk.dev/wrenn/sandbox/internal/auth/oauth" + "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/id" +) + +type oauthHandler struct { + db *db.Queries + pool *pgxpool.Pool + jwtSecret []byte + registry *oauth.Registry + redirectURL string // base frontend URL (e.g. "https://app.wrenn.dev") +} + +func newOAuthHandler(db *db.Queries, pool *pgxpool.Pool, jwtSecret []byte, registry *oauth.Registry, redirectURL string) *oauthHandler { + return &oauthHandler{ + db: db, + pool: pool, + jwtSecret: jwtSecret, + registry: registry, + redirectURL: strings.TrimRight(redirectURL, "/"), + } +} + +// Redirect handles GET /v1/auth/oauth/{provider} — redirects to the provider's authorization page. +func (h *oauthHandler) Redirect(w http.ResponseWriter, r *http.Request) { + provider := chi.URLParam(r, "provider") + p, ok := h.registry.Get(provider) + if !ok { + writeError(w, http.StatusNotFound, "provider_not_found", "unsupported OAuth provider") + return + } + + state, err := generateState() + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate state") + return + } + + mac := computeHMAC(h.jwtSecret, state) + cookieVal := state + ":" + mac + + http.SetCookie(w, &http.Cookie{ + Name: "oauth_state", + Value: cookieVal, + Path: "/", + MaxAge: 600, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: isSecure(r), + }) + + http.Redirect(w, r, p.AuthCodeURL(state), http.StatusFound) +} + +// Callback handles GET /v1/auth/oauth/{provider}/callback — exchanges the code and logs in or registers the user. +func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) { + provider := chi.URLParam(r, "provider") + p, ok := h.registry.Get(provider) + if !ok { + writeError(w, http.StatusNotFound, "provider_not_found", "unsupported OAuth provider") + return + } + + redirectBase := h.redirectURL + "/auth/" + provider + "/callback" + + // Check if the provider returned an error. + if errParam := r.URL.Query().Get("error"); errParam != "" { + redirectWithError(w, r, redirectBase, "access_denied") + return + } + + // Validate CSRF state. + stateCookie, err := r.Cookie("oauth_state") + if err != nil { + redirectWithError(w, r, redirectBase, "invalid_state") + return + } + // Expire the state cookie immediately. + http.SetCookie(w, &http.Cookie{ + Name: "oauth_state", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: isSecure(r), + }) + + parts := strings.SplitN(stateCookie.Value, ":", 2) + if len(parts) != 2 { + redirectWithError(w, r, redirectBase, "invalid_state") + return + } + nonce, expectedMAC := parts[0], parts[1] + if !hmac.Equal([]byte(computeHMAC(h.jwtSecret, nonce)), []byte(expectedMAC)) { + redirectWithError(w, r, redirectBase, "invalid_state") + return + } + if r.URL.Query().Get("state") != nonce { + redirectWithError(w, r, redirectBase, "invalid_state") + return + } + + code := r.URL.Query().Get("code") + if code == "" { + redirectWithError(w, r, redirectBase, "missing_code") + return + } + + // Exchange authorization code for user profile. + ctx := r.Context() + profile, err := p.Exchange(ctx, code) + if err != nil { + slog.Error("oauth exchange failed", "provider", provider, "error", err) + redirectWithError(w, r, redirectBase, "exchange_failed") + return + } + + email := strings.TrimSpace(strings.ToLower(profile.Email)) + + // Check if this OAuth identity already exists. + existing, err := h.db.GetOAuthProvider(ctx, db.GetOAuthProviderParams{ + Provider: provider, + ProviderID: profile.ProviderID, + }) + if err == nil { + // Existing OAuth user — log them in. + user, err := h.db.GetUserByID(ctx, existing.UserID) + if err != nil { + slog.Error("oauth login: failed to get user", "error", err) + redirectWithError(w, r, redirectBase, "db_error") + return + } + team, err := h.db.GetDefaultTeamForUser(ctx, user.ID) + if err != nil { + slog.Error("oauth login: failed to get team", "error", err) + redirectWithError(w, r, redirectBase, "db_error") + return + } + token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email) + if err != nil { + slog.Error("oauth login: failed to sign jwt", "error", err) + redirectWithError(w, r, redirectBase, "internal_error") + return + } + redirectWithToken(w, r, redirectBase, token, user.ID, team.ID, user.Email) + return + } + if !errors.Is(err, pgx.ErrNoRows) { + slog.Error("oauth: db lookup failed", "error", err) + redirectWithError(w, r, redirectBase, "db_error") + return + } + + // New OAuth identity — check for email collision. + _, err = h.db.GetUserByEmail(ctx, email) + if err == nil { + // Email already taken by another account. + redirectWithError(w, r, redirectBase, "email_taken") + return + } + if !errors.Is(err, pgx.ErrNoRows) { + slog.Error("oauth: email check failed", "error", err) + redirectWithError(w, r, redirectBase, "db_error") + return + } + + // Register: create user + team + membership + oauth_provider atomically. + tx, err := h.pool.Begin(ctx) + if err != nil { + slog.Error("oauth: failed to begin tx", "error", err) + redirectWithError(w, r, redirectBase, "db_error") + return + } + defer tx.Rollback(ctx) //nolint:errcheck + + qtx := h.db.WithTx(tx) + + userID := id.NewUserID() + _, err = qtx.InsertUserOAuth(ctx, db.InsertUserOAuthParams{ + ID: userID, + Email: email, + }) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + // Race condition: another request just created this user. + // Rollback and retry as a login. + tx.Rollback(ctx) //nolint:errcheck + h.retryAsLogin(w, r, provider, profile.ProviderID, redirectBase) + return + } + slog.Error("oauth: failed to create user", "error", err) + redirectWithError(w, r, redirectBase, "db_error") + return + } + + teamID := id.NewTeamID() + teamName := profile.Name + "'s Team" + if _, err := qtx.InsertTeam(ctx, db.InsertTeamParams{ + ID: teamID, + Name: teamName, + }); err != nil { + slog.Error("oauth: failed to create team", "error", err) + redirectWithError(w, r, redirectBase, "db_error") + return + } + + if err := qtx.InsertTeamMember(ctx, db.InsertTeamMemberParams{ + UserID: userID, + TeamID: teamID, + IsDefault: true, + Role: "owner", + }); err != nil { + slog.Error("oauth: failed to add team member", "error", err) + redirectWithError(w, r, redirectBase, "db_error") + return + } + + if err := qtx.InsertOAuthProvider(ctx, db.InsertOAuthProviderParams{ + Provider: provider, + ProviderID: profile.ProviderID, + UserID: userID, + Email: email, + }); err != nil { + slog.Error("oauth: failed to save oauth provider", "error", err) + redirectWithError(w, r, redirectBase, "db_error") + return + } + + if err := tx.Commit(ctx); err != nil { + slog.Error("oauth: failed to commit", "error", err) + redirectWithError(w, r, redirectBase, "db_error") + return + } + + token, err := auth.SignJWT(h.jwtSecret, userID, teamID, email) + if err != nil { + slog.Error("oauth: failed to sign jwt", "error", err) + redirectWithError(w, r, redirectBase, "internal_error") + return + } + + redirectWithToken(w, r, redirectBase, token, userID, teamID, email) +} + +// retryAsLogin handles the race where a concurrent request already created the user. +// It looks up the oauth_providers row and logs in the existing user. +func (h *oauthHandler) retryAsLogin(w http.ResponseWriter, r *http.Request, provider, providerID, redirectBase string) { + ctx := r.Context() + existing, err := h.db.GetOAuthProvider(ctx, db.GetOAuthProviderParams{ + Provider: provider, + ProviderID: providerID, + }) + if err != nil { + slog.Error("oauth: retry login failed", "error", err) + redirectWithError(w, r, redirectBase, "email_taken") + return + } + user, err := h.db.GetUserByID(ctx, existing.UserID) + if err != nil { + slog.Error("oauth: retry login: failed to get user", "error", err) + redirectWithError(w, r, redirectBase, "db_error") + return + } + team, err := h.db.GetDefaultTeamForUser(ctx, user.ID) + if err != nil { + slog.Error("oauth: retry login: failed to get team", "error", err) + redirectWithError(w, r, redirectBase, "db_error") + return + } + token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email) + if err != nil { + slog.Error("oauth: retry login: failed to sign jwt", "error", err) + redirectWithError(w, r, redirectBase, "internal_error") + return + } + redirectWithToken(w, r, redirectBase, token, user.ID, team.ID, user.Email) +} + +func redirectWithToken(w http.ResponseWriter, r *http.Request, base, token, userID, teamID, email string) { + u := base + "?" + url.Values{ + "token": {token}, + "user_id": {userID}, + "team_id": {teamID}, + "email": {email}, + }.Encode() + http.Redirect(w, r, u, http.StatusFound) +} + +func redirectWithError(w http.ResponseWriter, r *http.Request, base, code string) { + http.Redirect(w, r, base+"?error="+url.QueryEscape(code), http.StatusFound) +} + +func generateState() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func computeHMAC(key []byte, data string) string { + h := hmac.New(sha256.New, key) + h.Write([]byte(data)) + return hex.EncodeToString(h.Sum(nil)) +} + +func isSecure(r *http.Request) bool { + return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" +} diff --git a/internal/api/handlers_sandbox.go b/internal/api/handlers_sandbox.go index e69de29..a312e5f 100644 --- a/internal/api/handlers_sandbox.go +++ b/internal/api/handlers_sandbox.go @@ -0,0 +1,186 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + + "git.omukk.dev/wrenn/sandbox/internal/auth" + "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/service" +) + +type sandboxHandler struct { + svc *service.SandboxService +} + +func newSandboxHandler(svc *service.SandboxService) *sandboxHandler { + return &sandboxHandler{svc: svc} +} + +type createSandboxRequest struct { + Template string `json:"template"` + VCPUs int32 `json:"vcpus"` + MemoryMB int32 `json:"memory_mb"` + TimeoutSec int32 `json:"timeout_sec"` +} + +type sandboxResponse struct { + ID string `json:"id"` + Status string `json:"status"` + Template string `json:"template"` + VCPUs int32 `json:"vcpus"` + MemoryMB int32 `json:"memory_mb"` + TimeoutSec int32 `json:"timeout_sec"` + GuestIP string `json:"guest_ip,omitempty"` + HostIP string `json:"host_ip,omitempty"` + CreatedAt string `json:"created_at"` + StartedAt *string `json:"started_at,omitempty"` + LastActiveAt *string `json:"last_active_at,omitempty"` + LastUpdated string `json:"last_updated"` +} + +func sandboxToResponse(sb db.Sandbox) sandboxResponse { + resp := sandboxResponse{ + ID: sb.ID, + Status: sb.Status, + Template: sb.Template, + VCPUs: sb.Vcpus, + MemoryMB: sb.MemoryMb, + TimeoutSec: sb.TimeoutSec, + GuestIP: sb.GuestIp, + HostIP: sb.HostIp, + } + if sb.CreatedAt.Valid { + resp.CreatedAt = sb.CreatedAt.Time.Format(time.RFC3339) + } + if sb.StartedAt.Valid { + s := sb.StartedAt.Time.Format(time.RFC3339) + resp.StartedAt = &s + } + if sb.LastActiveAt.Valid { + s := sb.LastActiveAt.Time.Format(time.RFC3339) + resp.LastActiveAt = &s + } + if sb.LastUpdated.Valid { + resp.LastUpdated = sb.LastUpdated.Time.Format(time.RFC3339) + } + return resp +} + +// Create handles POST /v1/sandboxes. +func (h *sandboxHandler) Create(w http.ResponseWriter, r *http.Request) { + var req createSandboxRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return + } + + ac := auth.MustFromContext(r.Context()) + + sb, err := h.svc.Create(r.Context(), service.SandboxCreateParams{ + TeamID: ac.TeamID, + Template: req.Template, + VCPUs: req.VCPUs, + MemoryMB: req.MemoryMB, + TimeoutSec: req.TimeoutSec, + }) + if err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + writeJSON(w, http.StatusCreated, sandboxToResponse(sb)) +} + +// List handles GET /v1/sandboxes. +func (h *sandboxHandler) List(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + sandboxes, err := h.svc.List(r.Context(), ac.TeamID) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to list sandboxes") + return + } + + resp := make([]sandboxResponse, len(sandboxes)) + for i, sb := range sandboxes { + resp[i] = sandboxToResponse(sb) + } + + writeJSON(w, http.StatusOK, resp) +} + +// Get handles GET /v1/sandboxes/{id}. +func (h *sandboxHandler) Get(w http.ResponseWriter, r *http.Request) { + sandboxID := chi.URLParam(r, "id") + ac := auth.MustFromContext(r.Context()) + + sb, err := h.svc.Get(r.Context(), sandboxID, ac.TeamID) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "sandbox not found") + return + } + + writeJSON(w, http.StatusOK, sandboxToResponse(sb)) +} + +// Pause handles POST /v1/sandboxes/{id}/pause. +func (h *sandboxHandler) Pause(w http.ResponseWriter, r *http.Request) { + sandboxID := chi.URLParam(r, "id") + ac := auth.MustFromContext(r.Context()) + + sb, err := h.svc.Pause(r.Context(), sandboxID, ac.TeamID) + if err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + writeJSON(w, http.StatusOK, sandboxToResponse(sb)) +} + +// Resume handles POST /v1/sandboxes/{id}/resume. +func (h *sandboxHandler) Resume(w http.ResponseWriter, r *http.Request) { + sandboxID := chi.URLParam(r, "id") + ac := auth.MustFromContext(r.Context()) + + sb, err := h.svc.Resume(r.Context(), sandboxID, ac.TeamID) + if err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + writeJSON(w, http.StatusOK, sandboxToResponse(sb)) +} + +// Ping handles POST /v1/sandboxes/{id}/ping. +func (h *sandboxHandler) Ping(w http.ResponseWriter, r *http.Request) { + sandboxID := chi.URLParam(r, "id") + ac := auth.MustFromContext(r.Context()) + + if err := h.svc.Ping(r.Context(), sandboxID, ac.TeamID); err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// Destroy handles DELETE /v1/sandboxes/{id}. +func (h *sandboxHandler) Destroy(w http.ResponseWriter, r *http.Request) { + sandboxID := chi.URLParam(r, "id") + ac := auth.MustFromContext(r.Context()) + + if err := h.svc.Destroy(r.Context(), sandboxID, ac.TeamID); err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/handlers_snapshots.go b/internal/api/handlers_snapshots.go new file mode 100644 index 0000000..f48539a --- /dev/null +++ b/internal/api/handlers_snapshots.go @@ -0,0 +1,203 @@ +package api + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "time" + + "connectrpc.com/connect" + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgtype" + + "git.omukk.dev/wrenn/sandbox/internal/auth" + "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/id" + "git.omukk.dev/wrenn/sandbox/internal/service" + "git.omukk.dev/wrenn/sandbox/internal/validate" + pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" + "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" +) + +type snapshotHandler struct { + svc *service.TemplateService + db *db.Queries + agent hostagentv1connect.HostAgentServiceClient +} + +func newSnapshotHandler(svc *service.TemplateService, db *db.Queries, agent hostagentv1connect.HostAgentServiceClient) *snapshotHandler { + return &snapshotHandler{svc: svc, db: db, agent: agent} +} + +type createSnapshotRequest struct { + SandboxID string `json:"sandbox_id"` + Name string `json:"name"` +} + +type snapshotResponse struct { + Name string `json:"name"` + Type string `json:"type"` + VCPUs *int32 `json:"vcpus,omitempty"` + MemoryMB *int32 `json:"memory_mb,omitempty"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt string `json:"created_at"` +} + +func templateToResponse(t db.Template) snapshotResponse { + resp := snapshotResponse{ + Name: t.Name, + Type: t.Type, + SizeBytes: t.SizeBytes, + } + if t.Vcpus.Valid { + resp.VCPUs = &t.Vcpus.Int32 + } + if t.MemoryMb.Valid { + resp.MemoryMB = &t.MemoryMb.Int32 + } + if t.CreatedAt.Valid { + resp.CreatedAt = t.CreatedAt.Time.Format(time.RFC3339) + } + return resp +} + +// Create handles POST /v1/snapshots. +func (h *snapshotHandler) Create(w http.ResponseWriter, r *http.Request) { + var req createSnapshotRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return + } + + if req.SandboxID == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "sandbox_id is required") + return + } + + if req.Name == "" { + req.Name = id.NewSnapshotName() + } + if err := validate.SafeName(req.Name); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", fmt.Sprintf("invalid snapshot name: %s", err)) + return + } + + ctx := r.Context() + ac := auth.MustFromContext(ctx) + overwrite := r.URL.Query().Get("overwrite") == "true" + + // Check if name already exists for this team. + if _, err := h.db.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: req.Name, TeamID: ac.TeamID}); err == nil { + if !overwrite { + writeError(w, http.StatusConflict, "already_exists", "snapshot name already exists; use ?overwrite=true to replace") + return + } + // Delete old files from the agent before removing the DB record. + if _, err := h.agent.DeleteSnapshot(ctx, connect.NewRequest(&pb.DeleteSnapshotRequest{Name: req.Name})); err != nil { + status, code, msg := agentErrToHTTP(err) + writeError(w, status, code, "failed to delete existing snapshot files: "+msg) + return + } + if err := h.db.DeleteTemplateByTeam(ctx, db.DeleteTemplateByTeamParams{Name: req.Name, TeamID: ac.TeamID}); err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to remove existing template record") + return + } + } + + // Verify sandbox exists, belongs to team, and is running or paused. + sb, err := h.db.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: req.SandboxID, TeamID: ac.TeamID}) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "sandbox not found") + return + } + if sb.Status != "running" && sb.Status != "paused" { + writeError(w, http.StatusConflict, "invalid_state", "sandbox must be running or paused") + return + } + + resp, err := h.agent.CreateSnapshot(ctx, connect.NewRequest(&pb.CreateSnapshotRequest{ + SandboxId: req.SandboxID, + Name: req.Name, + })) + if err != nil { + status, code, msg := agentErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + // Mark sandbox as paused (if it was running, it got paused by the snapshot). + if sb.Status != "paused" { + if _, err := h.db.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{ + ID: req.SandboxID, Status: "paused", + }); err != nil { + slog.Error("failed to update sandbox status after snapshot", "sandbox_id", req.SandboxID, "error", err) + } + } + + tmpl, err := h.db.InsertTemplate(ctx, db.InsertTemplateParams{ + Name: req.Name, + Type: "snapshot", + Vcpus: pgtype.Int4{Int32: sb.Vcpus, Valid: true}, + MemoryMb: pgtype.Int4{Int32: sb.MemoryMb, Valid: true}, + SizeBytes: resp.Msg.SizeBytes, + TeamID: ac.TeamID, + }) + if err != nil { + slog.Error("failed to insert template record", "name", req.Name, "error", err) + writeError(w, http.StatusInternalServerError, "db_error", "snapshot created but failed to record in database") + return + } + + writeJSON(w, http.StatusCreated, templateToResponse(tmpl)) +} + +// List handles GET /v1/snapshots. +func (h *snapshotHandler) List(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + typeFilter := r.URL.Query().Get("type") + + templates, err := h.svc.List(r.Context(), ac.TeamID, typeFilter) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to list templates") + return + } + + resp := make([]snapshotResponse, len(templates)) + for i, t := range templates { + resp[i] = templateToResponse(t) + } + + writeJSON(w, http.StatusOK, resp) +} + +// Delete handles DELETE /v1/snapshots/{name}. +func (h *snapshotHandler) Delete(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + if err := validate.SafeName(name); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", fmt.Sprintf("invalid snapshot name: %s", err)) + return + } + ctx := r.Context() + ac := auth.MustFromContext(ctx) + + if _, err := h.db.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: name, TeamID: ac.TeamID}); err != nil { + writeError(w, http.StatusNotFound, "not_found", "template not found") + return + } + + if _, err := h.agent.DeleteSnapshot(ctx, connect.NewRequest(&pb.DeleteSnapshotRequest{ + Name: name, + })); err != nil { + status, code, msg := agentErrToHTTP(err) + writeError(w, status, code, "failed to delete snapshot files: "+msg) + return + } + + if err := h.db.DeleteTemplateByTeam(ctx, db.DeleteTemplateByTeamParams{Name: name, TeamID: ac.TeamID}); err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to delete template record") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/handlers_terminal.go b/internal/api/handlers_terminal.go index e69de29..778f64e 100644 --- a/internal/api/handlers_terminal.go +++ b/internal/api/handlers_terminal.go @@ -0,0 +1 @@ +package api diff --git a/internal/api/middleware.go b/internal/api/middleware.go index e69de29..b327dd6 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -0,0 +1,122 @@ +package api + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "strings" + "time" + + "connectrpc.com/connect" +) + +type errorResponse struct { + Error errorDetail `json:"error"` +} + +type errorDetail struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, code, message string) { + writeJSON(w, status, errorResponse{ + Error: errorDetail{Code: code, Message: message}, + }) +} + +// agentErrToHTTP maps a Connect RPC error to an HTTP status, error code, and message. +func agentErrToHTTP(err error) (int, string, string) { + switch connect.CodeOf(err) { + case connect.CodeNotFound: + return http.StatusNotFound, "not_found", err.Error() + case connect.CodeInvalidArgument: + return http.StatusBadRequest, "invalid_request", err.Error() + case connect.CodeFailedPrecondition: + return http.StatusConflict, "conflict", err.Error() + default: + return http.StatusBadGateway, "agent_error", err.Error() + } +} + +// requestLogger returns middleware that logs each request. +func requestLogger() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(sw, r) + slog.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", sw.status, + "duration", time.Since(start), + ) + }) + } +} + +func decodeJSON(r *http.Request, v any) error { + return json.NewDecoder(r.Body).Decode(v) +} + +// serviceErrToHTTP maps a service-layer error to an HTTP status, code, and message. +// It inspects the underlying Connect RPC error if present, otherwise returns 500. +func serviceErrToHTTP(err error) (int, string, string) { + msg := err.Error() + + // Check for Connect RPC errors wrapped by the service layer. + var connectErr *connect.Error + if errors.As(err, &connectErr) { + return agentErrToHTTP(connectErr) + } + + // Map well-known service error patterns. + switch { + case strings.Contains(msg, "not found"): + return http.StatusNotFound, "not_found", msg + case strings.Contains(msg, "not running"), strings.Contains(msg, "not paused"): + return http.StatusConflict, "invalid_state", msg + case strings.Contains(msg, "forbidden"): + return http.StatusForbidden, "forbidden", msg + case strings.Contains(msg, "invalid"): + return http.StatusBadRequest, "invalid_request", msg + default: + return http.StatusInternalServerError, "internal_error", msg + } +} + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (w *statusWriter) WriteHeader(status int) { + w.status = status + w.ResponseWriter.WriteHeader(status) +} + +// Hijack implements http.Hijacker, required for WebSocket upgrade. +func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + if hj, ok := w.ResponseWriter.(http.Hijacker); ok { + return hj.Hijack() + } + return nil, nil, fmt.Errorf("underlying ResponseWriter does not implement http.Hijacker") +} + +// Flush implements http.Flusher, required for streaming responses. +func (w *statusWriter) Flush() { + if fl, ok := w.ResponseWriter.(http.Flusher); ok { + fl.Flush() + } +} diff --git a/internal/api/middleware_apikey.go b/internal/api/middleware_apikey.go new file mode 100644 index 0000000..8a53506 --- /dev/null +++ b/internal/api/middleware_apikey.go @@ -0,0 +1,38 @@ +package api + +import ( + "log/slog" + "net/http" + + "git.omukk.dev/wrenn/sandbox/internal/auth" + "git.omukk.dev/wrenn/sandbox/internal/db" +) + +// requireAPIKey validates the X-API-Key header, looks up the SHA-256 hash in DB, +// and stamps TeamID into the request context. +func requireAPIKey(queries *db.Queries) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := r.Header.Get("X-API-Key") + if key == "" { + writeError(w, http.StatusUnauthorized, "unauthorized", "X-API-Key header required") + return + } + + hash := auth.HashAPIKey(key) + row, err := queries.GetAPIKeyByHash(r.Context(), hash) + if err != nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "invalid API key") + return + } + + // Best-effort update of last_used timestamp. + if err := queries.UpdateAPIKeyLastUsed(r.Context(), row.ID); err != nil { + slog.Warn("failed to update api key last_used", "key_id", row.ID, "error", err) + } + + ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{TeamID: row.TeamID}) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/internal/api/middleware_auth.go b/internal/api/middleware_auth.go new file mode 100644 index 0000000..f850344 --- /dev/null +++ b/internal/api/middleware_auth.go @@ -0,0 +1,56 @@ +package api + +import ( + "log/slog" + "net/http" + "strings" + + "git.omukk.dev/wrenn/sandbox/internal/auth" + "git.omukk.dev/wrenn/sandbox/internal/db" +) + +// requireAPIKeyOrJWT accepts either X-API-Key header or Authorization: Bearer JWT. +// Both stamp TeamID into the request context via auth.AuthContext. +func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Try API key first. + if key := r.Header.Get("X-API-Key"); key != "" { + hash := auth.HashAPIKey(key) + row, err := queries.GetAPIKeyByHash(r.Context(), hash) + if err != nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "invalid API key") + return + } + + if err := queries.UpdateAPIKeyLastUsed(r.Context(), row.ID); err != nil { + slog.Warn("failed to update api key last_used", "key_id", row.ID, "error", err) + } + + ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{TeamID: row.TeamID}) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + // Try JWT bearer token. + if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") { + tokenStr := strings.TrimPrefix(header, "Bearer ") + claims, err := auth.VerifyJWT(jwtSecret, tokenStr) + if err != nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "invalid or expired token") + return + } + + ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{ + TeamID: claims.TeamID, + UserID: claims.Subject, + Email: claims.Email, + }) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + writeError(w, http.StatusUnauthorized, "unauthorized", "X-API-Key or Authorization: Bearer required") + }) + } +} diff --git a/internal/api/middleware_hosttoken.go b/internal/api/middleware_hosttoken.go new file mode 100644 index 0000000..a5c5e6f --- /dev/null +++ b/internal/api/middleware_hosttoken.go @@ -0,0 +1,30 @@ +package api + +import ( + "net/http" + + "git.omukk.dev/wrenn/sandbox/internal/auth" +) + +// requireHostToken validates the X-Host-Token header containing a host JWT, +// verifies the signature and expiry, and stamps HostContext into the request context. +func requireHostToken(secret []byte) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tokenStr := r.Header.Get("X-Host-Token") + if tokenStr == "" { + writeError(w, http.StatusUnauthorized, "unauthorized", "X-Host-Token header required") + return + } + + claims, err := auth.VerifyHostJWT(secret, tokenStr) + if err != nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "invalid or expired host token") + return + } + + ctx := auth.WithHostContext(r.Context(), auth.HostContext{HostID: claims.HostID}) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/internal/api/middleware_jwt.go b/internal/api/middleware_jwt.go new file mode 100644 index 0000000..c071064 --- /dev/null +++ b/internal/api/middleware_jwt.go @@ -0,0 +1,36 @@ +package api + +import ( + "net/http" + "strings" + + "git.omukk.dev/wrenn/sandbox/internal/auth" +) + +// requireJWT validates the Authorization: Bearer header, verifies the JWT +// signature and expiry, and stamps UserID + TeamID + Email into the request context. +func requireJWT(secret []byte) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := r.Header.Get("Authorization") + if !strings.HasPrefix(header, "Bearer ") { + writeError(w, http.StatusUnauthorized, "unauthorized", "Authorization: Bearer required") + return + } + + tokenStr := strings.TrimPrefix(header, "Bearer ") + claims, err := auth.VerifyJWT(secret, tokenStr) + if err != nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "invalid or expired token") + return + } + + ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{ + TeamID: claims.TeamID, + UserID: claims.Subject, + Email: claims.Email, + }) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml new file mode 100644 index 0000000..f4c8f66 --- /dev/null +++ b/internal/api/openapi.yaml @@ -0,0 +1,1350 @@ +openapi: "3.1.0" +info: + title: Wrenn Sandbox API + description: MicroVM-based code execution platform API. + version: "0.1.0" + +servers: + - url: http://localhost:8080 + description: Local development + +security: [] + +paths: + /v1/auth/signup: + post: + summary: Create a new account + operationId: signup + tags: [auth] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SignupRequest" + responses: + "201": + description: Account created + content: + application/json: + schema: + $ref: "#/components/schemas/AuthResponse" + "400": + description: Invalid request (bad email, short password) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Email already registered + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/auth/login: + post: + summary: Log in with email and password + operationId: login + tags: [auth] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + responses: + "200": + description: Login successful + content: + application/json: + schema: + $ref: "#/components/schemas/AuthResponse" + "401": + description: Invalid credentials + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/auth/oauth/{provider}: + parameters: + - name: provider + in: path + required: true + schema: + type: string + enum: [github] + description: OAuth provider name + + get: + summary: Start OAuth login flow + operationId: oauthRedirect + tags: [auth] + description: | + Redirects the user to the OAuth provider's authorization page. + Sets a short-lived CSRF state cookie for validation on callback. + responses: + "302": + description: Redirect to provider authorization URL + "404": + description: Provider not found or not configured + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/auth/oauth/{provider}/callback: + parameters: + - name: provider + in: path + required: true + schema: + type: string + enum: [github] + description: OAuth provider name + + get: + summary: OAuth callback + operationId: oauthCallback + tags: [auth] + description: | + Handles the OAuth provider's callback after user authorization. + Exchanges the authorization code for a user profile, creates or + logs in the user, and redirects to the frontend with a JWT token. + + **On success:** redirects to `{OAUTH_REDIRECT_URL}/auth/{provider}/callback?token=...&user_id=...&team_id=...&email=...` + + **On error:** redirects to `{OAUTH_REDIRECT_URL}/auth/{provider}/callback?error=...` + + Possible error codes: `access_denied`, `invalid_state`, `missing_code`, + `exchange_failed`, `email_taken`, `internal_error`. + parameters: + - name: code + in: query + schema: + type: string + description: Authorization code from the OAuth provider + - name: state + in: query + schema: + type: string + description: CSRF state parameter (must match the cookie) + responses: + "302": + description: Redirect to frontend with token or error + + /v1/api-keys: + post: + summary: Create an API key + operationId: createAPIKey + tags: [api-keys] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateAPIKeyRequest" + responses: + "201": + description: API key created (plaintext key only shown once) + content: + application/json: + schema: + $ref: "#/components/schemas/APIKeyResponse" + "401": + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + get: + summary: List API keys for your team + operationId: listAPIKeys + tags: [api-keys] + security: + - bearerAuth: [] + responses: + "200": + description: List of API keys (plaintext keys are never returned) + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/APIKeyResponse" + + /v1/api-keys/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + + delete: + summary: Delete an API key + operationId: deleteAPIKey + tags: [api-keys] + security: + - bearerAuth: [] + responses: + "204": + description: API key deleted + + /v1/sandboxes: + post: + summary: Create a sandbox + operationId: createSandbox + tags: [sandboxes] + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateSandboxRequest" + responses: + "201": + description: Sandbox created + content: + application/json: + schema: + $ref: "#/components/schemas/Sandbox" + "502": + description: Host agent error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + get: + summary: List sandboxes for your team + operationId: listSandboxes + tags: [sandboxes] + security: + - apiKeyAuth: [] + responses: + "200": + description: List of sandboxes + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Sandbox" + + /v1/sandboxes/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + + get: + summary: Get sandbox details + operationId: getSandbox + tags: [sandboxes] + security: + - apiKeyAuth: [] + responses: + "200": + description: Sandbox details + content: + application/json: + schema: + $ref: "#/components/schemas/Sandbox" + "404": + description: Sandbox not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + delete: + summary: Destroy a sandbox + operationId: destroySandbox + tags: [sandboxes] + security: + - apiKeyAuth: [] + responses: + "204": + description: Sandbox destroyed + + /v1/sandboxes/{id}/exec: + parameters: + - name: id + in: path + required: true + schema: + type: string + + post: + summary: Execute a command + operationId: execCommand + tags: [sandboxes] + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ExecRequest" + responses: + "200": + description: Command output + content: + application/json: + schema: + $ref: "#/components/schemas/ExecResponse" + "404": + description: Sandbox not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Sandbox not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/sandboxes/{id}/ping: + parameters: + - name: id + in: path + required: true + schema: + type: string + + post: + summary: Reset sandbox inactivity timer + operationId: pingSandbox + tags: [sandboxes] + security: + - apiKeyAuth: [] + description: | + Resets the last_active_at timestamp for a running sandbox, preventing + the auto-pause TTL from expiring. Use this as a keepalive for sandboxes + that are idle but should remain running. + responses: + "204": + description: Ping acknowledged, inactivity timer reset + "404": + description: Sandbox not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Sandbox not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/sandboxes/{id}/pause: + parameters: + - name: id + in: path + required: true + schema: + type: string + + post: + summary: Pause a running sandbox + operationId: pauseSandbox + tags: [sandboxes] + security: + - apiKeyAuth: [] + description: | + Takes a snapshot of the sandbox (VM state + memory + rootfs), then + destroys all running resources. The sandbox exists only as files on + disk and can be resumed later. + responses: + "200": + description: Sandbox paused (snapshot taken, resources released) + content: + application/json: + schema: + $ref: "#/components/schemas/Sandbox" + "409": + description: Sandbox not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/sandboxes/{id}/resume: + parameters: + - name: id + in: path + required: true + schema: + type: string + + post: + summary: Resume a paused sandbox + operationId: resumeSandbox + tags: [sandboxes] + security: + - apiKeyAuth: [] + description: | + Restores a paused sandbox from its snapshot using UFFD for lazy + memory loading. Boots a fresh Firecracker process, sets up a new + network slot, and waits for envd to become ready. + responses: + "200": + description: Sandbox resumed (new VM booted from snapshot) + content: + application/json: + schema: + $ref: "#/components/schemas/Sandbox" + "409": + description: Sandbox not paused + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/snapshots: + post: + summary: Create a snapshot template + operationId: createSnapshot + tags: [snapshots] + security: + - apiKeyAuth: [] + description: | + Pauses a running sandbox, takes a full snapshot, copies the snapshot + files to the images directory as a reusable template, then destroys + the sandbox. The template can be used to create new sandboxes. + parameters: + - name: overwrite + in: query + required: false + schema: + type: string + enum: ["true"] + description: Set to "true" to overwrite an existing snapshot with the same name. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateSnapshotRequest" + responses: + "201": + description: Snapshot created + content: + application/json: + schema: + $ref: "#/components/schemas/Template" + "409": + description: Name already exists or sandbox not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + get: + summary: List templates for your team + operationId: listSnapshots + tags: [snapshots] + security: + - apiKeyAuth: [] + parameters: + - name: type + in: query + required: false + schema: + type: string + enum: [base, snapshot] + description: Filter by template type. + responses: + "200": + description: List of templates + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Template" + + /v1/snapshots/{name}: + parameters: + - name: name + in: path + required: true + schema: + type: string + + delete: + summary: Delete a snapshot template + operationId: deleteSnapshot + tags: [snapshots] + security: + - apiKeyAuth: [] + description: Removes the snapshot files from disk and deletes the database record. + responses: + "204": + description: Snapshot deleted + "404": + description: Template not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/sandboxes/{id}/files/write: + parameters: + - name: id + in: path + required: true + schema: + type: string + + post: + summary: Upload a file + operationId: uploadFile + tags: [sandboxes] + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [path, file] + properties: + path: + type: string + description: Absolute destination path inside the sandbox + file: + type: string + format: binary + description: File content + responses: + "204": + description: File uploaded + "409": + description: Sandbox not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "413": + description: File too large + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/sandboxes/{id}/files/read: + parameters: + - name: id + in: path + required: true + schema: + type: string + + post: + summary: Download a file + operationId: downloadFile + tags: [sandboxes] + security: + - apiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ReadFileRequest" + responses: + "200": + description: File content + content: + application/octet-stream: + schema: + type: string + format: binary + "404": + description: Sandbox or file not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/sandboxes/{id}/exec/stream: + parameters: + - name: id + in: path + required: true + schema: + type: string + + get: + summary: Stream command execution via WebSocket + operationId: execStream + tags: [sandboxes] + security: + - apiKeyAuth: [] + description: | + Opens a WebSocket connection for streaming command execution. + + **Client sends** (first message to start the process): + ```json + {"type": "start", "cmd": "tail", "args": ["-f", "/var/log/syslog"]} + ``` + + **Client sends** (to stop the process): + ```json + {"type": "stop"} + ``` + + **Server sends** (process events as they arrive): + ```json + {"type": "start", "pid": 1234} + {"type": "stdout", "data": "line of output\n"} + {"type": "stderr", "data": "warning message\n"} + {"type": "exit", "exit_code": 0} + {"type": "error", "data": "description of error"} + ``` + + The connection closes automatically after the process exits. + responses: + "101": + description: WebSocket upgrade + "404": + description: Sandbox not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Sandbox not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/sandboxes/{id}/files/stream/write: + parameters: + - name: id + in: path + required: true + schema: + type: string + + post: + summary: Upload a file (streaming) + operationId: streamUploadFile + tags: [sandboxes] + security: + - apiKeyAuth: [] + description: | + Streams file content to the sandbox without buffering in memory. + Suitable for large files. Uses the same multipart/form-data format + as the non-streaming upload endpoint. + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [path, file] + properties: + path: + type: string + description: Absolute destination path inside the sandbox + file: + type: string + format: binary + description: File content + responses: + "204": + description: File uploaded + "404": + description: Sandbox not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Sandbox not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/sandboxes/{id}/files/stream/read: + parameters: + - name: id + in: path + required: true + schema: + type: string + + post: + summary: Download a file (streaming) + operationId: streamDownloadFile + tags: [sandboxes] + security: + - apiKeyAuth: [] + description: | + Streams file content from the sandbox without buffering in memory. + Suitable for large files. Returns raw bytes with chunked transfer encoding. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ReadFileRequest" + responses: + "200": + description: File content streamed in chunks + content: + application/octet-stream: + schema: + type: string + format: binary + "404": + description: Sandbox or file not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Sandbox not running + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/hosts: + post: + summary: Create a host + operationId: createHost + tags: [hosts] + security: + - bearerAuth: [] + description: | + Creates a new host record and returns a one-time registration token. + Regular hosts can only be created by admins. BYOC hosts can be created + by admins or team owners. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateHostRequest" + responses: + "201": + description: Host created with registration token + content: + application/json: + schema: + $ref: "#/components/schemas/CreateHostResponse" + "400": + description: Invalid request + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "403": + description: Insufficient permissions + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + get: + summary: List hosts + operationId: listHosts + tags: [hosts] + security: + - bearerAuth: [] + description: | + Admins see all hosts. Non-admins see only BYOC hosts belonging to their team. + responses: + "200": + description: List of hosts + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Host" + + /v1/hosts/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + + get: + summary: Get host details + operationId: getHost + tags: [hosts] + security: + - bearerAuth: [] + responses: + "200": + description: Host details + content: + application/json: + schema: + $ref: "#/components/schemas/Host" + "404": + description: Host not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + delete: + summary: Delete a host + operationId: deleteHost + tags: [hosts] + security: + - bearerAuth: [] + description: | + Admins can delete any host. Team owners can delete BYOC hosts + belonging to their team. + responses: + "204": + description: Host deleted + "403": + description: Insufficient permissions + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/hosts/{id}/token: + parameters: + - name: id + in: path + required: true + schema: + type: string + + post: + summary: Regenerate registration token + operationId: regenerateHostToken + tags: [hosts] + security: + - bearerAuth: [] + description: | + Issues a new registration token for a host still in "pending" status. + Use this when a previous registration attempt failed after consuming + the original token. Same permission model as host creation. + responses: + "201": + description: New registration token issued + content: + application/json: + schema: + $ref: "#/components/schemas/CreateHostResponse" + "403": + description: Insufficient permissions + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: Host is not in pending status + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/hosts/register: + post: + summary: Register a host agent + operationId: registerHost + tags: [hosts] + description: | + Called by the host agent on first startup. Validates the one-time + registration token, records machine specs, sets the host status to + "online", and returns a long-lived JWT for subsequent API calls + (heartbeats). + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RegisterHostRequest" + responses: + "201": + description: Host registered, JWT returned + content: + application/json: + schema: + $ref: "#/components/schemas/RegisterHostResponse" + "400": + description: Invalid request + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + description: Invalid or expired registration token + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/hosts/{id}/heartbeat: + parameters: + - name: id + in: path + required: true + schema: + type: string + + post: + summary: Host agent heartbeat + operationId: hostHeartbeat + tags: [hosts] + security: + - hostTokenAuth: [] + description: | + Updates the host's last_heartbeat_at timestamp. The host ID in the URL + must match the host ID in the JWT. + responses: + "204": + description: Heartbeat recorded + "401": + description: Invalid or missing host token + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "403": + description: Host ID mismatch + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/hosts/{id}/tags: + parameters: + - name: id + in: path + required: true + schema: + type: string + + get: + summary: List host tags + operationId: listHostTags + tags: [hosts] + security: + - bearerAuth: [] + responses: + "200": + description: List of tags + content: + application/json: + schema: + type: array + items: + type: string + + post: + summary: Add a tag to a host + operationId: addHostTag + tags: [hosts] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AddTagRequest" + responses: + "204": + description: Tag added + "404": + description: Host not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/hosts/{id}/tags/{tag}: + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: tag + in: path + required: true + schema: + type: string + + delete: + summary: Remove a tag from a host + operationId: removeHostTag + tags: [hosts] + security: + - bearerAuth: [] + responses: + "204": + description: Tag removed + "404": + description: Host not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + +components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: header + name: X-API-Key + description: API key for sandbox lifecycle operations. Create via POST /v1/api-keys. + + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: JWT token from /v1/auth/login or /v1/auth/signup. Valid for 6 hours. + + hostTokenAuth: + type: apiKey + in: header + name: X-Host-Token + description: Long-lived host JWT returned from POST /v1/hosts/register. Valid for 1 year. + + schemas: + SignupRequest: + type: object + required: [email, password] + properties: + email: + type: string + format: email + password: + type: string + minLength: 8 + + LoginRequest: + type: object + required: [email, password] + properties: + email: + type: string + format: email + password: + type: string + + AuthResponse: + type: object + properties: + token: + type: string + description: JWT token (valid for 6 hours) + user_id: + type: string + team_id: + type: string + email: + type: string + + CreateAPIKeyRequest: + type: object + properties: + name: + type: string + default: Unnamed API Key + + APIKeyResponse: + type: object + properties: + id: + type: string + team_id: + type: string + name: + type: string + key_prefix: + type: string + description: Display prefix (e.g. "wrn_ab12cd34...") + created_at: + type: string + format: date-time + last_used: + type: string + format: date-time + nullable: true + key: + type: string + description: Full plaintext key. Only returned on creation, never again. + nullable: true + + CreateSandboxRequest: + type: object + properties: + template: + type: string + default: minimal + vcpus: + type: integer + default: 1 + memory_mb: + type: integer + default: 512 + timeout_sec: + type: integer + default: 0 + description: > + Auto-pause TTL in seconds. The sandbox is automatically paused + after this duration of inactivity (no exec or ping). 0 means + no auto-pause. + + Sandbox: + type: object + properties: + id: + type: string + status: + type: string + enum: [pending, running, paused, stopped, error] + template: + type: string + vcpus: + type: integer + memory_mb: + type: integer + timeout_sec: + type: integer + guest_ip: + type: string + host_ip: + type: string + created_at: + type: string + format: date-time + started_at: + type: string + format: date-time + nullable: true + last_active_at: + type: string + format: date-time + nullable: true + last_updated: + type: string + format: date-time + + CreateSnapshotRequest: + type: object + required: [sandbox_id] + properties: + sandbox_id: + type: string + description: ID of the running sandbox to snapshot. + name: + type: string + description: Name for the snapshot template. Auto-generated if omitted. + + Template: + type: object + properties: + name: + type: string + type: + type: string + enum: [base, snapshot] + vcpus: + type: integer + nullable: true + memory_mb: + type: integer + nullable: true + size_bytes: + type: integer + format: int64 + created_at: + type: string + format: date-time + + ExecRequest: + type: object + required: [cmd] + properties: + cmd: + type: string + args: + type: array + items: + type: string + timeout_sec: + type: integer + default: 30 + + ExecResponse: + type: object + properties: + sandbox_id: + type: string + cmd: + type: string + stdout: + type: string + stderr: + type: string + exit_code: + type: integer + duration_ms: + type: integer + encoding: + type: string + enum: [utf-8, base64] + description: Output encoding. "base64" when stdout/stderr contain binary data. + + ReadFileRequest: + type: object + required: [path] + properties: + path: + type: string + description: Absolute file path inside the sandbox + + CreateHostRequest: + type: object + required: [type] + properties: + type: + type: string + enum: [regular, byoc] + description: Host type. Regular hosts are shared; BYOC hosts belong to a team. + team_id: + type: string + description: Required for BYOC hosts. + provider: + type: string + description: Cloud provider (e.g. aws, gcp, hetzner, bare-metal). + availability_zone: + type: string + description: Availability zone (e.g. us-east, eu-west). + + CreateHostResponse: + type: object + properties: + host: + $ref: "#/components/schemas/Host" + registration_token: + type: string + description: One-time registration token for the host agent. Expires in 1 hour. + + RegisterHostRequest: + type: object + required: [token, address] + properties: + token: + type: string + description: One-time registration token from POST /v1/hosts. + arch: + type: string + description: CPU architecture (e.g. x86_64, aarch64). + cpu_cores: + type: integer + memory_mb: + type: integer + disk_gb: + type: integer + address: + type: string + description: Host agent address (ip:port). + + RegisterHostResponse: + type: object + properties: + host: + $ref: "#/components/schemas/Host" + token: + type: string + description: Long-lived host JWT for X-Host-Token header. Valid for 1 year. + + Host: + type: object + properties: + id: + type: string + type: + type: string + enum: [regular, byoc] + team_id: + type: string + nullable: true + provider: + type: string + nullable: true + availability_zone: + type: string + nullable: true + arch: + type: string + nullable: true + cpu_cores: + type: integer + nullable: true + memory_mb: + type: integer + nullable: true + disk_gb: + type: integer + nullable: true + address: + type: string + nullable: true + status: + type: string + enum: [pending, online, offline, draining] + last_heartbeat_at: + type: string + format: date-time + nullable: true + created_by: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + AddTagRequest: + type: object + required: [tag] + properties: + tag: + type: string + + Error: + type: object + properties: + error: + type: object + properties: + code: + type: string + message: + type: string diff --git a/internal/api/reconciler.go b/internal/api/reconciler.go new file mode 100644 index 0000000..fcc2388 --- /dev/null +++ b/internal/api/reconciler.go @@ -0,0 +1,126 @@ +package api + +import ( + "context" + "log/slog" + "time" + + "connectrpc.com/connect" + + "git.omukk.dev/wrenn/sandbox/internal/db" + pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" + "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" +) + +// Reconciler periodically compares the host agent's sandbox list with the DB +// and marks sandboxes that no longer exist on the host as stopped. +type Reconciler struct { + db *db.Queries + agent hostagentv1connect.HostAgentServiceClient + hostID string + interval time.Duration +} + +// NewReconciler creates a new reconciler. +func NewReconciler(db *db.Queries, agent hostagentv1connect.HostAgentServiceClient, hostID string, interval time.Duration) *Reconciler { + return &Reconciler{ + db: db, + agent: agent, + hostID: hostID, + interval: interval, + } +} + +// Start runs the reconciliation loop until the context is cancelled. +func (rc *Reconciler) Start(ctx context.Context) { + go func() { + ticker := time.NewTicker(rc.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + rc.reconcile(ctx) + } + } + }() +} + +func (rc *Reconciler) reconcile(ctx context.Context) { + // Single RPC returns both the running sandbox list and any IDs that + // were auto-paused by the TTL reaper since the last call. + resp, err := rc.agent.ListSandboxes(ctx, connect.NewRequest(&pb.ListSandboxesRequest{})) + if err != nil { + slog.Warn("reconciler: failed to list sandboxes from host agent", "error", err) + return + } + + // Build a set of sandbox IDs that are alive on the host. + alive := make(map[string]struct{}, len(resp.Msg.Sandboxes)) + for _, sb := range resp.Msg.Sandboxes { + alive[sb.SandboxId] = struct{}{} + } + + // Build auto-paused set from the same response. + autoPausedSet := make(map[string]struct{}, len(resp.Msg.AutoPausedSandboxIds)) + for _, id := range resp.Msg.AutoPausedSandboxIds { + autoPausedSet[id] = struct{}{} + } + + // Get all DB sandboxes for this host that are running. + // Paused sandboxes are excluded: they are expected to not exist on the + // host agent because pause = snapshot + destroy resources. + dbSandboxes, err := rc.db.ListSandboxesByHostAndStatus(ctx, db.ListSandboxesByHostAndStatusParams{ + HostID: rc.hostID, + Column2: []string{"running"}, + }) + if err != nil { + slog.Warn("reconciler: failed to list DB sandboxes", "error", err) + return + } + + // Find sandboxes in DB that are no longer on the host. + var stale []string + for _, sb := range dbSandboxes { + if _, ok := alive[sb.ID]; !ok { + stale = append(stale, sb.ID) + } + } + + if len(stale) == 0 { + return + } + + // Split stale sandboxes into those auto-paused by the TTL reaper vs + // those that crashed/were orphaned. + var toPause, toStop []string + for _, id := range stale { + if _, ok := autoPausedSet[id]; ok { + toPause = append(toPause, id) + } else { + toStop = append(toStop, id) + } + } + + if len(toPause) > 0 { + slog.Info("reconciler: marking auto-paused sandboxes", "count", len(toPause), "ids", toPause) + if err := rc.db.BulkUpdateStatusByIDs(ctx, db.BulkUpdateStatusByIDsParams{ + Column1: toPause, + Status: "paused", + }); err != nil { + slog.Warn("reconciler: failed to mark auto-paused sandboxes", "error", err) + } + } + + if len(toStop) > 0 { + slog.Info("reconciler: marking stale sandboxes as stopped", "count", len(toStop), "ids", toStop) + if err := rc.db.BulkUpdateStatusByIDs(ctx, db.BulkUpdateStatusByIDsParams{ + Column1: toStop, + Status: "stopped", + }); err != nil { + slog.Warn("reconciler: failed to update stale sandboxes", "error", err) + } + } +} diff --git a/internal/api/server.go b/internal/api/server.go index e69de29..3760167 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -0,0 +1,158 @@ +package api + +import ( + _ "embed" + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" + + "git.omukk.dev/wrenn/sandbox/internal/auth/oauth" + "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/service" + "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" +) + +//go:embed openapi.yaml +var openapiYAML []byte + +// Server is the control plane HTTP server. +type Server struct { + router chi.Router +} + +// New constructs the chi router and registers all routes. +func New(queries *db.Queries, agent hostagentv1connect.HostAgentServiceClient, pool *pgxpool.Pool, rdb *redis.Client, jwtSecret []byte, oauthRegistry *oauth.Registry, oauthRedirectURL string) *Server { + r := chi.NewRouter() + r.Use(requestLogger()) + + // Shared service layer. + sandboxSvc := &service.SandboxService{DB: queries, Agent: agent} + apiKeySvc := &service.APIKeyService{DB: queries} + templateSvc := &service.TemplateService{DB: queries} + hostSvc := &service.HostService{DB: queries, Redis: rdb, JWT: jwtSecret} + + sandbox := newSandboxHandler(sandboxSvc) + exec := newExecHandler(queries, agent) + execStream := newExecStreamHandler(queries, agent) + files := newFilesHandler(queries, agent) + filesStream := newFilesStreamHandler(queries, agent) + snapshots := newSnapshotHandler(templateSvc, queries, agent) + authH := newAuthHandler(queries, pool, jwtSecret) + oauthH := newOAuthHandler(queries, pool, jwtSecret, oauthRegistry, oauthRedirectURL) + apiKeys := newAPIKeyHandler(apiKeySvc) + hostH := newHostHandler(hostSvc, queries) + + // OpenAPI spec and docs. + r.Get("/openapi.yaml", serveOpenAPI) + r.Get("/docs", serveDocs) + + // Unauthenticated auth endpoints. + r.Post("/v1/auth/signup", authH.Signup) + r.Post("/v1/auth/login", authH.Login) + r.Get("/auth/oauth/{provider}", oauthH.Redirect) + r.Get("/auth/oauth/{provider}/callback", oauthH.Callback) + + // JWT-authenticated: API key management. + r.Route("/v1/api-keys", func(r chi.Router) { + r.Use(requireJWT(jwtSecret)) + r.Post("/", apiKeys.Create) + r.Get("/", apiKeys.List) + r.Delete("/{id}", apiKeys.Delete) + }) + + // Sandbox lifecycle: accepts API key or JWT bearer token. + r.Route("/v1/sandboxes", func(r chi.Router) { + r.Use(requireAPIKeyOrJWT(queries, jwtSecret)) + r.Post("/", sandbox.Create) + r.Get("/", sandbox.List) + + r.Route("/{id}", func(r chi.Router) { + r.Get("/", sandbox.Get) + r.Delete("/", sandbox.Destroy) + r.Post("/exec", exec.Exec) + r.Get("/exec/stream", execStream.ExecStream) + r.Post("/ping", sandbox.Ping) + r.Post("/pause", sandbox.Pause) + r.Post("/resume", sandbox.Resume) + r.Post("/files/write", files.Upload) + r.Post("/files/read", files.Download) + r.Post("/files/stream/write", filesStream.StreamUpload) + r.Post("/files/stream/read", filesStream.StreamDownload) + }) + }) + + // Snapshot / template management: accepts API key or JWT bearer token. + r.Route("/v1/snapshots", func(r chi.Router) { + r.Use(requireAPIKeyOrJWT(queries, jwtSecret)) + r.Post("/", snapshots.Create) + r.Get("/", snapshots.List) + r.Delete("/{name}", snapshots.Delete) + }) + + // Host management. + r.Route("/v1/hosts", func(r chi.Router) { + // Unauthenticated: one-time registration token. + r.Post("/register", hostH.Register) + + // Host-token-authenticated: heartbeat. + r.With(requireHostToken(jwtSecret)).Post("/{id}/heartbeat", hostH.Heartbeat) + + // JWT-authenticated: host CRUD and tags. + r.Group(func(r chi.Router) { + r.Use(requireJWT(jwtSecret)) + r.Post("/", hostH.Create) + r.Get("/", hostH.List) + r.Route("/{id}", func(r chi.Router) { + r.Get("/", hostH.Get) + r.Delete("/", hostH.Delete) + r.Post("/token", hostH.RegenerateToken) + r.Get("/tags", hostH.ListTags) + r.Post("/tags", hostH.AddTag) + r.Delete("/tags/{tag}", hostH.RemoveTag) + }) + }) + }) + + return &Server{router: r} +} + +// Handler returns the HTTP handler. +func (s *Server) Handler() http.Handler { + return s.router +} + +func serveOpenAPI(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/yaml") + _, _ = w.Write(openapiYAML) +} + +func serveDocs(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + fmt.Fprint(w, ` + + + + + Wrenn Sandbox API + + + + +
+ + + +`) +} diff --git a/internal/auth/apikey.go b/internal/auth/apikey.go index e69de29..bc00a31 100644 --- a/internal/auth/apikey.go +++ b/internal/auth/apikey.go @@ -0,0 +1,35 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" +) + +// GenerateAPIKey returns a plaintext key in the form "wrn_" + 32 random hex chars +// and its SHA-256 hash. The caller must show the plaintext to the user exactly once; +// only the hash is stored. +func GenerateAPIKey() (plaintext, hash string, err error) { + b := make([]byte, 16) // 16 bytes → 32 hex chars + if _, err = rand.Read(b); err != nil { + return "", "", fmt.Errorf("generate api key: %w", err) + } + plaintext = "wrn_" + hex.EncodeToString(b) + hash = HashAPIKey(plaintext) + return plaintext, hash, nil +} + +// HashAPIKey returns the hex-encoded SHA-256 hash of a plaintext API key. +func HashAPIKey(plaintext string) string { + sum := sha256.Sum256([]byte(plaintext)) + return hex.EncodeToString(sum[:]) +} + +// APIKeyPrefix returns the first 8 characters of a plaintext API key (e.g. "wrn_ab12"). +func APIKeyPrefix(plaintext string) string { + if len(plaintext) > 10 { + return plaintext[:10] + } + return plaintext +} diff --git a/internal/auth/context.go b/internal/auth/context.go new file mode 100644 index 0000000..a1ebf69 --- /dev/null +++ b/internal/auth/context.go @@ -0,0 +1,63 @@ +package auth + +import "context" + +type contextKey int + +const authCtxKey contextKey = 0 + +// AuthContext is stamped into request context by auth middleware. +type AuthContext struct { + TeamID string + UserID string // empty when authenticated via API key + Email string // empty when authenticated via API key +} + +// WithAuthContext returns a new context with the given AuthContext. +func WithAuthContext(ctx context.Context, a AuthContext) context.Context { + return context.WithValue(ctx, authCtxKey, a) +} + +// FromContext retrieves the AuthContext. Returns zero value and false if absent. +func FromContext(ctx context.Context) (AuthContext, bool) { + a, ok := ctx.Value(authCtxKey).(AuthContext) + return a, ok +} + +// MustFromContext retrieves the AuthContext. Panics if absent — only call +// inside handlers behind auth middleware. +func MustFromContext(ctx context.Context) AuthContext { + a, ok := FromContext(ctx) + if !ok { + panic("auth: MustFromContext called on unauthenticated request") + } + return a +} + +const hostCtxKey contextKey = 1 + +// HostContext is stamped into request context by host token middleware. +type HostContext struct { + HostID string +} + +// WithHostContext returns a new context with the given HostContext. +func WithHostContext(ctx context.Context, h HostContext) context.Context { + return context.WithValue(ctx, hostCtxKey, h) +} + +// HostFromContext retrieves the HostContext. Returns zero value and false if absent. +func HostFromContext(ctx context.Context) (HostContext, bool) { + h, ok := ctx.Value(hostCtxKey).(HostContext) + return h, ok +} + +// MustHostFromContext retrieves the HostContext. Panics if absent — only call +// inside handlers behind host token middleware. +func MustHostFromContext(ctx context.Context) HostContext { + h, ok := HostFromContext(ctx) + if !ok { + panic("auth: MustHostFromContext called on unauthenticated request") + } + return h +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..45818ff --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,102 @@ +package auth + +import ( + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const jwtExpiry = 6 * time.Hour +const hostJWTExpiry = 8760 * time.Hour // 1 year + +// Claims are the JWT payload for user tokens. +type Claims struct { + Type string `json:"typ,omitempty"` // empty for user tokens; used to reject host tokens + TeamID string `json:"team_id"` + Email string `json:"email"` + jwt.RegisteredClaims +} + +// SignJWT signs a new 6-hour JWT for the given user. +func SignJWT(secret []byte, userID, teamID, email string) (string, error) { + now := time.Now() + claims := Claims{ + TeamID: teamID, + Email: email, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(jwtExpiry)), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(secret) +} + +// VerifyJWT parses and validates a user JWT, returning the claims on success. +// Rejects host JWTs (which carry a "typ" claim) to prevent cross-token confusion. +func VerifyJWT(secret []byte, tokenStr string) (Claims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return secret, nil + }) + if err != nil { + return Claims{}, fmt.Errorf("invalid token: %w", err) + } + c, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return Claims{}, fmt.Errorf("invalid token claims") + } + if c.Type == "host" { + return Claims{}, fmt.Errorf("invalid token: host token cannot be used as user token") + } + return *c, nil +} + +// HostClaims are the JWT payload for host agent tokens. +type HostClaims struct { + Type string `json:"typ"` // always "host" + HostID string `json:"host_id"` + jwt.RegisteredClaims +} + +// SignHostJWT signs a long-lived (1 year) JWT for a registered host agent. +func SignHostJWT(secret []byte, hostID string) (string, error) { + now := time.Now() + claims := HostClaims{ + Type: "host", + HostID: hostID, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: hostID, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(hostJWTExpiry)), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(secret) +} + +// VerifyHostJWT parses and validates a host JWT, returning the claims on success. +// It rejects user JWTs by checking the "typ" claim. +func VerifyHostJWT(secret []byte, tokenStr string) (HostClaims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &HostClaims{}, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return secret, nil + }) + if err != nil { + return HostClaims{}, fmt.Errorf("invalid token: %w", err) + } + c, ok := token.Claims.(*HostClaims) + if !ok || !token.Valid { + return HostClaims{}, fmt.Errorf("invalid token claims") + } + if c.Type != "host" { + return HostClaims{}, fmt.Errorf("invalid token type: expected host") + } + return *c, nil +} diff --git a/internal/auth/oauth/github.go b/internal/auth/oauth/github.go new file mode 100644 index 0000000..76d3f4f --- /dev/null +++ b/internal/auth/oauth/github.go @@ -0,0 +1,127 @@ +package oauth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/endpoints" +) + +// GitHubProvider implements Provider for GitHub OAuth. +type GitHubProvider struct { + cfg *oauth2.Config +} + +// NewGitHubProvider creates a GitHub OAuth provider. +func NewGitHubProvider(clientID, clientSecret, callbackURL string) *GitHubProvider { + return &GitHubProvider{ + cfg: &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + Endpoint: endpoints.GitHub, + Scopes: []string{"user:email"}, + RedirectURL: callbackURL, + }, + } +} + +func (p *GitHubProvider) Name() string { return "github" } + +func (p *GitHubProvider) AuthCodeURL(state string) string { + return p.cfg.AuthCodeURL(state, oauth2.AccessTypeOnline) +} + +func (p *GitHubProvider) Exchange(ctx context.Context, code string) (UserProfile, error) { + token, err := p.cfg.Exchange(ctx, code) + if err != nil { + return UserProfile{}, fmt.Errorf("exchange code: %w", err) + } + + client := p.cfg.Client(ctx, token) + + profile, err := fetchGitHubUser(client) + if err != nil { + return UserProfile{}, err + } + + // GitHub may not include email if the user's email is private. + if profile.Email == "" { + email, err := fetchGitHubPrimaryEmail(client) + if err != nil { + return UserProfile{}, err + } + profile.Email = email + } + + return profile, nil +} + +type githubUser struct { + ID int64 `json:"id"` + Login string `json:"login"` + Email string `json:"email"` + Name string `json:"name"` +} + +func fetchGitHubUser(client *http.Client) (UserProfile, error) { + resp, err := client.Get("https://api.github.com/user") + if err != nil { + return UserProfile{}, fmt.Errorf("fetch github user: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return UserProfile{}, fmt.Errorf("github /user returned %d", resp.StatusCode) + } + + var u githubUser + if err := json.NewDecoder(resp.Body).Decode(&u); err != nil { + return UserProfile{}, fmt.Errorf("decode github user: %w", err) + } + + name := u.Name + if name == "" { + name = u.Login + } + + return UserProfile{ + ProviderID: strconv.FormatInt(u.ID, 10), + Email: u.Email, + Name: name, + }, nil +} + +type githubEmail struct { + Email string `json:"email"` + Primary bool `json:"primary"` + Verified bool `json:"verified"` +} + +func fetchGitHubPrimaryEmail(client *http.Client) (string, error) { + resp, err := client.Get("https://api.github.com/user/emails") + if err != nil { + return "", fmt.Errorf("fetch github emails: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("github /user/emails returned %d", resp.StatusCode) + } + + var emails []githubEmail + if err := json.NewDecoder(resp.Body).Decode(&emails); err != nil { + return "", fmt.Errorf("decode github emails: %w", err) + } + + for _, e := range emails { + if e.Primary && e.Verified { + return e.Email, nil + } + } + + return "", fmt.Errorf("github account has no verified primary email") +} diff --git a/internal/auth/oauth/provider.go b/internal/auth/oauth/provider.go new file mode 100644 index 0000000..f5beca8 --- /dev/null +++ b/internal/auth/oauth/provider.go @@ -0,0 +1,41 @@ +package oauth + +import "context" + +// UserProfile is the normalized user info returned by an OAuth provider. +type UserProfile struct { + ProviderID string + Email string + Name string +} + +// Provider abstracts an OAuth 2.0 identity provider. +type Provider interface { + // Name returns the provider identifier (e.g. "github", "google"). + Name() string + // AuthCodeURL returns the URL to redirect the user to for authorization. + AuthCodeURL(state string) string + // Exchange trades an authorization code for a user profile. + Exchange(ctx context.Context, code string) (UserProfile, error) +} + +// Registry maps provider names to Provider implementations. +type Registry struct { + providers map[string]Provider +} + +// NewRegistry creates an empty provider registry. +func NewRegistry() *Registry { + return &Registry{providers: make(map[string]Provider)} +} + +// Register adds a provider to the registry. +func (r *Registry) Register(p Provider) { + r.providers[p.Name()] = p +} + +// Get looks up a provider by name. +func (r *Registry) Get(name string) (Provider, bool) { + p, ok := r.providers[name] + return p, ok +} diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..0c285a6 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,16 @@ +package auth + +import "golang.org/x/crypto/bcrypt" + +const bcryptCost = 12 + +// HashPassword returns the bcrypt hash of a plaintext password. +func HashPassword(plaintext string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(plaintext), bcryptCost) + return string(b), err +} + +// CheckPassword returns nil if plaintext matches the stored hash. +func CheckPassword(hash, plaintext string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintext)) +} diff --git a/internal/auth/ratelimit.go b/internal/auth/ratelimit.go deleted file mode 100644 index e69de29..0000000 diff --git a/internal/config/config.go b/internal/config/config.go index e69de29..b881afb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -0,0 +1,56 @@ +package config + +import ( + "os" + "strings" + + "github.com/joho/godotenv" +) + +// Config holds the control plane configuration. +type Config struct { + DatabaseURL string + RedisURL string + ListenAddr string + HostAgentAddr string + JWTSecret string + + OAuthGitHubClientID string + OAuthGitHubClientSecret string + OAuthRedirectURL string + CPPublicURL string +} + +// Load reads configuration from a .env file (if present) and environment variables. +// Real environment variables take precedence over .env values. +func Load() Config { + // Best-effort load — missing .env file is fine. + _ = godotenv.Load() + + cfg := Config{ + DatabaseURL: envOrDefault("DATABASE_URL", "postgres://wrenn:wrenn@localhost:5432/wrenn?sslmode=disable"), + RedisURL: envOrDefault("REDIS_URL", "redis://localhost:6379/0"), + ListenAddr: envOrDefault("CP_LISTEN_ADDR", ":8080"), + HostAgentAddr: envOrDefault("CP_HOST_AGENT_ADDR", "http://localhost:50051"), + JWTSecret: os.Getenv("JWT_SECRET"), + + OAuthGitHubClientID: os.Getenv("OAUTH_GITHUB_CLIENT_ID"), + OAuthGitHubClientSecret: os.Getenv("OAUTH_GITHUB_CLIENT_SECRET"), + OAuthRedirectURL: envOrDefault("OAUTH_REDIRECT_URL", "https://app.wrenn.dev"), + CPPublicURL: os.Getenv("CP_PUBLIC_URL"), + } + + // Ensure the host agent address has a scheme. + if !strings.HasPrefix(cfg.HostAgentAddr, "http://") && !strings.HasPrefix(cfg.HostAgentAddr, "https://") { + cfg.HostAgentAddr = "http://" + cfg.HostAgentAddr + } + + return cfg +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/internal/db/api_keys.sql.go b/internal/db/api_keys.sql.go new file mode 100644 index 0000000..b4f0ffc --- /dev/null +++ b/internal/db/api_keys.sql.go @@ -0,0 +1,177 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: api_keys.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const deleteAPIKey = `-- name: DeleteAPIKey :exec +DELETE FROM team_api_keys WHERE id = $1 AND team_id = $2 +` + +type DeleteAPIKeyParams struct { + ID string `json:"id"` + TeamID string `json:"team_id"` +} + +func (q *Queries) DeleteAPIKey(ctx context.Context, arg DeleteAPIKeyParams) error { + _, err := q.db.Exec(ctx, deleteAPIKey, arg.ID, arg.TeamID) + 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 +` + +func (q *Queries) GetAPIKeyByHash(ctx context.Context, keyHash string) (TeamApiKey, error) { + row := q.db.QueryRow(ctx, getAPIKeyByHash, keyHash) + var i TeamApiKey + err := row.Scan( + &i.ID, + &i.TeamID, + &i.Name, + &i.KeyHash, + &i.KeyPrefix, + &i.CreatedBy, + &i.CreatedAt, + &i.LastUsed, + ) + return i, err +} + +const insertAPIKey = `-- name: InsertAPIKey :one +INSERT INTO team_api_keys (id, team_id, name, key_hash, key_prefix, created_by) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, team_id, name, key_hash, key_prefix, created_by, created_at, last_used +` + +type InsertAPIKeyParams struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + Name string `json:"name"` + KeyHash string `json:"key_hash"` + KeyPrefix string `json:"key_prefix"` + CreatedBy string `json:"created_by"` +} + +func (q *Queries) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (TeamApiKey, error) { + row := q.db.QueryRow(ctx, insertAPIKey, + arg.ID, + arg.TeamID, + arg.Name, + arg.KeyHash, + arg.KeyPrefix, + arg.CreatedBy, + ) + var i TeamApiKey + err := row.Scan( + &i.ID, + &i.TeamID, + &i.Name, + &i.KeyHash, + &i.KeyPrefix, + &i.CreatedBy, + &i.CreatedAt, + &i.LastUsed, + ) + return i, err +} + +const listAPIKeysByTeam = `-- name: ListAPIKeysByTeam :many +SELECT id, team_id, name, key_hash, key_prefix, created_by, created_at, last_used FROM team_api_keys WHERE team_id = $1 ORDER BY created_at DESC +` + +func (q *Queries) ListAPIKeysByTeam(ctx context.Context, teamID string) ([]TeamApiKey, error) { + rows, err := q.db.Query(ctx, listAPIKeysByTeam, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []TeamApiKey + for rows.Next() { + var i TeamApiKey + if err := rows.Scan( + &i.ID, + &i.TeamID, + &i.Name, + &i.KeyHash, + &i.KeyPrefix, + &i.CreatedBy, + &i.CreatedAt, + &i.LastUsed, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAPIKeysByTeamWithCreator = `-- name: ListAPIKeysByTeamWithCreator :many +SELECT k.id, k.team_id, k.name, k.key_hash, k.key_prefix, k.created_by, k.created_at, k.last_used, + u.email AS creator_email +FROM team_api_keys k +JOIN users u ON u.id = k.created_by +WHERE k.team_id = $1 +ORDER BY k.created_at DESC +` + +type ListAPIKeysByTeamWithCreatorRow struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + Name string `json:"name"` + KeyHash string `json:"key_hash"` + KeyPrefix string `json:"key_prefix"` + CreatedBy string `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + LastUsed pgtype.Timestamptz `json:"last_used"` + CreatorEmail string `json:"creator_email"` +} + +func (q *Queries) ListAPIKeysByTeamWithCreator(ctx context.Context, teamID string) ([]ListAPIKeysByTeamWithCreatorRow, error) { + rows, err := q.db.Query(ctx, listAPIKeysByTeamWithCreator, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAPIKeysByTeamWithCreatorRow + for rows.Next() { + var i ListAPIKeysByTeamWithCreatorRow + if err := rows.Scan( + &i.ID, + &i.TeamID, + &i.Name, + &i.KeyHash, + &i.KeyPrefix, + &i.CreatedBy, + &i.CreatedAt, + &i.LastUsed, + &i.CreatorEmail, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateAPIKeyLastUsed = `-- name: UpdateAPIKeyLastUsed :exec +UPDATE team_api_keys SET last_used = NOW() WHERE id = $1 +` + +func (q *Queries) UpdateAPIKeyLastUsed(ctx context.Context, id string) error { + _, err := q.db.Exec(ctx, updateAPIKeyLastUsed, id) + return err +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..9d485b5 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/db/hosts.sql.go b/internal/db/hosts.sql.go new file mode 100644 index 0000000..ad15290 --- /dev/null +++ b/internal/db/hosts.sql.go @@ -0,0 +1,536 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: hosts.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const addHostTag = `-- name: AddHostTag :exec +INSERT INTO host_tags (host_id, tag) VALUES ($1, $2) ON CONFLICT DO NOTHING +` + +type AddHostTagParams struct { + HostID string `json:"host_id"` + Tag string `json:"tag"` +} + +func (q *Queries) AddHostTag(ctx context.Context, arg AddHostTagParams) error { + _, err := q.db.Exec(ctx, addHostTag, arg.HostID, arg.Tag) + return err +} + +const deleteHost = `-- name: DeleteHost :exec +DELETE FROM hosts WHERE id = $1 +` + +func (q *Queries) DeleteHost(ctx context.Context, id string) error { + _, err := q.db.Exec(ctx, deleteHost, id) + return err +} + +const getHost = `-- name: GetHost :one +SELECT id, type, team_id, provider, availability_zone, arch, cpu_cores, memory_mb, disk_gb, address, status, last_heartbeat_at, metadata, created_by, created_at, updated_at, cert_fingerprint, mtls_enabled FROM hosts WHERE id = $1 +` + +func (q *Queries) GetHost(ctx context.Context, id string) (Host, error) { + row := q.db.QueryRow(ctx, getHost, id) + var i Host + err := row.Scan( + &i.ID, + &i.Type, + &i.TeamID, + &i.Provider, + &i.AvailabilityZone, + &i.Arch, + &i.CpuCores, + &i.MemoryMb, + &i.DiskGb, + &i.Address, + &i.Status, + &i.LastHeartbeatAt, + &i.Metadata, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.CertFingerprint, + &i.MtlsEnabled, + ) + return i, err +} + +const getHostByTeam = `-- name: GetHostByTeam :one +SELECT id, type, team_id, provider, availability_zone, arch, cpu_cores, memory_mb, disk_gb, address, status, last_heartbeat_at, metadata, created_by, created_at, updated_at, cert_fingerprint, mtls_enabled FROM hosts WHERE id = $1 AND team_id = $2 +` + +type GetHostByTeamParams struct { + ID string `json:"id"` + TeamID pgtype.Text `json:"team_id"` +} + +func (q *Queries) GetHostByTeam(ctx context.Context, arg GetHostByTeamParams) (Host, error) { + row := q.db.QueryRow(ctx, getHostByTeam, arg.ID, arg.TeamID) + var i Host + err := row.Scan( + &i.ID, + &i.Type, + &i.TeamID, + &i.Provider, + &i.AvailabilityZone, + &i.Arch, + &i.CpuCores, + &i.MemoryMb, + &i.DiskGb, + &i.Address, + &i.Status, + &i.LastHeartbeatAt, + &i.Metadata, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.CertFingerprint, + &i.MtlsEnabled, + ) + return i, err +} + +const getHostTags = `-- name: GetHostTags :many +SELECT tag FROM host_tags WHERE host_id = $1 ORDER BY tag +` + +func (q *Queries) GetHostTags(ctx context.Context, hostID string) ([]string, error) { + rows, err := q.db.Query(ctx, getHostTags, hostID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var tag string + if err := rows.Scan(&tag); err != nil { + return nil, err + } + items = append(items, tag) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getHostTokensByHost = `-- name: GetHostTokensByHost :many +SELECT id, host_id, created_by, created_at, expires_at, used_at FROM host_tokens WHERE host_id = $1 ORDER BY created_at DESC +` + +func (q *Queries) GetHostTokensByHost(ctx context.Context, hostID string) ([]HostToken, error) { + rows, err := q.db.Query(ctx, getHostTokensByHost, hostID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []HostToken + for rows.Next() { + var i HostToken + if err := rows.Scan( + &i.ID, + &i.HostID, + &i.CreatedBy, + &i.CreatedAt, + &i.ExpiresAt, + &i.UsedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const insertHost = `-- name: InsertHost :one +INSERT INTO hosts (id, type, team_id, provider, availability_zone, created_by) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING id, type, team_id, provider, availability_zone, arch, cpu_cores, memory_mb, disk_gb, address, status, last_heartbeat_at, metadata, created_by, created_at, updated_at, cert_fingerprint, mtls_enabled +` + +type InsertHostParams struct { + ID string `json:"id"` + Type string `json:"type"` + TeamID pgtype.Text `json:"team_id"` + Provider pgtype.Text `json:"provider"` + AvailabilityZone pgtype.Text `json:"availability_zone"` + CreatedBy string `json:"created_by"` +} + +func (q *Queries) InsertHost(ctx context.Context, arg InsertHostParams) (Host, error) { + row := q.db.QueryRow(ctx, insertHost, + arg.ID, + arg.Type, + arg.TeamID, + arg.Provider, + arg.AvailabilityZone, + arg.CreatedBy, + ) + var i Host + err := row.Scan( + &i.ID, + &i.Type, + &i.TeamID, + &i.Provider, + &i.AvailabilityZone, + &i.Arch, + &i.CpuCores, + &i.MemoryMb, + &i.DiskGb, + &i.Address, + &i.Status, + &i.LastHeartbeatAt, + &i.Metadata, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.CertFingerprint, + &i.MtlsEnabled, + ) + return i, err +} + +const insertHostToken = `-- name: InsertHostToken :one +INSERT INTO host_tokens (id, host_id, created_by, expires_at) +VALUES ($1, $2, $3, $4) +RETURNING id, host_id, created_by, created_at, expires_at, used_at +` + +type InsertHostTokenParams struct { + ID string `json:"id"` + HostID string `json:"host_id"` + CreatedBy string `json:"created_by"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` +} + +func (q *Queries) InsertHostToken(ctx context.Context, arg InsertHostTokenParams) (HostToken, error) { + row := q.db.QueryRow(ctx, insertHostToken, + arg.ID, + arg.HostID, + arg.CreatedBy, + arg.ExpiresAt, + ) + var i HostToken + err := row.Scan( + &i.ID, + &i.HostID, + &i.CreatedBy, + &i.CreatedAt, + &i.ExpiresAt, + &i.UsedAt, + ) + return i, err +} + +const listHosts = `-- name: ListHosts :many +SELECT id, type, team_id, provider, availability_zone, arch, cpu_cores, memory_mb, disk_gb, address, status, last_heartbeat_at, metadata, created_by, created_at, updated_at, cert_fingerprint, mtls_enabled FROM hosts ORDER BY created_at DESC +` + +func (q *Queries) ListHosts(ctx context.Context) ([]Host, error) { + rows, err := q.db.Query(ctx, listHosts) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Host + for rows.Next() { + var i Host + if err := rows.Scan( + &i.ID, + &i.Type, + &i.TeamID, + &i.Provider, + &i.AvailabilityZone, + &i.Arch, + &i.CpuCores, + &i.MemoryMb, + &i.DiskGb, + &i.Address, + &i.Status, + &i.LastHeartbeatAt, + &i.Metadata, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.CertFingerprint, + &i.MtlsEnabled, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listHostsByStatus = `-- name: ListHostsByStatus :many +SELECT id, type, team_id, provider, availability_zone, arch, cpu_cores, memory_mb, disk_gb, address, status, last_heartbeat_at, metadata, created_by, created_at, updated_at, cert_fingerprint, mtls_enabled FROM hosts WHERE status = $1 ORDER BY created_at DESC +` + +func (q *Queries) ListHostsByStatus(ctx context.Context, status string) ([]Host, error) { + rows, err := q.db.Query(ctx, listHostsByStatus, status) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Host + for rows.Next() { + var i Host + if err := rows.Scan( + &i.ID, + &i.Type, + &i.TeamID, + &i.Provider, + &i.AvailabilityZone, + &i.Arch, + &i.CpuCores, + &i.MemoryMb, + &i.DiskGb, + &i.Address, + &i.Status, + &i.LastHeartbeatAt, + &i.Metadata, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.CertFingerprint, + &i.MtlsEnabled, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listHostsByTag = `-- name: ListHostsByTag :many +SELECT h.id, h.type, h.team_id, h.provider, h.availability_zone, h.arch, h.cpu_cores, h.memory_mb, h.disk_gb, h.address, h.status, h.last_heartbeat_at, h.metadata, h.created_by, h.created_at, h.updated_at, h.cert_fingerprint, h.mtls_enabled FROM hosts h +JOIN host_tags ht ON ht.host_id = h.id +WHERE ht.tag = $1 +ORDER BY h.created_at DESC +` + +func (q *Queries) ListHostsByTag(ctx context.Context, tag string) ([]Host, error) { + rows, err := q.db.Query(ctx, listHostsByTag, tag) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Host + for rows.Next() { + var i Host + if err := rows.Scan( + &i.ID, + &i.Type, + &i.TeamID, + &i.Provider, + &i.AvailabilityZone, + &i.Arch, + &i.CpuCores, + &i.MemoryMb, + &i.DiskGb, + &i.Address, + &i.Status, + &i.LastHeartbeatAt, + &i.Metadata, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.CertFingerprint, + &i.MtlsEnabled, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listHostsByTeam = `-- name: ListHostsByTeam :many +SELECT id, type, team_id, provider, availability_zone, arch, cpu_cores, memory_mb, disk_gb, address, status, last_heartbeat_at, metadata, created_by, created_at, updated_at, cert_fingerprint, mtls_enabled FROM hosts WHERE team_id = $1 AND type = 'byoc' ORDER BY created_at DESC +` + +func (q *Queries) ListHostsByTeam(ctx context.Context, teamID pgtype.Text) ([]Host, error) { + rows, err := q.db.Query(ctx, listHostsByTeam, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Host + for rows.Next() { + var i Host + if err := rows.Scan( + &i.ID, + &i.Type, + &i.TeamID, + &i.Provider, + &i.AvailabilityZone, + &i.Arch, + &i.CpuCores, + &i.MemoryMb, + &i.DiskGb, + &i.Address, + &i.Status, + &i.LastHeartbeatAt, + &i.Metadata, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.CertFingerprint, + &i.MtlsEnabled, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listHostsByType = `-- name: ListHostsByType :many +SELECT id, type, team_id, provider, availability_zone, arch, cpu_cores, memory_mb, disk_gb, address, status, last_heartbeat_at, metadata, created_by, created_at, updated_at, cert_fingerprint, mtls_enabled FROM hosts WHERE type = $1 ORDER BY created_at DESC +` + +func (q *Queries) ListHostsByType(ctx context.Context, type_ string) ([]Host, error) { + rows, err := q.db.Query(ctx, listHostsByType, type_) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Host + for rows.Next() { + var i Host + if err := rows.Scan( + &i.ID, + &i.Type, + &i.TeamID, + &i.Provider, + &i.AvailabilityZone, + &i.Arch, + &i.CpuCores, + &i.MemoryMb, + &i.DiskGb, + &i.Address, + &i.Status, + &i.LastHeartbeatAt, + &i.Metadata, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.CertFingerprint, + &i.MtlsEnabled, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markHostTokenUsed = `-- name: MarkHostTokenUsed :exec +UPDATE host_tokens SET used_at = NOW() WHERE id = $1 +` + +func (q *Queries) MarkHostTokenUsed(ctx context.Context, id string) error { + _, err := q.db.Exec(ctx, markHostTokenUsed, id) + return err +} + +const registerHost = `-- name: RegisterHost :execrows +UPDATE hosts +SET arch = $2, + cpu_cores = $3, + memory_mb = $4, + disk_gb = $5, + address = $6, + status = 'online', + last_heartbeat_at = NOW(), + updated_at = NOW() +WHERE id = $1 AND status = 'pending' +` + +type RegisterHostParams struct { + ID string `json:"id"` + Arch pgtype.Text `json:"arch"` + CpuCores pgtype.Int4 `json:"cpu_cores"` + MemoryMb pgtype.Int4 `json:"memory_mb"` + DiskGb pgtype.Int4 `json:"disk_gb"` + Address pgtype.Text `json:"address"` +} + +func (q *Queries) RegisterHost(ctx context.Context, arg RegisterHostParams) (int64, error) { + result, err := q.db.Exec(ctx, registerHost, + arg.ID, + arg.Arch, + arg.CpuCores, + arg.MemoryMb, + arg.DiskGb, + arg.Address, + ) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const removeHostTag = `-- name: RemoveHostTag :exec +DELETE FROM host_tags WHERE host_id = $1 AND tag = $2 +` + +type RemoveHostTagParams struct { + HostID string `json:"host_id"` + Tag string `json:"tag"` +} + +func (q *Queries) RemoveHostTag(ctx context.Context, arg RemoveHostTagParams) error { + _, err := q.db.Exec(ctx, removeHostTag, arg.HostID, arg.Tag) + return err +} + +const updateHostHeartbeat = `-- name: UpdateHostHeartbeat :exec +UPDATE hosts SET last_heartbeat_at = NOW(), updated_at = NOW() WHERE id = $1 +` + +func (q *Queries) UpdateHostHeartbeat(ctx context.Context, id string) error { + _, err := q.db.Exec(ctx, updateHostHeartbeat, id) + return err +} + +const updateHostStatus = `-- name: UpdateHostStatus :exec +UPDATE hosts SET status = $2, updated_at = NOW() WHERE id = $1 +` + +type UpdateHostStatusParams struct { + ID string `json:"id"` + Status string `json:"status"` +} + +func (q *Queries) UpdateHostStatus(ctx context.Context, arg UpdateHostStatusParams) error { + _, err := q.db.Exec(ctx, updateHostStatus, arg.ID, arg.Status) + return err +} diff --git a/internal/db/models.go b/internal/db/models.go new file mode 100644 index 0000000..663a37b --- /dev/null +++ b/internal/db/models.go @@ -0,0 +1,121 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package db + +import ( + "github.com/jackc/pgx/v5/pgtype" +) + +type AdminPermission struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Permission string `json:"permission"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Host struct { + ID string `json:"id"` + Type string `json:"type"` + TeamID pgtype.Text `json:"team_id"` + Provider pgtype.Text `json:"provider"` + AvailabilityZone pgtype.Text `json:"availability_zone"` + Arch pgtype.Text `json:"arch"` + CpuCores pgtype.Int4 `json:"cpu_cores"` + MemoryMb pgtype.Int4 `json:"memory_mb"` + DiskGb pgtype.Int4 `json:"disk_gb"` + Address pgtype.Text `json:"address"` + Status string `json:"status"` + LastHeartbeatAt pgtype.Timestamptz `json:"last_heartbeat_at"` + Metadata []byte `json:"metadata"` + CreatedBy string `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + CertFingerprint pgtype.Text `json:"cert_fingerprint"` + MtlsEnabled bool `json:"mtls_enabled"` +} + +type HostTag struct { + HostID string `json:"host_id"` + Tag string `json:"tag"` +} + +type HostToken struct { + ID string `json:"id"` + HostID string `json:"host_id"` + CreatedBy string `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + UsedAt pgtype.Timestamptz `json:"used_at"` +} + +type OauthProvider struct { + Provider string `json:"provider"` + ProviderID string `json:"provider_id"` + UserID string `json:"user_id"` + Email string `json:"email"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Sandbox struct { + ID string `json:"id"` + HostID string `json:"host_id"` + Template string `json:"template"` + Status string `json:"status"` + Vcpus int32 `json:"vcpus"` + MemoryMb int32 `json:"memory_mb"` + TimeoutSec int32 `json:"timeout_sec"` + GuestIp string `json:"guest_ip"` + HostIp string `json:"host_ip"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + StartedAt pgtype.Timestamptz `json:"started_at"` + LastActiveAt pgtype.Timestamptz `json:"last_active_at"` + LastUpdated pgtype.Timestamptz `json:"last_updated"` + TeamID string `json:"team_id"` +} + +type Team struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + IsByoc bool `json:"is_byoc"` +} + +type TeamApiKey struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + Name string `json:"name"` + KeyHash string `json:"key_hash"` + KeyPrefix string `json:"key_prefix"` + CreatedBy string `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + LastUsed pgtype.Timestamptz `json:"last_used"` +} + +type Template struct { + Name string `json:"name"` + Type string `json:"type"` + Vcpus pgtype.Int4 `json:"vcpus"` + MemoryMb pgtype.Int4 `json:"memory_mb"` + SizeBytes int64 `json:"size_bytes"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + TeamID string `json:"team_id"` +} + +type User struct { + ID string `json:"id"` + Email string `json:"email"` + PasswordHash pgtype.Text `json:"password_hash"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + IsAdmin bool `json:"is_admin"` +} + +type UsersTeam struct { + UserID string `json:"user_id"` + TeamID string `json:"team_id"` + IsDefault bool `json:"is_default"` + Role string `json:"role"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} diff --git a/internal/db/oauth.sql.go b/internal/db/oauth.sql.go new file mode 100644 index 0000000..ab79eec --- /dev/null +++ b/internal/db/oauth.sql.go @@ -0,0 +1,55 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: oauth.sql + +package db + +import ( + "context" +) + +const getOAuthProvider = `-- name: GetOAuthProvider :one +SELECT provider, provider_id, user_id, email, created_at FROM oauth_providers +WHERE provider = $1 AND provider_id = $2 +` + +type GetOAuthProviderParams struct { + Provider string `json:"provider"` + ProviderID string `json:"provider_id"` +} + +func (q *Queries) GetOAuthProvider(ctx context.Context, arg GetOAuthProviderParams) (OauthProvider, error) { + row := q.db.QueryRow(ctx, getOAuthProvider, arg.Provider, arg.ProviderID) + var i OauthProvider + err := row.Scan( + &i.Provider, + &i.ProviderID, + &i.UserID, + &i.Email, + &i.CreatedAt, + ) + return i, err +} + +const insertOAuthProvider = `-- name: InsertOAuthProvider :exec +INSERT INTO oauth_providers (provider, provider_id, user_id, email) +VALUES ($1, $2, $3, $4) +` + +type InsertOAuthProviderParams struct { + Provider string `json:"provider"` + ProviderID string `json:"provider_id"` + UserID string `json:"user_id"` + Email string `json:"email"` +} + +func (q *Queries) InsertOAuthProvider(ctx context.Context, arg InsertOAuthProviderParams) error { + _, err := q.db.Exec(ctx, insertOAuthProvider, + arg.Provider, + arg.ProviderID, + arg.UserID, + arg.Email, + ) + return err +} diff --git a/internal/db/sandboxes.sql.go b/internal/db/sandboxes.sql.go new file mode 100644 index 0000000..2bc9481 --- /dev/null +++ b/internal/db/sandboxes.sql.go @@ -0,0 +1,358 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: sandboxes.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const bulkUpdateStatusByIDs = `-- name: BulkUpdateStatusByIDs :exec +UPDATE sandboxes +SET status = $2, + last_updated = NOW() +WHERE id = ANY($1::text[]) +` + +type BulkUpdateStatusByIDsParams struct { + Column1 []string `json:"column_1"` + Status string `json:"status"` +} + +func (q *Queries) BulkUpdateStatusByIDs(ctx context.Context, arg BulkUpdateStatusByIDsParams) error { + _, err := q.db.Exec(ctx, bulkUpdateStatusByIDs, arg.Column1, arg.Status) + return err +} + +const getSandbox = `-- name: GetSandbox :one +SELECT id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id FROM sandboxes WHERE id = $1 +` + +func (q *Queries) GetSandbox(ctx context.Context, id string) (Sandbox, error) { + row := q.db.QueryRow(ctx, getSandbox, id) + var i Sandbox + err := row.Scan( + &i.ID, + &i.HostID, + &i.Template, + &i.Status, + &i.Vcpus, + &i.MemoryMb, + &i.TimeoutSec, + &i.GuestIp, + &i.HostIp, + &i.CreatedAt, + &i.StartedAt, + &i.LastActiveAt, + &i.LastUpdated, + &i.TeamID, + ) + return i, err +} + +const getSandboxByTeam = `-- name: GetSandboxByTeam :one +SELECT id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id FROM sandboxes WHERE id = $1 AND team_id = $2 +` + +type GetSandboxByTeamParams struct { + ID string `json:"id"` + TeamID string `json:"team_id"` +} + +func (q *Queries) GetSandboxByTeam(ctx context.Context, arg GetSandboxByTeamParams) (Sandbox, error) { + row := q.db.QueryRow(ctx, getSandboxByTeam, arg.ID, arg.TeamID) + var i Sandbox + err := row.Scan( + &i.ID, + &i.HostID, + &i.Template, + &i.Status, + &i.Vcpus, + &i.MemoryMb, + &i.TimeoutSec, + &i.GuestIp, + &i.HostIp, + &i.CreatedAt, + &i.StartedAt, + &i.LastActiveAt, + &i.LastUpdated, + &i.TeamID, + ) + return i, err +} + +const insertSandbox = `-- name: InsertSandbox :one +INSERT INTO sandboxes (id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8) +RETURNING id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id +` + +type InsertSandboxParams struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + HostID string `json:"host_id"` + Template string `json:"template"` + Status string `json:"status"` + Vcpus int32 `json:"vcpus"` + MemoryMb int32 `json:"memory_mb"` + TimeoutSec int32 `json:"timeout_sec"` +} + +func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (Sandbox, error) { + row := q.db.QueryRow(ctx, insertSandbox, + arg.ID, + arg.TeamID, + arg.HostID, + arg.Template, + arg.Status, + arg.Vcpus, + arg.MemoryMb, + arg.TimeoutSec, + ) + var i Sandbox + err := row.Scan( + &i.ID, + &i.HostID, + &i.Template, + &i.Status, + &i.Vcpus, + &i.MemoryMb, + &i.TimeoutSec, + &i.GuestIp, + &i.HostIp, + &i.CreatedAt, + &i.StartedAt, + &i.LastActiveAt, + &i.LastUpdated, + &i.TeamID, + ) + return i, err +} + +const listSandboxes = `-- name: ListSandboxes :many +SELECT id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id FROM sandboxes ORDER BY created_at DESC +` + +func (q *Queries) ListSandboxes(ctx context.Context) ([]Sandbox, error) { + rows, err := q.db.Query(ctx, listSandboxes) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Sandbox + for rows.Next() { + var i Sandbox + if err := rows.Scan( + &i.ID, + &i.HostID, + &i.Template, + &i.Status, + &i.Vcpus, + &i.MemoryMb, + &i.TimeoutSec, + &i.GuestIp, + &i.HostIp, + &i.CreatedAt, + &i.StartedAt, + &i.LastActiveAt, + &i.LastUpdated, + &i.TeamID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSandboxesByHostAndStatus = `-- name: ListSandboxesByHostAndStatus :many +SELECT id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id FROM sandboxes +WHERE host_id = $1 AND status = ANY($2::text[]) +ORDER BY created_at DESC +` + +type ListSandboxesByHostAndStatusParams struct { + HostID string `json:"host_id"` + Column2 []string `json:"column_2"` +} + +func (q *Queries) ListSandboxesByHostAndStatus(ctx context.Context, arg ListSandboxesByHostAndStatusParams) ([]Sandbox, error) { + rows, err := q.db.Query(ctx, listSandboxesByHostAndStatus, arg.HostID, arg.Column2) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Sandbox + for rows.Next() { + var i Sandbox + if err := rows.Scan( + &i.ID, + &i.HostID, + &i.Template, + &i.Status, + &i.Vcpus, + &i.MemoryMb, + &i.TimeoutSec, + &i.GuestIp, + &i.HostIp, + &i.CreatedAt, + &i.StartedAt, + &i.LastActiveAt, + &i.LastUpdated, + &i.TeamID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSandboxesByTeam = `-- name: ListSandboxesByTeam :many +SELECT id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id FROM sandboxes +WHERE team_id = $1 AND status NOT IN ('stopped', 'error') +ORDER BY created_at DESC +` + +func (q *Queries) ListSandboxesByTeam(ctx context.Context, teamID string) ([]Sandbox, error) { + rows, err := q.db.Query(ctx, listSandboxesByTeam, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Sandbox + for rows.Next() { + var i Sandbox + if err := rows.Scan( + &i.ID, + &i.HostID, + &i.Template, + &i.Status, + &i.Vcpus, + &i.MemoryMb, + &i.TimeoutSec, + &i.GuestIp, + &i.HostIp, + &i.CreatedAt, + &i.StartedAt, + &i.LastActiveAt, + &i.LastUpdated, + &i.TeamID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateLastActive = `-- name: UpdateLastActive :exec +UPDATE sandboxes +SET last_active_at = $2, + last_updated = NOW() +WHERE id = $1 +` + +type UpdateLastActiveParams struct { + ID string `json:"id"` + LastActiveAt pgtype.Timestamptz `json:"last_active_at"` +} + +func (q *Queries) UpdateLastActive(ctx context.Context, arg UpdateLastActiveParams) error { + _, err := q.db.Exec(ctx, updateLastActive, arg.ID, arg.LastActiveAt) + return err +} + +const updateSandboxRunning = `-- name: UpdateSandboxRunning :one +UPDATE sandboxes +SET status = 'running', + host_ip = $2, + guest_ip = $3, + started_at = $4, + last_active_at = $4, + last_updated = NOW() +WHERE id = $1 +RETURNING id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id +` + +type UpdateSandboxRunningParams struct { + ID string `json:"id"` + HostIp string `json:"host_ip"` + GuestIp string `json:"guest_ip"` + StartedAt pgtype.Timestamptz `json:"started_at"` +} + +func (q *Queries) UpdateSandboxRunning(ctx context.Context, arg UpdateSandboxRunningParams) (Sandbox, error) { + row := q.db.QueryRow(ctx, updateSandboxRunning, + arg.ID, + arg.HostIp, + arg.GuestIp, + arg.StartedAt, + ) + var i Sandbox + err := row.Scan( + &i.ID, + &i.HostID, + &i.Template, + &i.Status, + &i.Vcpus, + &i.MemoryMb, + &i.TimeoutSec, + &i.GuestIp, + &i.HostIp, + &i.CreatedAt, + &i.StartedAt, + &i.LastActiveAt, + &i.LastUpdated, + &i.TeamID, + ) + return i, err +} + +const updateSandboxStatus = `-- name: UpdateSandboxStatus :one +UPDATE sandboxes +SET status = $2, + last_updated = NOW() +WHERE id = $1 +RETURNING id, host_id, template, status, vcpus, memory_mb, timeout_sec, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, team_id +` + +type UpdateSandboxStatusParams struct { + ID string `json:"id"` + Status string `json:"status"` +} + +func (q *Queries) UpdateSandboxStatus(ctx context.Context, arg UpdateSandboxStatusParams) (Sandbox, error) { + row := q.db.QueryRow(ctx, updateSandboxStatus, arg.ID, arg.Status) + var i Sandbox + err := row.Scan( + &i.ID, + &i.HostID, + &i.Template, + &i.Status, + &i.Vcpus, + &i.MemoryMb, + &i.TimeoutSec, + &i.GuestIp, + &i.HostIp, + &i.CreatedAt, + &i.StartedAt, + &i.LastActiveAt, + &i.LastUpdated, + &i.TeamID, + ) + return i, err +} diff --git a/internal/db/teams.sql.go b/internal/db/teams.sql.go new file mode 100644 index 0000000..c135bf1 --- /dev/null +++ b/internal/db/teams.sql.go @@ -0,0 +1,155 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: teams.sql + +package db + +import ( + "context" +) + +const getBYOCTeams = `-- name: GetBYOCTeams :many +SELECT id, name, created_at, is_byoc FROM teams WHERE is_byoc = TRUE ORDER BY created_at +` + +func (q *Queries) GetBYOCTeams(ctx context.Context) ([]Team, error) { + rows, err := q.db.Query(ctx, getBYOCTeams) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Team + for rows.Next() { + var i Team + if err := rows.Scan( + &i.ID, + &i.Name, + &i.CreatedAt, + &i.IsByoc, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getDefaultTeamForUser = `-- name: GetDefaultTeamForUser :one +SELECT t.id, t.name, t.created_at, t.is_byoc FROM teams t +JOIN users_teams ut ON ut.team_id = t.id +WHERE ut.user_id = $1 AND ut.is_default = TRUE +LIMIT 1 +` + +func (q *Queries) GetDefaultTeamForUser(ctx context.Context, userID string) (Team, error) { + row := q.db.QueryRow(ctx, getDefaultTeamForUser, userID) + var i Team + err := row.Scan( + &i.ID, + &i.Name, + &i.CreatedAt, + &i.IsByoc, + ) + return i, err +} + +const getTeam = `-- name: GetTeam :one +SELECT id, name, created_at, is_byoc FROM teams WHERE id = $1 +` + +func (q *Queries) GetTeam(ctx context.Context, id string) (Team, error) { + row := q.db.QueryRow(ctx, getTeam, id) + var i Team + err := row.Scan( + &i.ID, + &i.Name, + &i.CreatedAt, + &i.IsByoc, + ) + return i, err +} + +const getTeamMembership = `-- name: GetTeamMembership :one +SELECT user_id, team_id, is_default, role, created_at FROM users_teams WHERE user_id = $1 AND team_id = $2 +` + +type GetTeamMembershipParams struct { + UserID string `json:"user_id"` + TeamID string `json:"team_id"` +} + +func (q *Queries) GetTeamMembership(ctx context.Context, arg GetTeamMembershipParams) (UsersTeam, error) { + row := q.db.QueryRow(ctx, getTeamMembership, arg.UserID, arg.TeamID) + var i UsersTeam + err := row.Scan( + &i.UserID, + &i.TeamID, + &i.IsDefault, + &i.Role, + &i.CreatedAt, + ) + return i, err +} + +const insertTeam = `-- name: InsertTeam :one +INSERT INTO teams (id, name) +VALUES ($1, $2) +RETURNING id, name, created_at, is_byoc +` + +type InsertTeamParams struct { + ID string `json:"id"` + Name string `json:"name"` +} + +func (q *Queries) InsertTeam(ctx context.Context, arg InsertTeamParams) (Team, error) { + row := q.db.QueryRow(ctx, insertTeam, arg.ID, arg.Name) + var i Team + err := row.Scan( + &i.ID, + &i.Name, + &i.CreatedAt, + &i.IsByoc, + ) + return i, err +} + +const insertTeamMember = `-- name: InsertTeamMember :exec +INSERT INTO users_teams (user_id, team_id, is_default, role) +VALUES ($1, $2, $3, $4) +` + +type InsertTeamMemberParams struct { + UserID string `json:"user_id"` + TeamID string `json:"team_id"` + IsDefault bool `json:"is_default"` + Role string `json:"role"` +} + +func (q *Queries) InsertTeamMember(ctx context.Context, arg InsertTeamMemberParams) error { + _, err := q.db.Exec(ctx, insertTeamMember, + arg.UserID, + arg.TeamID, + arg.IsDefault, + arg.Role, + ) + return err +} + +const setTeamBYOC = `-- name: SetTeamBYOC :exec +UPDATE teams SET is_byoc = $2 WHERE id = $1 +` + +type SetTeamBYOCParams struct { + ID string `json:"id"` + IsByoc bool `json:"is_byoc"` +} + +func (q *Queries) SetTeamBYOC(ctx context.Context, arg SetTeamBYOCParams) error { + _, err := q.db.Exec(ctx, setTeamBYOC, arg.ID, arg.IsByoc) + return err +} diff --git a/internal/db/templates.sql.go b/internal/db/templates.sql.go new file mode 100644 index 0000000..cafae69 --- /dev/null +++ b/internal/db/templates.sql.go @@ -0,0 +1,248 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: templates.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const deleteTemplate = `-- name: DeleteTemplate :exec +DELETE FROM templates WHERE name = $1 +` + +func (q *Queries) DeleteTemplate(ctx context.Context, name string) error { + _, err := q.db.Exec(ctx, deleteTemplate, name) + return err +} + +const deleteTemplateByTeam = `-- name: DeleteTemplateByTeam :exec +DELETE FROM templates WHERE name = $1 AND team_id = $2 +` + +type DeleteTemplateByTeamParams struct { + Name string `json:"name"` + TeamID string `json:"team_id"` +} + +func (q *Queries) DeleteTemplateByTeam(ctx context.Context, arg DeleteTemplateByTeamParams) error { + _, err := q.db.Exec(ctx, deleteTemplateByTeam, arg.Name, arg.TeamID) + return err +} + +const getTemplate = `-- name: GetTemplate :one +SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates WHERE name = $1 +` + +func (q *Queries) GetTemplate(ctx context.Context, name string) (Template, error) { + row := q.db.QueryRow(ctx, getTemplate, name) + var i Template + err := row.Scan( + &i.Name, + &i.Type, + &i.Vcpus, + &i.MemoryMb, + &i.SizeBytes, + &i.CreatedAt, + &i.TeamID, + ) + return i, err +} + +const getTemplateByTeam = `-- name: GetTemplateByTeam :one +SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates WHERE name = $1 AND team_id = $2 +` + +type GetTemplateByTeamParams struct { + Name string `json:"name"` + TeamID string `json:"team_id"` +} + +func (q *Queries) GetTemplateByTeam(ctx context.Context, arg GetTemplateByTeamParams) (Template, error) { + row := q.db.QueryRow(ctx, getTemplateByTeam, arg.Name, arg.TeamID) + var i Template + err := row.Scan( + &i.Name, + &i.Type, + &i.Vcpus, + &i.MemoryMb, + &i.SizeBytes, + &i.CreatedAt, + &i.TeamID, + ) + return i, err +} + +const insertTemplate = `-- name: InsertTemplate :one +INSERT INTO templates (name, type, vcpus, memory_mb, size_bytes, team_id) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING name, type, vcpus, memory_mb, size_bytes, created_at, team_id +` + +type InsertTemplateParams struct { + Name string `json:"name"` + Type string `json:"type"` + Vcpus pgtype.Int4 `json:"vcpus"` + MemoryMb pgtype.Int4 `json:"memory_mb"` + SizeBytes int64 `json:"size_bytes"` + TeamID string `json:"team_id"` +} + +func (q *Queries) InsertTemplate(ctx context.Context, arg InsertTemplateParams) (Template, error) { + row := q.db.QueryRow(ctx, insertTemplate, + arg.Name, + arg.Type, + arg.Vcpus, + arg.MemoryMb, + arg.SizeBytes, + arg.TeamID, + ) + var i Template + err := row.Scan( + &i.Name, + &i.Type, + &i.Vcpus, + &i.MemoryMb, + &i.SizeBytes, + &i.CreatedAt, + &i.TeamID, + ) + return i, err +} + +const listTemplates = `-- name: ListTemplates :many +SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates ORDER BY created_at DESC +` + +func (q *Queries) ListTemplates(ctx context.Context) ([]Template, error) { + rows, err := q.db.Query(ctx, listTemplates) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Template + for rows.Next() { + var i Template + if err := rows.Scan( + &i.Name, + &i.Type, + &i.Vcpus, + &i.MemoryMb, + &i.SizeBytes, + &i.CreatedAt, + &i.TeamID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTemplatesByTeam = `-- name: ListTemplatesByTeam :many +SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates WHERE team_id = $1 ORDER BY created_at DESC +` + +func (q *Queries) ListTemplatesByTeam(ctx context.Context, teamID string) ([]Template, error) { + rows, err := q.db.Query(ctx, listTemplatesByTeam, teamID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Template + for rows.Next() { + var i Template + if err := rows.Scan( + &i.Name, + &i.Type, + &i.Vcpus, + &i.MemoryMb, + &i.SizeBytes, + &i.CreatedAt, + &i.TeamID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTemplatesByTeamAndType = `-- name: ListTemplatesByTeamAndType :many +SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates WHERE team_id = $1 AND type = $2 ORDER BY created_at DESC +` + +type ListTemplatesByTeamAndTypeParams struct { + TeamID string `json:"team_id"` + Type string `json:"type"` +} + +func (q *Queries) ListTemplatesByTeamAndType(ctx context.Context, arg ListTemplatesByTeamAndTypeParams) ([]Template, error) { + rows, err := q.db.Query(ctx, listTemplatesByTeamAndType, arg.TeamID, arg.Type) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Template + for rows.Next() { + var i Template + if err := rows.Scan( + &i.Name, + &i.Type, + &i.Vcpus, + &i.MemoryMb, + &i.SizeBytes, + &i.CreatedAt, + &i.TeamID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTemplatesByType = `-- name: ListTemplatesByType :many +SELECT name, type, vcpus, memory_mb, size_bytes, created_at, team_id FROM templates WHERE type = $1 ORDER BY created_at DESC +` + +func (q *Queries) ListTemplatesByType(ctx context.Context, type_ string) ([]Template, error) { + rows, err := q.db.Query(ctx, listTemplatesByType, type_) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Template + for rows.Next() { + var i Template + if err := rows.Scan( + &i.Name, + &i.Type, + &i.Vcpus, + &i.MemoryMb, + &i.SizeBytes, + &i.CreatedAt, + &i.TeamID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/users.sql.go b/internal/db/users.sql.go new file mode 100644 index 0000000..dd975e7 --- /dev/null +++ b/internal/db/users.sql.go @@ -0,0 +1,221 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: users.sql + +package db + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const deleteAdminPermission = `-- name: DeleteAdminPermission :exec +DELETE FROM admin_permissions WHERE user_id = $1 AND permission = $2 +` + +type DeleteAdminPermissionParams struct { + UserID string `json:"user_id"` + Permission string `json:"permission"` +} + +func (q *Queries) DeleteAdminPermission(ctx context.Context, arg DeleteAdminPermissionParams) error { + _, err := q.db.Exec(ctx, deleteAdminPermission, arg.UserID, arg.Permission) + return err +} + +const getAdminPermissions = `-- name: GetAdminPermissions :many +SELECT id, user_id, permission, created_at FROM admin_permissions WHERE user_id = $1 ORDER BY permission +` + +func (q *Queries) GetAdminPermissions(ctx context.Context, userID string) ([]AdminPermission, error) { + rows, err := q.db.Query(ctx, getAdminPermissions, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AdminPermission + for rows.Next() { + var i AdminPermission + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Permission, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getAdminUsers = `-- name: GetAdminUsers :many +SELECT id, email, password_hash, created_at, updated_at, is_admin FROM users WHERE is_admin = TRUE ORDER BY created_at +` + +func (q *Queries) GetAdminUsers(ctx context.Context) ([]User, error) { + rows, err := q.db.Query(ctx, getAdminUsers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.CreatedAt, + &i.UpdatedAt, + &i.IsAdmin, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT id, email, password_hash, created_at, updated_at, is_admin FROM users WHERE email = $1 +` + +func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { + row := q.db.QueryRow(ctx, getUserByEmail, email) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.CreatedAt, + &i.UpdatedAt, + &i.IsAdmin, + ) + return i, err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, email, password_hash, created_at, updated_at, is_admin FROM users WHERE id = $1 +` + +func (q *Queries) GetUserByID(ctx context.Context, id string) (User, error) { + row := q.db.QueryRow(ctx, getUserByID, id) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.CreatedAt, + &i.UpdatedAt, + &i.IsAdmin, + ) + return i, err +} + +const hasAdminPermission = `-- name: HasAdminPermission :one +SELECT EXISTS( + SELECT 1 FROM admin_permissions WHERE user_id = $1 AND permission = $2 +) AS has_permission +` + +type HasAdminPermissionParams struct { + UserID string `json:"user_id"` + Permission string `json:"permission"` +} + +func (q *Queries) HasAdminPermission(ctx context.Context, arg HasAdminPermissionParams) (bool, error) { + row := q.db.QueryRow(ctx, hasAdminPermission, arg.UserID, arg.Permission) + var has_permission bool + err := row.Scan(&has_permission) + return has_permission, err +} + +const insertAdminPermission = `-- name: InsertAdminPermission :exec +INSERT INTO admin_permissions (id, user_id, permission) +VALUES ($1, $2, $3) +` + +type InsertAdminPermissionParams struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Permission string `json:"permission"` +} + +func (q *Queries) InsertAdminPermission(ctx context.Context, arg InsertAdminPermissionParams) error { + _, err := q.db.Exec(ctx, insertAdminPermission, arg.ID, arg.UserID, arg.Permission) + return err +} + +const insertUser = `-- name: InsertUser :one +INSERT INTO users (id, email, password_hash) +VALUES ($1, $2, $3) +RETURNING id, email, password_hash, created_at, updated_at, is_admin +` + +type InsertUserParams struct { + ID string `json:"id"` + Email string `json:"email"` + PasswordHash pgtype.Text `json:"password_hash"` +} + +func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, error) { + row := q.db.QueryRow(ctx, insertUser, arg.ID, arg.Email, arg.PasswordHash) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.CreatedAt, + &i.UpdatedAt, + &i.IsAdmin, + ) + return i, err +} + +const insertUserOAuth = `-- name: InsertUserOAuth :one +INSERT INTO users (id, email) +VALUES ($1, $2) +RETURNING id, email, password_hash, created_at, updated_at, is_admin +` + +type InsertUserOAuthParams struct { + ID string `json:"id"` + Email string `json:"email"` +} + +func (q *Queries) InsertUserOAuth(ctx context.Context, arg InsertUserOAuthParams) (User, error) { + row := q.db.QueryRow(ctx, insertUserOAuth, arg.ID, arg.Email) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.CreatedAt, + &i.UpdatedAt, + &i.IsAdmin, + ) + return i, err +} + +const setUserAdmin = `-- name: SetUserAdmin :exec +UPDATE users SET is_admin = $2, updated_at = NOW() WHERE id = $1 +` + +type SetUserAdminParams struct { + ID string `json:"id"` + IsAdmin bool `json:"is_admin"` +} + +func (q *Queries) SetUserAdmin(ctx context.Context, arg SetUserAdminParams) error { + _, err := q.db.Exec(ctx, setUserAdmin, arg.ID, arg.IsAdmin) + return err +} diff --git a/internal/devicemapper/devicemapper.go b/internal/devicemapper/devicemapper.go new file mode 100644 index 0000000..ea14fcd --- /dev/null +++ b/internal/devicemapper/devicemapper.go @@ -0,0 +1,360 @@ +// Package devicemapper provides device-mapper snapshot operations for +// copy-on-write rootfs management. Each sandbox gets a dm-snapshot backed +// by a shared read-only loop device (the base template image) and a +// per-sandbox sparse CoW file that stores only modified blocks. +package devicemapper + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "time" +) + +const ( + // ChunkSize is the dm-snapshot chunk size in 512-byte sectors. + // 8 sectors = 4KB, matching the standard page/block size. + ChunkSize = 8 +) + +// loopEntry tracks a loop device and its reference count. +type loopEntry struct { + device string // e.g., /dev/loop0 + refcount int +} + +// LoopRegistry manages loop devices for base template images. +// Each unique image path gets one read-only loop device, shared +// across all sandboxes using that template. Reference counting +// ensures the loop device is released when no sandboxes use it. +type LoopRegistry struct { + mu sync.Mutex + entries map[string]*loopEntry // imagePath → loopEntry +} + +// NewLoopRegistry creates a new loop device registry. +func NewLoopRegistry() *LoopRegistry { + return &LoopRegistry{ + entries: make(map[string]*loopEntry), + } +} + +// Acquire returns a read-only loop device for the given image path. +// If one already exists, its refcount is incremented. Otherwise a new +// loop device is created via losetup. +func (r *LoopRegistry) Acquire(imagePath string) (string, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if e, ok := r.entries[imagePath]; ok { + e.refcount++ + slog.Debug("loop device reused", "image", imagePath, "device", e.device, "refcount", e.refcount) + return e.device, nil + } + + dev, err := losetupCreate(imagePath) + if err != nil { + return "", fmt.Errorf("losetup %s: %w", imagePath, err) + } + + r.entries[imagePath] = &loopEntry{device: dev, refcount: 1} + slog.Info("loop device created", "image", imagePath, "device", dev) + return dev, nil +} + +// Release decrements the refcount for the given image path. +// When the refcount reaches zero, the loop device is detached. +func (r *LoopRegistry) Release(imagePath string) { + r.mu.Lock() + defer r.mu.Unlock() + + e, ok := r.entries[imagePath] + if !ok { + return + } + + e.refcount-- + if e.refcount <= 0 { + if err := losetupDetach(e.device); err != nil { + slog.Warn("losetup detach failed", "device", e.device, "error", err) + } + delete(r.entries, imagePath) + slog.Info("loop device released", "image", imagePath, "device", e.device) + } +} + +// ReleaseAll detaches all loop devices. Used during shutdown. +func (r *LoopRegistry) ReleaseAll() { + r.mu.Lock() + defer r.mu.Unlock() + + for path, e := range r.entries { + if err := losetupDetach(e.device); err != nil { + slog.Warn("losetup detach failed", "device", e.device, "error", err) + } + delete(r.entries, path) + } +} + +// SnapshotDevice holds the state for a single dm-snapshot device. +type SnapshotDevice struct { + Name string // dm device name, e.g., "wrenn-sb-a1b2c3d4" + DevicePath string // /dev/mapper/ + CowPath string // path to the sparse CoW file + CowLoopDev string // loop device for the CoW file +} + +// CreateSnapshot sets up a new dm-snapshot device. +// +// It creates a sparse CoW file, attaches it as a loop device, and creates +// a device-mapper snapshot target combining the read-only origin with the +// writable CoW layer. +// +// The origin loop device must already exist (from LoopRegistry.Acquire). +func CreateSnapshot(name, originLoopDev, cowPath string, originSizeBytes int64) (*SnapshotDevice, error) { + // Create sparse CoW file sized to match the origin. + if err := createSparseFile(cowPath, originSizeBytes); err != nil { + return nil, fmt.Errorf("create cow file: %w", err) + } + + cowLoopDev, err := losetupCreateRW(cowPath) + if err != nil { + os.Remove(cowPath) + return nil, fmt.Errorf("losetup cow: %w", err) + } + + sectors := originSizeBytes / 512 + if err := dmsetupCreate(name, originLoopDev, cowLoopDev, sectors); err != nil { + if detachErr := losetupDetach(cowLoopDev); detachErr != nil { + slog.Warn("cow losetup detach failed during cleanup", "device", cowLoopDev, "error", detachErr) + } + os.Remove(cowPath) + return nil, fmt.Errorf("dmsetup create: %w", err) + } + + devPath := "/dev/mapper/" + name + + slog.Info("dm-snapshot created", + "name", name, + "device", devPath, + "origin", originLoopDev, + "cow", cowPath, + ) + + return &SnapshotDevice{ + Name: name, + DevicePath: devPath, + CowPath: cowPath, + CowLoopDev: cowLoopDev, + }, nil +} + +// RestoreSnapshot re-attaches a dm-snapshot from an existing persistent CoW file. +// The CoW file must have been created with the persistent (P) flag and still +// contain valid dm-snapshot metadata. +func RestoreSnapshot(ctx context.Context, name, originLoopDev, cowPath string, originSizeBytes int64) (*SnapshotDevice, error) { + // Defensively remove a stale device with the same name. This can happen + // if a previous pause failed to clean up the dm device (e.g. "device busy"). + if dmDeviceExists(name) { + slog.Warn("removing stale dm device before restore", "name", name) + if err := dmsetupRemove(ctx, name); err != nil { + return nil, fmt.Errorf("remove stale device %s: %w", name, err) + } + } + + cowLoopDev, err := losetupCreateRW(cowPath) + if err != nil { + return nil, fmt.Errorf("losetup cow: %w", err) + } + + sectors := originSizeBytes / 512 + if err := dmsetupCreate(name, originLoopDev, cowLoopDev, sectors); err != nil { + if detachErr := losetupDetach(cowLoopDev); detachErr != nil { + slog.Warn("cow losetup detach failed during cleanup", "device", cowLoopDev, "error", detachErr) + } + return nil, fmt.Errorf("dmsetup create: %w", err) + } + + devPath := "/dev/mapper/" + name + + slog.Info("dm-snapshot restored", + "name", name, + "device", devPath, + "origin", originLoopDev, + "cow", cowPath, + ) + + return &SnapshotDevice{ + Name: name, + DevicePath: devPath, + CowPath: cowPath, + CowLoopDev: cowLoopDev, + }, nil +} + +// 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. +func RemoveSnapshot(ctx context.Context, dev *SnapshotDevice) error { + if err := dmsetupRemove(ctx, dev.Name); err != nil { + return fmt.Errorf("dmsetup remove %s: %w", dev.Name, err) + } + + if err := losetupDetach(dev.CowLoopDev); err != nil { + slog.Warn("cow losetup detach failed", "device", dev.CowLoopDev, "error", err) + } + + slog.Info("dm-snapshot removed", "name", dev.Name) + return 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. +func FlattenSnapshot(dmDevPath, outputPath string) error { + cmd := exec.Command("dd", + "if="+dmDevPath, + "of="+outputPath, + "bs=4M", + "status=none", + ) + if out, err := cmd.CombinedOutput(); err != nil { + os.Remove(outputPath) + return fmt.Errorf("dd flatten: %s: %w", string(out), err) + } + return nil +} + +// OriginSizeBytes returns the size in bytes of a loop device's backing file. +func OriginSizeBytes(loopDev string) (int64, error) { + // blockdev --getsize64 returns size in bytes. + out, err := exec.Command("blockdev", "--getsize64", loopDev).CombinedOutput() + if err != nil { + return 0, fmt.Errorf("blockdev --getsize64 %s: %s: %w", loopDev, strings.TrimSpace(string(out)), err) + } + s := strings.TrimSpace(string(out)) + return strconv.ParseInt(s, 10, 64) +} + +// CleanupStaleDevices removes any device-mapper devices matching the +// "wrenn-" prefix that may have been left behind by a previous agent +// instance that crashed or was killed. Should be called at agent startup. +func CleanupStaleDevices() { + out, err := exec.Command("dmsetup", "ls", "--target", "snapshot").CombinedOutput() + if err != nil { + slog.Debug("dmsetup ls failed (may be normal if no devices exist)", "error", err) + return + } + + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" || line == "No devices found" { + continue + } + // dmsetup ls output format: "name\t(major:minor)" + name, _, _ := strings.Cut(line, "\t") + if !strings.HasPrefix(name, "wrenn-") { + continue + } + + slog.Warn("removing stale dm-snapshot device", "name", name) + if err := dmsetupRemove(context.Background(), name); err != nil { + slog.Warn("failed to remove stale device", "name", name, "error", err) + } + } +} + +// --- low-level helpers --- + +// losetupCreate attaches a file as a read-only loop device. +func losetupCreate(imagePath string) (string, error) { + out, err := exec.Command("losetup", "--read-only", "--find", "--show", imagePath).Output() + if err != nil { + return "", fmt.Errorf("losetup --read-only: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + +// losetupCreateRW attaches a file as a read-write loop device. +func losetupCreateRW(path string) (string, error) { + out, err := exec.Command("losetup", "--find", "--show", path).Output() + if err != nil { + return "", fmt.Errorf("losetup: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + +// losetupDetach detaches a loop device. +func losetupDetach(dev string) error { + return exec.Command("losetup", "-d", dev).Run() +} + +// dmsetupCreate creates a dm-snapshot device with persistent metadata. +func dmsetupCreate(name, originDev, cowDev string, sectors int64) error { + // Table format: snapshot P + // P = persistent — CoW metadata survives dmsetup remove. + table := fmt.Sprintf("0 %d snapshot %s %s P %d", sectors, originDev, cowDev, ChunkSize) + cmd := exec.Command("dmsetup", "create", name, "--table", table) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("%s: %w", strings.TrimSpace(string(out)), err) + } + return nil +} + +// dmDeviceExists checks whether a device-mapper device with the given name exists. +func dmDeviceExists(name string) bool { + return exec.Command("dmsetup", "info", name).Run() == nil +} + +// dmsetupRemove removes a device-mapper device, retrying on transient +// "device busy" errors that occur when the kernel hasn't fully released +// the device after a Firecracker process exits. +func dmsetupRemove(ctx context.Context, name string) error { + var lastErr error + for attempt := range 5 { + if attempt > 0 { + select { + case <-ctx.Done(): + return fmt.Errorf("context cancelled while retrying dmsetup remove %s: %w", name, lastErr) + case <-time.After(200 * time.Millisecond): + } + } + cmd := exec.CommandContext(ctx, "dmsetup", "remove", name) + out, err := cmd.CombinedOutput() + if err == nil { + return nil + } + // If the context was cancelled, the process was killed and its + // output is unreliable. Return the context error directly so + // callers can distinguish cancellation from a real dm failure. + if ctx.Err() != nil { + return fmt.Errorf("dmsetup remove %s: %w", name, ctx.Err()) + } + outStr := strings.TrimSpace(string(out)) + lastErr = fmt.Errorf("%s: %w", outStr, err) + // Only retry on transient "busy" errors. Other failures + // (device not found, permission denied) are permanent. + if !strings.Contains(outStr, "Device or resource busy") { + return lastErr + } + slog.Debug("dmsetup remove retry", "name", name, "attempt", attempt+1, "error", lastErr) + } + return lastErr +} + +// createSparseFile creates a sparse file of the given size. +func createSparseFile(path string, sizeBytes int64) error { + f, err := os.Create(path) + if err != nil { + return err + } + if err := f.Truncate(sizeBytes); err != nil { + f.Close() + os.Remove(path) + return err + } + return f.Close() +} diff --git a/internal/envdclient/client.go b/internal/envdclient/client.go index e69de29..04a1dc2 100644 --- a/internal/envdclient/client.go +++ b/internal/envdclient/client.go @@ -0,0 +1,315 @@ +package envdclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "mime/multipart" + "net/http" + "net/url" + "time" + + "connectrpc.com/connect" + + envdpb "git.omukk.dev/wrenn/sandbox/proto/envd/gen" + "git.omukk.dev/wrenn/sandbox/proto/envd/gen/genconnect" +) + +// Client wraps the Connect RPC client for envd's Process and Filesystem services. +type Client struct { + hostIP string + base string + healthURL string + httpClient *http.Client + + process genconnect.ProcessClient + filesystem genconnect.FilesystemClient +} + +// New creates a new envd client that connects to the given host IP. +func New(hostIP string) *Client { + base := baseURL(hostIP) + httpClient := newHTTPClient() + + return &Client{ + hostIP: hostIP, + base: base, + healthURL: base + "/health", + httpClient: httpClient, + process: genconnect.NewProcessClient(httpClient, base), + filesystem: genconnect.NewFilesystemClient(httpClient, base), + } +} + +// BaseURL returns the HTTP base URL for reaching envd. +func (c *Client) BaseURL() string { + return c.base +} + +// Init calls POST /init on envd to sync the guest clock with the host. +// This is important after snapshot resume where the guest clock is frozen. +func (c *Client) Init(ctx context.Context) error { + now := time.Now().UTC() + body, err := json.Marshal(map[string]any{"timestamp": now}) + if err != nil { + return fmt.Errorf("marshal init body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/init", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create init request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("init request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("init: status %d: %s", resp.StatusCode, string(respBody)) + } + + return nil +} + +// ExecResult holds the output of a command execution. +type ExecResult struct { + Stdout []byte + Stderr []byte + ExitCode int32 +} + +// Exec runs a command inside the sandbox and collects all stdout/stderr output. +// It blocks until the command completes. +func (c *Client) Exec(ctx context.Context, cmd string, args ...string) (*ExecResult, error) { + stdin := false + req := connect.NewRequest(&envdpb.StartRequest{ + Process: &envdpb.ProcessConfig{ + Cmd: cmd, + Args: args, + }, + Stdin: &stdin, + }) + + stream, err := c.process.Start(ctx, req) + if err != nil { + return nil, fmt.Errorf("start process: %w", err) + } + defer stream.Close() + + result := &ExecResult{} + + for stream.Receive() { + msg := stream.Msg() + if msg.Event == nil { + continue + } + + event := msg.Event.GetEvent() + switch e := event.(type) { + case *envdpb.ProcessEvent_Start: + slog.Debug("process started", "pid", e.Start.GetPid()) + + case *envdpb.ProcessEvent_Data: + output := e.Data.GetOutput() + switch o := output.(type) { + case *envdpb.ProcessEvent_DataEvent_Stdout: + result.Stdout = append(result.Stdout, o.Stdout...) + case *envdpb.ProcessEvent_DataEvent_Stderr: + result.Stderr = append(result.Stderr, o.Stderr...) + } + + case *envdpb.ProcessEvent_End: + result.ExitCode = e.End.GetExitCode() + if e.End.Error != nil { + slog.Debug("process ended with error", + "exit_code", e.End.GetExitCode(), + "error", e.End.GetError(), + ) + } + + case *envdpb.ProcessEvent_Keepalive: + // Ignore keepalives. + } + } + + if err := stream.Err(); err != nil && err != io.EOF { + return result, fmt.Errorf("stream error: %w", err) + } + + return result, nil +} + +// ExecStreamEvent represents a single event from a streaming exec. +type ExecStreamEvent struct { + Type string // "start", "stdout", "stderr", "end" + PID uint32 + Data []byte + ExitCode int32 + Error string +} + +// ExecStream runs a command inside the sandbox and returns a channel of output events. +// The channel is closed when the process ends or the context is cancelled. +func (c *Client) ExecStream(ctx context.Context, cmd string, args ...string) (<-chan ExecStreamEvent, error) { + stdin := false + req := connect.NewRequest(&envdpb.StartRequest{ + Process: &envdpb.ProcessConfig{ + Cmd: cmd, + Args: args, + }, + Stdin: &stdin, + }) + + stream, err := c.process.Start(ctx, req) + if err != nil { + return nil, fmt.Errorf("start process: %w", err) + } + + ch := make(chan ExecStreamEvent, 16) + go func() { + defer close(ch) + defer stream.Close() + + for stream.Receive() { + msg := stream.Msg() + if msg.Event == nil { + continue + } + + var ev ExecStreamEvent + event := msg.Event.GetEvent() + switch e := event.(type) { + case *envdpb.ProcessEvent_Start: + ev = ExecStreamEvent{Type: "start", PID: e.Start.GetPid()} + + case *envdpb.ProcessEvent_Data: + output := e.Data.GetOutput() + switch o := output.(type) { + case *envdpb.ProcessEvent_DataEvent_Stdout: + ev = ExecStreamEvent{Type: "stdout", Data: o.Stdout} + case *envdpb.ProcessEvent_DataEvent_Stderr: + ev = ExecStreamEvent{Type: "stderr", Data: o.Stderr} + } + + case *envdpb.ProcessEvent_End: + ev = ExecStreamEvent{Type: "end", ExitCode: e.End.GetExitCode()} + if e.End.Error != nil { + ev.Error = e.End.GetError() + } + + case *envdpb.ProcessEvent_Keepalive: + continue + } + + select { + case ch <- ev: + case <-ctx.Done(): + return + } + } + + if err := stream.Err(); err != nil && err != io.EOF { + slog.Debug("exec stream error", "error", err) + } + }() + + return ch, nil +} + +// 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"). +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) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("write file: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("write file %s: status %d: %s", path, resp.StatusCode, string(respBody)) + } + + slog.Debug("envd write file", "path", path, "status", resp.StatusCode, "response", string(respBody)) + return nil +} + +// ReadFile reads a file from inside the sandbox via envd's REST endpoint. +// envd expects GET /files?path=...&username=root. +func (c *Client) ReadFile(ctx context.Context, path string) ([]byte, error) { + u := fmt.Sprintf("%s/files?%s", c.base, url.Values{ + "path": {path}, + "username": {"root"}, + }.Encode()) + + slog.Debug("envd read file", "url", u, "path", path) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("read file: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("read file %s: status %d: %s", path, resp.StatusCode, string(respBody)) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read file body: %w", err) + } + + return data, nil +} + +// ListDir lists directory contents inside the sandbox. +func (c *Client) ListDir(ctx context.Context, path string, depth uint32) (*envdpb.ListDirResponse, error) { + req := connect.NewRequest(&envdpb.ListDirRequest{ + Path: path, + Depth: depth, + }) + + resp, err := c.filesystem.ListDir(ctx, req) + if err != nil { + return nil, fmt.Errorf("list dir: %w", err) + } + + return resp.Msg, nil +} diff --git a/internal/envdclient/dialer.go b/internal/envdclient/dialer.go index e69de29..ea6492d 100644 --- a/internal/envdclient/dialer.go +++ b/internal/envdclient/dialer.go @@ -0,0 +1,21 @@ +package envdclient + +import ( + "fmt" + "net/http" +) + +// envdPort is the default port envd listens on inside the guest. +const envdPort = 49983 + +// baseURL returns the HTTP base URL for reaching envd at the given host IP. +func baseURL(hostIP string) string { + return fmt.Sprintf("http://%s:%d", hostIP, envdPort) +} + +// newHTTPClient returns an http.Client suitable for talking to envd. +// No special transport is needed — envd is reachable via the host IP +// through the veth/TAP network path. +func newHTTPClient() *http.Client { + return &http.Client{} +} diff --git a/internal/envdclient/health.go b/internal/envdclient/health.go index e69de29..dfb7df8 100644 --- a/internal/envdclient/health.go +++ b/internal/envdclient/health.go @@ -0,0 +1,52 @@ +package envdclient + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "time" +) + +// WaitUntilReady polls envd's health endpoint until it responds successfully +// or the context is cancelled. It retries every retryInterval. +func (c *Client) WaitUntilReady(ctx context.Context) error { + const retryInterval = 100 * time.Millisecond + + slog.Info("waiting for envd to be ready", "url", c.healthURL) + + ticker := time.NewTicker(retryInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return fmt.Errorf("envd not ready: %w", ctx.Err()) + case <-ticker.C: + if err := c.healthCheck(ctx); err == nil { + slog.Info("envd is ready", "host", c.hostIP) + return nil + } + } + } +} + +// healthCheck sends a single GET /health request to envd. +func (c *Client) healthCheck(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.healthURL, nil) + if err != nil { + return err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("health check returned %d", resp.StatusCode) + } + + return nil +} diff --git a/internal/filesystem/clone.go b/internal/filesystem/clone.go deleted file mode 100644 index e69de29..0000000 diff --git a/internal/filesystem/images.go b/internal/filesystem/images.go deleted file mode 100644 index e69de29..0000000 diff --git a/internal/hostagent/registration.go b/internal/hostagent/registration.go new file mode 100644 index 0000000..fc74d55 --- /dev/null +++ b/internal/hostagent/registration.go @@ -0,0 +1,205 @@ +package hostagent + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "runtime" + "strings" + "time" + + "golang.org/x/sys/unix" +) + +// RegistrationConfig holds the configuration for host registration. +type RegistrationConfig struct { + CPURL string // Control plane base URL (e.g., http://localhost:8000) + RegistrationToken string // One-time registration token from the control plane + TokenFile string // Path to persist the host JWT after registration + Address string // Externally-reachable address (ip:port) for this host +} + +type registerRequest struct { + Token string `json:"token"` + Arch string `json:"arch"` + CPUCores int32 `json:"cpu_cores"` + MemoryMB int32 `json:"memory_mb"` + DiskGB int32 `json:"disk_gb"` + Address string `json:"address"` +} + +type registerResponse struct { + Host json.RawMessage `json:"host"` + Token string `json:"token"` +} + +type errorResponse struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +// Register calls the control plane to register this host agent and persists +// the returned JWT to disk. Returns the host JWT token string. +func Register(ctx context.Context, cfg RegistrationConfig) (string, error) { + // Check if we already have a saved token. + if data, err := os.ReadFile(cfg.TokenFile); err == nil { + token := strings.TrimSpace(string(data)) + if token != "" { + slog.Info("loaded existing host token", "file", cfg.TokenFile) + return token, nil + } + } + + if cfg.RegistrationToken == "" { + return "", fmt.Errorf("no saved host token and no registration token provided") + } + + arch := runtime.GOARCH + cpuCores := int32(runtime.NumCPU()) + memoryMB := getMemoryMB() + diskGB := getDiskGB() + + reqBody := registerRequest{ + Token: cfg.RegistrationToken, + Arch: arch, + CPUCores: cpuCores, + MemoryMB: memoryMB, + DiskGB: diskGB, + Address: cfg.Address, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshal registration request: %w", err) + } + + url := strings.TrimRight(cfg.CPURL, "/") + "/v1/hosts/register" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("create registration request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("registration request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read registration response: %w", err) + } + + if resp.StatusCode != http.StatusCreated { + var errResp errorResponse + if err := json.Unmarshal(respBody, &errResp); err == nil { + return "", fmt.Errorf("registration failed (%d): %s", resp.StatusCode, errResp.Error.Message) + } + return "", fmt.Errorf("registration failed (%d): %s", resp.StatusCode, string(respBody)) + } + + var regResp registerResponse + if err := json.Unmarshal(respBody, ®Resp); err != nil { + return "", fmt.Errorf("parse registration response: %w", err) + } + + if regResp.Token == "" { + return "", fmt.Errorf("registration response missing token") + } + + // Persist the token to disk for subsequent startups. + if err := os.WriteFile(cfg.TokenFile, []byte(regResp.Token), 0600); err != nil { + return "", fmt.Errorf("save host token: %w", err) + } + slog.Info("host registered and token saved", "file", cfg.TokenFile) + + return regResp.Token, nil +} + +// StartHeartbeat launches a background goroutine that sends periodic heartbeats +// to the control plane. It runs until the context is cancelled. +func StartHeartbeat(ctx context.Context, cpURL, hostID, hostToken string, interval time.Duration) { + url := strings.TrimRight(cpURL, "/") + "/v1/hosts/" + hostID + "/heartbeat" + client := &http.Client{Timeout: 10 * time.Second} + + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil) + if err != nil { + slog.Warn("heartbeat: failed to create request", "error", err) + continue + } + req.Header.Set("X-Host-Token", hostToken) + + resp, err := client.Do(req) + if err != nil { + slog.Warn("heartbeat: request failed", "error", err) + continue + } + resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + slog.Warn("heartbeat: unexpected status", "status", resp.StatusCode) + } + } + } + }() +} + +// HostIDFromToken extracts the host_id claim from a host JWT without +// verifying the signature (the agent doesn't have the signing secret). +func HostIDFromToken(token string) (string, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "", fmt.Errorf("invalid JWT format") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", fmt.Errorf("decode JWT payload: %w", err) + } + var claims struct { + HostID string `json:"host_id"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return "", fmt.Errorf("parse JWT claims: %w", err) + } + if claims.HostID == "" { + return "", fmt.Errorf("host_id claim missing from token") + } + return claims.HostID, nil +} + +// getMemoryMB returns total system memory in MB. +func getMemoryMB() int32 { + var info unix.Sysinfo_t + if err := unix.Sysinfo(&info); err != nil { + return 0 + } + return int32(info.Totalram * uint64(info.Unit) / (1024 * 1024)) +} + +// getDiskGB returns total disk space of the root filesystem in GB. +func getDiskGB() int32 { + var stat unix.Statfs_t + if err := unix.Statfs("/", &stat); err != nil { + return 0 + } + return int32(stat.Blocks * uint64(stat.Bsize) / (1024 * 1024 * 1024)) +} diff --git a/internal/hostagent/server.go b/internal/hostagent/server.go new file mode 100644 index 0000000..d545e59 --- /dev/null +++ b/internal/hostagent/server.go @@ -0,0 +1,414 @@ +package hostagent + +import ( + "context" + "fmt" + "io" + "log/slog" + "mime/multipart" + "net/http" + "net/url" + "strings" + "time" + + "connectrpc.com/connect" + + pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" + "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" + + "git.omukk.dev/wrenn/sandbox/internal/sandbox" +) + +// Server implements the HostAgentService Connect RPC handler. +type Server struct { + hostagentv1connect.UnimplementedHostAgentServiceHandler + mgr *sandbox.Manager +} + +// NewServer creates a new host agent RPC server. +func NewServer(mgr *sandbox.Manager) *Server { + return &Server{mgr: mgr} +} + +func (s *Server) CreateSandbox( + ctx context.Context, + req *connect.Request[pb.CreateSandboxRequest], +) (*connect.Response[pb.CreateSandboxResponse], error) { + msg := req.Msg + + sb, err := s.mgr.Create(ctx, msg.SandboxId, msg.Template, int(msg.Vcpus), int(msg.MemoryMb), int(msg.TimeoutSec)) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("create sandbox: %w", err)) + } + + return connect.NewResponse(&pb.CreateSandboxResponse{ + SandboxId: sb.ID, + Status: string(sb.Status), + HostIp: sb.HostIP.String(), + }), nil +} + +func (s *Server) DestroySandbox( + ctx context.Context, + req *connect.Request[pb.DestroySandboxRequest], +) (*connect.Response[pb.DestroySandboxResponse], error) { + if err := s.mgr.Destroy(ctx, req.Msg.SandboxId); err != nil { + return nil, connect.NewError(connect.CodeNotFound, err) + } + return connect.NewResponse(&pb.DestroySandboxResponse{}), nil +} + +func (s *Server) PauseSandbox( + ctx context.Context, + req *connect.Request[pb.PauseSandboxRequest], +) (*connect.Response[pb.PauseSandboxResponse], error) { + if err := s.mgr.Pause(ctx, req.Msg.SandboxId); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&pb.PauseSandboxResponse{}), nil +} + +func (s *Server) ResumeSandbox( + ctx context.Context, + req *connect.Request[pb.ResumeSandboxRequest], +) (*connect.Response[pb.ResumeSandboxResponse], error) { + sb, err := s.mgr.Resume(ctx, req.Msg.SandboxId, int(req.Msg.TimeoutSec)) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return connect.NewResponse(&pb.ResumeSandboxResponse{ + SandboxId: sb.ID, + Status: string(sb.Status), + HostIp: sb.HostIP.String(), + }), nil +} + +func (s *Server) CreateSnapshot( + ctx context.Context, + req *connect.Request[pb.CreateSnapshotRequest], +) (*connect.Response[pb.CreateSnapshotResponse], error) { + sizeBytes, err := s.mgr.CreateSnapshot(ctx, req.Msg.SandboxId, req.Msg.Name) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("create snapshot: %w", err)) + } + return connect.NewResponse(&pb.CreateSnapshotResponse{ + Name: req.Msg.Name, + SizeBytes: sizeBytes, + }), nil +} + +func (s *Server) DeleteSnapshot( + ctx context.Context, + req *connect.Request[pb.DeleteSnapshotRequest], +) (*connect.Response[pb.DeleteSnapshotResponse], error) { + if err := s.mgr.DeleteSnapshot(req.Msg.Name); err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("delete snapshot: %w", err)) + } + return connect.NewResponse(&pb.DeleteSnapshotResponse{}), nil +} + +func (s *Server) PingSandbox( + ctx context.Context, + req *connect.Request[pb.PingSandboxRequest], +) (*connect.Response[pb.PingSandboxResponse], error) { + if err := s.mgr.Ping(req.Msg.SandboxId); err != nil { + if strings.Contains(err.Error(), "not found") { + return nil, connect.NewError(connect.CodeNotFound, err) + } + return nil, connect.NewError(connect.CodeFailedPrecondition, err) + } + return connect.NewResponse(&pb.PingSandboxResponse{}), nil +} + +func (s *Server) Exec( + ctx context.Context, + req *connect.Request[pb.ExecRequest], +) (*connect.Response[pb.ExecResponse], error) { + msg := req.Msg + + timeout := 30 * time.Second + if msg.TimeoutSec > 0 { + timeout = time.Duration(msg.TimeoutSec) * time.Second + } + + execCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + result, err := s.mgr.Exec(execCtx, msg.SandboxId, msg.Cmd, msg.Args...) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("exec: %w", err)) + } + + return connect.NewResponse(&pb.ExecResponse{ + Stdout: result.Stdout, + Stderr: result.Stderr, + ExitCode: result.ExitCode, + }), nil +} + +func (s *Server) WriteFile( + ctx context.Context, + req *connect.Request[pb.WriteFileRequest], +) (*connect.Response[pb.WriteFileResponse], error) { + msg := req.Msg + + client, err := s.mgr.GetClient(msg.SandboxId) + if err != nil { + return nil, connect.NewError(connect.CodeNotFound, err) + } + + if err := client.WriteFile(ctx, msg.Path, msg.Content); err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("write file: %w", err)) + } + + return connect.NewResponse(&pb.WriteFileResponse{}), nil +} + +func (s *Server) ReadFile( + ctx context.Context, + req *connect.Request[pb.ReadFileRequest], +) (*connect.Response[pb.ReadFileResponse], error) { + msg := req.Msg + + client, err := s.mgr.GetClient(msg.SandboxId) + if err != nil { + return nil, connect.NewError(connect.CodeNotFound, err) + } + + content, err := client.ReadFile(ctx, msg.Path) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("read file: %w", err)) + } + + return connect.NewResponse(&pb.ReadFileResponse{Content: content}), nil +} + +func (s *Server) ExecStream( + ctx context.Context, + req *connect.Request[pb.ExecStreamRequest], + stream *connect.ServerStream[pb.ExecStreamResponse], +) error { + msg := req.Msg + + // Only apply a timeout if explicitly requested; streaming execs may be long-running. + execCtx := ctx + if msg.TimeoutSec > 0 { + var cancel context.CancelFunc + execCtx, cancel = context.WithTimeout(ctx, time.Duration(msg.TimeoutSec)*time.Second) + defer cancel() + } + + events, err := s.mgr.ExecStream(execCtx, msg.SandboxId, msg.Cmd, msg.Args...) + if err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("exec stream: %w", err)) + } + + for ev := range events { + var resp pb.ExecStreamResponse + switch ev.Type { + case "start": + resp.Event = &pb.ExecStreamResponse_Start{ + Start: &pb.ExecStreamStart{Pid: ev.PID}, + } + case "stdout": + resp.Event = &pb.ExecStreamResponse_Data{ + Data: &pb.ExecStreamData{ + Output: &pb.ExecStreamData_Stdout{Stdout: ev.Data}, + }, + } + case "stderr": + resp.Event = &pb.ExecStreamResponse_Data{ + Data: &pb.ExecStreamData{ + Output: &pb.ExecStreamData_Stderr{Stderr: ev.Data}, + }, + } + case "end": + resp.Event = &pb.ExecStreamResponse_End{ + End: &pb.ExecStreamEnd{ + ExitCode: ev.ExitCode, + Error: ev.Error, + }, + } + } + if err := stream.Send(&resp); err != nil { + return err + } + } + + return nil +} + +func (s *Server) WriteFileStream( + ctx context.Context, + stream *connect.ClientStream[pb.WriteFileStreamRequest], +) (*connect.Response[pb.WriteFileStreamResponse], error) { + // First message must contain metadata. + if !stream.Receive() { + if err := stream.Err(); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("empty stream")) + } + + first := stream.Msg() + meta := first.GetMeta() + if meta == nil { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("first message must contain metadata")) + } + + client, err := s.mgr.GetClient(meta.SandboxId) + if err != nil { + return nil, connect.NewError(connect.CodeNotFound, err) + } + + // Use io.Pipe to stream chunks into a multipart body for envd's REST endpoint. + pr, pw := io.Pipe() + mpWriter := multipart.NewWriter(pw) + + // Write multipart data 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 { + errCh <- fmt.Errorf("write chunk: %w", err) + return + } + } + if err := stream.Err(); err != nil { + errCh <- err + return + } + mpWriter.Close() + errCh <- nil + }() + + // Send the streaming multipart 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) + 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()) + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + pw.CloseWithError(err) + <-errCh + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("write file stream: %w", err)) + } + defer resp.Body.Close() + + // Wait for the writer goroutine. + if writerErr := <-errCh; writerErr != nil { + return nil, connect.NewError(connect.CodeInternal, writerErr) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(resp.Body) + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("envd write: status %d: %s", resp.StatusCode, string(body))) + } + + slog.Debug("streaming file write complete", "sandbox_id", meta.SandboxId, "path", meta.Path) + return connect.NewResponse(&pb.WriteFileStreamResponse{}), nil +} + +func (s *Server) ReadFileStream( + ctx context.Context, + req *connect.Request[pb.ReadFileStreamRequest], + stream *connect.ServerStream[pb.ReadFileStreamResponse], +) error { + msg := req.Msg + + client, err := s.mgr.GetClient(msg.SandboxId) + if err != nil { + return connect.NewError(connect.CodeNotFound, err) + } + + base := client.BaseURL() + u := fmt.Sprintf("%s/files?%s", base, url.Values{ + "path": {msg.Path}, + "username": {"root"}, + }.Encode()) + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("create request: %w", err)) + } + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("read file stream: %w", err)) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return connect.NewError(connect.CodeInternal, fmt.Errorf("envd read: status %d: %s", resp.StatusCode, string(body))) + } + + // Stream file content in 64KB chunks. + buf := make([]byte, 64*1024) + for { + n, err := resp.Body.Read(buf) + if n > 0 { + chunk := make([]byte, n) + copy(chunk, buf[:n]) + if sendErr := stream.Send(&pb.ReadFileStreamResponse{Chunk: chunk}); sendErr != nil { + return sendErr + } + } + if err == io.EOF { + break + } + if err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("read body: %w", err)) + } + } + + return nil +} + +func (s *Server) ListSandboxes( + ctx context.Context, + req *connect.Request[pb.ListSandboxesRequest], +) (*connect.Response[pb.ListSandboxesResponse], error) { + sandboxes := s.mgr.List() + + infos := make([]*pb.SandboxInfo, len(sandboxes)) + for i, sb := range sandboxes { + infos[i] = &pb.SandboxInfo{ + SandboxId: sb.ID, + Status: string(sb.Status), + Template: sb.Template, + Vcpus: int32(sb.VCPUs), + MemoryMb: int32(sb.MemoryMB), + HostIp: sb.HostIP.String(), + CreatedAtUnix: sb.CreatedAt.Unix(), + LastActiveAtUnix: sb.LastActiveAt.Unix(), + TimeoutSec: int32(sb.TimeoutSec), + } + } + + return connect.NewResponse(&pb.ListSandboxesResponse{ + Sandboxes: infos, + AutoPausedSandboxIds: s.mgr.DrainAutoPausedIDs(), + }), nil +} diff --git a/internal/id/id.go b/internal/id/id.go index e69de29..62cb682 100644 --- a/internal/id/id.go +++ b/internal/id/id.go @@ -0,0 +1,59 @@ +package id + +import ( + "crypto/rand" + "encoding/hex" + "fmt" +) + +func hex8() string { + b := make([]byte, 4) + if _, err := rand.Read(b); err != nil { + panic(fmt.Sprintf("crypto/rand failed: %v", err)) + } + return hex.EncodeToString(b) +} + +// NewSandboxID generates a new sandbox ID in the format "sb-" + 8 hex chars. +func NewSandboxID() string { + return "sb-" + hex8() +} + +// NewSnapshotName generates a snapshot name in the format "template-" + 8 hex chars. +func NewSnapshotName() string { + return "template-" + hex8() +} + +// NewUserID generates a new user ID in the format "usr-" + 8 hex chars. +func NewUserID() string { + return "usr-" + hex8() +} + +// NewTeamID generates a new team ID in the format "team-" + 8 hex chars. +func NewTeamID() string { + return "team-" + hex8() +} + +// NewAPIKeyID generates a new API key ID in the format "key-" + 8 hex chars. +func NewAPIKeyID() string { + return "key-" + hex8() +} + +// NewHostID generates a new host ID in the format "host-" + 8 hex chars. +func NewHostID() string { + return "host-" + hex8() +} + +// NewHostTokenID generates a new host token audit ID in the format "htok-" + 8 hex chars. +func NewHostTokenID() string { + return "htok-" + hex8() +} + +// NewRegistrationToken generates a 64-char hex token (32 bytes of entropy). +func NewRegistrationToken() string { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + panic(fmt.Sprintf("crypto/rand failed: %v", err)) + } + return hex.EncodeToString(b) +} diff --git a/internal/lifecycle/manager.go b/internal/lifecycle/manager.go index e69de29..dd39965 100644 --- a/internal/lifecycle/manager.go +++ b/internal/lifecycle/manager.go @@ -0,0 +1 @@ +package lifecycle diff --git a/internal/metrics/collector.go b/internal/metrics/collector.go index e69de29..1abe097 100644 --- a/internal/metrics/collector.go +++ b/internal/metrics/collector.go @@ -0,0 +1 @@ +package metrics diff --git a/internal/metrics/exporter.go b/internal/metrics/exporter.go index e69de29..1abe097 100644 --- a/internal/metrics/exporter.go +++ b/internal/metrics/exporter.go @@ -0,0 +1 @@ +package metrics diff --git a/internal/models/host.go b/internal/models/host.go index e69de29..2640e7f 100644 --- a/internal/models/host.go +++ b/internal/models/host.go @@ -0,0 +1 @@ +package models diff --git a/internal/models/sandbox.go b/internal/models/sandbox.go index e69de29..b99bd6b 100644 --- a/internal/models/sandbox.go +++ b/internal/models/sandbox.go @@ -0,0 +1,32 @@ +package models + +import ( + "net" + "time" +) + +// SandboxStatus represents the current state of a sandbox. +type SandboxStatus string + +const ( + StatusPending SandboxStatus = "pending" + StatusRunning SandboxStatus = "running" + StatusPaused SandboxStatus = "paused" + StatusStopped SandboxStatus = "stopped" + StatusError SandboxStatus = "error" +) + +// Sandbox holds all state for a running sandbox on this host. +type Sandbox struct { + ID string + Status SandboxStatus + Template string + VCPUs int + MemoryMB int + TimeoutSec int + SlotIndex int + HostIP net.IP + RootfsPath string + CreatedAt time.Time + LastActiveAt time.Time +} diff --git a/internal/network/allocator.go b/internal/network/allocator.go index e69de29..b7265e6 100644 --- a/internal/network/allocator.go +++ b/internal/network/allocator.go @@ -0,0 +1,41 @@ +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 <= 65534; 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) +} diff --git a/internal/network/manager.go b/internal/network/manager.go index e69de29..1ae2e9d 100644 --- a/internal/network/manager.go +++ b/internal/network/manager.go @@ -0,0 +1 @@ +package network diff --git a/internal/network/nat.go b/internal/network/nat.go index e69de29..1ae2e9d 100644 --- a/internal/network/nat.go +++ b/internal/network/nat.go @@ -0,0 +1 @@ +package network diff --git a/internal/network/setup.go b/internal/network/setup.go new file mode 100644 index 0000000..70a8a54 --- /dev/null +++ b/internal/network/setup.go @@ -0,0 +1,468 @@ +package network + +import ( + "errors" + "fmt" + "log/slog" + "net" + "os/exec" + "runtime" + + "github.com/vishvananda/netlink" + "github.com/vishvananda/netns" +) + +const ( + // Fixed addresses inside each network namespace (safe because each + // sandbox gets its own netns). + tapName = "tap0" + tapIP = "169.254.0.22" + tapMask = 30 + tapMAC = "02:FC:00:00:00:05" + guestIP = "169.254.0.21" + guestNetMask = "255.255.255.252" + + // Base IPs for host-reachable and veth addressing. + hostBase = "10.11.0.0" + vrtBase = "10.12.0.0" + + // Each slot gets a /31 from the vrt range (2 IPs per slot). + vrtAddressesPerSlot = 2 +) + +// Slot holds the network addressing for a single sandbox. +type Slot struct { + Index int + + // Derived addresses + HostIP net.IP // 10.11.0.{idx} — reachable from host + VethIP net.IP // 10.12.0.{idx*2} — host side of veth pair + VpeerIP net.IP // 10.12.0.{idx*2+1} — namespace side of veth + + // Fixed per-namespace + TapIP string // 169.254.0.22 + TapMask int // 30 + TapMAC string // 02:FC:00:00:00:05 + GuestIP string // 169.254.0.21 + GuestNetMask string // 255.255.255.252 + TapName string // tap0 + + // Names + NamespaceID string // ns-{idx} + VethName string // veth-{idx} +} + +// NewSlot computes the addressing for the given slot index (1-based). +func NewSlot(index int) *Slot { + hostBaseIP := net.ParseIP(hostBase).To4() + vrtBaseIP := net.ParseIP(vrtBase).To4() + + hostIP := make(net.IP, 4) + copy(hostIP, hostBaseIP) + hostIP[2] += byte(index / 256) + hostIP[3] += byte(index % 256) + + vethOffset := index * vrtAddressesPerSlot + vethIP := make(net.IP, 4) + copy(vethIP, vrtBaseIP) + vethIP[2] += byte(vethOffset / 256) + vethIP[3] += byte(vethOffset % 256) + + vpeerIP := make(net.IP, 4) + copy(vpeerIP, vrtBaseIP) + vpeerIP[2] += byte((vethOffset + 1) / 256) + vpeerIP[3] += byte((vethOffset + 1) % 256) + + return &Slot{ + Index: index, + HostIP: hostIP, + VethIP: vethIP, + VpeerIP: vpeerIP, + TapIP: tapIP, + TapMask: tapMask, + TapMAC: tapMAC, + GuestIP: guestIP, + GuestNetMask: guestNetMask, + TapName: tapName, + NamespaceID: fmt.Sprintf("ns-%d", index), + VethName: fmt.Sprintf("veth-%d", index), + } +} + +// CreateNetwork sets up the full network topology for a sandbox: +// - Named network namespace +// - Veth pair bridging host and namespace +// - TAP device inside namespace for Firecracker +// - Routes and NAT rules for connectivity +// +// On error, all partially created resources are rolled back. +func CreateNetwork(slot *Slot) error { + // Lock this goroutine to the OS thread — required for netns manipulation. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + // Save host namespace. + hostNS, err := netns.Get() + if err != nil { + return fmt.Errorf("get host namespace: %w", err) + } + defer hostNS.Close() + defer func() { _ = netns.Set(hostNS) }() + + // rollbacks accumulates cleanup functions; on error they run in reverse. + var rollbacks []func() + rollback := func() { + for i := len(rollbacks) - 1; i >= 0; i-- { + rollbacks[i]() + } + } + + // Create named network namespace. + ns, err := netns.NewNamed(slot.NamespaceID) + if err != nil { + return fmt.Errorf("create namespace %s: %w", slot.NamespaceID, err) + } + defer ns.Close() + // Deleting the namespace also cleans up TAP, loopback, namespace-internal + // routes, and namespace-internal iptables rules. + rollbacks = append(rollbacks, func() { + _ = netns.DeleteNamed(slot.NamespaceID) + }) + // We are now inside the new namespace. + + slog.Info("created network namespace", "ns", slot.NamespaceID) + + // Create veth pair. Both ends start in the new namespace. + vethAttrs := netlink.NewLinkAttrs() + vethAttrs.Name = slot.VethName + veth := &netlink.Veth{ + LinkAttrs: vethAttrs, + PeerName: "eth0", + } + if err := netlink.LinkAdd(veth); err != nil { + rollback() + return fmt.Errorf("create veth pair: %w", err) + } + + // Configure vpeer (eth0) inside namespace. + vpeer, err := netlink.LinkByName("eth0") + if err != nil { + rollback() + return fmt.Errorf("find eth0: %w", err) + } + vpeerAddr := &netlink.Addr{ + IPNet: &net.IPNet{ + IP: slot.VpeerIP, + Mask: net.CIDRMask(31, 32), + }, + } + if err := netlink.AddrAdd(vpeer, vpeerAddr); err != nil { + rollback() + return fmt.Errorf("set vpeer addr: %w", err) + } + if err := netlink.LinkSetUp(vpeer); err != nil { + rollback() + return fmt.Errorf("bring up vpeer: %w", err) + } + + // Move veth to host namespace. + vethLink, err := netlink.LinkByName(slot.VethName) + if err != nil { + rollback() + return fmt.Errorf("find veth: %w", err) + } + if err := netlink.LinkSetNsFd(vethLink, int(hostNS)); err != nil { + rollback() + return fmt.Errorf("move veth to host ns: %w", err) + } + // Once the veth is in the host namespace, we need to clean it up from there. + rollbacks = append(rollbacks, func() { + if l, err := netlink.LinkByName(slot.VethName); err == nil { + _ = netlink.LinkDel(l) + } + }) + + // Create TAP device inside namespace. + tapAttrs := netlink.NewLinkAttrs() + tapAttrs.Name = tapName + tap := &netlink.Tuntap{ + LinkAttrs: tapAttrs, + Mode: netlink.TUNTAP_MODE_TAP, + } + if err := netlink.LinkAdd(tap); err != nil { + rollback() + return fmt.Errorf("create tap device: %w", err) + } + tapLink, err := netlink.LinkByName(tapName) + if err != nil { + rollback() + return fmt.Errorf("find tap: %w", err) + } + tapAddr := &netlink.Addr{ + IPNet: &net.IPNet{ + IP: net.ParseIP(tapIP), + Mask: net.CIDRMask(tapMask, 32), + }, + } + if err := netlink.AddrAdd(tapLink, tapAddr); err != nil { + rollback() + return fmt.Errorf("set tap addr: %w", err) + } + if err := netlink.LinkSetUp(tapLink); err != nil { + rollback() + return fmt.Errorf("bring up tap: %w", err) + } + + // Bring up loopback. + lo, err := netlink.LinkByName("lo") + if err != nil { + rollback() + return fmt.Errorf("find loopback: %w", err) + } + if err := netlink.LinkSetUp(lo); err != nil { + rollback() + return fmt.Errorf("bring up loopback: %w", err) + } + + // Default route inside namespace — traffic exits via veth on host. + if err := netlink.RouteAdd(&netlink.Route{ + Scope: netlink.SCOPE_UNIVERSE, + Gw: slot.VethIP, + }); err != nil { + rollback() + return fmt.Errorf("add default route in namespace: %w", err) + } + + // Enable IP forwarding inside namespace (eth0 -> tap0). + if err := nsExec(slot.NamespaceID, + "sysctl", "-w", "net.ipv4.ip_forward=1", + ); err != nil { + rollback() + return fmt.Errorf("enable ip_forward in namespace: %w", err) + } + + // NAT rules inside namespace: + // Outbound: guest (169.254.0.21) -> internet. SNAT to vpeer IP so replies return. + if err := iptables(slot.NamespaceID, + "-t", "nat", "-A", "POSTROUTING", + "-o", "eth0", "-s", guestIP, + "-j", "SNAT", "--to", slot.VpeerIP.String(), + ); err != nil { + rollback() + return fmt.Errorf("add SNAT rule: %w", err) + } + // Inbound: host -> guest. Packets arrive with dst=hostIP, DNAT to guest IP. + if err := iptables(slot.NamespaceID, + "-t", "nat", "-A", "PREROUTING", + "-i", "eth0", "-d", slot.HostIP.String(), + "-j", "DNAT", "--to", guestIP, + ); err != nil { + rollback() + return fmt.Errorf("add DNAT rule: %w", err) + } + + // Switch back to host namespace for host-side config. + if err := netns.Set(hostNS); err != nil { + rollback() + return fmt.Errorf("switch to host ns: %w", err) + } + + // Configure veth on host side. + hostVeth, err := netlink.LinkByName(slot.VethName) + if err != nil { + rollback() + return fmt.Errorf("find veth in host: %w", err) + } + vethAddr := &netlink.Addr{ + IPNet: &net.IPNet{ + IP: slot.VethIP, + Mask: net.CIDRMask(31, 32), + }, + } + if err := netlink.AddrAdd(hostVeth, vethAddr); err != nil { + rollback() + return fmt.Errorf("set veth addr: %w", err) + } + if err := netlink.LinkSetUp(hostVeth); err != nil { + rollback() + return fmt.Errorf("bring up veth: %w", err) + } + + // Route to sandbox's host IP via vpeer. + _, hostNet, _ := net.ParseCIDR(fmt.Sprintf("%s/32", slot.HostIP.String())) + if err := netlink.RouteAdd(&netlink.Route{ + Dst: hostNet, + Gw: slot.VpeerIP, + }); err != nil { + rollback() + return fmt.Errorf("add host route: %w", err) + } + rollbacks = append(rollbacks, func() { + _ = netlink.RouteDel(&netlink.Route{Dst: hostNet, Gw: slot.VpeerIP}) + }) + + // Find default gateway interface for FORWARD rules. + defaultIface, err := getDefaultInterface() + if err != nil { + rollback() + return fmt.Errorf("get default interface: %w", err) + } + + // FORWARD rules: allow traffic between veth and default interface. + if err := iptablesHost( + "-A", "FORWARD", + "-i", slot.VethName, "-o", defaultIface, + "-j", "ACCEPT", + ); err != nil { + rollback() + return fmt.Errorf("add forward rule (out): %w", err) + } + rollbacks = append(rollbacks, func() { + _ = iptablesHost("-D", "FORWARD", "-i", slot.VethName, "-o", defaultIface, "-j", "ACCEPT") + }) + + if err := iptablesHost( + "-A", "FORWARD", + "-i", defaultIface, "-o", slot.VethName, + "-j", "ACCEPT", + ); err != nil { + rollback() + return fmt.Errorf("add forward rule (in): %w", err) + } + rollbacks = append(rollbacks, func() { + _ = iptablesHost("-D", "FORWARD", "-i", defaultIface, "-o", slot.VethName, "-j", "ACCEPT") + }) + + // MASQUERADE for outbound traffic from sandbox. + // After SNAT inside the namespace, outbound packets arrive on the host + // with source = vpeerIP, so we match on that (not hostIP). + if err := iptablesHost( + "-t", "nat", "-A", "POSTROUTING", + "-s", fmt.Sprintf("%s/32", slot.VpeerIP.String()), + "-o", defaultIface, + "-j", "MASQUERADE", + ); err != nil { + rollback() + return fmt.Errorf("add masquerade rule: %w", err) + } + + slog.Info("network created", + "ns", slot.NamespaceID, + "host_ip", slot.HostIP.String(), + "guest_ip", guestIP, + ) + + return nil +} + +// RemoveNetwork tears down the network topology for a sandbox. +// All steps are attempted even if earlier ones fail. Returns a combined +// error describing which cleanup steps failed. +func RemoveNetwork(slot *Slot) error { + var errs []error + + defaultIface, _ := getDefaultInterface() + + // Remove host-side iptables rules. + if defaultIface != "" { + if err := iptablesHost( + "-D", "FORWARD", + "-i", slot.VethName, "-o", defaultIface, + "-j", "ACCEPT", + ); err != nil { + errs = append(errs, fmt.Errorf("remove forward rule (out): %w", err)) + } + if err := iptablesHost( + "-D", "FORWARD", + "-i", defaultIface, "-o", slot.VethName, + "-j", "ACCEPT", + ); err != nil { + errs = append(errs, fmt.Errorf("remove forward rule (in): %w", err)) + } + if err := iptablesHost( + "-t", "nat", "-D", "POSTROUTING", + "-s", fmt.Sprintf("%s/32", slot.VpeerIP.String()), + "-o", defaultIface, + "-j", "MASQUERADE", + ); err != nil { + errs = append(errs, fmt.Errorf("remove masquerade rule: %w", err)) + } + } else { + errs = append(errs, fmt.Errorf("could not determine default interface; host iptables rules not removed")) + } + + // Remove host route. + _, hostNet, _ := net.ParseCIDR(fmt.Sprintf("%s/32", slot.HostIP.String())) + if err := netlink.RouteDel(&netlink.Route{ + Dst: hostNet, + Gw: slot.VpeerIP, + }); err != nil { + errs = append(errs, fmt.Errorf("remove host route: %w", err)) + } + + // Delete veth (also destroys the peer in the namespace). + if veth, err := netlink.LinkByName(slot.VethName); err == nil { + if err := netlink.LinkDel(veth); err != nil { + errs = append(errs, fmt.Errorf("delete veth: %w", err)) + } + } + + // Delete the named namespace. + if err := netns.DeleteNamed(slot.NamespaceID); err != nil { + errs = append(errs, fmt.Errorf("delete namespace: %w", err)) + } + + slog.Info("network removed", "ns", slot.NamespaceID, "cleanup_errors", len(errs)) + + return errors.Join(errs...) +} + +// 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...) + cmd := exec.Command("ip", cmdArgs...) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s %v: %s: %w", command, args, string(out), err) + } + return nil +} + +// iptables runs an iptables command inside a network namespace. +func iptables(nsName string, args ...string) error { + cmdArgs := append([]string{"netns", "exec", nsName, "iptables"}, args...) + cmd := exec.Command("ip", cmdArgs...) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("iptables %v: %s: %w", args, string(out), err) + } + return nil +} + +// iptablesHost runs an iptables command in the host namespace. +func iptablesHost(args ...string) error { + cmd := exec.Command("iptables", args...) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("iptables %v: %s: %w", args, string(out), err) + } + return nil +} + +// getDefaultInterface returns the name of the host's default gateway interface. +func getDefaultInterface() (string, error) { + routes, err := netlink.RouteList(nil, netlink.FAMILY_V4) + if err != nil { + return "", fmt.Errorf("list routes: %w", err) + } + for _, r := range routes { + if r.Dst == nil || r.Dst.String() == "0.0.0.0/0" { + link, err := netlink.LinkByIndex(r.LinkIndex) + if err != nil { + return "", fmt.Errorf("get link by index %d: %w", r.LinkIndex, err) + } + return link.Attrs().Name, nil + } + } + return "", fmt.Errorf("no default route found") +} diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go new file mode 100644 index 0000000..ef4e092 --- /dev/null +++ b/internal/sandbox/manager.go @@ -0,0 +1,1213 @@ +package sandbox + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "sync" + "time" + + "github.com/google/uuid" + + "git.omukk.dev/wrenn/sandbox/internal/devicemapper" + "git.omukk.dev/wrenn/sandbox/internal/envdclient" + "git.omukk.dev/wrenn/sandbox/internal/id" + "git.omukk.dev/wrenn/sandbox/internal/models" + "git.omukk.dev/wrenn/sandbox/internal/network" + "git.omukk.dev/wrenn/sandbox/internal/snapshot" + "git.omukk.dev/wrenn/sandbox/internal/uffd" + "git.omukk.dev/wrenn/sandbox/internal/validate" + "git.omukk.dev/wrenn/sandbox/internal/vm" +) + +// Config holds the paths and defaults for the sandbox manager. +type Config struct { + KernelPath string + ImagesDir string // directory containing template images (e.g., /var/lib/wrenn/images/{name}/rootfs.ext4) + SandboxesDir string // directory for per-sandbox rootfs clones (e.g., /var/lib/wrenn/sandboxes) + SnapshotsDir string // directory for pause snapshots (e.g., /var/lib/wrenn/snapshots/{sandbox-id}/) + EnvdTimeout time.Duration +} + +// Manager orchestrates sandbox lifecycle: VM, network, filesystem, envd. +type Manager struct { + cfg Config + vm *vm.Manager + slots *network.SlotAllocator + loops *devicemapper.LoopRegistry + mu sync.RWMutex + boxes map[string]*sandboxState + stopCh chan struct{} + + autoPausedMu sync.Mutex + autoPausedIDs []string +} + +// sandboxState holds the runtime state for a single sandbox. +type sandboxState struct { + models.Sandbox + slot *network.Slot + client *envdclient.Client + uffdSocketPath string // non-empty for sandboxes restored from snapshot + dmDevice *devicemapper.SnapshotDevice + baseImagePath string // path to the base template rootfs (for loop registry release) + + // parent holds the snapshot header and diff file paths from which this + // sandbox was restored. Non-nil means re-pause should use "Diff" snapshot + // type instead of "Full", avoiding the UFFD fault-in storm. + parent *snapshotParent +} + +// snapshotParent stores the previous generation's snapshot state so that +// re-pause can produce an incremental diff instead of a full memory dump. +type snapshotParent struct { + header *snapshot.Header + diffPaths map[string]string // build ID → file path +} + +// maxDiffGenerations caps how many incremental diff generations we chain +// before falling back to a Full snapshot to collapse the chain. +const maxDiffGenerations = 10 + +// New creates a new sandbox manager. +func New(cfg Config) *Manager { + if cfg.EnvdTimeout == 0 { + cfg.EnvdTimeout = 30 * time.Second + } + return &Manager{ + cfg: cfg, + vm: vm.NewManager(), + slots: network.NewSlotAllocator(), + loops: devicemapper.NewLoopRegistry(), + boxes: make(map[string]*sandboxState), + stopCh: make(chan struct{}), + } +} + +// Create boots a new sandbox: clone rootfs, set up network, start VM, wait for envd. +// If sandboxID is empty, a new ID is generated. +func (m *Manager) Create(ctx context.Context, sandboxID, template string, vcpus, memoryMB, timeoutSec int) (*models.Sandbox, error) { + if sandboxID == "" { + sandboxID = id.NewSandboxID() + } + + if vcpus <= 0 { + vcpus = 1 + } + if memoryMB <= 0 { + memoryMB = 512 + } + + if template == "" { + template = "minimal" + } + if err := validate.SafeName(template); err != nil { + return nil, fmt.Errorf("invalid template name: %w", err) + } + + // Check if template refers to a snapshot (has snapfile + memfile + header + rootfs). + if snapshot.IsSnapshot(m.cfg.ImagesDir, template) { + return m.createFromSnapshot(ctx, sandboxID, template, vcpus, memoryMB, timeoutSec) + } + + // Resolve base rootfs image: /var/lib/wrenn/images/{template}/rootfs.ext4 + baseRootfs := filepath.Join(m.cfg.ImagesDir, template, "rootfs.ext4") + if _, err := os.Stat(baseRootfs); err != nil { + return nil, fmt.Errorf("base rootfs not found at %s: %w", baseRootfs, err) + } + + // Acquire shared read-only loop device for the base image. + originLoop, err := m.loops.Acquire(baseRootfs) + if err != nil { + return nil, fmt.Errorf("acquire loop device: %w", err) + } + + originSize, err := devicemapper.OriginSizeBytes(originLoop) + if err != nil { + m.loops.Release(baseRootfs) + return nil, fmt.Errorf("get origin size: %w", err) + } + + // Create dm-snapshot with per-sandbox CoW file. + dmName := "wrenn-" + sandboxID + cowPath := filepath.Join(m.cfg.SandboxesDir, fmt.Sprintf("%s.cow", sandboxID)) + dmDev, err := devicemapper.CreateSnapshot(dmName, originLoop, cowPath, originSize) + if err != nil { + m.loops.Release(baseRootfs) + return nil, fmt.Errorf("create dm-snapshot: %w", err) + } + + // Allocate network slot. + slotIdx, err := m.slots.Allocate() + if err != nil { + warnErr("dm-snapshot remove error", sandboxID, devicemapper.RemoveSnapshot(context.Background(), dmDev)) + os.Remove(cowPath) + m.loops.Release(baseRootfs) + return nil, fmt.Errorf("allocate network slot: %w", err) + } + slot := network.NewSlot(slotIdx) + + // Set up network. + if err := network.CreateNetwork(slot); err != nil { + m.slots.Release(slotIdx) + warnErr("dm-snapshot remove error", sandboxID, devicemapper.RemoveSnapshot(context.Background(), dmDev)) + os.Remove(cowPath) + m.loops.Release(baseRootfs) + return nil, fmt.Errorf("create network: %w", err) + } + + // Boot VM — Firecracker gets the dm device path. + vmCfg := vm.VMConfig{ + SandboxID: sandboxID, + KernelPath: m.cfg.KernelPath, + RootfsPath: dmDev.DevicePath, + VCPUs: vcpus, + MemoryMB: memoryMB, + NetworkNamespace: slot.NamespaceID, + TapDevice: slot.TapName, + TapMAC: slot.TapMAC, + GuestIP: slot.GuestIP, + GatewayIP: slot.TapIP, + NetMask: slot.GuestNetMask, + } + + if _, err := m.vm.Create(ctx, vmCfg); err != nil { + warnErr("network cleanup error", sandboxID, network.RemoveNetwork(slot)) + m.slots.Release(slotIdx) + warnErr("dm-snapshot remove error", sandboxID, devicemapper.RemoveSnapshot(context.Background(), dmDev)) + os.Remove(cowPath) + m.loops.Release(baseRootfs) + return nil, fmt.Errorf("create VM: %w", err) + } + + // Wait for envd to be ready. + client := envdclient.New(slot.HostIP.String()) + waitCtx, waitCancel := context.WithTimeout(ctx, m.cfg.EnvdTimeout) + defer waitCancel() + + if err := client.WaitUntilReady(waitCtx); err != nil { + warnErr("vm destroy error", sandboxID, m.vm.Destroy(context.Background(), sandboxID)) + warnErr("network cleanup error", sandboxID, network.RemoveNetwork(slot)) + m.slots.Release(slotIdx) + warnErr("dm-snapshot remove error", sandboxID, devicemapper.RemoveSnapshot(context.Background(), dmDev)) + os.Remove(cowPath) + m.loops.Release(baseRootfs) + return nil, fmt.Errorf("wait for envd: %w", err) + } + + // Sync guest clock in background. Non-fatal — sandbox is usable before this completes. + // Run in a goroutine so Init latency doesn't block the RPC response back to the control plane. + go func() { + initCtx, initCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer initCancel() + if err := client.Init(initCtx); err != nil { + slog.Warn("envd init (clock sync) failed", "sandbox", sandboxID, "error", err) + } + }() + + now := time.Now() + sb := &sandboxState{ + Sandbox: models.Sandbox{ + ID: sandboxID, + Status: models.StatusRunning, + Template: template, + VCPUs: vcpus, + MemoryMB: memoryMB, + TimeoutSec: timeoutSec, + SlotIndex: slotIdx, + HostIP: slot.HostIP, + RootfsPath: dmDev.DevicePath, + CreatedAt: now, + LastActiveAt: now, + }, + slot: slot, + client: client, + dmDevice: dmDev, + baseImagePath: baseRootfs, + } + + m.mu.Lock() + m.boxes[sandboxID] = sb + m.mu.Unlock() + + slog.Info("sandbox created", + "id", sandboxID, + "template", template, + "host_ip", slot.HostIP.String(), + "dm_device", dmDev.DevicePath, + ) + + return &sb.Sandbox, nil +} + +// Destroy stops and cleans up a sandbox. If the sandbox is running, its VM, +// network, and rootfs are torn down. Any pause snapshot files are also removed. +func (m *Manager) Destroy(ctx context.Context, sandboxID string) error { + m.mu.Lock() + sb, ok := m.boxes[sandboxID] + if ok { + delete(m.boxes, sandboxID) + } + m.mu.Unlock() + + if ok { + m.cleanup(ctx, sb) + } + + // Always clean up pause snapshot files (may exist if sandbox was paused). + warnErr("snapshot cleanup error", sandboxID, snapshot.Remove(m.cfg.SnapshotsDir, sandboxID)) + + slog.Info("sandbox destroyed", "id", sandboxID) + return nil +} + +// cleanup tears down all resources for a sandbox. +func (m *Manager) cleanup(ctx context.Context, sb *sandboxState) { + if err := m.vm.Destroy(ctx, sb.ID); err != nil { + slog.Warn("vm destroy error", "id", sb.ID, "error", err) + } + if err := network.RemoveNetwork(sb.slot); err != nil { + slog.Warn("network cleanup error", "id", sb.ID, "error", err) + } + m.slots.Release(sb.SlotIndex) + + // Tear down dm-snapshot and release the base image loop device. + 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) + } + os.Remove(sb.dmDevice.CowPath) + } + if sb.baseImagePath != "" { + m.loops.Release(sb.baseImagePath) + } + + if sb.uffdSocketPath != "" { + os.Remove(sb.uffdSocketPath) + } +} + +// Pause takes a snapshot of a running sandbox, then destroys all resources. +// The sandbox's snapshot files are stored at SnapshotsDir/{sandboxID}/. +// After this call, the sandbox is no longer running but can be resumed. +func (m *Manager) Pause(ctx context.Context, sandboxID string) error { + sb, err := m.get(sandboxID) + if err != nil { + return err + } + + if sb.Status != models.StatusRunning { + return fmt.Errorf("sandbox %s is not running (status: %s)", sandboxID, sb.Status) + } + + pauseStart := time.Now() + + // Step 1: Pause the VM (freeze vCPUs). + if err := m.vm.Pause(ctx, sandboxID); err != nil { + return fmt.Errorf("pause VM: %w", err) + } + slog.Debug("pause: VM paused", "id", sandboxID, "elapsed", time.Since(pauseStart)) + + // Determine snapshot type: Diff if resumed from snapshot (avoids UFFD + // fault-in storm), Full otherwise or if generation cap is reached. + snapshotType := "Full" + if sb.parent != nil && sb.parent.header.Metadata.Generation < maxDiffGenerations { + snapshotType = "Diff" + } + + // resumeOnError unpauses the VM so the sandbox stays usable when a + // post-freeze step fails. If the resume itself fails, the sandbox is + // left frozen — the caller should destroy it. + resumeOnError := func() { + if err := m.vm.Resume(ctx, sandboxID); err != nil { + slog.Error("failed to resume VM after pause error — sandbox is frozen", "id", sandboxID, "error", err) + } + } + + // Step 2: Take VM state snapshot (snapfile + memfile). + if err := snapshot.EnsureDir(m.cfg.SnapshotsDir, sandboxID); err != nil { + resumeOnError() + return fmt.Errorf("create snapshot dir: %w", err) + } + + snapDir := snapshot.DirPath(m.cfg.SnapshotsDir, sandboxID) + rawMemPath := filepath.Join(snapDir, "memfile.raw") + snapPath := snapshot.SnapPath(m.cfg.SnapshotsDir, sandboxID) + + snapshotStart := time.Now() + if err := m.vm.Snapshot(ctx, sandboxID, snapPath, rawMemPath, snapshotType); err != nil { + warnErr("snapshot dir cleanup error", sandboxID, snapshot.Remove(m.cfg.SnapshotsDir, sandboxID)) + resumeOnError() + return fmt.Errorf("create VM snapshot: %w", err) + } + slog.Debug("pause: FC snapshot created", "id", sandboxID, "type", snapshotType, "elapsed", time.Since(snapshotStart)) + + // Step 3: Process the raw memfile into a compact diff + header. + buildID := uuid.New() + headerPath := snapshot.MemHeaderPath(m.cfg.SnapshotsDir, sandboxID) + + processStart := time.Now() + if sb.parent != nil && snapshotType == "Diff" { + // Diff: process against parent header, producing only changed blocks. + diffPath := snapshot.MemDiffPathForBuild(m.cfg.SnapshotsDir, sandboxID, buildID) + if _, err := snapshot.ProcessMemfileWithParent(rawMemPath, diffPath, headerPath, sb.parent.header, buildID); err != nil { + warnErr("snapshot dir cleanup error", sandboxID, snapshot.Remove(m.cfg.SnapshotsDir, sandboxID)) + resumeOnError() + return fmt.Errorf("process memfile with parent: %w", err) + } + + // Copy previous generation diff files into the snapshot directory. + for prevBuildID, prevPath := range sb.parent.diffPaths { + dstPath := snapshot.MemDiffPathForBuild(m.cfg.SnapshotsDir, sandboxID, uuid.MustParse(prevBuildID)) + if prevPath != dstPath { + if err := copyFile(prevPath, dstPath); err != nil { + warnErr("snapshot dir cleanup error", sandboxID, snapshot.Remove(m.cfg.SnapshotsDir, sandboxID)) + resumeOnError() + return fmt.Errorf("copy parent diff file: %w", err) + } + } + } + } else { + // Full: first generation or generation cap reached — single diff file. + diffPath := snapshot.MemDiffPath(m.cfg.SnapshotsDir, sandboxID) + if _, err := snapshot.ProcessMemfile(rawMemPath, diffPath, headerPath, buildID); err != nil { + warnErr("snapshot dir cleanup error", sandboxID, snapshot.Remove(m.cfg.SnapshotsDir, sandboxID)) + resumeOnError() + return fmt.Errorf("process memfile: %w", err) + } + } + slog.Debug("pause: memfile processed", "id", sandboxID, "type", snapshotType, "elapsed", time.Since(processStart)) + + // Remove the raw memfile — we only keep the compact diff(s). + os.Remove(rawMemPath) + + // Step 4: Destroy the VM first so Firecracker releases the dm device. + if err := m.vm.Destroy(ctx, sb.ID); err != nil { + slog.Warn("vm destroy error during pause", "id", sb.ID, "error", err) + } + + // Step 5: Now that FC is gone, safely remove the dm-snapshot and save the CoW. + if sb.dmDevice != nil { + if err := devicemapper.RemoveSnapshot(ctx, sb.dmDevice); err != nil { + // Hard error: if the dm device isn't removed, the CoW file is still + // in use and we can't safely move it. The VM is already destroyed so + // the sandbox is unrecoverable — clean up remaining resources. + // Note: we intentionally skip m.loops.Release here because the stale + // dm device still references the origin loop device. Detaching it now + // would corrupt the dm device. CleanupStaleDevices handles this on + // next agent startup. + warnErr("network cleanup error during pause", sandboxID, network.RemoveNetwork(sb.slot)) + m.slots.Release(sb.SlotIndex) + if sb.uffdSocketPath != "" { + os.Remove(sb.uffdSocketPath) + } + warnErr("snapshot dir cleanup error", sandboxID, snapshot.Remove(m.cfg.SnapshotsDir, sandboxID)) + m.mu.Lock() + delete(m.boxes, sandboxID) + m.mu.Unlock() + return fmt.Errorf("remove dm-snapshot: %w", err) + } + + // Move (not copy) the CoW file into the snapshot directory. + snapshotCow := snapshot.CowPath(m.cfg.SnapshotsDir, sandboxID) + if err := os.Rename(sb.dmDevice.CowPath, snapshotCow); err != nil { + warnErr("snapshot dir cleanup error", sandboxID, snapshot.Remove(m.cfg.SnapshotsDir, sandboxID)) + // VM and dm-snapshot are already gone — clean up remaining resources. + warnErr("network cleanup error during pause", sandboxID, network.RemoveNetwork(sb.slot)) + m.slots.Release(sb.SlotIndex) + if sb.baseImagePath != "" { + m.loops.Release(sb.baseImagePath) + } + if sb.uffdSocketPath != "" { + os.Remove(sb.uffdSocketPath) + } + m.mu.Lock() + delete(m.boxes, sandboxID) + m.mu.Unlock() + return fmt.Errorf("move cow file: %w", err) + } + + // Record which base template this CoW was built against. + if err := snapshot.WriteMeta(m.cfg.SnapshotsDir, sandboxID, &snapshot.RootfsMeta{ + BaseTemplate: sb.baseImagePath, + }); err != nil { + warnErr("snapshot dir cleanup error", sandboxID, snapshot.Remove(m.cfg.SnapshotsDir, sandboxID)) + // VM and dm-snapshot are already gone — clean up remaining resources. + warnErr("network cleanup error during pause", sandboxID, network.RemoveNetwork(sb.slot)) + m.slots.Release(sb.SlotIndex) + if sb.baseImagePath != "" { + m.loops.Release(sb.baseImagePath) + } + if sb.uffdSocketPath != "" { + os.Remove(sb.uffdSocketPath) + } + m.mu.Lock() + delete(m.boxes, sandboxID) + m.mu.Unlock() + return fmt.Errorf("write rootfs meta: %w", err) + } + } + + // Step 6: Clean up remaining resources (network, loop device, uffd socket). + if err := network.RemoveNetwork(sb.slot); err != nil { + slog.Warn("network cleanup error during pause", "id", sb.ID, "error", err) + } + m.slots.Release(sb.SlotIndex) + if sb.baseImagePath != "" { + m.loops.Release(sb.baseImagePath) + } + if sb.uffdSocketPath != "" { + os.Remove(sb.uffdSocketPath) + } + + m.mu.Lock() + delete(m.boxes, sandboxID) + m.mu.Unlock() + + slog.Info("sandbox paused", "id", sandboxID, "snapshot_type", snapshotType, "total_elapsed", time.Since(pauseStart)) + return nil +} + +// Resume restores a paused sandbox from its snapshot using UFFD for +// lazy memory loading. The sandbox gets a new network slot. +func (m *Manager) Resume(ctx context.Context, sandboxID string, timeoutSec int) (*models.Sandbox, error) { + snapDir := m.cfg.SnapshotsDir + if !snapshot.Exists(snapDir, sandboxID) { + return nil, fmt.Errorf("no snapshot found for sandbox %s", sandboxID) + } + + // Read the header to set up the UFFD memory source. + headerData, err := os.ReadFile(snapshot.MemHeaderPath(snapDir, sandboxID)) + if err != nil { + return nil, fmt.Errorf("read header: %w", err) + } + + header, err := snapshot.Deserialize(headerData) + if err != nil { + return nil, fmt.Errorf("deserialize header: %w", err) + } + + // Build diff file map — supports both single-generation and multi-generation. + diffPaths, err := snapshot.ListDiffFiles(snapDir, sandboxID, header) + if err != nil { + return nil, fmt.Errorf("list diff files: %w", err) + } + + source, err := uffd.NewDiffFileSource(header, diffPaths) + if err != nil { + return nil, fmt.Errorf("create memory source: %w", err) + } + + // Read rootfs metadata to find the base template image. + meta, err := snapshot.ReadMeta(snapDir, sandboxID) + if err != nil { + source.Close() + return nil, fmt.Errorf("read rootfs meta: %w", err) + } + + // Acquire the base image loop device and restore dm-snapshot from saved CoW. + baseImagePath := meta.BaseTemplate + originLoop, err := m.loops.Acquire(baseImagePath) + if err != nil { + source.Close() + return nil, fmt.Errorf("acquire loop device: %w", err) + } + + originSize, err := devicemapper.OriginSizeBytes(originLoop) + if err != nil { + source.Close() + m.loops.Release(baseImagePath) + return nil, fmt.Errorf("get origin size: %w", err) + } + + // Move CoW file from snapshot dir to sandboxes dir for the running sandbox. + savedCow := snapshot.CowPath(snapDir, sandboxID) + cowPath := filepath.Join(m.cfg.SandboxesDir, fmt.Sprintf("%s.cow", sandboxID)) + if err := os.Rename(savedCow, cowPath); err != nil { + source.Close() + m.loops.Release(baseImagePath) + return nil, fmt.Errorf("move cow file: %w", err) + } + + // rollbackCow attempts to move the CoW file back to the snapshot dir. + // Best-effort — logs a warning if it fails. + rollbackCow := func() { + if err := os.Rename(cowPath, savedCow); err != nil { + slog.Warn("failed to rollback cow file", "src", cowPath, "dst", savedCow, "error", err) + } + } + + // Restore dm-snapshot from existing persistent CoW file. + dmName := "wrenn-" + sandboxID + dmDev, err := devicemapper.RestoreSnapshot(ctx, dmName, originLoop, cowPath, originSize) + if err != nil { + source.Close() + m.loops.Release(baseImagePath) + rollbackCow() + return nil, fmt.Errorf("restore dm-snapshot: %w", err) + } + + // Allocate network slot. + slotIdx, err := m.slots.Allocate() + if err != nil { + source.Close() + warnErr("dm-snapshot remove error", sandboxID, devicemapper.RemoveSnapshot(context.Background(), dmDev)) + rollbackCow() + m.loops.Release(baseImagePath) + return nil, fmt.Errorf("allocate network slot: %w", err) + } + slot := network.NewSlot(slotIdx) + + if err := network.CreateNetwork(slot); err != nil { + source.Close() + m.slots.Release(slotIdx) + warnErr("dm-snapshot remove error", sandboxID, devicemapper.RemoveSnapshot(context.Background(), dmDev)) + rollbackCow() + m.loops.Release(baseImagePath) + return nil, fmt.Errorf("create network: %w", err) + } + + // Start UFFD server. + uffdSocketPath := filepath.Join(m.cfg.SandboxesDir, fmt.Sprintf("%s-uffd.sock", sandboxID)) + os.Remove(uffdSocketPath) // Clean stale socket. + uffdServer := uffd.NewServer(uffdSocketPath, source) + if err := uffdServer.Start(ctx); err != nil { + source.Close() + warnErr("network cleanup error", sandboxID, network.RemoveNetwork(slot)) + m.slots.Release(slotIdx) + warnErr("dm-snapshot remove error", sandboxID, devicemapper.RemoveSnapshot(context.Background(), dmDev)) + rollbackCow() + m.loops.Release(baseImagePath) + return nil, fmt.Errorf("start uffd server: %w", err) + } + + // Restore VM from snapshot. + vmCfg := vm.VMConfig{ + SandboxID: sandboxID, + KernelPath: m.cfg.KernelPath, + RootfsPath: dmDev.DevicePath, + VCPUs: 1, // Placeholder; overridden by snapshot. + MemoryMB: int(header.Metadata.Size / (1024 * 1024)), // Placeholder; overridden by snapshot. + NetworkNamespace: slot.NamespaceID, + TapDevice: slot.TapName, + TapMAC: slot.TapMAC, + GuestIP: slot.GuestIP, + GatewayIP: slot.TapIP, + NetMask: slot.GuestNetMask, + } + + snapPath := snapshot.SnapPath(snapDir, sandboxID) + if _, err := m.vm.CreateFromSnapshot(ctx, vmCfg, snapPath, uffdSocketPath); err != nil { + warnErr("uffd server stop error", sandboxID, uffdServer.Stop()) + source.Close() + warnErr("network cleanup error", sandboxID, network.RemoveNetwork(slot)) + m.slots.Release(slotIdx) + warnErr("dm-snapshot remove error", sandboxID, devicemapper.RemoveSnapshot(context.Background(), dmDev)) + rollbackCow() + m.loops.Release(baseImagePath) + return nil, fmt.Errorf("restore VM from snapshot: %w", err) + } + + // Wait for envd to be ready. + client := envdclient.New(slot.HostIP.String()) + waitCtx, waitCancel := context.WithTimeout(ctx, m.cfg.EnvdTimeout) + defer waitCancel() + + if err := client.WaitUntilReady(waitCtx); err != nil { + warnErr("uffd server stop error", sandboxID, uffdServer.Stop()) + source.Close() + warnErr("vm destroy error", sandboxID, m.vm.Destroy(context.Background(), sandboxID)) + warnErr("network cleanup error", sandboxID, network.RemoveNetwork(slot)) + m.slots.Release(slotIdx) + warnErr("dm-snapshot remove error", sandboxID, devicemapper.RemoveSnapshot(context.Background(), dmDev)) + rollbackCow() + m.loops.Release(baseImagePath) + return nil, fmt.Errorf("wait for envd: %w", err) + } + + // Sync guest clock in background. Non-fatal — sandbox is usable before this completes. + // Run in a goroutine so Init latency doesn't block the RPC response back to the control plane. + go func() { + initCtx, initCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer initCancel() + if err := client.Init(initCtx); err != nil { + slog.Warn("envd init (clock sync) failed", "sandbox", sandboxID, "error", err) + } + }() + + now := time.Now() + sb := &sandboxState{ + Sandbox: models.Sandbox{ + ID: sandboxID, + Status: models.StatusRunning, + Template: "", + VCPUs: vmCfg.VCPUs, + MemoryMB: vmCfg.MemoryMB, + TimeoutSec: timeoutSec, + SlotIndex: slotIdx, + HostIP: slot.HostIP, + RootfsPath: dmDev.DevicePath, + CreatedAt: now, + LastActiveAt: now, + }, + slot: slot, + client: client, + uffdSocketPath: uffdSocketPath, + dmDevice: dmDev, + baseImagePath: baseImagePath, + // Preserve parent snapshot info so re-pause can use Diff snapshots. + parent: &snapshotParent{ + header: header, + diffPaths: diffPaths, + }, + } + + m.mu.Lock() + m.boxes[sandboxID] = sb + m.mu.Unlock() + + // Don't delete snapshot dir — diff files are needed for re-pause. + // The CoW file was already moved out. The dir will be cleaned up + // on destroy or overwritten on re-pause. + + slog.Info("sandbox resumed from snapshot", + "id", sandboxID, + "host_ip", slot.HostIP.String(), + "dm_device", dmDev.DevicePath, + "generation", header.Metadata.Generation, + ) + + return &sb.Sandbox, nil +} + +// CreateSnapshot creates a reusable template from a sandbox. Works on both +// running and paused sandboxes. If the sandbox is running, it is paused first. +// The sandbox remains paused after this call (it can still be resumed). +// +// The rootfs is flattened (base + CoW merged) into a new standalone rootfs.ext4 +// so the template has no dependency on the original base image. Memory state +// and VM snapshot files are copied as-is. +func (m *Manager) CreateSnapshot(ctx context.Context, sandboxID, name string) (int64, error) { + if err := validate.SafeName(name); err != nil { + return 0, fmt.Errorf("invalid snapshot name: %w", err) + } + + // If the sandbox is running, pause it first. + if _, err := m.get(sandboxID); err == nil { + if err := m.Pause(ctx, sandboxID); err != nil { + return 0, fmt.Errorf("pause sandbox: %w", err) + } + } + + // At this point, pause snapshot files must exist in SnapshotsDir/{sandboxID}/. + if !snapshot.Exists(m.cfg.SnapshotsDir, sandboxID) { + return 0, fmt.Errorf("no snapshot found for sandbox %s", sandboxID) + } + + // Create template directory. + if err := snapshot.EnsureDir(m.cfg.ImagesDir, name); err != nil { + return 0, fmt.Errorf("create template dir: %w", err) + } + + // Copy VM snapshot file and memory header. + srcDir := snapshot.DirPath(m.cfg.SnapshotsDir, sandboxID) + dstDir := snapshot.DirPath(m.cfg.ImagesDir, name) + + for _, fname := range []string{snapshot.SnapFileName, snapshot.MemHeaderName} { + src := filepath.Join(srcDir, fname) + dst := filepath.Join(dstDir, fname) + if err := copyFile(src, dst); err != nil { + warnErr("template dir cleanup error", name, snapshot.Remove(m.cfg.ImagesDir, name)) + return 0, fmt.Errorf("copy %s: %w", fname, err) + } + } + + // Copy all memory diff files referenced by the header (supports multi-generation). + headerData, err := os.ReadFile(filepath.Join(srcDir, snapshot.MemHeaderName)) + if err != nil { + warnErr("template dir cleanup error", name, snapshot.Remove(m.cfg.ImagesDir, name)) + return 0, fmt.Errorf("read header for template: %w", err) + } + srcHeader, err := snapshot.Deserialize(headerData) + if err != nil { + warnErr("template dir cleanup error", name, snapshot.Remove(m.cfg.ImagesDir, name)) + return 0, fmt.Errorf("deserialize header for template: %w", err) + } + srcDiffPaths, err := snapshot.ListDiffFiles(m.cfg.SnapshotsDir, sandboxID, srcHeader) + if err != nil { + warnErr("template dir cleanup error", name, snapshot.Remove(m.cfg.ImagesDir, name)) + return 0, fmt.Errorf("list diff files for template: %w", err) + } + for _, srcPath := range srcDiffPaths { + dstPath := filepath.Join(dstDir, filepath.Base(srcPath)) + if err := copyFile(srcPath, dstPath); err != nil { + warnErr("template dir cleanup error", name, snapshot.Remove(m.cfg.ImagesDir, name)) + return 0, fmt.Errorf("copy diff file %s: %w", filepath.Base(srcPath), err) + } + } + + // Flatten rootfs: temporarily set up dm device from base + CoW, dd to new image. + meta, err := snapshot.ReadMeta(m.cfg.SnapshotsDir, sandboxID) + if err != nil { + warnErr("template dir cleanup error", name, snapshot.Remove(m.cfg.ImagesDir, name)) + return 0, fmt.Errorf("read rootfs meta: %w", err) + } + + originLoop, err := m.loops.Acquire(meta.BaseTemplate) + if err != nil { + warnErr("template dir cleanup error", name, snapshot.Remove(m.cfg.ImagesDir, name)) + return 0, fmt.Errorf("acquire loop device for flatten: %w", err) + } + + originSize, err := devicemapper.OriginSizeBytes(originLoop) + if err != nil { + m.loops.Release(meta.BaseTemplate) + warnErr("template dir cleanup error", name, snapshot.Remove(m.cfg.ImagesDir, name)) + return 0, fmt.Errorf("get origin size: %w", err) + } + + // Temporarily restore the dm-snapshot to read the merged view. + cowPath := snapshot.CowPath(m.cfg.SnapshotsDir, sandboxID) + tmpDmName := "wrenn-flatten-" + sandboxID + tmpDev, err := devicemapper.RestoreSnapshot(ctx, tmpDmName, originLoop, cowPath, originSize) + if err != nil { + m.loops.Release(meta.BaseTemplate) + warnErr("template dir cleanup error", name, snapshot.Remove(m.cfg.ImagesDir, name)) + return 0, fmt.Errorf("restore dm-snapshot for flatten: %w", err) + } + + // Flatten to new standalone rootfs. + flattenedPath := snapshot.RootfsPath(m.cfg.ImagesDir, name) + flattenErr := devicemapper.FlattenSnapshot(tmpDev.DevicePath, flattenedPath) + + // Always clean up the temporary dm device. + warnErr("dm-snapshot remove error", sandboxID, devicemapper.RemoveSnapshot(context.Background(), tmpDev)) + m.loops.Release(meta.BaseTemplate) + + if flattenErr != nil { + warnErr("template dir cleanup error", name, snapshot.Remove(m.cfg.ImagesDir, name)) + return 0, fmt.Errorf("flatten rootfs: %w", flattenErr) + } + + sizeBytes, err := snapshot.DirSize(m.cfg.ImagesDir, name) + if err != nil { + slog.Warn("failed to calculate snapshot size", "error", err) + } + + slog.Info("template snapshot created (rootfs flattened)", + "sandbox", sandboxID, + "name", name, + "size_bytes", sizeBytes, + ) + return sizeBytes, nil +} + +// DeleteSnapshot removes a snapshot template from disk. +func (m *Manager) DeleteSnapshot(name string) error { + if err := validate.SafeName(name); err != nil { + return fmt.Errorf("invalid snapshot name: %w", err) + } + return snapshot.Remove(m.cfg.ImagesDir, name) +} + +// createFromSnapshot creates a new sandbox by restoring from a snapshot template +// in ImagesDir/{snapshotName}/. Uses UFFD for lazy memory loading. +// The template's rootfs.ext4 is a flattened standalone image — we create a +// dm-snapshot on top of it just like a normal Create. +func (m *Manager) createFromSnapshot(ctx context.Context, sandboxID, snapshotName string, vcpus, _, timeoutSec int) (*models.Sandbox, error) { + imagesDir := m.cfg.ImagesDir + + // Read the header. + headerData, err := os.ReadFile(snapshot.MemHeaderPath(imagesDir, snapshotName)) + if err != nil { + return nil, fmt.Errorf("read snapshot header: %w", err) + } + + header, err := snapshot.Deserialize(headerData) + if err != nil { + return nil, fmt.Errorf("deserialize header: %w", err) + } + + // Snapshot determines memory size. + memoryMB := int(header.Metadata.Size / (1024 * 1024)) + + // Build diff file map — supports multi-generation templates. + diffPaths, err := snapshot.ListDiffFiles(imagesDir, snapshotName, header) + if err != nil { + return nil, fmt.Errorf("list diff files: %w", err) + } + + source, err := uffd.NewDiffFileSource(header, diffPaths) + if err != nil { + return nil, fmt.Errorf("create memory source: %w", err) + } + + // Set up dm-snapshot on the template's flattened rootfs. + baseRootfs := snapshot.RootfsPath(imagesDir, snapshotName) + originLoop, err := m.loops.Acquire(baseRootfs) + if err != nil { + source.Close() + return nil, fmt.Errorf("acquire loop device: %w", err) + } + + originSize, err := devicemapper.OriginSizeBytes(originLoop) + if err != nil { + source.Close() + m.loops.Release(baseRootfs) + return nil, fmt.Errorf("get origin size: %w", err) + } + + dmName := "wrenn-" + sandboxID + cowPath := filepath.Join(m.cfg.SandboxesDir, fmt.Sprintf("%s.cow", sandboxID)) + dmDev, err := devicemapper.CreateSnapshot(dmName, originLoop, cowPath, originSize) + if err != nil { + source.Close() + m.loops.Release(baseRootfs) + return nil, fmt.Errorf("create dm-snapshot: %w", err) + } + + // Allocate network. + slotIdx, err := m.slots.Allocate() + if err != nil { + source.Close() + warnErr("dm-snapshot remove error", sandboxID, devicemapper.RemoveSnapshot(context.Background(), dmDev)) + os.Remove(cowPath) + m.loops.Release(baseRootfs) + return nil, fmt.Errorf("allocate network slot: %w", err) + } + slot := network.NewSlot(slotIdx) + + if err := network.CreateNetwork(slot); err != nil { + source.Close() + m.slots.Release(slotIdx) + warnErr("dm-snapshot remove error", sandboxID, devicemapper.RemoveSnapshot(context.Background(), dmDev)) + os.Remove(cowPath) + m.loops.Release(baseRootfs) + return nil, fmt.Errorf("create network: %w", err) + } + + // Start UFFD server. + uffdSocketPath := filepath.Join(m.cfg.SandboxesDir, fmt.Sprintf("%s-uffd.sock", sandboxID)) + os.Remove(uffdSocketPath) + uffdServer := uffd.NewServer(uffdSocketPath, source) + if err := uffdServer.Start(ctx); err != nil { + source.Close() + warnErr("network cleanup error", sandboxID, network.RemoveNetwork(slot)) + m.slots.Release(slotIdx) + warnErr("dm-snapshot remove error", sandboxID, devicemapper.RemoveSnapshot(context.Background(), dmDev)) + os.Remove(cowPath) + m.loops.Release(baseRootfs) + return nil, fmt.Errorf("start uffd server: %w", err) + } + + // Restore VM. + vmCfg := vm.VMConfig{ + SandboxID: sandboxID, + KernelPath: m.cfg.KernelPath, + RootfsPath: dmDev.DevicePath, + VCPUs: vcpus, + MemoryMB: memoryMB, + NetworkNamespace: slot.NamespaceID, + TapDevice: slot.TapName, + TapMAC: slot.TapMAC, + GuestIP: slot.GuestIP, + GatewayIP: slot.TapIP, + NetMask: slot.GuestNetMask, + } + + snapPath := snapshot.SnapPath(imagesDir, snapshotName) + if _, err := m.vm.CreateFromSnapshot(ctx, vmCfg, snapPath, uffdSocketPath); err != nil { + warnErr("uffd server stop error", sandboxID, uffdServer.Stop()) + source.Close() + warnErr("network cleanup error", sandboxID, network.RemoveNetwork(slot)) + m.slots.Release(slotIdx) + warnErr("dm-snapshot remove error", sandboxID, devicemapper.RemoveSnapshot(context.Background(), dmDev)) + os.Remove(cowPath) + m.loops.Release(baseRootfs) + return nil, fmt.Errorf("restore VM from snapshot: %w", err) + } + + // Wait for envd. + client := envdclient.New(slot.HostIP.String()) + waitCtx, waitCancel := context.WithTimeout(ctx, m.cfg.EnvdTimeout) + defer waitCancel() + + if err := client.WaitUntilReady(waitCtx); err != nil { + warnErr("uffd server stop error", sandboxID, uffdServer.Stop()) + source.Close() + warnErr("vm destroy error", sandboxID, m.vm.Destroy(context.Background(), sandboxID)) + warnErr("network cleanup error", sandboxID, network.RemoveNetwork(slot)) + m.slots.Release(slotIdx) + warnErr("dm-snapshot remove error", sandboxID, devicemapper.RemoveSnapshot(context.Background(), dmDev)) + os.Remove(cowPath) + m.loops.Release(baseRootfs) + return nil, fmt.Errorf("wait for envd: %w", err) + } + + // Sync guest clock in background. Non-fatal — sandbox is usable before this completes. + // Run in a goroutine so Init latency doesn't block the RPC response back to the control plane. + go func() { + initCtx, initCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer initCancel() + if err := client.Init(initCtx); err != nil { + slog.Warn("envd init (clock sync) failed", "sandbox", sandboxID, "error", err) + } + }() + + now := time.Now() + sb := &sandboxState{ + Sandbox: models.Sandbox{ + ID: sandboxID, + Status: models.StatusRunning, + Template: snapshotName, + VCPUs: vcpus, + MemoryMB: memoryMB, + TimeoutSec: timeoutSec, + SlotIndex: slotIdx, + HostIP: slot.HostIP, + RootfsPath: dmDev.DevicePath, + CreatedAt: now, + LastActiveAt: now, + }, + slot: slot, + client: client, + uffdSocketPath: uffdSocketPath, + dmDevice: dmDev, + baseImagePath: baseRootfs, + // Template-spawned sandboxes also get diff re-pause support. + parent: &snapshotParent{ + header: header, + diffPaths: diffPaths, + }, + } + + m.mu.Lock() + m.boxes[sandboxID] = sb + m.mu.Unlock() + + slog.Info("sandbox created from snapshot", + "id", sandboxID, + "snapshot", snapshotName, + "host_ip", slot.HostIP.String(), + "dm_device", dmDev.DevicePath, + ) + + return &sb.Sandbox, nil +} + +// Exec runs a command inside a sandbox. +func (m *Manager) Exec(ctx context.Context, sandboxID string, cmd string, args ...string) (*envdclient.ExecResult, error) { + sb, err := m.get(sandboxID) + if err != nil { + return nil, err + } + + if sb.Status != models.StatusRunning { + return nil, fmt.Errorf("sandbox %s is not running (status: %s)", sandboxID, sb.Status) + } + + m.mu.Lock() + sb.LastActiveAt = time.Now() + m.mu.Unlock() + + return sb.client.Exec(ctx, cmd, args...) +} + +// ExecStream runs a command inside a sandbox and returns a channel of streaming events. +func (m *Manager) ExecStream(ctx context.Context, sandboxID string, cmd string, args ...string) (<-chan envdclient.ExecStreamEvent, error) { + sb, err := m.get(sandboxID) + if err != nil { + return nil, err + } + + if sb.Status != models.StatusRunning { + return nil, fmt.Errorf("sandbox %s is not running (status: %s)", sandboxID, sb.Status) + } + + m.mu.Lock() + sb.LastActiveAt = time.Now() + m.mu.Unlock() + + return sb.client.ExecStream(ctx, cmd, args...) +} + +// List returns all sandboxes. +func (m *Manager) List() []models.Sandbox { + m.mu.RLock() + defer m.mu.RUnlock() + + result := make([]models.Sandbox, 0, len(m.boxes)) + for _, sb := range m.boxes { + result = append(result, sb.Sandbox) + } + return result +} + +// Get returns a sandbox by ID. +func (m *Manager) Get(sandboxID string) (*models.Sandbox, error) { + sb, err := m.get(sandboxID) + if err != nil { + return nil, err + } + return &sb.Sandbox, nil +} + +// GetClient returns the envd client for a sandbox. +func (m *Manager) GetClient(sandboxID string) (*envdclient.Client, error) { + sb, err := m.get(sandboxID) + if err != nil { + return nil, err + } + if sb.Status != models.StatusRunning { + return nil, fmt.Errorf("sandbox %s is not running (status: %s)", sandboxID, sb.Status) + } + return sb.client, nil +} + +// Ping resets the inactivity timer for a running sandbox. +func (m *Manager) Ping(sandboxID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + sb, ok := m.boxes[sandboxID] + if !ok { + return fmt.Errorf("sandbox not found: %s", sandboxID) + } + if sb.Status != models.StatusRunning { + return fmt.Errorf("sandbox %s is not running (status: %s)", sandboxID, sb.Status) + } + sb.LastActiveAt = time.Now() + return nil +} + +// DrainAutoPausedIDs returns and clears the list of sandbox IDs that were +// automatically paused by the TTL reaper since the last call. +func (m *Manager) DrainAutoPausedIDs() []string { + m.autoPausedMu.Lock() + defer m.autoPausedMu.Unlock() + + ids := m.autoPausedIDs + m.autoPausedIDs = nil + return ids +} + +func (m *Manager) get(sandboxID string) (*sandboxState, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + sb, ok := m.boxes[sandboxID] + if !ok { + return nil, fmt.Errorf("sandbox not found: %s", sandboxID) + } + return sb, nil +} + +// StartTTLReaper starts a background goroutine that destroys sandboxes +// that have exceeded their TTL (timeout_sec of inactivity). +func (m *Manager) StartTTLReaper(ctx context.Context) { + go func() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-m.stopCh: + return + case <-ticker.C: + m.reapExpired(ctx) + } + } + }() +} + +func (m *Manager) reapExpired(_ context.Context) { + m.mu.RLock() + var expired []string + now := time.Now() + for id, sb := range m.boxes { + if sb.TimeoutSec <= 0 { + continue + } + if sb.Status != models.StatusRunning { + continue + } + if now.Sub(sb.LastActiveAt) > time.Duration(sb.TimeoutSec)*time.Second { + expired = append(expired, id) + } + } + m.mu.RUnlock() + + for _, id := range expired { + slog.Info("TTL expired, auto-pausing sandbox", "id", id) + // Use a detached context so that an app shutdown does not cancel + // a pause mid-flight, which would leave the VM frozen without a + // valid snapshot. + pauseCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + err := m.Pause(pauseCtx, id) + cancel() + if err != nil { + slog.Warn("TTL auto-pause failed, destroying sandbox", "id", id, "error", err) + if destroyErr := m.Destroy(context.Background(), id); destroyErr != nil { + slog.Warn("TTL destroy after failed pause also failed", "id", id, "error", destroyErr) + } + continue + } + m.autoPausedMu.Lock() + m.autoPausedIDs = append(m.autoPausedIDs, id) + m.autoPausedMu.Unlock() + } +} + +// Shutdown destroys all sandboxes, releases loop devices, and stops the TTL reaper. +func (m *Manager) Shutdown(ctx context.Context) { + close(m.stopCh) + + m.mu.Lock() + ids := make([]string, 0, len(m.boxes)) + for id := range m.boxes { + ids = append(ids, id) + } + m.mu.Unlock() + + for _, sbID := range ids { + slog.Info("shutdown: destroying sandbox", "id", sbID) + if err := m.Destroy(ctx, sbID); err != nil { + slog.Warn("shutdown destroy failed", "id", sbID, "error", err) + } + } + + m.loops.ReleaseAll() +} + +// warnErr logs a warning if err is non-nil. Used for best-effort cleanup +// in error paths where the primary error has already been captured. +func warnErr(msg string, id string, err error) { + if err != nil { + slog.Warn(msg, "id", id, "error", err) + } +} + +// copyFile copies a regular file from src to dst using streaming I/O. +func copyFile(src, dst string) error { + sf, err := os.Open(src) + if err != nil { + return fmt.Errorf("open %s: %w", src, err) + } + defer sf.Close() + + df, err := os.Create(dst) + if err != nil { + return fmt.Errorf("create %s: %w", dst, err) + } + defer df.Close() + + if _, err := df.ReadFrom(sf); err != nil { + os.Remove(dst) + return fmt.Errorf("copy %s → %s: %w", src, dst, err) + } + return nil +} diff --git a/internal/scheduler/least_loaded.go b/internal/scheduler/least_loaded.go index e69de29..6990da0 100644 --- a/internal/scheduler/least_loaded.go +++ b/internal/scheduler/least_loaded.go @@ -0,0 +1 @@ +package scheduler diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index e69de29..6990da0 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -0,0 +1 @@ +package scheduler diff --git a/internal/scheduler/single_host.go b/internal/scheduler/single_host.go index e69de29..6990da0 100644 --- a/internal/scheduler/single_host.go +++ b/internal/scheduler/single_host.go @@ -0,0 +1 @@ +package scheduler diff --git a/internal/service/apikey.go b/internal/service/apikey.go new file mode 100644 index 0000000..c49ddca --- /dev/null +++ b/internal/service/apikey.go @@ -0,0 +1,63 @@ +package service + +import ( + "context" + "fmt" + + "git.omukk.dev/wrenn/sandbox/internal/auth" + "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/id" +) + +// APIKeyService provides API key operations shared between the REST API and the dashboard. +type APIKeyService struct { + DB *db.Queries +} + +// APIKeyCreateResult holds the result of creating an API key, including the +// plaintext key which is only available at creation time. +type APIKeyCreateResult struct { + Row db.TeamApiKey + Plaintext string +} + +// Create generates a new API key for the given team. +func (s *APIKeyService) Create(ctx context.Context, teamID, userID, name string) (APIKeyCreateResult, error) { + if name == "" { + name = "Unnamed API Key" + } + + plaintext, hash, err := auth.GenerateAPIKey() + if err != nil { + return APIKeyCreateResult{}, fmt.Errorf("generate key: %w", err) + } + + row, err := s.DB.InsertAPIKey(ctx, db.InsertAPIKeyParams{ + ID: id.NewAPIKeyID(), + TeamID: teamID, + Name: name, + KeyHash: hash, + KeyPrefix: auth.APIKeyPrefix(plaintext), + CreatedBy: userID, + }) + if err != nil { + return APIKeyCreateResult{}, fmt.Errorf("insert key: %w", err) + } + + return APIKeyCreateResult{Row: row, Plaintext: plaintext}, nil +} + +// List returns all API keys belonging to the given team. +func (s *APIKeyService) List(ctx context.Context, teamID string) ([]db.TeamApiKey, error) { + return s.DB.ListAPIKeysByTeam(ctx, teamID) +} + +// ListWithCreator returns all API keys for the team, joined with the creator's email. +func (s *APIKeyService) ListWithCreator(ctx context.Context, teamID string) ([]db.ListAPIKeysByTeamWithCreatorRow, error) { + return s.DB.ListAPIKeysByTeamWithCreator(ctx, teamID) +} + +// Delete removes an API key by ID, scoped to the given team. +func (s *APIKeyService) Delete(ctx context.Context, keyID, teamID string) error { + return s.DB.DeleteAPIKey(ctx, db.DeleteAPIKeyParams{ID: keyID, TeamID: teamID}) +} diff --git a/internal/service/host.go b/internal/service/host.go new file mode 100644 index 0000000..bae412e --- /dev/null +++ b/internal/service/host.go @@ -0,0 +1,358 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/redis/go-redis/v9" + + "git.omukk.dev/wrenn/sandbox/internal/auth" + "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/id" +) + +// HostService provides host management operations. +type HostService struct { + DB *db.Queries + Redis *redis.Client + JWT []byte +} + +// HostCreateParams holds the parameters for creating a host. +type HostCreateParams struct { + Type string + TeamID string // required for BYOC, empty for regular + Provider string + AvailabilityZone string + RequestingUserID string + IsRequestorAdmin bool +} + +// HostCreateResult holds the created host and the one-time registration token. +type HostCreateResult struct { + Host db.Host + RegistrationToken string +} + +// HostRegisterParams holds the parameters for host agent registration. +type HostRegisterParams struct { + Token string + Arch string + CPUCores int32 + MemoryMB int32 + DiskGB int32 + Address string +} + +// HostRegisterResult holds the registered host and its long-lived JWT. +type HostRegisterResult struct { + Host db.Host + JWT string +} + +// regTokenPayload is the JSON stored in Redis for registration tokens. +type regTokenPayload struct { + HostID string `json:"host_id"` + TokenID string `json:"token_id"` +} + +const regTokenTTL = time.Hour + +// Create creates a new host record and generates a one-time registration token. +func (s *HostService) Create(ctx context.Context, p HostCreateParams) (HostCreateResult, error) { + if p.Type != "regular" && p.Type != "byoc" { + return HostCreateResult{}, fmt.Errorf("invalid host type: must be 'regular' or 'byoc'") + } + + if p.Type == "regular" { + if !p.IsRequestorAdmin { + return HostCreateResult{}, fmt.Errorf("forbidden: only admins can create regular hosts") + } + } else { + // BYOC: admin or team owner. + if p.TeamID == "" { + return HostCreateResult{}, fmt.Errorf("invalid request: team_id is required for BYOC hosts") + } + if !p.IsRequestorAdmin { + membership, err := s.DB.GetTeamMembership(ctx, db.GetTeamMembershipParams{ + UserID: p.RequestingUserID, + TeamID: p.TeamID, + }) + if errors.Is(err, pgx.ErrNoRows) { + return HostCreateResult{}, fmt.Errorf("forbidden: not a member of the specified team") + } + if err != nil { + return HostCreateResult{}, fmt.Errorf("check team membership: %w", err) + } + if membership.Role != "owner" { + return HostCreateResult{}, fmt.Errorf("forbidden: only team owners can create BYOC hosts") + } + } + } + + // Validate team exists for BYOC hosts. + if p.TeamID != "" { + if _, err := s.DB.GetTeam(ctx, p.TeamID); err != nil { + return HostCreateResult{}, fmt.Errorf("invalid request: team not found") + } + } + + hostID := id.NewHostID() + + var teamID pgtype.Text + if p.TeamID != "" { + teamID = pgtype.Text{String: p.TeamID, Valid: true} + } + var provider pgtype.Text + if p.Provider != "" { + provider = pgtype.Text{String: p.Provider, Valid: true} + } + var az pgtype.Text + if p.AvailabilityZone != "" { + az = pgtype.Text{String: p.AvailabilityZone, Valid: true} + } + + host, err := s.DB.InsertHost(ctx, db.InsertHostParams{ + ID: hostID, + Type: p.Type, + TeamID: teamID, + Provider: provider, + AvailabilityZone: az, + CreatedBy: p.RequestingUserID, + }) + if err != nil { + return HostCreateResult{}, fmt.Errorf("insert host: %w", err) + } + + // Generate registration token and store in Redis + Postgres audit trail. + token := id.NewRegistrationToken() + tokenID := id.NewHostTokenID() + + payload, _ := json.Marshal(regTokenPayload{ + HostID: hostID, + TokenID: tokenID, + }) + if err := s.Redis.Set(ctx, "host:reg:"+token, payload, regTokenTTL).Err(); err != nil { + return HostCreateResult{}, fmt.Errorf("store registration token: %w", err) + } + + now := time.Now() + if _, err := s.DB.InsertHostToken(ctx, db.InsertHostTokenParams{ + ID: tokenID, + HostID: hostID, + CreatedBy: p.RequestingUserID, + ExpiresAt: pgtype.Timestamptz{Time: now.Add(regTokenTTL), Valid: true}, + }); err != nil { + slog.Warn("failed to insert host token audit record", "host_id", hostID, "error", err) + } + + return HostCreateResult{Host: host, RegistrationToken: token}, nil +} + +// RegenerateToken issues a new registration token for a host still in "pending" +// status. This allows retry when a previous registration attempt failed after +// the original token was consumed. +func (s *HostService) RegenerateToken(ctx context.Context, hostID, userID, teamID string, isAdmin bool) (HostCreateResult, error) { + host, err := s.DB.GetHost(ctx, hostID) + if err != nil { + return HostCreateResult{}, fmt.Errorf("host not found: %w", err) + } + if host.Status != "pending" { + return HostCreateResult{}, fmt.Errorf("invalid state: can only regenerate token for pending hosts (status: %s)", host.Status) + } + + // Same permission model as Create/Delete. + if !isAdmin { + if host.Type != "byoc" { + return HostCreateResult{}, fmt.Errorf("forbidden: only admins can manage regular hosts") + } + if !host.TeamID.Valid || host.TeamID.String != teamID { + return HostCreateResult{}, fmt.Errorf("forbidden: host does not belong to your team") + } + membership, err := s.DB.GetTeamMembership(ctx, db.GetTeamMembershipParams{ + UserID: userID, + TeamID: teamID, + }) + if errors.Is(err, pgx.ErrNoRows) { + return HostCreateResult{}, fmt.Errorf("forbidden: not a member of the specified team") + } + if err != nil { + return HostCreateResult{}, fmt.Errorf("check team membership: %w", err) + } + if membership.Role != "owner" { + return HostCreateResult{}, fmt.Errorf("forbidden: only team owners can regenerate tokens") + } + } + + token := id.NewRegistrationToken() + tokenID := id.NewHostTokenID() + + payload, _ := json.Marshal(regTokenPayload{ + HostID: hostID, + TokenID: tokenID, + }) + if err := s.Redis.Set(ctx, "host:reg:"+token, payload, regTokenTTL).Err(); err != nil { + return HostCreateResult{}, fmt.Errorf("store registration token: %w", err) + } + + now := time.Now() + if _, err := s.DB.InsertHostToken(ctx, db.InsertHostTokenParams{ + ID: tokenID, + HostID: hostID, + CreatedBy: userID, + ExpiresAt: pgtype.Timestamptz{Time: now.Add(regTokenTTL), Valid: true}, + }); err != nil { + slog.Warn("failed to insert host token audit record", "host_id", hostID, "error", err) + } + + return HostCreateResult{Host: host, RegistrationToken: token}, nil +} + +// Register validates a one-time registration token, updates the host with +// machine specs, and returns a long-lived host JWT. +func (s *HostService) Register(ctx context.Context, p HostRegisterParams) (HostRegisterResult, error) { + // Atomic consume: GetDel returns the value and deletes in one operation, + // preventing concurrent requests from consuming the same token. + raw, err := s.Redis.GetDel(ctx, "host:reg:"+p.Token).Bytes() + if err == redis.Nil { + return HostRegisterResult{}, fmt.Errorf("invalid or expired registration token") + } + if err != nil { + return HostRegisterResult{}, fmt.Errorf("token lookup: %w", err) + } + + var payload regTokenPayload + if err := json.Unmarshal(raw, &payload); err != nil { + return HostRegisterResult{}, fmt.Errorf("corrupted registration token") + } + + if _, err := s.DB.GetHost(ctx, payload.HostID); err != nil { + return HostRegisterResult{}, fmt.Errorf("host not found: %w", err) + } + + // Sign JWT before mutating DB — if signing fails, the host stays pending. + hostJWT, err := auth.SignHostJWT(s.JWT, payload.HostID) + if err != nil { + return HostRegisterResult{}, fmt.Errorf("sign host token: %w", err) + } + + // Atomically update only if still pending (defense-in-depth against races). + rowsAffected, err := s.DB.RegisterHost(ctx, db.RegisterHostParams{ + ID: payload.HostID, + Arch: pgtype.Text{String: p.Arch, Valid: p.Arch != ""}, + CpuCores: pgtype.Int4{Int32: p.CPUCores, Valid: p.CPUCores > 0}, + MemoryMb: pgtype.Int4{Int32: p.MemoryMB, Valid: p.MemoryMB > 0}, + DiskGb: pgtype.Int4{Int32: p.DiskGB, Valid: p.DiskGB > 0}, + Address: pgtype.Text{String: p.Address, Valid: p.Address != ""}, + }) + if err != nil { + return HostRegisterResult{}, fmt.Errorf("register host: %w", err) + } + if rowsAffected == 0 { + return HostRegisterResult{}, fmt.Errorf("host already registered or not found") + } + + // Mark audit trail. + if err := s.DB.MarkHostTokenUsed(ctx, payload.TokenID); err != nil { + slog.Warn("failed to mark host token used", "token_id", payload.TokenID, "error", err) + } + + // Re-fetch the host to get the updated state. + host, err := s.DB.GetHost(ctx, payload.HostID) + if err != nil { + return HostRegisterResult{}, fmt.Errorf("fetch updated host: %w", err) + } + + return HostRegisterResult{Host: host, JWT: hostJWT}, nil +} + +// Heartbeat updates the last heartbeat timestamp for a host. +func (s *HostService) Heartbeat(ctx context.Context, hostID string) error { + return s.DB.UpdateHostHeartbeat(ctx, hostID) +} + +// List returns hosts visible to the caller. +// Admins see all hosts; non-admins see only BYOC hosts belonging to their team. +func (s *HostService) List(ctx context.Context, teamID string, isAdmin bool) ([]db.Host, error) { + if isAdmin { + return s.DB.ListHosts(ctx) + } + return s.DB.ListHostsByTeam(ctx, pgtype.Text{String: teamID, Valid: true}) +} + +// Get returns a single host, enforcing access control. +func (s *HostService) Get(ctx context.Context, hostID, teamID string, isAdmin bool) (db.Host, error) { + host, err := s.DB.GetHost(ctx, hostID) + if err != nil { + return db.Host{}, fmt.Errorf("host not found: %w", err) + } + if !isAdmin { + if !host.TeamID.Valid || host.TeamID.String != teamID { + return db.Host{}, fmt.Errorf("host not found") + } + } + return host, nil +} + +// Delete removes a host. Admins can delete any host. Team owners can delete +// BYOC hosts belonging to their team. +func (s *HostService) Delete(ctx context.Context, hostID, userID, teamID string, isAdmin bool) error { + host, err := s.DB.GetHost(ctx, hostID) + if err != nil { + return fmt.Errorf("host not found: %w", err) + } + + if !isAdmin { + if host.Type != "byoc" { + return fmt.Errorf("forbidden: only admins can delete regular hosts") + } + if !host.TeamID.Valid || host.TeamID.String != teamID { + return fmt.Errorf("forbidden: host does not belong to your team") + } + membership, err := s.DB.GetTeamMembership(ctx, db.GetTeamMembershipParams{ + UserID: userID, + TeamID: teamID, + }) + if errors.Is(err, pgx.ErrNoRows) { + return fmt.Errorf("forbidden: not a member of the specified team") + } + if err != nil { + return fmt.Errorf("check team membership: %w", err) + } + if membership.Role != "owner" { + return fmt.Errorf("forbidden: only team owners can delete BYOC hosts") + } + } + + return s.DB.DeleteHost(ctx, hostID) +} + +// AddTag adds a tag to a host. +func (s *HostService) AddTag(ctx context.Context, hostID, teamID string, isAdmin bool, tag string) error { + if _, err := s.Get(ctx, hostID, teamID, isAdmin); err != nil { + return err + } + return s.DB.AddHostTag(ctx, db.AddHostTagParams{HostID: hostID, Tag: tag}) +} + +// RemoveTag removes a tag from a host. +func (s *HostService) RemoveTag(ctx context.Context, hostID, teamID string, isAdmin bool, tag string) error { + if _, err := s.Get(ctx, hostID, teamID, isAdmin); err != nil { + return err + } + return s.DB.RemoveHostTag(ctx, db.RemoveHostTagParams{HostID: hostID, Tag: tag}) +} + +// ListTags returns all tags for a host. +func (s *HostService) ListTags(ctx context.Context, hostID, teamID string, isAdmin bool) ([]string, error) { + if _, err := s.Get(ctx, hostID, teamID, isAdmin); err != nil { + return nil, err + } + return s.DB.GetHostTags(ctx, hostID) +} diff --git a/internal/service/sandbox.go b/internal/service/sandbox.go new file mode 100644 index 0000000..ae4bac3 --- /dev/null +++ b/internal/service/sandbox.go @@ -0,0 +1,225 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "time" + + "connectrpc.com/connect" + "github.com/jackc/pgx/v5/pgtype" + + "git.omukk.dev/wrenn/sandbox/internal/db" + "git.omukk.dev/wrenn/sandbox/internal/id" + "git.omukk.dev/wrenn/sandbox/internal/validate" + pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" + "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect" +) + +// SandboxService provides sandbox lifecycle operations shared between the +// REST API and the dashboard. +type SandboxService struct { + DB *db.Queries + Agent hostagentv1connect.HostAgentServiceClient +} + +// SandboxCreateParams holds the parameters for creating a sandbox. +type SandboxCreateParams struct { + TeamID string + Template string + VCPUs int32 + MemoryMB int32 + TimeoutSec int32 +} + +// Create creates a new sandbox: inserts a pending DB record, calls the host agent, +// and updates the record to running. Returns the sandbox DB row. +func (s *SandboxService) Create(ctx context.Context, p SandboxCreateParams) (db.Sandbox, error) { + if p.Template == "" { + p.Template = "minimal" + } + if err := validate.SafeName(p.Template); err != nil { + return db.Sandbox{}, fmt.Errorf("invalid template name: %w", err) + } + if p.VCPUs <= 0 { + p.VCPUs = 1 + } + if p.MemoryMB <= 0 { + p.MemoryMB = 512 + } + + // If the template is a snapshot, use its baked-in vcpus/memory. + if tmpl, err := s.DB.GetTemplateByTeam(ctx, db.GetTemplateByTeamParams{Name: p.Template, TeamID: p.TeamID}); err == nil && tmpl.Type == "snapshot" { + if tmpl.Vcpus.Valid { + p.VCPUs = tmpl.Vcpus.Int32 + } + if tmpl.MemoryMb.Valid { + p.MemoryMB = tmpl.MemoryMb.Int32 + } + } + + sandboxID := id.NewSandboxID() + + if _, err := s.DB.InsertSandbox(ctx, db.InsertSandboxParams{ + ID: sandboxID, + TeamID: p.TeamID, + HostID: "default", + Template: p.Template, + Status: "pending", + Vcpus: p.VCPUs, + MemoryMb: p.MemoryMB, + TimeoutSec: p.TimeoutSec, + }); err != nil { + return db.Sandbox{}, fmt.Errorf("insert sandbox: %w", err) + } + + resp, err := s.Agent.CreateSandbox(ctx, connect.NewRequest(&pb.CreateSandboxRequest{ + SandboxId: sandboxID, + Template: p.Template, + Vcpus: p.VCPUs, + MemoryMb: p.MemoryMB, + TimeoutSec: p.TimeoutSec, + })) + if err != nil { + if _, dbErr := s.DB.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{ + ID: sandboxID, Status: "error", + }); dbErr != nil { + slog.Warn("failed to update sandbox status to error", "id", sandboxID, "error", dbErr) + } + return db.Sandbox{}, fmt.Errorf("agent create: %w", err) + } + + now := time.Now() + sb, err := s.DB.UpdateSandboxRunning(ctx, db.UpdateSandboxRunningParams{ + ID: sandboxID, + HostIp: resp.Msg.HostIp, + GuestIp: "", + StartedAt: pgtype.Timestamptz{ + Time: now, + Valid: true, + }, + }) + if err != nil { + return db.Sandbox{}, fmt.Errorf("update sandbox running: %w", err) + } + + return sb, nil +} + +// List returns active sandboxes (excludes stopped/error) belonging to the given team. +func (s *SandboxService) List(ctx context.Context, teamID string) ([]db.Sandbox, error) { + return s.DB.ListSandboxesByTeam(ctx, teamID) +} + +// Get returns a single sandbox by ID, scoped to the given team. +func (s *SandboxService) Get(ctx context.Context, sandboxID, teamID string) (db.Sandbox, error) { + return s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID}) +} + +// Pause snapshots and freezes a running sandbox to disk. +func (s *SandboxService) Pause(ctx context.Context, sandboxID, teamID string) (db.Sandbox, error) { + sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID}) + if err != nil { + return db.Sandbox{}, fmt.Errorf("sandbox not found: %w", err) + } + if sb.Status != "running" { + return db.Sandbox{}, fmt.Errorf("sandbox is not running (status: %s)", sb.Status) + } + + if _, err := s.Agent.PauseSandbox(ctx, connect.NewRequest(&pb.PauseSandboxRequest{ + SandboxId: sandboxID, + })); err != nil { + return db.Sandbox{}, fmt.Errorf("agent pause: %w", err) + } + + sb, err = s.DB.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{ + ID: sandboxID, Status: "paused", + }) + if err != nil { + return db.Sandbox{}, fmt.Errorf("update status: %w", err) + } + return sb, nil +} + +// Resume restores a paused sandbox from snapshot. +func (s *SandboxService) Resume(ctx context.Context, sandboxID, teamID string) (db.Sandbox, error) { + sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID}) + if err != nil { + return db.Sandbox{}, fmt.Errorf("sandbox not found: %w", err) + } + if sb.Status != "paused" { + return db.Sandbox{}, fmt.Errorf("sandbox is not paused (status: %s)", sb.Status) + } + + resp, err := s.Agent.ResumeSandbox(ctx, connect.NewRequest(&pb.ResumeSandboxRequest{ + SandboxId: sandboxID, + TimeoutSec: sb.TimeoutSec, + })) + if err != nil { + return db.Sandbox{}, fmt.Errorf("agent resume: %w", err) + } + + now := time.Now() + sb, err = s.DB.UpdateSandboxRunning(ctx, db.UpdateSandboxRunningParams{ + ID: sandboxID, + HostIp: resp.Msg.HostIp, + GuestIp: "", + StartedAt: pgtype.Timestamptz{ + Time: now, + Valid: true, + }, + }) + if err != nil { + return db.Sandbox{}, fmt.Errorf("update status: %w", err) + } + return sb, nil +} + +// Destroy stops a sandbox and marks it as stopped. +func (s *SandboxService) Destroy(ctx context.Context, sandboxID, teamID string) error { + if _, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID}); err != nil { + return fmt.Errorf("sandbox not found: %w", err) + } + + // Destroy on host agent. A not-found response is fine — sandbox is already gone. + if _, err := s.Agent.DestroySandbox(ctx, connect.NewRequest(&pb.DestroySandboxRequest{ + SandboxId: sandboxID, + })); err != nil && connect.CodeOf(err) != connect.CodeNotFound { + return fmt.Errorf("agent destroy: %w", err) + } + + if _, err := s.DB.UpdateSandboxStatus(ctx, db.UpdateSandboxStatusParams{ + ID: sandboxID, Status: "stopped", + }); err != nil { + return fmt.Errorf("update status: %w", err) + } + return nil +} + +// Ping resets the inactivity timer for a running sandbox. +func (s *SandboxService) Ping(ctx context.Context, sandboxID, teamID string) error { + sb, err := s.DB.GetSandboxByTeam(ctx, db.GetSandboxByTeamParams{ID: sandboxID, TeamID: teamID}) + if err != nil { + return fmt.Errorf("sandbox not found: %w", err) + } + if sb.Status != "running" { + return fmt.Errorf("sandbox is not running (status: %s)", sb.Status) + } + + if _, err := s.Agent.PingSandbox(ctx, connect.NewRequest(&pb.PingSandboxRequest{ + SandboxId: sandboxID, + })); err != nil { + return fmt.Errorf("agent ping: %w", err) + } + + if err := s.DB.UpdateLastActive(ctx, db.UpdateLastActiveParams{ + ID: sandboxID, + LastActiveAt: pgtype.Timestamptz{ + Time: time.Now(), + Valid: true, + }, + }); err != nil { + slog.Warn("ping: failed to update last_active_at", "sandbox_id", sandboxID, "error", err) + } + return nil +} diff --git a/internal/service/template.go b/internal/service/template.go new file mode 100644 index 0000000..d669e45 --- /dev/null +++ b/internal/service/template.go @@ -0,0 +1,25 @@ +package service + +import ( + "context" + + "git.omukk.dev/wrenn/sandbox/internal/db" +) + +// TemplateService provides template/snapshot operations shared between the +// REST API and the dashboard. +type TemplateService struct { + DB *db.Queries +} + +// List returns all templates belonging to the given team. If typeFilter is +// non-empty, only templates of that type ("base" or "snapshot") are returned. +func (s *TemplateService) List(ctx context.Context, teamID, typeFilter string) ([]db.Template, error) { + if typeFilter != "" { + return s.DB.ListTemplatesByTeamAndType(ctx, db.ListTemplatesByTeamAndTypeParams{ + TeamID: teamID, + Type: typeFilter, + }) + } + return s.DB.ListTemplatesByTeam(ctx, teamID) +} diff --git a/internal/snapshot/header.go b/internal/snapshot/header.go new file mode 100644 index 0000000..e679529 --- /dev/null +++ b/internal/snapshot/header.go @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modifications by M/S Omukk + +// Package snapshot implements snapshot storage, header-based memory mapping, +// and memory file processing for Firecracker VM snapshots. +// +// The header system implements a generational copy-on-write memory mapping. +// Each snapshot generation stores only the blocks that changed since the +// previous generation. A Header contains a sorted list of BuildMap entries +// that together cover the entire memory address space, with each entry +// pointing to a specific generation's diff file. +package snapshot + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "io" + + "github.com/google/uuid" +) + +const metadataVersion = 1 + +// Metadata is the fixed-size header prefix describing the snapshot memory layout. +// Binary layout (little-endian, 64 bytes total): +// +// Version uint64 (8 bytes) +// BlockSize uint64 (8 bytes) +// Size uint64 (8 bytes) — total memory size in bytes +// Generation uint64 (8 bytes) +// BuildID [16]byte (UUID) +// BaseBuildID [16]byte (UUID) +type Metadata struct { + Version uint64 + BlockSize uint64 + Size uint64 + Generation uint64 + BuildID uuid.UUID + BaseBuildID uuid.UUID +} + +// NewMetadata creates metadata for a first-generation snapshot. +func NewMetadata(buildID uuid.UUID, blockSize, size uint64) *Metadata { + return &Metadata{ + Version: metadataVersion, + Generation: 0, + BlockSize: blockSize, + Size: size, + BuildID: buildID, + BaseBuildID: buildID, + } +} + +// NextGeneration creates metadata for the next generation in the chain. +func (m *Metadata) NextGeneration(buildID uuid.UUID) *Metadata { + return &Metadata{ + Version: m.Version, + Generation: m.Generation + 1, + BlockSize: m.BlockSize, + Size: m.Size, + BuildID: buildID, + BaseBuildID: m.BaseBuildID, + } +} + +// BuildMap maps a contiguous range of the memory address space to a specific +// generation's diff file. Binary layout (little-endian, 40 bytes): +// +// Offset uint64 — byte offset in the virtual address space +// Length uint64 — byte count (multiple of BlockSize) +// BuildID [16]byte — which generation's diff file, uuid.Nil = zero-fill +// BuildStorageOffset uint64 — byte offset within that generation's diff file +type BuildMap struct { + Offset uint64 + Length uint64 + BuildID uuid.UUID + BuildStorageOffset uint64 +} + +// Header is the in-memory representation of a snapshot's memory mapping. +// It provides O(log N) lookup from any memory offset to the correct +// generation's diff file and offset within it. +type Header struct { + Metadata *Metadata + Mapping []*BuildMap + + // blockStarts tracks which block indices start a new BuildMap entry. + // startMap provides direct access from block index to the BuildMap. + blockStarts []bool + startMap map[int64]*BuildMap +} + +// NewHeader creates a Header from metadata and mapping entries. +// If mapping is nil/empty, a single entry covering the full size is created. +func NewHeader(metadata *Metadata, mapping []*BuildMap) (*Header, error) { + if metadata.BlockSize == 0 { + return nil, fmt.Errorf("block size cannot be zero") + } + + if len(mapping) == 0 { + mapping = []*BuildMap{{ + Offset: 0, + Length: metadata.Size, + BuildID: metadata.BuildID, + BuildStorageOffset: 0, + }} + } + + blocks := TotalBlocks(int64(metadata.Size), int64(metadata.BlockSize)) + starts := make([]bool, blocks) + startMap := make(map[int64]*BuildMap, len(mapping)) + + for _, m := range mapping { + idx := BlockIdx(int64(m.Offset), int64(metadata.BlockSize)) + if idx >= 0 && idx < blocks { + starts[idx] = true + startMap[idx] = m + } + } + + return &Header{ + Metadata: metadata, + Mapping: mapping, + blockStarts: starts, + startMap: startMap, + }, nil +} + +// GetShiftedMapping resolves a memory offset to the corresponding diff file +// offset, remaining length, and build ID. This is the hot path called for +// every UFFD page fault. +func (h *Header) GetShiftedMapping(_ context.Context, offset int64) (mappedOffset int64, mappedLength int64, buildID *uuid.UUID, err error) { + if offset < 0 || offset >= int64(h.Metadata.Size) { + return 0, 0, nil, fmt.Errorf("offset %d out of bounds (size: %d)", offset, h.Metadata.Size) + } + + blockSize := int64(h.Metadata.BlockSize) + block := BlockIdx(offset, blockSize) + + // Walk backwards to find the BuildMap that contains this block. + start := block + for start >= 0 { + if h.blockStarts[start] { + break + } + start-- + } + if start < 0 { + return 0, 0, nil, fmt.Errorf("no mapping found for offset %d", offset) + } + + m, ok := h.startMap[start] + if !ok { + return 0, 0, nil, fmt.Errorf("no mapping at block %d", start) + } + + shift := (block - start) * blockSize + if shift >= int64(m.Length) { + return 0, 0, nil, fmt.Errorf("offset %d beyond mapping end (mapping offset=%d, length=%d)", offset, m.Offset, m.Length) + } + + return int64(m.BuildStorageOffset) + shift, int64(m.Length) - shift, &m.BuildID, nil +} + +// Serialize writes metadata + mapping entries to binary (little-endian). +func Serialize(metadata *Metadata, mappings []*BuildMap) ([]byte, error) { + var buf bytes.Buffer + + if err := binary.Write(&buf, binary.LittleEndian, metadata); err != nil { + return nil, fmt.Errorf("write metadata: %w", err) + } + + for _, m := range mappings { + if err := binary.Write(&buf, binary.LittleEndian, m); err != nil { + return nil, fmt.Errorf("write mapping: %w", err) + } + } + + return buf.Bytes(), nil +} + +// Deserialize reads a header from binary data. +func Deserialize(data []byte) (*Header, error) { + reader := bytes.NewReader(data) + + var metadata Metadata + if err := binary.Read(reader, binary.LittleEndian, &metadata); err != nil { + return nil, fmt.Errorf("read metadata: %w", err) + } + + var mappings []*BuildMap + for { + var m BuildMap + if err := binary.Read(reader, binary.LittleEndian, &m); err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, fmt.Errorf("read mapping: %w", err) + } + mappings = append(mappings, &m) + } + + return NewHeader(&metadata, mappings) +} + +// Block index helpers. + +func TotalBlocks(size, blockSize int64) int64 { + return (size + blockSize - 1) / blockSize +} + +func BlockIdx(offset, blockSize int64) int64 { + return offset / blockSize +} + +func BlockOffset(idx, blockSize int64) int64 { + return idx * blockSize +} diff --git a/internal/snapshot/local.go b/internal/snapshot/local.go index e69de29..8e667b8 100644 --- a/internal/snapshot/local.go +++ b/internal/snapshot/local.go @@ -0,0 +1,235 @@ +package snapshot + +import ( + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "syscall" + + "github.com/google/uuid" +) + +const ( + SnapFileName = "snapfile" + MemDiffName = "memfile" + MemHeaderName = "memfile.header" + RootfsFileName = "rootfs.ext4" + RootfsCowName = "rootfs.cow" + RootfsMetaName = "rootfs.meta" +) + +// DirPath returns the snapshot directory for a given name. +func DirPath(baseDir, name string) string { + return filepath.Join(baseDir, name) +} + +// SnapPath returns the path to the VM state snapshot file. +func SnapPath(baseDir, name string) string { + return filepath.Join(DirPath(baseDir, name), SnapFileName) +} + +// MemDiffPath returns the path to the compact memory diff file (legacy single-generation). +func MemDiffPath(baseDir, name string) string { + return filepath.Join(DirPath(baseDir, name), MemDiffName) +} + +// MemDiffPathForBuild returns the path to a specific generation's diff file. +// Format: memfile.{buildID} +func MemDiffPathForBuild(baseDir, name string, buildID uuid.UUID) string { + return filepath.Join(DirPath(baseDir, name), fmt.Sprintf("memfile.%s", buildID.String())) +} + +// MemHeaderPath returns the path to the memory mapping header file. +func MemHeaderPath(baseDir, name string) string { + return filepath.Join(DirPath(baseDir, name), MemHeaderName) +} + +// RootfsPath returns the path to the rootfs image. +func RootfsPath(baseDir, name string) string { + return filepath.Join(DirPath(baseDir, name), RootfsFileName) +} + +// CowPath returns the path to the rootfs CoW diff file. +func CowPath(baseDir, name string) string { + return filepath.Join(DirPath(baseDir, name), RootfsCowName) +} + +// MetaPath returns the path to the rootfs metadata file. +func MetaPath(baseDir, name string) string { + return filepath.Join(DirPath(baseDir, name), RootfsMetaName) +} + +// RootfsMeta records which base template a CoW file was created against. +type RootfsMeta struct { + BaseTemplate string `json:"base_template"` +} + +// WriteMeta writes rootfs metadata to the snapshot directory. +func WriteMeta(baseDir, name string, meta *RootfsMeta) error { + data, err := json.Marshal(meta) + if err != nil { + return fmt.Errorf("marshal rootfs meta: %w", err) + } + if err := os.WriteFile(MetaPath(baseDir, name), data, 0644); err != nil { + return fmt.Errorf("write rootfs meta: %w", err) + } + return nil +} + +// ReadMeta reads rootfs metadata from the snapshot directory. +func ReadMeta(baseDir, name string) (*RootfsMeta, error) { + data, err := os.ReadFile(MetaPath(baseDir, name)) + if err != nil { + return nil, fmt.Errorf("read rootfs meta: %w", err) + } + var meta RootfsMeta + if err := json.Unmarshal(data, &meta); err != nil { + return nil, fmt.Errorf("unmarshal rootfs meta: %w", err) + } + return &meta, nil +} + +// Exists reports whether a complete snapshot exists (all required files present). +// Supports both legacy (rootfs.ext4) and CoW-based (rootfs.cow + rootfs.meta) snapshots. +// Memory diff files can be either legacy "memfile" or generation-specific "memfile.{uuid}". +func Exists(baseDir, name string) bool { + dir := DirPath(baseDir, name) + + // snapfile and header are always required. + for _, f := range []string{SnapFileName, MemHeaderName} { + if _, err := os.Stat(filepath.Join(dir, f)); err != nil { + return false + } + } + + // Check that at least one memfile exists (legacy or generation-specific). + // We verify by reading the header and checking that referenced diff files exist. + // Fall back to checking for the legacy memfile name if header can't be read. + if _, err := os.Stat(filepath.Join(dir, MemDiffName)); err != nil { + // No legacy memfile — check if any memfile.{uuid} exists by + // looking for files matching the pattern. + matches, _ := filepath.Glob(filepath.Join(dir, "memfile.*")) + hasGenDiff := false + for _, m := range matches { + base := filepath.Base(m) + if base != MemHeaderName { + hasGenDiff = true + break + } + } + if !hasGenDiff { + return false + } + } + + // Accept either rootfs.ext4 (legacy/template) or rootfs.cow + rootfs.meta (dm-snapshot). + if _, err := os.Stat(filepath.Join(dir, RootfsFileName)); err == nil { + return true + } + if _, err := os.Stat(filepath.Join(dir, RootfsCowName)); err == nil { + if _, err := os.Stat(filepath.Join(dir, RootfsMetaName)); err == nil { + return true + } + } + return false +} + +// IsTemplate reports whether a template image directory exists (has rootfs.ext4). +func IsTemplate(baseDir, name string) bool { + _, err := os.Stat(filepath.Join(DirPath(baseDir, name), RootfsFileName)) + return err == nil +} + +// IsSnapshot reports whether a directory is a snapshot (has all snapshot files). +func IsSnapshot(baseDir, name string) bool { + return Exists(baseDir, name) +} + +// HasCow reports whether a snapshot uses CoW format (rootfs.cow + rootfs.meta) +// as opposed to legacy full rootfs (rootfs.ext4). +func HasCow(baseDir, name string) bool { + dir := DirPath(baseDir, name) + _, cowErr := os.Stat(filepath.Join(dir, RootfsCowName)) + _, metaErr := os.Stat(filepath.Join(dir, RootfsMetaName)) + return cowErr == nil && metaErr == nil +} + +// ListDiffFiles returns a map of build ID → file path for all memory diff files +// referenced by the given header. Handles both the legacy "memfile" name +// (single-generation) and generation-specific "memfile.{uuid}" names. +func ListDiffFiles(baseDir, name string, header *Header) (map[string]string, error) { + dir := DirPath(baseDir, name) + result := make(map[string]string) + + for _, m := range header.Mapping { + if m.BuildID == uuid.Nil { + continue // zero-fill, no file needed + } + idStr := m.BuildID.String() + if _, exists := result[idStr]; exists { + continue + } + // Try generation-specific path first, fall back to legacy. + genPath := filepath.Join(dir, fmt.Sprintf("memfile.%s", idStr)) + if _, err := os.Stat(genPath); err == nil { + result[idStr] = genPath + continue + } + legacyPath := filepath.Join(dir, MemDiffName) + if _, err := os.Stat(legacyPath); err == nil { + result[idStr] = legacyPath + continue + } + return nil, fmt.Errorf("diff file not found for build %s", idStr) + } + return result, nil +} + +// EnsureDir creates the snapshot directory if it doesn't exist. +func EnsureDir(baseDir, name string) error { + dir := DirPath(baseDir, name) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create snapshot dir %s: %w", dir, err) + } + return nil +} + +// Remove deletes the entire snapshot directory. +func Remove(baseDir, name string) error { + return os.RemoveAll(DirPath(baseDir, name)) +} + +// DirSize returns the actual disk usage of all files in the snapshot directory. +// Uses block-based accounting (stat.Blocks * 512) so sparse files report only +// the blocks that are actually allocated, not their apparent size. +func DirSize(baseDir, name string) (int64, error) { + var total int64 + dir := DirPath(baseDir, name) + + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + if sys, ok := info.Sys().(*syscall.Stat_t); ok { + // Blocks is in 512-byte units regardless of filesystem block size. + total += sys.Blocks * 512 + } else { + // Fallback to apparent size if syscall stat is unavailable. + total += info.Size() + } + return nil + }) + if err != nil { + return 0, fmt.Errorf("calculate snapshot size: %w", err) + } + return total, nil +} diff --git a/internal/snapshot/manager.go b/internal/snapshot/manager.go index e69de29..8df14bc 100644 --- a/internal/snapshot/manager.go +++ b/internal/snapshot/manager.go @@ -0,0 +1 @@ +package snapshot diff --git a/internal/snapshot/mapping.go b/internal/snapshot/mapping.go new file mode 100644 index 0000000..8451518 --- /dev/null +++ b/internal/snapshot/mapping.go @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modifications by M/S Omukk + +package snapshot + +import "github.com/google/uuid" + +// CreateMapping converts a dirty-block bitset (represented as a []bool) into +// a sorted list of BuildMap entries. Consecutive dirty blocks are merged into +// a single entry. BuildStorageOffset tracks the sequential position in the +// compact diff file. +func CreateMapping(buildID uuid.UUID, dirty []bool, blockSize int64) []*BuildMap { + var mappings []*BuildMap + var runStart int64 = -1 + var runLength int64 + var storageOffset uint64 + + for i, set := range dirty { + if !set { + if runLength > 0 { + mappings = append(mappings, &BuildMap{ + Offset: uint64(runStart) * uint64(blockSize), + Length: uint64(runLength) * uint64(blockSize), + BuildID: buildID, + BuildStorageOffset: storageOffset, + }) + storageOffset += uint64(runLength) * uint64(blockSize) + runLength = 0 + } + runStart = -1 + continue + } + + if runStart < 0 { + runStart = int64(i) + runLength = 1 + } else { + runLength++ + } + } + + if runLength > 0 { + mappings = append(mappings, &BuildMap{ + Offset: uint64(runStart) * uint64(blockSize), + Length: uint64(runLength) * uint64(blockSize), + BuildID: buildID, + BuildStorageOffset: storageOffset, + }) + } + + return mappings +} + +// MergeMappings overlays diffMapping on top of baseMapping. Where they overlap, +// diff takes priority. The result covers the entire address space. +// +// Both inputs must be sorted by Offset. The base mapping should cover the full size. +// +// Inspired by e2b's snapshot system (Apache 2.0, modified by Omukk). +func MergeMappings(baseMapping, diffMapping []*BuildMap) []*BuildMap { + if len(diffMapping) == 0 { + return baseMapping + } + + // Work on a copy of baseMapping to avoid mutating the original. + baseCopy := make([]*BuildMap, len(baseMapping)) + for i, m := range baseMapping { + cp := *m + baseCopy[i] = &cp + } + + var result []*BuildMap + var bi, di int + + for bi < len(baseCopy) && di < len(diffMapping) { + base := baseCopy[bi] + diff := diffMapping[di] + + if base.Length == 0 { + bi++ + continue + } + if diff.Length == 0 { + di++ + continue + } + + // No overlap: base entirely before diff. + if base.Offset+base.Length <= diff.Offset { + result = append(result, base) + bi++ + continue + } + + // No overlap: diff entirely before base. + if diff.Offset+diff.Length <= base.Offset { + result = append(result, diff) + di++ + continue + } + + // Base fully inside diff — skip base. + if base.Offset >= diff.Offset && base.Offset+base.Length <= diff.Offset+diff.Length { + bi++ + continue + } + + // Diff fully inside base — split base around diff. + if diff.Offset >= base.Offset && diff.Offset+diff.Length <= base.Offset+base.Length { + leftLen := int64(diff.Offset) - int64(base.Offset) + if leftLen > 0 { + result = append(result, &BuildMap{ + Offset: base.Offset, + Length: uint64(leftLen), + BuildID: base.BuildID, + BuildStorageOffset: base.BuildStorageOffset, + }) + } + + result = append(result, diff) + di++ + + rightShift := int64(diff.Offset) + int64(diff.Length) - int64(base.Offset) + rightLen := int64(base.Length) - rightShift + + if rightLen > 0 { + baseCopy[bi] = &BuildMap{ + Offset: base.Offset + uint64(rightShift), + Length: uint64(rightLen), + BuildID: base.BuildID, + BuildStorageOffset: base.BuildStorageOffset + uint64(rightShift), + } + } else { + bi++ + } + continue + } + + // Base starts after diff with overlap — emit diff, trim base. + if base.Offset > diff.Offset { + result = append(result, diff) + di++ + + rightShift := int64(diff.Offset) + int64(diff.Length) - int64(base.Offset) + rightLen := int64(base.Length) - rightShift + + if rightLen > 0 { + baseCopy[bi] = &BuildMap{ + Offset: base.Offset + uint64(rightShift), + Length: uint64(rightLen), + BuildID: base.BuildID, + BuildStorageOffset: base.BuildStorageOffset + uint64(rightShift), + } + } else { + bi++ + } + continue + } + + // Diff starts after base with overlap — emit left part of base. + if diff.Offset > base.Offset { + leftLen := int64(diff.Offset) - int64(base.Offset) + if leftLen > 0 { + result = append(result, &BuildMap{ + Offset: base.Offset, + Length: uint64(leftLen), + BuildID: base.BuildID, + BuildStorageOffset: base.BuildStorageOffset, + }) + } + bi++ + continue + } + } + + // Append remaining entries. + result = append(result, baseCopy[bi:]...) + result = append(result, diffMapping[di:]...) + + return result +} + +// NormalizeMappings merges adjacent entries with the same BuildID. +func NormalizeMappings(mappings []*BuildMap) []*BuildMap { + if len(mappings) == 0 { + return nil + } + + result := make([]*BuildMap, 0, len(mappings)) + current := &BuildMap{ + Offset: mappings[0].Offset, + Length: mappings[0].Length, + BuildID: mappings[0].BuildID, + BuildStorageOffset: mappings[0].BuildStorageOffset, + } + + for i := 1; i < len(mappings); i++ { + m := mappings[i] + if m.BuildID == current.BuildID { + current.Length += m.Length + } else { + result = append(result, current) + current = &BuildMap{ + Offset: m.Offset, + Length: m.Length, + BuildID: m.BuildID, + BuildStorageOffset: m.BuildStorageOffset, + } + } + } + result = append(result, current) + + return result +} diff --git a/internal/snapshot/memfile.go b/internal/snapshot/memfile.go new file mode 100644 index 0000000..aabe885 --- /dev/null +++ b/internal/snapshot/memfile.go @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modifications by M/S Omukk + +package snapshot + +import ( + "fmt" + "io" + "os" + + "github.com/google/uuid" +) + +const ( + // DefaultBlockSize is 4KB — standard page size for Firecracker. + DefaultBlockSize int64 = 4096 +) + +// ProcessMemfile reads a full memory file produced by Firecracker's +// PUT /snapshot/create, identifies non-zero blocks, and writes only those +// blocks to a compact diff file. Returns the Header describing the mapping. +// +// The output diff file contains non-zero blocks written sequentially. +// The header maps each block in the full address space to either: +// - A position in the diff file (for non-zero blocks) +// - uuid.Nil (for zero/empty blocks, served as zeros without I/O) +// +// buildID identifies this snapshot generation in the header chain. +func ProcessMemfile(memfilePath, diffPath, headerPath string, buildID uuid.UUID) (*Header, error) { + src, err := os.Open(memfilePath) + if err != nil { + return nil, fmt.Errorf("open memfile: %w", err) + } + defer src.Close() + + info, err := src.Stat() + if err != nil { + return nil, fmt.Errorf("stat memfile: %w", err) + } + memSize := info.Size() + + dst, err := os.Create(diffPath) + if err != nil { + return nil, fmt.Errorf("create diff file: %w", err) + } + defer dst.Close() + + totalBlocks := TotalBlocks(memSize, DefaultBlockSize) + dirty := make([]bool, totalBlocks) + empty := make([]bool, totalBlocks) + buf := make([]byte, DefaultBlockSize) + + for i := int64(0); i < totalBlocks; i++ { + n, err := io.ReadFull(src, buf) + if err != nil && err != io.ErrUnexpectedEOF { + return nil, fmt.Errorf("read block %d: %w", i, err) + } + + // Zero-pad the last block if it's short. + if int64(n) < DefaultBlockSize { + for j := n; j < int(DefaultBlockSize); j++ { + buf[j] = 0 + } + } + + if isZeroBlock(buf) { + empty[i] = true + continue + } + + dirty[i] = true + if _, err := dst.Write(buf); err != nil { + return nil, fmt.Errorf("write diff block %d: %w", i, err) + } + } + + // Build header. + dirtyMappings := CreateMapping(buildID, dirty, DefaultBlockSize) + emptyMappings := CreateMapping(uuid.Nil, empty, DefaultBlockSize) + merged := MergeMappings(dirtyMappings, emptyMappings) + normalized := NormalizeMappings(merged) + + metadata := NewMetadata(buildID, uint64(DefaultBlockSize), uint64(memSize)) + header, err := NewHeader(metadata, normalized) + if err != nil { + return nil, fmt.Errorf("create header: %w", err) + } + + // Write header to disk. + headerData, err := Serialize(metadata, normalized) + if err != nil { + return nil, fmt.Errorf("serialize header: %w", err) + } + if err := os.WriteFile(headerPath, headerData, 0644); err != nil { + return nil, fmt.Errorf("write header: %w", err) + } + + return header, nil +} + +// ProcessMemfileWithParent processes a memory file as a new generation on top +// of an existing parent header. The new diff file contains only blocks that +// differ from what the parent header maps. This is used for re-pause of a +// sandbox that was restored from a snapshot. +func ProcessMemfileWithParent(memfilePath, diffPath, headerPath string, parentHeader *Header, buildID uuid.UUID) (*Header, error) { + src, err := os.Open(memfilePath) + if err != nil { + return nil, fmt.Errorf("open memfile: %w", err) + } + defer src.Close() + + info, err := src.Stat() + if err != nil { + return nil, fmt.Errorf("stat memfile: %w", err) + } + memSize := info.Size() + + dst, err := os.Create(diffPath) + if err != nil { + return nil, fmt.Errorf("create diff file: %w", err) + } + defer dst.Close() + + totalBlocks := TotalBlocks(memSize, DefaultBlockSize) + dirty := make([]bool, totalBlocks) + buf := make([]byte, DefaultBlockSize) + + for i := int64(0); i < totalBlocks; i++ { + n, err := io.ReadFull(src, buf) + if err != nil && err != io.ErrUnexpectedEOF { + return nil, fmt.Errorf("read block %d: %w", i, err) + } + + if int64(n) < DefaultBlockSize { + for j := n; j < int(DefaultBlockSize); j++ { + buf[j] = 0 + } + } + + if isZeroBlock(buf) { + // For a diff memfile, zero blocks mean "not dirtied since resume" — + // they should inherit the parent's mapping, not be zero-filled. + continue + } + + dirty[i] = true + if _, err := dst.Write(buf); err != nil { + return nil, fmt.Errorf("write diff block %d: %w", i, err) + } + } + + // Only dirty blocks go into the diff overlay; MergeMappings preserves the + // parent's mapping for everything else. + dirtyMappings := CreateMapping(buildID, dirty, DefaultBlockSize) + merged := MergeMappings(parentHeader.Mapping, dirtyMappings) + normalized := NormalizeMappings(merged) + + metadata := parentHeader.Metadata.NextGeneration(buildID) + header, err := NewHeader(metadata, normalized) + if err != nil { + return nil, fmt.Errorf("create header: %w", err) + } + + headerData, err := Serialize(metadata, normalized) + if err != nil { + return nil, fmt.Errorf("serialize header: %w", err) + } + if err := os.WriteFile(headerPath, headerData, 0644); err != nil { + return nil, fmt.Errorf("write header: %w", err) + } + + return header, nil +} + +// isZeroBlock checks if a block is entirely zero bytes. +func isZeroBlock(block []byte) bool { + // Fast path: compare 8 bytes at a time. + for i := 0; i+8 <= len(block); i += 8 { + if block[i] != 0 || block[i+1] != 0 || block[i+2] != 0 || block[i+3] != 0 || + block[i+4] != 0 || block[i+5] != 0 || block[i+6] != 0 || block[i+7] != 0 { + return false + } + } + // Tail bytes. + for i := len(block) &^ 7; i < len(block); i++ { + if block[i] != 0 { + return false + } + } + return true +} diff --git a/internal/snapshot/remote.go b/internal/snapshot/remote.go index e69de29..8df14bc 100644 --- a/internal/snapshot/remote.go +++ b/internal/snapshot/remote.go @@ -0,0 +1 @@ +package snapshot diff --git a/internal/uffd/fd.go b/internal/uffd/fd.go new file mode 100644 index 0000000..492a520 --- /dev/null +++ b/internal/uffd/fd.go @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modifications by M/S Omukk + +// Package uffd implements a userfaultfd-based memory server for Firecracker +// snapshot restore. When a VM is restored from a snapshot, instead of loading +// the entire memory file upfront, the UFFD handler intercepts page faults +// and serves memory pages on demand from the snapshot's compact diff file. +package uffd + +/* +#include +#include +#include +#include + +struct uffd_pagefault { + __u64 flags; + __u64 address; + __u32 ptid; +}; +*/ +import "C" + +import ( + "fmt" + "syscall" + "unsafe" +) + +const ( + UFFD_EVENT_PAGEFAULT = C.UFFD_EVENT_PAGEFAULT + UFFD_PAGEFAULT_FLAG_WRITE = C.UFFD_PAGEFAULT_FLAG_WRITE + UFFDIO_COPY = C.UFFDIO_COPY + UFFDIO_COPY_MODE_WP = C.UFFDIO_COPY_MODE_WP +) + +type ( + uffdMsg = C.struct_uffd_msg + uffdPagefault = C.struct_uffd_pagefault + uffdioCopy = C.struct_uffdio_copy +) + +// fd wraps a userfaultfd file descriptor received from Firecracker. +type fd uintptr + +// copy installs a page into guest memory at the given address using UFFDIO_COPY. +// mode controls write-protection: use UFFDIO_COPY_MODE_WP to preserve WP bit. +func (f fd) copy(addr, pagesize uintptr, data []byte, mode C.ulonglong) error { + alignedAddr := addr &^ (pagesize - 1) + cpy := uffdioCopy{ + src: C.ulonglong(uintptr(unsafe.Pointer(&data[0]))), + dst: C.ulonglong(alignedAddr), + len: C.ulonglong(pagesize), + mode: mode, + copy: 0, + } + + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(f), UFFDIO_COPY, uintptr(unsafe.Pointer(&cpy))) + if errno != 0 { + return errno + } + + if cpy.copy != C.longlong(pagesize) { + return fmt.Errorf("UFFDIO_COPY copied %d bytes, expected %d", cpy.copy, pagesize) + } + + return nil +} + +// close closes the userfaultfd file descriptor. +func (f fd) close() error { + return syscall.Close(int(f)) +} + +// getMsgEvent extracts the event type from a uffd_msg. +func getMsgEvent(msg *uffdMsg) C.uchar { + return msg.event +} + +// getMsgArg extracts the arg union from a uffd_msg. +func getMsgArg(msg *uffdMsg) [24]byte { + return msg.arg +} + +// getPagefaultAddress extracts the faulting address from a uffd_pagefault. +func getPagefaultAddress(pf *uffdPagefault) uintptr { + return uintptr(pf.address) +} diff --git a/internal/uffd/region.go b/internal/uffd/region.go new file mode 100644 index 0000000..20b3921 --- /dev/null +++ b/internal/uffd/region.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modifications by M/S Omukk +// +// Modifications by Omukk (Wrenn Sandbox): merged Region and Mapping into +// single file, inlined shiftedOffset helper. + +package uffd + +import "fmt" + +// Region is a mapping of guest memory to host virtual address space. +// Firecracker sends these as JSON when connecting to the UFFD socket. +// The JSON field names match Firecracker's UFFD protocol. +type Region struct { + BaseHostVirtAddr uintptr `json:"base_host_virt_addr"` + Size uintptr `json:"size"` + Offset uintptr `json:"offset"` + PageSize uintptr `json:"page_size_kib"` // Actually in bytes despite the name. +} + +// Mapping translates between host virtual addresses and logical memory offsets. +type Mapping struct { + Regions []Region +} + +// NewMapping creates a Mapping from a list of regions. +func NewMapping(regions []Region) *Mapping { + return &Mapping{Regions: regions} +} + +// GetOffset converts a host virtual address to a logical memory file offset +// and returns the page size. This is called on every UFFD page fault. +func (m *Mapping) GetOffset(hostVirtAddr uintptr) (int64, uintptr, error) { + for _, r := range m.Regions { + if hostVirtAddr >= r.BaseHostVirtAddr && hostVirtAddr < r.BaseHostVirtAddr+r.Size { + offset := int64(hostVirtAddr-r.BaseHostVirtAddr) + int64(r.Offset) + return offset, r.PageSize, nil + } + } + return 0, 0, fmt.Errorf("address %#x not found in any memory region", hostVirtAddr) +} diff --git a/internal/uffd/server.go b/internal/uffd/server.go new file mode 100644 index 0000000..40d29c7 --- /dev/null +++ b/internal/uffd/server.go @@ -0,0 +1,360 @@ +// SPDX-License-Identifier: Apache-2.0 +// Modifications by M/S Omukk +// +// Modifications by Omukk (Wrenn Sandbox): replaced errgroup with WaitGroup +// + semaphore, replaced fdexit abstraction with pipe, integrated with +// snapshot.Header-based DiffFileSource instead of block.ReadonlyDevice, +// fixed EAGAIN handling in poll loop. + +package uffd + +/* +#include +*/ +import "C" + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net" + "os" + "sync" + "syscall" + "unsafe" + + "golang.org/x/sys/unix" + + "git.omukk.dev/wrenn/sandbox/internal/snapshot" +) + +const ( + fdSize = 4 + regionMappingsSize = 1024 + maxConcurrentFaults = 4096 +) + +// MemorySource provides page data for the UFFD handler. +// Given a logical memory offset and a size, it returns the page data. +type MemorySource interface { + ReadPage(ctx context.Context, offset int64, size int64) ([]byte, error) +} + +// Server manages the UFFD Unix socket lifecycle and page fault handling +// for a single Firecracker snapshot restore. +type Server struct { + socketPath string + source MemorySource + lis *net.UnixListener + + readyCh chan struct{} + readyOnce sync.Once + doneCh chan struct{} + doneErr error + + // exitPipe signals the poll loop to stop. + exitR *os.File + exitW *os.File +} + +// NewServer creates a UFFD server that will listen on the given socket path +// and serve memory pages from the given source. +func NewServer(socketPath string, source MemorySource) *Server { + return &Server{ + socketPath: socketPath, + source: source, + readyCh: make(chan struct{}), + doneCh: make(chan struct{}), + } +} + +// Start begins listening on the Unix socket. Firecracker will connect to this +// socket after loadSnapshot is called with the UFFD backend. +// Start returns immediately; the server runs in a background goroutine. +func (s *Server) Start(ctx context.Context) error { + lis, err := net.ListenUnix("unix", &net.UnixAddr{Name: s.socketPath, Net: "unix"}) + if err != nil { + return fmt.Errorf("listen on uffd socket: %w", err) + } + s.lis = lis + + if err := os.Chmod(s.socketPath, 0o777); err != nil { + lis.Close() + return fmt.Errorf("chmod uffd socket: %w", err) + } + + // Create exit signal pipe. + r, w, err := os.Pipe() + if err != nil { + lis.Close() + return fmt.Errorf("create exit pipe: %w", err) + } + s.exitR = r + s.exitW = w + + go func() { + defer close(s.doneCh) + s.doneErr = s.handle(ctx) + s.lis.Close() + s.exitR.Close() + s.exitW.Close() + s.readyOnce.Do(func() { close(s.readyCh) }) + }() + + return nil +} + +// Ready returns a channel that is closed when the UFFD handler is ready +// (after Firecracker has connected and sent the uffd fd). +func (s *Server) Ready() <-chan struct{} { + return s.readyCh +} + +// Stop signals the UFFD poll loop to exit and waits for it to finish. +func (s *Server) Stop() error { + // Write a byte to the exit pipe to wake the poll loop. + _, _ = s.exitW.Write([]byte{0}) + <-s.doneCh + return s.doneErr +} + +// Wait blocks until the server exits. +func (s *Server) Wait() error { + <-s.doneCh + return s.doneErr +} + +// handle accepts the Firecracker connection, receives the UFFD fd via +// SCM_RIGHTS, and runs the page fault poll loop. +func (s *Server) handle(ctx context.Context) error { + conn, err := s.lis.Accept() + if err != nil { + return fmt.Errorf("accept uffd connection: %w", err) + } + + unixConn := conn.(*net.UnixConn) + defer unixConn.Close() + + // Read the memory region mappings (JSON) and the UFFD fd (SCM_RIGHTS). + regionBuf := make([]byte, regionMappingsSize) + uffdBuf := make([]byte, syscall.CmsgSpace(fdSize)) + + nRegion, nFd, _, _, err := unixConn.ReadMsgUnix(regionBuf, uffdBuf) + if err != nil { + return fmt.Errorf("read uffd message: %w", err) + } + + var regions []Region + if err := json.Unmarshal(regionBuf[:nRegion], ®ions); err != nil { + return fmt.Errorf("parse memory regions: %w", err) + } + + controlMsgs, err := syscall.ParseSocketControlMessage(uffdBuf[:nFd]) + if err != nil { + return fmt.Errorf("parse control messages: %w", err) + } + if len(controlMsgs) != 1 { + return fmt.Errorf("expected 1 control message, got %d", len(controlMsgs)) + } + + fds, err := syscall.ParseUnixRights(&controlMsgs[0]) + if err != nil { + return fmt.Errorf("parse unix rights: %w", err) + } + if len(fds) != 1 { + return fmt.Errorf("expected 1 fd, got %d", len(fds)) + } + + uffdFd := fd(fds[0]) + defer uffdFd.close() + + mapping := NewMapping(regions) + + slog.Info("uffd handler connected", + "regions", len(regions), + "fd", int(uffdFd), + ) + + // Signal readiness. + s.readyOnce.Do(func() { close(s.readyCh) }) + + // Run the poll loop. + return s.serve(ctx, uffdFd, mapping) +} + +// serve is the main poll loop. It polls the UFFD fd for page fault events +// and the exit pipe for shutdown signals. +func (s *Server) serve(ctx context.Context, uffdFd fd, mapping *Mapping) error { + pollFds := []unix.PollFd{ + {Fd: int32(uffdFd), Events: unix.POLLIN}, + {Fd: int32(s.exitR.Fd()), Events: unix.POLLIN}, + } + + var wg sync.WaitGroup + sem := make(chan struct{}, maxConcurrentFaults) + + // Always wait for in-flight goroutines before returning, so the caller + // can safely close the uffd fd after serve returns. + defer wg.Wait() + + for { + if _, err := unix.Poll(pollFds, -1); err != nil { + if err == unix.EINTR || err == unix.EAGAIN { + continue + } + return fmt.Errorf("poll: %w", err) + } + + // Check exit signal. + if pollFds[1].Revents&unix.POLLIN != 0 { + return nil + } + + if pollFds[0].Revents&unix.POLLIN == 0 { + continue + } + + // Read the uffd_msg. The fd is O_NONBLOCK (set by Firecracker), + // so EAGAIN is expected — just go back to poll. + buf := make([]byte, unsafe.Sizeof(uffdMsg{})) + n, err := readUffdMsg(uffdFd, buf) + if err == syscall.EAGAIN { + continue + } + if err != nil { + return fmt.Errorf("read uffd msg: %w", err) + } + if n == 0 { + continue + } + + msg := *(*uffdMsg)(unsafe.Pointer(&buf[0])) + if getMsgEvent(&msg) != UFFD_EVENT_PAGEFAULT { + return fmt.Errorf("unexpected uffd event type: %d", getMsgEvent(&msg)) + } + + arg := getMsgArg(&msg) + pf := *(*uffdPagefault)(unsafe.Pointer(&arg[0])) + addr := getPagefaultAddress(&pf) + + offset, pagesize, err := mapping.GetOffset(addr) + if err != nil { + return fmt.Errorf("resolve address %#x: %w", addr, err) + } + + sem <- struct{}{} + wg.Add(1) + go func() { + defer wg.Done() + defer func() { <-sem }() + + if err := s.faultPage(ctx, uffdFd, addr, offset, pagesize); err != nil { + slog.Error("uffd fault page error", + "addr", fmt.Sprintf("%#x", addr), + "offset", offset, + "error", err, + ) + } + }() + } +} + +// readUffdMsg reads a single uffd_msg, retrying on EINTR. +// Returns (n, EAGAIN) if the non-blocking read has nothing available. +func readUffdMsg(uffdFd fd, buf []byte) (int, error) { + for { + n, err := syscall.Read(int(uffdFd), buf) + if err == syscall.EINTR { + continue + } + return n, err + } +} + +// faultPage fetches a page from the memory source and copies it into +// guest memory via UFFDIO_COPY. +func (s *Server) faultPage(ctx context.Context, uffdFd fd, addr uintptr, offset int64, pagesize uintptr) error { + data, err := s.source.ReadPage(ctx, offset, int64(pagesize)) + if err != nil { + return fmt.Errorf("read page at offset %d: %w", offset, err) + } + + // Mode 0: no write-protect. Standard Firecracker does not register + // UFFD ranges with WP support, so UFFDIO_COPY_MODE_WP would fail. + if err := uffdFd.copy(addr, pagesize, data, 0); err != nil { + if errors.Is(err, unix.EEXIST) { + // Page already mapped (race with prefetch or concurrent fault). + return nil + } + return fmt.Errorf("uffdio_copy: %w", err) + } + + return nil +} + +// DiffFileSource serves pages from a snapshot's compact diff file using +// the header's block mapping to resolve offsets. +type DiffFileSource struct { + header *snapshot.Header + // diffs maps build ID → open file handle for each generation's diff file. + diffs map[string]*os.File +} + +// NewDiffFileSource creates a memory source backed by snapshot diff files. +// diffs maps build ID string to the file path of each generation's diff file. +func NewDiffFileSource(header *snapshot.Header, diffPaths map[string]string) (*DiffFileSource, error) { + diffs := make(map[string]*os.File, len(diffPaths)) + for id, path := range diffPaths { + f, err := os.Open(path) + if err != nil { + // Close already opened files. + for _, opened := range diffs { + opened.Close() + } + return nil, fmt.Errorf("open diff file %s: %w", path, err) + } + diffs[id] = f + } + return &DiffFileSource{header: header, diffs: diffs}, nil +} + +// ReadPage resolves a memory offset through the header mapping and reads +// the corresponding page from the correct generation's diff file. +func (s *DiffFileSource) ReadPage(ctx context.Context, offset int64, size int64) ([]byte, error) { + mappedOffset, _, buildID, err := s.header.GetShiftedMapping(ctx, offset) + if err != nil { + return nil, fmt.Errorf("resolve offset %d: %w", offset, err) + } + + // uuid.Nil means zero-fill (empty page). + var nilUUID [16]byte + if *buildID == nilUUID { + return make([]byte, size), nil + } + + f, ok := s.diffs[buildID.String()] + if !ok { + return nil, fmt.Errorf("no diff file for build %s", buildID) + } + + buf := make([]byte, size) + n, err := f.ReadAt(buf, mappedOffset) + if err != nil && int64(n) < size { + return nil, fmt.Errorf("read diff at offset %d: %w", mappedOffset, err) + } + + return buf, nil +} + +// Close closes all open diff file handles. +func (s *DiffFileSource) Close() error { + var errs []error + for _, f := range s.diffs { + if err := f.Close(); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} diff --git a/internal/validate/name.go b/internal/validate/name.go new file mode 100644 index 0000000..2051d87 --- /dev/null +++ b/internal/validate/name.go @@ -0,0 +1,24 @@ +package validate + +import ( + "fmt" + "regexp" +) + +// nameRe matches safe path component names: alphanumeric start, then +// alphanumeric, dash, underscore, or dot. Max 64 characters. +var nameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`) + +// SafeName checks that name is safe for use as a single filesystem path +// component. It rejects empty strings, path separators, ".." sequences, +// leading dots, and anything outside the alphanumeric+dash+underscore+dot +// allowlist. +func SafeName(name string) error { + if name == "" { + return fmt.Errorf("name must not be empty") + } + if !nameRe.MatchString(name) { + return fmt.Errorf("name %q contains invalid characters or is too long (max 64, must match %s)", name, nameRe.String()) + } + return nil +} diff --git a/internal/validate/name_test.go b/internal/validate/name_test.go new file mode 100644 index 0000000..4b7769e --- /dev/null +++ b/internal/validate/name_test.go @@ -0,0 +1,41 @@ +package validate + +import "testing" + +func TestSafeName(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + {"simple", "minimal", false}, + {"with-dash", "template-abc123", false}, + {"with-dot", "my-snapshot.v2", false}, + {"sandbox-id", "sb-12345678", false}, + {"single-char", "a", false}, + {"numbers", "123", false}, + {"max-length", "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz01", false}, + + {"empty", "", true}, + {"dot-dot", "..", true}, + {"single-dot", ".", true}, + {"leading-dot", ".hidden", true}, + {"slash", "foo/bar", true}, + {"backslash", "foo\\bar", true}, + {"traversal", "../etc/passwd", true}, + {"embedded-traversal", "foo/../bar", true}, + {"space", "foo bar", true}, + {"too-long", "abcdefghijklmnopqrstuvwxyz012345678901abcdefghijklmnopqrstuvwxyz01", true}, + {"absolute", "/etc/passwd", true}, + {"tilde", "~root", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := SafeName(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("SafeName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + } + }) + } +} diff --git a/internal/vm/config.go b/internal/vm/config.go index e69de29..35bc293 100644 --- a/internal/vm/config.go +++ b/internal/vm/config.go @@ -0,0 +1,122 @@ +package vm + +import "fmt" + +// VMConfig holds the configuration for creating a Firecracker microVM. +type VMConfig struct { + // SandboxID is the unique identifier for this sandbox (e.g., "sb-a1b2c3d4"). + SandboxID string + + // KernelPath is the path to the uncompressed Linux kernel (vmlinux). + KernelPath string + + // RootfsPath is the path to the rootfs block device for this sandbox. + // Typically a dm-snapshot device (e.g., /dev/mapper/wrenn-sb-a1b2c3d4). + RootfsPath string + + // VCPUs is the number of virtual CPUs to allocate (default: 1). + VCPUs int + + // MemoryMB is the amount of RAM in megabytes (default: 512). + MemoryMB int + + // NetworkNamespace is the name of the network namespace to launch + // Firecracker inside (e.g., "ns-1"). The namespace must already exist + // with a TAP device configured. + NetworkNamespace string + + // TapDevice is the name of the TAP device inside the network namespace + // that Firecracker will attach to (e.g., "tap0"). + TapDevice string + + // TapMAC is the MAC address for the TAP device. + TapMAC string + + // GuestIP is the IP address assigned to the guest VM (e.g., "169.254.0.21"). + GuestIP string + + // GatewayIP is the gateway IP (the TAP device's IP, e.g., "169.254.0.22"). + GatewayIP string + + // NetMask is the subnet mask for the guest network (e.g., "255.255.255.252"). + NetMask string + + // FirecrackerBin is the path to the firecracker binary. + FirecrackerBin string + + // SocketPath is the path for the Firecracker API Unix socket. + SocketPath string + + // SandboxDir is the tmpfs mount point for per-sandbox files inside the + // mount namespace (e.g., "/fc-vm"). + SandboxDir string + + // InitPath is the path to the init process inside the guest. + // Defaults to "/sbin/init" if empty. + InitPath string +} + +func (c *VMConfig) applyDefaults() { + if c.VCPUs == 0 { + c.VCPUs = 1 + } + if c.MemoryMB == 0 { + c.MemoryMB = 512 + } + if c.FirecrackerBin == "" { + c.FirecrackerBin = "/usr/local/bin/firecracker" + } + if c.SocketPath == "" { + c.SocketPath = fmt.Sprintf("/tmp/fc-%s.sock", c.SandboxID) + } + if c.SandboxDir == "" { + c.SandboxDir = "/tmp/fc-vm" + } + if c.TapDevice == "" { + c.TapDevice = "tap0" + } + if c.TapMAC == "" { + c.TapMAC = "02:FC:00:00:00:05" + } + if c.InitPath == "" { + c.InitPath = "/usr/local/bin/wrenn-init" + } +} + +// kernelArgs builds the kernel command line for the VM. +func (c *VMConfig) kernelArgs() string { + // ip= format: :::::: + ipArg := fmt.Sprintf("ip=%s::%s:%s:sandbox:eth0:off", + c.GuestIP, c.GatewayIP, c.NetMask, + ) + + return fmt.Sprintf( + "console=ttyS0 reboot=k panic=1 pci=off quiet loglevel=1 init=%s %s", + c.InitPath, ipArg, + ) +} + +func (c *VMConfig) validate() error { + if c.SandboxID == "" { + return fmt.Errorf("SandboxID is required") + } + if c.KernelPath == "" { + return fmt.Errorf("KernelPath is required") + } + if c.RootfsPath == "" { + return fmt.Errorf("RootfsPath is required") + } + if c.NetworkNamespace == "" { + return fmt.Errorf("NetworkNamespace is required") + } + if c.GuestIP == "" { + return fmt.Errorf("GuestIP is required") + } + if c.GatewayIP == "" { + return fmt.Errorf("GatewayIP is required") + } + if c.NetMask == "" { + return fmt.Errorf("NetMask is required") + } + return nil +} diff --git a/internal/vm/fc.go b/internal/vm/fc.go new file mode 100644 index 0000000..b5af5db --- /dev/null +++ b/internal/vm/fc.go @@ -0,0 +1,147 @@ +package vm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "time" +) + +// fcClient talks to the Firecracker HTTP API over a Unix socket. +type fcClient struct { + http *http.Client + socketPath string +} + +func newFCClient(socketPath string) *fcClient { + return &fcClient{ + socketPath: socketPath, + http: &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "unix", socketPath) + }, + }, + Timeout: 10 * time.Second, + }, + } +} + +func (c *fcClient) do(ctx context.Context, method, path string, body any) error { + var bodyReader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal request body: %w", err) + } + bodyReader = bytes.NewReader(data) + } + + // The host in the URL is ignored for Unix sockets; we use "localhost" by convention. + req, err := http.NewRequestWithContext(ctx, method, "http://localhost"+path, bodyReader) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("%s %s: %w", method, path, err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("%s %s: status %d: %s", method, path, resp.StatusCode, string(respBody)) + } + + return nil +} + +// setBootSource configures the kernel and boot args. +func (c *fcClient) setBootSource(ctx context.Context, kernelPath, bootArgs string) error { + return c.do(ctx, http.MethodPut, "/boot-source", map[string]string{ + "kernel_image_path": kernelPath, + "boot_args": bootArgs, + }) +} + +// setRootfsDrive configures the root filesystem drive. +func (c *fcClient) setRootfsDrive(ctx context.Context, driveID, path string, readOnly bool) error { + return c.do(ctx, http.MethodPut, "/drives/"+driveID, map[string]any{ + "drive_id": driveID, + "path_on_host": path, + "is_root_device": true, + "is_read_only": readOnly, + }) +} + +// setNetworkInterface configures a network interface attached to a TAP device. +func (c *fcClient) setNetworkInterface(ctx context.Context, ifaceID, tapName, macAddr string) error { + return c.do(ctx, http.MethodPut, "/network-interfaces/"+ifaceID, map[string]any{ + "iface_id": ifaceID, + "host_dev_name": tapName, + "guest_mac": macAddr, + }) +} + +// setMachineConfig configures vCPUs, memory, and other machine settings. +func (c *fcClient) setMachineConfig(ctx context.Context, vcpus, memMB int) error { + return c.do(ctx, http.MethodPut, "/machine-config", map[string]any{ + "vcpu_count": vcpus, + "mem_size_mib": memMB, + "smt": false, + }) +} + +// startVM issues the InstanceStart action. +func (c *fcClient) startVM(ctx context.Context) error { + return c.do(ctx, http.MethodPut, "/actions", map[string]string{ + "action_type": "InstanceStart", + }) +} + +// pauseVM pauses the microVM. +func (c *fcClient) pauseVM(ctx context.Context) error { + return c.do(ctx, http.MethodPatch, "/vm", map[string]string{ + "state": "Paused", + }) +} + +// resumeVM resumes a paused microVM. +func (c *fcClient) resumeVM(ctx context.Context) error { + return c.do(ctx, http.MethodPatch, "/vm", map[string]string{ + "state": "Resumed", + }) +} + +// createSnapshot creates a VM snapshot. +// snapshotType is "Full" (all memory) or "Diff" (only dirty pages since last resume). +func (c *fcClient) createSnapshot(ctx context.Context, snapPath, memPath, snapshotType string) error { + return c.do(ctx, http.MethodPut, "/snapshot/create", map[string]any{ + "snapshot_type": snapshotType, + "snapshot_path": snapPath, + "mem_file_path": memPath, + }) +} + +// loadSnapshotWithUffd loads a VM snapshot using a UFFD socket for +// lazy memory loading. Firecracker will connect to the socket and +// send the uffd fd + memory region mappings. +func (c *fcClient) loadSnapshotWithUffd(ctx context.Context, snapPath, uffdSocketPath string) error { + return c.do(ctx, http.MethodPut, "/snapshot/load", map[string]any{ + "snapshot_path": snapPath, + "resume_vm": false, + "mem_backend": map[string]any{ + "backend_type": "Uffd", + "backend_path": uffdSocketPath, + }, + }) +} diff --git a/internal/vm/jailer.go b/internal/vm/jailer.go index e69de29..e528540 100644 --- a/internal/vm/jailer.go +++ b/internal/vm/jailer.go @@ -0,0 +1,128 @@ +package vm + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/exec" + "syscall" + "time" +) + +// process represents a running Firecracker process with mount and network +// namespace isolation. +type process struct { + cmd *exec.Cmd + cancel context.CancelFunc + + exitCh chan struct{} + exitErr error +} + +// startProcess launches the Firecracker binary inside an isolated mount namespace +// and the specified network namespace. The launch sequence: +// +// 1. unshare -m: creates a private mount namespace +// 2. mount --make-rprivate /: prevents mount propagation to host +// 3. mount tmpfs at SandboxDir: ephemeral workspace for this VM +// 4. symlink kernel and rootfs into SandboxDir +// 5. ip netns exec : enters the network namespace where TAP is configured +// 6. exec firecracker with the API socket path +func startProcess(ctx context.Context, cfg *VMConfig) (*process, error) { + // Use a background context for the long-lived Firecracker process. + // The request context (ctx) is only used for the startup phase — we must + // not tie the VM's lifetime to the HTTP request that created it. + execCtx, cancel := context.WithCancel(context.Background()) + + script := buildStartScript(cfg) + + cmd := exec.CommandContext(execCtx, "unshare", "-m", "--", "bash", "-c", script) + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, // new session so signals don't propagate from parent + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("start firecracker process: %w", err) + } + + p := &process{ + cmd: cmd, + cancel: cancel, + exitCh: make(chan struct{}), + } + + go func() { + p.exitErr = cmd.Wait() + close(p.exitCh) + }() + + slog.Info("firecracker process started", + "pid", cmd.Process.Pid, + "sandbox", cfg.SandboxID, + ) + + return p, nil +} + +// buildStartScript generates the bash script that sets up the mount namespace, +// symlinks kernel/rootfs, and execs Firecracker inside the network namespace. +func buildStartScript(cfg *VMConfig) string { + return fmt.Sprintf(` +set -euo pipefail + +# Prevent mount propagation to the host +mount --make-rprivate / + +# Create ephemeral tmpfs workspace +mkdir -p %[1]s +mount -t tmpfs tmpfs %[1]s + +# Symlink kernel and rootfs into the workspace +ln -s %[2]s %[1]s/vmlinux +ln -s %[3]s %[1]s/rootfs.ext4 + +# Launch Firecracker inside the network namespace +exec ip netns exec %[4]s %[5]s --api-sock %[6]s +`, + cfg.SandboxDir, // 1 + cfg.KernelPath, // 2 + cfg.RootfsPath, // 3 + cfg.NetworkNamespace, // 4 + cfg.FirecrackerBin, // 5 + cfg.SocketPath, // 6 + ) +} + +// stop sends SIGTERM and waits for the process to exit. If it doesn't exit +// within 10 seconds, SIGKILL is sent. +func (p *process) stop() error { + if p.cmd.Process == nil { + return nil + } + + // Send SIGTERM to the process group (negative PID). + if err := syscall.Kill(-p.cmd.Process.Pid, syscall.SIGTERM); err != nil { + slog.Debug("sigterm failed, process may have exited", "error", err) + } + + select { + case <-p.exitCh: + return nil + case <-time.After(10 * time.Second): + slog.Warn("firecracker did not exit after SIGTERM, sending SIGKILL") + if err := syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL); err != nil { + slog.Debug("sigkill failed", "error", err) + } + <-p.exitCh + return nil + } +} + +// exited returns a channel that is closed when the process exits. +func (p *process) exited() <-chan struct{} { + return p.exitCh +} diff --git a/internal/vm/manager.go b/internal/vm/manager.go index e69de29..b68bde1 100644 --- a/internal/vm/manager.go +++ b/internal/vm/manager.go @@ -0,0 +1,280 @@ +package vm + +import ( + "context" + "fmt" + "log/slog" + "os" + "time" +) + +// VM represents a running Firecracker microVM. +type VM struct { + Config VMConfig + process *process + client *fcClient +} + +// Manager handles the lifecycle of Firecracker microVMs. +type Manager struct { + // vms tracks running VMs by sandbox ID. + vms map[string]*VM +} + +// NewManager creates a new VM manager. +func NewManager() *Manager { + return &Manager{ + vms: make(map[string]*VM), + } +} + +// Create boots a new Firecracker microVM with the given configuration. +// The network namespace and TAP device must already be set up. +func (m *Manager) Create(ctx context.Context, cfg VMConfig) (*VM, error) { + cfg.applyDefaults() + if err := cfg.validate(); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + // Clean up any leftover socket from a previous run. + os.Remove(cfg.SocketPath) + + slog.Info("creating VM", + "sandbox", cfg.SandboxID, + "vcpus", cfg.VCPUs, + "memory_mb", cfg.MemoryMB, + ) + + // Step 1: Launch the Firecracker process. + proc, err := startProcess(ctx, &cfg) + if err != nil { + return nil, fmt.Errorf("start process: %w", err) + } + + // Step 2: Wait for the API socket to appear. + if err := waitForSocket(ctx, cfg.SocketPath, proc); err != nil { + _ = proc.stop() + return nil, fmt.Errorf("wait for socket: %w", err) + } + + // Step 3: Configure the VM via the Firecracker API. + client := newFCClient(cfg.SocketPath) + + if err := configureVM(ctx, client, &cfg); err != nil { + _ = proc.stop() + return nil, fmt.Errorf("configure VM: %w", err) + } + + // Step 4: Start the VM. + if err := client.startVM(ctx); err != nil { + _ = proc.stop() + return nil, fmt.Errorf("start VM: %w", err) + } + + vm := &VM{ + Config: cfg, + process: proc, + client: client, + } + + m.vms[cfg.SandboxID] = vm + + slog.Info("VM started successfully", "sandbox", cfg.SandboxID) + + return vm, nil +} + +// configureVM sends the configuration to Firecracker via its HTTP API. +func configureVM(ctx context.Context, client *fcClient, cfg *VMConfig) error { + // Boot source (kernel + args) + if err := client.setBootSource(ctx, cfg.KernelPath, cfg.kernelArgs()); err != nil { + return fmt.Errorf("set boot source: %w", err) + } + + // Root drive — use the symlink path inside the mount namespace so that + // snapshots record a stable path that works on restore. + rootfsSymlink := cfg.SandboxDir + "/rootfs.ext4" + if err := client.setRootfsDrive(ctx, "rootfs", rootfsSymlink, false); err != nil { + return fmt.Errorf("set rootfs drive: %w", err) + } + + // Network interface + if err := client.setNetworkInterface(ctx, "eth0", cfg.TapDevice, cfg.TapMAC); err != nil { + return fmt.Errorf("set network interface: %w", err) + } + + // Machine config (vCPUs + memory) + if err := client.setMachineConfig(ctx, cfg.VCPUs, cfg.MemoryMB); err != nil { + return fmt.Errorf("set machine config: %w", err) + } + + return nil +} + +// Pause pauses a running VM. +func (m *Manager) Pause(ctx context.Context, sandboxID string) error { + vm, ok := m.vms[sandboxID] + if !ok { + return fmt.Errorf("VM not found: %s", sandboxID) + } + + if err := vm.client.pauseVM(ctx); err != nil { + return fmt.Errorf("pause VM: %w", err) + } + + slog.Info("VM paused", "sandbox", sandboxID) + return nil +} + +// Resume resumes a paused VM. +func (m *Manager) Resume(ctx context.Context, sandboxID string) error { + vm, ok := m.vms[sandboxID] + if !ok { + return fmt.Errorf("VM not found: %s", sandboxID) + } + + if err := vm.client.resumeVM(ctx); err != nil { + return fmt.Errorf("resume VM: %w", err) + } + + slog.Info("VM resumed", "sandbox", sandboxID) + return nil +} + +// Destroy stops and cleans up a VM. +func (m *Manager) Destroy(ctx context.Context, sandboxID string) error { + vm, ok := m.vms[sandboxID] + if !ok { + return fmt.Errorf("VM not found: %s", sandboxID) + } + + slog.Info("destroying VM", "sandbox", sandboxID) + + // Stop the Firecracker process. + if err := vm.process.stop(); err != nil { + slog.Warn("error stopping process", "sandbox", sandboxID, "error", err) + } + + // Clean up the API socket. + os.Remove(vm.Config.SocketPath) + + delete(m.vms, sandboxID) + + slog.Info("VM destroyed", "sandbox", sandboxID) + return nil +} + +// Snapshot creates a VM snapshot. The VM must already be paused. +// snapshotType is "Full" (all memory) or "Diff" (only dirty pages since last resume). +func (m *Manager) Snapshot(ctx context.Context, sandboxID, snapPath, memPath, snapshotType string) error { + vm, ok := m.vms[sandboxID] + if !ok { + return fmt.Errorf("VM not found: %s", sandboxID) + } + + if err := vm.client.createSnapshot(ctx, snapPath, memPath, snapshotType); err != nil { + return fmt.Errorf("create snapshot: %w", err) + } + + slog.Info("VM snapshot created", "sandbox", sandboxID, "snap_path", snapPath, "type", snapshotType) + return nil +} + +// CreateFromSnapshot boots a new Firecracker VM by loading a snapshot +// using UFFD for lazy memory loading. The network namespace and TAP +// device must already be set up. +// +// No boot resources (kernel, drives, machine config) are configured — +// the snapshot carries all that state. The rootfs path recorded in the +// snapshot is resolved via a stable symlink at SandboxDir/rootfs.ext4 +// inside the mount namespace (created by the start script in jailer.go). +// +// The sequence is: +// 1. Start FC process in mount+network namespace (creates tmpfs + rootfs symlink) +// 2. Wait for API socket +// 3. Load snapshot with UFFD backend +// 4. Resume VM execution +func (m *Manager) CreateFromSnapshot(ctx context.Context, cfg VMConfig, snapPath, uffdSocketPath string) (*VM, error) { + cfg.applyDefaults() + if err := cfg.validate(); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + os.Remove(cfg.SocketPath) + + slog.Info("restoring VM from snapshot", + "sandbox", cfg.SandboxID, + "snap_path", snapPath, + ) + + // Step 1: Launch the Firecracker process. + // The start script creates a tmpfs at SandboxDir and symlinks + // rootfs.ext4 → cfg.RootfsPath, so the snapshot's recorded rootfs + // path (/fc-vm/rootfs.ext4) resolves to the new clone. + proc, err := startProcess(ctx, &cfg) + if err != nil { + return nil, fmt.Errorf("start process: %w", err) + } + + // Step 2: Wait for the API socket. + if err := waitForSocket(ctx, cfg.SocketPath, proc); err != nil { + _ = proc.stop() + return nil, fmt.Errorf("wait for socket: %w", err) + } + + client := newFCClient(cfg.SocketPath) + + // Step 3: Load the snapshot with UFFD backend. + // No boot resources are configured — the snapshot carries kernel, + // drive, network, and machine config state. + if err := client.loadSnapshotWithUffd(ctx, snapPath, uffdSocketPath); err != nil { + _ = proc.stop() + return nil, fmt.Errorf("load snapshot: %w", err) + } + + // Step 4: Resume the VM. + if err := client.resumeVM(ctx); err != nil { + _ = proc.stop() + return nil, fmt.Errorf("resume VM: %w", err) + } + + vm := &VM{ + Config: cfg, + process: proc, + client: client, + } + + m.vms[cfg.SandboxID] = vm + + slog.Info("VM restored from snapshot", "sandbox", cfg.SandboxID) + return vm, nil +} + +// Get returns a running VM by sandbox ID. +func (m *Manager) Get(sandboxID string) (*VM, bool) { + vm, ok := m.vms[sandboxID] + return vm, ok +} + +// waitForSocket polls for the Firecracker API socket to appear on disk. +func waitForSocket(ctx context.Context, socketPath string, proc *process) error { + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + + timeout := time.After(5 * time.Second) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-proc.exited(): + return fmt.Errorf("firecracker process exited before socket was ready") + case <-timeout: + return fmt.Errorf("timed out waiting for API socket at %s", socketPath) + case <-ticker.C: + if _, err := os.Stat(socketPath); err == nil { + return nil + } + } + } +} diff --git a/proto/envd/buf.gen.yaml b/proto/envd/buf.gen.yaml new file mode 100644 index 0000000..89f2ab8 --- /dev/null +++ b/proto/envd/buf.gen.yaml @@ -0,0 +1,13 @@ +version: v2 +plugins: + - protoc_builtin: go + out: gen + opt: paths=source_relative + - local: protoc-gen-connect-go + out: gen + opt: paths=source_relative +managed: + enabled: true + override: + - file_option: go_package_prefix + value: git.omukk.dev/wrenn/sandbox/proto/envd/gen diff --git a/proto/envd/buf.yaml b/proto/envd/buf.yaml new file mode 100644 index 0000000..b869981 --- /dev/null +++ b/proto/envd/buf.yaml @@ -0,0 +1,3 @@ +version: v2 +modules: + - path: . diff --git a/proto/envd/filesystem.proto b/proto/envd/filesystem.proto index e69de29..44383c6 100644 --- a/proto/envd/filesystem.proto +++ b/proto/envd/filesystem.proto @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package filesystem; + +import "google/protobuf/timestamp.proto"; + +service Filesystem { + rpc Stat(StatRequest) returns (StatResponse); + rpc MakeDir(MakeDirRequest) returns (MakeDirResponse); + rpc Move(MoveRequest) returns (MoveResponse); + rpc ListDir(ListDirRequest) returns (ListDirResponse); + rpc Remove(RemoveRequest) returns (RemoveResponse); + + rpc WatchDir(WatchDirRequest) returns (stream WatchDirResponse); + + // Non-streaming versions of WatchDir + rpc CreateWatcher(CreateWatcherRequest) returns (CreateWatcherResponse); + rpc GetWatcherEvents(GetWatcherEventsRequest) returns (GetWatcherEventsResponse); + rpc RemoveWatcher(RemoveWatcherRequest) returns (RemoveWatcherResponse); +} + +message MoveRequest { + string source = 1; + string destination = 2; +} + +message MoveResponse { + EntryInfo entry = 1; +} + +message MakeDirRequest { + string path = 1; +} + +message MakeDirResponse { + EntryInfo entry = 1; +} + +message RemoveRequest { + string path = 1; +} + +message RemoveResponse {} + +message StatRequest { + string path = 1; +} + +message StatResponse { + EntryInfo entry = 1; +} + +message EntryInfo { + string name = 1; + FileType type = 2; + string path = 3; + int64 size = 4; + uint32 mode = 5; + string permissions = 6; + string owner = 7; + string group = 8; + google.protobuf.Timestamp modified_time = 9; + // If the entry is a symlink, this field contains the target of the symlink. + optional string symlink_target = 10; +} + +enum FileType { + FILE_TYPE_UNSPECIFIED = 0; + FILE_TYPE_FILE = 1; + FILE_TYPE_DIRECTORY = 2; + FILE_TYPE_SYMLINK = 3; +} + +message ListDirRequest { + string path = 1; + uint32 depth = 2; +} + +message ListDirResponse { + repeated EntryInfo entries = 1; +} + +message WatchDirRequest { + string path = 1; + bool recursive = 2; +} + +message FilesystemEvent { + string name = 1; + EventType type = 2; +} + +message WatchDirResponse { + oneof event { + StartEvent start = 1; + FilesystemEvent filesystem = 2; + KeepAlive keepalive = 3; + } + + message StartEvent {} + + message KeepAlive {} +} + +message CreateWatcherRequest { + string path = 1; + bool recursive = 2; +} + +message CreateWatcherResponse { + string watcher_id = 1; +} + +message GetWatcherEventsRequest { + string watcher_id = 1; +} + +message GetWatcherEventsResponse { + repeated FilesystemEvent events = 1; +} + +message RemoveWatcherRequest { + string watcher_id = 1; +} + +message RemoveWatcherResponse {} + +enum EventType { + EVENT_TYPE_UNSPECIFIED = 0; + EVENT_TYPE_CREATE = 1; + EVENT_TYPE_WRITE = 2; + EVENT_TYPE_REMOVE = 3; + EVENT_TYPE_RENAME = 4; + EVENT_TYPE_CHMOD = 5; +} diff --git a/proto/envd/gen/filesystem.pb.go b/proto/envd/gen/filesystem.pb.go new file mode 100644 index 0000000..ad14655 --- /dev/null +++ b/proto/envd/gen/filesystem.pb.go @@ -0,0 +1,1446 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: filesystem.proto + +package gen + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type FileType int32 + +const ( + FileType_FILE_TYPE_UNSPECIFIED FileType = 0 + FileType_FILE_TYPE_FILE FileType = 1 + FileType_FILE_TYPE_DIRECTORY FileType = 2 + FileType_FILE_TYPE_SYMLINK FileType = 3 +) + +// Enum value maps for FileType. +var ( + FileType_name = map[int32]string{ + 0: "FILE_TYPE_UNSPECIFIED", + 1: "FILE_TYPE_FILE", + 2: "FILE_TYPE_DIRECTORY", + 3: "FILE_TYPE_SYMLINK", + } + FileType_value = map[string]int32{ + "FILE_TYPE_UNSPECIFIED": 0, + "FILE_TYPE_FILE": 1, + "FILE_TYPE_DIRECTORY": 2, + "FILE_TYPE_SYMLINK": 3, + } +) + +func (x FileType) Enum() *FileType { + p := new(FileType) + *p = x + return p +} + +func (x FileType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (FileType) Descriptor() protoreflect.EnumDescriptor { + return file_filesystem_proto_enumTypes[0].Descriptor() +} + +func (FileType) Type() protoreflect.EnumType { + return &file_filesystem_proto_enumTypes[0] +} + +func (x FileType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use FileType.Descriptor instead. +func (FileType) EnumDescriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{0} +} + +type EventType int32 + +const ( + EventType_EVENT_TYPE_UNSPECIFIED EventType = 0 + EventType_EVENT_TYPE_CREATE EventType = 1 + EventType_EVENT_TYPE_WRITE EventType = 2 + EventType_EVENT_TYPE_REMOVE EventType = 3 + EventType_EVENT_TYPE_RENAME EventType = 4 + EventType_EVENT_TYPE_CHMOD EventType = 5 +) + +// Enum value maps for EventType. +var ( + EventType_name = map[int32]string{ + 0: "EVENT_TYPE_UNSPECIFIED", + 1: "EVENT_TYPE_CREATE", + 2: "EVENT_TYPE_WRITE", + 3: "EVENT_TYPE_REMOVE", + 4: "EVENT_TYPE_RENAME", + 5: "EVENT_TYPE_CHMOD", + } + EventType_value = map[string]int32{ + "EVENT_TYPE_UNSPECIFIED": 0, + "EVENT_TYPE_CREATE": 1, + "EVENT_TYPE_WRITE": 2, + "EVENT_TYPE_REMOVE": 3, + "EVENT_TYPE_RENAME": 4, + "EVENT_TYPE_CHMOD": 5, + } +) + +func (x EventType) Enum() *EventType { + p := new(EventType) + *p = x + return p +} + +func (x EventType) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (EventType) Descriptor() protoreflect.EnumDescriptor { + return file_filesystem_proto_enumTypes[1].Descriptor() +} + +func (EventType) Type() protoreflect.EnumType { + return &file_filesystem_proto_enumTypes[1] +} + +func (x EventType) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use EventType.Descriptor instead. +func (EventType) EnumDescriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{1} +} + +type MoveRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Source string `protobuf:"bytes,1,opt,name=source,proto3" json:"source,omitempty"` + Destination string `protobuf:"bytes,2,opt,name=destination,proto3" json:"destination,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MoveRequest) Reset() { + *x = MoveRequest{} + mi := &file_filesystem_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MoveRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MoveRequest) ProtoMessage() {} + +func (x *MoveRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MoveRequest.ProtoReflect.Descriptor instead. +func (*MoveRequest) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{0} +} + +func (x *MoveRequest) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *MoveRequest) GetDestination() string { + if x != nil { + return x.Destination + } + return "" +} + +type MoveResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *EntryInfo `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MoveResponse) Reset() { + *x = MoveResponse{} + mi := &file_filesystem_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MoveResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MoveResponse) ProtoMessage() {} + +func (x *MoveResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MoveResponse.ProtoReflect.Descriptor instead. +func (*MoveResponse) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{1} +} + +func (x *MoveResponse) GetEntry() *EntryInfo { + if x != nil { + return x.Entry + } + return nil +} + +type MakeDirRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MakeDirRequest) Reset() { + *x = MakeDirRequest{} + mi := &file_filesystem_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MakeDirRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MakeDirRequest) ProtoMessage() {} + +func (x *MakeDirRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MakeDirRequest.ProtoReflect.Descriptor instead. +func (*MakeDirRequest) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{2} +} + +func (x *MakeDirRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type MakeDirResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *EntryInfo `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MakeDirResponse) Reset() { + *x = MakeDirResponse{} + mi := &file_filesystem_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MakeDirResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MakeDirResponse) ProtoMessage() {} + +func (x *MakeDirResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MakeDirResponse.ProtoReflect.Descriptor instead. +func (*MakeDirResponse) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{3} +} + +func (x *MakeDirResponse) GetEntry() *EntryInfo { + if x != nil { + return x.Entry + } + return nil +} + +type RemoveRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveRequest) Reset() { + *x = RemoveRequest{} + mi := &file_filesystem_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveRequest) ProtoMessage() {} + +func (x *RemoveRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveRequest.ProtoReflect.Descriptor instead. +func (*RemoveRequest) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{4} +} + +func (x *RemoveRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type RemoveResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveResponse) Reset() { + *x = RemoveResponse{} + mi := &file_filesystem_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveResponse) ProtoMessage() {} + +func (x *RemoveResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveResponse.ProtoReflect.Descriptor instead. +func (*RemoveResponse) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{5} +} + +type StatRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatRequest) Reset() { + *x = StatRequest{} + mi := &file_filesystem_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatRequest) ProtoMessage() {} + +func (x *StatRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatRequest.ProtoReflect.Descriptor instead. +func (*StatRequest) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{6} +} + +func (x *StatRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type StatResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entry *EntryInfo `protobuf:"bytes,1,opt,name=entry,proto3" json:"entry,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatResponse) Reset() { + *x = StatResponse{} + mi := &file_filesystem_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatResponse) ProtoMessage() {} + +func (x *StatResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatResponse.ProtoReflect.Descriptor instead. +func (*StatResponse) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{7} +} + +func (x *StatResponse) GetEntry() *EntryInfo { + if x != nil { + return x.Entry + } + return nil +} + +type EntryInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Type FileType `protobuf:"varint,2,opt,name=type,proto3,enum=filesystem.FileType" json:"type,omitempty"` + Path string `protobuf:"bytes,3,opt,name=path,proto3" json:"path,omitempty"` + Size int64 `protobuf:"varint,4,opt,name=size,proto3" json:"size,omitempty"` + Mode uint32 `protobuf:"varint,5,opt,name=mode,proto3" json:"mode,omitempty"` + Permissions string `protobuf:"bytes,6,opt,name=permissions,proto3" json:"permissions,omitempty"` + Owner string `protobuf:"bytes,7,opt,name=owner,proto3" json:"owner,omitempty"` + Group string `protobuf:"bytes,8,opt,name=group,proto3" json:"group,omitempty"` + ModifiedTime *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=modified_time,json=modifiedTime,proto3" json:"modified_time,omitempty"` + // If the entry is a symlink, this field contains the target of the symlink. + SymlinkTarget *string `protobuf:"bytes,10,opt,name=symlink_target,json=symlinkTarget,proto3,oneof" json:"symlink_target,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EntryInfo) Reset() { + *x = EntryInfo{} + mi := &file_filesystem_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EntryInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EntryInfo) ProtoMessage() {} + +func (x *EntryInfo) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EntryInfo.ProtoReflect.Descriptor instead. +func (*EntryInfo) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{8} +} + +func (x *EntryInfo) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *EntryInfo) GetType() FileType { + if x != nil { + return x.Type + } + return FileType_FILE_TYPE_UNSPECIFIED +} + +func (x *EntryInfo) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *EntryInfo) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +func (x *EntryInfo) GetMode() uint32 { + if x != nil { + return x.Mode + } + return 0 +} + +func (x *EntryInfo) GetPermissions() string { + if x != nil { + return x.Permissions + } + return "" +} + +func (x *EntryInfo) GetOwner() string { + if x != nil { + return x.Owner + } + return "" +} + +func (x *EntryInfo) GetGroup() string { + if x != nil { + return x.Group + } + return "" +} + +func (x *EntryInfo) GetModifiedTime() *timestamppb.Timestamp { + if x != nil { + return x.ModifiedTime + } + return nil +} + +func (x *EntryInfo) GetSymlinkTarget() string { + if x != nil && x.SymlinkTarget != nil { + return *x.SymlinkTarget + } + return "" +} + +type ListDirRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Depth uint32 `protobuf:"varint,2,opt,name=depth,proto3" json:"depth,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListDirRequest) Reset() { + *x = ListDirRequest{} + mi := &file_filesystem_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListDirRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListDirRequest) ProtoMessage() {} + +func (x *ListDirRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListDirRequest.ProtoReflect.Descriptor instead. +func (*ListDirRequest) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{9} +} + +func (x *ListDirRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *ListDirRequest) GetDepth() uint32 { + if x != nil { + return x.Depth + } + return 0 +} + +type ListDirResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Entries []*EntryInfo `protobuf:"bytes,1,rep,name=entries,proto3" json:"entries,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListDirResponse) Reset() { + *x = ListDirResponse{} + mi := &file_filesystem_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListDirResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListDirResponse) ProtoMessage() {} + +func (x *ListDirResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListDirResponse.ProtoReflect.Descriptor instead. +func (*ListDirResponse) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{10} +} + +func (x *ListDirResponse) GetEntries() []*EntryInfo { + if x != nil { + return x.Entries + } + return nil +} + +type WatchDirRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Recursive bool `protobuf:"varint,2,opt,name=recursive,proto3" json:"recursive,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchDirRequest) Reset() { + *x = WatchDirRequest{} + mi := &file_filesystem_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchDirRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchDirRequest) ProtoMessage() {} + +func (x *WatchDirRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchDirRequest.ProtoReflect.Descriptor instead. +func (*WatchDirRequest) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{11} +} + +func (x *WatchDirRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *WatchDirRequest) GetRecursive() bool { + if x != nil { + return x.Recursive + } + return false +} + +type FilesystemEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Type EventType `protobuf:"varint,2,opt,name=type,proto3,enum=filesystem.EventType" json:"type,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FilesystemEvent) Reset() { + *x = FilesystemEvent{} + mi := &file_filesystem_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FilesystemEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FilesystemEvent) ProtoMessage() {} + +func (x *FilesystemEvent) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FilesystemEvent.ProtoReflect.Descriptor instead. +func (*FilesystemEvent) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{12} +} + +func (x *FilesystemEvent) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *FilesystemEvent) GetType() EventType { + if x != nil { + return x.Type + } + return EventType_EVENT_TYPE_UNSPECIFIED +} + +type WatchDirResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *WatchDirResponse_Start + // *WatchDirResponse_Filesystem + // *WatchDirResponse_Keepalive + Event isWatchDirResponse_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchDirResponse) Reset() { + *x = WatchDirResponse{} + mi := &file_filesystem_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchDirResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchDirResponse) ProtoMessage() {} + +func (x *WatchDirResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchDirResponse.ProtoReflect.Descriptor instead. +func (*WatchDirResponse) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{13} +} + +func (x *WatchDirResponse) GetEvent() isWatchDirResponse_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *WatchDirResponse) GetStart() *WatchDirResponse_StartEvent { + if x != nil { + if x, ok := x.Event.(*WatchDirResponse_Start); ok { + return x.Start + } + } + return nil +} + +func (x *WatchDirResponse) GetFilesystem() *FilesystemEvent { + if x != nil { + if x, ok := x.Event.(*WatchDirResponse_Filesystem); ok { + return x.Filesystem + } + } + return nil +} + +func (x *WatchDirResponse) GetKeepalive() *WatchDirResponse_KeepAlive { + if x != nil { + if x, ok := x.Event.(*WatchDirResponse_Keepalive); ok { + return x.Keepalive + } + } + return nil +} + +type isWatchDirResponse_Event interface { + isWatchDirResponse_Event() +} + +type WatchDirResponse_Start struct { + Start *WatchDirResponse_StartEvent `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type WatchDirResponse_Filesystem struct { + Filesystem *FilesystemEvent `protobuf:"bytes,2,opt,name=filesystem,proto3,oneof"` +} + +type WatchDirResponse_Keepalive struct { + Keepalive *WatchDirResponse_KeepAlive `protobuf:"bytes,3,opt,name=keepalive,proto3,oneof"` +} + +func (*WatchDirResponse_Start) isWatchDirResponse_Event() {} + +func (*WatchDirResponse_Filesystem) isWatchDirResponse_Event() {} + +func (*WatchDirResponse_Keepalive) isWatchDirResponse_Event() {} + +type CreateWatcherRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Recursive bool `protobuf:"varint,2,opt,name=recursive,proto3" json:"recursive,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateWatcherRequest) Reset() { + *x = CreateWatcherRequest{} + mi := &file_filesystem_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateWatcherRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateWatcherRequest) ProtoMessage() {} + +func (x *CreateWatcherRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateWatcherRequest.ProtoReflect.Descriptor instead. +func (*CreateWatcherRequest) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{14} +} + +func (x *CreateWatcherRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *CreateWatcherRequest) GetRecursive() bool { + if x != nil { + return x.Recursive + } + return false +} + +type CreateWatcherResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + WatcherId string `protobuf:"bytes,1,opt,name=watcher_id,json=watcherId,proto3" json:"watcher_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateWatcherResponse) Reset() { + *x = CreateWatcherResponse{} + mi := &file_filesystem_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateWatcherResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateWatcherResponse) ProtoMessage() {} + +func (x *CreateWatcherResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateWatcherResponse.ProtoReflect.Descriptor instead. +func (*CreateWatcherResponse) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{15} +} + +func (x *CreateWatcherResponse) GetWatcherId() string { + if x != nil { + return x.WatcherId + } + return "" +} + +type GetWatcherEventsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + WatcherId string `protobuf:"bytes,1,opt,name=watcher_id,json=watcherId,proto3" json:"watcher_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetWatcherEventsRequest) Reset() { + *x = GetWatcherEventsRequest{} + mi := &file_filesystem_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetWatcherEventsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetWatcherEventsRequest) ProtoMessage() {} + +func (x *GetWatcherEventsRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetWatcherEventsRequest.ProtoReflect.Descriptor instead. +func (*GetWatcherEventsRequest) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{16} +} + +func (x *GetWatcherEventsRequest) GetWatcherId() string { + if x != nil { + return x.WatcherId + } + return "" +} + +type GetWatcherEventsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Events []*FilesystemEvent `protobuf:"bytes,1,rep,name=events,proto3" json:"events,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetWatcherEventsResponse) Reset() { + *x = GetWatcherEventsResponse{} + mi := &file_filesystem_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetWatcherEventsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetWatcherEventsResponse) ProtoMessage() {} + +func (x *GetWatcherEventsResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetWatcherEventsResponse.ProtoReflect.Descriptor instead. +func (*GetWatcherEventsResponse) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{17} +} + +func (x *GetWatcherEventsResponse) GetEvents() []*FilesystemEvent { + if x != nil { + return x.Events + } + return nil +} + +type RemoveWatcherRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + WatcherId string `protobuf:"bytes,1,opt,name=watcher_id,json=watcherId,proto3" json:"watcher_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveWatcherRequest) Reset() { + *x = RemoveWatcherRequest{} + mi := &file_filesystem_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveWatcherRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveWatcherRequest) ProtoMessage() {} + +func (x *RemoveWatcherRequest) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveWatcherRequest.ProtoReflect.Descriptor instead. +func (*RemoveWatcherRequest) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{18} +} + +func (x *RemoveWatcherRequest) GetWatcherId() string { + if x != nil { + return x.WatcherId + } + return "" +} + +type RemoveWatcherResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RemoveWatcherResponse) Reset() { + *x = RemoveWatcherResponse{} + mi := &file_filesystem_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RemoveWatcherResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RemoveWatcherResponse) ProtoMessage() {} + +func (x *RemoveWatcherResponse) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RemoveWatcherResponse.ProtoReflect.Descriptor instead. +func (*RemoveWatcherResponse) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{19} +} + +type WatchDirResponse_StartEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchDirResponse_StartEvent) Reset() { + *x = WatchDirResponse_StartEvent{} + mi := &file_filesystem_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchDirResponse_StartEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchDirResponse_StartEvent) ProtoMessage() {} + +func (x *WatchDirResponse_StartEvent) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchDirResponse_StartEvent.ProtoReflect.Descriptor instead. +func (*WatchDirResponse_StartEvent) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{13, 0} +} + +type WatchDirResponse_KeepAlive struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchDirResponse_KeepAlive) Reset() { + *x = WatchDirResponse_KeepAlive{} + mi := &file_filesystem_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchDirResponse_KeepAlive) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchDirResponse_KeepAlive) ProtoMessage() {} + +func (x *WatchDirResponse_KeepAlive) ProtoReflect() protoreflect.Message { + mi := &file_filesystem_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchDirResponse_KeepAlive.ProtoReflect.Descriptor instead. +func (*WatchDirResponse_KeepAlive) Descriptor() ([]byte, []int) { + return file_filesystem_proto_rawDescGZIP(), []int{13, 1} +} + +var File_filesystem_proto protoreflect.FileDescriptor + +const file_filesystem_proto_rawDesc = "" + + "\n" + + "\x10filesystem.proto\x12\n" + + "filesystem\x1a\x1fgoogle/protobuf/timestamp.proto\"G\n" + + "\vMoveRequest\x12\x16\n" + + "\x06source\x18\x01 \x01(\tR\x06source\x12 \n" + + "\vdestination\x18\x02 \x01(\tR\vdestination\";\n" + + "\fMoveResponse\x12+\n" + + "\x05entry\x18\x01 \x01(\v2\x15.filesystem.EntryInfoR\x05entry\"$\n" + + "\x0eMakeDirRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\">\n" + + "\x0fMakeDirResponse\x12+\n" + + "\x05entry\x18\x01 \x01(\v2\x15.filesystem.EntryInfoR\x05entry\"#\n" + + "\rRemoveRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\"\x10\n" + + "\x0eRemoveResponse\"!\n" + + "\vStatRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\";\n" + + "\fStatResponse\x12+\n" + + "\x05entry\x18\x01 \x01(\v2\x15.filesystem.EntryInfoR\x05entry\"\xd3\x02\n" + + "\tEntryInfo\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12(\n" + + "\x04type\x18\x02 \x01(\x0e2\x14.filesystem.FileTypeR\x04type\x12\x12\n" + + "\x04path\x18\x03 \x01(\tR\x04path\x12\x12\n" + + "\x04size\x18\x04 \x01(\x03R\x04size\x12\x12\n" + + "\x04mode\x18\x05 \x01(\rR\x04mode\x12 \n" + + "\vpermissions\x18\x06 \x01(\tR\vpermissions\x12\x14\n" + + "\x05owner\x18\a \x01(\tR\x05owner\x12\x14\n" + + "\x05group\x18\b \x01(\tR\x05group\x12?\n" + + "\rmodified_time\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\fmodifiedTime\x12*\n" + + "\x0esymlink_target\x18\n" + + " \x01(\tH\x00R\rsymlinkTarget\x88\x01\x01B\x11\n" + + "\x0f_symlink_target\":\n" + + "\x0eListDirRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x14\n" + + "\x05depth\x18\x02 \x01(\rR\x05depth\"B\n" + + "\x0fListDirResponse\x12/\n" + + "\aentries\x18\x01 \x03(\v2\x15.filesystem.EntryInfoR\aentries\"C\n" + + "\x0fWatchDirRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n" + + "\trecursive\x18\x02 \x01(\bR\trecursive\"P\n" + + "\x0fFilesystemEvent\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12)\n" + + "\x04type\x18\x02 \x01(\x0e2\x15.filesystem.EventTypeR\x04type\"\xfe\x01\n" + + "\x10WatchDirResponse\x12?\n" + + "\x05start\x18\x01 \x01(\v2'.filesystem.WatchDirResponse.StartEventH\x00R\x05start\x12=\n" + + "\n" + + "filesystem\x18\x02 \x01(\v2\x1b.filesystem.FilesystemEventH\x00R\n" + + "filesystem\x12F\n" + + "\tkeepalive\x18\x03 \x01(\v2&.filesystem.WatchDirResponse.KeepAliveH\x00R\tkeepalive\x1a\f\n" + + "\n" + + "StartEvent\x1a\v\n" + + "\tKeepAliveB\a\n" + + "\x05event\"H\n" + + "\x14CreateWatcherRequest\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n" + + "\trecursive\x18\x02 \x01(\bR\trecursive\"6\n" + + "\x15CreateWatcherResponse\x12\x1d\n" + + "\n" + + "watcher_id\x18\x01 \x01(\tR\twatcherId\"8\n" + + "\x17GetWatcherEventsRequest\x12\x1d\n" + + "\n" + + "watcher_id\x18\x01 \x01(\tR\twatcherId\"O\n" + + "\x18GetWatcherEventsResponse\x123\n" + + "\x06events\x18\x01 \x03(\v2\x1b.filesystem.FilesystemEventR\x06events\"5\n" + + "\x14RemoveWatcherRequest\x12\x1d\n" + + "\n" + + "watcher_id\x18\x01 \x01(\tR\twatcherId\"\x17\n" + + "\x15RemoveWatcherResponse*i\n" + + "\bFileType\x12\x19\n" + + "\x15FILE_TYPE_UNSPECIFIED\x10\x00\x12\x12\n" + + "\x0eFILE_TYPE_FILE\x10\x01\x12\x17\n" + + "\x13FILE_TYPE_DIRECTORY\x10\x02\x12\x15\n" + + "\x11FILE_TYPE_SYMLINK\x10\x03*\x98\x01\n" + + "\tEventType\x12\x1a\n" + + "\x16EVENT_TYPE_UNSPECIFIED\x10\x00\x12\x15\n" + + "\x11EVENT_TYPE_CREATE\x10\x01\x12\x14\n" + + "\x10EVENT_TYPE_WRITE\x10\x02\x12\x15\n" + + "\x11EVENT_TYPE_REMOVE\x10\x03\x12\x15\n" + + "\x11EVENT_TYPE_RENAME\x10\x04\x12\x14\n" + + "\x10EVENT_TYPE_CHMOD\x10\x052\x9f\x05\n" + + "\n" + + "Filesystem\x129\n" + + "\x04Stat\x12\x17.filesystem.StatRequest\x1a\x18.filesystem.StatResponse\x12B\n" + + "\aMakeDir\x12\x1a.filesystem.MakeDirRequest\x1a\x1b.filesystem.MakeDirResponse\x129\n" + + "\x04Move\x12\x17.filesystem.MoveRequest\x1a\x18.filesystem.MoveResponse\x12B\n" + + "\aListDir\x12\x1a.filesystem.ListDirRequest\x1a\x1b.filesystem.ListDirResponse\x12?\n" + + "\x06Remove\x12\x19.filesystem.RemoveRequest\x1a\x1a.filesystem.RemoveResponse\x12G\n" + + "\bWatchDir\x12\x1b.filesystem.WatchDirRequest\x1a\x1c.filesystem.WatchDirResponse0\x01\x12T\n" + + "\rCreateWatcher\x12 .filesystem.CreateWatcherRequest\x1a!.filesystem.CreateWatcherResponse\x12]\n" + + "\x10GetWatcherEvents\x12#.filesystem.GetWatcherEventsRequest\x1a$.filesystem.GetWatcherEventsResponse\x12T\n" + + "\rRemoveWatcher\x12 .filesystem.RemoveWatcherRequest\x1a!.filesystem.RemoveWatcherResponseB\x95\x01\n" + + "\x0ecom.filesystemB\x0fFilesystemProtoP\x01Z*git.omukk.dev/wrenn/sandbox/proto/envd/gen\xa2\x02\x03FXX\xaa\x02\n" + + "Filesystem\xca\x02\n" + + "Filesystem\xe2\x02\x16Filesystem\\GPBMetadata\xea\x02\n" + + "Filesystemb\x06proto3" + +var ( + file_filesystem_proto_rawDescOnce sync.Once + file_filesystem_proto_rawDescData []byte +) + +func file_filesystem_proto_rawDescGZIP() []byte { + file_filesystem_proto_rawDescOnce.Do(func() { + file_filesystem_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_filesystem_proto_rawDesc), len(file_filesystem_proto_rawDesc))) + }) + return file_filesystem_proto_rawDescData +} + +var file_filesystem_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_filesystem_proto_msgTypes = make([]protoimpl.MessageInfo, 22) +var file_filesystem_proto_goTypes = []any{ + (FileType)(0), // 0: filesystem.FileType + (EventType)(0), // 1: filesystem.EventType + (*MoveRequest)(nil), // 2: filesystem.MoveRequest + (*MoveResponse)(nil), // 3: filesystem.MoveResponse + (*MakeDirRequest)(nil), // 4: filesystem.MakeDirRequest + (*MakeDirResponse)(nil), // 5: filesystem.MakeDirResponse + (*RemoveRequest)(nil), // 6: filesystem.RemoveRequest + (*RemoveResponse)(nil), // 7: filesystem.RemoveResponse + (*StatRequest)(nil), // 8: filesystem.StatRequest + (*StatResponse)(nil), // 9: filesystem.StatResponse + (*EntryInfo)(nil), // 10: filesystem.EntryInfo + (*ListDirRequest)(nil), // 11: filesystem.ListDirRequest + (*ListDirResponse)(nil), // 12: filesystem.ListDirResponse + (*WatchDirRequest)(nil), // 13: filesystem.WatchDirRequest + (*FilesystemEvent)(nil), // 14: filesystem.FilesystemEvent + (*WatchDirResponse)(nil), // 15: filesystem.WatchDirResponse + (*CreateWatcherRequest)(nil), // 16: filesystem.CreateWatcherRequest + (*CreateWatcherResponse)(nil), // 17: filesystem.CreateWatcherResponse + (*GetWatcherEventsRequest)(nil), // 18: filesystem.GetWatcherEventsRequest + (*GetWatcherEventsResponse)(nil), // 19: filesystem.GetWatcherEventsResponse + (*RemoveWatcherRequest)(nil), // 20: filesystem.RemoveWatcherRequest + (*RemoveWatcherResponse)(nil), // 21: filesystem.RemoveWatcherResponse + (*WatchDirResponse_StartEvent)(nil), // 22: filesystem.WatchDirResponse.StartEvent + (*WatchDirResponse_KeepAlive)(nil), // 23: filesystem.WatchDirResponse.KeepAlive + (*timestamppb.Timestamp)(nil), // 24: google.protobuf.Timestamp +} +var file_filesystem_proto_depIdxs = []int32{ + 10, // 0: filesystem.MoveResponse.entry:type_name -> filesystem.EntryInfo + 10, // 1: filesystem.MakeDirResponse.entry:type_name -> filesystem.EntryInfo + 10, // 2: filesystem.StatResponse.entry:type_name -> filesystem.EntryInfo + 0, // 3: filesystem.EntryInfo.type:type_name -> filesystem.FileType + 24, // 4: filesystem.EntryInfo.modified_time:type_name -> google.protobuf.Timestamp + 10, // 5: filesystem.ListDirResponse.entries:type_name -> filesystem.EntryInfo + 1, // 6: filesystem.FilesystemEvent.type:type_name -> filesystem.EventType + 22, // 7: filesystem.WatchDirResponse.start:type_name -> filesystem.WatchDirResponse.StartEvent + 14, // 8: filesystem.WatchDirResponse.filesystem:type_name -> filesystem.FilesystemEvent + 23, // 9: filesystem.WatchDirResponse.keepalive:type_name -> filesystem.WatchDirResponse.KeepAlive + 14, // 10: filesystem.GetWatcherEventsResponse.events:type_name -> filesystem.FilesystemEvent + 8, // 11: filesystem.Filesystem.Stat:input_type -> filesystem.StatRequest + 4, // 12: filesystem.Filesystem.MakeDir:input_type -> filesystem.MakeDirRequest + 2, // 13: filesystem.Filesystem.Move:input_type -> filesystem.MoveRequest + 11, // 14: filesystem.Filesystem.ListDir:input_type -> filesystem.ListDirRequest + 6, // 15: filesystem.Filesystem.Remove:input_type -> filesystem.RemoveRequest + 13, // 16: filesystem.Filesystem.WatchDir:input_type -> filesystem.WatchDirRequest + 16, // 17: filesystem.Filesystem.CreateWatcher:input_type -> filesystem.CreateWatcherRequest + 18, // 18: filesystem.Filesystem.GetWatcherEvents:input_type -> filesystem.GetWatcherEventsRequest + 20, // 19: filesystem.Filesystem.RemoveWatcher:input_type -> filesystem.RemoveWatcherRequest + 9, // 20: filesystem.Filesystem.Stat:output_type -> filesystem.StatResponse + 5, // 21: filesystem.Filesystem.MakeDir:output_type -> filesystem.MakeDirResponse + 3, // 22: filesystem.Filesystem.Move:output_type -> filesystem.MoveResponse + 12, // 23: filesystem.Filesystem.ListDir:output_type -> filesystem.ListDirResponse + 7, // 24: filesystem.Filesystem.Remove:output_type -> filesystem.RemoveResponse + 15, // 25: filesystem.Filesystem.WatchDir:output_type -> filesystem.WatchDirResponse + 17, // 26: filesystem.Filesystem.CreateWatcher:output_type -> filesystem.CreateWatcherResponse + 19, // 27: filesystem.Filesystem.GetWatcherEvents:output_type -> filesystem.GetWatcherEventsResponse + 21, // 28: filesystem.Filesystem.RemoveWatcher:output_type -> filesystem.RemoveWatcherResponse + 20, // [20:29] is the sub-list for method output_type + 11, // [11:20] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name +} + +func init() { file_filesystem_proto_init() } +func file_filesystem_proto_init() { + if File_filesystem_proto != nil { + return + } + file_filesystem_proto_msgTypes[8].OneofWrappers = []any{} + file_filesystem_proto_msgTypes[13].OneofWrappers = []any{ + (*WatchDirResponse_Start)(nil), + (*WatchDirResponse_Filesystem)(nil), + (*WatchDirResponse_Keepalive)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_filesystem_proto_rawDesc), len(file_filesystem_proto_rawDesc)), + NumEnums: 2, + NumMessages: 22, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_filesystem_proto_goTypes, + DependencyIndexes: file_filesystem_proto_depIdxs, + EnumInfos: file_filesystem_proto_enumTypes, + MessageInfos: file_filesystem_proto_msgTypes, + }.Build() + File_filesystem_proto = out.File + file_filesystem_proto_goTypes = nil + file_filesystem_proto_depIdxs = nil +} diff --git a/proto/envd/gen/genconnect/filesystem.connect.go b/proto/envd/gen/genconnect/filesystem.connect.go new file mode 100644 index 0000000..adaf84f --- /dev/null +++ b/proto/envd/gen/genconnect/filesystem.connect.go @@ -0,0 +1,339 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: filesystem.proto + +package genconnect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + gen "git.omukk.dev/wrenn/sandbox/proto/envd/gen" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // FilesystemName is the fully-qualified name of the Filesystem service. + FilesystemName = "filesystem.Filesystem" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // FilesystemStatProcedure is the fully-qualified name of the Filesystem's Stat RPC. + FilesystemStatProcedure = "/filesystem.Filesystem/Stat" + // FilesystemMakeDirProcedure is the fully-qualified name of the Filesystem's MakeDir RPC. + FilesystemMakeDirProcedure = "/filesystem.Filesystem/MakeDir" + // FilesystemMoveProcedure is the fully-qualified name of the Filesystem's Move RPC. + FilesystemMoveProcedure = "/filesystem.Filesystem/Move" + // FilesystemListDirProcedure is the fully-qualified name of the Filesystem's ListDir RPC. + FilesystemListDirProcedure = "/filesystem.Filesystem/ListDir" + // FilesystemRemoveProcedure is the fully-qualified name of the Filesystem's Remove RPC. + FilesystemRemoveProcedure = "/filesystem.Filesystem/Remove" + // FilesystemWatchDirProcedure is the fully-qualified name of the Filesystem's WatchDir RPC. + FilesystemWatchDirProcedure = "/filesystem.Filesystem/WatchDir" + // FilesystemCreateWatcherProcedure is the fully-qualified name of the Filesystem's CreateWatcher + // RPC. + FilesystemCreateWatcherProcedure = "/filesystem.Filesystem/CreateWatcher" + // FilesystemGetWatcherEventsProcedure is the fully-qualified name of the Filesystem's + // GetWatcherEvents RPC. + FilesystemGetWatcherEventsProcedure = "/filesystem.Filesystem/GetWatcherEvents" + // FilesystemRemoveWatcherProcedure is the fully-qualified name of the Filesystem's RemoveWatcher + // RPC. + FilesystemRemoveWatcherProcedure = "/filesystem.Filesystem/RemoveWatcher" +) + +// FilesystemClient is a client for the filesystem.Filesystem service. +type FilesystemClient interface { + Stat(context.Context, *connect.Request[gen.StatRequest]) (*connect.Response[gen.StatResponse], error) + MakeDir(context.Context, *connect.Request[gen.MakeDirRequest]) (*connect.Response[gen.MakeDirResponse], error) + Move(context.Context, *connect.Request[gen.MoveRequest]) (*connect.Response[gen.MoveResponse], error) + ListDir(context.Context, *connect.Request[gen.ListDirRequest]) (*connect.Response[gen.ListDirResponse], error) + Remove(context.Context, *connect.Request[gen.RemoveRequest]) (*connect.Response[gen.RemoveResponse], error) + WatchDir(context.Context, *connect.Request[gen.WatchDirRequest]) (*connect.ServerStreamForClient[gen.WatchDirResponse], error) + // Non-streaming versions of WatchDir + CreateWatcher(context.Context, *connect.Request[gen.CreateWatcherRequest]) (*connect.Response[gen.CreateWatcherResponse], error) + GetWatcherEvents(context.Context, *connect.Request[gen.GetWatcherEventsRequest]) (*connect.Response[gen.GetWatcherEventsResponse], error) + RemoveWatcher(context.Context, *connect.Request[gen.RemoveWatcherRequest]) (*connect.Response[gen.RemoveWatcherResponse], error) +} + +// NewFilesystemClient constructs a client for the filesystem.Filesystem service. By default, it +// uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends +// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or +// connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewFilesystemClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) FilesystemClient { + baseURL = strings.TrimRight(baseURL, "/") + filesystemMethods := gen.File_filesystem_proto.Services().ByName("Filesystem").Methods() + return &filesystemClient{ + stat: connect.NewClient[gen.StatRequest, gen.StatResponse]( + httpClient, + baseURL+FilesystemStatProcedure, + connect.WithSchema(filesystemMethods.ByName("Stat")), + connect.WithClientOptions(opts...), + ), + makeDir: connect.NewClient[gen.MakeDirRequest, gen.MakeDirResponse]( + httpClient, + baseURL+FilesystemMakeDirProcedure, + connect.WithSchema(filesystemMethods.ByName("MakeDir")), + connect.WithClientOptions(opts...), + ), + move: connect.NewClient[gen.MoveRequest, gen.MoveResponse]( + httpClient, + baseURL+FilesystemMoveProcedure, + connect.WithSchema(filesystemMethods.ByName("Move")), + connect.WithClientOptions(opts...), + ), + listDir: connect.NewClient[gen.ListDirRequest, gen.ListDirResponse]( + httpClient, + baseURL+FilesystemListDirProcedure, + connect.WithSchema(filesystemMethods.ByName("ListDir")), + connect.WithClientOptions(opts...), + ), + remove: connect.NewClient[gen.RemoveRequest, gen.RemoveResponse]( + httpClient, + baseURL+FilesystemRemoveProcedure, + connect.WithSchema(filesystemMethods.ByName("Remove")), + connect.WithClientOptions(opts...), + ), + watchDir: connect.NewClient[gen.WatchDirRequest, gen.WatchDirResponse]( + httpClient, + baseURL+FilesystemWatchDirProcedure, + connect.WithSchema(filesystemMethods.ByName("WatchDir")), + connect.WithClientOptions(opts...), + ), + createWatcher: connect.NewClient[gen.CreateWatcherRequest, gen.CreateWatcherResponse]( + httpClient, + baseURL+FilesystemCreateWatcherProcedure, + connect.WithSchema(filesystemMethods.ByName("CreateWatcher")), + connect.WithClientOptions(opts...), + ), + getWatcherEvents: connect.NewClient[gen.GetWatcherEventsRequest, gen.GetWatcherEventsResponse]( + httpClient, + baseURL+FilesystemGetWatcherEventsProcedure, + connect.WithSchema(filesystemMethods.ByName("GetWatcherEvents")), + connect.WithClientOptions(opts...), + ), + removeWatcher: connect.NewClient[gen.RemoveWatcherRequest, gen.RemoveWatcherResponse]( + httpClient, + baseURL+FilesystemRemoveWatcherProcedure, + connect.WithSchema(filesystemMethods.ByName("RemoveWatcher")), + connect.WithClientOptions(opts...), + ), + } +} + +// filesystemClient implements FilesystemClient. +type filesystemClient struct { + stat *connect.Client[gen.StatRequest, gen.StatResponse] + makeDir *connect.Client[gen.MakeDirRequest, gen.MakeDirResponse] + move *connect.Client[gen.MoveRequest, gen.MoveResponse] + listDir *connect.Client[gen.ListDirRequest, gen.ListDirResponse] + remove *connect.Client[gen.RemoveRequest, gen.RemoveResponse] + watchDir *connect.Client[gen.WatchDirRequest, gen.WatchDirResponse] + createWatcher *connect.Client[gen.CreateWatcherRequest, gen.CreateWatcherResponse] + getWatcherEvents *connect.Client[gen.GetWatcherEventsRequest, gen.GetWatcherEventsResponse] + removeWatcher *connect.Client[gen.RemoveWatcherRequest, gen.RemoveWatcherResponse] +} + +// Stat calls filesystem.Filesystem.Stat. +func (c *filesystemClient) Stat(ctx context.Context, req *connect.Request[gen.StatRequest]) (*connect.Response[gen.StatResponse], error) { + return c.stat.CallUnary(ctx, req) +} + +// MakeDir calls filesystem.Filesystem.MakeDir. +func (c *filesystemClient) MakeDir(ctx context.Context, req *connect.Request[gen.MakeDirRequest]) (*connect.Response[gen.MakeDirResponse], error) { + return c.makeDir.CallUnary(ctx, req) +} + +// Move calls filesystem.Filesystem.Move. +func (c *filesystemClient) Move(ctx context.Context, req *connect.Request[gen.MoveRequest]) (*connect.Response[gen.MoveResponse], error) { + return c.move.CallUnary(ctx, req) +} + +// ListDir calls filesystem.Filesystem.ListDir. +func (c *filesystemClient) ListDir(ctx context.Context, req *connect.Request[gen.ListDirRequest]) (*connect.Response[gen.ListDirResponse], error) { + return c.listDir.CallUnary(ctx, req) +} + +// Remove calls filesystem.Filesystem.Remove. +func (c *filesystemClient) Remove(ctx context.Context, req *connect.Request[gen.RemoveRequest]) (*connect.Response[gen.RemoveResponse], error) { + return c.remove.CallUnary(ctx, req) +} + +// WatchDir calls filesystem.Filesystem.WatchDir. +func (c *filesystemClient) WatchDir(ctx context.Context, req *connect.Request[gen.WatchDirRequest]) (*connect.ServerStreamForClient[gen.WatchDirResponse], error) { + return c.watchDir.CallServerStream(ctx, req) +} + +// CreateWatcher calls filesystem.Filesystem.CreateWatcher. +func (c *filesystemClient) CreateWatcher(ctx context.Context, req *connect.Request[gen.CreateWatcherRequest]) (*connect.Response[gen.CreateWatcherResponse], error) { + return c.createWatcher.CallUnary(ctx, req) +} + +// GetWatcherEvents calls filesystem.Filesystem.GetWatcherEvents. +func (c *filesystemClient) GetWatcherEvents(ctx context.Context, req *connect.Request[gen.GetWatcherEventsRequest]) (*connect.Response[gen.GetWatcherEventsResponse], error) { + return c.getWatcherEvents.CallUnary(ctx, req) +} + +// RemoveWatcher calls filesystem.Filesystem.RemoveWatcher. +func (c *filesystemClient) RemoveWatcher(ctx context.Context, req *connect.Request[gen.RemoveWatcherRequest]) (*connect.Response[gen.RemoveWatcherResponse], error) { + return c.removeWatcher.CallUnary(ctx, req) +} + +// FilesystemHandler is an implementation of the filesystem.Filesystem service. +type FilesystemHandler interface { + Stat(context.Context, *connect.Request[gen.StatRequest]) (*connect.Response[gen.StatResponse], error) + MakeDir(context.Context, *connect.Request[gen.MakeDirRequest]) (*connect.Response[gen.MakeDirResponse], error) + Move(context.Context, *connect.Request[gen.MoveRequest]) (*connect.Response[gen.MoveResponse], error) + ListDir(context.Context, *connect.Request[gen.ListDirRequest]) (*connect.Response[gen.ListDirResponse], error) + Remove(context.Context, *connect.Request[gen.RemoveRequest]) (*connect.Response[gen.RemoveResponse], error) + WatchDir(context.Context, *connect.Request[gen.WatchDirRequest], *connect.ServerStream[gen.WatchDirResponse]) error + // Non-streaming versions of WatchDir + CreateWatcher(context.Context, *connect.Request[gen.CreateWatcherRequest]) (*connect.Response[gen.CreateWatcherResponse], error) + GetWatcherEvents(context.Context, *connect.Request[gen.GetWatcherEventsRequest]) (*connect.Response[gen.GetWatcherEventsResponse], error) + RemoveWatcher(context.Context, *connect.Request[gen.RemoveWatcherRequest]) (*connect.Response[gen.RemoveWatcherResponse], error) +} + +// NewFilesystemHandler builds an HTTP handler from the service implementation. It returns the path +// on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewFilesystemHandler(svc FilesystemHandler, opts ...connect.HandlerOption) (string, http.Handler) { + filesystemMethods := gen.File_filesystem_proto.Services().ByName("Filesystem").Methods() + filesystemStatHandler := connect.NewUnaryHandler( + FilesystemStatProcedure, + svc.Stat, + connect.WithSchema(filesystemMethods.ByName("Stat")), + connect.WithHandlerOptions(opts...), + ) + filesystemMakeDirHandler := connect.NewUnaryHandler( + FilesystemMakeDirProcedure, + svc.MakeDir, + connect.WithSchema(filesystemMethods.ByName("MakeDir")), + connect.WithHandlerOptions(opts...), + ) + filesystemMoveHandler := connect.NewUnaryHandler( + FilesystemMoveProcedure, + svc.Move, + connect.WithSchema(filesystemMethods.ByName("Move")), + connect.WithHandlerOptions(opts...), + ) + filesystemListDirHandler := connect.NewUnaryHandler( + FilesystemListDirProcedure, + svc.ListDir, + connect.WithSchema(filesystemMethods.ByName("ListDir")), + connect.WithHandlerOptions(opts...), + ) + filesystemRemoveHandler := connect.NewUnaryHandler( + FilesystemRemoveProcedure, + svc.Remove, + connect.WithSchema(filesystemMethods.ByName("Remove")), + connect.WithHandlerOptions(opts...), + ) + filesystemWatchDirHandler := connect.NewServerStreamHandler( + FilesystemWatchDirProcedure, + svc.WatchDir, + connect.WithSchema(filesystemMethods.ByName("WatchDir")), + connect.WithHandlerOptions(opts...), + ) + filesystemCreateWatcherHandler := connect.NewUnaryHandler( + FilesystemCreateWatcherProcedure, + svc.CreateWatcher, + connect.WithSchema(filesystemMethods.ByName("CreateWatcher")), + connect.WithHandlerOptions(opts...), + ) + filesystemGetWatcherEventsHandler := connect.NewUnaryHandler( + FilesystemGetWatcherEventsProcedure, + svc.GetWatcherEvents, + connect.WithSchema(filesystemMethods.ByName("GetWatcherEvents")), + connect.WithHandlerOptions(opts...), + ) + filesystemRemoveWatcherHandler := connect.NewUnaryHandler( + FilesystemRemoveWatcherProcedure, + svc.RemoveWatcher, + connect.WithSchema(filesystemMethods.ByName("RemoveWatcher")), + connect.WithHandlerOptions(opts...), + ) + return "/filesystem.Filesystem/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case FilesystemStatProcedure: + filesystemStatHandler.ServeHTTP(w, r) + case FilesystemMakeDirProcedure: + filesystemMakeDirHandler.ServeHTTP(w, r) + case FilesystemMoveProcedure: + filesystemMoveHandler.ServeHTTP(w, r) + case FilesystemListDirProcedure: + filesystemListDirHandler.ServeHTTP(w, r) + case FilesystemRemoveProcedure: + filesystemRemoveHandler.ServeHTTP(w, r) + case FilesystemWatchDirProcedure: + filesystemWatchDirHandler.ServeHTTP(w, r) + case FilesystemCreateWatcherProcedure: + filesystemCreateWatcherHandler.ServeHTTP(w, r) + case FilesystemGetWatcherEventsProcedure: + filesystemGetWatcherEventsHandler.ServeHTTP(w, r) + case FilesystemRemoveWatcherProcedure: + filesystemRemoveWatcherHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedFilesystemHandler returns CodeUnimplemented from all methods. +type UnimplementedFilesystemHandler struct{} + +func (UnimplementedFilesystemHandler) Stat(context.Context, *connect.Request[gen.StatRequest]) (*connect.Response[gen.StatResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.Stat is not implemented")) +} + +func (UnimplementedFilesystemHandler) MakeDir(context.Context, *connect.Request[gen.MakeDirRequest]) (*connect.Response[gen.MakeDirResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.MakeDir is not implemented")) +} + +func (UnimplementedFilesystemHandler) Move(context.Context, *connect.Request[gen.MoveRequest]) (*connect.Response[gen.MoveResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.Move is not implemented")) +} + +func (UnimplementedFilesystemHandler) ListDir(context.Context, *connect.Request[gen.ListDirRequest]) (*connect.Response[gen.ListDirResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.ListDir is not implemented")) +} + +func (UnimplementedFilesystemHandler) Remove(context.Context, *connect.Request[gen.RemoveRequest]) (*connect.Response[gen.RemoveResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.Remove is not implemented")) +} + +func (UnimplementedFilesystemHandler) WatchDir(context.Context, *connect.Request[gen.WatchDirRequest], *connect.ServerStream[gen.WatchDirResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.WatchDir is not implemented")) +} + +func (UnimplementedFilesystemHandler) CreateWatcher(context.Context, *connect.Request[gen.CreateWatcherRequest]) (*connect.Response[gen.CreateWatcherResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.CreateWatcher is not implemented")) +} + +func (UnimplementedFilesystemHandler) GetWatcherEvents(context.Context, *connect.Request[gen.GetWatcherEventsRequest]) (*connect.Response[gen.GetWatcherEventsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.GetWatcherEvents is not implemented")) +} + +func (UnimplementedFilesystemHandler) RemoveWatcher(context.Context, *connect.Request[gen.RemoveWatcherRequest]) (*connect.Response[gen.RemoveWatcherResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.RemoveWatcher is not implemented")) +} diff --git a/proto/envd/gen/genconnect/process.connect.go b/proto/envd/gen/genconnect/process.connect.go new file mode 100644 index 0000000..d3bd13a --- /dev/null +++ b/proto/envd/gen/genconnect/process.connect.go @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: process.proto + +package genconnect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + gen "git.omukk.dev/wrenn/sandbox/proto/envd/gen" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // ProcessName is the fully-qualified name of the Process service. + ProcessName = "process.Process" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // ProcessListProcedure is the fully-qualified name of the Process's List RPC. + ProcessListProcedure = "/process.Process/List" + // ProcessConnectProcedure is the fully-qualified name of the Process's Connect RPC. + ProcessConnectProcedure = "/process.Process/Connect" + // ProcessStartProcedure is the fully-qualified name of the Process's Start RPC. + ProcessStartProcedure = "/process.Process/Start" + // ProcessUpdateProcedure is the fully-qualified name of the Process's Update RPC. + ProcessUpdateProcedure = "/process.Process/Update" + // ProcessStreamInputProcedure is the fully-qualified name of the Process's StreamInput RPC. + ProcessStreamInputProcedure = "/process.Process/StreamInput" + // ProcessSendInputProcedure is the fully-qualified name of the Process's SendInput RPC. + ProcessSendInputProcedure = "/process.Process/SendInput" + // ProcessSendSignalProcedure is the fully-qualified name of the Process's SendSignal RPC. + ProcessSendSignalProcedure = "/process.Process/SendSignal" + // ProcessCloseStdinProcedure is the fully-qualified name of the Process's CloseStdin RPC. + ProcessCloseStdinProcedure = "/process.Process/CloseStdin" +) + +// ProcessClient is a client for the process.Process service. +type ProcessClient interface { + List(context.Context, *connect.Request[gen.ListRequest]) (*connect.Response[gen.ListResponse], error) + Connect(context.Context, *connect.Request[gen.ConnectRequest]) (*connect.ServerStreamForClient[gen.ConnectResponse], error) + Start(context.Context, *connect.Request[gen.StartRequest]) (*connect.ServerStreamForClient[gen.StartResponse], error) + Update(context.Context, *connect.Request[gen.UpdateRequest]) (*connect.Response[gen.UpdateResponse], error) + // Client input stream ensures ordering of messages + StreamInput(context.Context) *connect.ClientStreamForClient[gen.StreamInputRequest, gen.StreamInputResponse] + SendInput(context.Context, *connect.Request[gen.SendInputRequest]) (*connect.Response[gen.SendInputResponse], error) + SendSignal(context.Context, *connect.Request[gen.SendSignalRequest]) (*connect.Response[gen.SendSignalResponse], error) + // Close stdin to signal EOF to the process. + // Only works for non-PTY processes. For PTY, send Ctrl+D (0x04) instead. + CloseStdin(context.Context, *connect.Request[gen.CloseStdinRequest]) (*connect.Response[gen.CloseStdinResponse], error) +} + +// NewProcessClient constructs a client for the process.Process service. By default, it uses the +// Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends +// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or +// connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewProcessClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ProcessClient { + baseURL = strings.TrimRight(baseURL, "/") + processMethods := gen.File_process_proto.Services().ByName("Process").Methods() + return &processClient{ + list: connect.NewClient[gen.ListRequest, gen.ListResponse]( + httpClient, + baseURL+ProcessListProcedure, + connect.WithSchema(processMethods.ByName("List")), + connect.WithClientOptions(opts...), + ), + connect: connect.NewClient[gen.ConnectRequest, gen.ConnectResponse]( + httpClient, + baseURL+ProcessConnectProcedure, + connect.WithSchema(processMethods.ByName("Connect")), + connect.WithClientOptions(opts...), + ), + start: connect.NewClient[gen.StartRequest, gen.StartResponse]( + httpClient, + baseURL+ProcessStartProcedure, + connect.WithSchema(processMethods.ByName("Start")), + connect.WithClientOptions(opts...), + ), + update: connect.NewClient[gen.UpdateRequest, gen.UpdateResponse]( + httpClient, + baseURL+ProcessUpdateProcedure, + connect.WithSchema(processMethods.ByName("Update")), + connect.WithClientOptions(opts...), + ), + streamInput: connect.NewClient[gen.StreamInputRequest, gen.StreamInputResponse]( + httpClient, + baseURL+ProcessStreamInputProcedure, + connect.WithSchema(processMethods.ByName("StreamInput")), + connect.WithClientOptions(opts...), + ), + sendInput: connect.NewClient[gen.SendInputRequest, gen.SendInputResponse]( + httpClient, + baseURL+ProcessSendInputProcedure, + connect.WithSchema(processMethods.ByName("SendInput")), + connect.WithClientOptions(opts...), + ), + sendSignal: connect.NewClient[gen.SendSignalRequest, gen.SendSignalResponse]( + httpClient, + baseURL+ProcessSendSignalProcedure, + connect.WithSchema(processMethods.ByName("SendSignal")), + connect.WithClientOptions(opts...), + ), + closeStdin: connect.NewClient[gen.CloseStdinRequest, gen.CloseStdinResponse]( + httpClient, + baseURL+ProcessCloseStdinProcedure, + connect.WithSchema(processMethods.ByName("CloseStdin")), + connect.WithClientOptions(opts...), + ), + } +} + +// processClient implements ProcessClient. +type processClient struct { + list *connect.Client[gen.ListRequest, gen.ListResponse] + connect *connect.Client[gen.ConnectRequest, gen.ConnectResponse] + start *connect.Client[gen.StartRequest, gen.StartResponse] + update *connect.Client[gen.UpdateRequest, gen.UpdateResponse] + streamInput *connect.Client[gen.StreamInputRequest, gen.StreamInputResponse] + sendInput *connect.Client[gen.SendInputRequest, gen.SendInputResponse] + sendSignal *connect.Client[gen.SendSignalRequest, gen.SendSignalResponse] + closeStdin *connect.Client[gen.CloseStdinRequest, gen.CloseStdinResponse] +} + +// List calls process.Process.List. +func (c *processClient) List(ctx context.Context, req *connect.Request[gen.ListRequest]) (*connect.Response[gen.ListResponse], error) { + return c.list.CallUnary(ctx, req) +} + +// Connect calls process.Process.Connect. +func (c *processClient) Connect(ctx context.Context, req *connect.Request[gen.ConnectRequest]) (*connect.ServerStreamForClient[gen.ConnectResponse], error) { + return c.connect.CallServerStream(ctx, req) +} + +// Start calls process.Process.Start. +func (c *processClient) Start(ctx context.Context, req *connect.Request[gen.StartRequest]) (*connect.ServerStreamForClient[gen.StartResponse], error) { + return c.start.CallServerStream(ctx, req) +} + +// Update calls process.Process.Update. +func (c *processClient) Update(ctx context.Context, req *connect.Request[gen.UpdateRequest]) (*connect.Response[gen.UpdateResponse], error) { + return c.update.CallUnary(ctx, req) +} + +// StreamInput calls process.Process.StreamInput. +func (c *processClient) StreamInput(ctx context.Context) *connect.ClientStreamForClient[gen.StreamInputRequest, gen.StreamInputResponse] { + return c.streamInput.CallClientStream(ctx) +} + +// SendInput calls process.Process.SendInput. +func (c *processClient) SendInput(ctx context.Context, req *connect.Request[gen.SendInputRequest]) (*connect.Response[gen.SendInputResponse], error) { + return c.sendInput.CallUnary(ctx, req) +} + +// SendSignal calls process.Process.SendSignal. +func (c *processClient) SendSignal(ctx context.Context, req *connect.Request[gen.SendSignalRequest]) (*connect.Response[gen.SendSignalResponse], error) { + return c.sendSignal.CallUnary(ctx, req) +} + +// CloseStdin calls process.Process.CloseStdin. +func (c *processClient) CloseStdin(ctx context.Context, req *connect.Request[gen.CloseStdinRequest]) (*connect.Response[gen.CloseStdinResponse], error) { + return c.closeStdin.CallUnary(ctx, req) +} + +// ProcessHandler is an implementation of the process.Process service. +type ProcessHandler interface { + List(context.Context, *connect.Request[gen.ListRequest]) (*connect.Response[gen.ListResponse], error) + Connect(context.Context, *connect.Request[gen.ConnectRequest], *connect.ServerStream[gen.ConnectResponse]) error + Start(context.Context, *connect.Request[gen.StartRequest], *connect.ServerStream[gen.StartResponse]) error + Update(context.Context, *connect.Request[gen.UpdateRequest]) (*connect.Response[gen.UpdateResponse], error) + // Client input stream ensures ordering of messages + StreamInput(context.Context, *connect.ClientStream[gen.StreamInputRequest]) (*connect.Response[gen.StreamInputResponse], error) + SendInput(context.Context, *connect.Request[gen.SendInputRequest]) (*connect.Response[gen.SendInputResponse], error) + SendSignal(context.Context, *connect.Request[gen.SendSignalRequest]) (*connect.Response[gen.SendSignalResponse], error) + // Close stdin to signal EOF to the process. + // Only works for non-PTY processes. For PTY, send Ctrl+D (0x04) instead. + CloseStdin(context.Context, *connect.Request[gen.CloseStdinRequest]) (*connect.Response[gen.CloseStdinResponse], error) +} + +// NewProcessHandler builds an HTTP handler from the service implementation. It returns the path on +// which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewProcessHandler(svc ProcessHandler, opts ...connect.HandlerOption) (string, http.Handler) { + processMethods := gen.File_process_proto.Services().ByName("Process").Methods() + processListHandler := connect.NewUnaryHandler( + ProcessListProcedure, + svc.List, + connect.WithSchema(processMethods.ByName("List")), + connect.WithHandlerOptions(opts...), + ) + processConnectHandler := connect.NewServerStreamHandler( + ProcessConnectProcedure, + svc.Connect, + connect.WithSchema(processMethods.ByName("Connect")), + connect.WithHandlerOptions(opts...), + ) + processStartHandler := connect.NewServerStreamHandler( + ProcessStartProcedure, + svc.Start, + connect.WithSchema(processMethods.ByName("Start")), + connect.WithHandlerOptions(opts...), + ) + processUpdateHandler := connect.NewUnaryHandler( + ProcessUpdateProcedure, + svc.Update, + connect.WithSchema(processMethods.ByName("Update")), + connect.WithHandlerOptions(opts...), + ) + processStreamInputHandler := connect.NewClientStreamHandler( + ProcessStreamInputProcedure, + svc.StreamInput, + connect.WithSchema(processMethods.ByName("StreamInput")), + connect.WithHandlerOptions(opts...), + ) + processSendInputHandler := connect.NewUnaryHandler( + ProcessSendInputProcedure, + svc.SendInput, + connect.WithSchema(processMethods.ByName("SendInput")), + connect.WithHandlerOptions(opts...), + ) + processSendSignalHandler := connect.NewUnaryHandler( + ProcessSendSignalProcedure, + svc.SendSignal, + connect.WithSchema(processMethods.ByName("SendSignal")), + connect.WithHandlerOptions(opts...), + ) + processCloseStdinHandler := connect.NewUnaryHandler( + ProcessCloseStdinProcedure, + svc.CloseStdin, + connect.WithSchema(processMethods.ByName("CloseStdin")), + connect.WithHandlerOptions(opts...), + ) + return "/process.Process/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case ProcessListProcedure: + processListHandler.ServeHTTP(w, r) + case ProcessConnectProcedure: + processConnectHandler.ServeHTTP(w, r) + case ProcessStartProcedure: + processStartHandler.ServeHTTP(w, r) + case ProcessUpdateProcedure: + processUpdateHandler.ServeHTTP(w, r) + case ProcessStreamInputProcedure: + processStreamInputHandler.ServeHTTP(w, r) + case ProcessSendInputProcedure: + processSendInputHandler.ServeHTTP(w, r) + case ProcessSendSignalProcedure: + processSendSignalHandler.ServeHTTP(w, r) + case ProcessCloseStdinProcedure: + processCloseStdinHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedProcessHandler returns CodeUnimplemented from all methods. +type UnimplementedProcessHandler struct{} + +func (UnimplementedProcessHandler) List(context.Context, *connect.Request[gen.ListRequest]) (*connect.Response[gen.ListResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.List is not implemented")) +} + +func (UnimplementedProcessHandler) Connect(context.Context, *connect.Request[gen.ConnectRequest], *connect.ServerStream[gen.ConnectResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.Connect is not implemented")) +} + +func (UnimplementedProcessHandler) Start(context.Context, *connect.Request[gen.StartRequest], *connect.ServerStream[gen.StartResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.Start is not implemented")) +} + +func (UnimplementedProcessHandler) Update(context.Context, *connect.Request[gen.UpdateRequest]) (*connect.Response[gen.UpdateResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.Update is not implemented")) +} + +func (UnimplementedProcessHandler) StreamInput(context.Context, *connect.ClientStream[gen.StreamInputRequest]) (*connect.Response[gen.StreamInputResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.StreamInput is not implemented")) +} + +func (UnimplementedProcessHandler) SendInput(context.Context, *connect.Request[gen.SendInputRequest]) (*connect.Response[gen.SendInputResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.SendInput is not implemented")) +} + +func (UnimplementedProcessHandler) SendSignal(context.Context, *connect.Request[gen.SendSignalRequest]) (*connect.Response[gen.SendSignalResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.SendSignal is not implemented")) +} + +func (UnimplementedProcessHandler) CloseStdin(context.Context, *connect.Request[gen.CloseStdinRequest]) (*connect.Response[gen.CloseStdinResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.CloseStdin is not implemented")) +} diff --git a/proto/envd/gen/process.pb.go b/proto/envd/gen/process.pb.go new file mode 100644 index 0000000..9b1533f --- /dev/null +++ b/proto/envd/gen/process.pb.go @@ -0,0 +1,1972 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: process.proto + +package gen + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Signal int32 + +const ( + Signal_SIGNAL_UNSPECIFIED Signal = 0 + Signal_SIGNAL_SIGTERM Signal = 15 + Signal_SIGNAL_SIGKILL Signal = 9 +) + +// Enum value maps for Signal. +var ( + Signal_name = map[int32]string{ + 0: "SIGNAL_UNSPECIFIED", + 15: "SIGNAL_SIGTERM", + 9: "SIGNAL_SIGKILL", + } + Signal_value = map[string]int32{ + "SIGNAL_UNSPECIFIED": 0, + "SIGNAL_SIGTERM": 15, + "SIGNAL_SIGKILL": 9, + } +) + +func (x Signal) Enum() *Signal { + p := new(Signal) + *p = x + return p +} + +func (x Signal) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Signal) Descriptor() protoreflect.EnumDescriptor { + return file_process_proto_enumTypes[0].Descriptor() +} + +func (Signal) Type() protoreflect.EnumType { + return &file_process_proto_enumTypes[0] +} + +func (x Signal) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Signal.Descriptor instead. +func (Signal) EnumDescriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{0} +} + +type PTY struct { + state protoimpl.MessageState `protogen:"open.v1"` + Size *PTY_Size `protobuf:"bytes,1,opt,name=size,proto3" json:"size,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PTY) Reset() { + *x = PTY{} + mi := &file_process_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PTY) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PTY) ProtoMessage() {} + +func (x *PTY) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PTY.ProtoReflect.Descriptor instead. +func (*PTY) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{0} +} + +func (x *PTY) GetSize() *PTY_Size { + if x != nil { + return x.Size + } + return nil +} + +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"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessConfig) Reset() { + *x = ProcessConfig{} + mi := &file_process_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessConfig) ProtoMessage() {} + +func (x *ProcessConfig) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessConfig.ProtoReflect.Descriptor instead. +func (*ProcessConfig) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{1} +} + +func (x *ProcessConfig) GetCmd() string { + if x != nil { + return x.Cmd + } + return "" +} + +func (x *ProcessConfig) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + +func (x *ProcessConfig) GetEnvs() map[string]string { + if x != nil { + return x.Envs + } + return nil +} + +func (x *ProcessConfig) GetCwd() string { + if x != nil && x.Cwd != nil { + return *x.Cwd + } + return "" +} + +type ListRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListRequest) Reset() { + *x = ListRequest{} + mi := &file_process_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRequest) ProtoMessage() {} + +func (x *ListRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListRequest.ProtoReflect.Descriptor instead. +func (*ListRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{2} +} + +type ProcessInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Config *ProcessConfig `protobuf:"bytes,1,opt,name=config,proto3" json:"config,omitempty"` + Pid uint32 `protobuf:"varint,2,opt,name=pid,proto3" json:"pid,omitempty"` + Tag *string `protobuf:"bytes,3,opt,name=tag,proto3,oneof" json:"tag,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessInfo) Reset() { + *x = ProcessInfo{} + mi := &file_process_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessInfo) ProtoMessage() {} + +func (x *ProcessInfo) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessInfo.ProtoReflect.Descriptor instead. +func (*ProcessInfo) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{3} +} + +func (x *ProcessInfo) GetConfig() *ProcessConfig { + if x != nil { + return x.Config + } + return nil +} + +func (x *ProcessInfo) GetPid() uint32 { + if x != nil { + return x.Pid + } + return 0 +} + +func (x *ProcessInfo) GetTag() string { + if x != nil && x.Tag != nil { + return *x.Tag + } + return "" +} + +type ListResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Processes []*ProcessInfo `protobuf:"bytes,1,rep,name=processes,proto3" json:"processes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListResponse) Reset() { + *x = ListResponse{} + mi := &file_process_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListResponse) ProtoMessage() {} + +func (x *ListResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListResponse.ProtoReflect.Descriptor instead. +func (*ListResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{4} +} + +func (x *ListResponse) GetProcesses() []*ProcessInfo { + if x != nil { + return x.Processes + } + return nil +} + +type StartRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessConfig `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Pty *PTY `protobuf:"bytes,2,opt,name=pty,proto3,oneof" json:"pty,omitempty"` + Tag *string `protobuf:"bytes,3,opt,name=tag,proto3,oneof" json:"tag,omitempty"` + // This is optional for backwards compatibility. + // We default to true. New SDK versions will set this to false by default. + Stdin *bool `protobuf:"varint,4,opt,name=stdin,proto3,oneof" json:"stdin,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartRequest) Reset() { + *x = StartRequest{} + mi := &file_process_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartRequest) ProtoMessage() {} + +func (x *StartRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StartRequest.ProtoReflect.Descriptor instead. +func (*StartRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{5} +} + +func (x *StartRequest) GetProcess() *ProcessConfig { + if x != nil { + return x.Process + } + return nil +} + +func (x *StartRequest) GetPty() *PTY { + if x != nil { + return x.Pty + } + return nil +} + +func (x *StartRequest) GetTag() string { + if x != nil && x.Tag != nil { + return *x.Tag + } + return "" +} + +func (x *StartRequest) GetStdin() bool { + if x != nil && x.Stdin != nil { + return *x.Stdin + } + return false +} + +type UpdateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Pty *PTY `protobuf:"bytes,2,opt,name=pty,proto3,oneof" json:"pty,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateRequest) Reset() { + *x = UpdateRequest{} + mi := &file_process_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateRequest) ProtoMessage() {} + +func (x *UpdateRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateRequest.ProtoReflect.Descriptor instead. +func (*UpdateRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{6} +} + +func (x *UpdateRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +func (x *UpdateRequest) GetPty() *PTY { + if x != nil { + return x.Pty + } + return nil +} + +type UpdateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateResponse) Reset() { + *x = UpdateResponse{} + mi := &file_process_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateResponse) ProtoMessage() {} + +func (x *UpdateResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateResponse.ProtoReflect.Descriptor instead. +func (*UpdateResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{7} +} + +type ProcessEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *ProcessEvent_Start + // *ProcessEvent_Data + // *ProcessEvent_End + // *ProcessEvent_Keepalive + Event isProcessEvent_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent) Reset() { + *x = ProcessEvent{} + mi := &file_process_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent) ProtoMessage() {} + +func (x *ProcessEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{8} +} + +func (x *ProcessEvent) GetEvent() isProcessEvent_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *ProcessEvent) GetStart() *ProcessEvent_StartEvent { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_Start); ok { + return x.Start + } + } + return nil +} + +func (x *ProcessEvent) GetData() *ProcessEvent_DataEvent { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_Data); ok { + return x.Data + } + } + return nil +} + +func (x *ProcessEvent) GetEnd() *ProcessEvent_EndEvent { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_End); ok { + return x.End + } + } + return nil +} + +func (x *ProcessEvent) GetKeepalive() *ProcessEvent_KeepAlive { + if x != nil { + if x, ok := x.Event.(*ProcessEvent_Keepalive); ok { + return x.Keepalive + } + } + return nil +} + +type isProcessEvent_Event interface { + isProcessEvent_Event() +} + +type ProcessEvent_Start struct { + Start *ProcessEvent_StartEvent `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type ProcessEvent_Data struct { + Data *ProcessEvent_DataEvent `protobuf:"bytes,2,opt,name=data,proto3,oneof"` +} + +type ProcessEvent_End struct { + End *ProcessEvent_EndEvent `protobuf:"bytes,3,opt,name=end,proto3,oneof"` +} + +type ProcessEvent_Keepalive struct { + Keepalive *ProcessEvent_KeepAlive `protobuf:"bytes,4,opt,name=keepalive,proto3,oneof"` +} + +func (*ProcessEvent_Start) isProcessEvent_Event() {} + +func (*ProcessEvent_Data) isProcessEvent_Event() {} + +func (*ProcessEvent_End) isProcessEvent_Event() {} + +func (*ProcessEvent_Keepalive) isProcessEvent_Event() {} + +type StartResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Event *ProcessEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartResponse) Reset() { + *x = StartResponse{} + mi := &file_process_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartResponse) ProtoMessage() {} + +func (x *StartResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StartResponse.ProtoReflect.Descriptor instead. +func (*StartResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{9} +} + +func (x *StartResponse) GetEvent() *ProcessEvent { + if x != nil { + return x.Event + } + return nil +} + +type ConnectResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Event *ProcessEvent `protobuf:"bytes,1,opt,name=event,proto3" json:"event,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectResponse) Reset() { + *x = ConnectResponse{} + mi := &file_process_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectResponse) ProtoMessage() {} + +func (x *ConnectResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectResponse.ProtoReflect.Descriptor instead. +func (*ConnectResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{10} +} + +func (x *ConnectResponse) GetEvent() *ProcessEvent { + if x != nil { + return x.Event + } + return nil +} + +type SendInputRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Input *ProcessInput `protobuf:"bytes,2,opt,name=input,proto3" json:"input,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendInputRequest) Reset() { + *x = SendInputRequest{} + mi := &file_process_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendInputRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendInputRequest) ProtoMessage() {} + +func (x *SendInputRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendInputRequest.ProtoReflect.Descriptor instead. +func (*SendInputRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{11} +} + +func (x *SendInputRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +func (x *SendInputRequest) GetInput() *ProcessInput { + if x != nil { + return x.Input + } + return nil +} + +type SendInputResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendInputResponse) Reset() { + *x = SendInputResponse{} + mi := &file_process_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendInputResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendInputResponse) ProtoMessage() {} + +func (x *SendInputResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendInputResponse.ProtoReflect.Descriptor instead. +func (*SendInputResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{12} +} + +type ProcessInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Input: + // + // *ProcessInput_Stdin + // *ProcessInput_Pty + Input isProcessInput_Input `protobuf_oneof:"input"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessInput) Reset() { + *x = ProcessInput{} + mi := &file_process_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessInput) ProtoMessage() {} + +func (x *ProcessInput) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessInput.ProtoReflect.Descriptor instead. +func (*ProcessInput) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{13} +} + +func (x *ProcessInput) GetInput() isProcessInput_Input { + if x != nil { + return x.Input + } + return nil +} + +func (x *ProcessInput) GetStdin() []byte { + if x != nil { + if x, ok := x.Input.(*ProcessInput_Stdin); ok { + return x.Stdin + } + } + return nil +} + +func (x *ProcessInput) GetPty() []byte { + if x != nil { + if x, ok := x.Input.(*ProcessInput_Pty); ok { + return x.Pty + } + } + return nil +} + +type isProcessInput_Input interface { + isProcessInput_Input() +} + +type ProcessInput_Stdin struct { + Stdin []byte `protobuf:"bytes,1,opt,name=stdin,proto3,oneof"` +} + +type ProcessInput_Pty struct { + Pty []byte `protobuf:"bytes,2,opt,name=pty,proto3,oneof"` +} + +func (*ProcessInput_Stdin) isProcessInput_Input() {} + +func (*ProcessInput_Pty) isProcessInput_Input() {} + +type StreamInputRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *StreamInputRequest_Start + // *StreamInputRequest_Data + // *StreamInputRequest_Keepalive + Event isStreamInputRequest_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest) Reset() { + *x = StreamInputRequest{} + mi := &file_process_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest) ProtoMessage() {} + +func (x *StreamInputRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest.ProtoReflect.Descriptor instead. +func (*StreamInputRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{14} +} + +func (x *StreamInputRequest) GetEvent() isStreamInputRequest_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *StreamInputRequest) GetStart() *StreamInputRequest_StartEvent { + if x != nil { + if x, ok := x.Event.(*StreamInputRequest_Start); ok { + return x.Start + } + } + return nil +} + +func (x *StreamInputRequest) GetData() *StreamInputRequest_DataEvent { + if x != nil { + if x, ok := x.Event.(*StreamInputRequest_Data); ok { + return x.Data + } + } + return nil +} + +func (x *StreamInputRequest) GetKeepalive() *StreamInputRequest_KeepAlive { + if x != nil { + if x, ok := x.Event.(*StreamInputRequest_Keepalive); ok { + return x.Keepalive + } + } + return nil +} + +type isStreamInputRequest_Event interface { + isStreamInputRequest_Event() +} + +type StreamInputRequest_Start struct { + Start *StreamInputRequest_StartEvent `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type StreamInputRequest_Data struct { + Data *StreamInputRequest_DataEvent `protobuf:"bytes,2,opt,name=data,proto3,oneof"` +} + +type StreamInputRequest_Keepalive struct { + Keepalive *StreamInputRequest_KeepAlive `protobuf:"bytes,3,opt,name=keepalive,proto3,oneof"` +} + +func (*StreamInputRequest_Start) isStreamInputRequest_Event() {} + +func (*StreamInputRequest_Data) isStreamInputRequest_Event() {} + +func (*StreamInputRequest_Keepalive) isStreamInputRequest_Event() {} + +type StreamInputResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputResponse) Reset() { + *x = StreamInputResponse{} + mi := &file_process_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputResponse) ProtoMessage() {} + +func (x *StreamInputResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputResponse.ProtoReflect.Descriptor instead. +func (*StreamInputResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{15} +} + +type SendSignalRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + Signal Signal `protobuf:"varint,2,opt,name=signal,proto3,enum=process.Signal" json:"signal,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendSignalRequest) Reset() { + *x = SendSignalRequest{} + mi := &file_process_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendSignalRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendSignalRequest) ProtoMessage() {} + +func (x *SendSignalRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendSignalRequest.ProtoReflect.Descriptor instead. +func (*SendSignalRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{16} +} + +func (x *SendSignalRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +func (x *SendSignalRequest) GetSignal() Signal { + if x != nil { + return x.Signal + } + return Signal_SIGNAL_UNSPECIFIED +} + +type SendSignalResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendSignalResponse) Reset() { + *x = SendSignalResponse{} + mi := &file_process_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendSignalResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendSignalResponse) ProtoMessage() {} + +func (x *SendSignalResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendSignalResponse.ProtoReflect.Descriptor instead. +func (*SendSignalResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{17} +} + +type CloseStdinRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CloseStdinRequest) Reset() { + *x = CloseStdinRequest{} + mi := &file_process_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CloseStdinRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CloseStdinRequest) ProtoMessage() {} + +func (x *CloseStdinRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CloseStdinRequest.ProtoReflect.Descriptor instead. +func (*CloseStdinRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{18} +} + +func (x *CloseStdinRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +type CloseStdinResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CloseStdinResponse) Reset() { + *x = CloseStdinResponse{} + mi := &file_process_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CloseStdinResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CloseStdinResponse) ProtoMessage() {} + +func (x *CloseStdinResponse) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CloseStdinResponse.ProtoReflect.Descriptor instead. +func (*CloseStdinResponse) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{19} +} + +type ConnectRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ConnectRequest) Reset() { + *x = ConnectRequest{} + mi := &file_process_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ConnectRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ConnectRequest) ProtoMessage() {} + +func (x *ConnectRequest) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ConnectRequest.ProtoReflect.Descriptor instead. +func (*ConnectRequest) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{20} +} + +func (x *ConnectRequest) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +type ProcessSelector struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Selector: + // + // *ProcessSelector_Pid + // *ProcessSelector_Tag + Selector isProcessSelector_Selector `protobuf_oneof:"selector"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessSelector) Reset() { + *x = ProcessSelector{} + mi := &file_process_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessSelector) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessSelector) ProtoMessage() {} + +func (x *ProcessSelector) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessSelector.ProtoReflect.Descriptor instead. +func (*ProcessSelector) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{21} +} + +func (x *ProcessSelector) GetSelector() isProcessSelector_Selector { + if x != nil { + return x.Selector + } + return nil +} + +func (x *ProcessSelector) GetPid() uint32 { + if x != nil { + if x, ok := x.Selector.(*ProcessSelector_Pid); ok { + return x.Pid + } + } + return 0 +} + +func (x *ProcessSelector) GetTag() string { + if x != nil { + if x, ok := x.Selector.(*ProcessSelector_Tag); ok { + return x.Tag + } + } + return "" +} + +type isProcessSelector_Selector interface { + isProcessSelector_Selector() +} + +type ProcessSelector_Pid struct { + Pid uint32 `protobuf:"varint,1,opt,name=pid,proto3,oneof"` +} + +type ProcessSelector_Tag struct { + Tag string `protobuf:"bytes,2,opt,name=tag,proto3,oneof"` +} + +func (*ProcessSelector_Pid) isProcessSelector_Selector() {} + +func (*ProcessSelector_Tag) isProcessSelector_Selector() {} + +type PTY_Size struct { + state protoimpl.MessageState `protogen:"open.v1"` + Cols uint32 `protobuf:"varint,1,opt,name=cols,proto3" json:"cols,omitempty"` + Rows uint32 `protobuf:"varint,2,opt,name=rows,proto3" json:"rows,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PTY_Size) Reset() { + *x = PTY_Size{} + mi := &file_process_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PTY_Size) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PTY_Size) ProtoMessage() {} + +func (x *PTY_Size) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PTY_Size.ProtoReflect.Descriptor instead. +func (*PTY_Size) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{0, 0} +} + +func (x *PTY_Size) GetCols() uint32 { + if x != nil { + return x.Cols + } + return 0 +} + +func (x *PTY_Size) GetRows() uint32 { + if x != nil { + return x.Rows + } + return 0 +} + +type ProcessEvent_StartEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Pid uint32 `protobuf:"varint,1,opt,name=pid,proto3" json:"pid,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_StartEvent) Reset() { + *x = ProcessEvent_StartEvent{} + mi := &file_process_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_StartEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_StartEvent) ProtoMessage() {} + +func (x *ProcessEvent_StartEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_StartEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent_StartEvent) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{8, 0} +} + +func (x *ProcessEvent_StartEvent) GetPid() uint32 { + if x != nil { + return x.Pid + } + return 0 +} + +type ProcessEvent_DataEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Output: + // + // *ProcessEvent_DataEvent_Stdout + // *ProcessEvent_DataEvent_Stderr + // *ProcessEvent_DataEvent_Pty + Output isProcessEvent_DataEvent_Output `protobuf_oneof:"output"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_DataEvent) Reset() { + *x = ProcessEvent_DataEvent{} + mi := &file_process_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_DataEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_DataEvent) ProtoMessage() {} + +func (x *ProcessEvent_DataEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_DataEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent_DataEvent) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{8, 1} +} + +func (x *ProcessEvent_DataEvent) GetOutput() isProcessEvent_DataEvent_Output { + if x != nil { + return x.Output + } + return nil +} + +func (x *ProcessEvent_DataEvent) GetStdout() []byte { + if x != nil { + if x, ok := x.Output.(*ProcessEvent_DataEvent_Stdout); ok { + return x.Stdout + } + } + return nil +} + +func (x *ProcessEvent_DataEvent) GetStderr() []byte { + if x != nil { + if x, ok := x.Output.(*ProcessEvent_DataEvent_Stderr); ok { + return x.Stderr + } + } + return nil +} + +func (x *ProcessEvent_DataEvent) GetPty() []byte { + if x != nil { + if x, ok := x.Output.(*ProcessEvent_DataEvent_Pty); ok { + return x.Pty + } + } + return nil +} + +type isProcessEvent_DataEvent_Output interface { + isProcessEvent_DataEvent_Output() +} + +type ProcessEvent_DataEvent_Stdout struct { + Stdout []byte `protobuf:"bytes,1,opt,name=stdout,proto3,oneof"` +} + +type ProcessEvent_DataEvent_Stderr struct { + Stderr []byte `protobuf:"bytes,2,opt,name=stderr,proto3,oneof"` +} + +type ProcessEvent_DataEvent_Pty struct { + Pty []byte `protobuf:"bytes,3,opt,name=pty,proto3,oneof"` +} + +func (*ProcessEvent_DataEvent_Stdout) isProcessEvent_DataEvent_Output() {} + +func (*ProcessEvent_DataEvent_Stderr) isProcessEvent_DataEvent_Output() {} + +func (*ProcessEvent_DataEvent_Pty) isProcessEvent_DataEvent_Output() {} + +type ProcessEvent_EndEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + ExitCode int32 `protobuf:"zigzag32,1,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + Exited bool `protobuf:"varint,2,opt,name=exited,proto3" json:"exited,omitempty"` + Status string `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + Error *string `protobuf:"bytes,4,opt,name=error,proto3,oneof" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_EndEvent) Reset() { + *x = ProcessEvent_EndEvent{} + mi := &file_process_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_EndEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_EndEvent) ProtoMessage() {} + +func (x *ProcessEvent_EndEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_EndEvent.ProtoReflect.Descriptor instead. +func (*ProcessEvent_EndEvent) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{8, 2} +} + +func (x *ProcessEvent_EndEvent) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +func (x *ProcessEvent_EndEvent) GetExited() bool { + if x != nil { + return x.Exited + } + return false +} + +func (x *ProcessEvent_EndEvent) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *ProcessEvent_EndEvent) GetError() string { + if x != nil && x.Error != nil { + return *x.Error + } + return "" +} + +type ProcessEvent_KeepAlive struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ProcessEvent_KeepAlive) Reset() { + *x = ProcessEvent_KeepAlive{} + mi := &file_process_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ProcessEvent_KeepAlive) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ProcessEvent_KeepAlive) ProtoMessage() {} + +func (x *ProcessEvent_KeepAlive) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ProcessEvent_KeepAlive.ProtoReflect.Descriptor instead. +func (*ProcessEvent_KeepAlive) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{8, 3} +} + +type StreamInputRequest_StartEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Process *ProcessSelector `protobuf:"bytes,1,opt,name=process,proto3" json:"process,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest_StartEvent) Reset() { + *x = StreamInputRequest_StartEvent{} + mi := &file_process_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest_StartEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest_StartEvent) ProtoMessage() {} + +func (x *StreamInputRequest_StartEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest_StartEvent.ProtoReflect.Descriptor instead. +func (*StreamInputRequest_StartEvent) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{14, 0} +} + +func (x *StreamInputRequest_StartEvent) GetProcess() *ProcessSelector { + if x != nil { + return x.Process + } + return nil +} + +type StreamInputRequest_DataEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Input *ProcessInput `protobuf:"bytes,2,opt,name=input,proto3" json:"input,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest_DataEvent) Reset() { + *x = StreamInputRequest_DataEvent{} + mi := &file_process_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest_DataEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest_DataEvent) ProtoMessage() {} + +func (x *StreamInputRequest_DataEvent) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest_DataEvent.ProtoReflect.Descriptor instead. +func (*StreamInputRequest_DataEvent) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{14, 1} +} + +func (x *StreamInputRequest_DataEvent) GetInput() *ProcessInput { + if x != nil { + return x.Input + } + return nil +} + +type StreamInputRequest_KeepAlive struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StreamInputRequest_KeepAlive) Reset() { + *x = StreamInputRequest_KeepAlive{} + mi := &file_process_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StreamInputRequest_KeepAlive) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamInputRequest_KeepAlive) ProtoMessage() {} + +func (x *StreamInputRequest_KeepAlive) ProtoReflect() protoreflect.Message { + mi := &file_process_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamInputRequest_KeepAlive.ProtoReflect.Descriptor instead. +func (*StreamInputRequest_KeepAlive) Descriptor() ([]byte, []int) { + return file_process_proto_rawDescGZIP(), []int{14, 2} +} + +var File_process_proto protoreflect.FileDescriptor + +const file_process_proto_rawDesc = "" + + "\n" + + "\rprocess.proto\x12\aprocess\"\\\n" + + "\x03PTY\x12%\n" + + "\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" + + "\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" + + "\tEnvsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x06\n" + + "\x04_cwd\"\r\n" + + "\vListRequest\"n\n" + + "\vProcessInfo\x12.\n" + + "\x06config\x18\x01 \x01(\v2\x16.process.ProcessConfigR\x06config\x12\x10\n" + + "\x03pid\x18\x02 \x01(\rR\x03pid\x12\x15\n" + + "\x03tag\x18\x03 \x01(\tH\x00R\x03tag\x88\x01\x01B\x06\n" + + "\x04_tag\"B\n" + + "\fListResponse\x122\n" + + "\tprocesses\x18\x01 \x03(\v2\x14.process.ProcessInfoR\tprocesses\"\xb1\x01\n" + + "\fStartRequest\x120\n" + + "\aprocess\x18\x01 \x01(\v2\x16.process.ProcessConfigR\aprocess\x12#\n" + + "\x03pty\x18\x02 \x01(\v2\f.process.PTYH\x00R\x03pty\x88\x01\x01\x12\x15\n" + + "\x03tag\x18\x03 \x01(\tH\x01R\x03tag\x88\x01\x01\x12\x19\n" + + "\x05stdin\x18\x04 \x01(\bH\x02R\x05stdin\x88\x01\x01B\x06\n" + + "\x04_ptyB\x06\n" + + "\x04_tagB\b\n" + + "\x06_stdin\"p\n" + + "\rUpdateRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x12#\n" + + "\x03pty\x18\x02 \x01(\v2\f.process.PTYH\x00R\x03pty\x88\x01\x01B\x06\n" + + "\x04_pty\"\x10\n" + + "\x0eUpdateResponse\"\x87\x04\n" + + "\fProcessEvent\x128\n" + + "\x05start\x18\x01 \x01(\v2 .process.ProcessEvent.StartEventH\x00R\x05start\x125\n" + + "\x04data\x18\x02 \x01(\v2\x1f.process.ProcessEvent.DataEventH\x00R\x04data\x122\n" + + "\x03end\x18\x03 \x01(\v2\x1e.process.ProcessEvent.EndEventH\x00R\x03end\x12?\n" + + "\tkeepalive\x18\x04 \x01(\v2\x1f.process.ProcessEvent.KeepAliveH\x00R\tkeepalive\x1a\x1e\n" + + "\n" + + "StartEvent\x12\x10\n" + + "\x03pid\x18\x01 \x01(\rR\x03pid\x1a]\n" + + "\tDataEvent\x12\x18\n" + + "\x06stdout\x18\x01 \x01(\fH\x00R\x06stdout\x12\x18\n" + + "\x06stderr\x18\x02 \x01(\fH\x00R\x06stderr\x12\x12\n" + + "\x03pty\x18\x03 \x01(\fH\x00R\x03ptyB\b\n" + + "\x06output\x1a|\n" + + "\bEndEvent\x12\x1b\n" + + "\texit_code\x18\x01 \x01(\x11R\bexitCode\x12\x16\n" + + "\x06exited\x18\x02 \x01(\bR\x06exited\x12\x16\n" + + "\x06status\x18\x03 \x01(\tR\x06status\x12\x19\n" + + "\x05error\x18\x04 \x01(\tH\x00R\x05error\x88\x01\x01B\b\n" + + "\x06_error\x1a\v\n" + + "\tKeepAliveB\a\n" + + "\x05event\"<\n" + + "\rStartResponse\x12+\n" + + "\x05event\x18\x01 \x01(\v2\x15.process.ProcessEventR\x05event\">\n" + + "\x0fConnectResponse\x12+\n" + + "\x05event\x18\x01 \x01(\v2\x15.process.ProcessEventR\x05event\"s\n" + + "\x10SendInputRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x12+\n" + + "\x05input\x18\x02 \x01(\v2\x15.process.ProcessInputR\x05input\"\x13\n" + + "\x11SendInputResponse\"C\n" + + "\fProcessInput\x12\x16\n" + + "\x05stdin\x18\x01 \x01(\fH\x00R\x05stdin\x12\x12\n" + + "\x03pty\x18\x02 \x01(\fH\x00R\x03ptyB\a\n" + + "\x05input\"\xea\x02\n" + + "\x12StreamInputRequest\x12>\n" + + "\x05start\x18\x01 \x01(\v2&.process.StreamInputRequest.StartEventH\x00R\x05start\x12;\n" + + "\x04data\x18\x02 \x01(\v2%.process.StreamInputRequest.DataEventH\x00R\x04data\x12E\n" + + "\tkeepalive\x18\x03 \x01(\v2%.process.StreamInputRequest.KeepAliveH\x00R\tkeepalive\x1a@\n" + + "\n" + + "StartEvent\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x1a8\n" + + "\tDataEvent\x12+\n" + + "\x05input\x18\x02 \x01(\v2\x15.process.ProcessInputR\x05input\x1a\v\n" + + "\tKeepAliveB\a\n" + + "\x05event\"\x15\n" + + "\x13StreamInputResponse\"p\n" + + "\x11SendSignalRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\x12'\n" + + "\x06signal\x18\x02 \x01(\x0e2\x0f.process.SignalR\x06signal\"\x14\n" + + "\x12SendSignalResponse\"G\n" + + "\x11CloseStdinRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\"\x14\n" + + "\x12CloseStdinResponse\"D\n" + + "\x0eConnectRequest\x122\n" + + "\aprocess\x18\x01 \x01(\v2\x18.process.ProcessSelectorR\aprocess\"E\n" + + "\x0fProcessSelector\x12\x12\n" + + "\x03pid\x18\x01 \x01(\rH\x00R\x03pid\x12\x12\n" + + "\x03tag\x18\x02 \x01(\tH\x00R\x03tagB\n" + + "\n" + + "\bselector*H\n" + + "\x06Signal\x12\x16\n" + + "\x12SIGNAL_UNSPECIFIED\x10\x00\x12\x12\n" + + "\x0eSIGNAL_SIGTERM\x10\x0f\x12\x12\n" + + "\x0eSIGNAL_SIGKILL\x10\t2\x91\x04\n" + + "\aProcess\x123\n" + + "\x04List\x12\x14.process.ListRequest\x1a\x15.process.ListResponse\x12>\n" + + "\aConnect\x12\x17.process.ConnectRequest\x1a\x18.process.ConnectResponse0\x01\x128\n" + + "\x05Start\x12\x15.process.StartRequest\x1a\x16.process.StartResponse0\x01\x129\n" + + "\x06Update\x12\x16.process.UpdateRequest\x1a\x17.process.UpdateResponse\x12J\n" + + "\vStreamInput\x12\x1b.process.StreamInputRequest\x1a\x1c.process.StreamInputResponse(\x01\x12B\n" + + "\tSendInput\x12\x19.process.SendInputRequest\x1a\x1a.process.SendInputResponse\x12E\n" + + "\n" + + "SendSignal\x12\x1a.process.SendSignalRequest\x1a\x1b.process.SendSignalResponse\x12E\n" + + "\n" + + "CloseStdin\x12\x1a.process.CloseStdinRequest\x1a\x1b.process.CloseStdinResponseB\x83\x01\n" + + "\vcom.processB\fProcessProtoP\x01Z*git.omukk.dev/wrenn/sandbox/proto/envd/gen\xa2\x02\x03PXX\xaa\x02\aProcess\xca\x02\aProcess\xe2\x02\x13Process\\GPBMetadata\xea\x02\aProcessb\x06proto3" + +var ( + file_process_proto_rawDescOnce sync.Once + file_process_proto_rawDescData []byte +) + +func file_process_proto_rawDescGZIP() []byte { + file_process_proto_rawDescOnce.Do(func() { + file_process_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_process_proto_rawDesc), len(file_process_proto_rawDesc))) + }) + return file_process_proto_rawDescData +} + +var file_process_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_process_proto_msgTypes = make([]protoimpl.MessageInfo, 31) +var file_process_proto_goTypes = []any{ + (Signal)(0), // 0: process.Signal + (*PTY)(nil), // 1: process.PTY + (*ProcessConfig)(nil), // 2: process.ProcessConfig + (*ListRequest)(nil), // 3: process.ListRequest + (*ProcessInfo)(nil), // 4: process.ProcessInfo + (*ListResponse)(nil), // 5: process.ListResponse + (*StartRequest)(nil), // 6: process.StartRequest + (*UpdateRequest)(nil), // 7: process.UpdateRequest + (*UpdateResponse)(nil), // 8: process.UpdateResponse + (*ProcessEvent)(nil), // 9: process.ProcessEvent + (*StartResponse)(nil), // 10: process.StartResponse + (*ConnectResponse)(nil), // 11: process.ConnectResponse + (*SendInputRequest)(nil), // 12: process.SendInputRequest + (*SendInputResponse)(nil), // 13: process.SendInputResponse + (*ProcessInput)(nil), // 14: process.ProcessInput + (*StreamInputRequest)(nil), // 15: process.StreamInputRequest + (*StreamInputResponse)(nil), // 16: process.StreamInputResponse + (*SendSignalRequest)(nil), // 17: process.SendSignalRequest + (*SendSignalResponse)(nil), // 18: process.SendSignalResponse + (*CloseStdinRequest)(nil), // 19: process.CloseStdinRequest + (*CloseStdinResponse)(nil), // 20: process.CloseStdinResponse + (*ConnectRequest)(nil), // 21: process.ConnectRequest + (*ProcessSelector)(nil), // 22: process.ProcessSelector + (*PTY_Size)(nil), // 23: process.PTY.Size + nil, // 24: process.ProcessConfig.EnvsEntry + (*ProcessEvent_StartEvent)(nil), // 25: process.ProcessEvent.StartEvent + (*ProcessEvent_DataEvent)(nil), // 26: process.ProcessEvent.DataEvent + (*ProcessEvent_EndEvent)(nil), // 27: process.ProcessEvent.EndEvent + (*ProcessEvent_KeepAlive)(nil), // 28: process.ProcessEvent.KeepAlive + (*StreamInputRequest_StartEvent)(nil), // 29: process.StreamInputRequest.StartEvent + (*StreamInputRequest_DataEvent)(nil), // 30: process.StreamInputRequest.DataEvent + (*StreamInputRequest_KeepAlive)(nil), // 31: process.StreamInputRequest.KeepAlive +} +var file_process_proto_depIdxs = []int32{ + 23, // 0: process.PTY.size:type_name -> process.PTY.Size + 24, // 1: process.ProcessConfig.envs:type_name -> process.ProcessConfig.EnvsEntry + 2, // 2: process.ProcessInfo.config:type_name -> process.ProcessConfig + 4, // 3: process.ListResponse.processes:type_name -> process.ProcessInfo + 2, // 4: process.StartRequest.process:type_name -> process.ProcessConfig + 1, // 5: process.StartRequest.pty:type_name -> process.PTY + 22, // 6: process.UpdateRequest.process:type_name -> process.ProcessSelector + 1, // 7: process.UpdateRequest.pty:type_name -> process.PTY + 25, // 8: process.ProcessEvent.start:type_name -> process.ProcessEvent.StartEvent + 26, // 9: process.ProcessEvent.data:type_name -> process.ProcessEvent.DataEvent + 27, // 10: process.ProcessEvent.end:type_name -> process.ProcessEvent.EndEvent + 28, // 11: process.ProcessEvent.keepalive:type_name -> process.ProcessEvent.KeepAlive + 9, // 12: process.StartResponse.event:type_name -> process.ProcessEvent + 9, // 13: process.ConnectResponse.event:type_name -> process.ProcessEvent + 22, // 14: process.SendInputRequest.process:type_name -> process.ProcessSelector + 14, // 15: process.SendInputRequest.input:type_name -> process.ProcessInput + 29, // 16: process.StreamInputRequest.start:type_name -> process.StreamInputRequest.StartEvent + 30, // 17: process.StreamInputRequest.data:type_name -> process.StreamInputRequest.DataEvent + 31, // 18: process.StreamInputRequest.keepalive:type_name -> process.StreamInputRequest.KeepAlive + 22, // 19: process.SendSignalRequest.process:type_name -> process.ProcessSelector + 0, // 20: process.SendSignalRequest.signal:type_name -> process.Signal + 22, // 21: process.CloseStdinRequest.process:type_name -> process.ProcessSelector + 22, // 22: process.ConnectRequest.process:type_name -> process.ProcessSelector + 22, // 23: process.StreamInputRequest.StartEvent.process:type_name -> process.ProcessSelector + 14, // 24: process.StreamInputRequest.DataEvent.input:type_name -> process.ProcessInput + 3, // 25: process.Process.List:input_type -> process.ListRequest + 21, // 26: process.Process.Connect:input_type -> process.ConnectRequest + 6, // 27: process.Process.Start:input_type -> process.StartRequest + 7, // 28: process.Process.Update:input_type -> process.UpdateRequest + 15, // 29: process.Process.StreamInput:input_type -> process.StreamInputRequest + 12, // 30: process.Process.SendInput:input_type -> process.SendInputRequest + 17, // 31: process.Process.SendSignal:input_type -> process.SendSignalRequest + 19, // 32: process.Process.CloseStdin:input_type -> process.CloseStdinRequest + 5, // 33: process.Process.List:output_type -> process.ListResponse + 11, // 34: process.Process.Connect:output_type -> process.ConnectResponse + 10, // 35: process.Process.Start:output_type -> process.StartResponse + 8, // 36: process.Process.Update:output_type -> process.UpdateResponse + 16, // 37: process.Process.StreamInput:output_type -> process.StreamInputResponse + 13, // 38: process.Process.SendInput:output_type -> process.SendInputResponse + 18, // 39: process.Process.SendSignal:output_type -> process.SendSignalResponse + 20, // 40: process.Process.CloseStdin:output_type -> process.CloseStdinResponse + 33, // [33:41] is the sub-list for method output_type + 25, // [25:33] is the sub-list for method input_type + 25, // [25:25] is the sub-list for extension type_name + 25, // [25:25] is the sub-list for extension extendee + 0, // [0:25] is the sub-list for field type_name +} + +func init() { file_process_proto_init() } +func file_process_proto_init() { + if File_process_proto != nil { + return + } + file_process_proto_msgTypes[1].OneofWrappers = []any{} + file_process_proto_msgTypes[3].OneofWrappers = []any{} + file_process_proto_msgTypes[5].OneofWrappers = []any{} + file_process_proto_msgTypes[6].OneofWrappers = []any{} + file_process_proto_msgTypes[8].OneofWrappers = []any{ + (*ProcessEvent_Start)(nil), + (*ProcessEvent_Data)(nil), + (*ProcessEvent_End)(nil), + (*ProcessEvent_Keepalive)(nil), + } + file_process_proto_msgTypes[13].OneofWrappers = []any{ + (*ProcessInput_Stdin)(nil), + (*ProcessInput_Pty)(nil), + } + file_process_proto_msgTypes[14].OneofWrappers = []any{ + (*StreamInputRequest_Start)(nil), + (*StreamInputRequest_Data)(nil), + (*StreamInputRequest_Keepalive)(nil), + } + file_process_proto_msgTypes[21].OneofWrappers = []any{ + (*ProcessSelector_Pid)(nil), + (*ProcessSelector_Tag)(nil), + } + file_process_proto_msgTypes[25].OneofWrappers = []any{ + (*ProcessEvent_DataEvent_Stdout)(nil), + (*ProcessEvent_DataEvent_Stderr)(nil), + (*ProcessEvent_DataEvent_Pty)(nil), + } + file_process_proto_msgTypes[26].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_process_proto_rawDesc), len(file_process_proto_rawDesc)), + NumEnums: 1, + NumMessages: 31, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_process_proto_goTypes, + DependencyIndexes: file_process_proto_depIdxs, + EnumInfos: file_process_proto_enumTypes, + MessageInfos: file_process_proto_msgTypes, + }.Build() + File_process_proto = out.File + file_process_proto_goTypes = nil + file_process_proto_depIdxs = nil +} diff --git a/proto/envd/process.proto b/proto/envd/process.proto index e69de29..414daec 100644 --- a/proto/envd/process.proto +++ b/proto/envd/process.proto @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package process; + +service Process { + rpc List(ListRequest) returns (ListResponse); + + rpc Connect(ConnectRequest) returns (stream ConnectResponse); + rpc Start(StartRequest) returns (stream StartResponse); + + rpc Update(UpdateRequest) returns (UpdateResponse); + + // Client input stream ensures ordering of messages + rpc StreamInput(stream StreamInputRequest) returns (StreamInputResponse); + rpc SendInput(SendInputRequest) returns (SendInputResponse); + rpc SendSignal(SendSignalRequest) returns (SendSignalResponse); + + // Close stdin to signal EOF to the process. + // Only works for non-PTY processes. For PTY, send Ctrl+D (0x04) instead. + rpc CloseStdin(CloseStdinRequest) returns (CloseStdinResponse); +} + +message PTY { + Size size = 1; + + message Size { + uint32 cols = 1; + uint32 rows = 2; + } +} + +message ProcessConfig { + string cmd = 1; + repeated string args = 2; + + map envs = 3; + optional string cwd = 4; +} + +message ListRequest {} + +message ProcessInfo { + ProcessConfig config = 1; + uint32 pid = 2; + optional string tag = 3; +} + +message ListResponse { + repeated ProcessInfo processes = 1; +} + +message StartRequest { + ProcessConfig process = 1; + optional PTY pty = 2; + optional string tag = 3; + // This is optional for backwards compatibility. + // We default to true. New SDK versions will set this to false by default. + optional bool stdin = 4; +} + +message UpdateRequest { + ProcessSelector process = 1; + + optional PTY pty = 2; +} + +message UpdateResponse {} + +message ProcessEvent { + oneof event { + StartEvent start = 1; + DataEvent data = 2; + EndEvent end = 3; + KeepAlive keepalive = 4; + } + + message StartEvent { + uint32 pid = 1; + } + + message DataEvent { + oneof output { + bytes stdout = 1; + bytes stderr = 2; + bytes pty = 3; + } + } + + message EndEvent { + sint32 exit_code = 1; + bool exited = 2; + string status = 3; + optional string error = 4; + } + + message KeepAlive {} +} + +message StartResponse { + ProcessEvent event = 1; +} + +message ConnectResponse { + ProcessEvent event = 1; +} + +message SendInputRequest { + ProcessSelector process = 1; + + ProcessInput input = 2; +} + +message SendInputResponse {} + +message ProcessInput { + oneof input { + bytes stdin = 1; + bytes pty = 2; + } +} + +message StreamInputRequest { + oneof event { + StartEvent start = 1; + DataEvent data = 2; + KeepAlive keepalive = 3; + } + + message StartEvent { + ProcessSelector process = 1; + } + + message DataEvent { + ProcessInput input = 2; + } + + message KeepAlive {} +} + +message StreamInputResponse {} + +enum Signal { + SIGNAL_UNSPECIFIED = 0; + SIGNAL_SIGTERM = 15; + SIGNAL_SIGKILL = 9; +} + +message SendSignalRequest { + ProcessSelector process = 1; + + Signal signal = 2; +} + +message SendSignalResponse {} + +message CloseStdinRequest { + ProcessSelector process = 1; +} + +message CloseStdinResponse {} + +message ConnectRequest { + ProcessSelector process = 1; +} + +message ProcessSelector { + oneof selector { + uint32 pid = 1; + string tag = 2; + } +} diff --git a/proto/hostagent/buf.gen.yaml b/proto/hostagent/buf.gen.yaml new file mode 100644 index 0000000..acba972 --- /dev/null +++ b/proto/hostagent/buf.gen.yaml @@ -0,0 +1,13 @@ +version: v2 +plugins: + - protoc_builtin: go + out: gen + opt: paths=source_relative + - local: protoc-gen-connect-go + out: gen + opt: paths=source_relative +managed: + enabled: true + override: + - file_option: go_package_prefix + value: git.omukk.dev/wrenn/sandbox/proto/hostagent/gen diff --git a/proto/hostagent/buf.yaml b/proto/hostagent/buf.yaml new file mode 100644 index 0000000..b869981 --- /dev/null +++ b/proto/hostagent/buf.yaml @@ -0,0 +1,3 @@ +version: v2 +modules: + - path: . diff --git a/proto/hostagent/gen/hostagent.pb.go b/proto/hostagent/gen/hostagent.pb.go new file mode 100644 index 0000000..447f1f7 --- /dev/null +++ b/proto/hostagent/gen/hostagent.pb.go @@ -0,0 +1,2101 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: hostagent.proto + +package hostagentv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type CreateSandboxRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Sandbox ID assigned by the control plane. If empty, the host agent generates one. + SandboxId string `protobuf:"bytes,5,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // Template name (e.g., "minimal", "python311"). Determines base rootfs. + Template string `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"` + // Number of virtual CPUs (default: 1). + Vcpus int32 `protobuf:"varint,2,opt,name=vcpus,proto3" json:"vcpus,omitempty"` + // Memory in MB (default: 512). + MemoryMb int32 `protobuf:"varint,3,opt,name=memory_mb,json=memoryMb,proto3" json:"memory_mb,omitempty"` + // TTL in seconds. Sandbox is auto-paused after this duration of + // inactivity. 0 means no auto-pause. + TimeoutSec int32 `protobuf:"varint,4,opt,name=timeout_sec,json=timeoutSec,proto3" json:"timeout_sec,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateSandboxRequest) Reset() { + *x = CreateSandboxRequest{} + mi := &file_hostagent_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateSandboxRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSandboxRequest) ProtoMessage() {} + +func (x *CreateSandboxRequest) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateSandboxRequest.ProtoReflect.Descriptor instead. +func (*CreateSandboxRequest) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{0} +} + +func (x *CreateSandboxRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *CreateSandboxRequest) GetTemplate() string { + if x != nil { + return x.Template + } + return "" +} + +func (x *CreateSandboxRequest) GetVcpus() int32 { + if x != nil { + return x.Vcpus + } + return 0 +} + +func (x *CreateSandboxRequest) GetMemoryMb() int32 { + if x != nil { + return x.MemoryMb + } + return 0 +} + +func (x *CreateSandboxRequest) GetTimeoutSec() int32 { + if x != nil { + return x.TimeoutSec + } + return 0 +} + +type CreateSandboxResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + HostIp string `protobuf:"bytes,3,opt,name=host_ip,json=hostIp,proto3" json:"host_ip,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateSandboxResponse) Reset() { + *x = CreateSandboxResponse{} + mi := &file_hostagent_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateSandboxResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSandboxResponse) ProtoMessage() {} + +func (x *CreateSandboxResponse) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateSandboxResponse.ProtoReflect.Descriptor instead. +func (*CreateSandboxResponse) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{1} +} + +func (x *CreateSandboxResponse) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *CreateSandboxResponse) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *CreateSandboxResponse) GetHostIp() string { + if x != nil { + return x.HostIp + } + return "" +} + +type DestroySandboxRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DestroySandboxRequest) Reset() { + *x = DestroySandboxRequest{} + mi := &file_hostagent_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DestroySandboxRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DestroySandboxRequest) ProtoMessage() {} + +func (x *DestroySandboxRequest) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DestroySandboxRequest.ProtoReflect.Descriptor instead. +func (*DestroySandboxRequest) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{2} +} + +func (x *DestroySandboxRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +type DestroySandboxResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DestroySandboxResponse) Reset() { + *x = DestroySandboxResponse{} + mi := &file_hostagent_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DestroySandboxResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DestroySandboxResponse) ProtoMessage() {} + +func (x *DestroySandboxResponse) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DestroySandboxResponse.ProtoReflect.Descriptor instead. +func (*DestroySandboxResponse) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{3} +} + +type PauseSandboxRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PauseSandboxRequest) Reset() { + *x = PauseSandboxRequest{} + mi := &file_hostagent_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PauseSandboxRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PauseSandboxRequest) ProtoMessage() {} + +func (x *PauseSandboxRequest) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PauseSandboxRequest.ProtoReflect.Descriptor instead. +func (*PauseSandboxRequest) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{4} +} + +func (x *PauseSandboxRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +type PauseSandboxResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PauseSandboxResponse) Reset() { + *x = PauseSandboxResponse{} + mi := &file_hostagent_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PauseSandboxResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PauseSandboxResponse) ProtoMessage() {} + +func (x *PauseSandboxResponse) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PauseSandboxResponse.ProtoReflect.Descriptor instead. +func (*PauseSandboxResponse) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{5} +} + +type ResumeSandboxRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + // TTL in seconds restored from the DB so the reaper can auto-pause + // the sandbox again after inactivity. 0 means no auto-pause. + TimeoutSec int32 `protobuf:"varint,2,opt,name=timeout_sec,json=timeoutSec,proto3" json:"timeout_sec,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResumeSandboxRequest) Reset() { + *x = ResumeSandboxRequest{} + mi := &file_hostagent_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResumeSandboxRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResumeSandboxRequest) ProtoMessage() {} + +func (x *ResumeSandboxRequest) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResumeSandboxRequest.ProtoReflect.Descriptor instead. +func (*ResumeSandboxRequest) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{6} +} + +func (x *ResumeSandboxRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *ResumeSandboxRequest) GetTimeoutSec() int32 { + if x != nil { + return x.TimeoutSec + } + return 0 +} + +type ResumeSandboxResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + HostIp string `protobuf:"bytes,3,opt,name=host_ip,json=hostIp,proto3" json:"host_ip,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResumeSandboxResponse) Reset() { + *x = ResumeSandboxResponse{} + mi := &file_hostagent_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResumeSandboxResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResumeSandboxResponse) ProtoMessage() {} + +func (x *ResumeSandboxResponse) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResumeSandboxResponse.ProtoReflect.Descriptor instead. +func (*ResumeSandboxResponse) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{7} +} + +func (x *ResumeSandboxResponse) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *ResumeSandboxResponse) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *ResumeSandboxResponse) GetHostIp() string { + if x != nil { + return x.HostIp + } + return "" +} + +type CreateSnapshotRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateSnapshotRequest) Reset() { + *x = CreateSnapshotRequest{} + mi := &file_hostagent_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateSnapshotRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSnapshotRequest) ProtoMessage() {} + +func (x *CreateSnapshotRequest) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateSnapshotRequest.ProtoReflect.Descriptor instead. +func (*CreateSnapshotRequest) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{8} +} + +func (x *CreateSnapshotRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *CreateSnapshotRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type CreateSnapshotResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + SizeBytes int64 `protobuf:"varint,2,opt,name=size_bytes,json=sizeBytes,proto3" json:"size_bytes,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateSnapshotResponse) Reset() { + *x = CreateSnapshotResponse{} + mi := &file_hostagent_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateSnapshotResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSnapshotResponse) ProtoMessage() {} + +func (x *CreateSnapshotResponse) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateSnapshotResponse.ProtoReflect.Descriptor instead. +func (*CreateSnapshotResponse) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{9} +} + +func (x *CreateSnapshotResponse) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *CreateSnapshotResponse) GetSizeBytes() int64 { + if x != nil { + return x.SizeBytes + } + return 0 +} + +type DeleteSnapshotRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteSnapshotRequest) Reset() { + *x = DeleteSnapshotRequest{} + mi := &file_hostagent_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteSnapshotRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteSnapshotRequest) ProtoMessage() {} + +func (x *DeleteSnapshotRequest) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteSnapshotRequest.ProtoReflect.Descriptor instead. +func (*DeleteSnapshotRequest) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{10} +} + +func (x *DeleteSnapshotRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type DeleteSnapshotResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteSnapshotResponse) Reset() { + *x = DeleteSnapshotResponse{} + mi := &file_hostagent_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteSnapshotResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteSnapshotResponse) ProtoMessage() {} + +func (x *DeleteSnapshotResponse) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteSnapshotResponse.ProtoReflect.Descriptor instead. +func (*DeleteSnapshotResponse) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{11} +} + +type ExecRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + Cmd string `protobuf:"bytes,2,opt,name=cmd,proto3" json:"cmd,omitempty"` + Args []string `protobuf:"bytes,3,rep,name=args,proto3" json:"args,omitempty"` + // Timeout for the command in seconds (default: 30). + TimeoutSec int32 `protobuf:"varint,4,opt,name=timeout_sec,json=timeoutSec,proto3" json:"timeout_sec,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecRequest) Reset() { + *x = ExecRequest{} + mi := &file_hostagent_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecRequest) ProtoMessage() {} + +func (x *ExecRequest) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecRequest.ProtoReflect.Descriptor instead. +func (*ExecRequest) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{12} +} + +func (x *ExecRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *ExecRequest) GetCmd() string { + if x != nil { + return x.Cmd + } + return "" +} + +func (x *ExecRequest) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + +func (x *ExecRequest) GetTimeoutSec() int32 { + if x != nil { + return x.TimeoutSec + } + return 0 +} + +type ExecResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Stdout []byte `protobuf:"bytes,1,opt,name=stdout,proto3" json:"stdout,omitempty"` + Stderr []byte `protobuf:"bytes,2,opt,name=stderr,proto3" json:"stderr,omitempty"` + ExitCode int32 `protobuf:"varint,3,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecResponse) Reset() { + *x = ExecResponse{} + mi := &file_hostagent_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecResponse) ProtoMessage() {} + +func (x *ExecResponse) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecResponse.ProtoReflect.Descriptor instead. +func (*ExecResponse) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{13} +} + +func (x *ExecResponse) GetStdout() []byte { + if x != nil { + return x.Stdout + } + return nil +} + +func (x *ExecResponse) GetStderr() []byte { + if x != nil { + return x.Stderr + } + return nil +} + +func (x *ExecResponse) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +type ListSandboxesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSandboxesRequest) Reset() { + *x = ListSandboxesRequest{} + mi := &file_hostagent_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSandboxesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSandboxesRequest) ProtoMessage() {} + +func (x *ListSandboxesRequest) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[14] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSandboxesRequest.ProtoReflect.Descriptor instead. +func (*ListSandboxesRequest) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{14} +} + +type ListSandboxesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sandboxes []*SandboxInfo `protobuf:"bytes,1,rep,name=sandboxes,proto3" json:"sandboxes,omitempty"` + // IDs of sandboxes that were automatically paused by the TTL reaper + // since the last call. Drained on read. + AutoPausedSandboxIds []string `protobuf:"bytes,2,rep,name=auto_paused_sandbox_ids,json=autoPausedSandboxIds,proto3" json:"auto_paused_sandbox_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSandboxesResponse) Reset() { + *x = ListSandboxesResponse{} + mi := &file_hostagent_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSandboxesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSandboxesResponse) ProtoMessage() {} + +func (x *ListSandboxesResponse) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSandboxesResponse.ProtoReflect.Descriptor instead. +func (*ListSandboxesResponse) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{15} +} + +func (x *ListSandboxesResponse) GetSandboxes() []*SandboxInfo { + if x != nil { + return x.Sandboxes + } + return nil +} + +func (x *ListSandboxesResponse) GetAutoPausedSandboxIds() []string { + if x != nil { + return x.AutoPausedSandboxIds + } + return nil +} + +type SandboxInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` + Template string `protobuf:"bytes,3,opt,name=template,proto3" json:"template,omitempty"` + Vcpus int32 `protobuf:"varint,4,opt,name=vcpus,proto3" json:"vcpus,omitempty"` + MemoryMb int32 `protobuf:"varint,5,opt,name=memory_mb,json=memoryMb,proto3" json:"memory_mb,omitempty"` + HostIp string `protobuf:"bytes,6,opt,name=host_ip,json=hostIp,proto3" json:"host_ip,omitempty"` + CreatedAtUnix int64 `protobuf:"varint,7,opt,name=created_at_unix,json=createdAtUnix,proto3" json:"created_at_unix,omitempty"` + LastActiveAtUnix int64 `protobuf:"varint,8,opt,name=last_active_at_unix,json=lastActiveAtUnix,proto3" json:"last_active_at_unix,omitempty"` + TimeoutSec int32 `protobuf:"varint,9,opt,name=timeout_sec,json=timeoutSec,proto3" json:"timeout_sec,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SandboxInfo) Reset() { + *x = SandboxInfo{} + mi := &file_hostagent_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SandboxInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SandboxInfo) ProtoMessage() {} + +func (x *SandboxInfo) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SandboxInfo.ProtoReflect.Descriptor instead. +func (*SandboxInfo) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{16} +} + +func (x *SandboxInfo) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *SandboxInfo) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *SandboxInfo) GetTemplate() string { + if x != nil { + return x.Template + } + return "" +} + +func (x *SandboxInfo) GetVcpus() int32 { + if x != nil { + return x.Vcpus + } + return 0 +} + +func (x *SandboxInfo) GetMemoryMb() int32 { + if x != nil { + return x.MemoryMb + } + return 0 +} + +func (x *SandboxInfo) GetHostIp() string { + if x != nil { + return x.HostIp + } + return "" +} + +func (x *SandboxInfo) GetCreatedAtUnix() int64 { + if x != nil { + return x.CreatedAtUnix + } + return 0 +} + +func (x *SandboxInfo) GetLastActiveAtUnix() int64 { + if x != nil { + return x.LastActiveAtUnix + } + return 0 +} + +func (x *SandboxInfo) GetTimeoutSec() int32 { + if x != nil { + return x.TimeoutSec + } + return 0 +} + +type WriteFileRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + Content []byte `protobuf:"bytes,3,opt,name=content,proto3" json:"content,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WriteFileRequest) Reset() { + *x = WriteFileRequest{} + mi := &file_hostagent_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WriteFileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WriteFileRequest) ProtoMessage() {} + +func (x *WriteFileRequest) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WriteFileRequest.ProtoReflect.Descriptor instead. +func (*WriteFileRequest) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{17} +} + +func (x *WriteFileRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *WriteFileRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *WriteFileRequest) GetContent() []byte { + if x != nil { + return x.Content + } + return nil +} + +type WriteFileResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WriteFileResponse) Reset() { + *x = WriteFileResponse{} + mi := &file_hostagent_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WriteFileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WriteFileResponse) ProtoMessage() {} + +func (x *WriteFileResponse) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WriteFileResponse.ProtoReflect.Descriptor instead. +func (*WriteFileResponse) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{18} +} + +type ReadFileRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReadFileRequest) Reset() { + *x = ReadFileRequest{} + mi := &file_hostagent_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReadFileRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReadFileRequest) ProtoMessage() {} + +func (x *ReadFileRequest) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReadFileRequest.ProtoReflect.Descriptor instead. +func (*ReadFileRequest) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{19} +} + +func (x *ReadFileRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *ReadFileRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type ReadFileResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Content []byte `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReadFileResponse) Reset() { + *x = ReadFileResponse{} + mi := &file_hostagent_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReadFileResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReadFileResponse) ProtoMessage() {} + +func (x *ReadFileResponse) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReadFileResponse.ProtoReflect.Descriptor instead. +func (*ReadFileResponse) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{20} +} + +func (x *ReadFileResponse) GetContent() []byte { + if x != nil { + return x.Content + } + return nil +} + +type ExecStreamRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + Cmd string `protobuf:"bytes,2,opt,name=cmd,proto3" json:"cmd,omitempty"` + Args []string `protobuf:"bytes,3,rep,name=args,proto3" json:"args,omitempty"` + TimeoutSec int32 `protobuf:"varint,4,opt,name=timeout_sec,json=timeoutSec,proto3" json:"timeout_sec,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecStreamRequest) Reset() { + *x = ExecStreamRequest{} + mi := &file_hostagent_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecStreamRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecStreamRequest) ProtoMessage() {} + +func (x *ExecStreamRequest) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecStreamRequest.ProtoReflect.Descriptor instead. +func (*ExecStreamRequest) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{21} +} + +func (x *ExecStreamRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *ExecStreamRequest) GetCmd() string { + if x != nil { + return x.Cmd + } + return "" +} + +func (x *ExecStreamRequest) GetArgs() []string { + if x != nil { + return x.Args + } + return nil +} + +func (x *ExecStreamRequest) GetTimeoutSec() int32 { + if x != nil { + return x.TimeoutSec + } + return 0 +} + +type ExecStreamResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Event: + // + // *ExecStreamResponse_Start + // *ExecStreamResponse_Data + // *ExecStreamResponse_End + Event isExecStreamResponse_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecStreamResponse) Reset() { + *x = ExecStreamResponse{} + mi := &file_hostagent_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecStreamResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecStreamResponse) ProtoMessage() {} + +func (x *ExecStreamResponse) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecStreamResponse.ProtoReflect.Descriptor instead. +func (*ExecStreamResponse) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{22} +} + +func (x *ExecStreamResponse) GetEvent() isExecStreamResponse_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *ExecStreamResponse) GetStart() *ExecStreamStart { + if x != nil { + if x, ok := x.Event.(*ExecStreamResponse_Start); ok { + return x.Start + } + } + return nil +} + +func (x *ExecStreamResponse) GetData() *ExecStreamData { + if x != nil { + if x, ok := x.Event.(*ExecStreamResponse_Data); ok { + return x.Data + } + } + return nil +} + +func (x *ExecStreamResponse) GetEnd() *ExecStreamEnd { + if x != nil { + if x, ok := x.Event.(*ExecStreamResponse_End); ok { + return x.End + } + } + return nil +} + +type isExecStreamResponse_Event interface { + isExecStreamResponse_Event() +} + +type ExecStreamResponse_Start struct { + Start *ExecStreamStart `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type ExecStreamResponse_Data struct { + Data *ExecStreamData `protobuf:"bytes,2,opt,name=data,proto3,oneof"` +} + +type ExecStreamResponse_End struct { + End *ExecStreamEnd `protobuf:"bytes,3,opt,name=end,proto3,oneof"` +} + +func (*ExecStreamResponse_Start) isExecStreamResponse_Event() {} + +func (*ExecStreamResponse_Data) isExecStreamResponse_Event() {} + +func (*ExecStreamResponse_End) isExecStreamResponse_Event() {} + +type ExecStreamStart struct { + state protoimpl.MessageState `protogen:"open.v1"` + Pid uint32 `protobuf:"varint,1,opt,name=pid,proto3" json:"pid,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecStreamStart) Reset() { + *x = ExecStreamStart{} + mi := &file_hostagent_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecStreamStart) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecStreamStart) ProtoMessage() {} + +func (x *ExecStreamStart) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecStreamStart.ProtoReflect.Descriptor instead. +func (*ExecStreamStart) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{23} +} + +func (x *ExecStreamStart) GetPid() uint32 { + if x != nil { + return x.Pid + } + return 0 +} + +type ExecStreamData struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Output: + // + // *ExecStreamData_Stdout + // *ExecStreamData_Stderr + Output isExecStreamData_Output `protobuf_oneof:"output"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecStreamData) Reset() { + *x = ExecStreamData{} + mi := &file_hostagent_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecStreamData) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecStreamData) ProtoMessage() {} + +func (x *ExecStreamData) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecStreamData.ProtoReflect.Descriptor instead. +func (*ExecStreamData) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{24} +} + +func (x *ExecStreamData) GetOutput() isExecStreamData_Output { + if x != nil { + return x.Output + } + return nil +} + +func (x *ExecStreamData) GetStdout() []byte { + if x != nil { + if x, ok := x.Output.(*ExecStreamData_Stdout); ok { + return x.Stdout + } + } + return nil +} + +func (x *ExecStreamData) GetStderr() []byte { + if x != nil { + if x, ok := x.Output.(*ExecStreamData_Stderr); ok { + return x.Stderr + } + } + return nil +} + +type isExecStreamData_Output interface { + isExecStreamData_Output() +} + +type ExecStreamData_Stdout struct { + Stdout []byte `protobuf:"bytes,1,opt,name=stdout,proto3,oneof"` +} + +type ExecStreamData_Stderr struct { + Stderr []byte `protobuf:"bytes,2,opt,name=stderr,proto3,oneof"` +} + +func (*ExecStreamData_Stdout) isExecStreamData_Output() {} + +func (*ExecStreamData_Stderr) isExecStreamData_Output() {} + +type ExecStreamEnd struct { + state protoimpl.MessageState `protogen:"open.v1"` + ExitCode int32 `protobuf:"varint,1,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ExecStreamEnd) Reset() { + *x = ExecStreamEnd{} + mi := &file_hostagent_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ExecStreamEnd) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ExecStreamEnd) ProtoMessage() {} + +func (x *ExecStreamEnd) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ExecStreamEnd.ProtoReflect.Descriptor instead. +func (*ExecStreamEnd) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{25} +} + +func (x *ExecStreamEnd) GetExitCode() int32 { + if x != nil { + return x.ExitCode + } + return 0 +} + +func (x *ExecStreamEnd) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type WriteFileStreamRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Content: + // + // *WriteFileStreamRequest_Meta + // *WriteFileStreamRequest_Chunk + Content isWriteFileStreamRequest_Content `protobuf_oneof:"content"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WriteFileStreamRequest) Reset() { + *x = WriteFileStreamRequest{} + mi := &file_hostagent_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WriteFileStreamRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WriteFileStreamRequest) ProtoMessage() {} + +func (x *WriteFileStreamRequest) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WriteFileStreamRequest.ProtoReflect.Descriptor instead. +func (*WriteFileStreamRequest) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{26} +} + +func (x *WriteFileStreamRequest) GetContent() isWriteFileStreamRequest_Content { + if x != nil { + return x.Content + } + return nil +} + +func (x *WriteFileStreamRequest) GetMeta() *WriteFileStreamMeta { + if x != nil { + if x, ok := x.Content.(*WriteFileStreamRequest_Meta); ok { + return x.Meta + } + } + return nil +} + +func (x *WriteFileStreamRequest) GetChunk() []byte { + if x != nil { + if x, ok := x.Content.(*WriteFileStreamRequest_Chunk); ok { + return x.Chunk + } + } + return nil +} + +type isWriteFileStreamRequest_Content interface { + isWriteFileStreamRequest_Content() +} + +type WriteFileStreamRequest_Meta struct { + Meta *WriteFileStreamMeta `protobuf:"bytes,1,opt,name=meta,proto3,oneof"` +} + +type WriteFileStreamRequest_Chunk struct { + Chunk []byte `protobuf:"bytes,2,opt,name=chunk,proto3,oneof"` +} + +func (*WriteFileStreamRequest_Meta) isWriteFileStreamRequest_Content() {} + +func (*WriteFileStreamRequest_Chunk) isWriteFileStreamRequest_Content() {} + +type WriteFileStreamMeta struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WriteFileStreamMeta) Reset() { + *x = WriteFileStreamMeta{} + mi := &file_hostagent_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WriteFileStreamMeta) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WriteFileStreamMeta) ProtoMessage() {} + +func (x *WriteFileStreamMeta) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WriteFileStreamMeta.ProtoReflect.Descriptor instead. +func (*WriteFileStreamMeta) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{27} +} + +func (x *WriteFileStreamMeta) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *WriteFileStreamMeta) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type WriteFileStreamResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WriteFileStreamResponse) Reset() { + *x = WriteFileStreamResponse{} + mi := &file_hostagent_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WriteFileStreamResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WriteFileStreamResponse) ProtoMessage() {} + +func (x *WriteFileStreamResponse) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WriteFileStreamResponse.ProtoReflect.Descriptor instead. +func (*WriteFileStreamResponse) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{28} +} + +type ReadFileStreamRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + Path string `protobuf:"bytes,2,opt,name=path,proto3" json:"path,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReadFileStreamRequest) Reset() { + *x = ReadFileStreamRequest{} + mi := &file_hostagent_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReadFileStreamRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReadFileStreamRequest) ProtoMessage() {} + +func (x *ReadFileStreamRequest) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReadFileStreamRequest.ProtoReflect.Descriptor instead. +func (*ReadFileStreamRequest) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{29} +} + +func (x *ReadFileStreamRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +func (x *ReadFileStreamRequest) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +type ReadFileStreamResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Chunk []byte `protobuf:"bytes,1,opt,name=chunk,proto3" json:"chunk,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReadFileStreamResponse) Reset() { + *x = ReadFileStreamResponse{} + mi := &file_hostagent_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReadFileStreamResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReadFileStreamResponse) ProtoMessage() {} + +func (x *ReadFileStreamResponse) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReadFileStreamResponse.ProtoReflect.Descriptor instead. +func (*ReadFileStreamResponse) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{30} +} + +func (x *ReadFileStreamResponse) GetChunk() []byte { + if x != nil { + return x.Chunk + } + return nil +} + +type PingSandboxRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PingSandboxRequest) Reset() { + *x = PingSandboxRequest{} + mi := &file_hostagent_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PingSandboxRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PingSandboxRequest) ProtoMessage() {} + +func (x *PingSandboxRequest) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PingSandboxRequest.ProtoReflect.Descriptor instead. +func (*PingSandboxRequest) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{31} +} + +func (x *PingSandboxRequest) GetSandboxId() string { + if x != nil { + return x.SandboxId + } + return "" +} + +type PingSandboxResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PingSandboxResponse) Reset() { + *x = PingSandboxResponse{} + mi := &file_hostagent_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PingSandboxResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PingSandboxResponse) ProtoMessage() {} + +func (x *PingSandboxResponse) ProtoReflect() protoreflect.Message { + mi := &file_hostagent_proto_msgTypes[32] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PingSandboxResponse.ProtoReflect.Descriptor instead. +func (*PingSandboxResponse) Descriptor() ([]byte, []int) { + return file_hostagent_proto_rawDescGZIP(), []int{32} +} + +var File_hostagent_proto protoreflect.FileDescriptor + +const file_hostagent_proto_rawDesc = "" + + "\n" + + "\x0fhostagent.proto\x12\fhostagent.v1\"\xa5\x01\n" + + "\x14CreateSandboxRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x05 \x01(\tR\tsandboxId\x12\x1a\n" + + "\btemplate\x18\x01 \x01(\tR\btemplate\x12\x14\n" + + "\x05vcpus\x18\x02 \x01(\x05R\x05vcpus\x12\x1b\n" + + "\tmemory_mb\x18\x03 \x01(\x05R\bmemoryMb\x12\x1f\n" + + "\vtimeout_sec\x18\x04 \x01(\x05R\n" + + "timeoutSec\"g\n" + + "\x15CreateSandboxResponse\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x16\n" + + "\x06status\x18\x02 \x01(\tR\x06status\x12\x17\n" + + "\ahost_ip\x18\x03 \x01(\tR\x06hostIp\"6\n" + + "\x15DestroySandboxRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\x18\n" + + "\x16DestroySandboxResponse\"4\n" + + "\x13PauseSandboxRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\x16\n" + + "\x14PauseSandboxResponse\"V\n" + + "\x14ResumeSandboxRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x1f\n" + + "\vtimeout_sec\x18\x02 \x01(\x05R\n" + + "timeoutSec\"g\n" + + "\x15ResumeSandboxResponse\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x16\n" + + "\x06status\x18\x02 \x01(\tR\x06status\x12\x17\n" + + "\ahost_ip\x18\x03 \x01(\tR\x06hostIp\"J\n" + + "\x15CreateSnapshotRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\"K\n" + + "\x16CreateSnapshotResponse\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x1d\n" + + "\n" + + "size_bytes\x18\x02 \x01(\x03R\tsizeBytes\"+\n" + + "\x15DeleteSnapshotRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"\x18\n" + + "\x16DeleteSnapshotResponse\"s\n" + + "\vExecRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x10\n" + + "\x03cmd\x18\x02 \x01(\tR\x03cmd\x12\x12\n" + + "\x04args\x18\x03 \x03(\tR\x04args\x12\x1f\n" + + "\vtimeout_sec\x18\x04 \x01(\x05R\n" + + "timeoutSec\"[\n" + + "\fExecResponse\x12\x16\n" + + "\x06stdout\x18\x01 \x01(\fR\x06stdout\x12\x16\n" + + "\x06stderr\x18\x02 \x01(\fR\x06stderr\x12\x1b\n" + + "\texit_code\x18\x03 \x01(\x05R\bexitCode\"\x16\n" + + "\x14ListSandboxesRequest\"\x87\x01\n" + + "\x15ListSandboxesResponse\x127\n" + + "\tsandboxes\x18\x01 \x03(\v2\x19.hostagent.v1.SandboxInfoR\tsandboxes\x125\n" + + "\x17auto_paused_sandbox_ids\x18\x02 \x03(\tR\x14autoPausedSandboxIds\"\xa4\x02\n" + + "\vSandboxInfo\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x16\n" + + "\x06status\x18\x02 \x01(\tR\x06status\x12\x1a\n" + + "\btemplate\x18\x03 \x01(\tR\btemplate\x12\x14\n" + + "\x05vcpus\x18\x04 \x01(\x05R\x05vcpus\x12\x1b\n" + + "\tmemory_mb\x18\x05 \x01(\x05R\bmemoryMb\x12\x17\n" + + "\ahost_ip\x18\x06 \x01(\tR\x06hostIp\x12&\n" + + "\x0fcreated_at_unix\x18\a \x01(\x03R\rcreatedAtUnix\x12-\n" + + "\x13last_active_at_unix\x18\b \x01(\x03R\x10lastActiveAtUnix\x12\x1f\n" + + "\vtimeout_sec\x18\t \x01(\x05R\n" + + "timeoutSec\"_\n" + + "\x10WriteFileRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\x12\x18\n" + + "\acontent\x18\x03 \x01(\fR\acontent\"\x13\n" + + "\x11WriteFileResponse\"D\n" + + "\x0fReadFileRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\",\n" + + "\x10ReadFileResponse\x12\x18\n" + + "\acontent\x18\x01 \x01(\fR\acontent\"y\n" + + "\x11ExecStreamRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x10\n" + + "\x03cmd\x18\x02 \x01(\tR\x03cmd\x12\x12\n" + + "\x04args\x18\x03 \x03(\tR\x04args\x12\x1f\n" + + "\vtimeout_sec\x18\x04 \x01(\x05R\n" + + "timeoutSec\"\xb9\x01\n" + + "\x12ExecStreamResponse\x125\n" + + "\x05start\x18\x01 \x01(\v2\x1d.hostagent.v1.ExecStreamStartH\x00R\x05start\x122\n" + + "\x04data\x18\x02 \x01(\v2\x1c.hostagent.v1.ExecStreamDataH\x00R\x04data\x12/\n" + + "\x03end\x18\x03 \x01(\v2\x1b.hostagent.v1.ExecStreamEndH\x00R\x03endB\a\n" + + "\x05event\"#\n" + + "\x0fExecStreamStart\x12\x10\n" + + "\x03pid\x18\x01 \x01(\rR\x03pid\"N\n" + + "\x0eExecStreamData\x12\x18\n" + + "\x06stdout\x18\x01 \x01(\fH\x00R\x06stdout\x12\x18\n" + + "\x06stderr\x18\x02 \x01(\fH\x00R\x06stderrB\b\n" + + "\x06output\"B\n" + + "\rExecStreamEnd\x12\x1b\n" + + "\texit_code\x18\x01 \x01(\x05R\bexitCode\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"t\n" + + "\x16WriteFileStreamRequest\x127\n" + + "\x04meta\x18\x01 \x01(\v2!.hostagent.v1.WriteFileStreamMetaH\x00R\x04meta\x12\x16\n" + + "\x05chunk\x18\x02 \x01(\fH\x00R\x05chunkB\t\n" + + "\acontent\"H\n" + + "\x13WriteFileStreamMeta\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\"\x19\n" + + "\x17WriteFileStreamResponse\"J\n" + + "\x15ReadFileStreamRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x12\n" + + "\x04path\x18\x02 \x01(\tR\x04path\".\n" + + "\x16ReadFileStreamResponse\x12\x14\n" + + "\x05chunk\x18\x01 \x01(\fR\x05chunk\"3\n" + + "\x12PingSandboxRequest\x12\x1d\n" + + "\n" + + "sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\x15\n" + + "\x13PingSandboxResponse2\xce\t\n" + + "\x10HostAgentService\x12X\n" + + "\rCreateSandbox\x12\".hostagent.v1.CreateSandboxRequest\x1a#.hostagent.v1.CreateSandboxResponse\x12[\n" + + "\x0eDestroySandbox\x12#.hostagent.v1.DestroySandboxRequest\x1a$.hostagent.v1.DestroySandboxResponse\x12U\n" + + "\fPauseSandbox\x12!.hostagent.v1.PauseSandboxRequest\x1a\".hostagent.v1.PauseSandboxResponse\x12X\n" + + "\rResumeSandbox\x12\".hostagent.v1.ResumeSandboxRequest\x1a#.hostagent.v1.ResumeSandboxResponse\x12=\n" + + "\x04Exec\x12\x19.hostagent.v1.ExecRequest\x1a\x1a.hostagent.v1.ExecResponse\x12X\n" + + "\rListSandboxes\x12\".hostagent.v1.ListSandboxesRequest\x1a#.hostagent.v1.ListSandboxesResponse\x12L\n" + + "\tWriteFile\x12\x1e.hostagent.v1.WriteFileRequest\x1a\x1f.hostagent.v1.WriteFileResponse\x12I\n" + + "\bReadFile\x12\x1d.hostagent.v1.ReadFileRequest\x1a\x1e.hostagent.v1.ReadFileResponse\x12[\n" + + "\x0eCreateSnapshot\x12#.hostagent.v1.CreateSnapshotRequest\x1a$.hostagent.v1.CreateSnapshotResponse\x12[\n" + + "\x0eDeleteSnapshot\x12#.hostagent.v1.DeleteSnapshotRequest\x1a$.hostagent.v1.DeleteSnapshotResponse\x12Q\n" + + "\n" + + "ExecStream\x12\x1f.hostagent.v1.ExecStreamRequest\x1a .hostagent.v1.ExecStreamResponse0\x01\x12`\n" + + "\x0fWriteFileStream\x12$.hostagent.v1.WriteFileStreamRequest\x1a%.hostagent.v1.WriteFileStreamResponse(\x01\x12]\n" + + "\x0eReadFileStream\x12#.hostagent.v1.ReadFileStreamRequest\x1a$.hostagent.v1.ReadFileStreamResponse0\x01\x12R\n" + + "\vPingSandbox\x12 .hostagent.v1.PingSandboxRequest\x1a!.hostagent.v1.PingSandboxResponseB\xb0\x01\n" + + "\x10com.hostagent.v1B\x0eHostagentProtoP\x01Z;git.omukk.dev/wrenn/sandbox/proto/hostagent/gen;hostagentv1\xa2\x02\x03HXX\xaa\x02\fHostagent.V1\xca\x02\fHostagent\\V1\xe2\x02\x18Hostagent\\V1\\GPBMetadata\xea\x02\rHostagent::V1b\x06proto3" + +var ( + file_hostagent_proto_rawDescOnce sync.Once + file_hostagent_proto_rawDescData []byte +) + +func file_hostagent_proto_rawDescGZIP() []byte { + file_hostagent_proto_rawDescOnce.Do(func() { + file_hostagent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_hostagent_proto_rawDesc), len(file_hostagent_proto_rawDesc))) + }) + return file_hostagent_proto_rawDescData +} + +var file_hostagent_proto_msgTypes = make([]protoimpl.MessageInfo, 33) +var file_hostagent_proto_goTypes = []any{ + (*CreateSandboxRequest)(nil), // 0: hostagent.v1.CreateSandboxRequest + (*CreateSandboxResponse)(nil), // 1: hostagent.v1.CreateSandboxResponse + (*DestroySandboxRequest)(nil), // 2: hostagent.v1.DestroySandboxRequest + (*DestroySandboxResponse)(nil), // 3: hostagent.v1.DestroySandboxResponse + (*PauseSandboxRequest)(nil), // 4: hostagent.v1.PauseSandboxRequest + (*PauseSandboxResponse)(nil), // 5: hostagent.v1.PauseSandboxResponse + (*ResumeSandboxRequest)(nil), // 6: hostagent.v1.ResumeSandboxRequest + (*ResumeSandboxResponse)(nil), // 7: hostagent.v1.ResumeSandboxResponse + (*CreateSnapshotRequest)(nil), // 8: hostagent.v1.CreateSnapshotRequest + (*CreateSnapshotResponse)(nil), // 9: hostagent.v1.CreateSnapshotResponse + (*DeleteSnapshotRequest)(nil), // 10: hostagent.v1.DeleteSnapshotRequest + (*DeleteSnapshotResponse)(nil), // 11: hostagent.v1.DeleteSnapshotResponse + (*ExecRequest)(nil), // 12: hostagent.v1.ExecRequest + (*ExecResponse)(nil), // 13: hostagent.v1.ExecResponse + (*ListSandboxesRequest)(nil), // 14: hostagent.v1.ListSandboxesRequest + (*ListSandboxesResponse)(nil), // 15: hostagent.v1.ListSandboxesResponse + (*SandboxInfo)(nil), // 16: hostagent.v1.SandboxInfo + (*WriteFileRequest)(nil), // 17: hostagent.v1.WriteFileRequest + (*WriteFileResponse)(nil), // 18: hostagent.v1.WriteFileResponse + (*ReadFileRequest)(nil), // 19: hostagent.v1.ReadFileRequest + (*ReadFileResponse)(nil), // 20: hostagent.v1.ReadFileResponse + (*ExecStreamRequest)(nil), // 21: hostagent.v1.ExecStreamRequest + (*ExecStreamResponse)(nil), // 22: hostagent.v1.ExecStreamResponse + (*ExecStreamStart)(nil), // 23: hostagent.v1.ExecStreamStart + (*ExecStreamData)(nil), // 24: hostagent.v1.ExecStreamData + (*ExecStreamEnd)(nil), // 25: hostagent.v1.ExecStreamEnd + (*WriteFileStreamRequest)(nil), // 26: hostagent.v1.WriteFileStreamRequest + (*WriteFileStreamMeta)(nil), // 27: hostagent.v1.WriteFileStreamMeta + (*WriteFileStreamResponse)(nil), // 28: hostagent.v1.WriteFileStreamResponse + (*ReadFileStreamRequest)(nil), // 29: hostagent.v1.ReadFileStreamRequest + (*ReadFileStreamResponse)(nil), // 30: hostagent.v1.ReadFileStreamResponse + (*PingSandboxRequest)(nil), // 31: hostagent.v1.PingSandboxRequest + (*PingSandboxResponse)(nil), // 32: hostagent.v1.PingSandboxResponse +} +var file_hostagent_proto_depIdxs = []int32{ + 16, // 0: hostagent.v1.ListSandboxesResponse.sandboxes:type_name -> hostagent.v1.SandboxInfo + 23, // 1: hostagent.v1.ExecStreamResponse.start:type_name -> hostagent.v1.ExecStreamStart + 24, // 2: hostagent.v1.ExecStreamResponse.data:type_name -> hostagent.v1.ExecStreamData + 25, // 3: hostagent.v1.ExecStreamResponse.end:type_name -> hostagent.v1.ExecStreamEnd + 27, // 4: hostagent.v1.WriteFileStreamRequest.meta:type_name -> hostagent.v1.WriteFileStreamMeta + 0, // 5: hostagent.v1.HostAgentService.CreateSandbox:input_type -> hostagent.v1.CreateSandboxRequest + 2, // 6: hostagent.v1.HostAgentService.DestroySandbox:input_type -> hostagent.v1.DestroySandboxRequest + 4, // 7: hostagent.v1.HostAgentService.PauseSandbox:input_type -> hostagent.v1.PauseSandboxRequest + 6, // 8: hostagent.v1.HostAgentService.ResumeSandbox:input_type -> hostagent.v1.ResumeSandboxRequest + 12, // 9: hostagent.v1.HostAgentService.Exec:input_type -> hostagent.v1.ExecRequest + 14, // 10: hostagent.v1.HostAgentService.ListSandboxes:input_type -> hostagent.v1.ListSandboxesRequest + 17, // 11: hostagent.v1.HostAgentService.WriteFile:input_type -> hostagent.v1.WriteFileRequest + 19, // 12: hostagent.v1.HostAgentService.ReadFile:input_type -> hostagent.v1.ReadFileRequest + 8, // 13: hostagent.v1.HostAgentService.CreateSnapshot:input_type -> hostagent.v1.CreateSnapshotRequest + 10, // 14: hostagent.v1.HostAgentService.DeleteSnapshot:input_type -> hostagent.v1.DeleteSnapshotRequest + 21, // 15: hostagent.v1.HostAgentService.ExecStream:input_type -> hostagent.v1.ExecStreamRequest + 26, // 16: hostagent.v1.HostAgentService.WriteFileStream:input_type -> hostagent.v1.WriteFileStreamRequest + 29, // 17: hostagent.v1.HostAgentService.ReadFileStream:input_type -> hostagent.v1.ReadFileStreamRequest + 31, // 18: hostagent.v1.HostAgentService.PingSandbox:input_type -> hostagent.v1.PingSandboxRequest + 1, // 19: hostagent.v1.HostAgentService.CreateSandbox:output_type -> hostagent.v1.CreateSandboxResponse + 3, // 20: hostagent.v1.HostAgentService.DestroySandbox:output_type -> hostagent.v1.DestroySandboxResponse + 5, // 21: hostagent.v1.HostAgentService.PauseSandbox:output_type -> hostagent.v1.PauseSandboxResponse + 7, // 22: hostagent.v1.HostAgentService.ResumeSandbox:output_type -> hostagent.v1.ResumeSandboxResponse + 13, // 23: hostagent.v1.HostAgentService.Exec:output_type -> hostagent.v1.ExecResponse + 15, // 24: hostagent.v1.HostAgentService.ListSandboxes:output_type -> hostagent.v1.ListSandboxesResponse + 18, // 25: hostagent.v1.HostAgentService.WriteFile:output_type -> hostagent.v1.WriteFileResponse + 20, // 26: hostagent.v1.HostAgentService.ReadFile:output_type -> hostagent.v1.ReadFileResponse + 9, // 27: hostagent.v1.HostAgentService.CreateSnapshot:output_type -> hostagent.v1.CreateSnapshotResponse + 11, // 28: hostagent.v1.HostAgentService.DeleteSnapshot:output_type -> hostagent.v1.DeleteSnapshotResponse + 22, // 29: hostagent.v1.HostAgentService.ExecStream:output_type -> hostagent.v1.ExecStreamResponse + 28, // 30: hostagent.v1.HostAgentService.WriteFileStream:output_type -> hostagent.v1.WriteFileStreamResponse + 30, // 31: hostagent.v1.HostAgentService.ReadFileStream:output_type -> hostagent.v1.ReadFileStreamResponse + 32, // 32: hostagent.v1.HostAgentService.PingSandbox:output_type -> hostagent.v1.PingSandboxResponse + 19, // [19:33] is the sub-list for method output_type + 5, // [5:19] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name +} + +func init() { file_hostagent_proto_init() } +func file_hostagent_proto_init() { + if File_hostagent_proto != nil { + return + } + file_hostagent_proto_msgTypes[22].OneofWrappers = []any{ + (*ExecStreamResponse_Start)(nil), + (*ExecStreamResponse_Data)(nil), + (*ExecStreamResponse_End)(nil), + } + file_hostagent_proto_msgTypes[24].OneofWrappers = []any{ + (*ExecStreamData_Stdout)(nil), + (*ExecStreamData_Stderr)(nil), + } + file_hostagent_proto_msgTypes[26].OneofWrappers = []any{ + (*WriteFileStreamRequest_Meta)(nil), + (*WriteFileStreamRequest_Chunk)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_hostagent_proto_rawDesc), len(file_hostagent_proto_rawDesc)), + NumEnums: 0, + NumMessages: 33, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_hostagent_proto_goTypes, + DependencyIndexes: file_hostagent_proto_depIdxs, + MessageInfos: file_hostagent_proto_msgTypes, + }.Build() + File_hostagent_proto = out.File + file_hostagent_proto_goTypes = nil + file_hostagent_proto_depIdxs = nil +} diff --git a/proto/hostagent/gen/hostagentv1connect/hostagent.connect.go b/proto/hostagent/gen/hostagentv1connect/hostagent.connect.go new file mode 100644 index 0000000..6eb5d45 --- /dev/null +++ b/proto/hostagent/gen/hostagentv1connect/hostagent.connect.go @@ -0,0 +1,517 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: hostagent.proto + +package hostagentv1connect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + gen "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // HostAgentServiceName is the fully-qualified name of the HostAgentService service. + HostAgentServiceName = "hostagent.v1.HostAgentService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // HostAgentServiceCreateSandboxProcedure is the fully-qualified name of the HostAgentService's + // CreateSandbox RPC. + HostAgentServiceCreateSandboxProcedure = "/hostagent.v1.HostAgentService/CreateSandbox" + // HostAgentServiceDestroySandboxProcedure is the fully-qualified name of the HostAgentService's + // DestroySandbox RPC. + HostAgentServiceDestroySandboxProcedure = "/hostagent.v1.HostAgentService/DestroySandbox" + // HostAgentServicePauseSandboxProcedure is the fully-qualified name of the HostAgentService's + // PauseSandbox RPC. + HostAgentServicePauseSandboxProcedure = "/hostagent.v1.HostAgentService/PauseSandbox" + // HostAgentServiceResumeSandboxProcedure is the fully-qualified name of the HostAgentService's + // ResumeSandbox RPC. + HostAgentServiceResumeSandboxProcedure = "/hostagent.v1.HostAgentService/ResumeSandbox" + // HostAgentServiceExecProcedure is the fully-qualified name of the HostAgentService's Exec RPC. + HostAgentServiceExecProcedure = "/hostagent.v1.HostAgentService/Exec" + // HostAgentServiceListSandboxesProcedure is the fully-qualified name of the HostAgentService's + // ListSandboxes RPC. + HostAgentServiceListSandboxesProcedure = "/hostagent.v1.HostAgentService/ListSandboxes" + // HostAgentServiceWriteFileProcedure is the fully-qualified name of the HostAgentService's + // WriteFile RPC. + HostAgentServiceWriteFileProcedure = "/hostagent.v1.HostAgentService/WriteFile" + // HostAgentServiceReadFileProcedure is the fully-qualified name of the HostAgentService's ReadFile + // RPC. + HostAgentServiceReadFileProcedure = "/hostagent.v1.HostAgentService/ReadFile" + // HostAgentServiceCreateSnapshotProcedure is the fully-qualified name of the HostAgentService's + // CreateSnapshot RPC. + HostAgentServiceCreateSnapshotProcedure = "/hostagent.v1.HostAgentService/CreateSnapshot" + // HostAgentServiceDeleteSnapshotProcedure is the fully-qualified name of the HostAgentService's + // DeleteSnapshot RPC. + HostAgentServiceDeleteSnapshotProcedure = "/hostagent.v1.HostAgentService/DeleteSnapshot" + // HostAgentServiceExecStreamProcedure is the fully-qualified name of the HostAgentService's + // ExecStream RPC. + HostAgentServiceExecStreamProcedure = "/hostagent.v1.HostAgentService/ExecStream" + // HostAgentServiceWriteFileStreamProcedure is the fully-qualified name of the HostAgentService's + // WriteFileStream RPC. + HostAgentServiceWriteFileStreamProcedure = "/hostagent.v1.HostAgentService/WriteFileStream" + // HostAgentServiceReadFileStreamProcedure is the fully-qualified name of the HostAgentService's + // ReadFileStream RPC. + HostAgentServiceReadFileStreamProcedure = "/hostagent.v1.HostAgentService/ReadFileStream" + // HostAgentServicePingSandboxProcedure is the fully-qualified name of the HostAgentService's + // PingSandbox RPC. + HostAgentServicePingSandboxProcedure = "/hostagent.v1.HostAgentService/PingSandbox" +) + +// HostAgentServiceClient is a client for the hostagent.v1.HostAgentService service. +type HostAgentServiceClient interface { + // CreateSandbox boots a new microVM with the given configuration. + CreateSandbox(context.Context, *connect.Request[gen.CreateSandboxRequest]) (*connect.Response[gen.CreateSandboxResponse], error) + // DestroySandbox stops and cleans up a sandbox (VM, network, rootfs). + DestroySandbox(context.Context, *connect.Request[gen.DestroySandboxRequest]) (*connect.Response[gen.DestroySandboxResponse], error) + // PauseSandbox pauses a running sandbox's VM. + PauseSandbox(context.Context, *connect.Request[gen.PauseSandboxRequest]) (*connect.Response[gen.PauseSandboxResponse], error) + // ResumeSandbox resumes a paused sandbox's VM. + ResumeSandbox(context.Context, *connect.Request[gen.ResumeSandboxRequest]) (*connect.Response[gen.ResumeSandboxResponse], error) + // Exec runs a command inside a sandbox and returns the collected output. + Exec(context.Context, *connect.Request[gen.ExecRequest]) (*connect.Response[gen.ExecResponse], error) + // ListSandboxes returns all sandboxes managed by this host agent. + ListSandboxes(context.Context, *connect.Request[gen.ListSandboxesRequest]) (*connect.Response[gen.ListSandboxesResponse], error) + // WriteFile writes content to a file inside a sandbox. + WriteFile(context.Context, *connect.Request[gen.WriteFileRequest]) (*connect.Response[gen.WriteFileResponse], error) + // ReadFile reads a file from inside a sandbox. + ReadFile(context.Context, *connect.Request[gen.ReadFileRequest]) (*connect.Response[gen.ReadFileResponse], error) + // CreateSnapshot pauses a sandbox, takes a snapshot, stores it as a reusable + // template, and destroys the sandbox. + CreateSnapshot(context.Context, *connect.Request[gen.CreateSnapshotRequest]) (*connect.Response[gen.CreateSnapshotResponse], error) + // DeleteSnapshot removes a snapshot template from disk. + DeleteSnapshot(context.Context, *connect.Request[gen.DeleteSnapshotRequest]) (*connect.Response[gen.DeleteSnapshotResponse], error) + // ExecStream runs a command inside a sandbox and streams output events as they arrive. + ExecStream(context.Context, *connect.Request[gen.ExecStreamRequest]) (*connect.ServerStreamForClient[gen.ExecStreamResponse], error) + // WriteFileStream writes a file to a sandbox using chunked streaming. + // First message must contain metadata (sandbox_id, path). Subsequent messages contain data chunks. + WriteFileStream(context.Context) *connect.ClientStreamForClient[gen.WriteFileStreamRequest, gen.WriteFileStreamResponse] + // ReadFileStream reads a file from a sandbox and streams it back in chunks. + ReadFileStream(context.Context, *connect.Request[gen.ReadFileStreamRequest]) (*connect.ServerStreamForClient[gen.ReadFileStreamResponse], error) + // PingSandbox resets the inactivity timer for a running sandbox. + PingSandbox(context.Context, *connect.Request[gen.PingSandboxRequest]) (*connect.Response[gen.PingSandboxResponse], error) +} + +// NewHostAgentServiceClient constructs a client for the hostagent.v1.HostAgentService service. By +// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, +// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the +// connect.WithGRPC() or connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewHostAgentServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) HostAgentServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + hostAgentServiceMethods := gen.File_hostagent_proto.Services().ByName("HostAgentService").Methods() + return &hostAgentServiceClient{ + createSandbox: connect.NewClient[gen.CreateSandboxRequest, gen.CreateSandboxResponse]( + httpClient, + baseURL+HostAgentServiceCreateSandboxProcedure, + connect.WithSchema(hostAgentServiceMethods.ByName("CreateSandbox")), + connect.WithClientOptions(opts...), + ), + destroySandbox: connect.NewClient[gen.DestroySandboxRequest, gen.DestroySandboxResponse]( + httpClient, + baseURL+HostAgentServiceDestroySandboxProcedure, + connect.WithSchema(hostAgentServiceMethods.ByName("DestroySandbox")), + connect.WithClientOptions(opts...), + ), + pauseSandbox: connect.NewClient[gen.PauseSandboxRequest, gen.PauseSandboxResponse]( + httpClient, + baseURL+HostAgentServicePauseSandboxProcedure, + connect.WithSchema(hostAgentServiceMethods.ByName("PauseSandbox")), + connect.WithClientOptions(opts...), + ), + resumeSandbox: connect.NewClient[gen.ResumeSandboxRequest, gen.ResumeSandboxResponse]( + httpClient, + baseURL+HostAgentServiceResumeSandboxProcedure, + connect.WithSchema(hostAgentServiceMethods.ByName("ResumeSandbox")), + connect.WithClientOptions(opts...), + ), + exec: connect.NewClient[gen.ExecRequest, gen.ExecResponse]( + httpClient, + baseURL+HostAgentServiceExecProcedure, + connect.WithSchema(hostAgentServiceMethods.ByName("Exec")), + connect.WithClientOptions(opts...), + ), + listSandboxes: connect.NewClient[gen.ListSandboxesRequest, gen.ListSandboxesResponse]( + httpClient, + baseURL+HostAgentServiceListSandboxesProcedure, + connect.WithSchema(hostAgentServiceMethods.ByName("ListSandboxes")), + connect.WithClientOptions(opts...), + ), + writeFile: connect.NewClient[gen.WriteFileRequest, gen.WriteFileResponse]( + httpClient, + baseURL+HostAgentServiceWriteFileProcedure, + connect.WithSchema(hostAgentServiceMethods.ByName("WriteFile")), + connect.WithClientOptions(opts...), + ), + readFile: connect.NewClient[gen.ReadFileRequest, gen.ReadFileResponse]( + httpClient, + baseURL+HostAgentServiceReadFileProcedure, + connect.WithSchema(hostAgentServiceMethods.ByName("ReadFile")), + connect.WithClientOptions(opts...), + ), + createSnapshot: connect.NewClient[gen.CreateSnapshotRequest, gen.CreateSnapshotResponse]( + httpClient, + baseURL+HostAgentServiceCreateSnapshotProcedure, + connect.WithSchema(hostAgentServiceMethods.ByName("CreateSnapshot")), + connect.WithClientOptions(opts...), + ), + deleteSnapshot: connect.NewClient[gen.DeleteSnapshotRequest, gen.DeleteSnapshotResponse]( + httpClient, + baseURL+HostAgentServiceDeleteSnapshotProcedure, + connect.WithSchema(hostAgentServiceMethods.ByName("DeleteSnapshot")), + connect.WithClientOptions(opts...), + ), + execStream: connect.NewClient[gen.ExecStreamRequest, gen.ExecStreamResponse]( + httpClient, + baseURL+HostAgentServiceExecStreamProcedure, + connect.WithSchema(hostAgentServiceMethods.ByName("ExecStream")), + connect.WithClientOptions(opts...), + ), + writeFileStream: connect.NewClient[gen.WriteFileStreamRequest, gen.WriteFileStreamResponse]( + httpClient, + baseURL+HostAgentServiceWriteFileStreamProcedure, + connect.WithSchema(hostAgentServiceMethods.ByName("WriteFileStream")), + connect.WithClientOptions(opts...), + ), + readFileStream: connect.NewClient[gen.ReadFileStreamRequest, gen.ReadFileStreamResponse]( + httpClient, + baseURL+HostAgentServiceReadFileStreamProcedure, + connect.WithSchema(hostAgentServiceMethods.ByName("ReadFileStream")), + connect.WithClientOptions(opts...), + ), + pingSandbox: connect.NewClient[gen.PingSandboxRequest, gen.PingSandboxResponse]( + httpClient, + baseURL+HostAgentServicePingSandboxProcedure, + connect.WithSchema(hostAgentServiceMethods.ByName("PingSandbox")), + connect.WithClientOptions(opts...), + ), + } +} + +// hostAgentServiceClient implements HostAgentServiceClient. +type hostAgentServiceClient struct { + createSandbox *connect.Client[gen.CreateSandboxRequest, gen.CreateSandboxResponse] + destroySandbox *connect.Client[gen.DestroySandboxRequest, gen.DestroySandboxResponse] + pauseSandbox *connect.Client[gen.PauseSandboxRequest, gen.PauseSandboxResponse] + resumeSandbox *connect.Client[gen.ResumeSandboxRequest, gen.ResumeSandboxResponse] + exec *connect.Client[gen.ExecRequest, gen.ExecResponse] + listSandboxes *connect.Client[gen.ListSandboxesRequest, gen.ListSandboxesResponse] + writeFile *connect.Client[gen.WriteFileRequest, gen.WriteFileResponse] + readFile *connect.Client[gen.ReadFileRequest, gen.ReadFileResponse] + createSnapshot *connect.Client[gen.CreateSnapshotRequest, gen.CreateSnapshotResponse] + deleteSnapshot *connect.Client[gen.DeleteSnapshotRequest, gen.DeleteSnapshotResponse] + execStream *connect.Client[gen.ExecStreamRequest, gen.ExecStreamResponse] + writeFileStream *connect.Client[gen.WriteFileStreamRequest, gen.WriteFileStreamResponse] + readFileStream *connect.Client[gen.ReadFileStreamRequest, gen.ReadFileStreamResponse] + pingSandbox *connect.Client[gen.PingSandboxRequest, gen.PingSandboxResponse] +} + +// CreateSandbox calls hostagent.v1.HostAgentService.CreateSandbox. +func (c *hostAgentServiceClient) CreateSandbox(ctx context.Context, req *connect.Request[gen.CreateSandboxRequest]) (*connect.Response[gen.CreateSandboxResponse], error) { + return c.createSandbox.CallUnary(ctx, req) +} + +// DestroySandbox calls hostagent.v1.HostAgentService.DestroySandbox. +func (c *hostAgentServiceClient) DestroySandbox(ctx context.Context, req *connect.Request[gen.DestroySandboxRequest]) (*connect.Response[gen.DestroySandboxResponse], error) { + return c.destroySandbox.CallUnary(ctx, req) +} + +// PauseSandbox calls hostagent.v1.HostAgentService.PauseSandbox. +func (c *hostAgentServiceClient) PauseSandbox(ctx context.Context, req *connect.Request[gen.PauseSandboxRequest]) (*connect.Response[gen.PauseSandboxResponse], error) { + return c.pauseSandbox.CallUnary(ctx, req) +} + +// ResumeSandbox calls hostagent.v1.HostAgentService.ResumeSandbox. +func (c *hostAgentServiceClient) ResumeSandbox(ctx context.Context, req *connect.Request[gen.ResumeSandboxRequest]) (*connect.Response[gen.ResumeSandboxResponse], error) { + return c.resumeSandbox.CallUnary(ctx, req) +} + +// Exec calls hostagent.v1.HostAgentService.Exec. +func (c *hostAgentServiceClient) Exec(ctx context.Context, req *connect.Request[gen.ExecRequest]) (*connect.Response[gen.ExecResponse], error) { + return c.exec.CallUnary(ctx, req) +} + +// ListSandboxes calls hostagent.v1.HostAgentService.ListSandboxes. +func (c *hostAgentServiceClient) ListSandboxes(ctx context.Context, req *connect.Request[gen.ListSandboxesRequest]) (*connect.Response[gen.ListSandboxesResponse], error) { + return c.listSandboxes.CallUnary(ctx, req) +} + +// WriteFile calls hostagent.v1.HostAgentService.WriteFile. +func (c *hostAgentServiceClient) WriteFile(ctx context.Context, req *connect.Request[gen.WriteFileRequest]) (*connect.Response[gen.WriteFileResponse], error) { + return c.writeFile.CallUnary(ctx, req) +} + +// ReadFile calls hostagent.v1.HostAgentService.ReadFile. +func (c *hostAgentServiceClient) ReadFile(ctx context.Context, req *connect.Request[gen.ReadFileRequest]) (*connect.Response[gen.ReadFileResponse], error) { + return c.readFile.CallUnary(ctx, req) +} + +// CreateSnapshot calls hostagent.v1.HostAgentService.CreateSnapshot. +func (c *hostAgentServiceClient) CreateSnapshot(ctx context.Context, req *connect.Request[gen.CreateSnapshotRequest]) (*connect.Response[gen.CreateSnapshotResponse], error) { + return c.createSnapshot.CallUnary(ctx, req) +} + +// DeleteSnapshot calls hostagent.v1.HostAgentService.DeleteSnapshot. +func (c *hostAgentServiceClient) DeleteSnapshot(ctx context.Context, req *connect.Request[gen.DeleteSnapshotRequest]) (*connect.Response[gen.DeleteSnapshotResponse], error) { + return c.deleteSnapshot.CallUnary(ctx, req) +} + +// ExecStream calls hostagent.v1.HostAgentService.ExecStream. +func (c *hostAgentServiceClient) ExecStream(ctx context.Context, req *connect.Request[gen.ExecStreamRequest]) (*connect.ServerStreamForClient[gen.ExecStreamResponse], error) { + return c.execStream.CallServerStream(ctx, req) +} + +// WriteFileStream calls hostagent.v1.HostAgentService.WriteFileStream. +func (c *hostAgentServiceClient) WriteFileStream(ctx context.Context) *connect.ClientStreamForClient[gen.WriteFileStreamRequest, gen.WriteFileStreamResponse] { + return c.writeFileStream.CallClientStream(ctx) +} + +// ReadFileStream calls hostagent.v1.HostAgentService.ReadFileStream. +func (c *hostAgentServiceClient) ReadFileStream(ctx context.Context, req *connect.Request[gen.ReadFileStreamRequest]) (*connect.ServerStreamForClient[gen.ReadFileStreamResponse], error) { + return c.readFileStream.CallServerStream(ctx, req) +} + +// PingSandbox calls hostagent.v1.HostAgentService.PingSandbox. +func (c *hostAgentServiceClient) PingSandbox(ctx context.Context, req *connect.Request[gen.PingSandboxRequest]) (*connect.Response[gen.PingSandboxResponse], error) { + return c.pingSandbox.CallUnary(ctx, req) +} + +// HostAgentServiceHandler is an implementation of the hostagent.v1.HostAgentService service. +type HostAgentServiceHandler interface { + // CreateSandbox boots a new microVM with the given configuration. + CreateSandbox(context.Context, *connect.Request[gen.CreateSandboxRequest]) (*connect.Response[gen.CreateSandboxResponse], error) + // DestroySandbox stops and cleans up a sandbox (VM, network, rootfs). + DestroySandbox(context.Context, *connect.Request[gen.DestroySandboxRequest]) (*connect.Response[gen.DestroySandboxResponse], error) + // PauseSandbox pauses a running sandbox's VM. + PauseSandbox(context.Context, *connect.Request[gen.PauseSandboxRequest]) (*connect.Response[gen.PauseSandboxResponse], error) + // ResumeSandbox resumes a paused sandbox's VM. + ResumeSandbox(context.Context, *connect.Request[gen.ResumeSandboxRequest]) (*connect.Response[gen.ResumeSandboxResponse], error) + // Exec runs a command inside a sandbox and returns the collected output. + Exec(context.Context, *connect.Request[gen.ExecRequest]) (*connect.Response[gen.ExecResponse], error) + // ListSandboxes returns all sandboxes managed by this host agent. + ListSandboxes(context.Context, *connect.Request[gen.ListSandboxesRequest]) (*connect.Response[gen.ListSandboxesResponse], error) + // WriteFile writes content to a file inside a sandbox. + WriteFile(context.Context, *connect.Request[gen.WriteFileRequest]) (*connect.Response[gen.WriteFileResponse], error) + // ReadFile reads a file from inside a sandbox. + ReadFile(context.Context, *connect.Request[gen.ReadFileRequest]) (*connect.Response[gen.ReadFileResponse], error) + // CreateSnapshot pauses a sandbox, takes a snapshot, stores it as a reusable + // template, and destroys the sandbox. + CreateSnapshot(context.Context, *connect.Request[gen.CreateSnapshotRequest]) (*connect.Response[gen.CreateSnapshotResponse], error) + // DeleteSnapshot removes a snapshot template from disk. + DeleteSnapshot(context.Context, *connect.Request[gen.DeleteSnapshotRequest]) (*connect.Response[gen.DeleteSnapshotResponse], error) + // ExecStream runs a command inside a sandbox and streams output events as they arrive. + ExecStream(context.Context, *connect.Request[gen.ExecStreamRequest], *connect.ServerStream[gen.ExecStreamResponse]) error + // WriteFileStream writes a file to a sandbox using chunked streaming. + // First message must contain metadata (sandbox_id, path). Subsequent messages contain data chunks. + WriteFileStream(context.Context, *connect.ClientStream[gen.WriteFileStreamRequest]) (*connect.Response[gen.WriteFileStreamResponse], error) + // ReadFileStream reads a file from a sandbox and streams it back in chunks. + ReadFileStream(context.Context, *connect.Request[gen.ReadFileStreamRequest], *connect.ServerStream[gen.ReadFileStreamResponse]) error + // PingSandbox resets the inactivity timer for a running sandbox. + PingSandbox(context.Context, *connect.Request[gen.PingSandboxRequest]) (*connect.Response[gen.PingSandboxResponse], error) +} + +// NewHostAgentServiceHandler builds an HTTP handler from the service implementation. It returns the +// path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewHostAgentServiceHandler(svc HostAgentServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + hostAgentServiceMethods := gen.File_hostagent_proto.Services().ByName("HostAgentService").Methods() + hostAgentServiceCreateSandboxHandler := connect.NewUnaryHandler( + HostAgentServiceCreateSandboxProcedure, + svc.CreateSandbox, + connect.WithSchema(hostAgentServiceMethods.ByName("CreateSandbox")), + connect.WithHandlerOptions(opts...), + ) + hostAgentServiceDestroySandboxHandler := connect.NewUnaryHandler( + HostAgentServiceDestroySandboxProcedure, + svc.DestroySandbox, + connect.WithSchema(hostAgentServiceMethods.ByName("DestroySandbox")), + connect.WithHandlerOptions(opts...), + ) + hostAgentServicePauseSandboxHandler := connect.NewUnaryHandler( + HostAgentServicePauseSandboxProcedure, + svc.PauseSandbox, + connect.WithSchema(hostAgentServiceMethods.ByName("PauseSandbox")), + connect.WithHandlerOptions(opts...), + ) + hostAgentServiceResumeSandboxHandler := connect.NewUnaryHandler( + HostAgentServiceResumeSandboxProcedure, + svc.ResumeSandbox, + connect.WithSchema(hostAgentServiceMethods.ByName("ResumeSandbox")), + connect.WithHandlerOptions(opts...), + ) + hostAgentServiceExecHandler := connect.NewUnaryHandler( + HostAgentServiceExecProcedure, + svc.Exec, + connect.WithSchema(hostAgentServiceMethods.ByName("Exec")), + connect.WithHandlerOptions(opts...), + ) + hostAgentServiceListSandboxesHandler := connect.NewUnaryHandler( + HostAgentServiceListSandboxesProcedure, + svc.ListSandboxes, + connect.WithSchema(hostAgentServiceMethods.ByName("ListSandboxes")), + connect.WithHandlerOptions(opts...), + ) + hostAgentServiceWriteFileHandler := connect.NewUnaryHandler( + HostAgentServiceWriteFileProcedure, + svc.WriteFile, + connect.WithSchema(hostAgentServiceMethods.ByName("WriteFile")), + connect.WithHandlerOptions(opts...), + ) + hostAgentServiceReadFileHandler := connect.NewUnaryHandler( + HostAgentServiceReadFileProcedure, + svc.ReadFile, + connect.WithSchema(hostAgentServiceMethods.ByName("ReadFile")), + connect.WithHandlerOptions(opts...), + ) + hostAgentServiceCreateSnapshotHandler := connect.NewUnaryHandler( + HostAgentServiceCreateSnapshotProcedure, + svc.CreateSnapshot, + connect.WithSchema(hostAgentServiceMethods.ByName("CreateSnapshot")), + connect.WithHandlerOptions(opts...), + ) + hostAgentServiceDeleteSnapshotHandler := connect.NewUnaryHandler( + HostAgentServiceDeleteSnapshotProcedure, + svc.DeleteSnapshot, + connect.WithSchema(hostAgentServiceMethods.ByName("DeleteSnapshot")), + connect.WithHandlerOptions(opts...), + ) + hostAgentServiceExecStreamHandler := connect.NewServerStreamHandler( + HostAgentServiceExecStreamProcedure, + svc.ExecStream, + connect.WithSchema(hostAgentServiceMethods.ByName("ExecStream")), + connect.WithHandlerOptions(opts...), + ) + hostAgentServiceWriteFileStreamHandler := connect.NewClientStreamHandler( + HostAgentServiceWriteFileStreamProcedure, + svc.WriteFileStream, + connect.WithSchema(hostAgentServiceMethods.ByName("WriteFileStream")), + connect.WithHandlerOptions(opts...), + ) + hostAgentServiceReadFileStreamHandler := connect.NewServerStreamHandler( + HostAgentServiceReadFileStreamProcedure, + svc.ReadFileStream, + connect.WithSchema(hostAgentServiceMethods.ByName("ReadFileStream")), + connect.WithHandlerOptions(opts...), + ) + hostAgentServicePingSandboxHandler := connect.NewUnaryHandler( + HostAgentServicePingSandboxProcedure, + svc.PingSandbox, + connect.WithSchema(hostAgentServiceMethods.ByName("PingSandbox")), + connect.WithHandlerOptions(opts...), + ) + return "/hostagent.v1.HostAgentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case HostAgentServiceCreateSandboxProcedure: + hostAgentServiceCreateSandboxHandler.ServeHTTP(w, r) + case HostAgentServiceDestroySandboxProcedure: + hostAgentServiceDestroySandboxHandler.ServeHTTP(w, r) + case HostAgentServicePauseSandboxProcedure: + hostAgentServicePauseSandboxHandler.ServeHTTP(w, r) + case HostAgentServiceResumeSandboxProcedure: + hostAgentServiceResumeSandboxHandler.ServeHTTP(w, r) + case HostAgentServiceExecProcedure: + hostAgentServiceExecHandler.ServeHTTP(w, r) + case HostAgentServiceListSandboxesProcedure: + hostAgentServiceListSandboxesHandler.ServeHTTP(w, r) + case HostAgentServiceWriteFileProcedure: + hostAgentServiceWriteFileHandler.ServeHTTP(w, r) + case HostAgentServiceReadFileProcedure: + hostAgentServiceReadFileHandler.ServeHTTP(w, r) + case HostAgentServiceCreateSnapshotProcedure: + hostAgentServiceCreateSnapshotHandler.ServeHTTP(w, r) + case HostAgentServiceDeleteSnapshotProcedure: + hostAgentServiceDeleteSnapshotHandler.ServeHTTP(w, r) + case HostAgentServiceExecStreamProcedure: + hostAgentServiceExecStreamHandler.ServeHTTP(w, r) + case HostAgentServiceWriteFileStreamProcedure: + hostAgentServiceWriteFileStreamHandler.ServeHTTP(w, r) + case HostAgentServiceReadFileStreamProcedure: + hostAgentServiceReadFileStreamHandler.ServeHTTP(w, r) + case HostAgentServicePingSandboxProcedure: + hostAgentServicePingSandboxHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedHostAgentServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedHostAgentServiceHandler struct{} + +func (UnimplementedHostAgentServiceHandler) CreateSandbox(context.Context, *connect.Request[gen.CreateSandboxRequest]) (*connect.Response[gen.CreateSandboxResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.CreateSandbox is not implemented")) +} + +func (UnimplementedHostAgentServiceHandler) DestroySandbox(context.Context, *connect.Request[gen.DestroySandboxRequest]) (*connect.Response[gen.DestroySandboxResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.DestroySandbox is not implemented")) +} + +func (UnimplementedHostAgentServiceHandler) PauseSandbox(context.Context, *connect.Request[gen.PauseSandboxRequest]) (*connect.Response[gen.PauseSandboxResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.PauseSandbox is not implemented")) +} + +func (UnimplementedHostAgentServiceHandler) ResumeSandbox(context.Context, *connect.Request[gen.ResumeSandboxRequest]) (*connect.Response[gen.ResumeSandboxResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.ResumeSandbox is not implemented")) +} + +func (UnimplementedHostAgentServiceHandler) Exec(context.Context, *connect.Request[gen.ExecRequest]) (*connect.Response[gen.ExecResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.Exec is not implemented")) +} + +func (UnimplementedHostAgentServiceHandler) ListSandboxes(context.Context, *connect.Request[gen.ListSandboxesRequest]) (*connect.Response[gen.ListSandboxesResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.ListSandboxes is not implemented")) +} + +func (UnimplementedHostAgentServiceHandler) WriteFile(context.Context, *connect.Request[gen.WriteFileRequest]) (*connect.Response[gen.WriteFileResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.WriteFile is not implemented")) +} + +func (UnimplementedHostAgentServiceHandler) ReadFile(context.Context, *connect.Request[gen.ReadFileRequest]) (*connect.Response[gen.ReadFileResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.ReadFile is not implemented")) +} + +func (UnimplementedHostAgentServiceHandler) CreateSnapshot(context.Context, *connect.Request[gen.CreateSnapshotRequest]) (*connect.Response[gen.CreateSnapshotResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.CreateSnapshot is not implemented")) +} + +func (UnimplementedHostAgentServiceHandler) DeleteSnapshot(context.Context, *connect.Request[gen.DeleteSnapshotRequest]) (*connect.Response[gen.DeleteSnapshotResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.DeleteSnapshot is not implemented")) +} + +func (UnimplementedHostAgentServiceHandler) ExecStream(context.Context, *connect.Request[gen.ExecStreamRequest], *connect.ServerStream[gen.ExecStreamResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.ExecStream is not implemented")) +} + +func (UnimplementedHostAgentServiceHandler) WriteFileStream(context.Context, *connect.ClientStream[gen.WriteFileStreamRequest]) (*connect.Response[gen.WriteFileStreamResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.WriteFileStream is not implemented")) +} + +func (UnimplementedHostAgentServiceHandler) ReadFileStream(context.Context, *connect.Request[gen.ReadFileStreamRequest], *connect.ServerStream[gen.ReadFileStreamResponse]) error { + return connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.ReadFileStream is not implemented")) +} + +func (UnimplementedHostAgentServiceHandler) PingSandbox(context.Context, *connect.Request[gen.PingSandboxRequest]) (*connect.Response[gen.PingSandboxResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.PingSandbox is not implemented")) +} diff --git a/proto/hostagent/hostagent.proto b/proto/hostagent/hostagent.proto index e69de29..b9ceccf 100644 --- a/proto/hostagent/hostagent.proto +++ b/proto/hostagent/hostagent.proto @@ -0,0 +1,238 @@ +syntax = "proto3"; + +package hostagent.v1; + +// HostAgentService manages sandbox VMs on a single physical host. +// The control plane calls these RPCs to orchestrate sandbox lifecycle. +service HostAgentService { + // CreateSandbox boots a new microVM with the given configuration. + rpc CreateSandbox(CreateSandboxRequest) returns (CreateSandboxResponse); + + // DestroySandbox stops and cleans up a sandbox (VM, network, rootfs). + rpc DestroySandbox(DestroySandboxRequest) returns (DestroySandboxResponse); + + // PauseSandbox pauses a running sandbox's VM. + rpc PauseSandbox(PauseSandboxRequest) returns (PauseSandboxResponse); + + // ResumeSandbox resumes a paused sandbox's VM. + rpc ResumeSandbox(ResumeSandboxRequest) returns (ResumeSandboxResponse); + + // Exec runs a command inside a sandbox and returns the collected output. + rpc Exec(ExecRequest) returns (ExecResponse); + + // ListSandboxes returns all sandboxes managed by this host agent. + rpc ListSandboxes(ListSandboxesRequest) returns (ListSandboxesResponse); + + // WriteFile writes content to a file inside a sandbox. + rpc WriteFile(WriteFileRequest) returns (WriteFileResponse); + + // ReadFile reads a file from inside a sandbox. + rpc ReadFile(ReadFileRequest) returns (ReadFileResponse); + + // CreateSnapshot pauses a sandbox, takes a snapshot, stores it as a reusable + // template, and destroys the sandbox. + rpc CreateSnapshot(CreateSnapshotRequest) returns (CreateSnapshotResponse); + + // DeleteSnapshot removes a snapshot template from disk. + rpc DeleteSnapshot(DeleteSnapshotRequest) returns (DeleteSnapshotResponse); + + // ExecStream runs a command inside a sandbox and streams output events as they arrive. + rpc ExecStream(ExecStreamRequest) returns (stream ExecStreamResponse); + + // WriteFileStream writes a file to a sandbox using chunked streaming. + // First message must contain metadata (sandbox_id, path). Subsequent messages contain data chunks. + rpc WriteFileStream(stream WriteFileStreamRequest) returns (WriteFileStreamResponse); + + // ReadFileStream reads a file from a sandbox and streams it back in chunks. + rpc ReadFileStream(ReadFileStreamRequest) returns (stream ReadFileStreamResponse); + + // PingSandbox resets the inactivity timer for a running sandbox. + rpc PingSandbox(PingSandboxRequest) returns (PingSandboxResponse); + +} + +message CreateSandboxRequest { + // Sandbox ID assigned by the control plane. If empty, the host agent generates one. + string sandbox_id = 5; + + // Template name (e.g., "minimal", "python311"). Determines base rootfs. + string template = 1; + + // Number of virtual CPUs (default: 1). + int32 vcpus = 2; + + // Memory in MB (default: 512). + int32 memory_mb = 3; + + // TTL in seconds. Sandbox is auto-paused after this duration of + // inactivity. 0 means no auto-pause. + int32 timeout_sec = 4; +} + +message CreateSandboxResponse { + string sandbox_id = 1; + string status = 2; + string host_ip = 3; +} + +message DestroySandboxRequest { + string sandbox_id = 1; +} + +message DestroySandboxResponse {} + +message PauseSandboxRequest { + string sandbox_id = 1; +} + +message PauseSandboxResponse {} + +message ResumeSandboxRequest { + string sandbox_id = 1; + + // TTL in seconds restored from the DB so the reaper can auto-pause + // the sandbox again after inactivity. 0 means no auto-pause. + int32 timeout_sec = 2; +} + +message ResumeSandboxResponse { + string sandbox_id = 1; + string status = 2; + string host_ip = 3; +} + +message CreateSnapshotRequest { + string sandbox_id = 1; + string name = 2; +} + +message CreateSnapshotResponse { + string name = 1; + int64 size_bytes = 2; +} + +message DeleteSnapshotRequest { + string name = 1; +} + +message DeleteSnapshotResponse {} + +message ExecRequest { + string sandbox_id = 1; + string cmd = 2; + repeated string args = 3; + // Timeout for the command in seconds (default: 30). + int32 timeout_sec = 4; +} + +message ExecResponse { + bytes stdout = 1; + bytes stderr = 2; + int32 exit_code = 3; +} + +message ListSandboxesRequest {} + +message ListSandboxesResponse { + repeated SandboxInfo sandboxes = 1; + + // IDs of sandboxes that were automatically paused by the TTL reaper + // since the last call. Drained on read. + repeated string auto_paused_sandbox_ids = 2; +} + +message SandboxInfo { + string sandbox_id = 1; + string status = 2; + string template = 3; + int32 vcpus = 4; + int32 memory_mb = 5; + string host_ip = 6; + int64 created_at_unix = 7; + int64 last_active_at_unix = 8; + int32 timeout_sec = 9; +} + +message WriteFileRequest { + string sandbox_id = 1; + string path = 2; + bytes content = 3; +} + +message WriteFileResponse {} + +message ReadFileRequest { + string sandbox_id = 1; + string path = 2; +} + +message ReadFileResponse { + bytes content = 1; +} + +// ── Streaming Exec ────────────────────────────────────────────────── + +message ExecStreamRequest { + string sandbox_id = 1; + string cmd = 2; + repeated string args = 3; + int32 timeout_sec = 4; +} + +message ExecStreamResponse { + oneof event { + ExecStreamStart start = 1; + ExecStreamData data = 2; + ExecStreamEnd end = 3; + } +} + +message ExecStreamStart { + uint32 pid = 1; +} + +message ExecStreamData { + oneof output { + bytes stdout = 1; + bytes stderr = 2; + } +} + +message ExecStreamEnd { + int32 exit_code = 1; + string error = 2; +} + +// ── Streaming File Transfer ───────────────────────────────────────── + +message WriteFileStreamRequest { + oneof content { + WriteFileStreamMeta meta = 1; + bytes chunk = 2; + } +} + +message WriteFileStreamMeta { + string sandbox_id = 1; + string path = 2; +} + +message WriteFileStreamResponse {} + +message ReadFileStreamRequest { + string sandbox_id = 1; + string path = 2; +} + +message ReadFileStreamResponse { + bytes chunk = 1; +} + +// ── Ping ──────────────────────────────────────────────────────────── + +message PingSandboxRequest { + string sandbox_id = 1; +} + +message PingSandboxResponse {} + diff --git a/scripts/rootfs-from-container.sh b/scripts/rootfs-from-container.sh new file mode 100755 index 0000000..ce1dd52 --- /dev/null +++ b/scripts/rootfs-from-container.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# +# rootfs-from-container.sh — Create a bootable Wrenn rootfs from a Docker container. +# +# Exports a container's filesystem, writes it into an ext4 image, injects +# envd + wrenn-init, and shrinks the image to minimum size. +# +# Usage: +# bash scripts/rootfs-from-container.sh +# +# Arguments: +# container — Docker container name or ID to export +# image_name — Directory name under images dir (e.g. "waitlist") +# +# Output: +# ${AGENT_FILES_ROOTDIR}/images//rootfs.ext4 +# +# Requires: docker, mkfs.ext4, resize2fs, e2fsck, make (for building envd), curl (for tini download) +# Sudo is used only for mount/umount/copy-into-image operations. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +AGENT_FILES_ROOTDIR="${AGENT_FILES_ROOTDIR:-/var/lib/wrenn}" +AGENT_IMAGES_PATH="${AGENT_FILES_ROOTDIR}/images" + +if [ $# -lt 2 ]; then + echo "Usage: $0 " + exit 1 +fi + +CONTAINER="$1" +IMAGE_NAME="$2" +OUTPUT_DIR="${AGENT_IMAGES_PATH}/${IMAGE_NAME}" +OUTPUT_FILE="${OUTPUT_DIR}/rootfs.ext4" +MOUNT_DIR="/tmp/wrenn-rootfs-build" +TAR_FILE="/tmp/wrenn-rootfs-export-${IMAGE_NAME}.tar" + +# Verify the container exists. +if ! docker inspect "${CONTAINER}" > /dev/null 2>&1; then + echo "ERROR: Container '${CONTAINER}' not found" + exit 1 +fi + +# Step 1: Build envd. +echo "==> Building envd..." +cd "${PROJECT_ROOT}" +make build-envd +ENVD_BIN="${PROJECT_ROOT}/builds/envd" + +if [ ! -f "${ENVD_BIN}" ]; then + echo "ERROR: envd binary not found at ${ENVD_BIN}" + exit 1 +fi + +if ! file "${ENVD_BIN}" | grep -q "statically linked"; then + echo "ERROR: envd is not statically linked!" + exit 1 +fi + +# Step 2: Export container filesystem. +echo "==> Exporting container '${CONTAINER}'..." +docker export "${CONTAINER}" -o "${TAR_FILE}" + +cleanup() { + echo "==> Cleaning up..." + sudo umount "${MOUNT_DIR}" 2>/dev/null || true + rmdir "${MOUNT_DIR}" 2>/dev/null || true + rm -f "${TAR_FILE}" +} +trap cleanup EXIT + +# Step 3: Create an oversized ext4 image. +# Use 2x the tar size + 256MB headroom for filesystem overhead and injected binaries. +TAR_SIZE_BYTES="$(stat --format=%s "${TAR_FILE}")" +INITIAL_SIZE_MB=$(( (TAR_SIZE_BYTES / 1024 / 1024) * 2 + 256 )) +echo "==> Creating ${INITIAL_SIZE_MB}MB ext4 image (will shrink after populating)..." +sudo mkdir -p "${OUTPUT_DIR}" +sudo dd if=/dev/zero of="${OUTPUT_FILE}" bs=1M count="${INITIAL_SIZE_MB}" status=progress +sudo mkfs.ext4 -F "${OUTPUT_FILE}" + +# Step 4: Mount and populate. +echo "==> Mounting image at ${MOUNT_DIR}..." +mkdir -p "${MOUNT_DIR}" +sudo mount -o loop "${OUTPUT_FILE}" "${MOUNT_DIR}" + +echo "==> Extracting container filesystem..." +sudo tar xf "${TAR_FILE}" -C "${MOUNT_DIR}" + +# Step 5: Inject wrenn guest binaries. +echo "==> Installing envd..." +sudo mkdir -p "${MOUNT_DIR}/usr/local/bin" +sudo cp "${ENVD_BIN}" "${MOUNT_DIR}/usr/local/bin/envd" +sudo chmod 755 "${MOUNT_DIR}/usr/local/bin/envd" + +echo "==> Installing wrenn-init..." +sudo cp "${PROJECT_ROOT}/images/wrenn-init.sh" "${MOUNT_DIR}/usr/local/bin/wrenn-init" +sudo chmod 755 "${MOUNT_DIR}/usr/local/bin/wrenn-init" + +echo "==> Installing tini..." +TINI_BIN="" +# 1. Already in the exported container image? +for p in "${MOUNT_DIR}/usr/bin/tini" "${MOUNT_DIR}/sbin/tini" "${MOUNT_DIR}/usr/local/bin/tini"; do + if [ -f "$p" ]; then TINI_BIN="$p"; break; fi +done +# 2. Available on the host? +if [ -z "${TINI_BIN}" ]; then + for p in /usr/bin/tini /usr/local/bin/tini /sbin/tini; do + if [ -f "$p" ]; then TINI_BIN="$p"; break; fi + done +fi +# 3. Download from GitHub releases. +if [ -z "${TINI_BIN}" ]; then + ARCH="$(uname -m)" + case "${ARCH}" in + x86_64) TINI_ARCH="amd64" ;; + aarch64) TINI_ARCH="arm64" ;; + *) echo "ERROR: Unsupported architecture: ${ARCH}"; exit 1 ;; + esac + TINI_VERSION="v0.19.0" + TINI_URL="https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-${TINI_ARCH}" + TINI_TMP="/tmp/tini-${TINI_ARCH}" + echo " Downloading tini ${TINI_VERSION} (${TINI_ARCH})..." + curl -fsSL "${TINI_URL}" -o "${TINI_TMP}" + chmod +x "${TINI_TMP}" + TINI_BIN="${TINI_TMP}" +fi +sudo mkdir -p "${MOUNT_DIR}/sbin" +sudo cp "${TINI_BIN}" "${MOUNT_DIR}/sbin/tini" +sudo chmod 755 "${MOUNT_DIR}/sbin/tini" + +# Step 6: Verify. +echo "" +echo "==> Installed guest binaries:" +ls -la "${MOUNT_DIR}/usr/local/bin/envd" "${MOUNT_DIR}/usr/local/bin/wrenn-init" "${MOUNT_DIR}/sbin/tini" + +# Unmount before shrinking. +sudo umount "${MOUNT_DIR}" +rmdir "${MOUNT_DIR}" 2>/dev/null || true + +# Step 7: Shrink the image to minimum size. +echo "" +echo "==> Shrinking image..." +sudo e2fsck -fy "${OUTPUT_FILE}" +sudo resize2fs -M "${OUTPUT_FILE}" + +# Truncate the file to match the shrunk filesystem. +BLOCK_COUNT="$(sudo dumpe2fs -h "${OUTPUT_FILE}" 2>/dev/null | grep "Block count:" | awk '{print $3}')" +BLOCK_SIZE="$(sudo dumpe2fs -h "${OUTPUT_FILE}" 2>/dev/null | grep "Block size:" | awk '{print $3}')" +FS_SIZE_BYTES=$((BLOCK_COUNT * BLOCK_SIZE)) +sudo truncate -s "${FS_SIZE_BYTES}" "${OUTPUT_FILE}" + +FINAL_SIZE_MB=$((FS_SIZE_BYTES / 1024 / 1024)) +echo "" +echo "==> Done. Rootfs created at: ${OUTPUT_FILE} (${FINAL_SIZE_MB}MB)" diff --git a/scripts/seed.go b/scripts/seed.go deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/test-host.sh b/scripts/test-host.sh new file mode 100755 index 0000000..1cd61e7 --- /dev/null +++ b/scripts/test-host.sh @@ -0,0 +1,233 @@ +#!/usr/bin/env bash +# +# test-host.sh — Integration test for the Wrenn host agent. +# +# Prerequisites: +# - Host agent running: sudo ./builds/wrenn-agent +# - Firecracker installed at /usr/local/bin/firecracker +# - Kernel at /var/lib/wrenn/kernels/vmlinux +# - Base rootfs at /var/lib/wrenn/images/minimal.ext4 (with envd + wrenn-init baked in) +# +# Usage: +# ./scripts/test-host.sh [agent_url] +# +# The agent URL defaults to http://localhost:50051. + +set -euo pipefail + +AGENT="${1:-http://localhost:50051}" +BASE="/hostagent.v1.HostAgentService" +SANDBOX_ID="" + +# Colors for output. +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +NC='\033[0m' + +pass() { echo -e "${GREEN}PASS${NC}: $1"; } +fail() { echo -e "${RED}FAIL${NC}: $1"; exit 1; } +info() { echo -e "${YELLOW}----${NC}: $1"; } + +rpc() { + local method="$1" + local body="$2" + curl -s -X POST \ + -H "Content-Type: application/json" \ + "${AGENT}${BASE}/${method}" \ + -d "${body}" +} + +# ────────────────────────────────────────────────── +# Test 1: List sandboxes (should be empty) +# ────────────────────────────────────────────────── +info "Test 1: List sandboxes (expect empty)" + +RESULT=$(rpc "ListSandboxes" '{}') +echo " Response: ${RESULT}" + +echo "${RESULT}" | grep -q '"sandboxes"' || echo "${RESULT}" | grep -q '{}' && \ + pass "ListSandboxes returned" || \ + fail "ListSandboxes failed" + +# ────────────────────────────────────────────────── +# Test 2: Create a sandbox +# ────────────────────────────────────────────────── +info "Test 2: Create a sandbox" + +RESULT=$(rpc "CreateSandbox" '{ + "template": "minimal", + "vcpus": 1, + "memoryMb": 512, + "timeoutSec": 300 +}') +echo " Response: ${RESULT}" + +SANDBOX_ID=$(echo "${RESULT}" | python3 -c "import sys,json; print(json.load(sys.stdin)['sandboxId'])" 2>/dev/null) || \ + fail "CreateSandbox did not return sandboxId" + +echo " Sandbox ID: ${SANDBOX_ID}" +pass "Sandbox created: ${SANDBOX_ID}" + +# ────────────────────────────────────────────────── +# Test 3: List sandboxes (should have one) +# ────────────────────────────────────────────────── +info "Test 3: List sandboxes (expect one)" + +RESULT=$(rpc "ListSandboxes" '{}') +echo " Response: ${RESULT}" + +echo "${RESULT}" | grep -q "${SANDBOX_ID}" && \ + pass "Sandbox ${SANDBOX_ID} found in list" || \ + fail "Sandbox not found in list" + +# ────────────────────────────────────────────────── +# Test 4: Execute a command +# ────────────────────────────────────────────────── +info "Test 4: Execute 'echo hello world'" + +RESULT=$(rpc "Exec" "{ + \"sandboxId\": \"${SANDBOX_ID}\", + \"cmd\": \"/bin/sh\", + \"args\": [\"-c\", \"echo hello world\"], + \"timeoutSec\": 10 +}") +echo " Response: ${RESULT}" + +# stdout is base64-encoded in Connect RPC JSON. +STDOUT=$(echo "${RESULT}" | python3 -c " +import sys, json, base64 +r = json.load(sys.stdin) +print(base64.b64decode(r['stdout']).decode().strip()) +" 2>/dev/null) || fail "Exec did not return stdout" + +[ "${STDOUT}" = "hello world" ] && \ + pass "Exec returned correct output: '${STDOUT}'" || \ + fail "Expected 'hello world', got '${STDOUT}'" + +# ────────────────────────────────────────────────── +# Test 5: Execute a multi-line command +# ────────────────────────────────────────────────── +info "Test 5: Execute multi-line command" + +RESULT=$(rpc "Exec" "{ + \"sandboxId\": \"${SANDBOX_ID}\", + \"cmd\": \"/bin/sh\", + \"args\": [\"-c\", \"echo line1; echo line2; echo line3\"], + \"timeoutSec\": 10 +}") +echo " Response: ${RESULT}" + +LINE_COUNT=$(echo "${RESULT}" | python3 -c " +import sys, json, base64 +r = json.load(sys.stdin) +lines = base64.b64decode(r['stdout']).decode().strip().split('\n') +print(len(lines)) +" 2>/dev/null) + +[ "${LINE_COUNT}" = "3" ] && \ + pass "Multi-line output: ${LINE_COUNT} lines" || \ + fail "Expected 3 lines, got ${LINE_COUNT}" + +# ────────────────────────────────────────────────── +# Test 6: Pause the sandbox +# ────────────────────────────────────────────────── +info "Test 6: Pause sandbox" + +RESULT=$(rpc "PauseSandbox" "{\"sandboxId\": \"${SANDBOX_ID}\"}") +echo " Response: ${RESULT}" + +# Verify status is paused. +LIST=$(rpc "ListSandboxes" '{}') +echo "${LIST}" | grep -q '"paused"' && \ + pass "Sandbox paused" || \ + fail "Sandbox not in paused state" + +# ────────────────────────────────────────────────── +# Test 7: Exec should fail while paused +# ────────────────────────────────────────────────── +info "Test 7: Exec while paused (expect error)" + +RESULT=$(rpc "Exec" "{ + \"sandboxId\": \"${SANDBOX_ID}\", + \"cmd\": \"/bin/echo\", + \"args\": [\"should fail\"] +}") +echo " Response: ${RESULT}" + +echo "${RESULT}" | grep -qi "not running\|error\|code" && \ + pass "Exec correctly rejected while paused" || \ + fail "Exec should have failed while paused" + +# ────────────────────────────────────────────────── +# Test 8: Resume the sandbox +# ────────────────────────────────────────────────── +info "Test 8: Resume sandbox" + +RESULT=$(rpc "ResumeSandbox" "{\"sandboxId\": \"${SANDBOX_ID}\"}") +echo " Response: ${RESULT}" + +# Verify status is running. +LIST=$(rpc "ListSandboxes" '{}') +echo "${LIST}" | grep -q '"running"' && \ + pass "Sandbox resumed" || \ + fail "Sandbox not in running state" + +# ────────────────────────────────────────────────── +# Test 9: Exec after resume +# ────────────────────────────────────────────────── +info "Test 9: Exec after resume" + +RESULT=$(rpc "Exec" "{ + \"sandboxId\": \"${SANDBOX_ID}\", + \"cmd\": \"/bin/sh\", + \"args\": [\"-c\", \"echo resumed ok\"], + \"timeoutSec\": 10 +}") +echo " Response: ${RESULT}" + +STDOUT=$(echo "${RESULT}" | python3 -c " +import sys, json, base64 +r = json.load(sys.stdin) +print(base64.b64decode(r['stdout']).decode().strip()) +" 2>/dev/null) || fail "Exec after resume failed" + +[ "${STDOUT}" = "resumed ok" ] && \ + pass "Exec after resume works: '${STDOUT}'" || \ + fail "Expected 'resumed ok', got '${STDOUT}'" + +# ────────────────────────────────────────────────── +# Test 10: Destroy the sandbox +# ────────────────────────────────────────────────── +info "Test 10: Destroy sandbox" + +RESULT=$(rpc "DestroySandbox" "{\"sandboxId\": \"${SANDBOX_ID}\"}") +echo " Response: ${RESULT}" +pass "Sandbox destroyed" + +# ────────────────────────────────────────────────── +# Test 11: List sandboxes (should be empty again) +# ────────────────────────────────────────────────── +info "Test 11: List sandboxes (expect empty)" + +RESULT=$(rpc "ListSandboxes" '{}') +echo " Response: ${RESULT}" + +echo "${RESULT}" | grep -q "${SANDBOX_ID}" && \ + fail "Destroyed sandbox still in list" || \ + pass "Sandbox list is clean" + +# ────────────────────────────────────────────────── +# Test 12: Destroy non-existent sandbox (expect error) +# ────────────────────────────────────────────────── +info "Test 12: Destroy non-existent sandbox (expect error)" + +RESULT=$(rpc "DestroySandbox" '{"sandboxId": "sb-nonexist"}') +echo " Response: ${RESULT}" + +echo "${RESULT}" | grep -qi "not found\|error\|code" && \ + pass "Correctly rejected non-existent sandbox" || \ + fail "Should have returned error for non-existent sandbox" + +echo "" +echo -e "${GREEN}All tests passed!${NC}" diff --git a/scripts/update-debug-rootfs.sh b/scripts/update-debug-rootfs.sh new file mode 100755 index 0000000..7d0544e --- /dev/null +++ b/scripts/update-debug-rootfs.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# +# update-debug-rootfs.sh — Build envd and inject it (plus wrenn-init) into the debug rootfs. +# +# This script: +# 1. Builds a fresh envd static binary via make +# 2. Mounts the rootfs image +# 3. Copies envd and wrenn-init into the image +# 4. Unmounts cleanly +# +# Usage: +# bash scripts/update-debug-rootfs.sh [rootfs_path] +# +# Defaults to /var/lib/wrenn/images/minimal/rootfs.ext4 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +ROOTFS="${1:-/var/lib/wrenn/images/minimal/rootfs.ext4}" +MOUNT_DIR="/tmp/wrenn-rootfs-update" + +if [ ! -f "${ROOTFS}" ]; then + echo "ERROR: Rootfs not found at ${ROOTFS}" + exit 1 +fi + +# Step 1: Build envd. +echo "==> Building envd..." +cd "${PROJECT_ROOT}" +make build-envd +ENVD_BIN="${PROJECT_ROOT}/builds/envd" + +if [ ! -f "${ENVD_BIN}" ]; then + echo "ERROR: envd binary not found at ${ENVD_BIN}" + exit 1 +fi + +# Verify it's statically linked. +if ! file "${ENVD_BIN}" | grep -q "statically linked"; then + echo "ERROR: envd is not statically linked!" + exit 1 +fi + +# Step 2: Mount the rootfs. +echo "==> Mounting rootfs at ${MOUNT_DIR}..." +mkdir -p "${MOUNT_DIR}" +sudo mount -o loop "${ROOTFS}" "${MOUNT_DIR}" + +cleanup() { + echo "==> Unmounting rootfs..." + sudo umount "${MOUNT_DIR}" 2>/dev/null || true + rmdir "${MOUNT_DIR}" 2>/dev/null || true +} +trap cleanup EXIT + +# Step 3: Copy files into rootfs. +echo "==> Installing envd..." +sudo mkdir -p "${MOUNT_DIR}/usr/local/bin" +sudo cp "${ENVD_BIN}" "${MOUNT_DIR}/usr/local/bin/envd" +sudo chmod 755 "${MOUNT_DIR}/usr/local/bin/envd" + +echo "==> Installing wrenn-init..." +sudo cp "${PROJECT_ROOT}/images/wrenn-init.sh" "${MOUNT_DIR}/usr/local/bin/wrenn-init" +sudo chmod 755 "${MOUNT_DIR}/usr/local/bin/wrenn-init" + +echo "==> Installing tini..." +TINI_BIN="" +# 1. Already in the rootfs? +for p in "${MOUNT_DIR}/usr/bin/tini" "${MOUNT_DIR}/sbin/tini" "${MOUNT_DIR}/usr/local/bin/tini"; do + if [ -f "$p" ]; then TINI_BIN="$p"; break; fi +done +# 2. Available on the host? +if [ -z "${TINI_BIN}" ]; then + for p in /usr/bin/tini /usr/local/bin/tini /sbin/tini; do + if [ -f "$p" ]; then TINI_BIN="$p"; break; fi + done +fi +# 3. Download from GitHub releases. +if [ -z "${TINI_BIN}" ]; then + ARCH="$(uname -m)" + case "${ARCH}" in + x86_64) TINI_ARCH="amd64" ;; + aarch64) TINI_ARCH="arm64" ;; + *) echo "ERROR: Unsupported architecture: ${ARCH}"; exit 1 ;; + esac + TINI_VERSION="v0.19.0" + TINI_URL="https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-${TINI_ARCH}" + TINI_TMP="/tmp/tini-${TINI_ARCH}" + echo " Downloading tini ${TINI_VERSION} (${TINI_ARCH})..." + curl -fsSL "${TINI_URL}" -o "${TINI_TMP}" + chmod +x "${TINI_TMP}" + TINI_BIN="${TINI_TMP}" +fi +sudo mkdir -p "${MOUNT_DIR}/sbin" +sudo cp "${TINI_BIN}" "${MOUNT_DIR}/sbin/tini" +sudo chmod 755 "${MOUNT_DIR}/sbin/tini" + +# Step 4: Verify. +echo "" +echo "==> Installed files:" +ls -la "${MOUNT_DIR}/usr/local/bin/envd" "${MOUNT_DIR}/usr/local/bin/wrenn-init" "${MOUNT_DIR}/sbin/tini" + +echo "" +echo "==> Done. Rootfs updated: ${ROOTFS}" diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..eb9298f --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,11 @@ +version: "2" +sql: + - engine: "postgresql" + queries: "db/queries" + schema: "db/migrations" + gen: + go: + package: "db" + out: "internal/db" + sql_package: "pgx/v5" + emit_json_tags: true