mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-23 20:57:02 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5b6204875 | |||
| 21aa1296c9 | |||
| ea6b5d770a | |||
| f986cf9748 | |||
| 11f7c99955 | |||
| 1b45c7fc7d | |||
| 868a92bd55 | |||
| af52d881b9 | |||
| c51ea0c29d | |||
| 8cf0cc332f | |||
| 1d6cefd9d2 | |||
| 0c160d7f45 | |||
| 91e4efb0ef | |||
| f5ec8c3bae | |||
| 8795a7b926 | |||
| 39e9fa6fb8 | |||
| 853b5bfd32 | |||
| d7c72e80af | |||
| 475e632404 |
@@ -1,7 +1,7 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: gomod
|
||||
target-branch: dev
|
||||
target-branch: main
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
@@ -15,7 +15,7 @@ updates:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
target-branch: dev
|
||||
target-branch: main
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
@@ -34,7 +34,7 @@ updates:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: npm
|
||||
target-branch: dev
|
||||
target-branch: main
|
||||
directory: /spinifex/services/spinifexui/frontend
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- "**/*.go"
|
||||
- go.mod
|
||||
@@ -13,6 +12,13 @@ on:
|
||||
- tests/e2e/**
|
||||
- .github/workflows/e2e.yml
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sha:
|
||||
description: "PR head SHA to report status on"
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
statuses: write
|
||||
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
@@ -25,7 +31,7 @@ jobs:
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-single-${{ github.run_id }}.log
|
||||
steps:
|
||||
- name: Clean workspace
|
||||
- name: Clean Workspace
|
||||
run: rm -rf "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
@@ -89,7 +95,7 @@ jobs:
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-pseudo-${{ github.run_id }}.log
|
||||
steps:
|
||||
- name: Clean workspace
|
||||
- name: Clean Workspace
|
||||
run: rm -rf "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
@@ -152,7 +158,7 @@ jobs:
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-multi-${{ github.run_id }}.log
|
||||
steps:
|
||||
- name: Clean workspace
|
||||
- name: Clean Workspace
|
||||
run: rm -rf "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
@@ -216,3 +222,29 @@ jobs:
|
||||
tofu workspace select -or-create env2 >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/env2.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
./scrub-env.sh env2 >> "$TOFU_LOG" 2>&1 || true
|
||||
|
||||
e2e_status:
|
||||
name: Report E2E Status
|
||||
if: always() && inputs.sha
|
||||
needs: [e2e_single, e2e_pseudo_multi, e2e_real_multi]
|
||||
runs-on: [self-hosted, ci-single]
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Report status to PR
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if [[ "${{ needs.e2e_single.result }}" == "success" && \
|
||||
"${{ needs.e2e_pseudo_multi.result }}" == "success" && \
|
||||
"${{ needs.e2e_real_multi.result }}" == "success" ]]; then
|
||||
STATE="success"
|
||||
DESC="All E2E suites passed"
|
||||
else
|
||||
STATE="failure"
|
||||
DESC="E2E failed: single=${{ needs.e2e_single.result }} pseudo=${{ needs.e2e_pseudo_multi.result }} multi=${{ needs.e2e_real_multi.result }}"
|
||||
fi
|
||||
gh api "repos/${{ github.repository }}/statuses/${{ inputs.sha }}" \
|
||||
-f state="$STATE" \
|
||||
-f context=e2e \
|
||||
-f description="$DESC" \
|
||||
-f target_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
|
||||
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- "**/*.go"
|
||||
- go.mod
|
||||
@@ -14,7 +13,6 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -4,14 +4,12 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- spinifex/services/spinifexui/frontend/**
|
||||
- .github/workflows/ui.yml
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- spinifex/services/spinifexui/frontend/**
|
||||
- .github/workflows/ui.yml
|
||||
|
||||
@@ -42,6 +42,7 @@ RUN cd viperblock && go build -o /out/nbdkit-viperblock-plugin.so -buildmode=c-s
|
||||
RUN cp spinifex/scripts/setup.sh /out/setup.sh 2>/dev/null || true
|
||||
RUN cp spinifex/scripts/setup-ovn.sh /out/setup-ovn.sh
|
||||
RUN mkdir -p /out/systemd && cp spinifex/build/systemd/* /out/systemd/
|
||||
RUN mkdir -p /out/scripts && cp spinifex/build/scripts/* /out/scripts/
|
||||
RUN cp spinifex/build/logrotate/spinifex /out/logrotate-spinifex
|
||||
|
||||
FROM scratch AS export
|
||||
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
# Helper script for spinifex-predastore.service
|
||||
# Detects host, port, backend, and node-id from spinifex.toml
|
||||
# then execs spx. Falls back to single-node defaults if no config found.
|
||||
|
||||
CONF=/etc/spinifex/spinifex.toml
|
||||
BIND="0.0.0.0:8443"
|
||||
BACKEND="filesystem"
|
||||
NODE_ID="0"
|
||||
|
||||
if [ -f "$CONF" ]; then
|
||||
H=$(awk -F'"' '/\[nodes\..*\.predastore\]/{f=1} f&&/^host/{print $2;exit}' "$CONF")
|
||||
if [ -n "$H" ]; then
|
||||
BIND="$H"
|
||||
BACKEND="distributed"
|
||||
fi
|
||||
|
||||
N=$(awk -F'= *' '/node_id/{gsub(/ /,"",$2);print $2;exit}' "$CONF")
|
||||
if [ -n "$N" ] && [ "$N" != "0" ]; then
|
||||
NODE_ID="$N"
|
||||
fi
|
||||
fi
|
||||
|
||||
export SPINIFEX_PREDASTORE_HOST="${BIND%%:*}"
|
||||
export SPINIFEX_PREDASTORE_PORT="${BIND##*:}"
|
||||
export SPINIFEX_PREDASTORE_BACKEND="$BACKEND"
|
||||
export SPINIFEX_PREDASTORE_NODE_ID="$NODE_ID"
|
||||
|
||||
exec /usr/local/bin/spx service predastore start
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/bin/sh
|
||||
# Wait for NATS to be reachable before starting a dependent service.
|
||||
# Reads the NATS address from spinifex.toml [nodes.*.nats] section,
|
||||
# falls back to nats.conf, then to 127.0.0.1:4222.
|
||||
|
||||
CONF=/etc/spinifex/spinifex.toml
|
||||
NATS_CONF=/etc/spinifex/nats/nats.conf
|
||||
NATS_HOST=""
|
||||
NATS_PORT="4222"
|
||||
TIMEOUT=30
|
||||
|
||||
# Try spinifex.toml first
|
||||
if [ -f "$CONF" ]; then
|
||||
NATS_BIND=$(awk -F'"' '/\[nodes\..*\.nats\]/{f=1} f&&/^host/{print $2;exit}' "$CONF")
|
||||
if [ -n "$NATS_BIND" ]; then
|
||||
NATS_HOST="${NATS_BIND%%:*}"
|
||||
NATS_PORT="${NATS_BIND##*:}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fall back to nats.conf
|
||||
if [ -z "$NATS_HOST" ] && [ -f "$NATS_CONF" ]; then
|
||||
NATS_HOST=$(grep "^listen:" "$NATS_CONF" | sed 's/listen: *//;s/:.*//')
|
||||
fi
|
||||
|
||||
# Final fallback
|
||||
NATS_HOST="${NATS_HOST:-127.0.0.1}"
|
||||
|
||||
for i in $(seq 1 "$TIMEOUT"); do
|
||||
if nc -z "$NATS_HOST" "$NATS_PORT" 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Timed out waiting for NATS at $NATS_HOST:$NATS_PORT" >&2
|
||||
exit 1
|
||||
@@ -2,13 +2,13 @@
|
||||
Description=Spinifex AWS Gateway
|
||||
PartOf=spinifex.target
|
||||
After=spinifex-nats.service spinifex-daemon.service
|
||||
Requires=spinifex-nats.service
|
||||
Wants=spinifex-nats.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spinifex
|
||||
Group=spinifex
|
||||
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
|
||||
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
ExecStart=/usr/local/bin/spx service awsgw start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
Description=Spinifex Daemon (VM Orchestration)
|
||||
PartOf=spinifex.target
|
||||
After=spinifex-nats.service spinifex-viperblock.service
|
||||
Requires=spinifex-nats.service
|
||||
Wants=spinifex-nats.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spinifex
|
||||
Group=spinifex
|
||||
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
|
||||
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
ExecStart=/usr/local/bin/spx service spinifex start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
Description=Spinifex Predastore (S3-compatible Object Storage)
|
||||
PartOf=spinifex.target
|
||||
After=spinifex-nats.service
|
||||
Requires=spinifex-nats.service
|
||||
Wants=spinifex-nats.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spinifex
|
||||
Group=spinifex
|
||||
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
|
||||
ExecStart=/usr/local/bin/spx service predastore start
|
||||
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
ExecStart=/var/lib/spinifex/predastore-start.sh
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
OOMScoreAdjust=-500
|
||||
|
||||
@@ -11,6 +11,8 @@ ExecStart=/usr/local/bin/spx service spinifex-ui start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
OOMScoreAdjust=-500
|
||||
Environment=SPINIFEX_UI_TLS_CERT=/etc/spinifex/server.pem
|
||||
Environment=SPINIFEX_UI_TLS_KEY=/etc/spinifex/server.key
|
||||
|
||||
[Install]
|
||||
WantedBy=spinifex.target
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
Description=Spinifex Viperblock (Block Storage)
|
||||
PartOf=spinifex.target
|
||||
After=spinifex-nats.service spinifex-predastore.service
|
||||
Requires=spinifex-nats.service
|
||||
Wants=spinifex-nats.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spinifex
|
||||
Group=spinifex
|
||||
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
|
||||
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
ExecStart=/usr/local/bin/spx service viperblock start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
Description=Spinifex VPC Daemon (OVN Networking)
|
||||
PartOf=spinifex.target
|
||||
After=spinifex-nats.service spinifex-daemon.service
|
||||
Requires=spinifex-nats.service
|
||||
Wants=spinifex-nats.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spinifex
|
||||
Group=spinifex
|
||||
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
|
||||
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
ExecStart=/usr/local/bin/spx service vpcd start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
@@ -19,6 +19,7 @@ package cmd
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@@ -1245,6 +1246,16 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
|
||||
|
||||
admin.CreateServiceDirectories(spxRoot)
|
||||
|
||||
// In production layout (running as root), fix ownership so the service user
|
||||
// can read config and write data. SUDO_USER identifies the operator account.
|
||||
if os.Getuid() == 0 {
|
||||
sudoUser := os.Getenv("SUDO_USER")
|
||||
if sudoUser != "" {
|
||||
admin.ChownRecursive(configDir, sudoUser)
|
||||
admin.ChownRecursive(spxRoot, sudoUser)
|
||||
}
|
||||
}
|
||||
|
||||
// Keep formation server running briefly so joining nodes can fetch complete status
|
||||
fmt.Println("\n⏳ Waiting for joining nodes to fetch cluster data...")
|
||||
time.Sleep(15 * time.Second)
|
||||
@@ -1348,9 +1359,14 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // formation server uses ephemeral self-signed cert
|
||||
},
|
||||
}
|
||||
|
||||
joinURL := fmt.Sprintf("http://%s/formation/join", leaderHost)
|
||||
joinURL := fmt.Sprintf("https://%s/formation/join", leaderHost)
|
||||
resp, err := client.Post(joinURL, "application/json", bytes.NewBuffer(reqBody))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "❌ Error connecting to formation server: %v\n", err)
|
||||
@@ -1379,7 +1395,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
|
||||
fmt.Printf("✅ Registered with formation server (%d/%d nodes joined)\n", joinResp.Joined, joinResp.Expected)
|
||||
|
||||
// Poll status until formation is complete
|
||||
statusURL := fmt.Sprintf("http://%s/formation/status", leaderHost)
|
||||
statusURL := fmt.Sprintf("https://%s/formation/status", leaderHost)
|
||||
var statusResp formation.StatusResponse
|
||||
|
||||
for {
|
||||
@@ -1632,6 +1648,16 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
|
||||
|
||||
admin.CreateServiceDirectories(dataDir)
|
||||
|
||||
// In production layout (running as root), fix ownership so the service user
|
||||
// can read config and write data. SUDO_USER identifies the operator account.
|
||||
if os.Getuid() == 0 {
|
||||
sudoUser := os.Getenv("SUDO_USER")
|
||||
if sudoUser != "" {
|
||||
admin.ChownRecursive(configDir, sudoUser)
|
||||
admin.ChownRecursive(dataDir, sudoUser)
|
||||
}
|
||||
}
|
||||
|
||||
// Print cluster summary
|
||||
fmt.Println("\n🎉 Node successfully joined cluster!")
|
||||
fmt.Printf(" Cluster: %s (%d nodes)\n", creds.ClusterName, len(statusResp.Nodes))
|
||||
|
||||
@@ -245,13 +245,13 @@ var viperblockStartCmd = &cobra.Command{
|
||||
|
||||
accessKey := viper.GetString("access-key")
|
||||
if accessKey != "" {
|
||||
fmt.Println("Overwriting access-key:", accessKey)
|
||||
fmt.Println("Overwriting access-key: ****")
|
||||
nodeConfig.Predastore.AccessKey = accessKey
|
||||
}
|
||||
|
||||
secretKey := viper.GetString("secret-key")
|
||||
if secretKey != "" {
|
||||
fmt.Println("Overwriting secret-key:", secretKey)
|
||||
fmt.Println("Overwriting secret-key: ****")
|
||||
nodeConfig.Predastore.SecretKey = secretKey
|
||||
}
|
||||
|
||||
|
||||
+176
-37
@@ -54,11 +54,23 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--services`, `--formation-timeout`, `--force` | None (first-time setup) | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/` | 1. Init creates all config files<br>2. Root credentials printed once<br>3. master.key is 32 bytes, mode 0600<br>4. bootstrap.json consumed on first start<br>5. `--force` re-initializes existing config<br>6. AWS CLI profile `spx` auto-configured | **DONE** |
|
||||
| `spx admin join` | `--host` (required), `--node` (required), `--bind`, `--port`, `--region`, `--az`, `--cluster-bind`, `--cluster-routes`, `--data-dir`, `--services` | Leader node must be running | Connects to leader node → retrieves cluster configuration → configures local node to join cluster and participate in distributed operations | 1. Join existing cluster<br>2. Missing host (error)<br>3. Missing node name (error) | **DONE** |
|
||||
|
||||
### Version
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Status |
|
||||
|---------|-------|---------------|-------------|--------|
|
||||
| `spx version` | — | None | Prints Spinifex version, commit hash, OS, and architecture (populated via build-time ldflags) | **DONE** |
|
||||
|
||||
### Cluster Operations
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `spx admin cluster shutdown` | — | Cluster must be running | Gracefully shuts down the entire cluster | 1. Shutdown running cluster<br>2. All nodes stop cleanly | **DONE** |
|
||||
| `spx admin cluster shutdown` | `--force` (shutdown even if nodes don't respond), `--timeout` (max wait per phase, default 120s), `--dry-run` (print phase plan without executing) | Cluster must be running | Performs coordinated, phased shutdown of entire cluster. Phases execute in order: GATE (stop API/UI) → DRAIN (stop VMs) → STORAGE (stop viperblock) → PERSIST (stop predastore) → INFRA (stop NATS/daemon). Each phase waits for all nodes to ACK before proceeding. Uses JetStream state tracking. | 1. Shutdown running cluster<br>2. All nodes stop cleanly<br>3. Force shutdown with unresponsive nodes<br>4. Dry-run prints plan | **DONE** |
|
||||
|
||||
### Certificate Management
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `spx admin cert renew` | `--extra-ip` (additional IPs for SANs), `--extra-dns` (additional DNS names for SANs) | Existing CA from `spx admin init` | Reads existing CA → regenerates server certificate with all current network interface IPs and machine hostname in SANs → writes new cert. Use after adding a new network interface or changing IP addresses. | 1. Renew with auto-detected IPs<br>2. Renew with extra IPs<br>3. Renew with extra DNS names | **DONE** |
|
||||
|
||||
### Account Management
|
||||
|
||||
@@ -81,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** |
|
||||
@@ -99,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** |
|
||||
|
||||
@@ -107,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** |
|
||||
@@ -121,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
|
||||
@@ -137,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
|
||||
@@ -190,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
|
||||
|
||||
@@ -246,16 +258,18 @@ 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
|
||||
|
||||
NAT gateways are stored in `spinifex-vpc-nat-gateways` KV bucket. Deleted gateways are moved to `spinifex-vpc-deleted-nat-gateways` with a 1-hour TTL for Terraform polling. SNAT rule coordination uses `vpc.add-nat-gateway` and `vpc.delete-nat-gateway` NATS events (published via route table service on CreateRoute and during DeleteNatGateway respectively).
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-nat-gateway` | — | `--subnet-id`, `--allocation-id`, `--connectivity-type`, `--tag-specifications` | Subnet must exist, EIP must be allocated (for public type) | NATS `ec2.CreateNatGateway` → daemon creates NAT gateway in JetStream KV with nat-ID → configure OVS SNAT rules → state=pending→available → return NatGateway | 1. Create public NAT gateway<br>2. Create private NAT gateway (no allocation-id)<br>3. Invalid subnet (error) | **NOT STARTED** |
|
||||
| `delete-nat-gateway` | — | `--nat-gateway-id`, `--dry-run` | NAT gateway must exist | NATS `ec2.DeleteNatGateway` → daemon marks as deleting → remove OVS SNAT rules → delete from KV → return NatGatewayId | 1. Delete existing NAT gateway<br>2. Delete non-existent (error) | **NOT STARTED** |
|
||||
| `describe-nat-gateways` | — | `--nat-gateway-ids`, `--filters`, `--max-results` | None | NATS `ec2.DescribeNatGateways` → daemon lists NAT gateways from KV → return list with state, subnet, addresses | 1. List all NAT gateways<br>2. Filter by ID<br>3. Filter by VPC ID | **NOT STARTED** |
|
||||
| `create-nat-gateway` | `--subnet-id`, `--allocation-id` | `--connectivity-type` (public only), `--tag-specifications`, `--dry-run` | Subnet must exist, EIP must be allocated and unassociated | Gateway validates SubnetId + AllocationId (required) → NATS `ec2.CreateNatGateway` with `spinifex-workers` queue group → daemon verifies subnet exists in `spinifex-vpc-subnets` KV → verifies EIP exists in `spinifex-vpc-eips` KV and has no AssociationId (Resource.AlreadyAssociated if in use) → generates nat-ID → stores NatGatewayRecord in `spinifex-vpc-nat-gateways` KV with state=available → returns NatGateway with addresses | 1. Create NAT gateway (happy path)<br>2. Subnet not found (InvalidSubnetID.NotFound)<br>3. EIP not found (InvalidAllocationID.NotFound)<br>4. EIP already associated (Resource.AlreadyAssociated)<br>5. Missing SubnetId or AllocationId (MissingParameter) | **DONE** |
|
||||
| `delete-nat-gateway` | `--nat-gateway-id` | `--dry-run` | NAT gateway must exist | Gateway validates NatGatewayId (required) → NATS `ec2.DeleteNatGateway` → daemon retrieves record from KV → scans all route tables in VPC for routes targeting this NAT gateway → publishes `vpc.delete-nat-gateway` event per affected subnet (SNAT rule cleanup) → sets state=deleted → stores in `spinifex-vpc-deleted-nat-gateways` with 1-hour TTL → deletes from active KV bucket | 1. Delete existing NAT gateway<br>2. Delete non-existent (InvalidNatGatewayID.NotFound)<br>3. Deleted gateway visible when queried by ID (state=deleted)<br>4. Deleted gateway excluded from general list queries (TTL auto-expire) | **DONE** |
|
||||
| `describe-nat-gateways` | `--nat-gateway-ids`, `--filters` (vpc-id, state) | `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeNatGateways` → daemon scans `spinifex-vpc-nat-gateways` KV → applies NatGatewayIds filter and/or vpc-id/state filters → for specific IDs not found in active bucket, checks deleted bucket → returns InvalidNatGatewayID.NotFound if ID never existed → returns list with state, addresses, subnet, VPC | 1. List all NAT gateways<br>2. Filter by specific IDs<br>3. Filter by state<br>4. Filter by vpc-id<br>5. Non-existent ID (InvalidNatGatewayID.NotFound)<br>6. Deleted gateway visible by ID query | **DONE** |
|
||||
| `assign-private-nat-gateway-address` | — | `--nat-gateway-id`, `--private-ip-addresses` | NAT gateway must exist (private type) | NATS `ec2.AssignPrivateNatGatewayAddress` → daemon assigns private IPs to NAT gateway → return assigned addresses | 1. Assign address<br>2. Missing NAT gateway ID (error) | **NOT STARTED** |
|
||||
| `associate-nat-gateway-address` | — | `--nat-gateway-id`, `--allocation-ids` | NAT gateway and EIPs must exist | NATS `ec2.AssociateNatGatewayAddress` → daemon associates EIPs with NAT gateway → return association info | 1. Associate EIP<br>2. Missing NAT gateway ID (error) | **NOT STARTED** |
|
||||
|
||||
@@ -267,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** |
|
||||
@@ -289,20 +303,23 @@ 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
|
||||
|
||||
Route tables are stored in `spinifex-vpc-route-tables` JetStream KV bucket, keyed by `{accountID}.{routeTableId}`. Each route table has a Routes array (with local route pre-populated) and an Associations array. All operations validate VPC consistency. When a route targeting a NAT gateway is created, `vpc.add-nat-gateway` events are published per associated subnet for vpcd SNAT rule creation.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-route-table` | — | `--vpc-id`, `--tag-specifications` | VPC must exist | NATS `ec2.CreateRouteTable` → daemon creates route table in KV with rtb-ID → add default local route for VPC CIDR → return route table | 1. Create route table<br>2. Verify default local route<br>3. Missing VPC ID (error) | **NOT STARTED** |
|
||||
| `delete-route-table` | — | `--route-table-id`, `--dry-run` | Route table must exist and not be main route table | NATS `ec2.DeleteRouteTable` → daemon verifies not main table → delete from KV → return success | 1. Delete non-main route table<br>2. Delete main route table (error)<br>3. Delete non-existent (error) | **NOT STARTED** |
|
||||
| `describe-route-tables` | — | `--route-table-ids`, `--filters`, `--max-results` | None | NATS `ec2.DescribeRouteTables` → daemon lists route tables from KV → return tables with routes | 1. List all route tables<br>2. Filter by VPC | **NOT STARTED** |
|
||||
| `create-route` | — | `--route-table-id`, `--destination-cidr-block`, `--gateway-id` or `--nat-gateway-id` or `--egress-only-internet-gateway-id` | Route table and target must exist | NATS `ec2.CreateRoute` → daemon adds route entry → configure OVS flow for destination CIDR → update metadata | 1. Add internet route via IGW<br>2. Add route to NAT gateway<br>3. Add route to EIGW<br>4. Conflicting route (error) | **NOT STARTED** |
|
||||
| `delete-route` | — | `--route-table-id`, `--destination-cidr-block` | Route must exist | NATS `ec2.DeleteRoute` → daemon removes route entry → remove OVS flow → update metadata | 1. Delete existing route<br>2. Delete non-existent route (error)<br>3. Delete local route (error) | **NOT STARTED** |
|
||||
| `replace-route` | — | `--route-table-id`, `--destination-cidr-block`, `--gateway-id` or `--nat-gateway-id` | Route must exist | NATS `ec2.ReplaceRoute` → daemon replaces route target → update OVS flow → update metadata | 1. Replace IGW route with NAT<br>2. Replace non-existent route (error) | **NOT STARTED** |
|
||||
| `associate-route-table` | — | `--route-table-id`, `--subnet-id` or `--gateway-id` | Route table and target must exist | NATS `ec2.AssociateRouteTable` → daemon links route table to subnet/gateway → return rtbassoc-ID | 1. Associate with subnet<br>2. Missing route table ID (error) | **NOT STARTED** |
|
||||
| `disassociate-route-table` | — | `--association-id` | Association must exist, not main table association | NATS `ec2.DisassociateRouteTable` → daemon unlinks route table → return success | 1. Disassociate existing<br>2. Disassociate main table (error) | **NOT STARTED** |
|
||||
| `create-route-table` | `--vpc-id` | `--tag-specifications`, `--dry-run` | VPC must exist | Gateway validates VpcId (required) → NATS `ec2.CreateRouteTable` with `spinifex-workers` queue group → daemon verifies VPC exists in `spinifex-vpc-vpcs` KV → retrieves VPC CIDR → generates rtb-ID → creates RouteTableRecord with IsMain=false, local route pre-populated (destination=VPC CIDR, gateway="local"), empty associations → stores in `spinifex-vpc-route-tables` KV → returns RouteTable | 1. Create route table with local route (happy path)<br>2. VPC not found (InvalidVpcID.NotFound)<br>3. Missing VpcId (MissingParameter) | **DONE** |
|
||||
| `delete-route-table` | `--route-table-id` | `--dry-run` | Route table must exist, not main, no subnet associations | Gateway validates RouteTableId (required) → NATS `ec2.DeleteRouteTable` → daemon retrieves record → rejects if IsMain=true (DependencyViolation) → rejects if has non-main subnet associations (DependencyViolation) → deletes from KV → returns success | 1. Delete custom route table<br>2. Delete main route table (DependencyViolation)<br>3. Delete table with subnet associations (DependencyViolation)<br>4. Delete non-existent (InvalidRouteTableID.NotFound) | **DONE** |
|
||||
| `describe-route-tables` | `--route-table-ids`, `--filters` (vpc-id, route-table-id, association.main, association.route-table-association-id, association.subnet-id, route.destination-cidr-block, route.gateway-id) | `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeRouteTables` → daemon scans all keys in `spinifex-vpc-route-tables` KV → filters by RouteTableIds if specified → applies all provided filters (AND logic) → returns error for non-existent requested IDs → converts records to EC2 RouteTable objects | 1. List all route tables<br>2. Filter by vpc-id<br>3. Filter by association.main (true/false)<br>4. Non-existent ID (InvalidRouteTableID.NotFound)<br>5. Empty result when no route tables | **DONE** |
|
||||
| `create-route` | `--route-table-id`, `--destination-cidr-block`, `--gateway-id`, `--nat-gateway-id` | `--egress-only-internet-gateway-id`, `--vpc-peering-connection-id`, `--dry-run` | Route table and target must exist in same VPC | Gateway validates RouteTableId + DestinationCidrBlock (required) → NATS `ec2.CreateRoute` → daemon retrieves route table → checks for duplicate destination (RouteAlreadyExists) → validates exactly one of GatewayId/NatGatewayId provided → if GatewayId: verifies IGW exists in `spinifex-igw` KV and is attached to same VPC → if NatGatewayId: verifies NAT GW exists in `spinifex-vpc-nat-gateways` KV and is in same VPC → for NAT GW routes, publishes `vpc.add-nat-gateway` event per associated subnet → appends RouteRecord (state=active, origin=CreateRoute) → updates KV → returns Return=true | 1. Add IGW route (happy path)<br>2. Duplicate destination (RouteAlreadyExists)<br>3. IGW not found (InvalidInternetGatewayID.NotFound)<br>4. NAT GW not found (InvalidNatGatewayID.NotFound)<br>5. Gateway not in same VPC (InvalidParameterValue)<br>6. Missing RouteTableId or DestinationCidrBlock (MissingParameter) | **DONE** |
|
||||
| `delete-route` | `--route-table-id`, `--destination-cidr-block` | `--dry-run` | Route must exist | Gateway validates RouteTableId + DestinationCidrBlock (required) → NATS `ec2.DeleteRoute` → daemon retrieves route table → finds matching route → rejects if gateway is "local" (InvalidParameterValue — local routes cannot be deleted) → removes route from Routes array → updates KV → returns success | 1. Delete IGW route (happy path, local route remains)<br>2. Delete local route (InvalidParameterValue)<br>3. Route not found (InvalidRoute.NotFound)<br>4. Missing parameters (MissingParameter) | **DONE** |
|
||||
| `replace-route` | `--route-table-id`, `--destination-cidr-block`, `--gateway-id` | `--nat-gateway-id`, `--dry-run` | Route must exist, new target must exist in same VPC | Gateway validates RouteTableId + DestinationCidrBlock + GatewayId (required) → NATS `ec2.ReplaceRoute` → daemon retrieves route table → finds matching route → rejects if local route → verifies IGW exists and is attached to same VPC → updates route GatewayId, clears NatGatewayId, sets state=active → updates KV → returns success | 1. Replace route target (IGW swap)<br>2. Replace local route (InvalidParameterValue)<br>3. Route not found (InvalidRoute.NotFound)<br>4. IGW not found (InvalidInternetGatewayID.NotFound) | **DONE** |
|
||||
| `associate-route-table` | `--route-table-id`, `--subnet-id` | `--gateway-id`, `--dry-run` | Route table and subnet must exist in same VPC | Gateway validates RouteTableId + SubnetId (required) → NATS `ec2.AssociateRouteTable` → daemon retrieves route table → verifies subnet exists and is in same VPC → scans ALL route tables in VPC to check subnet isn't already explicitly associated (Resource.AlreadyAssociated) → generates rtbassoc-ID → appends AssociationRecord (Main=false) → updates KV → returns AssociationId with state=associated | 1. Associate subnet (happy path)<br>2. Duplicate subnet association (Resource.AlreadyAssociated)<br>3. Subnet not found (InvalidSubnetID.NotFound)<br>4. Subnet in different VPC (InvalidParameterValue)<br>5. Missing parameters (MissingParameter) | **DONE** |
|
||||
| `disassociate-route-table` | `--association-id` | `--dry-run` | Association must exist, not main table association | Gateway validates AssociationId (required) → NATS `ec2.DisassociateRouteTable` → daemon scans ALL route tables in account to find matching AssociationId → rejects if Main=true (InvalidParameterValue — main associations are immutable) → removes association from Associations array → updates KV → returns success | 1. Disassociate subnet (happy path)<br>2. Disassociate main table (InvalidParameterValue)<br>3. Association not found (InvalidAssociationID.NotFound) | **DONE** |
|
||||
| `replace-route-table-association` | `--association-id`, `--route-table-id` | `--dry-run` | Association and new route table must exist | Gateway validates AssociationId + RouteTableId (required) → NATS `ec2.ReplaceRouteTableAssociation` → daemon verifies new route table exists → scans all route tables to find matching AssociationId → if Main=true: atomically swaps IsMain flag between old and new table → two-phase commit: removes association from old table, creates new AssociationId on new table → compensating write on failure (logs CRITICAL if compensation fails) → returns new AssociationId with state=associated | 1. Move subnet between tables (happy path)<br>2. Replace main table association (swaps IsMain flags)<br>3. Association not found (InvalidAssociationID.NotFound)<br>4. Route table not found (InvalidRouteTableID.NotFound) | **DONE** |
|
||||
|
||||
### EC2 - VPC Peering
|
||||
|
||||
@@ -415,19 +432,6 @@ ENIs are managed via `spinifex-vpc-enis` KV bucket with CAS-based IPAM allocatio
|
||||
| `list-snapshot-blocks` | — | `--snapshot-id`, `--max-results`, `--next-token` | Snapshot must exist | List all non-empty blocks in snapshot with tokens and sizes | 1. List blocks of populated snapshot<br>2. List blocks of empty snapshot | **NOT STARTED** |
|
||||
| `list-changed-blocks` | — | `--second-snapshot-id`, `--first-snapshot-id`, `--max-results` | Snapshots must exist | Compare two snapshots → return list of differing block indexes | 1. Compare parent and child snapshots<br>2. Compare unrelated snapshots | **NOT STARTED** |
|
||||
|
||||
### S3 (via Predastore)
|
||||
|
||||
Currently S3 operations are handled directly by Predastore. Consider moving control/data plane to Spinifex format for AWS gateway integration.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `s3 mb` (CreateBucket) | — | `s3://bucket-name` | None | NATS `s3.createbucket` → Predastore creates bucket → return success | 1. Create valid bucket<br>2. Duplicate bucket name (error)<br>3. Invalid bucket name (error) | **NOT STARTED** (Predastore handles directly) |
|
||||
| `s3 rb` (DeleteBucket) | — | `s3://bucket-name`, `--force` | Bucket must exist | NATS `s3.deletebucket` → verify bucket empty (or force) → Predastore deletes bucket | 1. Delete empty bucket<br>2. Delete non-empty bucket (error)<br>3. Force delete non-empty bucket | **NOT STARTED** |
|
||||
| `s3 ls` (ListBuckets/Objects) | — | `s3://bucket/prefix`, `--recursive` | None | NATS `s3.listobjects` → Predastore lists objects with prefix → return list | 1. List all buckets<br>2. List objects in bucket<br>3. List with prefix filter | **NOT STARTED** |
|
||||
| `s3 cp` (PutObject/GetObject) | — | `source dest`, `--recursive`, `--acl` | Bucket must exist | NATS `s3.putobject`/`s3.getobject` → Predastore stores/retrieves with Reed-Solomon encoding | 1. Upload single file<br>2. Download single file<br>3. Recursive upload/download<br>4. Large file (multipart) | **NOT STARTED** |
|
||||
| `s3 rm` (DeleteObject) | — | `s3://bucket/key`, `--recursive` | Object must exist | NATS `s3.deleteobject` → Predastore deletes object shards | 1. Delete single object<br>2. Recursive delete<br>3. Delete non-existent (idempotent) | **NOT STARTED** |
|
||||
| `s3 sync` | — | `source dest`, `--delete`, `--exclude`, `--include` | Bucket must exist | Compare source and dest → upload changed/new files → optionally delete removed files | 1. Sync local dir to S3<br>2. Sync with --delete<br>3. Sync with exclude filter | **NOT STARTED** |
|
||||
|
||||
### IAM - User Management
|
||||
|
||||
All IAM operations are account-scoped. Users, policies, and access keys are namespaced per account. The gateway extracts the account ID from the authenticated access key via `c.Locals("sigv4.accountId")` and passes it to all IAM service methods. Root user (account `000000000000`) bypasses policy evaluation entirely.
|
||||
@@ -485,3 +489,138 @@ The gateway evaluates IAM policies on every request (except root user bypass). P
|
||||
| Account-scoped evaluation | Policies resolved from caller's account context | **DONE** |
|
||||
| EC2 action mapping | All registered EC2 actions mapped to IAM policy strings | **DONE** |
|
||||
| IAM action mapping | 16 IAM actions mapped to IAM policy strings | **DONE** |
|
||||
|
||||
### IAM - Roles
|
||||
|
||||
IAM roles are required for instance profiles, cross-account access, and service-linked permissions. Without roles, workloads must use static access keys. Roles should be stored in a `spinifex-iam-roles` KV bucket keyed by `{accountID}.{roleName}`. Trust policies (AssumeRolePolicyDocument) define who can assume the role.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-role` | — | `--role-name`, `--assume-role-policy-document`, `--path`, `--description`, `--max-session-duration`, `--permissions-boundary`, `--tags` | None | IAMService.CreateRole(accountID, input) → validate RoleName + AssumeRolePolicyDocument (required) → validate trust policy JSON → generate AROA-prefixed RoleId → store RoleRecord in `spinifex-iam-roles` KV at `{accountID}.{roleName}` → ARN: `arn:aws:iam::{accountID}:role/{path}{roleName}` → return Role | 1. Create with valid trust policy<br>2. Malformed trust policy (MalformedPolicyDocument)<br>3. Duplicate name (EntityAlreadyExists)<br>4. Missing RoleName (InvalidInput)<br>5. With path and description | **NOT STARTED** |
|
||||
| `get-role` | — | `--role-name` | Role must exist | IAMService.GetRole(accountID, input) → reads from KV at `{accountID}.{roleName}` → returns Role with ARN, Path, AssumeRolePolicyDocument, CreateDate | 1. Get existing role<br>2. Non-existent role (NoSuchEntity) | **NOT STARTED** |
|
||||
| `list-roles` | — | `--path-prefix`, `--max-items`, `--marker` | None | IAMService.ListRoles(accountID, input) → prefix-scans KV for `{accountID}.*` → filters by path prefix → returns role list scoped to caller's account | 1. List all roles<br>2. List with path prefix<br>3. Empty list<br>4. Account isolation | **NOT STARTED** |
|
||||
| `delete-role` | — | `--role-name` | Role must exist, no attached policies, no instance profiles | IAMService.DeleteRole(accountID, input) → verify no attached policies (DeleteConflict) → verify not in any instance profile (DeleteConflict) → delete from KV | 1. Delete role with no attachments<br>2. Delete role with policies (DeleteConflict)<br>3. Delete role in instance profile (DeleteConflict)<br>4. Non-existent role (NoSuchEntity) | **NOT STARTED** |
|
||||
| `update-role` | — | `--role-name`, `--description`, `--max-session-duration` | Role must exist | IAMService.UpdateRole(accountID, input) → update description and/or session duration in KV → return success | 1. Update description<br>2. Update max session duration<br>3. Non-existent role (NoSuchEntity) | **NOT STARTED** |
|
||||
| `update-assume-role-policy` | — | `--role-name`, `--policy-document` | Role must exist | IAMService.UpdateAssumeRolePolicy(accountID, input) → validate trust policy JSON → update AssumeRolePolicyDocument in KV → return success | 1. Update trust policy<br>2. Malformed policy (MalformedPolicyDocument)<br>3. Non-existent role (NoSuchEntity) | **NOT STARTED** |
|
||||
| `attach-role-policy` | — | `--role-name`, `--policy-arn` | Role and policy must exist | IAMService.AttachRolePolicy(accountID, input) → verify policy exists → add ARN to role's AttachedPolicies → idempotent | 1. Attach policy<br>2. Already attached (idempotent)<br>3. Non-existent role (NoSuchEntity)<br>4. Non-existent policy (NoSuchEntity) | **NOT STARTED** |
|
||||
| `detach-role-policy` | — | `--role-name`, `--policy-arn` | Policy must be attached | IAMService.DetachRolePolicy(accountID, input) → remove ARN from role's AttachedPolicies | 1. Detach attached policy<br>2. Not attached (NoSuchEntity)<br>3. Non-existent role (NoSuchEntity) | **NOT STARTED** |
|
||||
| `list-attached-role-policies` | — | `--role-name`, `--path-prefix`, `--max-items`, `--marker` | Role must exist | IAMService.ListAttachedRolePolicies(accountID, input) → reads role's AttachedPolicies list → returns ARNs and names | 1. List attached policies<br>2. No policies (empty list)<br>3. Non-existent role (NoSuchEntity) | **NOT STARTED** |
|
||||
|
||||
### IAM - Instance Profiles
|
||||
|
||||
Instance profiles are containers for IAM roles that allow EC2 instances to assume a role and obtain temporary credentials. Required for `run-instances --iam-instance-profile`. Stored in `spinifex-iam-instance-profiles` KV bucket keyed by `{accountID}.{profileName}`.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-instance-profile` | — | `--instance-profile-name`, `--path`, `--tags` | None | IAMService.CreateInstanceProfile(accountID, input) → validate name (required) → generate AIPA-prefixed InstanceProfileId → store in `spinifex-iam-instance-profiles` KV with empty Roles array → ARN: `arn:aws:iam::{accountID}:instance-profile/{path}{name}` → return InstanceProfile | 1. Create profile<br>2. Duplicate name (EntityAlreadyExists)<br>3. Missing name (InvalidInput) | **NOT STARTED** |
|
||||
| `get-instance-profile` | — | `--instance-profile-name` | Profile must exist | IAMService.GetInstanceProfile(accountID, input) → reads from KV → returns InstanceProfile with Roles array | 1. Get existing profile<br>2. Non-existent (NoSuchEntity) | **NOT STARTED** |
|
||||
| `list-instance-profiles` | — | `--path-prefix`, `--max-items`, `--marker` | None | IAMService.ListInstanceProfiles(accountID, input) → prefix-scans KV → returns list | 1. List all profiles<br>2. Empty list | **NOT STARTED** |
|
||||
| `list-instance-profiles-for-role` | — | `--role-name`, `--max-items`, `--marker` | Role must exist | IAMService.ListInstanceProfilesForRole(accountID, input) → scan all profiles → filter where Roles contains role name → return list | 1. List profiles for role<br>2. Role not in any profile (empty) | **NOT STARTED** |
|
||||
| `delete-instance-profile` | — | `--instance-profile-name` | Profile must exist, Roles must be empty | IAMService.DeleteInstanceProfile(accountID, input) → verify Roles empty (DeleteConflict if not) → delete from KV | 1. Delete empty profile<br>2. Delete profile with role (DeleteConflict)<br>3. Non-existent (NoSuchEntity) | **NOT STARTED** |
|
||||
| `add-role-to-instance-profile` | — | `--instance-profile-name`, `--role-name` | Both must exist, max 1 role per profile | IAMService.AddRoleToInstanceProfile(accountID, input) → verify profile exists → verify role exists → check Roles array is empty (LimitExceeded if role already present) → append role → update KV | 1. Add role to empty profile<br>2. Profile already has role (LimitExceeded)<br>3. Non-existent profile (NoSuchEntity)<br>4. Non-existent role (NoSuchEntity) | **NOT STARTED** |
|
||||
| `remove-role-from-instance-profile` | — | `--instance-profile-name`, `--role-name` | Role must be in profile | IAMService.RemoveRoleFromInstanceProfile(accountID, input) → remove role from Roles array → update KV | 1. Remove role<br>2. Role not in profile (NoSuchEntity) | **NOT STARTED** |
|
||||
|
||||
### IAM - Groups
|
||||
|
||||
Groups provide a way to attach policies to multiple users. Stored in `spinifex-iam-groups` KV bucket keyed by `{accountID}.{groupName}`.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-group` | — | `--group-name`, `--path` | None | IAMService.CreateGroup(accountID, input) → validate GroupName (required) → generate AGPA-prefixed GroupId → store in KV → return Group | 1. Create group<br>2. Duplicate name (EntityAlreadyExists)<br>3. Missing name (InvalidInput) | **NOT STARTED** |
|
||||
| `get-group` | — | `--group-name` | Group must exist | IAMService.GetGroup(accountID, input) → reads group from KV → returns Group with member Users list | 1. Get existing group<br>2. Non-existent (NoSuchEntity) | **NOT STARTED** |
|
||||
| `list-groups` | — | `--path-prefix`, `--max-items`, `--marker` | None | IAMService.ListGroups(accountID, input) → prefix-scans KV → returns list | 1. List all groups<br>2. Empty list | **NOT STARTED** |
|
||||
| `delete-group` | — | `--group-name` | Group must exist, no members, no attached policies | IAMService.DeleteGroup(accountID, input) → verify no members → verify no attached policies → delete from KV | 1. Delete empty group<br>2. Delete group with members (DeleteConflict)<br>3. Delete group with policies (DeleteConflict)<br>4. Non-existent (NoSuchEntity) | **NOT STARTED** |
|
||||
| `add-user-to-group` | — | `--group-name`, `--user-name` | Both must exist | IAMService.AddUserToGroup(accountID, input) → verify group + user exist → add user to group's Members list → idempotent | 1. Add user<br>2. Already a member (idempotent)<br>3. Non-existent group (NoSuchEntity)<br>4. Non-existent user (NoSuchEntity) | **NOT STARTED** |
|
||||
| `remove-user-from-group` | — | `--group-name`, `--user-name` | User must be in group | IAMService.RemoveUserFromGroup(accountID, input) → remove user from Members list → update KV | 1. Remove member<br>2. Not a member (NoSuchEntity) | **NOT STARTED** |
|
||||
| `list-groups-for-user` | — | `--user-name`, `--max-items`, `--marker` | User must exist | IAMService.ListGroupsForUser(accountID, input) → scan all groups → filter where Members contains user → return list | 1. List groups for user<br>2. User in no groups (empty) | **NOT STARTED** |
|
||||
| `attach-group-policy` | — | `--group-name`, `--policy-arn` | Group and policy must exist | IAMService.AttachGroupPolicy(accountID, input) → verify policy exists → add ARN to group's AttachedPolicies → idempotent | 1. Attach policy<br>2. Already attached (idempotent)<br>3. Non-existent group (NoSuchEntity) | **NOT STARTED** |
|
||||
| `detach-group-policy` | — | `--group-name`, `--policy-arn` | Policy must be attached | IAMService.DetachGroupPolicy(accountID, input) → remove ARN from AttachedPolicies | 1. Detach policy<br>2. Not attached (NoSuchEntity) | **NOT STARTED** |
|
||||
| `list-attached-group-policies` | — | `--group-name`, `--path-prefix`, `--max-items`, `--marker` | Group must exist | IAMService.ListAttachedGroupPolicies(accountID, input) → reads group's AttachedPolicies → returns ARNs and names | 1. List attached policies<br>2. No policies (empty) | **NOT STARTED** |
|
||||
|
||||
### STS (Security Token Service)
|
||||
|
||||
STS provides temporary credentials and identity verification. `GetCallerIdentity` is called by every AWS SDK/CLI invocation. `AssumeRole` is required once IAM roles are implemented. STS needs its own gateway service endpoint (`sts` service in the `supportedServices` map) with a dedicated action map.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `get-caller-identity` | — | *(no flags)* | Valid SigV4 credentials | Gateway extracts accountID and username from authenticated request → returns Account, Arn (`arn:aws:iam::{accountID}:user/{userName}` or `arn:aws:sts::{accountID}:assumed-role/{roleName}/{sessionName}`), UserId. Note: a Spinifex-custom version exists at `spinifex.GetCallerIdentity` but needs a proper STS endpoint. | 1. Get identity for IAM user<br>2. Get identity for assumed role<br>3. Get identity for root user<br>4. Invalid credentials (AuthFailure) | **NOT STARTED** |
|
||||
| `assume-role` | — | `--role-arn`, `--role-session-name`, `--duration-seconds`, `--policy`, `--external-id`, `--serial-number`, `--token-code`, `--tags`, `--transitive-tag-keys` | Role must exist, caller must be allowed by trust policy | Gateway validates RoleArn + RoleSessionName (required) → looks up role → evaluates trust policy (AssumeRolePolicyDocument) against caller → generates temporary credentials (AccessKeyId with ASIA prefix, SecretAccessKey, SessionToken) → credentials expire after DurationSeconds (default 3600, max 43200) → return Credentials + AssumedRoleUser (ARN, AssumedRoleId) | 1. Assume role with valid trust policy<br>2. Trust policy denies caller (AccessDenied)<br>3. Non-existent role (NoSuchEntity)<br>4. Expired session token rejected at SigV4<br>5. Custom duration<br>6. Session tags | **NOT STARTED** |
|
||||
| `get-session-token` | — | `--duration-seconds`, `--serial-number`, `--token-code` | Valid IAM user credentials (not role) | Gateway validates caller is IAM user (not assumed role) → generates temporary credentials (ASIA prefix) → session inherits user's permissions → return Credentials | 1. Get token for IAM user<br>2. Duration within range<br>3. Reject if caller is assumed role | **NOT STARTED** |
|
||||
|
||||
### ELBv2 (Elastic Load Balancing v2 — Application Load Balancer)
|
||||
|
||||
All ELBv2 resources are stored in the `spinifex-elbv2` JetStream KV bucket. Key format: `lb.{lbId}`, `tg.{tgId}`, `listener.{listenerId}`. The data plane uses a system-managed ALB VM running HAProxy, launched automatically during CreateLoadBalancer. HAProxy configuration is pushed via NATS (`elbv2.alb.{lbId}.config` topic) whenever listeners or targets change. Agent readiness is polled via `elbv2.alb.{lbId}.ping`.
|
||||
|
||||
#### ELBv2 - Load Balancers
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-load-balancer` | `--name`, `--subnets`, `--security-groups`, `--scheme` (internet-facing/internal), `--tags`, `--ip-address-type` (ipv4 only) | `--type` (hardcoded application), `--customer-owned-ipv4-pool`, `--dry-run` | None (subnets optional) | Gateway validates Name (required) → NATS `elbv2.CreateLoadBalancer` with `spinifex-workers` queue group → daemon validates scheme (internet-facing or internal, InvalidScheme if other) → checks duplicate name in account (DuplicateLoadBalancerName) → generates LB ID → builds DNS name `{name}-{lbId}.{region}.elb.spinifex.local` → if subnets provided, creates one ENI per subnet with `spinifex:managed-by=elbv2` tag (rolls back on failure) → if system AMI available, launches t3.nano ALB VM with cloud-init running `/usr/local/bin/alb-agent` → state=provisioning → background goroutine polls agent ping for ~5min → on agent ready: state=active, on timeout: state=failed → if no VM launcher: state=active immediately → persists to KV → returns LoadBalancer with ARN, DNSName, State | 1. Create with name (happy path)<br>2. Internal scheme<br>3. Invalid scheme (InvalidScheme)<br>4. Duplicate name (DuplicateLoadBalancerName)<br>5. Missing name (MissingParameter)<br>6. With tags<br>7. With subnets (ENI creation) | **DONE** |
|
||||
| `delete-load-balancer` | `--load-balancer-arn` | `--dry-run` | LB must exist | Gateway validates LoadBalancerArn (required) → NATS `elbv2.DeleteLoadBalancer` → daemon verifies LB exists and belongs to account → terminates ALB VM (async background) → deletes all listeners for this LB → deletes system-managed ENIs → deletes LB record from KV → returns success | 1. Delete existing LB<br>2. Not found (LoadBalancerNotFound)<br>3. Cascades listener cleanup<br>4. ENI cleanup | **DONE** |
|
||||
| `describe-load-balancers` | `--load-balancer-arns`, `--names` | `--page-size`, `--marker`, `--dry-run` | None | NATS `elbv2.DescribeLoadBalancers` → daemon lists all LB records from KV → filters by account ID → applies ARN filter and/or Name filter → returns LoadBalancer list with State, DNSName, Scheme, VpcId, AvailabilityZones | 1. List all LBs<br>2. Filter by name<br>3. Account isolation | **DONE** |
|
||||
|
||||
#### ELBv2 - Target Groups
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-target-group` | `--name`, `--protocol` (HTTP), `--port` (default 80), `--vpc-id`, `--target-type` (instance only), `--health-check-protocol`, `--health-check-port`, `--health-check-path` (default /), `--health-check-interval-seconds` (default 30), `--health-check-timeout-seconds` (default 5), `--healthy-threshold-count` (default 5), `--unhealthy-threshold-count` (default 2), `--matcher` (HttpCode, default 200), `--tags` | `--health-check-enabled`, `--protocol-version`, `--ip-address-type`, `--dry-run` | None | Gateway validates Name (required) → NATS `elbv2.CreateTargetGroup` → daemon checks duplicate name in same VPC (DuplicateTargetGroupName) → generates TG ID → creates HealthCheckConfig with provided values or defaults → stores record with empty Targets array → returns TargetGroup with ARN | 1. Create with defaults<br>2. Custom health check<br>3. Duplicate name (DuplicateTargetGroupName)<br>4. Missing name (MissingParameter) | **DONE** |
|
||||
| `delete-target-group` | `--target-group-arn` | `--dry-run` | TG must exist, not referenced by listener | Gateway validates TargetGroupArn (required) → NATS `elbv2.DeleteTargetGroup` → daemon verifies TG exists → scans all listeners to check if any DefaultActions reference this TG (TargetGroupInUse if referenced) → deletes from KV | 1. Delete TG<br>2. TG in use by listener (TargetGroupInUse)<br>3. Not found (TargetGroupNotFound) | **DONE** |
|
||||
| `describe-target-groups` | `--target-group-arns`, `--names`, `--load-balancer-arn` | `--page-size`, `--marker`, `--dry-run` | None | NATS `elbv2.DescribeTargetGroups` → daemon lists all TG records → filters by account → applies ARN/Name filters → if LoadBalancerArn specified, lists listeners for that LB and includes only TGs referenced by those listeners → returns TargetGroup list | 1. List all TGs<br>2. Filter by LB ARN (via listener lookup)<br>3. Account isolation | **DONE** |
|
||||
|
||||
#### ELBv2 - Targets
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `register-targets` | `--target-group-arn`, `--targets` (Id=instance-id, Port=optional override) | `--dry-run` | TG must exist, instances should exist | Gateway validates TargetGroupArn + Targets (required) → NATS `elbv2.RegisterTargets` → daemon fetches TG → deduplicates against existing targets (key: `{id}:{port}`, idempotent) → resolves instance ID → private IP via VPC ENI lookup → adds target with state=initial → persists to KV → calls `pushConfigForTargetGroup()` to regenerate HAProxy config on all LBs using this TG → returns success | 1. Register targets<br>2. Idempotent re-registration<br>3. TG not found (TargetGroupNotFound)<br>4. Missing ARN or targets (MissingParameter) | **DONE** |
|
||||
| `deregister-targets` | `--target-group-arn`, `--targets` (Id, Port) | `--dry-run` | TG must exist | Gateway validates TargetGroupArn + Targets (required) → NATS `elbv2.DeregisterTargets` → daemon fetches TG → builds removal set (key: `{id}:{port}`) → filters Targets array to keep only non-matching → persists to KV → calls `pushConfigForTargetGroup()` to update HAProxy config → returns success | 1. Deregister targets<br>2. TG not found (TargetGroupNotFound)<br>3. Missing parameters (MissingParameter) | **DONE** |
|
||||
| `describe-target-health` | `--target-group-arn`, `--targets` (optional filter to specific targets) | `--include` | TG must exist | Gateway validates TargetGroupArn (required) → NATS `elbv2.DescribeTargetHealth` → daemon fetches TG → builds filter set from Targets param (if specified) → for each target, returns TargetHealthDescription with Target (Id, Port) and TargetHealth (State, Description). Note: health state is stored in records, not actively polled — HAProxy runs its own independent health checks. | 1. Describe all targets<br>2. Filter to specific targets<br>3. TG not found (TargetGroupNotFound) | **DONE** |
|
||||
|
||||
#### ELBv2 - Listeners
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-listener` | `--load-balancer-arn`, `--default-actions` (Type=forward, TargetGroupArn), `--protocol` (HTTP, default), `--port` (default 80) | `--ssl-policy`, `--certificates`, `--alpn-policy`, `--mutual-authentication`, `--dry-run` | LB must exist | Gateway validates LoadBalancerArn + DefaultActions (required) → NATS `elbv2.CreateListener` → daemon verifies LB exists → checks for duplicate port on same LB (DuplicateListener) → generates listener ID → builds ARN → stores ListenerRecord in KV → calls `pushConfig(lb)` to regenerate HAProxy config (creates frontend for listener port, backend for target group with health checks) → returns Listener with ARN, Protocol, Port, DefaultActions | 1. Create listener<br>2. Duplicate port (DuplicateListener)<br>3. LB not found (LoadBalancerNotFound)<br>4. Graceful handling when NATS unavailable | **DONE** |
|
||||
| `delete-listener` | `--listener-arn` | `--dry-run` | Listener must exist | Gateway validates ListenerArn (required) → NATS `elbv2.DeleteListener` → daemon verifies listener exists and belongs to account → deletes from KV → fetches parent LB → calls `pushConfig(lb)` to regenerate HAProxy config without this listener → returns success | 1. Delete listener<br>2. Not found (ListenerNotFound) | **DONE** |
|
||||
| `describe-listeners` | `--load-balancer-arn`, `--listener-arns` | `--page-size`, `--marker`, `--dry-run` | None | NATS `elbv2.DescribeListeners` → daemon lists listeners → if LoadBalancerArn specified, filters to that LB → applies ARN filter → account isolation → returns Listener list | 1. Filter by LB ARN<br>2. Account isolation | **DONE** |
|
||||
|
||||
#### ELBv2 - Not Implemented
|
||||
|
||||
| Feature | Description | Priority | Status |
|
||||
|---------|-------------|----------|--------|
|
||||
| Listener rules | Path-based and hostname-based routing (CreateRule, DeleteRule, DescribeRules, ModifyRule) | High | **NOT STARTED** |
|
||||
| HTTPS/TLS termination | SSL certificates, SSL policies, ALPN | High | **NOT STARTED** |
|
||||
| Modify operations | ModifyLoadBalancerAttributes, ModifyTargetGroup, ModifyTargetGroupAttributes, ModifyListener | Medium | **NOT STARTED** |
|
||||
| Connection draining | Deregistration delay for graceful target removal | Medium | **NOT STARTED** |
|
||||
| Stickiness | Session affinity / sticky sessions on target groups | Medium | **NOT STARTED** |
|
||||
| Active health checking | API-driven health state updates (currently HAProxy-only) | Medium | **NOT STARTED** |
|
||||
| Target types | IP and Lambda target types (only instance supported) | Low | **NOT STARTED** |
|
||||
| Access logs | S3 access log delivery | Low | **NOT STARTED** |
|
||||
| WAF integration | AWS WAF association for web application firewall | Low | **NOT STARTED** |
|
||||
|
||||
### CloudWatch (Basic Monitoring)
|
||||
|
||||
Basic CloudWatch metrics support is required for `monitor-instances`/`unmonitor-instances` to be meaningful. Daemon nodes should publish CPU, disk, and network metrics for running instances. Metrics stored in a time-series-friendly structure in JetStream KV or dedicated NATS subjects.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `put-metric-data` | — | `--namespace`, `--metric-data` (MetricName, Value, Unit, Timestamp, Dimensions) | None | Gateway validates Namespace + MetricData (required) → NATS `cloudwatch.PutMetricData` → daemon stores metric data points in time-series KV (key: `{namespace}.{metricName}.{dimensionHash}`) → return success | 1. Put single metric<br>2. Put with dimensions (InstanceId)<br>3. Missing namespace (MissingParameter) | **NOT STARTED** |
|
||||
| `get-metric-statistics` | — | `--namespace`, `--metric-name`, `--start-time`, `--end-time`, `--period`, `--statistics` (Average, Sum, Min, Max, SampleCount), `--dimensions` | None | Gateway validates required fields → NATS `cloudwatch.GetMetricStatistics` → daemon queries time-series data → aggregates by period → returns Datapoints with requested statistics | 1. Get CPU utilization over 1 hour<br>2. Get with dimensions (InstanceId)<br>3. No data returns empty | **NOT STARTED** |
|
||||
| `list-metrics` | — | `--namespace`, `--metric-name`, `--dimensions`, `--recently-active` | None | NATS `cloudwatch.ListMetrics` → daemon scans metric namespaces → returns Metrics list with Namespace, MetricName, Dimensions | 1. List all metrics<br>2. Filter by namespace<br>3. Filter by metric name | **NOT STARTED** |
|
||||
| `describe-alarms` | — | `--alarm-names`, `--alarm-name-prefix`, `--state-value`, `--action-prefix` | None | NATS `cloudwatch.DescribeAlarms` → daemon lists alarms from KV → return MetricAlarms with State, Threshold, ComparisonOperator | 1. List all alarms<br>2. Filter by state (OK, ALARM, INSUFFICIENT_DATA) | **NOT STARTED** |
|
||||
| `put-metric-alarm` | — | `--alarm-name`, `--namespace`, `--metric-name`, `--statistic`, `--period`, `--evaluation-periods`, `--threshold`, `--comparison-operator`, `--alarm-actions`, `--dimensions` | None | NATS `cloudwatch.PutMetricAlarm` → daemon stores alarm config in KV → alarm evaluation runs on metric publish → state transitions trigger alarm actions | 1. Create alarm<br>2. Update existing alarm<br>3. Missing required fields | **NOT STARTED** |
|
||||
| `delete-alarms` | — | `--alarm-names` | Alarms must exist | NATS `cloudwatch.DeleteAlarms` → daemon deletes alarm records from KV → return success | 1. Delete existing alarm<br>2. Non-existent alarm (ResourceNotFound) | **NOT STARTED** |
|
||||
|
||||
### Auto Scaling
|
||||
|
||||
Auto Scaling manages instance fleets based on scaling policies. Depends on Launch Templates (EC2) and optionally ELBv2 target groups for health-based scaling. Stored in `spinifex-autoscaling` KV bucket.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-auto-scaling-group` | — | `--auto-scaling-group-name`, `--launch-template` (Id/Name, Version), `--min-size`, `--max-size`, `--desired-capacity`, `--vpc-zone-identifier` (subnet IDs), `--target-group-arns`, `--health-check-type` (EC2/ELB), `--health-check-grace-period`, `--tags` | Launch template must exist | NATS `autoscaling.CreateAutoScalingGroup` → daemon validates launch template → stores ASG record in KV → launches DesiredCapacity instances using template → registers with target groups if specified → starts health check loop → return success | 1. Create ASG with launch template<br>2. Scale to desired capacity<br>3. Missing launch template (ValidationError)<br>4. Min > Max (ValidationError) | **NOT STARTED** |
|
||||
| `update-auto-scaling-group` | — | `--auto-scaling-group-name`, `--min-size`, `--max-size`, `--desired-capacity`, `--launch-template`, `--health-check-type`, `--health-check-grace-period` | ASG must exist | NATS `autoscaling.UpdateAutoScalingGroup` → daemon updates ASG config in KV → if DesiredCapacity changed, scale up/down accordingly → return success | 1. Update desired capacity<br>2. Update launch template<br>3. Non-existent ASG (ValidationError) | **NOT STARTED** |
|
||||
| `delete-auto-scaling-group` | — | `--auto-scaling-group-name`, `--force-delete` | ASG must exist | NATS `autoscaling.DeleteAutoScalingGroup` → if force: terminate all instances → deregister from target groups → delete from KV → return success. Without force: reject if instances > 0 | 1. Delete empty ASG<br>2. Force delete with instances<br>3. Delete with instances (ResourceInUse) | **NOT STARTED** |
|
||||
| `describe-auto-scaling-groups` | — | `--auto-scaling-group-names`, `--filters`, `--max-records` | None | NATS `autoscaling.DescribeAutoScalingGroups` → daemon lists ASG records → return list with Instances, DesiredCapacity, MinSize, MaxSize | 1. List all ASGs<br>2. Filter by name | **NOT STARTED** |
|
||||
| `set-desired-capacity` | — | `--auto-scaling-group-name`, `--desired-capacity`, `--honor-cooldown` | ASG must exist | NATS `autoscaling.SetDesiredCapacity` → daemon updates desired count → scale up (launch) or scale down (terminate oldest) → return success | 1. Scale up<br>2. Scale down<br>3. Exceeds max (ValidationError) | **NOT STARTED** |
|
||||
| `describe-auto-scaling-instances` | — | `--instance-ids`, `--max-records` | None | NATS `autoscaling.DescribeAutoScalingInstances` → daemon lists all ASG-managed instances → return list with ASG name, lifecycle state, health status | 1. List all ASG instances<br>2. Filter by instance ID | **NOT STARTED** |
|
||||
| `put-scaling-policy` | — | `--auto-scaling-group-name`, `--policy-name`, `--policy-type` (TargetTrackingScaling, StepScaling, SimpleScaling), `--target-tracking-configuration`, `--scaling-adjustment`, `--cooldown` | ASG must exist | NATS `autoscaling.PutScalingPolicy` → daemon stores policy in KV → links to CloudWatch alarms for metric-based triggers → return PolicyARN | 1. Create target tracking policy<br>2. Create step scaling policy<br>3. Non-existent ASG (ValidationError) | **NOT STARTED** |
|
||||
| `delete-scaling-policy` | — | `--auto-scaling-group-name`, `--policy-name` | Policy must exist | NATS `autoscaling.DeleteScalingPolicy` → daemon deletes policy → remove CloudWatch alarm links → return success | 1. Delete existing policy<br>2. Non-existent policy | **NOT STARTED** |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ set -euo pipefail
|
||||
# Usage: scripts/check-coverage.sh <coverprofile> [quiet]
|
||||
|
||||
PROFILE="${1:?Usage: check-coverage.sh <coverprofile> [quiet]}"
|
||||
MIN=60.0
|
||||
MIN=65.0
|
||||
QUIET="${2:-}"
|
||||
|
||||
if [[ ! -s "$PROFILE" ]]; then
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
# Spinifex Development Dependencies Setup Script
|
||||
# This script clones Viperblock and Predastore repositories for cross-repo development
|
||||
|
||||
# TODO: Improve, remove CLAUDE readme reference, simplify next-steps
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Run Go tests with coverage and report total code coverage.
|
||||
|
||||
COVERPROFILE=$(mktemp /tmp/spinifex-coverage-XXXXXX.out)
|
||||
trap 'rm -f "$COVERPROFILE"' EXIT
|
||||
|
||||
echo "Running tests with coverage..."
|
||||
echo ""
|
||||
LOG_IGNORE=1 go test -timeout 120s -coverprofile="$COVERPROFILE" -covermode=atomic ./spinifex/... 2>&1
|
||||
|
||||
if [[ ! -s "$COVERPROFILE" ]]; then
|
||||
echo "No coverage data collected."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Total ==="
|
||||
go tool cover -func="$COVERPROFILE" | awk '/^total:/ { print $NF }'
|
||||
@@ -7,13 +7,12 @@ set -euo pipefail
|
||||
# Usage: scripts/diff-coverage.sh <coverprofile> [--base <ref>] [--threshold <pct>]
|
||||
#
|
||||
# Base ref auto-detection:
|
||||
# dev branch → origin/main
|
||||
# main branch → HEAD~1
|
||||
# other branch → origin/dev
|
||||
# other branch → origin/main
|
||||
|
||||
PROFILE=""
|
||||
BASE_REF=""
|
||||
THRESHOLD=60
|
||||
THRESHOLD=65
|
||||
QUIET="${QUIET:-}"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
@@ -45,8 +44,7 @@ if [[ -z "$BASE_REF" ]]; then
|
||||
BRANCH="${GITHUB_REF_NAME:-$(git rev-parse --abbrev-ref HEAD)}"
|
||||
case "$BRANCH" in
|
||||
main) BASE_REF="HEAD~1" ;;
|
||||
dev) BASE_REF="origin/main" ;;
|
||||
*) BASE_REF="origin/dev" ;;
|
||||
*) BASE_REF="origin/main" ;;
|
||||
esac
|
||||
[[ -z "$QUIET" ]] && echo "Base ref: $BASE_REF (branch: $BRANCH)"
|
||||
fi
|
||||
|
||||
@@ -110,7 +110,7 @@ INIT_PID=$!
|
||||
# Wait for formation server to be ready
|
||||
echo " Waiting for formation server on $NODE1_IP..."
|
||||
attempts=0
|
||||
while ! curl -s --connect-timeout 3 "http://$NODE1_IP:4432/formation/health" >/dev/null 2>&1; do
|
||||
while ! curl -sk --connect-timeout 3 "https://$NODE1_IP:4432/formation/health" >/dev/null 2>&1; do
|
||||
attempts=$((attempts + 1))
|
||||
if [ $attempts -ge 60 ]; then
|
||||
echo "Error: Formation server did not start on $NODE1_IP after 5 minutes"
|
||||
|
||||
+12
-1
@@ -196,7 +196,8 @@ download_spinifex() {
|
||||
fi
|
||||
info "Checksum verified"
|
||||
else
|
||||
warn "Checksum not available, skipping verification"
|
||||
rm -rf "$SPINIFEX_TMPDIR"
|
||||
fatal "Checksum not available at $CHECKSUM_URL. Cannot verify download integrity."
|
||||
fi
|
||||
|
||||
# Extract
|
||||
@@ -233,6 +234,7 @@ install_files() {
|
||||
$SUDO install -m 0755 "$EXTRACT_DIR/setup-ovn.sh" /usr/local/share/spinifex/setup-ovn.sh
|
||||
info " /usr/local/share/spinifex/setup-ovn.sh"
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
# --- Create directories ---
|
||||
@@ -272,6 +274,15 @@ SPINIFEX_VIPERBLOCK_PLUGIN_PATH=${PLUGINDIR}/nbdkit-viperblock-plugin.so
|
||||
EOF
|
||||
$SUDO chown "$SPINIFEX_USER:$SPINIFEX_GROUP" /etc/spinifex/systemd.env
|
||||
info "Generated /etc/spinifex/systemd.env"
|
||||
|
||||
# Service helper scripts
|
||||
if [ -d "$EXTRACT_DIR/scripts" ]; then
|
||||
for script in "$EXTRACT_DIR"/scripts/*.sh; do
|
||||
$SUDO install -o "$SPINIFEX_USER" -g "$SPINIFEX_GROUP" -m 0755 \
|
||||
"$script" "/var/lib/spinifex/$(basename "$script")"
|
||||
info " /var/lib/spinifex/$(basename "$script")"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Install systemd units ---
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
SPINIFEX_CONFIG_PATH=~/node1/config/nats/nats.conf ./bin/spx service nats start
|
||||
@@ -343,6 +343,7 @@ var (
|
||||
ErrorPendingVpcPeeringConnectionLimitExceeded = "PendingVpcPeeringConnectionLimitExceeded"
|
||||
ErrorPlacementGroupLimitExceeded = "PlacementGroupLimitExceeded"
|
||||
ErrorPrivateIpAddressLimitExceeded = "PrivateIpAddressLimitExceeded"
|
||||
ErrorRequestEntityTooLarge = "RequestEntityTooLarge"
|
||||
ErrorRequestExpired = "RequestExpired"
|
||||
ErrorRequestLimitExceeded = "RequestLimitExceeded"
|
||||
ErrorRequestResourceCountExceeded = "RequestResourceCountExceeded"
|
||||
@@ -800,6 +801,7 @@ var ErrorLookup = map[string]ErrorMessage{
|
||||
ErrorPendingVpcPeeringConnectionLimitExceeded: {HTTPCode: 409, Message: "You've reached the limit on the number of pending VPC peering connections that you can have."},
|
||||
ErrorPlacementGroupLimitExceeded: {HTTPCode: 400, Message: "You've reached the limit on the number of placement groups that you can have."},
|
||||
ErrorPrivateIpAddressLimitExceeded: {HTTPCode: 400, Message: "You've reached the limit on the number of private IP addresses that you can assign to the specified network interface for that type of instance. For more information, see IP addresses per network interface."},
|
||||
ErrorRequestEntityTooLarge: {HTTPCode: 413, Message: "Request body exceeds the maximum allowed size."},
|
||||
ErrorRequestExpired: {HTTPCode: 403, Message: "The request reached the service more than 15 minutes after the date stamp on the request or more than 15 minutes after the request expiration date (such as for presigned URLs), or the date stamp on the request is more than 15 minutes in the future. If you're using temporary security credentials, this error can also occur if the credentials have expired. For more information, see Temporary security credentials in the IAM User Guide."},
|
||||
ErrorRequestLimitExceeded: {HTTPCode: 500, Message: "The maximum request rate permitted by the Amazon EC2 APIs has been exceeded for your account. For best results, use an increasing or variable sleep interval between requests. For more information, see Query API request rate."},
|
||||
ErrorRequestResourceCountExceeded: {HTTPCode: 409, Message: "Details in your Spot request exceed the numbers allowed by the Spot service in one of the following ways, depending on the action that generated the error: \u2014If you get this error when you submitted a request for Spot Instances, check the number of Spot Instances specified in your request. The number shouldn't exceed the 3,000 maximum allowed per request. Resend your Spot Instance request and specify a number less than 3,000. If your account's regional Spot request limit is greater than 3,000 instances, you can access these instances by submitting multiple smaller requests. \u2014If you get this error when you sent Describe Spot Instance requests, check the number of requests for Spot Instance data, the amount of data you requested, and how often you sent the request. The frequency with which you requested the data combined with the amount of data exceeds the levels allowed by the Spot service. Try again and submit fewer large Describe requests over longer intervals."},
|
||||
|
||||
@@ -345,6 +345,7 @@ func TestErrorLookup(t *testing.T) {
|
||||
{code: "PendingVpcPeeringConnectionLimitExceeded", http: 409, message: "You've reached the limit on the number of pending VPC peering connections that you can have."},
|
||||
{code: "PlacementGroupLimitExceeded", http: 400, message: "You've reached the limit on the number of placement groups that you can have."},
|
||||
{code: "PrivateIpAddressLimitExceeded", http: 400, message: "You've reached the limit on the number of private IP addresses that you can assign to the specified network interface for that type of instance. For more information, see IP addresses per network interface."},
|
||||
{code: "RequestEntityTooLarge", http: 413, message: "Request body exceeds the maximum allowed size."},
|
||||
{code: "RequestExpired", http: 403, message: "The request reached the service more than 15 minutes after the date stamp on the request or more than 15 minutes after the request expiration date (such as for presigned URLs), or the date stamp on the request is more than 15 minutes in the future. If you're using temporary security credentials, this error can also occur if the credentials have expired. For more information, see Temporary security credentials in the IAM User Guide."},
|
||||
{code: "RequestLimitExceeded", http: 500, message: "The maximum request rate permitted by the Amazon EC2 APIs has been exceeded for your account. For best results, use an increasing or variable sleep interval between requests. For more information, see Query API request rate."},
|
||||
{code: "RequestResourceCountExceeded", http: 409, message: "Details in your Spot request exceed the numbers allowed by the Spot service in one of the following ways, depending on the action that generated the error: \u2014If you get this error when you submitted a request for Spot Instances, check the number of Spot Instances specified in your request. The number shouldn't exceed the 3,000 maximum allowed per request. Resend your Spot Instance request and specify a number less than 3,000. If your account's regional Spot request limit is greater than 3,000 instances, you can access these instances by submitting multiple smaller requests. \u2014If you get this error when you sent Describe Spot Instance requests, check the number of requests for Spot Instance data, the amount of data you requested, and how often you sent the request. The frequency with which you requested the data combined with the amount of data exceeds the levels allowed by the Spot service. Try again and submit fewer large Describe requests over longer intervals."},
|
||||
|
||||
+86
-65
@@ -2215,74 +2215,18 @@ func (d *Daemon) StartInstance(instance *vm.VM) error {
|
||||
consoleLogPath := filepath.Join(runtimeDir, fmt.Sprintf("console-%s.log", instance.ID))
|
||||
serialSocket := filepath.Join(runtimeDir, fmt.Sprintf("serial-%s.sock", instance.ID))
|
||||
|
||||
instance.Config = vm.Config{
|
||||
Name: instance.ID,
|
||||
PIDFile: pidFile,
|
||||
EnableKVM: true, // If available, if kvm fails, will use cpu max
|
||||
NoGraphic: true,
|
||||
MachineType: "q35",
|
||||
ConsoleLogPath: consoleLogPath,
|
||||
SerialSocket: serialSocket,
|
||||
CPUType: "host", // If available, if kvm fails, will use cpu max
|
||||
Memory: int(memoryMiB),
|
||||
CPUCount: vCPUs,
|
||||
Architecture: architecture,
|
||||
}
|
||||
instance.Config = buildBaseVMConfig(instance.ID, pidFile, consoleLogPath, serialSocket, architecture, vCPUs, int(memoryMiB))
|
||||
|
||||
// Add PCIe root ports for volume hotplug (Q35 requires explicit root ports).
|
||||
// 11 ports for /dev/sd[f-p] hotplug slots, starting at chassis 1.
|
||||
for i := 1; i <= 11; i++ {
|
||||
instance.Config.Devices = append(instance.Config.Devices, vm.Device{
|
||||
Value: fmt.Sprintf("pcie-root-port,id=hotplug%d,chassis=%d,slot=0", i, i),
|
||||
})
|
||||
}
|
||||
|
||||
// Loop through each volume in volumes
|
||||
// Build QEMU drives from EBS volume requests.
|
||||
instance.EBSRequests.Mu.Lock()
|
||||
|
||||
for _, v := range instance.EBSRequests.Requests {
|
||||
drive := vm.Drive{}
|
||||
|
||||
// Use the NBDURI from mount response - contains socket path or TCP address
|
||||
// NBDURI format: "nbd:unix:/path/to/socket.sock" or "nbd://host:port"
|
||||
if v.NBDURI == "" {
|
||||
slog.Error("NBDURI not set for volume", "volume", v.Name)
|
||||
return fmt.Errorf("NBDURI not set for volume %s - was volume mounted?", v.Name)
|
||||
}
|
||||
drive.File = v.NBDURI
|
||||
slog.Info("Using NBD URI for drive", "volume", v.Name, "uri", v.NBDURI)
|
||||
|
||||
if v.Boot {
|
||||
drive.Format = "raw"
|
||||
drive.If = "none"
|
||||
drive.Media = "disk"
|
||||
drive.ID = "os"
|
||||
drive.Cache = "none"
|
||||
|
||||
iothreadID := "ioth-os"
|
||||
instance.Config.IOThreads = append(instance.Config.IOThreads, vm.IOThread{ID: iothreadID})
|
||||
|
||||
instance.Config.Devices = append(instance.Config.Devices, vm.Device{
|
||||
Value: fmt.Sprintf("virtio-blk-pci,drive=%s,iothread=%s,num-queues=%d,bootindex=1",
|
||||
drive.ID, iothreadID, instance.Config.CPUCount),
|
||||
})
|
||||
}
|
||||
|
||||
if v.CloudInit {
|
||||
drive.Format = "raw"
|
||||
drive.If = "virtio"
|
||||
drive.Media = "cdrom"
|
||||
drive.ID = "cloudinit"
|
||||
}
|
||||
|
||||
// TODO: Add EFI support
|
||||
if v.EFI {
|
||||
continue
|
||||
}
|
||||
|
||||
instance.Config.Drives = append(instance.Config.Drives, drive)
|
||||
}
|
||||
drives, iothreads, devices, err := buildDrives(instance.EBSRequests.Requests, vCPUs)
|
||||
instance.EBSRequests.Mu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
instance.Config.Drives = append(instance.Config.Drives, drives...)
|
||||
instance.Config.IOThreads = append(instance.Config.IOThreads, iothreads...)
|
||||
instance.Config.Devices = append(instance.Config.Devices, devices...)
|
||||
|
||||
// VPC tap networking vs user-mode fallback
|
||||
if instance.ENIId != "" && d.networkPlumber != nil {
|
||||
@@ -2523,6 +2467,83 @@ func (d *Daemon) StartInstance(instance *vm.VM) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildBaseVMConfig creates a vm.Config with base QEMU settings and PCIe
|
||||
// hotplug root ports. Architecture, vCPU, and memory come from the caller
|
||||
// (resolved from instance type info).
|
||||
func buildBaseVMConfig(instanceID, pidFile, consoleLogPath, serialSocket, architecture string, vCPUs, memoryMiB int) vm.Config {
|
||||
cfg := vm.Config{
|
||||
Name: instanceID,
|
||||
PIDFile: pidFile,
|
||||
EnableKVM: true,
|
||||
NoGraphic: true,
|
||||
MachineType: "q35",
|
||||
ConsoleLogPath: consoleLogPath,
|
||||
SerialSocket: serialSocket,
|
||||
CPUType: "host",
|
||||
Memory: memoryMiB,
|
||||
CPUCount: vCPUs,
|
||||
Architecture: architecture,
|
||||
}
|
||||
|
||||
// Add PCIe root ports for volume hotplug (Q35 requires explicit root ports).
|
||||
// 11 ports for /dev/sd[f-p] hotplug slots, starting at chassis 1.
|
||||
for i := 1; i <= 11; i++ {
|
||||
cfg.Devices = append(cfg.Devices, vm.Device{
|
||||
Value: fmt.Sprintf("pcie-root-port,id=hotplug%d,chassis=%d,slot=0", i, i),
|
||||
})
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// buildDrives converts EBS volume requests into QEMU drive, iothread, and device
|
||||
// configurations. Returns an error if any non-EFI volume is missing its NBDURI.
|
||||
func buildDrives(requests []types.EBSRequest, cpuCount int) ([]vm.Drive, []vm.IOThread, []vm.Device, error) {
|
||||
var drives []vm.Drive
|
||||
var iothreads []vm.IOThread
|
||||
var devices []vm.Device
|
||||
|
||||
for _, v := range requests {
|
||||
// TODO: Add EFI support
|
||||
if v.EFI {
|
||||
continue
|
||||
}
|
||||
|
||||
if v.NBDURI == "" {
|
||||
return nil, nil, nil, fmt.Errorf("NBDURI not set for volume %s - was volume mounted?", v.Name)
|
||||
}
|
||||
|
||||
drive := vm.Drive{File: v.NBDURI}
|
||||
|
||||
if v.Boot {
|
||||
drive.Format = "raw"
|
||||
drive.If = "none"
|
||||
drive.Media = "disk"
|
||||
drive.ID = "os"
|
||||
drive.Cache = "none"
|
||||
|
||||
iothreadID := "ioth-os"
|
||||
iothreads = append(iothreads, vm.IOThread{ID: iothreadID})
|
||||
devices = append(devices, vm.Device{
|
||||
Value: fmt.Sprintf("virtio-blk-pci,drive=%s,iothread=%s,num-queues=%d,bootindex=1",
|
||||
drive.ID, iothreadID, cpuCount),
|
||||
})
|
||||
}
|
||||
|
||||
if v.CloudInit {
|
||||
drive.Format = "raw"
|
||||
drive.If = "virtio"
|
||||
drive.Media = "cdrom"
|
||||
drive.ID = "cloudinit"
|
||||
}
|
||||
|
||||
slog.Info("Using NBD URI for drive", "volume", v.Name, "uri", v.NBDURI)
|
||||
drives = append(drives, drive)
|
||||
}
|
||||
|
||||
return drives, iothreads, devices, nil
|
||||
}
|
||||
|
||||
// ebsTopic returns a node-specific EBS NATS topic, e.g. "ebs.node1.mount".
|
||||
// This ensures mount/unmount requests are routed to the viperblock instance
|
||||
// running on the same node as the daemon (NBD sockets are local).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -3838,3 +3838,191 @@ func TestDaemonReadyFlag(t *testing.T) {
|
||||
daemon.ready.Store(true)
|
||||
assert.True(t, daemon.ready.Load(), "daemon should be ready after setting flag")
|
||||
}
|
||||
|
||||
func TestBuildBaseVMConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
instanceID string
|
||||
pidFile string
|
||||
consolePath string
|
||||
serialSocket string
|
||||
architecture string
|
||||
vCPUs int
|
||||
memoryMiB int
|
||||
}{
|
||||
{
|
||||
name: "x86_64 instance",
|
||||
instanceID: "i-abc123",
|
||||
pidFile: "/tmp/qemu-i-abc123.pid",
|
||||
consolePath: "/run/console-i-abc123.log",
|
||||
serialSocket: "/run/serial-i-abc123.sock",
|
||||
architecture: "x86_64",
|
||||
vCPUs: 4,
|
||||
memoryMiB: 8192,
|
||||
},
|
||||
{
|
||||
name: "arm64 instance",
|
||||
instanceID: "i-def456",
|
||||
pidFile: "/tmp/qemu-i-def456.pid",
|
||||
consolePath: "/run/console-i-def456.log",
|
||||
serialSocket: "/run/serial-i-def456.sock",
|
||||
architecture: "arm64",
|
||||
vCPUs: 2,
|
||||
memoryMiB: 4096,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := buildBaseVMConfig(tt.instanceID, tt.pidFile, tt.consolePath, tt.serialSocket, tt.architecture, tt.vCPUs, tt.memoryMiB)
|
||||
|
||||
assert.Equal(t, tt.instanceID, cfg.Name)
|
||||
assert.Equal(t, tt.pidFile, cfg.PIDFile)
|
||||
assert.Equal(t, tt.consolePath, cfg.ConsoleLogPath)
|
||||
assert.Equal(t, tt.serialSocket, cfg.SerialSocket)
|
||||
assert.Equal(t, tt.architecture, cfg.Architecture)
|
||||
assert.Equal(t, tt.vCPUs, cfg.CPUCount)
|
||||
assert.Equal(t, tt.memoryMiB, cfg.Memory)
|
||||
assert.True(t, cfg.EnableKVM)
|
||||
assert.True(t, cfg.NoGraphic)
|
||||
assert.Equal(t, "q35", cfg.MachineType)
|
||||
assert.Equal(t, "host", cfg.CPUType)
|
||||
|
||||
// 11 PCIe root ports for hotplug slots
|
||||
require.Len(t, cfg.Devices, 11)
|
||||
for i, dev := range cfg.Devices {
|
||||
expected := fmt.Sprintf("pcie-root-port,id=hotplug%d,chassis=%d,slot=0", i+1, i+1)
|
||||
assert.Equal(t, expected, dev.Value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDrives(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requests []types.EBSRequest
|
||||
cpuCount int
|
||||
wantDrives int
|
||||
wantIOThreads int
|
||||
wantDevices int
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "boot volume",
|
||||
requests: []types.EBSRequest{
|
||||
{Name: "vol-boot", NBDURI: "nbd:unix:/tmp/boot.sock", Boot: true},
|
||||
},
|
||||
cpuCount: 4,
|
||||
wantDrives: 1,
|
||||
wantIOThreads: 1,
|
||||
wantDevices: 1,
|
||||
},
|
||||
{
|
||||
name: "cloud-init volume",
|
||||
requests: []types.EBSRequest{
|
||||
{Name: "vol-ci", NBDURI: "nbd:unix:/tmp/ci.sock", CloudInit: true},
|
||||
},
|
||||
cpuCount: 2,
|
||||
wantDrives: 1,
|
||||
wantIOThreads: 0,
|
||||
wantDevices: 0,
|
||||
},
|
||||
{
|
||||
name: "EFI volume skipped",
|
||||
requests: []types.EBSRequest{
|
||||
{Name: "vol-efi", EFI: true},
|
||||
},
|
||||
cpuCount: 2,
|
||||
wantDrives: 0,
|
||||
wantIOThreads: 0,
|
||||
wantDevices: 0,
|
||||
},
|
||||
{
|
||||
name: "missing NBDURI returns error",
|
||||
requests: []types.EBSRequest{
|
||||
{Name: "vol-bad"},
|
||||
},
|
||||
cpuCount: 2,
|
||||
wantErr: "NBDURI not set for volume vol-bad",
|
||||
},
|
||||
{
|
||||
name: "mixed boot + cloud-init + EFI",
|
||||
requests: []types.EBSRequest{
|
||||
{Name: "vol-boot", NBDURI: "nbd:unix:/tmp/boot.sock", Boot: true},
|
||||
{Name: "vol-ci", NBDURI: "nbd:unix:/tmp/ci.sock", CloudInit: true},
|
||||
{Name: "vol-efi", EFI: true},
|
||||
},
|
||||
cpuCount: 4,
|
||||
wantDrives: 2, // boot + cloud-init (EFI skipped)
|
||||
wantIOThreads: 1, // only boot gets iothread
|
||||
wantDevices: 1, // only boot gets virtio-blk device
|
||||
},
|
||||
{
|
||||
name: "empty requests",
|
||||
requests: []types.EBSRequest{},
|
||||
cpuCount: 2,
|
||||
wantDrives: 0,
|
||||
wantIOThreads: 0,
|
||||
wantDevices: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
drives, iothreads, devices, err := buildDrives(tt.requests, tt.cpuCount)
|
||||
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, drives, tt.wantDrives)
|
||||
assert.Len(t, iothreads, tt.wantIOThreads)
|
||||
assert.Len(t, devices, tt.wantDevices)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDrives_BootVolume(t *testing.T) {
|
||||
requests := []types.EBSRequest{
|
||||
{Name: "vol-boot", NBDURI: "nbd:unix:/tmp/boot.sock", Boot: true},
|
||||
}
|
||||
|
||||
drives, iothreads, devices, err := buildDrives(requests, 4)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, drives, 1)
|
||||
d := drives[0]
|
||||
assert.Equal(t, "nbd:unix:/tmp/boot.sock", d.File)
|
||||
assert.Equal(t, "raw", d.Format)
|
||||
assert.Equal(t, "none", d.If)
|
||||
assert.Equal(t, "disk", d.Media)
|
||||
assert.Equal(t, "os", d.ID)
|
||||
assert.Equal(t, "none", d.Cache)
|
||||
|
||||
require.Len(t, iothreads, 1)
|
||||
assert.Equal(t, "ioth-os", iothreads[0].ID)
|
||||
|
||||
require.Len(t, devices, 1)
|
||||
assert.Equal(t, "virtio-blk-pci,drive=os,iothread=ioth-os,num-queues=4,bootindex=1", devices[0].Value)
|
||||
}
|
||||
|
||||
func TestBuildDrives_CloudInitVolume(t *testing.T) {
|
||||
requests := []types.EBSRequest{
|
||||
{Name: "vol-ci", NBDURI: "nbd:unix:/tmp/ci.sock", CloudInit: true},
|
||||
}
|
||||
|
||||
drives, _, _, err := buildDrives(requests, 2)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, drives, 1)
|
||||
d := drives[0]
|
||||
assert.Equal(t, "nbd:unix:/tmp/ci.sock", d.File)
|
||||
assert.Equal(t, "raw", d.Format)
|
||||
assert.Equal(t, "virtio", d.If)
|
||||
assert.Equal(t, "cdrom", d.Media)
|
||||
assert.Equal(t, "cloudinit", d.ID)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -10,10 +10,9 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/types"
|
||||
"github.com/mulgadc/spinifex/spinifex/vm"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -23,23 +22,7 @@ import (
|
||||
func createDaemonWithJetStream(t *testing.T) *Daemon {
|
||||
t.Helper()
|
||||
|
||||
jsTmpDir := t.TempDir()
|
||||
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: jsTmpDir,
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second), "NATS server failed to start")
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
natsURL := ns.ClientURL()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
@@ -51,10 +34,6 @@ func createDaemonWithJetStream(t *testing.T) *Daemon {
|
||||
require.NoError(t, err)
|
||||
daemon.config = &config.Config{BaseDir: tmpDir}
|
||||
|
||||
nc, err := nats.Connect(natsURL)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
|
||||
daemon.natsConn = nc
|
||||
daemon.jsManager, err = NewJetStreamManager(nc, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -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,11 +2,11 @@ package formation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -190,8 +190,14 @@ func (fs *FormationServer) SetMasterKey(key string) {
|
||||
fs.masterKey = key
|
||||
}
|
||||
|
||||
// Start launches the HTTP server on the given address (e.g. "10.0.0.1:4432").
|
||||
// Start launches the HTTPS server on the given address (e.g. "10.0.0.1:4432")
|
||||
// using the cluster CA certificate and key for TLS.
|
||||
func (fs *FormationServer) Start(bindAddr string) error {
|
||||
tlsCert, err := tls.X509KeyPair([]byte(fs.caCert), []byte(fs.caKey))
|
||||
if err != nil {
|
||||
return fmt.Errorf("formation server TLS keypair: %w", err)
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /formation/join", fs.handleJoin)
|
||||
mux.HandleFunc("GET /formation/status", fs.handleStatus)
|
||||
@@ -204,9 +210,12 @@ func (fs *FormationServer) Start(bindAddr string) error {
|
||||
WriteTimeout: 10 * time.Second,
|
||||
IdleTimeout: 30 * time.Second,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
TLSConfig: &tls.Config{
|
||||
Certificates: []tls.Certificate{tlsCert},
|
||||
},
|
||||
}
|
||||
|
||||
ln, err := net.Listen("tcp", bindAddr)
|
||||
ln, err := tls.Listen("tcp", bindAddr, fs.server.TLSConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("formation server listen: %w", err)
|
||||
}
|
||||
@@ -217,7 +226,7 @@ func (fs *FormationServer) Start(bindAddr string) error {
|
||||
}
|
||||
}()
|
||||
|
||||
slog.Info("Formation server started", "addr", bindAddr)
|
||||
slog.Info("Formation server started (TLS)", "addr", bindAddr)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -21,6 +22,9 @@ import (
|
||||
const (
|
||||
// Maximum allowed clock skew for signature validation (5 minutes)
|
||||
maxClockSkew = 5 * time.Minute
|
||||
|
||||
// Maximum request body size for signature validation (10 MB)
|
||||
maxBodySize = 10 * 1024 * 1024
|
||||
)
|
||||
|
||||
// SigV4AuthMiddleware returns stdlib middleware that validates AWS Signature V4 authentication.
|
||||
@@ -139,9 +143,18 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// Limit request body size to prevent OOM from unauthenticated requests
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
|
||||
|
||||
// Read body once and re-buffer for downstream handlers
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesErr) {
|
||||
slog.Warn("Request body too large", "limit", maxBodySize)
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
slog.Error("Failed to read request body", "err", err)
|
||||
gw.writeSigV4Error(w, r, awserrors.ErrorInternalError)
|
||||
return
|
||||
|
||||
@@ -667,6 +667,34 @@ func TestSigV4Auth_DecryptFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSigV4Auth_RequestBodyTooLarge(t *testing.T) {
|
||||
handler := setupTestApp(testAccessKey, testSecretKey)
|
||||
|
||||
// Create a body that exceeds maxBodySize (10 MB + 1 byte)
|
||||
oversizedBody := strings.Repeat("x", maxBodySize+1)
|
||||
authHeader, timestamp := generateTestAuthHeader(
|
||||
"POST", "/", "", oversizedBody,
|
||||
testAccessKey, testSecretKey, testRegion, testService,
|
||||
)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(oversizedBody))
|
||||
req.Host = "localhost:9999"
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
req.Header.Set("X-Amz-Date", timestamp)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp := doRequest(handler, req)
|
||||
|
||||
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("Expected status 413, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), "RequestEntityTooLarge") {
|
||||
t.Errorf("Expected RequestEntityTooLarge error, got: %s", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalHeaderName(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input string
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,41 +2,14 @@ package gateway_ec2_instance
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// startTestNATSServer starts an embedded NATS server for testing
|
||||
func startTestNATSServer(t *testing.T) (*server.Server, *nats.Conn) {
|
||||
t.Helper()
|
||||
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: false,
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err, "Failed to create NATS server")
|
||||
|
||||
go ns.Start()
|
||||
|
||||
if !ns.ReadyForConnections(5 * time.Second) {
|
||||
t.Fatal("NATS server failed to start")
|
||||
}
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err, "Failed to connect to NATS")
|
||||
|
||||
t.Cleanup(func() {
|
||||
nc.Close()
|
||||
ns.Shutdown()
|
||||
})
|
||||
|
||||
return ns, nc
|
||||
return testutil.StartTestNATS(t)
|
||||
}
|
||||
|
||||
@@ -11,12 +11,11 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/types"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -264,25 +263,7 @@ func TestErrorHandler_ContentTypeXML(t *testing.T) {
|
||||
|
||||
func startTestNATS(t *testing.T) *nats.Conn {
|
||||
t.Helper()
|
||||
ns, err := server.NewServer(&server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second), "NATS server failed to start")
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
nc.Close()
|
||||
ns.Shutdown()
|
||||
})
|
||||
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
return nc
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ package spx
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/types"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
@@ -52,23 +52,7 @@ func TestGetVersion_Dev(t *testing.T) {
|
||||
// startEmbeddedNATS starts an in-process NATS server for testing.
|
||||
func startEmbeddedNATS(t *testing.T) (*server.Server, *nats.Conn) {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1, // random port
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
if !ns.ReadyForConnections(2 * time.Second) {
|
||||
t.Fatal("NATS server not ready")
|
||||
}
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
nc.Close()
|
||||
ns.Shutdown()
|
||||
})
|
||||
return ns, nc
|
||||
return testutil.StartTestNATS(t)
|
||||
}
|
||||
|
||||
func TestGetNodes_NoResponders(t *testing.T) {
|
||||
|
||||
@@ -2,12 +2,10 @@ package handlers_ec2_account
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -16,20 +14,7 @@ import (
|
||||
// the service against a NATS server without JetStream returns an error instead
|
||||
// of silently degrading.
|
||||
func TestNewAccountSettingsServiceImplWithNATS_NoJetStream(t *testing.T) {
|
||||
ns, err := server.NewServer(&server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
|
||||
svc, err := NewAccountSettingsServiceImplWithNATS(nil, nc)
|
||||
assert.Error(t, err)
|
||||
@@ -40,23 +25,7 @@ var _ AccountSettingsService = (*AccountSettingsServiceImpl)(nil)
|
||||
|
||||
func setupTestAccountService(t *testing.T) *AccountSettingsServiceImpl {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
svc, err := NewAccountSettingsServiceImplWithNATS(nil, nc)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -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"
|
||||
@@ -93,20 +94,10 @@ func (s *EgressOnlyIGWServiceImpl) CreateEgressOnlyInternetGateway(input *ec2.Cr
|
||||
EgressOnlyInternetGatewayId: eigwID,
|
||||
VpcId: *input.VpcId,
|
||||
State: "attached",
|
||||
Tags: make(map[string]string),
|
||||
Tags: utils.ExtractTags(input.TagSpecifications, "egress-only-internet-gateway"),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
for _, tagSpec := range input.TagSpecifications {
|
||||
if tagSpec.ResourceType != nil && *tagSpec.ResourceType == "egress-only-internet-gateway" {
|
||||
for _, tag := range tagSpec.Tags {
|
||||
if tag.Key != nil && tag.Value != nil {
|
||||
record.Tags[*tag.Key] = *tag.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal Egress-only IGW record: %w", err)
|
||||
@@ -149,6 +140,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 +156,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 +192,10 @@ func (s *EgressOnlyIGWServiceImpl) DescribeEgressOnlyInternetGateways(input *ec2
|
||||
continue
|
||||
}
|
||||
|
||||
if len(parsedFilters) > 0 && !eigwMatchesFilters(&record, parsedFilters) {
|
||||
continue
|
||||
}
|
||||
|
||||
egressOnlyIGWs = append(egressOnlyIGWs, s.recordToEC2(&record))
|
||||
}
|
||||
|
||||
@@ -200,6 +206,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),
|
||||
@@ -211,13 +240,7 @@ func (s *EgressOnlyIGWServiceImpl) recordToEC2(record *EgressOnlyIGWRecord) *ec2
|
||||
},
|
||||
}
|
||||
|
||||
if len(record.Tags) > 0 {
|
||||
tags := make([]*ec2.Tag, 0, len(record.Tags))
|
||||
for k, v := range record.Tags {
|
||||
tags = append(tags, &ec2.Tag{Key: aws.String(k), Value: aws.String(v)})
|
||||
}
|
||||
eigw.Tags = tags
|
||||
}
|
||||
eigw.Tags = utils.MapToEC2Tags(record.Tags)
|
||||
|
||||
return eigw
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@ package handlers_ec2_eigw
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -18,33 +16,14 @@ const testAccountID = "123456789012"
|
||||
|
||||
func setupTestEIGWService(t *testing.T) *EgressOnlyIGWServiceImpl {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
|
||||
// Create VPC KV bucket and register test VPCs so fail-closed ownership checks pass
|
||||
js, err := nc.JetStream()
|
||||
require.NoError(t, err)
|
||||
vpcKV, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: handlers_ec2_vpc.KVBucketVPCs, History: 1})
|
||||
require.NoError(t, err)
|
||||
vpcEntries := map[string][]byte{}
|
||||
for _, vpcID := range []string{"vpc-test123", "vpc-tagged", "vpc-tagged2"} {
|
||||
_, err = vpcKV.Put(utils.AccountKey(testAccountID, vpcID), []byte(`{"vpc_id":"`+vpcID+`","state":"available"}`))
|
||||
require.NoError(t, err)
|
||||
vpcEntries[utils.AccountKey(testAccountID, vpcID)] = []byte(`{"vpc_id":"` + vpcID + `","state":"available"}`)
|
||||
}
|
||||
testutil.SeedKV(t, js, handlers_ec2_vpc.KVBucketVPCs, vpcEntries)
|
||||
|
||||
svc, err := NewEgressOnlyIGWServiceImplWithNATS(nil, nc)
|
||||
require.NoError(t, err)
|
||||
@@ -199,35 +178,109 @@ 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
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
|
||||
// Create VPC KV bucket with a VPC owned by testAccountID
|
||||
js, err := nc.JetStream()
|
||||
require.NoError(t, err)
|
||||
vpcKV, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: handlers_ec2_vpc.KVBucketVPCs, History: 1})
|
||||
require.NoError(t, err)
|
||||
|
||||
vpcID := "vpc-alpha456"
|
||||
_, err = vpcKV.Put(utils.AccountKey(testAccountID, vpcID), []byte(`{"vpc_id":"vpc-alpha456","state":"available"}`))
|
||||
testutil.SeedKV(t, js, handlers_ec2_vpc.KVBucketVPCs, map[string][]byte{
|
||||
utils.AccountKey(testAccountID, vpcID): []byte(`{"vpc_id":"vpc-alpha456","state":"available"}`),
|
||||
})
|
||||
|
||||
var err error
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create EIGW service (VPC KV will be populated since we created it before EIGW init)
|
||||
|
||||
@@ -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"
|
||||
@@ -92,21 +93,10 @@ func (s *EIPServiceImpl) AllocateAddress(input *ec2.AllocateAddressInput, accoun
|
||||
PublicIp: publicIP,
|
||||
PoolName: poolName,
|
||||
State: "allocated",
|
||||
Tags: make(map[string]string),
|
||||
Tags: utils.ExtractTags(input.TagSpecifications, "elastic-ip"),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Store tags from input.
|
||||
for _, tagSpec := range input.TagSpecifications {
|
||||
if tagSpec.ResourceType != nil && *tagSpec.ResourceType == "elastic-ip" {
|
||||
for _, tag := range tagSpec.Tags {
|
||||
if tag.Key != nil && tag.Value != nil {
|
||||
record.Tags[*tag.Key] = *tag.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal EIP record: %w", err)
|
||||
@@ -293,6 +283,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 +308,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 +348,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 +377,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
|
||||
@@ -529,26 +569,13 @@ func (s *EIPServiceImpl) findByAssociationID(accountID, associationID string) (*
|
||||
// publishNATEvent publishes a NAT lifecycle event to NATS for vpcd consumption.
|
||||
// This is fire-and-forget; errors are logged but do not fail the API response.
|
||||
func (s *EIPServiceImpl) publishNATEvent(topic, vpcID, externalIP, logicalIP, eniID, mac string) {
|
||||
if s.natsConn == nil {
|
||||
return
|
||||
}
|
||||
|
||||
evt := natEvent{
|
||||
utils.PublishEvent(s.natsConn, topic, natEvent{
|
||||
VpcId: vpcID,
|
||||
ExternalIP: externalIP,
|
||||
LogicalIP: logicalIP,
|
||||
PortName: eniID,
|
||||
MAC: mac,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal NAT event", "topic", topic, "err", err)
|
||||
return
|
||||
}
|
||||
if err := s.natsConn.Publish(topic, data); err != nil {
|
||||
slog.Error("Failed to publish NAT event", "topic", topic, "err", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// eipRecordToEC2 converts an EIPRecord to an EC2 Address.
|
||||
@@ -573,13 +600,7 @@ func (s *EIPServiceImpl) eipRecordToEC2(record *EIPRecord) *ec2.Address {
|
||||
addr.PrivateIpAddress = aws.String(record.PrivateIp)
|
||||
}
|
||||
|
||||
if len(record.Tags) > 0 {
|
||||
tags := make([]*ec2.Tag, 0, len(record.Tags))
|
||||
for k, v := range record.Tags {
|
||||
tags = append(tags, &ec2.Tag{Key: aws.String(k), Value: aws.String(v)})
|
||||
}
|
||||
addr.Tags = tags
|
||||
}
|
||||
addr.Tags = utils.MapToEC2Tags(record.Tags)
|
||||
|
||||
return addr
|
||||
}
|
||||
|
||||
@@ -3,13 +3,11 @@ package handlers_ec2_eip
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -28,26 +26,7 @@ func testPool() handlers_ec2_vpc.ExternalPoolConfig {
|
||||
|
||||
func setupTestEIP(t *testing.T) (*EIPServiceImpl, *handlers_ec2_vpc.ExternalIPAM) {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
|
||||
js, err := nc.JetStream()
|
||||
require.NoError(t, err)
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
|
||||
pool := testPool()
|
||||
ipam, err := handlers_ec2_vpc.NewExternalIPAM(js, []handlers_ec2_vpc.ExternalPoolConfig{pool})
|
||||
@@ -367,3 +346,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"
|
||||
@@ -90,20 +91,10 @@ func (s *IGWServiceImpl) createIGW(input *ec2.CreateInternetGatewayInput, accoun
|
||||
record := IGWRecord{
|
||||
InternetGatewayId: igwID,
|
||||
State: "available",
|
||||
Tags: make(map[string]string),
|
||||
Tags: utils.ExtractTags(input.TagSpecifications, "internet-gateway"),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
for _, tagSpec := range input.TagSpecifications {
|
||||
if tagSpec.ResourceType != nil && *tagSpec.ResourceType == "internet-gateway" {
|
||||
for _, tag := range tagSpec.Tags {
|
||||
if tag.Key != nil && tag.Value != nil {
|
||||
record.Tags[*tag.Key] = *tag.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal IGW record: %w", err)
|
||||
@@ -152,6 +143,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 +161,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 +199,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 +221,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) {
|
||||
@@ -353,13 +391,7 @@ func (s *IGWServiceImpl) recordToEC2(record *IGWRecord) *ec2.InternetGateway {
|
||||
}
|
||||
}
|
||||
|
||||
if len(record.Tags) > 0 {
|
||||
tags := make([]*ec2.Tag, 0, len(record.Tags))
|
||||
for k, v := range record.Tags {
|
||||
tags = append(tags, &ec2.Tag{Key: aws.String(k), Value: aws.String(v)})
|
||||
}
|
||||
igw.Tags = tags
|
||||
}
|
||||
igw.Tags = utils.MapToEC2Tags(record.Tags)
|
||||
|
||||
return igw
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -16,33 +16,14 @@ import (
|
||||
|
||||
func setupTestIGWService(t *testing.T) (*IGWServiceImpl, *nats.Conn) {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
|
||||
// Create VPC KV bucket and register test VPCs so fail-closed ownership checks pass
|
||||
js, err := nc.JetStream()
|
||||
require.NoError(t, err)
|
||||
vpcKV, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: handlers_ec2_vpc.KVBucketVPCs, History: 1})
|
||||
require.NoError(t, err)
|
||||
vpcEntries := map[string][]byte{}
|
||||
for _, vpcID := range []string{"vpc-test123", "vpc-other", "vpc-lifecycle", "vpc-event-test"} {
|
||||
_, err = vpcKV.Put(utils.AccountKey(testAccountID, vpcID), []byte(`{"vpc_id":"`+vpcID+`","state":"available"}`))
|
||||
require.NoError(t, err)
|
||||
vpcEntries[utils.AccountKey(testAccountID, vpcID)] = []byte(`{"vpc_id":"` + vpcID + `","state":"available"}`)
|
||||
}
|
||||
testutil.SeedKV(t, js, handlers_ec2_vpc.KVBucketVPCs, vpcEntries)
|
||||
|
||||
svc, err := NewIGWServiceImplWithNATS(nil, nc)
|
||||
require.NoError(t, err)
|
||||
@@ -495,6 +476,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)
|
||||
}
|
||||
|
||||
@@ -39,15 +39,8 @@ users:
|
||||
sudo: "ALL=(ALL) NOPASSWD:ALL"
|
||||
ssh_authorized_keys:
|
||||
- {{.SSHKey}}
|
||||
{{else}}
|
||||
chpasswd:
|
||||
expire: false
|
||||
users:
|
||||
- name: root
|
||||
password: spinifex
|
||||
type: text
|
||||
ssh_pwauth: true
|
||||
{{end}}
|
||||
ssh_pwauth: false
|
||||
|
||||
hostname: {{.Hostname}}
|
||||
manage_etc_hosts: true
|
||||
@@ -613,7 +606,9 @@ func (s *InstanceServiceImpl) createCloudInitISO(input *ec2.RunInstancesInput, i
|
||||
// Generate instance metadata
|
||||
hostname := generateHostname(instance.ID)
|
||||
|
||||
// Retrieve SSH pubkey from S3 (optional — system instances may not have a key)
|
||||
// Retrieve SSH pubkey from S3 — required for instance access.
|
||||
// Password authentication is not supported; instances without a key
|
||||
// pair have no remote access method.
|
||||
keyName := ""
|
||||
if input.KeyName != nil {
|
||||
keyName = *input.KeyName
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
@@ -18,29 +19,10 @@ import (
|
||||
|
||||
// startTestNATSServer starts an embedded NATS server for testing
|
||||
func startTestNATSServer(t *testing.T) (*server.Server, string) {
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1, // Auto-allocate available port
|
||||
JetStream: false,
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err, "Failed to create NATS server")
|
||||
|
||||
// Start server in goroutine
|
||||
go ns.Start()
|
||||
|
||||
// Wait for server to be ready
|
||||
if !ns.ReadyForConnections(5 * time.Second) {
|
||||
t.Fatal("NATS server failed to start")
|
||||
}
|
||||
|
||||
url := ns.ClientURL()
|
||||
t.Logf("Test NATS server started at: %s", url)
|
||||
|
||||
return ns, url
|
||||
t.Helper()
|
||||
ns, nc := testutil.StartTestNATS(t)
|
||||
_ = nc // connection managed by testutil cleanup
|
||||
return ns, ns.ClientURL()
|
||||
}
|
||||
|
||||
// createValidRunInstancesInput creates a valid RunInstancesInput for testing
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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_eip "github.com/mulgadc/spinifex/spinifex/handlers/ec2/eip"
|
||||
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
@@ -287,20 +287,12 @@ func (s *NatGatewayServiceImpl) publishDeleteEventsForNatGateway(record *NatGate
|
||||
continue
|
||||
}
|
||||
|
||||
evt := natGatewayEvent{
|
||||
utils.PublishEvent(s.natsConn, "vpc.delete-nat-gateway", natGatewayEvent{
|
||||
VpcId: record.VpcId,
|
||||
NatGatewayId: record.NatGatewayId,
|
||||
PublicIp: record.PublicIp,
|
||||
SubnetCidr: subnet.CidrBlock,
|
||||
}
|
||||
eventData, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to marshal NAT GW delete event", "err", err)
|
||||
continue
|
||||
}
|
||||
if err := s.natsConn.Publish("vpc.delete-nat-gateway", eventData); err != nil {
|
||||
slog.Warn("Failed to publish NAT GW delete event", "subnet", subnet.CidrBlock, "err", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,8 +302,21 @@ func mustJS(nc *nats.Conn) nats.JetStreamContext {
|
||||
return js
|
||||
}
|
||||
|
||||
var describeNatGatewaysValidFilters = map[string]bool{
|
||||
"nat-gateway-id": true,
|
||||
"subnet-id": true,
|
||||
"vpc-id": true,
|
||||
"state": true,
|
||||
}
|
||||
|
||||
// DescribeNatGateways lists NAT Gateways, optionally filtered
|
||||
func (s *NatGatewayServiceImpl) DescribeNatGateways(input *ec2.DescribeNatGatewaysInput, accountID string) (*ec2.DescribeNatGatewaysOutput, error) {
|
||||
parsedFilters, err := filterutil.ParseFilters(input.Filter, describeNatGatewaysValidFilters)
|
||||
if err != nil {
|
||||
slog.Warn("DescribeNatGateways: invalid filter", "err", err)
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
natgwIDs := make(map[string]bool)
|
||||
for _, id := range input.NatGatewayIds {
|
||||
if id != nil {
|
||||
@@ -319,18 +324,6 @@ func (s *NatGatewayServiceImpl) DescribeNatGateways(input *ec2.DescribeNatGatewa
|
||||
}
|
||||
}
|
||||
|
||||
// Build filter map
|
||||
filters := make(map[string][]string)
|
||||
for _, f := range input.Filter {
|
||||
if f.Name != nil {
|
||||
for _, v := range f.Values {
|
||||
if v != nil {
|
||||
filters[*f.Name] = append(filters[*f.Name], *v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prefix := accountID + "."
|
||||
keys, err := s.natgwKV.Keys()
|
||||
if err != nil && !errors.Is(err, nats.ErrNoKeysFound) {
|
||||
@@ -363,19 +356,8 @@ func (s *NatGatewayServiceImpl) DescribeNatGateways(input *ec2.DescribeNatGatewa
|
||||
if len(natgwIDs) > 0 && !natgwIDs[record.NatGatewayId] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
if vpcFilter, ok := filters["vpc-id"]; ok {
|
||||
found := slices.Contains(vpcFilter, record.VpcId)
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if stateFilter, ok := filters["state"]; ok {
|
||||
found := slices.Contains(stateFilter, record.State)
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if !natgwMatchesFilters(&record, parsedFilters) {
|
||||
continue
|
||||
}
|
||||
|
||||
natGateways = append(natGateways, recordToEC2(&record))
|
||||
@@ -407,47 +389,55 @@ func (s *NatGatewayServiceImpl) DescribeNatGateways(input *ec2.DescribeNatGatewa
|
||||
}, nil
|
||||
}
|
||||
|
||||
// natgwMatchesFilters checks whether a NAT Gateway record matches all parsed filters.
|
||||
func natgwMatchesFilters(record *NatGatewayRecord, filters map[string][]string) bool {
|
||||
for name, values := range filters {
|
||||
if strings.HasPrefix(name, "tag:") {
|
||||
continue
|
||||
}
|
||||
switch name {
|
||||
case "nat-gateway-id":
|
||||
if !filterutil.MatchesAny(values, record.NatGatewayId) {
|
||||
return false
|
||||
}
|
||||
case "subnet-id":
|
||||
if !filterutil.MatchesAny(values, record.SubnetId) {
|
||||
return false
|
||||
}
|
||||
case "vpc-id":
|
||||
if !filterutil.MatchesAny(values, record.VpcId) {
|
||||
return false
|
||||
}
|
||||
case "state":
|
||||
if !filterutil.MatchesAny(values, record.State) {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return filterutil.MatchesTags(filters, record.Tags)
|
||||
}
|
||||
|
||||
// PublishAddEvent publishes a vpc.add-nat-gateway event for vpcd to create the SNAT rule.
|
||||
// Called by the route table service when CreateRoute targets a NAT GW.
|
||||
func (s *NatGatewayServiceImpl) PublishAddEvent(vpcId, natGatewayId, publicIp, subnetCidr string) {
|
||||
if s.natsConn == nil {
|
||||
return
|
||||
}
|
||||
evt := natGatewayEvent{
|
||||
utils.PublishEvent(s.natsConn, "vpc.add-nat-gateway", natGatewayEvent{
|
||||
VpcId: vpcId,
|
||||
NatGatewayId: natGatewayId,
|
||||
PublicIp: publicIp,
|
||||
SubnetCidr: subnetCidr,
|
||||
}
|
||||
eventData, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal NAT GW add event", "err", err)
|
||||
return
|
||||
}
|
||||
if err := s.natsConn.Publish("vpc.add-nat-gateway", eventData); err != nil {
|
||||
slog.Warn("Failed to publish NAT GW add event", "err", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// PublishDeleteEvent publishes a vpc.delete-nat-gateway event for vpcd to remove the SNAT rule.
|
||||
func (s *NatGatewayServiceImpl) PublishDeleteEvent(vpcId, natGatewayId, publicIp, subnetCidr string) {
|
||||
if s.natsConn == nil {
|
||||
return
|
||||
}
|
||||
evt := natGatewayEvent{
|
||||
utils.PublishEvent(s.natsConn, "vpc.delete-nat-gateway", natGatewayEvent{
|
||||
VpcId: vpcId,
|
||||
NatGatewayId: natGatewayId,
|
||||
PublicIp: publicIp,
|
||||
SubnetCidr: subnetCidr,
|
||||
}
|
||||
eventData, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal NAT GW delete event", "err", err)
|
||||
return
|
||||
}
|
||||
if err := s.natsConn.Publish("vpc.delete-nat-gateway", eventData); err != nil {
|
||||
slog.Warn("Failed to publish NAT GW delete event", "err", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// GetNatGateway retrieves a NAT Gateway record by ID
|
||||
@@ -485,13 +475,7 @@ func recordToEC2(record *NatGatewayRecord) *ec2.NatGateway {
|
||||
}
|
||||
}
|
||||
|
||||
if len(record.Tags) > 0 {
|
||||
tags := make([]*ec2.Tag, 0, len(record.Tags))
|
||||
for k, v := range record.Tags {
|
||||
tags = append(tags, &ec2.Tag{Key: aws.String(k), Value: aws.String(v)})
|
||||
}
|
||||
ngw.Tags = tags
|
||||
}
|
||||
ngw.Tags = utils.MapToEC2Tags(record.Tags)
|
||||
|
||||
return ngw
|
||||
}
|
||||
|
||||
@@ -2,16 +2,14 @@ package handlers_ec2_natgw
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_eip "github.com/mulgadc/spinifex/spinifex/handlers/ec2/eip"
|
||||
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -20,46 +18,23 @@ const testAccountID = "123456789012"
|
||||
|
||||
func setupTestService(t *testing.T) *NatGatewayServiceImpl {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
|
||||
js, err := nc.JetStream()
|
||||
require.NoError(t, err)
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
|
||||
// Seed VPC KV
|
||||
vpcKV, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: handlers_ec2_vpc.KVBucketVPCs, History: 1})
|
||||
require.NoError(t, err)
|
||||
_, err = vpcKV.Put(utils.AccountKey(testAccountID, "vpc-test1"), []byte(`{"vpc_id":"vpc-test1","cidr_block":"10.0.0.0/16","state":"available"}`))
|
||||
require.NoError(t, err)
|
||||
testutil.SeedKV(t, js, handlers_ec2_vpc.KVBucketVPCs, map[string][]byte{
|
||||
utils.AccountKey(testAccountID, "vpc-test1"): []byte(`{"vpc_id":"vpc-test1","cidr_block":"10.0.0.0/16","state":"available"}`),
|
||||
})
|
||||
|
||||
// Seed subnet KV (public subnet)
|
||||
subnetKV, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: handlers_ec2_vpc.KVBucketSubnets, History: 1})
|
||||
require.NoError(t, err)
|
||||
_, err = subnetKV.Put(utils.AccountKey(testAccountID, "subnet-pub1"), []byte(`{"subnet_id":"subnet-pub1","vpc_id":"vpc-test1","cidr_block":"10.0.1.0/24","state":"available","map_public_ip_on_launch":true}`))
|
||||
require.NoError(t, err)
|
||||
testutil.SeedKV(t, js, handlers_ec2_vpc.KVBucketSubnets, map[string][]byte{
|
||||
utils.AccountKey(testAccountID, "subnet-pub1"): []byte(`{"subnet_id":"subnet-pub1","vpc_id":"vpc-test1","cidr_block":"10.0.1.0/24","state":"available","map_public_ip_on_launch":true}`),
|
||||
})
|
||||
|
||||
// Seed EIP KV
|
||||
eipKV, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: handlers_ec2_eip.KVBucketEIPs, History: 1})
|
||||
require.NoError(t, err)
|
||||
_, err = eipKV.Put(utils.AccountKey(testAccountID, "eipalloc-test1"), []byte(`{"allocation_id":"eipalloc-test1","public_ip":"203.0.113.50","state":"allocated"}`))
|
||||
require.NoError(t, err)
|
||||
_, err = eipKV.Put(utils.AccountKey(testAccountID, "eipalloc-used"), []byte(`{"allocation_id":"eipalloc-used","public_ip":"203.0.113.51","state":"associated","association_id":"eipassoc-xxx"}`))
|
||||
require.NoError(t, err)
|
||||
testutil.SeedKV(t, js, handlers_ec2_eip.KVBucketEIPs, map[string][]byte{
|
||||
utils.AccountKey(testAccountID, "eipalloc-test1"): []byte(`{"allocation_id":"eipalloc-test1","public_ip":"203.0.113.50","state":"allocated"}`),
|
||||
utils.AccountKey(testAccountID, "eipalloc-used"): []byte(`{"allocation_id":"eipalloc-used","public_ip":"203.0.113.51","state":"associated","association_id":"eipassoc-xxx"}`),
|
||||
})
|
||||
|
||||
svc, err := NewNatGatewayServiceImplWithNATS(nc)
|
||||
require.NoError(t, err)
|
||||
@@ -268,6 +243,61 @@ func TestDescribeNatGateways_DeletedGatewayVisible(t *testing.T) {
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidNatGatewayIDNotFound)
|
||||
}
|
||||
|
||||
func TestDescribeNatGateways_FilterByNatGatewayId(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
createOut, err := svc.CreateNatGateway(&ec2.CreateNatGatewayInput{
|
||||
SubnetId: aws.String("subnet-pub1"),
|
||||
AllocationId: aws.String("eipalloc-test1"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
natgwID := *createOut.NatGateway.NatGatewayId
|
||||
|
||||
// Exact match
|
||||
out, err := svc.DescribeNatGateways(&ec2.DescribeNatGatewaysInput{
|
||||
Filter: []*ec2.Filter{{Name: aws.String("nat-gateway-id"), Values: []*string{aws.String(natgwID)}}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.NatGateways, 1)
|
||||
assert.Equal(t, natgwID, *out.NatGateways[0].NatGatewayId)
|
||||
|
||||
// Non-match
|
||||
out, err = svc.DescribeNatGateways(&ec2.DescribeNatGatewaysInput{
|
||||
Filter: []*ec2.Filter{{Name: aws.String("nat-gateway-id"), Values: []*string{aws.String("nat-000000")}}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out.NatGateways)
|
||||
|
||||
// Wildcard
|
||||
out, err = svc.DescribeNatGateways(&ec2.DescribeNatGatewaysInput{
|
||||
Filter: []*ec2.Filter{{Name: aws.String("nat-gateway-id"), Values: []*string{aws.String("nat-*")}}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.NatGateways, 1)
|
||||
}
|
||||
|
||||
func TestDescribeNatGateways_FilterBySubnetId(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
_, err := svc.CreateNatGateway(&ec2.CreateNatGatewayInput{
|
||||
SubnetId: aws.String("subnet-pub1"),
|
||||
AllocationId: aws.String("eipalloc-test1"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Exact match
|
||||
out, err := svc.DescribeNatGateways(&ec2.DescribeNatGatewaysInput{
|
||||
Filter: []*ec2.Filter{{Name: aws.String("subnet-id"), Values: []*string{aws.String("subnet-pub1")}}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.NatGateways, 1)
|
||||
|
||||
// Non-match
|
||||
out, err = svc.DescribeNatGateways(&ec2.DescribeNatGatewaysInput{
|
||||
Filter: []*ec2.Filter{{Name: aws.String("subnet-id"), Values: []*string{aws.String("subnet-000000")}}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out.NatGateways)
|
||||
}
|
||||
|
||||
func TestDescribeNatGateways_FilterByVpcId(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
_, err := svc.CreateNatGateway(&ec2.CreateNatGatewayInput{
|
||||
|
||||
@@ -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"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
@@ -156,9 +157,23 @@ func (s *PlacementGroupServiceImpl) DeletePlacementGroup(input *ec2.DeletePlacem
|
||||
return &ec2.DeletePlacementGroupOutput{}, nil
|
||||
}
|
||||
|
||||
var describePlacementGroupsValidFilters = map[string]bool{
|
||||
"group-id": true,
|
||||
"strategy": true,
|
||||
"state": true,
|
||||
"spread-level": true,
|
||||
"group-name": true,
|
||||
}
|
||||
|
||||
// DescribePlacementGroups lists placement groups with optional filters.
|
||||
func (s *PlacementGroupServiceImpl) DescribePlacementGroups(input *ec2.DescribePlacementGroupsInput, accountID string) (*ec2.DescribePlacementGroupsOutput, error) {
|
||||
// Build filter maps
|
||||
parsedFilters, err := filterutil.ParseFilters(input.Filters, describePlacementGroupsValidFilters)
|
||||
if err != nil {
|
||||
slog.Warn("DescribePlacementGroups: invalid filter", "err", err)
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
// Build filter maps for GroupNames/GroupIds parameters
|
||||
nameSet := make(map[string]bool)
|
||||
for _, name := range input.GroupNames {
|
||||
if name != nil {
|
||||
@@ -172,27 +187,6 @@ func (s *PlacementGroupServiceImpl) DescribePlacementGroups(input *ec2.DescribeP
|
||||
}
|
||||
}
|
||||
|
||||
// Extract filter values
|
||||
filterStrategy := ""
|
||||
filterState := ""
|
||||
filterSpreadLevel := ""
|
||||
filterGroupName := ""
|
||||
for _, f := range input.Filters {
|
||||
if f.Name == nil || len(f.Values) == 0 || f.Values[0] == nil {
|
||||
continue
|
||||
}
|
||||
switch *f.Name {
|
||||
case "strategy":
|
||||
filterStrategy = *f.Values[0]
|
||||
case "state":
|
||||
filterState = *f.Values[0]
|
||||
case "spread-level":
|
||||
filterSpreadLevel = *f.Values[0]
|
||||
case "group-name":
|
||||
filterGroupName = *f.Values[0]
|
||||
}
|
||||
}
|
||||
|
||||
prefix := accountID + "."
|
||||
keys, err := s.kv.Keys()
|
||||
if err != nil && !errors.Is(err, nats.ErrNoKeysFound) {
|
||||
@@ -228,17 +222,7 @@ func (s *PlacementGroupServiceImpl) DescribePlacementGroups(input *ec2.DescribeP
|
||||
if len(idSet) > 0 && !idSet[record.GroupId] {
|
||||
continue
|
||||
}
|
||||
// Apply Filters
|
||||
if filterStrategy != "" && record.Strategy != filterStrategy {
|
||||
continue
|
||||
}
|
||||
if filterState != "" && record.State != filterState {
|
||||
continue
|
||||
}
|
||||
if filterSpreadLevel != "" && record.SpreadLevel != filterSpreadLevel {
|
||||
continue
|
||||
}
|
||||
if filterGroupName != "" && record.GroupName != filterGroupName {
|
||||
if !pgMatchesFilters(&record, parsedFilters) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -267,6 +251,37 @@ func (s *PlacementGroupServiceImpl) DescribePlacementGroups(input *ec2.DescribeP
|
||||
}, nil
|
||||
}
|
||||
|
||||
// pgMatchesFilters checks whether a placement group record matches all parsed filters.
|
||||
func pgMatchesFilters(record *PlacementGroupRecord, filters map[string][]string) bool {
|
||||
for name, values := range filters {
|
||||
switch name {
|
||||
case "group-id":
|
||||
if !filterutil.MatchesAny(values, record.GroupId) {
|
||||
return false
|
||||
}
|
||||
case "strategy":
|
||||
if !filterutil.MatchesAny(values, record.Strategy) {
|
||||
return false
|
||||
}
|
||||
case "state":
|
||||
if !filterutil.MatchesAny(values, record.State) {
|
||||
return false
|
||||
}
|
||||
case "spread-level":
|
||||
if !filterutil.MatchesAny(values, record.SpreadLevel) {
|
||||
return false
|
||||
}
|
||||
case "group-name":
|
||||
if !filterutil.MatchesAny(values, record.GroupName) {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GetPlacementGroupRecord reads a placement group record from KV with its revision for CAS operations.
|
||||
// Returns the record and the KV entry (for revision). Exported for use by gateway spread routing.
|
||||
func (s *PlacementGroupServiceImpl) GetPlacementGroupRecord(accountID, groupName string) (*PlacementGroupRecord, nats.KeyValueEntry, error) {
|
||||
|
||||
@@ -2,13 +2,11 @@ package handlers_ec2_placementgroup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -17,23 +15,7 @@ const testAccountID = "123456789012"
|
||||
|
||||
func setupTestService(t *testing.T) *PlacementGroupServiceImpl {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
svc, err := NewPlacementGroupServiceImplWithNATS(nil, nc)
|
||||
require.NoError(t, err)
|
||||
@@ -226,6 +208,40 @@ func TestDescribePlacementGroups_FilterByGroupId(t *testing.T) {
|
||||
assert.Equal(t, *pg.GroupId, *out.PlacementGroups[0].GroupId)
|
||||
}
|
||||
|
||||
func TestDescribePlacementGroups_FilterByGroupIdFilter(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
pg := createTestGroup(t, svc, "gid-filter", "spread")
|
||||
createTestGroup(t, svc, "other-group", "cluster")
|
||||
|
||||
// Exact match
|
||||
out, err := svc.DescribePlacementGroups(&ec2.DescribePlacementGroupsInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("group-id"), Values: []*string{pg.GroupId}},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.PlacementGroups, 1)
|
||||
assert.Equal(t, *pg.GroupId, *out.PlacementGroups[0].GroupId)
|
||||
|
||||
// Non-match
|
||||
out, err = svc.DescribePlacementGroups(&ec2.DescribePlacementGroupsInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("group-id"), Values: []*string{aws.String("pg-000000")}},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out.PlacementGroups)
|
||||
|
||||
// Wildcard
|
||||
out, err = svc.DescribePlacementGroups(&ec2.DescribePlacementGroupsInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("group-id"), Values: []*string{aws.String("pg-*")}},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.PlacementGroups, 2)
|
||||
}
|
||||
|
||||
func TestDescribePlacementGroups_FilterByStrategy(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
createTestGroup(t, svc, "spread1", "spread")
|
||||
|
||||
@@ -8,120 +8,102 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- containsString ---
|
||||
// --- rtbMatchesFilters ---
|
||||
|
||||
func TestContainsString_Found(t *testing.T) {
|
||||
assert.True(t, containsString([]string{"a", "b", "c"}, "b"))
|
||||
}
|
||||
|
||||
func TestContainsString_NotFound(t *testing.T) {
|
||||
assert.False(t, containsString([]string{"a", "b", "c"}, "d"))
|
||||
}
|
||||
|
||||
func TestContainsString_EmptySlice(t *testing.T) {
|
||||
assert.False(t, containsString([]string{}, "a"))
|
||||
}
|
||||
|
||||
func TestContainsString_NilSlice(t *testing.T) {
|
||||
assert.False(t, containsString(nil, "a"))
|
||||
}
|
||||
|
||||
// --- matchesFilters ---
|
||||
|
||||
func TestMatchesFilters_EmptyFilters(t *testing.T) {
|
||||
func TestRtbMatchesFilters_EmptyFilters(t *testing.T) {
|
||||
record := &RouteTableRecord{RouteTableId: "rtb-1", VpcId: "vpc-1"}
|
||||
assert.True(t, matchesFilters(record, map[string][]string{}))
|
||||
assert.True(t, rtbMatchesFilters(record, map[string][]string{}))
|
||||
}
|
||||
|
||||
func TestMatchesFilters_VpcId(t *testing.T) {
|
||||
func TestRtbMatchesFilters_VpcId(t *testing.T) {
|
||||
record := &RouteTableRecord{RouteTableId: "rtb-1", VpcId: "vpc-aaa"}
|
||||
assert.True(t, matchesFilters(record, map[string][]string{"vpc-id": {"vpc-aaa"}}))
|
||||
assert.False(t, matchesFilters(record, map[string][]string{"vpc-id": {"vpc-bbb"}}))
|
||||
assert.True(t, rtbMatchesFilters(record, map[string][]string{"vpc-id": {"vpc-aaa"}}))
|
||||
assert.False(t, rtbMatchesFilters(record, map[string][]string{"vpc-id": {"vpc-bbb"}}))
|
||||
}
|
||||
|
||||
func TestMatchesFilters_RouteTableId(t *testing.T) {
|
||||
func TestRtbMatchesFilters_RouteTableId(t *testing.T) {
|
||||
record := &RouteTableRecord{RouteTableId: "rtb-xyz", VpcId: "vpc-1"}
|
||||
assert.True(t, matchesFilters(record, map[string][]string{"route-table-id": {"rtb-xyz"}}))
|
||||
assert.False(t, matchesFilters(record, map[string][]string{"route-table-id": {"rtb-other"}}))
|
||||
assert.True(t, rtbMatchesFilters(record, map[string][]string{"route-table-id": {"rtb-xyz"}}))
|
||||
assert.False(t, rtbMatchesFilters(record, map[string][]string{"route-table-id": {"rtb-other"}}))
|
||||
}
|
||||
|
||||
func TestMatchesFilters_AssociationMain_True(t *testing.T) {
|
||||
func TestRtbMatchesFilters_AssociationMain_True(t *testing.T) {
|
||||
record := &RouteTableRecord{
|
||||
Associations: []AssociationRecord{
|
||||
{AssociationId: "rtbassoc-1", Main: true},
|
||||
},
|
||||
}
|
||||
assert.True(t, matchesFilters(record, map[string][]string{"association.main": {"true"}}))
|
||||
assert.False(t, matchesFilters(record, map[string][]string{"association.main": {"false"}}))
|
||||
assert.True(t, rtbMatchesFilters(record, map[string][]string{"association.main": {"true"}}))
|
||||
assert.False(t, rtbMatchesFilters(record, map[string][]string{"association.main": {"false"}}))
|
||||
}
|
||||
|
||||
func TestMatchesFilters_AssociationMain_False(t *testing.T) {
|
||||
func TestRtbMatchesFilters_AssociationMain_False(t *testing.T) {
|
||||
record := &RouteTableRecord{
|
||||
Associations: []AssociationRecord{
|
||||
{AssociationId: "rtbassoc-1", Main: false},
|
||||
},
|
||||
}
|
||||
assert.False(t, matchesFilters(record, map[string][]string{"association.main": {"true"}}))
|
||||
assert.False(t, rtbMatchesFilters(record, map[string][]string{"association.main": {"true"}}))
|
||||
}
|
||||
|
||||
func TestMatchesFilters_AssociationId(t *testing.T) {
|
||||
func TestRtbMatchesFilters_AssociationId(t *testing.T) {
|
||||
record := &RouteTableRecord{
|
||||
Associations: []AssociationRecord{
|
||||
{AssociationId: "rtbassoc-abc"},
|
||||
},
|
||||
}
|
||||
assert.True(t, matchesFilters(record, map[string][]string{
|
||||
assert.True(t, rtbMatchesFilters(record, map[string][]string{
|
||||
"association.route-table-association-id": {"rtbassoc-abc"},
|
||||
}))
|
||||
assert.False(t, matchesFilters(record, map[string][]string{
|
||||
assert.False(t, rtbMatchesFilters(record, map[string][]string{
|
||||
"association.route-table-association-id": {"rtbassoc-other"},
|
||||
}))
|
||||
}
|
||||
|
||||
func TestMatchesFilters_AssociationSubnetId(t *testing.T) {
|
||||
func TestRtbMatchesFilters_AssociationSubnetId(t *testing.T) {
|
||||
record := &RouteTableRecord{
|
||||
Associations: []AssociationRecord{
|
||||
{AssociationId: "rtbassoc-1", SubnetId: "subnet-aaa"},
|
||||
},
|
||||
}
|
||||
assert.True(t, matchesFilters(record, map[string][]string{
|
||||
assert.True(t, rtbMatchesFilters(record, map[string][]string{
|
||||
"association.subnet-id": {"subnet-aaa"},
|
||||
}))
|
||||
assert.False(t, matchesFilters(record, map[string][]string{
|
||||
assert.False(t, rtbMatchesFilters(record, map[string][]string{
|
||||
"association.subnet-id": {"subnet-bbb"},
|
||||
}))
|
||||
}
|
||||
|
||||
func TestMatchesFilters_RouteDestinationCidr(t *testing.T) {
|
||||
func TestRtbMatchesFilters_RouteDestinationCidr(t *testing.T) {
|
||||
record := &RouteTableRecord{
|
||||
Routes: []RouteRecord{
|
||||
{DestinationCidrBlock: "10.0.0.0/16", GatewayId: "local", State: "active"},
|
||||
{DestinationCidrBlock: "0.0.0.0/0", GatewayId: "igw-123", State: "active"},
|
||||
},
|
||||
}
|
||||
assert.True(t, matchesFilters(record, map[string][]string{
|
||||
assert.True(t, rtbMatchesFilters(record, map[string][]string{
|
||||
"route.destination-cidr-block": {"0.0.0.0/0"},
|
||||
}))
|
||||
assert.False(t, matchesFilters(record, map[string][]string{
|
||||
assert.False(t, rtbMatchesFilters(record, map[string][]string{
|
||||
"route.destination-cidr-block": {"192.168.0.0/16"},
|
||||
}))
|
||||
}
|
||||
|
||||
func TestMatchesFilters_RouteGatewayId(t *testing.T) {
|
||||
func TestRtbMatchesFilters_RouteGatewayId(t *testing.T) {
|
||||
record := &RouteTableRecord{
|
||||
Routes: []RouteRecord{
|
||||
{DestinationCidrBlock: "0.0.0.0/0", GatewayId: "igw-abc", State: "active"},
|
||||
},
|
||||
}
|
||||
assert.True(t, matchesFilters(record, map[string][]string{
|
||||
assert.True(t, rtbMatchesFilters(record, map[string][]string{
|
||||
"route.gateway-id": {"igw-abc"},
|
||||
}))
|
||||
assert.False(t, matchesFilters(record, map[string][]string{
|
||||
assert.False(t, rtbMatchesFilters(record, map[string][]string{
|
||||
"route.gateway-id": {"igw-other"},
|
||||
}))
|
||||
}
|
||||
|
||||
func TestMatchesFilters_MultipleFilters(t *testing.T) {
|
||||
func TestRtbMatchesFilters_MultipleFilters(t *testing.T) {
|
||||
record := &RouteTableRecord{
|
||||
RouteTableId: "rtb-1",
|
||||
VpcId: "vpc-aaa",
|
||||
@@ -130,38 +112,69 @@ func TestMatchesFilters_MultipleFilters(t *testing.T) {
|
||||
},
|
||||
}
|
||||
// Both match -> true
|
||||
assert.True(t, matchesFilters(record, map[string][]string{
|
||||
assert.True(t, rtbMatchesFilters(record, map[string][]string{
|
||||
"vpc-id": {"vpc-aaa"},
|
||||
"route.gateway-id": {"igw-123"},
|
||||
}))
|
||||
// One mismatches -> false
|
||||
assert.False(t, matchesFilters(record, map[string][]string{
|
||||
assert.False(t, rtbMatchesFilters(record, map[string][]string{
|
||||
"vpc-id": {"vpc-aaa"},
|
||||
"route.gateway-id": {"igw-other"},
|
||||
}))
|
||||
}
|
||||
|
||||
func TestMatchesFilters_MultipleValues(t *testing.T) {
|
||||
func TestRtbMatchesFilters_MultipleValues(t *testing.T) {
|
||||
record := &RouteTableRecord{VpcId: "vpc-bbb"}
|
||||
assert.True(t, matchesFilters(record, map[string][]string{
|
||||
assert.True(t, rtbMatchesFilters(record, map[string][]string{
|
||||
"vpc-id": {"vpc-aaa", "vpc-bbb", "vpc-ccc"},
|
||||
}))
|
||||
}
|
||||
|
||||
func TestMatchesFilters_NoAssociations(t *testing.T) {
|
||||
func TestRtbMatchesFilters_NoAssociations(t *testing.T) {
|
||||
record := &RouteTableRecord{RouteTableId: "rtb-1"}
|
||||
assert.False(t, matchesFilters(record, map[string][]string{
|
||||
assert.False(t, rtbMatchesFilters(record, map[string][]string{
|
||||
"association.subnet-id": {"subnet-1"},
|
||||
}))
|
||||
}
|
||||
|
||||
func TestMatchesFilters_NoRoutes(t *testing.T) {
|
||||
func TestRtbMatchesFilters_NoRoutes(t *testing.T) {
|
||||
record := &RouteTableRecord{RouteTableId: "rtb-1"}
|
||||
assert.False(t, matchesFilters(record, map[string][]string{
|
||||
assert.False(t, rtbMatchesFilters(record, map[string][]string{
|
||||
"route.destination-cidr-block": {"0.0.0.0/0"},
|
||||
}))
|
||||
}
|
||||
|
||||
func TestRtbMatchesFilters_UnknownFilter(t *testing.T) {
|
||||
record := &RouteTableRecord{RouteTableId: "rtb-1", VpcId: "vpc-1"}
|
||||
assert.False(t, rtbMatchesFilters(record, map[string][]string{
|
||||
"bogus-filter": {"value"},
|
||||
}))
|
||||
}
|
||||
|
||||
func TestRtbMatchesFilters_Wildcard(t *testing.T) {
|
||||
record := &RouteTableRecord{RouteTableId: "rtb-1", VpcId: "vpc-aaa"}
|
||||
assert.True(t, rtbMatchesFilters(record, map[string][]string{
|
||||
"vpc-id": {"vpc-*"},
|
||||
}))
|
||||
}
|
||||
|
||||
func TestRtbMatchesFilters_TagFilter(t *testing.T) {
|
||||
record := &RouteTableRecord{
|
||||
RouteTableId: "rtb-1",
|
||||
VpcId: "vpc-1",
|
||||
Tags: map[string]string{"Name": "my-rt", "env": "prod"},
|
||||
}
|
||||
assert.True(t, rtbMatchesFilters(record, map[string][]string{
|
||||
"tag:Name": {"my-rt"},
|
||||
}))
|
||||
assert.False(t, rtbMatchesFilters(record, map[string][]string{
|
||||
"tag:Name": {"other"},
|
||||
}))
|
||||
assert.False(t, rtbMatchesFilters(record, map[string][]string{
|
||||
"tag:missing": {"value"},
|
||||
}))
|
||||
}
|
||||
|
||||
// --- recordToEC2 ---
|
||||
|
||||
func TestRecordToEC2_Basic(t *testing.T) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -13,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_igw "github.com/mulgadc/spinifex/spinifex/handlers/ec2/igw"
|
||||
handlers_ec2_natgw "github.com/mulgadc/spinifex/spinifex/handlers/ec2/natgw"
|
||||
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
|
||||
@@ -257,6 +257,20 @@ func (s *RouteTableServiceImpl) DeleteRouteTable(input *ec2.DeleteRouteTableInpu
|
||||
return &ec2.DeleteRouteTableOutput{}, nil
|
||||
}
|
||||
|
||||
var describeRouteTablesValidFilters = map[string]bool{
|
||||
"vpc-id": true,
|
||||
"route-table-id": true,
|
||||
"association.main": true,
|
||||
"association.route-table-association-id": true,
|
||||
"association.subnet-id": true,
|
||||
"route.destination-cidr-block": true,
|
||||
"route.gateway-id": true,
|
||||
"route.nat-gateway-id": true,
|
||||
"route.state": true,
|
||||
"route.origin": true,
|
||||
"owner-id": true,
|
||||
}
|
||||
|
||||
// DescribeRouteTables lists route tables, optionally filtered
|
||||
func (s *RouteTableServiceImpl) DescribeRouteTables(input *ec2.DescribeRouteTablesInput, accountID string) (*ec2.DescribeRouteTablesOutput, error) {
|
||||
rtbIDs := make(map[string]bool)
|
||||
@@ -266,16 +280,10 @@ func (s *RouteTableServiceImpl) DescribeRouteTables(input *ec2.DescribeRouteTabl
|
||||
}
|
||||
}
|
||||
|
||||
// Build filter map
|
||||
filters := make(map[string][]string)
|
||||
for _, f := range input.Filters {
|
||||
if f.Name != nil {
|
||||
for _, v := range f.Values {
|
||||
if v != nil {
|
||||
filters[*f.Name] = append(filters[*f.Name], *v)
|
||||
}
|
||||
}
|
||||
}
|
||||
parsedFilters, err := filterutil.ParseFilters(input.Filters, describeRouteTablesValidFilters)
|
||||
if err != nil {
|
||||
slog.Warn("DescribeRouteTables: invalid filter", "err", err)
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
prefix := accountID + "."
|
||||
@@ -311,7 +319,7 @@ func (s *RouteTableServiceImpl) DescribeRouteTables(input *ec2.DescribeRouteTabl
|
||||
continue
|
||||
}
|
||||
|
||||
if !matchesFilters(&record, filters) {
|
||||
if !rtbMatchesFilters(&record, parsedFilters) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -756,20 +764,22 @@ func (s *RouteTableServiceImpl) ReplaceRouteTableAssociation(input *ec2.ReplaceR
|
||||
return nil, errors.New(awserrors.ErrorInvalidAssociationIDNotFound)
|
||||
}
|
||||
|
||||
// matchesFilters checks if a route table record matches the given DescribeRouteTables filters
|
||||
func matchesFilters(record *RouteTableRecord, filters map[string][]string) bool {
|
||||
// rtbMatchesFilters checks if a route table record matches all parsed filters.
|
||||
func rtbMatchesFilters(record *RouteTableRecord, filters map[string][]string) bool {
|
||||
for name, values := range filters {
|
||||
if strings.HasPrefix(name, "tag:") {
|
||||
continue
|
||||
}
|
||||
switch name {
|
||||
case "vpc-id":
|
||||
if !containsString(values, record.VpcId) {
|
||||
if !filterutil.MatchesAny(values, record.VpcId) {
|
||||
return false
|
||||
}
|
||||
case "route-table-id":
|
||||
if !containsString(values, record.RouteTableId) {
|
||||
if !filterutil.MatchesAny(values, record.RouteTableId) {
|
||||
return false
|
||||
}
|
||||
case "association.main":
|
||||
wantMain := containsString(values, "true")
|
||||
hasMain := false
|
||||
for _, a := range record.Associations {
|
||||
if a.Main {
|
||||
@@ -777,13 +787,14 @@ func matchesFilters(record *RouteTableRecord, filters map[string][]string) bool
|
||||
break
|
||||
}
|
||||
}
|
||||
wantMain := filterutil.MatchesAny(values, "true")
|
||||
if wantMain != hasMain {
|
||||
return false
|
||||
}
|
||||
case "association.route-table-association-id":
|
||||
found := false
|
||||
for _, a := range record.Associations {
|
||||
if containsString(values, a.AssociationId) {
|
||||
if filterutil.MatchesAny(values, a.AssociationId) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
@@ -794,7 +805,7 @@ func matchesFilters(record *RouteTableRecord, filters map[string][]string) bool
|
||||
case "association.subnet-id":
|
||||
found := false
|
||||
for _, a := range record.Associations {
|
||||
if containsString(values, a.SubnetId) {
|
||||
if filterutil.MatchesAny(values, a.SubnetId) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
@@ -805,7 +816,7 @@ func matchesFilters(record *RouteTableRecord, filters map[string][]string) bool
|
||||
case "route.destination-cidr-block":
|
||||
found := false
|
||||
for _, r := range record.Routes {
|
||||
if containsString(values, r.DestinationCidrBlock) {
|
||||
if filterutil.MatchesAny(values, r.DestinationCidrBlock) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
@@ -816,7 +827,7 @@ func matchesFilters(record *RouteTableRecord, filters map[string][]string) bool
|
||||
case "route.gateway-id":
|
||||
found := false
|
||||
for _, r := range record.Routes {
|
||||
if containsString(values, r.GatewayId) {
|
||||
if filterutil.MatchesAny(values, r.GatewayId) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
@@ -824,13 +835,48 @@ func matchesFilters(record *RouteTableRecord, filters map[string][]string) bool
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
case "route.nat-gateway-id":
|
||||
found := false
|
||||
for _, r := range record.Routes {
|
||||
if filterutil.MatchesAny(values, r.NatGatewayId) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
case "route.state":
|
||||
found := false
|
||||
for _, r := range record.Routes {
|
||||
if filterutil.MatchesAny(values, r.State) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
case "route.origin":
|
||||
found := false
|
||||
for _, r := range record.Routes {
|
||||
if filterutil.MatchesAny(values, r.Origin) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
case "owner-id":
|
||||
if !filterutil.MatchesAny(values, record.AccountID) {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func containsString(slice []string, s string) bool {
|
||||
return slices.Contains(slice, s)
|
||||
return filterutil.MatchesTags(filters, record.Tags)
|
||||
}
|
||||
|
||||
// publishNatGatewayEvents publishes vpc.add-nat-gateway events for each subnet
|
||||
@@ -914,13 +960,7 @@ func recordToEC2(record *RouteTableRecord) *ec2.RouteTable {
|
||||
rtb.Associations = append(rtb.Associations, assoc)
|
||||
}
|
||||
|
||||
if len(record.Tags) > 0 {
|
||||
tags := make([]*ec2.Tag, 0, len(record.Tags))
|
||||
for k, v := range record.Tags {
|
||||
tags = append(tags, &ec2.Tag{Key: aws.String(k), Value: aws.String(v)})
|
||||
}
|
||||
rtb.Tags = tags
|
||||
}
|
||||
rtb.Tags = utils.MapToEC2Tags(record.Tags)
|
||||
|
||||
return rtb
|
||||
}
|
||||
|
||||
@@ -2,16 +2,14 @@ package handlers_ec2_routetable
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_igw "github.com/mulgadc/spinifex/spinifex/handlers/ec2/igw"
|
||||
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -20,44 +18,22 @@ const testAccountID = "123456789012"
|
||||
|
||||
func setupTestService(t *testing.T) *RouteTableServiceImpl {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
|
||||
js, err := nc.JetStream()
|
||||
require.NoError(t, err)
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
|
||||
// Seed VPC KV
|
||||
vpcKV, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: handlers_ec2_vpc.KVBucketVPCs, History: 1})
|
||||
require.NoError(t, err)
|
||||
_, err = vpcKV.Put(utils.AccountKey(testAccountID, "vpc-test1"), []byte(`{"vpc_id":"vpc-test1","cidr_block":"10.0.0.0/16","state":"available"}`))
|
||||
require.NoError(t, err)
|
||||
testutil.SeedKV(t, js, handlers_ec2_vpc.KVBucketVPCs, map[string][]byte{
|
||||
utils.AccountKey(testAccountID, "vpc-test1"): []byte(`{"vpc_id":"vpc-test1","cidr_block":"10.0.0.0/16","state":"available"}`),
|
||||
})
|
||||
|
||||
// Seed IGW KV (attached to vpc-test1)
|
||||
igwKV, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: handlers_ec2_igw.KVBucketIGW, History: 1})
|
||||
require.NoError(t, err)
|
||||
_, err = igwKV.Put(utils.AccountKey(testAccountID, "igw-test1"), []byte(`{"internet_gateway_id":"igw-test1","vpc_id":"vpc-test1","state":"attached"}`))
|
||||
require.NoError(t, err)
|
||||
testutil.SeedKV(t, js, handlers_ec2_igw.KVBucketIGW, map[string][]byte{
|
||||
utils.AccountKey(testAccountID, "igw-test1"): []byte(`{"internet_gateway_id":"igw-test1","vpc_id":"vpc-test1","state":"attached"}`),
|
||||
})
|
||||
|
||||
// Seed subnet KV
|
||||
subnetKV, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: handlers_ec2_vpc.KVBucketSubnets, History: 1})
|
||||
require.NoError(t, err)
|
||||
_, err = subnetKV.Put(utils.AccountKey(testAccountID, "subnet-test1"), []byte(`{"subnet_id":"subnet-test1","vpc_id":"vpc-test1","cidr_block":"10.0.1.0/24","state":"available"}`))
|
||||
require.NoError(t, err)
|
||||
testutil.SeedKV(t, js, handlers_ec2_vpc.KVBucketSubnets, map[string][]byte{
|
||||
utils.AccountKey(testAccountID, "subnet-test1"): []byte(`{"subnet_id":"subnet-test1","vpc_id":"vpc-test1","cidr_block":"10.0.1.0/24","state":"available"}`),
|
||||
})
|
||||
|
||||
svc, err := NewRouteTableServiceImplWithNATS(nil, nc)
|
||||
require.NoError(t, err)
|
||||
@@ -194,6 +170,87 @@ func TestDescribeRouteTables_FilterByMain(t *testing.T) {
|
||||
assert.True(t, *out.RouteTables[0].Associations[0].Main)
|
||||
}
|
||||
|
||||
func TestDescribeRouteTables_FilterByRouteState(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
createTestRtb(t, svc) // has local route with state=active
|
||||
|
||||
// Exact match
|
||||
out, err := svc.DescribeRouteTables(&ec2.DescribeRouteTablesInput{
|
||||
Filters: []*ec2.Filter{{Name: aws.String("route.state"), Values: []*string{aws.String("active")}}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.RouteTables, 1)
|
||||
|
||||
// Non-match
|
||||
out, err = svc.DescribeRouteTables(&ec2.DescribeRouteTablesInput{
|
||||
Filters: []*ec2.Filter{{Name: aws.String("route.state"), Values: []*string{aws.String("blackhole")}}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out.RouteTables)
|
||||
}
|
||||
|
||||
func TestDescribeRouteTables_FilterByRouteOrigin(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
createTestRtb(t, svc) // local route has origin=CreateRouteTable
|
||||
|
||||
out, err := svc.DescribeRouteTables(&ec2.DescribeRouteTablesInput{
|
||||
Filters: []*ec2.Filter{{Name: aws.String("route.origin"), Values: []*string{aws.String("CreateRouteTable")}}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.RouteTables, 1)
|
||||
|
||||
out, err = svc.DescribeRouteTables(&ec2.DescribeRouteTablesInput{
|
||||
Filters: []*ec2.Filter{{Name: aws.String("route.origin"), Values: []*string{aws.String("CreateRoute")}}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out.RouteTables)
|
||||
}
|
||||
|
||||
func TestDescribeRouteTables_FilterByRouteNatGatewayId(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
createTestRtb(t, svc) // no NAT GW route
|
||||
|
||||
// No match — no routes have a nat-gateway-id
|
||||
out, err := svc.DescribeRouteTables(&ec2.DescribeRouteTablesInput{
|
||||
Filters: []*ec2.Filter{{Name: aws.String("route.nat-gateway-id"), Values: []*string{aws.String("nat-000000")}}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out.RouteTables)
|
||||
|
||||
// Wildcard on empty field should not match
|
||||
out, err = svc.DescribeRouteTables(&ec2.DescribeRouteTablesInput{
|
||||
Filters: []*ec2.Filter{{Name: aws.String("route.nat-gateway-id"), Values: []*string{aws.String("nat-*")}}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out.RouteTables)
|
||||
}
|
||||
|
||||
func TestDescribeRouteTables_FilterByOwnerId(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
createTestRtb(t, svc)
|
||||
|
||||
// Exact match
|
||||
out, err := svc.DescribeRouteTables(&ec2.DescribeRouteTablesInput{
|
||||
Filters: []*ec2.Filter{{Name: aws.String("owner-id"), Values: []*string{aws.String(testAccountID)}}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.RouteTables, 1)
|
||||
|
||||
// Non-match
|
||||
out, err = svc.DescribeRouteTables(&ec2.DescribeRouteTablesInput{
|
||||
Filters: []*ec2.Filter{{Name: aws.String("owner-id"), Values: []*string{aws.String("999999999999")}}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out.RouteTables)
|
||||
|
||||
// Wildcard
|
||||
out, err = svc.DescribeRouteTables(&ec2.DescribeRouteTablesInput{
|
||||
Filters: []*ec2.Filter{{Name: aws.String("owner-id"), Values: []*string{aws.String("1234*")}}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.RouteTables, 1)
|
||||
}
|
||||
|
||||
func TestCreateRoute(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
rtbID := createTestRtb(t, svc)
|
||||
|
||||
@@ -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"
|
||||
@@ -267,23 +268,13 @@ func (s *SnapshotServiceImpl) CreateSnapshot(input *ec2.CreateSnapshotInput, acc
|
||||
Encrypted: volumeConfig.VolumeMetadata.IsEncrypted,
|
||||
OwnerID: accountID,
|
||||
AvailabilityZone: volumeConfig.VolumeMetadata.AvailabilityZone,
|
||||
Tags: make(map[string]string),
|
||||
Tags: utils.ExtractTags(input.TagSpecifications, "snapshot"),
|
||||
}
|
||||
|
||||
if input.Description != nil {
|
||||
snapshotCfg.Description = *input.Description
|
||||
}
|
||||
|
||||
for _, tagSpec := range input.TagSpecifications {
|
||||
if tagSpec.ResourceType != nil && *tagSpec.ResourceType == "snapshot" {
|
||||
for _, tag := range tagSpec.Tags {
|
||||
if tag.Key != nil && tag.Value != nil {
|
||||
snapshotCfg.Tags[*tag.Key] = *tag.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track the volume→snapshot dependency in KV before persisting to S3.
|
||||
// This ensures we never have an untracked snapshot in S3.
|
||||
if err := s.addSnapshotRef(volumeID, snapshotID); err != nil {
|
||||
@@ -302,6 +293,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 +316,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 +332,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 +351,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 +370,10 @@ func (s *SnapshotServiceImpl) DescribeSnapshots(input *ec2.DescribeSnapshotsInpu
|
||||
continue
|
||||
}
|
||||
|
||||
if len(parsedFilters) > 0 && !snapshotMatchesFilters(cfg, parsedFilters) {
|
||||
continue
|
||||
}
|
||||
|
||||
snapshots = append(snapshots, snapshotConfigToEC2(cfg))
|
||||
}
|
||||
|
||||
@@ -359,6 +384,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)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,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"
|
||||
)
|
||||
|
||||
@@ -96,15 +97,6 @@ func getTagsPrefix(accountID string) string {
|
||||
return "tags/" + accountID + "/"
|
||||
}
|
||||
|
||||
// collectFilterValues adds non-nil string pointer values to the target map
|
||||
func collectFilterValues(values []*string, target map[string]bool) {
|
||||
for _, v := range values {
|
||||
if v != nil {
|
||||
target[*v] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getResourceTags retrieves tags for a specific resource from S3
|
||||
func (s *TagsServiceImpl) getResourceTags(accountID, resourceID string) (map[string]string, error) {
|
||||
key := getTagsKey(accountID, resourceID)
|
||||
@@ -198,8 +190,25 @@ func (s *TagsServiceImpl) CreateTags(input *ec2.CreateTagsInput, accountID strin
|
||||
return &ec2.CreateTagsOutput{}, nil
|
||||
}
|
||||
|
||||
var describeTagsValidFilters = map[string]bool{
|
||||
"resource-id": true,
|
||||
"resource-type": true,
|
||||
"key": true,
|
||||
"value": true,
|
||||
}
|
||||
|
||||
// DescribeTags returns tags matching the specified filters
|
||||
func (s *TagsServiceImpl) DescribeTags(input *ec2.DescribeTagsInput, accountID string) (*ec2.DescribeTagsOutput, error) {
|
||||
var filters map[string][]string
|
||||
if input != nil {
|
||||
var err error
|
||||
filters, err = filterutil.ParseFilters(input.Filters, describeTagsValidFilters)
|
||||
if err != nil {
|
||||
slog.Warn("DescribeTags: invalid filter", "err", err)
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
}
|
||||
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
@@ -217,32 +226,6 @@ func (s *TagsServiceImpl) DescribeTags(input *ec2.DescribeTagsInput, accountID s
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
// Build filter maps for efficient lookup
|
||||
resourceIDFilter := make(map[string]bool)
|
||||
resourceTypeFilter := make(map[string]bool)
|
||||
keyFilter := make(map[string]bool)
|
||||
valueFilter := make(map[string]bool)
|
||||
|
||||
if input != nil {
|
||||
for _, filter := range input.Filters {
|
||||
if filter.Name == nil {
|
||||
continue
|
||||
}
|
||||
switch *filter.Name {
|
||||
case "resource-id":
|
||||
collectFilterValues(filter.Values, resourceIDFilter)
|
||||
case "resource-type":
|
||||
collectFilterValues(filter.Values, resourceTypeFilter)
|
||||
case "key":
|
||||
collectFilterValues(filter.Values, keyFilter)
|
||||
case "value":
|
||||
collectFilterValues(filter.Values, valueFilter)
|
||||
default:
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process each tag file
|
||||
for _, obj := range listResult.Contents {
|
||||
if obj.Key == nil {
|
||||
@@ -254,13 +237,10 @@ func (s *TagsServiceImpl) DescribeTags(input *ec2.DescribeTagsInput, accountID s
|
||||
resourceID = strings.TrimSuffix(resourceID, ".json")
|
||||
resourceType := getResourceType(resourceID)
|
||||
|
||||
// Apply resource-id filter
|
||||
if len(resourceIDFilter) > 0 && !resourceIDFilter[resourceID] {
|
||||
if !filterutil.MatchesAny(filters["resource-id"], resourceID) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply resource-type filter
|
||||
if len(resourceTypeFilter) > 0 && !resourceTypeFilter[resourceType] {
|
||||
if !filterutil.MatchesAny(filters["resource-type"], resourceType) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -272,13 +252,10 @@ func (s *TagsServiceImpl) DescribeTags(input *ec2.DescribeTagsInput, accountID s
|
||||
}
|
||||
|
||||
for key, value := range resourceTags {
|
||||
// Apply key filter
|
||||
if len(keyFilter) > 0 && !keyFilter[key] {
|
||||
if !filterutil.MatchesAny(filters["key"], key) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply value filter
|
||||
if len(valueFilter) > 0 && !valueFilter[value] {
|
||||
if !filterutil.MatchesAny(filters["value"], value) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -516,16 +722,7 @@ func (s *VolumeServiceImpl) getVolumeByID(volumeID string) (*volumeResult, error
|
||||
}
|
||||
}
|
||||
|
||||
if len(volMeta.Tags) > 0 {
|
||||
tags := make([]*ec2.Tag, 0, len(volMeta.Tags))
|
||||
for key, value := range volMeta.Tags {
|
||||
tags = append(tags, &ec2.Tag{
|
||||
Key: aws.String(key),
|
||||
Value: aws.String(value),
|
||||
})
|
||||
}
|
||||
volume.Tags = tags
|
||||
}
|
||||
volume.Tags = utils.MapToEC2Tags(volMeta.Tags)
|
||||
|
||||
return &volumeResult{volume: volume, tenantID: volMeta.TenantID}, nil
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -99,20 +100,10 @@ func (s *VPCServiceImpl) CreateNetworkInterface(input *ec2.CreateNetworkInterfac
|
||||
Description: description,
|
||||
Status: "available",
|
||||
SecurityGroupIds: sgIds,
|
||||
Tags: make(map[string]string),
|
||||
Tags: utils.ExtractTags(input.TagSpecifications, "network-interface"),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
for _, tagSpec := range input.TagSpecifications {
|
||||
if tagSpec.ResourceType != nil && *tagSpec.ResourceType == "network-interface" {
|
||||
for _, tag := range tagSpec.Tags {
|
||||
if tag.Key != nil && tag.Value != nil {
|
||||
record.Tags[*tag.Key] = *tag.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal ENI record: %w", err)
|
||||
@@ -225,8 +216,29 @@ func (s *VPCServiceImpl) ModifyNetworkInterfaceAttribute(input *ec2.ModifyNetwor
|
||||
return &ec2.ModifyNetworkInterfaceAttributeOutput{}, nil
|
||||
}
|
||||
|
||||
var describeNetworkInterfacesValidFilters = map[string]bool{
|
||||
"network-interface-id": true,
|
||||
"subnet-id": true,
|
||||
"vpc-id": true,
|
||||
"status": true,
|
||||
"private-ip-address": true,
|
||||
"availability-zone": true,
|
||||
"group-id": true,
|
||||
"mac-address": true,
|
||||
"description": true,
|
||||
"attachment.attachment-id": true,
|
||||
"attachment.instance-id": true,
|
||||
"attachment.status": true,
|
||||
}
|
||||
|
||||
// DescribeNetworkInterfaces lists ENIs with optional filters
|
||||
func (s *VPCServiceImpl) DescribeNetworkInterfaces(input *ec2.DescribeNetworkInterfacesInput, accountID string) (*ec2.DescribeNetworkInterfacesOutput, error) {
|
||||
parsedFilters, err := filterutil.ParseFilters(input.Filters, describeNetworkInterfacesValidFilters)
|
||||
if err != nil {
|
||||
slog.Warn("DescribeNetworkInterfaces: invalid filter", "err", err)
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
enis := make([]*ec2.NetworkInterface, 0)
|
||||
|
||||
eniIDs := make(map[string]bool)
|
||||
@@ -236,27 +248,6 @@ func (s *VPCServiceImpl) DescribeNetworkInterfaces(input *ec2.DescribeNetworkInt
|
||||
}
|
||||
}
|
||||
|
||||
// Extract filters
|
||||
subnetFilter := ""
|
||||
vpcFilter := ""
|
||||
descriptionFilter := ""
|
||||
attachmentInstanceFilter := ""
|
||||
for _, f := range input.Filters {
|
||||
if f.Name == nil || len(f.Values) == 0 || f.Values[0] == nil {
|
||||
continue
|
||||
}
|
||||
switch *f.Name {
|
||||
case "subnet-id":
|
||||
subnetFilter = *f.Values[0]
|
||||
case "vpc-id":
|
||||
vpcFilter = *f.Values[0]
|
||||
case "description":
|
||||
descriptionFilter = *f.Values[0]
|
||||
case "attachment.instance-id":
|
||||
attachmentInstanceFilter = *f.Values[0]
|
||||
}
|
||||
}
|
||||
|
||||
prefix := accountID + "."
|
||||
keys, err := s.eniKV.Keys()
|
||||
if err != nil && !errors.Is(err, nats.ErrNoKeysFound) {
|
||||
@@ -286,17 +277,7 @@ func (s *VPCServiceImpl) DescribeNetworkInterfaces(input *ec2.DescribeNetworkInt
|
||||
if len(eniIDs) > 0 && !eniIDs[record.NetworkInterfaceId] {
|
||||
continue
|
||||
}
|
||||
|
||||
if subnetFilter != "" && record.SubnetId != subnetFilter {
|
||||
continue
|
||||
}
|
||||
if vpcFilter != "" && record.VpcId != vpcFilter {
|
||||
continue
|
||||
}
|
||||
if descriptionFilter != "" && record.Description != descriptionFilter {
|
||||
continue
|
||||
}
|
||||
if attachmentInstanceFilter != "" && record.InstanceId != attachmentInstanceFilter {
|
||||
if !eniMatchesFilters(&record, parsedFilters) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -325,6 +306,79 @@ func (s *VPCServiceImpl) DescribeNetworkInterfaces(input *ec2.DescribeNetworkInt
|
||||
}, nil
|
||||
}
|
||||
|
||||
// eniMatchesFilters checks whether an ENI record matches all parsed filters.
|
||||
func eniMatchesFilters(record *ENIRecord, filters map[string][]string) bool {
|
||||
for name, values := range filters {
|
||||
if strings.HasPrefix(name, "tag:") {
|
||||
continue
|
||||
}
|
||||
switch name {
|
||||
case "network-interface-id":
|
||||
if !filterutil.MatchesAny(values, record.NetworkInterfaceId) {
|
||||
return false
|
||||
}
|
||||
case "subnet-id":
|
||||
if !filterutil.MatchesAny(values, record.SubnetId) {
|
||||
return false
|
||||
}
|
||||
case "vpc-id":
|
||||
if !filterutil.MatchesAny(values, record.VpcId) {
|
||||
return false
|
||||
}
|
||||
case "status":
|
||||
if !filterutil.MatchesAny(values, record.Status) {
|
||||
return false
|
||||
}
|
||||
case "private-ip-address":
|
||||
if !filterutil.MatchesAny(values, record.PrivateIpAddress) {
|
||||
return false
|
||||
}
|
||||
case "availability-zone":
|
||||
if !filterutil.MatchesAny(values, record.AvailabilityZone) {
|
||||
return false
|
||||
}
|
||||
case "group-id":
|
||||
found := false
|
||||
for _, sgId := range record.SecurityGroupIds {
|
||||
if filterutil.MatchesAny(values, sgId) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
case "mac-address":
|
||||
if !filterutil.MatchesAny(values, record.MacAddress) {
|
||||
return false
|
||||
}
|
||||
case "description":
|
||||
if !filterutil.MatchesAny(values, record.Description) {
|
||||
return false
|
||||
}
|
||||
case "attachment.attachment-id":
|
||||
if !filterutil.MatchesAny(values, record.AttachmentId) {
|
||||
return false
|
||||
}
|
||||
case "attachment.instance-id":
|
||||
if !filterutil.MatchesAny(values, record.InstanceId) {
|
||||
return false
|
||||
}
|
||||
case "attachment.status":
|
||||
status := "detached"
|
||||
if record.InstanceId != "" {
|
||||
status = "attached"
|
||||
}
|
||||
if !filterutil.MatchesAny(values, status) {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return filterutil.MatchesTags(filters, record.Tags)
|
||||
}
|
||||
|
||||
// AttachENI marks an ENI as attached to an instance (internal use by RunInstances).
|
||||
// accountID scopes the lookup to the correct KV key.
|
||||
func (s *VPCServiceImpl) AttachENI(accountID, eniId, instanceId string, deviceIndex int64) (string, error) {
|
||||
@@ -473,13 +527,7 @@ func (s *VPCServiceImpl) eniRecordToEC2(record *ENIRecord, accountID string) *ec
|
||||
}
|
||||
}
|
||||
|
||||
if len(record.Tags) > 0 {
|
||||
tags := make([]*ec2.Tag, 0, len(record.Tags))
|
||||
for k, v := range record.Tags {
|
||||
tags = append(tags, &ec2.Tag{Key: aws.String(k), Value: aws.String(v)})
|
||||
}
|
||||
eni.TagSet = tags
|
||||
}
|
||||
eni.TagSet = utils.MapToEC2Tags(record.Tags)
|
||||
|
||||
return eni
|
||||
}
|
||||
@@ -495,10 +543,7 @@ func generateENIMac(eniId string) string {
|
||||
|
||||
// publishENIEvent publishes an ENI lifecycle event to NATS for vpcd consumption.
|
||||
func (s *VPCServiceImpl) publishENIEvent(topic, eniId, subnetId, vpcId, privateIP, macAddr string) {
|
||||
if s.natsConn == nil {
|
||||
return
|
||||
}
|
||||
evt := struct {
|
||||
utils.PublishEvent(s.natsConn, topic, struct {
|
||||
NetworkInterfaceId string `json:"network_interface_id"`
|
||||
SubnetId string `json:"subnet_id"`
|
||||
VpcId string `json:"vpc_id"`
|
||||
@@ -510,14 +555,5 @@ func (s *VPCServiceImpl) publishENIEvent(topic, eniId, subnetId, vpcId, privateI
|
||||
VpcId: vpcId,
|
||||
PrivateIpAddress: privateIP,
|
||||
MacAddress: macAddr,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal ENI event", "topic", topic, "err", err)
|
||||
return
|
||||
}
|
||||
if err := s.natsConn.Publish(topic, data); err != nil {
|
||||
slog.Error("Failed to publish ENI event", "topic", topic, "err", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -524,3 +524,242 @@ func TestCreateNetworkInterface_WithSecurityGroups(t *testing.T) {
|
||||
assert.Equal(t, "sg-aaa", *out.NetworkInterface.Groups[0].GroupId)
|
||||
assert.Equal(t, "sg-bbb", *out.NetworkInterface.Groups[1].GroupId)
|
||||
}
|
||||
|
||||
func TestDescribeNetworkInterfaces_FilterByNetworkInterfaceId(t *testing.T) {
|
||||
svc := setupTestVPCService(t)
|
||||
vpcId := createTestVPC(t, svc, "10.0.0.0/16")
|
||||
subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24")
|
||||
|
||||
eniId := createTestENI(t, svc, subnetId)
|
||||
createTestENI(t, svc, subnetId)
|
||||
|
||||
out, err := svc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("network-interface-id"), Values: []*string{aws.String(eniId)}},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.NetworkInterfaces, 1)
|
||||
assert.Equal(t, eniId, *out.NetworkInterfaces[0].NetworkInterfaceId)
|
||||
|
||||
// Wildcard
|
||||
out, err = svc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("network-interface-id"), Values: []*string{aws.String("eni-*")}},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.NetworkInterfaces, 2)
|
||||
}
|
||||
|
||||
func TestDescribeNetworkInterfaces_FilterByStatus(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")
|
||||
|
||||
eni1 := createTestENI(t, svc, subnetId)
|
||||
createTestENI(t, svc, subnetId) // stays available
|
||||
|
||||
_, err := svc.AttachENI(testAccountID, eni1, "i-test", 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
out, err := svc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("status"), Values: []*string{aws.String("in-use")}},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.NetworkInterfaces, 1)
|
||||
assert.Equal(t, eni1, *out.NetworkInterfaces[0].NetworkInterfaceId)
|
||||
|
||||
out, err = svc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("status"), Values: []*string{aws.String("available")}},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.NetworkInterfaces, 1)
|
||||
}
|
||||
|
||||
func TestDescribeNetworkInterfaces_FilterByPrivateIpAddress(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")
|
||||
|
||||
createTestENI(t, svc, subnetId) // 10.0.1.4
|
||||
createTestENI(t, svc, subnetId) // 10.0.1.5
|
||||
|
||||
out, err := svc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("private-ip-address"), Values: []*string{aws.String("10.0.1.4")}},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.NetworkInterfaces, 1)
|
||||
assert.Equal(t, "10.0.1.4", *out.NetworkInterfaces[0].PrivateIpAddress)
|
||||
|
||||
// Wildcard
|
||||
out, err = svc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("private-ip-address"), Values: []*string{aws.String("10.0.1.*")}},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.NetworkInterfaces, 2)
|
||||
|
||||
// Non-match
|
||||
out, err = svc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("private-ip-address"), Values: []*string{aws.String("192.168.0.1")}},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out.NetworkInterfaces)
|
||||
}
|
||||
|
||||
func TestDescribeNetworkInterfaces_FilterByAvailabilityZone(t *testing.T) {
|
||||
svc := setupTestVPCService(t)
|
||||
vpcId := createTestVPC(t, svc, "10.0.0.0/16")
|
||||
// Create subnet with explicit AZ
|
||||
subnetOut, err := svc.CreateSubnet(&ec2.CreateSubnetInput{
|
||||
VpcId: aws.String(vpcId),
|
||||
CidrBlock: aws.String("10.0.1.0/24"),
|
||||
AvailabilityZone: aws.String("ap-southeast-2a"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
subnetId := *subnetOut.Subnet.SubnetId
|
||||
|
||||
createTestENI(t, svc, subnetId)
|
||||
|
||||
out, err := svc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("availability-zone"), Values: []*string{aws.String("ap-southeast-2a")}},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.NetworkInterfaces, 1)
|
||||
|
||||
out, err = svc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("availability-zone"), Values: []*string{aws.String("us-east-1a")}},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out.NetworkInterfaces)
|
||||
}
|
||||
|
||||
func TestDescribeNetworkInterfaces_FilterByGroupId(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")
|
||||
|
||||
// Create ENI with security groups
|
||||
out, err := svc.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
|
||||
SubnetId: aws.String(subnetId),
|
||||
Groups: []*string{aws.String("sg-aaa"), aws.String("sg-bbb")},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
eniId := *out.NetworkInterface.NetworkInterfaceId
|
||||
|
||||
createTestENI(t, svc, subnetId) // no SGs
|
||||
|
||||
// Match one of the SGs
|
||||
desc, err := svc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("group-id"), Values: []*string{aws.String("sg-bbb")}},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, desc.NetworkInterfaces, 1)
|
||||
assert.Equal(t, eniId, *desc.NetworkInterfaces[0].NetworkInterfaceId)
|
||||
|
||||
// Non-match
|
||||
desc, err = svc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("group-id"), Values: []*string{aws.String("sg-zzz")}},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, desc.NetworkInterfaces)
|
||||
}
|
||||
|
||||
func TestDescribeNetworkInterfaces_FilterByMacAddress(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")
|
||||
|
||||
outCreate, err := svc.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
|
||||
SubnetId: aws.String(subnetId),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
mac := *outCreate.NetworkInterface.MacAddress
|
||||
|
||||
out, err := svc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("mac-address"), Values: []*string{aws.String(mac)}},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.NetworkInterfaces, 1)
|
||||
|
||||
out, err = svc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("mac-address"), Values: []*string{aws.String("ff:ff:ff:ff:ff:ff")}},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out.NetworkInterfaces)
|
||||
}
|
||||
|
||||
func TestDescribeNetworkInterfaces_FilterByAttachmentId(t *testing.T) {
|
||||
svc := setupTestVPCService(t)
|
||||
vpcId := createTestVPC(t, svc, "10.0.0.0/16")
|
||||
subnetId := createTestSubnet(t, svc, vpcId, "10.0.1.0/24")
|
||||
|
||||
eniId := createTestENI(t, svc, subnetId)
|
||||
attachId, err := svc.AttachENI(testAccountID, eniId, "i-test", 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
createTestENI(t, svc, subnetId) // not attached
|
||||
|
||||
out, err := svc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("attachment.attachment-id"), Values: []*string{aws.String(attachId)}},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.NetworkInterfaces, 1)
|
||||
assert.Equal(t, eniId, *out.NetworkInterfaces[0].NetworkInterfaceId)
|
||||
}
|
||||
|
||||
func TestDescribeNetworkInterfaces_FilterByAttachmentStatus(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")
|
||||
|
||||
eni1 := createTestENI(t, svc, subnetId)
|
||||
createTestENI(t, svc, subnetId) // stays detached
|
||||
|
||||
_, err := svc.AttachENI(testAccountID, eni1, "i-test", 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Filter attached
|
||||
out, err := svc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("attachment.status"), Values: []*string{aws.String("attached")}},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.NetworkInterfaces, 1)
|
||||
assert.Equal(t, eni1, *out.NetworkInterfaces[0].NetworkInterfaceId)
|
||||
|
||||
// Filter detached
|
||||
out, err = svc.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: aws.String("attachment.status"), Values: []*string{aws.String("detached")}},
|
||||
},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.NetworkInterfaces, 1)
|
||||
assert.NotEqual(t, eni1, *out.NetworkInterfaces[0].NetworkInterfaceId)
|
||||
}
|
||||
|
||||
@@ -3,36 +3,15 @@ package handlers_ec2_vpc
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupTestExternalIPAM(t *testing.T, pools []ExternalPoolConfig) *ExternalIPAM {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
|
||||
js, err := nc.JetStream()
|
||||
require.NoError(t, err)
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
|
||||
ipam, err := NewExternalIPAM(js, pools)
|
||||
require.NoError(t, err)
|
||||
@@ -355,26 +334,7 @@ func TestExternalIPAM_RangeValidation(t *testing.T) {
|
||||
func TestExternalIPAM_InitFromConfig(t *testing.T) {
|
||||
pool := testPool()
|
||||
// Create IPAM twice — second init should be idempotent
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
|
||||
js, err := nc.JetStream()
|
||||
require.NoError(t, err)
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
|
||||
// First init
|
||||
ipam1, err := NewExternalIPAM(js, []ExternalPoolConfig{pool})
|
||||
|
||||
@@ -3,9 +3,8 @@ package handlers_ec2_vpc
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -13,26 +12,7 @@ import (
|
||||
|
||||
func setupTestIPAM(t *testing.T) *IPAM {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
|
||||
js, err := nc.JetStream()
|
||||
require.NoError(t, err)
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
|
||||
ipam, err := NewIPAM(js)
|
||||
require.NoError(t, err)
|
||||
@@ -186,26 +166,7 @@ func TestIPAM_LargerSubnet(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIPAM_NewIPAMWithKV(t *testing.T) {
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
|
||||
js, err := nc.JetStream()
|
||||
require.NoError(t, err)
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
|
||||
kv, err := js.CreateKeyValue(&nats.KeyValueConfig{
|
||||
Bucket: "test-ipam-kv",
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -111,20 +112,10 @@ func (s *VPCServiceImpl) CreateSecurityGroup(input *ec2.CreateSecurityGroupInput
|
||||
VpcId: vpcId,
|
||||
IngressRules: []SGRule{},
|
||||
EgressRules: defaultEgress,
|
||||
Tags: make(map[string]string),
|
||||
Tags: utils.ExtractTags(input.TagSpecifications, "security-group"),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
for _, tagSpec := range input.TagSpecifications {
|
||||
if tagSpec.ResourceType != nil && *tagSpec.ResourceType == "security-group" {
|
||||
for _, tag := range tagSpec.Tags {
|
||||
if tag.Key != nil && tag.Value != nil {
|
||||
record.Tags[*tag.Key] = *tag.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal security group record: %w", err)
|
||||
@@ -182,6 +173,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 +193,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 +228,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 +258,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 == "" {
|
||||
@@ -459,13 +494,7 @@ func (s *VPCServiceImpl) sgRecordToEC2(record *SecurityGroupRecord, accountID st
|
||||
IpPermissionsEgress: sgRulesToIpPermissions(record.EgressRules),
|
||||
}
|
||||
|
||||
if len(record.Tags) > 0 {
|
||||
tags := make([]*ec2.Tag, 0, len(record.Tags))
|
||||
for k, v := range record.Tags {
|
||||
tags = append(tags, &ec2.Tag{Key: aws.String(k), Value: aws.String(v)})
|
||||
}
|
||||
sg.Tags = tags
|
||||
}
|
||||
sg.Tags = utils.MapToEC2Tags(record.Tags)
|
||||
|
||||
return sg
|
||||
}
|
||||
@@ -593,15 +622,5 @@ func sgRuleKey(r SGRule) string {
|
||||
|
||||
// publishSGEvent publishes a security group lifecycle event to NATS for vpcd consumption.
|
||||
func (s *VPCServiceImpl) publishSGEvent(topic string, evt SGEvent) {
|
||||
if s.natsConn == nil {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal SG event", "topic", topic, "err", err)
|
||||
return
|
||||
}
|
||||
if err := s.natsConn.Publish(topic, data); err != nil {
|
||||
slog.Error("Failed to publish SG event", "topic", topic, "err", err)
|
||||
}
|
||||
utils.PublishEvent(s.natsConn, topic, evt)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -219,20 +220,10 @@ func (s *VPCServiceImpl) CreateVpc(input *ec2.CreateVpcInput, accountID string)
|
||||
VNI: vni,
|
||||
EnableDnsSupport: true, // AWS default
|
||||
EnableDnsHostnames: false, // AWS default
|
||||
Tags: make(map[string]string),
|
||||
Tags: utils.ExtractTags(input.TagSpecifications, "vpc"),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
for _, tagSpec := range input.TagSpecifications {
|
||||
if tagSpec.ResourceType != nil && *tagSpec.ResourceType == "vpc" {
|
||||
for _, tag := range tagSpec.Tags {
|
||||
if tag.Key != nil && tag.Value != nil {
|
||||
record.Tags[*tag.Key] = *tag.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal VPC record: %w", err)
|
||||
@@ -309,6 +300,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 +320,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 +356,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))
|
||||
}
|
||||
|
||||
@@ -474,20 +484,10 @@ func (s *VPCServiceImpl) CreateSubnet(input *ec2.CreateSubnetInput, accountID st
|
||||
AvailabilityZone: az,
|
||||
State: "available",
|
||||
IsDefault: false,
|
||||
Tags: make(map[string]string),
|
||||
Tags: utils.ExtractTags(input.TagSpecifications, "subnet"),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
for _, tagSpec := range input.TagSpecifications {
|
||||
if tagSpec.ResourceType != nil && *tagSpec.ResourceType == "subnet" {
|
||||
for _, tag := range tagSpec.Tags {
|
||||
if tag.Key != nil && tag.Value != nil {
|
||||
record.Tags[*tag.Key] = *tag.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal subnet record: %w", err)
|
||||
@@ -546,12 +546,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 +582,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 +619,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),
|
||||
@@ -642,13 +713,7 @@ func (s *VPCServiceImpl) vpcRecordToEC2(record *VPCRecord, accountID string) *ec
|
||||
InstanceTenancy: aws.String("default"),
|
||||
}
|
||||
|
||||
if len(record.Tags) > 0 {
|
||||
tags := make([]*ec2.Tag, 0, len(record.Tags))
|
||||
for k, v := range record.Tags {
|
||||
tags = append(tags, &ec2.Tag{Key: aws.String(k), Value: aws.String(v)})
|
||||
}
|
||||
vpc.Tags = tags
|
||||
}
|
||||
vpc.Tags = utils.MapToEC2Tags(record.Tags)
|
||||
|
||||
return vpc
|
||||
}
|
||||
@@ -666,13 +731,7 @@ func (s *VPCServiceImpl) subnetRecordToEC2(record *SubnetRecord, availableIPs in
|
||||
MapPublicIpOnLaunch: aws.Bool(record.MapPublicIpOnLaunch),
|
||||
}
|
||||
|
||||
if len(record.Tags) > 0 {
|
||||
tags := make([]*ec2.Tag, 0, len(record.Tags))
|
||||
for k, v := range record.Tags {
|
||||
tags = append(tags, &ec2.Tag{Key: aws.String(k), Value: aws.String(v)})
|
||||
}
|
||||
subnet.Tags = tags
|
||||
}
|
||||
subnet.Tags = utils.MapToEC2Tags(record.Tags)
|
||||
|
||||
return subnet
|
||||
}
|
||||
@@ -1065,42 +1124,18 @@ func (s *VPCServiceImpl) GetSubnet(accountID, subnetId string) (*SubnetRecord, e
|
||||
// publishVPCEvent publishes a VPC lifecycle event to NATS for vpcd consumption.
|
||||
// This is fire-and-forget; errors are logged but do not fail the API response.
|
||||
func (s *VPCServiceImpl) publishVPCEvent(topic, vpcId, cidrBlock string, vni int64) {
|
||||
if s.natsConn == nil {
|
||||
return
|
||||
}
|
||||
evt := struct {
|
||||
utils.PublishEvent(s.natsConn, topic, struct {
|
||||
VpcId string `json:"vpc_id"`
|
||||
CidrBlock string `json:"cidr_block"`
|
||||
VNI int64 `json:"vni"`
|
||||
}{VpcId: vpcId, CidrBlock: cidrBlock, VNI: vni}
|
||||
|
||||
data, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal VPC event", "topic", topic, "err", err)
|
||||
return
|
||||
}
|
||||
if err := s.natsConn.Publish(topic, data); err != nil {
|
||||
slog.Error("Failed to publish VPC event", "topic", topic, "err", err)
|
||||
}
|
||||
}{VpcId: vpcId, CidrBlock: cidrBlock, VNI: vni})
|
||||
}
|
||||
|
||||
// publishSubnetEvent publishes a subnet lifecycle event to NATS for vpcd consumption.
|
||||
func (s *VPCServiceImpl) publishSubnetEvent(topic, subnetId, vpcId, cidrBlock string) {
|
||||
if s.natsConn == nil {
|
||||
return
|
||||
}
|
||||
evt := struct {
|
||||
utils.PublishEvent(s.natsConn, topic, struct {
|
||||
SubnetId string `json:"subnet_id"`
|
||||
VpcId string `json:"vpc_id"`
|
||||
CidrBlock string `json:"cidr_block"`
|
||||
}{SubnetId: subnetId, VpcId: vpcId, CidrBlock: cidrBlock}
|
||||
|
||||
data, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal subnet event", "topic", topic, "err", err)
|
||||
return
|
||||
}
|
||||
if err := s.natsConn.Publish(topic, data); err != nil {
|
||||
slog.Error("Failed to publish subnet event", "topic", topic, "err", err)
|
||||
}
|
||||
}{SubnetId: subnetId, VpcId: vpcId, CidrBlock: cidrBlock})
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/config"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -18,23 +18,7 @@ const testAccountID = "123456789012"
|
||||
|
||||
func setupTestVPCServiceWithNC(t *testing.T) (*VPCServiceImpl, *nats.Conn) {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
svc, err := NewVPCServiceImplWithNATS(nil, nc)
|
||||
require.NoError(t, err)
|
||||
@@ -795,23 +779,7 @@ func TestDeleteSubnet_PublishesEvent(t *testing.T) {
|
||||
|
||||
func TestEnsureDefaultVPC_WithConfigAZ(t *testing.T) {
|
||||
// Create a service with custom config that has AZ set
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
cfg := &config.Config{AZ: "us-west-2b"}
|
||||
svc, err := NewVPCServiceImplWithNATS(cfg, nc)
|
||||
@@ -1214,3 +1182,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)
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@ package handlers_elbv2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -18,23 +16,7 @@ import (
|
||||
// and a simulated instance ENI (attached to an instance ID).
|
||||
func setupTestServiceWithInstance(t *testing.T, instanceID, instanceIP string) (*ELBv2ServiceImpl, *handlers_ec2_vpc.VPCServiceImpl, string) {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
vpcSvc, err := handlers_ec2_vpc.NewVPCServiceImplWithNATS(nil, nc)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -2,35 +2,17 @@ package handlers_elbv2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupTestService(t *testing.T) *ELBv2ServiceImpl {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
svc, err := NewELBv2ServiceImplWithNATS(nil, nc)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -2,14 +2,12 @@ package handlers_elbv2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -18,23 +16,7 @@ import (
|
||||
// with a pre-created VPC and subnet for ENI allocation testing.
|
||||
func setupTestServiceWithVPC(t *testing.T) (*ELBv2ServiceImpl, *handlers_ec2_vpc.VPCServiceImpl) {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
// Create VPC service
|
||||
vpcSvc, err := handlers_ec2_vpc.NewVPCServiceImplWithNATS(nil, nc)
|
||||
|
||||
@@ -3,12 +3,11 @@ package handlers_elbv2
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -19,23 +18,7 @@ import (
|
||||
func setupNATSELBv2Test(t *testing.T) (ELBv2Service, *ELBv2ServiceImpl) {
|
||||
t.Helper()
|
||||
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
backend, err := NewELBv2ServiceImplWithNATS(nil, nc)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -4,8 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -14,23 +13,7 @@ const testAccountID = "123456789012"
|
||||
|
||||
func setupTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
store, err := NewStore(nc)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -11,9 +11,8 @@ import (
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/admin"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -25,23 +24,7 @@ const testAccountID = utils.GlobalAccountID
|
||||
|
||||
func setupTestIAMService(t *testing.T) *IAMServiceImpl {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
masterKey, err := GenerateMasterKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -65,14 +65,7 @@ func (svc *Service) Stop() (err error) {
|
||||
}
|
||||
|
||||
func (svc *Service) Status() (string, error) {
|
||||
pid, err := utils.ReadPidFileFrom(svc.Config.NodeBaseDir(), serviceName)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "stopped", nil
|
||||
}
|
||||
return "", fmt.Errorf("read pid file: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("running (pid: %d)", pid), nil
|
||||
return utils.ServiceStatus(svc.Config.NodeBaseDir(), serviceName)
|
||||
}
|
||||
|
||||
func (svc *Service) Shutdown() (err error) {
|
||||
|
||||
@@ -52,14 +52,7 @@ func (svc *Service) Stop() (err error) {
|
||||
}
|
||||
|
||||
func (svc *Service) Status() (string, error) {
|
||||
pid, err := utils.ReadPidFileFrom(svc.Config.DataDir, serviceName)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "stopped", nil
|
||||
}
|
||||
return "", fmt.Errorf("read pid file: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("running (pid: %d)", pid), nil
|
||||
return utils.ServiceStatus(svc.Config.DataDir, serviceName)
|
||||
}
|
||||
|
||||
func (svc *Service) Shutdown() (err error) {
|
||||
|
||||
@@ -106,14 +106,7 @@ func (svc *Service) Stop() error {
|
||||
|
||||
// Status returns the status of the predastore service
|
||||
func (svc *Service) Status() (string, error) {
|
||||
pid, err := utils.ReadPidFileFrom(svc.Config.BasePath, serviceName)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "stopped", nil
|
||||
}
|
||||
return "", fmt.Errorf("read pid file: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("running (pid: %d)", pid), nil
|
||||
return utils.ServiceStatus(svc.Config.BasePath, serviceName)
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the predastore service
|
||||
|
||||
@@ -49,14 +49,7 @@ func (svc *Service) Stop() (err error) {
|
||||
}
|
||||
|
||||
func (svc *Service) Status() (string, error) {
|
||||
pid, err := utils.ReadPidFileFrom(svc.Config.NodeBaseDir(), serviceName)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "stopped", nil
|
||||
}
|
||||
return "", fmt.Errorf("read pid file: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("running (pid: %d)", pid), nil
|
||||
return utils.ServiceStatus(svc.Config.NodeBaseDir(), serviceName)
|
||||
}
|
||||
|
||||
func (svc *Service) Shutdown() (err error) {
|
||||
|
||||
@@ -59,10 +59,17 @@ func New(config any) (*Service, error) {
|
||||
cfg.Host = "0.0.0.0"
|
||||
}
|
||||
|
||||
// Default TLS paths from home directory if not specified
|
||||
// Default TLS paths: production layout (/etc/spinifex/) if it exists,
|
||||
// otherwise fall back to dev layout (~/spinifex/config/).
|
||||
if cfg.TLSCert == "" || cfg.TLSKey == "" {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
if info, err := os.Stat("/etc/spinifex"); err == nil && info.IsDir() {
|
||||
if cfg.TLSCert == "" {
|
||||
cfg.TLSCert = "/etc/spinifex/server.pem"
|
||||
}
|
||||
if cfg.TLSKey == "" {
|
||||
cfg.TLSKey = "/etc/spinifex/server.key"
|
||||
}
|
||||
} else if homeDir, err := os.UserHomeDir(); err == nil {
|
||||
if cfg.TLSCert == "" {
|
||||
cfg.TLSCert = filepath.Join(homeDir, "spinifex", "config", "server.pem")
|
||||
}
|
||||
@@ -98,14 +105,7 @@ func (svc *Service) Stop() error {
|
||||
|
||||
// Status returns the status of the spinifex-ui service
|
||||
func (svc *Service) Status() (string, error) {
|
||||
pid, err := utils.ReadPidFile(serviceName)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "stopped", nil
|
||||
}
|
||||
return "", fmt.Errorf("read pid file: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("running (pid: %d)", pid), nil
|
||||
return utils.ServiceStatus("", serviceName)
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the spinifex-ui service
|
||||
|
||||
@@ -86,10 +86,7 @@ func makeSnapshotHandler(vb *viperblock.VB, volumeName string) nats.MsgHandler {
|
||||
var snapRequest types.EBSSnapshotRequest
|
||||
if err := json.Unmarshal(msg.Data, &snapRequest); err != nil {
|
||||
slog.Error("Failed to unmarshal ebs.snapshot message", "volume", volumeName, "err", err)
|
||||
errResp, _ := json.Marshal(types.EBSSnapshotResponse{Error: fmt.Sprintf("bad request: %v", err)})
|
||||
if err := msg.Respond(errResp); err != nil {
|
||||
slog.Error("Failed to respond to ebs.snapshot request", "err", err)
|
||||
}
|
||||
respondJSON(msg, types.EBSSnapshotResponse{Error: fmt.Sprintf("bad request: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -105,18 +102,38 @@ func makeSnapshotHandler(vb *viperblock.VB, volumeName string) nats.MsgHandler {
|
||||
slog.Info("ebs.snapshot: snapshot created", "volume", volumeName, "snapshotId", snapRequest.SnapshotID)
|
||||
}
|
||||
|
||||
response, err := json.Marshal(snapResponse)
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal ebs.snapshot response", "err", err)
|
||||
if err := msg.Respond([]byte(`{"Error":"internal marshal failure"}`)); err != nil {
|
||||
slog.Error("Failed to respond to ebs.snapshot request", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
respondJSON(msg, snapResponse)
|
||||
}
|
||||
}
|
||||
|
||||
if err := msg.Respond(response); err != nil {
|
||||
slog.Error("Failed to respond to ebs.snapshot request", "err", err)
|
||||
}
|
||||
// respondJSON marshals data and sends it as a NATS response. On marshal
|
||||
// failure a raw JSON error string is sent instead.
|
||||
func respondJSON(msg *nats.Msg, data any) {
|
||||
response, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal response", "type", fmt.Sprintf("%T", data), "err", err)
|
||||
_ = msg.Respond([]byte(`{"Error":"internal marshal failure"}`))
|
||||
return
|
||||
}
|
||||
if err := msg.Respond(response); err != nil {
|
||||
slog.Error("Failed to respond to NATS request", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// respondAndPublish is like respondJSON but also publishes the marshaled
|
||||
// response to the given NATS topic (used for ebs.mount.response etc.).
|
||||
func respondAndPublish(msg *nats.Msg, nc *nats.Conn, topic string, data any) {
|
||||
response, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal response", "type", fmt.Sprintf("%T", data), "err", err)
|
||||
_ = msg.Respond([]byte(`{"Error":"internal marshal failure"}`))
|
||||
return
|
||||
}
|
||||
if err := msg.Respond(response); err != nil {
|
||||
slog.Error("Failed to respond to NATS request", "err", err)
|
||||
}
|
||||
if err := nc.Publish(topic, response); err != nil {
|
||||
slog.Error("Failed to publish response", "topic", topic, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,14 +156,7 @@ func (svc *Service) Stop() (err error) {
|
||||
}
|
||||
|
||||
func (svc *Service) Status() (string, error) {
|
||||
pid, err := utils.ReadPidFileFrom(svc.Config.BaseDir, serviceName)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "stopped", nil
|
||||
}
|
||||
return "", fmt.Errorf("read pid file: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("running (pid: %d)", pid), nil
|
||||
return utils.ServiceStatus(svc.Config.BaseDir, serviceName)
|
||||
}
|
||||
|
||||
func (svc *Service) Shutdown() (err error) {
|
||||
@@ -177,13 +187,9 @@ func launchService(cfg *Config) (err error) {
|
||||
slog.Info("Received ebs.delete message", "data", string(msg.Data))
|
||||
|
||||
var ebsRequest types.EBSDeleteRequest
|
||||
err := json.Unmarshal(msg.Data, &ebsRequest)
|
||||
if err != nil {
|
||||
if err := json.Unmarshal(msg.Data, &ebsRequest); err != nil {
|
||||
slog.Error("Failed to unmarshal ebs.delete message", "err", err)
|
||||
errResp, _ := json.Marshal(types.EBSDeleteResponse{Error: fmt.Sprintf("bad request: %v", err)})
|
||||
if err := msg.Respond(errResp); err != nil {
|
||||
slog.Error("Failed to respond to ebs.delete request", "err", err)
|
||||
}
|
||||
respondJSON(msg, types.EBSDeleteResponse{Error: fmt.Sprintf("bad request: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -232,18 +238,7 @@ func launchService(cfg *Config) (err error) {
|
||||
slog.Info("ebs.delete: volume not mounted (expected for available volumes)", "volume", ebsRequest.Volume)
|
||||
}
|
||||
|
||||
respData, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal ebs.delete response", "err", err)
|
||||
if err := msg.Respond([]byte(`{"Error":"internal marshal failure"}`)); err != nil {
|
||||
slog.Error("Failed to respond to ebs.delete request", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := msg.Respond(respData); err != nil {
|
||||
slog.Error("Failed to respond to ebs.delete request", "err", err)
|
||||
}
|
||||
respondJSON(msg, response)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to subscribe to ebs.delete: %w", err)
|
||||
}
|
||||
@@ -264,18 +259,9 @@ func launchService(cfg *Config) (err error) {
|
||||
|
||||
// Parse the message
|
||||
var ebsRequest types.EBSRequest
|
||||
err := json.Unmarshal(msg.Data, &ebsRequest)
|
||||
if err != nil {
|
||||
if err := json.Unmarshal(msg.Data, &ebsRequest); err != nil {
|
||||
slog.Error("Failed to unmarshal ebs.unmount message", "err", err)
|
||||
errResp, marshalErr := json.Marshal(types.EBSUnMountResponse{Error: fmt.Sprintf("bad request: %v", err)})
|
||||
if marshalErr != nil {
|
||||
slog.Error("Failed to marshal ebs.unmount error response", "err", marshalErr)
|
||||
_ = msg.Respond([]byte(`{"Error":"internal marshal failure"}`))
|
||||
return
|
||||
}
|
||||
if err := msg.Respond(errResp); err != nil {
|
||||
slog.Error("Failed to respond to ebs.unmount request", "err", err)
|
||||
}
|
||||
respondJSON(msg, types.EBSUnMountResponse{Error: fmt.Sprintf("bad request: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -335,20 +321,7 @@ func launchService(cfg *Config) (err error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Marshal the response
|
||||
response, err := json.Marshal(ebsResponse)
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal response", "err", err)
|
||||
_ = msg.Respond([]byte(`{"Error":"internal marshal failure"}`))
|
||||
return
|
||||
}
|
||||
|
||||
if err := msg.Respond(response); err != nil {
|
||||
slog.Error("Failed to respond to ebs.unmount request", "err", err)
|
||||
}
|
||||
if err := nc.Publish("ebs.unmount.response", response); err != nil {
|
||||
slog.Error("Failed to publish ebs.unmount.response", "err", err)
|
||||
}
|
||||
respondAndPublish(msg, nc, "ebs.unmount.response", ebsResponse)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to subscribe to %s: %w", unmountTopic, err)
|
||||
}
|
||||
@@ -359,15 +332,7 @@ func launchService(cfg *Config) (err error) {
|
||||
var syncRequest types.EBSSyncRequest
|
||||
if err := json.Unmarshal(msg.Data, &syncRequest); err != nil {
|
||||
slog.Error("Failed to unmarshal ebs.sync message", "err", err)
|
||||
errResp, marshalErr := json.Marshal(types.EBSSyncResponse{Error: fmt.Sprintf("bad request: %v", err)})
|
||||
if marshalErr != nil {
|
||||
slog.Error("Failed to marshal ebs.sync error response", "err", marshalErr)
|
||||
_ = msg.Respond([]byte(`{"Error":"internal marshal failure"}`))
|
||||
return
|
||||
}
|
||||
if err := msg.Respond(errResp); err != nil {
|
||||
slog.Error("Failed to respond to ebs.sync request", "err", err)
|
||||
}
|
||||
respondJSON(msg, types.EBSSyncResponse{Error: fmt.Sprintf("bad request: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -396,18 +361,7 @@ func launchService(cfg *Config) (err error) {
|
||||
"volumeSize", foundVB.GetVolumeSize())
|
||||
}
|
||||
|
||||
response, err := json.Marshal(syncResponse)
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal ebs.sync response", "err", err)
|
||||
if err := msg.Respond([]byte(`{"Error":"internal marshal failure"}`)); err != nil {
|
||||
slog.Error("Failed to respond to ebs.sync request", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := msg.Respond(response); err != nil {
|
||||
slog.Error("Failed to respond to ebs.sync request", "err", err)
|
||||
}
|
||||
respondJSON(msg, syncResponse)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to subscribe to ebs.sync: %w", err)
|
||||
}
|
||||
@@ -432,18 +386,9 @@ func launchService(cfg *Config) (err error) {
|
||||
|
||||
// Parse the message
|
||||
var ebsRequest types.EBSRequest
|
||||
err := json.Unmarshal(msg.Data, &ebsRequest)
|
||||
if err != nil {
|
||||
if err := json.Unmarshal(msg.Data, &ebsRequest); err != nil {
|
||||
slog.Error("Failed to unmarshal ebs.mount message", "err", err)
|
||||
errResp, marshalErr := json.Marshal(types.EBSMountResponse{Error: fmt.Sprintf("bad request: %v", err)})
|
||||
if marshalErr != nil {
|
||||
slog.Error("Failed to marshal ebs.mount error response", "err", marshalErr)
|
||||
_ = msg.Respond([]byte(`{"Error":"internal marshal failure"}`))
|
||||
return
|
||||
}
|
||||
if err := msg.Respond(errResp); err != nil {
|
||||
slog.Error("Failed to respond to ebs.mount request", "err", err)
|
||||
}
|
||||
respondJSON(msg, types.EBSMountResponse{Error: fmt.Sprintf("bad request: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -498,19 +443,7 @@ func launchService(cfg *Config) (err error) {
|
||||
|
||||
if err != nil {
|
||||
ebsResponse.Error = fmt.Sprintf("Failed to connect to Viperblock store: %v", err)
|
||||
// Marshal and send error response immediately
|
||||
response, marshalErr := json.Marshal(ebsResponse)
|
||||
if marshalErr != nil {
|
||||
slog.Error("Failed to marshal ebs.mount error response", "err", marshalErr)
|
||||
_ = msg.Respond([]byte(`{"Error":"internal marshal failure"}`))
|
||||
return
|
||||
}
|
||||
if err := msg.Respond(response); err != nil {
|
||||
slog.Error("Failed to respond to ebs.mount request", "err", err)
|
||||
}
|
||||
if err := nc.Publish("ebs.mount.response", response); err != nil {
|
||||
slog.Error("Failed to publish ebs.mount.response", "err", err)
|
||||
}
|
||||
respondAndPublish(msg, nc, "ebs.mount.response", ebsResponse)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -523,19 +456,7 @@ func launchService(cfg *Config) (err error) {
|
||||
|
||||
if err != nil {
|
||||
ebsResponse.Error = err.Error()
|
||||
// Marshal and send error response immediately
|
||||
response, marshalErr := json.Marshal(ebsResponse)
|
||||
if marshalErr != nil {
|
||||
slog.Error("Failed to marshal ebs.mount error response", "err", marshalErr)
|
||||
_ = msg.Respond([]byte(`{"Error":"internal marshal failure"}`))
|
||||
return
|
||||
}
|
||||
if err := msg.Respond(response); err != nil {
|
||||
slog.Error("Failed to respond to ebs.mount request", "err", err)
|
||||
}
|
||||
if err := nc.Publish("ebs.mount.response", response); err != nil {
|
||||
slog.Error("Failed to publish ebs.mount.response", "err", err)
|
||||
}
|
||||
respondAndPublish(msg, nc, "ebs.mount.response", ebsResponse)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -545,19 +466,7 @@ func launchService(cfg *Config) (err error) {
|
||||
|
||||
if err != nil {
|
||||
ebsResponse.Error = err.Error()
|
||||
// Marshal and send error response immediately
|
||||
response, marshalErr := json.Marshal(ebsResponse)
|
||||
if marshalErr != nil {
|
||||
slog.Error("Failed to marshal ebs.mount error response", "err", marshalErr)
|
||||
_ = msg.Respond([]byte(`{"Error":"internal marshal failure"}`))
|
||||
return
|
||||
}
|
||||
if err := msg.Respond(response); err != nil {
|
||||
slog.Error("Failed to respond to ebs.mount request", "err", err)
|
||||
}
|
||||
if err := nc.Publish("ebs.mount.response", response); err != nil {
|
||||
slog.Error("Failed to publish ebs.mount.response", "err", err)
|
||||
}
|
||||
respondAndPublish(msg, nc, "ebs.mount.response", ebsResponse)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -575,18 +484,7 @@ func launchService(cfg *Config) (err error) {
|
||||
portStr, err := viperblock.FindFreePort()
|
||||
if err != nil {
|
||||
ebsResponse.Error = err.Error()
|
||||
response, marshalErr := json.Marshal(ebsResponse)
|
||||
if marshalErr != nil {
|
||||
slog.Error("Failed to marshal ebs.mount error response", "err", marshalErr)
|
||||
_ = msg.Respond([]byte(`{"Error":"internal marshal failure"}`))
|
||||
return
|
||||
}
|
||||
if err := msg.Respond(response); err != nil {
|
||||
slog.Error("Failed to respond to ebs.mount request", "err", err)
|
||||
}
|
||||
if err := nc.Publish("ebs.mount.response", response); err != nil {
|
||||
slog.Error("Failed to publish ebs.mount.response", "err", err)
|
||||
}
|
||||
respondAndPublish(msg, nc, "ebs.mount.response", ebsResponse)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -596,18 +494,7 @@ func launchService(cfg *Config) (err error) {
|
||||
if err != nil {
|
||||
slog.Error("Failed to convert port to int", "err", err)
|
||||
ebsResponse.Error = fmt.Sprintf("failed to parse port: %v", err)
|
||||
response, marshalErr := json.Marshal(ebsResponse)
|
||||
if marshalErr != nil {
|
||||
slog.Error("Failed to marshal ebs.mount error response", "err", marshalErr)
|
||||
_ = msg.Respond([]byte(`{"Error":"internal marshal failure"}`))
|
||||
return
|
||||
}
|
||||
if err := msg.Respond(response); err != nil {
|
||||
slog.Error("Failed to respond to ebs.mount request", "err", err)
|
||||
}
|
||||
if err := nc.Publish("ebs.mount.response", response); err != nil {
|
||||
slog.Error("Failed to publish ebs.mount.response", "err", err)
|
||||
}
|
||||
respondAndPublish(msg, nc, "ebs.mount.response", ebsResponse)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -618,18 +505,7 @@ func launchService(cfg *Config) (err error) {
|
||||
nbdSocket, err = utils.GenerateUniqueSocketFile(ebsRequest.Name)
|
||||
if err != nil {
|
||||
ebsResponse.Error = err.Error()
|
||||
response, marshalErr := json.Marshal(ebsResponse)
|
||||
if marshalErr != nil {
|
||||
slog.Error("Failed to marshal ebs.mount error response", "err", marshalErr)
|
||||
_ = msg.Respond([]byte(`{"Error":"internal marshal failure"}`))
|
||||
return
|
||||
}
|
||||
if err := msg.Respond(response); err != nil {
|
||||
slog.Error("Failed to respond to ebs.mount request", "err", err)
|
||||
}
|
||||
if err := nc.Publish("ebs.mount.response", response); err != nil {
|
||||
slog.Error("Failed to publish ebs.mount.response", "err", err)
|
||||
}
|
||||
respondAndPublish(msg, nc, "ebs.mount.response", ebsResponse)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -642,18 +518,7 @@ func launchService(cfg *Config) (err error) {
|
||||
if err != nil {
|
||||
slog.Error("Failed to generate nbdkit pid file", "err", err)
|
||||
ebsResponse.Error = fmt.Sprintf("failed to generate pid file: %v", err)
|
||||
response, marshalErr := json.Marshal(ebsResponse)
|
||||
if marshalErr != nil {
|
||||
slog.Error("Failed to marshal ebs.mount error response", "err", marshalErr)
|
||||
_ = msg.Respond([]byte(`{"Error":"internal marshal failure"}`))
|
||||
return
|
||||
}
|
||||
if err := msg.Respond(response); err != nil {
|
||||
slog.Error("Failed to respond to ebs.mount request", "err", err)
|
||||
}
|
||||
if err := nc.Publish("ebs.mount.response", response); err != nil {
|
||||
slog.Error("Failed to publish ebs.mount.response", "err", err)
|
||||
}
|
||||
respondAndPublish(msg, nc, "ebs.mount.response", ebsResponse)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -716,19 +581,7 @@ func launchService(cfg *Config) (err error) {
|
||||
|
||||
if pid == 0 {
|
||||
ebsResponse.Error = "Failed to start nbdkit"
|
||||
// Marshal and send error response immediately
|
||||
response, marshalErr := json.Marshal(ebsResponse)
|
||||
if marshalErr != nil {
|
||||
slog.Error("Failed to marshal ebs.mount error response", "err", marshalErr)
|
||||
_ = msg.Respond([]byte(`{"Error":"internal marshal failure"}`))
|
||||
return
|
||||
}
|
||||
if err := msg.Respond(response); err != nil {
|
||||
slog.Error("Failed to respond to ebs.mount request", "err", err)
|
||||
}
|
||||
if err := nc.Publish("ebs.mount.response", response); err != nil {
|
||||
slog.Error("Failed to publish ebs.mount.response", "err", err)
|
||||
}
|
||||
respondAndPublish(msg, nc, "ebs.mount.response", ebsResponse)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -740,18 +593,7 @@ func launchService(cfg *Config) (err error) {
|
||||
case exitErr := <-exitChan:
|
||||
if exitErr != 0 {
|
||||
ebsResponse.Error = fmt.Sprintf("nbdkit failed: %v", exitErr)
|
||||
response, marshalErr := json.Marshal(ebsResponse)
|
||||
if marshalErr != nil {
|
||||
slog.Error("Failed to marshal ebs.mount error response", "err", marshalErr)
|
||||
_ = msg.Respond([]byte(`{"Error":"internal marshal failure"}`))
|
||||
return
|
||||
}
|
||||
if err := msg.Respond(response); err != nil {
|
||||
slog.Error("Failed to respond to ebs.mount request", "err", err)
|
||||
}
|
||||
if err := nc.Publish("ebs.mount.response", response); err != nil {
|
||||
slog.Error("Failed to publish ebs.mount.response", "err", err)
|
||||
}
|
||||
respondAndPublish(msg, nc, "ebs.mount.response", ebsResponse)
|
||||
return
|
||||
}
|
||||
default:
|
||||
@@ -780,25 +622,8 @@ func launchService(cfg *Config) (err error) {
|
||||
})
|
||||
cfg.mu.Unlock()
|
||||
|
||||
// Marshal the response
|
||||
response, err := json.Marshal(ebsResponse)
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal response", "err", err)
|
||||
_ = msg.Respond([]byte(`{"Error":"internal marshal failure"}`))
|
||||
return
|
||||
}
|
||||
|
||||
if err := msg.Respond(response); err != nil {
|
||||
slog.Error("Failed to respond to ebs.mount request", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := nc.Publish("ebs.mount.response", response); err != nil {
|
||||
slog.Error("Failed to publish ebs.mount.response", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("Sent ebs.mount response", "response", string(response))
|
||||
respondAndPublish(msg, nc, "ebs.mount.response", ebsResponse)
|
||||
slog.Debug("Sent ebs.mount response")
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to subscribe to %s: %w", mountTopic, err)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
handlers_ec2_igw "github.com/mulgadc/spinifex/spinifex/handlers/ec2/igw"
|
||||
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
@@ -20,34 +21,7 @@ const testIGWAccountID = "123456789012"
|
||||
// startTestJetStreamNATS starts an embedded NATS server with JetStream for integration tests.
|
||||
func startTestJetStreamNATS(t *testing.T) (*server.Server, *nats.Conn) {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("start NATS server: %v", err)
|
||||
}
|
||||
go ns.Start()
|
||||
if !ns.ReadyForConnections(5_000_000_000) {
|
||||
t.Fatal("NATS server not ready")
|
||||
}
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
if err != nil {
|
||||
ns.Shutdown()
|
||||
t.Fatalf("connect to NATS: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
nc.Close()
|
||||
ns.Shutdown()
|
||||
})
|
||||
|
||||
ns, nc, _ := testutil.StartTestJetStream(t)
|
||||
return ns, nc
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/services/vpcd/nbdb"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/types"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
@@ -14,31 +15,7 @@ import (
|
||||
// startTestNATS starts an embedded NATS server for testing.
|
||||
func startTestNATS(t *testing.T) (*server.Server, *nats.Conn) {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1, // auto-assign
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("start NATS server: %v", err)
|
||||
}
|
||||
go ns.Start()
|
||||
if !ns.ReadyForConnections(5_000_000_000) { // 5s
|
||||
t.Fatal("NATS server not ready")
|
||||
}
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
if err != nil {
|
||||
ns.Shutdown()
|
||||
t.Fatalf("connect to NATS: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
nc.Close()
|
||||
ns.Shutdown()
|
||||
})
|
||||
|
||||
return ns, nc
|
||||
return testutil.StartTestNATS(t)
|
||||
}
|
||||
|
||||
func TestTopologyHandler_VPCCreate(t *testing.T) {
|
||||
|
||||
@@ -127,14 +127,7 @@ func (svc *Service) Stop() error {
|
||||
|
||||
// Status returns the vpcd service status.
|
||||
func (svc *Service) Status() (string, error) {
|
||||
pid, err := utils.ReadPidFileFrom(svc.Config.BaseDir, serviceName)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "stopped", nil
|
||||
}
|
||||
return "", fmt.Errorf("read pid file: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("running (pid: %d)", pid), nil
|
||||
return utils.ServiceStatus(svc.Config.BaseDir, serviceName)
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the vpcd service.
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// StartTestNATS starts an embedded NATS server (no JetStream) and returns
|
||||
// the server and a connected client. Both are cleaned up via t.Cleanup.
|
||||
func StartTestNATS(t *testing.T) (*server.Server, *nats.Conn) {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
return ns, nc
|
||||
}
|
||||
|
||||
// StartTestJetStream starts an embedded NATS server with JetStream enabled
|
||||
// and returns the server, a connected client, and a JetStream context.
|
||||
// All resources are cleaned up via t.Cleanup.
|
||||
func StartTestJetStream(t *testing.T) (*server.Server, *nats.Conn, nats.JetStreamContext) {
|
||||
t.Helper()
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { nc.Close() })
|
||||
|
||||
js, err := nc.JetStream()
|
||||
require.NoError(t, err)
|
||||
return ns, nc, js
|
||||
}
|
||||
|
||||
// SeedKV creates a KV bucket and populates it with the given entries.
|
||||
// Returns the KV handle for further use.
|
||||
func SeedKV(t *testing.T, js nats.JetStreamContext, bucket string, entries map[string][]byte) nats.KeyValue {
|
||||
t.Helper()
|
||||
kv, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: bucket, History: 1})
|
||||
require.NoError(t, err)
|
||||
for k, v := range entries {
|
||||
_, err := kv.Put(k, v)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
return kv
|
||||
}
|
||||
@@ -2,9 +2,8 @@ package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -62,33 +61,14 @@ func TestGlobalAccountID(t *testing.T) {
|
||||
}
|
||||
|
||||
// startJSNATSServer starts an embedded JetStream-enabled NATS server for testing.
|
||||
func startJSNATSServer(t *testing.T) *server.Server {
|
||||
func startJSNATSServer(t *testing.T) (*nats.Conn, nats.JetStreamContext) {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
ns, err := server.NewServer(&server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: tmpDir,
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second))
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
return ns
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
return nc, js
|
||||
}
|
||||
|
||||
func TestWriteVersion_WritesKey(t *testing.T) {
|
||||
ns := startJSNATSServer(t)
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
js, err := nc.JetStream()
|
||||
require.NoError(t, err)
|
||||
_, js := startJSNATSServer(t)
|
||||
|
||||
kv, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: "test-write-version"})
|
||||
require.NoError(t, err)
|
||||
@@ -102,13 +82,7 @@ func TestWriteVersion_WritesKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReadVersion_ReturnsZeroWhenUnset(t *testing.T) {
|
||||
ns := startJSNATSServer(t)
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
js, err := nc.JetStream()
|
||||
require.NoError(t, err)
|
||||
_, js := startJSNATSServer(t)
|
||||
|
||||
kv, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: "test-read-unset"})
|
||||
require.NoError(t, err)
|
||||
@@ -119,13 +93,7 @@ func TestReadVersion_ReturnsZeroWhenUnset(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWriteVersion_IdempotentOnSameVersion(t *testing.T) {
|
||||
ns := startJSNATSServer(t)
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
js, err := nc.JetStream()
|
||||
require.NoError(t, err)
|
||||
_, js := startJSNATSServer(t)
|
||||
|
||||
kv, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: "test-idempotent"})
|
||||
require.NoError(t, err)
|
||||
@@ -139,13 +107,7 @@ func TestWriteVersion_IdempotentOnSameVersion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWriteVersion_UpdatesOnHigherVersion(t *testing.T) {
|
||||
ns := startJSNATSServer(t)
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
js, err := nc.JetStream()
|
||||
require.NoError(t, err)
|
||||
_, js := startJSNATSServer(t)
|
||||
|
||||
kv, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: "test-upgrade"})
|
||||
require.NoError(t, err)
|
||||
@@ -159,13 +121,7 @@ func TestWriteVersion_UpdatesOnHigherVersion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWriteVersion_NoOpOnLowerVersion(t *testing.T) {
|
||||
ns := startJSNATSServer(t)
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
require.NoError(t, err)
|
||||
defer nc.Close()
|
||||
|
||||
js, err := nc.JetStream()
|
||||
require.NoError(t, err)
|
||||
_, js := startJSNATSServer(t)
|
||||
|
||||
kv, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: "test-downgrade"})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -151,6 +151,22 @@ func NATSScatterGather[Out any](conn *nats.Conn, subject string, input any, time
|
||||
return nil, fmt.Errorf("scatter-gather timeout: no responses received for %s", subject)
|
||||
}
|
||||
|
||||
// PublishEvent marshals event as JSON and publishes it to the given NATS topic.
|
||||
// Errors are logged but not returned (fire-and-forget). A nil connection is a no-op.
|
||||
func PublishEvent(nc *nats.Conn, topic string, event any) {
|
||||
if nc == nil {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to marshal event", "topic", topic, "error", err)
|
||||
return
|
||||
}
|
||||
if err := nc.Publish(topic, data); err != nil {
|
||||
slog.Warn("Failed to publish event", "topic", topic, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// AccountIDFromMsg extracts the caller's account ID from a NATS message header.
|
||||
// Returns the account ID, or empty string if the header is not set.
|
||||
func AccountIDFromMsg(msg *nats.Msg) string {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -13,21 +14,7 @@ import (
|
||||
|
||||
func startTestNATSServer(t *testing.T) *server.Server {
|
||||
t.Helper()
|
||||
|
||||
opts := &server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
NoLog: true,
|
||||
NoSigs: true,
|
||||
}
|
||||
|
||||
ns, err := server.NewServer(opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
go ns.Start()
|
||||
require.True(t, ns.ReadyForConnections(5*time.Second), "NATS server failed to start")
|
||||
|
||||
t.Cleanup(func() { ns.Shutdown() })
|
||||
ns, _ := testutil.StartTestNATS(t)
|
||||
return ns
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,19 @@ func RemovePidFileAt(dir string, name string) error {
|
||||
return os.Remove(filepath.Join(dir, fmt.Sprintf("%s.pid", name)))
|
||||
}
|
||||
|
||||
// ServiceStatus returns a human-readable status string for a service by
|
||||
// checking its PID file. If dir is empty, the default pidPath() is used.
|
||||
func ServiceStatus(dir, name string) (string, error) {
|
||||
pid, err := ReadPidFileFrom(dir, name)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "stopped", nil
|
||||
}
|
||||
return "", fmt.Errorf("read pid file: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("running (pid: %d)", pid), nil
|
||||
}
|
||||
|
||||
// StopProcessAt stops a process using a PID file in a specific directory.
|
||||
// If dir is empty, falls back to the default pidPath(). The PID file is
|
||||
// always removed, even if the process is already dead, to prevent stale
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
)
|
||||
|
||||
// MapToEC2Tags converts a map[string]string to a slice of EC2 Tag pointers.
|
||||
// Returns nil when the input map is empty.
|
||||
func MapToEC2Tags(m map[string]string) []*ec2.Tag {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
tags := make([]*ec2.Tag, 0, len(m))
|
||||
for k, v := range m {
|
||||
tags = append(tags, &ec2.Tag{Key: aws.String(k), Value: aws.String(v)})
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
// ExtractTags returns a map of tags from the TagSpecification that matches
|
||||
// the given resourceType. If no specification matches, the returned map is
|
||||
// empty (never nil).
|
||||
func ExtractTags(tagSpecs []*ec2.TagSpecification, resourceType string) map[string]string {
|
||||
tags := make(map[string]string)
|
||||
for _, tagSpec := range tagSpecs {
|
||||
if tagSpec.ResourceType != nil && *tagSpec.ResourceType == resourceType {
|
||||
for _, tag := range tagSpec.Tags {
|
||||
if tag.Key != nil && tag.Value != nil {
|
||||
tags[*tag.Key] = *tag.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
@@ -779,7 +779,7 @@ init_leader_node() {
|
||||
# Wait for formation server to be ready
|
||||
echo "Waiting for formation server..."
|
||||
for i in $(seq 1 60); do
|
||||
if curl -s "http://${NODE1_IP}:${CLUSTER_PORT}/formation/health" > /dev/null 2>&1; then
|
||||
if curl -sk "https://${NODE1_IP}:${CLUSTER_PORT}/formation/health" > /dev/null 2>&1; then
|
||||
echo " Formation server is ready (PID: $LEADER_INIT_PID)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user