mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-22 20:26:44 +00:00
30c58ec358
Wire the four NotImplemented ECS teardown actions so an IaC stack and the console can reverse a deployment: - DeleteCluster cascades: force-stops every task (releasing ENIs, deregistering TG targets, returning capacity), then sweeps the cluster's whole KV subtree. Unlike AWS it cascades rather than rejecting a non-empty cluster, so terraform destroy round-trips regardless of teardown ordering. - DeregisterTaskDefinition marks a specific family:revision INACTIVE (kept describable); a bare family is rejected, matching AWS. - DeregisterContainerInstance removes an instance, force-stopping its tasks when Force is set and rejecting a non-empty instance otherwise. - UpdateContainerInstancesState sets ACTIVE/DRAINING; draining force-stops the instance's service-owned tasks so the reconciler relaunches them elsewhere. Reuses the existing forceStopTask primitive and the reaper drain pattern; adds a deleteKeysWithPrefix KV helper and INACTIVE status constants.
2545 lines
99 KiB
Go
2545 lines
99 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"maps"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
|
|
"net/http"
|
|
|
|
"github.com/aws/aws-sdk-go/aws"
|
|
"github.com/aws/aws-sdk-go/service/ec2"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/mulgadc/spinifex/internal/tlsconfig"
|
|
"github.com/mulgadc/spinifex/spinifex/admin"
|
|
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
|
"github.com/mulgadc/spinifex/spinifex/config"
|
|
"github.com/mulgadc/spinifex/spinifex/gpu"
|
|
handlers_acm "github.com/mulgadc/spinifex/spinifex/handlers/acm"
|
|
handlers_ec2_account "github.com/mulgadc/spinifex/spinifex/handlers/ec2/account"
|
|
handlers_ec2_eigw "github.com/mulgadc/spinifex/spinifex/handlers/ec2/eigw"
|
|
handlers_ec2_eip "github.com/mulgadc/spinifex/spinifex/handlers/ec2/eip"
|
|
handlers_ec2_igw "github.com/mulgadc/spinifex/spinifex/handlers/ec2/igw"
|
|
handlers_ec2_image "github.com/mulgadc/spinifex/spinifex/handlers/ec2/image"
|
|
handlers_ec2_instance "github.com/mulgadc/spinifex/spinifex/handlers/ec2/instance"
|
|
handlers_ec2_key "github.com/mulgadc/spinifex/spinifex/handlers/ec2/key"
|
|
handlers_ec2_natgw "github.com/mulgadc/spinifex/spinifex/handlers/ec2/natgw"
|
|
handlers_ec2_placementgroup "github.com/mulgadc/spinifex/spinifex/handlers/ec2/placementgroup"
|
|
handlers_ec2_routetable "github.com/mulgadc/spinifex/spinifex/handlers/ec2/routetable"
|
|
handlers_ec2_snapshot "github.com/mulgadc/spinifex/spinifex/handlers/ec2/snapshot"
|
|
handlers_ec2_tags "github.com/mulgadc/spinifex/spinifex/handlers/ec2/tags"
|
|
handlers_ec2_volume "github.com/mulgadc/spinifex/spinifex/handlers/ec2/volume"
|
|
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
|
|
handlers_ecr "github.com/mulgadc/spinifex/spinifex/handlers/ecr"
|
|
handlers_ecs "github.com/mulgadc/spinifex/spinifex/handlers/ecs"
|
|
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
|
|
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
|
"github.com/mulgadc/spinifex/spinifex/instancetypes"
|
|
"github.com/mulgadc/spinifex/spinifex/network/external/dhcp"
|
|
"github.com/mulgadc/spinifex/spinifex/network/host"
|
|
"github.com/mulgadc/spinifex/spinifex/objectstore"
|
|
"github.com/mulgadc/spinifex/spinifex/types"
|
|
"github.com/mulgadc/spinifex/spinifex/utils"
|
|
"github.com/mulgadc/spinifex/spinifex/vm"
|
|
"github.com/nats-io/nats.go"
|
|
)
|
|
|
|
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
|
|
// hostVCPU / hostMemGB: physical cores (not SMT threads) and total RAM.
|
|
// Schedulable capacity = host - reserved - allocated.
|
|
hostVCPU int
|
|
hostMemGB float64
|
|
// reservedVCPU / reservedMem: resources held back for the daemon and
|
|
// co-located services. See hostReserve / defaultHostReserve.
|
|
reservedVCPU int
|
|
reservedMem float64
|
|
allocatedVCPU int
|
|
allocatedMem float64
|
|
// reservedCRVCPU / reservedCRMem: compute held by capacity reservations
|
|
// pinned to this node. Subtracted from schedulable capacity exactly like
|
|
// the host reserve. In-memory only — lost on daemon restart.
|
|
reservedCRVCPU int
|
|
reservedCRMem float64
|
|
// reservations: in-memory capacity reservations owned by this node, keyed
|
|
// by id. Mutated together with reservedCR* under mu.
|
|
reservations map[string]*capacityReservation
|
|
// nbdkitMainMiB / nbdkitAuxMiB: per-volume nbdkit memory charged at
|
|
// admission so nbdkit backing a guest's volumes is accounted explicitly.
|
|
nbdkitMainMiB int
|
|
nbdkitAuxMiB int
|
|
instanceTypes map[string]*ec2.InstanceTypeInfo
|
|
gpuManager *gpu.Manager // nil if GPU passthrough is disabled or no GPUs present
|
|
|
|
// readMemAvailableGB is the live second admission gate (MemAvailable from
|
|
// /proc/meminfo). Catches real overcommit the static -m accounting misses.
|
|
// nil disables it (SPINIFEX_ADMISSION_LIVE_MEM=0); read failure fails open.
|
|
readMemAvailableGB func() (float64, bool)
|
|
|
|
// Dynamic instance-type subscription management
|
|
subsMu sync.Mutex
|
|
natsConn *nats.Conn
|
|
instanceSubs map[string]*nats.Subscription
|
|
handler nats.MsgHandler
|
|
systemHandler nats.MsgHandler // handles system.LaunchInstance.* requests (ALB-VM fan-out)
|
|
nodeID string // node identifier for node-specific topic subscriptions
|
|
}
|
|
|
|
// Compile-time guarantee that the RouteTable service satisfies the IGW
|
|
// handler's GatePublisher hook — the two services are wired together below.
|
|
var _ handlers_ec2_igw.GatePublisher = (*handlers_ec2_routetable.RouteTableServiceImpl)(nil)
|
|
|
|
// 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
|
|
placementGroupService *handlers_ec2_placementgroup.PlacementGroupServiceImpl
|
|
vpcService *handlers_ec2_vpc.VPCServiceImpl
|
|
eipService *handlers_ec2_eip.EIPServiceImpl
|
|
elbv2Service *handlers_elbv2.ELBv2ServiceImpl
|
|
eksService *handlers_eks.EKSServiceImpl
|
|
ecsService *handlers_ecs.Service
|
|
ecsScheduler *handlers_ecs.Scheduler
|
|
acmService *handlers_acm.ACMServiceImpl
|
|
ecrMetaService *handlers_ecr.MetaServiceImpl
|
|
routeTableService *handlers_ec2_routetable.RouteTableServiceImpl
|
|
natGatewayService *handlers_ec2_natgw.NatGatewayServiceImpl
|
|
externalIPAM *handlers_ec2_vpc.ExternalIPAM
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
shutdownWg sync.WaitGroup
|
|
|
|
// systemDispatchWg tracks in-flight system.LaunchInstance / TerminateInstance
|
|
// handlers. Each runs in its own goroutine so a slow VM boot never blocks
|
|
// the NATS subscription. Used by tests to await dispatch completion.
|
|
systemDispatchWg sync.WaitGroup
|
|
|
|
// vmMgr owns the in-memory map of VMs running on this node.
|
|
vmMgr *vm.Manager
|
|
|
|
// NAT Subscriptions
|
|
natsSubscriptions map[string]*nats.Subscription
|
|
|
|
// Cluster manager
|
|
clusterServer *http.Server
|
|
startTime time.Time
|
|
configPath string
|
|
|
|
// System credentials for ALB agent SigV4 auth (loaded from system-credentials.json)
|
|
systemAccessKey string
|
|
systemSecretKey string
|
|
|
|
// JetStream manager for KV state storage (nil if JetStream disabled)
|
|
jsManager *JetStreamManager
|
|
|
|
// stateStore is the vm.StateStore view over jsManager, initialized after
|
|
// initJetStream succeeds.
|
|
stateStore vm.StateStore
|
|
|
|
// Delay after QMP device_del before blockdev-del (default 1s, 0 in tests)
|
|
detachDelay time.Duration
|
|
|
|
// NATS connect retry options (nil uses defaults: 5min max, 500ms initial delay)
|
|
natsRetryOpts []utils.RetryOption
|
|
|
|
// requireNATSTimeout caps the first connectNATS attempt under
|
|
// SPINIFEX_REQUIRE_NATS=1. Default 30s; tests use a shorter value.
|
|
requireNATSTimeout time.Duration
|
|
|
|
// exitFunc is called when SPINIFEX_REQUIRE_NATS=1 and the first connect
|
|
// times out. Defaults to os.Exit; tests override to avoid killing the process.
|
|
exitFunc func(int)
|
|
|
|
// networkPlumber handles tap device lifecycle for VPC and management networking
|
|
networkPlumber vm.NetworkPlumber
|
|
|
|
// mgmtBridgeIP / mgmtIPAllocator: management NIC infrastructure for system
|
|
// instances. Populated at startup when br-mgmt is detected.
|
|
mgmtBridgeIP string
|
|
mgmtIPAllocator *MgmtIPAllocator
|
|
// mgmtRouteVia: AWSGW bind IP system instances route via br-mgmt (multi-node).
|
|
mgmtRouteVia string
|
|
|
|
// gpuProbe: startup GPU hardware probe result, always populated.
|
|
gpuProbe gpuProbeResult
|
|
|
|
// gpuManager: VFIO bind/unbind for GPU passthrough. Nil when disabled or no GPUs found.
|
|
gpuManager *gpu.Manager
|
|
|
|
// shuttingDown: set during GATE or SIGTERM; daemon rejects new work and
|
|
// crash handlers bail out.
|
|
shuttingDown atomic.Bool
|
|
|
|
// ready: set once NATS, JetStream, and all services are initialized.
|
|
// /health reports "starting" until true.
|
|
ready atomic.Bool
|
|
|
|
// natsConnected: true when the NATS TCP connection is live.
|
|
natsConnected atomic.Bool
|
|
// peersReachable: true when at least one peer /health responded in the
|
|
// last probe cycle. Pinned true on single-node clusters.
|
|
peersReachable atomic.Bool
|
|
|
|
// natsRetryCount: disconnect→reconnect cycles since process start.
|
|
natsRetryCount atomic.Int64
|
|
|
|
// stateRevision: incremented on each successful WriteState.
|
|
stateRevision atomic.Uint64
|
|
|
|
// kvSyncFailures: best-effort JetStream KV sync failures since start.
|
|
kvSyncFailures atomic.Int64
|
|
// lastKVSyncAt: unix-nano timestamp of the last successful KV sync.
|
|
lastKVSyncAt atomic.Int64
|
|
// lastKVSyncError: most recent KV sync error message; "" on success.
|
|
lastKVSyncError atomic.Value
|
|
|
|
// reconciling: coalesces concurrent reconcileOnHeal calls.
|
|
reconciling atomic.Bool
|
|
// stateWriteMu: serialises WriteState to prevent races on the .tmp staging file.
|
|
stateWriteMu sync.Mutex
|
|
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// Daemon connectivity modes derived by Mode().
|
|
const (
|
|
DaemonModeStandalone = "standalone"
|
|
DaemonModeCluster = "cluster"
|
|
)
|
|
|
|
// Mode returns "cluster" iff both natsConnected and peersReachable are true;
|
|
// otherwise "standalone". Two signals are required so a NATS-up partition
|
|
// (DDIL Scenario C) still reports standalone when no peer responds.
|
|
func (d *Daemon) Mode() string {
|
|
if d.natsConnected.Load() && d.peersReachable.Load() {
|
|
return DaemonModeCluster
|
|
}
|
|
return DaemonModeStandalone
|
|
}
|
|
|
|
// NATSRetryCount returns the number of disconnect→reconnect cycles observed
|
|
// since process start.
|
|
func (d *Daemon) NATSRetryCount() int64 {
|
|
return d.natsRetryCount.Load()
|
|
}
|
|
|
|
// Revision returns the local-state revision counter. Bumped on every successful
|
|
// WriteState; observers can detect changes without diffing the full payload.
|
|
func (d *Daemon) Revision() uint64 {
|
|
return d.stateRevision.Load()
|
|
}
|
|
|
|
// KVSyncFailures returns the number of best-effort JetStream KV sync failures
|
|
// (timeout or put error) observed since process start.
|
|
func (d *Daemon) KVSyncFailures() int64 {
|
|
return d.kvSyncFailures.Load()
|
|
}
|
|
|
|
// LastKVSyncAt returns the timestamp of the most recent successful best-effort
|
|
// KV sync. Zero time means "never synced since process start".
|
|
func (d *Daemon) LastKVSyncAt() time.Time {
|
|
n := d.lastKVSyncAt.Load()
|
|
if n == 0 {
|
|
return time.Time{}
|
|
}
|
|
return time.Unix(0, n)
|
|
}
|
|
|
|
// LastKVSyncError returns the most recent best-effort KV sync error message.
|
|
// Empty string means the last attempt succeeded (or no attempt has been made).
|
|
func (d *Daemon) LastKVSyncError() string {
|
|
v := d.lastKVSyncError.Load()
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
s, _ := v.(string)
|
|
return s
|
|
}
|
|
|
|
// RecordKVSyncSuccess implements KVSyncObserver. The JetStream manager calls
|
|
// this from its best-effort write path on a successful Put.
|
|
func (d *Daemon) RecordKVSyncSuccess(_ string) {
|
|
d.lastKVSyncAt.Store(time.Now().UnixNano())
|
|
d.lastKVSyncError.Store("")
|
|
}
|
|
|
|
// RecordKVSyncFailure implements KVSyncObserver. Bumps the failure counter and
|
|
// records the error message for /local/status.
|
|
func (d *Daemon) RecordKVSyncFailure(_ string, err error) {
|
|
d.kvSyncFailures.Add(1)
|
|
if err != nil {
|
|
d.lastKVSyncError.Store(err.Error())
|
|
}
|
|
}
|
|
|
|
// onNATSDisconnect runs when the NATS client loses its connection. Flips
|
|
// natsConnected to false so Mode() reports standalone and scatter-gather
|
|
// bailouts react immediately. Must not block — runs on a NATS client goroutine.
|
|
func (d *Daemon) onNATSDisconnect(_ *nats.Conn, _ error) {
|
|
d.natsConnected.Store(false)
|
|
}
|
|
|
|
// onNATSReconnect runs when the NATS client reattaches to a server. Marks
|
|
// NATS connected, bumps the retry counter, and dispatches reconcileOnHeal
|
|
// in a goroutine to keep this NATS client callback non-blocking.
|
|
func (d *Daemon) onNATSReconnect(_ *nats.Conn) {
|
|
d.natsConnected.Store(true)
|
|
d.natsRetryCount.Add(1)
|
|
|
|
go d.reconcileOnHeal("nats-reconnect")
|
|
}
|
|
|
|
// execCommand wraps exec.Command so tests can substitute a fake implementation.
|
|
var execCommand = exec.Command
|
|
|
|
// getSystemMemory returns the total system memory in GB
|
|
func getSystemMemory() (float64, error) {
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
// macOS: use sysctl
|
|
cmd := execCommand("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 := execCommand("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)
|
|
}
|
|
}
|
|
|
|
// physicalCoreCount returns the number of physical CPU cores by parsing
|
|
// distinct (physical id, core id) pairs from /proc/cpuinfo. Falls back to
|
|
// runtime.NumCPU() on non-Linux or when topology fields are absent.
|
|
func physicalCoreCount() int {
|
|
logical := runtime.NumCPU()
|
|
if runtime.GOOS != "linux" {
|
|
return logical
|
|
}
|
|
data, err := os.ReadFile("/proc/cpuinfo")
|
|
if err != nil {
|
|
slog.Warn("physical core detection failed, scheduling against logical CPUs",
|
|
"err", err, "logicalCPU", logical)
|
|
return logical
|
|
}
|
|
if n, ok := parsePhysicalCores(data); ok {
|
|
return n
|
|
}
|
|
return logical
|
|
}
|
|
|
|
// parsePhysicalCores counts distinct (physical id, core id) pairs in
|
|
// /proc/cpuinfo, collapsing SMT siblings. Returns ok=false when topology
|
|
// fields are absent so the caller can fall back to the logical CPU count.
|
|
func parsePhysicalCores(data []byte) (int, bool) {
|
|
cores := make(map[string]struct{})
|
|
var phys, core string
|
|
sawCore := false
|
|
flush := func() {
|
|
if core != "" {
|
|
cores[phys+":"+core] = struct{}{}
|
|
}
|
|
phys, core = "", ""
|
|
}
|
|
for line := range strings.SplitSeq(string(data), "\n") {
|
|
if strings.TrimSpace(line) == "" {
|
|
flush()
|
|
continue
|
|
}
|
|
key, val, ok := strings.Cut(line, ":")
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch strings.TrimSpace(key) {
|
|
case "physical id":
|
|
phys = strings.TrimSpace(val)
|
|
case "core id":
|
|
core = strings.TrimSpace(val)
|
|
sawCore = true
|
|
}
|
|
}
|
|
flush()
|
|
if !sawCore || len(cores) == 0 {
|
|
return 0, false
|
|
}
|
|
return len(cores), true
|
|
}
|
|
|
|
// NewResourceManager creates a new ResourceManager. Errors if memory detection
|
|
// fails or if the host is too small to satisfy the daemon's reserve.
|
|
func NewResourceManager(gpuModels []instancetypes.GPUModel, migProfiles []instancetypes.MIGProfileSpec, gpuMgr *gpu.Manager) (*ResourceManager, error) {
|
|
// Use physical cores (not SMT threads); SPINIFEX_HOST_VCPU overrides.
|
|
hostVCPU := resolveHostVCPU(os.Getenv, physicalCoreCount())
|
|
|
|
totalMemGB, err := getSystemMemory()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("detect system memory: %w", err)
|
|
}
|
|
|
|
reserve := resolveHostReserve(os.Getenv)
|
|
reservedVCPU, reservedMem, err := applyHostReserve(reserve, hostVCPU, totalMemGB)
|
|
if err != nil {
|
|
slog.Error("host below minimum reserve — daemon refuses to start",
|
|
"err", err, "hostVCPU", hostVCPU, "hostMemGB", totalMemGB,
|
|
"reserveVCPU", reserve.vCPU, "reserveMemGB", reserve.memGB)
|
|
return nil, fmt.Errorf("validate host reserve: %w", err)
|
|
}
|
|
|
|
arch := "x86_64"
|
|
if runtime.GOARCH == "arm64" {
|
|
arch = "arm64"
|
|
}
|
|
|
|
instanceTypes := instancetypes.DetectAndGenerate(instancetypes.HostCPU{}, arch, gpuModels)
|
|
if len(migProfiles) > 0 {
|
|
maps.Copy(instanceTypes, instancetypes.GenerateMIGTypes(migProfiles, arch))
|
|
}
|
|
|
|
slog.Info("System resources detected",
|
|
"hostVCPU", hostVCPU, "logicalCPU", runtime.NumCPU(), "hostMemGB", totalMemGB,
|
|
"reservedVCPU", reservedVCPU, "reservedMemGB", reservedMem,
|
|
"schedulableVCPU", hostVCPU-reservedVCPU, "schedulableMemGB", totalMemGB-reservedMem,
|
|
"instanceTypes", len(instanceTypes))
|
|
|
|
var memReader func() (float64, bool)
|
|
if liveMemAdmissionEnabled(os.Getenv) {
|
|
memReader = readMemAvailableGB
|
|
}
|
|
|
|
nbdkitMainMiB, nbdkitAuxMiB := resolveNbdkitCharge(os.Getenv)
|
|
|
|
return &ResourceManager{
|
|
hostVCPU: hostVCPU,
|
|
hostMemGB: totalMemGB,
|
|
reservedVCPU: reservedVCPU,
|
|
reservedMem: reservedMem,
|
|
nbdkitMainMiB: nbdkitMainMiB,
|
|
nbdkitAuxMiB: nbdkitAuxMiB,
|
|
instanceTypes: instanceTypes,
|
|
gpuManager: gpuMgr,
|
|
readMemAvailableGB: memReader,
|
|
reservations: make(map[string]*capacityReservation),
|
|
}, nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// instanceMemChargeMiB is the full per-instance memory charge: guest -m plus
|
|
// nbdkit processes for its volumes. All admission gates use this value.
|
|
func (rm *ResourceManager) instanceMemChargeMiB(it *ec2.InstanceTypeInfo) int64 {
|
|
return instanceTypeMemoryMiB(it) +
|
|
nbdkitChargeMiB(defaultMainVolumes, defaultAuxVolumes, rm.nbdkitMainMiB, rm.nbdkitAuxMiB)
|
|
}
|
|
|
|
// 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 name, it := range rm.instanceTypes {
|
|
if instancetypes.IsSystemType(name) {
|
|
continue
|
|
}
|
|
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 name, it := range rm.instanceTypes {
|
|
if instancetypes.IsSystemType(name) {
|
|
continue
|
|
}
|
|
|
|
vCPUs := instanceTypeVCPUs(it)
|
|
memMiB := instanceTypeMemoryMiB(it)
|
|
|
|
if vCPUs == 0 || memMiB == 0 {
|
|
continue
|
|
}
|
|
|
|
// GPU types are capacity-gated by GPU pool size, not host CPU/memory.
|
|
// The GPU is the scarce resource; CPU/memory on GPU-class hardware is abundant.
|
|
if instancetypes.IsGPUType(it) {
|
|
availGPU := 0
|
|
if rm.gpuManager != nil {
|
|
availGPU = rm.gpuManager.Available()
|
|
}
|
|
gpusNeeded := instancetypes.GPUCountForType(name)
|
|
count := 0
|
|
if gpusNeeded > 0 {
|
|
count = availGPU / gpusNeeded
|
|
}
|
|
if showCapacity {
|
|
for range count {
|
|
infos = append(infos, it)
|
|
}
|
|
} else if count > 0 {
|
|
infos = append(infos, it)
|
|
}
|
|
continue
|
|
}
|
|
|
|
count := canAllocateCount(
|
|
rm.hostVCPU-rm.reservedVCPU-rm.reservedCRVCPU, rm.allocatedVCPU,
|
|
rm.hostMemGB-rm.reservedMem-rm.reservedCRMem, rm.allocatedMem,
|
|
vCPUs, rm.instanceMemChargeMiB(it),
|
|
1<<30, // effectively unlimited — let resources be the constraint
|
|
0, false,
|
|
)
|
|
|
|
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.hostVCPU, "hostMem", rm.hostMemGB,
|
|
"reservedVCPU", rm.reservedVCPU, "reservedMem", rm.reservedMem,
|
|
"reservedCRVCPU", rm.reservedCRVCPU, "reservedCRMem", rm.reservedCRMem,
|
|
"showCapacity", showCapacity)
|
|
|
|
return infos
|
|
}
|
|
|
|
// GetSupportedInstanceTypeInfos returns every supported instance type regardless
|
|
// of current capacity, mirroring AWS DescribeInstanceTypes semantics. System
|
|
// types and entries with incomplete metadata are skipped.
|
|
func (rm *ResourceManager) GetSupportedInstanceTypeInfos() []*ec2.InstanceTypeInfo {
|
|
rm.mu.RLock()
|
|
defer rm.mu.RUnlock()
|
|
|
|
var infos []*ec2.InstanceTypeInfo
|
|
for name, it := range rm.instanceTypes {
|
|
if instancetypes.IsSystemType(name) {
|
|
continue
|
|
}
|
|
if instanceTypeVCPUs(it) == 0 || instanceTypeMemoryMiB(it) == 0 {
|
|
continue
|
|
}
|
|
infos = append(infos, it)
|
|
}
|
|
|
|
slog.Info("GetSupportedInstanceTypeInfos", "total_types", len(rm.instanceTypes), "supported", len(infos))
|
|
return infos
|
|
}
|
|
|
|
// GetResourceStats returns host resource figures, reservation, allocation, and
|
|
// per-type capacity caps for the node status response.
|
|
func (rm *ResourceManager) GetResourceStats() (totalVCPU int, totalMemGB float64, reservedVCPU int, reservedMemGB float64, allocVCPU int, allocMemGB float64, caps []types.InstanceTypeCap) {
|
|
rm.mu.RLock()
|
|
defer rm.mu.RUnlock()
|
|
|
|
totalVCPU = rm.hostVCPU
|
|
totalMemGB = rm.hostMemGB
|
|
// Fold the capacity-reservation carve-out into the reported reserve figures;
|
|
// Phase 1 has no separate status field for it.
|
|
reservedVCPU = rm.reservedVCPU + rm.reservedCRVCPU
|
|
reservedMemGB = rm.reservedMem + rm.reservedCRMem
|
|
allocVCPU = rm.allocatedVCPU
|
|
allocMemGB = rm.allocatedMem
|
|
|
|
remainingVCPU := rm.hostVCPU - reservedVCPU - rm.allocatedVCPU
|
|
remainingMem := rm.hostMemGB - reservedMemGB - rm.allocatedMem
|
|
if remainingVCPU < 0 || remainingMem < 0 {
|
|
slog.Error("schedulable capacity negative — reserve misconfigured or allocation drift",
|
|
"hostVCPU", rm.hostVCPU, "reservedVCPU", reservedVCPU, "allocatedVCPU", rm.allocatedVCPU,
|
|
"hostMemGB", rm.hostMemGB, "reservedMem", reservedMemGB, "allocatedMem", rm.allocatedMem,
|
|
"remainingVCPU", remainingVCPU, "remainingMem", remainingMem)
|
|
}
|
|
|
|
for name, it := range rm.instanceTypes {
|
|
if instancetypes.IsSystemType(name) {
|
|
continue
|
|
}
|
|
typeCap := resourceStatsForType(remainingVCPU, remainingMem, it)
|
|
if typeCap.VCPU == 0 || typeCap.MemoryGB == 0 {
|
|
continue
|
|
}
|
|
caps = append(caps, typeCap)
|
|
}
|
|
return totalVCPU, totalMemGB, reservedVCPU, reservedMemGB, allocVCPU, allocMemGB, caps
|
|
}
|
|
|
|
// 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, error) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
// If WalDir is not set, use BaseDir
|
|
nodeCfg := cfg.Nodes[cfg.Node]
|
|
if cfg.Nodes[cfg.Node].WalDir == "" {
|
|
nodeCfg.WalDir = nodeCfg.BaseDir
|
|
cfg.Nodes[cfg.Node] = nodeCfg
|
|
}
|
|
|
|
// Phase 1: always probe GPU hardware (no side effects, no config required).
|
|
gpuProbe := probeGPU()
|
|
|
|
// Phase 2: activate GPU passthrough only when the operator has opted in.
|
|
var gpuModels []instancetypes.GPUModel
|
|
var gpuMigProfiles []instancetypes.MIGProfileSpec
|
|
var gpuMgr *gpu.Manager
|
|
if nodeCfg.Daemon.GPUPassthrough {
|
|
if !gpuProbe.Capable {
|
|
slog.Warn("GPU passthrough enabled in config but prerequisites not met",
|
|
"iommu", gpuProbe.IOMMUActive, "vfio", gpuProbe.VFIOPresent,
|
|
"gpus", len(gpuProbe.Devices))
|
|
} else {
|
|
gpuMgr, gpuModels, gpuMigProfiles = buildGPUPool(gpuProbe.Devices, nodeCfg.Daemon)
|
|
}
|
|
} else if gpuProbe.Capable {
|
|
slog.Info("GPU hardware detected, passthrough not enabled",
|
|
"gpus", len(gpuProbe.Devices), "hint", "run 'spx admin gpu enable' to activate")
|
|
}
|
|
|
|
rm, err := NewResourceManager(gpuModels, gpuMigProfiles, gpuMgr)
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("initialize resource manager: %w", err)
|
|
}
|
|
|
|
d := &Daemon{
|
|
node: cfg.Node,
|
|
clusterConfig: cfg,
|
|
config: &nodeCfg,
|
|
resourceMgr: rm,
|
|
gpuProbe: gpuProbe,
|
|
gpuManager: gpuMgr,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
vmMgr: vm.NewManager(),
|
|
natsSubscriptions: make(map[string]*nats.Subscription),
|
|
startTime: time.Now(),
|
|
detachDelay: 1 * time.Second,
|
|
requireNATSTimeout: 30 * time.Second,
|
|
exitFunc: os.Exit,
|
|
}
|
|
// Initialise peersReachable true so the first probe tick never fires a
|
|
// spurious reconcileOnHeal at startup. Mode() still requires natsConnected
|
|
// (starts false), so this can't falsely report cluster mode.
|
|
d.peersReachable.Store(true)
|
|
return d, nil
|
|
}
|
|
|
|
// 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, "spinifex-workers"},
|
|
{"ec2.DeleteKeyPair", d.handleEC2DeleteKeyPair, "spinifex-workers"},
|
|
{"ec2.DescribeKeyPairs", d.handleEC2DescribeKeyPairs, "spinifex-workers"},
|
|
{"ec2.ImportKeyPair", d.handleEC2ImportKeyPair, "spinifex-workers"},
|
|
{"imds.ec2.get_public_key", d.handleIMDSGetPublicKey, "spinifex-workers"},
|
|
{"ec2.DescribeImages", d.handleEC2DescribeImages, "spinifex-workers"},
|
|
{"ec2.CreateImage", d.handleEC2CreateImage, ""},
|
|
{"ec2.DeregisterImage", d.handleEC2DeregisterImage, "spinifex-workers"},
|
|
{"ec2.RegisterImage", d.handleEC2RegisterImage, "spinifex-workers"},
|
|
{"ec2.CopyImage", d.handleEC2CopyImage, "spinifex-workers"},
|
|
{"ec2.DescribeImageAttribute", d.handleEC2DescribeImageAttribute, "spinifex-workers"},
|
|
{"ec2.ModifyImageAttribute", d.handleEC2ModifyImageAttribute, "spinifex-workers"},
|
|
{"ec2.ResetImageAttribute", d.handleEC2ResetImageAttribute, "spinifex-workers"},
|
|
{"ec2.CreateVolume", d.handleEC2CreateVolume, "spinifex-workers"},
|
|
{"ec2.DescribeVolumes", d.handleEC2DescribeVolumes, "spinifex-workers"},
|
|
{"ec2.ModifyVolume", d.handleEC2ModifyVolume, "spinifex-workers"},
|
|
{"ec2.DeleteVolume", d.handleEC2DeleteVolume, "spinifex-workers"},
|
|
{"ec2.DescribeVolumeStatus", d.handleEC2DescribeVolumeStatus, "spinifex-workers"},
|
|
{"ec2.DescribeVolumesModifications", d.handleEC2DescribeVolumesModifications, "spinifex-workers"},
|
|
{"ec2.CreateSnapshot", d.handleEC2CreateSnapshot, "spinifex-workers"},
|
|
{"ec2.DescribeSnapshots", d.handleEC2DescribeSnapshots, "spinifex-workers"},
|
|
{"ec2.DeleteSnapshot", d.handleEC2DeleteSnapshot, "spinifex-workers"},
|
|
{"ec2.CopySnapshot", d.handleEC2CopySnapshot, "spinifex-workers"},
|
|
{"ec2.CreateTags", d.handleEC2CreateTags, "spinifex-workers"},
|
|
{"ec2.DeleteTags", d.handleEC2DeleteTags, "spinifex-workers"},
|
|
{"ec2.DescribeTags", d.handleEC2DescribeTags, "spinifex-workers"},
|
|
{"ec2.CreateEgressOnlyInternetGateway", d.handleEC2CreateEgressOnlyInternetGateway, "spinifex-workers"},
|
|
{"ec2.DeleteEgressOnlyInternetGateway", d.handleEC2DeleteEgressOnlyInternetGateway, "spinifex-workers"},
|
|
{"ec2.DescribeEgressOnlyInternetGateways", d.handleEC2DescribeEgressOnlyInternetGateways, "spinifex-workers"},
|
|
{"ec2.CreateInternetGateway", d.handleEC2CreateInternetGateway, "spinifex-workers"},
|
|
{"ec2.DeleteInternetGateway", d.handleEC2DeleteInternetGateway, "spinifex-workers"},
|
|
{"ec2.DescribeInternetGateways", d.handleEC2DescribeInternetGateways, "spinifex-workers"},
|
|
{"ec2.AttachInternetGateway", d.handleEC2AttachInternetGateway, "spinifex-workers"},
|
|
{"ec2.DetachInternetGateway", d.handleEC2DetachInternetGateway, "spinifex-workers"},
|
|
{"ec2.CreatePlacementGroup", d.handleEC2CreatePlacementGroup, "spinifex-workers"},
|
|
{"ec2.DeletePlacementGroup", d.handleEC2DeletePlacementGroup, "spinifex-workers"},
|
|
{"ec2.DescribePlacementGroups", d.handleEC2DescribePlacementGroups, "spinifex-workers"},
|
|
{"ec2.ReserveSpreadNodes", d.handleEC2ReserveSpreadNodes, "spinifex-workers"},
|
|
{"ec2.FinalizeSpreadInstances", d.handleEC2FinalizeSpreadInstances, "spinifex-workers"},
|
|
{"ec2.ReleaseSpreadNodes", d.handleEC2ReleaseSpreadNodes, "spinifex-workers"},
|
|
{"ec2.RemoveInstanceFromPlacementGroup", d.handleEC2RemoveInstanceFromPlacementGroup, "spinifex-workers"},
|
|
{"ec2.ReserveClusterNode", d.handleEC2ReserveClusterNode, "spinifex-workers"},
|
|
{"ec2.FinalizeClusterInstances", d.handleEC2FinalizeClusterInstances, "spinifex-workers"},
|
|
// Capacity reservations: Create is node-targeted (gateway pins one node);
|
|
// Describe fans out and Cancel broadcasts, so both use plain Subscribe.
|
|
{fmt.Sprintf("ec2.CreateCapacityReservation.%s", d.node), d.handleEC2CreateCapacityReservation, ""},
|
|
{"ec2.DescribeCapacityReservations", d.handleEC2DescribeCapacityReservations, ""},
|
|
{"ec2.CancelCapacityReservation", d.handleEC2CancelCapacityReservation, ""},
|
|
{"ec2.CreateNatGateway", d.handleEC2CreateNatGateway, "spinifex-workers"},
|
|
{"ec2.DeleteNatGateway", d.handleEC2DeleteNatGateway, "spinifex-workers"},
|
|
{"ec2.DescribeNatGateways", d.handleEC2DescribeNatGateways, "spinifex-workers"},
|
|
{"ec2.CreateRouteTable", d.handleEC2CreateRouteTable, "spinifex-workers"},
|
|
{"ec2.DeleteRouteTable", d.handleEC2DeleteRouteTable, "spinifex-workers"},
|
|
{"ec2.DescribeRouteTables", d.handleEC2DescribeRouteTables, "spinifex-workers"},
|
|
{"ec2.CreateRoute", d.handleEC2CreateRoute, "spinifex-workers"},
|
|
{"ec2.DeleteRoute", d.handleEC2DeleteRoute, "spinifex-workers"},
|
|
{"ec2.ReplaceRoute", d.handleEC2ReplaceRoute, "spinifex-workers"},
|
|
{"ec2.AssociateRouteTable", d.handleEC2AssociateRouteTable, "spinifex-workers"},
|
|
{"ec2.DisassociateRouteTable", d.handleEC2DisassociateRouteTable, "spinifex-workers"},
|
|
{"ec2.ReplaceRouteTableAssociation", d.handleEC2ReplaceRouteTableAssociation, "spinifex-workers"},
|
|
{"ec2.CreateVpc", d.handleEC2CreateVpc, "spinifex-workers"},
|
|
{"ec2.DeleteVpc", d.handleEC2DeleteVpc, "spinifex-workers"},
|
|
{"ec2.DescribeVpcs", d.handleEC2DescribeVpcs, "spinifex-workers"},
|
|
{"ec2.CreateSubnet", d.handleEC2CreateSubnet, "spinifex-workers"},
|
|
{"ec2.DeleteSubnet", d.handleEC2DeleteSubnet, "spinifex-workers"},
|
|
{"ec2.DescribeSubnets", d.handleEC2DescribeSubnets, "spinifex-workers"},
|
|
{"ec2.ModifySubnetAttribute", d.handleEC2ModifySubnetAttribute, "spinifex-workers"},
|
|
{"ec2.ModifyVpcAttribute", d.handleEC2ModifyVpcAttribute, "spinifex-workers"},
|
|
{"ec2.DescribeVpcAttribute", d.handleEC2DescribeVpcAttribute, "spinifex-workers"},
|
|
{"ec2.CreateNetworkInterface", d.handleEC2CreateNetworkInterface, "spinifex-workers"},
|
|
{"ec2.DeleteNetworkInterface", d.handleEC2DeleteNetworkInterface, "spinifex-workers"},
|
|
{"ec2.DescribeNetworkInterfaces", d.handleEC2DescribeNetworkInterfaces, "spinifex-workers"},
|
|
{"ec2.ModifyNetworkInterfaceAttribute", d.handleEC2ModifyNetworkInterfaceAttribute, "spinifex-workers"},
|
|
{"ec2.CreateSecurityGroup", d.handleEC2CreateSecurityGroup, "spinifex-workers"},
|
|
{"ec2.DeleteSecurityGroup", d.handleEC2DeleteSecurityGroup, "spinifex-workers"},
|
|
{"ec2.DescribeSecurityGroups", d.handleEC2DescribeSecurityGroups, "spinifex-workers"},
|
|
{"ec2.DescribeSecurityGroupRules", d.handleEC2DescribeSecurityGroupRules, "spinifex-workers"},
|
|
{"ec2.AuthorizeSecurityGroupIngress", d.handleEC2AuthorizeSecurityGroupIngress, "spinifex-workers"},
|
|
{"ec2.AuthorizeSecurityGroupEgress", d.handleEC2AuthorizeSecurityGroupEgress, "spinifex-workers"},
|
|
{"ec2.RevokeSecurityGroupIngress", d.handleEC2RevokeSecurityGroupIngress, "spinifex-workers"},
|
|
{"ec2.RevokeSecurityGroupEgress", d.handleEC2RevokeSecurityGroupEgress, "spinifex-workers"},
|
|
{"ec2.ModifyInstanceAttribute", d.handleEC2ModifyInstanceAttribute, "spinifex-workers"},
|
|
{"ec2.start", d.handleEC2StartStoppedInstance, "spinifex-workers"},
|
|
{fmt.Sprintf("ec2.start.%s", d.node), d.handleEC2StartStoppedInstanceDirect, ""},
|
|
{"ec2.terminate", d.handleEC2TerminateStoppedInstance, "spinifex-workers"},
|
|
{"ec2.DescribeStoppedInstances", d.handleEC2DescribeStoppedInstances, "spinifex-workers"},
|
|
{"ec2.DescribeTerminatedInstances", d.handleEC2DescribeTerminatedInstances, "spinifex-workers"},
|
|
// these fan out to all nodes and gateway aggregates the results. The
|
|
// handler only sees per-daemon local state (vmMgr/stoppedStore), so
|
|
// any queue-grouped routing produces 1/N false NotFound responses.
|
|
{"ec2.DescribeInstances", d.handleEC2DescribeInstances, ""},
|
|
{"ec2.DescribeInstanceStatus", d.handleEC2DescribeInstanceStatus, ""},
|
|
{"ec2.DescribeInstanceTypes", d.handleEC2DescribeInstanceTypes, ""},
|
|
{"ec2.DescribeInstanceAttribute", d.handleEC2DescribeInstanceAttribute, ""},
|
|
// IAM instance profile associations: Disassociate/Replace mutate the
|
|
// owning daemon's vm.VM (non-owners NoOp with Found=false); Describe
|
|
// returns per-daemon matches that the gateway concatenates.
|
|
{"ec2.IamProfileAssociation.disassociate", d.handleIamProfileDisassociate, ""},
|
|
{"ec2.IamProfileAssociation.replace", d.handleIamProfileReplace, ""},
|
|
{"ec2.IamProfileAssociation.describe", d.handleIamProfileDescribe, ""},
|
|
{"ec2.EnableEbsEncryptionByDefault", d.handleEC2EnableEbsEncryptionByDefault, "spinifex-workers"},
|
|
{"ec2.DisableEbsEncryptionByDefault", d.handleEC2DisableEbsEncryptionByDefault, "spinifex-workers"},
|
|
{"ec2.GetEbsEncryptionByDefault", d.handleEC2GetEbsEncryptionByDefault, "spinifex-workers"},
|
|
{"ec2.GetSerialConsoleAccessStatus", d.handleEC2GetSerialConsoleAccessStatus, "spinifex-workers"},
|
|
{"ec2.EnableSerialConsoleAccess", d.handleEC2EnableSerialConsoleAccess, "spinifex-workers"},
|
|
{"ec2.DisableSerialConsoleAccess", d.handleEC2DisableSerialConsoleAccess, "spinifex-workers"},
|
|
{fmt.Sprintf("spinifex.admin.%s.health", d.node), d.handleHealthCheck, ""},
|
|
{"spinifex.nodes.discover", d.handleNodeDiscover, ""},
|
|
{"spinifex.node.status", d.handleNodeStatus, ""},
|
|
{"spinifex.node.vms", d.handleNodeVMs, ""},
|
|
{"spinifex.storage.config", d.handleStorageConfig, ""},
|
|
// Account creation → create default VPC for new account
|
|
{"iam.account.created", d.handleAccountCreated, "spinifex-workers"},
|
|
// Coordinated cluster shutdown phases (fan-out, no queue group)
|
|
{"spinifex.cluster.shutdown.gate", d.handleShutdownGate, ""},
|
|
{"spinifex.cluster.shutdown.drain", d.handleShutdownDrain, ""},
|
|
{"spinifex.cluster.shutdown.storage", d.handleShutdownStorage, ""},
|
|
{"spinifex.cluster.shutdown.persist", d.handleShutdownPersist, ""},
|
|
{"spinifex.cluster.shutdown.infra", d.handleShutdownInfra, ""},
|
|
}
|
|
|
|
// ELBv2 operations require a resolved gateway URL.
|
|
// Without a subscriber the gateway returns nats.ErrNoResponders → ServiceUnavailable.
|
|
if d.elbv2Service.GatewayURL != "" {
|
|
subs = append(subs,
|
|
natsSub{"elbv2.CreateLoadBalancer", d.handleELBv2CreateLoadBalancer, "spinifex-workers"},
|
|
natsSub{"elbv2.DeleteLoadBalancer", d.handleELBv2DeleteLoadBalancer, "spinifex-workers"},
|
|
natsSub{"elbv2.DescribeLoadBalancers", d.handleELBv2DescribeLoadBalancers, "spinifex-workers"},
|
|
natsSub{"elbv2.CreateTargetGroup", d.handleELBv2CreateTargetGroup, "spinifex-workers"},
|
|
natsSub{"elbv2.ModifyTargetGroup", d.handleELBv2ModifyTargetGroup, "spinifex-workers"},
|
|
natsSub{"elbv2.DeleteTargetGroup", d.handleELBv2DeleteTargetGroup, "spinifex-workers"},
|
|
natsSub{"elbv2.DescribeTargetGroups", d.handleELBv2DescribeTargetGroups, "spinifex-workers"},
|
|
natsSub{"elbv2.RegisterTargets", d.handleELBv2RegisterTargets, "spinifex-workers"},
|
|
natsSub{"elbv2.DeregisterTargets", d.handleELBv2DeregisterTargets, "spinifex-workers"},
|
|
natsSub{"elbv2.DescribeTargetHealth", d.handleELBv2DescribeTargetHealth, "spinifex-workers"},
|
|
natsSub{"elbv2.CreateListener", d.handleELBv2CreateListener, "spinifex-workers"},
|
|
natsSub{"elbv2.DeleteListener", d.handleELBv2DeleteListener, "spinifex-workers"},
|
|
natsSub{"elbv2.ModifyListener", d.handleELBv2ModifyListener, "spinifex-workers"},
|
|
natsSub{"elbv2.DescribeListeners", d.handleELBv2DescribeListeners, "spinifex-workers"},
|
|
natsSub{"elbv2.CreateRule", d.handleELBv2CreateRule, "spinifex-workers"},
|
|
natsSub{"elbv2.ModifyRule", d.handleELBv2ModifyRule, "spinifex-workers"},
|
|
natsSub{"elbv2.DeleteRule", d.handleELBv2DeleteRule, "spinifex-workers"},
|
|
natsSub{"elbv2.DescribeRules", d.handleELBv2DescribeRules, "spinifex-workers"},
|
|
natsSub{"elbv2.SetRulePriorities", d.handleELBv2SetRulePriorities, "spinifex-workers"},
|
|
natsSub{"elbv2.DescribeTags", d.handleELBv2DescribeTags, "spinifex-workers"},
|
|
natsSub{"elbv2.AddTags", d.handleELBv2AddTags, "spinifex-workers"},
|
|
natsSub{"elbv2.RemoveTags", d.handleELBv2RemoveTags, "spinifex-workers"},
|
|
natsSub{"elbv2.LBAgentHeartbeat", d.handleELBv2LBAgentHeartbeat, "spinifex-workers"},
|
|
natsSub{"elbv2.GetLBConfig", d.handleELBv2GetLBConfig, "spinifex-workers"},
|
|
natsSub{"elbv2.ModifyTargetGroupAttributes", d.handleELBv2ModifyTargetGroupAttributes, "spinifex-workers"},
|
|
natsSub{"elbv2.DescribeTargetGroupAttributes", d.handleELBv2DescribeTargetGroupAttributes, "spinifex-workers"},
|
|
natsSub{"elbv2.ModifyLoadBalancerAttributes", d.handleELBv2ModifyLoadBalancerAttributes, "spinifex-workers"},
|
|
natsSub{"elbv2.DescribeLoadBalancerAttributes", d.handleELBv2DescribeLoadBalancerAttributes, "spinifex-workers"},
|
|
natsSub{"elbv2.SetSecurityGroups", d.handleELBv2SetSecurityGroups, "spinifex-workers"},
|
|
natsSub{"elbv2.SetIpAddressType", d.handleELBv2SetIpAddressType, "spinifex-workers"},
|
|
natsSub{"elbv2.SetSubnets", d.handleELBv2SetSubnets, "spinifex-workers"},
|
|
natsSub{"elbv2.AddListenerCertificates", d.handleELBv2AddListenerCertificates, "spinifex-workers"},
|
|
natsSub{"elbv2.RemoveListenerCertificates", d.handleELBv2RemoveListenerCertificates, "spinifex-workers"},
|
|
natsSub{"elbv2.DescribeListenerCertificates", d.handleELBv2DescribeListenerCertificates, "spinifex-workers"},
|
|
natsSub{"elbv2.DescribeSSLPolicies", d.handleELBv2DescribeSSLPolicies, "spinifex-workers"},
|
|
)
|
|
}
|
|
|
|
// EKS gateway → daemon subscriptions. Every handler currently returns
|
|
// NotImplemented; topics are subscribed up-front so the wiring layer is
|
|
// stable while real bodies land.
|
|
if d.eksService != nil {
|
|
subs = append(subs,
|
|
natsSub{"eks.CreateCluster", d.handleEKSCreateCluster, "spinifex-workers"},
|
|
natsSub{"eks.DescribeCluster", d.handleEKSDescribeCluster, "spinifex-workers"},
|
|
natsSub{"eks.ListClusters", d.handleEKSListClusters, "spinifex-workers"},
|
|
natsSub{"eks.UpdateClusterConfig", d.handleEKSUpdateClusterConfig, "spinifex-workers"},
|
|
natsSub{"eks.UpdateClusterVersion", d.handleEKSUpdateClusterVersion, "spinifex-workers"},
|
|
natsSub{"eks.DeleteCluster", d.handleEKSDeleteCluster, "spinifex-workers"},
|
|
natsSub{"eks.CreateNodegroup", d.handleEKSCreateNodegroup, "spinifex-workers"},
|
|
natsSub{"eks.DescribeNodegroup", d.handleEKSDescribeNodegroup, "spinifex-workers"},
|
|
natsSub{"eks.ListNodegroups", d.handleEKSListNodegroups, "spinifex-workers"},
|
|
natsSub{"eks.UpdateNodegroupConfig", d.handleEKSUpdateNodegroupConfig, "spinifex-workers"},
|
|
natsSub{"eks.UpdateNodegroupVersion", d.handleEKSUpdateNodegroupVersion, "spinifex-workers"},
|
|
natsSub{"eks.DeleteNodegroup", d.handleEKSDeleteNodegroup, "spinifex-workers"},
|
|
natsSub{"eks.CreateAccessEntry", d.handleEKSCreateAccessEntry, "spinifex-workers"},
|
|
natsSub{"eks.DescribeAccessEntry", d.handleEKSDescribeAccessEntry, "spinifex-workers"},
|
|
natsSub{"eks.ListAccessEntries", d.handleEKSListAccessEntries, "spinifex-workers"},
|
|
natsSub{"eks.UpdateAccessEntry", d.handleEKSUpdateAccessEntry, "spinifex-workers"},
|
|
natsSub{"eks.DeleteAccessEntry", d.handleEKSDeleteAccessEntry, "spinifex-workers"},
|
|
natsSub{"eks.AssociateAccessPolicy", d.handleEKSAssociateAccessPolicy, "spinifex-workers"},
|
|
natsSub{"eks.DisassociateAccessPolicy", d.handleEKSDisassociateAccessPolicy, "spinifex-workers"},
|
|
natsSub{"eks.ListAssociatedAccessPolicies", d.handleEKSListAssociatedAccessPolicies, "spinifex-workers"},
|
|
natsSub{"eks.ListAccessPolicies", d.handleEKSListAccessPolicies, "spinifex-workers"},
|
|
natsSub{"eks.ListAddons", d.handleEKSListAddons, "spinifex-workers"},
|
|
natsSub{"eks.DescribeAddonVersions", d.handleEKSDescribeAddonVersions, "spinifex-workers"},
|
|
natsSub{"eks.CreateAddon", d.handleEKSCreateAddon, "spinifex-workers"},
|
|
natsSub{"eks.DeleteAddon", d.handleEKSDeleteAddon, "spinifex-workers"},
|
|
natsSub{"eks.DescribeAddon", d.handleEKSDescribeAddon, "spinifex-workers"},
|
|
natsSub{"eks.UpdateAddon", d.handleEKSUpdateAddon, "spinifex-workers"},
|
|
natsSub{"eks.ListStagedAddonManifests", d.handleEKSListStagedAddonManifests, "spinifex-workers"},
|
|
natsSub{"eks.AssociateIdentityProviderConfig", d.handleEKSAssociateIdentityProviderConfig, "spinifex-workers"},
|
|
natsSub{"eks.DescribeIdentityProviderConfig", d.handleEKSDescribeIdentityProviderConfig, "spinifex-workers"},
|
|
natsSub{"eks.ListIdentityProviderConfigs", d.handleEKSListIdentityProviderConfigs, "spinifex-workers"},
|
|
natsSub{"eks.DisassociateIdentityProviderConfig", d.handleEKSDisassociateIdentityProviderConfig, "spinifex-workers"},
|
|
natsSub{"eks.TagResource", d.handleEKSTagResource, "spinifex-workers"},
|
|
natsSub{"eks.UntagResource", d.handleEKSUntagResource, "spinifex-workers"},
|
|
natsSub{"eks.ListTagsForResource", d.handleEKSListTagsForResource, "spinifex-workers"},
|
|
)
|
|
}
|
|
|
|
// ECS gateway → daemon subscriptions (control plane; per-account KV).
|
|
if d.ecsService != nil {
|
|
subs = append(subs,
|
|
natsSub{"ecs.CreateCluster", d.handleECSCreateCluster, "spinifex-workers"},
|
|
natsSub{"ecs.DeleteCluster", d.handleECSDeleteCluster, "spinifex-workers"},
|
|
natsSub{"ecs.DescribeClusters", d.handleECSDescribeClusters, "spinifex-workers"},
|
|
natsSub{"ecs.ListClusters", d.handleECSListClusters, "spinifex-workers"},
|
|
natsSub{"ecs.RegisterTaskDefinition", d.handleECSRegisterTaskDefinition, "spinifex-workers"},
|
|
natsSub{"ecs.DeregisterTaskDefinition", d.handleECSDeregisterTaskDefinition, "spinifex-workers"},
|
|
natsSub{"ecs.DescribeTaskDefinition", d.handleECSDescribeTaskDefinition, "spinifex-workers"},
|
|
natsSub{"ecs.ListTaskDefinitions", d.handleECSListTaskDefinitions, "spinifex-workers"},
|
|
natsSub{"ecs.RegisterContainerInstance", d.handleECSRegisterContainerInstance, "spinifex-workers"},
|
|
natsSub{"ecs.DeregisterContainerInstance", d.handleECSDeregisterContainerInstance, "spinifex-workers"},
|
|
natsSub{"ecs.UpdateContainerInstancesState", d.handleECSUpdateContainerInstancesState, "spinifex-workers"},
|
|
natsSub{"ecs.DescribeContainerInstances", d.handleECSDescribeContainerInstances, "spinifex-workers"},
|
|
natsSub{"ecs.ListContainerInstances", d.handleECSListContainerInstances, "spinifex-workers"},
|
|
natsSub{"ecs.RunTask", d.handleECSRunTask, "spinifex-workers"},
|
|
natsSub{"ecs.StartTask", d.handleECSStartTask, "spinifex-workers"},
|
|
natsSub{"ecs.StopTask", d.handleECSStopTask, "spinifex-workers"},
|
|
natsSub{"ecs.DescribeTasks", d.handleECSDescribeTasks, "spinifex-workers"},
|
|
natsSub{"ecs.ListTasks", d.handleECSListTasks, "spinifex-workers"},
|
|
natsSub{"ecs.CreateService", d.handleECSCreateService, "spinifex-workers"},
|
|
natsSub{"ecs.UpdateService", d.handleECSUpdateService, "spinifex-workers"},
|
|
natsSub{"ecs.DeleteService", d.handleECSDeleteService, "spinifex-workers"},
|
|
natsSub{"ecs.DescribeServices", d.handleECSDescribeServices, "spinifex-workers"},
|
|
natsSub{"ecs.ListServices", d.handleECSListServices, "spinifex-workers"},
|
|
natsSub{"ecs.SubmitTaskStateChange", d.handleECSSubmitTaskStateChange, "spinifex-workers"},
|
|
natsSub{"ecs.PollAssignments", d.handleECSPollAssignments, "spinifex-workers"},
|
|
)
|
|
}
|
|
|
|
// ACM gateway → daemon subscriptions (minimal certificate store).
|
|
if d.acmService != nil {
|
|
subs = append(subs,
|
|
natsSub{"acm.ImportCertificate", d.handleACMImportCertificate, "spinifex-workers"},
|
|
natsSub{"acm.DescribeCertificate", d.handleACMDescribeCertificate, "spinifex-workers"},
|
|
natsSub{"acm.ListCertificates", d.handleACMListCertificates, "spinifex-workers"},
|
|
natsSub{"acm.DeleteCertificate", d.handleACMDeleteCertificate, "spinifex-workers"},
|
|
natsSub{"acm.ListTagsForCertificate", d.handleACMListTagsForCertificate, "spinifex-workers"},
|
|
natsSub{"acm.AddTagsToCertificate", d.handleACMAddTagsToCertificate, "spinifex-workers"},
|
|
natsSub{"acm.RemoveTagsFromCertificate", d.handleACMRemoveTagsFromCertificate, "spinifex-workers"},
|
|
)
|
|
}
|
|
|
|
// ECR gateway → daemon subscriptions. The daemon owns the per-account
|
|
// JetStream KV metadata; blob/manifest bytes never traverse these subjects.
|
|
if d.ecrMetaService != nil {
|
|
subs = append(subs,
|
|
natsSub{handlers_ecr.SubjectRepoCreate, d.handleECRRepoCreate, "spinifex-workers"},
|
|
natsSub{handlers_ecr.SubjectRepoDescribe, d.handleECRRepoDescribe, "spinifex-workers"},
|
|
natsSub{handlers_ecr.SubjectRepoList, d.handleECRRepoList, "spinifex-workers"},
|
|
natsSub{handlers_ecr.SubjectRepoDelete, d.handleECRRepoDelete, "spinifex-workers"},
|
|
natsSub{handlers_ecr.SubjectPolicyPut, d.handleECRPolicyPut, "spinifex-workers"},
|
|
natsSub{handlers_ecr.SubjectPolicyGet, d.handleECRPolicyGet, "spinifex-workers"},
|
|
natsSub{handlers_ecr.SubjectPolicyDelete, d.handleECRPolicyDelete, "spinifex-workers"},
|
|
natsSub{handlers_ecr.SubjectLifecyclePut, d.handleECRLifecyclePut, "spinifex-workers"},
|
|
natsSub{handlers_ecr.SubjectLifecycleGet, d.handleECRLifecycleGet, "spinifex-workers"},
|
|
natsSub{handlers_ecr.SubjectLifecycleDelete, d.handleECRLifecycleDelete, "spinifex-workers"},
|
|
natsSub{handlers_ecr.SubjectTagPut, d.handleECRTagPut, "spinifex-workers"},
|
|
natsSub{handlers_ecr.SubjectTagGet, d.handleECRTagGet, "spinifex-workers"},
|
|
natsSub{handlers_ecr.SubjectTagList, d.handleECRTagList, "spinifex-workers"},
|
|
natsSub{handlers_ecr.SubjectTagDelete, d.handleECRTagDelete, "spinifex-workers"},
|
|
natsSub{handlers_ecr.SubjectManifestPut, d.handleECRManifestPut, "spinifex-workers"},
|
|
natsSub{handlers_ecr.SubjectManifestDescribe, d.handleECRManifestDescribe, "spinifex-workers"},
|
|
natsSub{handlers_ecr.SubjectManifestList, d.handleECRManifestList, "spinifex-workers"},
|
|
natsSub{handlers_ecr.SubjectManifestDelete, d.handleECRManifestDelete, "spinifex-workers"},
|
|
natsSub{handlers_ecr.SubjectUploadCreate, d.handleECRUploadCreate, "spinifex-workers"},
|
|
natsSub{handlers_ecr.SubjectUploadGet, d.handleECRUploadGet, "spinifex-workers"},
|
|
natsSub{handlers_ecr.SubjectUploadUpdate, d.handleECRUploadUpdate, "spinifex-workers"},
|
|
natsSub{handlers_ecr.SubjectUploadDelete, d.handleECRUploadDelete, "spinifex-workers"},
|
|
)
|
|
}
|
|
|
|
// EIP operations require external IPAM (pool mode). Only subscribe when available;
|
|
// without a subscriber the gateway returns a NATS timeout → clean error to the client.
|
|
if d.eipService != nil {
|
|
subs = append(subs,
|
|
natsSub{"ec2.AllocateAddress", d.handleEC2AllocateAddress, "spinifex-workers"},
|
|
natsSub{"ec2.ReleaseAddress", d.handleEC2ReleaseAddress, "spinifex-workers"},
|
|
natsSub{"ec2.AssociateAddress", d.handleEC2AssociateAddress, "spinifex-workers"},
|
|
natsSub{"ec2.DisassociateAddress", d.handleEC2DisassociateAddress, "spinifex-workers"},
|
|
natsSub{"ec2.DescribeAddresses", d.handleEC2DescribeAddresses, "spinifex-workers"},
|
|
natsSub{"ec2.DescribeAddressesAttribute", d.handleEC2DescribeAddressesAttribute, "spinifex-workers"},
|
|
)
|
|
}
|
|
|
|
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 bootstraps the daemon in two phases (DDIL Tier 1): startLocal brings
|
|
// up HTTPS + local state without NATS, then startCluster retries NATS
|
|
// indefinitely and joins the cluster once connected.
|
|
func (d *Daemon) Start() error {
|
|
if err := d.startLocal(); err != nil {
|
|
return err
|
|
}
|
|
|
|
d.setupShutdown()
|
|
|
|
d.shutdownWg.Go(func() {
|
|
if err := d.startCluster(); err != nil {
|
|
slog.Warn("Cluster bootstrap aborted", "err", err)
|
|
}
|
|
})
|
|
|
|
d.awaitShutdown()
|
|
return nil
|
|
}
|
|
|
|
// startLocal performs the no-NATS bootstrap. Failures here are fatal (local
|
|
// config errors that retry cannot fix). The daemon is reachable via /local/*
|
|
// and /health once this returns.
|
|
//
|
|
// DDIL §1e-audit: no JetStream KV must be touched here. All KV buckets are
|
|
// initialised in startCluster. assertNoClusterServicesInitialised enforces this.
|
|
func (d *Daemon) startLocal() error {
|
|
// ClusterManager serves /health and /local/* over HTTPS. NATS-independent.
|
|
if err := d.ClusterManager(); err != nil {
|
|
return fmt.Errorf("failed to start cluster manager: %w", err)
|
|
}
|
|
|
|
// Detect management bridge for system instance control plane NICs.
|
|
mgmtBridge := "br-mgmt"
|
|
if d.config.Daemon.MgmtBridge != "" {
|
|
mgmtBridge = d.config.Daemon.MgmtBridge
|
|
}
|
|
bridgeIP, bridgeErr := host.GetBridgeIPv4(mgmtBridge)
|
|
if bridgeErr != nil {
|
|
slog.Warn("Management bridge not detected, system instances will not get mgmt NIC", "bridge", mgmtBridge, "err", bridgeErr)
|
|
} else if bridgeIP == "" {
|
|
slog.Warn("Management bridge not found, system instances will not get mgmt NIC", "bridge", mgmtBridge)
|
|
} else {
|
|
d.mgmtBridgeIP = bridgeIP
|
|
alloc, allocErr := NewMgmtIPAllocator(bridgeIP)
|
|
if allocErr != nil {
|
|
slog.Error("Failed to create mgmt IP allocator", "bridgeIP", bridgeIP, "err", allocErr)
|
|
} else {
|
|
d.mgmtIPAllocator = alloc
|
|
slog.Info("Management bridge detected", "bridge", mgmtBridge, "ip", bridgeIP)
|
|
}
|
|
}
|
|
|
|
// Initialise OVS network plumber (no NATS dep).
|
|
if d.networkPlumber == nil {
|
|
d.networkPlumber = host.NewOVSPlumber()
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Recover local instance state from disk. Fatal on corruption — orphaned VMs.
|
|
if err := d.LoadState(); err != nil {
|
|
return fmt.Errorf("load local instance state: %w", err)
|
|
}
|
|
slog.Info("Loaded local instance state", "instance count", d.vmMgr.Count())
|
|
|
|
// Rebuild mgmt IP allocator from restored VMs.
|
|
if d.mgmtIPAllocator != nil {
|
|
d.mgmtIPAllocator.Rebuild(d.vmMgr.SnapshotMap())
|
|
slog.Info("Rebuilt mgmt IP allocator from restored instances", "allocated", d.mgmtIPAllocator.AllocatedCount())
|
|
}
|
|
|
|
if err := d.assertNoClusterServicesInitialised(); err != nil {
|
|
return fmt.Errorf("startLocal Tier 1 invariant violated: %w", err)
|
|
}
|
|
|
|
// Peer-health probe is NATS-independent (dials /health directly) and must
|
|
// start here so Mode() can detect partitions even if NATS never connects.
|
|
d.shutdownWg.Go(d.monitorPeerReachability)
|
|
|
|
d.ready.Store(true)
|
|
slog.Info("Daemon local-bootstrap complete", "node", d.node, "elapsed", time.Since(d.startTime).Round(time.Second))
|
|
return nil
|
|
}
|
|
|
|
// assertNoClusterServicesInitialised enforces the DDIL §1e-audit invariant:
|
|
// no NATS-dependent handle may exist at the end of startLocal.
|
|
func (d *Daemon) assertNoClusterServicesInitialised() error {
|
|
switch {
|
|
case d.natsConn != nil:
|
|
return errors.New("d.natsConn must be nil before startCluster")
|
|
case d.jsManager != nil:
|
|
return errors.New("d.jsManager must be nil before startCluster")
|
|
case d.instanceService != nil:
|
|
return errors.New("d.instanceService must be nil before startCluster")
|
|
case d.imageService != nil:
|
|
return errors.New("d.imageService must be nil before startCluster")
|
|
case d.snapshotService != nil:
|
|
return errors.New("d.snapshotService must be nil before startCluster")
|
|
case d.volumeService != nil:
|
|
return errors.New("d.volumeService must be nil before startCluster")
|
|
case d.eigwService != nil:
|
|
return errors.New("d.eigwService must be nil before startCluster")
|
|
case d.igwService != nil:
|
|
return errors.New("d.igwService must be nil before startCluster")
|
|
case d.placementGroupService != nil:
|
|
return errors.New("d.placementGroupService must be nil before startCluster")
|
|
case d.vpcService != nil:
|
|
return errors.New("d.vpcService must be nil before startCluster")
|
|
case d.routeTableService != nil:
|
|
return errors.New("d.routeTableService must be nil before startCluster")
|
|
case d.natGatewayService != nil:
|
|
return errors.New("d.natGatewayService must be nil before startCluster")
|
|
case d.externalIPAM != nil:
|
|
return errors.New("d.externalIPAM must be nil before startCluster")
|
|
case d.eipService != nil:
|
|
return errors.New("d.eipService must be nil before startCluster")
|
|
case d.accountService != nil:
|
|
return errors.New("d.accountService must be nil before startCluster")
|
|
case d.elbv2Service != nil:
|
|
return errors.New("d.elbv2Service must be nil before startCluster")
|
|
case d.eksService != nil:
|
|
return errors.New("d.eksService must be nil before startCluster")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// startCluster retries NATS indefinitely and initialises all cluster-scoped
|
|
// services. Errors are logged, not fatal. All JetStream KV buckets (DDIL
|
|
// §1e-audit) must be initialised here, never in startLocal.
|
|
func (d *Daemon) startCluster() error {
|
|
if os.Getenv("SPINIFEX_REQUIRE_NATS") == "1" {
|
|
// §1d-strict opt-in: bounded first connect, abort on timeout. Restores
|
|
// the pre-DDIL fail-fast UX for dev/test/single-node deploys without
|
|
// flipping the prod default (which would re-introduce the SPOF that 1d
|
|
// removed).
|
|
if err := d.connectNATS(utils.WithMaxWait(d.requireNATSTimeout)); err != nil {
|
|
slog.Error("SPINIFEX_REQUIRE_NATS=1 set, NATS connect failed within 30s, aborting", "err", err, "timeout", d.requireNATSTimeout)
|
|
d.exitFunc(1)
|
|
return fmt.Errorf("connect NATS (strict): %w", err)
|
|
}
|
|
} else if err := d.connectNATS(); err != nil {
|
|
return fmt.Errorf("connect NATS: %w", err)
|
|
}
|
|
|
|
if err := d.initJetStream(); err != nil {
|
|
return fmt.Errorf("initialize JetStream: %w", err)
|
|
}
|
|
|
|
// Remove the obsolete spinifex-dhcp-leases bucket (idempotent).
|
|
if js, jsErr := d.natsConn.JetStream(); jsErr == nil {
|
|
if err := utils.DeleteKVBucketIfExists(js, "spinifex-dhcp-leases"); err != nil {
|
|
slog.Warn("Failed to delete obsolete spinifex-dhcp-leases KV bucket", "err", err)
|
|
}
|
|
}
|
|
|
|
// Enable OVN native IPsec when configured (idempotent).
|
|
if d.clusterConfig != nil && d.clusterConfig.Network.IPSecEnabled {
|
|
if err := host.EnableOVNIPSec(d.configPath, d.clusterConfig); err != nil {
|
|
slog.Warn("Failed to enable OVN native IPsec; intra-AZ Geneve will be plaintext", "err", 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(),
|
|
admin.DialTarget(d.config.NATS.Host),
|
|
admin.DialTarget(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(admin.DialTarget(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, store, d.vmMgr, d.resourceMgr, d.jsManager)
|
|
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.placementGroupService, err = initServiceWithRetry("placement group service", func() (*handlers_ec2_placementgroup.PlacementGroupServiceImpl, error) {
|
|
return handlers_ec2_placementgroup.NewPlacementGroupServiceImplWithNATS(d.config, d.natsConn)
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize placement group 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.routeTableService, err = initServiceWithRetry("RouteTable service", func() (*handlers_ec2_routetable.RouteTableServiceImpl, error) {
|
|
return handlers_ec2_routetable.NewRouteTableServiceImplWithNATS(d.config, d.natsConn)
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize RouteTable service: %w", err)
|
|
}
|
|
|
|
// Wire IGW attach/detach to RT-aware per-subnet egress gate fan-out.
|
|
d.igwService.SetGatePublisher(d.routeTableService)
|
|
|
|
d.natGatewayService, err = initServiceWithRetry("NatGateway service", func() (*handlers_ec2_natgw.NatGatewayServiceImpl, error) {
|
|
return handlers_ec2_natgw.NewNatGatewayServiceImplWithNATS(d.natsConn)
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize NatGateway service: %w", err)
|
|
}
|
|
|
|
// Initialize external IPAM for pool mode (per-VM public IPs).
|
|
if d.clusterConfig != nil && d.clusterConfig.Network.ExternalMode == "pool" {
|
|
js, jsErr := d.natsConn.JetStream()
|
|
if jsErr != nil {
|
|
slog.Warn("Failed to get JetStream for external IPAM", "err", jsErr)
|
|
} else {
|
|
var pools []handlers_ec2_vpc.ExternalPoolConfig
|
|
anyDHCP := false
|
|
for _, p := range d.clusterConfig.Network.ExternalPools {
|
|
pools = append(pools, handlers_ec2_vpc.ExternalPoolConfig{
|
|
Name: p.Name,
|
|
Source: p.Source,
|
|
BindBridge: p.BindBridge,
|
|
RangeStart: p.RangeStart,
|
|
RangeEnd: p.RangeEnd,
|
|
Gateway: p.Gateway,
|
|
GatewayIP: p.GatewayIP,
|
|
PrefixLen: p.PrefixLen,
|
|
Region: p.Region,
|
|
AZ: p.AZ,
|
|
GwLrpRangeStart: p.GwLrpRangeStart,
|
|
GwLrpRangeEnd: p.GwLrpRangeEnd,
|
|
})
|
|
if p.Source == "dhcp" {
|
|
anyDHCP = true
|
|
}
|
|
}
|
|
d.externalIPAM, err = handlers_ec2_vpc.NewExternalIPAM(js, pools)
|
|
if err != nil {
|
|
slog.Warn("Failed to initialize external IPAM", "err", err)
|
|
} else {
|
|
if anyDHCP {
|
|
dhcpClient := dhcp.NewNATSClient(d.natsConn, 0)
|
|
if dhcpErr := d.externalIPAM.EnableDHCP(dhcpClient); dhcpErr != nil {
|
|
slog.Warn("Failed to enable DHCP allocator on external IPAM", "err", dhcpErr)
|
|
}
|
|
}
|
|
slog.Info("External IPAM initialized", "mode", d.clusterConfig.Network.ExternalMode, "pools", len(pools), "dhcp", anyDHCP)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize EIP service if external IPAM is available
|
|
if d.externalIPAM != nil && d.vpcService != nil {
|
|
eipSvc, eipErr := handlers_ec2_eip.NewEIPServiceImpl(d.natsConn, d.externalIPAM, d.vpcService)
|
|
if eipErr != nil {
|
|
slog.Warn("Failed to initialize EIP service", "err", eipErr)
|
|
} else {
|
|
d.eipService = eipSvc
|
|
slog.Info("EIP service initialized")
|
|
}
|
|
|
|
// Inject external IPAM + EIP KV into VPC service so DeleteNetworkInterface
|
|
// can release auto-assigned public IPs and NAT rules.
|
|
eipJS, eipJSErr := d.natsConn.JetStream()
|
|
if eipJSErr != nil {
|
|
slog.Warn("Failed to get JetStream for VPC external IPAM injection", "err", eipJSErr)
|
|
} else {
|
|
eipKV, eipKVErr := utils.GetOrCreateKVBucket(eipJS, handlers_ec2_eip.KVBucketEIPs, 10)
|
|
if eipKVErr != nil {
|
|
slog.Warn("Failed to get EIP KV bucket for VPC service", "err", eipKVErr)
|
|
} else {
|
|
d.vpcService.SetExternalIPAM(d.externalIPAM, eipKV)
|
|
}
|
|
}
|
|
}
|
|
|
|
d.instanceService.SetTerminationDeps(d.volumeService, d.vpcService, d.externalIPAM)
|
|
d.instanceService.SetRunInstancesDeps(d.imageService, d.keyService, &daemonENICreator{d: d}, d.externalIPAM)
|
|
|
|
if d.gpuManager != nil {
|
|
d.instanceService.SetGPUClaimer(&daemonGPUClaimer{d: d})
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
d.elbv2Service, err = initServiceWithRetry("ELBv2 service", func() (*handlers_elbv2.ELBv2ServiceImpl, error) {
|
|
return handlers_elbv2.NewELBv2ServiceImplWithNATS(d.config, d.natsConn)
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize ELBv2 service: %w", err)
|
|
}
|
|
if d.vpcService != nil {
|
|
d.elbv2Service.VPCService = d.vpcService
|
|
}
|
|
|
|
// Route system VM launches through NATS so they fan out across the cluster.
|
|
d.elbv2Service.InstanceLauncher = handlers_elbv2.NewNATSSystemInstanceLauncher(d.natsConn, 0)
|
|
|
|
d.wireLBAgentConfig()
|
|
|
|
d.elbv2Service.SetSystemInstanceTypeFunc(func() string {
|
|
return "sys.micro"
|
|
})
|
|
|
|
// Invalidate stale "healthy" target state from before restart. Best-effort.
|
|
if err := d.elbv2Service.ResetTargetHealthOnStartup(context.Background()); err != nil {
|
|
slog.Warn("ELBv2: target-health reset failed; continuing with stale state",
|
|
"err", err)
|
|
}
|
|
|
|
d.eksService, err = initServiceWithRetry("EKS service", func() (*handlers_eks.EKSServiceImpl, error) {
|
|
return handlers_eks.NewEKSServiceImpl(d.buildEKSServiceDeps())
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize EKS service: %w", err)
|
|
}
|
|
|
|
// ECS control plane: per-account KV-backed handlers + a leader-elected
|
|
// scheduler goroutine that owns the Layer-2 bus subscriptions and heartbeat
|
|
// reaper. The scheduler is disabled (handlers still serve) when JetStream is
|
|
// unavailable.
|
|
d.ecsService = handlers_ecs.NewService(d.natsConn, d.config.Region, d.clusterConfig.AWS.InternalSuffix)
|
|
if js, jsErr := d.natsConn.JetStream(); jsErr != nil {
|
|
slog.Warn("ECS scheduler disabled: JetStream unavailable", "err", jsErr)
|
|
} else if _, lbErr := handlers_ecs.InitLeaderBucket(js); lbErr != nil {
|
|
slog.Warn("ECS scheduler disabled: leader bucket init failed", "err", lbErr)
|
|
} else {
|
|
d.ecsScheduler = handlers_ecs.NewScheduler(d.natsConn, d.ecsService, d.node)
|
|
d.shutdownWg.Go(func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
slog.Error("ECS scheduler goroutine panicked", "recover", r)
|
|
}
|
|
}()
|
|
d.ecsScheduler.Run(d.ctx)
|
|
})
|
|
}
|
|
|
|
d.acmService, err = initServiceWithRetry("ACM service", func() (*handlers_acm.ACMServiceImpl, error) {
|
|
return handlers_acm.NewACMServiceImplWithNATS(d.config, d.natsConn)
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to initialize ACM service: %w", err)
|
|
}
|
|
|
|
// ECR metadata service: owns per-account JetStream KV for repos, tags,
|
|
// manifest records and upload-state CAS. Disabled (gateway returns NATS
|
|
// timeouts) when JetStream is unavailable.
|
|
if js, jsErr := d.natsConn.JetStream(); jsErr != nil {
|
|
slog.Warn("ECR metadata service disabled: JetStream unavailable", "err", jsErr)
|
|
} else {
|
|
d.ecrMetaService = handlers_ecr.NewKVMetaService(js)
|
|
}
|
|
|
|
if err := d.eksService.SpawnRegisteredReconcilers(); err != nil {
|
|
slog.Warn("EKS: SpawnRegisteredReconcilers failed", "err", err)
|
|
}
|
|
|
|
// Ensure default VPC exists for system and admin accounts
|
|
// (matches AWS: every account has a default VPC with IGW + default SG)
|
|
if d.vpcService != nil {
|
|
failedDefaultVPCs := map[string]struct{}{}
|
|
for _, accountID := range []string{utils.GlobalAccountID, admin.DefaultAccountID()} {
|
|
// Pass bootstrap IDs for the admin account so EnsureDefaultVPC uses
|
|
// the same IDs that admin init wrote to [bootstrap] in spinifex.toml.
|
|
var opts []handlers_ec2_vpc.BootstrapIDs
|
|
if accountID == admin.DefaultAccountID() && d.clusterConfig != nil && d.clusterConfig.Bootstrap.VpcId != "" {
|
|
opts = append(opts, handlers_ec2_vpc.BootstrapIDs{
|
|
VpcId: d.clusterConfig.Bootstrap.VpcId,
|
|
SubnetId: d.clusterConfig.Bootstrap.SubnetId,
|
|
})
|
|
}
|
|
if _, err := d.vpcService.EnsureDefaultVPC(accountID, opts...); err != nil {
|
|
slog.Error("Failed to ensure default VPC", "accountID", accountID, "error", err)
|
|
failedDefaultVPCs[accountID] = struct{}{}
|
|
}
|
|
}
|
|
// Skip IGW/route setup for accounts whose default VPC failed; otherwise
|
|
// we'd attach infrastructure to a half-built VPC (no default SG yet).
|
|
d.ensureDefaultVPCInfrastructure(failedDefaultVPCs)
|
|
}
|
|
|
|
d.vmMgr.SetDeps(d.buildVMManagerDeps())
|
|
|
|
d.waitForClusterReady()
|
|
d.upgradeJetStreamReplicas()
|
|
if err := d.restoreInstances(); err != nil {
|
|
return fmt.Errorf("restore instances: %w", err)
|
|
}
|
|
|
|
// Rebuild mgmt IP allocator so already-allocated IPs aren't reused.
|
|
if d.mgmtIPAllocator != nil {
|
|
d.mgmtIPAllocator.Rebuild(d.vmMgr.SnapshotMap())
|
|
slog.Info("Rebuilt mgmt IP allocator from restored instances", "allocated", d.mgmtIPAllocator.AllocatedCount())
|
|
}
|
|
|
|
if err := d.subscribeAll(); err != nil {
|
|
return fmt.Errorf("failed to subscribe to NATS topics: %w", err)
|
|
}
|
|
|
|
// Initialize per-instance-type NATS subscriptions for capacity-aware routing.
|
|
d.resourceMgr.initSubscriptions(d.natsConn, d.handleEC2RunInstances, d.handleSystemLaunchInstance, d.node)
|
|
|
|
d.startHeartbeat()
|
|
d.vmMgr.StartPendingWatchdog(d.ctx)
|
|
|
|
// Reality→desired GC backstop (ADR-0003 §3): finish teardown interrupted by
|
|
// a node-down mid-cascade and purge completed terminated records. The volume
|
|
// data-safety reaper (ADR-0005 §3) rides the same backstop but only marks +
|
|
// alarms — it never deletes volume data.
|
|
if d.jsManager != nil {
|
|
reapers := []vm.Reaper{d.vmMgr.NewTerminatedTeardownReaper()}
|
|
if eniRec := d.newENIReconciler(); eniRec != nil {
|
|
reapers = append(reapers, eniRec)
|
|
}
|
|
if d.volumeService != nil {
|
|
reapers = append(reapers, d.volumeService.NewVolumeLeakReaper(d.leakedVolumeInstances))
|
|
}
|
|
if d.eksService != nil {
|
|
reapers = append(reapers, d.eksService.NewBillableReaper(d.nodeRunningVMs))
|
|
reapers = append(reapers, d.eksService.NewDeletingReaper())
|
|
}
|
|
gc := vm.NewGarbageCollector(d.jsManager.KVHealthy, reapers...)
|
|
gc.Start(d.ctx)
|
|
}
|
|
|
|
d.ready.Store(true)
|
|
slog.Info("Daemon fully initialized", "node", d.node, "startupTime", time.Since(d.startTime).Round(time.Second))
|
|
|
|
d.setupReload()
|
|
d.setupShutdown()
|
|
d.awaitShutdown()
|
|
|
|
return nil
|
|
}
|
|
|
|
// leakedVolumeInstances returns the set of instance IDs this node owns whose
|
|
// teardown leaked a volume — terminated here with a failed volumes-teardown.
|
|
// The volume data-safety reaper marks (never deletes) volumes still attached to
|
|
// these definitively-gone instances. Keying on this node's terminated set keeps
|
|
// the shared-store scan from false-marking another node's live-instance volume.
|
|
func (d *Daemon) leakedVolumeInstances() (map[string]bool, error) {
|
|
terminated, err := d.jsManager.ListTerminatedInstances()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
leaked := make(map[string]bool)
|
|
for _, v := range terminated {
|
|
if v.LastNode == d.node && v.Teardown[vm.TeardownVolumes] == string(vm.TeardownFailed) {
|
|
leaked[v.ID] = true
|
|
}
|
|
}
|
|
return leaked, nil
|
|
}
|
|
|
|
// nodeRunningVMs returns this node's running VMs for the EKS billable reaper to
|
|
// scan. A nil stateStore (early init / test) yields an empty set.
|
|
func (d *Daemon) nodeRunningVMs() ([]*vm.VM, error) {
|
|
if d.stateStore == nil {
|
|
return nil, nil
|
|
}
|
|
running, err := d.stateStore.LoadRunningState(d.node)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
vms := make([]*vm.VM, 0, len(running))
|
|
for _, v := range running {
|
|
vms = append(vms, v)
|
|
}
|
|
return vms, nil
|
|
}
|
|
|
|
// connectNATS connects to NATS with infinite retry (cap 60s backoff). Tests
|
|
// override d.natsRetryOpts; extraOpts override any conflicting fields.
|
|
func (d *Daemon) connectNATS(extraOpts ...utils.RetryOption) error {
|
|
opts := append([]utils.RetryOption{
|
|
utils.WithMaxWait(0), // infinite retry; cancelled via d.ctx
|
|
utils.WithMaxRetryDelay(60 * time.Second),
|
|
utils.WithContext(d.ctx),
|
|
utils.WithDisconnectHandler(d.onNATSDisconnect),
|
|
utils.WithReconnectHandler(d.onNATSReconnect),
|
|
utils.WithAttemptErrHandler(func(_ error, _ int) {
|
|
d.natsRetryCount.Add(1)
|
|
}),
|
|
}, d.natsRetryOpts...)
|
|
opts = append(opts, extraOpts...)
|
|
nc, err := utils.ConnectNATSWithRetry(admin.DialTarget(d.config.NATS.Host), d.config.NATS.ACL.Token, d.config.NATS.CACert, opts...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
d.natsConn = nc
|
|
d.natsConnected.Store(true)
|
|
return nil
|
|
}
|
|
|
|
// initJetStream initialises JetStream KV stores, retrying up to 5 minutes to
|
|
// allow late-joining nodes to reach quorum.
|
|
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 {
|
|
d.jsManager.SetSyncObserver(d)
|
|
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)
|
|
}
|
|
|
|
d.stateStore = newStateStoreAdapter(d.jsManager)
|
|
|
|
return nil
|
|
}
|
|
|
|
// upgradeJetStreamReplicas bumps KV_* stream replication to match the cluster
|
|
// size. Runs after all buckets are created and the cluster is ready.
|
|
func (d *Daemon) upgradeJetStreamReplicas() {
|
|
clusterSize := len(d.clusterConfig.Nodes)
|
|
if clusterSize <= 1 || d.jsManager == nil {
|
|
return
|
|
}
|
|
if err := d.jsManager.UpdateReplicas(clusterSize); err != nil {
|
|
slog.Warn("Failed to upgrade JetStream replicas", "targetReplicas", clusterSize, "error", err)
|
|
}
|
|
}
|
|
|
|
// initRetrySleep is the sleep seam used by initServiceWithRetry; tests override it.
|
|
var initRetrySleep = time.Sleep
|
|
|
|
// initServiceWithRetry initialises a service with exponential backoff (500ms→10s)
|
|
// for up to 5 minutes, allowing time for JetStream quorum during cluster restarts.
|
|
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))
|
|
initRetrySleep(retryDelay)
|
|
retryDelay = min(retryDelay*2, 10*time.Second)
|
|
}
|
|
}
|
|
|
|
// waitForClusterReady blocks until viperblock and predastore are reachable,
|
|
// preventing races during VM recovery.
|
|
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 reports whether viperblock is reachable via 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 := admin.DialTarget(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
|
|
}
|
|
|
|
// LoadState loads instance state from disk. Missing file → empty map (fresh
|
|
// install). Corrupt or unknown-schema files are fatal.
|
|
func (d *Daemon) LoadState() error {
|
|
path := d.localStatePath()
|
|
state, err := ReadLocalState(path)
|
|
if err != nil {
|
|
slog.Error("Local state load failed", "path", path, "error", err)
|
|
return fmt.Errorf("read local state: %w", err)
|
|
}
|
|
|
|
if state == nil {
|
|
d.vmMgr.Replace(map[string]*vm.VM{})
|
|
slog.Info("No local state file, starting with empty instance map", "path", path)
|
|
return nil
|
|
}
|
|
|
|
d.vmMgr.Replace(state.VMS)
|
|
slog.Info("Loaded local state", "path", path, "instances", len(state.VMS))
|
|
return nil
|
|
}
|
|
|
|
// restoreInstances delegates to vm.Manager.Restore and syncs the local state
|
|
// file so it matches in-memory state.
|
|
func (d *Daemon) restoreInstances() error {
|
|
d.vmMgr.Restore()
|
|
if err := d.WriteState(); err != nil {
|
|
slog.Error("Failed to persist local state after restore", "error", err)
|
|
}
|
|
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 a SHA256 hash of the shared cluster config.
|
|
func (d *Daemon) computeConfigHash() (string, error) {
|
|
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
|
|
}
|
|
|
|
// ClusterManager starts the HTTPS cluster management server.
|
|
func (d *Daemon) ClusterManager() error {
|
|
daemonHost := d.config.Daemon.Host
|
|
if daemonHost == "" {
|
|
return fmt.Errorf("daemon.host not configured")
|
|
}
|
|
|
|
r := chi.NewRouter()
|
|
|
|
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(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte(`{"error":"failed to compute config hash"}`))
|
|
return
|
|
}
|
|
|
|
serviceHealth := make(map[string]string)
|
|
for _, svc := range d.config.GetServices() {
|
|
serviceHealth[svc] = "ok"
|
|
}
|
|
if !d.config.HasService("nats") {
|
|
if d.natsConn != nil && d.natsConn.IsConnected() {
|
|
serviceHealth["nats"] = "remote_ok"
|
|
} else {
|
|
serviceHealth["nats"] = "remote_unreachable"
|
|
}
|
|
}
|
|
|
|
ovnHealth := host.HealthStatus()
|
|
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")
|
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
slog.Error("Failed to encode health response", "error", err)
|
|
}
|
|
})
|
|
|
|
d.registerLocalRoutes(r)
|
|
|
|
// Resolve relative cert paths against the config directory.
|
|
tlsCert := d.config.Daemon.TLSCert
|
|
tlsKey := d.config.Daemon.TLSKey
|
|
if tlsCert == "" || tlsKey == "" {
|
|
return fmt.Errorf("cluster manager TLS not configured: set daemon.tlscert and daemon.tlskey in config")
|
|
}
|
|
if d.configPath != "" {
|
|
configDir := filepath.Dir(d.configPath)
|
|
if !filepath.IsAbs(tlsCert) {
|
|
tlsCert = filepath.Join(configDir, filepath.Base(tlsCert))
|
|
}
|
|
if !filepath.IsAbs(tlsKey) {
|
|
tlsKey = filepath.Join(configDir, filepath.Base(tlsKey))
|
|
}
|
|
}
|
|
cert, err := tls.LoadX509KeyPair(tlsCert, tlsKey)
|
|
if err != nil {
|
|
return fmt.Errorf("cluster manager load TLS cert: %w", err)
|
|
}
|
|
|
|
tlsConfig := &tls.Config{
|
|
Certificates: []tls.Certificate{cert},
|
|
MinVersion: tls.VersionTLS13,
|
|
CurvePreferences: tlsconfig.Curves,
|
|
}
|
|
|
|
d.clusterServer = &http.Server{
|
|
Addr: daemonHost,
|
|
Handler: r,
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
TLSConfig: tlsConfig,
|
|
}
|
|
|
|
ln, err := tls.Listen("tcp", daemonHost, tlsConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("cluster manager listen on %s: %w", daemonHost, err)
|
|
}
|
|
|
|
go func() {
|
|
slog.Info("Starting cluster manager (TLS)", "host", daemonHost)
|
|
if err := d.clusterServer.Serve(ln); err != nil && err != http.ErrServerClosed {
|
|
slog.Error("Cluster manager failed", "error", err)
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// kvSyncTimeout caps the best-effort KV sync; 1s is above healthy Put latency.
|
|
const kvSyncTimeout = time.Second
|
|
|
|
// localStatePath returns the on-disk path to this daemon's instance state file.
|
|
func (d *Daemon) localStatePath() string {
|
|
if d.config == nil {
|
|
return LocalStatePath("")
|
|
}
|
|
return LocalStatePath(d.config.DataDir)
|
|
}
|
|
|
|
// WriteState persists instance state. Local file is the source of truth; KV is
|
|
// best-effort. Both forms are marshalled inside vmMgr.View to avoid data races.
|
|
func (d *Daemon) WriteState() error {
|
|
d.stateWriteMu.Lock()
|
|
defer d.stateWriteMu.Unlock()
|
|
|
|
var (
|
|
localData, kvData []byte
|
|
marshalErr error
|
|
)
|
|
d.vmMgr.View(func(vms map[string]*vm.VM) {
|
|
localData, marshalErr = MarshalLocalState(vms)
|
|
if marshalErr != nil {
|
|
return
|
|
}
|
|
kvData, marshalErr = marshalInstanceState(vms)
|
|
})
|
|
if marshalErr != nil {
|
|
return fmt.Errorf("marshal state: %w", marshalErr)
|
|
}
|
|
|
|
path := d.localStatePath()
|
|
if err := WriteLocalStateBytes(path, localData); err != nil {
|
|
slog.Error("Local state write failed", "path", path, "error", err)
|
|
return fmt.Errorf("write local state: %w", err)
|
|
}
|
|
d.stateRevision.Add(1)
|
|
|
|
if d.jsManager != nil {
|
|
d.jsManager.WriteStateBytesBestEffort(d.node, kvData, kvSyncTimeout)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// setupReload registers a SIGHUP handler that reloads GPU config without restarting.
|
|
func (d *Daemon) setupReload() {
|
|
d.shutdownWg.Go(func() {
|
|
sigChan := make(chan os.Signal, 1)
|
|
signal.Notify(sigChan, syscall.SIGHUP)
|
|
defer signal.Stop(sigChan)
|
|
for {
|
|
select {
|
|
case <-d.ctx.Done():
|
|
return
|
|
case <-sigChan:
|
|
slog.Info("SIGHUP received — reloading GPU config")
|
|
d.reloadConfig()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// reloadConfig re-reads spinifex.toml and applies any GPU passthrough changes.
|
|
func (d *Daemon) reloadConfig() {
|
|
if d.configPath == "" {
|
|
slog.Warn("SIGHUP: no config path set, cannot reload")
|
|
return
|
|
}
|
|
newCfg, err := config.LoadConfig(d.configPath)
|
|
if err != nil {
|
|
slog.Error("SIGHUP: config reload failed", "err", err)
|
|
return
|
|
}
|
|
newNodeCfg := newCfg.Nodes[d.node]
|
|
d.applyGPUConfig(newNodeCfg.Daemon.GPUPassthrough)
|
|
}
|
|
|
|
// applyGPUConfig activates or deactivates GPU passthrough at runtime.
|
|
// Transition false→true: re-probes hardware, initialises gpuManager, adds g5 types.
|
|
// Transition true→false: refused when GPU instances are running; otherwise tears down.
|
|
func (d *Daemon) applyGPUConfig(enabled bool) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
|
|
wasEnabled := d.gpuManager != nil
|
|
if enabled == wasEnabled {
|
|
slog.Debug("GPU passthrough state unchanged on reload", "passthrough", enabled)
|
|
return
|
|
}
|
|
|
|
if enabled {
|
|
probe := probeGPU()
|
|
d.gpuProbe = probe
|
|
if !probe.Capable {
|
|
slog.Warn("GPU passthrough enable failed: prerequisites not met",
|
|
"iommu", probe.IOMMUActive, "vfio", probe.VFIOPresent, "gpus", len(probe.Devices))
|
|
return
|
|
}
|
|
mgr, models, migProfiles := buildGPUPool(probe.Devices, d.config.Daemon)
|
|
d.gpuManager = mgr
|
|
d.resourceMgr.reloadGPUTypes(models, migProfiles, mgr)
|
|
d.instanceService.SetGPUClaimer(&daemonGPUClaimer{d: d})
|
|
slog.Info("GPU passthrough enabled via config reload", "gpus", len(probe.Devices))
|
|
return
|
|
}
|
|
|
|
// true → false: refuse if instances are running
|
|
if d.gpuManager.AllocatedCount() > 0 {
|
|
slog.Warn("GPU passthrough disable refused: GPU instances are running",
|
|
"allocated", d.gpuManager.AllocatedCount())
|
|
return
|
|
}
|
|
d.gpuManager = nil
|
|
d.instanceService.SetGPUClaimer(nil)
|
|
d.resourceMgr.reloadGPUTypes(nil, nil, nil)
|
|
slog.Info("GPU passthrough disabled via config reload")
|
|
}
|
|
|
|
func (d *Daemon) setupShutdown() {
|
|
d.shutdownWg.Go(func() {
|
|
sigChan := make(chan os.Signal, 1)
|
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
<-sigChan
|
|
slog.Info("Received shutdown signal, cleaning up...")
|
|
|
|
d.cancel()
|
|
|
|
// DDIL Tier 1: SIGTERM alone never stops VMs — the new daemon reattaches
|
|
// via the local state file. VMs stop only after coordinated DRAIN.
|
|
if d.shuttingDown.Load() {
|
|
slog.Info("Coordinated shutdown in progress, skipping VM stop (already handled by DRAIN phase)")
|
|
} else {
|
|
slog.Info("SIGTERM with no coordinated drain — leaving local VMs running for restart recovery")
|
|
d.shuttingDown.Store(true)
|
|
}
|
|
|
|
if d.elbv2Service != nil {
|
|
d.elbv2Service.Close()
|
|
}
|
|
|
|
if d.eksService != nil {
|
|
d.eksService.Shutdown()
|
|
}
|
|
|
|
for _, sub := range d.natsSubscriptions {
|
|
slog.Info("Unsubscribing from NATS", "subject", sub.Subject)
|
|
if err := sub.Unsubscribe(); err != nil {
|
|
slog.Error("Error unsubscribing from NATS", "err", err)
|
|
}
|
|
}
|
|
|
|
if d.jsManager != nil {
|
|
if err := d.jsManager.WriteShutdownMarker(d.node); err != nil {
|
|
slog.Error("Failed to write shutdown marker", "err", err)
|
|
}
|
|
}
|
|
|
|
err := d.WriteState()
|
|
if err != nil {
|
|
slog.Error("Failed to write state", "err", err)
|
|
}
|
|
|
|
// natsConn is nil when NATS was unreachable at startup (DDIL Scenario B).
|
|
if d.natsConn != nil {
|
|
d.natsConn.Close()
|
|
}
|
|
|
|
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")
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 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 rm.canAllocateLocked(instanceType, count)
|
|
}
|
|
|
|
// canAllocateLocked is the lock-free body of canAllocate. Caller must already
|
|
// hold rm.mu for read or write. Extracted so allocate can re-check capacity
|
|
// while holding the write lock without dropping it.
|
|
func (rm *ResourceManager) canAllocateLocked(instanceType *ec2.InstanceTypeInfo, count int) int {
|
|
// GPU capacity is managed exclusively by gpuManager.Claim; don't double-gate
|
|
// on host CPU/memory which is always abundant on GPU-class hardware.
|
|
if instancetypes.IsGPUType(instanceType) {
|
|
return count
|
|
}
|
|
|
|
n := canAllocateCount(
|
|
rm.hostVCPU-rm.reservedVCPU-rm.reservedCRVCPU, rm.allocatedVCPU,
|
|
rm.hostMemGB-rm.reservedMem-rm.reservedCRMem, rm.allocatedMem,
|
|
instanceTypeVCPUs(instanceType),
|
|
rm.instanceMemChargeMiB(instanceType),
|
|
count,
|
|
0, false,
|
|
)
|
|
return rm.liveMemGate(n, instanceType)
|
|
}
|
|
|
|
// liveMemGate clamps n by current MemAvailable, catching overcommit that the
|
|
// static -m budget misses. Returns n unchanged when disabled or on read error.
|
|
func (rm *ResourceManager) liveMemGate(n int, instanceType *ec2.InstanceTypeInfo) int {
|
|
if rm.readMemAvailableGB == nil || n <= 0 {
|
|
return n
|
|
}
|
|
availGB, ok := rm.readMemAvailableGB()
|
|
if !ok {
|
|
return n
|
|
}
|
|
memGB := float64(rm.instanceMemChargeMiB(instanceType)) / 1024.0
|
|
return liveMemCount(n, availGB, rm.reservedMem+rm.reservedCRMem, memGB)
|
|
}
|
|
|
|
// allocate reserves resources for one instance and updates NATS subscriptions.
|
|
// Check and commit run under a single write-lock acquisition; without this,
|
|
// two concurrent callers could both observe free capacity through the read
|
|
// lock and then both commit, overcommitting the host. Multi-instance launch
|
|
// paths loop on allocate per VM, relying on this per-call atomicity.
|
|
func (rm *ResourceManager) allocate(instanceType *ec2.InstanceTypeInfo) error {
|
|
rm.mu.Lock()
|
|
if rm.canAllocateLocked(instanceType, 1) < 1 {
|
|
rm.mu.Unlock()
|
|
instanceTypeName := ""
|
|
if instanceType.InstanceType != nil {
|
|
instanceTypeName = *instanceType.InstanceType
|
|
}
|
|
return fmt.Errorf("insufficient resources for instance type %s", instanceTypeName)
|
|
}
|
|
vCPUs := instanceTypeVCPUs(instanceType)
|
|
memoryGB := float64(rm.instanceMemChargeMiB(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(rm.instanceMemChargeMiB(instanceType)) / 1024.0
|
|
rm.allocatedVCPU -= int(vCPUs)
|
|
rm.allocatedMem -= memoryGB
|
|
rm.mu.Unlock()
|
|
|
|
rm.updateInstanceSubscriptions()
|
|
}
|
|
|
|
var _ handlers_ec2_instance.InstanceTypeAllocator = (*ResourceManager)(nil)
|
|
|
|
// Allocate, Deallocate, CanAllocate satisfy handlers_ec2_instance.InstanceTypeAllocator.
|
|
func (rm *ResourceManager) Allocate(it *ec2.InstanceTypeInfo) error { return rm.allocate(it) }
|
|
func (rm *ResourceManager) Deallocate(it *ec2.InstanceTypeInfo) { rm.deallocate(it) }
|
|
func (rm *ResourceManager) CanAllocate(it *ec2.InstanceTypeInfo, count int) int {
|
|
return rm.canAllocate(it, count)
|
|
}
|
|
|
|
// InstanceTypes returns the shared instance-type map. Callers must not mutate
|
|
// the returned map; reloadGPUTypes mutates it in place under rm.mu.
|
|
func (rm *ResourceManager) InstanceTypes() map[string]*ec2.InstanceTypeInfo {
|
|
return rm.instanceTypes
|
|
}
|
|
|
|
// reloadGPUTypes replaces GPU instance types in the shared map (in-place, so
|
|
// all holders see the update) and refreshes NATS subscriptions. Called on SIGHUP.
|
|
func (rm *ResourceManager) reloadGPUTypes(models []instancetypes.GPUModel, migProfiles []instancetypes.MIGProfileSpec, mgr *gpu.Manager) {
|
|
arch := "x86_64"
|
|
if runtime.GOARCH == "arm64" {
|
|
arch = "arm64"
|
|
}
|
|
|
|
rm.mu.Lock()
|
|
for name, it := range rm.instanceTypes {
|
|
if instancetypes.IsGPUType(it) {
|
|
delete(rm.instanceTypes, name)
|
|
}
|
|
}
|
|
if len(models) > 0 {
|
|
maps.Copy(rm.instanceTypes, instancetypes.GenerateGPUTypes(models, arch))
|
|
}
|
|
if len(migProfiles) > 0 {
|
|
maps.Copy(rm.instanceTypes, instancetypes.GenerateMIGTypes(migProfiles, arch))
|
|
}
|
|
rm.gpuManager = mgr
|
|
rm.mu.Unlock()
|
|
|
|
rm.updateInstanceSubscriptions()
|
|
}
|
|
|
|
func (rm *ResourceManager) initSubscriptions(nc *nats.Conn, handler nats.MsgHandler, systemHandler nats.MsgHandler, nodeID string) {
|
|
rm.natsConn = nc
|
|
rm.handler = handler
|
|
rm.systemHandler = systemHandler
|
|
rm.nodeID = nodeID
|
|
rm.instanceSubs = make(map[string]*nats.Subscription)
|
|
rm.updateInstanceSubscriptions()
|
|
}
|
|
|
|
// updateInstanceSubscriptions subscribes/unsubscribes per-type NATS topics
|
|
// based on current capacity. Customer types use ec2.RunInstances.*; system
|
|
// types use system.LaunchInstance.*. Each gets a queue-group and a node-targeted topic.
|
|
func (rm *ResourceManager) updateInstanceSubscriptions() {
|
|
if rm.natsConn == nil {
|
|
return
|
|
}
|
|
|
|
rm.subsMu.Lock()
|
|
defer rm.subsMu.Unlock()
|
|
|
|
for typeName, typeInfo := range rm.instanceTypes {
|
|
canFit := rm.canAllocate(typeInfo, 1) >= 1
|
|
|
|
subjectRoot := "ec2.RunInstances"
|
|
handler := rm.handler
|
|
queueGroup := "spinifex-workers"
|
|
if instancetypes.IsSystemType(typeName) {
|
|
// System types (sys.micro, etc.) are internal-only — not exposed
|
|
// via the customer EC2 API and use a dedicated subject root so
|
|
// ELBv2 can fan out ALB-VM launches across the cluster.
|
|
if rm.systemHandler == nil {
|
|
continue
|
|
}
|
|
subjectRoot = "system.LaunchInstance"
|
|
handler = rm.systemHandler
|
|
}
|
|
|
|
queueTopic := fmt.Sprintf("%s.%s", subjectRoot, typeName)
|
|
_, subscribed := rm.instanceSubs[queueTopic]
|
|
if canFit && !subscribed {
|
|
sub, err := rm.natsConn.QueueSubscribe(queueTopic, queueGroup, handler)
|
|
if err != nil {
|
|
slog.Error("Failed to subscribe to instance type topic", "topic", queueTopic, "err", err)
|
|
continue
|
|
}
|
|
rm.instanceSubs[queueTopic] = sub
|
|
slog.Debug("Subscribed to instance type", "topic", queueTopic)
|
|
} else if !canFit && subscribed {
|
|
if err := rm.instanceSubs[queueTopic].Unsubscribe(); err != nil {
|
|
slog.Error("Failed to unsubscribe from instance type topic", "topic", queueTopic, "err", err)
|
|
}
|
|
delete(rm.instanceSubs, queueTopic)
|
|
slog.Info("Unsubscribed from instance type (capacity full)", "topic", queueTopic)
|
|
}
|
|
|
|
if rm.nodeID != "" {
|
|
// Node-targeted topic stays subscribed even when capacity is full:
|
|
// a committed reservation (e.g. spread placement) must not fail with
|
|
// "no responders". Capacity is enforced at launch time by allocate().
|
|
nodeTopic := fmt.Sprintf("%s.%s.%s", subjectRoot, typeName, rm.nodeID)
|
|
if _, nodeSubscribed := rm.instanceSubs[nodeTopic]; canFit && !nodeSubscribed {
|
|
sub, err := rm.natsConn.Subscribe(nodeTopic, handler)
|
|
if err != nil {
|
|
slog.Error("Failed to subscribe to node-specific topic", "topic", nodeTopic, "err", err)
|
|
continue
|
|
}
|
|
rm.instanceSubs[nodeTopic] = sub
|
|
slog.Debug("Subscribed to node-specific instance type", "topic", nodeTopic)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// wireLBAgentConfig loads system credentials, resolves the gateway URL,
|
|
// and wires them into the ELBv2 service for LB VM cloud-init injection.
|
|
func (d *Daemon) wireLBAgentConfig() {
|
|
if d.config.Predastore.AccessKey != "" && d.config.Predastore.SecretKey != "" {
|
|
d.systemAccessKey = d.config.Predastore.AccessKey
|
|
d.systemSecretKey = d.config.Predastore.SecretKey
|
|
d.elbv2Service.SystemAccessKey = d.config.Predastore.AccessKey
|
|
d.elbv2Service.SystemSecretKey = d.config.Predastore.SecretKey
|
|
slog.Info("System credentials loaded for LB agent auth")
|
|
} else {
|
|
slog.Warn("System credentials missing from spinifex.toml predastore section — LB VMs will not have SigV4 credentials for agent auth")
|
|
}
|
|
|
|
awsgwBindIP := ""
|
|
if d.config.AWSGW.Host != "" {
|
|
if h, _, splitErr := net.SplitHostPort(d.config.AWSGW.Host); splitErr == nil {
|
|
awsgwBindIP = h
|
|
}
|
|
}
|
|
|
|
advertiseIP := d.config.AdvertiseIP
|
|
|
|
gatewayHost := d.resolveGatewayHost()
|
|
|
|
// Set mgmtRouteVia when AWSGW is only reachable via br-mgmt (multi-node).
|
|
if gatewayHost != "" && gatewayHost == awsgwBindIP && d.mgmtBridgeIP != "" && awsgwBindIP != advertiseIP {
|
|
d.mgmtRouteVia = awsgwBindIP
|
|
}
|
|
|
|
gatewayPort := "9999"
|
|
if d.config.AWSGW.Host != "" {
|
|
if _, port, splitErr := net.SplitHostPort(d.config.AWSGW.Host); splitErr == nil && port != "" {
|
|
gatewayPort = port
|
|
}
|
|
}
|
|
|
|
if gatewayHost != "" {
|
|
gatewayURL := "https://" + net.JoinHostPort(gatewayHost, gatewayPort)
|
|
d.elbv2Service.GatewayURL = gatewayURL
|
|
slog.Info("LB agent gateway URL configured", "url", gatewayURL)
|
|
} else {
|
|
slog.Error("LB agent gateway URL not configured: no reachable host found — CreateLoadBalancer will fail until --advertise or br-mgmt is configured",
|
|
"awsgwBindIP", awsgwBindIP, "mgmtBridgeIP", d.mgmtBridgeIP, "advertiseIP", advertiseIP)
|
|
}
|
|
|
|
if d.mgmtRouteVia != "" {
|
|
d.elbv2Service.MgmtRouteGateway = d.mgmtBridgeIP
|
|
d.elbv2Service.MgmtRouteTarget = d.mgmtRouteVia
|
|
}
|
|
|
|
d.elbv2Service.MgmtBridgeIP = d.mgmtBridgeIP
|
|
d.elbv2Service.AdvertiseIP = advertiseIP
|
|
|
|
if d.config.NATS.CACert != "" {
|
|
if caBytes, err := os.ReadFile(d.config.NATS.CACert); err == nil {
|
|
d.elbv2Service.CACert = string(caBytes)
|
|
slog.Info("CA cert loaded for LB agent TLS", "path", d.config.NATS.CACert)
|
|
} else {
|
|
slog.Warn("Failed to read CA cert for LB agent TLS", "path", d.config.NATS.CACert, "err", err)
|
|
}
|
|
} else {
|
|
slog.Warn("NATS CACert not configured — direct-boot LB VMs will not verify AWSGW TLS")
|
|
}
|
|
}
|
|
|
|
// buildGPUPool partitions GPU devices into whole-GPU and MIG entries, constructs
|
|
// a Manager, and returns models and MIG profile specs for instance-type generation.
|
|
func buildGPUPool(devices []gpu.GPUDevice, cfg config.DaemonConfig) (*gpu.Manager, []instancetypes.GPUModel, []instancetypes.MIGProfileSpec) {
|
|
type migEntry struct {
|
|
dev gpu.GPUDevice
|
|
existing []gpu.MIGInstance // non-nil = restart recovery; nil = fresh free GPU
|
|
}
|
|
|
|
var wholeGPU []gpu.GPUDevice
|
|
var migEntries []migEntry
|
|
var migProfiles []instancetypes.MIGProfileSpec
|
|
seenProfiles := make(map[string]bool)
|
|
recoveredSlices, freeMIGGPUs := 0, 0
|
|
|
|
for _, dev := range devices {
|
|
if !dev.MIGCapable || !dev.MIGEnabled {
|
|
if dev.MIGCapable && !dev.MIGEnabled {
|
|
slog.Warn("MIG-capable GPU but MIG mode not active; using whole-GPU passthrough",
|
|
"gpu", dev.PCIAddress, "hint", "run 'spx admin gpu mig enable'")
|
|
}
|
|
wholeGPU = append(wholeGPU, dev)
|
|
continue
|
|
}
|
|
|
|
// Collect available profiles for MIG instance-type generation.
|
|
profiles, err := gpu.ListProfiles(dev.PCIAddress)
|
|
if err != nil {
|
|
slog.Warn("Could not list MIG profiles; GPU will not advertise MIG types",
|
|
"gpu", dev.PCIAddress, "err", err)
|
|
}
|
|
for _, p := range profiles {
|
|
if !seenProfiles[p.Name] {
|
|
seenProfiles[p.Name] = true
|
|
migProfiles = append(migProfiles, instancetypes.MIGProfileSpec{
|
|
Name: p.Name, MemoryMiB: p.MemoryMiB,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Re-discover existing instances from a previous daemon run.
|
|
existing, listErr := gpu.ListInstances(dev.PCIAddress)
|
|
if listErr != nil {
|
|
slog.Error("MIG list instances failed, falling back to whole-GPU passthrough",
|
|
"gpu", dev.PCIAddress, "err", listErr)
|
|
wholeGPU = append(wholeGPU, dev)
|
|
continue
|
|
}
|
|
migEntries = append(migEntries, migEntry{dev: dev, existing: existing})
|
|
if len(existing) > 0 {
|
|
recoveredSlices += len(existing)
|
|
} else {
|
|
freeMIGGPUs++
|
|
}
|
|
}
|
|
|
|
var models []instancetypes.GPUModel
|
|
for _, dev := range wholeGPU {
|
|
models = append(models, resolveGPUModel(dev, cfg.GPUModelOverrides))
|
|
}
|
|
|
|
mgr := gpu.NewManager(wholeGPU)
|
|
for _, me := range migEntries {
|
|
if len(me.existing) > 0 {
|
|
mgr.AddMIGInstances(me.dev, me.existing)
|
|
} else {
|
|
mgr.AddMIGGPU(me.dev)
|
|
}
|
|
}
|
|
|
|
slog.Info("GPU pool built",
|
|
"whole_gpu", len(wholeGPU), "mig_free", freeMIGGPUs,
|
|
"mig_slices_recovered", recoveredSlices, "mig_profiles", len(migProfiles))
|
|
return mgr, models, migProfiles
|
|
}
|
|
|
|
// resolveGPUModel maps a GPU device to an instance type model. Overrides take
|
|
// priority, then the production model list, then a g5 default.
|
|
func resolveGPUModel(dev gpu.GPUDevice, overrides []config.GPUModelOverride) instancetypes.GPUModel {
|
|
for i := range overrides {
|
|
o := &overrides[i]
|
|
if o.VendorID == dev.VendorID && o.DeviceID == dev.DeviceID {
|
|
return instancetypes.GPUModel{
|
|
VendorID: o.VendorID,
|
|
DeviceID: o.DeviceID,
|
|
Family: o.Family,
|
|
Manufacturer: o.Manufacturer,
|
|
Name: o.Name,
|
|
MemoryMiB: o.MemoryMiB,
|
|
}
|
|
}
|
|
}
|
|
if m := instancetypes.GPUModelForVendorDevice(dev.VendorID, dev.DeviceID); m != nil {
|
|
return *m
|
|
}
|
|
name := dev.Model
|
|
if name == "" {
|
|
name = fmt.Sprintf("GPU %s:%s", dev.VendorID, dev.DeviceID)
|
|
}
|
|
return instancetypes.GPUModel{
|
|
VendorID: dev.VendorID,
|
|
DeviceID: dev.DeviceID,
|
|
Family: "g5",
|
|
Manufacturer: gpuVendorDisplayName(dev.Vendor),
|
|
Name: name,
|
|
MemoryMiB: dev.MemoryMiB,
|
|
}
|
|
}
|
|
|
|
// gpuXVGAEnabled returns true when the QEMU device should include x-vga=on.
|
|
// Consumer GPUs default to true; known datacenter/compute cards default to false.
|
|
// A GPUModelOverride with XVGAOff=true forces false regardless of the model table.
|
|
func gpuXVGAEnabled(dev *gpu.GPUDevice, overrides []config.GPUModelOverride) bool {
|
|
for _, o := range overrides {
|
|
if o.VendorID == dev.VendorID && o.DeviceID == dev.DeviceID {
|
|
return !o.XVGAOff
|
|
}
|
|
}
|
|
return !gpu.IsComputeGPU(dev.VendorID, dev.DeviceID)
|
|
}
|
|
|
|
func gpuVendorDisplayName(v gpu.Vendor) string {
|
|
switch v {
|
|
case gpu.VendorNVIDIA:
|
|
return "NVIDIA"
|
|
case gpu.VendorAMD:
|
|
return "AMD"
|
|
case gpu.VendorIntel:
|
|
return "Intel"
|
|
default:
|
|
return "Unknown"
|
|
}
|
|
}
|
|
|
|
// gpuProbeResult holds the outcome of the startup GPU hardware probe.
|
|
type gpuProbeResult struct {
|
|
Capable bool // true when Devices, IOMMUActive, and VFIOPresent are all satisfied
|
|
IOMMUActive bool
|
|
VFIOPresent bool
|
|
Devices []gpu.GPUDevice
|
|
}
|
|
|
|
// probeGPU discovers GPU hardware and checks passthrough prerequisites (read-only).
|
|
func probeGPU() gpuProbeResult {
|
|
var r gpuProbeResult
|
|
|
|
devices, err := gpu.Discover()
|
|
if err != nil {
|
|
slog.Debug("GPU probe: discover failed", "err", err)
|
|
}
|
|
r.Devices = devices
|
|
|
|
// IOMMU is active when the kernel has populated iommu_groups in sysfs.
|
|
groups, err := os.ReadDir("/sys/kernel/iommu_groups")
|
|
r.IOMMUActive = err == nil && len(groups) > 0
|
|
|
|
// vfio_pci module is present when its sysfs module directory exists.
|
|
_, err = os.Stat("/sys/module/vfio_pci")
|
|
r.VFIOPresent = err == nil
|
|
|
|
// MIG-enabled GPUs are capable without vfio-pci: the NVIDIA driver owns
|
|
// isolation via the mdev subsystem, so vfio-pci is not required.
|
|
hasMIG := false
|
|
for _, d := range r.Devices {
|
|
if d.MIGEnabled {
|
|
hasMIG = true
|
|
break
|
|
}
|
|
}
|
|
r.Capable = len(r.Devices) > 0 && ((r.IOMMUActive && r.VFIOPresent) || hasMIG)
|
|
return r
|
|
}
|