Add host agent RPC server with sandbox lifecycle management

Implement the host agent as a Connect RPC server that orchestrates
sandbox creation, destruction, pause/resume, and command execution.
Includes sandbox manager with TTL-based reaper, network slot allocator,
rootfs cloning, hostagent proto definition with generated stubs, and
test/debug scripts. Fix Firecracker process lifetime bug where VM was
tied to HTTP request context instead of background context.
This commit is contained in:
2026-03-10 03:54:53 +06:00
parent c31ce90306
commit 6f0c365d44
24 changed files with 6236 additions and 121 deletions

View File

@ -770,8 +770,8 @@ open http://localhost:8000/admin/
1. Write `internal/network/` — TAP + NAT per sandbox 1. Write `internal/network/` — TAP + NAT per sandbox
2. Write `internal/filesystem/` — CoW rootfs clones 2. Write `internal/filesystem/` — CoW rootfs clones
3. Define hostagent.proto, generate stubs 3. Define hostagent.proto, generate stubs
4. Write host agent gRPC server 4. Write host agent rpc server
5. Test: grpcurl to create/exec/destroy 5. Test: curl to create/exec/destroy
### Phase 3: Control Plane ### Phase 3: Control Plane
1. Set up PostgreSQL, write goose migrations 1. Set up PostgreSQL, write goose migrations

View File

@ -21,7 +21,7 @@ build-agent:
build-envd: build-envd:
cd $(ENVD_DIR) && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \ cd $(ENVD_DIR) && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="$(LDFLAGS)" -o ../$(GOBIN)/envd . go build -ldflags="$(LDFLAGS)" -o $(GOBIN)/envd .
@file $(GOBIN)/envd | grep -q "statically linked" || \ @file $(GOBIN)/envd | grep -q "statically linked" || \
(echo "ERROR: envd is not statically linked!" && exit 1) (echo "ERROR: envd is not statically linked!" && exit 1)
@ -85,6 +85,7 @@ generate: proto sqlc
proto: proto:
cd proto/envd && buf generate cd proto/envd && buf generate
cd proto/hostagent && buf generate
cd $(ENVD_DIR)/spec && buf generate cd $(ENVD_DIR)/spec && buf generate
sqlc: sqlc:

View File

