From 3a256dc6e29f04bbda7b078f7246cd9336a76177 Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Tue, 7 Apr 2026 19:29:17 +0000 Subject: [PATCH] feat: init firecracker --- internal/firecracker/doc.go | 4 + internal/firecracker/network.go | 199 +++++++++++++++++++++ internal/firecracker/paths.go | 45 +++++ internal/firecracker/runtime.go | 301 ++++++++++++++++++++++++++++++++ internal/firecracker/sdk.go | 83 +++++++++ internal/firecracker/spec.go | 94 ++++++++++ internal/firecracker/state.go | 31 ++++ 7 files changed, 757 insertions(+) create mode 100644 internal/firecracker/doc.go create mode 100644 internal/firecracker/network.go create mode 100644 internal/firecracker/paths.go create mode 100644 internal/firecracker/runtime.go create mode 100644 internal/firecracker/sdk.go create mode 100644 internal/firecracker/spec.go create mode 100644 internal/firecracker/state.go diff --git a/internal/firecracker/doc.go b/internal/firecracker/doc.go new file mode 100644 index 0000000..0d411cc --- /dev/null +++ b/internal/firecracker/doc.go @@ -0,0 +1,4 @@ +// the Firecracker Go SDK for microagentcomputer. +// its supposed to be very thin and the main point is to abstract jailer away and build new +// primitives for agentcomputer +package firecracker diff --git a/internal/firecracker/network.go b/internal/firecracker/network.go new file mode 100644 index 0000000..d6f3f32 --- /dev/null +++ b/internal/firecracker/network.go @@ -0,0 +1,199 @@ +package firecracker + +import ( + "context" + "encoding/binary" + "fmt" + "net/netip" + "os/exec" + "strings" +) + +const ( + defaultNetworkCIDR = "172.16.0.0/16" + defaultNetworkPrefixBits = 30 + defaultInterfaceID = "net0" + defaultTapPrefix = "fctap" +) + +// NetworkAllocation describes the concrete host-local network values assigned to a machine +type NetworkAllocation struct { + InterfaceID string + TapName string + HostCIDR netip.Prefix + GuestCIDR netip.Prefix + GatewayIP netip.Addr + GuestMAC string +} + +// GuestIP returns the guest IP address. +func (n NetworkAllocation) GuestIP() netip.Addr { + return n.GuestCIDR.Addr() +} + +// NetworkAllocator allocates /30 tap networks to machines. +type NetworkAllocator struct { + basePrefix netip.Prefix +} + +// NewNetworkAllocator returns a new /30 allocator rooted at the provided IPv4 +// prefix. +func NewNetworkAllocator(cidr string) (*NetworkAllocator, error) { + cidr = strings.TrimSpace(cidr) + if cidr == "" { + cidr = defaultNetworkCIDR + } + + prefix, err := netip.ParsePrefix(cidr) + if err != nil { + return nil, fmt.Errorf("parse network cidr %q: %w", cidr, err) + } + prefix = prefix.Masked() + if !prefix.Addr().Is4() { + return nil, fmt.Errorf("network cidr %q must be IPv4", cidr) + } + if prefix.Bits() > defaultNetworkPrefixBits { + return nil, fmt.Errorf("network cidr %q must be no more specific than /%d", cidr, defaultNetworkPrefixBits) + } + + return &NetworkAllocator{basePrefix: prefix}, nil +} + +// Allocate chooses the first free /30 network not present in used. +func (a *NetworkAllocator) Allocate(used []NetworkAllocation) (NetworkAllocation, error) { + if a == nil { + return NetworkAllocation{}, fmt.Errorf("network allocator is required") + } + + allocated := make(map[netip.Addr]struct{}, len(used)) + for _, network := range used { + if network.GuestIP().IsValid() { + allocated[network.GuestIP()] = struct{}{} + } + } + + totalSubnets := 1 << uint(defaultNetworkPrefixBits-a.basePrefix.Bits()) + for i := range totalSubnets { + network, err := a.networkForIndex(i) + if err != nil { + return NetworkAllocation{}, err + } + if _, exists := allocated[network.GuestIP()]; exists { + continue + } + return network, nil + } + + return NetworkAllocation{}, fmt.Errorf("network cidr %q is exhausted", a.basePrefix) +} + +func (a *NetworkAllocator) networkForIndex(index int) (NetworkAllocation, error) { + if index < 0 { + return NetworkAllocation{}, fmt.Errorf("network index must be non-negative") + } + + base := ipv4ToUint32(a.basePrefix.Addr()) + subnetBase := base + uint32(index*4) + hostIP := uint32ToIPv4(subnetBase + 1) + guestIP := uint32ToIPv4(subnetBase + 2) + + return NetworkAllocation{ + InterfaceID: defaultInterfaceID, + TapName: fmt.Sprintf("%s%d", defaultTapPrefix, index), + HostCIDR: netip.PrefixFrom(hostIP, defaultNetworkPrefixBits), + GuestCIDR: netip.PrefixFrom(guestIP, defaultNetworkPrefixBits), + GatewayIP: hostIP, + GuestMAC: macForIPv4(guestIP), + }, nil +} + +// NetworkProvisioner prepares the host-side tap device for a machine. +type NetworkProvisioner interface { + Ensure(context.Context, NetworkAllocation) error + Remove(context.Context, NetworkAllocation) error +} + +// IPTapProvisioner provisions tap devices through the `ip` CLI. +type IPTapProvisioner struct { + runCommand func(context.Context, string, ...string) error +} + +// NewIPTapProvisioner returns a provisioner backed by `ip`. +func NewIPTapProvisioner() *IPTapProvisioner { + return &IPTapProvisioner{ + runCommand: func(ctx context.Context, name string, args ...string) error { + cmd := exec.CommandContext(ctx, name, args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, strings.TrimSpace(string(output))) + } + return nil + }, + } +} + +// Ensure creates and brings up the tap device with the host-side address. +func (p *IPTapProvisioner) Ensure(ctx context.Context, network NetworkAllocation) error { + if p == nil || p.runCommand == nil { + return fmt.Errorf("network provisioner is required") + } + if strings.TrimSpace(network.TapName) == "" { + return fmt.Errorf("tap name is required") + } + + err := p.runCommand(ctx, "ip", "tuntap", "add", "dev", network.TapName, "mode", "tap") + if err != nil { + lower := strings.ToLower(err.Error()) + if !strings.Contains(lower, "file exists") && !strings.Contains(lower, "device or resource busy") { + return fmt.Errorf("create tap device %q: %w", network.TapName, err) + } + if removeErr := p.Remove(ctx, network); removeErr != nil { + return fmt.Errorf("remove stale tap device %q: %w", network.TapName, removeErr) + } + if err := p.runCommand(ctx, "ip", "tuntap", "add", "dev", network.TapName, "mode", "tap"); err != nil { + return fmt.Errorf("create tap device %q after cleanup: %w", network.TapName, err) + } + } + + if err := p.runCommand(ctx, "ip", "addr", "replace", network.HostCIDR.String(), "dev", network.TapName); err != nil { + return fmt.Errorf("assign host address to %q: %w", network.TapName, err) + } + if err := p.runCommand(ctx, "ip", "link", "set", "dev", network.TapName, "up"); err != nil { + return fmt.Errorf("bring up tap device %q: %w", network.TapName, err) + } + return nil +} + +// Remove deletes the tap device if it exists. +func (p *IPTapProvisioner) Remove(ctx context.Context, network NetworkAllocation) error { + if p == nil || p.runCommand == nil { + return fmt.Errorf("network provisioner is required") + } + if strings.TrimSpace(network.TapName) == "" { + return nil + } + if err := p.runCommand(ctx, "ip", "link", "del", "dev", network.TapName); err != nil { + lower := strings.ToLower(err.Error()) + if strings.Contains(lower, "cannot find device") || strings.Contains(lower, "does not exist") { + return nil + } + return fmt.Errorf("remove tap device %q: %w", network.TapName, err) + } + return nil +} + +func ipv4ToUint32(ip netip.Addr) uint32 { + bytes := ip.As4() + return binary.BigEndian.Uint32(bytes[:]) +} + +func uint32ToIPv4(value uint32) netip.Addr { + var bytes [4]byte + binary.BigEndian.PutUint32(bytes[:], value) + return netip.AddrFrom4(bytes) +} + +func macForIPv4(ip netip.Addr) string { + bytes := ip.As4() + return fmt.Sprintf("06:00:%02x:%02x:%02x:%02x", bytes[0], bytes[1], bytes[2], bytes[3]) +} diff --git a/internal/firecracker/paths.go b/internal/firecracker/paths.go new file mode 100644 index 0000000..3d1bd98 --- /dev/null +++ b/internal/firecracker/paths.go @@ -0,0 +1,45 @@ +package firecracker + +import ( + "fmt" + "path/filepath" + "strings" +) + +const defaultSocketName = "api.socket" + +type machinePaths struct { + BaseDir string + JailerBaseDir string + ChrootRootDir string + SocketName string + SocketPath string + FirecrackerBin string +} + +func buildMachinePaths(rootDir string, id MachineID, firecrackerBinaryPath string) (machinePaths, error) { + rootDir = strings.TrimSpace(rootDir) + if rootDir == "" { + return machinePaths{}, fmt.Errorf("root dir is required") + } + if strings.TrimSpace(string(id)) == "" { + return machinePaths{}, fmt.Errorf("machine id is required") + } + binName := filepath.Base(strings.TrimSpace(firecrackerBinaryPath)) + if binName == "." || binName == string(filepath.Separator) || binName == "" { + return machinePaths{}, fmt.Errorf("firecracker binary path is required") + } + + baseDir := filepath.Join(rootDir, "machines", string(id)) + jailerBaseDir := filepath.Join(baseDir, "jailer") + chrootRootDir := filepath.Join(jailerBaseDir, binName, string(id), "root") + + return machinePaths{ + BaseDir: baseDir, + JailerBaseDir: jailerBaseDir, + ChrootRootDir: chrootRootDir, + SocketName: defaultSocketName, + SocketPath: filepath.Join(chrootRootDir, defaultSocketName), + FirecrackerBin: binName, + }, nil +} diff --git a/internal/firecracker/runtime.go b/internal/firecracker/runtime.go new file mode 100644 index 0000000..a1067c8 --- /dev/null +++ b/internal/firecracker/runtime.go @@ -0,0 +1,301 @@ +package firecracker + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + sdk "github.com/firecracker-microvm/firecracker-go-sdk" +) + +// ErrMachineNotFound is returned when the runtime does not know a machine ID. +var ErrMachineNotFound = errors.New("machine not found") + +// RuntimeConfig configures the host-local Firecracker runtime wrapper. +type RuntimeConfig struct { + RootDir string + FirecrackerBinaryPath string + JailerBinaryPath string + JailerUID int + JailerGID int + NumaNode int + NetworkCIDR string + NetworkAllocator *NetworkAllocator + NetworkProvisioner NetworkProvisioner +} + +// Runtime manages local Firecracker machines on a single host. +type Runtime struct { + config RuntimeConfig + + mu sync.RWMutex + machines map[MachineID]*managedMachine + + newMachine func(context.Context, sdk.Config) (*sdk.Machine, error) +} + +type managedMachine struct { + spec MachineSpec + network NetworkAllocation + paths machinePaths + machine *sdk.Machine + state MachineState +} + +// NewRuntime creates a new host-local Firecracker runtime wrapper. +func NewRuntime(cfg RuntimeConfig) (*Runtime, error) { + cfg.RootDir = filepath.Clean(cfg.RootDir) + if cfg.RootDir == "." || cfg.RootDir == "" { + return nil, fmt.Errorf("runtime root dir is required") + } + if err := os.MkdirAll(cfg.RootDir, 0o755); err != nil { + return nil, fmt.Errorf("create runtime root dir %q: %w", cfg.RootDir, err) + } + + if cfg.FirecrackerBinaryPath == "" { + cfg.FirecrackerBinaryPath = "firecracker" + } + if cfg.JailerBinaryPath == "" { + cfg.JailerBinaryPath = "jailer" + } + if cfg.JailerUID == 0 { + cfg.JailerUID = os.Getuid() + } + if cfg.JailerGID == 0 { + cfg.JailerGID = os.Getgid() + } + if cfg.NumaNode < 0 { + cfg.NumaNode = 0 + } + if cfg.NetworkAllocator == nil { + allocator, err := NewNetworkAllocator(cfg.NetworkCIDR) + if err != nil { + return nil, err + } + cfg.NetworkAllocator = allocator + } + if cfg.NetworkProvisioner == nil { + cfg.NetworkProvisioner = NewIPTapProvisioner() + } + + return &Runtime{ + config: cfg, + machines: make(map[MachineID]*managedMachine), + newMachine: func(ctx context.Context, cfg sdk.Config) (*sdk.Machine, error) { + return sdk.NewMachine(ctx, cfg) + }, + }, nil +} + +// Boot provisions host resources and starts a new jailed Firecracker process. +func (r *Runtime) Boot(ctx context.Context, spec MachineSpec) (*MachineState, error) { + if err := spec.Validate(); err != nil { + return nil, err + } + + r.mu.Lock() + if _, exists := r.machines[spec.ID]; exists { + r.mu.Unlock() + return nil, fmt.Errorf("machine %q already exists", spec.ID) + } + usedNetworks := make([]NetworkAllocation, 0, len(r.machines)) + for _, machine := range r.machines { + if machine != nil { + usedNetworks = append(usedNetworks, machine.network) + } + } + r.machines[spec.ID] = &managedMachine{ + spec: spec, + state: MachineState{ + ID: spec.ID, + Phase: PhaseProvisioning, + }, + } + r.mu.Unlock() + + cleanup := func(network NetworkAllocation, paths machinePaths) { + _ = r.config.NetworkProvisioner.Remove(context.Background(), network) + if paths.BaseDir != "" { + _ = os.RemoveAll(paths.BaseDir) + } + r.mu.Lock() + delete(r.machines, spec.ID) + r.mu.Unlock() + } + + network, err := r.config.NetworkAllocator.Allocate(usedNetworks) + if err != nil { + cleanup(NetworkAllocation{}, machinePaths{}) + return nil, err + } + + paths, err := buildMachinePaths(r.config.RootDir, spec.ID, r.config.FirecrackerBinaryPath) + if err != nil { + cleanup(network, machinePaths{}) + return nil, err + } + if err := os.MkdirAll(paths.JailerBaseDir, 0o755); err != nil { + cleanup(network, paths) + return nil, fmt.Errorf("create machine jailer dir %q: %w", paths.JailerBaseDir, err) + } + if err := r.config.NetworkProvisioner.Ensure(ctx, network); err != nil { + cleanup(network, paths) + return nil, err + } + + cfg, err := buildSDKConfig(spec, paths, network, r.config) + if err != nil { + cleanup(network, paths) + return nil, err + } + + machine, err := r.newMachine(ctx, cfg) + if err != nil { + cleanup(network, paths) + return nil, fmt.Errorf("create firecracker machine: %w", err) + } + if err := machine.Start(ctx); err != nil { + cleanup(network, paths) + return nil, fmt.Errorf("start firecracker machine: %w", err) + } + + pid, _ := machine.PID() + now := time.Now().UTC() + state := MachineState{ + ID: spec.ID, + Phase: PhaseRunning, + PID: pid, + RuntimeHost: network.GuestIP().String(), + SocketPath: machine.Cfg.SocketPath, + TapName: network.TapName, + StartedAt: &now, + } + + r.mu.Lock() + entry := r.machines[spec.ID] + entry.network = network + entry.paths = paths + entry.machine = machine + entry.state = state + r.mu.Unlock() + + go r.watchMachine(spec.ID, machine) + + out := state + return &out, nil +} + +// Inspect returns the currently known state for a machine. +func (r *Runtime) Inspect(id MachineID) (*MachineState, error) { + r.mu.RLock() + entry, ok := r.machines[id] + r.mu.RUnlock() + if !ok || entry == nil { + return nil, ErrMachineNotFound + } + + state := entry.state + if entry.machine != nil { + pid, err := entry.machine.PID() + if err != nil { + if state.Phase == PhaseRunning { + state.Phase = PhaseStopped + state.PID = 0 + } + } else { + state.PID = pid + } + } + + return &state, nil +} + +// Stop terminates a running Firecracker process and updates local state. +func (r *Runtime) Stop(ctx context.Context, id MachineID) error { + r.mu.RLock() + entry, ok := r.machines[id] + r.mu.RUnlock() + if !ok || entry == nil { + return ErrMachineNotFound + } + if entry.machine == nil { + return fmt.Errorf("machine %q has no firecracker process", id) + } + + errCh := make(chan error, 1) + go func() { + errCh <- entry.machine.StopVMM() + }() + + select { + case err := <-errCh: + if err != nil { + return fmt.Errorf("stop machine %q: %w", id, err) + } + case <-ctx.Done(): + return ctx.Err() + } + + r.mu.Lock() + entry.state.Phase = PhaseStopped + entry.state.PID = 0 + entry.state.Error = "" + r.mu.Unlock() + + return nil +} + +// Delete stops a machine if necessary and removes its local resources. +func (r *Runtime) Delete(ctx context.Context, id MachineID) error { + r.mu.RLock() + entry, ok := r.machines[id] + r.mu.RUnlock() + if !ok || entry == nil { + return ErrMachineNotFound + } + + if entry.machine != nil { + if err := r.Stop(ctx, id); err != nil && !errors.Is(err, context.Canceled) { + return err + } + } + if err := r.config.NetworkProvisioner.Remove(ctx, entry.network); err != nil { + return err + } + if err := os.RemoveAll(entry.paths.BaseDir); err != nil { + return fmt.Errorf("remove machine dir %q: %w", entry.paths.BaseDir, err) + } + + r.mu.Lock() + delete(r.machines, id) + r.mu.Unlock() + return nil +} + +func (r *Runtime) watchMachine(id MachineID, machine *sdk.Machine) { + err := machine.Wait(context.Background()) + + r.mu.Lock() + defer r.mu.Unlock() + + entry, ok := r.machines[id] + if !ok || entry == nil || entry.machine != machine { + return + } + + entry.state.PID = 0 + if err != nil { + entry.state.Phase = PhaseError + entry.state.Error = err.Error() + return + } + + if entry.state.Phase != PhaseStopped { + entry.state.Phase = PhaseStopped + } + entry.state.Error = "" +} diff --git a/internal/firecracker/sdk.go b/internal/firecracker/sdk.go new file mode 100644 index 0000000..4ccc995 --- /dev/null +++ b/internal/firecracker/sdk.go @@ -0,0 +1,83 @@ +package firecracker + +import ( + "fmt" + "net" + "net/netip" + "path/filepath" + "strings" + + sdk "github.com/firecracker-microvm/firecracker-go-sdk" + models "github.com/firecracker-microvm/firecracker-go-sdk/client/models" +) + +func buildSDKConfig(spec MachineSpec, paths machinePaths, network NetworkAllocation, runtime RuntimeConfig) (sdk.Config, error) { + if err := spec.Validate(); err != nil { + return sdk.Config{}, err + } + + if runtime.FirecrackerBinaryPath == "" { + return sdk.Config{}, fmt.Errorf("firecracker binary path is required") + } + + drives := sdk.NewDrivesBuilder(spec.RootFSPath) + for _, drive := range spec.Drives { + drives = drives.AddDrive( + drive.Path, + drive.ReadOnly, + sdk.WithDriveID(strings.TrimSpace(drive.ID)), + ) + } + + cfg := sdk.Config{ + SocketPath: paths.SocketName, + KernelImagePath: spec.KernelImagePath, + KernelArgs: strings.TrimSpace(spec.KernelArgs), + Drives: drives.Build(), + NetworkInterfaces: sdk.NetworkInterfaces{{ + StaticConfiguration: &sdk.StaticNetworkConfiguration{ + HostDevName: network.TapName, + MacAddress: network.GuestMAC, + IPConfiguration: &sdk.IPConfiguration{ + IPAddr: toIPNet(network.GuestCIDR), + Gateway: net.ParseIP(network.GatewayIP.String()), + Nameservers: nil, + }, + }, + }}, + MachineCfg: models.MachineConfiguration{ + VcpuCount: sdk.Int64(spec.VCPUs), + MemSizeMib: sdk.Int64(spec.MemoryMiB), + Smt: sdk.Bool(false), + }, + JailerCfg: &sdk.JailerConfig{ + GID: sdk.Int(runtime.JailerGID), + UID: sdk.Int(runtime.JailerUID), + ID: string(spec.ID), + NumaNode: sdk.Int(runtime.NumaNode), + ExecFile: runtime.FirecrackerBinaryPath, + JailerBinary: runtime.JailerBinaryPath, + ChrootBaseDir: paths.JailerBaseDir, + ChrootStrategy: sdk.NewNaiveChrootStrategy(filepath.Clean(spec.KernelImagePath)), + }, + VMID: string(spec.ID), + } + + if spec.Vsock != nil { + cfg.VsockDevices = []sdk.VsockDevice{{ + ID: spec.Vsock.ID, + Path: spec.Vsock.Path, + CID: spec.Vsock.CID, + }} + } + + return cfg, nil +} + +func toIPNet(prefix netip.Prefix) net.IPNet { + bits := prefix.Bits() + return net.IPNet{ + IP: net.ParseIP(prefix.Addr().String()), + Mask: net.CIDRMask(bits, 32), + } +} diff --git a/internal/firecracker/spec.go b/internal/firecracker/spec.go new file mode 100644 index 0000000..1e4237c --- /dev/null +++ b/internal/firecracker/spec.go @@ -0,0 +1,94 @@ +package firecracker + +import ( + "fmt" + "path/filepath" + "strings" +) + +// MachineID uniquely identifies a single microVM on a host. +type MachineID string + +// MachineSpec describes the minimum machine inputs required to boot a guest. +type MachineSpec struct { + ID MachineID + VCPUs int64 + MemoryMiB int64 + KernelImagePath string + RootFSPath string + KernelArgs string + Drives []DriveSpec + Vsock *VsockSpec +} + +// Validate reports whether the machine specification is usable for boot. +func (s MachineSpec) Validate() error { + if strings.TrimSpace(string(s.ID)) == "" { + return fmt.Errorf("machine id is required") + } + if s.VCPUs < 1 { + return fmt.Errorf("machine vcpus must be at least 1") + } + if s.MemoryMiB < 1 { + return fmt.Errorf("machine memory must be at least 1 MiB") + } + if strings.TrimSpace(s.KernelImagePath) == "" { + return fmt.Errorf("machine kernel image path is required") + } + if strings.TrimSpace(s.RootFSPath) == "" { + return fmt.Errorf("machine rootfs path is required") + } + if filepath.Base(strings.TrimSpace(string(s.ID))) != strings.TrimSpace(string(s.ID)) { + return fmt.Errorf("machine id %q must not contain path separators", s.ID) + } + for i, drive := range s.Drives { + if err := drive.Validate(); err != nil { + return fmt.Errorf("drive %d: %w", i, err) + } + } + if s.Vsock != nil { + if err := s.Vsock.Validate(); err != nil { + return fmt.Errorf("vsock: %w", err) + } + } + return nil +} + +// DriveSpec describes an additional guest block device. +type DriveSpec struct { + ID string + Path string + ReadOnly bool +} + +// Validate reports whether the drive specification is usable. +func (d DriveSpec) Validate() error { + if strings.TrimSpace(d.ID) == "" { + return fmt.Errorf("drive id is required") + } + if strings.TrimSpace(d.Path) == "" { + return fmt.Errorf("drive path is required") + } + return nil +} + +// VsockSpec describes a single host-guest vsock device. +type VsockSpec struct { + ID string + CID uint32 + Path string +} + +// Validate reports whether the vsock specification is usable. +func (v VsockSpec) Validate() error { + if strings.TrimSpace(v.ID) == "" { + return fmt.Errorf("vsock id is required") + } + if v.CID == 0 { + return fmt.Errorf("vsock cid must be non-zero") + } + if strings.TrimSpace(v.Path) == "" { + return fmt.Errorf("vsock path is required") + } + return nil +} diff --git a/internal/firecracker/state.go b/internal/firecracker/state.go new file mode 100644 index 0000000..d9280b1 --- /dev/null +++ b/internal/firecracker/state.go @@ -0,0 +1,31 @@ +package firecracker + +import "time" + +// Phase represents the lifecycle phase of a local microVM. +type Phase string + +const ( + // PhaseProvisioning means host-local resources are still being prepared. + PhaseProvisioning Phase = "provisioning" + // PhaseRunning means the Firecracker process is live. + PhaseRunning Phase = "running" + // PhaseStopped means the VM is no longer running. + PhaseStopped Phase = "stopped" + // PhaseMissing means the machine is not known to the runtime. + PhaseMissing Phase = "missing" + // PhaseError means the runtime observed a terminal failure. + PhaseError Phase = "error" +) + +// MachineState describes the current host-local state for a machine. +type MachineState struct { + ID MachineID + Phase Phase + PID int + RuntimeHost string + SocketPath string + TapName string + StartedAt *time.Time + Error string +}