feat: ModifyNetworkInterfaceAttribute

This commit is contained in:
Julian Sommer
2026-03-26 14:08:28 +10:00
parent 051ecbd4ce
commit e91b67a7c6
10 changed files with 204 additions and 2 deletions
+1 -1
View File
@@ -381,7 +381,7 @@ ENIs are managed via `spinifex-vpc-enis` KV bucket with CAS-based IPAM allocatio
| `describe-network-interfaces` | `--network-interface-ids`, `--filters` (subnet-id, vpc-id, attachment.instance-id) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeNetworkInterfaces` → daemon lists all keys from `spinifex-vpc-enis` KV → applies filters (subnet-id, vpc-id, attachment.instance-id) → filters by ENI IDs if specified → returns error for non-existent requested IDs | 1. List all ENIs<br>2. Filter by subnet-id<br>3. Filter by vpc-id<br>4. Filter by attachment.instance-id<br>5. Non-existent ENI returns error | **DONE** |
| `attach-network-interface` | — | `--network-interface-id`, `--instance-id`, `--device-index` | ENI and instance must exist | Internal operation used by `run-instances --subnet-id`. Marks ENI as "in-use", sets instance attachment, generates attachment ID. No standalone API endpoint yet. | 1. Attach ENI to instance<br>2. Already attached (error)<br>3. Missing device index (error) | **NOT STARTED** (internal only) |
| `detach-network-interface` | — | `--attachment-id`, `--force` | ENI must be attached | Internal operation used by terminate-instances. Marks ENI as "available", clears attachment info. No standalone API endpoint yet. | 1. Detach ENI<br>2. Force detach<br>3. Not attached (error) | **NOT STARTED** (internal only) |
| `modify-network-interface-attribute` | — | `--network-interface-id`, `--description`, `--source-dest-check`, `--groups` | ENI must exist | NATS `ec2.ModifyNetworkInterfaceAttribute` → daemon updates ENI attributes in KV → return success | 1. Modify description<br>2. Toggle source/dest check<br>3. Update security groups | **NOT STARTED** |
| `modify-network-interface-attribute` | — | `--network-interface-id`, `--description`, `--groups` | ENI must exist; at least one attribute required | NATS `ec2.ModifyNetworkInterfaceAttribute` → daemon validates ENI exists → updates description and/or security groups in `spinifex-vpc-enis` KV (optimistic locking) → return success | 1. Modify description<br>2. Update security groups<br>3. No attributes (InvalidParameterValue)<br>4. Non-existent ENI (error) | **DONE** |
| `assign-private-ip-addresses` | — | `--network-interface-id`, `--private-ip-addresses` or `--secondary-private-ip-address-count` | ENI must exist | NATS `ec2.AssignPrivateIpAddresses` → daemon allocates IPs from subnet pool → assign to ENI → return assigned IPs | 1. Assign specific IPs<br>2. Assign by count<br>3. Cannot specify both (error) | **NOT STARTED** |
| `unassign-private-ip-addresses` | — | `--network-interface-id`, `--private-ip-addresses` | IPs must be assigned to ENI | NATS `ec2.UnassignPrivateIpAddresses` → daemon releases IPs → return success | 1. Unassign secondary IP<br>2. Unassign primary IP (error) | **NOT STARTED** |
+1
View File
@@ -425,6 +425,7 @@ func (d *Daemon) subscribeAll() error {
{"ec2.CreateNetworkInterface", d.handleEC2CreateNetworkInterface, "spinifex-workers"},
{"ec2.DeleteNetworkInterface", d.handleEC2DeleteNetworkInterface, "spinifex-workers"},
{"ec2.DescribeNetworkInterfaces", d.handleEC2DescribeNetworkInterfaces, "spinifex-workers"},
{"ec2.ModifyNetworkInterfaceAttribute", d.handleEC2ModifyNetworkInterfaceAttribute, "spinifex-workers"},
{"ec2.CreateSecurityGroup", d.handleEC2CreateSecurityGroup, "spinifex-workers"},
{"ec2.DeleteSecurityGroup", d.handleEC2DeleteSecurityGroup, "spinifex-workers"},
{"ec2.DescribeSecurityGroups", d.handleEC2DescribeSecurityGroups, "spinifex-workers"},
+4
View File
@@ -54,6 +54,10 @@ func (d *Daemon) handleEC2DeleteNetworkInterface(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.DeleteNetworkInterface)
}
func (d *Daemon) handleEC2ModifyNetworkInterfaceAttribute(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.ModifyNetworkInterfaceAttribute)
}
func (d *Daemon) handleEC2DescribeNetworkInterfaces(msg *nats.Msg) {
handleNATSRequest(msg, d.vpcService.DescribeNetworkInterfaces)
}
+3
View File
@@ -278,6 +278,9 @@ var ec2Actions = map[string]EC2Handler{
"DescribeNetworkInterfaces": ec2Handler(func(input *ec2.DescribeNetworkInterfacesInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_ec2_vpc.DescribeNetworkInterfaces(input, gw.NATSConn, accountID)
}),
"ModifyNetworkInterfaceAttribute": ec2Handler(func(input *ec2.ModifyNetworkInterfaceAttributeInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_ec2_vpc.ModifyNetworkInterfaceAttribute(input, gw.NATSConn, accountID)
}),
"CreateSecurityGroup": ec2Handler(func(input *ec2.CreateSecurityGroupInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_ec2_vpc.CreateSecurityGroup(input, gw.NATSConn, accountID)
}),
@@ -0,0 +1,30 @@
package gateway_ec2_vpc
import (
"errors"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
"github.com/nats-io/nats.go"
)
// ModifyNetworkInterfaceAttribute handles the EC2 ModifyNetworkInterfaceAttribute API call
func ModifyNetworkInterfaceAttribute(input *ec2.ModifyNetworkInterfaceAttributeInput, natsConn *nats.Conn, accountID string) (ec2.ModifyNetworkInterfaceAttributeOutput, error) {
var output ec2.ModifyNetworkInterfaceAttributeOutput
if input == nil {
return output, errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.NetworkInterfaceId == nil || *input.NetworkInterfaceId == "" {
return output, errors.New(awserrors.ErrorMissingParameter)
}
svc := handlers_ec2_vpc.NewNATSVPCService(natsConn)
result, err := svc.ModifyNetworkInterfaceAttribute(input, accountID)
if err != nil {
return output, err
}
return *result, nil
}
+1 -1
View File
@@ -1023,7 +1023,7 @@ func TestEC2ActionMapCompleteness(t *testing.T) {
"CreatePlacementGroup", "DeletePlacementGroup", "DescribePlacementGroups",
"CreateVpc", "DeleteVpc", "DescribeVpcs", "ModifyVpcAttribute", "DescribeVpcAttribute",
"CreateSubnet", "DeleteSubnet", "DescribeSubnets", "ModifySubnetAttribute",
"CreateNetworkInterface", "DeleteNetworkInterface", "DescribeNetworkInterfaces",
"CreateNetworkInterface", "DeleteNetworkInterface", "DescribeNetworkInterfaces", "ModifyNetworkInterfaceAttribute",
"CreateSecurityGroup", "DeleteSecurityGroup", "DescribeSecurityGroups",
"AuthorizeSecurityGroupIngress", "AuthorizeSecurityGroupEgress",
"RevokeSecurityGroupIngress", "RevokeSecurityGroupEgress",
+71
View File
@@ -35,6 +35,7 @@ type ENIRecord struct {
DeviceIndex int64 `json:"device_index"`
PublicIpAddress string `json:"public_ip_address,omitempty"` // Auto-assigned or EIP
PublicIpPool string `json:"public_ip_pool,omitempty"` // Pool name the public IP came from
SecurityGroupIds []string `json:"security_group_ids,omitempty"`
Tags map[string]string `json:"tags"`
CreatedAt time.Time `json:"created_at"`
}
@@ -81,6 +82,13 @@ func (s *VPCServiceImpl) CreateNetworkInterface(input *ec2.CreateNetworkInterfac
description = *input.Description
}
var sgIds []string
for _, id := range input.Groups {
if id != nil {
sgIds = append(sgIds, *id)
}
}
record := ENIRecord{
NetworkInterfaceId: eniId,
SubnetId: subnetId,
@@ -90,6 +98,7 @@ func (s *VPCServiceImpl) CreateNetworkInterface(input *ec2.CreateNetworkInterfac
MacAddress: macAddr,
Description: description,
Status: "available",
SecurityGroupIds: sgIds,
Tags: make(map[string]string),
CreatedAt: time.Now(),
}
@@ -164,6 +173,58 @@ func (s *VPCServiceImpl) DeleteNetworkInterface(input *ec2.DeleteNetworkInterfac
return &ec2.DeleteNetworkInterfaceOutput{}, nil
}
// ModifyNetworkInterfaceAttribute modifies ENI attributes (security groups, description).
func (s *VPCServiceImpl) ModifyNetworkInterfaceAttribute(input *ec2.ModifyNetworkInterfaceAttributeInput, accountID string) (*ec2.ModifyNetworkInterfaceAttributeOutput, error) {
if input.NetworkInterfaceId == nil || *input.NetworkInterfaceId == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
if len(input.Groups) == 0 && input.Description == nil {
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
eniId := *input.NetworkInterfaceId
key := utils.AccountKey(accountID, eniId)
entry, err := s.eniKV.Get(key)
if err != nil {
return nil, errors.New(awserrors.ErrorInvalidNetworkInterfaceIDNotFound)
}
var record ENIRecord
if err := json.Unmarshal(entry.Value(), &record); err != nil {
slog.Error("ModifyNetworkInterfaceAttribute: corrupted ENI record", "eniId", eniId, "accountID", accountID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
if len(input.Groups) > 0 {
sgIds := make([]string, 0, len(input.Groups))
for _, id := range input.Groups {
if id != nil {
sgIds = append(sgIds, *id)
}
}
record.SecurityGroupIds = sgIds
}
if input.Description != nil && input.Description.Value != nil {
record.Description = *input.Description.Value
}
data, err := json.Marshal(record)
if err != nil {
slog.Error("ModifyNetworkInterfaceAttribute: failed to marshal ENI record", "eniId", eniId, "accountID", accountID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
if _, err := s.eniKV.Update(key, data, entry.Revision()); err != nil {
slog.Error("ModifyNetworkInterfaceAttribute: KV update failed", "eniId", eniId, "accountID", accountID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
slog.Info("ModifyNetworkInterfaceAttribute completed", "eniId", eniId, "accountID", accountID)
return &ec2.ModifyNetworkInterfaceAttributeOutput{}, nil
}
// DescribeNetworkInterfaces lists ENIs with optional filters
func (s *VPCServiceImpl) DescribeNetworkInterfaces(input *ec2.DescribeNetworkInterfacesInput, accountID string) (*ec2.DescribeNetworkInterfacesOutput, error) {
var enis []*ec2.NetworkInterface
@@ -376,6 +437,16 @@ func (s *VPCServiceImpl) eniRecordToEC2(record *ENIRecord, accountID string) *ec
Groups: []*ec2.GroupIdentifier{},
}
if len(record.SecurityGroupIds) > 0 {
groups := make([]*ec2.GroupIdentifier, 0, len(record.SecurityGroupIds))
for _, sgId := range record.SecurityGroupIds {
groups = append(groups, &ec2.GroupIdentifier{
GroupId: aws.String(sgId),
})
}
eni.Groups = groups
}
if record.PublicIpAddress != "" {
eni.Association = &ec2.NetworkInterfaceAssociation{
PublicIp: aws.String(record.PublicIpAddress),
+88
View File
@@ -407,3 +407,91 @@ func TestDeleteNetworkInterface_PublishesEvent(t *testing.T) {
t.Fatal("timed out waiting for vpc.delete-port event")
}
}
func TestModifyNetworkInterfaceAttribute_SecurityGroups(t *testing.T) {
svc := setupTestVPCService(t)
vpcId := createTestVPC(t, svc, "10.0.0.0/16")
subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24")
eniId := createTestENI(t, svc, subnetId)
_, err := svc.ModifyNetworkInterfaceAttribute(&ec2.ModifyNetworkInterfaceAttributeInput{
NetworkInterfaceId: aws.String(eniId),
Groups: []*string{aws.String("sg-111"), aws.String("sg-222")},
}, testAccountID)
require.NoError(t, err)
desc, err := svc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
NetworkInterfaceIds: []*string{aws.String(eniId)},
}, testAccountID)
require.NoError(t, err)
require.Len(t, desc.NetworkInterfaces, 1)
require.Len(t, desc.NetworkInterfaces[0].Groups, 2)
assert.Equal(t, "sg-111", *desc.NetworkInterfaces[0].Groups[0].GroupId)
assert.Equal(t, "sg-222", *desc.NetworkInterfaces[0].Groups[1].GroupId)
}
func TestModifyNetworkInterfaceAttribute_Description(t *testing.T) {
svc := setupTestVPCService(t)
vpcId := createTestVPC(t, svc, "10.0.0.0/16")
subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24")
eniId := createTestENI(t, svc, subnetId)
_, err := svc.ModifyNetworkInterfaceAttribute(&ec2.ModifyNetworkInterfaceAttributeInput{
NetworkInterfaceId: aws.String(eniId),
Description: &ec2.AttributeValue{Value: aws.String("updated description")},
}, testAccountID)
require.NoError(t, err)
desc, err := svc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
NetworkInterfaceIds: []*string{aws.String(eniId)},
}, testAccountID)
require.NoError(t, err)
require.Len(t, desc.NetworkInterfaces, 1)
assert.Equal(t, "updated description", *desc.NetworkInterfaces[0].Description)
}
func TestModifyNetworkInterfaceAttribute_NoAttributes(t *testing.T) {
svc := setupTestVPCService(t)
vpcId := createTestVPC(t, svc, "10.0.0.0/16")
subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24")
eniId := createTestENI(t, svc, subnetId)
_, err := svc.ModifyNetworkInterfaceAttribute(&ec2.ModifyNetworkInterfaceAttributeInput{
NetworkInterfaceId: aws.String(eniId),
}, testAccountID)
assert.ErrorContains(t, err, "InvalidParameterValue")
}
func TestModifyNetworkInterfaceAttribute_NotFound(t *testing.T) {
svc := setupTestVPCService(t)
_, err := svc.ModifyNetworkInterfaceAttribute(&ec2.ModifyNetworkInterfaceAttributeInput{
NetworkInterfaceId: aws.String("eni-nonexistent"),
Groups: []*string{aws.String("sg-111")},
}, testAccountID)
assert.ErrorContains(t, err, "InvalidNetworkInterfaceID.NotFound")
}
func TestModifyNetworkInterfaceAttribute_MissingID(t *testing.T) {
svc := setupTestVPCService(t)
_, err := svc.ModifyNetworkInterfaceAttribute(&ec2.ModifyNetworkInterfaceAttributeInput{
Groups: []*string{aws.String("sg-111")},
}, testAccountID)
assert.ErrorContains(t, err, "MissingParameter")
}
func TestCreateNetworkInterface_WithSecurityGroups(t *testing.T) {
svc := setupTestVPCService(t)
vpcId := createTestVPC(t, svc, "10.0.0.0/16")
subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24")
out, err := svc.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
SubnetId: aws.String(subnetId),
Groups: []*string{aws.String("sg-aaa"), aws.String("sg-bbb")},
}, testAccountID)
require.NoError(t, err)
require.Len(t, out.NetworkInterface.Groups, 2)
assert.Equal(t, "sg-aaa", *out.NetworkInterface.Groups[0].GroupId)
assert.Equal(t, "sg-bbb", *out.NetworkInterface.Groups[1].GroupId)
}
+1
View File
@@ -16,6 +16,7 @@ type VPCService interface {
CreateNetworkInterface(input *ec2.CreateNetworkInterfaceInput, accountID string) (*ec2.CreateNetworkInterfaceOutput, error)
DeleteNetworkInterface(input *ec2.DeleteNetworkInterfaceInput, accountID string) (*ec2.DeleteNetworkInterfaceOutput, error)
DescribeNetworkInterfaces(input *ec2.DescribeNetworkInterfacesInput, accountID string) (*ec2.DescribeNetworkInterfacesOutput, error)
ModifyNetworkInterfaceAttribute(input *ec2.ModifyNetworkInterfaceAttributeInput, accountID string) (*ec2.ModifyNetworkInterfaceAttributeOutput, error)
CreateSecurityGroup(input *ec2.CreateSecurityGroupInput, accountID string) (*ec2.CreateSecurityGroupOutput, error)
DeleteSecurityGroup(input *ec2.DeleteSecurityGroupInput, accountID string) (*ec2.DeleteSecurityGroupOutput, error)
DescribeSecurityGroups(input *ec2.DescribeSecurityGroupsInput, accountID string) (*ec2.DescribeSecurityGroupsOutput, error)
@@ -66,6 +66,10 @@ func (s *NATSVPCService) DescribeNetworkInterfaces(input *ec2.DescribeNetworkInt
return utils.NATSRequest[ec2.DescribeNetworkInterfacesOutput](s.natsConn, "ec2.DescribeNetworkInterfaces", input, 30*time.Second, accountID)
}
func (s *NATSVPCService) ModifyNetworkInterfaceAttribute(input *ec2.ModifyNetworkInterfaceAttributeInput, accountID string) (*ec2.ModifyNetworkInterfaceAttributeOutput, error) {
return utils.NATSRequest[ec2.ModifyNetworkInterfaceAttributeOutput](s.natsConn, "ec2.ModifyNetworkInterfaceAttribute", input, 30*time.Second, accountID)
}
func (s *NATSVPCService) CreateSecurityGroup(input *ec2.CreateSecurityGroupInput, accountID string) (*ec2.CreateSecurityGroupOutput, error) {
return utils.NATSRequest[ec2.CreateSecurityGroupOutput](s.natsConn, "ec2.CreateSecurityGroup", input, 30*time.Second, accountID)
}