Add host agent with VM lifecycle, TAP networking, and envd client

Implements Phase 1: boot a Firecracker microVM, execute a command inside
it via envd, and get the output back. Uses raw Firecracker HTTP API via
Unix socket (not the Go SDK) for full control over the VM lifecycle.

- internal/vm: VM manager with create/pause/resume/destroy, Firecracker
  HTTP client, process launcher with unshare + ip netns exec isolation
- internal/network: per-sandbox network namespace with veth pair, TAP
  device, NAT rules, and IP forwarding
- internal/envdclient: Connect RPC client for envd process/filesystem
  services with health check retry
- cmd/host-agent: demo binary that boots a VM, runs "echo hello", prints
  output, and cleans up
- proto/envd: canonical proto files with buf + protoc-gen-connect-go
  code generation
- images/wrenn-init.sh: minimal PID 1 init script for guest VMs
- CLAUDE.md: updated architecture to reflect TAP networking (not vsock)
  and Firecracker HTTP API (not Go SDK)
This commit is contained in:
2026-03-10 00:06:47 +06:00
parent a3898d68fb
commit 7753938044
26 changed files with 5773 additions and 1275 deletions

View File

@ -22,17 +22,17 @@ Control Plane (Go binary, single process)
│ gRPC (mTLS) │ gRPC (mTLS)
Host Agent (Go binary, one per physical machine) Host Agent (Go binary, one per physical machine)
├── VM Manager (Firecracker Go SDK + jailer) ├── VM Manager (Firecracker HTTP API via Unix socket)
├── Network Manager (TAP devices, NAT, IP allocator) ├── Network Manager (TAP devices, NAT, IP allocator)
├── Filesystem Manager (CoW rootfs clones) ├── Filesystem Manager (CoW rootfs clones)
├── Envd Client (vsock gRPC to guest agent) ├── Envd Client (HTTP/Connect RPC to guest agent via TAP network)
├── Snapshot Manager (pause/hibernate/resume) ├── Snapshot Manager (pause/hibernate/resume)
├── Metrics Exporter (Prometheus) ├── Metrics Exporter (Prometheus)
└── gRPC server (listens for control plane) └── gRPC server (listens for control plane)
vsock (AF_VSOCK, through Firecracker) HTTP over TAP network (veth + namespace isolation)
envd (Go binary, runs inside each microVM as PID 1) envd (Go binary, runs inside each microVM via wrenn-init)
├── ProcessService (exec commands, stream stdout/stderr) ├── ProcessService (exec commands, stream stdout/stderr)
├── FilesystemService (read/write/list files) ├── FilesystemService (read/write/list files)
└── Terminal (PTY handling for interactive sessions) └── Terminal (PTY handling for interactive sessions)
@ -133,7 +133,7 @@ wrenn-sandbox/
│ │ │ │
│ ├── envdclient/ │ ├── envdclient/
│ │ ├── client.go # gRPC client wrapper for envd │ │ ├── client.go # gRPC client wrapper for envd
│ │ ├── dialer.go # vsock CONNECT handshake dialer │ │ ├── dialer.go # HTTP transport to envd via TAP network
│ │ └── health.go # Health check with retry │ │ └── health.go # Health check with retry
│ │ │ │
│ ├── snapshot/ │ ├── snapshot/
@ -414,7 +414,7 @@ POST /v1/keys Create API key (admin)
- API keys: `wrn_` prefix + 32 random chars - API keys: `wrn_` prefix + 32 random chars
- Host IDs: hostname or `host-` prefix + 8 hex chars - Host IDs: hostname or `host-` prefix + 8 hex chars
- TAP devices: `tap-` + first 8 chars of sandbox ID - TAP devices: `tap-` + first 8 chars of sandbox ID
- vsock CIDs: allocated from pool starting at 3 - Network slot index: 1-based, determines all per-sandbox IPs
### Error Responses ### Error Responses
```json ```json
@ -436,7 +436,7 @@ POST /v1/keys Create API key (admin)
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. 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. **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 HTTP/Connect RPC via TAP networking — same as talking to any remote service.
**envd's own structure:** **envd's own structure:**
``` ```
@ -763,7 +763,7 @@ open http://localhost:8000/admin/
1. Build envd static binary 1. Build envd static binary
2. Create minimal rootfs with envd baked in 2. Create minimal rootfs with envd baked in
3. Write `internal/vm/` — boot Firecracker 3. Write `internal/vm/` — boot Firecracker
4. Write `internal/envdclient/` — connect to envd over vsock 4. Write `internal/envdclient/` — connect to envd over TAP network
5. Test: boot VM, run "echo hello", get output back 5. Test: boot VM, run "echo hello", get output back
### Phase 2: Host Agent ### Phase 2: Host Agent
@ -813,7 +813,7 @@ github.com/go-chi/chi/v5
github.com/jackc/pgx/v5 github.com/jackc/pgx/v5
github.com/pressly/goose/v3 github.com/pressly/goose/v3
github.com/firecracker-microvm/firecracker-go-sdk github.com/firecracker-microvm/firecracker-go-sdk
github.com/mdlayher/vsock github.com/vishvananda/netlink
google.golang.org/grpc google.golang.org/grpc
google.golang.org/protobuf google.golang.org/protobuf
github.com/prometheus/client_golang github.com/prometheus/client_golang
@ -826,7 +826,7 @@ golang.org/x/crypto
``` ```
google.golang.org/grpc google.golang.org/grpc
google.golang.org/protobuf google.golang.org/protobuf
github.com/mdlayher/vsock github.com/vishvananda/netlink
``` ```
### External services ### External services