@ -2,26 +2,16 @@ package main
import ( import (
"context" "context"
"fmt"
"log/slog" "log/slog"
"net/http"
"os" "os"
"os/exec"
"os/signal" "os/signal"
"path/filepath"
"syscall" "syscall"
"time" "time"
"git.omukk.dev/wrenn/sandbox/internal/envdclient" "git.omukk.dev/wrenn/sandbox/internal/hostagent"
"git.omukk.dev/wrenn/sandbox/internal/network" "git.omukk.dev/wrenn/sandbox/internal/sandbox"
"git.omukk.dev/wrenn/sandbox/internal/vm" "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect"
)
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() { func main() {
@ -39,112 +29,66 @@ func main() {
slog.Warn("failed to enable ip_forward", "error", err) slog.Warn("failed to enable ip_forward", "error", err)
} }
listenAddr := envOrDefault("AGENT_LISTEN_ADDR", ":50051")
kernelPath := envOrDefault("AGENT_KERNEL_PATH", "/var/lib/wrenn/kernels/vmlinux")
imagesPath := envOrDefault("AGENT_IMAGES_PATH", "/var/lib/wrenn/images")
sandboxesPath := envOrDefault("AGENT_SANDBOXES_PATH", "/var/lib/wrenn/sandboxes")
cfg := sandbox.Config{
KernelPath: kernelPath,
ImagesDir: imagesPath,
SandboxesDir: sandboxesPath,
}
mgr := sandbox.New(cfg)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
// Handle signals for clean shutdown. mgr.StartTTLReaper(ctx)
srv := hostagent.NewServer(mgr)
path, handler := hostagentv1connect.NewHostAgentServiceHandler(srv)
mux := http.NewServeMux()
mux.Handle(path, handler)
httpServer := &http.Server{
Addr: listenAddr,
Handler: mux,
}
// Graceful shutdown on signal.
sigCh := make(chan os.Signal, 1) sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() { go func() {
sig := <-sigCh sig := <-sigCh
slog.Info("received signal, shutting down", "signal", sig) slog.Info("received signal, shutting down", "signal", sig)
cancel() cancel()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
mgr.Shutdown(shutdownCtx)
if err := httpServer.Shutdown(shutdownCtx); err != nil {
slog.Error("http server shutdown error", "error", err)
}
}() }()
if err := run(ctx); err != nil { slog.Info("host agent starting", "addr", listenAddr)
slog.Error("fatal error", "error", err) if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("http server error", "error", err)
os.Exit(1) os.Exit(1)
} }
slog.Info("host agent stopped")
} }
func run(ctx context.Context) error { func envOrDefault(key, def string) string {
// Step 1: Clone rootfs for this sandbox. if v := os.Getenv(key); v != "" {
sandboxRootfs := filepath.Join(sandboxesDir, fmt.Sprintf("%s-rootfs.ext4", sandboxID)) return v
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) return def
// 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
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,337 @@
// Code generated by protoc-gen-connect-go. DO NOT EDIT.
//
// Source: filesystem.proto
package specconnect
import (
connect "connectrpc.com/connect"
context "context"
errors "errors"
spec "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec"
http "net/http"
strings "strings"
)
// This is a compile-time assertion to ensure that this generated file and the connect package are
// compatible. If you get a compiler error that this constant is not defined, this code was
// generated with a version of connect newer than the one compiled into your binary. You can fix the
// problem by either regenerating this code with an older version of connect or updating the connect
// version compiled into your binary.
const _ = connect.IsAtLeastVersion1_13_0
const (
// FilesystemName is the fully-qualified name of the Filesystem service.
FilesystemName = "filesystem.Filesystem"
)
// These constants are the fully-qualified names of the RPCs defined in this package. They're
// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route.
//
// Note that these are different from the fully-qualified method names used by
// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to
// reflection-formatted method names, remove the leading slash and convert the remaining slash to a
// period.
const (
// FilesystemStatProcedure is the fully-qualified name of the Filesystem's Stat RPC.
FilesystemStatProcedure = "/filesystem.Filesystem/Stat"
// FilesystemMakeDirProcedure is the fully-qualified name of the Filesystem's MakeDir RPC.
FilesystemMakeDirProcedure = "/filesystem.Filesystem/MakeDir"
// FilesystemMoveProcedure is the fully-qualified name of the Filesystem's Move RPC.
FilesystemMoveProcedure = "/filesystem.Filesystem/Move"
// FilesystemListDirProcedure is the fully-qualified name of the Filesystem's ListDir RPC.
FilesystemListDirProcedure = "/filesystem.Filesystem/ListDir"
// FilesystemRemoveProcedure is the fully-qualified name of the Filesystem's Remove RPC.
FilesystemRemoveProcedure = "/filesystem.Filesystem/Remove"
// FilesystemWatchDirProcedure is the fully-qualified name of the Filesystem's WatchDir RPC.
FilesystemWatchDirProcedure = "/filesystem.Filesystem/WatchDir"
// FilesystemCreateWatcherProcedure is the fully-qualified name of the Filesystem's CreateWatcher
// RPC.
FilesystemCreateWatcherProcedure = "/filesystem.Filesystem/CreateWatcher"
// FilesystemGetWatcherEventsProcedure is the fully-qualified name of the Filesystem's
// GetWatcherEvents RPC.
FilesystemGetWatcherEventsProcedure = "/filesystem.Filesystem/GetWatcherEvents"
// FilesystemRemoveWatcherProcedure is the fully-qualified name of the Filesystem's RemoveWatcher
// RPC.
FilesystemRemoveWatcherProcedure = "/filesystem.Filesystem/RemoveWatcher"
)
// FilesystemClient is a client for the filesystem.Filesystem service.
type FilesystemClient interface {
Stat(context.Context, *connect.Request[spec.StatRequest]) (*connect.Response[spec.StatResponse], error)
MakeDir(context.Context, *connect.Request[spec.MakeDirRequest]) (*connect.Response[spec.MakeDirResponse], error)
Move(context.Context, *connect.Request[spec.MoveRequest]) (*connect.Response[spec.MoveResponse], error)
ListDir(context.Context, *connect.Request[spec.ListDirRequest]) (*connect.Response[spec.ListDirResponse], error)
Remove(context.Context, *connect.Request[spec.RemoveRequest]) (*connect.Response[spec.RemoveResponse], error)
WatchDir(context.Context, *connect.Request[spec.WatchDirRequest]) (*connect.ServerStreamForClient[spec.WatchDirResponse], error)
// Non-streaming versions of WatchDir
CreateWatcher(context.Context, *connect.Request[spec.CreateWatcherRequest]) (*connect.Response[spec.CreateWatcherResponse], error)
GetWatcherEvents(context.Context, *connect.Request[spec.GetWatcherEventsRequest]) (*connect.Response[spec.GetWatcherEventsResponse], error)
RemoveWatcher(context.Context, *connect.Request[spec.RemoveWatcherRequest]) (*connect.Response[spec.RemoveWatcherResponse], error)
}
// NewFilesystemClient constructs a client for the filesystem.Filesystem service. By default, it
// uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends
// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or
// connect.WithGRPCWeb() options.
//
// The URL supplied here should be the base URL for the Connect or gRPC server (for example,
// http://api.acme.com or https://acme.com/grpc).
func NewFilesystemClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) FilesystemClient {
baseURL = strings.TrimRight(baseURL, "/")
filesystemMethods := spec.File_filesystem_proto.Services().ByName("Filesystem").Methods()
return &filesystemClient{
stat: connect.NewClient[spec.StatRequest, spec.StatResponse](
httpClient,
baseURL+FilesystemStatProcedure,
connect.WithSchema(filesystemMethods.ByName("Stat")),
connect.WithClientOptions(opts...),
),
makeDir: connect.NewClient[spec.MakeDirRequest, spec.MakeDirResponse](
httpClient,
baseURL+FilesystemMakeDirProcedure,
connect.WithSchema(filesystemMethods.ByName("MakeDir")),
connect.WithClientOptions(opts...),
),
move: connect.NewClient[spec.MoveRequest, spec.MoveResponse](
httpClient,
baseURL+FilesystemMoveProcedure,
connect.WithSchema(filesystemMethods.ByName("Move")),
connect.WithClientOptions(opts...),
),
listDir: connect.NewClient[spec.ListDirRequest, spec.ListDirResponse](
httpClient,
baseURL+FilesystemListDirProcedure,
connect.WithSchema(filesystemMethods.ByName("ListDir")),
connect.WithClientOptions(opts...),
),
remove: connect.NewClient[spec.RemoveRequest, spec.RemoveResponse](
httpClient,
baseURL+FilesystemRemoveProcedure,
connect.WithSchema(filesystemMethods.ByName("Remove")),
connect.WithClientOptions(opts...),
),
watchDir: connect.NewClient[spec.WatchDirRequest, spec.WatchDirResponse](
httpClient,
baseURL+FilesystemWatchDirProcedure,
connect.WithSchema(filesystemMethods.ByName("WatchDir")),
connect.WithClientOptions(opts...),
),
createWatcher: connect.NewClient[spec.CreateWatcherRequest, spec.CreateWatcherResponse](
httpClient,
baseURL+FilesystemCreateWatcherProcedure,
connect.WithSchema(filesystemMethods.ByName("CreateWatcher")),
connect.WithClientOptions(opts...),
),
getWatcherEvents: connect.NewClient[spec.GetWatcherEventsRequest, spec.GetWatcherEventsResponse](
httpClient,
baseURL+FilesystemGetWatcherEventsProcedure,
connect.WithSchema(filesystemMethods.ByName("GetWatcherEvents")),
connect.WithClientOptions(opts...),
),
removeWatcher: connect.NewClient[spec.RemoveWatcherRequest, spec.RemoveWatcherResponse](
httpClient,
baseURL+FilesystemRemoveWatcherProcedure,
connect.WithSchema(filesystemMethods.ByName("RemoveWatcher")),
connect.WithClientOptions(opts...),
),
}
}
// filesystemClient implements FilesystemClient.
type filesystemClient struct {
stat *connect.Client[spec.StatRequest, spec.StatResponse]
makeDir *connect.Client[spec.MakeDirRequest, spec.MakeDirResponse]
move *connect.Client[spec.MoveRequest, spec.MoveResponse]
listDir *connect.Client[spec.ListDirRequest, spec.ListDirResponse]
remove *connect.Client[spec.RemoveRequest, spec.RemoveResponse]
watchDir *connect.Client[spec.WatchDirRequest, spec.WatchDirResponse]
createWatcher *connect.Client[spec.CreateWatcherRequest, spec.CreateWatcherResponse]
getWatcherEvents *connect.Client[spec.GetWatcherEventsRequest, spec.GetWatcherEventsResponse]
removeWatcher *connect.Client[spec.RemoveWatcherRequest, spec.RemoveWatcherResponse]
}
// Stat calls filesystem.Filesystem.Stat.
func (c *filesystemClient) Stat(ctx context.Context, req *connect.Request[spec.StatRequest]) (*connect.Response[spec.StatResponse], error) {
return c.stat.CallUnary(ctx, req)
}
// MakeDir calls filesystem.Filesystem.MakeDir.
func (c *filesystemClient) MakeDir(ctx context.Context, req *connect.Request[spec.MakeDirRequest]) (*connect.Response[spec.MakeDirResponse], error) {
return c.makeDir.CallUnary(ctx, req)
}
// Move calls filesystem.Filesystem.Move.
func (c *filesystemClient) Move(ctx context.Context, req *connect.Request[spec.MoveRequest]) (*connect.Response[spec.MoveResponse], error) {
return c.move.CallUnary(ctx, req)
}
// ListDir calls filesystem.Filesystem.ListDir.
func (c *filesystemClient) ListDir(ctx context.Context, req *connect.Request[spec.ListDirRequest]) (*connect.Response[spec.ListDirResponse], error) {
return c.listDir.CallUnary(ctx, req)
}
// Remove calls filesystem.Filesystem.Remove.
func (c *filesystemClient) Remove(ctx context.Context, req *connect.Request[spec.RemoveRequest]) (*connect.Response[spec.RemoveResponse], error) {
return c.remove.CallUnary(ctx, req)
}
// WatchDir calls filesystem.Filesystem.WatchDir.
func (c *filesystemClient) WatchDir(ctx context.Context, req *connect.Request[spec.WatchDirRequest]) (*connect.ServerStreamForClient[spec.WatchDirResponse], error) {
return c.watchDir.CallServerStream(ctx, req)
}
// CreateWatcher calls filesystem.Filesystem.CreateWatcher.
func (c *filesystemClient) CreateWatcher(ctx context.Context, req *connect.Request[spec.CreateWatcherRequest]) (*connect.Response[spec.CreateWatcherResponse], error) {
return c.createWatcher.CallUnary(ctx, req)
}
// GetWatcherEvents calls filesystem.Filesystem.GetWatcherEvents.
func (c *filesystemClient) GetWatcherEvents(ctx context.Context, req *connect.Request[spec.GetWatcherEventsRequest]) (*connect.Response[spec.GetWatcherEventsResponse], error) {
return c.getWatcherEvents.CallUnary(ctx, req)
}
// RemoveWatcher calls filesystem.Filesystem.RemoveWatcher.
func (c *filesystemClient) RemoveWatcher(ctx context.Context, req *connect.Request[spec.RemoveWatcherRequest]) (*connect.Response[spec.RemoveWatcherResponse], error) {
return c.removeWatcher.CallUnary(ctx, req)
}
// FilesystemHandler is an implementation of the filesystem.Filesystem service.
type FilesystemHandler interface {
Stat(context.Context, *connect.Request[spec.StatRequest]) (*connect.Response[spec.StatResponse], error)
MakeDir(context.Context, *connect.Request[spec.MakeDirRequest]) (*connect.Response[spec.MakeDirResponse], error)
Move(context.Context, *connect.Request[spec.MoveRequest]) (*connect.Response[spec.MoveResponse], error)
ListDir(context.Context, *connect.Request[spec.ListDirRequest]) (*connect.Response[spec.ListDirResponse], error)
Remove(context.Context, *connect.Request[spec.RemoveRequest]) (*connect.Response[spec.RemoveResponse], error)
WatchDir(context.Context, *connect.Request[spec.WatchDirRequest], *connect.ServerStream[spec.WatchDirResponse]) error
// Non-streaming versions of WatchDir
CreateWatcher(context.Context, *connect.Request[spec.CreateWatcherRequest]) (*connect.Response[spec.CreateWatcherResponse], error)
GetWatcherEvents(context.Context, *connect.Request[spec.GetWatcherEventsRequest]) (*connect.Response[spec.GetWatcherEventsResponse], error)
RemoveWatcher(context.Context, *connect.Request[spec.RemoveWatcherRequest]) (*connect.Response[spec.RemoveWatcherResponse], error)
}
// NewFilesystemHandler builds an HTTP handler from the service implementation. It returns the path
// on which to mount the handler and the handler itself.
//
// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf
// and JSON codecs. They also support gzip compression.
func NewFilesystemHandler(svc FilesystemHandler, opts ...connect.HandlerOption) (string, http.Handler) {
filesystemMethods := spec.File_filesystem_proto.Services().ByName("Filesystem").Methods()
filesystemStatHandler := connect.NewUnaryHandler(
FilesystemStatProcedure,
svc.Stat,
connect.WithSchema(filesystemMethods.ByName("Stat")),
connect.WithHandlerOptions(opts...),
)
filesystemMakeDirHandler := connect.NewUnaryHandler(
FilesystemMakeDirProcedure,
svc.MakeDir,
connect.WithSchema(filesystemMethods.ByName("MakeDir")),
connect.WithHandlerOptions(opts...),
)
filesystemMoveHandler := connect.NewUnaryHandler(
FilesystemMoveProcedure,
svc.Move,
connect.WithSchema(filesystemMethods.ByName("Move")),
connect.WithHandlerOptions(opts...),
)
filesystemListDirHandler := connect.NewUnaryHandler(
FilesystemListDirProcedure,
svc.ListDir,
connect.WithSchema(filesystemMethods.ByName("ListDir")),
connect.WithHandlerOptions(opts...),
)
filesystemRemoveHandler := connect.NewUnaryHandler(
FilesystemRemoveProcedure,
svc.Remove,
connect.WithSchema(filesystemMethods.ByName("Remove")),
connect.WithHandlerOptions(opts...),
)
filesystemWatchDirHandler := connect.NewServerStreamHandler(
FilesystemWatchDirProcedure,
svc.WatchDir,
connect.WithSchema(filesystemMethods.ByName("WatchDir")),
connect.WithHandlerOptions(opts...),
)
filesystemCreateWatcherHandler := connect.NewUnaryHandler(
FilesystemCreateWatcherProcedure,
svc.CreateWatcher,
connect.WithSchema(filesystemMethods.ByName("CreateWatcher")),
connect.WithHandlerOptions(opts...),
)
filesystemGetWatcherEventsHandler := connect.NewUnaryHandler(
FilesystemGetWatcherEventsProcedure,
svc.GetWatcherEvents,
connect.WithSchema(filesystemMethods.ByName("GetWatcherEvents")),
connect.WithHandlerOptions(opts...),
)
filesystemRemoveWatcherHandler := connect.NewUnaryHandler(
FilesystemRemoveWatcherProcedure,
svc.RemoveWatcher,
connect.WithSchema(filesystemMethods.ByName("RemoveWatcher")),
connect.WithHandlerOptions(opts...),
)
return "/filesystem.Filesystem/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case FilesystemStatProcedure:
filesystemStatHandler.ServeHTTP(w, r)
case FilesystemMakeDirProcedure:
filesystemMakeDirHandler.ServeHTTP(w, r)
case FilesystemMoveProcedure:
filesystemMoveHandler.ServeHTTP(w, r)
case FilesystemListDirProcedure:
filesystemListDirHandler.ServeHTTP(w, r)
case FilesystemRemoveProcedure:
filesystemRemoveHandler.ServeHTTP(w, r)
case FilesystemWatchDirProcedure:
filesystemWatchDirHandler.ServeHTTP(w, r)
case FilesystemCreateWatcherProcedure:
filesystemCreateWatcherHandler.ServeHTTP(w, r)
case FilesystemGetWatcherEventsProcedure:
filesystemGetWatcherEventsHandler.ServeHTTP(w, r)
case FilesystemRemoveWatcherProcedure:
filesystemRemoveWatcherHandler.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})
}
// UnimplementedFilesystemHandler returns CodeUnimplemented from all methods.
type UnimplementedFilesystemHandler struct{}
func (UnimplementedFilesystemHandler) Stat(context.Context, *connect.Request[spec.StatRequest]) (*connect.Response[spec.StatResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.Stat is not implemented"))
}
func (UnimplementedFilesystemHandler) MakeDir(context.Context, *connect.Request[spec.MakeDirRequest]) (*connect.Response[spec.MakeDirResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.MakeDir is not implemented"))
}
func (UnimplementedFilesystemHandler) Move(context.Context, *connect.Request[spec.MoveRequest]) (*connect.Response[spec.MoveResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.Move is not implemented"))
}
func (UnimplementedFilesystemHandler) ListDir(context.Context, *connect.Request[spec.ListDirRequest]) (*connect.Response[spec.ListDirResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.ListDir is not implemented"))
}
func (UnimplementedFilesystemHandler) Remove(context.Context, *connect.Request[spec.RemoveRequest]) (*connect.Response[spec.RemoveResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.Remove is not implemented"))
}
func (UnimplementedFilesystemHandler) WatchDir(context.Context, *connect.Request[spec.WatchDirRequest], *connect.ServerStream[spec.WatchDirResponse]) error {
return connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.WatchDir is not implemented"))
}
func (UnimplementedFilesystemHandler) CreateWatcher(context.Context, *connect.Request[spec.CreateWatcherRequest]) (*connect.Response[spec.CreateWatcherResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.CreateWatcher is not implemented"))
}
func (UnimplementedFilesystemHandler) GetWatcherEvents(context.Context, *connect.Request[spec.GetWatcherEventsRequest]) (*connect.Response[spec.GetWatcherEventsResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.GetWatcherEvents is not implemented"))
}
func (UnimplementedFilesystemHandler) RemoveWatcher(context.Context, *connect.Request[spec.RemoveWatcherRequest]) (*connect.Response[spec.RemoveWatcherResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("filesystem.Filesystem.RemoveWatcher is not implemented"))
}

View File

@ -0,0 +1,310 @@
// Code generated by protoc-gen-connect-go. DO NOT EDIT.
//
// Source: process.proto
package specconnect
import (
connect "connectrpc.com/connect"
context "context"
errors "errors"
spec "git.omukk.dev/wrenn/sandbox/envd/internal/services/spec"
http "net/http"
strings "strings"
)
// This is a compile-time assertion to ensure that this generated file and the connect package are
// compatible. If you get a compiler error that this constant is not defined, this code was
// generated with a version of connect newer than the one compiled into your binary. You can fix the
// problem by either regenerating this code with an older version of connect or updating the connect
// version compiled into your binary.
const _ = connect.IsAtLeastVersion1_13_0
const (
// ProcessName is the fully-qualified name of the Process service.
ProcessName = "process.Process"
)
// These constants are the fully-qualified names of the RPCs defined in this package. They're
// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route.
//
// Note that these are different from the fully-qualified method names used by
// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to
// reflection-formatted method names, remove the leading slash and convert the remaining slash to a
// period.
const (
// ProcessListProcedure is the fully-qualified name of the Process's List RPC.
ProcessListProcedure = "/process.Process/List"
// ProcessConnectProcedure is the fully-qualified name of the Process's Connect RPC.
ProcessConnectProcedure = "/process.Process/Connect"
// ProcessStartProcedure is the fully-qualified name of the Process's Start RPC.
ProcessStartProcedure = "/process.Process/Start"
// ProcessUpdateProcedure is the fully-qualified name of the Process's Update RPC.
ProcessUpdateProcedure = "/process.Process/Update"
// ProcessStreamInputProcedure is the fully-qualified name of the Process's StreamInput RPC.
ProcessStreamInputProcedure = "/process.Process/StreamInput"
// ProcessSendInputProcedure is the fully-qualified name of the Process's SendInput RPC.
ProcessSendInputProcedure = "/process.Process/SendInput"
// ProcessSendSignalProcedure is the fully-qualified name of the Process's SendSignal RPC.
ProcessSendSignalProcedure = "/process.Process/SendSignal"
// ProcessCloseStdinProcedure is the fully-qualified name of the Process's CloseStdin RPC.
ProcessCloseStdinProcedure = "/process.Process/CloseStdin"
)
// ProcessClient is a client for the process.Process service.
type ProcessClient interface {
List(context.Context, *connect.Request[spec.ListRequest]) (*connect.Response[spec.ListResponse], error)
Connect(context.Context, *connect.Request[spec.ConnectRequest]) (*connect.ServerStreamForClient[spec.ConnectResponse], error)
Start(context.Context, *connect.Request[spec.StartRequest]) (*connect.ServerStreamForClient[spec.StartResponse], error)
Update(context.Context, *connect.Request[spec.UpdateRequest]) (*connect.Response[spec.UpdateResponse], error)
// Client input stream ensures ordering of messages
StreamInput(context.Context) *connect.ClientStreamForClient[spec.StreamInputRequest, spec.StreamInputResponse]
SendInput(context.Context, *connect.Request[spec.SendInputRequest]) (*connect.Response[spec.SendInputResponse], error)
SendSignal(context.Context, *connect.Request[spec.SendSignalRequest]) (*connect.Response[spec.SendSignalResponse], error)
// Close stdin to signal EOF to the process.
// Only works for non-PTY processes. For PTY, send Ctrl+D (0x04) instead.
CloseStdin(context.Context, *connect.Request[spec.CloseStdinRequest]) (*connect.Response[spec.CloseStdinResponse], error)
}
// NewProcessClient constructs a client for the process.Process service. By default, it uses the
// Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends
// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or
// connect.WithGRPCWeb() options.
//
// The URL supplied here should be the base URL for the Connect or gRPC server (for example,
// http://api.acme.com or https://acme.com/grpc).
func NewProcessClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) ProcessClient {
baseURL = strings.TrimRight(baseURL, "/")
processMethods := spec.File_process_proto.Services().ByName("Process").Methods()
return &processClient{
list: connect.NewClient[spec.ListRequest, spec.ListResponse](
httpClient,
baseURL+ProcessListProcedure,
connect.WithSchema(processMethods.ByName("List")),
connect.WithClientOptions(opts...),
),
connect: connect.NewClient[spec.ConnectRequest, spec.ConnectResponse](
httpClient,
baseURL+ProcessConnectProcedure,
connect.WithSchema(processMethods.ByName("Connect")),
connect.WithClientOptions(opts...),
),
start: connect.NewClient[spec.StartRequest, spec.StartResponse](
httpClient,
baseURL+ProcessStartProcedure,
connect.WithSchema(processMethods.ByName("Start")),
connect.WithClientOptions(opts...),
),
update: connect.NewClient[spec.UpdateRequest, spec.UpdateResponse](
httpClient,
baseURL+ProcessUpdateProcedure,
connect.WithSchema(processMethods.ByName("Update")),
connect.WithClientOptions(opts...),
),
streamInput: connect.NewClient[spec.StreamInputRequest, spec.StreamInputResponse](
httpClient,
baseURL+ProcessStreamInputProcedure,
connect.WithSchema(processMethods.ByName("StreamInput")),
connect.WithClientOptions(opts...),
),
sendInput: connect.NewClient[spec.SendInputRequest, spec.SendInputResponse](
httpClient,
baseURL+ProcessSendInputProcedure,
connect.WithSchema(processMethods.ByName("SendInput")),
connect.WithClientOptions(opts...),
),
sendSignal: connect.NewClient[spec.SendSignalRequest, spec.SendSignalResponse](
httpClient,
baseURL+ProcessSendSignalProcedure,
connect.WithSchema(processMethods.ByName("SendSignal")),
connect.WithClientOptions(opts...),
),
closeStdin: connect.NewClient[spec.CloseStdinRequest, spec.CloseStdinResponse](
httpClient,
baseURL+ProcessCloseStdinProcedure,
connect.WithSchema(processMethods.ByName("CloseStdin")),
connect.WithClientOptions(opts...),
),
}
}
// processClient implements ProcessClient.
type processClient struct {
list *connect.Client[spec.ListRequest, spec.ListResponse]
connect *connect.Client[spec.ConnectRequest, spec.ConnectResponse]
start *connect.Client[spec.StartRequest, spec.StartResponse]
update *connect.Client[spec.UpdateRequest, spec.UpdateResponse]
streamInput *connect.Client[spec.StreamInputRequest, spec.StreamInputResponse]
sendInput *connect.Client[spec.SendInputRequest, spec.SendInputResponse]
sendSignal *connect.Client[spec.SendSignalRequest, spec.SendSignalResponse]
closeStdin *connect.Client[spec.CloseStdinRequest, spec.CloseStdinResponse]
}
// List calls process.Process.List.
func (c *processClient) List(ctx context.Context, req *connect.Request[spec.ListRequest]) (*connect.Response[spec.ListResponse], error) {
return c.list.CallUnary(ctx, req)
}
// Connect calls process.Process.Connect.
func (c *processClient) Connect(ctx context.Context, req *connect.Request[spec.ConnectRequest]) (*connect.ServerStreamForClient[spec.ConnectResponse], error) {
return c.connect.CallServerStream(ctx, req)
}
// Start calls process.Process.Start.
func (c *processClient) Start(ctx context.Context, req *connect.Request[spec.StartRequest]) (*connect.ServerStreamForClient[spec.StartResponse], error) {
return c.start.CallServerStream(ctx, req)
}
// Update calls process.Process.Update.
func (c *processClient) Update(ctx context.Context, req *connect.Request[spec.UpdateRequest]) (*connect.Response[spec.UpdateResponse], error) {
return c.update.CallUnary(ctx, req)
}
// StreamInput calls process.Process.StreamInput.
func (c *processClient) StreamInput(ctx context.Context) *connect.ClientStreamForClient[spec.StreamInputRequest, spec.StreamInputResponse] {
return c.streamInput.CallClientStream(ctx)
}
// SendInput calls process.Process.SendInput.
func (c *processClient) SendInput(ctx context.Context, req *connect.Request[spec.SendInputRequest]) (*connect.Response[spec.SendInputResponse], error) {
return c.sendInput.CallUnary(ctx, req)
}
// SendSignal calls process.Process.SendSignal.
func (c *processClient) SendSignal(ctx context.Context, req *connect.Request[spec.SendSignalRequest]) (*connect.Response[spec.SendSignalResponse], error) {
return c.sendSignal.CallUnary(ctx, req)
}
// CloseStdin calls process.Process.CloseStdin.
func (c *processClient) CloseStdin(ctx context.Context, req *connect.Request[spec.CloseStdinRequest]) (*connect.Response[spec.CloseStdinResponse], error) {
return c.closeStdin.CallUnary(ctx, req)
}
// ProcessHandler is an implementation of the process.Process service.
type ProcessHandler interface {
List(context.Context, *connect.Request[spec.ListRequest]) (*connect.Response[spec.ListResponse], error)
Connect(context.Context, *connect.Request[spec.ConnectRequest], *connect.ServerStream[spec.ConnectResponse]) error
Start(context.Context, *connect.Request[spec.StartRequest], *connect.ServerStream[spec.StartResponse]) error
Update(context.Context, *connect.Request[spec.UpdateRequest]) (*connect.Response[spec.UpdateResponse], error)
// Client input stream ensures ordering of messages
StreamInput(context.Context, *connect.ClientStream[spec.StreamInputRequest]) (*connect.Response[spec.StreamInputResponse], error)
SendInput(context.Context, *connect.Request[spec.SendInputRequest]) (*connect.Response[spec.SendInputResponse], error)
SendSignal(context.Context, *connect.Request[spec.SendSignalRequest]) (*connect.Response[spec.SendSignalResponse], error)
// Close stdin to signal EOF to the process.
// Only works for non-PTY processes. For PTY, send Ctrl+D (0x04) instead.
CloseStdin(context.Context, *connect.Request[spec.CloseStdinRequest]) (*connect.Response[spec.CloseStdinResponse], error)
}
// NewProcessHandler builds an HTTP handler from the service implementation. It returns the path on
// which to mount the handler and the handler itself.
//
// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf
// and JSON codecs. They also support gzip compression.
func NewProcessHandler(svc ProcessHandler, opts ...connect.HandlerOption) (string, http.Handler) {
processMethods := spec.File_process_proto.Services().ByName("Process").Methods()
processListHandler := connect.NewUnaryHandler(
ProcessListProcedure,
svc.List,
connect.WithSchema(processMethods.ByName("List")),
connect.WithHandlerOptions(opts...),
)
processConnectHandler := connect.NewServerStreamHandler(
ProcessConnectProcedure,
svc.Connect,
connect.WithSchema(processMethods.ByName("Connect")),
connect.WithHandlerOptions(opts...),
)
processStartHandler := connect.NewServerStreamHandler(
ProcessStartProcedure,
svc.Start,
connect.WithSchema(processMethods.ByName("Start")),
connect.WithHandlerOptions(opts...),
)
processUpdateHandler := connect.NewUnaryHandler(
ProcessUpdateProcedure,
svc.Update,
connect.WithSchema(processMethods.ByName("Update")),
connect.WithHandlerOptions(opts...),
)
processStreamInputHandler := connect.NewClientStreamHandler(
ProcessStreamInputProcedure,
svc.StreamInput,
connect.WithSchema(processMethods.ByName("StreamInput")),
connect.WithHandlerOptions(opts...),
)
processSendInputHandler := connect.NewUnaryHandler(
ProcessSendInputProcedure,
svc.SendInput,
connect.WithSchema(processMethods.ByName("SendInput")),
connect.WithHandlerOptions(opts...),
)
processSendSignalHandler := connect.NewUnaryHandler(
ProcessSendSignalProcedure,
svc.SendSignal,
connect.WithSchema(processMethods.ByName("SendSignal")),
connect.WithHandlerOptions(opts...),
)
processCloseStdinHandler := connect.NewUnaryHandler(
ProcessCloseStdinProcedure,
svc.CloseStdin,
connect.WithSchema(processMethods.ByName("CloseStdin")),
connect.WithHandlerOptions(opts...),
)
return "/process.Process/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case ProcessListProcedure:
processListHandler.ServeHTTP(w, r)
case ProcessConnectProcedure:
processConnectHandler.ServeHTTP(w, r)
case ProcessStartProcedure:
processStartHandler.ServeHTTP(w, r)
case ProcessUpdateProcedure:
processUpdateHandler.ServeHTTP(w, r)
case ProcessStreamInputProcedure:
processStreamInputHandler.ServeHTTP(w, r)
case ProcessSendInputProcedure:
processSendInputHandler.ServeHTTP(w, r)
case ProcessSendSignalProcedure:
processSendSignalHandler.ServeHTTP(w, r)
case ProcessCloseStdinProcedure:
processCloseStdinHandler.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})
}
// UnimplementedProcessHandler returns CodeUnimplemented from all methods.
type UnimplementedProcessHandler struct{}
func (UnimplementedProcessHandler) List(context.Context, *connect.Request[spec.ListRequest]) (*connect.Response[spec.ListResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.List is not implemented"))
}
func (UnimplementedProcessHandler) Connect(context.Context, *connect.Request[spec.ConnectRequest], *connect.ServerStream[spec.ConnectResponse]) error {
return connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.Connect is not implemented"))
}
func (UnimplementedProcessHandler) Start(context.Context, *connect.Request[spec.StartRequest], *connect.ServerStream[spec.StartResponse]) error {
return connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.Start is not implemented"))
}
func (UnimplementedProcessHandler) Update(context.Context, *connect.Request[spec.UpdateRequest]) (*connect.Response[spec.UpdateResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.Update is not implemented"))
}
func (UnimplementedProcessHandler) StreamInput(context.Context, *connect.ClientStream[spec.StreamInputRequest]) (*connect.Response[spec.StreamInputResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.StreamInput is not implemented"))
}
func (UnimplementedProcessHandler) SendInput(context.Context, *connect.Request[spec.SendInputRequest]) (*connect.Response[spec.SendInputResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.SendInput is not implemented"))
}
func (UnimplementedProcessHandler) SendSignal(context.Context, *connect.Request[spec.SendSignalRequest]) (*connect.Response[spec.SendSignalResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.SendSignal is not implemented"))
}
func (UnimplementedProcessHandler) CloseStdin(context.Context, *connect.Request[spec.CloseStdinRequest]) (*connect.Response[spec.CloseStdinResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("process.Process.CloseStdin is not implemented"))
}

View File

@ -1,12 +0,0 @@
[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

View File

@ -0,0 +1,16 @@
package filesystem
import (
"fmt"
"os/exec"
)
// 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 {
cmd := exec.Command("cp", "--reflink=auto", src, dst)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("cp --reflink=auto: %s: %w", string(out), err)
}
return nil
}

View File

@ -0,0 +1 @@
package filesystem

View File

@ -0,0 +1,125 @@
package hostagent
import (
"context"
"fmt"
"time"
"connectrpc.com/connect"
pb "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
"git.omukk.dev/wrenn/sandbox/proto/hostagent/gen/hostagentv1connect"
"git.omukk.dev/wrenn/sandbox/internal/sandbox"
)
// Server implements the HostAgentService Connect RPC handler.
type Server struct {
hostagentv1connect.UnimplementedHostAgentServiceHandler
mgr *sandbox.Manager
}
// NewServer creates a new host agent RPC server.
func NewServer(mgr *sandbox.Manager) *Server {
return &Server{mgr: mgr}
}
func (s *Server) CreateSandbox(
ctx context.Context,
req *connect.Request[pb.CreateSandboxRequest],
) (*connect.Response[pb.CreateSandboxResponse], error) {
msg := req.Msg
sb, err := s.mgr.Create(ctx, msg.Template, int(msg.Vcpus), int(msg.MemoryMb), int(msg.TimeoutSec))
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("create sandbox: %w", err))
}
return connect.NewResponse(&pb.CreateSandboxResponse{
SandboxId: sb.ID,
Status: string(sb.Status),
HostIp: sb.HostIP.String(),
}), nil
}
func (s *Server) DestroySandbox(
ctx context.Context,
req *connect.Request[pb.DestroySandboxRequest],
) (*connect.Response[pb.DestroySandboxResponse], error) {
if err := s.mgr.Destroy(ctx, req.Msg.SandboxId); err != nil {
return nil, connect.NewError(connect.CodeNotFound, err)
}
return connect.NewResponse(&pb.DestroySandboxResponse{}), nil
}
func (s *Server) PauseSandbox(
ctx context.Context,
req *connect.Request[pb.PauseSandboxRequest],
) (*connect.Response[pb.PauseSandboxResponse], error) {
if err := s.mgr.Pause(ctx, req.Msg.SandboxId); err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
}
return connect.NewResponse(&pb.PauseSandboxResponse{}), nil
}
func (s *Server) ResumeSandbox(
ctx context.Context,
req *connect.Request[pb.ResumeSandboxRequest],
) (*connect.Response[pb.ResumeSandboxResponse], error) {
if err := s.mgr.Resume(ctx, req.Msg.SandboxId); err != nil {
return nil, connect.NewError(connect.CodeInternal, err)
}
return connect.NewResponse(&pb.ResumeSandboxResponse{}), nil
}
func (s *Server) Exec(
ctx context.Context,
req *connect.Request[pb.ExecRequest],
) (*connect.Response[pb.ExecResponse], error) {
msg := req.Msg
timeout := 30 * time.Second
if msg.TimeoutSec > 0 {
timeout = time.Duration(msg.TimeoutSec) * time.Second
}
execCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
result, err := s.mgr.Exec(execCtx, msg.SandboxId, msg.Cmd, msg.Args...)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("exec: %w", err))
}
return connect.NewResponse(&pb.ExecResponse{
Stdout: result.Stdout,
Stderr: result.Stderr,
ExitCode: result.ExitCode,
}), nil
}
func (s *Server) ListSandboxes(
ctx context.Context,
req *connect.Request[pb.ListSandboxesRequest],
) (*connect.Response[pb.ListSandboxesResponse], error) {
sandboxes := s.mgr.List()
infos := make([]*pb.SandboxInfo, len(sandboxes))
for i, sb := range sandboxes {
infos[i] = &pb.SandboxInfo{
SandboxId: sb.ID,
Status: string(sb.Status),
Template: sb.Template,
Vcpus: int32(sb.VCPUs),
MemoryMb: int32(sb.MemoryMB),
HostIp: sb.HostIP.String(),
CreatedAtUnix: sb.CreatedAt.Unix(),
LastActiveAtUnix: sb.LastActiveAt.Unix(),
TimeoutSec: int32(sb.TimeoutSec),
}
}
return connect.NewResponse(&pb.ListSandboxesResponse{
Sandboxes: infos,
}), nil
}

View File

@ -0,0 +1,16 @@
package id
import (
"crypto/rand"
"encoding/hex"
"fmt"
)
// NewSandboxID generates a new sandbox ID in the format "sb-" + 8 hex chars.
func NewSandboxID() string {
b := make([]byte, 4)
if _, err := rand.Read(b); err != nil {
panic(fmt.Sprintf("crypto/rand failed: %v", err))
}
return "sb-" + hex.EncodeToString(b)
}

View File

@ -0,0 +1 @@
package models

View File

@ -0,0 +1,32 @@
package models
import (
"net"
"time"
)
// SandboxStatus represents the current state of a sandbox.
type SandboxStatus string
const (
StatusPending SandboxStatus = "pending"
StatusRunning SandboxStatus = "running"
StatusPaused SandboxStatus = "paused"
StatusStopped SandboxStatus = "stopped"
StatusError SandboxStatus = "error"
)
// Sandbox holds all state for a running sandbox on this host.
type Sandbox struct {
ID string
Status SandboxStatus
Template string
VCPUs int
MemoryMB int
TimeoutSec int
SlotIndex int
HostIP net.IP
RootfsPath string
CreatedAt time.Time
LastActiveAt time.Time
}

View File

@ -1 +1,41 @@
package network package network
import (
"fmt"
"sync"
)
// SlotAllocator manages network slot indices for sandboxes.
// Each sandbox needs a unique slot index for its network addressing.
type SlotAllocator struct {
mu sync.Mutex
inUse map[int]bool
}
// NewSlotAllocator creates a new slot allocator.
func NewSlotAllocator() *SlotAllocator {
return &SlotAllocator{
inUse: make(map[int]bool),
}
}
// Allocate returns the next available slot index (1-based).
func (a *SlotAllocator) Allocate() (int, error) {
a.mu.Lock()
defer a.mu.Unlock()
for i := 1; i <= 65534; i++ {
if !a.inUse[i] {
a.inUse[i] = true
return i, nil
}
}
return 0, fmt.Errorf("no free network slots")
}
// Release frees a slot index for reuse.
func (a *SlotAllocator) Release(index int) {
a.mu.Lock()
defer a.mu.Unlock()
delete(a.inUse, index)
}

356
internal/sandbox/manager.go Normal file
View File

@ -0,0 +1,356 @@
package sandbox
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
"git.omukk.dev/wrenn/sandbox/internal/envdclient"
"git.omukk.dev/wrenn/sandbox/internal/filesystem"
"git.omukk.dev/wrenn/sandbox/internal/id"
"git.omukk.dev/wrenn/sandbox/internal/models"
"git.omukk.dev/wrenn/sandbox/internal/network"
"git.omukk.dev/wrenn/sandbox/internal/vm"
)
// Config holds the paths and defaults for the sandbox manager.
type Config struct {
KernelPath string
ImagesDir string // directory containing base rootfs images (e.g., /var/lib/wrenn/images/minimal.ext4)
SandboxesDir string // directory for per-sandbox rootfs clones (e.g., /var/lib/wrenn/sandboxes)
EnvdTimeout time.Duration
}
// Manager orchestrates sandbox lifecycle: VM, network, filesystem, envd.
type Manager struct {
cfg Config
vm *vm.Manager
slots *network.SlotAllocator
mu sync.RWMutex
boxes map[string]*sandboxState
stopCh chan struct{}
}
// sandboxState holds the runtime state for a single sandbox.
type sandboxState struct {
models.Sandbox
slot *network.Slot
client *envdclient.Client
}
// New creates a new sandbox manager.
func New(cfg Config) *Manager {
if cfg.EnvdTimeout == 0 {
cfg.EnvdTimeout = 30 * time.Second
}
return &Manager{
cfg: cfg,
vm: vm.NewManager(),
slots: network.NewSlotAllocator(),
boxes: make(map[string]*sandboxState),
stopCh: make(chan struct{}),
}
}
// Create boots a new sandbox: clone rootfs, set up network, start VM, wait for envd.
func (m *Manager) Create(ctx context.Context, template string, vcpus, memoryMB, timeoutSec int) (*models.Sandbox, error) {
sandboxID := id.NewSandboxID()
if vcpus <= 0 {
vcpus = 1
}
if memoryMB <= 0 {
memoryMB = 512
}
if template == "" {
template = "minimal"
}
// Resolve base rootfs image: /var/lib/wrenn/images/{template}.ext4
baseRootfs := filepath.Join(m.cfg.ImagesDir, template+".ext4")
if _, err := os.Stat(baseRootfs); err != nil {
return nil, fmt.Errorf("base rootfs not found at %s: %w", baseRootfs, err)
}
// Clone rootfs.
rootfsPath := filepath.Join(m.cfg.SandboxesDir, fmt.Sprintf("%s-%s.ext4", sandboxID, template))
if err := filesystem.CloneRootfs(baseRootfs, rootfsPath); err != nil {
return nil, fmt.Errorf("clone rootfs: %w", err)
}
// Allocate network slot.
slotIdx, err := m.slots.Allocate()
if err != nil {
os.Remove(rootfsPath)
return nil, fmt.Errorf("allocate network slot: %w", err)
}
slot := network.NewSlot(slotIdx)
// Set up network.
if err := network.CreateNetwork(slot); err != nil {
m.slots.Release(slotIdx)
os.Remove(rootfsPath)
return nil, fmt.Errorf("create network: %w", err)
}
// Boot VM.
vmCfg := vm.VMConfig{
SandboxID: sandboxID,
KernelPath: m.cfg.KernelPath,
RootfsPath: rootfsPath,
VCPUs: vcpus,
MemoryMB: memoryMB,
NetworkNamespace: slot.NamespaceID,
TapDevice: slot.TapName,
TapMAC: slot.TapMAC,
GuestIP: slot.GuestIP,
GatewayIP: slot.TapIP,
NetMask: slot.GuestNetMask,
}
if _, err := m.vm.Create(ctx, vmCfg); err != nil {
network.RemoveNetwork(slot)
m.slots.Release(slotIdx)
os.Remove(rootfsPath)
return nil, fmt.Errorf("create VM: %w", err)
}
// Wait for envd to be ready.
client := envdclient.New(slot.HostIP.String())
waitCtx, waitCancel := context.WithTimeout(ctx, m.cfg.EnvdTimeout)
defer waitCancel()
if err := client.WaitUntilReady(waitCtx); err != nil {
m.vm.Destroy(context.Background(), sandboxID)
network.RemoveNetwork(slot)
m.slots.Release(slotIdx)
os.Remove(rootfsPath)
return nil, fmt.Errorf("wait for envd: %w", err)
}
now := time.Now()
sb := &sandboxState{
Sandbox: models.Sandbox{
ID: sandboxID,
Status: models.StatusRunning,
Template: template,
VCPUs: vcpus,
MemoryMB: memoryMB,
TimeoutSec: timeoutSec,
SlotIndex: slotIdx,
HostIP: slot.HostIP,
RootfsPath: rootfsPath,
CreatedAt: now,
LastActiveAt: now,
},
slot: slot,
client: client,
}
m.mu.Lock()
m.boxes[sandboxID] = sb
m.mu.Unlock()
slog.Info("sandbox created",
"id", sandboxID,
"template", template,
"host_ip", slot.HostIP.String(),
)
return &sb.Sandbox, nil
}
// Destroy stops and cleans up a sandbox.
func (m *Manager) Destroy(ctx context.Context, sandboxID string) error {
m.mu.Lock()
sb, ok := m.boxes[sandboxID]
if !ok {
m.mu.Unlock()
return fmt.Errorf("sandbox not found: %s", sandboxID)
}
delete(m.boxes, sandboxID)
m.mu.Unlock()
m.cleanup(ctx, sb)
slog.Info("sandbox destroyed", "id", sandboxID)
return nil
}
// cleanup tears down all resources for a sandbox.
func (m *Manager) cleanup(ctx context.Context, sb *sandboxState) {
if err := m.vm.Destroy(ctx, sb.ID); err != nil {
slog.Warn("vm destroy error", "id", sb.ID, "error", err)
}
if err := network.RemoveNetwork(sb.slot); err != nil {
slog.Warn("network cleanup error", "id", sb.ID, "error", err)
}
m.slots.Release(sb.SlotIndex)
os.Remove(sb.RootfsPath)
}
// Pause pauses a running sandbox.
func (m *Manager) Pause(ctx context.Context, sandboxID string) error {
sb, err := m.get(sandboxID)
if err != nil {
return err
}
if sb.Status != models.StatusRunning {
return fmt.Errorf("sandbox %s is not running (status: %s)", sandboxID, sb.Status)
}
if err := m.vm.Pause(ctx, sandboxID); err != nil {
return fmt.Errorf("pause VM: %w", err)
}
m.mu.Lock()
sb.Status = models.StatusPaused
m.mu.Unlock()
slog.Info("sandbox paused", "id", sandboxID)
return nil
}
// Resume resumes a paused sandbox.
func (m *Manager) Resume(ctx context.Context, sandboxID string) error {
sb, err := m.get(sandboxID)
if err != nil {
return err
}
if sb.Status != models.StatusPaused {
return fmt.Errorf("sandbox %s is not paused (status: %s)", sandboxID, sb.Status)
}
if err := m.vm.Resume(ctx, sandboxID); err != nil {
return fmt.Errorf("resume VM: %w", err)
}
m.mu.Lock()
sb.Status = models.StatusRunning
sb.LastActiveAt = time.Now()
m.mu.Unlock()
slog.Info("sandbox resumed", "id", sandboxID)
return nil
}
// Exec runs a command inside a sandbox.
func (m *Manager) Exec(ctx context.Context, sandboxID string, cmd string, args ...string) (*envdclient.ExecResult, error) {
sb, err := m.get(sandboxID)
if err != nil {
return nil, err
}
if sb.Status != models.StatusRunning {
return nil, fmt.Errorf("sandbox %s is not running (status: %s)", sandboxID, sb.Status)
}
m.mu.Lock()
sb.LastActiveAt = time.Now()
m.mu.Unlock()
return sb.client.Exec(ctx, cmd, args...)
}
// List returns all sandboxes.
func (m *Manager) List() []models.Sandbox {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]models.Sandbox, 0, len(m.boxes))
for _, sb := range m.boxes {
result = append(result, sb.Sandbox)
}
return result
}
// Get returns a sandbox by ID.
func (m *Manager) Get(sandboxID string) (*models.Sandbox, error) {
sb, err := m.get(sandboxID)
if err != nil {
return nil, err
}
return &sb.Sandbox, nil
}
func (m *Manager) get(sandboxID string) (*sandboxState, error) {
m.mu.RLock()
defer m.mu.RUnlock()
sb, ok := m.boxes[sandboxID]
if !ok {
return nil, fmt.Errorf("sandbox not found: %s", sandboxID)
}
return sb, nil
}
// StartTTLReaper starts a background goroutine that destroys sandboxes
// that have exceeded their TTL (timeout_sec of inactivity).
func (m *Manager) StartTTLReaper(ctx context.Context) {
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-m.stopCh:
return
case <-ticker.C:
m.reapExpired(ctx)
}
}
}()
}
func (m *Manager) reapExpired(ctx context.Context) {
m.mu.RLock()
var expired []string
now := time.Now()
for id, sb := range m.boxes {
if sb.TimeoutSec <= 0 {
continue
}
if sb.Status != models.StatusRunning && sb.Status != models.StatusPaused {
continue
}
if now.Sub(sb.LastActiveAt) > time.Duration(sb.TimeoutSec)*time.Second {
expired = append(expired, id)
}
}
m.mu.RUnlock()
for _, id := range expired {
slog.Info("TTL expired, destroying sandbox", "id", id)
if err := m.Destroy(ctx, id); err != nil {
slog.Warn("TTL reap failed", "id", id, "error", err)
}
}
}
// Shutdown destroys all sandboxes and stops the TTL reaper.
func (m *Manager) Shutdown(ctx context.Context) {
close(m.stopCh)
m.mu.Lock()
ids := make([]string, 0, len(m.boxes))
for id := range m.boxes {
ids = append(ids, id)
}
m.mu.Unlock()
for _, sbID := range ids {
slog.Info("shutdown: destroying sandbox", "id", sbID)
if err := m.Destroy(ctx, sbID); err != nil {
slog.Warn("shutdown destroy failed", "id", sbID, "error", err)
}
}
}

