mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
feat: modify-instance-attribute (#48)
This commit is contained in:
+8
-8
@@ -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) |
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user