View File

@ -84,12 +84,8 @@ migrate-reset:
generate: proto sqlc generate: proto sqlc
proto: proto:
protoc --go_out=. --go_opt=paths=source_relative \ cd proto/envd && buf generate
--go-grpc_out=. --go-grpc_opt=paths=source_relative \ cd $(ENVD_DIR)/spec && buf generate
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: sqlc:
@if command -v sqlc > /dev/null; then sqlc generate; \ @if command -v sqlc > /dev/null; then sqlc generate; \

View File

@ -0,0 +1,150 @@
package main
import (
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"os/signal"
"path/filepath"
"syscall"
"time"
"git.omukk.dev/wrenn/sandbox/internal/envdclient"
"git.omukk.dev/wrenn/sandbox/internal/network"
"git.omukk.dev/wrenn/sandbox/internal/vm"
)
const (
kernelPath = "/var/lib/wrenn/kernels/vmlinux"
baseRootfs = "/var/lib/wrenn/sandboxes/rootfs.ext4"
sandboxesDir = "/var/lib/wrenn/sandboxes"
sandboxID = "sb-demo0001"
slotIndex = 1
)
func main() {
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)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Handle signals for clean shutdown.
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()
}()
if err := run(ctx); err != nil {
slog.Error("fatal error", "error", err)
os.Exit(1)
}
}
func run(ctx context.Context) error {
// Step 1: Clone rootfs for this sandbox.
sandboxRootfs := filepath.Join(sandboxesDir, fmt.Sprintf("%s-rootfs.ext4", sandboxID))
slog.Info("cloning rootfs", "src", baseRootfs, "dst", sandboxRootfs)
if err := cloneRootfs(baseRootfs, sandboxRootfs); err != nil {
return fmt.Errorf("clone rootfs: %w", err)
}
defer os.Remove(sandboxRootfs)
// Step 2: Set up network.
slot := network.NewSlot(slotIndex)
slog.Info("setting up network", "slot", slotIndex)
if err := network.CreateNetwork(slot); err != nil {
return fmt.Errorf("create network: %w", err)
}
defer func() {
slog.Info("tearing down network")
network.RemoveNetwork(slot)
}()
// Step 3: Boot the VM.
mgr := vm.NewManager()
cfg := vm.VMConfig{
SandboxID: sandboxID,
KernelPath: kernelPath,
RootfsPath: sandboxRootfs,
VCPUs: 1,
MemoryMB: 512,
NetworkNamespace: slot.NamespaceID,
TapDevice: slot.TapName,
TapMAC: slot.TapMAC,
GuestIP: slot.GuestIP,
GatewayIP: slot.TapIP,
NetMask: slot.GuestNetMask,
}
vmInstance, err := mgr.Create(ctx, cfg)
if err != nil {
return fmt.Errorf("create VM: %w", err)
}
_ = vmInstance
defer func() {
slog.Info("destroying VM")
mgr.Destroy(context.Background(), sandboxID)
}()
// Step 4: Wait for envd to be ready.
client := envdclient.New(slot.HostIP.String())
waitCtx, waitCancel := context.WithTimeout(ctx, 30*time.Second)
defer waitCancel()
if err := client.WaitUntilReady(waitCtx); err != nil {
return fmt.Errorf("wait for envd: %w", err)
}
// Step 5: Run "echo hello" inside the sandbox.
slog.Info("executing command", "cmd", "echo hello")
result, err := client.Exec(ctx, "/bin/sh", "-c", "echo hello")
if err != nil {
return fmt.Errorf("exec: %w", err)
}
fmt.Printf("\n=== Command Output ===\n")
fmt.Printf("stdout: %s", string(result.Stdout))
if len(result.Stderr) > 0 {
fmt.Printf("stderr: %s", string(result.Stderr))
}
fmt.Printf("exit code: %d\n", result.ExitCode)
fmt.Printf("======================\n\n")
// Step 6: Clean shutdown.
slog.Info("demo complete, cleaning up")
return nil
}
// cloneRootfs creates a copy-on-write clone of the base rootfs image.
// Uses reflink if supported by the filesystem, falls back to regular copy.
func cloneRootfs(src, dst string) error {
// Try reflink first (instant, CoW).
cmd := exec.Command("cp", "--reflink=auto", src, dst)
if err := cmd.Run(); err != nil {
return fmt.Errorf("cp --reflink=auto: %w", err)
}
return nil
}