View File

@ -30,7 +30,10 @@ type process struct {
// 5. ip netns exec <ns>: enters the network namespace where TAP is configured // 5. ip netns exec <ns>: enters the network namespace where TAP is configured
// 6. exec firecracker with the API socket path // 6. exec firecracker with the API socket path
func startProcess(ctx context.Context, cfg *VMConfig) (*process, error) { func startProcess(ctx context.Context, cfg *VMConfig) (*process, error) {
execCtx, cancel := context.WithCancel(ctx) // Use a background context for the long-lived Firecracker process.
// The request context (ctx) is only used for the startup phase — we must
// not tie the VM's lifetime to the HTTP request that created it.
execCtx, cancel := context.WithCancel(context.Background())
script := buildStartScript(cfg) script := buildStartScript(cfg)

View File

@ -0,0 +1,13 @@
version: v2
plugins:
- protoc_builtin: go
out: gen
opt: paths=source_relative
- local: protoc-gen-connect-go
out: gen
opt: paths=source_relative
managed:
enabled: true
override:
- file_option: go_package_prefix
value: git.omukk.dev/wrenn/sandbox/proto/hostagent/gen

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

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

View File

@ -0,0 +1,848 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc (unknown)
// source: hostagent.proto
package hostagentv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type CreateSandboxRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Template name (e.g., "minimal", "python311"). Determines base rootfs.
Template string `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"`
// Number of virtual CPUs (default: 1).
Vcpus int32 `protobuf:"varint,2,opt,name=vcpus,proto3" json:"vcpus,omitempty"`
// Memory in MB (default: 512).
MemoryMb int32 `protobuf:"varint,3,opt,name=memory_mb,json=memoryMb,proto3" json:"memory_mb,omitempty"`
// TTL in seconds. Sandbox is auto-destroyed after this duration of
// inactivity. 0 means no auto-destroy.
TimeoutSec int32 `protobuf:"varint,4,opt,name=timeout_sec,json=timeoutSec,proto3" json:"timeout_sec,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CreateSandboxRequest) Reset() {
*x = CreateSandboxRequest{}
mi := &file_hostagent_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CreateSandboxRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CreateSandboxRequest) ProtoMessage() {}
func (x *CreateSandboxRequest) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CreateSandboxRequest.ProtoReflect.Descriptor instead.
func (*CreateSandboxRequest) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{0}
}
func (x *CreateSandboxRequest) GetTemplate() string {
if x != nil {
return x.Template
}
return ""
}
func (x *CreateSandboxRequest) GetVcpus() int32 {
if x != nil {
return x.Vcpus
}
return 0
}
func (x *CreateSandboxRequest) GetMemoryMb() int32 {
if x != nil {
return x.MemoryMb
}
return 0
}
func (x *CreateSandboxRequest) GetTimeoutSec() int32 {
if x != nil {
return x.TimeoutSec
}
return 0
}
type CreateSandboxResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"`
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
HostIp string `protobuf:"bytes,3,opt,name=host_ip,json=hostIp,proto3" json:"host_ip,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CreateSandboxResponse) Reset() {
*x = CreateSandboxResponse{}
mi := &file_hostagent_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CreateSandboxResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CreateSandboxResponse) ProtoMessage() {}
func (x *CreateSandboxResponse) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CreateSandboxResponse.ProtoReflect.Descriptor instead.
func (*CreateSandboxResponse) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{1}
}
func (x *CreateSandboxResponse) GetSandboxId() string {
if x != nil {
return x.SandboxId
}
return ""
}
func (x *CreateSandboxResponse) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
func (x *CreateSandboxResponse) GetHostIp() string {
if x != nil {
return x.HostIp
}
return ""
}
type DestroySandboxRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DestroySandboxRequest) Reset() {
*x = DestroySandboxRequest{}
mi := &file_hostagent_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DestroySandboxRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DestroySandboxRequest) ProtoMessage() {}
func (x *DestroySandboxRequest) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DestroySandboxRequest.ProtoReflect.Descriptor instead.
func (*DestroySandboxRequest) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{2}
}
func (x *DestroySandboxRequest) GetSandboxId() string {
if x != nil {
return x.SandboxId
}
return ""
}
type DestroySandboxResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DestroySandboxResponse) Reset() {
*x = DestroySandboxResponse{}
mi := &file_hostagent_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DestroySandboxResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DestroySandboxResponse) ProtoMessage() {}
func (x *DestroySandboxResponse) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DestroySandboxResponse.ProtoReflect.Descriptor instead.
func (*DestroySandboxResponse) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{3}
}
type PauseSandboxRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PauseSandboxRequest) Reset() {
*x = PauseSandboxRequest{}
mi := &file_hostagent_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PauseSandboxRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PauseSandboxRequest) ProtoMessage() {}
func (x *PauseSandboxRequest) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PauseSandboxRequest.ProtoReflect.Descriptor instead.
func (*PauseSandboxRequest) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{4}
}
func (x *PauseSandboxRequest) GetSandboxId() string {
if x != nil {
return x.SandboxId
}
return ""
}
type PauseSandboxResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PauseSandboxResponse) Reset() {
*x = PauseSandboxResponse{}
mi := &file_hostagent_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PauseSandboxResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PauseSandboxResponse) ProtoMessage() {}
func (x *PauseSandboxResponse) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use PauseSandboxResponse.ProtoReflect.Descriptor instead.
func (*PauseSandboxResponse) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{5}
}
type ResumeSandboxRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ResumeSandboxRequest) Reset() {
*x = ResumeSandboxRequest{}
mi := &file_hostagent_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ResumeSandboxRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ResumeSandboxRequest) ProtoMessage() {}
func (x *ResumeSandboxRequest) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ResumeSandboxRequest.ProtoReflect.Descriptor instead.
func (*ResumeSandboxRequest) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{6}
}
func (x *ResumeSandboxRequest) GetSandboxId() string {
if x != nil {
return x.SandboxId
}
return ""
}
type ResumeSandboxResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ResumeSandboxResponse) Reset() {
*x = ResumeSandboxResponse{}
mi := &file_hostagent_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ResumeSandboxResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ResumeSandboxResponse) ProtoMessage() {}
func (x *ResumeSandboxResponse) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ResumeSandboxResponse.ProtoReflect.Descriptor instead.
func (*ResumeSandboxResponse) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{7}
}
type ExecRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"`
Cmd string `protobuf:"bytes,2,opt,name=cmd,proto3" json:"cmd,omitempty"`
Args []string `protobuf:"bytes,3,rep,name=args,proto3" json:"args,omitempty"`
// Timeout for the command in seconds (default: 30).
TimeoutSec int32 `protobuf:"varint,4,opt,name=timeout_sec,json=timeoutSec,proto3" json:"timeout_sec,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExecRequest) Reset() {
*x = ExecRequest{}
mi := &file_hostagent_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExecRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExecRequest) ProtoMessage() {}
func (x *ExecRequest) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExecRequest.ProtoReflect.Descriptor instead.
func (*ExecRequest) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{8}
}
func (x *ExecRequest) GetSandboxId() string {
if x != nil {
return x.SandboxId
}
return ""
}
func (x *ExecRequest) GetCmd() string {
if x != nil {
return x.Cmd
}
return ""
}
func (x *ExecRequest) GetArgs() []string {
if x != nil {
return x.Args
}
return nil
}
func (x *ExecRequest) GetTimeoutSec() int32 {
if x != nil {
return x.TimeoutSec
}
return 0
}
type ExecResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Stdout []byte `protobuf:"bytes,1,opt,name=stdout,proto3" json:"stdout,omitempty"`
Stderr []byte `protobuf:"bytes,2,opt,name=stderr,proto3" json:"stderr,omitempty"`
ExitCode int32 `protobuf:"varint,3,opt,name=exit_code,json=exitCode,proto3" json:"exit_code,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExecResponse) Reset() {
*x = ExecResponse{}
mi := &file_hostagent_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExecResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExecResponse) ProtoMessage() {}
func (x *ExecResponse) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExecResponse.ProtoReflect.Descriptor instead.
func (*ExecResponse) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{9}
}
func (x *ExecResponse) GetStdout() []byte {
if x != nil {
return x.Stdout
}
return nil
}
func (x *ExecResponse) GetStderr() []byte {
if x != nil {
return x.Stderr
}
return nil
}
func (x *ExecResponse) GetExitCode() int32 {
if x != nil {
return x.ExitCode
}
return 0
}
type ListSandboxesRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListSandboxesRequest) Reset() {
*x = ListSandboxesRequest{}
mi := &file_hostagent_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListSandboxesRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListSandboxesRequest) ProtoMessage() {}
func (x *ListSandboxesRequest) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListSandboxesRequest.ProtoReflect.Descriptor instead.
func (*ListSandboxesRequest) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{10}
}
type ListSandboxesResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Sandboxes []*SandboxInfo `protobuf:"bytes,1,rep,name=sandboxes,proto3" json:"sandboxes,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListSandboxesResponse) Reset() {
*x = ListSandboxesResponse{}
mi := &file_hostagent_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListSandboxesResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListSandboxesResponse) ProtoMessage() {}
func (x *ListSandboxesResponse) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListSandboxesResponse.ProtoReflect.Descriptor instead.
func (*ListSandboxesResponse) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{11}
}
func (x *ListSandboxesResponse) GetSandboxes() []*SandboxInfo {
if x != nil {
return x.Sandboxes
}
return nil
}
type SandboxInfo struct {
state protoimpl.MessageState `protogen:"open.v1"`
SandboxId string `protobuf:"bytes,1,opt,name=sandbox_id,json=sandboxId,proto3" json:"sandbox_id,omitempty"`
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
Template string `protobuf:"bytes,3,opt,name=template,proto3" json:"template,omitempty"`
Vcpus int32 `protobuf:"varint,4,opt,name=vcpus,proto3" json:"vcpus,omitempty"`
MemoryMb int32 `protobuf:"varint,5,opt,name=memory_mb,json=memoryMb,proto3" json:"memory_mb,omitempty"`
HostIp string `protobuf:"bytes,6,opt,name=host_ip,json=hostIp,proto3" json:"host_ip,omitempty"`
CreatedAtUnix int64 `protobuf:"varint,7,opt,name=created_at_unix,json=createdAtUnix,proto3" json:"created_at_unix,omitempty"`
LastActiveAtUnix int64 `protobuf:"varint,8,opt,name=last_active_at_unix,json=lastActiveAtUnix,proto3" json:"last_active_at_unix,omitempty"`
TimeoutSec int32 `protobuf:"varint,9,opt,name=timeout_sec,json=timeoutSec,proto3" json:"timeout_sec,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SandboxInfo) Reset() {
*x = SandboxInfo{}
mi := &file_hostagent_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SandboxInfo) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SandboxInfo) ProtoMessage() {}
func (x *SandboxInfo) ProtoReflect() protoreflect.Message {
mi := &file_hostagent_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SandboxInfo.ProtoReflect.Descriptor instead.
func (*SandboxInfo) Descriptor() ([]byte, []int) {
return file_hostagent_proto_rawDescGZIP(), []int{12}
}
func (x *SandboxInfo) GetSandboxId() string {
if x != nil {
return x.SandboxId
}
return ""
}
func (x *SandboxInfo) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
func (x *SandboxInfo) GetTemplate() string {
if x != nil {
return x.Template
}
return ""
}
func (x *SandboxInfo) GetVcpus() int32 {
if x != nil {
return x.Vcpus
}
return 0
}
func (x *SandboxInfo) GetMemoryMb() int32 {
if x != nil {
return x.MemoryMb
}
return 0
}
func (x *SandboxInfo) GetHostIp() string {
if x != nil {
return x.HostIp
}
return ""
}
func (x *SandboxInfo) GetCreatedAtUnix() int64 {
if x != nil {
return x.CreatedAtUnix
}
return 0
}
func (x *SandboxInfo) GetLastActiveAtUnix() int64 {
if x != nil {
return x.LastActiveAtUnix
}
return 0
}
func (x *SandboxInfo) GetTimeoutSec() int32 {
if x != nil {
return x.TimeoutSec
}
return 0
}
var File_hostagent_proto protoreflect.FileDescriptor
const file_hostagent_proto_rawDesc = "" +
"\n" +
"\x0fhostagent.proto\x12\fhostagent.v1\"\x86\x01\n" +
"\x14CreateSandboxRequest\x12\x1a\n" +
"\btemplate\x18\x01 \x01(\tR\btemplate\x12\x14\n" +
"\x05vcpus\x18\x02 \x01(\x05R\x05vcpus\x12\x1b\n" +
"\tmemory_mb\x18\x03 \x01(\x05R\bmemoryMb\x12\x1f\n" +
"\vtimeout_sec\x18\x04 \x01(\x05R\n" +
"timeoutSec\"g\n" +
"\x15CreateSandboxResponse\x12\x1d\n" +
"\n" +
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\x12\x17\n" +
"\ahost_ip\x18\x03 \x01(\tR\x06hostIp\"6\n" +
"\x15DestroySandboxRequest\x12\x1d\n" +
"\n" +
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\x18\n" +
"\x16DestroySandboxResponse\"4\n" +
"\x13PauseSandboxRequest\x12\x1d\n" +
"\n" +
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\x16\n" +
"\x14PauseSandboxResponse\"5\n" +
"\x14ResumeSandboxRequest\x12\x1d\n" +
"\n" +
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\"\x17\n" +
"\x15ResumeSandboxResponse\"s\n" +
"\vExecRequest\x12\x1d\n" +
"\n" +
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x10\n" +
"\x03cmd\x18\x02 \x01(\tR\x03cmd\x12\x12\n" +
"\x04args\x18\x03 \x03(\tR\x04args\x12\x1f\n" +
"\vtimeout_sec\x18\x04 \x01(\x05R\n" +
"timeoutSec\"[\n" +
"\fExecResponse\x12\x16\n" +
"\x06stdout\x18\x01 \x01(\fR\x06stdout\x12\x16\n" +
"\x06stderr\x18\x02 \x01(\fR\x06stderr\x12\x1b\n" +
"\texit_code\x18\x03 \x01(\x05R\bexitCode\"\x16\n" +
"\x14ListSandboxesRequest\"P\n" +
"\x15ListSandboxesResponse\x127\n" +
"\tsandboxes\x18\x01 \x03(\v2\x19.hostagent.v1.SandboxInfoR\tsandboxes\"\xa4\x02\n" +
"\vSandboxInfo\x12\x1d\n" +
"\n" +
"sandbox_id\x18\x01 \x01(\tR\tsandboxId\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\x12\x1a\n" +
"\btemplate\x18\x03 \x01(\tR\btemplate\x12\x14\n" +
"\x05vcpus\x18\x04 \x01(\x05R\x05vcpus\x12\x1b\n" +
"\tmemory_mb\x18\x05 \x01(\x05R\bmemoryMb\x12\x17\n" +
"\ahost_ip\x18\x06 \x01(\tR\x06hostIp\x12&\n" +
"\x0fcreated_at_unix\x18\a \x01(\x03R\rcreatedAtUnix\x12-\n" +
"\x13last_active_at_unix\x18\b \x01(\x03R\x10lastActiveAtUnix\x12\x1f\n" +
"\vtimeout_sec\x18\t \x01(\x05R\n" +
"timeoutSec2\x93\x04\n" +
"\x10HostAgentService\x12X\n" +
"\rCreateSandbox\x12\".hostagent.v1.CreateSandboxRequest\x1a#.hostagent.v1.CreateSandboxResponse\x12[\n" +
"\x0eDestroySandbox\x12#.hostagent.v1.DestroySandboxRequest\x1a$.hostagent.v1.DestroySandboxResponse\x12U\n" +
"\fPauseSandbox\x12!.hostagent.v1.PauseSandboxRequest\x1a\".hostagent.v1.PauseSandboxResponse\x12X\n" +
"\rResumeSandbox\x12\".hostagent.v1.ResumeSandboxRequest\x1a#.hostagent.v1.ResumeSandboxResponse\x12=\n" +
"\x04Exec\x12\x19.hostagent.v1.ExecRequest\x1a\x1a.hostagent.v1.ExecResponse\x12X\n" +
"\rListSandboxes\x12\".hostagent.v1.ListSandboxesRequest\x1a#.hostagent.v1.ListSandboxesResponseB\xb0\x01\n" +
"\x10com.hostagent.v1B\x0eHostagentProtoP\x01Z;git.omukk.dev/wrenn/sandbox/proto/hostagent/gen;hostagentv1\xa2\x02\x03HXX\xaa\x02\fHostagent.V1\xca\x02\fHostagent\\V1\xe2\x02\x18Hostagent\\V1\\GPBMetadata\xea\x02\rHostagent::V1b\x06proto3"
var (
file_hostagent_proto_rawDescOnce sync.Once
file_hostagent_proto_rawDescData []byte
)
func file_hostagent_proto_rawDescGZIP() []byte {
file_hostagent_proto_rawDescOnce.Do(func() {
file_hostagent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_hostagent_proto_rawDesc), len(file_hostagent_proto_rawDesc)))
})
return file_hostagent_proto_rawDescData
}
var file_hostagent_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
var file_hostagent_proto_goTypes = []any{
(*CreateSandboxRequest)(nil), // 0: hostagent.v1.CreateSandboxRequest
(*CreateSandboxResponse)(nil), // 1: hostagent.v1.CreateSandboxResponse
(*DestroySandboxRequest)(nil), // 2: hostagent.v1.DestroySandboxRequest
(*DestroySandboxResponse)(nil), // 3: hostagent.v1.DestroySandboxResponse
(*PauseSandboxRequest)(nil), // 4: hostagent.v1.PauseSandboxRequest
(*PauseSandboxResponse)(nil), // 5: hostagent.v1.PauseSandboxResponse
(*ResumeSandboxRequest)(nil), // 6: hostagent.v1.ResumeSandboxRequest
(*ResumeSandboxResponse)(nil), // 7: hostagent.v1.ResumeSandboxResponse
(*ExecRequest)(nil), // 8: hostagent.v1.ExecRequest
(*ExecResponse)(nil), // 9: hostagent.v1.ExecResponse
(*ListSandboxesRequest)(nil), // 10: hostagent.v1.ListSandboxesRequest
(*ListSandboxesResponse)(nil), // 11: hostagent.v1.ListSandboxesResponse
(*SandboxInfo)(nil), // 12: hostagent.v1.SandboxInfo
}
var file_hostagent_proto_depIdxs = []int32{
12, // 0: hostagent.v1.ListSandboxesResponse.sandboxes:type_name -> hostagent.v1.SandboxInfo
0, // 1: hostagent.v1.HostAgentService.CreateSandbox:input_type -> hostagent.v1.CreateSandboxRequest
2, // 2: hostagent.v1.HostAgentService.DestroySandbox:input_type -> hostagent.v1.DestroySandboxRequest
4, // 3: hostagent.v1.HostAgentService.PauseSandbox:input_type -> hostagent.v1.PauseSandboxRequest
6, // 4: hostagent.v1.HostAgentService.ResumeSandbox:input_type -> hostagent.v1.ResumeSandboxRequest
8, // 5: hostagent.v1.HostAgentService.Exec:input_type -> hostagent.v1.ExecRequest
10, // 6: hostagent.v1.HostAgentService.ListSandboxes:input_type -> hostagent.v1.ListSandboxesRequest
1, // 7: hostagent.v1.HostAgentService.CreateSandbox:output_type -> hostagent.v1.CreateSandboxResponse
3, // 8: hostagent.v1.HostAgentService.DestroySandbox:output_type -> hostagent.v1.DestroySandboxResponse
5, // 9: hostagent.v1.HostAgentService.PauseSandbox:output_type -> hostagent.v1.PauseSandboxResponse
7, // 10: hostagent.v1.HostAgentService.ResumeSandbox:output_type -> hostagent.v1.ResumeSandboxResponse
9, // 11: hostagent.v1.HostAgentService.Exec:output_type -> hostagent.v1.ExecResponse
11, // 12: hostagent.v1.HostAgentService.ListSandboxes:output_type -> hostagent.v1.ListSandboxesResponse
7, // [7:13] is the sub-list for method output_type
1, // [1:7] is the sub-list for method input_type
1, // [1:1] is the sub-list for extension type_name
1, // [1:1] is the sub-list for extension extendee
0, // [0:1] is the sub-list for field type_name
}
func init() { file_hostagent_proto_init() }
func file_hostagent_proto_init() {
if File_hostagent_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_hostagent_proto_rawDesc), len(file_hostagent_proto_rawDesc)),
NumEnums: 0,
NumMessages: 13,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_hostagent_proto_goTypes,
DependencyIndexes: file_hostagent_proto_depIdxs,
MessageInfos: file_hostagent_proto_msgTypes,
}.Build()
File_hostagent_proto = out.File
file_hostagent_proto_goTypes = nil
file_hostagent_proto_depIdxs = nil
}

