Files
spinifex/hive/daemon/daemon_handlers_instance.go
T
2026-03-13 05:27:28 +00:00

1085 lines
40 KiB
Go

package daemon
import (
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/hive/hive/awserrors"
"github.com/mulgadc/hive/hive/qmp"
"github.com/mulgadc/hive/hive/types"
"github.com/mulgadc/hive/hive/utils"
"github.com/mulgadc/hive/hive/vm"
"github.com/nats-io/nats.go"
)
// handleEC2RunInstances processes incoming EC2 RunInstances requests
func (d *Daemon) handleEC2RunInstances(msg *nats.Msg) {
slog.Debug("Received message on subject", "subject", msg.Subject)
slog.Debug("Message data", "data", string(msg.Data))
// Extract account ID from NATS header
accountID := utils.AccountIDFromMsg(msg)
if accountID == "" {
slog.Error("handleEC2RunInstances: missing account ID in NATS header")
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
// Initialize runInstancesInput before unmarshaling into it
runInstancesInput := &ec2.RunInstancesInput{}
errResp := utils.UnmarshalJsonPayload(runInstancesInput, msg.Data)
if errResp != nil {
if err := msg.Respond(errResp); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
slog.Error("Request does not match RunInstancesInput")
return
}
slog.Info("Processing RunInstances request for instance type", "instanceType", *runInstancesInput.InstanceType)
// Check if instance type is supported
instanceType, exists := d.resourceMgr.instanceTypes[*runInstancesInput.InstanceType]
if !exists {
slog.Error("handleEC2RunInstances instance lookup", "err", awserrors.ErrorInvalidInstanceType, "InstanceType", *runInstancesInput.InstanceType)
respondWithError(msg, awserrors.ErrorInvalidInstanceType)
return
}
// Validate AMI exists before allocating resources
if runInstancesInput.ImageId == nil || *runInstancesInput.ImageId == "" {
slog.Error("handleEC2RunInstances missing ImageId")
respondWithError(msg, awserrors.ErrorMissingParameter)
return
}
if d.imageService == nil {
slog.Error("handleEC2RunInstances image service not initialized")
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
amiMeta, err := d.imageService.GetAMIConfig(*runInstancesInput.ImageId)
if err != nil {
slog.Error("handleEC2RunInstances AMI not found", "imageId", *runInstancesInput.ImageId, "err", err)
respondWithError(msg, awserrors.ErrorInvalidAMIIDNotFound)
return
}
// Verify the caller can use this AMI: must own it or it must be a system/pre-phase4 AMI.
// System AMIs have non-account-ID owner aliases (e.g. "self", "hive", empty).
amiOwner := amiMeta.ImageOwnerAlias
if amiOwner != "" && amiOwner != accountID {
if utils.IsAccountID(amiOwner) {
slog.Warn("handleEC2RunInstances AMI not owned by caller", "imageId", *runInstancesInput.ImageId, "amiOwner", amiOwner, "accountID", accountID)
respondWithError(msg, awserrors.ErrorInvalidAMIIDNotFound)
return
}
}
// Validate key pair exists (if specified)
if runInstancesInput.KeyName != nil && *runInstancesInput.KeyName != "" {
if err := d.keyService.ValidateKeyPairExists(accountID, *runInstancesInput.KeyName); err != nil {
slog.Error("handleEC2RunInstances key pair not found", "keyName", *runInstancesInput.KeyName, "err", err)
respondWithError(msg, awserrors.ErrorInvalidKeyPairNotFound)
return
}
}
// Determine how many instances to launch based on MinCount/MaxCount
minCount := int(*runInstancesInput.MinCount)
maxCount := int(*runInstancesInput.MaxCount)
// Check how many we can actually launch
allocatableCount := d.resourceMgr.canAllocate(instanceType, maxCount)
if allocatableCount < minCount {
// Cannot satisfy MinCount requirement - fail entirely
slog.Error("handleEC2RunInstances insufficient capacity", "requested", minCount, "available", allocatableCount, "InstanceType", *runInstancesInput.InstanceType)
respondWithError(msg, awserrors.ErrorInsufficientInstanceCapacity)
return
}
// Launch up to MaxCount, capped by available capacity
// Note: canAllocate() already caps at maxCount, so allocatableCount <= maxCount
launchCount := allocatableCount
slog.Info("Instance count determined", "min", minCount, "max", maxCount, "launching", launchCount)
// Allocate resources for all instances upfront
var allocatedCount int
for i := 0; i < launchCount; i++ {
if err := d.resourceMgr.allocate(instanceType); err != nil {
slog.Error("handleEC2RunInstances allocate failed mid-allocation", "allocated", allocatedCount, "err", err)
break
}
allocatedCount++
}
// Check if we still meet MinCount after allocation
if allocatedCount < minCount {
// Rollback allocations
for i := 0; i < allocatedCount; i++ {
d.resourceMgr.deallocate(instanceType)
}
slog.Error("handleEC2RunInstances insufficient capacity after allocation", "allocated", allocatedCount, "minCount", minCount)
respondWithError(msg, awserrors.ErrorInsufficientInstanceCapacity)
return
}
// Update launchCount to what we actually allocated
launchCount = allocatedCount
// Delegate to service for business logic (volume creation, cloud-init, etc.)
instanceTypeName := ""
if instanceType.InstanceType != nil {
instanceTypeName = *instanceType.InstanceType
}
slog.Info("Launching EC2 instances", "instanceType", instanceTypeName, "count", launchCount)
// Create all instances
var instances []*vm.VM
var allEC2Instances []*ec2.Instance
var lastRunErr error
for i := 0; i < launchCount; i++ {
instance, ec2Instance, err := d.instanceService.RunInstance(runInstancesInput)
if err != nil {
slog.Error("handleEC2RunInstances service.RunInstance failed", "index", i, "err", err)
lastRunErr = err
// Deallocate this instance's resources
d.resourceMgr.deallocate(instanceType)
continue
}
// Auto-create ENI when SubnetId is provided (matches AWS behavior)
if runInstancesInput.SubnetId != nil && *runInstancesInput.SubnetId != "" && d.vpcService != nil {
eniOut, eniErr := d.vpcService.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
SubnetId: runInstancesInput.SubnetId,
Description: aws.String("Primary network interface for " + instance.ID),
}, accountID)
if eniErr != nil {
slog.Error("handleEC2RunInstances auto-create ENI failed", "instanceId", instance.ID, "subnetId", *runInstancesInput.SubnetId, "err", eniErr)
lastRunErr = eniErr
d.resourceMgr.deallocate(instanceType)
continue
}
eni := eniOut.NetworkInterface
instance.ENIId = *eni.NetworkInterfaceId
instance.ENIMac = *eni.MacAddress
ec2Instance.SetPrivateIpAddress(*eni.PrivateIpAddress)
ec2Instance.SetSubnetId(*runInstancesInput.SubnetId)
ec2Instance.SetVpcId(*eni.VpcId)
ec2Instance.NetworkInterfaces = []*ec2.InstanceNetworkInterface{
{
NetworkInterfaceId: eni.NetworkInterfaceId,
PrivateIpAddress: eni.PrivateIpAddress,
MacAddress: eni.MacAddress,
SubnetId: runInstancesInput.SubnetId,
VpcId: eni.VpcId,
Status: aws.String("in-use"),
Attachment: &ec2.InstanceNetworkInterfaceAttachment{
DeviceIndex: aws.Int64(0),
Status: aws.String("attached"),
},
},
}
slog.Info("Auto-created ENI for VPC instance",
"instanceId", instance.ID,
"eniId", instance.ENIId,
"privateIp", *eni.PrivateIpAddress,
"mac", instance.ENIMac,
)
}
instances = append(instances, instance)
allEC2Instances = append(allEC2Instances, ec2Instance)
}
// Check if we still have enough instances after creation errors
if len(instances) < minCount {
// Rollback: deallocate resources for successfully created instances
// (failed instances already deallocated their resources above)
for range instances {
d.resourceMgr.deallocate(instanceType)
}
// Propagate the service-layer error if it's a known AWS error code
errCode := awserrors.ErrorServerInternal
if lastRunErr != nil {
if _, ok := awserrors.ErrorLookup[lastRunErr.Error()]; ok {
errCode = lastRunErr.Error()
}
}
slog.Error("handleEC2RunInstances failed to create minimum instances", "created", len(instances), "minCount", minCount, "err", errCode)
respondWithError(msg, errCode)
return
}
// Build reservation with all instances
reservation := ec2.Reservation{}
reservation.SetReservationId(utils.GenerateResourceID("r"))
reservation.SetOwnerId(accountID)
reservation.Instances = allEC2Instances
// Store reservation reference and account ID in all VMs
for _, instance := range instances {
instance.Reservation = &reservation
instance.AccountID = accountID
}
// Respond to NATS immediately with reservation (instances are provisioning)
jsonResponse, err := json.Marshal(reservation)
if err != nil {
slog.Error("handleEC2RunInstances failed to marshal reservation", "err", err)
respondWithError(msg, awserrors.ErrorServerInternal)
// Deallocate all resources
for range instances {
d.resourceMgr.deallocate(instanceType)
}
return
}
if err := msg.Respond(jsonResponse); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
// Add all instances to state immediately so DescribeInstances can find them
// while volumes are being prepared and VMs are launching
d.Instances.Mu.Lock()
for _, instance := range instances {
d.Instances.VMS[instance.ID] = instance
}
d.Instances.Mu.Unlock()
if err := d.WriteState(); err != nil {
slog.Error("handleEC2RunInstances failed to write initial state", "err", err)
}
slog.Info("Instances added to state with pending status", "count", len(instances))
// Launch all instances (volumes and VMs)
var successCount int
for _, instance := range instances {
// Pre-compute dev MAC so cloud-init can generate per-interface netplan
// that suppresses the default route on the dev/hostfwd NIC.
if d.config.Daemon.DevNetworking && instance.ENIId != "" {
instance.DevMAC = generateDevMAC(instance.ID)
}
// Prepare the root volume, cloud-init, EFI drives via NBD (AMI clone to new volume)
volumeInfos, err := d.instanceService.GenerateVolumes(runInstancesInput, instance)
if err != nil {
slog.Error("handleEC2RunInstances GenerateVolumes failed", "instanceId", instance.ID, "err", err)
d.resourceMgr.deallocate(instanceType)
d.markInstanceFailed(instance, "volume_preparation_failed")
continue
}
// Populate BlockDeviceMappings on the ec2.Instance
instance.Instance.BlockDeviceMappings = make([]*ec2.InstanceBlockDeviceMapping, 0, len(volumeInfos))
for _, vi := range volumeInfos {
mapping := &ec2.InstanceBlockDeviceMapping{}
mapping.SetDeviceName(vi.DeviceName)
mapping.Ebs = &ec2.EbsInstanceBlockDevice{}
mapping.Ebs.SetVolumeId(vi.VolumeId)
mapping.Ebs.SetAttachTime(vi.AttachTime)
mapping.Ebs.SetDeleteOnTermination(vi.DeleteOnTermination)
mapping.Ebs.SetStatus("attached")
instance.Instance.BlockDeviceMappings = append(instance.Instance.BlockDeviceMappings, mapping)
}
// Launch the instance infrastructure (QEMU, QMP, NATS subscriptions)
err = d.LaunchInstance(instance)
if err != nil {
slog.Error("handleEC2RunInstances LaunchInstance failed", "instanceId", instance.ID, "err", err)
d.resourceMgr.deallocate(instanceType)
d.markInstanceFailed(instance, "launch_failed")
continue
}
// Discover actual guest device names via QMP query-block
d.updateGuestDeviceNames(instance)
successCount++
slog.Info("handleEC2RunInstances launched instance", "instanceId", instance.ID)
}
slog.Info("handleEC2RunInstances completed", "requested", launchCount, "created", len(instances), "launched", successCount)
}
func (d *Daemon) handleRebootInstance(msg *nats.Msg, command types.EC2InstanceCommand, instance *vm.VM) {
slog.Info("Rebooting instance", "id", command.ID)
d.Instances.Mu.Lock()
status := instance.Status
d.Instances.Mu.Unlock()
if status != vm.StateRunning {
slog.Error("RebootInstance: instance not in running state", "instanceId", command.ID, "status", status)
respondWithError(msg, awserrors.ErrorIncorrectInstanceState)
return
}
_, err := d.SendQMPCommand(instance.QMPClient, qmp.QMPCommand{Execute: "system_reset"}, command.ID)
if err != nil {
slog.Error("RebootInstance: QMP system_reset failed", "instanceId", command.ID, "err", err)
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
slog.Info("Instance rebooted", "instanceId", command.ID)
if err := msg.Respond([]byte(`{}`)); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
}
func (d *Daemon) handleStartInstance(msg *nats.Msg, command types.EC2InstanceCommand, instance *vm.VM) {
slog.Info("Starting instance", "id", command.ID)
// Validate instance is in stopped state
d.Instances.Mu.Lock()
status := instance.Status
d.Instances.Mu.Unlock()
if status != vm.StateStopped {
slog.Error("StartInstance: instance not in stopped state", "instanceId", command.ID, "status", status)
respondWithError(msg, awserrors.ErrorIncorrectInstanceState)
return
}
// Allocate resources
instanceType, ok := d.resourceMgr.instanceTypes[instance.InstanceType]
if ok {
if err := d.resourceMgr.allocate(instanceType); err != nil {
slog.Error("Failed to allocate resources for start command", "id", command.ID, "err", err)
respondWithError(msg, awserrors.ErrorInsufficientInstanceCapacity)
return
}
}
// Clear stop attribute before launch so WriteState inside LaunchInstance
// persists the correct attributes. Without this, a daemon restart after
// a stop→start cycle would see StopInstance=true and skip reconnecting QEMU.
d.Instances.Mu.Lock()
instance.Attributes = command.Attributes
d.Instances.Mu.Unlock()
// Launch the instance infrastructure (QEMU, QMP, NATS subscriptions)
err := d.LaunchInstance(instance)
if err != nil {
slog.Error("handleEC2RunInstances LaunchInstance failed", "err", err)
// Free the resource on failure
if ok {
d.resourceMgr.deallocate(instanceType)
}
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
// Discover actual guest device names via QMP query-block
d.updateGuestDeviceNames(instance)
slog.Info("Instance started", "instanceId", instance.ID)
if err := msg.Respond(fmt.Appendf(nil, `{"status":"running","instanceId":"%s"}`, instance.ID)); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
}
func (d *Daemon) handleStopOrTerminateInstance(msg *nats.Msg, command types.EC2InstanceCommand, instance *vm.VM) {
isTerminate := command.Attributes.TerminateInstance
action := "Stopping"
initialState := vm.StateStopping
finalState := vm.StateStopped
if isTerminate {
action = "Terminating"
initialState = vm.StateShuttingDown
finalState = vm.StateTerminated
}
slog.Info(action+" instance", "id", command.ID)
// Check state validity before attempting transition — return the correct
// AWS error code when the instance is already stopped/terminated/etc.
d.Instances.Mu.Lock()
currentState := instance.Status
d.Instances.Mu.Unlock()
if !vm.IsValidTransition(currentState, initialState) {
slog.Warn("Instance in incorrect state for "+strings.ToLower(action),
"instanceId", instance.ID, "currentState", string(currentState))
respondWithError(msg, awserrors.ErrorIncorrectInstanceState)
return
}
// Transition to the initial transitional state
if err := d.TransitionState(instance, initialState); err != nil {
slog.Error("Failed to transition to "+string(initialState), "instanceId", instance.ID, "err", err)
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
// Respond immediately - operation will complete in background
// stopInstance() handles the QMP shutdown command, so we don't send it here
if err := msg.Respond([]byte(`{}`)); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
// Run cleanup in goroutine to not block NATS
go func(inst *vm.VM, attrs types.EC2CommandAttributes) {
stopErr := d.stopInstance(map[string]*vm.VM{inst.ID: inst}, isTerminate)
if stopErr != nil {
slog.Error("Failed to "+strings.ToLower(action)+" instance", "err", stopErr, "id", inst.ID)
if err := d.TransitionState(inst, vm.StateError); err != nil {
slog.Error("Failed to transition to error state", "instanceId", inst.ID, "err", err)
}
} else {
d.Instances.Mu.Lock()
inst.Attributes = attrs
inst.LastNode = d.node
d.Instances.Mu.Unlock()
if err := d.TransitionState(inst, finalState); err != nil {
slog.Error("Failed to transition to final state", "instanceId", inst.ID, "err", err)
}
slog.Info("Instance "+string(finalState), "id", inst.ID)
if d.jsManager != nil {
if isTerminate {
// Write to terminated KV bucket (auto-expires after 1 hour via TTL).
// If this fails, keep the instance in local state so DescribeInstances
// still sees it and restoreInstances can retry the KV migration.
if err := d.jsManager.WriteTerminatedInstance(inst.ID, inst); err != nil {
slog.Error("Failed to write terminated instance to KV, keeping in local state for retry",
"instanceId", inst.ID, "err", err)
return
}
} else {
// Write to shared KV first — if daemon crashes after this but
// before local cleanup, restoreInstances handles the overlap.
if err := d.jsManager.WriteStoppedInstance(inst.ID, inst); err != nil {
slog.Error("Failed to write stopped instance to shared KV, keeping local ownership",
"instanceId", inst.ID, "err", err)
return
}
}
// Guard + delete must be atomic under the same lock hold.
// A concurrent ec2.start handler may have loaded the instance
// from stopped KV, re-added it to VMS with a new pointer, and
// launched it. Deleting here would destroy the running instance's
// state — creating a "ghost instance" visible nowhere.
d.Instances.Mu.Lock()
current, exists := d.Instances.VMS[inst.ID]
if !exists || current != inst {
d.Instances.Mu.Unlock()
slog.Info("Instance was reclaimed by another handler, skipping local cleanup",
"instanceId", inst.ID, "state", string(finalState))
return
}
delete(d.Instances.VMS, inst.ID)
d.Instances.Mu.Unlock()
// Unsubscribe from per-instance NATS topic. Safe to do after
// the delete — LaunchInstance already unsubscribes stale entries
// before creating new ones (daemon.go:1658-1664).
d.mu.Lock()
if sub, ok := d.natsSubscriptions[inst.ID]; ok {
if err := sub.Unsubscribe(); err != nil {
slog.Error("Failed to unsubscribe instance", "instanceId", inst.ID, "err", err)
}
delete(d.natsSubscriptions, inst.ID)
}
d.mu.Unlock()
// Persist local state without the instance
if err := d.WriteState(); err != nil {
slog.Error("Failed to persist state after releasing instance, re-adding to local map for consistency",
"instanceId", inst.ID, "err", err)
// Only re-add if another handler hasn't claimed the slot
d.Instances.Mu.Lock()
if _, occupied := d.Instances.VMS[inst.ID]; !occupied {
d.Instances.VMS[inst.ID] = inst
}
d.Instances.Mu.Unlock()
} else {
slog.Info("Released instance ownership to KV",
"instanceId", inst.ID, "state", string(finalState), "lastNode", d.node)
}
}
}
}(instance, command.Attributes)
}
// handleEC2DescribeInstances processes incoming EC2 DescribeInstances requests
// This handler responds with instances running on this node owned by the caller's account
func (d *Daemon) handleEC2DescribeInstances(msg *nats.Msg) {
slog.Debug("Received message", "subject", msg.Subject, "data", string(msg.Data))
// Extract account ID from NATS header for scoping
accountID := utils.AccountIDFromMsg(msg)
// Initialize describeInstancesInput before unmarshaling into it
describeInstancesInput := &ec2.DescribeInstancesInput{}
errResp := utils.UnmarshalJsonPayload(describeInstancesInput, msg.Data)
if errResp != nil {
if err := msg.Respond(errResp); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
slog.Error("Request does not match DescribeInstancesInput")
return
}
slog.Info("Processing DescribeInstances request from this node", "accountID", accountID)
d.Instances.Mu.Lock()
defer d.Instances.Mu.Unlock()
// Validate and filter instances if specific instance IDs were requested
instanceIDFilter := make(map[string]bool)
if len(describeInstancesInput.InstanceIds) > 0 {
for _, id := range describeInstancesInput.InstanceIds {
if id != nil && *id != "" {
if !strings.HasPrefix(*id, "i-") {
respondWithError(msg, awserrors.ErrorInvalidInstanceIDMalformed)
return
}
instanceIDFilter[*id] = true
}
}
}
// Group instances by reservation ID (AWS returns instances grouped by reservation)
reservationMap := make(map[string]*ec2.Reservation)
// Iterate through all instances on this node
for _, instance := range d.Instances.VMS {
// Skip instances not owned by the caller's account.
// Pre-Phase4 instances (empty AccountID) are only visible to root.
if !isInstanceVisible(accountID, instance.AccountID) {
continue
}
// Skip if filtering by instance IDs and this instance is not in the filter
if len(instanceIDFilter) > 0 && !instanceIDFilter[instance.ID] {
continue
}
// Use stored reservation metadata if available
if instance.Reservation != nil && instance.Instance != nil {
resID := ""
if instance.Reservation.ReservationId != nil {
resID = *instance.Reservation.ReservationId
}
// Create reservation entry if it doesn't exist
if _, exists := reservationMap[resID]; !exists {
reservation := &ec2.Reservation{}
reservation.SetReservationId(resID)
if instance.Reservation.OwnerId != nil {
reservation.SetOwnerId(*instance.Reservation.OwnerId)
}
reservation.Instances = []*ec2.Instance{}
reservationMap[resID] = reservation
}
// Update the instance state to current state
instanceCopy := *instance.Instance
instanceCopy.State = &ec2.InstanceState{}
// Map internal status to EC2 state codes using the centralized mapping
if info, ok := vm.EC2StateCodes[instance.Status]; ok {
instanceCopy.State.SetCode(info.Code)
instanceCopy.State.SetName(info.Name)
} else {
slog.Warn("Instance has unmapped status, reporting as pending",
"instanceId", instance.ID, "status", string(instance.Status))
instanceCopy.State.SetCode(0)
instanceCopy.State.SetName("pending")
}
// Add instance to its reservation
reservationMap[resID].Instances = append(reservationMap[resID].Instances, &instanceCopy)
}
}
// Convert map to slice
reservations := make([]*ec2.Reservation, 0, len(reservationMap))
for _, reservation := range reservationMap {
reservations = append(reservations, reservation)
}
// Create the response
output := &ec2.DescribeInstancesOutput{
Reservations: reservations,
}
respondWithJSON(msg, output)
slog.Info("handleEC2DescribeInstances completed", "count", len(reservations))
}
// handleEC2DescribeInstanceTypes processes incoming EC2 DescribeInstanceTypes requests
// This handler responds with instance types that can currently be provisioned on this node
// based on available resources (CPU and memory not already allocated to running instances)
func (d *Daemon) handleEC2DescribeInstanceTypes(msg *nats.Msg) {
slog.Debug("Received message", "subject", msg.Subject)
// Initialize input
describeInput := &ec2.DescribeInstanceTypesInput{}
errResp := utils.UnmarshalJsonPayload(describeInput, msg.Data)
if errResp != nil {
if err := msg.Respond(errResp); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
slog.Error("Request does not match DescribeInstanceTypesInput")
return
}
slog.Info("Processing DescribeInstanceTypes request from this node")
// Check if "capacity" filter is set to "true"
showCapacity := false
for _, f := range describeInput.Filters {
if f.Name != nil && *f.Name == "capacity" {
for _, v := range f.Values {
if v != nil && *v == "true" {
showCapacity = true
break
}
}
}
}
// Get instance types based on capacity and the showCapacity flag
filteredTypes := d.resourceMgr.GetAvailableInstanceTypeInfos(showCapacity)
// Create the response
output := &ec2.DescribeInstanceTypesOutput{
InstanceTypes: filteredTypes,
}
respondWithJSON(msg, output)
slog.Info("handleEC2DescribeInstanceTypes completed", "count", len(filteredTypes))
}
// startStoppedInstanceRequest is the payload for ec2.start topic
type startStoppedInstanceRequest struct {
InstanceID string `json:"instance_id"`
}
// handleEC2StartStoppedInstance picks up a stopped instance from shared KV,
// re-launches it on this daemon node, and removes it from shared KV.
func (d *Daemon) handleEC2StartStoppedInstance(msg *nats.Msg) {
var req startStoppedInstanceRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
slog.Error("handleEC2StartStoppedInstance: failed to unmarshal request", "err", err)
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
if req.InstanceID == "" {
slog.Error("handleEC2StartStoppedInstance: missing instance_id")
respondWithError(msg, awserrors.ErrorMissingParameter)
return
}
if d.jsManager == nil {
slog.Error("handleEC2StartStoppedInstance: JetStream not available")
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
// Load instance from shared KV
instance, err := d.jsManager.LoadStoppedInstance(req.InstanceID)
if err != nil {
slog.Error("handleEC2StartStoppedInstance: failed to load stopped instance", "instanceId", req.InstanceID, "err", err)
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
if instance == nil {
slog.Warn("handleEC2StartStoppedInstance: instance not found in shared KV", "instanceId", req.InstanceID)
respondWithError(msg, awserrors.ErrorInvalidInstanceIDNotFound)
return
}
if instance.Status != vm.StateStopped {
slog.Error("handleEC2StartStoppedInstance: instance not in stopped state", "instanceId", req.InstanceID, "status", instance.Status)
respondWithError(msg, awserrors.ErrorIncorrectInstanceState)
return
}
// Verify the caller owns this instance
if !checkInstanceOwnership(msg, req.InstanceID, instance.AccountID) {
return
}
// Reset node-local fields that are stale after cross-node migration
instance.ResetNodeLocalState()
// Allocate resources
instanceType, ok := d.resourceMgr.instanceTypes[instance.InstanceType]
if !ok {
slog.Error("handleEC2StartStoppedInstance: instance type not available on this node",
"instanceId", req.InstanceID, "instanceType", instance.InstanceType)
respondWithError(msg, awserrors.ErrorInsufficientInstanceCapacity)
return
}
if err := d.resourceMgr.allocate(instanceType); err != nil {
slog.Error("handleEC2StartStoppedInstance: failed to allocate resources", "instanceId", req.InstanceID, "err", err)
respondWithError(msg, awserrors.ErrorInsufficientInstanceCapacity)
return
}
// Add instance to local map and clear stop attribute before launch
d.Instances.Mu.Lock()
d.Instances.VMS[instance.ID] = instance
instance.Attributes = types.EC2CommandAttributes{StartInstance: true}
d.Instances.Mu.Unlock()
// Launch the instance infrastructure (QEMU, QMP, NATS subscriptions)
err = d.LaunchInstance(instance)
if err != nil {
slog.Error("handleEC2StartStoppedInstance: LaunchInstance failed", "instanceId", req.InstanceID, "err", err)
// Rollback: deallocate resources and remove from local map
if ok {
d.resourceMgr.deallocate(instanceType)
}
d.Instances.Mu.Lock()
delete(d.Instances.VMS, instance.ID)
d.Instances.Mu.Unlock()
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
// Discover actual guest device names via QMP query-block
d.updateGuestDeviceNames(instance)
// Remove from shared KV now that it's running locally.
// Retry once on failure — a stale KV entry risks duplicate starts.
if err := d.jsManager.DeleteStoppedInstance(req.InstanceID); err != nil {
slog.Warn("handleEC2StartStoppedInstance: first KV delete failed, retrying",
"instanceId", req.InstanceID, "err", err)
if retryErr := d.jsManager.DeleteStoppedInstance(req.InstanceID); retryErr != nil {
slog.Error("handleEC2StartStoppedInstance: KV delete failed after retry, instance is running locally but stale entry remains in shared KV",
"instanceId", req.InstanceID, "err", retryErr)
}
}
slog.Info("Started stopped instance from shared KV", "instanceId", instance.ID, "node", d.node)
if err := msg.Respond(fmt.Appendf(nil, `{"status":"running","instanceId":"%s"}`, instance.ID)); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
}
// terminateStoppedInstanceRequest is the payload for ec2.terminate topic
type terminateStoppedInstanceRequest struct {
InstanceID string `json:"instance_id"`
}
// handleEC2TerminateStoppedInstance picks up a stopped instance from shared KV,
// deletes its volumes, and removes it from shared KV.
func (d *Daemon) handleEC2TerminateStoppedInstance(msg *nats.Msg) {
var req terminateStoppedInstanceRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
slog.Error("handleEC2TerminateStoppedInstance: failed to unmarshal request", "err", err)
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
if req.InstanceID == "" {
slog.Error("handleEC2TerminateStoppedInstance: missing instance_id")
respondWithError(msg, awserrors.ErrorMissingParameter)
return
}
if d.jsManager == nil {
slog.Error("handleEC2TerminateStoppedInstance: JetStream not available")
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
// Load instance from shared KV
instance, err := d.jsManager.LoadStoppedInstance(req.InstanceID)
if err != nil {
slog.Error("handleEC2TerminateStoppedInstance: failed to load stopped instance", "instanceId", req.InstanceID, "err", err)
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
if instance == nil {
slog.Warn("handleEC2TerminateStoppedInstance: instance not found in shared KV", "instanceId", req.InstanceID)
respondWithError(msg, awserrors.ErrorInvalidInstanceIDNotFound)
return
}
if instance.Status != vm.StateStopped {
slog.Error("handleEC2TerminateStoppedInstance: instance not in stopped state", "instanceId", req.InstanceID, "status", instance.Status)
respondWithError(msg, awserrors.ErrorIncorrectInstanceState)
return
}
// Verify the caller owns this instance
if !checkInstanceOwnership(msg, req.InstanceID, instance.AccountID) {
return
}
// Delete volumes — no QEMU shutdown or unmount needed (already done during stop)
instance.EBSRequests.Mu.Lock()
for _, ebsRequest := range instance.EBSRequests.Requests {
// Internal volumes (EFI, cloud-init) are always cleaned up via ebs.delete
if ebsRequest.EFI || ebsRequest.CloudInit {
ebsDeleteData, err := json.Marshal(types.EBSDeleteRequest{Volume: ebsRequest.Name})
if err != nil {
slog.Error("handleEC2TerminateStoppedInstance: failed to marshal ebs.delete request", "name", ebsRequest.Name, "err", err)
continue
}
deleteMsg, err := d.natsConn.Request("ebs.delete", ebsDeleteData, 30*time.Second)
if err != nil {
slog.Warn("handleEC2TerminateStoppedInstance: ebs.delete failed for internal volume", "name", ebsRequest.Name, "err", err)
} else {
slog.Info("handleEC2TerminateStoppedInstance: ebs.delete sent for internal volume", "name", ebsRequest.Name, "data", string(deleteMsg.Data))
}
continue
}
// User-visible volumes: respect DeleteOnTermination flag
if !ebsRequest.DeleteOnTermination {
slog.Info("handleEC2TerminateStoppedInstance: volume has DeleteOnTermination=false, skipping", "name", ebsRequest.Name)
continue
}
slog.Info("handleEC2TerminateStoppedInstance: deleting volume with DeleteOnTermination=true", "name", ebsRequest.Name)
_, err := d.volumeService.DeleteVolume(&ec2.DeleteVolumeInput{
VolumeId: &ebsRequest.Name,
}, instance.AccountID)
if err != nil {
slog.Error("handleEC2TerminateStoppedInstance: failed to delete volume", "name", ebsRequest.Name, "err", err)
}
}
instance.EBSRequests.Mu.Unlock()
// Write to terminated KV bucket FIRST so the instance is visible in DescribeInstances.
// If this fails, the instance remains in the stopped bucket (safe to retry).
instance.Status = vm.StateTerminated
if err := d.jsManager.WriteTerminatedInstance(req.InstanceID, instance); err != nil {
slog.Error("handleEC2TerminateStoppedInstance: failed to write to terminated KV, aborting", "instanceId", req.InstanceID, "err", err)
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
// Now safe to remove from shared stopped KV — instance already exists in terminated bucket.
// Retry once on failure to avoid duplicate entries in DescribeInstances.
if err := d.jsManager.DeleteStoppedInstance(req.InstanceID); err != nil {
slog.Warn("handleEC2TerminateStoppedInstance: first stopped KV delete failed, retrying",
"instanceId", req.InstanceID, "err", err)
if retryErr := d.jsManager.DeleteStoppedInstance(req.InstanceID); retryErr != nil {
slog.Error("handleEC2TerminateStoppedInstance: stopped KV delete failed after retry, instance may appear in both buckets",
"instanceId", req.InstanceID, "err", retryErr)
}
}
slog.Info("Terminated stopped instance from shared KV", "instanceId", req.InstanceID)
if err := msg.Respond(fmt.Appendf(nil, `{"status":"terminated","instanceId":"%s"}`, req.InstanceID)); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
}
// handleEC2DescribeStoppedInstances returns stopped instances from shared KV.
func (d *Daemon) handleEC2DescribeStoppedInstances(msg *nats.Msg) {
d.describeInstancesFromKV(msg, d.jsManager.ListStoppedInstances, 80, "stopped", "handleEC2DescribeStoppedInstances")
}
// handleEC2DescribeTerminatedInstances returns terminated instances from the terminated KV bucket.
func (d *Daemon) handleEC2DescribeTerminatedInstances(msg *nats.Msg) {
d.describeInstancesFromKV(msg, d.jsManager.ListTerminatedInstances, 48, "terminated", "handleEC2DescribeTerminatedInstances")
}
// describeInstancesFromKV is a shared helper for DescribeStopped/TerminatedInstances handlers.
// It lists instances from a KV bucket, filters by account/instance ID, and responds with reservations.
func (d *Daemon) describeInstancesFromKV(msg *nats.Msg, listFn func() ([]*vm.VM, error), fallbackCode int64, fallbackName, handlerName string) {
if d.jsManager == nil {
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
accountID := utils.AccountIDFromMsg(msg)
describeInput := &ec2.DescribeInstancesInput{}
if len(msg.Data) > 0 {
if errResp := utils.UnmarshalJsonPayload(describeInput, msg.Data); errResp != nil {
if err := msg.Respond(errResp); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
return
}
}
instanceIDFilter := make(map[string]bool)
for _, id := range describeInput.InstanceIds {
if id != nil {
instanceIDFilter[*id] = true
}
}
instances, err := listFn()
if err != nil {
slog.Error(handlerName+": failed to list instances", "err", err)
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
reservationMap := make(map[string]*ec2.Reservation)
for _, instance := range instances {
if !isInstanceVisible(accountID, instance.AccountID) {
continue
}
if len(instanceIDFilter) > 0 && !instanceIDFilter[instance.ID] {
continue
}
if instance.Reservation == nil || instance.Instance == nil {
slog.Warn(handlerName+": skipping instance with nil Reservation/Instance (data integrity issue)",
"instanceId", instance.ID)
continue
}
resID := ""
if instance.Reservation.ReservationId != nil {
resID = *instance.Reservation.ReservationId
}
if _, exists := reservationMap[resID]; !exists {
reservation := &ec2.Reservation{}
reservation.SetReservationId(resID)
if instance.Reservation.OwnerId != nil {
reservation.SetOwnerId(*instance.Reservation.OwnerId)
}
reservation.Instances = []*ec2.Instance{}
reservationMap[resID] = reservation
}
instanceCopy := *instance.Instance
instanceCopy.State = &ec2.InstanceState{}
if info, ok := vm.EC2StateCodes[instance.Status]; ok {
instanceCopy.State.SetCode(info.Code)
instanceCopy.State.SetName(info.Name)
} else {
instanceCopy.State.SetCode(fallbackCode)
instanceCopy.State.SetName(fallbackName)
}
reservationMap[resID].Instances = append(reservationMap[resID].Instances, &instanceCopy)
}
reservations := make([]*ec2.Reservation, 0, len(reservationMap))
for _, reservation := range reservationMap {
reservations = append(reservations, reservation)
}
respondWithJSON(msg, &ec2.DescribeInstancesOutput{Reservations: reservations})
slog.Info(handlerName+" completed", "count", len(reservations))
}
// handleEC2ModifyInstanceAttribute modifies attributes of a stopped instance in shared KV.
// All supported attributes (InstanceType, UserData) require the instance to be stopped.
func (d *Daemon) handleEC2ModifyInstanceAttribute(msg *nats.Msg) {
var input ec2.ModifyInstanceAttributeInput
if err := json.Unmarshal(msg.Data, &input); err != nil {
slog.Error("handleEC2ModifyInstanceAttribute: failed to unmarshal request", "err", err)
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
if input.InstanceId == nil || *input.InstanceId == "" {
slog.Error("handleEC2ModifyInstanceAttribute: missing instance_id")
respondWithError(msg, awserrors.ErrorMissingParameter)
return
}
instanceID := *input.InstanceId
if d.jsManager == nil {
slog.Error("handleEC2ModifyInstanceAttribute: JetStream not available")
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
instance, err := d.jsManager.LoadStoppedInstance(instanceID)
if err != nil {
slog.Error("handleEC2ModifyInstanceAttribute: failed to load stopped instance", "instanceId", instanceID, "err", err)
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
if instance == nil {
slog.Warn("handleEC2ModifyInstanceAttribute: instance not found in shared KV", "instanceId", instanceID)
respondWithError(msg, awserrors.ErrorInvalidInstanceIDNotFound)
return
}
if instance.Status != vm.StateStopped {
slog.Error("handleEC2ModifyInstanceAttribute: instance not in stopped state", "instanceId", instanceID, "status", instance.Status)
respondWithError(msg, awserrors.ErrorIncorrectInstanceState)
return
}
// Verify the caller owns this instance
if !checkInstanceOwnership(msg, instanceID, instance.AccountID) {
return
}
// Apply the requested attribute change
if input.InstanceType != nil && input.InstanceType.Value != nil {
newType := *input.InstanceType.Value
if newType == "" {
slog.Error("handleEC2ModifyInstanceAttribute: empty instance type value", "instanceId", instanceID)
respondWithError(msg, awserrors.ErrorInvalidInstanceAttributeValue)
return
}
if instance.Instance == nil {
slog.Error("handleEC2ModifyInstanceAttribute: instance.Instance is nil, data integrity issue", "instanceId", instanceID)
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
slog.Info("handleEC2ModifyInstanceAttribute: changing instance type",
"instanceId", instanceID, "oldType", instance.InstanceType, "newType", newType)
instance.InstanceType = newType
instance.Config.InstanceType = newType
instance.Instance.InstanceType = aws.String(newType)
// Clear StateReason — resolves capacity-unavailable state from instance-type-missing bug
instance.Instance.StateReason = nil
}
if input.UserData != nil && input.UserData.Value != nil {
slog.Info("handleEC2ModifyInstanceAttribute: changing user data", "instanceId", instanceID)
// Value arrives as decoded bytes (JSON unmarshal handles base64 → []byte automatically)
instance.UserData = string(input.UserData.Value)
if instance.RunInstancesInput != nil {
instance.RunInstancesInput.UserData = aws.String(base64.StdEncoding.EncodeToString(input.UserData.Value))
}
}
if err := d.jsManager.WriteStoppedInstance(instanceID, instance); err != nil {
slog.Error("handleEC2ModifyInstanceAttribute: failed to write modified instance to KV",
"instanceId", instanceID, "err", err)
respondWithError(msg, awserrors.ErrorServerInternal)
return
}
slog.Info("handleEC2ModifyInstanceAttribute: completed successfully", "instanceId", instanceID)
if err := msg.Respond([]byte(`{}`)); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
}