59
go.mod
View File

@ -3,57 +3,10 @@ module git.omukk.dev/wrenn/sandbox
go 1.25.0 go 1.25.0
require ( require (
connectrpc.com/connect v1.19.1 // indirect connectrpc.com/connect v1.19.1
github.com/PuerkitoBio/purell v1.1.1 // indirect github.com/vishvananda/netlink v1.1.1-0.20210330154013-f5de75959ad5
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect google.golang.org/protobuf v1.36.11
github.com/containerd/fifo v1.0.0 // indirect
github.com/containernetworking/cni v1.0.1 // indirect
github.com/containernetworking/plugins v1.0.1 // indirect
github.com/firecracker-microvm/firecracker-go-sdk v1.0.0 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-openapi/analysis v0.21.2 // indirect
github.com/go-openapi/errors v0.20.2 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/loads v0.21.1 // indirect
github.com/go-openapi/runtime v0.24.0 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/strfmt v0.21.2 // indirect
github.com/go-openapi/swag v0.21.1 // indirect
github.com/go-openapi/validate v0.22.0 // indirect
github.com/go-stack/stack v1.8.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mdlayher/socket v0.4.1 // indirect
github.com/mdlayher/vsock v1.2.1 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pressly/goose/v3 v3.27.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/rs/cors v1.11.1 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/vishvananda/netlink v1.1.1-0.20210330154013-f5de75959ad5 // indirect
github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f // indirect
go.mongodb.org/mongo-driver v1.8.3 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
) )
require golang.org/x/sys v0.41.0 // indirect

1208
go.sum

File diff suppressed because it is too large Load Diff

12
images/envd.service Normal file
View File

@ -0,0 +1,12 @@
[Unit]
Description=Wrenn envd guest agent
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/envd
Restart=on-failure
RestartSec=1
[Install]
WantedBy=multi-user.target

23
images/wrenn-init.sh Normal file
View File

@ -0,0 +1,23 @@
#!/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
# Exec envd as the main process (replaces this script, keeps PID 1).
exec /usr/local/bin/envd

View File

@ -0,0 +1,138 @@
package envdclient
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"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),
}
}
// 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
}
// WriteFile writes content to a file inside the sandbox via envd's filesystem service.
func (c *Client) WriteFile(ctx context.Context, path string, content []byte) error {
// envd uses HTTP upload for files, not Connect RPC.
// POST /files with multipart form data.
// For now, use the filesystem MakeDir for directories.
// TODO: Implement file upload via envd's REST endpoint.
return fmt.Errorf("WriteFile not yet implemented")
}
// ReadFile reads a file from inside the sandbox.
func (c *Client) ReadFile(ctx context.Context, path string) ([]byte, error) {
// TODO: Implement file download via envd's REST endpoint.
return nil, fmt.Errorf("ReadFile not yet implemented")
}
// 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
}

View File

@ -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{}
}

View File

@ -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
}

View File

@ -0,0 +1 @@
package network

View File

@ -0,0 +1 @@
package network

View File

@ -0,0 +1 @@
package network

391
internal/network/setup.go Normal file
View File