View File

@ -0,0 +1,265 @@
// Code generated by protoc-gen-connect-go. DO NOT EDIT.
//
// Source: hostagent.proto
package hostagentv1connect
import (
connect "connectrpc.com/connect"
context "context"
errors "errors"
gen "git.omukk.dev/wrenn/sandbox/proto/hostagent/gen"
http "net/http"
strings "strings"
)
// This is a compile-time assertion to ensure that this generated file and the connect package are
// compatible. If you get a compiler error that this constant is not defined, this code was
// generated with a version of connect newer than the one compiled into your binary. You can fix the
// problem by either regenerating this code with an older version of connect or updating the connect
// version compiled into your binary.
const _ = connect.IsAtLeastVersion1_13_0
const (
// HostAgentServiceName is the fully-qualified name of the HostAgentService service.
HostAgentServiceName = "hostagent.v1.HostAgentService"
)
// These constants are the fully-qualified names of the RPCs defined in this package. They're
// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route.
//
// Note that these are different from the fully-qualified method names used by
// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to
// reflection-formatted method names, remove the leading slash and convert the remaining slash to a
// period.
const (
// HostAgentServiceCreateSandboxProcedure is the fully-qualified name of the HostAgentService's
// CreateSandbox RPC.
HostAgentServiceCreateSandboxProcedure = "/hostagent.v1.HostAgentService/CreateSandbox"
// HostAgentServiceDestroySandboxProcedure is the fully-qualified name of the HostAgentService's
// DestroySandbox RPC.
HostAgentServiceDestroySandboxProcedure = "/hostagent.v1.HostAgentService/DestroySandbox"
// HostAgentServicePauseSandboxProcedure is the fully-qualified name of the HostAgentService's
// PauseSandbox RPC.
HostAgentServicePauseSandboxProcedure = "/hostagent.v1.HostAgentService/PauseSandbox"
// HostAgentServiceResumeSandboxProcedure is the fully-qualified name of the HostAgentService's
// ResumeSandbox RPC.
HostAgentServiceResumeSandboxProcedure = "/hostagent.v1.HostAgentService/ResumeSandbox"
// HostAgentServiceExecProcedure is the fully-qualified name of the HostAgentService's Exec RPC.
HostAgentServiceExecProcedure = "/hostagent.v1.HostAgentService/Exec"
// HostAgentServiceListSandboxesProcedure is the fully-qualified name of the HostAgentService's
// ListSandboxes RPC.
HostAgentServiceListSandboxesProcedure = "/hostagent.v1.HostAgentService/ListSandboxes"
)
// HostAgentServiceClient is a client for the hostagent.v1.HostAgentService service.
type HostAgentServiceClient interface {
// CreateSandbox boots a new microVM with the given configuration.
CreateSandbox(context.Context, *connect.Request[gen.CreateSandboxRequest]) (*connect.Response[gen.CreateSandboxResponse], error)
// DestroySandbox stops and cleans up a sandbox (VM, network, rootfs).
DestroySandbox(context.Context, *connect.Request[gen.DestroySandboxRequest]) (*connect.Response[gen.DestroySandboxResponse], error)
// PauseSandbox pauses a running sandbox's VM.
PauseSandbox(context.Context, *connect.Request[gen.PauseSandboxRequest]) (*connect.Response[gen.PauseSandboxResponse], error)
// ResumeSandbox resumes a paused sandbox's VM.
ResumeSandbox(context.Context, *connect.Request[gen.ResumeSandboxRequest]) (*connect.Response[gen.ResumeSandboxResponse], error)
// Exec runs a command inside a sandbox and returns the collected output.
Exec(context.Context, *connect.Request[gen.ExecRequest]) (*connect.Response[gen.ExecResponse], error)
// ListSandboxes returns all sandboxes managed by this host agent.
ListSandboxes(context.Context, *connect.Request[gen.ListSandboxesRequest]) (*connect.Response[gen.ListSandboxesResponse], error)
}
// NewHostAgentServiceClient constructs a client for the hostagent.v1.HostAgentService service. By
// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses,
// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the
// connect.WithGRPC() or connect.WithGRPCWeb() options.
//
// The URL supplied here should be the base URL for the Connect or gRPC server (for example,
// http://api.acme.com or https://acme.com/grpc).
func NewHostAgentServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) HostAgentServiceClient {
baseURL = strings.TrimRight(baseURL, "/")
hostAgentServiceMethods := gen.File_hostagent_proto.Services().ByName("HostAgentService").Methods()
return &hostAgentServiceClient{
createSandbox: connect.NewClient[gen.CreateSandboxRequest, gen.CreateSandboxResponse](
httpClient,
baseURL+HostAgentServiceCreateSandboxProcedure,
connect.WithSchema(hostAgentServiceMethods.ByName("CreateSandbox")),
connect.WithClientOptions(opts...),
),
destroySandbox: connect.NewClient[gen.DestroySandboxRequest, gen.DestroySandboxResponse](
httpClient,
baseURL+HostAgentServiceDestroySandboxProcedure,
connect.WithSchema(hostAgentServiceMethods.ByName("DestroySandbox")),
connect.WithClientOptions(opts...),
),
pauseSandbox: connect.NewClient[gen.PauseSandboxRequest, gen.PauseSandboxResponse](
httpClient,
baseURL+HostAgentServicePauseSandboxProcedure,
connect.WithSchema(hostAgentServiceMethods.ByName("PauseSandbox")),
connect.WithClientOptions(opts...),
),
resumeSandbox: connect.NewClient[gen.ResumeSandboxRequest, gen.ResumeSandboxResponse](
httpClient,
baseURL+HostAgentServiceResumeSandboxProcedure,
connect.WithSchema(hostAgentServiceMethods.ByName("ResumeSandbox")),
connect.WithClientOptions(opts...),
),
exec: connect.NewClient[gen.ExecRequest, gen.ExecResponse](
httpClient,
baseURL+HostAgentServiceExecProcedure,
connect.WithSchema(hostAgentServiceMethods.ByName("Exec")),
connect.WithClientOptions(opts...),
),
listSandboxes: connect.NewClient[gen.ListSandboxesRequest, gen.ListSandboxesResponse](
httpClient,
baseURL+HostAgentServiceListSandboxesProcedure,
connect.WithSchema(hostAgentServiceMethods.ByName("ListSandboxes")),
connect.WithClientOptions(opts...),
),
}
}
// hostAgentServiceClient implements HostAgentServiceClient.
type hostAgentServiceClient struct {
createSandbox *connect.Client[gen.CreateSandboxRequest, gen.CreateSandboxResponse]
destroySandbox *connect.Client[gen.DestroySandboxRequest, gen.DestroySandboxResponse]
pauseSandbox *connect.Client[gen.PauseSandboxRequest, gen.PauseSandboxResponse]
resumeSandbox *connect.Client[gen.ResumeSandboxRequest, gen.ResumeSandboxResponse]
exec *connect.Client[gen.ExecRequest, gen.ExecResponse]
listSandboxes *connect.Client[gen.ListSandboxesRequest, gen.ListSandboxesResponse]
}
// CreateSandbox calls hostagent.v1.HostAgentService.CreateSandbox.
func (c *hostAgentServiceClient) CreateSandbox(ctx context.Context, req *connect.Request[gen.CreateSandboxRequest]) (*connect.Response[gen.CreateSandboxResponse], error) {
return c.createSandbox.CallUnary(ctx, req)
}
// DestroySandbox calls hostagent.v1.HostAgentService.DestroySandbox.
func (c *hostAgentServiceClient) DestroySandbox(ctx context.Context, req *connect.Request[gen.DestroySandboxRequest]) (*connect.Response[gen.DestroySandboxResponse], error) {
return c.destroySandbox.CallUnary(ctx, req)
}
// PauseSandbox calls hostagent.v1.HostAgentService.PauseSandbox.
func (c *hostAgentServiceClient) PauseSandbox(ctx context.Context, req *connect.Request[gen.PauseSandboxRequest]) (*connect.Response[gen.PauseSandboxResponse], error) {
return c.pauseSandbox.CallUnary(ctx, req)
}
// ResumeSandbox calls hostagent.v1.HostAgentService.ResumeSandbox.
func (c *hostAgentServiceClient) ResumeSandbox(ctx context.Context, req *connect.Request[gen.ResumeSandboxRequest]) (*connect.Response[gen.ResumeSandboxResponse], error) {
return c.resumeSandbox.CallUnary(ctx, req)
}
// Exec calls hostagent.v1.HostAgentService.Exec.
func (c *hostAgentServiceClient) Exec(ctx context.Context, req *connect.Request[gen.ExecRequest]) (*connect.Response[gen.ExecResponse], error) {
return c.exec.CallUnary(ctx, req)
}
// ListSandboxes calls hostagent.v1.HostAgentService.ListSandboxes.
func (c *hostAgentServiceClient) ListSandboxes(ctx context.Context, req *connect.Request[gen.ListSandboxesRequest]) (*connect.Response[gen.ListSandboxesResponse], error) {
return c.listSandboxes.CallUnary(ctx, req)
}
// HostAgentServiceHandler is an implementation of the hostagent.v1.HostAgentService service.
type HostAgentServiceHandler interface {
// CreateSandbox boots a new microVM with the given configuration.
CreateSandbox(context.Context, *connect.Request[gen.CreateSandboxRequest]) (*connect.Response[gen.CreateSandboxResponse], error)
// DestroySandbox stops and cleans up a sandbox (VM, network, rootfs).
DestroySandbox(context.Context, *connect.Request[gen.DestroySandboxRequest]) (*connect.Response[gen.DestroySandboxResponse], error)
// PauseSandbox pauses a running sandbox's VM.
PauseSandbox(context.Context, *connect.Request[gen.PauseSandboxRequest]) (*connect.Response[gen.PauseSandboxResponse], error)
// ResumeSandbox resumes a paused sandbox's VM.
ResumeSandbox(context.Context, *connect.Request[gen.ResumeSandboxRequest]) (*connect.Response[gen.ResumeSandboxResponse], error)
// Exec runs a command inside a sandbox and returns the collected output.
Exec(context.Context, *connect.Request[gen.ExecRequest]) (*connect.Response[gen.ExecResponse], error)
// ListSandboxes returns all sandboxes managed by this host agent.
ListSandboxes(context.Context, *connect.Request[gen.ListSandboxesRequest]) (*connect.Response[gen.ListSandboxesResponse], error)
}
// NewHostAgentServiceHandler builds an HTTP handler from the service implementation. It returns the
// path on which to mount the handler and the handler itself.
//
// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf
// and JSON codecs. They also support gzip compression.
func NewHostAgentServiceHandler(svc HostAgentServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) {
hostAgentServiceMethods := gen.File_hostagent_proto.Services().ByName("HostAgentService").Methods()
hostAgentServiceCreateSandboxHandler := connect.NewUnaryHandler(
HostAgentServiceCreateSandboxProcedure,
svc.CreateSandbox,
connect.WithSchema(hostAgentServiceMethods.ByName("CreateSandbox")),
connect.WithHandlerOptions(opts...),
)
hostAgentServiceDestroySandboxHandler := connect.NewUnaryHandler(
HostAgentServiceDestroySandboxProcedure,
svc.DestroySandbox,
connect.WithSchema(hostAgentServiceMethods.ByName("DestroySandbox")),
connect.WithHandlerOptions(opts...),
)
hostAgentServicePauseSandboxHandler := connect.NewUnaryHandler(
HostAgentServicePauseSandboxProcedure,
svc.PauseSandbox,
connect.WithSchema(hostAgentServiceMethods.ByName("PauseSandbox")),
connect.WithHandlerOptions(opts...),
)
hostAgentServiceResumeSandboxHandler := connect.NewUnaryHandler(
HostAgentServiceResumeSandboxProcedure,
svc.ResumeSandbox,
connect.WithSchema(hostAgentServiceMethods.ByName("ResumeSandbox")),
connect.WithHandlerOptions(opts...),
)
hostAgentServiceExecHandler := connect.NewUnaryHandler(
HostAgentServiceExecProcedure,
svc.Exec,
connect.WithSchema(hostAgentServiceMethods.ByName("Exec")),
connect.WithHandlerOptions(opts...),
)
hostAgentServiceListSandboxesHandler := connect.NewUnaryHandler(
HostAgentServiceListSandboxesProcedure,
svc.ListSandboxes,
connect.WithSchema(hostAgentServiceMethods.ByName("ListSandboxes")),
connect.WithHandlerOptions(opts...),
)
return "/hostagent.v1.HostAgentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case HostAgentServiceCreateSandboxProcedure:
hostAgentServiceCreateSandboxHandler.ServeHTTP(w, r)
case HostAgentServiceDestroySandboxProcedure:
hostAgentServiceDestroySandboxHandler.ServeHTTP(w, r)
case HostAgentServicePauseSandboxProcedure:
hostAgentServicePauseSandboxHandler.ServeHTTP(w, r)
case HostAgentServiceResumeSandboxProcedure:
hostAgentServiceResumeSandboxHandler.ServeHTTP(w, r)
case HostAgentServiceExecProcedure:
hostAgentServiceExecHandler.ServeHTTP(w, r)
case HostAgentServiceListSandboxesProcedure:
hostAgentServiceListSandboxesHandler.ServeHTTP(w, r)
default:
http.NotFound(w, r)
}
})
}
// UnimplementedHostAgentServiceHandler returns CodeUnimplemented from all methods.
type UnimplementedHostAgentServiceHandler struct{}
func (UnimplementedHostAgentServiceHandler) CreateSandbox(context.Context, *connect.Request[gen.CreateSandboxRequest]) (*connect.Response[gen.CreateSandboxResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.CreateSandbox is not implemented"))
}
func (UnimplementedHostAgentServiceHandler) DestroySandbox(context.Context, *connect.Request[gen.DestroySandboxRequest]) (*connect.Response[gen.DestroySandboxResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.DestroySandbox is not implemented"))
}
func (UnimplementedHostAgentServiceHandler) PauseSandbox(context.Context, *connect.Request[gen.PauseSandboxRequest]) (*connect.Response[gen.PauseSandboxResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.PauseSandbox is not implemented"))
}
func (UnimplementedHostAgentServiceHandler) ResumeSandbox(context.Context, *connect.Request[gen.ResumeSandboxRequest]) (*connect.Response[gen.ResumeSandboxResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.ResumeSandbox is not implemented"))
}
func (UnimplementedHostAgentServiceHandler) Exec(context.Context, *connect.Request[gen.ExecRequest]) (*connect.Response[gen.ExecResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.Exec is not implemented"))
}
func (UnimplementedHostAgentServiceHandler) ListSandboxes(context.Context, *connect.Request[gen.ListSandboxesRequest]) (*connect.Response[gen.ListSandboxesResponse], error) {
return nil, connect.NewError(connect.CodeUnimplemented, errors.New("hostagent.v1.HostAgentService.ListSandboxes is not implemented"))
}

View File

@ -0,0 +1,96 @@
syntax = "proto3";
package hostagent.v1;
// HostAgentService manages sandbox VMs on a single physical host.
// The control plane calls these RPCs to orchestrate sandbox lifecycle.
service HostAgentService {
// CreateSandbox boots a new microVM with the given configuration.
rpc CreateSandbox(CreateSandboxRequest) returns (CreateSandboxResponse);
// DestroySandbox stops and cleans up a sandbox (VM, network, rootfs).
rpc DestroySandbox(DestroySandboxRequest) returns (DestroySandboxResponse);
// PauseSandbox pauses a running sandbox's VM.
rpc PauseSandbox(PauseSandboxRequest) returns (PauseSandboxResponse);
// ResumeSandbox resumes a paused sandbox's VM.
rpc ResumeSandbox(ResumeSandboxRequest) returns (ResumeSandboxResponse);
// Exec runs a command inside a sandbox and returns the collected output.
rpc Exec(ExecRequest) returns (ExecResponse);
// ListSandboxes returns all sandboxes managed by this host agent.
rpc ListSandboxes(ListSandboxesRequest) returns (ListSandboxesResponse);
}
message CreateSandboxRequest {
// Template name (e.g., "minimal", "python311"). Determines base rootfs.
string template = 1;
// Number of virtual CPUs (default: 1).
int32 vcpus = 2;
// Memory in MB (default: 512).
int32 memory_mb = 3;
// TTL in seconds. Sandbox is auto-destroyed after this duration of
// inactivity. 0 means no auto-destroy.
int32 timeout_sec = 4;
}
message CreateSandboxResponse {
string sandbox_id = 1;
string status = 2;
string host_ip = 3;
}
message DestroySandboxRequest {
string sandbox_id = 1;
}
message DestroySandboxResponse {}
message PauseSandboxRequest {
string sandbox_id = 1;
}
message PauseSandboxResponse {}
message ResumeSandboxRequest {
string sandbox_id = 1;
}
message ResumeSandboxResponse {}
message ExecRequest {
string sandbox_id = 1;
string cmd = 2;
repeated string args = 3;
// Timeout for the command in seconds (default: 30).
int32 timeout_sec = 4;
}
message ExecResponse {
bytes stdout = 1;
bytes stderr = 2;
int32 exit_code = 3;
}
message ListSandboxesRequest {}
message ListSandboxesResponse {
repeated SandboxInfo sandboxes = 1;
}
message SandboxInfo {
string sandbox_id = 1;
string status = 2;
string template = 3;
int32 vcpus = 4;
int32 memory_mb = 5;
string host_ip = 6;
int64 created_at_unix = 7;
int64 last_active_at_unix = 8;
int32 timeout_sec = 9;
}

233
scripts/test-host.sh Executable file
View File

@ -0,0 +1,233 @@
#!/usr/bin/env bash
#
# test-host.sh — Integration test for the Wrenn host agent.
#
# Prerequisites:
# - Host agent running: sudo ./builds/wrenn-agent
# - Firecracker installed at /usr/local/bin/firecracker
# - Kernel at /var/lib/wrenn/kernels/vmlinux
# - Base rootfs at /var/lib/wrenn/images/minimal.ext4 (with envd + wrenn-init baked in)
#
# Usage:
# ./scripts/test-host.sh [agent_url]
#
# The agent URL defaults to http://localhost:50051.
set -euo pipefail
AGENT="${1:-http://localhost:50051}"
BASE="/hostagent.v1.HostAgentService"
SANDBOX_ID=""
# Colors for output.
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m'
pass() { echo -e "${GREEN}PASS${NC}: $1"; }
fail() { echo -e "${RED}FAIL${NC}: $1"; exit 1; }
info() { echo -e "${YELLOW}----${NC}: $1"; }
rpc() {
local method="$1"
local body="$2"
curl -s -X POST \
-H "Content-Type: application/json" \
"${AGENT}${BASE}/${method}" \
-d "${body}"
}
# ──────────────────────────────────────────────────
# Test 1: List sandboxes (should be empty)
# ──────────────────────────────────────────────────
info "Test 1: List sandboxes (expect empty)"
RESULT=$(rpc "ListSandboxes" '{}')
echo " Response: ${RESULT}"
echo "${RESULT}" | grep -q '"sandboxes"' || echo "${RESULT}" | grep -q '{}' && \
pass "ListSandboxes returned" || \
fail "ListSandboxes failed"
# ──────────────────────────────────────────────────
# Test 2: Create a sandbox
# ──────────────────────────────────────────────────
info "Test 2: Create a sandbox"
RESULT=$(rpc "CreateSandbox" '{
"template": "minimal",
"vcpus": 1,
"memoryMb": 512,
"timeoutSec": 300
}')
echo " Response: ${RESULT}"
SANDBOX_ID=$(echo "${RESULT}" | python3 -c "import sys,json; print(json.load(sys.stdin)['sandboxId'])" 2>/dev/null) || \
fail "CreateSandbox did not return sandboxId"
echo " Sandbox ID: ${SANDBOX_ID}"
pass "Sandbox created: ${SANDBOX_ID}"
# ──────────────────────────────────────────────────
# Test 3: List sandboxes (should have one)
# ──────────────────────────────────────────────────
info "Test 3: List sandboxes (expect one)"
RESULT=$(rpc "ListSandboxes" '{}')
echo " Response: ${RESULT}"
echo "${RESULT}" | grep -q "${SANDBOX_ID}" && \
pass "Sandbox ${SANDBOX_ID} found in list" || \
fail "Sandbox not found in list"
# ──────────────────────────────────────────────────
# Test 4: Execute a command
# ──────────────────────────────────────────────────
info "Test 4: Execute 'echo hello world'"
RESULT=$(rpc "Exec" "{
\"sandboxId\": \"${SANDBOX_ID}\",
\"cmd\": \"/bin/sh\",
\"args\": [\"-c\", \"echo hello world\"],
\"timeoutSec\": 10
}")
echo " Response: ${RESULT}"
# stdout is base64-encoded in Connect RPC JSON.
STDOUT=$(echo "${RESULT}" | python3 -c "
import sys, json, base64
r = json.load(sys.stdin)
print(base64.b64decode(r['stdout']).decode().strip())
" 2>/dev/null) || fail "Exec did not return stdout"
[ "${STDOUT}" = "hello world" ] && \
pass "Exec returned correct output: '${STDOUT}'" || \
fail "Expected 'hello world', got '${STDOUT}'"
# ──────────────────────────────────────────────────
# Test 5: Execute a multi-line command
# ──────────────────────────────────────────────────
info "Test 5: Execute multi-line command"
RESULT=$(rpc "Exec" "{
\"sandboxId\": \"${SANDBOX_ID}\",
\"cmd\": \"/bin/sh\",
\"args\": [\"-c\", \"echo line1; echo line2; echo line3\"],
\"timeoutSec\": 10
}")
echo " Response: ${RESULT}"
LINE_COUNT=$(echo "${RESULT}" | python3 -c "
import sys, json, base64
r = json.load(sys.stdin)
lines = base64.b64decode(r['stdout']).decode().strip().split('\n')
print(len(lines))
" 2>/dev/null)
[ "${LINE_COUNT}" = "3" ] && \
pass "Multi-line output: ${LINE_COUNT} lines" || \
fail "Expected 3 lines, got ${LINE_COUNT}"
# ──────────────────────────────────────────────────
# Test 6: Pause the sandbox
# ──────────────────────────────────────────────────
info "Test 6: Pause sandbox"
RESULT=$(rpc "PauseSandbox" "{\"sandboxId\": \"${SANDBOX_ID}\"}")
echo " Response: ${RESULT}"
# Verify status is paused.
LIST=$(rpc "ListSandboxes" '{}')
echo "${LIST}" | grep -q '"paused"' && \
pass "Sandbox paused" || \
fail "Sandbox not in paused state"
# ──────────────────────────────────────────────────
# Test 7: Exec should fail while paused
# ──────────────────────────────────────────────────
info "Test 7: Exec while paused (expect error)"
RESULT=$(rpc "Exec" "{
\"sandboxId\": \"${SANDBOX_ID}\",
\"cmd\": \"/bin/echo\",
\"args\": [\"should fail\"]
}")
echo " Response: ${RESULT}"
echo "${RESULT}" | grep -qi "not running\|error\|code" && \
pass "Exec correctly rejected while paused" || \
fail "Exec should have failed while paused"
# ──────────────────────────────────────────────────
# Test 8: Resume the sandbox
# ──────────────────────────────────────────────────
info "Test 8: Resume sandbox"
RESULT=$(rpc "ResumeSandbox" "{\"sandboxId\": \"${SANDBOX_ID}\"}")
echo " Response: ${RESULT}"
# Verify status is running.
LIST=$(rpc "ListSandboxes" '{}')
echo "${LIST}" | grep -q '"running"' && \
pass "Sandbox resumed" || \
fail "Sandbox not in running state"
# ──────────────────────────────────────────────────
# Test 9: Exec after resume
# ──────────────────────────────────────────────────
info "Test 9: Exec after resume"
RESULT=$(rpc "Exec" "{
\"sandboxId\": \"${SANDBOX_ID}\",
\"cmd\": \"/bin/sh\",
\"args\": [\"-c\", \"echo resumed ok\"],
\"timeoutSec\": 10
}")
echo " Response: ${RESULT}"
STDOUT=$(echo "${RESULT}" | python3 -c "
import sys, json, base64
r = json.load(sys.stdin)
print(base64.b64decode(r['stdout']).decode().strip())
" 2>/dev/null) || fail "Exec after resume failed"
[ "${STDOUT}" = "resumed ok" ] && \
pass "Exec after resume works: '${STDOUT}'" || \
fail "Expected 'resumed ok', got '${STDOUT}'"
# ──────────────────────────────────────────────────
# Test 10: Destroy the sandbox
# ──────────────────────────────────────────────────
info "Test 10: Destroy sandbox"
RESULT=$(rpc "DestroySandbox" "{\"sandboxId\": \"${SANDBOX_ID}\"}")
echo " Response: ${RESULT}"
pass "Sandbox destroyed"
# ──────────────────────────────────────────────────
# Test 11: List sandboxes (should be empty again)
# ──────────────────────────────────────────────────
info "Test 11: List sandboxes (expect empty)"
RESULT=$(rpc "ListSandboxes" '{}')
echo " Response: ${RESULT}"
echo "${RESULT}" | grep -q "${SANDBOX_ID}" && \
fail "Destroyed sandbox still in list" || \
pass "Sandbox list is clean"
# ──────────────────────────────────────────────────
# Test 12: Destroy non-existent sandbox (expect error)
# ──────────────────────────────────────────────────
info "Test 12: Destroy non-existent sandbox (expect error)"
RESULT=$(rpc "DestroySandbox" '{"sandboxId": "sb-nonexist"}')
echo " Response: ${RESULT}"
echo "${RESULT}" | grep -qi "not found\|error\|code" && \
pass "Correctly rejected non-existent sandbox" || \
fail "Should have returned error for non-existent sandbox"
echo ""
echo -e "${GREEN}All tests passed!${NC}"

73
scripts/update-debug-rootfs.sh Executable file
View File

@ -0,0 +1,73 @@
#!/usr/bin/env bash
#
# update-debug-rootfs.sh — Build envd and inject it (plus wrenn-init) into the debug rootfs.
#
# This script:
# 1. Builds a fresh envd static binary via make
# 2. Mounts the rootfs image
# 3. Copies envd and wrenn-init into the image
# 4. Unmounts cleanly
#
# Usage:
# bash scripts/update-debug-rootfs.sh [rootfs_path]
#
# Defaults to /var/lib/wrenn/images/minimal.ext4
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
ROOTFS="${1:-/var/lib/wrenn/images/minimal.ext4}"
MOUNT_DIR="/tmp/wrenn-rootfs-update"
if [ ! -f "${ROOTFS}" ]; then
echo "ERROR: Rootfs not found at ${ROOTFS}"
exit 1
fi
# Step 1: Build envd.
echo "==> Building envd..."
cd "${PROJECT_ROOT}"
make build-envd
ENVD_BIN="${PROJECT_ROOT}/builds/envd"
if [ ! -f "${ENVD_BIN}" ]; then
echo "ERROR: envd binary not found at ${ENVD_BIN}"
exit 1
fi
# Verify it's statically linked.
if ! file "${ENVD_BIN}" | grep -q "statically linked"; then
echo "ERROR: envd is not statically linked!"
exit 1
fi
# Step 2: Mount the rootfs.
echo "==> Mounting rootfs at ${MOUNT_DIR}..."
mkdir -p "${MOUNT_DIR}"
sudo mount -o loop "${ROOTFS}" "${MOUNT_DIR}"
cleanup() {
echo "==> Unmounting rootfs..."
sudo umount "${MOUNT_DIR}" 2>/dev/null || true
rmdir "${MOUNT_DIR}" 2>/dev/null || true
}
trap cleanup EXIT
# Step 3: Copy files into rootfs.
echo "==> Installing envd..."
sudo mkdir -p "${MOUNT_DIR}/usr/local/bin"
sudo cp "${ENVD_BIN}" "${MOUNT_DIR}/usr/local/bin/envd"
sudo chmod 755 "${MOUNT_DIR}/usr/local/bin/envd"
echo "==> Installing wrenn-init..."
sudo cp "${PROJECT_ROOT}/images/wrenn-init.sh" "${MOUNT_DIR}/usr/local/bin/wrenn-init"
sudo chmod 755 "${MOUNT_DIR}/usr/local/bin/wrenn-init"
# Step 4: Verify.
echo ""
echo "==> Installed files:"
ls -la "${MOUNT_DIR}/usr/local/bin/envd" "${MOUNT_DIR}/usr/local/bin/wrenn-init"
echo ""
echo "==> Done. Rootfs updated: ${ROOTFS}"