mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 19:25:52 +00:00
2273 lines
74 KiB
Go
2273 lines
74 KiB
Go
package daemon
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/service/ec2"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/mulgadc/hive/hive/awserrors"
|
|
"github.com/mulgadc/hive/hive/config"
|
|
handlers_ec2_account "github.com/mulgadc/hive/hive/handlers/ec2/account"
|
|
handlers_ec2_eigw "github.com/mulgadc/hive/hive/handlers/ec2/eigw"
|
|
handlers_ec2_igw "github.com/mulgadc/hive/hive/handlers/ec2/igw"
|
|
handlers_ec2_image "github.com/mulgadc/hive/hive/handlers/ec2/image"
|
|
handlers_ec2_instance "github.com/mulgadc/hive/hive/handlers/ec2/instance"
|
|
handlers_ec2_key "github.com/mulgadc/hive/hive/handlers/ec2/key"
|
|
handlers_ec2_snapshot "github.com/mulgadc/hive/hive/handlers/ec2/snapshot"
|
|
handlers_ec2_tags "github.com/mulgadc/hive/hive/handlers/ec2/tags"
|
|
handlers_ec2_volume "github.com/mulgadc/hive/hive/handlers/ec2/volume"
|
|
handlers_ec2_vpc "github.com/mulgadc/hive/hive/handlers/ec2/vpc"
|
|
"github.com/mulgadc/hive/hive/instancetypes"
|
|
"github.com/mulgadc/hive/hive/objectstore"
|
|
"github.com/mulgadc/hive/hive/qmp"
|
|
"github.com/mulgadc/hive/hive/types"
|
|
"github.com/mulgadc/hive/hive/utils"
|
|
"github.com/mulgadc/hive/hive/vm"
|
|
"github.com/mulgadc/viperblock/viperblock"
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/pelletier/go-toml/v2"
|
|
"net/http"
|
|
)
|
|
|
|
type BlockDeviceMapping struct {
|
|
DeviceName string `json:"DeviceName"`
|
|
EBS EBS `json:"EBS"`
|
|
}
|
|
|
|
type EBS struct {
|
|
DeleteOnTermination bool
|
|
Encrypted bool
|
|
Iops int
|
|
KmsKeyId string
|
|
OutpostArn string
|
|
SnapshotId string
|
|
Throughput int
|
|
VolumeInitializationRate int
|
|
VolumeSize int
|
|
VolumeType string
|
|
}
|
|
|
|
// ResourceManager handles the allocation and tracking of system resources.
|
|
// It dynamically manages per-instance-type NATS subscriptions: when capacity
|
|
// is available for a type, the node subscribes to ec2.RunInstances.{type};
|
|
// when full, it unsubscribes so NATS routes requests to other nodes.
|
|
type ResourceManager struct {
|
|
mu sync.RWMutex
|
|
availableVCPU int
|
|
availableMem float64
|
|
allocatedVCPU int
|
|
allocatedMem float64
|
|
instanceTypes map[string]*ec2.InstanceTypeInfo
|
|
|
|
// Dynamic instance-type subscription management
|
|
subsMu sync.Mutex
|
|
natsConn *nats.Conn
|
|
instanceSubs map[string]*nats.Subscription
|
|
handler nats.MsgHandler
|
|
}
|
|
|
|
// Daemon represents the main daemon service
|
|
type Daemon struct {
|
|
node string
|
|
clusterConfig *config.ClusterConfig
|
|
config *config.Config
|
|
natsConn *nats.Conn
|
|
resourceMgr *ResourceManager
|
|
instanceService *handlers_ec2_instance.InstanceServiceImpl
|
|
keyService *handlers_ec2_key.KeyServiceImpl
|
|
imageService *handlers_ec2_image.ImageServiceImpl
|
|
volumeService *handlers_ec2_volume.VolumeServiceImpl
|
|
accountService *handlers_ec2_account.AccountSettingsServiceImpl
|
|
snapshotService *handlers_ec2_snapshot.SnapshotServiceImpl
|
|
tagsService *handlers_ec2_tags.TagsServiceImpl
|
|
eigwService *handlers_ec2_eigw.EgressOnlyIGWServiceImpl
|
|
igwService *handlers_ec2_igw.IGWServiceImpl
|
|
vpcService *handlers_ec2_vpc.VPCServiceImpl
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
shutdownWg sync.WaitGroup
|
|
|
|
// Local VM Instances
|
|
Instances vm.Instances
|
|
|
|
// NAT Subscriptions
|
|
natsSubscriptions map[string]*nats.Subscription
|
|
|
|
// Cluster manager
|
|
clusterServer *http.Server
|
|
startTime time.Time
|
|
configPath string
|
|
|
|
// JetStream manager for KV state storage (nil if JetStream disabled)
|
|
jsManager *JetStreamManager
|
|
|
|
// Delay after QMP device_del before blockdev-del (default 1s, 0 in tests)
|
|
detachDelay time.Duration
|
|
|
|
// NATS connect retry parameters (zero values use defaults: 5min max, 500ms initial delay)
|
|
natsMaxWait time.Duration
|
|
natsRetryDelay time.Duration
|
|
|
|
// NetworkPlumber handles tap device lifecycle for VPC networking
|
|
networkPlumber NetworkPlumber
|
|
|
|
// shuttingDown is set to true during coordinated cluster shutdown (GATE phase).
|
|
// When true, the daemon rejects new work and setupShutdown skips VM stops.
|
|
shuttingDown atomic.Bool
|
|
|
|
// ready is set to true once NATS connection, JetStream, and all services
|
|
// are fully initialized. The health endpoint reports "starting" until ready.
|
|
ready atomic.Bool
|
|
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// getSystemMemory returns the total system memory in GB
|
|
func getSystemMemory() (float64, error) {
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
// macOS: use sysctl
|
|
cmd := exec.Command("sysctl", "-n", "hw.memsize")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to get system memory on macOS: %w", err)
|
|
}
|
|
memBytes, err := strconv.ParseInt(strings.TrimSpace(string(output)), 10, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to parse memory size on macOS: %w", err)
|
|
}
|
|
return float64(memBytes) / (1024 * 1024 * 1024), nil
|
|
|
|
case "linux":
|
|
// Linux: read from /proc/meminfo
|
|
cmd := exec.Command("grep", "MemTotal", "/proc/meminfo")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to read /proc/meminfo: %w", err)
|
|
}
|
|
|
|
// Parse the output (format: "MemTotal: 16384 kB")
|
|
fields := strings.Fields(string(output))
|
|
if len(fields) < 3 {
|
|
return 0, fmt.Errorf("unexpected /proc/meminfo format")
|
|
}
|
|
|
|
memKB, err := strconv.ParseInt(fields[1], 10, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to parse memory size from /proc/meminfo: %w", err)
|
|
}
|
|
|
|
// Convert KB to GB
|
|
return float64(memKB) / (1024 * 1024), nil
|
|
|
|
default:
|
|
return 0, fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
|
|
}
|
|
}
|
|
|
|
// NewResourceManager creates a new resource manager with system capabilities
|
|
func NewResourceManager() *ResourceManager {
|
|
// Get system CPU cores
|
|
numCPU := runtime.NumCPU()
|
|
|
|
// Get system memory (in GB)
|
|
totalMemGB, err := getSystemMemory()
|
|
if err != nil {
|
|
slog.Warn("Failed to get system memory, using default of 8GB", "err", err)
|
|
totalMemGB = 8.0 // Default to 8GB if we can't get the actual memory
|
|
}
|
|
|
|
// Determine architecture
|
|
arch := "x86_64"
|
|
if runtime.GOARCH == "arm64" {
|
|
arch = "arm64"
|
|
}
|
|
|
|
// Detect CPU generation and generate matching instance types
|
|
instanceTypes := instancetypes.DetectAndGenerate(instancetypes.HostCPU{}, arch)
|
|
|
|
slog.Info("System resources detected",
|
|
"vCPUs", numCPU, "memGB", totalMemGB,
|
|
"instanceTypes", len(instanceTypes))
|
|
|
|
return &ResourceManager{
|
|
availableVCPU: numCPU,
|
|
availableMem: totalMemGB,
|
|
instanceTypes: instanceTypes,
|
|
}
|
|
}
|
|
|
|
// instanceTypeVCPUs returns the default vCPU count for an instance type, or 0 if unavailable.
|
|
func instanceTypeVCPUs(it *ec2.InstanceTypeInfo) int64 {
|
|
if it.VCpuInfo != nil && it.VCpuInfo.DefaultVCpus != nil {
|
|
return *it.VCpuInfo.DefaultVCpus
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// instanceTypeMemoryMiB returns the memory in MiB for an instance type, or 0 if unavailable.
|
|
func instanceTypeMemoryMiB(it *ec2.InstanceTypeInfo) int64 {
|
|
if it.MemoryInfo != nil && it.MemoryInfo.SizeInMiB != nil {
|
|
return *it.MemoryInfo.SizeInMiB
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// GetInstanceTypeInfos returns all instance types as ec2.InstanceTypeInfo for AWS API compatibility
|
|
func (rm *ResourceManager) GetInstanceTypeInfos() []*ec2.InstanceTypeInfo {
|
|
rm.mu.RLock()
|
|
defer rm.mu.RUnlock()
|
|
|
|
var infos []*ec2.InstanceTypeInfo
|
|
for _, it := range rm.instanceTypes {
|
|
infos = append(infos, it)
|
|
}
|
|
return infos
|
|
}
|
|
|
|
// GetAvailableInstanceTypeInfos returns instance types based on total host capacity.
|
|
// If showCapacity is true, it returns multiple entries representing available slots.
|
|
// If showCapacity is false, it returns each supported type only once.
|
|
func (rm *ResourceManager) GetAvailableInstanceTypeInfos(showCapacity bool) []*ec2.InstanceTypeInfo {
|
|
rm.mu.RLock()
|
|
defer rm.mu.RUnlock()
|
|
|
|
var infos []*ec2.InstanceTypeInfo
|
|
|
|
for _, it := range rm.instanceTypes {
|
|
vCPUs := instanceTypeVCPUs(it)
|
|
memMiB := instanceTypeMemoryMiB(it)
|
|
|
|
if vCPUs == 0 || memMiB == 0 {
|
|
continue
|
|
}
|
|
|
|
count := canAllocateCount(
|
|
rm.availableVCPU, rm.allocatedVCPU,
|
|
rm.availableMem, rm.allocatedMem,
|
|
vCPUs, memMiB,
|
|
1<<30, // effectively unlimited — let resources be the constraint
|
|
)
|
|
|
|
if showCapacity {
|
|
for range count {
|
|
infos = append(infos, it)
|
|
}
|
|
} else if count > 0 {
|
|
infos = append(infos, it)
|
|
}
|
|
}
|
|
|
|
slog.Info("GetAvailableInstanceTypeInfos", "total_types", len(rm.instanceTypes), "total_available_slots", len(infos),
|
|
"hostVCPU", rm.availableVCPU, "hostMem", rm.availableMem, "showCapacity", showCapacity)
|
|
|
|
return infos
|
|
}
|
|
|
|
// GetResourceStats returns current resource allocation stats for the node status response.
|
|
func (rm *ResourceManager) GetResourceStats() (totalVCPU int, totalMemGB float64, allocVCPU int, allocMemGB float64, caps []types.InstanceTypeCap) {
|
|
rm.mu.RLock()
|
|
defer rm.mu.RUnlock()
|
|
|
|
totalVCPU = rm.availableVCPU
|
|
totalMemGB = rm.availableMem
|
|
allocVCPU = rm.allocatedVCPU
|
|
allocMemGB = rm.allocatedMem
|
|
|
|
remainingVCPU := rm.availableVCPU - rm.allocatedVCPU
|
|
remainingMem := rm.availableMem - rm.allocatedMem
|
|
|
|
for _, it := range rm.instanceTypes {
|
|
cap := resourceStatsForType(remainingVCPU, remainingMem, it)
|
|
if cap.VCPU == 0 || cap.MemoryGB == 0 {
|
|
continue
|
|
}
|
|
caps = append(caps, cap)
|
|
}
|
|
return
|
|
}
|
|
|
|
// SetConfigPath sets the configuration file path for cluster management
|
|
func (d *Daemon) SetConfigPath(path string) {
|
|
d.configPath = path
|
|
}
|
|
|
|
// NewDaemon creates a new daemon instance
|
|
func NewDaemon(cfg *config.ClusterConfig) *Daemon {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
// If WalDir is not set, use BaseDir
|
|
config := cfg.Nodes[cfg.Node]
|
|
if cfg.Nodes[cfg.Node].WalDir == "" {
|
|
config.WalDir = config.BaseDir
|
|
|
|
cfg.Nodes[cfg.Node] = config
|
|
}
|
|
|
|
return &Daemon{
|
|
node: cfg.Node,
|
|
clusterConfig: cfg,
|
|
config: &config,
|
|
resourceMgr: NewResourceManager(),
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
Instances: vm.Instances{VMS: make(map[string]*vm.VM)},
|
|
natsSubscriptions: make(map[string]*nats.Subscription),
|
|
startTime: time.Now(),
|
|
detachDelay: 1 * time.Second,
|
|
}
|
|
}
|
|
|
|
// natsSub defines a single NATS subscription entry for the table-driven setup.
|
|
type natsSub struct {
|
|
topic string
|
|
handler nats.MsgHandler
|
|
queueGroup string // empty = plain Subscribe (fan-out)
|
|
}
|
|
|
|
// subscribeAll registers all NATS subscriptions using a table-driven approach.
|
|
func (d *Daemon) subscribeAll() error {
|
|
subs := []natsSub{
|
|
// ec2.RunInstances is handled by dynamic per-instance-type subscriptions
|
|
// managed by ResourceManager.initSubscriptions()
|
|
{"ec2.CreateKeyPair", d.handleEC2CreateKeyPair, "hive-workers"},
|
|
{"ec2.DeleteKeyPair", d.handleEC2DeleteKeyPair, "hive-workers"},
|
|
{"ec2.DescribeKeyPairs", d.handleEC2DescribeKeyPairs, "hive-workers"},
|
|
{"ec2.ImportKeyPair", d.handleEC2ImportKeyPair, "hive-workers"},
|
|
{"ec2.DescribeImages", d.handleEC2DescribeImages, "hive-workers"},
|
|
{"ec2.CreateImage", d.handleEC2CreateImage, ""},
|
|
{"ec2.CreateVolume", d.handleEC2CreateVolume, "hive-workers"},
|
|
{"ec2.DescribeVolumes", d.handleEC2DescribeVolumes, "hive-workers"},
|
|
{"ec2.ModifyVolume", d.handleEC2ModifyVolume, "hive-workers"},
|
|
{"ec2.DeleteVolume", d.handleEC2DeleteVolume, "hive-workers"},
|
|
{"ec2.DescribeVolumeStatus", d.handleEC2DescribeVolumeStatus, "hive-workers"},
|
|
{"ec2.CreateSnapshot", d.handleEC2CreateSnapshot, "hive-workers"},
|
|
{"ec2.DescribeSnapshots", d.handleEC2DescribeSnapshots, "hive-workers"},
|
|
{"ec2.DeleteSnapshot", d.handleEC2DeleteSnapshot, "hive-workers"},
|
|
{"ec2.CopySnapshot", d.handleEC2CopySnapshot, "hive-workers"},
|
|
{"ec2.CreateTags", d.handleEC2CreateTags, "hive-workers"},
|
|
{"ec2.DeleteTags", d.handleEC2DeleteTags, "hive-workers"},
|
|
{"ec2.DescribeTags", d.handleEC2DescribeTags, "hive-workers"},
|
|
{"ec2.CreateEgressOnlyInternetGateway", d.handleEC2CreateEgressOnlyInternetGateway, "hive-workers"},
|
|
{"ec2.DeleteEgressOnlyInternetGateway", d.handleEC2DeleteEgressOnlyInternetGateway, "hive-workers"},
|
|
{"ec2.DescribeEgressOnlyInternetGateways", d.handleEC2DescribeEgressOnlyInternetGateways, "hive-workers"},
|
|
{"ec2.CreateInternetGateway", d.handleEC2CreateInternetGateway, "hive-workers"},
|
|
{"ec2.DeleteInternetGateway", d.handleEC2DeleteInternetGateway, "hive-workers"},
|
|
{"ec2.DescribeInternetGateways", d.handleEC2DescribeInternetGateways, "hive-workers"},
|
|
{"ec2.AttachInternetGateway", d.handleEC2AttachInternetGateway, "hive-workers"},
|
|
{"ec2.DetachInternetGateway", d.handleEC2DetachInternetGateway, "hive-workers"},
|
|
{"ec2.CreateVpc", d.handleEC2CreateVpc, "hive-workers"},
|
|
{"ec2.DeleteVpc", d.handleEC2DeleteVpc, "hive-workers"},
|
|
{"ec2.DescribeVpcs", d.handleEC2DescribeVpcs, "hive-workers"},
|
|
{"ec2.CreateSubnet", d.handleEC2CreateSubnet, "hive-workers"},
|
|
{"ec2.DeleteSubnet", d.handleEC2DeleteSubnet, "hive-workers"},
|
|
{"ec2.DescribeSubnets", d.handleEC2DescribeSubnets, "hive-workers"},
|
|
{"ec2.CreateNetworkInterface", d.handleEC2CreateNetworkInterface, "hive-workers"},
|
|
{"ec2.DeleteNetworkInterface", d.handleEC2DeleteNetworkInterface, "hive-workers"},
|
|
{"ec2.DescribeNetworkInterfaces", d.handleEC2DescribeNetworkInterfaces, "hive-workers"},
|
|
{"ec2.ModifyInstanceAttribute", d.handleEC2ModifyInstanceAttribute, "hive-workers"},
|
|
{"ec2.start", d.handleEC2StartStoppedInstance, "hive-workers"},
|
|
{"ec2.terminate", d.handleEC2TerminateStoppedInstance, "hive-workers"},
|
|
{"ec2.DescribeStoppedInstances", d.handleEC2DescribeStoppedInstances, "hive-workers"},
|
|
{"ec2.DescribeTerminatedInstances", d.handleEC2DescribeTerminatedInstances, "hive-workers"},
|
|
// these 2 fan out to all nodes and gateway aggregates the results
|
|
{"ec2.DescribeInstances", d.handleEC2DescribeInstances, ""},
|
|
{"ec2.DescribeInstanceTypes", d.handleEC2DescribeInstanceTypes, ""},
|
|
{"ec2.EnableEbsEncryptionByDefault", d.handleEC2EnableEbsEncryptionByDefault, "hive-workers"},
|
|
{"ec2.DisableEbsEncryptionByDefault", d.handleEC2DisableEbsEncryptionByDefault, "hive-workers"},
|
|
{"ec2.GetEbsEncryptionByDefault", d.handleEC2GetEbsEncryptionByDefault, "hive-workers"},
|
|
{"ec2.GetSerialConsoleAccessStatus", d.handleEC2GetSerialConsoleAccessStatus, "hive-workers"},
|
|
{"ec2.EnableSerialConsoleAccess", d.handleEC2EnableSerialConsoleAccess, "hive-workers"},
|
|
{"ec2.DisableSerialConsoleAccess", d.handleEC2DisableSerialConsoleAccess, "hive-workers"},
|
|
{fmt.Sprintf("hive.admin.%s.health", d.node), d.handleHealthCheck, ""},
|
|
{"hive.nodes.discover", d.handleNodeDiscover, ""},
|
|
{"hive.node.status", d.handleNodeStatus, ""},
|
|
{"hive.node.vms", d.handleNodeVMs, ""},
|
|
// Coordinated cluster shutdown phases (fan-out, no queue group)
|
|
{"hive.cluster.shutdown.gate", d.handleShutdownGate, ""},
|
|
{"hive.cluster.shutdown.drain", d.handleShutdownDrain, ""},
|
|
{"hive.cluster.shutdown.storage", d.handleShutdownStorage, ""},
|
|
{"hive.cluster.shutdown.persist", d.handleShutdownPersist, ""},
|
|
{"hive.cluster.shutdown.infra", d.handleShutdownInfra, ""},
|
|
}
|
|
|
|
for _, s := range subs {
|
|
var sub *nats.Subscription
|
|
var err error
|
|
if s.queueGroup != "" {
|
|
sub, err = d.natsConn.QueueSubscribe(s.topic, s.queueGroup, s.handler)
|
|
} else {
|
|
sub, err = d.natsConn.Subscribe(s.topic, s.handler)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("failed to subscribe to %s: %w", s.topic, err)
|
|
}
|
|
d.natsSubscriptions[s.topic] = sub
|
|
slog.Info("Subscribed to NATS topic", "topic", s.topic, "queue", s.queueGroup)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Start initializes and starts the daemon
|
|
func (d *Daemon) Start() error {
|
|
if err := d.connectNATS(); err != nil {
|
|
return fmt.Errorf("failed to connect to NATS: %w", err)
|
|
}
|
|
|
|
// ClusterManager must start before JetStream init so other nodes can join
|
|
// via /join endpoint and help form the NATS cluster.
|
|
if err := d.ClusterManager(); err != nil {
|
|
return fmt.Errorf("failed to start cluster manager: %w", err)
|
|
}
|
|
|
|
if err := d.initJetStream(); err != nil {
|
|
return fmt.Errorf("failed to initialize JetStream: %w", err)
|
|
}
|
|
|
|
// Write service manifest so other nodes know what this node runs
|
|
if d.jsManager != nil {
|
|
if err := d.jsManager.WriteServiceManifest(
|
|
d.node,
|
|
d.config.GetServices(),
|
|
d.config.NATS.Host,
|
|
d.config.Predastore.Host,
|
|
); err != nil {
|
|
slog.Warn("Failed to write service manifest", "error", err)
|
|
}
|
|
}
|
|
|
|
// Create services before loading/launching instances, since LaunchInstance depends on them
|
|
store := objectstore.NewS3ObjectStoreFromConfig(d.config.Predastore.Host, d.config.Predastore.Region, d.config.Predastore.AccessKey, d.config.Predastore.SecretKey)
|
|
d.instanceService = handlers_ec2_instance.NewInstanceServiceImpl(d.config, d.resourceMgr.instanceTypes, d.natsConn, &d.Instances, store)
|
|
d.keyService = handlers_ec2_key.NewKeyServiceImpl(d.config)
|
|
d.imageService = handlers_ec2_image.NewImageServiceImpl(d.config, d.natsConn)
|
|
|
|
type snapResult struct {
|
|
svc *handlers_ec2_snapshot.SnapshotServiceImpl
|
|
kv nats.KeyValue
|
|
}
|
|
snap, err := initServiceWithRetry("snapshot service", func() (snapResult, error) {
|
|
svc, kv, err := handlers_ec2_snapshot.NewSnapshotServiceImplWithNATS(d.config, d.natsConn)
|
|
return snapResult{svc, kv}, err
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize snapshot service: %w", err)
|
|
}
|
|
d.snapshotService = snap.svc
|
|
|
|
d.volumeService = handlers_ec2_volume.NewVolumeServiceImpl(d.config, d.natsConn, snap.kv)
|
|
d.tagsService = handlers_ec2_tags.NewTagsServiceImpl(d.config)
|
|
|
|
d.eigwService, err = initServiceWithRetry("EIGW service", func() (*handlers_ec2_eigw.EgressOnlyIGWServiceImpl, error) {
|
|
return handlers_ec2_eigw.NewEgressOnlyIGWServiceImplWithNATS(d.config, d.natsConn)
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize EIGW service: %w", err)
|
|
}
|
|
|
|
d.igwService, err = initServiceWithRetry("IGW service", func() (*handlers_ec2_igw.IGWServiceImpl, error) {
|
|
return handlers_ec2_igw.NewIGWServiceImplWithNATS(d.config, d.natsConn)
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize IGW service: %w", err)
|
|
}
|
|
|
|
d.vpcService, err = initServiceWithRetry("VPC service", func() (*handlers_ec2_vpc.VPCServiceImpl, error) {
|
|
return handlers_ec2_vpc.NewVPCServiceImplWithNATS(d.config, d.natsConn)
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize VPC service: %w", err)
|
|
}
|
|
|
|
d.accountService, err = initServiceWithRetry("account settings service", func() (*handlers_ec2_account.AccountSettingsServiceImpl, error) {
|
|
return handlers_ec2_account.NewAccountSettingsServiceImplWithNATS(d.config, d.natsConn)
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize account settings service: %w", err)
|
|
}
|
|
|
|
// Ensure default VPC exists (matches AWS: every account has a default VPC)
|
|
if d.vpcService != nil {
|
|
if err := d.vpcService.EnsureDefaultVPC(utils.GlobalAccountID); err != nil {
|
|
slog.Error("Failed to ensure default VPC", "error", err)
|
|
}
|
|
}
|
|
|
|
// Initialize network plumber for VPC tap device management
|
|
if d.networkPlumber == nil {
|
|
d.networkPlumber = &OVSNetworkPlumber{}
|
|
}
|
|
|
|
// Protect daemon from OOM killer (prefer killing QEMU VMs instead)
|
|
if err := utils.SetOOMScore(os.Getpid(), -500); err != nil {
|
|
slog.Warn("Failed to set daemon OOM score", "err", err)
|
|
}
|
|
|
|
d.waitForClusterReady()
|
|
d.restoreInstances()
|
|
|
|
if err := d.subscribeAll(); err != nil {
|
|
return fmt.Errorf("failed to subscribe to NATS topics: %w", err)
|
|
}
|
|
|
|
// Initialize dynamic per-instance-type subscriptions for capacity-aware routing.
|
|
// Each instance type gets its own NATS topic (ec2.RunInstances.{type}) so requests
|
|
// are only routed to nodes with available capacity.
|
|
d.resourceMgr.initSubscriptions(d.natsConn, d.handleEC2RunInstances)
|
|
|
|
d.startHeartbeat()
|
|
d.startPendingWatchdog()
|
|
|
|
d.ready.Store(true)
|
|
slog.Info("Daemon fully initialized", "node", d.node, "startupTime", time.Since(d.startTime).Round(time.Second))
|
|
|
|
d.setupShutdown()
|
|
d.awaitShutdown()
|
|
|
|
return nil
|
|
}
|
|
|
|
// connectNATS establishes a connection to the NATS server with retry and
|
|
// exponential backoff. On multi-node clusters, the local NATS server may not
|
|
// be ready immediately after daemon start (e.g. if start-dev.sh is still
|
|
// launching services). This retries for up to 5 minutes before giving up.
|
|
func (d *Daemon) connectNATS() error {
|
|
maxWait := 5 * time.Minute
|
|
retryDelay := 500 * time.Millisecond
|
|
if d.natsMaxWait > 0 {
|
|
maxWait = d.natsMaxWait
|
|
}
|
|
if d.natsRetryDelay > 0 {
|
|
retryDelay = d.natsRetryDelay
|
|
}
|
|
start := time.Now()
|
|
|
|
for {
|
|
nc, err := utils.ConnectNATS(d.config.NATS.Host, d.config.NATS.ACL.Token)
|
|
if err == nil {
|
|
d.natsConn = nc
|
|
if time.Since(start) > time.Second {
|
|
slog.Info("NATS connection established", "elapsed", time.Since(start))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
elapsed := time.Since(start)
|
|
if elapsed >= maxWait {
|
|
return fmt.Errorf("NATS connect failed after %s: %w", elapsed.Round(time.Second), err)
|
|
}
|
|
|
|
slog.Warn("NATS not ready, retrying...", "error", err, "elapsed", elapsed.Round(time.Second), "retryIn", retryDelay)
|
|
time.Sleep(retryDelay)
|
|
retryDelay = min(retryDelay*2, 10*time.Second)
|
|
}
|
|
}
|
|
|
|
// initJetStream initializes JetStream with retry/backoff and upgrades replicas
|
|
// for multi-node clusters. On multi-node clusters, JetStream requires NATS
|
|
// cluster quorum which may take several minutes if nodes start at different
|
|
// times. This retries for up to 5 minutes to allow late-joining nodes.
|
|
func (d *Daemon) initJetStream() error {
|
|
const maxWait = 5 * time.Minute
|
|
retryDelay := 500 * time.Millisecond
|
|
start := time.Now()
|
|
attempt := 0
|
|
|
|
for {
|
|
attempt++
|
|
var err error
|
|
d.jsManager, err = NewJetStreamManager(d.natsConn, 1)
|
|
if err == nil {
|
|
err = d.jsManager.InitKVBucket()
|
|
}
|
|
|
|
if err == nil {
|
|
err = d.jsManager.InitClusterStateBucket()
|
|
}
|
|
|
|
if err == nil {
|
|
err = d.jsManager.InitTerminatedInstanceBucket()
|
|
}
|
|
|
|
if err == nil {
|
|
slog.Info("JetStream KV stores initialized successfully", "replicas", 1, "attempts", attempt, "elapsed", time.Since(start).Round(time.Second))
|
|
break
|
|
}
|
|
|
|
elapsed := time.Since(start)
|
|
if elapsed >= maxWait {
|
|
return fmt.Errorf("failed to initialize JetStream after %s (%d attempts): %w", elapsed.Round(time.Second), attempt, err)
|
|
}
|
|
|
|
slog.Warn("JetStream not ready (waiting for cluster quorum)", "error", err, "attempt", attempt, "elapsed", elapsed.Round(time.Second), "retryIn", retryDelay)
|
|
time.Sleep(retryDelay)
|
|
retryDelay = min(retryDelay*2, 10*time.Second)
|
|
}
|
|
|
|
// Upgrade replicas if cluster has more than one node
|
|
clusterSize := len(d.clusterConfig.Nodes)
|
|
if clusterSize > 1 {
|
|
if err := d.jsManager.UpdateReplicas(clusterSize); err != nil {
|
|
slog.Warn("Failed to upgrade JetStream replicas on startup (other NATS nodes may not be ready)", "targetReplicas", clusterSize, "error", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// initServiceWithRetry initializes a service using the provided init function,
|
|
// retrying with exponential backoff (500ms→10s) for up to 5 minutes. During
|
|
// cluster restarts, JetStream KV may be temporarily unavailable while NATS
|
|
// routes re-establish and the cluster forms quorum.
|
|
func initServiceWithRetry[T any](name string, initFn func() (T, error)) (T, error) {
|
|
const maxWait = 5 * time.Minute
|
|
retryDelay := 500 * time.Millisecond
|
|
start := time.Now()
|
|
attempt := 0
|
|
|
|
for {
|
|
attempt++
|
|
result, err := initFn()
|
|
if err == nil {
|
|
if attempt > 1 {
|
|
slog.Info(name+" initialized successfully", "attempts", attempt, "elapsed", time.Since(start).Round(time.Second))
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
elapsed := time.Since(start)
|
|
if elapsed >= maxWait {
|
|
var zero T
|
|
return zero, fmt.Errorf("%s unavailable after %s (%d attempts): %w", name, elapsed.Round(time.Second), attempt, err)
|
|
}
|
|
|
|
slog.Warn("Failed to init "+name, "error", err, "attempt", attempt, "elapsed", elapsed.Round(time.Second))
|
|
time.Sleep(retryDelay)
|
|
retryDelay = min(retryDelay*2, 10*time.Second)
|
|
}
|
|
}
|
|
|
|
// waitForClusterReady waits until dependent infrastructure services are reachable
|
|
// before starting VM recovery. This prevents races where VMs try to mount volumes
|
|
// before viperblock/predastore are ready.
|
|
func (d *Daemon) waitForClusterReady() {
|
|
slog.Info("Waiting for cluster readiness...")
|
|
maxWait := 2 * time.Minute
|
|
start := time.Now()
|
|
interval := 2 * time.Second
|
|
|
|
for time.Since(start) < maxWait {
|
|
ready := true
|
|
var reason string
|
|
|
|
// Viperblock must be reachable (local or remote)
|
|
if ready && !d.checkViperblockReady() {
|
|
ready = false
|
|
reason = "viperblock not ready"
|
|
}
|
|
|
|
// Predastore must be reachable (local or remote)
|
|
if ready && !d.checkPredastoreReady() {
|
|
ready = false
|
|
reason = "predastore not ready"
|
|
}
|
|
|
|
if ready {
|
|
slog.Info("Cluster readiness check passed", "elapsed", time.Since(start))
|
|
return
|
|
}
|
|
|
|
slog.Debug("Cluster not ready, waiting...", "reason", reason, "elapsed", time.Since(start))
|
|
time.Sleep(interval)
|
|
}
|
|
|
|
slog.Warn("Cluster readiness timeout, proceeding with recovery anyway", "maxWait", maxWait)
|
|
}
|
|
|
|
// checkViperblockReady checks if viperblock is reachable by verifying
|
|
// the NATS connection is up (viperblock subscribes to ebs topics on NATS).
|
|
func (d *Daemon) checkViperblockReady() bool {
|
|
if d.natsConn == nil {
|
|
return false
|
|
}
|
|
return d.natsConn.IsConnected()
|
|
}
|
|
|
|
// checkPredastoreReady checks if predastore is reachable via TCP.
|
|
func (d *Daemon) checkPredastoreReady() bool {
|
|
host := d.config.Predastore.Host
|
|
if host == "" {
|
|
return true // no predastore configured, skip check
|
|
}
|
|
conn, err := net.DialTimeout("tcp", host, 3*time.Second)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
_ = conn.Close()
|
|
return true
|
|
}
|
|
|
|
// migrateInstanceToKV writes an instance to the given KV write function and removes
|
|
// it from the local instance map. Returns true if migration succeeded.
|
|
func (d *Daemon) migrateInstanceToKV(instance *vm.VM, writeFn func(string, *vm.VM) error, label string) bool {
|
|
if d.jsManager == nil {
|
|
return false
|
|
}
|
|
instance.LastNode = d.node
|
|
if err := writeFn(instance.ID, instance); err != nil {
|
|
slog.Error("Failed to migrate instance to KV",
|
|
"instance", instance.ID, "bucket", label, "err", err)
|
|
return false
|
|
}
|
|
delete(d.Instances.VMS, instance.ID)
|
|
slog.Info("Migrated instance to KV", "instance", instance.ID, "bucket", label)
|
|
return true
|
|
}
|
|
|
|
func (d *Daemon) migrateStoppedToSharedKV(instance *vm.VM) bool {
|
|
return d.migrateInstanceToKV(instance, d.jsManager.WriteStoppedInstance, "stopped")
|
|
}
|
|
|
|
func (d *Daemon) migrateTerminatedToKV(instance *vm.VM) bool {
|
|
return d.migrateInstanceToKV(instance, d.jsManager.WriteTerminatedInstance, "terminated")
|
|
}
|
|
|
|
// maxConcurrentRecovery limits how many VMs are relaunched in parallel during recovery.
|
|
const maxConcurrentRecovery = 2
|
|
|
|
// restoreInstances loads persisted VM state and re-launches instances that are
|
|
// neither terminated nor flagged as user-stopped.
|
|
func (d *Daemon) restoreInstances() {
|
|
// Check for clean shutdown marker
|
|
cleanShutdown := false
|
|
if d.jsManager != nil {
|
|
if marker, err := d.jsManager.ReadShutdownMarker(d.node); err == nil {
|
|
cleanShutdown = marker
|
|
if marker {
|
|
slog.Info("Clean shutdown marker found, trusting KV state")
|
|
_ = d.jsManager.DeleteShutdownMarker(d.node)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !cleanShutdown {
|
|
slog.Warn("No clean shutdown marker — possible crash recovery, validating QEMU PIDs carefully")
|
|
time.Sleep(3 * time.Second)
|
|
}
|
|
|
|
err := d.LoadState()
|
|
if err != nil {
|
|
slog.Warn("Failed to load state, continuing with empty state", "error", err)
|
|
return
|
|
}
|
|
|
|
slog.Info("Loaded state", "instance count", len(d.Instances.VMS))
|
|
|
|
// Ensure mutexes and QMP clients are usable after deserialization
|
|
d.Instances.Mu = sync.Mutex{}
|
|
|
|
// Phase 1: Reconnect running QEMU, finalize transitional states, collect VMs to relaunch
|
|
var toLaunch []*vm.VM
|
|
|
|
for i := range d.Instances.VMS {
|
|
d.Instances.VMS[i].EBSRequests.Mu = sync.Mutex{}
|
|
d.Instances.VMS[i].QMPClient = &qmp.QMPClient{}
|
|
|
|
instance := d.Instances.VMS[i]
|
|
|
|
if instance.Status == vm.StateTerminated {
|
|
if !d.migrateTerminatedToKV(instance) {
|
|
// KV write failed — still remove from local map. The VM is gone;
|
|
// keeping it here would make it retry forever on every restart.
|
|
delete(d.Instances.VMS, instance.ID)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if instance.Status == vm.StateStopped {
|
|
d.migrateStoppedToSharedKV(instance)
|
|
continue
|
|
}
|
|
|
|
instanceType, ok := d.resourceMgr.instanceTypes[instance.InstanceType]
|
|
if !ok && instance.InstanceType != "" {
|
|
slog.Warn("Instance type not available on this node, moving to stopped",
|
|
"instanceId", instance.ID, "instanceType", instance.InstanceType)
|
|
instance.Status = vm.StateStopped
|
|
if instance.Instance != nil {
|
|
instance.Instance.StateReason = &ec2.StateReason{}
|
|
instance.Instance.StateReason.SetCode("Server.InsufficientInstanceCapacity")
|
|
instance.Instance.StateReason.SetMessage(
|
|
fmt.Sprintf("instance type %s is not available on this node", instance.InstanceType))
|
|
}
|
|
d.migrateStoppedToSharedKV(instance)
|
|
continue
|
|
}
|
|
|
|
if ok {
|
|
slog.Info("Re-allocating resources for instance", "instanceId", instance.ID, "type", instance.InstanceType)
|
|
if err := d.resourceMgr.allocate(instanceType); err != nil {
|
|
slog.Error("Failed to re-allocate resources for instance on startup", "instanceId", instance.ID, "err", err)
|
|
}
|
|
}
|
|
|
|
// Check if QEMU process is still alive from before the restart
|
|
if d.isInstanceProcessRunning(instance) {
|
|
slog.Info("Instance QEMU process still alive, reconnecting", "instance", instance.ID)
|
|
if err := d.reconnectInstance(instance); err != nil {
|
|
slog.Error("Failed to reconnect to running instance", "instanceId", instance.ID, "err", err)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// QEMU is not running -- resolve transitional states from interrupted operations
|
|
switch instance.Status {
|
|
case vm.StateStopping, vm.StateShuttingDown:
|
|
prevStatus := instance.Status
|
|
if instance.Status == vm.StateStopping {
|
|
instance.Status = vm.StateStopped
|
|
} else {
|
|
instance.Status = vm.StateTerminated
|
|
}
|
|
slog.Info("QEMU exited during transition, finalizing state",
|
|
"instance", instance.ID, "from", prevStatus, "to", instance.Status)
|
|
|
|
if instance.Status == vm.StateStopped && d.migrateStoppedToSharedKV(instance) {
|
|
continue
|
|
}
|
|
|
|
if instance.Status == vm.StateTerminated && d.migrateTerminatedToKV(instance) {
|
|
continue
|
|
}
|
|
|
|
if err := d.WriteState(); err != nil {
|
|
slog.Error("Failed to persist state, will retry on next restart",
|
|
"instance", instance.ID, "error", err)
|
|
instance.Status = prevStatus // revert so next restart retries
|
|
}
|
|
continue
|
|
case vm.StateRunning:
|
|
// Was running but QEMU died - reset to pending so LaunchInstance can transition to running
|
|
instance.Status = vm.StatePending
|
|
// Reset LaunchTime so the pending watchdog gives a fresh timeout window.
|
|
// Without this, the stale LaunchTime from the original launch causes the
|
|
// watchdog to immediately mark the instance as failed.
|
|
now := time.Now()
|
|
if instance.Instance != nil {
|
|
instance.Instance.LaunchTime = &now
|
|
}
|
|
slog.Info("Instance was running but QEMU exited, relaunching", "instance", instance.ID)
|
|
}
|
|
|
|
toLaunch = append(toLaunch, instance)
|
|
}
|
|
|
|
// Phase 2: Relaunch crashed VMs with semaphore-based throttling
|
|
if len(toLaunch) > 0 {
|
|
slog.Info("Launching instances (recovery)", "count", len(toLaunch), "maxConcurrent", maxConcurrentRecovery)
|
|
sem := make(chan struct{}, maxConcurrentRecovery)
|
|
var wg sync.WaitGroup
|
|
|
|
for _, instance := range toLaunch {
|
|
sem <- struct{}{} // acquire
|
|
wg.Add(1)
|
|
go func(inst *vm.VM) {
|
|
defer wg.Done()
|
|
defer func() { <-sem }() // release
|
|
slog.Info("Launching instance (recovery)", "instance", inst.ID)
|
|
if err := d.LaunchInstance(inst); err != nil {
|
|
slog.Error("Failed to launch instance", "instanceId", inst.ID, "err", err)
|
|
}
|
|
}(instance)
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
// Persist state after any migrations/removals during restore
|
|
if err := d.WriteState(); err != nil {
|
|
slog.Error("Failed to persist state after restore", "error", err)
|
|
}
|
|
}
|
|
|
|
// isInstanceProcessRunning checks if the QEMU process for an instance is still alive.
|
|
func (d *Daemon) isInstanceProcessRunning(instance *vm.VM) bool {
|
|
pid, err := utils.ReadPidFile(instance.ID)
|
|
if err != nil || pid <= 0 {
|
|
return false
|
|
}
|
|
process, err := os.FindProcess(pid)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return process.Signal(syscall.Signal(0)) == nil
|
|
}
|
|
|
|
// reconnectInstance re-establishes QMP and NATS connections to a running QEMU instance
|
|
// after a daemon restart. This bypasses the state machine since recovery is not a
|
|
// normal state transition.
|
|
func (d *Daemon) reconnectInstance(instance *vm.VM) error {
|
|
if err := d.CreateQMPClient(instance); err != nil {
|
|
return fmt.Errorf("failed to reconnect QMP: %w", err)
|
|
}
|
|
|
|
d.mu.Lock()
|
|
sub, err := d.natsConn.Subscribe(fmt.Sprintf("ec2.cmd.%s", instance.ID), d.handleEC2Events)
|
|
if err != nil {
|
|
d.mu.Unlock()
|
|
if instance.QMPClient != nil && instance.QMPClient.Conn != nil {
|
|
_ = instance.QMPClient.Conn.Close()
|
|
instance.QMPClient = nil
|
|
}
|
|
return fmt.Errorf("failed to subscribe to NATS: %w", err)
|
|
}
|
|
d.natsSubscriptions[instance.ID] = sub
|
|
|
|
consoleSub, err := d.natsConn.Subscribe(fmt.Sprintf("ec2.%s.GetConsoleOutput", instance.ID), d.handleEC2GetConsoleOutput)
|
|
if err != nil {
|
|
d.mu.Unlock()
|
|
return fmt.Errorf("failed to subscribe to console output NATS: %w", err)
|
|
}
|
|
d.natsSubscriptions[instance.ID+".console"] = consoleSub
|
|
d.mu.Unlock()
|
|
|
|
instance.Status = vm.StateRunning
|
|
|
|
if err := d.WriteState(); err != nil {
|
|
return fmt.Errorf("failed to persist reconnected instance state: %w", err)
|
|
}
|
|
|
|
slog.Info("Successfully reconnected to running instance", "instance", instance.ID)
|
|
return nil
|
|
}
|
|
|
|
// awaitShutdown blocks until the daemon's shutdown wait group completes.
|
|
func (d *Daemon) awaitShutdown() {
|
|
done := make(chan struct{})
|
|
go func() {
|
|
d.shutdownWg.Wait()
|
|
close(done)
|
|
}()
|
|
<-done
|
|
}
|
|
|
|
// computeConfigHash computes SHA256 hash of the shared cluster config (excluding node-specific fields)
|
|
func (d *Daemon) computeConfigHash() (string, error) {
|
|
// Only hash the shared cluster data, not the node-specific top-level field
|
|
sharedData := types.SharedClusterData{
|
|
Epoch: d.clusterConfig.Epoch,
|
|
Version: d.clusterConfig.Version,
|
|
Nodes: d.clusterConfig.Nodes,
|
|
}
|
|
|
|
configJSON, err := json.Marshal(sharedData)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
hash := sha256.Sum256(configJSON)
|
|
return hex.EncodeToString(hash[:]), nil
|
|
}
|
|
|
|
// saveClusterConfig writes the cluster config to disk in TOML format
|
|
func (d *Daemon) saveClusterConfig() error {
|
|
if d.configPath == "" {
|
|
return fmt.Errorf("config path not set")
|
|
}
|
|
|
|
// Marshal to TOML
|
|
configTOML, err := toml.Marshal(d.clusterConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal config to TOML: %w", err)
|
|
}
|
|
|
|
// Write to config file
|
|
if err := os.WriteFile(d.configPath, configTOML, 0600); err != nil {
|
|
return fmt.Errorf("failed to write config: %w", err)
|
|
}
|
|
|
|
slog.Info("Cluster config saved", "path", d.configPath, "epoch", d.clusterConfig.Epoch)
|
|
return nil
|
|
}
|
|
|
|
// ClusterManager starts the HTTP cluster management server
|
|
func (d *Daemon) ClusterManager() error {
|
|
|
|
// Get daemon host from config
|
|
daemonHost := d.config.Daemon.Host
|
|
if daemonHost == "" {
|
|
return fmt.Errorf("daemon.host not configured")
|
|
}
|
|
|
|
r := chi.NewRouter()
|
|
|
|
// Health endpoint - responds to HTTP and NATS
|
|
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
configHash, err := d.computeConfigHash()
|
|
if err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(500)
|
|
_, _ = w.Write([]byte(`{"error":"failed to compute config hash"}`))
|
|
return
|
|
}
|
|
|
|
serviceHealth := make(map[string]string)
|
|
for _, svc := range d.config.GetServices() {
|
|
serviceHealth[svc] = "ok"
|
|
}
|
|
// For remote dependencies, check connectivity
|
|
if !d.config.HasService("nats") {
|
|
if d.natsConn != nil && d.natsConn.IsConnected() {
|
|
serviceHealth["nats"] = "remote_ok"
|
|
} else {
|
|
serviceHealth["nats"] = "remote_unreachable"
|
|
}
|
|
}
|
|
|
|
// Check OVN networking readiness
|
|
ovnHealth := CheckOVNHealth()
|
|
if ovnHealth.BrIntExists {
|
|
serviceHealth["br-int"] = "ok"
|
|
} else {
|
|
serviceHealth["br-int"] = "missing"
|
|
}
|
|
if ovnHealth.OVNControllerUp {
|
|
serviceHealth["ovn-controller"] = "ok"
|
|
} else {
|
|
serviceHealth["ovn-controller"] = "not_running"
|
|
}
|
|
|
|
status := "running"
|
|
if !d.ready.Load() {
|
|
status = "starting"
|
|
}
|
|
|
|
response := types.NodeHealthResponse{
|
|
Node: d.node,
|
|
Status: status,
|
|
ConfigHash: configHash,
|
|
Epoch: d.clusterConfig.Epoch,
|
|
Uptime: int64(time.Since(d.startTime).Seconds()),
|
|
Services: d.config.GetServices(),
|
|
ServiceHealth: serviceHealth,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(response)
|
|
})
|
|
|
|
// Join endpoint - accepts new nodes joining the cluster
|
|
r.Post("/join", func(w http.ResponseWriter, r *http.Request) {
|
|
var req types.NodeJoinRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(400)
|
|
_ = json.NewEncoder(w).Encode(types.NodeJoinResponse{
|
|
Success: false,
|
|
Message: "invalid request body",
|
|
})
|
|
return
|
|
}
|
|
|
|
slog.Info("Node join request received", "node", req.Node, "region", req.Region, "az", req.AZ)
|
|
|
|
// Validate request
|
|
if req.Node == "" || req.Region == "" || req.AZ == "" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(400)
|
|
_ = json.NewEncoder(w).Encode(types.NodeJoinResponse{
|
|
Success: false,
|
|
Message: "node, region, and az are required",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Check if node already exists
|
|
if _, exists := d.clusterConfig.Nodes[req.Node]; exists {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(409)
|
|
_ = json.NewEncoder(w).Encode(types.NodeJoinResponse{
|
|
Success: false,
|
|
Message: fmt.Sprintf("node %s already exists in cluster", req.Node),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Add new node to cluster config
|
|
d.mu.Lock()
|
|
newNodeConfig := config.Config{
|
|
Node: req.Node,
|
|
Region: req.Region,
|
|
AZ: req.AZ,
|
|
DataDir: req.DataDir,
|
|
Daemon: config.DaemonConfig{
|
|
Host: req.DaemonHost,
|
|
},
|
|
// Copy shared config from current node
|
|
NATS: d.config.NATS,
|
|
Predastore: d.config.Predastore,
|
|
AWSGW: d.config.AWSGW,
|
|
BaseDir: req.DataDir,
|
|
}
|
|
|
|
d.clusterConfig.Nodes[req.Node] = newNodeConfig
|
|
d.clusterConfig.Epoch++ // Increment epoch for version tracking
|
|
d.mu.Unlock()
|
|
|
|
// Save updated config
|
|
if err := d.saveClusterConfig(); err != nil {
|
|
slog.Error("Failed to save cluster config", "error", err)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(500)
|
|
_ = json.NewEncoder(w).Encode(types.NodeJoinResponse{
|
|
Success: false,
|
|
Message: "failed to save cluster config",
|
|
})
|
|
return
|
|
}
|
|
|
|
configHash, _ := d.computeConfigHash()
|
|
|
|
slog.Info("Node joined cluster", "node", req.Node, "epoch", d.clusterConfig.Epoch)
|
|
|
|
// Update JetStream KV replicas to match new cluster size
|
|
// This may fail if the new node's NATS server isn't running yet - that's OK,
|
|
// replicas can be updated later when the cluster is fully formed
|
|
if d.jsManager != nil {
|
|
newReplicaCount := len(d.clusterConfig.Nodes)
|
|
if err := d.jsManager.UpdateReplicas(newReplicaCount); err != nil {
|
|
slog.Warn("Failed to update JetStream replicas (new node NATS may not be ready yet)", "targetReplicas", newReplicaCount, "error", err)
|
|
}
|
|
}
|
|
|
|
// Send only shared cluster data (exclude node-specific top-level fields)
|
|
sharedData := &types.SharedClusterData{
|
|
Epoch: d.clusterConfig.Epoch,
|
|
Version: d.clusterConfig.Version,
|
|
Nodes: d.clusterConfig.Nodes,
|
|
}
|
|
|
|
// Read CA certificate and key to share with joining node for per-node cert generation
|
|
configDir := filepath.Dir(d.configPath)
|
|
caCertPath := filepath.Join(configDir, "ca.pem")
|
|
caKeyPath := filepath.Join(configDir, "ca.key")
|
|
|
|
var caCert, caKey string
|
|
if caCertPEM, err := os.ReadFile(caCertPath); err == nil {
|
|
caCert = string(caCertPEM)
|
|
} else {
|
|
slog.Warn("Could not read CA cert for join response", "error", err)
|
|
}
|
|
|
|
if caKeyPEM, err := os.ReadFile(caKeyPath); err == nil {
|
|
caKey = string(caKeyPEM)
|
|
} else {
|
|
slog.Warn("Could not read CA key for join response", "error", err)
|
|
}
|
|
|
|
// Read predastore.toml to share with joining node (for multi-node predastore)
|
|
var predastoreConfig string
|
|
predastorePath := filepath.Join(configDir, "predastore", "predastore.toml")
|
|
if content, err := os.ReadFile(predastorePath); err == nil {
|
|
predastoreConfig = string(content)
|
|
} else {
|
|
slog.Warn("Could not read predastore.toml for join response", "path", predastorePath, "error", err)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(types.NodeJoinResponse{
|
|
Success: true,
|
|
Message: fmt.Sprintf("node %s successfully joined cluster", req.Node),
|
|
SharedData: sharedData,
|
|
ConfigHash: configHash,
|
|
JoiningNode: req.Node,
|
|
CACert: caCert,
|
|
CAKey: caKey,
|
|
PredastoreConfig: predastoreConfig,
|
|
})
|
|
})
|
|
|
|
// Get cluster config endpoint
|
|
r.Get("/config", func(w http.ResponseWriter, r *http.Request) {
|
|
configHash, _ := d.computeConfigHash()
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"config": d.clusterConfig,
|
|
"config_hash": configHash,
|
|
})
|
|
})
|
|
|
|
// Start HTTP server in goroutine
|
|
d.clusterServer = &http.Server{
|
|
Addr: daemonHost,
|
|
Handler: r,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
go func() {
|
|
slog.Info("Starting cluster manager", "host", daemonHost)
|
|
if err := d.clusterServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
slog.Error("Cluster manager failed to start", "error", err)
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// WriteState writes the instance state to JetStream KV store (required).
|
|
// It acquires d.Instances.Mu internally.
|
|
func (d *Daemon) WriteState() error {
|
|
if d.jsManager == nil {
|
|
return fmt.Errorf("JetStream manager not initialized - cannot write state")
|
|
}
|
|
if err := d.jsManager.WriteState(d.node, &d.Instances); err != nil {
|
|
slog.Error("JetStream write failed", "error", err)
|
|
return fmt.Errorf("failed to write state to JetStream: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LoadState loads the instance state from JetStream KV store (required)
|
|
func (d *Daemon) LoadState() error {
|
|
if d.jsManager == nil {
|
|
return fmt.Errorf("JetStream manager not initialized - cannot load state")
|
|
}
|
|
|
|
instances, err := d.jsManager.LoadState(d.node)
|
|
if err != nil {
|
|
slog.Error("JetStream load failed", "error", err)
|
|
return fmt.Errorf("failed to load state from JetStream: %w", err)
|
|
}
|
|
|
|
// Copy only the VMS map, not the mutex
|
|
d.Instances.VMS = instances.VMS
|
|
return nil
|
|
}
|
|
|
|
func (d *Daemon) SendQMPCommand(q *qmp.QMPClient, cmd qmp.QMPCommand, instanceId string) (*qmp.QMPResponse, error) {
|
|
|
|
// Confirm QMP client is initialized
|
|
if q.Encoder == nil || q.Decoder == nil {
|
|
return nil, fmt.Errorf("QMP client is not initialized")
|
|
}
|
|
|
|
// Lock the QMP client
|
|
q.Mu.Lock()
|
|
defer q.Mu.Unlock()
|
|
|
|
if err := q.Encoder.Encode(cmd); err != nil {
|
|
return nil, fmt.Errorf("encode error: %w", err)
|
|
}
|
|
|
|
for {
|
|
var msg map[string]any
|
|
if err := q.Decoder.Decode(&msg); err != nil {
|
|
return nil, fmt.Errorf("decode error: %w", err)
|
|
}
|
|
|
|
if _, ok := msg["event"]; ok {
|
|
// QMP events are informational only — state transitions are driven
|
|
// by the command handlers that initiate the action, avoiding races
|
|
// between event-driven and command-driven transitions.
|
|
slog.Info("QMP event", "event", msg["event"], "instanceId", instanceId)
|
|
continue
|
|
}
|
|
if errObj, ok := msg["error"].(map[string]any); ok {
|
|
return nil, fmt.Errorf("QMP error: %s: %s", errObj["class"], errObj["desc"])
|
|
}
|
|
if _, ok := msg["return"]; ok {
|
|
respBytes, _ := json.Marshal(msg)
|
|
var resp qmp.QMPResponse
|
|
if err := json.Unmarshal(respBytes, &resp); err != nil {
|
|
return nil, fmt.Errorf("unmarshal error: %w", err)
|
|
}
|
|
return &resp, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func (d *Daemon) stopInstance(instances map[string]*vm.VM, deleteVolume bool) error {
|
|
|
|
// Signal to shutdown each VM
|
|
var wg sync.WaitGroup
|
|
|
|
// Run asynchronously within a worker group
|
|
for _, instance := range instances {
|
|
|
|
wg.Go(func() {
|
|
|
|
// Send shutdown command - if it fails, VM may already be dead, continue with cleanup
|
|
_, err := d.SendQMPCommand(instance.QMPClient, qmp.QMPCommand{Execute: "system_powerdown"}, instance.ID)
|
|
if err != nil {
|
|
slog.Warn("QMP system_powerdown failed (VM may already be stopped)", "id", instance.ID, "err", err)
|
|
// Don't return - continue with cleanup
|
|
}
|
|
|
|
// Wait for PID file removal (or check if already gone)
|
|
err = utils.WaitForPidFileRemoval(instance.ID, 60*time.Second)
|
|
if err != nil {
|
|
slog.Warn("Timeout waiting for PID file removal", "id", instance.ID, "err", err)
|
|
|
|
// Try force killing the process if it's still running
|
|
pid, readErr := utils.ReadPidFile(instance.ID)
|
|
if readErr != nil {
|
|
slog.Debug("No PID file found (VM likely already stopped)", "id", instance.ID)
|
|
} else {
|
|
slog.Info("Force killing process", "pid", pid, "id", instance.ID)
|
|
if err := utils.KillProcess(pid); err != nil {
|
|
slog.Error("Failed to kill process", "pid", pid, "id", instance.ID, "err", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Unmount all EBS volumes
|
|
instance.EBSRequests.Mu.Lock()
|
|
defer instance.EBSRequests.Mu.Unlock()
|
|
|
|
for _, ebsRequest := range instance.EBSRequests.Requests {
|
|
|
|
// Send the volume payload as JSON
|
|
ebsUnMountRequest, err := json.Marshal(ebsRequest)
|
|
|
|
if err != nil {
|
|
slog.Error("Failed to marshal volume payload", "err", err)
|
|
continue
|
|
}
|
|
|
|
msg, err := d.natsConn.Request(d.ebsTopic("unmount"), ebsUnMountRequest, 30*time.Second)
|
|
if err != nil {
|
|
slog.Error("Failed to unmount volume", "name", ebsRequest.Name, "id", instance.ID, "err", err)
|
|
} else {
|
|
slog.Info("Unmounted Viperblock volume", "id", instance.ID, "data", string(msg.Data))
|
|
}
|
|
|
|
// Update volume state to "available" for all user-visible volumes (boot + hot-attached)
|
|
if !ebsRequest.EFI && !ebsRequest.CloudInit {
|
|
if err := d.volumeService.UpdateVolumeState(ebsRequest.Name, "available", "", ""); err != nil {
|
|
slog.Error("Failed to update volume state to available", "volumeId", ebsRequest.Name, "err", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// If flagged for termination, clean up volumes
|
|
if deleteVolume {
|
|
for _, ebsRequest := range instance.EBSRequests.Requests {
|
|
// Internal volumes (EFI, cloud-init) are always cleaned up via ebs.delete
|
|
// to stop viperblockd processes. S3 data cleanup happens via DeleteVolume
|
|
// on the parent root volume (which deletes -efi/ and -cloudinit/ prefixes).
|
|
if ebsRequest.EFI || ebsRequest.CloudInit {
|
|
ebsDeleteData, err := json.Marshal(types.EBSDeleteRequest{Volume: ebsRequest.Name})
|
|
if err != nil {
|
|
slog.Error("Failed to marshal ebs.delete request for internal volume", "name", ebsRequest.Name, "err", err)
|
|
continue
|
|
}
|
|
deleteMsg, err := d.natsConn.Request("ebs.delete", ebsDeleteData, 30*time.Second)
|
|
if err != nil {
|
|
slog.Warn("Failed to send ebs.delete for internal volume", "name", ebsRequest.Name, "id", instance.ID, "err", err)
|
|
} else {
|
|
slog.Info("Sent ebs.delete for internal volume", "name", ebsRequest.Name, "id", instance.ID, "data", string(deleteMsg.Data))
|
|
}
|
|
continue
|
|
}
|
|
|
|
// User-visible volumes: respect DeleteOnTermination flag
|
|
if !ebsRequest.DeleteOnTermination {
|
|
slog.Info("Volume has DeleteOnTermination=false, skipping deletion", "name", ebsRequest.Name, "id", instance.ID)
|
|
continue
|
|
}
|
|
|
|
// DeleteVolume handles: NATS ebs.delete notification + S3 cleanup
|
|
// (including -efi/ and -cloudinit/ sub-prefixes)
|
|
slog.Info("Deleting volume with DeleteOnTermination=true", "name", ebsRequest.Name, "id", instance.ID)
|
|
_, err := d.volumeService.DeleteVolume(&ec2.DeleteVolumeInput{
|
|
VolumeId: &ebsRequest.Name,
|
|
}, instance.AccountID)
|
|
if err != nil {
|
|
slog.Error("Failed to delete volume on termination", "name", ebsRequest.Name, "id", instance.ID, "err", err)
|
|
} else {
|
|
slog.Info("Deleted volume on termination", "name", ebsRequest.Name, "id", instance.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up VPC tap device if present
|
|
if instance.ENIId != "" && d.networkPlumber != nil {
|
|
if err := d.networkPlumber.CleanupTapDevice(instance.ENIId); err != nil {
|
|
slog.Warn("Failed to clean up tap device", "eni", instance.ENIId, "err", err)
|
|
}
|
|
}
|
|
|
|
// On termination, delete the auto-created ENI (releases IP back to IPAM,
|
|
// publishes vpc.delete-port for vpcd). On stop, ENI persists (AWS behavior).
|
|
if deleteVolume && instance.ENIId != "" && d.vpcService != nil {
|
|
_, eniErr := d.vpcService.DeleteNetworkInterface(&ec2.DeleteNetworkInterfaceInput{
|
|
NetworkInterfaceId: &instance.ENIId,
|
|
}, instance.AccountID)
|
|
if eniErr != nil {
|
|
slog.Error("Failed to delete ENI on termination", "eni", instance.ENIId, "err", eniErr)
|
|
} else {
|
|
slog.Info("Deleted ENI on termination", "eni", instance.ENIId, "instanceId", instance.ID)
|
|
}
|
|
}
|
|
|
|
// Deallocate resources
|
|
instanceType := d.resourceMgr.instanceTypes[instance.InstanceType]
|
|
if instanceType != nil {
|
|
slog.Info("Deallocating resources for stopped instance", "instanceId", instance.ID, "type", instance.InstanceType)
|
|
d.resourceMgr.deallocate(instanceType)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Wait for all shutdowns to finish
|
|
wg.Wait()
|
|
|
|
// Only unsubscribe from NATS subjects when terminating (deleteVolume=true)
|
|
// For stop operations, keep the subscription so we can receive start commands
|
|
if deleteVolume {
|
|
for _, instance := range instances {
|
|
d.mu.Lock()
|
|
if sub, ok := d.natsSubscriptions[instance.ID]; ok {
|
|
slog.Info("Unsubscribing from NATS subject", "instance", instance.ID)
|
|
if err := sub.Unsubscribe(); err != nil {
|
|
slog.Error("Failed to unsubscribe from NATS subject", "instance", instance.ID, "err", err)
|
|
}
|
|
delete(d.natsSubscriptions, instance.ID)
|
|
}
|
|
consoleSubKey := instance.ID + ".console"
|
|
if sub, ok := d.natsSubscriptions[consoleSubKey]; ok {
|
|
if err := sub.Unsubscribe(); err != nil {
|
|
slog.Error("Failed to unsubscribe from console NATS subject", "instance", instance.ID, "err", err)
|
|
}
|
|
delete(d.natsSubscriptions, consoleSubKey)
|
|
}
|
|
d.mu.Unlock()
|
|
}
|
|
}
|
|
return nil
|
|
|
|
}
|
|
|
|
func (d *Daemon) setupShutdown() {
|
|
d.shutdownWg.Go(func() {
|
|
|
|
sigChan := make(chan os.Signal, 1)
|
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
|
|
|
<-sigChan
|
|
slog.Info("Received shutdown signal, cleaning up...")
|
|
|
|
// Cancel context to stop heartbeat and other goroutines
|
|
d.cancel()
|
|
|
|
// If coordinated shutdown already handled VMs (DRAIN phase), skip stopInstance
|
|
if d.shuttingDown.Load() {
|
|
slog.Info("Coordinated shutdown in progress, skipping VM stop (already handled by DRAIN phase)")
|
|
} else {
|
|
// Pass instances to terminate
|
|
if err := d.stopInstance(d.Instances.VMS, false); err != nil {
|
|
slog.Error("Failed to stop instances during shutdown", "err", err)
|
|
}
|
|
}
|
|
|
|
// Final cleanup
|
|
for _, sub := range d.natsSubscriptions {
|
|
// Unsubscribe from each subscription
|
|
slog.Info("Unsubscribing from NATS", "subject", sub.Subject)
|
|
if err := sub.Unsubscribe(); err != nil {
|
|
slog.Error("Error unsubscribing from NATS", "err", err)
|
|
}
|
|
|
|
}
|
|
|
|
// Write shutdown marker to cluster state KV
|
|
if d.jsManager != nil {
|
|
if err := d.jsManager.WriteShutdownMarker(d.node); err != nil {
|
|
slog.Error("Failed to write shutdown marker", "err", err)
|
|
}
|
|
}
|
|
|
|
// Write state to JetStream before closing NATS connection
|
|
err := d.WriteState()
|
|
if err != nil {
|
|
slog.Error("Failed to write state", "err", err)
|
|
}
|
|
|
|
// Close NATS connection
|
|
d.natsConn.Close()
|
|
|
|
// Shutdown cluster manager
|
|
if d.clusterServer != nil {
|
|
slog.Info("Shutting down cluster manager...")
|
|
if err := d.clusterServer.Shutdown(context.Background()); err != nil {
|
|
slog.Error("Error shutting down cluster manager", "err", err)
|
|
}
|
|
}
|
|
|
|
slog.Info("Shutdown complete")
|
|
})
|
|
}
|
|
|
|
func (d *Daemon) CreateQMPClient(instance *vm.VM) (err error) {
|
|
|
|
// Create a new QMP client to communicate with the instance
|
|
instance.QMPClient, err = qmp.NewQMPClient(instance.Config.QMPSocket)
|
|
|
|
if err != nil {
|
|
slog.Error("Could not connect to QMP")
|
|
return err
|
|
}
|
|
|
|
// Send qmp_capabilities handshake to init
|
|
_, err = d.SendQMPCommand(instance.QMPClient, qmp.QMPCommand{Execute: "qmp_capabilities"}, instance.ID)
|
|
|
|
// Simple heartbeat to confirm QMP and the instance is running / healthy
|
|
go func() {
|
|
for {
|
|
time.Sleep(30 * time.Second)
|
|
|
|
// Check if instance is in a terminal or transitional state - exit heartbeat
|
|
d.Instances.Mu.Lock()
|
|
status := instance.Status
|
|
d.Instances.Mu.Unlock()
|
|
|
|
if status == vm.StateStopping || status == vm.StateStopped || status == vm.StateShuttingDown || status == vm.StateTerminated || status == vm.StateError {
|
|
slog.Info("QMP heartbeat exiting - instance not running", "instance", instance.ID, "status", status)
|
|
|
|
// Close the QMP client connection if it exists
|
|
if instance.QMPClient != nil && instance.QMPClient.Conn != nil {
|
|
if err := instance.QMPClient.Conn.Close(); err != nil {
|
|
slog.Error("Failed to close QMP connection", "instance", instance.ID, "err", err)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
slog.Debug("QMP heartbeat", "instance", instance.ID)
|
|
qmpStatus, err := d.SendQMPCommand(instance.QMPClient, qmp.QMPCommand{Execute: "query-status"}, instance.ID)
|
|
|
|
if err != nil {
|
|
slog.Warn("QMP heartbeat failed", "instance", instance.ID, "err", err)
|
|
// Don't exit on transient errors - let the status check above handle terminal states
|
|
continue
|
|
}
|
|
|
|
slog.Debug("QMP status", "instance", instance.ID, "status", string(qmpStatus.Return))
|
|
}
|
|
}()
|
|
|
|
if err != nil {
|
|
slog.Error("Failed to create QMP client", "err", err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
func (d *Daemon) LaunchInstance(instance *vm.VM) (err error) {
|
|
|
|
// First, confirm if the instance is already running
|
|
pid, err := utils.ReadPidFile(instance.ID)
|
|
|
|
if pid > 0 {
|
|
process, err := os.FindProcess(pid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Send a 0 signal to confirm process is running
|
|
err = process.Signal(syscall.Signal(0))
|
|
if err == nil {
|
|
slog.Error("Instance is already running", "InstanceID", instance.ID, "pid", pid)
|
|
return errors.New("instance is already running")
|
|
}
|
|
}
|
|
|
|
// Loop through each volume in volumes
|
|
err = d.MountVolumes(instance)
|
|
|
|
if err != nil {
|
|
slog.Error("Failed to mount volumes", "err", err)
|
|
return err
|
|
}
|
|
|
|
// Step 6: Launch the instance via QEMU/KVM
|
|
err = d.StartInstance(instance)
|
|
|
|
if err != nil {
|
|
slog.Error("Failed to launch instance", "err", err)
|
|
return err
|
|
}
|
|
|
|
// Step 7: Create QMP client to communicate with the instance
|
|
err = d.CreateQMPClient(instance)
|
|
|
|
if err != nil {
|
|
slog.Error("Failed to create QMP client", "err", err)
|
|
return err
|
|
}
|
|
|
|
// Step 8: Subscribe to start/stop/shutdown events
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
// Unsubscribe any existing subscriptions (e.g. from restoreInstances for stopped instances)
|
|
if existing, ok := d.natsSubscriptions[instance.ID]; ok {
|
|
_ = existing.Unsubscribe()
|
|
}
|
|
consoleSubKey := instance.ID + ".console"
|
|
if existing, ok := d.natsSubscriptions[consoleSubKey]; ok {
|
|
_ = existing.Unsubscribe()
|
|
}
|
|
|
|
d.natsSubscriptions[instance.ID], err = d.natsConn.Subscribe(fmt.Sprintf("ec2.cmd.%s", instance.ID), d.handleEC2Events)
|
|
if err != nil {
|
|
slog.Error("failed to subscribe to NATS", "err", err)
|
|
return err
|
|
}
|
|
|
|
d.natsSubscriptions[consoleSubKey], err = d.natsConn.Subscribe(fmt.Sprintf("ec2.%s.GetConsoleOutput", instance.ID), d.handleEC2GetConsoleOutput)
|
|
if err != nil {
|
|
slog.Error("failed to subscribe to console output NATS topic", "err", err)
|
|
return err
|
|
}
|
|
|
|
// Step 9: Update the instance metadata for running state and volume attached
|
|
d.Instances.Mu.Lock()
|
|
d.Instances.VMS[instance.ID] = instance
|
|
d.Instances.Mu.Unlock()
|
|
|
|
if err := d.TransitionState(instance, vm.StateRunning); err != nil {
|
|
slog.Error("Failed to transition instance to running", "instanceId", instance.ID, "err", err)
|
|
return err
|
|
}
|
|
|
|
// Step 10: Mark boot volumes as "in-use" now that instance is confirmed running
|
|
instance.EBSRequests.Mu.Lock()
|
|
for _, ebsReq := range instance.EBSRequests.Requests {
|
|
if ebsReq.Boot {
|
|
if err := d.volumeService.UpdateVolumeState(ebsReq.Name, "in-use", instance.ID, ""); err != nil {
|
|
slog.Error("Failed to update volume state to in-use", "volumeId", ebsReq.Name, "err", err)
|
|
}
|
|
}
|
|
}
|
|
instance.EBSRequests.Mu.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
// markInstanceFailed updates an instance status to indicate a failure during launch
|
|
func (d *Daemon) markInstanceFailed(instance *vm.VM, reason string) {
|
|
// Set state reason before transition (requires lock)
|
|
d.Instances.Mu.Lock()
|
|
if instance.Instance != nil {
|
|
instance.Instance.StateReason = &ec2.StateReason{}
|
|
instance.Instance.StateReason.SetCode("Server.InternalError")
|
|
instance.Instance.StateReason.SetMessage(reason)
|
|
}
|
|
d.Instances.Mu.Unlock()
|
|
|
|
if err := d.TransitionState(instance, vm.StateShuttingDown); err != nil {
|
|
slog.Error("markInstanceFailed transition failed", "instanceId", instance.ID, "err", err)
|
|
}
|
|
|
|
slog.Info("Instance marked as failed", "instanceId", instance.ID, "reason", reason)
|
|
}
|
|
|
|
const pendingWatchdogInterval = 60 * time.Second
|
|
const pendingWatchdogTimeout = 5 * time.Minute
|
|
|
|
// startPendingWatchdog runs a background goroutine that periodically checks for
|
|
// instances stuck in pending/provisioning beyond a timeout and marks them failed.
|
|
func (d *Daemon) startPendingWatchdog() {
|
|
ticker := time.NewTicker(pendingWatchdogInterval)
|
|
go func() {
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-d.ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
d.Instances.Mu.Lock()
|
|
var stuck []*vm.VM
|
|
for _, instance := range d.Instances.VMS {
|
|
if (instance.Status == vm.StatePending || instance.Status == vm.StateProvisioning) &&
|
|
instance.Instance != nil && instance.Instance.LaunchTime != nil &&
|
|
time.Since(*instance.Instance.LaunchTime) > pendingWatchdogTimeout {
|
|
stuck = append(stuck, instance)
|
|
}
|
|
}
|
|
d.Instances.Mu.Unlock()
|
|
|
|
for _, instance := range stuck {
|
|
slog.Warn("Instance stuck in pending, marking failed",
|
|
"instanceId", instance.ID, "status", instance.Status,
|
|
"elapsed", time.Since(*instance.Instance.LaunchTime))
|
|
d.markInstanceFailed(instance, "launch_timeout")
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (d *Daemon) StartInstance(instance *vm.VM) error {
|
|
|
|
pidFile, err := utils.GeneratePidFile(instance.ID)
|
|
|
|
if err != nil {
|
|
slog.Error("Failed to generate PID file", "err", err)
|
|
return err
|
|
}
|
|
|
|
instanceType := d.resourceMgr.instanceTypes[instance.InstanceType]
|
|
if instanceType == nil {
|
|
return fmt.Errorf("instance type %s not found", instance.InstanceType)
|
|
}
|
|
|
|
vCPUs := int(instanceTypeVCPUs(instanceType))
|
|
memoryMiB := instanceTypeMemoryMiB(instanceType)
|
|
architecture := "x86_64"
|
|
if instanceType.ProcessorInfo != nil && len(instanceType.ProcessorInfo.SupportedArchitectures) > 0 && instanceType.ProcessorInfo.SupportedArchitectures[0] != nil {
|
|
architecture = *instanceType.ProcessorInfo.SupportedArchitectures[0]
|
|
}
|
|
|
|
// Console log + serial socket paths (serial output capture + admin access via socat)
|
|
runtimeDir := utils.RuntimeDir()
|
|
consoleLogPath := filepath.Join(runtimeDir, fmt.Sprintf("console-%s.log", instance.ID))
|
|
serialSocket := filepath.Join(runtimeDir, fmt.Sprintf("serial-%s.sock", instance.ID))
|
|
|
|
instance.Config = vm.Config{
|
|
Name: instance.ID,
|
|
PIDFile: pidFile,
|
|
EnableKVM: true, // If available, if kvm fails, will use cpu max
|
|
NoGraphic: true,
|
|
MachineType: "q35",
|
|
ConsoleLogPath: consoleLogPath,
|
|
SerialSocket: serialSocket,
|
|
CPUType: "host", // If available, if kvm fails, will use cpu max
|
|
Memory: int(memoryMiB),
|
|
CPUCount: vCPUs,
|
|
Architecture: architecture,
|
|
}
|
|
|
|
// Add PCIe root ports for volume hotplug (Q35 requires explicit root ports).
|
|
// 11 ports for /dev/sd[f-p] hotplug slots, starting at chassis 1.
|
|
for i := 1; i <= 11; i++ {
|
|
instance.Config.Devices = append(instance.Config.Devices, vm.Device{
|
|
Value: fmt.Sprintf("pcie-root-port,id=hotplug%d,chassis=%d,slot=0", i, i),
|
|
})
|
|
}
|
|
|
|
// Loop through each volume in volumes
|
|
instance.EBSRequests.Mu.Lock()
|
|
|
|
for _, v := range instance.EBSRequests.Requests {
|
|
|
|
drive := vm.Drive{}
|
|
|
|
// Use the NBDURI from mount response - contains socket path or TCP address
|
|
// NBDURI format: "nbd:unix:/path/to/socket.sock" or "nbd://host:port"
|
|
if v.NBDURI == "" {
|
|
slog.Error("NBDURI not set for volume", "volume", v.Name)
|
|
return fmt.Errorf("NBDURI not set for volume %s - was volume mounted?", v.Name)
|
|
}
|
|
drive.File = v.NBDURI
|
|
slog.Info("Using NBD URI for drive", "volume", v.Name, "uri", v.NBDURI)
|
|
|
|
if v.Boot {
|
|
drive.Format = "raw"
|
|
drive.If = "none"
|
|
drive.Media = "disk"
|
|
drive.ID = "os"
|
|
drive.Cache = "none"
|
|
|
|
iothreadID := "ioth-os"
|
|
instance.Config.IOThreads = append(instance.Config.IOThreads, vm.IOThread{ID: iothreadID})
|
|
|
|
instance.Config.Devices = append(instance.Config.Devices, vm.Device{
|
|
Value: fmt.Sprintf("virtio-blk-pci,drive=%s,iothread=%s,num-queues=%d,bootindex=1",
|
|
drive.ID, iothreadID, instance.Config.CPUCount),
|
|
})
|
|
}
|
|
|
|
if v.CloudInit {
|
|
drive.Format = "raw"
|
|
drive.If = "virtio"
|
|
drive.Media = "cdrom"
|
|
drive.ID = "cloudinit"
|
|
}
|
|
|
|
// TODO: Add EFI support
|
|
if v.EFI {
|
|
continue
|
|
}
|
|
|
|
instance.Config.Drives = append(instance.Config.Drives, drive)
|
|
}
|
|
instance.EBSRequests.Mu.Unlock()
|
|
|
|
// VPC tap networking vs user-mode fallback
|
|
if instance.ENIId != "" && d.networkPlumber != nil {
|
|
// VPC mode: create tap device and add to OVS br-int
|
|
if err := d.networkPlumber.SetupTapDevice(instance.ENIId, instance.ENIMac); err != nil {
|
|
slog.Error("Failed to set up tap device", "eni", instance.ENIId, "err", err)
|
|
return fmt.Errorf("setup tap device: %w", err)
|
|
}
|
|
|
|
tapName := TapDeviceName(instance.ENIId)
|
|
instance.Config.NetDevs = append(instance.Config.NetDevs, vm.NetDev{
|
|
Value: fmt.Sprintf("tap,id=net0,ifname=%s,script=no,downscript=no", tapName),
|
|
})
|
|
instance.Config.Devices = append(instance.Config.Devices, vm.Device{
|
|
Value: fmt.Sprintf("virtio-net-pci,netdev=net0,mac=%s", instance.ENIMac),
|
|
})
|
|
|
|
slog.Info("VPC networking configured", "tap", tapName, "eni", instance.ENIId, "mac", instance.ENIMac)
|
|
|
|
// DEV_NETWORKING: add a second NIC with hostfwd for SSH dev access
|
|
if d.config.Daemon.DevNetworking {
|
|
sshDebugPort, err := viperblock.FindFreePort()
|
|
if err != nil {
|
|
slog.Warn("DEV_NETWORKING: failed to find free port for dev NIC", "err", err)
|
|
} else {
|
|
_, sshDebugPort, _ = net.SplitHostPort(sshDebugPort)
|
|
bindIP := d.config.Host
|
|
if bindIP == "" || bindIP == "0.0.0.0" {
|
|
bindIP = "127.0.0.1"
|
|
}
|
|
instance.Config.NetDevs = append(instance.Config.NetDevs, vm.NetDev{
|
|
Value: fmt.Sprintf("user,id=dev0,hostfwd=tcp:%s:%s-:22", bindIP, sshDebugPort),
|
|
})
|
|
devMac := generateDevMAC(instance.ID)
|
|
instance.Config.Devices = append(instance.Config.Devices, vm.Device{
|
|
Value: fmt.Sprintf("virtio-net-pci,netdev=dev0,mac=%s", devMac),
|
|
})
|
|
slog.Info("DEV_NETWORKING: added dev NIC with SSH hostfwd",
|
|
"bindIP", bindIP, "port", sshDebugPort, "mac", devMac, "instanceId", instance.ID)
|
|
}
|
|
}
|
|
} else {
|
|
// Non-VPC fallback: user-mode networking with SSH port forwarding
|
|
sshDebugPort, err := viperblock.FindFreePort()
|
|
if err != nil {
|
|
slog.Error("Failed to find free port", "err", err)
|
|
return err
|
|
}
|
|
_, sshDebugPort, _ = net.SplitHostPort(sshDebugPort)
|
|
|
|
bindIP := d.config.Host
|
|
if bindIP == "" || bindIP == "0.0.0.0" {
|
|
bindIP = "127.0.0.1"
|
|
}
|
|
instance.Config.NetDevs = append(instance.Config.NetDevs, vm.NetDev{
|
|
Value: fmt.Sprintf("user,id=net0,hostfwd=tcp:%s:%s-:22", bindIP, sshDebugPort),
|
|
})
|
|
instance.Config.Devices = append(instance.Config.Devices, vm.Device{
|
|
Value: "virtio-net-pci,netdev=net0",
|
|
})
|
|
}
|
|
|
|
instance.Config.Devices = append(instance.Config.Devices, vm.Device{
|
|
Value: "virtio-rng-pci",
|
|
})
|
|
|
|
// QMP socket
|
|
qmpSocket, err := utils.GenerateSocketFile(fmt.Sprintf("qmp-%s", instance.ID))
|
|
|
|
if err != nil {
|
|
slog.Error("Failed to generate QMP socket", "err", err)
|
|
return err
|
|
}
|
|
|
|
instance.Config.QMPSocket = qmpSocket
|
|
|
|
// Temp, wait for nbdkit to start
|
|
// TODO: Improve, confirm nbdkit started for each volume
|
|
time.Sleep(2 * time.Second)
|
|
|
|
// Create a unique error channel for this specific mount request
|
|
processChan := make(chan int, 1)
|
|
exitChan := make(chan int, 1)
|
|
startupConfirmed := make(chan bool, 1)
|
|
|
|
go func() {
|
|
cmd, err := instance.Config.Execute()
|
|
|
|
if err != nil {
|
|
slog.Error("Failed to execute VM", "err", err)
|
|
processChan <- 0
|
|
return
|
|
}
|
|
|
|
VMstdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
slog.Error("Failed to pipe STDERR VM", "err", err)
|
|
processChan <- 0
|
|
return
|
|
}
|
|
|
|
VMstderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
slog.Error("Failed to pipe STDERR VM", "err", err)
|
|
processChan <- 0
|
|
return
|
|
}
|
|
|
|
err = cmd.Start()
|
|
|
|
if err != nil {
|
|
slog.Error("Failed to start VM", "err", err)
|
|
processChan <- 0
|
|
return
|
|
}
|
|
|
|
slog.Info("VM started successfully", "pid", cmd.Process.Pid)
|
|
|
|
// Set OOM score for QEMU process (prefer killing VMs over system services)
|
|
if err := utils.SetOOMScore(cmd.Process.Pid, 500); err != nil {
|
|
slog.Warn("Failed to set QEMU OOM score", "pid", cmd.Process.Pid, "err", err)
|
|
}
|
|
|
|
// Log QEMU stdout (serial output is captured via chardev logfile, not stdout)
|
|
go func() {
|
|
scanner := bufio.NewScanner(VMstdout)
|
|
for scanner.Scan() {
|
|
slog.Info("[qemu]", "line", scanner.Text())
|
|
}
|
|
}()
|
|
|
|
// --- reader for STDERR ---
|
|
go func() {
|
|
scanner := bufio.NewScanner(VMstderr)
|
|
slog.Info("QEMU stderr reader started")
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
slog.Error("[qemu-stderr]", "line", line)
|
|
}
|
|
}()
|
|
|
|
processChan <- cmd.Process.Pid
|
|
|
|
// Block until QEMU exits
|
|
waitErr := cmd.Wait()
|
|
|
|
if waitErr != nil {
|
|
slog.Error("VM process exited", "instance", instance.ID, "err", waitErr)
|
|
}
|
|
|
|
// Signal startup check (non-blocking)
|
|
select {
|
|
case exitChan <- 1:
|
|
default:
|
|
}
|
|
|
|
// Wait for startup phase to complete before deciding on crash handling
|
|
confirmed := <-startupConfirmed
|
|
if !confirmed {
|
|
return // Startup failed, LaunchInstance handles the error
|
|
}
|
|
|
|
// Handle exit: crash vs clean shutdown
|
|
if waitErr != nil {
|
|
d.handleInstanceCrash(instance, waitErr)
|
|
} else {
|
|
slog.Info("VM process exited cleanly", "instance", instance.ID)
|
|
}
|
|
}()
|
|
|
|
// Wait for startup result
|
|
pid := <-processChan
|
|
|
|
if pid == 0 {
|
|
return fmt.Errorf("failed to start qemu")
|
|
}
|
|
|
|
// Wait for 1 second to confirm nbdkit is running
|
|
time.Sleep(1 * time.Second)
|
|
|
|
// Check if QEMU exited immediately with an error
|
|
select {
|
|
case exitErr := <-exitChan:
|
|
startupConfirmed <- false // tell goroutine not to handle crash
|
|
if exitErr != 0 {
|
|
errorMsg := fmt.Errorf("failed: %v", exitErr)
|
|
slog.Error("Failed to launch qemu", "err", errorMsg)
|
|
return errorMsg
|
|
}
|
|
default:
|
|
startupConfirmed <- true // goroutine will handle future crashes
|
|
slog.Info("QEMU started successfully and is running",
|
|
"console_log", instance.Config.ConsoleLogPath,
|
|
"serial_socket", instance.Config.SerialSocket)
|
|
}
|
|
|
|
// Confirm the instance has booted
|
|
_, err = utils.ReadPidFile(instance.ID)
|
|
|
|
if err != nil {
|
|
slog.Error("Failed to read PID file", "err", err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ebsTopic returns a node-specific EBS NATS topic, e.g. "ebs.node1.mount".
|
|
// This ensures mount/unmount requests are routed to the viperblock instance
|
|
// running on the same node as the daemon (NBD sockets are local).
|
|
func (d *Daemon) ebsTopic(action string) string {
|
|
return fmt.Sprintf("ebs.%s.%s", d.node, action)
|
|
}
|
|
|
|
// MountVolumes mounts the volumes for an instance
|
|
func (d *Daemon) MountVolumes(instance *vm.VM) error {
|
|
|
|
instance.EBSRequests.Mu.Lock()
|
|
for k, v := range instance.EBSRequests.Requests {
|
|
|
|
// Send the volume payload as JSON
|
|
ebsMountRequest, err := json.Marshal(v)
|
|
|
|
if err != nil {
|
|
slog.Error("Failed to marshal volume payload", "err", err)
|
|
return err
|
|
}
|
|
|
|
reply, err := d.natsConn.Request(d.ebsTopic("mount"), ebsMountRequest, 30*time.Second)
|
|
|
|
slog.Info("Mounting volume", "Vol", v.Name, "NBDURI", v.NBDURI)
|
|
|
|
// TODO: Improve timeout handling
|
|
if err != nil {
|
|
slog.Error("Failed to request EBS mount", "err", err)
|
|
return err
|
|
}
|
|
|
|
// Unmarshal the response
|
|
var ebsMountResponse types.EBSMountResponse
|
|
err = json.Unmarshal(reply.Data, &ebsMountResponse)
|
|
|
|
if err != nil {
|
|
slog.Error("Failed to unmarshal volume response:", "err", err)
|
|
return err
|
|
}
|
|
|
|
if ebsMountResponse.Error == "" {
|
|
|
|
slog.Debug("Mounted volume successfully", "response", ebsMountResponse.URI)
|
|
|
|
// Append the NBD URI to the request
|
|
instance.EBSRequests.Requests[k].NBDURI = ebsMountResponse.URI
|
|
|
|
} else {
|
|
slog.Error("Failed to mount volume", "error", ebsMountResponse.Error)
|
|
return fmt.Errorf("failed to mount volume: %s", ebsMountResponse.Error)
|
|
}
|
|
|
|
}
|
|
|
|
instance.EBSRequests.Mu.Unlock()
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
// rollbackEBSMount sends an ebs.unmount request to undo a previously successful ebs.mount.
|
|
// Rollback failures are logged but not propagated; callers treat this as best-effort cleanup.
|
|
func (d *Daemon) rollbackEBSMount(req types.EBSRequest) {
|
|
data, err := json.Marshal(req)
|
|
if err != nil {
|
|
slog.Error("rollbackEBSMount: failed to marshal unmount request", "volume", req.Name, "err", err)
|
|
return
|
|
}
|
|
msg, err := d.natsConn.Request(d.ebsTopic("unmount"), data, 10*time.Second)
|
|
if err != nil {
|
|
slog.Error("rollbackEBSMount: ebs.unmount NATS request failed", "volume", req.Name, "err", err)
|
|
return
|
|
}
|
|
var resp types.EBSUnMountResponse
|
|
if err := json.Unmarshal(msg.Data, &resp); err != nil {
|
|
slog.Error("rollbackEBSMount: failed to unmarshal response", "volume", req.Name, "err", err)
|
|
return
|
|
}
|
|
if resp.Error != "" {
|
|
slog.Error("rollbackEBSMount: ebs.unmount returned error", "volume", req.Name, "err", resp.Error)
|
|
return
|
|
}
|
|
if resp.Mounted {
|
|
slog.Error("rollbackEBSMount: volume still mounted after unmount", "volume", req.Name)
|
|
return
|
|
}
|
|
slog.Info("rollbackEBSMount: volume unmounted successfully", "volume", req.Name)
|
|
}
|
|
|
|
// respondWithVolumeAttachment builds an ec2.VolumeAttachment, marshals it to JSON, and
|
|
// responds on the NATS message. Used by both AttachVolume and DetachVolume handlers.
|
|
func (d *Daemon) respondWithVolumeAttachment(msg *nats.Msg, volumeID, instanceID, device, state string) {
|
|
attachment := ec2.VolumeAttachment{
|
|
VolumeId: aws.String(volumeID),
|
|
InstanceId: aws.String(instanceID),
|
|
Device: aws.String(device),
|
|
State: aws.String(state),
|
|
AttachTime: aws.Time(time.Now()),
|
|
DeleteOnTermination: aws.Bool(false),
|
|
}
|
|
|
|
jsonResp, err := json.Marshal(attachment)
|
|
if err != nil {
|
|
slog.Error("Failed to marshal VolumeAttachment response", "err", err)
|
|
respondWithError(msg, awserrors.ErrorServerInternal)
|
|
return
|
|
}
|
|
|
|
if err := msg.Respond(jsonResp); err != nil {
|
|
slog.Error("Failed to respond to NATS request", "err", err)
|
|
}
|
|
}
|
|
|
|
// nextAvailableDevice finds the next available /dev/sd[f-p] device name for an instance.
|
|
// It checks both EBSRequests and BlockDeviceMappings to avoid conflicts.
|
|
func nextAvailableDevice(instance *vm.VM) string {
|
|
usedDevices := make(map[string]bool)
|
|
|
|
// Collect devices from existing BlockDeviceMappings
|
|
if instance.Instance != nil {
|
|
for _, bdm := range instance.Instance.BlockDeviceMappings {
|
|
if bdm.DeviceName != nil {
|
|
usedDevices[*bdm.DeviceName] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collect devices from EBSRequests (may not yet be in BlockDeviceMappings)
|
|
instance.EBSRequests.Mu.Lock()
|
|
for _, req := range instance.EBSRequests.Requests {
|
|
if req.DeviceName != "" {
|
|
usedDevices[req.DeviceName] = true
|
|
}
|
|
}
|
|
instance.EBSRequests.Mu.Unlock()
|
|
|
|
// AWS convention: /dev/sd[f-p] for attached volumes
|
|
for c := 'f'; c <= 'p'; c++ {
|
|
dev := fmt.Sprintf("/dev/sd%c", c)
|
|
if !usedDevices[dev] {
|
|
return dev
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// canAllocate checks how many instances of the given type can be allocated.
|
|
// Returns the count that can actually be allocated (0 to count).
|
|
func (rm *ResourceManager) canAllocate(instanceType *ec2.InstanceTypeInfo, count int) int {
|
|
rm.mu.RLock()
|
|
defer rm.mu.RUnlock()
|
|
|
|
return canAllocateCount(
|
|
rm.availableVCPU, rm.allocatedVCPU,
|
|
rm.availableMem, rm.allocatedMem,
|
|
instanceTypeVCPUs(instanceType),
|
|
instanceTypeMemoryMiB(instanceType),
|
|
count,
|
|
)
|
|
}
|
|
|
|
// allocate reserves resources for an instance and updates NATS subscriptions
|
|
func (rm *ResourceManager) allocate(instanceType *ec2.InstanceTypeInfo) error {
|
|
if rm.canAllocate(instanceType, 1) < 1 {
|
|
instanceTypeName := ""
|
|
if instanceType.InstanceType != nil {
|
|
instanceTypeName = *instanceType.InstanceType
|
|
}
|
|
return fmt.Errorf("insufficient resources for instance type %s", instanceTypeName)
|
|
}
|
|
|
|
rm.mu.Lock()
|
|
vCPUs := instanceTypeVCPUs(instanceType)
|
|
memoryGB := float64(instanceTypeMemoryMiB(instanceType)) / 1024.0
|
|
rm.allocatedVCPU += int(vCPUs)
|
|
rm.allocatedMem += memoryGB
|
|
rm.mu.Unlock()
|
|
|
|
rm.updateInstanceSubscriptions()
|
|
return nil
|
|
}
|
|
|
|
// deallocate releases resources for an instance and updates NATS subscriptions
|
|
func (rm *ResourceManager) deallocate(instanceType *ec2.InstanceTypeInfo) {
|
|
rm.mu.Lock()
|
|
vCPUs := instanceTypeVCPUs(instanceType)
|
|
memoryGB := float64(instanceTypeMemoryMiB(instanceType)) / 1024.0
|
|
rm.allocatedVCPU -= int(vCPUs)
|
|
rm.allocatedMem -= memoryGB
|
|
rm.mu.Unlock()
|
|
|
|
rm.updateInstanceSubscriptions()
|
|
}
|
|
|
|
// initSubscriptions sets up dynamic per-instance-type NATS subscriptions.
|
|
// Called once during daemon startup after NATS is connected.
|
|
func (rm *ResourceManager) initSubscriptions(nc *nats.Conn, handler nats.MsgHandler) {
|
|
rm.natsConn = nc
|
|
rm.handler = handler
|
|
rm.instanceSubs = make(map[string]*nats.Subscription)
|
|
rm.updateInstanceSubscriptions()
|
|
}
|
|
|
|
// updateInstanceSubscriptions recalculates which instance types can fit on this
|
|
// node and subscribes/unsubscribes from the corresponding NATS topics. Each type
|
|
// gets its own topic (ec2.RunInstances.{type}) with the hive-workers queue group,
|
|
// so NATS only routes requests to nodes that can actually serve them.
|
|
func (rm *ResourceManager) updateInstanceSubscriptions() {
|
|
if rm.natsConn == nil {
|
|
return
|
|
}
|
|
|
|
rm.subsMu.Lock()
|
|
defer rm.subsMu.Unlock()
|
|
|
|
for typeName, typeInfo := range rm.instanceTypes {
|
|
topic := fmt.Sprintf("ec2.RunInstances.%s", typeName)
|
|
canFit := rm.canAllocate(typeInfo, 1) >= 1
|
|
|
|
_, subscribed := rm.instanceSubs[topic]
|
|
if canFit && !subscribed {
|
|
sub, err := rm.natsConn.QueueSubscribe(topic, "hive-workers", rm.handler)
|
|
if err != nil {
|
|
slog.Error("Failed to subscribe to instance type topic", "topic", topic, "err", err)
|
|
continue
|
|
}
|
|
rm.instanceSubs[topic] = sub
|
|
slog.Debug("Subscribed to instance type", "topic", topic)
|
|
} else if !canFit && subscribed {
|
|
if err := rm.instanceSubs[topic].Unsubscribe(); err != nil {
|
|
slog.Error("Failed to unsubscribe from instance type topic", "topic", topic, "err", err)
|
|
}
|
|
delete(rm.instanceSubs, topic)
|
|
slog.Info("Unsubscribed from instance type (capacity full)", "topic", topic)
|
|
}
|
|
}
|
|
}
|