diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md
index 233543006..645db88b1 100644
--- a/docs/COMMANDS.md
+++ b/docs/COMMANDS.md
@@ -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
2. Terminate stopped instance
3. Terminate with DeleteOnTermination=true deletes volumes
4. Terminate with DeleteOnTermination=false preserves volumes
5. Terminate already-terminated (idempotent)
6. Internal volumes (EFI, cloud-init) always cleaned up
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
2. Reboot stopped instance (error)
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
2. Filter by specific type
3. Filter with `capacity=true` shows available slots
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
2. Modify running instance (error)
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
2. Get output from stopped instance
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
2. Change user data while stopped
3. Modify running instance (error: NotFound — running instances not in KV)
4. Instance not found (error: InvalidInstanceID.NotFound)
5. Instance not stopped (error: IncorrectInstanceState)
6. Invalid instance type accepted (fails on start with InsufficientInstanceCapacity)
7. StateReason cleared on type change (recovery from capacity-unavailable)
8. Missing/malformed instance ID (error: InvalidInstanceID.Malformed)
9. No attribute set (error: InvalidParameterValue)
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
2. Empty log file returns empty output
3. Instance not found (error: InvalidInstanceID.NotFound)
4. Serial console disabled (error: SerialConsoleSessionUnavailable)
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
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
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
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
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
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
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
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
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
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
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
2. Verify via get
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
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
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) |
diff --git a/hive/daemon/daemon.go b/hive/daemon/daemon.go
index e096a0aa7..f73386fa7 100644
--- a/hive/daemon/daemon.go
+++ b/hive/daemon/daemon.go
@@ -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"},
diff --git a/hive/daemon/daemon_handlers.go b/hive/daemon/daemon_handlers.go
index e97ba3d6f..ee382a52b 100644
--- a/hive/daemon/daemon_handlers.go
+++ b/hive/daemon/daemon_handlers.go
@@ -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)
+ }
+}
diff --git a/hive/daemon/daemon_handlers_test.go b/hive/daemon/daemon_handlers_test.go
index 48a0596ac..ddce5c72c 100644
--- a/hive/daemon/daemon_handlers_test.go
+++ b/hive/daemon/daemon_handlers_test.go
@@ -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"])
+}
diff --git a/hive/gateway/ec2.go b/hive/gateway/ec2.go
index bf7357ef0..fa35edca4 100644
--- a/hive/gateway/ec2.go
+++ b/hive/gateway/ec2.go
@@ -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)
}),
diff --git a/hive/gateway/ec2/instance/ModifyInstanceAttribute.go b/hive/gateway/ec2/instance/ModifyInstanceAttribute.go
new file mode 100644
index 000000000..00540ba42
--- /dev/null
+++ b/hive/gateway/ec2/instance/ModifyInstanceAttribute.go
@@ -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
+}
diff --git a/hive/gateway/ec2/instance/ModifyInstanceAttribute_test.go b/hive/gateway/ec2/instance/ModifyInstanceAttribute_test.go
new file mode 100644
index 000000000..5b89cd11c
--- /dev/null
+++ b/hive/gateway/ec2/instance/ModifyInstanceAttribute_test.go
@@ -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())
+}