feat: modify-instance-attribute (#48)

This commit is contained in:
Julian Sommer
2026-02-20 12:19:20 +11:00
committed by GitHub
parent df0e8a6459
commit ff6ca8a057
7 changed files with 599 additions and 8 deletions
+8 -8
View File
@@ -11,8 +11,8 @@
| `terminate-instances` | `--instance-ids`, `DeleteOnTermination` (per-volume flag, default true) | `--dry-run` | `run-instances` (instance must exist) | Gateway sends NATS to target node → daemon kills QEMU process → cleans up NBD mounts → deletes volumes with `DeleteOnTermination=true` via `volumeService.DeleteVolume()` (S3 cleanup of vol/, vol-efi/, vol-cloudinit/) → internal volumes (EFI, cloud-init) always cleaned up via `ebs.delete` NATS → volumes with `DeleteOnTermination=false` left in available state → state→terminated | 1. Terminate running instance<br>2. Terminate stopped instance<br>3. Terminate with DeleteOnTermination=true deletes volumes<br>4. Terminate with DeleteOnTermination=false preserves volumes<br>5. Terminate already-terminated (idempotent)<br>6. Internal volumes (EFI, cloud-init) always cleaned up<br>7. Invalid instance ID | **DONE** |
| `reboot-instances` | — | `--instance-ids`, `--dry-run` | `run-instances` (instance must be running) | Gateway sends NATS to target node → daemon issues QMP `system_reset` → instance reboots without stopping | 1. Reboot running instance<br>2. Reboot stopped instance (error)<br>3. Verify instance stays in running state after reboot | **NOT STARTED** (parser test exists) |
| `describe-instance-types` | `--filters` (capacity filter only) | `--instance-types`, `--max-results`, `--next-token`, `--dry-run`, all other filters | None | Gateway fans out NATS `ec2.DescribeInstanceTypes` to all nodes → each daemon reports supported types (t3.micro/small/medium/large) with vCPU/memory specs → gateway deduplicates and returns | 1. List all instance types<br>2. Filter by specific type<br>3. Filter with `capacity=true` shows available slots<br>4. Verify vCPU/memory specs match hardware | **DONE** |
| `modify-instance-attribute` | — | `--instance-id`, `--instance-type`, `--user-data`, `--disable-api-termination`, `--ebs-optimized` | Instance must be stopped | Validate instance stopped → update instance metadata in NATS KV store → changes applied on next start | 1. Change instance type while stopped<br>2. Modify running instance (error)<br>3. Toggle disable-api-termination | **NOT STARTED** |
| `get-console-output` | — | `--instance-id`, `--latest` | Instance must exist | QMP command to read serial console ring buffer → return base64-encoded output with timestamp | 1. Get output from running instance<br>2. Get output from stopped instance<br>3. Verify cloud-init boot logs visible | **NOT STARTED** |
| `modify-instance-attribute` | `--instance-id`, `--instance-type`, `--user-data` | `--disable-api-termination`, `--ebs-optimized`, `--source-dest-check`, `--instance-initiated-shutdown-behavior`, `--block-device-mappings`, `--groups`, `--ena-support`, `--sriov-net-support` | Instance must be stopped (in NATS KV) | Gateway validates input (exactly one attribute per call, instance ID format) → NATS `ec2.ModifyInstanceAttribute` with `hive-workers` queue group → daemon loads stopped instance from JetStream KV → applies attribute change → writes back to KV → returns `{}` on success. **InstanceType**: updates vm.InstanceType, Config, and Instance fields; clears StateReason (enables recovery from instance-type-missing bug). **UserData**: stores decoded content in vm.UserData and re-encodes to base64 for RunInstancesInput (cloud-init on next start). No instance type pre-validation (matches AWS — invalid types accepted, fail at StartInstances time). | 1. Change instance type while stopped<br>2. Change user data while stopped<br>3. Modify running instance (error: NotFound — running instances not in KV)<br>4. Instance not found (error: InvalidInstanceID.NotFound)<br>5. Instance not stopped (error: IncorrectInstanceState)<br>6. Invalid instance type accepted (fails on start with InsufficientInstanceCapacity)<br>7. StateReason cleared on type change (recovery from capacity-unavailable)<br>8. Missing/malformed instance ID (error: InvalidInstanceID.Malformed)<br>9. No attribute set (error: InvalidParameterValue)<br>10. Multiple attributes in one call (error: InvalidParameterValue) | **DONE** |
| `get-console-output` | `--instance-id` | `--latest` (always returns latest), `--dry-run` | Instance must be running on a node, serial console access must be enabled via `enable-serial-console-access` | Gateway sends NATS `ec2.{instanceId}.GetConsoleOutput` (per-instance topic, routed to owning node) → daemon checks account-level serial console access → reads console log file from disk → returns last 64KB base64-encoded with timestamp. Respects `enable-serial-console-access` / `disable-serial-console-access` toggle. | 1. Get output from running instance<br>2. Empty log file returns empty output<br>3. Instance not found (error: InvalidInstanceID.NotFound)<br>4. Serial console disabled (error: SerialConsoleSessionUnavailable)<br>5. Enable then disable serial console access toggle | **DONE** |
| `monitor-instances` | — | `--instance-ids` | Instance must exist | Enable basic monitoring (CPU, disk, network) → store metrics in NATS KV → return monitoring state | 1. Enable monitoring<br>2. Verify monitoring state in describe-instances | **NOT STARTED** |
| `unmonitor-instances` | — | `--instance-ids` | Instance must exist | Disable detailed monitoring → revert to basic monitoring → return monitoring state | 1. Disable monitoring<br>2. Verify monitoring state reverts in describe-instances | **NOT STARTED** |
@@ -89,12 +89,12 @@ Persistence layer works (NATS JetStream KV), but stored values are not yet enfor
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `enable-ebs-encryption-by-default` | — | `--dry-run` | None | Store encryption default flag in JetStream KV → return `EbsEncryptionByDefault=true` | 1. Enable encryption<br>2. Verify via get | **NOT STARTED** (enforcement pending) |
| `disable-ebs-encryption-by-default` | — | `--dry-run` | None | Clear encryption default flag in JetStream KV → return `EbsEncryptionByDefault=false` | 1. Disable encryption<br>2. Verify via get | **NOT STARTED** (enforcement pending) |
| `get-ebs-encryption-by-default` | — | `--dry-run` | None | Read encryption default flag from JetStream KV → return current state | 1. Get default state<br>2. Verify matches last enable/disable | **NOT STARTED** (enforcement pending) |
| `enable-serial-console-access` | — | `--dry-run` | None | Store serial console access flag in JetStream KV → return enabled=true | 1. Enable access<br>2. Verify via get | **DONE** |
| `disable-serial-console-access` | — | `--dry-run` | None | Clear serial console access flag in JetStream KV → return enabled=false | 1. Disable access | **DONE** |
| `get-serial-console-access-status` | — | `--dry-run` | None | Read serial console access flag from JetStream KV | 1. Get current status | **DONE** |
| `enable-ebs-encryption-by-default` | *(no flags needed)* | `--dry-run` | None | Gateway validates input → NATS `ec2.EnableEbsEncryptionByDefault` with `hive-workers` queue group → daemon stores `EbsEncryptionByDefault=true` in JetStream KV (`hive-ec2-account-settings` bucket) → return `EbsEncryptionByDefault=true`. Enforcement deferred: stored value not yet checked by CreateVolume/CreateSnapshot. | 1. Enable encryption<br>2. Verify via get | **STARTED** (enforcement pending) |
| `disable-ebs-encryption-by-default` | *(no flags needed)* | `--dry-run` | None | Gateway validates input → NATS `ec2.DisableEbsEncryptionByDefault` with `hive-workers` queue group → daemon stores `EbsEncryptionByDefault=false` in JetStream KV → return `EbsEncryptionByDefault=false`. | 1. Disable encryption<br>2. Verify via get | **STARTED** (enforcement pending) |
| `get-ebs-encryption-by-default` | *(no flags needed)* | `--dry-run` | None | Gateway validates input → NATS `ec2.GetEbsEncryptionByDefault` with `hive-workers` queue group → daemon reads `EbsEncryptionByDefault` from JetStream KV → return current state. | 1. Get default state<br>2. Verify matches last enable/disable | **STARTED** (enforcement pending) |
| `enable-serial-console-access` | *(no flags needed)* | `--dry-run` | None | Gateway validates input → NATS `ec2.EnableSerialConsoleAccess` with `hive-workers` queue group → daemon stores `SerialConsoleAccessEnabled=true` in JetStream KV (`hive-ec2-account-settings` bucket) → return enabled=true. Controls `get-console-output` access. | 1. Enable access<br>2. Verify via get | **DONE** |
| `disable-serial-console-access` | *(no flags needed)* | `--dry-run` | None | Gateway validates input → NATS `ec2.DisableSerialConsoleAccess` with `hive-workers` queue group → daemon stores `SerialConsoleAccessEnabled=false` in JetStream KV → return enabled=false. Causes `get-console-output` to return `SerialConsoleSessionUnavailable`. | 1. Disable access<br>2. Verify via get<br>3. get-console-output blocked when disabled | **DONE** |
| `get-serial-console-access-status` | *(no flags needed)* | `--dry-run` | None | Gateway validates input → NATS `ec2.GetSerialConsoleAccessStatus` with `hive-workers` queue group → daemon reads `SerialConsoleAccessEnabled` from JetStream KV → return current state. | 1. Get current status<br>2. Verify matches last enable/disable | **DONE** |
| `enable-snapshot-block-public-access` | — | `--state` | None | Store snapshot block public access state in JetStream KV | 1. Enable with block-all-sharing<br>2. Enable with block-new-sharing | **NOT STARTED** (enforcement pending) |
| `disable-snapshot-block-public-access` | — | `--dry-run` | None | Clear snapshot block public access state in JetStream KV | 1. Disable access | **NOT STARTED** (enforcement pending) |
| `get-snapshot-block-public-access-state` | — | `--dry-run` | None | Read snapshot block public access state from JetStream KV | 1. Get current state | **NOT STARTED** (enforcement pending) |
+1
View File
@@ -378,6 +378,7 @@ func (d *Daemon) subscribeAll() error {
{"ec2.CreateEgressOnlyInternetGateway", d.handleEC2CreateEgressOnlyInternetGateway, "hive-workers"},
{"ec2.DeleteEgressOnlyInternetGateway", d.handleEC2DeleteEgressOnlyInternetGateway, "hive-workers"},
{"ec2.DescribeEgressOnlyInternetGateways", d.handleEC2DescribeEgressOnlyInternetGateways, "hive-workers"},
{"ec2.ModifyInstanceAttribute", d.handleEC2ModifyInstanceAttribute, "hive-workers"},
{"ec2.start", d.handleEC2StartStoppedInstance, "hive-workers"},
{"ec2.terminate", d.handleEC2TerminateStoppedInstance, "hive-workers"},
{"ec2.DescribeStoppedInstances", d.handleEC2DescribeStoppedInstances, "hive-workers"},
+96
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/hive/hive/awserrors"
"github.com/mulgadc/hive/hive/config"
@@ -1929,3 +1930,98 @@ func (d *Daemon) handleNodeVMs(msg *nats.Msg) {
slog.Error("Failed to respond to hive.node.vms", "err", err)
}
}
// handleEC2ModifyInstanceAttribute modifies attributes of a stopped instance in shared KV.
// All supported attributes (InstanceType, UserData) require the instance to be stopped.
func (d *Daemon) handleEC2ModifyInstanceAttribute(msg *nats.Msg) {
respondWithError := func(errCode string) {
if err := msg.Respond(utils.GenerateErrorPayload(errCode)); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
}
var input ec2.ModifyInstanceAttributeInput
if err := json.Unmarshal(msg.Data, &input); err != nil {
slog.Error("handleEC2ModifyInstanceAttribute: failed to unmarshal request", "err", err)
respondWithError(awserrors.ErrorServerInternal)
return
}
if input.InstanceId == nil || *input.InstanceId == "" {
slog.Error("handleEC2ModifyInstanceAttribute: missing instance_id")
respondWithError(awserrors.ErrorMissingParameter)
return
}
instanceID := *input.InstanceId
if d.jsManager == nil {
slog.Error("handleEC2ModifyInstanceAttribute: JetStream not available")
respondWithError(awserrors.ErrorServerInternal)
return
}
instance, err := d.jsManager.LoadStoppedInstance(instanceID)
if err != nil {
slog.Error("handleEC2ModifyInstanceAttribute: failed to load stopped instance", "instanceId", instanceID, "err", err)
respondWithError(awserrors.ErrorServerInternal)
return
}
if instance == nil {
slog.Warn("handleEC2ModifyInstanceAttribute: instance not found in shared KV", "instanceId", instanceID)
respondWithError(awserrors.ErrorInvalidInstanceIDNotFound)
return
}
if instance.Status != vm.StateStopped {
slog.Error("handleEC2ModifyInstanceAttribute: instance not in stopped state", "instanceId", instanceID, "status", instance.Status)
respondWithError(awserrors.ErrorIncorrectInstanceState)
return
}
// Apply the requested attribute change
if input.InstanceType != nil && input.InstanceType.Value != nil {
newType := *input.InstanceType.Value
if newType == "" {
slog.Error("handleEC2ModifyInstanceAttribute: empty instance type value", "instanceId", instanceID)
respondWithError(awserrors.ErrorInvalidInstanceAttributeValue)
return
}
if instance.Instance == nil {
slog.Error("handleEC2ModifyInstanceAttribute: instance.Instance is nil, data integrity issue", "instanceId", instanceID)
respondWithError(awserrors.ErrorServerInternal)
return
}
slog.Info("handleEC2ModifyInstanceAttribute: changing instance type",
"instanceId", instanceID, "oldType", instance.InstanceType, "newType", newType)
instance.InstanceType = newType
instance.Config.InstanceType = newType
instance.Instance.InstanceType = aws.String(newType)
// Clear StateReason — resolves capacity-unavailable state from instance-type-missing bug
instance.Instance.StateReason = nil
}
if input.UserData != nil && input.UserData.Value != nil {
slog.Info("handleEC2ModifyInstanceAttribute: changing user data", "instanceId", instanceID)
// Value arrives as decoded bytes (JSON unmarshal handles base64 → []byte automatically)
instance.UserData = string(input.UserData.Value)
if instance.RunInstancesInput != nil {
instance.RunInstancesInput.UserData = aws.String(base64.StdEncoding.EncodeToString(input.UserData.Value))
}
}
if err := d.jsManager.WriteStoppedInstance(instanceID, instance); err != nil {
slog.Error("handleEC2ModifyInstanceAttribute: failed to write modified instance to KV",
"instanceId", instanceID, "err", err)
respondWithError(awserrors.ErrorServerInternal)
return
}
slog.Info("handleEC2ModifyInstanceAttribute: completed successfully", "instanceId", instanceID)
if err := msg.Respond([]byte(`{}`)); err != nil {
slog.Error("Failed to respond to NATS request", "err", err)
}
}
+270
View File
@@ -1561,3 +1561,273 @@ func TestAttachVolume_ZoneMismatch(t *testing.T) {
require.NoError(t, err)
assert.Contains(t, string(resp.Data), "InvalidVolume.ZoneMismatch")
}
// --- handleEC2ModifyInstanceAttribute tests ---
func TestHandleEC2ModifyInstanceAttribute_ChangeInstanceType(t *testing.T) {
natsURL := sharedJSNATSURL
daemon := createFullTestDaemonWithJetStream(t, natsURL)
sub, err := daemon.natsConn.QueueSubscribe("ec2.ModifyInstanceAttribute", "hive-workers", daemon.handleEC2ModifyInstanceAttribute)
require.NoError(t, err)
defer sub.Unsubscribe()
instanceID := "i-modify-type-001"
instance := &vm.VM{
ID: instanceID,
Status: vm.StateStopped,
InstanceType: "t3.micro",
Config: vm.Config{InstanceType: "t3.micro"},
Instance: &ec2.Instance{
InstanceId: aws.String(instanceID),
InstanceType: aws.String("t3.micro"),
},
}
err = daemon.jsManager.WriteStoppedInstance(instanceID, instance)
require.NoError(t, err)
t.Cleanup(func() { _ = daemon.jsManager.DeleteStoppedInstance(instanceID) })
input := &ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String(instanceID),
InstanceType: &ec2.AttributeValue{Value: aws.String("t3.medium")},
}
reqData, _ := json.Marshal(input)
reply, err := daemon.natsConn.Request("ec2.ModifyInstanceAttribute", reqData, 5*time.Second)
require.NoError(t, err)
assert.Equal(t, `{}`, string(reply.Data))
updated, err := daemon.jsManager.LoadStoppedInstance(instanceID)
require.NoError(t, err)
require.NotNil(t, updated)
assert.Equal(t, "t3.medium", updated.InstanceType)
assert.Equal(t, "t3.medium", updated.Config.InstanceType)
assert.Equal(t, "t3.medium", *updated.Instance.InstanceType)
}
func TestHandleEC2ModifyInstanceAttribute_ChangeUserData(t *testing.T) {
natsURL := sharedJSNATSURL
daemon := createFullTestDaemonWithJetStream(t, natsURL)
sub, err := daemon.natsConn.QueueSubscribe("ec2.ModifyInstanceAttribute", "hive-workers", daemon.handleEC2ModifyInstanceAttribute)
require.NoError(t, err)
defer sub.Unsubscribe()
instanceID := "i-modify-ud-001"
instance := &vm.VM{
ID: instanceID,
Status: vm.StateStopped,
InstanceType: "t3.micro",
UserData: "old data",
RunInstancesInput: &ec2.RunInstancesInput{
UserData: aws.String("b2xkIGRhdGE="),
},
Instance: &ec2.Instance{
InstanceId: aws.String(instanceID),
},
}
err = daemon.jsManager.WriteStoppedInstance(instanceID, instance)
require.NoError(t, err)
t.Cleanup(func() { _ = daemon.jsManager.DeleteStoppedInstance(instanceID) })
// Value holds decoded bytes (the gateway query parser decodes base64 from the CLI,
// then json.Marshal/Unmarshal round-trips []byte through base64 transparently)
newContent := "#!/bin/bash"
input := &ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String(instanceID),
UserData: &ec2.BlobAttributeValue{Value: []byte(newContent)},
}
reqData, _ := json.Marshal(input)
reply, err := daemon.natsConn.Request("ec2.ModifyInstanceAttribute", reqData, 5*time.Second)
require.NoError(t, err)
assert.Equal(t, `{}`, string(reply.Data))
updated, err := daemon.jsManager.LoadStoppedInstance(instanceID)
require.NoError(t, err)
require.NotNil(t, updated)
assert.Equal(t, newContent, updated.UserData)
assert.Equal(t, "IyEvYmluL2Jhc2g=", *updated.RunInstancesInput.UserData)
}
func TestHandleEC2ModifyInstanceAttribute_InstanceNotFound(t *testing.T) {
natsURL := sharedJSNATSURL
daemon := createFullTestDaemonWithJetStream(t, natsURL)
sub, err := daemon.natsConn.QueueSubscribe("ec2.ModifyInstanceAttribute", "hive-workers", daemon.handleEC2ModifyInstanceAttribute)
require.NoError(t, err)
defer sub.Unsubscribe()
input := &ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String("i-nonexistent"),
InstanceType: &ec2.AttributeValue{Value: aws.String("t3.medium")},
}
reqData, _ := json.Marshal(input)
reply, err := daemon.natsConn.Request("ec2.ModifyInstanceAttribute", reqData, 5*time.Second)
require.NoError(t, err)
var errResp map[string]any
err = json.Unmarshal(reply.Data, &errResp)
require.NoError(t, err)
assert.Equal(t, "InvalidInstanceID.NotFound", errResp["Code"])
}
func TestHandleEC2ModifyInstanceAttribute_NotStopped(t *testing.T) {
natsURL := sharedJSNATSURL
daemon := createFullTestDaemonWithJetStream(t, natsURL)
sub, err := daemon.natsConn.QueueSubscribe("ec2.ModifyInstanceAttribute", "hive-workers", daemon.handleEC2ModifyInstanceAttribute)
require.NoError(t, err)
defer sub.Unsubscribe()
instanceID := "i-modify-running-001"
instance := &vm.VM{
ID: instanceID,
Status: vm.StateRunning,
InstanceType: "t3.micro",
}
err = daemon.jsManager.WriteStoppedInstance(instanceID, instance)
require.NoError(t, err)
t.Cleanup(func() { _ = daemon.jsManager.DeleteStoppedInstance(instanceID) })
input := &ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String(instanceID),
InstanceType: &ec2.AttributeValue{Value: aws.String("t3.medium")},
}
reqData, _ := json.Marshal(input)
reply, err := daemon.natsConn.Request("ec2.ModifyInstanceAttribute", reqData, 5*time.Second)
require.NoError(t, err)
var errResp map[string]any
err = json.Unmarshal(reply.Data, &errResp)
require.NoError(t, err)
assert.Equal(t, "IncorrectInstanceState", errResp["Code"])
}
func TestHandleEC2ModifyInstanceAttribute_ClearsStateReason(t *testing.T) {
natsURL := sharedJSNATSURL
daemon := createFullTestDaemonWithJetStream(t, natsURL)
sub, err := daemon.natsConn.QueueSubscribe("ec2.ModifyInstanceAttribute", "hive-workers", daemon.handleEC2ModifyInstanceAttribute)
require.NoError(t, err)
defer sub.Unsubscribe()
instanceID := "i-modify-recovery-001"
instance := &vm.VM{
ID: instanceID,
Status: vm.StateStopped,
InstanceType: "m7i.small",
Config: vm.Config{InstanceType: "m7i.small"},
Instance: &ec2.Instance{
InstanceId: aws.String(instanceID),
InstanceType: aws.String("m7i.small"),
StateReason: &ec2.StateReason{
Code: aws.String("Server.InsufficientInstanceCapacity"),
Message: aws.String("Instance type not available on any node"),
},
},
}
err = daemon.jsManager.WriteStoppedInstance(instanceID, instance)
require.NoError(t, err)
t.Cleanup(func() { _ = daemon.jsManager.DeleteStoppedInstance(instanceID) })
input := &ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String(instanceID),
InstanceType: &ec2.AttributeValue{Value: aws.String("t3.micro")},
}
reqData, _ := json.Marshal(input)
reply, err := daemon.natsConn.Request("ec2.ModifyInstanceAttribute", reqData, 5*time.Second)
require.NoError(t, err)
assert.Equal(t, `{}`, string(reply.Data))
updated, err := daemon.jsManager.LoadStoppedInstance(instanceID)
require.NoError(t, err)
require.NotNil(t, updated)
assert.Equal(t, "t3.micro", updated.InstanceType)
assert.Equal(t, "t3.micro", updated.Config.InstanceType)
assert.Equal(t, "t3.micro", *updated.Instance.InstanceType)
assert.Nil(t, updated.Instance.StateReason)
}
func TestHandleEC2ModifyInstanceAttribute_InvalidTypeAccepted(t *testing.T) {
natsURL := sharedJSNATSURL
daemon := createFullTestDaemonWithJetStream(t, natsURL)
sub, err := daemon.natsConn.QueueSubscribe("ec2.ModifyInstanceAttribute", "hive-workers", daemon.handleEC2ModifyInstanceAttribute)
require.NoError(t, err)
defer sub.Unsubscribe()
instanceID := "i-modify-nonsense-001"
instance := &vm.VM{
ID: instanceID,
Status: vm.StateStopped,
InstanceType: "t3.micro",
Config: vm.Config{InstanceType: "t3.micro"},
Instance: &ec2.Instance{
InstanceId: aws.String(instanceID),
InstanceType: aws.String("t3.micro"),
},
}
err = daemon.jsManager.WriteStoppedInstance(instanceID, instance)
require.NoError(t, err)
t.Cleanup(func() { _ = daemon.jsManager.DeleteStoppedInstance(instanceID) })
// z99.mega is nonsense — modify does not pre-validate, matching AWS behavior
input := &ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String(instanceID),
InstanceType: &ec2.AttributeValue{Value: aws.String("z99.mega")},
}
reqData, _ := json.Marshal(input)
reply, err := daemon.natsConn.Request("ec2.ModifyInstanceAttribute", reqData, 5*time.Second)
require.NoError(t, err)
assert.Equal(t, `{}`, string(reply.Data))
updated, err := daemon.jsManager.LoadStoppedInstance(instanceID)
require.NoError(t, err)
require.NotNil(t, updated)
assert.Equal(t, "z99.mega", updated.InstanceType)
assert.Equal(t, "z99.mega", updated.Config.InstanceType)
assert.Equal(t, "z99.mega", *updated.Instance.InstanceType)
}
func TestHandleEC2ModifyInstanceAttribute_MissingInstanceID(t *testing.T) {
natsURL := sharedJSNATSURL
daemon := createFullTestDaemonWithJetStream(t, natsURL)
sub, err := daemon.natsConn.QueueSubscribe("ec2.ModifyInstanceAttribute", "hive-workers", daemon.handleEC2ModifyInstanceAttribute)
require.NoError(t, err)
defer sub.Unsubscribe()
input := &ec2.ModifyInstanceAttributeInput{}
reqData, _ := json.Marshal(input)
reply, err := daemon.natsConn.Request("ec2.ModifyInstanceAttribute", reqData, 5*time.Second)
require.NoError(t, err)
var errResp map[string]any
err = json.Unmarshal(reply.Data, &errResp)
require.NoError(t, err)
assert.Equal(t, "MissingParameter", errResp["Code"])
}
func TestHandleEC2ModifyInstanceAttribute_InvalidJSON(t *testing.T) {
natsURL := sharedJSNATSURL
daemon := createFullTestDaemonWithJetStream(t, natsURL)
sub, err := daemon.natsConn.QueueSubscribe("ec2.ModifyInstanceAttribute", "hive-workers", daemon.handleEC2ModifyInstanceAttribute)
require.NoError(t, err)
defer sub.Unsubscribe()
reply, err := daemon.natsConn.Request("ec2.ModifyInstanceAttribute", []byte(`{invalid`), 5*time.Second)
require.NoError(t, err)
var errResp map[string]any
err = json.Unmarshal(reply.Data, &errResp)
require.NoError(t, err)
assert.Equal(t, "ServerInternal", errResp["Code"])
}
+3
View File
@@ -69,6 +69,9 @@ var ec2Actions = map[string]EC2Handler{
"GetConsoleOutput": ec2Handler(func(input *ec2.GetConsoleOutputInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_instance.GetConsoleOutput(input, gw.NATSConn)
}),
"ModifyInstanceAttribute": ec2Handler(func(input *ec2.ModifyInstanceAttributeInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_instance.ModifyInstanceAttribute(input, gw.NATSConn)
}),
"CreateKeyPair": ec2Handler(func(input *ec2.CreateKeyPairInput, gw *GatewayConfig) (any, error) {
return gateway_ec2_key.CreateKeyPair(input, gw.NATSConn)
}),
@@ -0,0 +1,78 @@
package gateway_ec2_instance
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"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"
)
// ValidateModifyInstanceAttributeInput validates the input constraints for ModifyInstanceAttribute.
// AWS rejects calls with multiple attributes set in a single request.
func ValidateModifyInstanceAttributeInput(input *ec2.ModifyInstanceAttributeInput) error {
if input == nil {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.InstanceId == nil || *input.InstanceId == "" {
return errors.New(awserrors.ErrorInvalidInstanceIDMalformed)
}
if !strings.HasPrefix(*input.InstanceId, "i-") {
return errors.New(awserrors.ErrorInvalidInstanceIDMalformed)
}
// Exactly one attribute must be set
count := 0
if input.InstanceType != nil {
count++
}
if input.UserData != nil {
count++
}
if count != 1 {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
// Validate instance type value is non-empty if present
if input.InstanceType != nil && (input.InstanceType.Value == nil || *input.InstanceType.Value == "") {
return errors.New(awserrors.ErrorInvalidInstanceAttributeValue)
}
return nil
}
// ModifyInstanceAttribute sends a modify request to the daemon via NATS.
// The daemon updates the stopped instance in KV and returns an empty response on success.
func ModifyInstanceAttribute(input *ec2.ModifyInstanceAttributeInput, natsConn *nats.Conn) (ec2.ModifyInstanceAttributeOutput, error) {
if err := ValidateModifyInstanceAttributeInput(input); err != nil {
return ec2.ModifyInstanceAttributeOutput{}, err
}
slog.Info("ModifyInstanceAttribute: Processing request", "instance_id", *input.InstanceId)
jsonData, err := json.Marshal(input)
if err != nil {
slog.Error("ModifyInstanceAttribute: Failed to marshal request", "instance_id", *input.InstanceId, "err", err)
return ec2.ModifyInstanceAttributeOutput{}, fmt.Errorf("failed to marshal request: %w", err)
}
msg, err := natsConn.Request("ec2.ModifyInstanceAttribute", jsonData, 30*time.Second)
if err != nil {
slog.Error("ModifyInstanceAttribute: Failed to send request", "instance_id", *input.InstanceId, "err", err)
return ec2.ModifyInstanceAttributeOutput{}, fmt.Errorf("failed to send modify request: %w", err)
}
if responseError, parseErr := utils.ValidateErrorPayload(msg.Data); parseErr != nil {
slog.Error("ModifyInstanceAttribute: Daemon returned error", "instance_id", *input.InstanceId, "code", *responseError.Code)
return ec2.ModifyInstanceAttributeOutput{}, errors.New(*responseError.Code)
}
slog.Info("ModifyInstanceAttribute: Completed successfully", "instance_id", *input.InstanceId)
return ec2.ModifyInstanceAttributeOutput{}, nil
}
@@ -0,0 +1,143 @@
package gateway_ec2_instance
import (
"encoding/json"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/hive/hive/awserrors"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- Validation tests ---
func TestValidateModifyInstanceAttributeInput_NilInput(t *testing.T) {
err := ValidateModifyInstanceAttributeInput(nil)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
}
func TestValidateModifyInstanceAttributeInput_MissingInstanceId(t *testing.T) {
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{})
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidInstanceIDMalformed, err.Error())
}
func TestValidateModifyInstanceAttributeInput_EmptyInstanceId(t *testing.T) {
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String(""),
})
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidInstanceIDMalformed, err.Error())
}
func TestValidateModifyInstanceAttributeInput_BadPrefix(t *testing.T) {
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String("x-12345"),
InstanceType: &ec2.AttributeValue{Value: aws.String("t3.micro")},
})
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidInstanceIDMalformed, err.Error())
}
func TestValidateModifyInstanceAttributeInput_NoAttributeSet(t *testing.T) {
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String("i-abc123"),
})
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
}
func TestValidateModifyInstanceAttributeInput_MultipleAttributes(t *testing.T) {
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String("i-abc123"),
InstanceType: &ec2.AttributeValue{Value: aws.String("t3.micro")},
UserData: &ec2.BlobAttributeValue{Value: []byte("data")},
})
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
}
func TestValidateModifyInstanceAttributeInput_EmptyInstanceType(t *testing.T) {
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String("i-abc123"),
InstanceType: &ec2.AttributeValue{Value: aws.String("")},
})
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidInstanceAttributeValue, err.Error())
}
func TestValidateModifyInstanceAttributeInput_NilInstanceTypeValue(t *testing.T) {
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String("i-abc123"),
InstanceType: &ec2.AttributeValue{},
})
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidInstanceAttributeValue, err.Error())
}
func TestValidateModifyInstanceAttributeInput_ValidInstanceType(t *testing.T) {
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String("i-abc123"),
InstanceType: &ec2.AttributeValue{Value: aws.String("t3.medium")},
})
assert.NoError(t, err)
}
func TestValidateModifyInstanceAttributeInput_ValidUserData(t *testing.T) {
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String("i-abc123"),
UserData: &ec2.BlobAttributeValue{Value: []byte("IyEvYmluL2Jhc2g=")},
})
assert.NoError(t, err)
}
// --- Gateway function tests ---
func TestModifyInstanceAttribute_Success(t *testing.T) {
_, nc := startTestNATSServer(t)
nc.QueueSubscribe("ec2.ModifyInstanceAttribute", "hive-workers", func(msg *nats.Msg) {
var input ec2.ModifyInstanceAttributeInput
err := json.Unmarshal(msg.Data, &input)
require.NoError(t, err)
assert.Equal(t, "i-test123", *input.InstanceId)
msg.Respond([]byte(`{}`))
})
input := &ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String("i-test123"),
InstanceType: &ec2.AttributeValue{Value: aws.String("t3.medium")},
}
_, err := ModifyInstanceAttribute(input, nc)
assert.NoError(t, err)
}
func TestModifyInstanceAttribute_DaemonError(t *testing.T) {
_, nc := startTestNATSServer(t)
nc.QueueSubscribe("ec2.ModifyInstanceAttribute", "hive-workers", func(msg *nats.Msg) {
msg.Respond([]byte(`{"Code":"InvalidInstanceID.NotFound"}`))
})
input := &ec2.ModifyInstanceAttributeInput{
InstanceId: aws.String("i-notfound"),
InstanceType: &ec2.AttributeValue{Value: aws.String("t3.medium")},
}
_, err := ModifyInstanceAttribute(input, nc)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidInstanceIDNotFound, err.Error())
}
func TestModifyInstanceAttribute_ValidationFailure(t *testing.T) {
_, nc := startTestNATSServer(t)
_, err := ModifyInstanceAttribute(nil, nc)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
}