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