From d7c72e80af08c35af17b73d11124cbc1bdac4894 Mon Sep 17 00:00:00 2001
From: Julian Sommer <115516918+juliansommer@users.noreply.github.com>
Date: Wed, 1 Apr 2026 11:20:56 +1000
Subject: [PATCH] feat: filters (#104)
---
docs/COMMANDS.md | 24 +-
spinifex/daemon/daemon_handlers_instance.go | 108 ++++
spinifex/daemon/instance_filter_test.go | 218 ++++++++
spinifex/filterutil/filterutil.go | 130 +++++
spinifex/filterutil/filterutil_test.go | 307 +++++++++++
.../gateway/ec2/instance/DescribeInstances.go | 24 +-
spinifex/handlers/ec2/eigw/service_impl.go | 39 ++
.../handlers/ec2/eigw/service_impl_test.go | 91 ++++
spinifex/handlers/ec2/eip/service_impl.go | 51 ++
.../handlers/ec2/eip/service_impl_test.go | 209 ++++++++
spinifex/handlers/ec2/igw/service_impl.go | 48 ++
.../handlers/ec2/igw/service_impl_test.go | 167 ++++++
spinifex/handlers/ec2/image/service_impl.go | 93 ++++
.../handlers/ec2/image/service_impl_test.go | 187 +++++++
spinifex/handlers/ec2/key/service_impl.go | 75 ++-
.../handlers/ec2/key/service_impl_test.go | 110 ++++
.../handlers/ec2/snapshot/service_impl.go | 66 +++
.../ec2/snapshot/service_impl_test.go | 217 ++++++++
spinifex/handlers/ec2/volume/service_impl.go | 210 +++++++-
.../handlers/ec2/volume/service_impl_test.go | 489 ++++++++++++++++++
spinifex/handlers/ec2/vpc/security_group.go | 79 ++-
.../handlers/ec2/vpc/security_group_test.go | 199 +++++++
spinifex/handlers/ec2/vpc/service_impl.go | 107 +++-
.../handlers/ec2/vpc/service_impl_test.go | 414 +++++++++++++++
24 files changed, 3619 insertions(+), 43 deletions(-)
create mode 100644 spinifex/daemon/instance_filter_test.go
create mode 100644 spinifex/filterutil/filterutil.go
create mode 100644 spinifex/filterutil/filterutil_test.go
diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md
index 1a02ae16d..c7a067dec 100644
--- a/docs/COMMANDS.md
+++ b/docs/COMMANDS.md
@@ -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
2. Launch with invalid AMI ID (error)
3. Launch with block device mappings (custom volume size)
4. Launch multiple instances (MinCount/MaxCount)
5. Launch with subnet-id (auto-creates ENI)
6. Invalid instance type returns error
7. Launch with spread placement group (1 per node)
8. Launch with cluster placement group (all on one node)
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)
2. Describe by instance ID
3. Describe with filters (e.g. instance-state-name)
4. Instance not found returns empty set
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)
2. Describe by instance ID
3. Describe with filters (e.g. instance-state-name)
4. Instance not found returns empty set
5. Multi-node aggregation returns instances from all nodes
6. Filter by tag
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
2. Start already-running instance (error: IncorrectInstanceState)
3. Start with invalid instance ID
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
2. Force stop (kills QEMU process)
3. Stop already-stopped instance (error)
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
2. Terminate stopped instance
3. Terminate with DeleteOnTermination=true deletes volumes
4. Terminate with DeleteOnTermination=false preserves volumes
5. Terminate already-terminated (idempotent)
6. Internal volumes (EFI, cloud-init) always cleaned up
7. Invalid instance ID | **DONE** |
@@ -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
2. Create ED25519 key pair
3. Duplicate key name (error: InvalidKeyPair.Duplicate)
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
2. Filter by key name
3. Filter by key pair ID
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
2. Filter by key name
3. Filter by key pair ID
4. Non-existent key returns empty
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
2. Delete non-existent key (idempotent, no error)
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
2. Import invalid key material (error)
3. Import duplicate name (error)
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
2. Filter by image ID
3. Filter by owner (self/amazon)
4. Non-existent AMI returns empty
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
2. Filter by image ID
3. Filter by owner (self/amazon)
4. Non-existent AMI returns empty
5. Verify metadata fields (architecture, state, rootDeviceName)
6. Filter by name wildcard
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
2. Create image from stopped instance
3. Invalid instance ID (error)
4. Duplicate AMI name (error)
5. Verify new AMI appears in describe-images
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
2. Missing required name (error)
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
2. Deregister non-existent AMI (error)
3. Verify deregistered AMI not in describe-images
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
2. Filter by volume ID
3. Filter by attachment state
4. Non-existent volume returns empty
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
2. Filter by volume ID
3. Filter by attachment state
4. Non-existent volume returns empty
5. DeleteOnTermination reflects persisted value
6. Filter by status, size, volume-type
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
2. Modify volume type
3. Decrease size (error - not supported)
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
2. Boundary sizes (1 GiB min, 16384 GiB max)
3. Invalid AZ (error)
4. Verify volume in describe-volumes
5. Unsupported volume type (error - only gp3)
6. Size out of range (error)
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
2. Delete attached volume (error: VolumeInUse)
3. Delete non-existent volume (error: InvalidVolume.NotFound)
4. Verify volume gone from describe-volumes
5. Malformed volume ID (error: InvalidVolumeID.Malformed)
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
2. Auto-assign device name
3. Attach already-attached volume (VolumeInUse)
4. Attach to non-existent instance (InvalidInstanceID.NotFound)
5. Attach to stopped instance (IncorrectInstanceState)
6. Volume not found (InvalidVolume.NotFound)
7. All device slots full (AttachmentLimitExceeded)
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
2. Detach without InstanceId (gateway resolution)
3. Detach with correct --device cross-check
4. Missing VolumeId (InvalidParameterValue)
5. Volume not attached (IncorrectState)
6. Nonexistent volume (InvalidVolume.NotFound)
7. Nonexistent instance (InvalidInstanceID.NotFound)
8. Instance not running (IncorrectInstanceState)
9. Device mismatch (InvalidParameterValue)
10. Boot volume protection (OperationNotPermitted)
11. Force flag (continues past device_del failure)
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
2. Filter by specific volume IDs (fast path)
3. Non-existent volume ID returns InvalidVolume.NotFound
4. Invalid volume ID format (InvalidVolume.Malformed)
5. Internal sub-volumes excluded from listing
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
2. Filter by specific volume IDs (fast path)
3. Non-existent volume ID returns InvalidVolume.NotFound
4. Invalid volume ID format (InvalidVolume.Malformed)
5. Internal sub-volumes excluded from listing
6. Nil/empty input defaults to all volumes
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
2. Check completed modification
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
2. Missing volume ID (InvalidParameterValue)
3. Volume not found (InvalidVolume.NotFound)
4. Invalid volume ID format (InvalidVolume.Malformed)
5. Snapshot with description
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
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
2. Delete non-existent snapshot (InvalidSnapshot.NotFound)
3. Missing snapshot ID (InvalidParameterValue)
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
2. Filter by snapshot ID
3. Empty snapshot list
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
2. Filter by snapshot ID
3. Empty snapshot list
4. Invalid snapshot ID format (InvalidSnapshot.Malformed)
5. Filter by status, volume-id, volume-size
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
2. Copy non-existent snapshot (InvalidSnapshot.NotFound)
3. Missing source ID (InvalidParameterValue)
4. Missing source region (MissingParameter)
5. Copy preserves tags
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
2. Missing VpcId (error: MissingParameter)
3. Create with inline tags
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
2. Delete non-existent EIGW (error)
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
2. Filter by specific ID
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
2. Filter by specific ID
3. Non-existent ID returns empty list
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
2. Release associated EIP (error: InvalidAddressLocked)
3. Release non-existent (error: InvalidAllocationID.NotFound)
4. Missing AllocationId (error: MissingParameter) | **DONE** |
| `associate-address` | `--allocation-id`, `--network-interface-id` OR `--instance-id`, `--private-ip-address` (optional override) | `--dry-run`, `--allow-reassociation` | EIP and target ENI/instance must exist | Gateway validates AllocationId + one of NetworkInterfaceId/InstanceId required → NATS `ec2.AssociateAddress` → daemon retrieves EIP → resolves target ENI (by ID or instance lookup) → generates AssociationId → updates record state=associated with ENI/instance/VPC/private IP → publishes `vpc.add-nat` event to vpcd (OVN NAT configuration) → returns AssociationId | 1. Associate with instance
2. Associate with specific ENI
3. Associate with private IP override
4. Missing AllocationId (MissingParameter)
5. Missing target (MissingParameter)
6. Non-existent EIP (InvalidAllocationID.NotFound)
7. Non-existent ENI (InvalidNetworkInterfaceID.NotFound) | **DONE** |
| `disassociate-address` | `--association-id` | `--dry-run` | Association must exist | Gateway validates AssociationId non-empty → NATS `ec2.DisassociateAddress` → daemon scans KV to find EIP by AssociationId → publishes `vpc.delete-nat` event to vpcd → clears association fields → reverts state to allocated → updates KV → returns success | 1. Disassociate existing
2. Non-existent association (InvalidAssociationID.NotFound)
3. Missing AssociationId (MissingParameter) | **DONE** |
-| `describe-addresses` | `--allocation-ids`, `--public-ips` | `--filters`, `--dry-run` | None | NATS `ec2.DescribeAddresses` → daemon scans all EIP records in account → filters by AllocationIds and/or PublicIps if specified → returns Address list with allocation, association, PublicIp, PrivateIpAddress, NetworkInterfaceId, InstanceId, Domain | 1. List all EIPs
2. Filter by allocation ID
3. Filter by public IP
4. Non-existent AllocationId returns error
5. Empty result when no EIPs | **DONE** |
+| `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
2. Filter by allocation ID
3. Filter by public IP
4. Non-existent AllocationId returns error
5. Empty result when no EIPs
6. Filter by instance-id, domain
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
2. Filter by allocation IDs
3. Non-existent AllocationId returns empty (not error)
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
2. Invalid CIDR format (error: InvalidVpcRange)
3. CIDR prefix outside /16–/28 (error)
4. Verify in describe-vpcs
5. Tags applied at creation | **DONE** |
| `delete-vpc` | `--vpc-id` | `--dry-run` | VPC must be empty (no subnets) | Gateway validates VpcId → NATS `ec2.DeleteVpc` → daemon checks for dependent subnets in `spinifex-vpc-subnets` KV → returns DependencyViolation if subnets exist → deletes from `spinifex-vpc-vpcs` KV → publishes `vpc.delete` event to vpcd (removes OVN resources) | 1. Delete empty VPC
2. Delete VPC with subnets (DependencyViolation)
3. Delete non-existent VPC (error) | **DONE** |
-| `describe-vpcs` | `--vpc-ids` | `--filters`, `--max-results`, `--dry-run` | None | NATS `ec2.DescribeVpcs` → daemon lists all keys from `spinifex-vpc-vpcs` KV → filters by VPC IDs if specified → returns error for non-existent requested IDs → returns VPC list with CIDR, state, CIDR associations | 1. List all VPCs
2. Filter by VPC ID
3. Non-existent VPC ID returns error | **DONE** |
+| `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
2. Filter by VPC ID
3. Non-existent VPC ID returns error
4. Filter by state, cidr-block, is-default
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
2. Enable DNS support
3. Enable network address usage metrics
4. Missing VPC ID (MissingParameter)
5. Non-existent VPC (InvalidVpcID.NotFound) | **DONE** |
| `describe-vpc-attribute` | `--vpc-id`, `--attribute` (enableDnsHostnames, enableDnsSupport, enableNetworkAddressUsageMetrics) | `--dry-run` | VPC must exist | Gateway validates VpcId + Attribute non-empty → NATS `ec2.DescribeVpcAttribute` → daemon retrieves VPC record → returns single attribute as AttributeBooleanValue (one attribute per call, matches AWS behavior) → validates attribute name | 1. Get enableDnsHostnames
2. Get enableDnsSupport
3. Get enableNetworkAddressUsageMetrics
4. Invalid attribute name (InvalidParameterValue)
5. Missing VPC ID (MissingParameter)
6. Non-existent VPC (InvalidVpcID.NotFound) | **DONE** |
| `associate-vpc-cidr-block` | — | `--vpc-id`, `--cidr-block` | VPC must exist | NATS `ec2.AssociateVpcCidrBlock` → daemon adds secondary CIDR to VPC metadata → return association | 1. Add secondary CIDR
2. Overlapping CIDR (error)
3. Max CIDR blocks exceeded (error) | **NOT STARTED** |
| `disassociate-vpc-cidr-block` | — | `--association-id` | Association must exist | NATS `ec2.DisassociateVpcCidrBlock` → daemon removes secondary CIDR from VPC → return success | 1. Remove secondary CIDR
2. Remove primary CIDR (error) | **NOT STARTED** |
| `create-subnet` | `--vpc-id`, `--cidr-block`, `--availability-zone`, `--tag-specifications` | `--dry-run` | VPC must exist | Gateway validates VpcId + CidrBlock (required, valid CIDR, /16–/28 prefix) → NATS `ec2.CreateSubnet` → daemon verifies parent VPC exists → validates subnet CIDR is within VPC CIDR range → checks for overlapping subnets in same VPC → calculates available IPs (total hosts - 5 AWS-reserved: .0/.1/.2/.3/.255) → generates subnet-ID → stores SubnetRecord in `spinifex-vpc-subnets` KV → publishes `vpc.create-subnet` event to vpcd → defaults AZ from config if omitted | 1. Create subnet within VPC CIDR
2. Subnet CIDR outside VPC range (error: InvalidSubnetRange)
3. Overlapping subnet CIDRs (error: InvalidSubnetConflict)
4. CIDR prefix outside /16–/28 (error)
5. Missing VPC or CIDR (error: MissingParameter) | **DONE** |
| `delete-subnet` | `--subnet-id` | `--dry-run` | Subnet must exist | NATS `ec2.DeleteSubnet` → daemon verifies subnet exists → deletes from `spinifex-vpc-subnets` KV → publishes `vpc.delete-subnet` event to vpcd | 1. Delete existing subnet
2. Delete non-existent subnet (error) | **DONE** |
-| `describe-subnets` | `--subnet-ids`, `--filters` (vpc-id) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeSubnets` → daemon lists all keys from `spinifex-vpc-subnets` KV → applies vpc-id filter if present → filters by subnet IDs if specified → recalculates available IPs from CIDR → returns error for non-existent requested IDs | 1. List all subnets
2. Filter by VPC ID
3. Filter by subnet ID
4. Non-existent subnet returns error | **DONE** |
+| `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
2. Filter by VPC ID
3. Filter by subnet ID
4. Non-existent subnet returns error
5. Filter by availability-zone, cidr-block, state
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
2. Disable auto-assign public IP
3. Missing SubnetId (MissingParameter)
4. Non-existent subnet (InvalidSubnetID.NotFound) | **DONE** |
| `associate-subnet-cidr-block` | — | `--subnet-id`, `--ipv6-cidr-block` | Subnet must exist | NATS `ec2.AssociateSubnetCidrBlock` → daemon adds IPv6 CIDR to subnet → return association | 1. Add IPv6 CIDR
2. Missing subnet ID (error) | **NOT STARTED** |
| `disassociate-subnet-cidr-block` | — | `--association-id` | Association must exist | NATS `ec2.DisassociateSubnetCidrBlock` → daemon removes IPv6 CIDR from subnet → return success | 1. Remove IPv6 CIDR
2. Invalid association (error) | **NOT STARTED** |
| `create-security-group` | `--group-name`, `--description`, `--vpc-id`, `--tag-specifications` | `--dry-run` | VPC must exist | Gateway validates GroupName + VpcId required → NATS `ec2.CreateSecurityGroup` → daemon verifies VPC exists → checks duplicate name in same VPC → generates sg-ID → creates SecurityGroupRecord in `spinifex-vpc-security-groups` KV with default egress rule (allow all outbound 0.0.0.0/0), no ingress rules → publishes `vpc.create-sg` event to vpcd (OVN ACL configuration) → returns GroupId | 1. Create SG in VPC
2. Duplicate name in same VPC (error: InvalidGroup.Duplicate)
3. Verify default egress rule (allow all)
4. Verify no default ingress rules
5. Missing GroupName (MissingParameter)
6. Non-existent VPC (InvalidVpcID.NotFound)
7. With tag specifications | **DONE** |
| `delete-security-group` | `--group-id` | `--dry-run` | SG must exist | Gateway validates GroupId non-empty → NATS `ec2.DeleteSecurityGroup` → daemon retrieves SG from KV → deletes from `spinifex-vpc-security-groups` KV → publishes `vpc.delete-sg` event to vpcd → returns success | 1. Delete existing SG
2. Delete non-existent SG (error: InvalidGroup.NotFound)
3. Missing GroupId (MissingParameter) | **DONE** |
-| `describe-security-groups` | `--group-ids`, `--filters` (vpc-id, group-name) | `--group-names`, `--max-results`, `--dry-run` | None | NATS `ec2.DescribeSecurityGroups` → daemon scans all SG records in account → filters by GroupIds, vpc-id filter, or group-name filter → returns SecurityGroup list with ingress/egress rules → returns error for non-existent requested GroupIds | 1. List all SGs
2. Filter by VPC ID
3. Filter by group name
4. Filter by GroupId
5. Non-existent GroupId (InvalidGroup.NotFound)
6. Empty result when no SGs | **DONE** |
+| `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
2. Filter by VPC ID
3. Filter by group name
4. Filter by GroupId
5. Non-existent GroupId (InvalidGroup.NotFound)
6. Empty result when no SGs
7. Filter by description, ip-permission.cidr
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
2. Allow from specific CIDR
3. Allow from source security group
4. Missing GroupId (MissingParameter)
5. Non-existent SG (InvalidGroup.NotFound) | **DONE** |
| `authorize-security-group-egress` | `--group-id`, `--ip-permissions` | `--dry-run` | SG must exist | Gateway validates GroupId → NATS `ec2.AuthorizeSecurityGroupEgress` → daemon retrieves SG → appends rules to EgressRules → updates KV with optimistic locking → publishes `vpc.update-sg` event → returns true | 1. Allow HTTPS outbound
2. Restrict to specific CIDR
3. Missing GroupId (MissingParameter)
4. Non-existent SG (InvalidGroup.NotFound) | **DONE** |
| `revoke-security-group-ingress` | `--group-id`, `--ip-permissions` (matching rules to remove) | `--dry-run` | SG must exist | Gateway validates GroupId → NATS `ec2.RevokeSecurityGroupIngress` → daemon retrieves SG → removes matching rules from IngressRules (set difference) → updates KV with optimistic locking → publishes `vpc.update-sg` event → returns true | 1. Revoke existing rule
2. Missing GroupId (MissingParameter)
3. Non-existent SG (InvalidGroup.NotFound) | **DONE** |
@@ -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
2. Attach already-attached IGW (ResourceAlreadyAssociated)
3. Non-existent IGW (error)
4. Missing params (MissingParameter) | **DONE** |
| `detach-internet-gateway` | `--internet-gateway-id`, `--vpc-id` | `--dry-run` | IGW must be attached to specified VPC | NATS `ec2.DetachInternetGateway` → daemon validates IGW exists and is attached to specified VPC (GatewayNotAttached if wrong VPC) → clears VpcId → state=available → publishes `vpc.igw-detach` event to vpcd (removes OVN resources) | 1. Detach attached IGW
2. Detach from wrong VPC (GatewayNotAttached)
3. Detach unattached IGW (error) | **DONE** |
| `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
2. Delete attached IGW (DependencyViolation)
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
2. Filter by IGW ID
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
2. Filter by IGW ID
3. Verify attachment info
4. Filter by attachment.vpc-id, attachment.state
5. Unknown filter returns InvalidParameterValue | **DONE** |
### EC2 - Route Tables
diff --git a/spinifex/daemon/daemon_handlers_instance.go b/spinifex/daemon/daemon_handlers_instance.go
index b0e16b5f4..a747d02da 100644
--- a/spinifex/daemon/daemon_handlers_instance.go
+++ b/spinifex/daemon/daemon_handlers_instance.go
@@ -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)
}
diff --git a/spinifex/daemon/instance_filter_test.go b/spinifex/daemon/instance_filter_test.go
new file mode 100644
index 000000000..0c630e05a
--- /dev/null
+++ b/spinifex/daemon/instance_filter_test.go
@@ -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))
+}
diff --git a/spinifex/filterutil/filterutil.go b/spinifex/filterutil/filterutil.go
new file mode 100644
index 000000000..0ebe4a45a
--- /dev/null
+++ b/spinifex/filterutil/filterutil.go
@@ -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
+}
diff --git a/spinifex/filterutil/filterutil_test.go b/spinifex/filterutil/filterutil_test.go
new file mode 100644
index 000000000..fac90fc94
--- /dev/null
+++ b/spinifex/filterutil/filterutil_test.go
@@ -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")
+ }
+}
diff --git a/spinifex/gateway/ec2/instance/DescribeInstances.go b/spinifex/gateway/ec2/instance/DescribeInstances.go
index 0660ab8e6..8511442af 100644
--- a/spinifex/gateway/ec2/instance/DescribeInstances.go
+++ b/spinifex/gateway/ec2/instance/DescribeInstances.go
@@ -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,
diff --git a/spinifex/handlers/ec2/eigw/service_impl.go b/spinifex/handlers/ec2/eigw/service_impl.go
index 4f32c29b7..96887e7c9 100644
--- a/spinifex/handlers/ec2/eigw/service_impl.go
+++ b/spinifex/handlers/ec2/eigw/service_impl.go
@@ -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),
diff --git a/spinifex/handlers/ec2/eigw/service_impl_test.go b/spinifex/handlers/ec2/eigw/service_impl_test.go
index 8a9ca9628..a902d4a22 100644
--- a/spinifex/handlers/ec2/eigw/service_impl_test.go
+++ b/spinifex/handlers/ec2/eigw/service_impl_test.go
@@ -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
diff --git a/spinifex/handlers/ec2/eip/service_impl.go b/spinifex/handlers/ec2/eip/service_impl.go
index 05862b121..f83ac8555 100644
--- a/spinifex/handlers/ec2/eip/service_impl.go
+++ b/spinifex/handlers/ec2/eip/service_impl.go
@@ -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
diff --git a/spinifex/handlers/ec2/eip/service_impl_test.go b/spinifex/handlers/ec2/eip/service_impl_test.go
index 69012f2e0..5b3c12dfd 100644
--- a/spinifex/handlers/ec2/eip/service_impl_test.go
+++ b/spinifex/handlers/ec2/eip/service_impl_test.go
@@ -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)
+}
diff --git a/spinifex/handlers/ec2/igw/service_impl.go b/spinifex/handlers/ec2/igw/service_impl.go
index 8b45f7c23..8cc50c715 100644
--- a/spinifex/handlers/ec2/igw/service_impl.go
+++ b/spinifex/handlers/ec2/igw/service_impl.go
@@ -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) {
diff --git a/spinifex/handlers/ec2/igw/service_impl_test.go b/spinifex/handlers/ec2/igw/service_impl_test.go
index 7114c82a9..9884d0bcd 100644
--- a/spinifex/handlers/ec2/igw/service_impl_test.go
+++ b/spinifex/handlers/ec2/igw/service_impl_test.go
@@ -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)
diff --git a/spinifex/handlers/ec2/image/service_impl.go b/spinifex/handlers/ec2/image/service_impl.go
index ee7ce4e58..746e3ab45 100644
--- a/spinifex/handlers/ec2/image/service_impl.go
+++ b/spinifex/handlers/ec2/image/service_impl.go
@@ -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-/ 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) {
diff --git a/spinifex/handlers/ec2/image/service_impl_test.go b/spinifex/handlers/ec2/image/service_impl_test.go
index 1fe2e137c..b15c770fa 100644
--- a/spinifex/handlers/ec2/image/service_impl_test.go
+++ b/spinifex/handlers/ec2/image/service_impl_test.go
@@ -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)
+}
diff --git a/spinifex/handlers/ec2/key/service_impl.go b/spinifex/handlers/ec2/key/service_impl.go
index c0372fddb..fab4756ec 100644
--- a/spinifex/handlers/ec2/key/service_impl.go
+++ b/spinifex/handlers/ec2/key/service_impl.go
@@ -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//.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//.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 {
diff --git a/spinifex/handlers/ec2/key/service_impl_test.go b/spinifex/handlers/ec2/key/service_impl_test.go
index 8277b6c39..e7a2923d3 100644
--- a/spinifex/handlers/ec2/key/service_impl_test.go
+++ b/spinifex/handlers/ec2/key/service_impl_test.go
@@ -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)
+}
diff --git a/spinifex/handlers/ec2/snapshot/service_impl.go b/spinifex/handlers/ec2/snapshot/service_impl.go
index e7f12f752..af4ff6012 100644
--- a/spinifex/handlers/ec2/snapshot/service_impl.go
+++ b/spinifex/handlers/ec2/snapshot/service_impl.go
@@ -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{
diff --git a/spinifex/handlers/ec2/snapshot/service_impl_test.go b/spinifex/handlers/ec2/snapshot/service_impl_test.go
index 9470f361d..c1346254e 100644
--- a/spinifex/handlers/ec2/snapshot/service_impl_test.go
+++ b/spinifex/handlers/ec2/snapshot/service_impl_test.go
@@ -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)
+}
diff --git a/spinifex/handlers/ec2/volume/service_impl.go b/spinifex/handlers/ec2/volume/service_impl.go
index 9d01e20f9..1f7a7e219 100644
--- a/spinifex/handlers/ec2/volume/service_impl.go
+++ b/spinifex/handlers/ec2/volume/service_impl.go
@@ -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.
diff --git a/spinifex/handlers/ec2/volume/service_impl_test.go b/spinifex/handlers/ec2/volume/service_impl_test.go
index 6c3ff81db..28d5eb50d 100644
--- a/spinifex/handlers/ec2/volume/service_impl_test.go
+++ b/spinifex/handlers/ec2/volume/service_impl_test.go
@@ -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)
+}
diff --git a/spinifex/handlers/ec2/vpc/security_group.go b/spinifex/handlers/ec2/vpc/security_group.go
index 119bffa70..b6382c608 100644
--- a/spinifex/handlers/ec2/vpc/security_group.go
+++ b/spinifex/handlers/ec2/vpc/security_group.go
@@ -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 == "" {
diff --git a/spinifex/handlers/ec2/vpc/security_group_test.go b/spinifex/handlers/ec2/vpc/security_group_test.go
index ec5f92e3c..9e01252be 100644
--- a/spinifex/handlers/ec2/vpc/security_group_test.go
+++ b/spinifex/handlers/ec2/vpc/security_group_test.go
@@ -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)
+}
diff --git a/spinifex/handlers/ec2/vpc/service_impl.go b/spinifex/handlers/ec2/vpc/service_impl.go
index 51fcc4f1c..80095bcf7 100644
--- a/spinifex/handlers/ec2/vpc/service_impl.go
+++ b/spinifex/handlers/ec2/vpc/service_impl.go
@@ -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),
diff --git a/spinifex/handlers/ec2/vpc/service_impl_test.go b/spinifex/handlers/ec2/vpc/service_impl_test.go
index 2f79934f7..0f372be8a 100644
--- a/spinifex/handlers/ec2/vpc/service_impl_test.go
+++ b/spinifex/handlers/ec2/vpc/service_impl_test.go
@@ -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)
+}