Handler framework updated, predastore version bump, startup tweaks

This commit is contained in:
benduncan
2025-10-17 02:09:01 +11:00
parent c87fddb4fa
commit 32cecf124f
16 changed files with 1543 additions and 920 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ require (
github.com/gofiber/fiber/v2 v2.52.9
github.com/google/uuid v1.6.0
github.com/kdomanski/iso9660 v0.4.0
github.com/mulgadc/predastore v0.1.4
github.com/mulgadc/predastore v0.1.5
github.com/mulgadc/viperblock v0.1.0
github.com/nats-io/nats-server/v2 v2.11.6
github.com/nats-io/nats.go v1.43.0
+2
View File
@@ -51,6 +51,8 @@ github.com/mulgadc/predastore v0.1.2 h1:Q3vQjmE7Pzy7wNy/qRrDh75a9lXqgx3QhWJRkiaD
github.com/mulgadc/predastore v0.1.2/go.mod h1:BhMymRV3zMTu2S+5pJnNDDm5+AGakjhSc/PKX0q8Xfg=
github.com/mulgadc/predastore v0.1.4 h1:gDdvETM+O5umU216gf7IIilO1Ja7dZMXdg1DFChdPh0=
github.com/mulgadc/predastore v0.1.4/go.mod h1:BhMymRV3zMTu2S+5pJnNDDm5+AGakjhSc/PKX0q8Xfg=
github.com/mulgadc/predastore v0.1.5 h1:7SySkQQ5MQ1NTcKcB14pCUIJipq0YVgCJZeKM6ytrLM=
github.com/mulgadc/predastore v0.1.5/go.mod h1:BhMymRV3zMTu2S+5pJnNDDm5+AGakjhSc/PKX0q8Xfg=
github.com/mulgadc/viperblock v0.0.0-20250701054805-00b5d3692c1b/go.mod h1:DUJU/5qcf8Hhb9wsnmdO75pkxy4xxH4rVVAx2w+5EY8=
github.com/mulgadc/viperblock v0.1.0 h1:vReunbVOrxzlw7n+GI35lDRHdWBR0HeslZun7t7I0Kk=
github.com/mulgadc/viperblock v0.1.0/go.mod h1:LPXBqDzIVQFs8VvVkTgAiZ4vQhe079hvB77mYzN6+/g=
+60 -799
View File
@@ -2,16 +2,12 @@ package daemon
import (
"bufio"
"bytes"
"context"
crand "crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"math/rand"
"os"
"os/exec"
"os/signal"
@@ -21,42 +17,19 @@ import (
"strings"
"sync"
"syscall"
"text/template"
"time"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/kdomanski/iso9660"
"github.com/mulgadc/hive/hive/awserrors"
"github.com/mulgadc/hive/hive/config"
gateway_ec2_instance "github.com/mulgadc/hive/hive/gateway/ec2/instance"
handlers_ec2_instance "github.com/mulgadc/hive/hive/handlers/ec2/instance"
"github.com/mulgadc/hive/hive/qmp"
"github.com/mulgadc/hive/hive/s3client"
"github.com/mulgadc/hive/hive/utils"
"github.com/mulgadc/hive/hive/vm"
"github.com/mulgadc/viperblock/types"
"github.com/mulgadc/viperblock/viperblock"
"github.com/mulgadc/viperblock/viperblock/backends/s3"
"github.com/nats-io/nats.go"
)
// EC2Request represents the EC2 launch request structure
type EC2Request struct {
Action string `json:"Action"`
ImageID string `json:"ImageId"`
InstanceType string `json:"InstanceType"`
KeyName string `json:"KeyName"`
SecurityGroups []string `json:"SecurityGroups"`
SubnetID string `json:"SubnetId"`
MaxCount int `json:"MaxCount"`
MinCount int `json:"MinCount"`
Version string `json:"Version"`
// Extended
BlockDeviceMapping []BlockDeviceMapping `json:"BlockDeviceMapping"`
UserData string `json:"UserData"`
}
type BlockDeviceMapping struct {
DeviceName string `json:"DeviceName"`
EBS EBS `json:"EBS"`
@@ -95,12 +68,13 @@ type ResourceManager struct {
// Daemon represents the main daemon service
type Daemon struct {
config *config.Config
natsConn *nats.Conn
resourceMgr *ResourceManager
ctx context.Context
cancel context.CancelFunc
shutdownWg sync.WaitGroup
config *config.Config
natsConn *nats.Conn
resourceMgr *ResourceManager
instanceService *handlers_ec2_instance.InstanceServiceImpl
ctx context.Context
cancel context.CancelFunc
shutdownWg sync.WaitGroup
// Local VM Instances
Instances vm.Instances
@@ -111,56 +85,6 @@ type Daemon struct {
mu sync.Mutex
}
const cloudInitUserDataTemplate = `#cloud-config
users:
- name: {{.Username}}
shell: /bin/bash
groups:
- sudo
sudo: "ALL=(ALL) NOPASSWD:ALL"
ssh_authorized_keys:
- {{.SSHKey}}
hostname: {{.Hostname}}
manage_etc_hosts: true
{{if .UserDataCloudConfig}}
# custom userdata cloud-config
{{.UserDataCloudConfig}}
{{end}}
{{if .UserDataScript}}
write_files:
- path: /tmp/cloud-init-startup.sh
permissions: '0755'
content: |
{{.UserDataScript}}
runcmd:
- [ "/bin/bash", "/tmp/cloud-init-startup.sh" ]
{{end}}
`
const cloudInitMetaTemplate = `# meta-data
instance-id: {{.InstanceID}}
local-hostname: {{.Hostname}}
`
type CloudInitData struct {
Username string
SSHKey string
Hostname string
UserDataCloudConfig string
UserDataScript string
}
type CloudInitMetaData struct {
InstanceID string
Hostname string
}
// getSystemMemory returns the total system memory in GB
func getSystemMemory() (float64, error) {
switch runtime.GOOS {
@@ -333,15 +257,36 @@ func (d *Daemon) Start() error {
}
// Create instance service for handling EC2 instance operations
instanceTypes := make(map[string]handlers_ec2_instance.InstanceType)
for k, v := range d.resourceMgr.instanceTypes {
instanceTypes[k] = handlers_ec2_instance.InstanceType{
Name: v.Name,
VCPUs: v.VCPUs,
MemoryGB: v.MemoryGB,
Architecture: v.Architecture,
}
}
d.instanceService = handlers_ec2_instance.NewInstanceServiceImpl(d.config, instanceTypes, d.natsConn, &d.Instances)
log.Printf("Subscribing to subject pattern: %s", "ec2.launch")
// Subscribe to EC2 events with queue group
d.natsSubscriptions["ec2.launch"], err = d.natsConn.QueueSubscribe("ec2.launch", "hive-workers", d.handleEC2Launch)
// Subscribe to EC2 events with queue group (legacy topic for backward compatibility)
d.natsSubscriptions["ec2.launch"], err = d.natsConn.QueueSubscribe("ec2.launch", "hive-workers", d.handleEC2RunInstances)
if err != nil {
return fmt.Errorf("failed to subscribe to NATS ec2.launch: %w", err)
}
log.Printf("Subscribing to subject pattern: %s", "ec2.RunInstances")
// Subscribe to EC2 RunInstances with queue group (AWS Action name format - recommended)
d.natsSubscriptions["ec2.RunInstances"], err = d.natsConn.QueueSubscribe("ec2.RunInstances", "hive-workers", d.handleEC2RunInstances)
if err != nil {
return fmt.Errorf("failed to subscribe to NATS ec2.RunInstances: %w", err)
}
// Subscribe to EC2 start instance events
// TODO: The instance state needs to be shared, not pinned to a single node.
// TODO: Handle this in a more generic function to group similar commands (start, stop, launch)
@@ -630,17 +575,13 @@ func (d *Daemon) handleEC2Events(msg *nats.Msg) {
}
// handleEC2Launch processes incoming EC2 launch requests
func (d *Daemon) handleEC2Launch(msg *nats.Msg) {
// handleEC2RunInstances processes incoming EC2 RunInstances requests
func (d *Daemon) handleEC2RunInstances(msg *nats.Msg) {
log.Printf("Received message on subject: %s", msg.Subject)
log.Printf("Message data: %s", string(msg.Data))
// Initialize runInstancesInput before unmarshaling into it
runInstancesInput := &ec2.RunInstancesInput{}
var reservation ec2.Reservation
//var ec2Req EC2Request
//var ec2Response config.EC2Response
var errResp []byte
errResp = utils.UnmarshalJsonPayload(runInstancesInput, msg.Data)
@@ -655,7 +596,7 @@ func (d *Daemon) handleEC2Launch(msg *nats.Msg) {
err := gateway_ec2_instance.ValidateRunInstancesInput(runInstancesInput)
if err != nil {
slog.Error("handleEC2Launch validation failed", "err", awserrors.ErrorValidationError)
slog.Error("handleEC2RunInstances validation failed", "err", awserrors.ErrorValidationError)
errResp = utils.GenerateErrorPayload(awserrors.ErrorValidationError)
msg.Respond(errResp)
return
@@ -667,7 +608,7 @@ func (d *Daemon) handleEC2Launch(msg *nats.Msg) {
// Check if instance type is supported
instanceType, exists := d.resourceMgr.instanceTypes[*runInstancesInput.InstanceType]
if !exists {
slog.Error("handleEC2Launch instance lookup", "err", awserrors.ErrorInvalidInstanceType, "InstanceType", *runInstancesInput.InstanceType)
slog.Error("handleEC2RunInstances instance lookup", "err", awserrors.ErrorInvalidInstanceType, "InstanceType", *runInstancesInput.InstanceType)
errResp = utils.GenerateErrorPayload(awserrors.ErrorInvalidInstanceType)
msg.Respond(errResp)
return
@@ -675,7 +616,7 @@ func (d *Daemon) handleEC2Launch(msg *nats.Msg) {
// Check if we have enough resources
if !d.resourceMgr.canAllocate(instanceType) {
slog.Error("handleEC2Launch canAllocate", "err", awserrors.ErrorInsufficientInstanceCapacity, "InstanceType", *runInstancesInput.InstanceType)
slog.Error("handleEC2RunInstances canAllocate", "err", awserrors.ErrorInsufficientInstanceCapacity, "InstanceType", *runInstancesInput.InstanceType)
errResp = utils.GenerateErrorPayload(awserrors.ErrorInsufficientInstanceCapacity)
msg.Respond(errResp)
return
@@ -683,30 +624,46 @@ func (d *Daemon) handleEC2Launch(msg *nats.Msg) {
// Allocate resources
if err := d.resourceMgr.allocate(instanceType); err != nil {
slog.Error("handleEC2Launch allocate", "err", awserrors.ErrorInsufficientInstanceCapacity, "InstanceType", *runInstancesInput.InstanceType)
slog.Error("handleEC2RunInstances allocate", "err", awserrors.ErrorInsufficientInstanceCapacity, "InstanceType", *runInstancesInput.InstanceType)
errResp = utils.GenerateErrorPayload(awserrors.ErrorInsufficientInstanceCapacity)
msg.Respond(errResp)
return
}
// Create a new VM instance
// Delegate to service for business logic (volume creation, cloud-init, etc.)
slog.Info("Launching EC2 instance", "instance", instanceType)
reservation, err = d.launchEC2Instance(runInstancesInput, msg)
instance, reservation, err := d.instanceService.RunInstances(runInstancesInput)
if err != nil {
slog.Error("handleEC2Launch launchEC2Instance", "err", awserrors.ErrorServerInternal)
slog.Error("handleEC2RunInstances service.RunInstances failed", "err", err)
errResp = utils.GenerateErrorPayload(awserrors.ErrorServerInternal)
msg.Respond(errResp)
d.resourceMgr.deallocate(instanceType)
return
}
// TODO: Confirm and cleanup, launchEC2Instance responds
//jsonResponse, err := json.Marshal(reservation)
//msg.Respond(jsonResponse)
// Respond to NATS immediately with reservation (instance is provisioning)
jsonResponse, err := json.Marshal(reservation)
if err != nil {
slog.Error("handleEC2RunInstances failed to marshal reservation", "err", err)
errResp = utils.GenerateErrorPayload(awserrors.ErrorServerInternal)
msg.Respond(errResp)
d.resourceMgr.deallocate(instanceType)
return
}
msg.Respond(jsonResponse)
slog.Info("handleEC2Launch launched", "instanceId", reservation.Instances[0].InstanceId)
// Launch the instance infrastructure (QEMU, QMP, NATS subscriptions)
err = d.LaunchInstance(instance)
if err != nil {
slog.Error("handleEC2RunInstances LaunchInstance failed", "err", err)
d.resourceMgr.deallocate(instanceType)
return
}
slog.Info("handleEC2RunInstances launched", "instanceId", reservation.Instances[0].InstanceId)
}
@@ -811,677 +768,6 @@ func (d *Daemon) setupShutdown() {
}()
}
// TODO: Support multiple instances in the single request
func (d *Daemon) launchEC2Instance(runInstancesInput *ec2.RunInstancesInput, msg *nats.Msg) (reservation ec2.Reservation, err error) {
err = gateway_ec2_instance.ValidateRunInstancesInput(runInstancesInput)
if err != nil {
return
}
// Validate input
instanceType := d.resourceMgr.instanceTypes[*runInstancesInput.InstanceType]
if instanceType.Name == "" {
return reservation, errors.New(awserrors.ErrorInvalidInstanceType)
}
var size int = 4 * 1024 * 1024 * 1024 // 4GB default size
var deviceName string
var volumeType string
var iops int
var imageId string
var snapshotId string
instanceId := vm.GenerateEC2InstanceID()
// Add state for our new instance
var instance = &vm.VM{
ID: instanceId,
Status: "provisioning",
InstanceType: *runInstancesInput.InstanceType,
}
// Add reservation ID
reservation.SetReservationId(vm.GenerateEC2ReservationID())
// TODO: Loop through multiple instance creation based on MinCount / MaxCount
// Respond with the instance ID and status, polling required to track status
reservation.Instances = make([]*ec2.Instance, 1)
// Initialize the instance before setting fields
reservation.Instances[0] = &ec2.Instance{
State: &ec2.InstanceState{},
}
// TODO: Consider using Set methods
reservation.Instances[0].SetInstanceId(instance.ID)
reservation.Instances[0].State.SetCode(0)
reservation.Instances[0].State.SetName("pending")
// Respond to NATS immediately, to reduce lag while the instance is provisioning.
jsonResponse, err := json.Marshal(reservation)
// TODO: Consider multiple instance types
msg.Respond(jsonResponse)
// TODO: Support multiple mounts per request
if len(reservation.Instances[0].BlockDeviceMappings) > 0 {
size = int(*runInstancesInput.BlockDeviceMappings[0].Ebs.VolumeSize)
deviceName = *runInstancesInput.BlockDeviceMappings[0].DeviceName
volumeType = *runInstancesInput.BlockDeviceMappings[0].Ebs.VolumeType
iops = int(*runInstancesInput.BlockDeviceMappings[0].Ebs.Iops)
}
// Check if the image starts with ami-
if strings.HasPrefix(*runInstancesInput.ImageId, "ami-") {
// Generate a random number to append to the volume ID ( 8 digits )
randomNumber := rand.Intn(100_000_000)
imageId = viperblock.GenerateVolumeID("vol", fmt.Sprintf("%d-%s", randomNumber, *runInstancesInput.ImageId), "predastore", time.Now().Unix())
snapshotId = *runInstancesInput.ImageId
} else {
// Allow creating an instance with an existing ImageID
imageId = *runInstancesInput.ImageId
}
// Pre-flight, confirm if the instance is already running (TODO)
// CONFIRM: All Viperblock AMI and volumes stored in a system S3 bucket, vs the individual users account.
// Step 1: Confirm if the volume already exists
cfg := s3.S3Config{
VolumeName: imageId,
VolumeSize: uint64(size),
Bucket: d.config.Predastore.Bucket,
Region: d.config.Predastore.Region,
AccessKey: d.config.AccessKey,
SecretKey: d.config.SecretKey,
Host: d.config.Predastore.Host,
}
volumeConfig := viperblock.VolumeConfig{
VolumeMetadata: viperblock.VolumeMetadata{
VolumeID: imageId,
SizeGiB: uint64(size / 1024 / 1024 / 1024),
CreatedAt: time.Now(),
DeviceName: deviceName,
VolumeType: volumeType,
IOPS: iops,
SnapshotID: snapshotId,
},
}
vbconfig := viperblock.VB{
VolumeName: imageId,
VolumeSize: uint64(size),
BaseDir: d.config.WalDir,
Cache: viperblock.Cache{
Config: viperblock.CacheConfig{
Size: 0,
},
},
VolumeConfig: volumeConfig,
}
vb, err := viperblock.New(vbconfig, "s3", cfg)
if err != nil {
slog.Error("Failed to connect to Viperblock store", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
vb.SetDebug(true)
// Initialize the backend
err = vb.Backend.Init()
if err != nil {
slog.Error("Failed to initialize backend", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
// Load the state from the remote backend
//err = vb.LoadState()
_, err = vb.LoadStateRequest("")
// Step 2: If launching from an AMI and the volume doesn't exist, clone the AMI to our new volume
if err != nil {
slog.Info("Volume does not yet exist, creating from EFI ...")
// Open the chunk WAL
err = vb.OpenWAL(&vb.WAL, fmt.Sprintf("%s/%s", vb.WAL.BaseDir, types.GetFilePath(types.FileTypeWALChunk, vb.WAL.WallNum.Load(), vb.GetVolume())))
if err != nil {
slog.Error("Failed to load WAL", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
// Open the block to object WAL
err = vb.OpenWAL(&vb.BlockToObjectWAL, fmt.Sprintf("%s/%s", vb.WAL.BaseDir, types.GetFilePath(types.FileTypeWALBlock, vb.BlockToObjectWAL.WallNum.Load(), vb.GetVolume())))
if err != nil {
slog.Error("Failed to load block WAL", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
amiCfg := s3.S3Config{
VolumeName: *runInstancesInput.ImageId,
VolumeSize: uint64(size),
Bucket: d.config.Predastore.Bucket,
Region: d.config.Predastore.Region,
AccessKey: d.config.AccessKey,
SecretKey: d.config.SecretKey,
Host: d.config.Predastore.Host,
}
amiVbConfig := viperblock.VB{
VolumeName: *runInstancesInput.ImageId,
VolumeSize: uint64(size),
BaseDir: d.config.WalDir,
Cache: viperblock.Cache{
Config: viperblock.CacheConfig{
Size: 0,
},
},
VolumeConfig: volumeConfig,
}
amiVb, err := viperblock.New(amiVbConfig, "s3", amiCfg)
if err != nil {
slog.Error("Failed to connect to Viperblock store for AMI", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
// Initialize the backend
slog.Debug("Initializing AMI Viperblock store backend")
err = amiVb.Backend.Init()
if err != nil {
slog.Error("Could not connect to AMI Viperblock store", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
slog.Debug("Loading state for AMI Viperblock store")
err = amiVb.LoadState()
if err != nil {
slog.Error("Could not load state for AMI Viperblock store", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
err = amiVb.LoadBlockState()
if err != nil {
slog.Error("Failed to load block state", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
slog.Debug("Starting to clone AMI to new volume")
var block uint64 = 0
nullBlock := make([]byte, vb.BlockSize)
// Read each block from the AMI, write to our new volume, skipping null blocks
for {
if block*uint64(vb.BlockSize) >= amiVb.VolumeSize {
slog.Debug("Reached end of AMI")
break
}
// Read 1MB
data, err := amiVb.ReadAt(block*uint64(vb.BlockSize), uint64(vb.BlockSize)*1024)
if err != nil && err != viperblock.ZeroBlock {
slog.Error("Failed to read block from AMI source", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
numBlocks := len(data) / int(vb.BlockSize)
// Write individual blocks to the new volume
for i := 0; i < numBlocks; i++ {
// Check if the input is a Zero block
if bytes.Equal(data[i*int(vb.BlockSize):(i+1)*int(vb.BlockSize)], nullBlock) {
//fmt.Printf("Null block found at %d, skipping\n", block)
block++
continue
}
vb.WriteAt(block*uint64(vb.BlockSize), data[i*int(vb.BlockSize):(i+1)*int(vb.BlockSize)])
block++
// Flush every 4MB
if block%uint64(vb.BlockSize) == 0 {
slog.Debug("Flush", "block", block)
vb.Flush()
vb.WriteWALToChunk(true)
}
}
}
err = vb.Close()
if err != nil {
slog.Error("Failed to close Viperblock store", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
err = vb.RemoveLocalFiles()
if err != nil {
slog.Warn("Failed to remove local files", "err", err)
}
// New volume is cloned.
}
// Append root volume
instance.EBSRequests.Mu.Lock()
instance.EBSRequests.Requests = append(instance.EBSRequests.Requests, config.EBSRequest{
Name: vbconfig.VolumeName,
Boot: true,
})
instance.EBSRequests.Mu.Unlock()
//var walNum uint64
// Step 3: Create the EFI partition if it does not yet exist
efiVolumeName := fmt.Sprintf("%s-efi", imageId)
efiSize := 64 * 1024 * 1024 // 64MB
efiCfg := s3.S3Config{
VolumeName: efiVolumeName,
VolumeSize: uint64(efiSize),
Bucket: d.config.Predastore.Bucket,
Region: d.config.Predastore.Region,
AccessKey: d.config.AccessKey,
SecretKey: d.config.SecretKey,
Host: d.config.Predastore.Host,
}
efiVbConfig := viperblock.VB{
VolumeName: efiVolumeName,
VolumeSize: uint64(efiSize),
BaseDir: d.config.WalDir,
Cache: viperblock.Cache{
Config: viperblock.CacheConfig{
Size: 0,
},
},
VolumeConfig: volumeConfig,
}
efiVb, err := viperblock.New(efiVbConfig, "s3", efiCfg)
if err != nil {
slog.Error("Could not create EFI viperblock")
return reservation, errors.New(awserrors.ErrorServerInternal)
}
efiVb.SetDebug(true)
if err != nil {
slog.Error("Failed to connect to Viperblock store for AMI", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
// Initialize the backend
slog.Debug("Initializing EFI Viperblock store backend")
err = efiVb.Backend.Init()
if err != nil {
slog.Error("Failed to initialize EFI Viperblock store backend", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
// Load the state from the remote backend
//err = vb.LoadState()
_, err = efiVb.LoadStateRequest("")
slog.Info("LoadStateRequest", "error", err)
// Step 2: If launching from an AMI and the volume doesn't exist, clone the AMI to our new volume
if err != nil {
slog.Info("Volume does not yet exist, creating from EFI disk ...")
// Open the chunk WAL
err = efiVb.OpenWAL(&efiVb.WAL, fmt.Sprintf("%s/%s", efiVb.WAL.BaseDir, types.GetFilePath(types.FileTypeWALChunk, efiVb.WAL.WallNum.Load(), efiVb.GetVolume())))
if err != nil {
slog.Error("Failed to load WAL", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
// Open the block to object WAL
err = vb.OpenWAL(&efiVb.BlockToObjectWAL, fmt.Sprintf("%s/%s", efiVb.WAL.BaseDir, types.GetFilePath(types.FileTypeWALBlock, efiVb.BlockToObjectWAL.WallNum.Load(), efiVb.GetVolume())))
if err != nil {
slog.Error("Failed to load block WAL", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
// Write an empty block to the EFI volume
efiVb.WriteAt(0, make([]byte, efiVb.BlockSize))
// Flush
efiVb.Flush()
}
slog.Info("Closing EFI")
err = efiVb.Close()
slog.Info("Close", "error", err)
if err != nil {
slog.Error("Failed to close EFI Viperblock store", "err", err)
}
err = efiVb.RemoveLocalFiles()
if err != nil {
slog.Error("Failed to remove local files", "err", err)
}
instance.EBSRequests.Mu.Lock()
instance.EBSRequests.Requests = append(instance.EBSRequests.Requests, config.EBSRequest{
Name: efiVb.VolumeName,
Boot: false,
EFI: true,
})
instance.EBSRequests.Mu.Unlock()
// Step 4: Create the cloud-init volume, with the specified SSH key and attributes
keyName := *runInstancesInput.KeyName
userData := *runInstancesInput.UserData
if keyName != "" || userData != "" {
slog.Info("Creating cloud-init volume")
cloudInitVolumeName := fmt.Sprintf("%s-cloudinit", imageId)
cloudInitSize := 1 * 1024 * 1024 // 1MB
cloudInitCfg := s3.S3Config{
VolumeName: cloudInitVolumeName,
VolumeSize: uint64(cloudInitSize),
Bucket: d.config.Predastore.Bucket,
Region: d.config.Predastore.Region,
AccessKey: d.config.AccessKey,
SecretKey: d.config.SecretKey,
Host: d.config.Predastore.Host,
}
cloudInitVbConfig := viperblock.VB{
VolumeName: cloudInitVolumeName,
VolumeSize: uint64(cloudInitSize),
BaseDir: d.config.WalDir,
Cache: viperblock.Cache{
Config: viperblock.CacheConfig{
Size: 0,
},
},
VolumeConfig: volumeConfig,
}
cloudInitVb, err := viperblock.New(cloudInitVbConfig, "s3", cloudInitCfg)
if err != nil {
slog.Error("Could not create cloudinit viperblock")
return reservation, errors.New(awserrors.ErrorServerInternal)
}
// TODO: Set debug flag config
cloudInitVb.SetDebug(true)
if err != nil {
slog.Error("Failed to connect to Viperblock store for AMI", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
// Initialize the backend
slog.Debug("Initializing cloud-init Viperblock store backend")
err = cloudInitVb.Backend.Init()
if err != nil {
slog.Error("Could not init backend")
return reservation, errors.New(awserrors.ErrorServerInternal)
}
// Load the state from the remote backend
//err = vb.LoadState()
_, err = cloudInitVb.LoadStateRequest("")
// Step 2: If launching from an AMI and the volume doesn't exist, clone the AMI to our new volume
if err != nil {
slog.Info("Volume does not yet exist, creating from cloud-init disk ...")
// Open the chunk WAL
err = cloudInitVb.OpenWAL(&cloudInitVb.WAL, fmt.Sprintf("%s/%s", cloudInitVb.WAL.BaseDir, types.GetFilePath(types.FileTypeWALChunk, cloudInitVb.WAL.WallNum.Load(), cloudInitVb.GetVolume())))
if err != nil {
slog.Error("Failed to load WAL", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
// Open the block to object WAL
err = cloudInitVb.OpenWAL(&cloudInitVb.BlockToObjectWAL, fmt.Sprintf("%s/%s", cloudInitVb.WAL.BaseDir, types.GetFilePath(types.FileTypeWALBlock, cloudInitVb.BlockToObjectWAL.WallNum.Load(), cloudInitVb.GetVolume())))
if err != nil {
slog.Error("Failed to load block WAL", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
// Create the cloud-init disk
writer, err := iso9660.NewWriter()
if err != nil {
slog.Error("failed to create writer", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
defer writer.Cleanup()
// Inject our data into the cloud-init template.
instanceId := generateInstanceID()
hostname := generateHostname(instanceId)
// Retrieve SSH pubkey from S3
// Connect to S3 to retrieve EC2 attributes (e.g SSH key)
s3c := s3client.New(s3client.S3Config{
AccessKey: d.config.AccessKey,
SecretKey: d.config.SecretKey,
Host: d.config.Predastore.Host,
Bucket: d.config.Predastore.Bucket,
Region: d.config.Predastore.Region,
})
err = s3c.Init()
if err != nil {
slog.Error("failed to initialize S3 client", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
sshKey, err := s3c.Read(fmt.Sprintf("/ssh/%s", keyName))
if err != nil {
slog.Error("failed to read SSH key", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
userData := CloudInitData{
Username: "ec2-user",
SSHKey: string(sshKey), // provided ssh key
Hostname: hostname,
}
var buf bytes.Buffer
t := template.Must(template.New("cloud-init").Parse(cloudInitUserDataTemplate))
if err := t.Execute(&buf, userData); err != nil {
slog.Error("failed to render template", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
//slog.Debug("user-data", "data", buf.String())
// Add user-data
err = writer.AddFile(&buf, "user-data")
if err != nil {
slog.Error("failed to add file", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
// Add meta-data
metaData := CloudInitMetaData{
InstanceID: instanceId,
Hostname: hostname,
}
t = template.Must(template.New("meta-data").Parse(cloudInitMetaTemplate))
buf.Reset()
if err := t.Execute(&buf, metaData); err != nil {
slog.Error("failed to render template", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
//slog.Debug("meta-data", buf.String())
err = writer.AddFile(&buf, "meta-data")
if err != nil {
slog.Error("failed to add file", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
// Store temp file
tempFile, err := os.CreateTemp("", "cloud-init-*.iso")
if err != nil {
slog.Error("Could not create cloud-init temp file")
return reservation, errors.New(awserrors.ErrorServerInternal)
}
slog.Info("Created temp ISO file", "file", tempFile.Name())
outputFile, err := os.OpenFile(tempFile.Name(), os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0644)
if err != nil {
slog.Error("failed to create file", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
// Requires cidata volume label for cloud-init to recognize
err = writer.WriteTo(outputFile, "cidata")
if err != nil {
slog.Error("failed to write ISO image", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
err = writer.Cleanup()
if err != nil {
slog.Error("failed to cleanup writer", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
err = outputFile.Close()
if err != nil {
slog.Error("failed to close output file", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
isoData, err := os.ReadFile(tempFile.Name())
if err != nil {
slog.Error("failed to read ISO image:", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
err = cloudInitVb.WriteAt(0, isoData)
if err != nil {
slog.Error("failed to write ISO image to viperblock volume", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
// Flush
cloudInitVb.Flush()
cloudInitVb.WriteWALToChunk(true)
// Remove the temp ISO file
err = os.Remove(tempFile.Name())
if err != nil {
slog.Error("Failed to remove temp file", "err", err)
}
}
err = cloudInitVb.Close()
if err != nil {
slog.Error("Failed to close cloud-init Viperblock store", "err", err)
}
err = cloudInitVb.RemoveLocalFiles()
if err != nil {
slog.Error("Failed to remove local files", "err", err)
}
instance.EBSRequests.Mu.Lock()
instance.EBSRequests.Requests = append(instance.EBSRequests.Requests, config.EBSRequest{
Name: cloudInitCfg.VolumeName,
Boot: false,
CloudInit: true,
})
instance.EBSRequests.Mu.Unlock()
}
// Step 5: Mount each volume via NBD, confirm running as expected for pre-flight checks.
// TODO: Run a goroutine for each volume
err = d.LaunchInstance(instance)
if err != nil {
slog.Error("Could not launch instance", "err", err)
return reservation, errors.New(awserrors.ErrorServerInternal)
}
// Step 10: Return the unique instance ID on success, ALREADY set.
//ec2Response.InstanceID = instance.ID
// TODO: Use AWS style hostname, ip-<a-b-c-d>.<region>.compute.internal
//ec2Response.Hostname = instance.ID
//ec2Response.Success = true
return reservation, nil
}
func (d *Daemon) CreateQMPClient(instance *vm.VM) (err error) {
// Create a new QMP client to communicate with the instance
@@ -1921,28 +1207,3 @@ func (rm *ResourceManager) deallocate(instanceType InstanceType) {
rm.allocatedVCPU -= instanceType.VCPUs
rm.allocatedMem -= instanceType.MemoryGB
}
// generateInstanceID creates a unique EC2-style instance ID
func generateInstanceID() string {
// Generate 8 random bytes (16 hex characters)
randomBytes := make([]byte, 8)
_, err := crand.Read(randomBytes)
if err != nil {
// Fallback to time-based ID if crypto/rand fails
timestamp := time.Now().UnixNano()
return fmt.Sprintf("i-%016x", timestamp)
}
// Convert to hex and format as EC2 instance ID
return fmt.Sprintf("i-%s", hex.EncodeToString(randomBytes))
}
// generateHostname creates a hostname based on instance ID
func generateHostname(instanceID string) string {
// Extract the unique part and create a hostname
if len(instanceID) > 2 {
uniquePart := instanceID[2:10] // Take first 8 chars after "i-"
return fmt.Sprintf("hive-vm-%s", uniquePart)
}
return "hive-vm-unknown"
}
+26 -7
View File
@@ -9,6 +9,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/hive/hive/config"
handlers_ec2_instance "github.com/mulgadc/hive/hive/handlers/ec2/instance"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
@@ -83,6 +84,18 @@ func createTestDaemon(t *testing.T, natsURL string) *Daemon {
daemon.natsConn = nc
// Initialize instance service (needed for handleEC2RunInstances)
instanceTypes := make(map[string]handlers_ec2_instance.InstanceType)
for k, v := range daemon.resourceMgr.instanceTypes {
instanceTypes[k] = handlers_ec2_instance.InstanceType{
Name: v.Name,
VCPUs: v.VCPUs,
MemoryGB: v.MemoryGB,
Architecture: v.Architecture,
}
}
daemon.instanceService = handlers_ec2_instance.NewInstanceServiceImpl(cfg, instanceTypes, nc, &daemon.Instances)
t.Cleanup(func() {
if daemon.natsConn != nil {
daemon.natsConn.Close()
@@ -108,8 +121,8 @@ func createValidRunInstancesInput() *ec2.RunInstancesInput {
}
}
// TestHandleEC2Launch_MessageParsing tests that the handler correctly parses NATS messages
func TestHandleEC2Launch_MessageParsing(t *testing.T) {
// TestHandleEC2RunInstances_MessageParsing tests that the handler correctly parses NATS messages
func TestHandleEC2RunInstances_MessageParsing(t *testing.T) {
// Skip integration tests on macOS since viperblock/nbdkit are not available
if os.Getenv("SKIP_INTEGRATION") != "" {
t.Skip("Skipping integration test - SKIP_INTEGRATION is set")
@@ -205,6 +218,12 @@ func TestHandleEC2Launch_MessageParsing(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Skip "Valid RunInstancesInput" test as it requires full infrastructure
// (Predastore S3 backend, Viperblock, NBDkit) to actually create volumes
if tt.name == "Valid RunInstancesInput" {
t.Skip("Skipping valid input test - requires full hive infrastructure (viperblock, nbdkit, predastore)")
}
// Start test NATS server
ns, natsURL := startTestNATSServer(t)
defer ns.Shutdown()
@@ -213,7 +232,7 @@ func TestHandleEC2Launch_MessageParsing(t *testing.T) {
daemon := createTestDaemon(t, natsURL)
// Subscribe to the ec2.launch topic with the handler
sub, err := daemon.natsConn.QueueSubscribe("ec2.launch", "hive-workers", daemon.handleEC2Launch)
sub, err := daemon.natsConn.QueueSubscribe("ec2.launch", "hive-workers", daemon.handleEC2RunInstances)
require.NoError(t, err, "Failed to subscribe to ec2.launch")
defer sub.Unsubscribe()
@@ -246,13 +265,13 @@ func TestHandleEC2Launch_MessageParsing(t *testing.T) {
}
}
// TestHandleEC2Launch_ResourceManagement tests resource allocation and validation
// TestHandleEC2RunInstances_ResourceManagement tests resource allocation and validation
// NOTE: This test validates message handling and resource allocation logic without
// actually launching VMs. Full VM launch requires viperblock, nbdkit, and QEMU/KVM
// which are not available on macOS.
func TestHandleEC2Launch_ResourceManagement(t *testing.T) {
func TestHandleEC2RunInstances_ResourceManagement(t *testing.T) {
// Skip this test as it requires full infrastructure
// The test validates NATS message handling, but daemon.handleEC2Launch
// The test validates NATS message handling, but daemon.handleEC2RunInstances
// attempts to create viperblock volumes which requires:
// - S3 backend (predastore) running
// - viperblock library with S3 backend
@@ -293,7 +312,7 @@ func TestHandleEC2Launch_ResourceManagement(t *testing.T) {
daemon := createTestDaemon(t, natsURL)
sub, err := daemon.natsConn.QueueSubscribe("ec2.launch", "hive-workers", daemon.handleEC2Launch)
sub, err := daemon.natsConn.QueueSubscribe("ec2.launch", "hive-workers", daemon.handleEC2RunInstances)
require.NoError(t, err)
defer sub.Unsubscribe()
+24 -3
View File
@@ -6,8 +6,8 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/gofiber/fiber/v2"
"github.com/mulgadc/hive/hive/awsec2query"
gateway_ec2_core "github.com/mulgadc/hive/hive/gateway/ec2/core"
gateway_ec2_instance "github.com/mulgadc/hive/hive/gateway/ec2/instance"
gateway_ec2_key "github.com/mulgadc/hive/hive/gateway/ec2/key"
"github.com/mulgadc/hive/hive/utils"
)
@@ -33,7 +33,7 @@ func (gw *GatewayConfig) EC2_Request(ctx *fiber.Ctx) error {
return err
}
output, err := gateway_ec2_instance.RunInstances(input)
output, err := gateway_ec2_instance.RunInstances(input, gw.NATSConn)
if err != nil {
return err
@@ -48,7 +48,28 @@ func (gw *GatewayConfig) EC2_Request(ctx *fiber.Ctx) error {
}
case "CreateKeyPair":
xmlOutput, err = gateway_ec2_core.CreateKeyPair(ctx, queryArgs)
var input = &ec2.CreateKeyPairInput{}
err = awsec2query.QueryParamsToStruct(queryArgs, input)
if err != nil {
return err
}
output, err := gateway_ec2_key.CreateKeyPair(input)
if err != nil {
return err
}
// Convert to XML
payload := utils.GenerateXMLPayload("CreateKeyPairResponse", output)
xmlOutput, err = utils.MarshalToXML(payload)
if err != nil {
return errors.New("failed to marshal response to XML")
}
default:
err = errors.New("InvalidAction")
}
-9
View File
@@ -1,9 +0,0 @@
package gateway_ec2_core
import "github.com/gofiber/fiber/v2"
func CreateKeyPair(ctx *fiber.Ctx, args map[string]string) ([]byte, error) {
return nil, nil
}
+11 -30
View File
@@ -1,15 +1,12 @@
package gateway_ec2_instance
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/hive/hive/handlers/ec2/instance"
"github.com/mulgadc/hive/hive/utils"
handlers_ec2_instance "github.com/mulgadc/hive/hive/handlers/ec2/instance"
"github.com/nats-io/nats.go"
)
type RunInstancesResponse struct {
@@ -176,7 +173,7 @@ func ValidateRunInstancesInput(input *ec2.RunInstancesInput) (err error) {
}
func RunInstances(input *ec2.RunInstancesInput) (reservation ec2.Reservation, err error) {
func RunInstances(input *ec2.RunInstancesInput, natsConn *nats.Conn) (reservation ec2.Reservation, err error) {
// Validate input
err = ValidateRunInstancesInput(input)
@@ -185,31 +182,15 @@ func RunInstances(input *ec2.RunInstancesInput) (reservation ec2.Reservation, er
return reservation, err
}
// Marshal to JSON, to send over the wire for processing (NATS)
jsonData, err := json.Marshal(input)
// Create NATS-based instance service
service := handlers_ec2_instance.NewNATSInstanceService(natsConn)
// Call the service directly (no need for JSON marshaling/unmarshaling in same process)
reservationPtr, err := service.RunInstances(input)
if err != nil {
return reservation, fmt.Errorf("failed to marshal input to JSON: %w", err)
return reservation, err
}
// Run the simulated JSON request via handler, which will return a JSON response
handler := handlers_ec2_instance.NewRunInstancesHandler(handlers_ec2_instance.NewMockInstanceService())
jsonResp := handler.Process(jsonData)
// Validate if the response is successful or an error
responseError, err := utils.ValidateErrorPayload(jsonResp)
if err != nil {
slog.Error("Runinstances failed", "err", responseError.Code)
return reservation, errors.New(*responseError.Code)
}
// Unmarshal the JSON response back into a Reservation struct
err = json.Unmarshal(jsonResp, &reservation)
if err != nil {
return reservation, fmt.Errorf("failed to unmarshal JSON response to Reservation: %w", err)
}
return reservation, nil
// Dereference pointer to return value
return *reservationPtr, nil
}
+38 -34
View File
@@ -3,16 +3,45 @@ package gateway_ec2_instance
import (
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/hive/hive/handlers/ec2/instance"
"github.com/mulgadc/hive/hive/utils"
"github.com/nats-io/nats-server/v2/server"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// startTestNATSServer starts an embedded NATS server for testing
func startTestNATSServer(t *testing.T) (*server.Server, string) {
opts := &server.Options{
Host: "127.0.0.1",
Port: -1, // Auto-allocate available port
JetStream: false,
NoLog: true,
NoSigs: true,
}
ns, err := server.NewServer(opts)
require.NoError(t, err, "Failed to create NATS server")
// Start server in goroutine
go ns.Start()
// Wait for server to be ready
if !ns.ReadyForConnections(5 * time.Second) {
t.Fatal("NATS server failed to start")
}
url := ns.ClientURL()
t.Logf("Test NATS server started at: %s", url)
return ns, url
}
var defaults = ec2.RunInstancesInput{
ImageId: aws.String("ami-0abcdef1234567890"),
InstanceType: aws.String("t2.micro"),
@@ -136,46 +165,21 @@ func TestParseRunInstances(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
response, err := RunInstances(test.input)
// Skip the valid test as it requires full daemon infrastructure
// These tests are covered by the integration tests in service_nats_test.go
if test.want == nil {
t.Skip("Skipping valid test - requires full daemon infrastructure")
}
// For validation tests, we can pass nil conn since validation happens before NATS call
response, err := RunInstances(test.input, nil)
// Use assert to check if the error is as expected
assert.Equal(t, test.want, err)
if err != nil {
assert.Len(t, response.Instances, 0)
//assert.Nil(t, response)
} else {
// Check expected output
assert.Len(t, response.Instances, 1)
// ImageID returned
if len(response.Instances) > 0 {
// ImageId matches our AMI
assert.Equal(t, defaults.ImageId, response.Instances[0].ImageId)
// InstanceID starts with i-
assert.True(t, true, strings.HasPrefix(*response.Instances[0].ImageId, "i-"))
// InstanceType matches
assert.Equal(t, defaults.InstanceType, response.Instances[0].InstanceType)
// KeyName matches
assert.Equal(t, defaults.KeyName, response.Instances[0].KeyName)
// State should be 16, booting.
assert.Equal(t, aws.Int64(16), response.Instances[0].State.Code)
assert.Equal(t, aws.String("running"), response.Instances[0].State.Name)
// Subnet should match
assert.Equal(t, defaults.SubnetId, response.Instances[0].SubnetId)
}
}
})
}
+5 -4
View File
@@ -9,16 +9,17 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/google/uuid"
"github.com/mulgadc/hive/hive/awsec2query"
"github.com/mulgadc/hive/hive/awserrors"
"github.com/mulgadc/predastore/s3"
"github.com/google/uuid"
"github.com/nats-io/nats.go"
)
type GatewayConfig struct {
Debug bool `json:"debug"`
DisableLogging bool `json:"disable_logging"`
Debug bool `json:"debug"`
DisableLogging bool `json:"disable_logging"`
NATSConn *nats.Conn // Shared NATS connection for service communication
}
var supportedServices = map[string]bool{
+160
View File
@@ -0,0 +1,160 @@
# NATS Handler Naming Convention
This document defines the naming convention for NATS message handlers in the Hive daemon.
## Pattern
**Format**: `handleEC2<AWSAction>` → NATS topic `ec2.<AWSAction>`
Where `<AWSAction>` matches the AWS API action name exactly (PascalCase).
### Benefits
1. **AWS API Alignment** - Handler names directly correlate with AWS documentation
2. **Self-Documenting** - Method name clearly indicates which AWS action it handles
3. **Scalable** - Pattern extends cleanly to all AWS services
4. **Consistent** - Same pattern across all handlers
## Current Handlers
### EC2 Instance Operations
```go
handleEC2RunInstances → ec2.RunInstances // Launch instances
handleEC2DescribeInstances → ec2.DescribeInstances // Query instance status
handleEC2StartInstances → ec2.StartInstances // Start stopped instances
handleEC2StopInstances → ec2.StopInstances // Stop running instances
handleEC2TerminateInstances → ec2.TerminateInstances // Terminate instances
handleEC2RebootInstances → ec2.RebootInstances // Reboot instances
```
### EC2 Images (AMI)
```go
handleEC2DescribeImages → ec2.DescribeImages // List available AMIs
handleEC2CreateImage → ec2.CreateImage // Create AMI from instance
handleEC2RegisterImage → ec2.RegisterImage // Register external AMI
handleEC2DeregisterImage → ec2.DeregisterImage // Remove AMI
handleEC2CopyImage → ec2.CopyImage // Copy AMI across regions
```
### EC2 Key Pairs
```go
handleEC2CreateKeyPair → ec2.CreateKeyPair // Generate new key pair
handleEC2DeleteKeyPair → ec2.DeleteKeyPair // Remove key pair
handleEC2DescribeKeyPairs → ec2.DescribeKeyPairs // List key pairs
handleEC2ImportKeyPair → ec2.ImportKeyPair // Import existing public key
```
### EBS Volumes
```go
handleEC2CreateVolume → ec2.CreateVolume // Create EBS volume
handleEC2AttachVolume → ec2.AttachVolume // Attach volume to instance
handleEC2DetachVolume → ec2.DetachVolume // Detach volume
handleEC2DeleteVolume → ec2.DeleteVolume // Remove volume
handleEC2DescribeVolumes → ec2.DescribeVolumes // List volumes
handleEC2CreateSnapshot → ec2.CreateSnapshot // Create volume snapshot
handleEC2DeleteSnapshot → ec2.DeleteSnapshot // Remove snapshot
```
### VPC Networking
```go
handleEC2CreateVpc → ec2.CreateVpc // Create VPC
handleEC2DeleteVpc → ec2.DeleteVpc // Remove VPC
handleEC2DescribeVpcs → ec2.DescribeVpcs // List VPCs
handleEC2CreateSubnet → ec2.CreateSubnet // Create subnet
handleEC2DeleteSubnet → ec2.DeleteSubnet // Remove subnet
handleEC2DescribeSubnets → ec2.DescribeSubnets // List subnets
```
### Security Groups
```go
handleEC2CreateSecurityGroup → ec2.CreateSecurityGroup // Create security group
handleEC2DeleteSecurityGroup → ec2.DeleteSecurityGroup // Remove security group
handleEC2DescribeSecurityGroups → ec2.DescribeSecurityGroups // List security groups
handleEC2AuthorizeSecurityGroupIngress → ec2.AuthorizeSecurityGroupIngress // Add inbound rule
handleEC2RevokeSecurityGroupIngress → ec2.RevokeSecurityGroupIngress // Remove inbound rule
```
## Implementation Example
### Daemon Handler Method
```go
// handleEC2RunInstances processes incoming EC2 RunInstances requests
func (d *Daemon) handleEC2RunInstances(msg *nats.Msg) {
// Parse request
runInstancesInput := &ec2.RunInstancesInput{}
errResp := utils.UnmarshalJsonPayload(runInstancesInput, msg.Data)
// Validate inputs
err := gateway_ec2_instance.ValidateRunInstancesInput(runInstancesInput)
// Process and respond...
}
```
### NATS Subscription
```go
// Subscribe to EC2 RunInstances with queue group
d.natsSubscriptions["ec2.RunInstances"], err = d.natsConn.QueueSubscribe(
"ec2.RunInstances", // NATS topic
"hive-workers", // Queue group for load balancing
d.handleEC2RunInstances, // Handler method
)
```
### Gateway Client Request
```go
// Gateway sends RunInstances request via NATS
msg, err := nc.Request("ec2.RunInstances", jsonData, 30*time.Second)
```
## Migration Notes
### Legacy Topics
For backward compatibility, some handlers may subscribe to both old and new topic formats:
```go
// Legacy topic (deprecated, for backward compatibility)
d.natsSubscriptions["ec2.launch"], err = d.natsConn.QueueSubscribe(
"ec2.launch", "hive-workers", d.handleEC2RunInstances)
// New topic (recommended)
d.natsSubscriptions["ec2.RunInstances"], err = d.natsConn.QueueSubscribe(
"ec2.RunInstances", "hive-workers", d.handleEC2RunInstances)
```
**Recommendation**: New code should use the AWS Action name format (`ec2.RunInstances`).
## Queue Groups
All handlers use the `"hive-workers"` queue group for:
- **Load Balancing** - NATS distributes requests across available daemon instances
- **High Availability** - If one daemon fails, others continue processing
- **Scalability** - Add more daemon instances to handle increased load
## Testing Pattern
Test function names follow the same convention:
```go
func TestHandleEC2RunInstances_MessageParsing(t *testing.T) { ... }
func TestHandleEC2RunInstances_ResourceManagement(t *testing.T) { ... }
func TestHandleEC2DescribeInstances_FilterByID(t *testing.T) { ... }
```
## Future Extensions
### S3 Operations
```go
handleS3CreateBucket → s3.CreateBucket
handleS3DeleteBucket → s3.DeleteBucket
handleS3PutObject → s3.PutObject
handleS3GetObject → s3.GetObject
```
### IAM Operations
```go
handleIAMCreateUser → iam.CreateUser
handleIAMDeleteUser → iam.DeleteUser
handleIAMCreateRole → iam.CreateRole
```
This pattern scales consistently across all AWS services.
+696
View File
@@ -0,0 +1,696 @@
package handlers_ec2_instance
import (
"bytes"
"errors"
"fmt"
"log/slog"
"math/rand"
"os"
"strings"
"text/template"
"time"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/kdomanski/iso9660"
"github.com/mulgadc/hive/hive/awserrors"
"github.com/mulgadc/hive/hive/config"
"github.com/mulgadc/hive/hive/s3client"
"github.com/mulgadc/hive/hive/vm"
"github.com/mulgadc/viperblock/types"
"github.com/mulgadc/viperblock/viperblock"
"github.com/mulgadc/viperblock/viperblock/backends/s3"
"github.com/nats-io/nats.go"
)
const cloudInitUserDataTemplate = `#cloud-config
users:
- name: {{.Username}}
shell: /bin/bash
groups:
- sudo
sudo: "ALL=(ALL) NOPASSWD:ALL"
ssh_authorized_keys:
- {{.SSHKey}}
hostname: {{.Hostname}}
manage_etc_hosts: true
{{if .UserDataCloudConfig}}
# custom userdata cloud-config
{{.UserDataCloudConfig}}
{{end}}
{{if .UserDataScript}}
write_files:
- path: /tmp/cloud-init-startup.sh
permissions: '0755'
content: |
{{.UserDataScript}}
runcmd:
- [ "/bin/bash", "/tmp/cloud-init-startup.sh" ]
{{end}}
`
const cloudInitMetaTemplate = `# meta-data
instance-id: {{.InstanceID}}
local-hostname: {{.Hostname}}
`
type CloudInitData struct {
Username string
SSHKey string
Hostname string
UserDataCloudConfig string
UserDataScript string
}
type CloudInitMetaData struct {
InstanceID string
Hostname string
}
// InstanceType represents the resource requirements for an EC2 instance type
type InstanceType struct {
Name string
VCPUs int
MemoryGB float64
Architecture string // e.g., "x86_64", "arm64"
}
// InstanceServiceImpl handles daemon-side EC2 instance operations
type InstanceServiceImpl struct {
config *config.Config
instanceTypes map[string]InstanceType
natsConn *nats.Conn
instances *vm.Instances
}
// NewInstanceServiceImpl creates a new instance service implementation for daemon use
func NewInstanceServiceImpl(cfg *config.Config, instanceTypes map[string]InstanceType, nc *nats.Conn, instances *vm.Instances) *InstanceServiceImpl {
return &InstanceServiceImpl{
config: cfg,
instanceTypes: instanceTypes,
natsConn: nc,
instances: instances,
}
}
// RunInstances handles the business logic for launching EC2 instances
// This prepares all volumes (root, EFI, cloud-init) and returns the instance ready to launch
func (s *InstanceServiceImpl) RunInstances(input *ec2.RunInstancesInput) (*vm.VM, ec2.Reservation, error) {
var reservation ec2.Reservation
var err error
// Validate input (validation is already done in daemon.handleEC2Launch)
// We can skip re-validation here for performance
// Validate instance type exists
_, exists := s.instanceTypes[*input.InstanceType]
if !exists {
return nil, reservation, errors.New(awserrors.ErrorInvalidInstanceType)
}
var size int = 4 * 1024 * 1024 * 1024 // 4GB default size
var deviceName string
var volumeType string
var iops int
var imageId string
var snapshotId string
instanceId := vm.GenerateEC2InstanceID()
// Create new instance structure
instance := &vm.VM{
ID: instanceId,
Status: "provisioning",
InstanceType: *input.InstanceType,
}
// Create reservation response
reservation.SetReservationId(vm.GenerateEC2ReservationID())
// TODO: Loop through multiple instance creation based on MinCount / MaxCount
reservation.Instances = make([]*ec2.Instance, 1)
reservation.Instances[0] = &ec2.Instance{
State: &ec2.InstanceState{},
}
reservation.Instances[0].SetInstanceId(instance.ID)
reservation.Instances[0].State.SetCode(0)
reservation.Instances[0].State.SetName("pending")
// Handle block device mappings
if input.BlockDeviceMappings != nil && len(input.BlockDeviceMappings) > 0 {
size = int(*input.BlockDeviceMappings[0].Ebs.VolumeSize)
deviceName = *input.BlockDeviceMappings[0].DeviceName
volumeType = *input.BlockDeviceMappings[0].Ebs.VolumeType
iops = int(*input.BlockDeviceMappings[0].Ebs.Iops)
}
// Determine image ID and snapshot ID
if strings.HasPrefix(*input.ImageId, "ami-") {
randomNumber := rand.Intn(100_000_000)
imageId = viperblock.GenerateVolumeID("vol", fmt.Sprintf("%d-%s", randomNumber, *input.ImageId), "predastore", time.Now().Unix())
snapshotId = *input.ImageId
} else {
imageId = *input.ImageId
}
volumeConfig := viperblock.VolumeConfig{
VolumeMetadata: viperblock.VolumeMetadata{
VolumeID: imageId,
SizeGiB: uint64(size / 1024 / 1024 / 1024),
CreatedAt: time.Now(),
DeviceName: deviceName,
VolumeType: volumeType,
IOPS: iops,
SnapshotID: snapshotId,
},
}
// Step 1: Create or validate root volume
err = s.prepareRootVolume(input, imageId, size, volumeConfig, instance)
if err != nil {
return nil, reservation, err
}
// Step 2: Create EFI partition
err = s.prepareEFIVolume(imageId, volumeConfig, instance)
if err != nil {
return nil, reservation, err
}
// Step 3: Create cloud-init volume if needed
if input.KeyName != nil && *input.KeyName != "" || (input.UserData != nil && *input.UserData != "") {
err = s.prepareCloudInitVolume(input, imageId, volumeConfig, instance)
if err != nil {
return nil, reservation, err
}
}
return instance, reservation, nil
}
// prepareRootVolume handles creation/cloning of the root volume
func (s *InstanceServiceImpl) prepareRootVolume(input *ec2.RunInstancesInput, imageId string, size int, volumeConfig viperblock.VolumeConfig, instance *vm.VM) error {
cfg := s3.S3Config{
VolumeName: imageId,
VolumeSize: uint64(size),
Bucket: s.config.Predastore.Bucket,
Region: s.config.Predastore.Region,
AccessKey: s.config.AccessKey,
SecretKey: s.config.SecretKey,
Host: s.config.Predastore.Host,
}
vbconfig := viperblock.VB{
VolumeName: imageId,
VolumeSize: uint64(size),
BaseDir: s.config.WalDir,
Cache: viperblock.Cache{Config: viperblock.CacheConfig{Size: 0}},
VolumeConfig: volumeConfig,
}
vb, err := viperblock.New(vbconfig, "s3", cfg)
if err != nil {
slog.Error("Failed to connect to Viperblock store", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
vb.SetDebug(true)
// Initialize the backend
err = vb.Backend.Init()
if err != nil {
slog.Error("Failed to initialize backend", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
// Load the state from the remote backend
_, err = vb.LoadStateRequest("")
// If volume doesn't exist, clone from AMI
if err != nil {
slog.Info("Volume does not yet exist, creating from AMI ...")
err = s.cloneAMIToVolume(input, size, volumeConfig, vb)
if err != nil {
return err
}
}
// Append root volume to instance
instance.EBSRequests.Mu.Lock()
instance.EBSRequests.Requests = append(instance.EBSRequests.Requests, config.EBSRequest{
Name: vbconfig.VolumeName,
Boot: true,
})
instance.EBSRequests.Mu.Unlock()
return nil
}
// cloneAMIToVolume clones an AMI to a new volume
func (s *InstanceServiceImpl) cloneAMIToVolume(input *ec2.RunInstancesInput, size int, volumeConfig viperblock.VolumeConfig, destVb *viperblock.VB) error {
// Open WALs for destination volume
err := destVb.OpenWAL(&destVb.WAL, fmt.Sprintf("%s/%s", destVb.WAL.BaseDir, types.GetFilePath(types.FileTypeWALChunk, destVb.WAL.WallNum.Load(), destVb.GetVolume())))
if err != nil {
slog.Error("Failed to load WAL", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
err = destVb.OpenWAL(&destVb.BlockToObjectWAL, fmt.Sprintf("%s/%s", destVb.WAL.BaseDir, types.GetFilePath(types.FileTypeWALBlock, destVb.BlockToObjectWAL.WallNum.Load(), destVb.GetVolume())))
if err != nil {
slog.Error("Failed to load block WAL", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
// Setup source AMI Viperblock
amiCfg := s3.S3Config{
VolumeName: *input.ImageId,
VolumeSize: uint64(size),
Bucket: s.config.Predastore.Bucket,
Region: s.config.Predastore.Region,
AccessKey: s.config.AccessKey,
SecretKey: s.config.SecretKey,
Host: s.config.Predastore.Host,
}
amiVbConfig := viperblock.VB{
VolumeName: *input.ImageId,
VolumeSize: uint64(size),
BaseDir: s.config.WalDir,
Cache: viperblock.Cache{Config: viperblock.CacheConfig{Size: 0}},
VolumeConfig: volumeConfig,
}
amiVb, err := viperblock.New(amiVbConfig, "s3", amiCfg)
if err != nil {
slog.Error("Failed to connect to Viperblock store for AMI", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
// Initialize AMI backend
slog.Debug("Initializing AMI Viperblock store backend")
err = amiVb.Backend.Init()
if err != nil {
slog.Error("Could not connect to AMI Viperblock store", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
slog.Debug("Loading state for AMI Viperblock store")
err = amiVb.LoadState()
if err != nil {
slog.Error("Could not load state for AMI Viperblock store", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
err = amiVb.LoadBlockState()
if err != nil {
slog.Error("Failed to load block state", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
slog.Debug("Starting to clone AMI to new volume")
// Clone blocks from AMI to new volume
var block uint64 = 0
nullBlock := make([]byte, destVb.BlockSize)
for {
if block*uint64(destVb.BlockSize) >= amiVb.VolumeSize {
slog.Debug("Reached end of AMI")
break
}
// Read 1MB
data, err := amiVb.ReadAt(block*uint64(destVb.BlockSize), uint64(destVb.BlockSize)*1024)
if err != nil && err != viperblock.ZeroBlock {
slog.Error("Failed to read block from AMI source", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
numBlocks := len(data) / int(destVb.BlockSize)
// Write individual blocks to the new volume
for i := 0; i < numBlocks; i++ {
// Check if the input is a Zero block
if bytes.Equal(data[i*int(destVb.BlockSize):(i+1)*int(destVb.BlockSize)], nullBlock) {
block++
continue
}
destVb.WriteAt(block*uint64(destVb.BlockSize), data[i*int(destVb.BlockSize):(i+1)*int(destVb.BlockSize)])
block++
// Flush every 4MB
if block%uint64(destVb.BlockSize) == 0 {
slog.Debug("Flush", "block", block)
destVb.Flush()
destVb.WriteWALToChunk(true)
}
}
}
err = destVb.Close()
if err != nil {
slog.Error("Failed to close Viperblock store", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
err = destVb.RemoveLocalFiles()
if err != nil {
slog.Warn("Failed to remove local files", "err", err)
}
return nil
}
// prepareEFIVolume creates the EFI boot partition
func (s *InstanceServiceImpl) prepareEFIVolume(imageId string, volumeConfig viperblock.VolumeConfig, instance *vm.VM) error {
efiVolumeName := fmt.Sprintf("%s-efi", imageId)
efiSize := 64 * 1024 * 1024 // 64MB
efiCfg := s3.S3Config{
VolumeName: efiVolumeName,
VolumeSize: uint64(efiSize),
Bucket: s.config.Predastore.Bucket,
Region: s.config.Predastore.Region,
AccessKey: s.config.AccessKey,
SecretKey: s.config.SecretKey,
Host: s.config.Predastore.Host,
}
efiVbConfig := viperblock.VB{
VolumeName: efiVolumeName,
VolumeSize: uint64(efiSize),
BaseDir: s.config.WalDir,
Cache: viperblock.Cache{Config: viperblock.CacheConfig{Size: 0}},
VolumeConfig: volumeConfig,
}
efiVb, err := viperblock.New(efiVbConfig, "s3", efiCfg)
if err != nil {
slog.Error("Could not create EFI viperblock", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
efiVb.SetDebug(true)
// Initialize the backend
slog.Debug("Initializing EFI Viperblock store backend")
err = efiVb.Backend.Init()
if err != nil {
slog.Error("Failed to initialize EFI Viperblock store backend", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
// Load the state from the remote backend
_, err = efiVb.LoadStateRequest("")
slog.Info("LoadStateRequest", "error", err)
// Create EFI volume if it doesn't exist
if err != nil {
slog.Info("Volume does not yet exist, creating EFI disk ...")
// Open the chunk WAL
err = efiVb.OpenWAL(&efiVb.WAL, fmt.Sprintf("%s/%s", efiVb.WAL.BaseDir, types.GetFilePath(types.FileTypeWALChunk, efiVb.WAL.WallNum.Load(), efiVb.GetVolume())))
if err != nil {
slog.Error("Failed to load WAL", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
// Open the block to object WAL
err = efiVb.OpenWAL(&efiVb.BlockToObjectWAL, fmt.Sprintf("%s/%s", efiVb.WAL.BaseDir, types.GetFilePath(types.FileTypeWALBlock, efiVb.BlockToObjectWAL.WallNum.Load(), efiVb.GetVolume())))
if err != nil {
slog.Error("Failed to load block WAL", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
// Write an empty block to the EFI volume
efiVb.WriteAt(0, make([]byte, efiVb.BlockSize))
efiVb.Flush()
}
slog.Info("Closing EFI")
err = efiVb.Close()
slog.Info("Close", "error", err)
if err != nil {
slog.Error("Failed to close EFI Viperblock store", "err", err)
}
err = efiVb.RemoveLocalFiles()
if err != nil {
slog.Error("Failed to remove local files", "err", err)
}
instance.EBSRequests.Mu.Lock()
instance.EBSRequests.Requests = append(instance.EBSRequests.Requests, config.EBSRequest{
Name: efiVb.VolumeName,
Boot: false,
EFI: true,
})
instance.EBSRequests.Mu.Unlock()
return nil
}
// prepareCloudInitVolume creates cloud-init ISO with SSH keys and user data
func (s *InstanceServiceImpl) prepareCloudInitVolume(input *ec2.RunInstancesInput, imageId string, volumeConfig viperblock.VolumeConfig, instance *vm.VM) error {
slog.Info("Creating cloud-init volume")
cloudInitVolumeName := fmt.Sprintf("%s-cloudinit", imageId)
cloudInitSize := 1 * 1024 * 1024 // 1MB
cloudInitCfg := s3.S3Config{
VolumeName: cloudInitVolumeName,
VolumeSize: uint64(cloudInitSize),
Bucket: s.config.Predastore.Bucket,
Region: s.config.Predastore.Region,
AccessKey: s.config.AccessKey,
SecretKey: s.config.SecretKey,
Host: s.config.Predastore.Host,
}
cloudInitVbConfig := viperblock.VB{
VolumeName: cloudInitVolumeName,
VolumeSize: uint64(cloudInitSize),
BaseDir: s.config.WalDir,
Cache: viperblock.Cache{Config: viperblock.CacheConfig{Size: 0}},
VolumeConfig: volumeConfig,
}
cloudInitVb, err := viperblock.New(cloudInitVbConfig, "s3", cloudInitCfg)
if err != nil {
slog.Error("Could not create cloudinit viperblock", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
cloudInitVb.SetDebug(true)
// Initialize the backend
slog.Debug("Initializing cloud-init Viperblock store backend")
err = cloudInitVb.Backend.Init()
if err != nil {
slog.Error("Could not init backend", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
// Load the state from the remote backend
_, err = cloudInitVb.LoadStateRequest("")
// Create cloud-init volume if it doesn't exist
if err != nil {
slog.Info("Volume does not yet exist, creating cloud-init disk ...")
// Open WALs
err = cloudInitVb.OpenWAL(&cloudInitVb.WAL, fmt.Sprintf("%s/%s", cloudInitVb.WAL.BaseDir, types.GetFilePath(types.FileTypeWALChunk, cloudInitVb.WAL.WallNum.Load(), cloudInitVb.GetVolume())))
if err != nil {
slog.Error("Failed to load WAL", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
err = cloudInitVb.OpenWAL(&cloudInitVb.BlockToObjectWAL, fmt.Sprintf("%s/%s", cloudInitVb.WAL.BaseDir, types.GetFilePath(types.FileTypeWALBlock, cloudInitVb.BlockToObjectWAL.WallNum.Load(), cloudInitVb.GetVolume())))
if err != nil {
slog.Error("Failed to load block WAL", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
// Create the cloud-init ISO
err = s.createCloudInitISO(input, instance.ID, cloudInitVb)
if err != nil {
return err
}
}
err = cloudInitVb.Close()
if err != nil {
slog.Error("Failed to close cloud-init Viperblock store", "err", err)
}
err = cloudInitVb.RemoveLocalFiles()
if err != nil {
slog.Error("Failed to remove local files", "err", err)
}
instance.EBSRequests.Mu.Lock()
instance.EBSRequests.Requests = append(instance.EBSRequests.Requests, config.EBSRequest{
Name: cloudInitCfg.VolumeName,
Boot: false,
CloudInit: true,
})
instance.EBSRequests.Mu.Unlock()
return nil
}
// createCloudInitISO generates the cloud-init ISO image
func (s *InstanceServiceImpl) createCloudInitISO(input *ec2.RunInstancesInput, instanceId string, cloudInitVb *viperblock.VB) error {
// Create ISO writer
writer, err := iso9660.NewWriter()
if err != nil {
slog.Error("failed to create writer", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
defer writer.Cleanup()
// Generate instance metadata
hostname := generateHostname(instanceId)
// Retrieve SSH pubkey from S3
s3c := s3client.New(s3client.S3Config{
AccessKey: s.config.AccessKey,
SecretKey: s.config.SecretKey,
Host: s.config.Predastore.Host,
Bucket: s.config.Predastore.Bucket,
Region: s.config.Predastore.Region,
})
err = s3c.Init()
if err != nil {
slog.Error("failed to initialize S3 client", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
keyName := ""
if input.KeyName != nil {
keyName = *input.KeyName
}
sshKey, err := s3c.Read(fmt.Sprintf("/ssh/%s", keyName))
if err != nil {
slog.Error("failed to read SSH key", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
userData := CloudInitData{
Username: "ec2-user",
SSHKey: string(sshKey),
Hostname: hostname,
}
var buf bytes.Buffer
t := template.Must(template.New("cloud-init").Parse(cloudInitUserDataTemplate))
if err := t.Execute(&buf, userData); err != nil {
slog.Error("failed to render template", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
// Add user-data
err = writer.AddFile(&buf, "user-data")
if err != nil {
slog.Error("failed to add file", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
// Add meta-data
metaData := CloudInitMetaData{
InstanceID: instanceId,
Hostname: hostname,
}
t = template.Must(template.New("meta-data").Parse(cloudInitMetaTemplate))
buf.Reset()
if err := t.Execute(&buf, metaData); err != nil {
slog.Error("failed to render template", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
err = writer.AddFile(&buf, "meta-data")
if err != nil {
slog.Error("failed to add file", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
// Store temp file
tempFile, err := os.CreateTemp("", "cloud-init-*.iso")
if err != nil {
slog.Error("Could not create cloud-init temp file", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
slog.Info("Created temp ISO file", "file", tempFile.Name())
outputFile, err := os.OpenFile(tempFile.Name(), os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0644)
if err != nil {
slog.Error("failed to create file", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
// Requires cidata volume label for cloud-init to recognize
err = writer.WriteTo(outputFile, "cidata")
if err != nil {
slog.Error("failed to write ISO image", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
err = writer.Cleanup()
if err != nil {
slog.Error("failed to cleanup writer", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
err = outputFile.Close()
if err != nil {
slog.Error("failed to close output file", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
isoData, err := os.ReadFile(tempFile.Name())
if err != nil {
slog.Error("failed to read ISO image:", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
err = cloudInitVb.WriteAt(0, isoData)
if err != nil {
slog.Error("failed to write ISO image to viperblock volume", "err", err)
return errors.New(awserrors.ErrorServerInternal)
}
// Flush
cloudInitVb.Flush()
cloudInitVb.WriteWALToChunk(true)
// Remove the temp ISO file
err = os.Remove(tempFile.Name())
if err != nil {
slog.Error("Failed to remove temp file", "err", err)
}
return nil
}
// generateHostname creates a hostname based on instance ID
func generateHostname(instanceID string) string {
if len(instanceID) > 2 {
uniquePart := instanceID[2:10] // Take first 8 chars after "i-"
return fmt.Sprintf("hive-vm-%s", uniquePart)
}
return "hive-vm-unknown"
}
+45 -11
View File
@@ -1,23 +1,57 @@
package handlers_ec2_instance
import (
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/hive/hive/utils"
"github.com/nats-io/nats.go"
)
// NATSInstanceService handles instance operations via NATS messaging
// This will be implemented in Phase 2
type NATSInstanceService struct {
// natsConn *nats.Conn - will be added in Phase 2
natsConn *nats.Conn
}
// NewNATSInstanceService creates a new NATS-based instance service
// func NewNATSInstanceService(conn *nats.Conn) InstanceService {
// return &NATSInstanceService{natsConn: conn}
// }
// TODO Phase 2: Implement NATS-based operations
// These will publish requests to NATS topics and wait for responses
func (s *NATSInstanceService) RunInstances(input *ec2.RunInstancesInput) (*ec2.Reservation, error) {
panic("NATS service not yet implemented - use MockInstanceService for testing")
func NewNATSInstanceService(conn *nats.Conn) InstanceService {
return &NATSInstanceService{natsConn: conn}
}
// RunInstances sends a RunInstances request to the daemon via NATS
func (s *NATSInstanceService) RunInstances(input *ec2.RunInstancesInput) (*ec2.Reservation, error) {
// Marshal input to JSON
jsonData, err := json.Marshal(input)
if err != nil {
slog.Error("NATSInstanceService: Failed to marshal RunInstancesInput", "err", err)
return nil, fmt.Errorf("failed to marshal input: %w", err)
}
// Send request to daemon via NATS with 30 second timeout
msg, err := s.natsConn.Request("ec2.RunInstances", jsonData, 30*time.Second)
if err != nil {
slog.Error("NATSInstanceService: Failed to send NATS request", "err", err)
return nil, fmt.Errorf("NATS request failed: %w", err)
}
// Check if the response is an error
responseError, err := utils.ValidateErrorPayload(msg.Data)
if err != nil {
// Response is an error payload
slog.Error("NATSInstanceService: Received error response from daemon", "code", responseError.Code)
return nil, fmt.Errorf("daemon returned error: %s", *responseError.Code)
}
// Unmarshal successful response
var reservation ec2.Reservation
err = json.Unmarshal(msg.Data, &reservation)
if err != nil {
slog.Error("NATSInstanceService: Failed to unmarshal reservation", "err", err)
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
return &reservation, nil
}
@@ -0,0 +1,424 @@
package handlers_ec2_instance
import (
"encoding/json"
"fmt"
"os"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/hive/hive/utils"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// startTestNATSServer starts an embedded NATS server for testing
func startTestNATSServer(t *testing.T) (*server.Server, string) {
opts := &server.Options{
Host: "127.0.0.1",
Port: -1, // Auto-allocate available port
JetStream: false,
NoLog: true,
NoSigs: true,
}
ns, err := server.NewServer(opts)
require.NoError(t, err, "Failed to create NATS server")
// Start server in goroutine
go ns.Start()
// Wait for server to be ready
if !ns.ReadyForConnections(5 * time.Second) {
t.Fatal("NATS server failed to start")
}
url := ns.ClientURL()
t.Logf("Test NATS server started at: %s", url)
return ns, url
}
// createValidRunInstancesInput creates a valid RunInstancesInput for testing
func createValidRunInstancesInput() *ec2.RunInstancesInput {
return &ec2.RunInstancesInput{
ImageId: aws.String("ami-0abcdef1234567890"),
InstanceType: aws.String("t3.micro"),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
KeyName: aws.String("test-key-pair"),
SecurityGroupIds: []*string{
aws.String("sg-0123456789abcdef0"),
},
SubnetId: aws.String("subnet-6e7f829e"),
UserData: aws.String("#!/bin/bash\necho 'test'"),
}
}
// createValidReservation creates a valid ec2.Reservation response for testing
func createValidReservation() *ec2.Reservation {
return &ec2.Reservation{
ReservationId: aws.String("r-0123456789abcdef0"),
OwnerId: aws.String("123456789012"),
Instances: []*ec2.Instance{
{
InstanceId: aws.String("i-0123456789abcdef0"),
InstanceType: aws.String("t3.micro"),
ImageId: aws.String("ami-0abcdef1234567890"),
State: &ec2.InstanceState{
Code: aws.Int64(0),
Name: aws.String("pending"),
},
PrivateIpAddress: aws.String("10.0.1.100"),
SubnetId: aws.String("subnet-6e7f829e"),
},
},
}
}
// TestNATSInstanceService_RunInstances_Success tests successful RunInstances operation
func TestNATSInstanceService_RunInstances_Success(t *testing.T) {
// Skip if LOG_IGNORE is set
if os.Getenv("LOG_IGNORE") != "" {
t.Setenv("LOG_IGNORE", "1")
}
// Start test NATS server
ns, natsURL := startTestNATSServer(t)
defer ns.Shutdown()
// Create NATS connections
nc, err := nats.Connect(natsURL)
require.NoError(t, err, "Failed to connect to NATS")
defer nc.Close()
// Create mock daemon subscriber that responds with valid reservation
mockReservation := createValidReservation()
_, err = nc.QueueSubscribe("ec2.RunInstances", "hive-workers", func(msg *nats.Msg) {
// Validate that request is properly formatted
var input ec2.RunInstancesInput
err := json.Unmarshal(msg.Data, &input)
if err != nil {
t.Errorf("Mock daemon received invalid JSON: %v", err)
return
}
// Send back valid reservation
responseData, _ := json.Marshal(mockReservation)
msg.Respond(responseData)
})
require.NoError(t, err, "Failed to subscribe mock daemon")
// Create NATSInstanceService
service := NewNATSInstanceService(nc)
require.NotNil(t, service)
// Create valid input
input := createValidRunInstancesInput()
// Call RunInstances
reservation, err := service.RunInstances(input)
// Verify success
require.NoError(t, err, "RunInstances should succeed")
require.NotNil(t, reservation, "Reservation should not be nil")
// Verify reservation contents
assert.Equal(t, *mockReservation.ReservationId, *reservation.ReservationId)
assert.Equal(t, *mockReservation.OwnerId, *reservation.OwnerId)
require.Len(t, reservation.Instances, 1)
instance := reservation.Instances[0]
assert.Equal(t, "i-0123456789abcdef0", *instance.InstanceId)
assert.Equal(t, "t3.micro", *instance.InstanceType)
assert.Equal(t, int64(0), *instance.State.Code)
assert.Equal(t, "pending", *instance.State.Name)
}
// TestNATSInstanceService_RunInstances_DaemonError tests error handling when daemon returns error
func TestNATSInstanceService_RunInstances_DaemonError(t *testing.T) {
if os.Getenv("LOG_IGNORE") != "" {
t.Setenv("LOG_IGNORE", "1")
}
ns, natsURL := startTestNATSServer(t)
defer ns.Shutdown()
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
// Create mock daemon that responds with error
_, err = nc.QueueSubscribe("ec2.RunInstances", "hive-workers", func(msg *nats.Msg) {
// Send back error response
errorResponse := utils.GenerateErrorPayload("InvalidInstanceType")
msg.Respond(errorResponse)
})
require.NoError(t, err)
service := NewNATSInstanceService(nc)
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-0abcdef1234567890"),
InstanceType: aws.String("invalid.type"),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
}
// Call RunInstances
reservation, err := service.RunInstances(input)
// Verify error handling
require.Error(t, err, "Should return error when daemon returns error")
assert.Nil(t, reservation, "Reservation should be nil on error")
assert.Contains(t, err.Error(), "daemon returned error", "Error should indicate daemon error")
}
// TestNATSInstanceService_RunInstances_Timeout tests timeout handling
func TestNATSInstanceService_RunInstances_Timeout(t *testing.T) {
if os.Getenv("LOG_IGNORE") != "" {
t.Setenv("LOG_IGNORE", "1")
}
ns, natsURL := startTestNATSServer(t)
defer ns.Shutdown()
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
// Create mock daemon that never responds (simulates timeout)
_, err = nc.QueueSubscribe("ec2.RunInstances", "hive-workers", func(msg *nats.Msg) {
// Don't respond - simulate timeout
time.Sleep(35 * time.Second)
})
require.NoError(t, err)
service := NewNATSInstanceService(nc)
input := createValidRunInstancesInput()
// Call RunInstances
start := time.Now()
reservation, err := service.RunInstances(input)
duration := time.Since(start)
// Verify timeout behavior
require.Error(t, err, "Should timeout after 30 seconds")
assert.Nil(t, reservation, "Reservation should be nil on timeout")
assert.Contains(t, err.Error(), "NATS request failed", "Error should indicate NATS failure")
// Verify timeout happened around 30 seconds (with some tolerance)
assert.GreaterOrEqual(t, duration.Seconds(), 29.0, "Should wait at least 29 seconds")
assert.LessOrEqual(t, duration.Seconds(), 32.0, "Should timeout within 32 seconds")
}
// TestNATSInstanceService_RunInstances_NoSubscriber tests behavior when no daemon is subscribed
func TestNATSInstanceService_RunInstances_NoSubscriber(t *testing.T) {
if os.Getenv("LOG_IGNORE") != "" {
t.Setenv("LOG_IGNORE", "1")
}
ns, natsURL := startTestNATSServer(t)
defer ns.Shutdown()
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
// No subscriber - no daemon running
service := NewNATSInstanceService(nc)
input := createValidRunInstancesInput()
// Call RunInstances
start := time.Now()
reservation, err := service.RunInstances(input)
duration := time.Since(start)
// Verify error behavior
// When no subscribers are available, NATS fails immediately with "no responders available"
// This is better than waiting 30 seconds for timeout
require.Error(t, err, "Should fail when no subscriber")
assert.Nil(t, reservation, "Reservation should be nil")
assert.Contains(t, err.Error(), "NATS request failed", "Error should indicate NATS failure")
// Should fail quickly (within 1 second), not wait for 30 second timeout
assert.Less(t, duration.Seconds(), 1.0, "Should fail immediately when no responders")
}
// TestNATSInstanceService_RunInstances_InvalidResponse tests handling of malformed responses
func TestNATSInstanceService_RunInstances_InvalidResponse(t *testing.T) {
if os.Getenv("LOG_IGNORE") != "" {
t.Setenv("LOG_IGNORE", "1")
}
ns, natsURL := startTestNATSServer(t)
defer ns.Shutdown()
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
// Create mock daemon that responds with invalid JSON
_, err = nc.QueueSubscribe("ec2.RunInstances", "hive-workers", func(msg *nats.Msg) {
// Send back malformed JSON
msg.Respond([]byte(`{"invalid": json response`))
})
require.NoError(t, err)
service := NewNATSInstanceService(nc)
input := createValidRunInstancesInput()
// Call RunInstances
reservation, err := service.RunInstances(input)
// Verify error handling
require.Error(t, err, "Should return error for invalid JSON")
assert.Nil(t, reservation, "Reservation should be nil")
assert.Contains(t, err.Error(), "failed to unmarshal", "Error should indicate unmarshal failure")
}
// TestNATSInstanceService_RunInstances_MarshalError tests handling when input marshaling fails
func TestNATSInstanceService_RunInstances_MarshalError(t *testing.T) {
if os.Getenv("LOG_IGNORE") != "" {
t.Setenv("LOG_IGNORE", "1")
}
ns, natsURL := startTestNATSServer(t)
defer ns.Shutdown()
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
service := NewNATSInstanceService(nc)
// Create input with unmarshalable fields (channels can't be marshaled to JSON)
// Note: ec2.RunInstancesInput doesn't have channel fields, so this test
// documents the error path even though it's hard to trigger with real AWS SDK types
// For now, we'll test with nil input which should cause validation issues downstream
reservation, err := service.RunInstances(nil)
// The service should handle nil gracefully
// Either by marshaling error or daemon validation error
require.Error(t, err, "Should handle nil input")
assert.Nil(t, reservation)
}
// TestNATSInstanceService_RunInstances_MultipleInstances tests launching multiple instances
func TestNATSInstanceService_RunInstances_MultipleInstances(t *testing.T) {
if os.Getenv("LOG_IGNORE") != "" {
t.Setenv("LOG_IGNORE", "1")
}
ns, natsURL := startTestNATSServer(t)
defer ns.Shutdown()
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
// Create mock daemon that returns multiple instances
_, err = nc.QueueSubscribe("ec2.RunInstances", "hive-workers", func(msg *nats.Msg) {
var input ec2.RunInstancesInput
json.Unmarshal(msg.Data, &input)
// Create reservation with count matching MinCount
count := int(*input.MinCount)
instances := make([]*ec2.Instance, count)
for i := 0; i < count; i++ {
instanceId := aws.String(aws.StringValue(aws.String(fmt.Sprintf("i-%d", i))))
instances[i] = &ec2.Instance{
InstanceId: instanceId,
InstanceType: input.InstanceType,
ImageId: input.ImageId,
State: &ec2.InstanceState{
Code: aws.Int64(0),
Name: aws.String("pending"),
},
}
}
reservation := &ec2.Reservation{
ReservationId: aws.String("r-0123456789abcdef0"),
Instances: instances,
}
responseData, _ := json.Marshal(reservation)
msg.Respond(responseData)
})
require.NoError(t, err)
service := NewNATSInstanceService(nc)
// Request 3 instances
input := createValidRunInstancesInput()
input.MinCount = aws.Int64(3)
input.MaxCount = aws.Int64(3)
reservation, err := service.RunInstances(input)
// Verify multiple instances returned
require.NoError(t, err)
require.NotNil(t, reservation)
assert.Len(t, reservation.Instances, 3, "Should have 3 instances")
}
// TestNATSInstanceService_RunInstances_QueueGroup tests load balancing with queue groups
func TestNATSInstanceService_RunInstances_QueueGroup(t *testing.T) {
if os.Getenv("LOG_IGNORE") != "" {
t.Setenv("LOG_IGNORE", "1")
}
ns, natsURL := startTestNATSServer(t)
defer ns.Shutdown()
nc, err := nats.Connect(natsURL)
require.NoError(t, err)
defer nc.Close()
// Create multiple mock daemon workers in same queue group
worker1Count := 0
worker2Count := 0
_, err = nc.QueueSubscribe("ec2.RunInstances", "hive-workers", func(msg *nats.Msg) {
worker1Count++
reservation := createValidReservation()
responseData, _ := json.Marshal(reservation)
msg.Respond(responseData)
})
require.NoError(t, err)
_, err = nc.QueueSubscribe("ec2.RunInstances", "hive-workers", func(msg *nats.Msg) {
worker2Count++
reservation := createValidReservation()
responseData, _ := json.Marshal(reservation)
msg.Respond(responseData)
})
require.NoError(t, err)
service := NewNATSInstanceService(nc)
input := createValidRunInstancesInput()
// Send multiple requests
requestCount := 10
for i := 0; i < requestCount; i++ {
reservation, err := service.RunInstances(input)
require.NoError(t, err)
require.NotNil(t, reservation)
}
// Verify load was distributed (both workers should have handled some requests)
// With queue groups, NATS distributes messages across subscribers
t.Logf("Worker 1 handled: %d requests", worker1Count)
t.Logf("Worker 2 handled: %d requests", worker2Count)
assert.Greater(t, worker1Count, 0, "Worker 1 should handle some requests")
assert.Greater(t, worker2Count, 0, "Worker 2 should handle some requests")
assert.Equal(t, requestCount, worker1Count+worker2Count, "All requests should be handled")
}
+26 -6
View File
@@ -4,10 +4,12 @@ import (
"log"
"log/slog"
"os"
"time"
"github.com/mulgadc/hive/hive/config"
"github.com/mulgadc/hive/hive/gateway"
"github.com/mulgadc/hive/hive/utils"
"github.com/nats-io/nats.go"
)
var serviceName = "awsgw"
@@ -52,21 +54,39 @@ func (svc *Service) Reload() (err error) {
func launchService(config *config.Config) (err error) {
/*
d := daemon.NewDaemon(config)
slog.Info("Starting AWS-GW daemon ...")
err = d.Start()
*/
// Connect to NATS for service communication
opts := []nats.Option{
nats.Token(config.NATS.ACL.Token),
nats.ReconnectWait(time.Second),
nats.MaxReconnects(-1), // Infinite reconnects
nats.DisconnectErrHandler(func(nc *nats.Conn, err error) {
log.Printf("NATS disconnected: %v", err)
}),
nats.ReconnectHandler(func(nc *nats.Conn) {
log.Printf("NATS reconnected to %s", nc.ConnectedUrl())
}),
}
natsConn, err := nats.Connect(config.NATS.Host, opts...)
if err != nil {
slog.Error("Failed to connect to NATS", "err", err)
return err
}
defer natsConn.Close()
slog.Info("Connected to NATS server", "host", config.NATS.Host)
// Create gateway with NATS connection
gw := gateway.GatewayConfig{
Debug: config.AWSGW.Debug,
DisableLogging: false,
NATSConn: natsConn,
}
app := gw.SetupRoutes()
if err != nil {
slog.Warn("Failed to start AWS-GW daemon", "err", err)
slog.Warn("Failed to setup gateway routes", "err", err)
return err
}
+18 -12
View File
@@ -22,17 +22,21 @@ echo "Data directory: $DATA_DIR"
# Create necessary directories
mkdir -p "$DATA_DIR"/{predastore,viperblock,logs}
mkdir -p /mnt/ramdisk 2>/dev/null || echo "⚠️ /mnt/ramdisk not available, using $DATA_DIR/viperblock"
if [ -d "/mnt/ramdisk" ]; then
# Check if /mnt/ramdisk is mounted, if not mount it as tmpfs
if ! mountpoint -q /mnt/ramdisk; then
echo "💾 Mounting /mnt/ramdisk as tmpfs"
sudo mount -t tmpfs -o size=8G tmpfs /mnt/ramdisk/
fi
# Check if /mnt/ramdisk is mounted, if not mount it as tmpfs
if ! mountpoint -q /mnt/ramdisk; then
echo "💾 Mounting /mnt/ramdisk as tmpfs"
sudo mount -t tmpfs -o size=8G tmpfs /mnt/ramdisk/
fi
# If /mnt/ramdisk is mounted, use it for the WAL directory (for development)
if mountpoint -q "/mnt/ramdisk"; then
WAL_DIR="/mnt/ramdisk/"
fi
# If /mnt/ramdisk is mounted, use it for the WAL directory (for development)
if mountpoint -q "/mnt/ramdisk"; then
WAL_DIR="/mnt/ramdisk/"
else
echo "⚠️ /mnt/ramdisk not available, using $DATA_DIR/viperblock"
fi
# Change to project root for all commands
@@ -184,7 +188,7 @@ start_service "viperblock" "$VIPERBLOCK_CMD"
# 4️⃣ Start Hive Gateway/Daemon
echo ""
echo "4️⃣ Starting Hive Gateway..."
echo "4️⃣. Starting Hive Gateway..."
# Use the same base directory as Viperblock for consistency
export HIVE_BASE_DIR="$VB_BASE_DIR"
@@ -193,13 +197,15 @@ export HIVE_BASE_DIR=$DATA_DIR/hive/
export HIVE_WAL_DIR=$WAL_DIR
#HIVE_CMD="air -c .air-hive.toml"
#HIVE_CMD="./bin/hive service hive start --config config/hive.toml --base-dir $HIVE_BASE_DIR --wal-dir $WAL_DIR"
#HIVE_CMD="./bin/hive service hive start --config $HIVE_CONFIG_PATH--base-dir $HIVE_BASE_DIR --wal-dir $HIVE_WAL_DIR"
HIVE_CMD="./bin/hive service hive start"
start_service "hive" "$HIVE_CMD"
# 5️⃣ Start AWS Gateway
echo ""
echo "4️5️⃣ Starting AWS Gateway..."
echo "5️⃣. Starting AWS Gateway..."
# Use the same base directory as Viperblock for consistency
+7 -4
View File
@@ -13,13 +13,13 @@ LOGS_DIR="$PROJECT_ROOT/data/logs"
#sudo umount /mnt/ramdisk
#fi
echo "🛑 Stopping Hive development environment..."
echo "Stopping Hive development environment..."
# Function to stop service by PID file
stop_service() {
local name="$1"
echo "🔻 Stopping $name..."
echo "🛑 Stopping $name..."
$PROJECT_ROOT/bin/hive service $name stop
@@ -30,17 +30,20 @@ stop_service() {
return 1
else
echo "✅ $name stopped"
echo ""
fi
}
# Stop services in reverse order
echo ""
echo "Stopping services..."
echo ""
# Stop Hive daemon/gateway first (it will terminate running instances, unmount nbd devices)
stop_service "hive"
# Stop AWSGW
stop_service "awsgw"
# Stop Viperblock
stop_service "viperblock"