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:
20
CLAUDE.md
20
CLAUDE.md
@ -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
|
||||||
|
|||||||
8
Makefile
8
Makefile
@ -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; \
|
||||||
|
|||||||
@ -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
59
go.mod
@ -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
|
||||||
|
|||||||
12
images/envd.service
Normal file
12
images/envd.service
Normal 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
23
images/wrenn-init.sh
Normal 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
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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{}
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
package network
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
package network
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
package network
|
||||||
|
|||||||
391
internal/network/setup.go
Normal file
391
internal/network/setup.go
Normal 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")
|
||||||
|
}
|
||||||
@ -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
141
internal/vm/fc.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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
13
proto/envd/buf.gen.yaml
Normal 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
3
proto/envd/buf.yaml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
version: v2
|
||||||
|
modules:
|
||||||
|
- path: .
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
1444
proto/envd/gen/filesystem.pb.go
Normal file
1444
proto/envd/gen/filesystem.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
337
proto/envd/gen/genconnect/filesystem.connect.go
Normal file
337
proto/envd/gen/genconnect/filesystem.connect.go
Normal 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"))
|
||||||
|
}
|
||||||
310
proto/envd/gen/genconnect/process.connect.go
Normal file
310
proto/envd/gen/genconnect/process.connect.go
Normal 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
1970
proto/envd/gen/process.pb.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user