From e21c50f33168f3234286fa9fffa2aba681abc0a6 Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Tue, 7 Apr 2026 20:36:23 +0000 Subject: [PATCH] feat: init service and config for firecracker-host --- internal/config/config.go | 236 ++++++++++++++++++++++++++++++++++++ internal/service/service.go | 136 +++++++++++++++++++++ 2 files changed, 372 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/service/service.go diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..dd772ea --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,236 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/getcompanion-ai/computer-host/internal/firecracker" +) + +const ( + defaultGuestKernelArgs = "console=ttyS0 reboot=k panic=1 pci=off" + defaultGuestMemoryMiB = 512 + defaultGuestVCPUs = 1 + defaultVSockCID = 3 + defaultVSockID = "vsock0" +) + +// Config contains the minimum host-local settings required to boot a machine. +type Config struct { + Runtime RuntimeConfig + Machine MachineConfig + VSock VSockConfig +} + +// RuntimeConfig contains host-local runtime settings. +type RuntimeConfig struct { + RootDir string + FirecrackerBinaryPath string + JailerBinaryPath string + JailerUID int + JailerGID int + NumaNode int + NetworkCIDR string +} + +// MachineConfig contains the default guest boot settings. +type MachineConfig struct { + KernelImagePath string + RootFSPath string + KernelArgs string + VCPUs int64 + MemoryMiB int64 +} + +// VSockConfig contains optional default vsock settings. +type VSockConfig struct { + Enabled bool + BaseDir string + ID string + CID uint32 +} + +// Load loads and validates the firecracker-host configuration from the +// environment. +func Load() (Config, error) { + cfg := Config{ + Runtime: RuntimeConfig{ + RootDir: strings.TrimSpace(os.Getenv("FIRECRACKER_HOST_ROOT_DIR")), + FirecrackerBinaryPath: strings.TrimSpace(os.Getenv("FIRECRACKER_BINARY_PATH")), + JailerBinaryPath: strings.TrimSpace(os.Getenv("JAILER_BINARY_PATH")), + JailerUID: os.Getuid(), + JailerGID: os.Getgid(), + NetworkCIDR: strings.TrimSpace(os.Getenv("FIRECRACKER_NETWORK_CIDR")), + }, + Machine: MachineConfig{ + KernelImagePath: strings.TrimSpace(os.Getenv("FIRECRACKER_GUEST_KERNEL_PATH")), + RootFSPath: strings.TrimSpace(os.Getenv("FIRECRACKER_GUEST_ROOTFS_PATH")), + KernelArgs: defaultGuestKernelArgs, + VCPUs: defaultGuestVCPUs, + MemoryMiB: defaultGuestMemoryMiB, + }, + VSock: VSockConfig{ + ID: defaultVSockID, + CID: defaultVSockCID, + }, + } + + if value := strings.TrimSpace(os.Getenv("FIRECRACKER_GUEST_KERNEL_ARGS")); value != "" { + cfg.Machine.KernelArgs = value + } + if value := strings.TrimSpace(os.Getenv("FIRECRACKER_BINARY_PATH")); value == "" { + cfg.Runtime.FirecrackerBinaryPath = "firecracker" + } + if value := strings.TrimSpace(os.Getenv("JAILER_BINARY_PATH")); value == "" { + cfg.Runtime.JailerBinaryPath = "jailer" + } + + if value, ok, err := lookupIntEnv("FIRECRACKER_JAILER_UID"); err != nil { + return Config{}, err + } else if ok { + cfg.Runtime.JailerUID = value + } + if value, ok, err := lookupIntEnv("FIRECRACKER_JAILER_GID"); err != nil { + return Config{}, err + } else if ok { + cfg.Runtime.JailerGID = value + } + if value, ok, err := lookupIntEnv("FIRECRACKER_NUMA_NODE"); err != nil { + return Config{}, err + } else if ok { + cfg.Runtime.NumaNode = value + } + if value, ok, err := lookupInt64Env("FIRECRACKER_GUEST_VCPUS"); err != nil { + return Config{}, err + } else if ok { + cfg.Machine.VCPUs = value + } + if value, ok, err := lookupInt64Env("FIRECRACKER_GUEST_MEMORY_MIB"); err != nil { + return Config{}, err + } else if ok { + cfg.Machine.MemoryMiB = value + } + if value, ok, err := lookupBoolEnv("FIRECRACKER_VSOCK_ENABLED"); err != nil { + return Config{}, err + } else if ok { + cfg.VSock.Enabled = value + } + if value := strings.TrimSpace(os.Getenv("FIRECRACKER_VSOCK_BASE_DIR")); value != "" { + cfg.VSock.BaseDir = value + } + if value := strings.TrimSpace(os.Getenv("FIRECRACKER_VSOCK_ID")); value != "" { + cfg.VSock.ID = value + } + if value, ok, err := lookupUint32Env("FIRECRACKER_VSOCK_CID"); err != nil { + return Config{}, err + } else if ok { + cfg.VSock.CID = value + } + + if err := cfg.Validate(); err != nil { + return Config{}, err + } + return cfg, nil +} + +// Validate reports whether the host configuration is usable. +func (c Config) Validate() error { + if strings.TrimSpace(c.Runtime.RootDir) == "" { + return fmt.Errorf("FIRECRACKER_HOST_ROOT_DIR is required") + } + if strings.TrimSpace(c.Machine.KernelImagePath) == "" { + return fmt.Errorf("FIRECRACKER_GUEST_KERNEL_PATH is required") + } + if strings.TrimSpace(c.Machine.RootFSPath) == "" { + return fmt.Errorf("FIRECRACKER_GUEST_ROOTFS_PATH is required") + } + if c.Machine.VCPUs < 1 { + return fmt.Errorf("FIRECRACKER_GUEST_VCPUS must be at least 1") + } + if c.Machine.MemoryMiB < 1 { + return fmt.Errorf("FIRECRACKER_GUEST_MEMORY_MIB must be at least 1") + } + if c.Runtime.NumaNode < 0 { + return fmt.Errorf("FIRECRACKER_NUMA_NODE must be non-negative") + } + if c.VSock.Enabled { + if strings.TrimSpace(c.VSock.BaseDir) == "" { + return fmt.Errorf("FIRECRACKER_VSOCK_BASE_DIR is required when FIRECRACKER_VSOCK_ENABLED is true") + } + if c.VSock.CID == 0 { + return fmt.Errorf("FIRECRACKER_VSOCK_CID must be non-zero when FIRECRACKER_VSOCK_ENABLED is true") + } + if strings.TrimSpace(c.VSock.ID) == "" { + return fmt.Errorf("FIRECRACKER_VSOCK_ID is required when FIRECRACKER_VSOCK_ENABLED is true") + } + } + return nil +} + +// FirecrackerRuntimeConfig converts the host config into the runtime wrapper's +// concrete runtime config. +func (c Config) FirecrackerRuntimeConfig() firecracker.RuntimeConfig { + return firecracker.RuntimeConfig{ + RootDir: c.Runtime.RootDir, + FirecrackerBinaryPath: c.Runtime.FirecrackerBinaryPath, + JailerBinaryPath: c.Runtime.JailerBinaryPath, + JailerUID: c.Runtime.JailerUID, + JailerGID: c.Runtime.JailerGID, + NumaNode: c.Runtime.NumaNode, + NetworkCIDR: c.Runtime.NetworkCIDR, + } +} + +func lookupBoolEnv(key string) (bool, bool, error) { + raw, ok := os.LookupEnv(key) + if !ok || strings.TrimSpace(raw) == "" { + return false, false, nil + } + + value, err := strconv.ParseBool(strings.TrimSpace(raw)) + if err != nil { + return false, false, fmt.Errorf("parse %s: %w", key, err) + } + return value, true, nil +} + +func lookupIntEnv(key string) (int, bool, error) { + raw, ok := os.LookupEnv(key) + if !ok || strings.TrimSpace(raw) == "" { + return 0, false, nil + } + + value, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil { + return 0, false, fmt.Errorf("parse %s: %w", key, err) + } + return value, true, nil +} + +func lookupInt64Env(key string) (int64, bool, error) { + raw, ok := os.LookupEnv(key) + if !ok || strings.TrimSpace(raw) == "" { + return 0, false, nil + } + + value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil { + return 0, false, fmt.Errorf("parse %s: %w", key, err) + } + return value, true, nil +} + +func lookupUint32Env(key string) (uint32, bool, error) { + raw, ok := os.LookupEnv(key) + if !ok || strings.TrimSpace(raw) == "" { + return 0, false, nil + } + + value, err := strconv.ParseUint(strings.TrimSpace(raw), 10, 32) + if err != nil { + return 0, false, fmt.Errorf("parse %s: %w", key, err) + } + return uint32(value), true, nil +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..5080b56 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,136 @@ +package service + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + appconfig "github.com/getcompanion-ai/computer-host/internal/config" + "github.com/getcompanion-ai/computer-host/internal/firecracker" +) + +// MachineRuntime is the minimum runtime surface the host-local service needs. +type MachineRuntime interface { + Boot(context.Context, firecracker.MachineSpec) (*firecracker.MachineState, error) + Inspect(firecracker.MachineID) (*firecracker.MachineState, error) + Stop(context.Context, firecracker.MachineID) error + Delete(context.Context, firecracker.MachineID) error +} + +// Service manages local machine lifecycle requests on a single host. +type Service struct { + config appconfig.Config + runtime MachineRuntime +} + +// CreateMachineRequest contains the minimum machine creation inputs above the +// raw runtime layer. +type CreateMachineRequest struct { + ID firecracker.MachineID + KernelImagePath string + RootFSPath string + KernelArgs string + VCPUs int64 + MemoryMiB int64 + Drives []firecracker.DriveSpec + VSock *firecracker.VsockSpec +} + +// New constructs a new host-local service from the app config. +func New(cfg appconfig.Config) (*Service, error) { + if err := cfg.Validate(); err != nil { + return nil, err + } + if cfg.VSock.Enabled { + if err := os.MkdirAll(cfg.VSock.BaseDir, 0o755); err != nil { + return nil, fmt.Errorf("create vsock base dir %q: %w", cfg.VSock.BaseDir, err) + } + } + + runtime, err := firecracker.NewRuntime(cfg.FirecrackerRuntimeConfig()) + if err != nil { + return nil, err + } + + return &Service{ + config: cfg, + runtime: runtime, + }, nil +} + +// CreateMachine boots a new local machine using config defaults plus request +// overrides. +func (s *Service) CreateMachine(ctx context.Context, req CreateMachineRequest) (*firecracker.MachineState, error) { + spec, err := s.buildMachineSpec(req) + if err != nil { + return nil, err + } + return s.runtime.Boot(ctx, spec) +} + +// GetMachine returns the current local state for a machine. +func (s *Service) GetMachine(id firecracker.MachineID) (*firecracker.MachineState, error) { + return s.runtime.Inspect(id) +} + +// StopMachine stops a running local machine. +func (s *Service) StopMachine(ctx context.Context, id firecracker.MachineID) error { + return s.runtime.Stop(ctx, id) +} + +// DeleteMachine removes a local machine and its host-local resources. +func (s *Service) DeleteMachine(ctx context.Context, id firecracker.MachineID) error { + return s.runtime.Delete(ctx, id) +} + +func (s *Service) buildMachineSpec(req CreateMachineRequest) (firecracker.MachineSpec, error) { + if strings.TrimSpace(string(req.ID)) == "" { + return firecracker.MachineSpec{}, fmt.Errorf("machine id is required") + } + if s == nil { + return firecracker.MachineSpec{}, fmt.Errorf("service is required") + } + + spec := firecracker.MachineSpec{ + ID: req.ID, + VCPUs: s.config.Machine.VCPUs, + MemoryMiB: s.config.Machine.MemoryMiB, + KernelImagePath: s.config.Machine.KernelImagePath, + RootFSPath: s.config.Machine.RootFSPath, + KernelArgs: s.config.Machine.KernelArgs, + Drives: append([]firecracker.DriveSpec(nil), req.Drives...), + } + + if value := strings.TrimSpace(req.KernelImagePath); value != "" { + spec.KernelImagePath = value + } + if value := strings.TrimSpace(req.RootFSPath); value != "" { + spec.RootFSPath = value + } + if value := strings.TrimSpace(req.KernelArgs); value != "" { + spec.KernelArgs = value + } + if req.VCPUs > 0 { + spec.VCPUs = req.VCPUs + } + if req.MemoryMiB > 0 { + spec.MemoryMiB = req.MemoryMiB + } + if req.VSock != nil { + vsock := *req.VSock + spec.Vsock = &vsock + } else if s.config.VSock.Enabled { + spec.Vsock = &firecracker.VsockSpec{ + ID: s.config.VSock.ID, + CID: s.config.VSock.CID, + Path: filepath.Join(s.config.VSock.BaseDir, string(req.ID)+".sock"), + } + } + + if err := spec.Validate(); err != nil { + return firecracker.MachineSpec{}, err + } + return spec, nil +}