diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 1c6750835..11decc713 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -236,13 +236,15 @@ Placement groups are stored in `spinifex-placement-groups` JetStream KV bucket, ### EC2 - Elastic IP +EIP operations are conditionally available — only registered when external IPAM is configured (pool mode). EIPs are stored in `spinifex-eip-allocations` KV bucket. Association publishes `vpc.add-nat`/`vpc.delete-nat` events to vpcd for OVN NAT configuration. + | Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status | |---------|-------------------|---------------|---------------|-------------|------------|--------| -| `allocate-address` | — | `--domain` (vpc), `--tag-specifications`, `--dry-run` | None | NATS `ec2.AllocateAddress` → daemon allocates IP from pool → stores in JetStream KV with eipalloc-ID → return AllocationId, PublicIp, Domain | 1. Allocate EIP
2. Verify in describe-addresses
3. Allocate without --domain uses default | **NOT STARTED** | -| `release-address` | — | `--allocation-id`, `--dry-run` | EIP must exist and be disassociated | NATS `ec2.ReleaseAddress` → daemon verifies not associated → release IP back to pool → delete from KV → return success | 1. Release disassociated EIP
2. Release associated EIP (error)
3. Release non-existent (error) | **NOT STARTED** | -| `associate-address` | — | `--allocation-id`, `--instance-id` or `--network-interface-id`, `--dry-run` | EIP and target must exist | NATS `ec2.AssociateAddress` → daemon links EIP to instance/ENI → configure OVS DNAT/SNAT rules → return eipassoc-ID | 1. Associate with instance
2. Re-associate (moves EIP)
3. Associate non-existent EIP (error) | **NOT STARTED** | -| `disassociate-address` | — | `--association-id`, `--dry-run` | Association must exist | NATS `ec2.DisassociateAddress` → daemon unlinks EIP from instance/ENI → remove OVS DNAT/SNAT rules → return success | 1. Disassociate existing
2. Disassociate non-existent (error) | **NOT STARTED** | -| `describe-addresses` | — | `--allocation-ids`, `--filters`, `--public-ips` | None | NATS `ec2.DescribeAddresses` → daemon lists EIPs from KV → return list with association info | 1. List all EIPs
2. Filter by allocation ID
3. Filter by public IP | **NOT STARTED** | +| `allocate-address` | `--public-ipv4-pool` (optional, specific IPAM pool), `--tag-specifications` | `--domain` (hardcoded vpc), `--dry-run` | EIP service must be initialized (external IPAM configured) | Gateway validates input → NATS `ec2.AllocateAddress` with `spinifex-workers` queue group → daemon allocates IP from external IPAM pool (named or best-matching) → creates EIPRecord with state=allocated in `spinifex-eip-allocations` KV → returns AllocationId, PublicIp, Domain=vpc, PublicIpv4Pool | 1. Allocate EIP from default pool
2. Allocate from specific pool
3. Pool exhausted (InsufficientAddressCapacity)
4. With tag specifications
5. Verify in describe-addresses | **DONE** | +| `release-address` | `--allocation-id` | `--dry-run` | EIP must exist and be disassociated | Gateway validates AllocationId non-empty → NATS `ec2.ReleaseAddress` → daemon retrieves EIP record from KV → checks state != associated (InvalidAddressLocked if still associated) → releases IP back to IPAM pool → deletes KV record → returns success | 1. Release disassociated EIP
2. Release associated EIP (error: InvalidAddressLocked)
3. Release non-existent (error: InvalidAllocationID.NotFound)
4. Missing AllocationId (error: MissingParameter) | **DONE** | +| `associate-address` | `--allocation-id`, `--network-interface-id` OR `--instance-id`, `--private-ip-address` (optional override) | `--dry-run`, `--allow-reassociation` | EIP and target ENI/instance must exist | Gateway validates AllocationId + one of NetworkInterfaceId/InstanceId required → NATS `ec2.AssociateAddress` → daemon retrieves EIP → resolves target ENI (by ID or instance lookup) → generates AssociationId → updates record state=associated with ENI/instance/VPC/private IP → publishes `vpc.add-nat` event to vpcd (OVN NAT configuration) → returns AssociationId | 1. Associate with instance
2. Associate with specific ENI
3. Associate with private IP override
4. Missing AllocationId (MissingParameter)
5. Missing target (MissingParameter)
6. Non-existent EIP (InvalidAllocationID.NotFound)
7. Non-existent ENI (InvalidNetworkInterfaceID.NotFound) | **DONE** | +| `disassociate-address` | `--association-id` | `--dry-run` | Association must exist | Gateway validates AssociationId non-empty → NATS `ec2.DisassociateAddress` → daemon scans KV to find EIP by AssociationId → publishes `vpc.delete-nat` event to vpcd → clears association fields → reverts state to allocated → updates KV → returns success | 1. Disassociate existing
2. Non-existent association (InvalidAssociationID.NotFound)
3. Missing AssociationId (MissingParameter) | **DONE** | +| `describe-addresses` | `--allocation-ids`, `--public-ips` | `--filters`, `--dry-run` | None | NATS `ec2.DescribeAddresses` → daemon scans all EIP records in account → filters by AllocationIds and/or PublicIps if specified → returns Address list with allocation, association, PublicIp, PrivateIpAddress, NetworkInterfaceId, InstanceId, Domain | 1. List all EIPs
2. Filter by allocation ID
3. Filter by public IP
4. Non-existent AllocationId returns error
5. Empty result when no EIPs | **DONE** | ### EC2 - NAT Gateway @@ -256,29 +258,30 @@ Placement groups are stored in `spinifex-placement-groups` JetStream KV bucket, ### EC2 - VPC Networking (Core) -Requires OVN/OVS (`apt install openvswitch-switch ovn-host`). VPC/Subnet/ENI CRUD stores metadata in NATS KV and publishes events to vpcd (the network topology daemon) which translates to OVN logical switches, ports, and routers. Single AZ for Spinifex v1. IPAM uses CAS-based allocation from NATS KV (`spinifex-vpc-ipam` bucket). +Requires OVN/OVS (`apt install openvswitch-switch ovn-host`). VPC/Subnet/ENI/SG CRUD stores metadata in NATS KV and publishes events to vpcd (the network topology daemon) which translates to OVN logical switches, ports, routers, and ACLs. Single AZ for Spinifex v1. IPAM uses CAS-based allocation from NATS KV (`spinifex-vpc-ipam` bucket). Security groups stored in `spinifex-vpc-security-groups` KV bucket with optimistic locking on rule updates. | Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status | |---------|-------------------|---------------|---------------|-------------|------------|--------| | `create-vpc` | `--cidr-block`, `--tag-specifications` | `--instance-tenancy`, `--dry-run` | None | Gateway validates CidrBlock (required, valid CIDR, /16–/28 prefix) → NATS `ec2.CreateVpc` → daemon allocates unique VNI from `spinifex-vpc-vni-counter` KV → generates vpc-ID → stores VPCRecord in `spinifex-vpc-vpcs` KV → publishes `vpc.create` event to vpcd (creates OVN logical switch) → returns VPC with state=available | 1. Create VPC with /16 CIDR
2. Invalid CIDR format (error: InvalidVpcRange)
3. CIDR prefix outside /16–/28 (error)
4. Verify in describe-vpcs
5. Tags applied at creation | **DONE** | | `delete-vpc` | `--vpc-id` | `--dry-run` | VPC must be empty (no subnets) | Gateway validates VpcId → NATS `ec2.DeleteVpc` → daemon checks for dependent subnets in `spinifex-vpc-subnets` KV → returns DependencyViolation if subnets exist → deletes from `spinifex-vpc-vpcs` KV → publishes `vpc.delete` event to vpcd (removes OVN resources) | 1. Delete empty VPC
2. Delete VPC with subnets (DependencyViolation)
3. Delete non-existent VPC (error) | **DONE** | | `describe-vpcs` | `--vpc-ids` | `--filters`, `--max-results`, `--dry-run` | None | NATS `ec2.DescribeVpcs` → daemon lists all keys from `spinifex-vpc-vpcs` KV → filters by VPC IDs if specified → returns error for non-existent requested IDs → returns VPC list with CIDR, state, CIDR associations | 1. List all VPCs
2. Filter by VPC ID
3. Non-existent VPC ID returns error | **DONE** | -| `modify-vpc-attribute` | — | `--vpc-id`, `--enable-dns-support`, `--enable-dns-hostnames` | VPC must exist | NATS `ec2.ModifyVpcAttribute` → daemon updates VPC attribute in KV → return success | 1. Enable DNS support
2. Enable DNS hostnames
3. Missing VPC ID (error) | **NOT STARTED** | +| `modify-vpc-attribute` | `--vpc-id`, `--enable-dns-hostnames`, `--enable-dns-support`, `--enable-network-address-usage-metrics` | `--dry-run` | VPC must exist | Gateway validates VpcId non-empty → NATS `ec2.ModifyVpcAttribute` → daemon retrieves VPC record from `spinifex-vpc-vpcs` KV → updates boolean flags if provided → updates KV with optimistic locking → returns empty output | 1. Enable DNS hostnames
2. Enable DNS support
3. Enable network address usage metrics
4. Missing VPC ID (MissingParameter)
5. Non-existent VPC (InvalidVpcID.NotFound) | **DONE** | +| `describe-vpc-attribute` | `--vpc-id`, `--attribute` (enableDnsHostnames, enableDnsSupport, enableNetworkAddressUsageMetrics) | `--dry-run` | VPC must exist | Gateway validates VpcId + Attribute non-empty → NATS `ec2.DescribeVpcAttribute` → daemon retrieves VPC record → returns single attribute as AttributeBooleanValue (one attribute per call, matches AWS behavior) → validates attribute name | 1. Get enableDnsHostnames
2. Get enableDnsSupport
3. Get enableNetworkAddressUsageMetrics
4. Invalid attribute name (InvalidParameterValue)
5. Missing VPC ID (MissingParameter)
6. Non-existent VPC (InvalidVpcID.NotFound) | **DONE** | | `associate-vpc-cidr-block` | — | `--vpc-id`, `--cidr-block` | VPC must exist | NATS `ec2.AssociateVpcCidrBlock` → daemon adds secondary CIDR to VPC metadata → return association | 1. Add secondary CIDR
2. Overlapping CIDR (error)
3. Max CIDR blocks exceeded (error) | **NOT STARTED** | | `disassociate-vpc-cidr-block` | — | `--association-id` | Association must exist | NATS `ec2.DisassociateVpcCidrBlock` → daemon removes secondary CIDR from VPC → return success | 1. Remove secondary CIDR
2. Remove primary CIDR (error) | **NOT STARTED** | | `create-subnet` | `--vpc-id`, `--cidr-block`, `--availability-zone`, `--tag-specifications` | `--dry-run` | VPC must exist | Gateway validates VpcId + CidrBlock (required, valid CIDR, /16–/28 prefix) → NATS `ec2.CreateSubnet` → daemon verifies parent VPC exists → validates subnet CIDR is within VPC CIDR range → checks for overlapping subnets in same VPC → calculates available IPs (total hosts - 5 AWS-reserved: .0/.1/.2/.3/.255) → generates subnet-ID → stores SubnetRecord in `spinifex-vpc-subnets` KV → publishes `vpc.create-subnet` event to vpcd → defaults AZ from config if omitted | 1. Create subnet within VPC CIDR
2. Subnet CIDR outside VPC range (error: InvalidSubnetRange)
3. Overlapping subnet CIDRs (error: InvalidSubnetConflict)
4. CIDR prefix outside /16–/28 (error)
5. Missing VPC or CIDR (error: MissingParameter) | **DONE** | | `delete-subnet` | `--subnet-id` | `--dry-run` | Subnet must exist | NATS `ec2.DeleteSubnet` → daemon verifies subnet exists → deletes from `spinifex-vpc-subnets` KV → publishes `vpc.delete-subnet` event to vpcd | 1. Delete existing subnet
2. Delete non-existent subnet (error) | **DONE** | | `describe-subnets` | `--subnet-ids`, `--filters` (vpc-id) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeSubnets` → daemon lists all keys from `spinifex-vpc-subnets` KV → applies vpc-id filter if present → filters by subnet IDs if specified → recalculates available IPs from CIDR → returns error for non-existent requested IDs | 1. List all subnets
2. Filter by VPC ID
3. Filter by subnet ID
4. Non-existent subnet returns error | **DONE** | -| `modify-subnet-attribute` | — | `--subnet-id`, `--map-public-ip-on-launch`, `--assign-ipv6-address-on-creation` | Subnet must exist | NATS `ec2.ModifySubnetAttribute` → daemon updates subnet attribute in KV → return success | 1. Enable auto-assign public IP
2. Missing subnet ID (error) | **NOT STARTED** | +| `modify-subnet-attribute` | `--subnet-id`, `--map-public-ip-on-launch` | `--assign-ipv6-address-on-creation`, `--dry-run` | Subnet must exist | Gateway validates SubnetId non-empty → NATS `ec2.ModifySubnetAttribute` → daemon retrieves Subnet record from `spinifex-vpc-subnets` KV → updates MapPublicIpOnLaunch flag if provided → updates KV with optimistic locking → returns empty output | 1. Enable auto-assign public IP
2. Disable auto-assign public IP
3. Missing SubnetId (MissingParameter)
4. Non-existent subnet (InvalidSubnetID.NotFound) | **DONE** | | `associate-subnet-cidr-block` | — | `--subnet-id`, `--ipv6-cidr-block` | Subnet must exist | NATS `ec2.AssociateSubnetCidrBlock` → daemon adds IPv6 CIDR to subnet → return association | 1. Add IPv6 CIDR
2. Missing subnet ID (error) | **NOT STARTED** | | `disassociate-subnet-cidr-block` | — | `--association-id` | Association must exist | NATS `ec2.DisassociateSubnetCidrBlock` → daemon removes IPv6 CIDR from subnet → return success | 1. Remove IPv6 CIDR
2. Invalid association (error) | **NOT STARTED** | -| `create-security-group` | — | `--group-name`, `--description`, `--vpc-id`, `--tag-specifications` | VPC must exist | Create security group metadata → create default OVS flow rules (deny all inbound, allow all outbound) → assign sg-ID | 1. Create SG in VPC
2. Duplicate name in same VPC (error)
3. Verify default rules | **NOT STARTED** | -| `delete-security-group` | — | `--group-id` | SG must not be in use by instances | Verify no instances reference SG → remove OVS flow rules → delete metadata | 1. Delete unused SG
2. Delete SG in use (error) | **NOT STARTED** | -| `describe-security-groups` | — | `--group-ids`, `--group-names`, `--filters`, `--max-results` | None | Query NATS KV for SG metadata → return SG list with rules | 1. List all SGs
2. Filter by VPC ID
3. Filter by group name | **NOT STARTED** | -| `authorize-security-group-ingress` | — | `--group-id`, `--protocol`, `--port`, `--cidr`, `--source-group` | SG must exist | Add inbound rule → create OVS OpenFlow rule → persist to metadata | 1. Allow SSH (port 22) from 0.0.0.0/0
2. Allow from specific CIDR
3. Duplicate rule (idempotent) | **NOT STARTED** | -| `authorize-security-group-egress` | — | `--group-id`, `--protocol`, `--port`, `--cidr` | SG must exist | Add outbound rule → create OVS OpenFlow rule → persist to metadata | 1. Allow HTTPS outbound
2. Restrict to specific CIDR | **NOT STARTED** | -| `revoke-security-group-ingress` | — | `--group-id`, `--protocol`, `--port`, `--cidr` | SG must exist, rule must exist | Remove inbound rule → delete OVS OpenFlow rule → update metadata | 1. Revoke existing rule
2. Revoke non-existent rule (error) | **NOT STARTED** | -| `revoke-security-group-egress` | — | `--group-id`, `--protocol`, `--port`, `--cidr` | SG must exist, rule must exist | Remove outbound rule → delete OVS OpenFlow rule → update metadata | 1. Revoke existing rule
2. Revoke non-existent rule (error) | **NOT STARTED** | +| `create-security-group` | `--group-name`, `--description`, `--vpc-id`, `--tag-specifications` | `--dry-run` | VPC must exist | Gateway validates GroupName + VpcId required → NATS `ec2.CreateSecurityGroup` → daemon verifies VPC exists → checks duplicate name in same VPC → generates sg-ID → creates SecurityGroupRecord in `spinifex-vpc-security-groups` KV with default egress rule (allow all outbound 0.0.0.0/0), no ingress rules → publishes `vpc.create-sg` event to vpcd (OVN ACL configuration) → returns GroupId | 1. Create SG in VPC
2. Duplicate name in same VPC (error: InvalidGroup.Duplicate)
3. Verify default egress rule (allow all)
4. Verify no default ingress rules
5. Missing GroupName (MissingParameter)
6. Non-existent VPC (InvalidVpcID.NotFound)
7. With tag specifications | **DONE** | +| `delete-security-group` | `--group-id` | `--dry-run` | SG must exist | Gateway validates GroupId non-empty → NATS `ec2.DeleteSecurityGroup` → daemon retrieves SG from KV → deletes from `spinifex-vpc-security-groups` KV → publishes `vpc.delete-sg` event to vpcd → returns success | 1. Delete existing SG
2. Delete non-existent SG (error: InvalidGroup.NotFound)
3. Missing GroupId (MissingParameter) | **DONE** | +| `describe-security-groups` | `--group-ids`, `--filters` (vpc-id, group-name) | `--group-names`, `--max-results`, `--dry-run` | None | NATS `ec2.DescribeSecurityGroups` → daemon scans all SG records in account → filters by GroupIds, vpc-id filter, or group-name filter → returns SecurityGroup list with ingress/egress rules → returns error for non-existent requested GroupIds | 1. List all SGs
2. Filter by VPC ID
3. Filter by group name
4. Filter by GroupId
5. Non-existent GroupId (InvalidGroup.NotFound)
6. Empty result when no SGs | **DONE** | +| `authorize-security-group-ingress` | `--group-id`, `--ip-permissions` (IpProtocol, FromPort, ToPort, IpRanges/CidrIp, UserIdGroupPairs/GroupId) | `--dry-run` | SG must exist | Gateway validates GroupId non-empty → NATS `ec2.AuthorizeSecurityGroupIngress` → daemon retrieves SG → converts IpPermissions to SGRule objects → appends to IngressRules → updates KV with optimistic locking (revision check) → publishes `vpc.update-sg` event to vpcd → returns true | 1. Allow SSH (port 22) from 0.0.0.0/0
2. Allow from specific CIDR
3. Allow from source security group
4. Missing GroupId (MissingParameter)
5. Non-existent SG (InvalidGroup.NotFound) | **DONE** | +| `authorize-security-group-egress` | `--group-id`, `--ip-permissions` | `--dry-run` | SG must exist | Gateway validates GroupId → NATS `ec2.AuthorizeSecurityGroupEgress` → daemon retrieves SG → appends rules to EgressRules → updates KV with optimistic locking → publishes `vpc.update-sg` event → returns true | 1. Allow HTTPS outbound
2. Restrict to specific CIDR
3. Missing GroupId (MissingParameter)
4. Non-existent SG (InvalidGroup.NotFound) | **DONE** | +| `revoke-security-group-ingress` | `--group-id`, `--ip-permissions` (matching rules to remove) | `--dry-run` | SG must exist | Gateway validates GroupId → NATS `ec2.RevokeSecurityGroupIngress` → daemon retrieves SG → removes matching rules from IngressRules (set difference) → updates KV with optimistic locking → publishes `vpc.update-sg` event → returns true | 1. Revoke existing rule
2. Missing GroupId (MissingParameter)
3. Non-existent SG (InvalidGroup.NotFound) | **DONE** | +| `revoke-security-group-egress` | `--group-id`, `--ip-permissions` (matching rules to remove) | `--dry-run` | SG must exist | Gateway validates GroupId → NATS `ec2.RevokeSecurityGroupEgress` → daemon retrieves SG → removes matching rules from EgressRules (set difference) → updates KV with optimistic locking → publishes `vpc.update-sg` event → returns true | 1. Revoke existing rule
2. Missing GroupId (MissingParameter)
3. Non-existent SG (InvalidGroup.NotFound) | **DONE** | | `create-internet-gateway` | `--tag-specifications` | `--dry-run` | None | NATS `ec2.CreateInternetGateway` → daemon generates igw-ID → creates IGWRecord in `spinifex-igw` KV bucket with state=available (not attached) → returns InternetGateway with empty Attachments | 1. Create IGW
2. Verify in describe-internet-gateways
3. Tags applied at creation | **DONE** | | `attach-internet-gateway` | `--internet-gateway-id`, `--vpc-id` | `--dry-run` | IGW and VPC must exist | NATS `ec2.AttachInternetGateway` → daemon validates IGW exists and is not already attached (ResourceAlreadyAssociated) → sets VpcId on record → state=attached → publishes `vpc.igw-attach` event to vpcd (creates OVN external switch, gateway port, SNAT rules) | 1. Attach IGW to VPC
2. Attach already-attached IGW (ResourceAlreadyAssociated)
3. Non-existent IGW (error)
4. Missing params (MissingParameter) | **DONE** | | `detach-internet-gateway` | `--internet-gateway-id`, `--vpc-id` | `--dry-run` | IGW must be attached to specified VPC | NATS `ec2.DetachInternetGateway` → daemon validates IGW exists and is attached to specified VPC (GatewayNotAttached if wrong VPC) → clears VpcId → state=available → publishes `vpc.igw-detach` event to vpcd (removes OVN resources) | 1. Detach attached IGW
2. Detach from wrong VPC (GatewayNotAttached)
3. Detach unattached IGW (error) | **DONE** | @@ -477,5 +480,5 @@ The gateway evaluates IAM policies on every request (except root user bypass). P | Explicit deny | Deny statements override Allow (deny wins) | **DONE** | | Wildcard matching | `*` matches all, `ec2:*` matches all EC2, `ec2:Describe*` matches prefix | **DONE** | | Account-scoped evaluation | Policies resolved from caller's account context | **DONE** | -| EC2 action mapping | 54 EC2 actions mapped to IAM policy strings | **DONE** | +| EC2 action mapping | All registered EC2 actions mapped to IAM policy strings | **DONE** | | IAM action mapping | 16 IAM actions mapped to IAM policy strings | **DONE** | diff --git a/scripts/iac/aws/basic/main.tf b/scripts/iac/aws/basic/main.tf index 6bde94fb3..71293e90e 100644 --- a/scripts/iac/aws/basic/main.tf +++ b/scripts/iac/aws/basic/main.tf @@ -97,9 +97,9 @@ data "aws_ami" "debian12" { # --------------------------------------------------------------------------- resource "aws_vpc" "test" { - cidr_block = "10.99.0.0/16" - - # enable_dns_hostnames and enable_dns_support require ModifyVpcAttribute (not yet implemented) + cidr_block = "10.99.0.0/16" + enable_dns_hostnames = true + enable_dns_support = true tags = { Name = "spx-basic-test-vpc" diff --git a/spinifex/daemon/daemon.go b/spinifex/daemon/daemon.go index 7b76e35ec..5de54c145 100644 --- a/spinifex/daemon/daemon.go +++ b/spinifex/daemon/daemon.go @@ -420,6 +420,8 @@ func (d *Daemon) subscribeAll() error { {"ec2.DeleteSubnet", d.handleEC2DeleteSubnet, "spinifex-workers"}, {"ec2.DescribeSubnets", d.handleEC2DescribeSubnets, "spinifex-workers"}, {"ec2.ModifySubnetAttribute", d.handleEC2ModifySubnetAttribute, "spinifex-workers"}, + {"ec2.ModifyVpcAttribute", d.handleEC2ModifyVpcAttribute, "spinifex-workers"}, + {"ec2.DescribeVpcAttribute", d.handleEC2DescribeVpcAttribute, "spinifex-workers"}, {"ec2.CreateNetworkInterface", d.handleEC2CreateNetworkInterface, "spinifex-workers"}, {"ec2.DeleteNetworkInterface", d.handleEC2DeleteNetworkInterface, "spinifex-workers"}, {"ec2.DescribeNetworkInterfaces", d.handleEC2DescribeNetworkInterfaces, "spinifex-workers"}, diff --git a/spinifex/daemon/daemon_handlers_vpc.go b/spinifex/daemon/daemon_handlers_vpc.go index 72b52aa06..4922b29c3 100644 --- a/spinifex/daemon/daemon_handlers_vpc.go +++ b/spinifex/daemon/daemon_handlers_vpc.go @@ -38,6 +38,14 @@ func (d *Daemon) handleEC2ModifySubnetAttribute(msg *nats.Msg) { handleNATSRequest(msg, d.vpcService.ModifySubnetAttribute) } +func (d *Daemon) handleEC2ModifyVpcAttribute(msg *nats.Msg) { + handleNATSRequest(msg, d.vpcService.ModifyVpcAttribute) +} + +func (d *Daemon) handleEC2DescribeVpcAttribute(msg *nats.Msg) { + handleNATSRequest(msg, d.vpcService.DescribeVpcAttribute) +} + func (d *Daemon) handleEC2CreateNetworkInterface(msg *nats.Msg) { handleNATSRequest(msg, d.vpcService.CreateNetworkInterface) } diff --git a/spinifex/gateway/ec2.go b/spinifex/gateway/ec2.go index 32e100526..b6a347062 100644 --- a/spinifex/gateway/ec2.go +++ b/spinifex/gateway/ec2.go @@ -218,6 +218,12 @@ var ec2Actions = map[string]EC2Handler{ "DescribeVpcs": ec2Handler(func(input *ec2.DescribeVpcsInput, gw *GatewayConfig, accountID string) (any, error) { return gateway_ec2_vpc.DescribeVpcs(input, gw.NATSConn, accountID) }), + "ModifyVpcAttribute": ec2Handler(func(input *ec2.ModifyVpcAttributeInput, gw *GatewayConfig, accountID string) (any, error) { + return gateway_ec2_vpc.ModifyVpcAttribute(input, gw.NATSConn, accountID) + }), + "DescribeVpcAttribute": ec2Handler(func(input *ec2.DescribeVpcAttributeInput, gw *GatewayConfig, accountID string) (any, error) { + return gateway_ec2_vpc.DescribeVpcAttribute(input, gw.NATSConn, accountID) + }), "CreateSubnet": ec2Handler(func(input *ec2.CreateSubnetInput, gw *GatewayConfig, accountID string) (any, error) { return gateway_ec2_vpc.CreateSubnet(input, gw.NATSConn, accountID) }), diff --git a/spinifex/gateway/ec2/vpc/DescribeVpcAttribute.go b/spinifex/gateway/ec2/vpc/DescribeVpcAttribute.go new file mode 100644 index 000000000..781147682 --- /dev/null +++ b/spinifex/gateway/ec2/vpc/DescribeVpcAttribute.go @@ -0,0 +1,33 @@ +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" +) + +// DescribeVpcAttribute handles the EC2 DescribeVpcAttribute API call +func DescribeVpcAttribute(input *ec2.DescribeVpcAttributeInput, natsConn *nats.Conn, accountID string) (ec2.DescribeVpcAttributeOutput, error) { + var output ec2.DescribeVpcAttributeOutput + + if input == nil { + return output, errors.New(awserrors.ErrorInvalidParameterValue) + } + if input.VpcId == nil || *input.VpcId == "" { + return output, errors.New(awserrors.ErrorMissingParameter) + } + if input.Attribute == nil || *input.Attribute == "" { + return output, errors.New(awserrors.ErrorMissingParameter) + } + + svc := handlers_ec2_vpc.NewNATSVPCService(natsConn) + result, err := svc.DescribeVpcAttribute(input, accountID) + if err != nil { + return output, err + } + + return *result, nil +} diff --git a/spinifex/gateway/ec2/vpc/ModifyVpcAttribute.go b/spinifex/gateway/ec2/vpc/ModifyVpcAttribute.go new file mode 100644 index 000000000..7420452ac --- /dev/null +++ b/spinifex/gateway/ec2/vpc/ModifyVpcAttribute.go @@ -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" +) + +// ModifyVpcAttribute handles the EC2 ModifyVpcAttribute API call +func ModifyVpcAttribute(input *ec2.ModifyVpcAttributeInput, natsConn *nats.Conn, accountID string) (ec2.ModifyVpcAttributeOutput, error) { + var output ec2.ModifyVpcAttributeOutput + + if input == nil { + return output, errors.New(awserrors.ErrorInvalidParameterValue) + } + if input.VpcId == nil || *input.VpcId == "" { + return output, errors.New(awserrors.ErrorMissingParameter) + } + + svc := handlers_ec2_vpc.NewNATSVPCService(natsConn) + result, err := svc.ModifyVpcAttribute(input, accountID) + if err != nil { + return output, err + } + + return *result, nil +} diff --git a/spinifex/gateway/ec2/vpc/vpc_test.go b/spinifex/gateway/ec2/vpc/vpc_test.go index 33dce829a..538121f86 100644 --- a/spinifex/gateway/ec2/vpc/vpc_test.go +++ b/spinifex/gateway/ec2/vpc/vpc_test.go @@ -166,3 +166,57 @@ func TestDescribeNetworkInterfaces_NilNATS(t *testing.T) { _, err = DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{}, nil, testAccountID) assert.Error(t, err) } + +// --- ModifyVpcAttribute gateway tests --- + +func TestModifyVpcAttribute_NilInput(t *testing.T) { + _, err := ModifyVpcAttribute(nil, nil, testAccountID) + assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue) +} + +func TestModifyVpcAttribute_NilVpcId(t *testing.T) { + _, err := ModifyVpcAttribute(&ec2.ModifyVpcAttributeInput{}, nil, testAccountID) + assert.EqualError(t, err, awserrors.ErrorMissingParameter) +} + +func TestModifyVpcAttribute_EmptyVpcId(t *testing.T) { + _, err := ModifyVpcAttribute(&ec2.ModifyVpcAttributeInput{VpcId: aws.String("")}, nil, testAccountID) + assert.EqualError(t, err, awserrors.ErrorMissingParameter) +} + +func TestModifyVpcAttribute_NilNATS(t *testing.T) { + _, err := ModifyVpcAttribute(&ec2.ModifyVpcAttributeInput{VpcId: aws.String("vpc-123")}, nil, testAccountID) + assert.Error(t, err) +} + +// --- DescribeVpcAttribute gateway tests --- + +func TestDescribeVpcAttribute_NilInput(t *testing.T) { + _, err := DescribeVpcAttribute(nil, nil, testAccountID) + assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue) +} + +func TestDescribeVpcAttribute_NilVpcId(t *testing.T) { + _, err := DescribeVpcAttribute(&ec2.DescribeVpcAttributeInput{}, nil, testAccountID) + assert.EqualError(t, err, awserrors.ErrorMissingParameter) +} + +func TestDescribeVpcAttribute_EmptyVpcId(t *testing.T) { + _, err := DescribeVpcAttribute(&ec2.DescribeVpcAttributeInput{VpcId: aws.String("")}, nil, testAccountID) + assert.EqualError(t, err, awserrors.ErrorMissingParameter) +} + +func TestDescribeVpcAttribute_MissingAttribute(t *testing.T) { + _, err := DescribeVpcAttribute(&ec2.DescribeVpcAttributeInput{VpcId: aws.String("vpc-123")}, nil, testAccountID) + assert.EqualError(t, err, awserrors.ErrorMissingParameter) +} + +func TestDescribeVpcAttribute_EmptyAttribute(t *testing.T) { + _, err := DescribeVpcAttribute(&ec2.DescribeVpcAttributeInput{VpcId: aws.String("vpc-123"), Attribute: aws.String("")}, nil, testAccountID) + assert.EqualError(t, err, awserrors.ErrorMissingParameter) +} + +func TestDescribeVpcAttribute_NilNATS(t *testing.T) { + _, err := DescribeVpcAttribute(&ec2.DescribeVpcAttributeInput{VpcId: aws.String("vpc-123"), Attribute: aws.String(ec2.VpcAttributeNameEnableDnsSupport)}, nil, testAccountID) + assert.Error(t, err) +} diff --git a/spinifex/gateway/gateway_test.go b/spinifex/gateway/gateway_test.go index 7c85dc177..700803af5 100644 --- a/spinifex/gateway/gateway_test.go +++ b/spinifex/gateway/gateway_test.go @@ -1021,7 +1021,7 @@ func TestEC2ActionMapCompleteness(t *testing.T) { "CreateEgressOnlyInternetGateway", "DeleteEgressOnlyInternetGateway", "DescribeEgressOnlyInternetGateways", "CreatePlacementGroup", "DeletePlacementGroup", "DescribePlacementGroups", - "CreateVpc", "DeleteVpc", "DescribeVpcs", + "CreateVpc", "DeleteVpc", "DescribeVpcs", "ModifyVpcAttribute", "DescribeVpcAttribute", "CreateSubnet", "DeleteSubnet", "DescribeSubnets", "ModifySubnetAttribute", "CreateNetworkInterface", "DeleteNetworkInterface", "DescribeNetworkInterfaces", "CreateSecurityGroup", "DeleteSecurityGroup", "DescribeSecurityGroups", diff --git a/spinifex/handlers/ec2/vpc/service.go b/spinifex/handlers/ec2/vpc/service.go index 07503e1a2..bb7b6cf38 100644 --- a/spinifex/handlers/ec2/vpc/service.go +++ b/spinifex/handlers/ec2/vpc/service.go @@ -11,6 +11,8 @@ type VPCService interface { DeleteSubnet(input *ec2.DeleteSubnetInput, accountID string) (*ec2.DeleteSubnetOutput, error) DescribeSubnets(input *ec2.DescribeSubnetsInput, accountID string) (*ec2.DescribeSubnetsOutput, error) ModifySubnetAttribute(input *ec2.ModifySubnetAttributeInput, accountID string) (*ec2.ModifySubnetAttributeOutput, error) + ModifyVpcAttribute(input *ec2.ModifyVpcAttributeInput, accountID string) (*ec2.ModifyVpcAttributeOutput, error) + DescribeVpcAttribute(input *ec2.DescribeVpcAttributeInput, accountID string) (*ec2.DescribeVpcAttributeOutput, error) 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) diff --git a/spinifex/handlers/ec2/vpc/service_impl.go b/spinifex/handlers/ec2/vpc/service_impl.go index 885e1bde0..01b7441cb 100644 --- a/spinifex/handlers/ec2/vpc/service_impl.go +++ b/spinifex/handlers/ec2/vpc/service_impl.go @@ -34,13 +34,16 @@ const ( // VPCRecord represents a stored VPC type VPCRecord struct { - VpcId string `json:"vpc_id"` - CidrBlock string `json:"cidr_block"` - State string `json:"state"` - IsDefault bool `json:"is_default"` - VNI int64 `json:"vni"` - Tags map[string]string `json:"tags"` - CreatedAt time.Time `json:"created_at"` + VpcId string `json:"vpc_id"` + CidrBlock string `json:"cidr_block"` + State string `json:"state"` + IsDefault bool `json:"is_default"` + VNI int64 `json:"vni"` + EnableDnsHostnames bool `json:"enable_dns_hostnames"` + EnableDnsSupport bool `json:"enable_dns_support"` + EnableNetworkAddressUsageMetrics bool `json:"enable_network_address_usage_metrics"` + Tags map[string]string `json:"tags"` + CreatedAt time.Time `json:"created_at"` } // SubnetRecord represents a stored Subnet @@ -209,13 +212,15 @@ func (s *VPCServiceImpl) CreateVpc(input *ec2.CreateVpcInput, accountID string) vpcID := utils.GenerateResourceID("vpc") record := VPCRecord{ - VpcId: vpcID, - CidrBlock: ipNet.String(), // Normalize CIDR - State: "available", - IsDefault: false, - VNI: vni, - Tags: make(map[string]string), - CreatedAt: time.Now(), + VpcId: vpcID, + CidrBlock: ipNet.String(), // Normalize CIDR + State: "available", + IsDefault: false, + VNI: vni, + EnableDnsSupport: true, // AWS default + EnableDnsHostnames: false, // AWS default + Tags: make(map[string]string), + CreatedAt: time.Now(), } for _, tagSpec := range input.TagSpecifications { @@ -706,6 +711,95 @@ func (s *VPCServiceImpl) ModifySubnetAttribute(input *ec2.ModifySubnetAttributeI return &ec2.ModifySubnetAttributeOutput{}, nil } +// ModifyVpcAttribute modifies a VPC's DNS attributes. +func (s *VPCServiceImpl) ModifyVpcAttribute(input *ec2.ModifyVpcAttributeInput, accountID string) (*ec2.ModifyVpcAttributeOutput, error) { + if input.VpcId == nil || *input.VpcId == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + if input.EnableDnsHostnames == nil && input.EnableDnsSupport == nil && input.EnableNetworkAddressUsageMetrics == nil { + return nil, errors.New(awserrors.ErrorInvalidParameterValue) + } + + vpcID := *input.VpcId + key := utils.AccountKey(accountID, vpcID) + + entry, err := s.vpcKV.Get(key) + if err != nil { + return nil, errors.New(awserrors.ErrorInvalidVpcIDNotFound) + } + + var record VPCRecord + if err := json.Unmarshal(entry.Value(), &record); err != nil { + slog.Error("ModifyVpcAttribute: corrupted VPC record", "vpcId", vpcID, "accountID", accountID, "err", err) + return nil, errors.New(awserrors.ErrorServerInternal) + } + + if input.EnableDnsHostnames != nil && input.EnableDnsHostnames.Value != nil { + record.EnableDnsHostnames = *input.EnableDnsHostnames.Value + } + if input.EnableDnsSupport != nil && input.EnableDnsSupport.Value != nil { + record.EnableDnsSupport = *input.EnableDnsSupport.Value + } + if input.EnableNetworkAddressUsageMetrics != nil && input.EnableNetworkAddressUsageMetrics.Value != nil { + record.EnableNetworkAddressUsageMetrics = *input.EnableNetworkAddressUsageMetrics.Value + } + + data, err := json.Marshal(record) + if err != nil { + slog.Error("ModifyVpcAttribute: failed to marshal VPC record", "vpcId", vpcID, "accountID", accountID, "err", err) + return nil, errors.New(awserrors.ErrorServerInternal) + } + if _, err := s.vpcKV.Update(key, data, entry.Revision()); err != nil { + slog.Error("ModifyVpcAttribute: KV update failed", "vpcId", vpcID, "accountID", accountID, "err", err) + return nil, errors.New(awserrors.ErrorServerInternal) + } + + slog.Info("ModifyVpcAttribute completed", "vpcId", vpcID, "accountID", accountID) + + return &ec2.ModifyVpcAttributeOutput{}, nil +} + +// DescribeVpcAttribute returns a single VPC attribute per call (AWS behavior). +func (s *VPCServiceImpl) DescribeVpcAttribute(input *ec2.DescribeVpcAttributeInput, accountID string) (*ec2.DescribeVpcAttributeOutput, error) { + if input.VpcId == nil || *input.VpcId == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + if input.Attribute == nil || *input.Attribute == "" { + return nil, errors.New(awserrors.ErrorMissingParameter) + } + + vpcID := *input.VpcId + key := utils.AccountKey(accountID, vpcID) + + entry, err := s.vpcKV.Get(key) + if err != nil { + return nil, errors.New(awserrors.ErrorInvalidVpcIDNotFound) + } + + var record VPCRecord + if err := json.Unmarshal(entry.Value(), &record); err != nil { + slog.Error("DescribeVpcAttribute: corrupted VPC record", "vpcId", vpcID, "accountID", accountID, "err", err) + return nil, errors.New(awserrors.ErrorServerInternal) + } + + output := &ec2.DescribeVpcAttributeOutput{ + VpcId: aws.String(vpcID), + } + + switch *input.Attribute { + case ec2.VpcAttributeNameEnableDnsHostnames: + output.EnableDnsHostnames = &ec2.AttributeBooleanValue{Value: aws.Bool(record.EnableDnsHostnames)} + case ec2.VpcAttributeNameEnableDnsSupport: + output.EnableDnsSupport = &ec2.AttributeBooleanValue{Value: aws.Bool(record.EnableDnsSupport)} + case ec2.VpcAttributeNameEnableNetworkAddressUsageMetrics: + output.EnableNetworkAddressUsageMetrics = &ec2.AttributeBooleanValue{Value: aws.Bool(record.EnableNetworkAddressUsageMetrics)} + default: + return nil, errors.New(awserrors.ErrorInvalidParameterValue) + } + + return output, nil +} + // Default VPC constants matching AWS defaults. const ( DefaultVPCCidr = "172.31.0.0/16" @@ -783,13 +877,15 @@ func (s *VPCServiceImpl) EnsureDefaultVPC(accountID string, bootstrap ...Bootstr vpcID = bootstrap[0].VpcId } vpcRecord := VPCRecord{ - VpcId: vpcID, - CidrBlock: DefaultVPCCidr, - State: "available", - IsDefault: true, - VNI: vni, - Tags: map[string]string{"Name": "default"}, - CreatedAt: time.Now(), + VpcId: vpcID, + CidrBlock: DefaultVPCCidr, + State: "available", + IsDefault: true, + VNI: vni, + EnableDnsSupport: true, // AWS default + EnableDnsHostnames: true, // AWS default for default VPC + Tags: map[string]string{"Name": "default"}, + CreatedAt: time.Now(), } data, err := json.Marshal(vpcRecord) diff --git a/spinifex/handlers/ec2/vpc/service_impl_test.go b/spinifex/handlers/ec2/vpc/service_impl_test.go index 5cf61b25e..2f79934f7 100644 --- a/spinifex/handlers/ec2/vpc/service_impl_test.go +++ b/spinifex/handlers/ec2/vpc/service_impl_test.go @@ -1054,3 +1054,163 @@ func TestSubnet_ModifyAttribute(t *testing.T) { require.Len(t, desc.Subnets, 1) assert.True(t, *desc.Subnets[0].MapPublicIpOnLaunch) } + +// --- VPC Attribute Tests --- + +func TestVpc_DescribeVpcAttribute_Defaults(t *testing.T) { + svc := setupTestVPCService(t) + vpcID := createTestVPC(t, svc, "10.0.0.0/16") + + // EnableDnsSupport defaults to true + desc, err := svc.DescribeVpcAttribute(&ec2.DescribeVpcAttributeInput{ + VpcId: aws.String(vpcID), + Attribute: aws.String(ec2.VpcAttributeNameEnableDnsSupport), + }, testAccountID) + require.NoError(t, err) + assert.Equal(t, vpcID, *desc.VpcId) + assert.True(t, *desc.EnableDnsSupport.Value) + + // EnableDnsHostnames defaults to false + desc, err = svc.DescribeVpcAttribute(&ec2.DescribeVpcAttributeInput{ + VpcId: aws.String(vpcID), + Attribute: aws.String(ec2.VpcAttributeNameEnableDnsHostnames), + }, testAccountID) + require.NoError(t, err) + assert.False(t, *desc.EnableDnsHostnames.Value) + + // EnableNetworkAddressUsageMetrics defaults to false + desc, err = svc.DescribeVpcAttribute(&ec2.DescribeVpcAttributeInput{ + VpcId: aws.String(vpcID), + Attribute: aws.String(ec2.VpcAttributeNameEnableNetworkAddressUsageMetrics), + }, testAccountID) + require.NoError(t, err) + assert.False(t, *desc.EnableNetworkAddressUsageMetrics.Value) +} + +func TestVpc_ModifyVpcAttribute_EnableDnsHostnames(t *testing.T) { + svc := setupTestVPCService(t) + vpcID := createTestVPC(t, svc, "10.0.0.0/16") + + // Set EnableDnsHostnames to true + _, err := svc.ModifyVpcAttribute(&ec2.ModifyVpcAttributeInput{ + VpcId: aws.String(vpcID), + EnableDnsHostnames: &ec2.AttributeBooleanValue{Value: aws.Bool(true)}, + }, testAccountID) + require.NoError(t, err) + + // Verify via DescribeVpcAttribute + desc, err := svc.DescribeVpcAttribute(&ec2.DescribeVpcAttributeInput{ + VpcId: aws.String(vpcID), + Attribute: aws.String(ec2.VpcAttributeNameEnableDnsHostnames), + }, testAccountID) + require.NoError(t, err) + assert.True(t, *desc.EnableDnsHostnames.Value) +} + +func TestVpc_ModifyVpcAttribute_EnableDnsSupport(t *testing.T) { + svc := setupTestVPCService(t) + vpcID := createTestVPC(t, svc, "10.0.0.0/16") + + // Set EnableDnsSupport to false + _, err := svc.ModifyVpcAttribute(&ec2.ModifyVpcAttributeInput{ + VpcId: aws.String(vpcID), + EnableDnsSupport: &ec2.AttributeBooleanValue{Value: aws.Bool(false)}, + }, testAccountID) + require.NoError(t, err) + + // Verify via DescribeVpcAttribute + desc, err := svc.DescribeVpcAttribute(&ec2.DescribeVpcAttributeInput{ + VpcId: aws.String(vpcID), + Attribute: aws.String(ec2.VpcAttributeNameEnableDnsSupport), + }, testAccountID) + require.NoError(t, err) + assert.False(t, *desc.EnableDnsSupport.Value) +} + +func TestVpc_ModifyVpcAttribute_IndependentFields(t *testing.T) { + svc := setupTestVPCService(t) + vpcID := createTestVPC(t, svc, "10.0.0.0/16") + + // Modify only EnableDnsHostnames — EnableDnsSupport should remain true + _, err := svc.ModifyVpcAttribute(&ec2.ModifyVpcAttributeInput{ + VpcId: aws.String(vpcID), + EnableDnsHostnames: &ec2.AttributeBooleanValue{Value: aws.Bool(true)}, + }, testAccountID) + require.NoError(t, err) + + desc, err := svc.DescribeVpcAttribute(&ec2.DescribeVpcAttributeInput{ + VpcId: aws.String(vpcID), + Attribute: aws.String(ec2.VpcAttributeNameEnableDnsSupport), + }, testAccountID) + require.NoError(t, err) + assert.True(t, *desc.EnableDnsSupport.Value, "EnableDnsSupport should remain true") +} + +func TestVpc_DescribeVpcAttribute_InvalidVpcID(t *testing.T) { + svc := setupTestVPCService(t) + + _, err := svc.DescribeVpcAttribute(&ec2.DescribeVpcAttributeInput{ + VpcId: aws.String("vpc-nonexistent"), + Attribute: aws.String(ec2.VpcAttributeNameEnableDnsSupport), + }, testAccountID) + assert.Error(t, err) +} + +func TestVpc_DescribeVpcAttribute_InvalidAttribute(t *testing.T) { + svc := setupTestVPCService(t) + vpcID := createTestVPC(t, svc, "10.0.0.0/16") + + _, err := svc.DescribeVpcAttribute(&ec2.DescribeVpcAttributeInput{ + VpcId: aws.String(vpcID), + Attribute: aws.String("invalidAttribute"), + }, testAccountID) + assert.Error(t, err) +} + +func TestVpc_DescribeVpcAttribute_MissingAttribute(t *testing.T) { + svc := setupTestVPCService(t) + vpcID := createTestVPC(t, svc, "10.0.0.0/16") + + _, err := svc.DescribeVpcAttribute(&ec2.DescribeVpcAttributeInput{ + VpcId: aws.String(vpcID), + }, testAccountID) + assert.Error(t, err) +} + +func TestVpc_ModifyVpcAttribute_EnableNetworkAddressUsageMetrics(t *testing.T) { + svc := setupTestVPCService(t) + vpcID := createTestVPC(t, svc, "10.0.0.0/16") + + _, err := svc.ModifyVpcAttribute(&ec2.ModifyVpcAttributeInput{ + VpcId: aws.String(vpcID), + EnableNetworkAddressUsageMetrics: &ec2.AttributeBooleanValue{Value: aws.Bool(true)}, + }, testAccountID) + require.NoError(t, err) + + desc, err := svc.DescribeVpcAttribute(&ec2.DescribeVpcAttributeInput{ + VpcId: aws.String(vpcID), + Attribute: aws.String(ec2.VpcAttributeNameEnableNetworkAddressUsageMetrics), + }, testAccountID) + require.NoError(t, err) + assert.True(t, *desc.EnableNetworkAddressUsageMetrics.Value) +} + +func TestVpc_ModifyVpcAttribute_NoAttributes(t *testing.T) { + svc := setupTestVPCService(t) + vpcID := createTestVPC(t, svc, "10.0.0.0/16") + + _, err := svc.ModifyVpcAttribute(&ec2.ModifyVpcAttributeInput{ + VpcId: aws.String(vpcID), + }, testAccountID) + assert.EqualError(t, err, "InvalidParameterValue") +} + +func TestVpc_ModifyVpcAttribute_InvalidVpcID(t *testing.T) { + svc := setupTestVPCService(t) + + _, err := svc.ModifyVpcAttribute(&ec2.ModifyVpcAttributeInput{ + VpcId: aws.String("vpc-nonexistent"), + EnableDnsHostnames: &ec2.AttributeBooleanValue{Value: aws.Bool(true)}, + }, testAccountID) + assert.Error(t, err) +} diff --git a/spinifex/handlers/ec2/vpc/service_nats.go b/spinifex/handlers/ec2/vpc/service_nats.go index 9772a1bca..5c278a15b 100644 --- a/spinifex/handlers/ec2/vpc/service_nats.go +++ b/spinifex/handlers/ec2/vpc/service_nats.go @@ -46,6 +46,14 @@ func (s *NATSVPCService) ModifySubnetAttribute(input *ec2.ModifySubnetAttributeI return utils.NATSRequest[ec2.ModifySubnetAttributeOutput](s.natsConn, "ec2.ModifySubnetAttribute", input, 30*time.Second, accountID) } +func (s *NATSVPCService) ModifyVpcAttribute(input *ec2.ModifyVpcAttributeInput, accountID string) (*ec2.ModifyVpcAttributeOutput, error) { + return utils.NATSRequest[ec2.ModifyVpcAttributeOutput](s.natsConn, "ec2.ModifyVpcAttribute", input, 30*time.Second, accountID) +} + +func (s *NATSVPCService) DescribeVpcAttribute(input *ec2.DescribeVpcAttributeInput, accountID string) (*ec2.DescribeVpcAttributeOutput, error) { + return utils.NATSRequest[ec2.DescribeVpcAttributeOutput](s.natsConn, "ec2.DescribeVpcAttribute", input, 30*time.Second, accountID) +} + func (s *NATSVPCService) CreateNetworkInterface(input *ec2.CreateNetworkInterfaceInput, accountID string) (*ec2.CreateNetworkInterfaceOutput, error) { return utils.NATSRequest[ec2.CreateNetworkInterfaceOutput](s.natsConn, "ec2.CreateNetworkInterface", input, 30*time.Second, accountID) }