feat: filters (#104)

This commit is contained in:
Julian Sommer
2026-04-01 11:20:56 +10:00
committed by GitHub
parent 475e632404
commit d7c72e80af
24 changed files with 3619 additions and 43 deletions
+12 -12
View File
@@ -93,7 +93,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `run-instances` | `--image-id`, `--instance-type`, `--count` (Min/MaxCount), `--key-name`, `--user-data`, `--subnet-id` (auto-creates ENI, assigns private IP), `--block-device-mappings` (DeviceName, VolumeSize, VolumeType, Iops, DeleteOnTermination), `--placement` (GroupName only — routes via spread or cluster strategy) | `--security-group-ids`, `--tag-specifications`, `--dry-run`, `--client-token`, `--disable-api-termination`, `--ebs-optimized`, `--iam-instance-profile`, `--network-interfaces`, `--private-ip-address`, `--monitoring`, `--credit-specification`, `--cpu-options`, `--metadata-options`, `--launch-template`, `--hibernate-options` | `describe-images` (AMI must exist), `create-key-pair` (optional), VPC/SG (optional) | Gateway parses AWS query → if Placement.GroupName set, looks up strategy: spread → `distributeInstancesSpread()` (1 instance per node, atomic CAS reservation), cluster → `distributeInstancesCluster()` (pin all to single node); otherwise NATS `ec2.runinstances` → daemon creates QEMU/KVM VM with viperblock-backed root volume via NBD → if SubnetId provided, auto-creates ENI with private IP → cloud-init injects user-data/keys → on termination, removes instance from placement group → returns reservation with instance ID | 1. Launch with valid AMI and key pair<br>2. Launch with invalid AMI ID (error)<br>3. Launch with block device mappings (custom volume size)<br>4. Launch multiple instances (MinCount/MaxCount)<br>5. Launch with subnet-id (auto-creates ENI)<br>6. Invalid instance type returns error<br>7. Launch with spread placement group (1 per node)<br>8. Launch with cluster placement group (all on one node)<br>9. Insufficient capacity for placement group (error) | **DONE** |
| `describe-instances` | `--instance-ids` | `--filters`, `--max-results`, `--next-token`, `--dry-run` | None | Gateway fans out NATS `ec2.DescribeInstances` to all nodes (no queue group) → each daemon returns local instances → gateway aggregates and returns combined list | 1. Describe all instances (no filter)<br>2. Describe by instance ID<br>3. Describe with filters (e.g. instance-state-name)<br>4. Instance not found returns empty set<br>5. Multi-node aggregation returns instances from all nodes | **DONE** |
| `describe-instances` | `--instance-ids`, `--filters` (instance-state-name, instance-id, instance-type, vpc-id, subnet-id, tag:\*, tag-key, tag-value) | `--max-results`, `--next-token`, `--dry-run` | None | Gateway fans out NATS `ec2.DescribeInstances` to all nodes (no queue group) → each daemon returns local instances → gateway aggregates and returns combined list. Filters applied per-node before aggregation (reduces payload). Also applies to stopped/terminated instances via `describeInstancesFromKV()`. | 1. Describe all instances (no filter)<br>2. Describe by instance ID<br>3. Describe with filters (e.g. instance-state-name)<br>4. Instance not found returns empty set<br>5. Multi-node aggregation returns instances from all nodes<br>6. Filter by tag<br>7. Unknown filter returns InvalidParameterValue | **DONE** |
| `start-instances` | `--instance-ids` | `--dry-run`, `--force` | `run-instances` (instance must exist in stopped state) | Gateway sends NATS `ec2.cmd.{instance-id}` → daemon restarts stopped QEMU process with same config → state transitions stopped→pending→running | 1. Start a stopped instance<br>2. Start already-running instance (error: IncorrectInstanceState)<br>3. Start with invalid instance ID<br>4. Verify volumes re-mount on start | **DONE** |
| `stop-instances` | `--instance-ids` | `--force`, `--hibernate`, `--dry-run` | `run-instances` (instance must be running) | Gateway sends NATS to target node → daemon issues QMP `system_powerdown` for graceful shutdown → monitors heartbeat until QEMU exits → state transitions running→stopping→stopped | 1. Graceful stop of running instance<br>2. Force stop (kills QEMU process)<br>3. Stop already-stopped instance (error)<br>4. Verify ~30s heartbeat detection | **DONE** |
| `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** |
@@ -111,7 +111,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-key-pair` | `--key-name`, `--key-type` (rsa/ed25519) | `--key-format` (pem/ppk), `--tag-specifications`, `--dry-run` | None | NATS `ec2.CreateKeyPair` → daemon generates SSH keypair → stores public key in Predastore S3 (`/bucket/ec2/{name}.pub`) → returns private key material (one-time) | 1. Create RSA key pair<br>2. Create ED25519 key pair<br>3. Duplicate key name (error: InvalidKeyPair.Duplicate)<br>4. Verify key material returned only on creation | **DONE** |
| `describe-key-pairs` | `--key-names`, `--key-pair-ids` | `--filters`, `--max-results`, `--dry-run` | None | NATS `ec2.DescribeKeyPairs` → daemon lists public keys from Predastore S3 → returns key names and fingerprints | 1. List all key pairs<br>2. Filter by key name<br>3. Filter by key pair ID<br>4. Non-existent key returns empty | **DONE** |
| `describe-key-pairs` | `--key-names`, `--key-pair-ids`, `--filters` (key-pair-id, key-name, fingerprint, tag:\*) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeKeyPairs` → daemon lists public keys from Predastore S3 → applies filters → returns key names and fingerprints | 1. List all key pairs<br>2. Filter by key name<br>3. Filter by key pair ID<br>4. Non-existent key returns empty<br>5. Unknown filter returns InvalidParameterValue | **DONE** |
| `delete-key-pair` | `--key-name`, `--key-pair-id` | `--dry-run` | Key must exist | NATS `ec2.DeleteKeyPair` → daemon deletes public key from Predastore S3 → returns success | 1. Delete existing key pair<br>2. Delete non-existent key (idempotent, no error)<br>3. Verify key no longer in describe-key-pairs | **DONE** |
| `import-key-pair` | `--key-name`, `--public-key-material` | `--tag-specifications`, `--dry-run` | None | NATS `ec2.ImportKeyPair` → daemon stores provided public key in Predastore S3 → returns key name and fingerprint | 1. Import valid SSH public key<br>2. Import invalid key material (error)<br>3. Import duplicate name (error)<br>4. Verify imported key usable with run-instances | **DONE** |
@@ -119,7 +119,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `describe-images` | `--image-ids` (format validation only), `--owners` (self, account ID, alias) | `--executable-users`, `--filters`, `--include-deprecated`, `--include-disabled`, `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeImages` → daemon reads AMI metadata from Predastore S3 buckets (ami-*) → filters by ImageIds and Owners → returns image list with state, architecture, block device mappings | 1. List all images<br>2. Filter by image ID<br>3. Filter by owner (self/amazon)<br>4. Non-existent AMI returns empty<br>5. Verify metadata fields (architecture, state, rootDeviceName) | **DONE** |
| `describe-images` | `--image-ids` (format validation only), `--owners` (self, account ID, alias), `--filters` (name, state, architecture, image-id, is-public, owner-id, description, image-type, tag:\*) | `--executable-users`, `--include-deprecated`, `--include-disabled`, `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeImages` → daemon reads AMI metadata from Predastore S3 buckets (ami-*) → filters by ImageIds, Owners, and Filters → returns image list with state, architecture, block device mappings | 1. List all images<br>2. Filter by image ID<br>3. Filter by owner (self/amazon)<br>4. Non-existent AMI returns empty<br>5. Verify metadata fields (architecture, state, rootDeviceName)<br>6. Filter by name wildcard<br>7. Unknown filter returns InvalidParameterValue | **DONE** |
| `create-image` | `--instance-id`, `--name`, `--description`, `--tag-specifications` | `--no-reboot`, `--block-device-mappings`, `--dry-run` | Instance must exist (running or stopped) | Gateway validates input → NATS `ec2.{instanceId}.CreateImage` (per-instance topic) → daemon extracts root volume → snapshots via `ebs.snapshot` NATS (running) or offline S3 copy (stopped) → creates AMI metadata in S3 (`{amiId}/config.json`) → stores tags → returns ami-ID. Duplicate AMI name validation enforced. | 1. Create image from running instance<br>2. Create image from stopped instance<br>3. Invalid instance ID (error)<br>4. Duplicate AMI name (error)<br>5. Verify new AMI appears in describe-images<br>6. Launch new instance from created AMI | **DONE** |
| `register-image` | — | `--name`, `--description`, `--architecture`, `--root-device-name`, `--virtualization-type`, `--block-device-mappings`, `--boot-mode`, `--ena-support`, `--sriov-net-support`, `--tpm-support`, `--imds-support` | Image data must exist in S3 | Create AMI metadata entry in Predastore → assign ami-ID → set state to available → return ami-ID | 1. Register with valid metadata<br>2. Missing required name (error)<br>3. Verify registered image in describe-images | **NOT STARTED** |
| `deregister-image` | — | `--image-id`, `--dry-run` | AMI must exist, no instances using it | Mark AMI metadata as deregistered in S3 → AMI no longer available for new launches → existing instances unaffected | 1. Deregister existing AMI<br>2. Deregister non-existent AMI (error)<br>3. Verify deregistered AMI not in describe-images<br>4. Existing instances from AMI still run | **NOT STARTED** |
@@ -133,13 +133,13 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `describe-volumes` | `--volume-ids` (fast-path lookup), `DeleteOnTermination` (from persisted VolumeMetadata) | `--filters`, `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeVolumes` → daemon queries viperblock for volume metadata → returns volume list with state, size, attachments, type, DeleteOnTermination flag | 1. List all volumes<br>2. Filter by volume ID<br>3. Filter by attachment state<br>4. Non-existent volume returns empty<br>5. DeleteOnTermination reflects persisted value | **DONE** |
| `describe-volumes` | `--volume-ids` (fast-path lookup), `DeleteOnTermination` (from persisted VolumeMetadata), `--filters` (volume-id, status, size, volume-type, attachment.instance-id, attachment.status, attachment.device, availability-zone, tag:\*) | `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeVolumes` → daemon queries viperblock for volume metadata → applies filters → returns volume list with state, size, attachments, type, DeleteOnTermination flag | 1. List all volumes<br>2. Filter by volume ID<br>3. Filter by attachment state<br>4. Non-existent volume returns empty<br>5. DeleteOnTermination reflects persisted value<br>6. Filter by status, size, volume-type<br>7. Unknown filter returns InvalidParameterValue | **DONE** |
| `modify-volume` | `--volume-id`, `--size`, `--volume-type`, `--iops` | `--throughput`, `--dry-run`, `--multi-attach-enabled` | Volume must exist | NATS `ec2.ModifyVolume` → daemon sends resize request to viperblock → NBD does not support live resize, instance must be stopped → returns modification state | 1. Increase volume size<br>2. Modify volume type<br>3. Decrease size (error - not supported)<br>4. Modify attached volume (requires stop/start) | **DONE** |
| `create-volume` | `--size`, `--availability-zone`, `--volume-type` (gp3 only), `--snapshot-id` (creates volume from snapshot) | `--iops` (hardcoded 3000), `--encrypted` (hardcoded false), `--throughput`, `--tag-specifications` | Valid AZ configured via `spinifex init` | Gateway validates input → NATS `ec2.CreateVolume` → daemon generates vol-ID via viperblock → creates volume (empty or from snapshot) of specified size → persists config.json to Predastore S3 → returns vol-ID with state=available | 1. Create 80GB gp3 volume<br>2. Boundary sizes (1 GiB min, 16384 GiB max)<br>3. Invalid AZ (error)<br>4. Verify volume in describe-volumes<br>5. Unsupported volume type (error - only gp3)<br>6. Size out of range (error)<br>7. Create from snapshot | **DONE** |
| `delete-volume` | `--volume-id` | `--dry-run` | Volume must exist and be detached (state=available) | Gateway validates vol- prefix → NATS `ec2.DeleteVolume` → daemon confirms state=available and no AttachedInstance → NATS `ebs.delete` to viperblockd (stops nbdkit/WAL) → deletes S3 objects under vol-id/, vol-id-efi/, vol-id-cloudinit/ → returns success | 1. Delete detached volume<br>2. Delete attached volume (error: VolumeInUse)<br>3. Delete non-existent volume (error: InvalidVolume.NotFound)<br>4. Verify volume gone from describe-volumes<br>5. Malformed volume ID (error: InvalidVolumeID.Malformed)<br>6. Double delete (idempotent NotFound) | **DONE** |
| `attach-volume` | `--volume-id`, `--instance-id`, `--device` (optional, auto-assigns `/dev/sd[f-p]`) | `--dry-run` | Volume must exist (available), instance must exist (running) | Gateway sends to `ec2.cmd.{instanceId}` → daemon validates volume (Predastore) → `ebs.mount` via NATS (viperblock starts NBD server) → QMP `blockdev-add` (nbd-{volId}) → QMP `device_add` (virtio-blk-pci, vdisk-{volId}) → three-phase rollback on failure → update EBSRequests + BlockDeviceMappings → persist to JetStream + Predastore → respond with VolumeAttachment | 1. Attach volume to running instance<br>2. Auto-assign device name<br>3. Attach already-attached volume (VolumeInUse)<br>4. Attach to non-existent instance (InvalidInstanceID.NotFound)<br>5. Attach to stopped instance (IncorrectInstanceState)<br>6. Volume not found (InvalidVolume.NotFound)<br>7. All device slots full (AttachmentLimitExceeded)<br>8. Volume persists across stop/start | **DONE** |
| `detach-volume` | `--volume-id`, `--instance-id` (optional, resolved via DescribeVolumes), `--device` (optional cross-check), `--force` | `--dry-run` | Volume must be attached, instance must be running | Gateway resolves InstanceId if omitted (via DescribeVolumes) → sends to `ec2.cmd.{instanceId}` → daemon validates (running, attached, not boot/EFI/CloudInit, device match) → three-phase hot-unplug: QMP `device_del` (force continues on failure) → QMP `blockdev-del` (abort if fails, preserves state to prevent double-attach) → `ebs.unmount` via NATS (best-effort) → remove from EBSRequests + BlockDeviceMappings → update volume metadata to available → persist state → respond with VolumeAttachment (state=detaching) | 1. Detach with explicit InstanceId<br>2. Detach without InstanceId (gateway resolution)<br>3. Detach with correct --device cross-check<br>4. Missing VolumeId (InvalidParameterValue)<br>5. Volume not attached (IncorrectState)<br>6. Nonexistent volume (InvalidVolume.NotFound)<br>7. Nonexistent instance (InvalidInstanceID.NotFound)<br>8. Instance not running (IncorrectInstanceState)<br>9. Device mismatch (InvalidParameterValue)<br>10. Boot volume protection (OperationNotPermitted)<br>11. Force flag (continues past device_del failure)<br>12. Volume reusability (re-attach after detach) | **DONE** |
| `describe-volume-status` | `--volume-ids` | `--filters`, `--max-results`, `--next-token`, `--dry-run` | None | Gateway validates vol- prefix → NATS `ec2.DescribeVolumeStatus` → daemon fetches VolumeConfig from Predastore S3 (parallel for specific IDs, sequential list-all for no IDs) → builds VolumeStatusItem per volume (status=ok, io-enabled=passed, io-performance=not-applicable) → returns InvalidVolume.NotFound for missing explicit IDs → skips internal sub-volumes (-efi, -cloudinit) | 1. List all volume statuses<br>2. Filter by specific volume IDs (fast path)<br>3. Non-existent volume ID returns InvalidVolume.NotFound<br>4. Invalid volume ID format (InvalidVolume.Malformed)<br>5. Internal sub-volumes excluded from listing<br>6. Nil/empty input defaults to all volumes | **DONE** |
| `describe-volume-status` | `--volume-ids`, `--filters` (volume-id, volume-status.status, availability-zone) | `--max-results`, `--next-token`, `--dry-run` | None | Gateway validates vol- prefix → NATS `ec2.DescribeVolumeStatus` → daemon fetches VolumeConfig from Predastore S3 (parallel for specific IDs, sequential list-all for no IDs) → applies filters → builds VolumeStatusItem per volume (status=ok, io-enabled=passed, io-performance=not-applicable) → returns InvalidVolume.NotFound for missing explicit IDs → skips internal sub-volumes (-efi, -cloudinit) | 1. List all volume statuses<br>2. Filter by specific volume IDs (fast path)<br>3. Non-existent volume ID returns InvalidVolume.NotFound<br>4. Invalid volume ID format (InvalidVolume.Malformed)<br>5. Internal sub-volumes excluded from listing<br>6. Nil/empty input defaults to all volumes<br>7. Unknown filter returns InvalidParameterValue | **DONE** |
| `describe-volumes-modifications` | — | `--volume-ids`, `--filters`, `--max-results` | None | Query pending/completed volume modifications → return modification state, progress, original/target size | 1. Check in-progress modification<br>2. Check completed modification<br>3. No modifications returns empty | **NOT STARTED** |
### EC2 - Snapshot Management
@@ -149,7 +149,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| `create-snapshot` | `--volume-id`, `--description`, `--tag-specifications` | `--dry-run` | Volume must exist in Predastore | Gateway validates vol- prefix → NATS `ec2.CreateSnapshot` → daemon reads VolumeConfig from Predastore → generates snap-ID via viperblock → stores SnapshotConfig (metadata-only, points to source volume) as completed → returns Snapshot with state=completed, progress=100% | 1. Snapshot from valid volume<br>2. Missing volume ID (InvalidParameterValue)<br>3. Volume not found (InvalidVolume.NotFound)<br>4. Invalid volume ID format (InvalidVolume.Malformed)<br>5. Snapshot with description<br>6. Snapshot with tag specifications | **DONE** |
| `create-snapshots` | — | `--instance-specification`, `--description`, `--tag-specifications` | Instance must exist, instance-volume attachment tracking | Create snapshots of all volumes attached to instance → return list of snapshot IDs. Blocked: requires instance-volume attachment tracking to discover which volumes to snapshot. | 1. Snapshot all volumes on instance<br>2. Instance with no volumes | **NOT STARTED** |
| `delete-snapshot` | `--snapshot-id` | `--dry-run` | Snapshot must exist | Gateway validates snap- prefix → NATS `ec2.DeleteSnapshot` → daemon verifies snapshot exists in Predastore → lists and deletes all objects under snapshot prefix → returns success | 1. Delete existing snapshot<br>2. Delete non-existent snapshot (InvalidSnapshot.NotFound)<br>3. Missing snapshot ID (InvalidParameterValue)<br>4. Invalid snapshot ID format (InvalidSnapshot.Malformed) | **DONE** |
| `describe-snapshots` | `--snapshot-ids` | `--owner-ids`, `--filters`, `--max-results`, `--dry-run` | None | Gateway validates snap- prefix on IDs → NATS `ec2.DescribeSnapshots` → daemon lists snap- prefixed objects in Predastore → reads SnapshotConfig for each → filters by snapshot ID if specified → returns snapshot list | 1. List all snapshots<br>2. Filter by snapshot ID<br>3. Empty snapshot list<br>4. Invalid snapshot ID format (InvalidSnapshot.Malformed) | **DONE** |
| `describe-snapshots` | `--snapshot-ids`, `--filters` (snapshot-id, status, volume-id, volume-size, owner-id, tag:\*) | `--owner-ids`, `--max-results`, `--dry-run` | None | Gateway validates snap- prefix on IDs → NATS `ec2.DescribeSnapshots` → daemon lists snap- prefixed objects in Predastore → reads SnapshotConfig for each → applies filters → returns snapshot list | 1. List all snapshots<br>2. Filter by snapshot ID<br>3. Empty snapshot list<br>4. Invalid snapshot ID format (InvalidSnapshot.Malformed)<br>5. Filter by status, volume-id, volume-size<br>6. Unknown filter returns InvalidParameterValue | **DONE** |
| `copy-snapshot` | `--source-snapshot-id`, `--source-region`, `--description` | `--encrypted`, `--dry-run` | Source snapshot must exist | Gateway validates snap- prefix + source region → NATS `ec2.CopySnapshot` → daemon reads source SnapshotConfig → generates new snap-ID → copies metadata (preserves tags, description override) → stores as completed → returns new snapshot ID | 1. Copy within same region<br>2. Copy non-existent snapshot (InvalidSnapshot.NotFound)<br>3. Missing source ID (InvalidParameterValue)<br>4. Missing source region (MissingParameter)<br>5. Copy preserves tags<br>6. Copy with description override | **DONE** |
### EC2 - Tags
@@ -202,7 +202,7 @@ Gateway routing and KV CRUD are wired up but no OVN/OVS integration exists. EIGW
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-egress-only-internet-gateway` | `--vpc-id`, `--tag-specifications` | `--client-token`, `--dry-run` | VPC must exist | Gateway validates VpcId → NATS `ec2.CreateEgressOnlyInternetGateway` → daemon generates eigw-ID → creates EgressOnlyIGWRecord in `spinifex-eigw` KV bucket with state=attached (always attached at creation, unlike IGW) → returns EgressOnlyInternetGateway with attachment | 1. Create with valid VpcId<br>2. Missing VpcId (error: MissingParameter)<br>3. Create with inline tags<br>4. Verify ID format eigw-{16hex} | **STARTED** (KV only, no OVN) |
| `delete-egress-only-internet-gateway` | `--egress-only-internet-gateway-id` | `--dry-run` | EIGW must exist | NATS `ec2.DeleteEgressOnlyInternetGateway` → daemon verifies EIGW exists in KV → deletes from `spinifex-eigw` KV → returns ReturnCode=true. No detach required (unlike IGW). | 1. Delete existing EIGW<br>2. Delete non-existent EIGW (error)<br>3. Missing ID (error: MissingParameter) | **STARTED** (KV only, no OVN) |
| `describe-egress-only-internet-gateways` | `--egress-only-internet-gateway-ids` | `--filters`, `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeEgressOnlyInternetGateways` → daemon lists all keys from `spinifex-eigw` KV bucket → filters by ID if specified → always includes Attachments array → returns list | 1. Describe all EIGWs<br>2. Filter by specific ID<br>3. Non-existent ID returns empty list | **STARTED** (KV only, no OVN) |
| `describe-egress-only-internet-gateways` | `--egress-only-internet-gateway-ids`, `--filters` (egress-only-internet-gateway-id, tag:\*) | `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeEgressOnlyInternetGateways` → daemon lists all keys from `spinifex-eigw` KV bucket → filters by ID if specified → applies filters → always includes Attachments array → returns list | 1. Describe all EIGWs<br>2. Filter by specific ID<br>3. Non-existent ID returns empty list<br>4. Unknown filter returns InvalidParameterValue | **STARTED** (KV only, no OVN) |
### EC2 - Placement Groups
@@ -258,7 +258,7 @@ EIP operations are conditionally available — only registered when external IPA
| `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<br>2. Release associated EIP (error: InvalidAddressLocked)<br>3. Release non-existent (error: InvalidAllocationID.NotFound)<br>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<br>2. Associate with specific ENI<br>3. Associate with private IP override<br>4. Missing AllocationId (MissingParameter)<br>5. Missing target (MissingParameter)<br>6. Non-existent EIP (InvalidAllocationID.NotFound)<br>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<br>2. Non-existent association (InvalidAssociationID.NotFound)<br>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<br>2. Filter by allocation ID<br>3. Filter by public IP<br>4. Non-existent AllocationId returns error<br>5. Empty result when no EIPs | **DONE** |
| `describe-addresses` | `--allocation-ids`, `--public-ips`, `--filters` (allocation-id, public-ip, instance-id, association-id, domain, tag:\*) | `--dry-run` | None | NATS `ec2.DescribeAddresses` → daemon scans all EIP records in account → filters by AllocationIds and/or PublicIps if specified → applies filters → returns Address list with allocation, association, PublicIp, PrivateIpAddress, NetworkInterfaceId, InstanceId, Domain | 1. List all EIPs<br>2. Filter by allocation ID<br>3. Filter by public IP<br>4. Non-existent AllocationId returns error<br>5. Empty result when no EIPs<br>6. Filter by instance-id, domain<br>7. Unknown filter returns InvalidParameterValue | **DONE** |
| `describe-addresses-attribute` | `--allocation-ids` (optional filter) | `--attribute`, `--dry-run`, `--max-results`, `--next-token` | None | Gateway validates input → NATS `ec2.DescribeAddressesAttribute` with `spinifex-workers` queue group → daemon looks up EIP records by AllocationIds (or scans all account EIPs if none specified) → returns AddressAttribute list with AllocationId and PublicIp per entry. Unlike DescribeAddresses, returns empty list (not error) for non-existent AllocationIds. PtrRecord always nil (reverse-DNS not supported). | 1. List all EIP attributes<br>2. Filter by allocation IDs<br>3. Non-existent AllocationId returns empty (not error)<br>4. Nil input returns error | **DONE** |
### EC2 - NAT Gateway
@@ -281,20 +281,20 @@ Requires OVN/OVS (`apt install openvswitch-switch ovn-host`). VPC/Subnet/ENI/SG
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `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<br>2. Invalid CIDR format (error: InvalidVpcRange)<br>3. CIDR prefix outside /16–/28 (error)<br>4. Verify in describe-vpcs<br>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<br>2. Delete VPC with subnets (DependencyViolation)<br>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<br>2. Filter by VPC ID<br>3. Non-existent VPC ID returns error | **DONE** |
| `describe-vpcs` | `--vpc-ids`, `--filters` (vpc-id, state, cidr-block, is-default, owner-id, tag:\*) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeVpcs` → daemon lists all keys from `spinifex-vpc-vpcs` KV → filters by VPC IDs if specified → applies filters → returns error for non-existent requested IDs → returns VPC list with CIDR, state, CIDR associations | 1. List all VPCs<br>2. Filter by VPC ID<br>3. Non-existent VPC ID returns error<br>4. Filter by state, cidr-block, is-default<br>5. Unknown filter returns InvalidParameterValue | **DONE** |
| `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<br>2. Enable DNS support<br>3. Enable network address usage metrics<br>4. Missing VPC ID (MissingParameter)<br>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<br>2. Get enableDnsSupport<br>3. Get enableNetworkAddressUsageMetrics<br>4. Invalid attribute name (InvalidParameterValue)<br>5. Missing VPC ID (MissingParameter)<br>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<br>2. Overlapping CIDR (error)<br>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<br>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<br>2. Subnet CIDR outside VPC range (error: InvalidSubnetRange)<br>3. Overlapping subnet CIDRs (error: InvalidSubnetConflict)<br>4. CIDR prefix outside /16–/28 (error)<br>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<br>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<br>2. Filter by VPC ID<br>3. Filter by subnet ID<br>4. Non-existent subnet returns error | **DONE** |
| `describe-subnets` | `--subnet-ids`, `--filters` (vpc-id, subnet-id, availability-zone, cidr-block, state, default-for-az, tag:\*) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeSubnets` → daemon lists all keys from `spinifex-vpc-subnets` KV → applies filters → filters by subnet IDs if specified → recalculates available IPs from CIDR → returns error for non-existent requested IDs | 1. List all subnets<br>2. Filter by VPC ID<br>3. Filter by subnet ID<br>4. Non-existent subnet returns error<br>5. Filter by availability-zone, cidr-block, state<br>6. Unknown filter returns InvalidParameterValue | **DONE** |
| `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<br>2. Disable auto-assign public IP<br>3. Missing SubnetId (MissingParameter)<br>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<br>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<br>2. Invalid association (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<br>2. Duplicate name in same VPC (error: InvalidGroup.Duplicate)<br>3. Verify default egress rule (allow all)<br>4. Verify no default ingress rules<br>5. Missing GroupName (MissingParameter)<br>6. Non-existent VPC (InvalidVpcID.NotFound)<br>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<br>2. Delete non-existent SG (error: InvalidGroup.NotFound)<br>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<br>2. Filter by VPC ID<br>3. Filter by group name<br>4. Filter by GroupId<br>5. Non-existent GroupId (InvalidGroup.NotFound)<br>6. Empty result when no SGs | **DONE** |
| `describe-security-groups` | `--group-ids`, `--filters` (vpc-id, group-name, group-id, description, ip-permission.cidr, tag:\*) | `--group-names`, `--max-results`, `--dry-run` | None | NATS `ec2.DescribeSecurityGroups` → daemon scans all SG records in account → applies filters (AND logic across filter names, OR across values) → returns SecurityGroup list with ingress/egress rules → returns error for non-existent requested GroupIds | 1. List all SGs<br>2. Filter by VPC ID<br>3. Filter by group name<br>4. Filter by GroupId<br>5. Non-existent GroupId (InvalidGroup.NotFound)<br>6. Empty result when no SGs<br>7. Filter by description, ip-permission.cidr<br>8. Unknown filter returns InvalidParameterValue | **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<br>2. Allow from specific CIDR<br>3. Allow from source security group<br>4. Missing GroupId (MissingParameter)<br>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<br>2. Restrict to specific CIDR<br>3. Missing GroupId (MissingParameter)<br>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<br>2. Missing GroupId (MissingParameter)<br>3. Non-existent SG (InvalidGroup.NotFound) | **DONE** |
@@ -303,7 +303,7 @@ Requires OVN/OVS (`apt install openvswitch-switch ovn-host`). VPC/Subnet/ENI/SG
| `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<br>2. Attach already-attached IGW (ResourceAlreadyAssociated)<br>3. Non-existent IGW (error)<br>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<br>2. Detach from wrong VPC (GatewayNotAttached)<br>3. Detach unattached IGW (error) | **DONE** |
| `delete-internet-gateway` | `--internet-gateway-id` | `--dry-run` | IGW must be detached | NATS `ec2.DeleteInternetGateway` → daemon validates IGW exists and VpcId is empty (DependencyViolation if attached) → deletes from `spinifex-igw` KV | 1. Delete detached IGW<br>2. Delete attached IGW (DependencyViolation)<br>3. Delete non-existent (error) | **DONE** |
| `describe-internet-gateways` | `--internet-gateway-ids` | `--filters`, `--max-results`, `--dry-run` | None | NATS `ec2.DescribeInternetGateways` → daemon lists all keys from `spinifex-igw` KV → filters by IGW IDs if specified → returns list with Attachments array (if attached) | 1. List all IGWs<br>2. Filter by IGW ID<br>3. Verify attachment info | **DONE** |
| `describe-internet-gateways` | `--internet-gateway-ids`, `--filters` (internet-gateway-id, attachment.vpc-id, attachment.state, tag:\*) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeInternetGateways` → daemon lists all keys from `spinifex-igw` KV → filters by IGW IDs if specified → applies filters → returns list with Attachments array (if attached) | 1. List all IGWs<br>2. Filter by IGW ID<br>3. Verify attachment info<br>4. Filter by attachment.vpc-id, attachment.state<br>5. Unknown filter returns InvalidParameterValue | **DONE** |
### EC2 - Route Tables
+108
View File
@@ -11,6 +11,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/filterutil"
handlers_ec2_placementgroup "github.com/mulgadc/spinifex/spinifex/handlers/ec2/placementgroup"
"github.com/mulgadc/spinifex/spinifex/qmp"
"github.com/mulgadc/spinifex/spinifex/types"
@@ -632,6 +633,89 @@ func (d *Daemon) handleStopOrTerminateInstance(msg *nats.Msg, command types.EC2I
}(instance, command.Attributes)
}
// describeInstancesValidFilters defines the set of filter names accepted by DescribeInstances.
var describeInstancesValidFilters = map[string]bool{
"instance-state-name": true,
"instance-id": true,
"instance-type": true,
"vpc-id": true,
"subnet-id": true,
"tag-key": true,
"tag-value": true,
}
// instanceMatchesFilters checks whether a VM + its built ec2.Instance copy satisfy all parsed filters.
func instanceMatchesFilters(inst *vm.VM, ic *ec2.Instance, filters map[string][]string) bool {
for name, values := range filters {
if strings.HasPrefix(name, "tag:") {
// tag:Key filters are handled after all field filters.
continue
}
var field string
switch name {
case "instance-state-name":
if ic.State != nil && ic.State.Name != nil {
field = *ic.State.Name
}
case "instance-id":
field = inst.ID
case "instance-type":
field = inst.InstanceType
case "vpc-id":
if ic.VpcId != nil {
field = *ic.VpcId
}
case "subnet-id":
if ic.SubnetId != nil {
field = *ic.SubnetId
}
case "tag-key":
if !matchTagKey(ic.Tags, values) {
return false
}
continue
case "tag-value":
if !matchTagValue(ic.Tags, values) {
return false
}
continue
default:
// Filter name passed ParseFilters but has no case — treat as non-match
// to avoid silently ignoring it.
return false
}
if !filterutil.MatchesAny(values, field) {
return false
}
}
// Check tag:Key filters via the instance's Tag slice.
tags := filterutil.EC2TagsToMap(ic.Tags)
return filterutil.MatchesTags(filters, tags)
}
// matchTagKey returns true if any tag key on the resource matches any of the filter values.
func matchTagKey(tags []*ec2.Tag, values []string) bool {
for _, t := range tags {
if t.Key != nil && filterutil.MatchesAny(values, *t.Key) {
return true
}
}
return false
}
// matchTagValue returns true if any tag value on the resource matches any of the filter values.
func matchTagValue(tags []*ec2.Tag, values []string) bool {
for _, t := range tags {
if t.Value != nil && filterutil.MatchesAny(values, *t.Value) {
return true
}
}
return false
}
// handleEC2DescribeInstances processes incoming EC2 DescribeInstances requests
// This handler responds with instances running on this node owned by the caller's account
func (d *Daemon) handleEC2DescribeInstances(msg *nats.Msg) {
@@ -671,6 +755,14 @@ func (d *Daemon) handleEC2DescribeInstances(msg *nats.Msg) {
}
}
// Parse filters (returns error for unknown filter names)
parsedFilters, err := filterutil.ParseFilters(describeInstancesInput.Filters, describeInstancesValidFilters)
if err != nil {
slog.Warn("DescribeInstances: invalid filter", "err", err)
respondWithError(msg, awserrors.ErrorInvalidParameterValue)
return
}
// Group instances by reservation ID (AWS returns instances grouped by reservation)
reservationMap := make(map[string]*ec2.Reservation)
@@ -733,6 +825,11 @@ func (d *Daemon) handleEC2DescribeInstances(msg *nats.Msg) {
}
}
// Apply filters against the fully-built instance copy
if len(parsedFilters) > 0 && !instanceMatchesFilters(instance, &instanceCopy, parsedFilters) {
continue
}
// Add instance to its reservation
reservationMap[resID].Instances = append(reservationMap[resID].Instances, &instanceCopy)
}
@@ -1091,6 +1188,13 @@ func (d *Daemon) describeInstancesFromKV(msg *nats.Msg, listFn func() ([]*vm.VM,
}
}
parsedFilters, filterErr := filterutil.ParseFilters(describeInput.Filters, describeInstancesValidFilters)
if filterErr != nil {
slog.Warn(handlerName+": invalid filter", "err", filterErr)
respondWithError(msg, awserrors.ErrorInvalidParameterValue)
return
}
instances, err := listFn()
if err != nil {
slog.Error(handlerName+": failed to list instances", "err", err)
@@ -1138,6 +1242,10 @@ func (d *Daemon) describeInstancesFromKV(msg *nats.Msg, listFn func() ([]*vm.VM,
instanceCopy.State.SetName(fallbackName)
}
if len(parsedFilters) > 0 && !instanceMatchesFilters(instance, &instanceCopy, parsedFilters) {
continue
}
reservationMap[resID].Instances = append(reservationMap[resID].Instances, &instanceCopy)
}
+218
View File
@@ -0,0 +1,218 @@
package daemon
import (
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/filterutil"
"github.com/mulgadc/spinifex/spinifex/vm"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInstanceMatchesFilters_NoFilters(t *testing.T) {
inst := &vm.VM{ID: "i-123", InstanceType: "t3.micro"}
ic := &ec2.Instance{
InstanceId: aws.String("i-123"),
InstanceType: aws.String("t3.micro"),
State: &ec2.InstanceState{Name: aws.String("running")},
}
assert.True(t, instanceMatchesFilters(inst, ic, nil))
}
func TestInstanceMatchesFilters_SingleFilter_Match(t *testing.T) {
inst := &vm.VM{ID: "i-abc", InstanceType: "t3.micro"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
InstanceType: aws.String("t3.micro"),
State: &ec2.InstanceState{Name: aws.String("running")},
}
filters := map[string][]string{
"instance-state-name": {"running"},
}
assert.True(t, instanceMatchesFilters(inst, ic, filters))
}
func TestInstanceMatchesFilters_SingleFilter_NoMatch(t *testing.T) {
inst := &vm.VM{ID: "i-abc", InstanceType: "t3.micro"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
InstanceType: aws.String("t3.micro"),
State: &ec2.InstanceState{Name: aws.String("running")},
}
filters := map[string][]string{
"instance-state-name": {"stopped"},
}
assert.False(t, instanceMatchesFilters(inst, ic, filters))
}
func TestInstanceMatchesFilters_MultipleValues_ORLogic(t *testing.T) {
inst := &vm.VM{ID: "i-abc", InstanceType: "t3.micro"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
InstanceType: aws.String("t3.micro"),
State: &ec2.InstanceState{Name: aws.String("running")},
}
filters := map[string][]string{
"instance-state-name": {"running", "stopped"},
}
assert.True(t, instanceMatchesFilters(inst, ic, filters))
}
func TestInstanceMatchesFilters_MultipleFilters_ANDLogic(t *testing.T) {
inst := &vm.VM{ID: "i-abc", InstanceType: "t3.micro"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
InstanceType: aws.String("t3.micro"),
VpcId: aws.String("vpc-111"),
State: &ec2.InstanceState{Name: aws.String("running")},
}
// Both must match
filters := map[string][]string{
"instance-state-name": {"running"},
"vpc-id": {"vpc-111"},
}
assert.True(t, instanceMatchesFilters(inst, ic, filters))
// One doesn't match
filters["vpc-id"] = []string{"vpc-999"}
assert.False(t, instanceMatchesFilters(inst, ic, filters))
}
func TestInstanceMatchesFilters_InstanceId(t *testing.T) {
inst := &vm.VM{ID: "i-abc"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
State: &ec2.InstanceState{Name: aws.String("running")},
}
filters := map[string][]string{"instance-id": {"i-abc"}}
assert.True(t, instanceMatchesFilters(inst, ic, filters))
filters["instance-id"] = []string{"i-other"}
assert.False(t, instanceMatchesFilters(inst, ic, filters))
}
func TestInstanceMatchesFilters_InstanceType(t *testing.T) {
inst := &vm.VM{ID: "i-abc", InstanceType: "m5.large"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
InstanceType: aws.String("m5.large"),
State: &ec2.InstanceState{Name: aws.String("running")},
}
filters := map[string][]string{"instance-type": {"m5.large"}}
assert.True(t, instanceMatchesFilters(inst, ic, filters))
}
func TestInstanceMatchesFilters_SubnetId(t *testing.T) {
inst := &vm.VM{ID: "i-abc"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
SubnetId: aws.String("subnet-123"),
State: &ec2.InstanceState{Name: aws.String("running")},
}
filters := map[string][]string{"subnet-id": {"subnet-123"}}
assert.True(t, instanceMatchesFilters(inst, ic, filters))
filters["subnet-id"] = []string{"subnet-999"}
assert.False(t, instanceMatchesFilters(inst, ic, filters))
}
func TestInstanceMatchesFilters_Wildcard(t *testing.T) {
inst := &vm.VM{ID: "i-abc", InstanceType: "t3.micro"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
InstanceType: aws.String("t3.micro"),
State: &ec2.InstanceState{Name: aws.String("running")},
}
filters := map[string][]string{"instance-type": {"t3.*"}}
assert.True(t, instanceMatchesFilters(inst, ic, filters))
filters["instance-type"] = []string{"m5.*"}
assert.False(t, instanceMatchesFilters(inst, ic, filters))
}
func TestInstanceMatchesFilters_TagFilter(t *testing.T) {
inst := &vm.VM{ID: "i-abc"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
State: &ec2.InstanceState{Name: aws.String("running")},
Tags: []*ec2.Tag{
{Key: aws.String("Environment"), Value: aws.String("prod")},
{Key: aws.String("Team"), Value: aws.String("backend")},
},
}
filters := map[string][]string{"tag:Environment": {"prod"}}
assert.True(t, instanceMatchesFilters(inst, ic, filters))
filters["tag:Environment"] = []string{"staging"}
assert.False(t, instanceMatchesFilters(inst, ic, filters))
}
func TestInstanceMatchesFilters_TagKey(t *testing.T) {
inst := &vm.VM{ID: "i-abc"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
State: &ec2.InstanceState{Name: aws.String("running")},
Tags: []*ec2.Tag{
{Key: aws.String("Environment"), Value: aws.String("prod")},
},
}
filters := map[string][]string{"tag-key": {"Environment"}}
assert.True(t, instanceMatchesFilters(inst, ic, filters))
filters["tag-key"] = []string{"MissingKey"}
assert.False(t, instanceMatchesFilters(inst, ic, filters))
}
func TestInstanceMatchesFilters_TagValue(t *testing.T) {
inst := &vm.VM{ID: "i-abc"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
State: &ec2.InstanceState{Name: aws.String("running")},
Tags: []*ec2.Tag{
{Key: aws.String("Environment"), Value: aws.String("prod")},
},
}
filters := map[string][]string{"tag-value": {"prod"}}
assert.True(t, instanceMatchesFilters(inst, ic, filters))
filters["tag-value"] = []string{"staging"}
assert.False(t, instanceMatchesFilters(inst, ic, filters))
}
func TestInstanceMatchesFilters_EmptyResults(t *testing.T) {
inst := &vm.VM{ID: "i-abc", InstanceType: "t3.micro"}
ic := &ec2.Instance{
InstanceId: aws.String("i-abc"),
InstanceType: aws.String("t3.micro"),
State: &ec2.InstanceState{Name: aws.String("running")},
}
// Filter that matches nothing
filters := map[string][]string{"instance-type": {"c5.4xlarge"}}
assert.False(t, instanceMatchesFilters(inst, ic, filters))
}
func TestParseFilters_UnknownFilterName(t *testing.T) {
filters := []*ec2.Filter{
{Name: aws.String("bogus-filter"), Values: []*string{aws.String("val")}},
}
_, err := filterutil.ParseFilters(filters, describeInstancesValidFilters)
require.Error(t, err)
assert.Contains(t, err.Error(), "InvalidParameterValue")
}
func TestEC2TagsToMap(t *testing.T) {
tags := []*ec2.Tag{
{Key: aws.String("a"), Value: aws.String("1")},
{Key: aws.String("b"), Value: aws.String("2")},
}
m := filterutil.EC2TagsToMap(tags)
assert.Equal(t, "1", m["a"])
assert.Equal(t, "2", m["b"])
}
func TestEC2TagsToMap_Nil(t *testing.T) {
assert.Nil(t, filterutil.EC2TagsToMap(nil))
}
+130
View File
@@ -0,0 +1,130 @@
package filterutil
import (
"fmt"
"log/slog"
"strings"
"github.com/aws/aws-sdk-go/service/ec2"
)
// ParseFilters converts AWS SDK filter types into a map[string][]string for
// easier matching. validNames defines the set of accepted filter names.
// Filter names starting with "tag:" are always accepted. Returns an
// InvalidParameterValue-style error if an unsupported filter name is
// encountered.
func ParseFilters(filters []*ec2.Filter, validNames map[string]bool) (map[string][]string, error) {
if len(filters) == 0 {
return nil, nil
}
result := make(map[string][]string, len(filters))
for _, f := range filters {
if f.Name == nil {
slog.Warn("ParseFilters: skipping filter with nil Name")
continue
}
name := *f.Name
if !strings.HasPrefix(name, "tag:") && !validNames[name] {
return nil, fmt.Errorf("InvalidParameterValue: The filter '%s' is invalid", name)
}
for _, v := range f.Values {
if v != nil {
result[name] = append(result[name], *v)
}
}
}
return result, nil
}
// MatchesAny returns true if value matches any of the filter values.
// Supports the AWS wildcard convention where * matches any substring.
// Returns true if filterValues is empty.
func MatchesAny(filterValues []string, value string) bool {
if len(filterValues) == 0 {
return true
}
for _, pattern := range filterValues {
if matchWildcard(pattern, value) {
return true
}
}
return false
}
// MatchesTags checks whether a resource's tags satisfy all tag:Key filters
// present in the filter map. Each tag:Key filter uses OR logic across its
// values — the resource must have the tag and its value must match at least
// one filter value (with wildcard support).
func MatchesTags(filters map[string][]string, tags map[string]string) bool {
for name, values := range filters {
if !strings.HasPrefix(name, "tag:") {
continue
}
tagKey := name[4:] // strip "tag:" prefix
tagValue, exists := tags[tagKey]
if !exists {
return false
}
if !MatchesAny(values, tagValue) {
return false
}
}
return true
}
// EC2TagsToMap converts []*ec2.Tag to map[string]string for MatchesTags.
func EC2TagsToMap(tags []*ec2.Tag) map[string]string {
if len(tags) == 0 {
return nil
}
m := make(map[string]string, len(tags))
for _, t := range tags {
if t.Key != nil && t.Value != nil {
m[*t.Key] = *t.Value
}
}
return m
}
// matchWildcard matches value against a pattern where * matches zero or more
// characters. This implements the AWS filter wildcard convention.
func matchWildcard(pattern, value string) bool {
if pattern == "*" {
return true
}
if !strings.Contains(pattern, "*") {
return pattern == value
}
parts := strings.Split(pattern, "*")
last := len(parts) - 1
// Check that value starts with the first part and ends with the last.
if !strings.HasPrefix(value, parts[0]) {
return false
}
if !strings.HasSuffix(value, parts[last]) {
return false
}
// Trim prefix and suffix from the search region so middle parts
// cannot consume characters reserved for the anchored ends.
remaining := value[len(parts[0]):]
if len(remaining) < len(parts[last]) {
return false
}
remaining = remaining[:len(remaining)-len(parts[last])]
// Walk through middle parts in order.
for i := 1; i < last; i++ {
idx := strings.Index(remaining, parts[i])
if idx < 0 {
return false
}
remaining = remaining[idx+len(parts[i]):]
}
return true
}
+307
View File
@@ -0,0 +1,307 @@
package filterutil
import (
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
)
func TestParseFilters_NilInput(t *testing.T) {
result, err := ParseFilters(nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil {
t.Fatalf("expected nil, got %v", result)
}
}
func TestParseFilters_EmptyInput(t *testing.T) {
result, err := ParseFilters([]*ec2.Filter{}, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil {
t.Fatalf("expected nil, got %v", result)
}
}
func TestParseFilters_ValidFilters(t *testing.T) {
valid := map[string]bool{"vpc-id": true, "state": true}
filters := []*ec2.Filter{
{Name: aws.String("vpc-id"), Values: []*string{aws.String("vpc-1"), aws.String("vpc-2")}},
{Name: aws.String("state"), Values: []*string{aws.String("running")}},
}
result, err := ParseFilters(filters, valid)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) != 2 {
t.Fatalf("expected 2 filter keys, got %d", len(result))
}
if len(result["vpc-id"]) != 2 {
t.Fatalf("expected 2 vpc-id values, got %d", len(result["vpc-id"]))
}
if result["state"][0] != "running" {
t.Fatalf("expected state=running, got %s", result["state"][0])
}
}
func TestParseFilters_InvalidFilterName(t *testing.T) {
valid := map[string]bool{"vpc-id": true}
filters := []*ec2.Filter{
{Name: aws.String("bogus-filter"), Values: []*string{aws.String("x")}},
}
_, err := ParseFilters(filters, valid)
if err == nil {
t.Fatal("expected error for invalid filter name")
}
}
func TestParseFilters_TagFiltersAlwaysAccepted(t *testing.T) {
valid := map[string]bool{"vpc-id": true}
filters := []*ec2.Filter{
{Name: aws.String("tag:Environment"), Values: []*string{aws.String("prod")}},
}
result, err := ParseFilters(filters, valid)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result["tag:Environment"]) != 1 {
t.Fatalf("expected 1 tag filter value, got %d", len(result["tag:Environment"]))
}
}
func TestParseFilters_NilNameSkipped(t *testing.T) {
valid := map[string]bool{"vpc-id": true}
filters := []*ec2.Filter{
{Name: nil, Values: []*string{aws.String("x")}},
{Name: aws.String("vpc-id"), Values: []*string{aws.String("vpc-1")}},
}
result, err := ParseFilters(filters, valid)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) != 1 {
t.Fatalf("expected 1 filter key, got %d", len(result))
}
}
func TestParseFilters_NilValuesSkipped(t *testing.T) {
valid := map[string]bool{"state": true}
filters := []*ec2.Filter{
{Name: aws.String("state"), Values: []*string{aws.String("running"), nil, aws.String("stopped")}},
}
result, err := ParseFilters(filters, valid)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result["state"]) != 2 {
t.Fatalf("expected 2 state values, got %d", len(result["state"]))
}
}
func TestMatchesAny_EmptyFilterValues(t *testing.T) {
if !MatchesAny(nil, "anything") {
t.Fatal("expected true for empty filter values")
}
if !MatchesAny([]string{}, "anything") {
t.Fatal("expected true for empty filter values")
}
}
func TestMatchesAny_ExactMatch(t *testing.T) {
if !MatchesAny([]string{"running", "stopped"}, "running") {
t.Fatal("expected match for 'running'")
}
if MatchesAny([]string{"running", "stopped"}, "terminated") {
t.Fatal("expected no match for 'terminated'")
}
}
func TestMatchesAny_WildcardStar(t *testing.T) {
if !MatchesAny([]string{"*"}, "anything") {
t.Fatal("expected * to match anything")
}
}
func TestMatchesAny_WildcardPrefix(t *testing.T) {
if !MatchesAny([]string{"prod-*"}, "prod-web") {
t.Fatal("expected prod-* to match prod-web")
}
if MatchesAny([]string{"prod-*"}, "staging-web") {
t.Fatal("expected prod-* to not match staging-web")
}
}
func TestMatchesAny_WildcardSuffix(t *testing.T) {
if !MatchesAny([]string{"*-web"}, "prod-web") {
t.Fatal("expected *-web to match prod-web")
}
if MatchesAny([]string{"*-web"}, "prod-api") {
t.Fatal("expected *-web to not match prod-api")
}
}
func TestMatchesAny_WildcardMiddle(t *testing.T) {
if !MatchesAny([]string{"prod-*-web"}, "prod-us-east-web") {
t.Fatal("expected prod-*-web to match prod-us-east-web")
}
if MatchesAny([]string{"prod-*-web"}, "prod-us-east-api") {
t.Fatal("expected prod-*-web to not match prod-us-east-api")
}
}
func TestMatchesAny_MultipleWildcards(t *testing.T) {
if !MatchesAny([]string{"*prod*web*"}, "my-prod-us-web-server") {
t.Fatal("expected *prod*web* to match")
}
if MatchesAny([]string{"*prod*web*"}, "my-staging-us-api-server") {
t.Fatal("expected *prod*web* to not match")
}
}
func TestMatchesAny_EmptyStringMatches(t *testing.T) {
if !MatchesAny([]string{""}, "") {
t.Fatal("expected empty string to match empty string")
}
if MatchesAny([]string{""}, "notempty") {
t.Fatal("expected empty string to not match non-empty")
}
}
func TestMatchesTags_NoTagFilters(t *testing.T) {
filters := map[string][]string{
"vpc-id": {"vpc-1"},
}
tags := map[string]string{"Environment": "prod"}
if !MatchesTags(filters, tags) {
t.Fatal("expected true when no tag: filters present")
}
}
func TestMatchesTags_MatchingSingleTag(t *testing.T) {
filters := map[string][]string{
"tag:Environment": {"prod"},
}
tags := map[string]string{"Environment": "prod"}
if !MatchesTags(filters, tags) {
t.Fatal("expected tag match")
}
}
func TestMatchesTags_NonMatchingTag(t *testing.T) {
filters := map[string][]string{
"tag:Environment": {"prod"},
}
tags := map[string]string{"Environment": "staging"}
if MatchesTags(filters, tags) {
t.Fatal("expected no tag match")
}
}
func TestMatchesTags_MissingTag(t *testing.T) {
filters := map[string][]string{
"tag:Environment": {"prod"},
}
tags := map[string]string{"Team": "infra"}
if MatchesTags(filters, tags) {
t.Fatal("expected no match when tag key is missing")
}
}
func TestMatchesTags_NilTags(t *testing.T) {
filters := map[string][]string{
"tag:Environment": {"prod"},
}
if MatchesTags(filters, nil) {
t.Fatal("expected no match with nil tags")
}
}
func TestMatchesTags_MultipleTagFiltersAND(t *testing.T) {
filters := map[string][]string{
"tag:Environment": {"prod"},
"tag:Team": {"infra", "platform"},
}
// Matches both: Environment=prod AND Team in {infra, platform}
tags := map[string]string{"Environment": "prod", "Team": "infra"}
if !MatchesTags(filters, tags) {
t.Fatal("expected match for both tag filters")
}
// Fails second filter
tags2 := map[string]string{"Environment": "prod", "Team": "frontend"}
if MatchesTags(filters, tags2) {
t.Fatal("expected no match when second tag doesn't match")
}
}
func TestMatchesTags_WildcardValues(t *testing.T) {
filters := map[string][]string{
"tag:Name": {"prod-*"},
}
tags := map[string]string{"Name": "prod-web-01"}
if !MatchesTags(filters, tags) {
t.Fatal("expected wildcard tag match")
}
}
func TestMatchesTags_EmptyFilters(t *testing.T) {
if !MatchesTags(nil, nil) {
t.Fatal("expected true for nil filters")
}
if !MatchesTags(map[string][]string{}, nil) {
t.Fatal("expected true for empty filters")
}
}
func TestEC2TagsToMap(t *testing.T) {
tags := []*ec2.Tag{
{Key: aws.String("Env"), Value: aws.String("prod")},
{Key: aws.String("Team"), Value: aws.String("infra")},
}
m := EC2TagsToMap(tags)
if m["Env"] != "prod" || m["Team"] != "infra" {
t.Fatalf("unexpected map: %v", m)
}
}
func TestEC2TagsToMap_NilAndEmpty(t *testing.T) {
if EC2TagsToMap(nil) != nil {
t.Fatal("expected nil for nil tags")
}
if EC2TagsToMap([]*ec2.Tag{}) != nil {
t.Fatal("expected nil for empty tags")
}
}
func TestMatchWildcard_OverlappingSuffixNotDoubled(t *testing.T) {
// Pattern "a*b*ab" — the suffix "ab" must not be consumed by the
// middle "b" match, leaving nothing for the suffix anchor.
if !MatchesAny([]string{"a*b*ab"}, "abab") {
t.Fatal("expected a*b*ab to match abab")
}
if MatchesAny([]string{"a*b*ab"}, "ab") {
t.Fatal("expected a*b*ab to NOT match ab (suffix overlap)")
}
}
func TestMatchWildcard_TwoPartPattern(t *testing.T) {
// Simple two-part: prefix + suffix, no middle parts.
if !MatchesAny([]string{"a*a"}, "aa") {
t.Fatal("expected a*a to match aa")
}
if !MatchesAny([]string{"a*a"}, "aXa") {
t.Fatal("expected a*a to match aXa")
}
if MatchesAny([]string{"a*a"}, "a") {
t.Fatal("expected a*a to NOT match a")
}
}
@@ -2,12 +2,14 @@ package gateway_ec2_instance
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"sync"
"time"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
)
@@ -48,6 +50,7 @@ func DescribeInstances(input *ec2.DescribeInstancesInput, natsConn *nats.Conn, e
deadline := time.Now().Add(timeout)
var allReservations []*ec2.Reservation
var clientError string // first client error code from any node (e.g. InvalidParameterValue)
responsesReceived := 0
// If expectedNodes is not configured (0), fall back to timeout-based collection
@@ -86,8 +89,19 @@ func DescribeInstances(input *ec2.DescribeInstancesInput, natsConn *nats.Conn, e
// Check if response is an error
responseError, err := utils.ValidateErrorPayload(msg.Data)
if err != nil {
// Response is an error payload - log but continue collecting from other nodes
slog.Warn("DescribeInstances: Received error from node", "code", responseError.Code, "responses_received", responsesReceived)
code := ""
if responseError.Code != nil {
code = *responseError.Code
}
// Capture the first client error (e.g. InvalidParameterValue). Client errors
// are deterministic — all nodes return the same error for the same invalid
// request — so we propagate them to the caller after collection completes.
if clientError == "" && code != "" {
if info, known := awserrors.ErrorLookup[code]; known && info.HTTPCode >= 400 && info.HTTPCode < 500 {
clientError = code
}
}
slog.Warn("DescribeInstances: Received error from node", "code", code, "responses_received", responsesReceived)
continue
}
@@ -123,6 +137,12 @@ func DescribeInstances(input *ec2.DescribeInstancesInput, natsConn *nats.Conn, e
}
kvWg.Wait()
// If every node returned a client error and we collected no data, propagate
// the error to the caller so the HTTP response carries the correct status.
if clientError != "" && len(allReservations) == 0 {
return nil, errors.New(clientError)
}
// Build final aggregated response
output := &ec2.DescribeInstancesOutput{
Reservations: allReservations,
@@ -12,6 +12,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/filterutil"
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
@@ -149,6 +150,11 @@ func (s *EgressOnlyIGWServiceImpl) DeleteEgressOnlyInternetGateway(input *ec2.De
}, nil
}
// describeEIGWValidFilters defines the set of filter names accepted by DescribeEgressOnlyInternetGateways.
var describeEIGWValidFilters = map[string]bool{
"egress-only-internet-gateway-id": true,
}
// DescribeEgressOnlyInternetGateways describes Egress-only Internet Gateways
func (s *EgressOnlyIGWServiceImpl) DescribeEgressOnlyInternetGateways(input *ec2.DescribeEgressOnlyInternetGatewaysInput, accountID string) (*ec2.DescribeEgressOnlyInternetGatewaysOutput, error) {
var egressOnlyIGWs []*ec2.EgressOnlyInternetGateway
@@ -160,6 +166,12 @@ func (s *EgressOnlyIGWServiceImpl) DescribeEgressOnlyInternetGateways(input *ec2
}
}
parsedFilters, err := filterutil.ParseFilters(input.Filters, describeEIGWValidFilters)
if err != nil {
slog.Warn("DescribeEgressOnlyInternetGateways: invalid filter", "err", err)
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
prefix := accountID + "."
keys, err := s.eigwKV.Keys()
if err != nil && !errors.Is(err, nats.ErrNoKeysFound) {
@@ -190,6 +202,10 @@ func (s *EgressOnlyIGWServiceImpl) DescribeEgressOnlyInternetGateways(input *ec2
continue
}
if len(parsedFilters) > 0 && !eigwMatchesFilters(&record, parsedFilters) {
continue
}
egressOnlyIGWs = append(egressOnlyIGWs, s.recordToEC2(&record))
}
@@ -200,6 +216,29 @@ func (s *EgressOnlyIGWServiceImpl) DescribeEgressOnlyInternetGateways(input *ec2
}, nil
}
// eigwMatchesFilters checks whether an EgressOnlyIGWRecord satisfies all parsed filters.
func eigwMatchesFilters(record *EgressOnlyIGWRecord, filters map[string][]string) bool {
for name, values := range filters {
if strings.HasPrefix(name, "tag:") {
continue
}
var field string
switch name {
case "egress-only-internet-gateway-id":
field = record.EgressOnlyInternetGatewayId
default:
return false
}
if !filterutil.MatchesAny(values, field) {
return false
}
}
return filterutil.MatchesTags(filters, record.Tags)
}
func (s *EgressOnlyIGWServiceImpl) recordToEC2(record *EgressOnlyIGWRecord) *ec2.EgressOnlyInternetGateway {
eigw := &ec2.EgressOnlyInternetGateway{
EgressOnlyInternetGatewayId: aws.String(record.EgressOnlyInternetGatewayId),
@@ -199,6 +199,97 @@ func TestDescribeEgressOnlyInternetGateways_Empty(t *testing.T) {
assert.Empty(t, desc.EgressOnlyInternetGateways)
}
func TestDescribeEgressOnlyInternetGateways_FilterByEIGWId(t *testing.T) {
svc := setupTestEIGWService(t)
eigwID := createTestEIGW(t, svc)
createTestEIGW(t, svc)
desc, err := svc.DescribeEgressOnlyInternetGateways(&ec2.DescribeEgressOnlyInternetGatewaysInput{
Filters: []*ec2.Filter{
{Name: aws.String("egress-only-internet-gateway-id"), Values: []*string{aws.String(eigwID)}},
},
}, testAccountID)
require.NoError(t, err)
require.Len(t, desc.EgressOnlyInternetGateways, 1)
assert.Equal(t, eigwID, *desc.EgressOnlyInternetGateways[0].EgressOnlyInternetGatewayId)
}
func TestDescribeEgressOnlyInternetGateways_FilterMultipleValues_OR(t *testing.T) {
svc := setupTestEIGWService(t)
eigwID1 := createTestEIGW(t, svc)
eigwID2 := createTestEIGW(t, svc)
createTestEIGW(t, svc)
desc, err := svc.DescribeEgressOnlyInternetGateways(&ec2.DescribeEgressOnlyInternetGatewaysInput{
Filters: []*ec2.Filter{
{Name: aws.String("egress-only-internet-gateway-id"), Values: []*string{aws.String(eigwID1), aws.String(eigwID2)}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, desc.EgressOnlyInternetGateways, 2)
}
func TestDescribeEgressOnlyInternetGateways_FilterUnknownName_Error(t *testing.T) {
svc := setupTestEIGWService(t)
_, err := svc.DescribeEgressOnlyInternetGateways(&ec2.DescribeEgressOnlyInternetGatewaysInput{
Filters: []*ec2.Filter{
{Name: aws.String("bogus-filter"), Values: []*string{aws.String("x")}},
},
}, testAccountID)
assert.Error(t, err)
}
func TestDescribeEgressOnlyInternetGateways_FilterWildcard(t *testing.T) {
svc := setupTestEIGWService(t)
createTestEIGW(t, svc)
desc, err := svc.DescribeEgressOnlyInternetGateways(&ec2.DescribeEgressOnlyInternetGatewaysInput{
Filters: []*ec2.Filter{
{Name: aws.String("egress-only-internet-gateway-id"), Values: []*string{aws.String("eigw-*")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, desc.EgressOnlyInternetGateways, 1)
}
func TestDescribeEgressOnlyInternetGateways_FilterNoResults(t *testing.T) {
svc := setupTestEIGWService(t)
createTestEIGW(t, svc)
desc, err := svc.DescribeEgressOnlyInternetGateways(&ec2.DescribeEgressOnlyInternetGatewaysInput{
Filters: []*ec2.Filter{
{Name: aws.String("egress-only-internet-gateway-id"), Values: []*string{aws.String("eigw-nonexistent")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, desc.EgressOnlyInternetGateways)
}
func TestDescribeEgressOnlyInternetGateways_FilterByTag(t *testing.T) {
svc := setupTestEIGWService(t)
out, err := svc.CreateEgressOnlyInternetGateway(&ec2.CreateEgressOnlyInternetGatewayInput{
VpcId: aws.String("vpc-tagged"),
TagSpecifications: []*ec2.TagSpecification{
{
ResourceType: aws.String("egress-only-internet-gateway"),
Tags: []*ec2.Tag{{Key: aws.String("Env"), Value: aws.String("prod")}},
},
},
}, testAccountID)
require.NoError(t, err)
createTestEIGW(t, svc) // untagged
desc, err := svc.DescribeEgressOnlyInternetGateways(&ec2.DescribeEgressOnlyInternetGatewaysInput{
Filters: []*ec2.Filter{
{Name: aws.String("tag:Env"), Values: []*string{aws.String("prod")}},
},
}, testAccountID)
require.NoError(t, err)
require.Len(t, desc.EgressOnlyInternetGateways, 1)
assert.Equal(t, *out.EgressOnlyInternetGateway.EgressOnlyInternetGatewayId, *desc.EgressOnlyInternetGateways[0].EgressOnlyInternetGatewayId)
}
// TestCreateEgressOnlyInternetGateway_CrossAccountVPCRejected tests that creating an EIGW in another account's VPC is rejected.
func TestCreateEgressOnlyInternetGateway_CrossAccountVPCRejected(t *testing.T) {
// Set up with manual NATS to get VPC KV access
+51
View File
@@ -11,6 +11,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/filterutil"
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
@@ -293,6 +294,15 @@ func (s *EIPServiceImpl) DisassociateAddress(input *ec2.DisassociateAddressInput
return &ec2.DisassociateAddressOutput{}, nil
}
// describeAddressesValidFilters defines the set of filter names accepted by DescribeAddresses.
var describeAddressesValidFilters = map[string]bool{
"allocation-id": true,
"public-ip": true,
"instance-id": true,
"association-id": true,
"domain": true,
}
// DescribeAddresses lists Elastic IPs with optional filtering by allocation ID.
func (s *EIPServiceImpl) DescribeAddresses(input *ec2.DescribeAddressesInput, accountID string) (*ec2.DescribeAddressesOutput, error) {
allocIDs := make(map[string]bool)
@@ -309,6 +319,12 @@ func (s *EIPServiceImpl) DescribeAddresses(input *ec2.DescribeAddressesInput, ac
}
}
parsedFilters, err := filterutil.ParseFilters(input.Filters, describeAddressesValidFilters)
if err != nil {
slog.Warn("DescribeAddresses: invalid filter", "err", err)
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
prefix := accountID + "."
keys, err := s.eipKV.Keys()
if err != nil && !errors.Is(err, nats.ErrNoKeysFound) {
@@ -343,6 +359,10 @@ func (s *EIPServiceImpl) DescribeAddresses(input *ec2.DescribeAddressesInput, ac
continue
}
if len(parsedFilters) > 0 && !addressMatchesFilters(&record, parsedFilters) {
continue
}
addresses = append(addresses, s.eipRecordToEC2(&record))
}
@@ -368,6 +388,37 @@ func (s *EIPServiceImpl) DescribeAddresses(input *ec2.DescribeAddressesInput, ac
}, nil
}
// addressMatchesFilters checks whether an EIPRecord satisfies all parsed filters.
func addressMatchesFilters(record *EIPRecord, filters map[string][]string) bool {
for name, values := range filters {
if strings.HasPrefix(name, "tag:") {
continue
}
var field string
switch name {
case "allocation-id":
field = record.AllocationId
case "public-ip":
field = record.PublicIp
case "instance-id":
field = record.InstanceId
case "association-id":
field = record.AssociationId
case "domain":
field = "vpc" // Spinifex only supports VPC domain
default:
return false
}
if !filterutil.MatchesAny(values, field) {
return false
}
}
return filterutil.MatchesTags(filters, record.Tags)
}
// DescribeAddressesAttribute returns per-EIP attributes (e.g. domain-name).
// Spinifex doesn't support reverse-DNS PTR records, so PtrRecord is left nil.
// Unlike DescribeAddresses, this returns an empty list (not an error) when
@@ -367,3 +367,212 @@ func TestEIP_DescribeAddresses(t *testing.T) {
_ = out2
_ = out3
}
func TestEIP_DescribeAddresses_FilterByAllocationId(t *testing.T) {
svc, _ := setupTestEIP(t)
out1, err := svc.AllocateAddress(&ec2.AllocateAddressInput{}, testAccountID)
require.NoError(t, err)
_, err = svc.AllocateAddress(&ec2.AllocateAddressInput{}, testAccountID)
require.NoError(t, err)
desc, err := svc.DescribeAddresses(&ec2.DescribeAddressesInput{
Filters: []*ec2.Filter{
{Name: aws.String("allocation-id"), Values: []*string{out1.AllocationId}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, desc.Addresses, 1)
assert.Equal(t, *out1.AllocationId, *desc.Addresses[0].AllocationId)
}
func TestEIP_DescribeAddresses_FilterByPublicIp(t *testing.T) {
svc, _ := setupTestEIP(t)
out1, err := svc.AllocateAddress(&ec2.AllocateAddressInput{}, testAccountID)
require.NoError(t, err)
_, err = svc.AllocateAddress(&ec2.AllocateAddressInput{}, testAccountID)
require.NoError(t, err)
desc, err := svc.DescribeAddresses(&ec2.DescribeAddressesInput{
Filters: []*ec2.Filter{
{Name: aws.String("public-ip"), Values: []*string{out1.PublicIp}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, desc.Addresses, 1)
assert.Equal(t, *out1.PublicIp, *desc.Addresses[0].PublicIp)
}
func TestEIP_DescribeAddresses_FilterByDomain(t *testing.T) {
svc, _ := setupTestEIP(t)
_, err := svc.AllocateAddress(&ec2.AllocateAddressInput{}, testAccountID)
require.NoError(t, err)
desc, err := svc.DescribeAddresses(&ec2.DescribeAddressesInput{
Filters: []*ec2.Filter{
{Name: aws.String("domain"), Values: []*string{aws.String("vpc")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, desc.Addresses, 1)
// Non-matching domain
desc, err = svc.DescribeAddresses(&ec2.DescribeAddressesInput{
Filters: []*ec2.Filter{
{Name: aws.String("domain"), Values: []*string{aws.String("standard")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, desc.Addresses)
}
func TestEIP_DescribeAddresses_FilterByInstanceId(t *testing.T) {
svc, _ := setupTestEIP(t)
out, err := svc.AllocateAddress(&ec2.AllocateAddressInput{}, testAccountID)
require.NoError(t, err)
// Manually set instance-id on the record
entry, err := svc.eipKV.Get(testAccountID + "." + *out.AllocationId)
require.NoError(t, err)
var record EIPRecord
require.NoError(t, json.Unmarshal(entry.Value(), &record))
record.InstanceId = "i-test123"
record.State = "associated"
data, err := json.Marshal(record)
require.NoError(t, err)
_, err = svc.eipKV.Update(testAccountID+"."+*out.AllocationId, data, entry.Revision())
require.NoError(t, err)
// Allocate another without association
_, err = svc.AllocateAddress(&ec2.AllocateAddressInput{}, testAccountID)
require.NoError(t, err)
desc, err := svc.DescribeAddresses(&ec2.DescribeAddressesInput{
Filters: []*ec2.Filter{
{Name: aws.String("instance-id"), Values: []*string{aws.String("i-test123")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, desc.Addresses, 1)
assert.Equal(t, "i-test123", *desc.Addresses[0].InstanceId)
}
func TestEIP_DescribeAddresses_FilterMultipleValues_OR(t *testing.T) {
svc, _ := setupTestEIP(t)
out1, err := svc.AllocateAddress(&ec2.AllocateAddressInput{}, testAccountID)
require.NoError(t, err)
out2, err := svc.AllocateAddress(&ec2.AllocateAddressInput{}, testAccountID)
require.NoError(t, err)
_, err = svc.AllocateAddress(&ec2.AllocateAddressInput{}, testAccountID)
require.NoError(t, err)
desc, err := svc.DescribeAddresses(&ec2.DescribeAddressesInput{
Filters: []*ec2.Filter{
{Name: aws.String("allocation-id"), Values: []*string{out1.AllocationId, out2.AllocationId}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, desc.Addresses, 2)
}
func TestEIP_DescribeAddresses_FilterMultipleFilters_AND(t *testing.T) {
svc, _ := setupTestEIP(t)
out, err := svc.AllocateAddress(&ec2.AllocateAddressInput{}, testAccountID)
require.NoError(t, err)
_, err = svc.AllocateAddress(&ec2.AllocateAddressInput{}, testAccountID)
require.NoError(t, err)
// Match both allocation-id and domain
desc, err := svc.DescribeAddresses(&ec2.DescribeAddressesInput{
Filters: []*ec2.Filter{
{Name: aws.String("allocation-id"), Values: []*string{out.AllocationId}},
{Name: aws.String("domain"), Values: []*string{aws.String("vpc")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, desc.Addresses, 1)
// Mismatch: correct allocation-id but wrong domain
desc, err = svc.DescribeAddresses(&ec2.DescribeAddressesInput{
Filters: []*ec2.Filter{
{Name: aws.String("allocation-id"), Values: []*string{out.AllocationId}},
{Name: aws.String("domain"), Values: []*string{aws.String("standard")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, desc.Addresses)
}
func TestEIP_DescribeAddresses_FilterUnknownName_Error(t *testing.T) {
svc, _ := setupTestEIP(t)
_, err := svc.DescribeAddresses(&ec2.DescribeAddressesInput{
Filters: []*ec2.Filter{
{Name: aws.String("bogus-filter"), Values: []*string{aws.String("x")}},
},
}, testAccountID)
assert.Error(t, err)
}
func TestEIP_DescribeAddresses_FilterWildcard(t *testing.T) {
svc, _ := setupTestEIP(t)
out, err := svc.AllocateAddress(&ec2.AllocateAddressInput{}, testAccountID)
require.NoError(t, err)
desc, err := svc.DescribeAddresses(&ec2.DescribeAddressesInput{
Filters: []*ec2.Filter{
{Name: aws.String("allocation-id"), Values: []*string{aws.String("eipalloc-*")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, desc.Addresses, 1)
assert.Equal(t, *out.AllocationId, *desc.Addresses[0].AllocationId)
}
func TestEIP_DescribeAddresses_FilterNoResults(t *testing.T) {
svc, _ := setupTestEIP(t)
_, err := svc.AllocateAddress(&ec2.AllocateAddressInput{}, testAccountID)
require.NoError(t, err)
desc, err := svc.DescribeAddresses(&ec2.DescribeAddressesInput{
Filters: []*ec2.Filter{
{Name: aws.String("public-ip"), Values: []*string{aws.String("1.1.1.1")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, desc.Addresses)
}
func TestEIP_DescribeAddresses_FilterByTag(t *testing.T) {
svc, _ := setupTestEIP(t)
out, err := svc.AllocateAddress(&ec2.AllocateAddressInput{
TagSpecifications: []*ec2.TagSpecification{
{
ResourceType: aws.String("elastic-ip"),
Tags: []*ec2.Tag{
{Key: aws.String("Env"), Value: aws.String("prod")},
},
},
},
}, testAccountID)
require.NoError(t, err)
_, err = svc.AllocateAddress(&ec2.AllocateAddressInput{}, testAccountID)
require.NoError(t, err)
desc, err := svc.DescribeAddresses(&ec2.DescribeAddressesInput{
Filters: []*ec2.Filter{
{Name: aws.String("tag:Env"), Values: []*string{aws.String("prod")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, desc.Addresses, 1)
assert.Equal(t, *out.AllocationId, *desc.Addresses[0].AllocationId)
}
+48
View File
@@ -12,6 +12,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/filterutil"
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/mulgadc/spinifex/spinifex/utils"
@@ -152,6 +153,13 @@ func (s *IGWServiceImpl) DeleteInternetGateway(input *ec2.DeleteInternetGatewayI
return &ec2.DeleteInternetGatewayOutput{}, nil
}
// describeIGWValidFilters defines the set of filter names accepted by DescribeInternetGateways.
var describeIGWValidFilters = map[string]bool{
"internet-gateway-id": true,
"attachment.vpc-id": true,
"attachment.state": true,
}
// DescribeInternetGateways lists Internet Gateways, optionally filtered by ID
func (s *IGWServiceImpl) DescribeInternetGateways(input *ec2.DescribeInternetGatewaysInput, accountID string) (*ec2.DescribeInternetGatewaysOutput, error) {
var igws []*ec2.InternetGateway
@@ -163,6 +171,12 @@ func (s *IGWServiceImpl) DescribeInternetGateways(input *ec2.DescribeInternetGat
}
}
parsedFilters, err := filterutil.ParseFilters(input.Filters, describeIGWValidFilters)
if err != nil {
slog.Warn("DescribeInternetGateways: invalid filter", "err", err)
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
prefix := accountID + "."
keys, err := s.igwKV.Keys()
if err != nil && !errors.Is(err, nats.ErrNoKeysFound) {
@@ -195,6 +209,10 @@ func (s *IGWServiceImpl) DescribeInternetGateways(input *ec2.DescribeInternetGat
continue
}
if len(parsedFilters) > 0 && !igwMatchesFilters(&record, parsedFilters) {
continue
}
igws = append(igws, s.recordToEC2(&record))
foundIDs[record.InternetGatewayId] = true
}
@@ -213,6 +231,36 @@ func (s *IGWServiceImpl) DescribeInternetGateways(input *ec2.DescribeInternetGat
}, nil
}
// igwMatchesFilters checks whether an IGWRecord satisfies all parsed filters.
func igwMatchesFilters(record *IGWRecord, filters map[string][]string) bool {
for name, values := range filters {
if strings.HasPrefix(name, "tag:") {
continue
}
var field string
switch name {
case "internet-gateway-id":
field = record.InternetGatewayId
case "attachment.vpc-id":
field = record.VpcId
case "attachment.state":
field = record.State
if record.VpcId == "" {
field = "" // no attachment means no state to match
}
default:
return false
}
if !filterutil.MatchesAny(values, field) {
return false
}
}
return filterutil.MatchesTags(filters, record.Tags)
}
// AttachInternetGateway attaches an IGW to a VPC and publishes a NATS event
// for vpcd to create the OVN external switch, gateway port, and SNAT rules.
func (s *IGWServiceImpl) AttachInternetGateway(input *ec2.AttachInternetGatewayInput, accountID string) (*ec2.AttachInternetGatewayOutput, error) {
@@ -495,6 +495,173 @@ func TestAttachInternetGateway_CrossAccountVPCRejected(t *testing.T) {
require.NoError(t, err)
}
func TestDescribeInternetGateways_FilterByIGWId(t *testing.T) {
svc, _ := setupTestIGWService(t)
igwID := createTestIGW(t, svc)
createTestIGW(t, svc)
desc, err := svc.DescribeInternetGateways(&ec2.DescribeInternetGatewaysInput{
Filters: []*ec2.Filter{
{Name: aws.String("internet-gateway-id"), Values: []*string{aws.String(igwID)}},
},
}, testAccountID)
require.NoError(t, err)
require.Len(t, desc.InternetGateways, 1)
assert.Equal(t, igwID, *desc.InternetGateways[0].InternetGatewayId)
}
func TestDescribeInternetGateways_FilterByAttachmentVpcId(t *testing.T) {
svc, _ := setupTestIGWService(t)
igwID := createTestIGW(t, svc)
createTestIGW(t, svc) // detached
// Attach first IGW
_, err := svc.AttachInternetGateway(&ec2.AttachInternetGatewayInput{
InternetGatewayId: aws.String(igwID),
VpcId: aws.String("vpc-test123"),
}, testAccountID)
require.NoError(t, err)
desc, err := svc.DescribeInternetGateways(&ec2.DescribeInternetGatewaysInput{
Filters: []*ec2.Filter{
{Name: aws.String("attachment.vpc-id"), Values: []*string{aws.String("vpc-test123")}},
},
}, testAccountID)
require.NoError(t, err)
require.Len(t, desc.InternetGateways, 1)
assert.Equal(t, igwID, *desc.InternetGateways[0].InternetGatewayId)
}
func TestDescribeInternetGateways_FilterByAttachmentState(t *testing.T) {
svc, _ := setupTestIGWService(t)
igwID := createTestIGW(t, svc)
createTestIGW(t, svc) // detached, won't match
// Attach
_, err := svc.AttachInternetGateway(&ec2.AttachInternetGatewayInput{
InternetGatewayId: aws.String(igwID),
VpcId: aws.String("vpc-test123"),
}, testAccountID)
require.NoError(t, err)
desc, err := svc.DescribeInternetGateways(&ec2.DescribeInternetGatewaysInput{
Filters: []*ec2.Filter{
{Name: aws.String("attachment.state"), Values: []*string{aws.String("available")}},
},
}, testAccountID)
require.NoError(t, err)
require.Len(t, desc.InternetGateways, 1)
assert.Equal(t, igwID, *desc.InternetGateways[0].InternetGatewayId)
}
func TestDescribeInternetGateways_FilterMultipleValues_OR(t *testing.T) {
svc, _ := setupTestIGWService(t)
igwID1 := createTestIGW(t, svc)
igwID2 := createTestIGW(t, svc)
createTestIGW(t, svc)
desc, err := svc.DescribeInternetGateways(&ec2.DescribeInternetGatewaysInput{
Filters: []*ec2.Filter{
{Name: aws.String("internet-gateway-id"), Values: []*string{aws.String(igwID1), aws.String(igwID2)}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, desc.InternetGateways, 2)
}
func TestDescribeInternetGateways_FilterMultipleFilters_AND(t *testing.T) {
svc, _ := setupTestIGWService(t)
igwID := createTestIGW(t, svc)
createTestIGW(t, svc) // detached
_, err := svc.AttachInternetGateway(&ec2.AttachInternetGatewayInput{
InternetGatewayId: aws.String(igwID),
VpcId: aws.String("vpc-test123"),
}, testAccountID)
require.NoError(t, err)
// Match both
desc, err := svc.DescribeInternetGateways(&ec2.DescribeInternetGatewaysInput{
Filters: []*ec2.Filter{
{Name: aws.String("internet-gateway-id"), Values: []*string{aws.String(igwID)}},
{Name: aws.String("attachment.vpc-id"), Values: []*string{aws.String("vpc-test123")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, desc.InternetGateways, 1)
// Mismatch
desc, err = svc.DescribeInternetGateways(&ec2.DescribeInternetGatewaysInput{
Filters: []*ec2.Filter{
{Name: aws.String("internet-gateway-id"), Values: []*string{aws.String(igwID)}},
{Name: aws.String("attachment.vpc-id"), Values: []*string{aws.String("vpc-wrong")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, desc.InternetGateways)
}
func TestDescribeInternetGateways_FilterUnknownName_Error(t *testing.T) {
svc, _ := setupTestIGWService(t)
_, err := svc.DescribeInternetGateways(&ec2.DescribeInternetGatewaysInput{
Filters: []*ec2.Filter{
{Name: aws.String("bogus-filter"), Values: []*string{aws.String("x")}},
},
}, testAccountID)
assert.Error(t, err)
}
func TestDescribeInternetGateways_FilterWildcard(t *testing.T) {
svc, _ := setupTestIGWService(t)
igwID := createTestIGW(t, svc)
desc, err := svc.DescribeInternetGateways(&ec2.DescribeInternetGatewaysInput{
Filters: []*ec2.Filter{
{Name: aws.String("internet-gateway-id"), Values: []*string{aws.String("igw-*")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, desc.InternetGateways, 1)
assert.Equal(t, igwID, *desc.InternetGateways[0].InternetGatewayId)
}
func TestDescribeInternetGateways_FilterNoResults(t *testing.T) {
svc, _ := setupTestIGWService(t)
createTestIGW(t, svc)
desc, err := svc.DescribeInternetGateways(&ec2.DescribeInternetGatewaysInput{
Filters: []*ec2.Filter{
{Name: aws.String("internet-gateway-id"), Values: []*string{aws.String("igw-nonexistent")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, desc.InternetGateways)
}
func TestDescribeInternetGateways_FilterByTag(t *testing.T) {
svc, _ := setupTestIGWService(t)
out, err := svc.CreateInternetGateway(&ec2.CreateInternetGatewayInput{
TagSpecifications: []*ec2.TagSpecification{
{
ResourceType: aws.String("internet-gateway"),
Tags: []*ec2.Tag{{Key: aws.String("Env"), Value: aws.String("prod")}},
},
},
}, testAccountID)
require.NoError(t, err)
createTestIGW(t, svc) // untagged
desc, err := svc.DescribeInternetGateways(&ec2.DescribeInternetGatewaysInput{
Filters: []*ec2.Filter{
{Name: aws.String("tag:Env"), Values: []*string{aws.String("prod")}},
},
}, testAccountID)
require.NoError(t, err)
require.Len(t, desc.InternetGateways, 1)
assert.Equal(t, *out.InternetGateway.InternetGatewayId, *desc.InternetGateways[0].InternetGatewayId)
}
func TestDeleteInternetGateway_PublishesNoEvent(t *testing.T) {
svc, nc := setupTestIGWService(t)
igwID := createTestIGW(t, svc)
@@ -15,6 +15,7 @@ import (
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/filterutil"
handlers_ec2_snapshot "github.com/mulgadc/spinifex/spinifex/handlers/ec2/snapshot"
"github.com/mulgadc/spinifex/spinifex/objectstore"
"github.com/mulgadc/spinifex/spinifex/types"
@@ -69,6 +70,18 @@ func NewImageServiceImplWithStore(store objectstore.ObjectStore, bucketName stri
}
}
// describeImagesValidFilters defines the set of filter names accepted by DescribeImages.
var describeImagesValidFilters = map[string]bool{
"name": true,
"state": true,
"architecture": true,
"image-id": true,
"is-public": true,
"owner-id": true,
"description": true,
"image-type": true,
}
// DescribeImages lists available AMI images by reading config.json files from S3
func (s *ImageServiceImpl) DescribeImages(input *ec2.DescribeImagesInput, accountID string) (*ec2.DescribeImagesOutput, error) {
if input == nil {
@@ -77,6 +90,12 @@ func (s *ImageServiceImpl) DescribeImages(input *ec2.DescribeImagesInput, accoun
slog.Info("Describing images", "filters", input.Filters, "imageIds", input.ImageIds)
parsedFilters, err := filterutil.ParseFilters(input.Filters, describeImagesValidFilters)
if err != nil {
slog.Warn("DescribeImages: invalid filter", "err", err)
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
// List all prefixes in the bucket (AMIs are stored as ami-<id>/ directories)
result, err := s.store.ListObjectsV2(&s3.ListObjectsV2Input{
Bucket: aws.String(s.bucketName),
@@ -88,6 +107,13 @@ func (s *ImageServiceImpl) DescribeImages(input *ec2.DescribeImagesInput, accoun
return nil, errors.New(awserrors.ErrorServerInternal)
}
// Extract image-id filter values for early prefix skipping to avoid
// unnecessary S3 GetObject calls on non-matching AMIs.
var imageIDFilterValues []string
if parsedFilters != nil {
imageIDFilterValues = parsedFilters["image-id"]
}
var images []*ec2.Image
// Iterate over CommonPrefixes to find ami-* directories
@@ -104,6 +130,15 @@ func (s *ImageServiceImpl) DescribeImages(input *ec2.DescribeImagesInput, accoun
continue
}
// Early skip: if image-id filter is set, check the prefix (ami-xxx/)
// against filter values before fetching config from S3.
if len(imageIDFilterValues) > 0 {
amiID := strings.TrimSuffix(prefixStr, "/")
if !filterutil.MatchesAny(imageIDFilterValues, amiID) {
continue
}
}
// Construct path to config.json for this AMI directory
configKey := prefixStr + "config.json"
@@ -256,6 +291,11 @@ func (s *ImageServiceImpl) DescribeImages(input *ec2.DescribeImagesInput, accoun
image.Tags = tags
}
// Apply filters against the fully-built image
if len(parsedFilters) > 0 && !imageMatchesFilters(image, parsedFilters, amiMeta.Tags) {
continue
}
images = append(images, image)
}
@@ -281,6 +321,59 @@ func (s *ImageServiceImpl) DescribeImages(input *ec2.DescribeImagesInput, accoun
}, nil
}
// imageMatchesFilters checks whether an ec2.Image satisfies all parsed filters.
func imageMatchesFilters(image *ec2.Image, filters map[string][]string, tags map[string]string) bool {
for name, values := range filters {
if strings.HasPrefix(name, "tag:") {
continue
}
var field string
switch name {
case "name":
if image.Name != nil {
field = *image.Name
}
case "state":
if image.State != nil {
field = *image.State
}
case "architecture":
if image.Architecture != nil {
field = *image.Architecture
}
case "image-id":
if image.ImageId != nil {
field = *image.ImageId
}
case "is-public":
if image.Public != nil {
field = fmt.Sprintf("%t", *image.Public)
}
case "owner-id":
if image.OwnerId != nil {
field = *image.OwnerId
}
case "description":
if image.Description != nil {
field = *image.Description
}
case "image-type":
if image.ImageType != nil {
field = *image.ImageType
}
default:
return false
}
if !filterutil.MatchesAny(values, field) {
return false
}
}
return filterutil.MatchesTags(filters, tags)
}
// CreateImage is the generic interface method — on the daemon side, the handler
// calls CreateImageFromInstance directly with the extra instance context.
func (s *ImageServiceImpl) CreateImage(input *ec2.CreateImageInput, accountID string) (*ec2.CreateImageOutput, error) {
@@ -677,3 +677,190 @@ func TestUnimplementedMethods(t *testing.T) {
_, err = svc.ResetImageAttribute(nil, "")
assert.Error(t, err)
}
// --- DescribeImages filter tests ---
// createTestAMIConfigFull creates an AMI with all metadata fields for filter testing.
func createTestAMIConfigFull(t *testing.T, store *objectstore.MemoryObjectStore, meta viperblock.AMIMetadata) {
t.Helper()
amiState := viperblock.VBState{
VolumeConfig: viperblock.VolumeConfig{
AMIMetadata: meta,
},
}
data, err := json.Marshal(amiState)
require.NoError(t, err)
_, err = store.PutObject(&awss3.PutObjectInput{
Bucket: aws.String(testBucket),
Key: aws.String(meta.ImageID + "/config.json"),
Body: strings.NewReader(string(data)),
ContentType: aws.String("application/json"),
})
require.NoError(t, err)
}
func TestDescribeImages_FilterByName(t *testing.T) {
svc, store := setupTestImageService(t)
createTestAMIConfigWithName(t, store, "ami-aaa", "debian-12")
createTestAMIConfigWithName(t, store, "ami-bbb", "ubuntu-22")
out, err := svc.DescribeImages(&ec2.DescribeImagesInput{
Filters: []*ec2.Filter{
{Name: aws.String("name"), Values: []*string{aws.String("debian-12")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Images, 1)
assert.Equal(t, "ami-aaa", *out.Images[0].ImageId)
}
func TestDescribeImages_FilterByArchitecture(t *testing.T) {
svc, store := setupTestImageService(t)
createTestAMIConfigFull(t, store, viperblock.AMIMetadata{
ImageID: "ami-x86", Name: "x86-img", Architecture: "x86_64",
RootDeviceType: "ebs", VolumeSizeGiB: 8,
})
createTestAMIConfigFull(t, store, viperblock.AMIMetadata{
ImageID: "ami-arm", Name: "arm-img", Architecture: "arm64",
RootDeviceType: "ebs", VolumeSizeGiB: 8,
})
out, err := svc.DescribeImages(&ec2.DescribeImagesInput{
Filters: []*ec2.Filter{
{Name: aws.String("architecture"), Values: []*string{aws.String("arm64")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Images, 1)
assert.Equal(t, "ami-arm", *out.Images[0].ImageId)
}
func TestDescribeImages_FilterMultipleValues_OR(t *testing.T) {
svc, store := setupTestImageService(t)
createTestAMIConfigWithName(t, store, "ami-aaa", "debian-12")
createTestAMIConfigWithName(t, store, "ami-bbb", "ubuntu-22")
createTestAMIConfigWithName(t, store, "ami-ccc", "centos-9")
out, err := svc.DescribeImages(&ec2.DescribeImagesInput{
Filters: []*ec2.Filter{
{Name: aws.String("name"), Values: []*string{aws.String("debian-12"), aws.String("centos-9")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Images, 2)
}
func TestDescribeImages_FilterMultipleNames_AND(t *testing.T) {
svc, store := setupTestImageService(t)
createTestAMIConfigFull(t, store, viperblock.AMIMetadata{
ImageID: "ami-match", Name: "debian-12", Architecture: "x86_64",
RootDeviceType: "ebs", VolumeSizeGiB: 8,
})
createTestAMIConfigFull(t, store, viperblock.AMIMetadata{
ImageID: "ami-nomatch", Name: "debian-12", Architecture: "arm64",
RootDeviceType: "ebs", VolumeSizeGiB: 8,
})
out, err := svc.DescribeImages(&ec2.DescribeImagesInput{
Filters: []*ec2.Filter{
{Name: aws.String("name"), Values: []*string{aws.String("debian-12")}},
{Name: aws.String("architecture"), Values: []*string{aws.String("x86_64")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Images, 1)
assert.Equal(t, "ami-match", *out.Images[0].ImageId)
}
func TestDescribeImages_FilterUnknownName_Error(t *testing.T) {
svc, _ := setupTestImageService(t)
_, err := svc.DescribeImages(&ec2.DescribeImagesInput{
Filters: []*ec2.Filter{
{Name: aws.String("bogus-filter"), Values: []*string{aws.String("val")}},
},
}, testAccountID)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
}
func TestDescribeImages_FilterWildcard(t *testing.T) {
svc, store := setupTestImageService(t)
createTestAMIConfigWithName(t, store, "ami-aaa", "prod-web-server")
createTestAMIConfigWithName(t, store, "ami-bbb", "prod-api-server")
createTestAMIConfigWithName(t, store, "ami-ccc", "dev-web-server")
out, err := svc.DescribeImages(&ec2.DescribeImagesInput{
Filters: []*ec2.Filter{
{Name: aws.String("name"), Values: []*string{aws.String("prod-*")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Images, 2)
}
func TestDescribeImages_FilterNoResults(t *testing.T) {
svc, store := setupTestImageService(t)
createTestAMIConfigWithName(t, store, "ami-aaa", "debian-12")
out, err := svc.DescribeImages(&ec2.DescribeImagesInput{
Filters: []*ec2.Filter{
{Name: aws.String("name"), Values: []*string{aws.String("nonexistent")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, out.Images)
}
func TestDescribeImages_FilterNoFilters(t *testing.T) {
svc, store := setupTestImageService(t)
createTestAMIConfigWithName(t, store, "ami-aaa", "debian-12")
createTestAMIConfigWithName(t, store, "ami-bbb", "ubuntu-22")
out, err := svc.DescribeImages(&ec2.DescribeImagesInput{}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Images, 2)
}
func TestDescribeImages_FilterByTag(t *testing.T) {
svc, store := setupTestImageService(t)
createTestAMIConfigFull(t, store, viperblock.AMIMetadata{
ImageID: "ami-tagged", Name: "tagged-img", Architecture: "x86_64",
RootDeviceType: "ebs", VolumeSizeGiB: 8,
Tags: map[string]string{"Environment": "prod"},
})
createTestAMIConfigFull(t, store, viperblock.AMIMetadata{
ImageID: "ami-untagged", Name: "untagged-img", Architecture: "x86_64",
RootDeviceType: "ebs", VolumeSizeGiB: 8,
})
out, err := svc.DescribeImages(&ec2.DescribeImagesInput{
Filters: []*ec2.Filter{
{Name: aws.String("tag:Environment"), Values: []*string{aws.String("prod")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Images, 1)
assert.Equal(t, "ami-tagged", *out.Images[0].ImageId)
}
func TestDescribeImages_FilterByState(t *testing.T) {
svc, store := setupTestImageService(t)
createTestAMIConfigWithName(t, store, "ami-aaa", "test")
// All images have state "available"
out, err := svc.DescribeImages(&ec2.DescribeImagesInput{
Filters: []*ec2.Filter{
{Name: aws.String("state"), Values: []*string{aws.String("available")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Images, 1)
out, err = svc.DescribeImages(&ec2.DescribeImagesInput{
Filters: []*ec2.Filter{
{Name: aws.String("state"), Values: []*string{aws.String("deregistered")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, out.Images)
}
+73 -2
View File
@@ -21,6 +21,7 @@ import (
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/filterutil"
"github.com/mulgadc/spinifex/spinifex/objectstore"
"github.com/mulgadc/spinifex/spinifex/utils"
)
@@ -452,6 +453,13 @@ func (s *KeyServiceImpl) DeleteKeyPair(input *ec2.DeleteKeyPairInput, accountID
}
// DescribeKeyPairs lists available key pairs by reading metadata files from S3
// describeKeyPairsValidFilters defines the set of filter names accepted by DescribeKeyPairs.
var describeKeyPairsValidFilters = map[string]bool{
"key-pair-id": true,
"key-name": true,
"fingerprint": true,
}
func (s *KeyServiceImpl) DescribeKeyPairs(input *ec2.DescribeKeyPairsInput, accountID string) (*ec2.DescribeKeyPairsOutput, error) {
if input == nil {
input = &ec2.DescribeKeyPairsInput{}
@@ -459,6 +467,12 @@ func (s *KeyServiceImpl) DescribeKeyPairs(input *ec2.DescribeKeyPairsInput, acco
slog.Info("Describing key pairs", "filters", input.Filters)
parsedFilters, err := filterutil.ParseFilters(input.Filters, describeKeyPairsValidFilters)
if err != nil {
slog.Warn("DescribeKeyPairs: invalid filter", "err", err)
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
prefix := fmt.Sprintf("keys/%s/", accountID)
// List all objects with the keys prefix
@@ -471,6 +485,14 @@ func (s *KeyServiceImpl) DescribeKeyPairs(input *ec2.DescribeKeyPairsInput, acco
return nil, errors.New(awserrors.ErrorServerInternal)
}
// Extract key-pair-id filter values for early S3 skip. The metadata
// filename is keys/<account>/<keyPairID>.json, so we can match the ID
// from the path before fetching the object.
var keyPairIDFilterValues []string
if parsedFilters != nil {
keyPairIDFilterValues = parsedFilters["key-pair-id"]
}
var keyPairs []*ec2.KeyPairInfo
// Check each .json metadata file
@@ -484,6 +506,16 @@ func (s *KeyServiceImpl) DescribeKeyPairs(input *ec2.DescribeKeyPairsInput, acco
continue
}
// Early skip: if key-pair-id filter is set, derive the ID from the
// S3 object path (keys/<account>/<keyPairID>.json) before fetching.
if len(keyPairIDFilterValues) > 0 {
objKey := *obj.Key
kpID := strings.TrimSuffix(strings.TrimPrefix(objKey, prefix), ".json")
if !filterutil.MatchesAny(keyPairIDFilterValues, kpID) {
continue
}
}
// Get the metadata file
getResult, err := s.store.GetObject(&s3.GetObjectInput{
Bucket: aws.String(s.bucketName),
@@ -546,13 +578,12 @@ func (s *KeyServiceImpl) DescribeKeyPairs(input *ec2.DescribeKeyPairsInput, acco
}
// Build KeyPairInfo from metadata
// Note: CreateTime would need to be stored in metadata or use S3 object LastModified
keyPairInfo := &ec2.KeyPairInfo{
KeyPairId: metadata.KeyPairId,
KeyFingerprint: metadata.KeyFingerprint,
KeyName: metadata.KeyName,
KeyType: aws.String(keyType),
Tags: []*ec2.Tag{}, // TODO: Implement tag support
Tags: []*ec2.Tag{},
}
// Use S3 object LastModified as CreateTime
@@ -560,6 +591,11 @@ func (s *KeyServiceImpl) DescribeKeyPairs(input *ec2.DescribeKeyPairsInput, acco
keyPairInfo.CreateTime = obj.LastModified
}
// Apply filters
if len(parsedFilters) > 0 && !keyPairMatchesFilters(keyPairInfo, parsedFilters) {
continue
}
keyPairs = append(keyPairs, keyPairInfo)
}
@@ -570,6 +606,41 @@ func (s *KeyServiceImpl) DescribeKeyPairs(input *ec2.DescribeKeyPairsInput, acco
}, nil
}
// keyPairMatchesFilters checks whether a KeyPairInfo satisfies all parsed filters.
func keyPairMatchesFilters(kp *ec2.KeyPairInfo, filters map[string][]string) bool {
for name, values := range filters {
if strings.HasPrefix(name, "tag:") {
continue
}
var field string
switch name {
case "key-pair-id":
if kp.KeyPairId != nil {
field = *kp.KeyPairId
}
case "key-name":
if kp.KeyName != nil {
field = *kp.KeyName
}
case "fingerprint":
if kp.KeyFingerprint != nil {
field = *kp.KeyFingerprint
}
default:
return false
}
if !filterutil.MatchesAny(values, field) {
return false
}
}
// Check tag:Key filters
tags := filterutil.EC2TagsToMap(kp.Tags)
return filterutil.MatchesTags(filters, tags)
}
// ImportKeyPair imports an existing public key
func (s *KeyServiceImpl) ImportKeyPair(input *ec2.ImportKeyPairInput, accountID string) (*ec2.ImportKeyPairOutput, error) {
if input == nil || input.KeyName == nil {
@@ -677,3 +677,113 @@ func TestCalculateFingerprint_InvalidBase64(t *testing.T) {
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to decode public key")
}
// --- DescribeKeyPairs AWS filter tests ---
func TestDescribeKeyPairs_AWSFilterByKeyName(t *testing.T) {
svc, _ := newTestKeyService()
importTestKey(t, svc, "key-alpha")
importTestKey(t, svc, "key-beta")
out, err := svc.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{
Filters: []*ec2.Filter{
{Name: aws.String("key-name"), Values: []*string{aws.String("key-alpha")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.KeyPairs, 1)
assert.Equal(t, "key-alpha", *out.KeyPairs[0].KeyName)
}
func TestDescribeKeyPairs_AWSFilterByKeyPairId(t *testing.T) {
svc, _ := newTestKeyService()
imported := importTestKey(t, svc, "key-target")
importTestKey(t, svc, "key-other")
out, err := svc.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{
Filters: []*ec2.Filter{
{Name: aws.String("key-pair-id"), Values: []*string{imported.KeyPairId}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.KeyPairs, 1)
assert.Equal(t, *imported.KeyPairId, *out.KeyPairs[0].KeyPairId)
}
func TestDescribeKeyPairs_AWSFilterByFingerprint(t *testing.T) {
svc, _ := newTestKeyService()
imported := importTestKey(t, svc, "key-fp")
out, err := svc.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{
Filters: []*ec2.Filter{
{Name: aws.String("fingerprint"), Values: []*string{imported.KeyFingerprint}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.KeyPairs, 1)
assert.Equal(t, *imported.KeyFingerprint, *out.KeyPairs[0].KeyFingerprint)
}
func TestDescribeKeyPairs_AWSFilterMultipleValues_OR(t *testing.T) {
svc, _ := newTestKeyService()
importTestKey(t, svc, "key-a")
importTestKey(t, svc, "key-b")
importTestKey(t, svc, "key-c")
out, err := svc.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{
Filters: []*ec2.Filter{
{Name: aws.String("key-name"), Values: []*string{aws.String("key-a"), aws.String("key-c")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.KeyPairs, 2)
}
func TestDescribeKeyPairs_AWSFilterUnknownName_Error(t *testing.T) {
svc, _ := newTestKeyService()
_, err := svc.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{
Filters: []*ec2.Filter{
{Name: aws.String("bogus-filter"), Values: []*string{aws.String("x")}},
},
}, testAccountID)
assert.Error(t, err)
}
func TestDescribeKeyPairs_AWSFilterWildcard(t *testing.T) {
svc, _ := newTestKeyService()
importTestKey(t, svc, "prod-web")
importTestKey(t, svc, "prod-api")
importTestKey(t, svc, "staging-web")
out, err := svc.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{
Filters: []*ec2.Filter{
{Name: aws.String("key-name"), Values: []*string{aws.String("prod-*")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.KeyPairs, 2)
}
func TestDescribeKeyPairs_AWSFilterNoResults(t *testing.T) {
svc, _ := newTestKeyService()
importTestKey(t, svc, "my-key")
out, err := svc.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{
Filters: []*ec2.Filter{
{Name: aws.String("key-name"), Values: []*string{aws.String("nonexistent")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, out.KeyPairs)
}
func TestDescribeKeyPairs_AWSFilterNoFilters(t *testing.T) {
svc, _ := newTestKeyService()
importTestKey(t, svc, "key-1")
importTestKey(t, svc, "key-2")
out, err := svc.DescribeKeyPairs(&ec2.DescribeKeyPairsInput{}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.KeyPairs, 2)
}
@@ -16,6 +16,7 @@ import (
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/filterutil"
"github.com/mulgadc/spinifex/spinifex/objectstore"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/mulgadc/spinifex/spinifex/utils"
@@ -302,6 +303,15 @@ func (s *SnapshotServiceImpl) CreateSnapshot(input *ec2.CreateSnapshotInput, acc
return snapshotConfigToEC2(snapshotCfg), nil
}
// describeSnapshotsValidFilters defines the set of filter names accepted by DescribeSnapshots.
var describeSnapshotsValidFilters = map[string]bool{
"snapshot-id": true,
"status": true,
"volume-id": true,
"volume-size": true,
"owner-id": true,
}
// DescribeSnapshots lists snapshots matching the specified criteria, scoped to the caller's account.
func (s *SnapshotServiceImpl) DescribeSnapshots(input *ec2.DescribeSnapshotsInput, accountID string) (*ec2.DescribeSnapshotsOutput, error) {
s.mutex.RLock()
@@ -316,6 +326,12 @@ func (s *SnapshotServiceImpl) DescribeSnapshots(input *ec2.DescribeSnapshotsInpu
}
}
parsedFilters, err := filterutil.ParseFilters(input.Filters, describeSnapshotsValidFilters)
if err != nil {
slog.Warn("DescribeSnapshots: invalid filter", "err", err)
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
listResult, err := s.store.ListObjectsV2(&s3.ListObjectsV2Input{
Bucket: aws.String(s.config.Predastore.Bucket),
Prefix: aws.String("snap-"),
@@ -326,6 +342,13 @@ func (s *SnapshotServiceImpl) DescribeSnapshots(input *ec2.DescribeSnapshotsInpu
return nil, errors.New(awserrors.ErrorServerInternal)
}
// Extract snapshot-id filter values for early prefix skipping to avoid
// unnecessary S3 GetObject calls on non-matching snapshots.
var snapshotIDFilterValues []string
if parsedFilters != nil {
snapshotIDFilterValues = parsedFilters["snapshot-id"]
}
var snapshots []*ec2.Snapshot
for _, prefix := range listResult.CommonPrefixes {
if prefix.Prefix == nil {
@@ -338,6 +361,14 @@ func (s *SnapshotServiceImpl) DescribeSnapshots(input *ec2.DescribeSnapshotsInpu
continue
}
// Early skip: if snapshot-id filter is set, check the prefix against
// filter values before fetching config from S3.
if len(snapshotIDFilterValues) > 0 {
if !filterutil.MatchesAny(snapshotIDFilterValues, snapshotID) {
continue
}
}
cfg, err := s.getSnapshotConfig(snapshotID)
if err != nil {
slog.Warn("DescribeSnapshots failed to get config", "snapshotId", snapshotID, "err", err)
@@ -349,6 +380,10 @@ func (s *SnapshotServiceImpl) DescribeSnapshots(input *ec2.DescribeSnapshotsInpu
continue
}
if len(parsedFilters) > 0 && !snapshotMatchesFilters(cfg, parsedFilters) {
continue
}
snapshots = append(snapshots, snapshotConfigToEC2(cfg))
}
@@ -359,6 +394,37 @@ func (s *SnapshotServiceImpl) DescribeSnapshots(input *ec2.DescribeSnapshotsInpu
}, nil
}
// snapshotMatchesFilters checks whether a SnapshotConfig satisfies all parsed filters.
func snapshotMatchesFilters(cfg *SnapshotConfig, filters map[string][]string) bool {
for name, values := range filters {
if strings.HasPrefix(name, "tag:") {
continue
}
var field string
switch name {
case "snapshot-id":
field = cfg.SnapshotID
case "status":
field = cfg.State
case "volume-id":
field = cfg.VolumeID
case "volume-size":
field = fmt.Sprintf("%d", cfg.VolumeSize)
case "owner-id":
field = cfg.OwnerID
default:
return false
}
if !filterutil.MatchesAny(values, field) {
return false
}
}
return filterutil.MatchesTags(filters, cfg.Tags)
}
// snapshotInUseByVolumes checks if any volume was created from the given snapshot.
func (s *SnapshotServiceImpl) snapshotInUseByVolumes(snapshotID string) (bool, error) {
listResult, err := s.store.ListObjectsV2(&s3.ListObjectsV2Input{
@@ -765,3 +765,220 @@ func TestCreateSnapshot_PrePhase4VolumeAllowed(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, otherAccountID, *result.OwnerId)
}
// --- DescribeSnapshots filter tests ---
// createTestSnapshot creates a snapshot and returns its ID.
func createTestSnapshot(t *testing.T, svc *SnapshotServiceImpl, store *objectstore.MemoryObjectStore, volumeID string, sizeGiB int, tags map[string]string) string {
t.Helper()
createTestVolume(t, store, volumeID, sizeGiB)
tagSpecs := []*ec2.TagSpecification{}
if len(tags) > 0 {
ec2Tags := make([]*ec2.Tag, 0, len(tags))
for k, v := range tags {
ec2Tags = append(ec2Tags, &ec2.Tag{Key: aws.String(k), Value: aws.String(v)})
}
tagSpecs = append(tagSpecs, &ec2.TagSpecification{
ResourceType: aws.String("snapshot"),
Tags: ec2Tags,
})
}
result, err := svc.CreateSnapshot(&ec2.CreateSnapshotInput{
VolumeId: aws.String(volumeID),
TagSpecifications: tagSpecs,
}, testAccountID)
require.NoError(t, err)
return *result.SnapshotId
}
func TestDescribeSnapshots_FilterByStatus(t *testing.T) {
svc, store := setupTestSnapshotService(t)
createTestSnapshot(t, svc, store, "vol-1", 10, nil)
out, err := svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{
Filters: []*ec2.Filter{
{Name: aws.String("status"), Values: []*string{aws.String("completed")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Snapshots, 1)
out, err = svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{
Filters: []*ec2.Filter{
{Name: aws.String("status"), Values: []*string{aws.String("pending")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, out.Snapshots)
}
func TestDescribeSnapshots_FilterByVolumeId(t *testing.T) {
svc, store := setupTestSnapshotService(t)
createTestSnapshot(t, svc, store, "vol-a", 10, nil)
createTestSnapshot(t, svc, store, "vol-b", 20, nil)
out, err := svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{
Filters: []*ec2.Filter{
{Name: aws.String("volume-id"), Values: []*string{aws.String("vol-a")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Snapshots, 1)
assert.Equal(t, "vol-a", *out.Snapshots[0].VolumeId)
}
func TestDescribeSnapshots_FilterByVolumeSize(t *testing.T) {
svc, store := setupTestSnapshotService(t)
createTestSnapshot(t, svc, store, "vol-small", 10, nil)
createTestSnapshot(t, svc, store, "vol-big", 100, nil)
out, err := svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{
Filters: []*ec2.Filter{
{Name: aws.String("volume-size"), Values: []*string{aws.String("100")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Snapshots, 1)
assert.Equal(t, int64(100), *out.Snapshots[0].VolumeSize)
}
func TestDescribeSnapshots_FilterBySnapshotId(t *testing.T) {
svc, store := setupTestSnapshotService(t)
snapID := createTestSnapshot(t, svc, store, "vol-1", 10, nil)
createTestSnapshot(t, svc, store, "vol-2", 20, nil)
out, err := svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{
Filters: []*ec2.Filter{
{Name: aws.String("snapshot-id"), Values: []*string{aws.String(snapID)}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Snapshots, 1)
assert.Equal(t, snapID, *out.Snapshots[0].SnapshotId)
}
func TestDescribeSnapshots_FilterByOwnerId(t *testing.T) {
svc, store := setupTestSnapshotService(t)
createTestSnapshot(t, svc, store, "vol-1", 10, nil)
out, err := svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{
Filters: []*ec2.Filter{
{Name: aws.String("owner-id"), Values: []*string{aws.String(testAccountID)}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Snapshots, 1)
out, err = svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{
Filters: []*ec2.Filter{
{Name: aws.String("owner-id"), Values: []*string{aws.String("999999999999")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, out.Snapshots)
}
func TestDescribeSnapshots_FilterMultipleValues_OR(t *testing.T) {
svc, store := setupTestSnapshotService(t)
createTestSnapshot(t, svc, store, "vol-a", 10, nil)
createTestSnapshot(t, svc, store, "vol-b", 20, nil)
createTestSnapshot(t, svc, store, "vol-c", 30, nil)
out, err := svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{
Filters: []*ec2.Filter{
{Name: aws.String("volume-id"), Values: []*string{aws.String("vol-a"), aws.String("vol-c")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Snapshots, 2)
}
func TestDescribeSnapshots_FilterMultipleFilters_AND(t *testing.T) {
svc, store := setupTestSnapshotService(t)
createTestSnapshot(t, svc, store, "vol-a", 10, nil)
createTestSnapshot(t, svc, store, "vol-b", 20, nil)
// Both match
out, err := svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{
Filters: []*ec2.Filter{
{Name: aws.String("volume-id"), Values: []*string{aws.String("vol-a")}},
{Name: aws.String("volume-size"), Values: []*string{aws.String("10")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Snapshots, 1)
// Mismatch
out, err = svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{
Filters: []*ec2.Filter{
{Name: aws.String("volume-id"), Values: []*string{aws.String("vol-a")}},
{Name: aws.String("volume-size"), Values: []*string{aws.String("20")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, out.Snapshots)
}
func TestDescribeSnapshots_FilterUnknownName_Error(t *testing.T) {
svc, _ := setupTestSnapshotService(t)
_, err := svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{
Filters: []*ec2.Filter{
{Name: aws.String("bogus-filter"), Values: []*string{aws.String("x")}},
},
}, testAccountID)
assert.Error(t, err)
}
func TestDescribeSnapshots_FilterWildcard(t *testing.T) {
svc, store := setupTestSnapshotService(t)
createTestSnapshot(t, svc, store, "vol-prod-1", 10, nil)
createTestSnapshot(t, svc, store, "vol-staging-1", 20, nil)
out, err := svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{
Filters: []*ec2.Filter{
{Name: aws.String("volume-id"), Values: []*string{aws.String("vol-prod-*")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Snapshots, 1)
}
func TestDescribeSnapshots_FilterNoResults(t *testing.T) {
svc, store := setupTestSnapshotService(t)
createTestSnapshot(t, svc, store, "vol-1", 10, nil)
out, err := svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{
Filters: []*ec2.Filter{
{Name: aws.String("volume-id"), Values: []*string{aws.String("vol-nonexistent")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, out.Snapshots)
}
func TestDescribeSnapshots_FilterByTag(t *testing.T) {
svc, store := setupTestSnapshotService(t)
createTestSnapshot(t, svc, store, "vol-tagged", 10, map[string]string{"Env": "prod"})
createTestSnapshot(t, svc, store, "vol-untagged", 20, nil)
out, err := svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{
Filters: []*ec2.Filter{
{Name: aws.String("tag:Env"), Values: []*string{aws.String("prod")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Snapshots, 1)
}
func TestDescribeSnapshots_FilterNoFilters(t *testing.T) {
svc, store := setupTestSnapshotService(t)
createTestSnapshot(t, svc, store, "vol-1", 10, nil)
createTestSnapshot(t, svc, store, "vol-2", 20, nil)
out, err := svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Snapshots, 2)
}
+208 -2
View File
@@ -11,11 +11,14 @@ import (
"sync"
"time"
"strconv"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/filterutil"
"github.com/mulgadc/spinifex/spinifex/objectstore"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/mulgadc/spinifex/spinifex/utils"
@@ -224,6 +227,18 @@ func (s *VolumeServiceImpl) CreateVolume(input *ec2.CreateVolumeInput, accountID
return vol, nil
}
// describeVolumesValidFilters defines the set of filter names accepted by DescribeVolumes.
var describeVolumesValidFilters = map[string]bool{
"volume-id": true,
"status": true,
"size": true,
"volume-type": true,
"attachment.instance-id": true,
"attachment.status": true,
"attachment.device": true,
"availability-zone": true,
}
// DescribeVolumes lists EBS volumes by reading config.json files from S3
func (s *VolumeServiceImpl) DescribeVolumes(input *ec2.DescribeVolumesInput, accountID string) (*ec2.DescribeVolumesOutput, error) {
if input == nil {
@@ -232,6 +247,12 @@ func (s *VolumeServiceImpl) DescribeVolumes(input *ec2.DescribeVolumesInput, acc
slog.Info("Describing volumes", "volumeIds", input.VolumeIds)
parsedFilters, err := filterutil.ParseFilters(input.Filters, describeVolumesValidFilters)
if err != nil {
slog.Warn("DescribeVolumes: invalid filter", "err", err)
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
var volumes []*ec2.Volume
// Fast path: if specific volume IDs are requested, fetch them directly
@@ -248,17 +269,44 @@ func (s *VolumeServiceImpl) DescribeVolumes(input *ec2.DescribeVolumesInput, acc
if len(volumes) != requested {
return nil, errors.New(awserrors.ErrorInvalidVolumeNotFound)
}
// Apply filters to the fetched volumes
if len(parsedFilters) > 0 {
filtered := make([]*ec2.Volume, 0, len(volumes))
for _, vol := range volumes {
if volumeMatchesFilters(vol, parsedFilters) {
filtered = append(filtered, vol)
}
}
volumes = filtered
}
slog.Info("DescribeVolumes completed", "count", len(volumes))
return &ec2.DescribeVolumesOutput{Volumes: volumes}, nil
}
// Slow path: list all volumes (no filter provided)
// Slow path: list all volumes (no specific IDs requested)
volumeIDs, err := s.listAllVolumeIDs()
if err != nil {
return nil, err
}
// Extract volume-id filter values for early skipping to avoid
// unnecessary S3 GetObject calls on non-matching volumes.
var volumeIDFilterValues []string
if parsedFilters != nil {
volumeIDFilterValues = parsedFilters["volume-id"]
}
for _, volumeID := range volumeIDs {
// Early skip: if volume-id filter is set, check the ID before
// fetching the full volume config from S3.
if len(volumeIDFilterValues) > 0 {
if !filterutil.MatchesAny(volumeIDFilterValues, volumeID) {
continue
}
}
result, err := s.getVolumeByID(volumeID)
if err != nil {
slog.Error("Failed to get volume", "volumeId", volumeID, "err", err)
@@ -270,6 +318,10 @@ func (s *VolumeServiceImpl) DescribeVolumes(input *ec2.DescribeVolumesInput, acc
continue
}
if len(parsedFilters) > 0 && !volumeMatchesFilters(result.volume, parsedFilters) {
continue
}
volumes = append(volumes, result.volume)
}
@@ -280,7 +332,100 @@ func (s *VolumeServiceImpl) DescribeVolumes(input *ec2.DescribeVolumesInput, acc
}, nil
}
// volumeMatchesFilters checks whether an ec2.Volume satisfies all parsed filters.
func volumeMatchesFilters(vol *ec2.Volume, filters map[string][]string) bool {
for name, values := range filters {
if strings.HasPrefix(name, "tag:") {
continue
}
var field string
switch name {
case "volume-id":
if vol.VolumeId != nil {
field = *vol.VolumeId
}
case "status":
if vol.State != nil {
field = *vol.State
}
case "size":
if vol.Size != nil {
field = strconv.FormatInt(*vol.Size, 10)
}
case "volume-type":
if vol.VolumeType != nil {
field = *vol.VolumeType
}
case "attachment.instance-id":
if !volumeAttachmentMatchesAny(vol.Attachments, func(a *ec2.VolumeAttachment) string {
if a.InstanceId != nil {
return *a.InstanceId
}
return ""
}, values) {
return false
}
continue
case "attachment.status":
if !volumeAttachmentMatchesAny(vol.Attachments, func(a *ec2.VolumeAttachment) string {
if a.State != nil {
return *a.State
}
return ""
}, values) {
return false
}
continue
case "attachment.device":
if !volumeAttachmentMatchesAny(vol.Attachments, func(a *ec2.VolumeAttachment) string {
if a.Device != nil {
return *a.Device
}
return ""
}, values) {
return false
}
continue
case "availability-zone":
if vol.AvailabilityZone != nil {
field = *vol.AvailabilityZone
}
default:
return false
}
if !filterutil.MatchesAny(values, field) {
return false
}
}
// Check tag:Key filters
tags := filterutil.EC2TagsToMap(vol.Tags)
return filterutil.MatchesTags(filters, tags)
}
// volumeAttachmentMatchesAny checks if any attachment's field matches any filter value.
func volumeAttachmentMatchesAny(attachments []*ec2.VolumeAttachment, fieldFn func(*ec2.VolumeAttachment) string, values []string) bool {
if len(attachments) == 0 {
return false
}
for _, a := range attachments {
if filterutil.MatchesAny(values, fieldFn(a)) {
return true
}
}
return false
}
// DescribeVolumeStatus returns the status of one or more EBS volumes
// describeVolumeStatusValidFilters defines the set of filter names accepted by DescribeVolumeStatus.
var describeVolumeStatusValidFilters = map[string]bool{
"volume-id": true,
"volume-status.status": true,
"availability-zone": true,
}
func (s *VolumeServiceImpl) DescribeVolumeStatus(input *ec2.DescribeVolumeStatusInput, accountID string) (*ec2.DescribeVolumeStatusOutput, error) {
if input == nil {
input = &ec2.DescribeVolumeStatusInput{}
@@ -288,6 +433,12 @@ func (s *VolumeServiceImpl) DescribeVolumeStatus(input *ec2.DescribeVolumeStatus
slog.Info("DescribeVolumeStatus", "volumeIds", input.VolumeIds)
parsedFilters, err := filterutil.ParseFilters(input.Filters, describeVolumeStatusValidFilters)
if err != nil {
slog.Warn("DescribeVolumeStatus: invalid filter", "err", err)
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
var statusItems []*ec2.VolumeStatusItem
// Fast path: if specific volume IDs are requested, fetch them directly
@@ -305,19 +456,37 @@ func (s *VolumeServiceImpl) DescribeVolumeStatus(input *ec2.DescribeVolumeStatus
if tenantID != accountID {
return nil, errors.New(awserrors.ErrorInvalidVolumeNotFound)
}
if len(parsedFilters) > 0 && !volumeStatusMatchesFilters(item, parsedFilters) {
continue
}
statusItems = append(statusItems, item)
}
slog.Info("DescribeVolumeStatus completed", "count", len(statusItems))
return &ec2.DescribeVolumeStatusOutput{VolumeStatuses: statusItems}, nil
}
// Slow path: list all volumes (no filter provided)
// Slow path: list all volumes (no specific IDs requested)
volumeIDs, err := s.listAllVolumeIDs()
if err != nil {
return nil, err
}
// Extract volume-id filter values for early skipping to avoid
// unnecessary S3 GetObject calls on non-matching volumes.
var volumeStatusIDFilterValues []string
if parsedFilters != nil {
volumeStatusIDFilterValues = parsedFilters["volume-id"]
}
for _, volumeID := range volumeIDs {
// Early skip: if volume-id filter is set, check the ID before
// fetching the full volume status from S3.
if len(volumeStatusIDFilterValues) > 0 {
if !filterutil.MatchesAny(volumeStatusIDFilterValues, volumeID) {
continue
}
}
item, tenantID, err := s.getVolumeStatusByID(volumeID)
if err != nil {
slog.Error("Failed to get volume status", "volumeId", volumeID, "err", err)
@@ -329,6 +498,10 @@ func (s *VolumeServiceImpl) DescribeVolumeStatus(input *ec2.DescribeVolumeStatus
continue
}
if len(parsedFilters) > 0 && !volumeStatusMatchesFilters(item, parsedFilters) {
continue
}
statusItems = append(statusItems, item)
}
@@ -339,6 +512,39 @@ func (s *VolumeServiceImpl) DescribeVolumeStatus(input *ec2.DescribeVolumeStatus
}, nil
}
// volumeStatusMatchesFilters checks whether a VolumeStatusItem satisfies all parsed filters.
func volumeStatusMatchesFilters(item *ec2.VolumeStatusItem, filters map[string][]string) bool {
for name, values := range filters {
if strings.HasPrefix(name, "tag:") {
// VolumeStatusItems don't have tags; any tag filter means no match.
return false
}
var field string
switch name {
case "volume-id":
if item.VolumeId != nil {
field = *item.VolumeId
}
case "volume-status.status":
if item.VolumeStatus != nil && item.VolumeStatus.Status != nil {
field = *item.VolumeStatus.Status
}
case "availability-zone":
if item.AvailabilityZone != nil {
field = *item.AvailabilityZone
}
default:
return false
}
if !filterutil.MatchesAny(values, field) {
return false
}
}
return true
}
// getVolumeStatusByID builds a VolumeStatusItem by reusing getVolumeByID
// to validate the volume exists, then returning static health status.
// Returns the status item, the tenant ID for account scoping, and any error.
@@ -1538,3 +1538,492 @@ func TestDeleteVolume_NATSTimeout(t *testing.T) {
}, "")
require.NoError(t, err)
}
// --- DescribeVolumes filter tests ---
func TestDescribeVolumes_FilterByStatus(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
createVolumeInStoreWithMeta(t, store, "vol-avail", viperblock.VolumeMetadata{
VolumeID: "vol-avail", SizeGiB: 10, State: "available", TenantID: "acct1",
})
createVolumeInStoreWithMeta(t, store, "vol-inuse", viperblock.VolumeMetadata{
VolumeID: "vol-inuse", SizeGiB: 20, State: "in-use", TenantID: "acct1",
})
out, err := svc.DescribeVolumes(&ec2.DescribeVolumesInput{
Filters: []*ec2.Filter{
{Name: aws.String("status"), Values: []*string{aws.String("available")}},
},
}, "acct1")
require.NoError(t, err)
assert.Len(t, out.Volumes, 1)
assert.Equal(t, "vol-avail", *out.Volumes[0].VolumeId)
}
func TestDescribeVolumes_FilterByVolumeType(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
createVolumeInStoreWithMeta(t, store, "vol-gp3", viperblock.VolumeMetadata{
VolumeID: "vol-gp3", SizeGiB: 10, State: "available", VolumeType: "gp3", TenantID: "acct1",
})
createVolumeInStoreWithMeta(t, store, "vol-io1", viperblock.VolumeMetadata{
VolumeID: "vol-io1", SizeGiB: 10, State: "available", VolumeType: "io1", TenantID: "acct1",
})
out, err := svc.DescribeVolumes(&ec2.DescribeVolumesInput{
Filters: []*ec2.Filter{
{Name: aws.String("volume-type"), Values: []*string{aws.String("gp3")}},
},
}, "acct1")
require.NoError(t, err)
assert.Len(t, out.Volumes, 1)
assert.Equal(t, "vol-gp3", *out.Volumes[0].VolumeId)
}
func TestDescribeVolumes_FilterBySize(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
createVolumeInStoreWithMeta(t, store, "vol-small", viperblock.VolumeMetadata{
VolumeID: "vol-small", SizeGiB: 10, State: "available", TenantID: "acct1",
})
createVolumeInStoreWithMeta(t, store, "vol-big", viperblock.VolumeMetadata{
VolumeID: "vol-big", SizeGiB: 100, State: "available", TenantID: "acct1",
})
out, err := svc.DescribeVolumes(&ec2.DescribeVolumesInput{
Filters: []*ec2.Filter{
{Name: aws.String("size"), Values: []*string{aws.String("100")}},
},
}, "acct1")
require.NoError(t, err)
assert.Len(t, out.Volumes, 1)
assert.Equal(t, "vol-big", *out.Volumes[0].VolumeId)
}
func TestDescribeVolumes_FilterByAttachmentInstanceId(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
createVolumeInStoreWithMeta(t, store, "vol-att", viperblock.VolumeMetadata{
VolumeID: "vol-att", SizeGiB: 10, State: "in-use",
AttachedInstance: "i-12345", DeviceName: "/dev/nbd0", TenantID: "acct1",
})
createVolumeInStoreWithMeta(t, store, "vol-free", viperblock.VolumeMetadata{
VolumeID: "vol-free", SizeGiB: 10, State: "available", TenantID: "acct1",
})
out, err := svc.DescribeVolumes(&ec2.DescribeVolumesInput{
Filters: []*ec2.Filter{
{Name: aws.String("attachment.instance-id"), Values: []*string{aws.String("i-12345")}},
},
}, "acct1")
require.NoError(t, err)
assert.Len(t, out.Volumes, 1)
assert.Equal(t, "vol-att", *out.Volumes[0].VolumeId)
}
func TestDescribeVolumes_FilterByAttachmentDevice(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
createVolumeInStoreWithMeta(t, store, "vol-nbd0", viperblock.VolumeMetadata{
VolumeID: "vol-nbd0", SizeGiB: 10, State: "in-use",
AttachedInstance: "i-12345", DeviceName: "/dev/nbd0", TenantID: "acct1",
})
createVolumeInStoreWithMeta(t, store, "vol-nbd1", viperblock.VolumeMetadata{
VolumeID: "vol-nbd1", SizeGiB: 10, State: "in-use",
AttachedInstance: "i-12345", DeviceName: "/dev/nbd1", TenantID: "acct1",
})
out, err := svc.DescribeVolumes(&ec2.DescribeVolumesInput{
Filters: []*ec2.Filter{
{Name: aws.String("attachment.device"), Values: []*string{aws.String("/dev/nbd1")}},
},
}, "acct1")
require.NoError(t, err)
assert.Len(t, out.Volumes, 1)
assert.Equal(t, "vol-nbd1", *out.Volumes[0].VolumeId)
}
func TestDescribeVolumes_FilterByAZ(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
createVolumeInStoreWithMeta(t, store, "vol-az1", viperblock.VolumeMetadata{
VolumeID: "vol-az1", SizeGiB: 10, State: "available",
AvailabilityZone: "ap-southeast-2a", TenantID: "acct1",
})
createVolumeInStoreWithMeta(t, store, "vol-az2", viperblock.VolumeMetadata{
VolumeID: "vol-az2", SizeGiB: 10, State: "available",
AvailabilityZone: "ap-southeast-2b", TenantID: "acct1",
})
out, err := svc.DescribeVolumes(&ec2.DescribeVolumesInput{
Filters: []*ec2.Filter{
{Name: aws.String("availability-zone"), Values: []*string{aws.String("ap-southeast-2a")}},
},
}, "acct1")
require.NoError(t, err)
assert.Len(t, out.Volumes, 1)
assert.Equal(t, "vol-az1", *out.Volumes[0].VolumeId)
}
func TestDescribeVolumes_FilterMultipleValues_OR(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
createVolumeInStoreWithMeta(t, store, "vol-avail", viperblock.VolumeMetadata{
VolumeID: "vol-avail", SizeGiB: 10, State: "available", TenantID: "acct1",
})
createVolumeInStoreWithMeta(t, store, "vol-inuse", viperblock.VolumeMetadata{
VolumeID: "vol-inuse", SizeGiB: 10, State: "in-use",
AttachedInstance: "i-1", DeviceName: "/dev/nbd0", TenantID: "acct1",
})
createVolumeInStoreWithMeta(t, store, "vol-del", viperblock.VolumeMetadata{
VolumeID: "vol-del", SizeGiB: 10, State: "deleted", TenantID: "acct1",
})
out, err := svc.DescribeVolumes(&ec2.DescribeVolumesInput{
Filters: []*ec2.Filter{
{Name: aws.String("status"), Values: []*string{aws.String("available"), aws.String("in-use")}},
},
}, "acct1")
require.NoError(t, err)
assert.Len(t, out.Volumes, 2)
}
func TestDescribeVolumes_FilterMultipleFilters_AND(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
createVolumeInStoreWithMeta(t, store, "vol-match", viperblock.VolumeMetadata{
VolumeID: "vol-match", SizeGiB: 10, State: "available",
VolumeType: "gp3", TenantID: "acct1",
})
createVolumeInStoreWithMeta(t, store, "vol-nomatch", viperblock.VolumeMetadata{
VolumeID: "vol-nomatch", SizeGiB: 10, State: "in-use",
VolumeType: "gp3", AttachedInstance: "i-1", DeviceName: "/dev/nbd0", TenantID: "acct1",
})
out, err := svc.DescribeVolumes(&ec2.DescribeVolumesInput{
Filters: []*ec2.Filter{
{Name: aws.String("status"), Values: []*string{aws.String("available")}},
{Name: aws.String("volume-type"), Values: []*string{aws.String("gp3")}},
},
}, "acct1")
require.NoError(t, err)
assert.Len(t, out.Volumes, 1)
assert.Equal(t, "vol-match", *out.Volumes[0].VolumeId)
}
func TestDescribeVolumes_FilterUnknownName_Error(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
_, err := svc.DescribeVolumes(&ec2.DescribeVolumesInput{
Filters: []*ec2.Filter{
{Name: aws.String("bogus-filter"), Values: []*string{aws.String("val")}},
},
}, "acct1")
require.Error(t, err)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
}
func TestDescribeVolumes_FilterNoResults(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
createVolumeInStoreWithMeta(t, store, "vol-one", viperblock.VolumeMetadata{
VolumeID: "vol-one", SizeGiB: 10, State: "available", TenantID: "acct1",
})
out, err := svc.DescribeVolumes(&ec2.DescribeVolumesInput{
Filters: []*ec2.Filter{
{Name: aws.String("status"), Values: []*string{aws.String("deleted")}},
},
}, "acct1")
require.NoError(t, err)
assert.Empty(t, out.Volumes)
}
func TestDescribeVolumes_FilterNoFilters(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
createVolumeInStoreWithMeta(t, store, "vol-a", viperblock.VolumeMetadata{
VolumeID: "vol-a", SizeGiB: 10, State: "available", TenantID: "acct1",
})
createVolumeInStoreWithMeta(t, store, "vol-b", viperblock.VolumeMetadata{
VolumeID: "vol-b", SizeGiB: 20, State: "available", TenantID: "acct1",
})
out, err := svc.DescribeVolumes(&ec2.DescribeVolumesInput{}, "acct1")
require.NoError(t, err)
assert.Len(t, out.Volumes, 2)
}
func TestDescribeVolumes_FilterWildcard(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
createVolumeInStoreWithMeta(t, store, "vol-az1", viperblock.VolumeMetadata{
VolumeID: "vol-az1", SizeGiB: 10, State: "available",
AvailabilityZone: "ap-southeast-2a", TenantID: "acct1",
})
createVolumeInStoreWithMeta(t, store, "vol-az2", viperblock.VolumeMetadata{
VolumeID: "vol-az2", SizeGiB: 10, State: "available",
AvailabilityZone: "us-east-1a", TenantID: "acct1",
})
out, err := svc.DescribeVolumes(&ec2.DescribeVolumesInput{
Filters: []*ec2.Filter{
{Name: aws.String("availability-zone"), Values: []*string{aws.String("ap-*")}},
},
}, "acct1")
require.NoError(t, err)
assert.Len(t, out.Volumes, 1)
assert.Equal(t, "vol-az1", *out.Volumes[0].VolumeId)
}
func TestDescribeVolumes_FilterByTag(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
createVolumeInStoreWithMeta(t, store, "vol-tagged", viperblock.VolumeMetadata{
VolumeID: "vol-tagged", SizeGiB: 10, State: "available", TenantID: "acct1",
Tags: map[string]string{"Environment": "prod"},
})
createVolumeInStoreWithMeta(t, store, "vol-untagged", viperblock.VolumeMetadata{
VolumeID: "vol-untagged", SizeGiB: 10, State: "available", TenantID: "acct1",
})
out, err := svc.DescribeVolumes(&ec2.DescribeVolumesInput{
Filters: []*ec2.Filter{
{Name: aws.String("tag:Environment"), Values: []*string{aws.String("prod")}},
},
}, "acct1")
require.NoError(t, err)
assert.Len(t, out.Volumes, 1)
assert.Equal(t, "vol-tagged", *out.Volumes[0].VolumeId)
}
func TestDescribeVolumes_FilterWithVolumeIds(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
createVolumeInStoreWithMeta(t, store, "vol-a", viperblock.VolumeMetadata{
VolumeID: "vol-a", SizeGiB: 10, State: "available", TenantID: "acct1",
})
createVolumeInStoreWithMeta(t, store, "vol-b", viperblock.VolumeMetadata{
VolumeID: "vol-b", SizeGiB: 10, State: "in-use",
AttachedInstance: "i-1", DeviceName: "/dev/nbd0", TenantID: "acct1",
})
// Request both by ID but filter to only available
out, err := svc.DescribeVolumes(&ec2.DescribeVolumesInput{
VolumeIds: []*string{aws.String("vol-a"), aws.String("vol-b")},
Filters: []*ec2.Filter{
{Name: aws.String("status"), Values: []*string{aws.String("available")}},
},
}, "acct1")
require.NoError(t, err)
assert.Len(t, out.Volumes, 1)
assert.Equal(t, "vol-a", *out.Volumes[0].VolumeId)
}
func TestDescribeVolumeStatus_FilterByVolumeId(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
createVolumeInStoreWithMeta(t, store, "vol-vs1", viperblock.VolumeMetadata{
VolumeID: "vol-vs1", SizeGiB: 10, State: "available", AvailabilityZone: "ap-southeast-2a",
})
createVolumeInStoreWithMeta(t, store, "vol-vs2", viperblock.VolumeMetadata{
VolumeID: "vol-vs2", SizeGiB: 20, State: "available", AvailabilityZone: "ap-southeast-2a",
})
out, err := svc.DescribeVolumeStatus(&ec2.DescribeVolumeStatusInput{
Filters: []*ec2.Filter{
{Name: aws.String("volume-id"), Values: []*string{aws.String("vol-vs1")}},
},
}, "")
require.NoError(t, err)
require.Len(t, out.VolumeStatuses, 1)
assert.Equal(t, "vol-vs1", *out.VolumeStatuses[0].VolumeId)
}
func TestDescribeVolumeStatus_FilterByStatus(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
createVolumeInStoreWithMeta(t, store, "vol-vss1", viperblock.VolumeMetadata{
VolumeID: "vol-vss1", SizeGiB: 10, State: "available", AvailabilityZone: "ap-southeast-2a",
})
// Status is always "ok" in Spinifex
out, err := svc.DescribeVolumeStatus(&ec2.DescribeVolumeStatusInput{
Filters: []*ec2.Filter{
{Name: aws.String("volume-status.status"), Values: []*string{aws.String("ok")}},
},
}, "")
require.NoError(t, err)
assert.Len(t, out.VolumeStatuses, 1)
out, err = svc.DescribeVolumeStatus(&ec2.DescribeVolumeStatusInput{
Filters: []*ec2.Filter{
{Name: aws.String("volume-status.status"), Values: []*string{aws.String("impaired")}},
},
}, "")
require.NoError(t, err)
assert.Empty(t, out.VolumeStatuses)
}
func TestDescribeVolumeStatus_FilterByAZ(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
createVolumeInStoreWithMeta(t, store, "vol-vsaz", viperblock.VolumeMetadata{
VolumeID: "vol-vsaz", SizeGiB: 10, State: "available", AvailabilityZone: "ap-southeast-2a",
})
out, err := svc.DescribeVolumeStatus(&ec2.DescribeVolumeStatusInput{
Filters: []*ec2.Filter{
{Name: aws.String("availability-zone"), Values: []*string{aws.String("ap-southeast-2a")}},
},
}, "")
require.NoError(t, err)
assert.Len(t, out.VolumeStatuses, 1)
out, err = svc.DescribeVolumeStatus(&ec2.DescribeVolumeStatusInput{
Filters: []*ec2.Filter{
{Name: aws.String("availability-zone"), Values: []*string{aws.String("us-east-1a")}},
},
}, "")
require.NoError(t, err)
assert.Empty(t, out.VolumeStatuses)
}
func TestDescribeVolumeStatus_FilterMultipleValues_OR(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
createVolumeInStoreWithMeta(t, store, "vol-vsor1", viperblock.VolumeMetadata{
VolumeID: "vol-vsor1", SizeGiB: 10, State: "available", AvailabilityZone: "ap-southeast-2a",
})
createVolumeInStoreWithMeta(t, store, "vol-vsor2", viperblock.VolumeMetadata{
VolumeID: "vol-vsor2", SizeGiB: 20, State: "available", AvailabilityZone: "ap-southeast-2a",
})
createVolumeInStoreWithMeta(t, store, "vol-vsor3", viperblock.VolumeMetadata{
VolumeID: "vol-vsor3", SizeGiB: 30, State: "available", AvailabilityZone: "ap-southeast-2a",
})
out, err := svc.DescribeVolumeStatus(&ec2.DescribeVolumeStatusInput{
Filters: []*ec2.Filter{
{Name: aws.String("volume-id"), Values: []*string{aws.String("vol-vsor1"), aws.String("vol-vsor3")}},
},
}, "")
require.NoError(t, err)
assert.Len(t, out.VolumeStatuses, 2)
}
func TestDescribeVolumeStatus_FilterMultipleFilters_AND(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
createVolumeInStoreWithMeta(t, store, "vol-vsand", viperblock.VolumeMetadata{
VolumeID: "vol-vsand", SizeGiB: 10, State: "available", AvailabilityZone: "ap-southeast-2a",
})
// Both match
out, err := svc.DescribeVolumeStatus(&ec2.DescribeVolumeStatusInput{
Filters: []*ec2.Filter{
{Name: aws.String("volume-id"), Values: []*string{aws.String("vol-vsand")}},
{Name: aws.String("volume-status.status"), Values: []*string{aws.String("ok")}},
},
}, "")
require.NoError(t, err)
assert.Len(t, out.VolumeStatuses, 1)
// Mismatch
out, err = svc.DescribeVolumeStatus(&ec2.DescribeVolumeStatusInput{
Filters: []*ec2.Filter{
{Name: aws.String("volume-id"), Values: []*string{aws.String("vol-vsand")}},
{Name: aws.String("availability-zone"), Values: []*string{aws.String("us-east-1a")}},
},
}, "")
require.NoError(t, err)
assert.Empty(t, out.VolumeStatuses)
}
func TestDescribeVolumeStatus_FilterUnknownName_Error(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
_, err := svc.DescribeVolumeStatus(&ec2.DescribeVolumeStatusInput{
Filters: []*ec2.Filter{
{Name: aws.String("bogus-filter"), Values: []*string{aws.String("x")}},
},
}, "")
assert.Error(t, err)
}
func TestDescribeVolumeStatus_FilterWildcard(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
createVolumeInStoreWithMeta(t, store, "vol-vswild", viperblock.VolumeMetadata{
VolumeID: "vol-vswild", SizeGiB: 10, State: "available", AvailabilityZone: "ap-southeast-2a",
})
out, err := svc.DescribeVolumeStatus(&ec2.DescribeVolumeStatusInput{
Filters: []*ec2.Filter{
{Name: aws.String("volume-id"), Values: []*string{aws.String("vol-vswild*")}},
},
}, "")
require.NoError(t, err)
assert.Len(t, out.VolumeStatuses, 1)
}
func TestDescribeVolumeStatus_FilterNoResults(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
createVolumeInStoreWithMeta(t, store, "vol-vsnr", viperblock.VolumeMetadata{
VolumeID: "vol-vsnr", SizeGiB: 10, State: "available", AvailabilityZone: "ap-southeast-2a",
})
out, err := svc.DescribeVolumeStatus(&ec2.DescribeVolumeStatusInput{
Filters: []*ec2.Filter{
{Name: aws.String("volume-id"), Values: []*string{aws.String("vol-nonexistent")}},
},
}, "")
require.NoError(t, err)
assert.Empty(t, out.VolumeStatuses)
}
func TestDescribeVolumeStatus_FilterWithVolumeIds(t *testing.T) {
store := objectstore.NewMemoryObjectStore()
svc := newTestVolumeServiceWithStore("ap-southeast-2a", store)
createVolumeInStoreWithMeta(t, store, "vol-vsf1", viperblock.VolumeMetadata{
VolumeID: "vol-vsf1", SizeGiB: 10, State: "available", AvailabilityZone: "ap-southeast-2a",
})
createVolumeInStoreWithMeta(t, store, "vol-vsf2", viperblock.VolumeMetadata{
VolumeID: "vol-vsf2", SizeGiB: 20, State: "available", AvailabilityZone: "us-east-1a",
})
// Fast path with VolumeIds + filter: should apply filter to requested IDs
out, err := svc.DescribeVolumeStatus(&ec2.DescribeVolumeStatusInput{
VolumeIds: []*string{aws.String("vol-vsf1"), aws.String("vol-vsf2")},
Filters: []*ec2.Filter{
{Name: aws.String("availability-zone"), Values: []*string{aws.String("ap-southeast-2a")}},
},
}, "")
require.NoError(t, err)
assert.Len(t, out.VolumeStatuses, 1)
assert.Equal(t, "vol-vsf1", *out.VolumeStatuses[0].VolumeId)
}
+62 -17
View File
@@ -11,6 +11,7 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/filterutil"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
)
@@ -182,6 +183,15 @@ func (s *VPCServiceImpl) DeleteSecurityGroup(input *ec2.DeleteSecurityGroupInput
return &ec2.DeleteSecurityGroupOutput{}, nil
}
// describeSecurityGroupsValidFilters defines the set of filter names accepted by DescribeSecurityGroups.
var describeSecurityGroupsValidFilters = map[string]bool{
"group-id": true,
"group-name": true,
"vpc-id": true,
"description": true,
"ip-permission.cidr": true,
}
// DescribeSecurityGroups lists security groups with optional filters.
func (s *VPCServiceImpl) DescribeSecurityGroups(input *ec2.DescribeSecurityGroupsInput, accountID string) (*ec2.DescribeSecurityGroupsOutput, error) {
var groups []*ec2.SecurityGroup
@@ -193,19 +203,10 @@ func (s *VPCServiceImpl) DescribeSecurityGroups(input *ec2.DescribeSecurityGroup
}
}
// Extract VPC ID filter
vpcIDFilter := ""
groupNameFilter := ""
for _, f := range input.Filters {
if f.Name == nil || len(f.Values) == 0 || f.Values[0] == nil {
continue
}
switch *f.Name {
case "vpc-id":
vpcIDFilter = *f.Values[0]
case "group-name":
groupNameFilter = *f.Values[0]
}
parsedFilters, err := filterutil.ParseFilters(input.Filters, describeSecurityGroupsValidFilters)
if err != nil {
slog.Warn("DescribeSecurityGroups: invalid filter", "err", err)
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
prefix := accountID + "."
@@ -237,10 +238,8 @@ func (s *VPCServiceImpl) DescribeSecurityGroups(input *ec2.DescribeSecurityGroup
if len(groupIDs) > 0 && !groupIDs[record.GroupId] {
continue
}
if vpcIDFilter != "" && record.VpcId != vpcIDFilter {
continue
}
if groupNameFilter != "" && record.GroupName != groupNameFilter {
if len(parsedFilters) > 0 && !sgMatchesFilters(&record, parsedFilters) {
continue
}
@@ -269,6 +268,52 @@ func (s *VPCServiceImpl) DescribeSecurityGroups(input *ec2.DescribeSecurityGroup
}, nil
}
// sgMatchesFilters checks whether a SecurityGroupRecord satisfies all parsed filters.
func sgMatchesFilters(record *SecurityGroupRecord, filters map[string][]string) bool {
for name, values := range filters {
if strings.HasPrefix(name, "tag:") {
continue
}
switch name {
case "group-id":
if !filterutil.MatchesAny(values, record.GroupId) {
return false
}
case "group-name":
if !filterutil.MatchesAny(values, record.GroupName) {
return false
}
case "vpc-id":
if !filterutil.MatchesAny(values, record.VpcId) {
return false
}
case "description":
if !filterutil.MatchesAny(values, record.Description) {
return false
}
case "ip-permission.cidr":
if !sgIngressCIDRMatchesAny(record.IngressRules, values) {
return false
}
default:
return false
}
}
return filterutil.MatchesTags(filters, record.Tags)
}
// sgIngressCIDRMatchesAny checks if any ingress rule's CIDR matches any of the filter values.
func sgIngressCIDRMatchesAny(rules []SGRule, values []string) bool {
for _, rule := range rules {
if rule.CidrIp != "" && filterutil.MatchesAny(values, rule.CidrIp) {
return true
}
}
return false
}
// AuthorizeSecurityGroupIngress adds ingress rules to a security group.
func (s *VPCServiceImpl) AuthorizeSecurityGroupIngress(input *ec2.AuthorizeSecurityGroupIngressInput, accountID string) (*ec2.AuthorizeSecurityGroupIngressOutput, error) {
if input.GroupId == nil || *input.GroupId == "" {
@@ -463,3 +463,202 @@ func TestRemoveSGRules_NoMatch(t *testing.T) {
result := removeSGRules(existing, toRemove)
assert.Len(t, result, 1)
}
// --- DescribeSecurityGroups filter tests ---
func TestDescribeSecurityGroups_FilterByGroupId(t *testing.T) {
svc := setupTestVPCService(t)
vpcID := createTestVPC(t, svc, "10.0.0.0/16")
sgID := createTestSG(t, svc, vpcID, "target-sg")
createTestSG(t, svc, vpcID, "other-sg")
out, err := svc.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{
Filters: []*ec2.Filter{
{Name: aws.String("group-id"), Values: []*string{aws.String(sgID)}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.SecurityGroups, 1)
assert.Equal(t, sgID, *out.SecurityGroups[0].GroupId)
}
func TestDescribeSecurityGroups_FilterByDescription(t *testing.T) {
svc := setupTestVPCService(t)
vpcID := createTestVPC(t, svc, "10.0.0.0/16")
_, err := svc.CreateSecurityGroup(&ec2.CreateSecurityGroupInput{
GroupName: aws.String("web-sg"),
Description: aws.String("Web server security group"),
VpcId: aws.String(vpcID),
}, testAccountID)
require.NoError(t, err)
_, err = svc.CreateSecurityGroup(&ec2.CreateSecurityGroupInput{
GroupName: aws.String("db-sg"),
Description: aws.String("Database security group"),
VpcId: aws.String(vpcID),
}, testAccountID)
require.NoError(t, err)
out, err := svc.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{
Filters: []*ec2.Filter{
{Name: aws.String("description"), Values: []*string{aws.String("Web server security group")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.SecurityGroups, 1)
assert.Equal(t, "web-sg", *out.SecurityGroups[0].GroupName)
}
func TestDescribeSecurityGroups_FilterByVpcId(t *testing.T) {
svc := setupTestVPCService(t)
vpc1 := createTestVPC(t, svc, "10.0.0.0/16")
vpc2 := createTestVPC(t, svc, "172.16.0.0/16")
createTestSG(t, svc, vpc1, "sg-in-vpc1")
createTestSG(t, svc, vpc2, "sg-in-vpc2")
out, err := svc.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{
Filters: []*ec2.Filter{
{Name: aws.String("vpc-id"), Values: []*string{aws.String(vpc1)}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.SecurityGroups, 1)
assert.Equal(t, vpc1, *out.SecurityGroups[0].VpcId)
}
func TestDescribeSecurityGroups_FilterByIpPermissionCidr(t *testing.T) {
svc := setupTestVPCService(t)
vpcID := createTestVPC(t, svc, "10.0.0.0/16")
sgID := createTestSG(t, svc, vpcID, "cidr-sg")
proto := "tcp"
_, err := svc.AuthorizeSecurityGroupIngress(&ec2.AuthorizeSecurityGroupIngressInput{
GroupId: aws.String(sgID),
IpPermissions: []*ec2.IpPermission{
{
IpProtocol: &proto,
FromPort: aws.Int64(80),
ToPort: aws.Int64(80),
IpRanges: []*ec2.IpRange{{CidrIp: aws.String("10.0.0.0/8")}},
},
},
}, testAccountID)
require.NoError(t, err)
// Create another SG without the ingress rule
createTestSG(t, svc, vpcID, "no-cidr-sg")
out, err := svc.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{
Filters: []*ec2.Filter{
{Name: aws.String("ip-permission.cidr"), Values: []*string{aws.String("10.0.0.0/8")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.SecurityGroups, 1)
assert.Equal(t, sgID, *out.SecurityGroups[0].GroupId)
}
func TestDescribeSecurityGroups_FilterMultipleValues_OR(t *testing.T) {
svc := setupTestVPCService(t)
vpcID := createTestVPC(t, svc, "10.0.0.0/16")
createTestSG(t, svc, vpcID, "sg-alpha")
createTestSG(t, svc, vpcID, "sg-beta")
createTestSG(t, svc, vpcID, "sg-gamma")
out, err := svc.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{
Filters: []*ec2.Filter{
{Name: aws.String("group-name"), Values: []*string{aws.String("sg-alpha"), aws.String("sg-gamma")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.SecurityGroups, 2)
}
func TestDescribeSecurityGroups_FilterMultipleFilters_AND(t *testing.T) {
svc := setupTestVPCService(t)
vpc1 := createTestVPC(t, svc, "10.0.0.0/16")
vpc2 := createTestVPC(t, svc, "172.16.0.0/16")
createTestSG(t, svc, vpc1, "same-name")
createTestSG(t, svc, vpc2, "same-name")
out, err := svc.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{
Filters: []*ec2.Filter{
{Name: aws.String("group-name"), Values: []*string{aws.String("same-name")}},
{Name: aws.String("vpc-id"), Values: []*string{aws.String(vpc1)}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.SecurityGroups, 1)
assert.Equal(t, vpc1, *out.SecurityGroups[0].VpcId)
}
func TestDescribeSecurityGroups_FilterUnknownName_Error(t *testing.T) {
svc := setupTestVPCService(t)
_, err := svc.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{
Filters: []*ec2.Filter{
{Name: aws.String("bogus-filter"), Values: []*string{aws.String("x")}},
},
}, testAccountID)
assert.Error(t, err)
}
func TestDescribeSecurityGroups_FilterWildcard(t *testing.T) {
svc := setupTestVPCService(t)
vpcID := createTestVPC(t, svc, "10.0.0.0/16")
createTestSG(t, svc, vpcID, "prod-web")
createTestSG(t, svc, vpcID, "prod-api")
createTestSG(t, svc, vpcID, "staging-web")
out, err := svc.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{
Filters: []*ec2.Filter{
{Name: aws.String("group-name"), Values: []*string{aws.String("prod-*")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.SecurityGroups, 2)
}
func TestDescribeSecurityGroups_FilterByTag(t *testing.T) {
svc := setupTestVPCService(t)
vpcID := createTestVPC(t, svc, "10.0.0.0/16")
out, err := svc.CreateSecurityGroup(&ec2.CreateSecurityGroupInput{
GroupName: aws.String("tagged-sg"),
Description: aws.String("tagged"),
VpcId: aws.String(vpcID),
TagSpecifications: []*ec2.TagSpecification{
{
ResourceType: aws.String("security-group"),
Tags: []*ec2.Tag{{Key: aws.String("Env"), Value: aws.String("prod")}},
},
},
}, testAccountID)
require.NoError(t, err)
createTestSG(t, svc, vpcID, "untagged-sg")
desc, err := svc.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{
Filters: []*ec2.Filter{
{Name: aws.String("tag:Env"), Values: []*string{aws.String("prod")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, desc.SecurityGroups, 1)
assert.Equal(t, *out.GroupId, *desc.SecurityGroups[0].GroupId)
}
func TestDescribeSecurityGroups_FilterNoResults(t *testing.T) {
svc := setupTestVPCService(t)
vpcID := createTestVPC(t, svc, "10.0.0.0/16")
createTestSG(t, svc, vpcID, "my-sg")
out, err := svc.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{
Filters: []*ec2.Filter{
{Name: aws.String("group-name"), Values: []*string{aws.String("nonexistent")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, out.SecurityGroups)
}
+99 -8
View File
@@ -13,6 +13,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/filterutil"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
)
@@ -309,6 +310,15 @@ func (s *VPCServiceImpl) DeleteVpc(input *ec2.DeleteVpcInput, accountID string)
return &ec2.DeleteVpcOutput{}, nil
}
// describeVpcsValidFilters defines the set of filter names accepted by DescribeVpcs.
var describeVpcsValidFilters = map[string]bool{
"vpc-id": true,
"state": true,
"cidr-block": true,
"is-default": true,
"owner-id": true,
}
// DescribeVpcs describes VPCs
func (s *VPCServiceImpl) DescribeVpcs(input *ec2.DescribeVpcsInput, accountID string) (*ec2.DescribeVpcsOutput, error) {
var vpcs []*ec2.Vpc
@@ -320,6 +330,12 @@ func (s *VPCServiceImpl) DescribeVpcs(input *ec2.DescribeVpcsInput, accountID st
}
}
parsedFilters, err := filterutil.ParseFilters(input.Filters, describeVpcsValidFilters)
if err != nil {
slog.Warn("DescribeVpcs: invalid filter", "err", err)
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
prefix := accountID + "."
keys, err := s.vpcKV.Keys()
if err != nil && !errors.Is(err, nats.ErrNoKeysFound) {
@@ -350,6 +366,10 @@ func (s *VPCServiceImpl) DescribeVpcs(input *ec2.DescribeVpcsInput, accountID st
continue
}
if len(parsedFilters) > 0 && !vpcMatchesFilters(&record, accountID, parsedFilters) {
continue
}
vpcs = append(vpcs, s.vpcRecordToEC2(&record, accountID))
}
@@ -546,12 +566,10 @@ func (s *VPCServiceImpl) DescribeSubnets(input *ec2.DescribeSubnetsInput, accoun
}
}
// Extract VPC ID filter if present
vpcIDFilter := ""
for _, f := range input.Filters {
if f.Name != nil && *f.Name == "vpc-id" && len(f.Values) > 0 && f.Values[0] != nil {
vpcIDFilter = *f.Values[0]
}
parsedFilters, err := filterutil.ParseFilters(input.Filters, describeSubnetsValidFilters)
if err != nil {
slog.Warn("DescribeSubnets: invalid filter", "err", err)
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
prefix := accountID + "."
@@ -584,8 +602,7 @@ func (s *VPCServiceImpl) DescribeSubnets(input *ec2.DescribeSubnetsInput, accoun
continue
}
// Apply VPC ID filter
if vpcIDFilter != "" && record.VpcId != vpcIDFilter {
if len(parsedFilters) > 0 && !subnetMatchesFilters(&record, parsedFilters) {
continue
}
@@ -622,6 +639,80 @@ func (s *VPCServiceImpl) DescribeSubnets(input *ec2.DescribeSubnetsInput, accoun
}, nil
}
// vpcMatchesFilters checks whether a VPCRecord satisfies all parsed filters.
func vpcMatchesFilters(record *VPCRecord, accountID string, filters map[string][]string) bool {
for name, values := range filters {
if strings.HasPrefix(name, "tag:") {
continue
}
var field string
switch name {
case "vpc-id":
field = record.VpcId
case "state":
field = record.State
case "cidr-block":
field = record.CidrBlock
case "is-default":
field = fmt.Sprintf("%t", record.IsDefault)
case "owner-id":
field = accountID
default:
return false
}
if !filterutil.MatchesAny(values, field) {
return false
}
}
return filterutil.MatchesTags(filters, record.Tags)
}
// describeSubnetsValidFilters defines the set of filter names accepted by DescribeSubnets.
var describeSubnetsValidFilters = map[string]bool{
"subnet-id": true,
"vpc-id": true,
"availability-zone": true,
"cidr-block": true,
"state": true,
"default-for-az": true,
}
// subnetMatchesFilters checks whether a SubnetRecord satisfies all parsed filters.
func subnetMatchesFilters(record *SubnetRecord, filters map[string][]string) bool {
for name, values := range filters {
if strings.HasPrefix(name, "tag:") {
continue
}
var field string
switch name {
case "subnet-id":
field = record.SubnetId
case "vpc-id":
field = record.VpcId
case "availability-zone":
field = record.AvailabilityZone
case "cidr-block":
field = record.CidrBlock
case "state":
field = record.State
case "default-for-az":
field = fmt.Sprintf("%t", record.IsDefault)
default:
return false
}
if !filterutil.MatchesAny(values, field) {
return false
}
}
return filterutil.MatchesTags(filters, record.Tags)
}
func (s *VPCServiceImpl) vpcRecordToEC2(record *VPCRecord, accountID string) *ec2.Vpc {
vpc := &ec2.Vpc{
VpcId: aws.String(record.VpcId),
@@ -1214,3 +1214,417 @@ func TestVpc_ModifyVpcAttribute_InvalidVpcID(t *testing.T) {
}, testAccountID)
assert.Error(t, err)
}
// --- DescribeVpcs filter tests ---
func TestDescribeVpcs_FilterByCidr(t *testing.T) {
svc := setupTestVPCService(t)
createTestVPC(t, svc, "10.0.0.0/16")
createTestVPC(t, svc, "172.16.0.0/16")
out, err := svc.DescribeVpcs(&ec2.DescribeVpcsInput{
Filters: []*ec2.Filter{
{Name: aws.String("cidr-block"), Values: []*string{aws.String("10.0.0.0/16")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Vpcs, 1)
assert.Equal(t, "10.0.0.0/16", *out.Vpcs[0].CidrBlock)
}
func TestDescribeVpcs_FilterByState(t *testing.T) {
svc := setupTestVPCService(t)
createTestVPC(t, svc, "10.0.0.0/16")
// VPCs are always "available"
out, err := svc.DescribeVpcs(&ec2.DescribeVpcsInput{
Filters: []*ec2.Filter{
{Name: aws.String("state"), Values: []*string{aws.String("available")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Vpcs, 1)
out, err = svc.DescribeVpcs(&ec2.DescribeVpcsInput{
Filters: []*ec2.Filter{
{Name: aws.String("state"), Values: []*string{aws.String("pending")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, out.Vpcs)
}
func TestDescribeVpcs_FilterByIsDefault(t *testing.T) {
svc := setupTestVPCService(t)
createTestVPC(t, svc, "10.0.0.0/16")
out, err := svc.DescribeVpcs(&ec2.DescribeVpcsInput{
Filters: []*ec2.Filter{
{Name: aws.String("is-default"), Values: []*string{aws.String("false")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Vpcs, 1)
out, err = svc.DescribeVpcs(&ec2.DescribeVpcsInput{
Filters: []*ec2.Filter{
{Name: aws.String("is-default"), Values: []*string{aws.String("true")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, out.Vpcs)
}
func TestDescribeVpcs_FilterMultipleValues_OR(t *testing.T) {
svc := setupTestVPCService(t)
createTestVPC(t, svc, "10.0.0.0/16")
createTestVPC(t, svc, "172.16.0.0/16")
createTestVPC(t, svc, "192.168.0.0/16")
out, err := svc.DescribeVpcs(&ec2.DescribeVpcsInput{
Filters: []*ec2.Filter{
{Name: aws.String("cidr-block"), Values: []*string{aws.String("10.0.0.0/16"), aws.String("192.168.0.0/16")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Vpcs, 2)
}
func TestDescribeVpcs_FilterMultipleFilters_AND(t *testing.T) {
svc := setupTestVPCService(t)
createTestVPC(t, svc, "10.0.0.0/16")
// Both match
out, err := svc.DescribeVpcs(&ec2.DescribeVpcsInput{
Filters: []*ec2.Filter{
{Name: aws.String("cidr-block"), Values: []*string{aws.String("10.0.0.0/16")}},
{Name: aws.String("state"), Values: []*string{aws.String("available")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Vpcs, 1)
// One doesn't match
out, err = svc.DescribeVpcs(&ec2.DescribeVpcsInput{
Filters: []*ec2.Filter{
{Name: aws.String("cidr-block"), Values: []*string{aws.String("10.0.0.0/16")}},
{Name: aws.String("state"), Values: []*string{aws.String("pending")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, out.Vpcs)
}
func TestDescribeVpcs_FilterUnknownName_Error(t *testing.T) {
svc := setupTestVPCService(t)
_, err := svc.DescribeVpcs(&ec2.DescribeVpcsInput{
Filters: []*ec2.Filter{
{Name: aws.String("bogus-filter"), Values: []*string{aws.String("val")}},
},
}, testAccountID)
require.Error(t, err)
}
func TestDescribeVpcs_FilterWildcard(t *testing.T) {
svc := setupTestVPCService(t)
createTestVPC(t, svc, "10.0.0.0/16")
createTestVPC(t, svc, "10.1.0.0/16")
createTestVPC(t, svc, "172.16.0.0/16")
out, err := svc.DescribeVpcs(&ec2.DescribeVpcsInput{
Filters: []*ec2.Filter{
{Name: aws.String("cidr-block"), Values: []*string{aws.String("10.*")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Vpcs, 2)
}
func TestDescribeVpcs_FilterNoResults(t *testing.T) {
svc := setupTestVPCService(t)
createTestVPC(t, svc, "10.0.0.0/16")
out, err := svc.DescribeVpcs(&ec2.DescribeVpcsInput{
Filters: []*ec2.Filter{
{Name: aws.String("cidr-block"), Values: []*string{aws.String("99.99.99.99/32")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, out.Vpcs)
}
func TestDescribeVpcs_FilterNoFilters(t *testing.T) {
svc := setupTestVPCService(t)
createTestVPC(t, svc, "10.0.0.0/16")
createTestVPC(t, svc, "172.16.0.0/16")
out, err := svc.DescribeVpcs(&ec2.DescribeVpcsInput{}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Vpcs, 2)
}
func TestDescribeVpcs_FilterByTag(t *testing.T) {
svc := setupTestVPCService(t)
out, err := svc.CreateVpc(&ec2.CreateVpcInput{
CidrBlock: aws.String("10.0.0.0/16"),
TagSpecifications: []*ec2.TagSpecification{
{
ResourceType: aws.String("vpc"),
Tags: []*ec2.Tag{
{Key: aws.String("Environment"), Value: aws.String("prod")},
},
},
},
}, testAccountID)
require.NoError(t, err)
_ = *out.Vpc.VpcId
createTestVPC(t, svc, "172.16.0.0/16")
desc, err := svc.DescribeVpcs(&ec2.DescribeVpcsInput{
Filters: []*ec2.Filter{
{Name: aws.String("tag:Environment"), Values: []*string{aws.String("prod")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, desc.Vpcs, 1)
assert.Equal(t, "10.0.0.0/16", *desc.Vpcs[0].CidrBlock)
}
func TestDescribeVpcs_FilterByVpcId(t *testing.T) {
svc := setupTestVPCService(t)
id1 := createTestVPC(t, svc, "10.0.0.0/16")
createTestVPC(t, svc, "172.16.0.0/16")
out, err := svc.DescribeVpcs(&ec2.DescribeVpcsInput{
Filters: []*ec2.Filter{
{Name: aws.String("vpc-id"), Values: []*string{aws.String(id1)}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Vpcs, 1)
assert.Equal(t, id1, *out.Vpcs[0].VpcId)
}
func TestDescribeVpcs_FilterByOwnerId(t *testing.T) {
svc := setupTestVPCService(t)
createTestVPC(t, svc, "10.0.0.0/16")
out, err := svc.DescribeVpcs(&ec2.DescribeVpcsInput{
Filters: []*ec2.Filter{
{Name: aws.String("owner-id"), Values: []*string{aws.String(testAccountID)}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Vpcs, 1)
out, err = svc.DescribeVpcs(&ec2.DescribeVpcsInput{
Filters: []*ec2.Filter{
{Name: aws.String("owner-id"), Values: []*string{aws.String("999999999999")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, out.Vpcs)
}
// --- DescribeSubnets filter tests ---
func TestDescribeSubnets_FilterByVpcId(t *testing.T) {
svc := setupTestVPCService(t)
vpc1 := createTestVPC(t, svc, "10.0.0.0/16")
vpc2 := createTestVPC(t, svc, "172.16.0.0/16")
createTestSubnet(t, svc, vpc1, "10.0.1.0/24")
createTestSubnet(t, svc, vpc2, "172.16.1.0/24")
out, err := svc.DescribeSubnets(&ec2.DescribeSubnetsInput{
Filters: []*ec2.Filter{
{Name: aws.String("vpc-id"), Values: []*string{aws.String(vpc1)}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Subnets, 1)
assert.Equal(t, vpc1, *out.Subnets[0].VpcId)
}
func TestDescribeSubnets_FilterByCidr(t *testing.T) {
svc := setupTestVPCService(t)
vpcID := createTestVPC(t, svc, "10.0.0.0/16")
createTestSubnet(t, svc, vpcID, "10.0.1.0/24")
createTestSubnet(t, svc, vpcID, "10.0.2.0/24")
out, err := svc.DescribeSubnets(&ec2.DescribeSubnetsInput{
Filters: []*ec2.Filter{
{Name: aws.String("cidr-block"), Values: []*string{aws.String("10.0.1.0/24")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Subnets, 1)
assert.Equal(t, "10.0.1.0/24", *out.Subnets[0].CidrBlock)
}
func TestDescribeSubnets_FilterByState(t *testing.T) {
svc := setupTestVPCService(t)
vpcID := createTestVPC(t, svc, "10.0.0.0/16")
createTestSubnet(t, svc, vpcID, "10.0.1.0/24")
out, err := svc.DescribeSubnets(&ec2.DescribeSubnetsInput{
Filters: []*ec2.Filter{
{Name: aws.String("state"), Values: []*string{aws.String("available")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Subnets, 1)
out, err = svc.DescribeSubnets(&ec2.DescribeSubnetsInput{
Filters: []*ec2.Filter{
{Name: aws.String("state"), Values: []*string{aws.String("pending")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, out.Subnets)
}
func TestDescribeSubnets_FilterBySubnetId(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")
createTestSubnet(t, svc, vpcID, "10.0.2.0/24")
out, err := svc.DescribeSubnets(&ec2.DescribeSubnetsInput{
Filters: []*ec2.Filter{
{Name: aws.String("subnet-id"), Values: []*string{aws.String(subnetID)}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Subnets, 1)
assert.Equal(t, subnetID, *out.Subnets[0].SubnetId)
}
func TestDescribeSubnets_FilterByDefaultForAz(t *testing.T) {
svc := setupTestVPCService(t)
vpcID := createTestVPC(t, svc, "10.0.0.0/16")
createTestSubnet(t, svc, vpcID, "10.0.1.0/24")
out, err := svc.DescribeSubnets(&ec2.DescribeSubnetsInput{
Filters: []*ec2.Filter{
{Name: aws.String("default-for-az"), Values: []*string{aws.String("false")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Subnets, 1)
}
func TestDescribeSubnets_FilterMultipleValues_OR(t *testing.T) {
svc := setupTestVPCService(t)
vpcID := createTestVPC(t, svc, "10.0.0.0/16")
createTestSubnet(t, svc, vpcID, "10.0.1.0/24")
createTestSubnet(t, svc, vpcID, "10.0.2.0/24")
createTestSubnet(t, svc, vpcID, "10.0.3.0/24")
out, err := svc.DescribeSubnets(&ec2.DescribeSubnetsInput{
Filters: []*ec2.Filter{
{Name: aws.String("cidr-block"), Values: []*string{aws.String("10.0.1.0/24"), aws.String("10.0.3.0/24")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Subnets, 2)
}
func TestDescribeSubnets_FilterMultipleFilters_AND(t *testing.T) {
svc := setupTestVPCService(t)
vpc1 := createTestVPC(t, svc, "10.0.0.0/16")
vpc2 := createTestVPC(t, svc, "172.16.0.0/16")
createTestSubnet(t, svc, vpc1, "10.0.1.0/24")
createTestSubnet(t, svc, vpc2, "172.16.1.0/24")
// Both filters match subnet in vpc1
out, err := svc.DescribeSubnets(&ec2.DescribeSubnetsInput{
Filters: []*ec2.Filter{
{Name: aws.String("vpc-id"), Values: []*string{aws.String(vpc1)}},
{Name: aws.String("cidr-block"), Values: []*string{aws.String("10.0.1.0/24")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Subnets, 1)
// Mismatched: vpc1 + wrong cidr
out, err = svc.DescribeSubnets(&ec2.DescribeSubnetsInput{
Filters: []*ec2.Filter{
{Name: aws.String("vpc-id"), Values: []*string{aws.String(vpc1)}},
{Name: aws.String("cidr-block"), Values: []*string{aws.String("172.16.1.0/24")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, out.Subnets)
}
func TestDescribeSubnets_FilterUnknownName_Error(t *testing.T) {
svc := setupTestVPCService(t)
_, err := svc.DescribeSubnets(&ec2.DescribeSubnetsInput{
Filters: []*ec2.Filter{
{Name: aws.String("bogus-filter"), Values: []*string{aws.String("x")}},
},
}, testAccountID)
assert.Error(t, err)
}
func TestDescribeSubnets_FilterWildcard(t *testing.T) {
svc := setupTestVPCService(t)
vpcID := createTestVPC(t, svc, "10.0.0.0/16")
createTestSubnet(t, svc, vpcID, "10.0.1.0/24")
createTestSubnet(t, svc, vpcID, "10.0.2.0/24")
out, err := svc.DescribeSubnets(&ec2.DescribeSubnetsInput{
Filters: []*ec2.Filter{
{Name: aws.String("cidr-block"), Values: []*string{aws.String("10.0.*")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, out.Subnets, 2)
}
func TestDescribeSubnets_FilterNoResults(t *testing.T) {
svc := setupTestVPCService(t)
vpcID := createTestVPC(t, svc, "10.0.0.0/16")
createTestSubnet(t, svc, vpcID, "10.0.1.0/24")
out, err := svc.DescribeSubnets(&ec2.DescribeSubnetsInput{
Filters: []*ec2.Filter{
{Name: aws.String("cidr-block"), Values: []*string{aws.String("192.168.0.0/16")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Empty(t, out.Subnets)
}
func TestDescribeSubnets_FilterByTag(t *testing.T) {
svc := setupTestVPCService(t)
vpcID := createTestVPC(t, svc, "10.0.0.0/16")
// Create subnet with tags
out, err := svc.CreateSubnet(&ec2.CreateSubnetInput{
VpcId: aws.String(vpcID),
CidrBlock: aws.String("10.0.1.0/24"),
TagSpecifications: []*ec2.TagSpecification{
{
ResourceType: aws.String("subnet"),
Tags: []*ec2.Tag{{Key: aws.String("Env"), Value: aws.String("prod")}},
},
},
}, testAccountID)
require.NoError(t, err)
// Create another without tag
createTestSubnet(t, svc, vpcID, "10.0.2.0/24")
desc, err := svc.DescribeSubnets(&ec2.DescribeSubnetsInput{
Filters: []*ec2.Filter{
{Name: aws.String("tag:Env"), Values: []*string{aws.String("prod")}},
},
}, testAccountID)
require.NoError(t, err)
assert.Len(t, desc.Subnets, 1)
assert.Equal(t, *out.Subnet.SubnetId, *desc.Subnets[0].SubnetId)
}