@ -0,0 +1,391 @@
package network
import (
"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
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 netns.Set(hostNS)
// 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()
// 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 {
return fmt.Errorf("create veth pair: %w", err)
}
// Configure vpeer (eth0) inside namespace.
vpeer, err := netlink.LinkByName("eth0")
if err != nil {
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 {
return fmt.Errorf("set vpeer addr: %w", err)
}
if err := netlink.LinkSetUp(vpeer); err != nil {
return fmt.Errorf("bring up vpeer: %w", err)
}
// Move veth to host namespace.
vethLink, err := netlink.LinkByName(slot.VethName)
if err != nil {
return fmt.Errorf("find veth: %w", err)
}
if err := netlink.LinkSetNsFd(vethLink, int(hostNS)); err != nil {
return fmt.Errorf("move veth to host ns: %w", err)
}
// 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 {
return fmt.Errorf("create tap device: %w", err)
}
tapLink, err := netlink.LinkByName(tapName)
if err != nil {
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 {
return fmt.Errorf("set tap addr: %w", err)
}
if err := netlink.LinkSetUp(tapLink); err != nil {
return fmt.Errorf("bring up tap: %w", err)
}
// Bring up loopback.
lo, err := netlink.LinkByName("lo")
if err != nil {
return fmt.Errorf("find loopback: %w", err)
}
if err := netlink.LinkSetUp(lo); err != nil {
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 {
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 {
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 {
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 {
return fmt.Errorf("add DNAT rule: %w", err)
}
// Switch back to host namespace for host-side config.
if err := netns.Set(hostNS); err != nil {
return fmt.Errorf("switch to host ns: %w", err)
}
// Configure veth on host side.
hostVeth, err := netlink.LinkByName(slot.VethName)
if err != nil {
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 {
return fmt.Errorf("set veth addr: %w", err)
}
if err := netlink.LinkSetUp(hostVeth); err != nil {
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 {
return fmt.Errorf("add host route: %w", err)
}
// Find default gateway interface for FORWARD rules.
defaultIface, err := getDefaultInterface()
if err != nil {
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 {
return fmt.Errorf("add forward rule (out): %w", err)
}
if err := iptablesHost(
"-A", "FORWARD",
"-i", defaultIface, "-o", slot.VethName,
"-j", "ACCEPT",
); err != nil {
return fmt.Errorf("add forward rule (in): %w", err)
}
// MASQUERADE for outbound traffic from sandbox.
if err := iptablesHost(
"-t", "nat", "-A", "POSTROUTING",
"-s", fmt.Sprintf("%s/32", slot.HostIP.String()),
"-o", defaultIface,
"-j", "MASQUERADE",
); err != nil {
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.
func RemoveNetwork(slot *Slot) error {
defaultIface, _ := getDefaultInterface()
// Remove host-side iptables rules (best effort).
if defaultIface != "" {
iptablesHost(
"-D", "FORWARD",
"-i", slot.VethName, "-o", defaultIface,
"-j", "ACCEPT",
)
iptablesHost(
"-D", "FORWARD",
"-i", defaultIface, "-o", slot.VethName,
"-j", "ACCEPT",
)
iptablesHost(
"-t", "nat", "-D", "POSTROUTING",
"-s", fmt.Sprintf("%s/32", slot.HostIP.String()),
"-o", defaultIface,
"-j", "MASQUERADE",
)
}
// Remove host route.
_, hostNet, _ := net.ParseCIDR(fmt.Sprintf("%s/32", slot.HostIP.String()))
netlink.RouteDel(&netlink.Route{
Dst: hostNet,
Gw: slot.VpeerIP,
})
// Delete veth (also destroys the peer in the namespace).
if veth, err := netlink.LinkByName(slot.VethName); err == nil {
netlink.LinkDel(veth)
}
// Delete the named namespace.
netns.DeleteNamed(slot.NamespaceID)
slog.Info("network removed", "ns", slot.NamespaceID)
return nil
}
// nsExec runs a command inside a network namespace.
func nsExec(nsName string, command string, args ...string) error {
cmdArgs := append([]string{"netns", "exec", nsName, command}, args...)
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")
}

View File

@ -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 ext4 rootfs image for this sandbox.
// This should be a per-sandbox copy (reflink clone of the base image).
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 = fmt.Sprintf("/tmp/fc-sandbox-%s", c.SandboxID)
}
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: <client-ip>::<gw-ip>:<netmask>:<hostname>:<iface>:<autoconf>
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
}

141
internal/vm/fc.go Normal file
View File

@ -0,0 +1,141 @@
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 full VM snapshot.
func (c *fcClient) createSnapshot(ctx context.Context, snapPath, memPath string) error {
return c.do(ctx, http.MethodPut, "/snapshot/create", map[string]any{
"snapshot_type": "Full",
"snapshot_path": snapPath,
"mem_file_path": memPath,
})
}
// loadSnapshot loads a VM snapshot.
func (c *fcClient) loadSnapshot(ctx context.Context, snapPath, memPath string) error {
return c.do(ctx, http.MethodPut, "/snapshot/load", map[string]any{
"snapshot_path": snapPath,
"mem_file_path": memPath,
"resume_vm": false,
})
}

View File

@ -0,0 +1,125 @@
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 <ns>: 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) {
execCtx, cancel := context.WithCancel(ctx)
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
}

View File

@ -0,0 +1,192 @@
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
if err := client.setRootfsDrive(ctx, "rootfs", cfg.RootfsPath, 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
}
// 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
}
}
}
}

13
proto/envd/buf.gen.yaml Normal file
View File

@ -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

3
proto/envd/buf.yaml Normal file
View File

@ -0,0 +1,3 @@
version: v2
modules:
- path: .

View File

@ -0,0 +1,135 @@
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;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,337 @@
// 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"))
}

View File

@ -0,0 +1,310 @@
// 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"))
}

1970
proto/envd/gen/process.pb.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,171 @@
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<string, string> 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;
}
}