mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-24 21:23:51 +00:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 92e25a9653 | |||
| ec525d3a6d | |||
| 94978f517b | |||
| 4d7239e645 | |||
| acc8dc1994 | |||
| a7ed93c6f3 | |||
| 67075c6600 | |||
| ae223cdf15 | |||
| b73a630f41 | |||
| c283100fcf | |||
| 355decd406 | |||
| b76953bfb4 | |||
| c9e83380df | |||
| e421840541 | |||
| 406ba9431e | |||
| 7882f77a36 | |||
| f7b99f0c50 | |||
| f3ee50e9b8 | |||
| 4f3b54abc2 | |||
| a7925d65f2 | |||
| 0d2e91da37 | |||
| e2c7a64aff | |||
| 588141cf67 | |||
| 9e3076727e | |||
| c1df418c5c | |||
| 5e0d819393 | |||
| 6d3b2f7e5d | |||
| f3c5bce811 | |||
| 9b8863a182 | |||
| 6e807f88cf | |||
| 32f5ec92c0 | |||
| 6a4b823de7 | |||
| 91483d632e | |||
| 5426dc1bb0 | |||
| 15f88caee7 | |||
| 5d194098ca | |||
| 94a80a3be7 | |||
| 3f642524a8 | |||
| de1340a331 | |||
| ae094cb936 | |||
| c2ab63fdef | |||
| 04bea48a39 | |||
| a92ea13798 | |||
| 46670ee8af | |||
| cbc04a2437 | |||
| 216842cc1b | |||
| 8ea40a1973 | |||
| 5bfc680a68 | |||
| 98d5ec92ed | |||
| 2244ba56b0 | |||
| 9e8e944faa | |||
| 3129c9eb70 | |||
| f744aedede | |||
| e5801aa6ba | |||
| 77a4a40ca6 | |||
| ab84905297 | |||
| 2a3ada5e93 | |||
| adfd20dee5 | |||
| 4ffc7785a5 | |||
| e91b67a7c6 | |||
| 051ecbd4ce | |||
| ef68c5bffe | |||
| 64c7305b37 | |||
| d025cab50e | |||
| 1c194da915 | |||
| 4ad5843ff5 | |||
| 7ea0353d2f | |||
| f8fc2b2cbb | |||
| 74671a05a5 | |||
| 90a3da2cc2 | |||
| 06a8da8fb5 |
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
# Only enforce preflight on dev and main
|
||||
if [ "$branch" != "dev" ] && [ "$branch" != "main" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Only run preflight if Go files were staged
|
||||
if git diff --cached --name-only | grep -q '\.go$'; then
|
||||
echo "Go files staged on $branch — running make preflight..."
|
||||
make preflight
|
||||
else
|
||||
echo "No Go files staged, skipping preflight."
|
||||
fi
|
||||
@@ -15,11 +15,14 @@ updates:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
target-branch: dev
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
timezone: Australia/Sydney
|
||||
cooldown:
|
||||
default-days: 1
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types:
|
||||
@@ -39,6 +42,10 @@ updates:
|
||||
timezone: Australia/Sydney
|
||||
cooldown:
|
||||
default-days: 1
|
||||
ignore:
|
||||
- dependency-name: "@types/node"
|
||||
update-types:
|
||||
- "version-update:semver-major"
|
||||
groups:
|
||||
npm-dependencies:
|
||||
patterns:
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
|
||||
'cd ~/Development/mulga/spinifex && ./tests/e2e/run-e2e.sh'
|
||||
'cd ~/Development/mulga/spinifex && ./tests/e2e/run-e2e.sh && ./tests/e2e/run-cert-e2e.sh'
|
||||
|
||||
- name: Teardown
|
||||
if: always()
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
done
|
||||
|
||||
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \
|
||||
"cd ~/Development/mulga/spinifex && ./tests/e2e/run-multinode-e2e.sh ${NODE_IPS}"
|
||||
"cd ~/Development/mulga/spinifex && ./tests/e2e/run-multinode-e2e.sh ${NODE_IPS} && ./tests/e2e/run-cert-e2e.sh --multinode ${NODE_IPS}"
|
||||
|
||||
- name: Teardown
|
||||
if: always()
|
||||
|
||||
@@ -40,3 +40,4 @@ docs/PLAN.md
|
||||
docs/TODO.md
|
||||
docs/development/
|
||||
docs/internal/
|
||||
scripts/iac/aws/spinifex-test.pem
|
||||
|
||||
@@ -63,6 +63,19 @@ go_build:
|
||||
@echo -e "\n....Building $(GO_PROJECT_NAME)"
|
||||
go build $(GO_BUILD_MOD) -ldflags "$(LDFLAGS)" -o ./bin/$(GO_PROJECT_NAME) cmd/spinifex/main.go
|
||||
|
||||
build-alb-agent:
|
||||
@echo -e "\n....Building alb-agent (static)"
|
||||
CGO_ENABLED=0 go build -ldflags "-s -w" -o ./bin/alb-agent cmd/alb-agent/main.go
|
||||
|
||||
build-system-image: ## Build a system image from manifest (use IMAGE=alb)
|
||||
ifndef IMAGE
|
||||
$(error IMAGE is required. Usage: make build-system-image IMAGE=alb)
|
||||
endif
|
||||
./scripts/build-system-image.sh scripts/images/$(IMAGE).conf
|
||||
|
||||
build-alb-image: ## Build ALB Alpine image
|
||||
$(MAKE) build-system-image IMAGE=alb
|
||||
|
||||
go_run:
|
||||
@echo -e "\n....Running $(GO_PROJECT_NAME)...."
|
||||
$(GOPATH)/bin/$(GO_PROJECT_NAME)
|
||||
@@ -194,7 +207,7 @@ distro-arm64:
|
||||
distro-clean:
|
||||
rm -rf dist/
|
||||
|
||||
.PHONY: build build-ui go_build go_run preflight test test-cover test-race diff-coverage bench run clean \
|
||||
.PHONY: build build-ui build-alb-agent build-system-image build-alb-image go_build go_run preflight test test-cover test-race diff-coverage bench run clean \
|
||||
install-system install-go install-aws quickinstall \
|
||||
lint fix govulncheck \
|
||||
distro distro-amd64 distro-arm64 distro-clean
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// alb-agent runs inside ALB VMs and manages HAProxy configuration.
|
||||
//
|
||||
// It connects to NATS, subscribes to config updates for its load balancer,
|
||||
// writes HAProxy configs to disk, and reloads HAProxy on changes. It also
|
||||
// responds to health pings so the daemon can verify the agent is alive.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// alb-agent --lb-id=lb-xxxxx --nats=nats://10.0.0.1:4222
|
||||
//
|
||||
// The LB ID defaults to the hostname (set by cloud-init to the LB ID).
|
||||
// The NATS URL defaults to nats://127.0.0.1:4222.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/albagent"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
lbID string
|
||||
natsURL string
|
||||
)
|
||||
|
||||
hostname, _ := os.Hostname()
|
||||
|
||||
flag.StringVar(&lbID, "lb-id", hostname, "Load balancer ID (defaults to hostname)")
|
||||
flag.StringVar(&natsURL, "nats", "nats://127.0.0.1:4222", "NATS server URL")
|
||||
flag.Parse()
|
||||
|
||||
if lbID == "" {
|
||||
slog.Error("--lb-id is required (or set hostname to the LB ID)")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
slog.Info("Starting ALB agent", "lbId", lbID, "nats", natsURL)
|
||||
|
||||
agent, err := albagent.New(lbID, natsURL)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create agent", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := agent.Start(); err != nil {
|
||||
slog.Error("Failed to start agent", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Wait for shutdown signal
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
||||
sig := <-sigCh
|
||||
|
||||
slog.Info("Received signal, shutting down", "signal", sig)
|
||||
agent.Stop()
|
||||
}
|
||||
+110
-2
@@ -32,6 +32,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
@@ -158,6 +159,22 @@ var accountListCmd = &cobra.Command{
|
||||
Run: runAccountList,
|
||||
}
|
||||
|
||||
var certCmd = &cobra.Command{
|
||||
Use: "cert",
|
||||
Short: "Manage TLS certificates",
|
||||
Long: `Manage TLS certificates for the Spinifex platform.`,
|
||||
}
|
||||
|
||||
var certRenewCmd = &cobra.Command{
|
||||
Use: "renew",
|
||||
Short: "Regenerate the server certificate with current IPs",
|
||||
Long: `Regenerate the server certificate signed by the existing CA.
|
||||
The new certificate will include all current network interface IPs
|
||||
and the machine hostname in its SANs. Use this after adding a new
|
||||
network interface or changing IP addresses.`,
|
||||
Run: runCertRenew,
|
||||
}
|
||||
|
||||
/*
|
||||
CLI ideas
|
||||
|
||||
@@ -195,6 +212,11 @@ func init() {
|
||||
adminCmd.AddCommand(accountCmd)
|
||||
accountCmd.AddCommand(accountCreateCmd)
|
||||
accountCmd.AddCommand(accountListCmd)
|
||||
|
||||
adminCmd.AddCommand(certCmd)
|
||||
certCmd.AddCommand(certRenewCmd)
|
||||
certRenewCmd.Flags().StringSlice("extra-ip", nil, "Additional IP addresses to include in SANs")
|
||||
certRenewCmd.Flags().StringSlice("extra-dns", nil, "Additional DNS names to include in SANs")
|
||||
accountCreateCmd.Flags().String("name", "", "Account name (required)")
|
||||
accountCreateCmd.MarkFlagRequired("name")
|
||||
|
||||
@@ -215,12 +237,13 @@ func init() {
|
||||
adminInitCmd.Flags().String("predastore-nodes", "", "Comma-separated IPs for multi-node Predastore cluster (e.g., 10.11.12.1,10.11.12.2,10.11.12.3). Requires >= 3 nodes.")
|
||||
adminInitCmd.Flags().String("formation-timeout", "10m", "Timeout for cluster formation (e.g., 5m, 30s)")
|
||||
adminInitCmd.Flags().String("cluster-name", "spinifex", "NATS cluster name")
|
||||
adminInitCmd.Flags().Bool("no-telemetry", false, "Disable telemetry metrics sent during init (default: enabled)")
|
||||
adminInitCmd.Flags().StringSlice("services", nil, "Services this node runs (default: all). Valid: nats,predastore,viperblock,daemon,awsgw,ui")
|
||||
|
||||
// External networking flags
|
||||
adminInitCmd.Flags().String("external-mode", "", "External network mode: 'pool' (default when WAN detected) or '' (disabled)")
|
||||
adminInitCmd.Flags().String("external-iface", "", "WAN NIC for br-external (auto-detected from default route)")
|
||||
adminInitCmd.Flags().String("external-source", "", "Pool IP source: 'static' (default, uses --external-pool range) or 'dhcp' (from router DHCP)")
|
||||
adminInitCmd.Flags().String("external-source", "", "Pool IP source: 'dhcp' (default when no --external-pool) or 'static' (uses --external-pool range)")
|
||||
adminInitCmd.Flags().String("external-pool", "", "External IP pool range as start-end (e.g., 192.168.1.150-192.168.1.250)")
|
||||
adminInitCmd.Flags().String("external-gateway", "", "WAN gateway IP (auto-detected from default route)")
|
||||
adminInitCmd.Flags().String("gateway-ip", "", "OVN gateway router's external IP for SNAT (default: pool range_start for pool mode, required for nat mode without DHCP)")
|
||||
@@ -238,6 +261,7 @@ func init() {
|
||||
adminJoinCmd.Flags().String("cluster-bind", "", "IP address to bind NATS cluster services to (e.g., 10.11.12.1 for multi-node)")
|
||||
adminJoinCmd.Flags().String("cluster-routes", "", "NATS cluster hosts for routing specify multiple with comma (e.g., 10.11.12.1:4248,10.11.12.2:4248 for multi-node)")
|
||||
adminJoinCmd.Flags().StringSlice("services", nil, "Services this node runs (default: all)")
|
||||
adminJoinCmd.Flags().Bool("no-telemetry", false, "Disable telemetry metrics sent during join (default: enabled)")
|
||||
adminJoinCmd.MarkFlagRequired("node")
|
||||
adminJoinCmd.MarkFlagRequired("host")
|
||||
|
||||
@@ -556,6 +580,31 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
gatewayIP, _ := cmd.Flags().GetString("gateway-ip")
|
||||
noExternal, _ := cmd.Flags().GetBool("no-external")
|
||||
|
||||
// Fire telemetry in background (completes during init work, waited at end)
|
||||
noTelemetry, _ := cmd.Flags().GetBool("no-telemetry")
|
||||
if os.Getenv("SPX_NO_TELEMETRY") == "1" {
|
||||
noTelemetry = true
|
||||
}
|
||||
var telemetryWg sync.WaitGroup
|
||||
defer telemetryWg.Wait()
|
||||
if !noTelemetry {
|
||||
telemetryWg.Go(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
admin.SendTelemetry(ctx, admin.TelemetryPayload{
|
||||
MachineID: admin.ReadMachineID(),
|
||||
Event: "init",
|
||||
Region: region,
|
||||
AZ: az,
|
||||
Node: node,
|
||||
Nodes: nodes,
|
||||
BindIP: bindIP,
|
||||
Version: Version,
|
||||
ExternalMode: externalMode,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Auto-detect network topology
|
||||
var poolStart, poolEnd string
|
||||
var detectedNet *admin.DetectedNetwork
|
||||
@@ -613,6 +662,14 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
if externalMode == "pool" {
|
||||
// Resolve source: if not explicitly set, infer from whether a pool range was given
|
||||
if externalSource == "" {
|
||||
if externalPool != "" {
|
||||
externalSource = "static"
|
||||
} else {
|
||||
externalSource = "dhcp"
|
||||
}
|
||||
}
|
||||
if externalSource == "dhcp" {
|
||||
// DHCP source: no static range needed, just gateway
|
||||
if externalGateway == "" {
|
||||
@@ -620,7 +677,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
// Static source (default): need pool range
|
||||
// Static source: need pool range
|
||||
if externalPool == "" {
|
||||
fmt.Fprintf(os.Stderr, "❌ Error: --external-pool is required when --external-mode=pool (e.g., 192.168.1.150-192.168.1.250)\n")
|
||||
fmt.Fprintf(os.Stderr, " Or use --external-source=dhcp to get IPs from router DHCP\n")
|
||||
@@ -1354,6 +1411,30 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
|
||||
|
||||
fmt.Printf("✅ Cluster formation complete! (%d nodes)\n\n", statusResp.Expected)
|
||||
|
||||
// Fire telemetry after formation (now we know the cluster topology)
|
||||
noTelemetry, _ := cmd.Flags().GetBool("no-telemetry")
|
||||
if os.Getenv("SPX_NO_TELEMETRY") == "1" {
|
||||
noTelemetry = true
|
||||
}
|
||||
var telemetryWg sync.WaitGroup
|
||||
defer telemetryWg.Wait()
|
||||
if !noTelemetry {
|
||||
telemetryWg.Go(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
admin.SendTelemetry(ctx, admin.TelemetryPayload{
|
||||
MachineID: admin.ReadMachineID(),
|
||||
Event: "join",
|
||||
Region: region,
|
||||
AZ: az,
|
||||
Node: node,
|
||||
Nodes: statusResp.Expected,
|
||||
BindIP: bindIP,
|
||||
Version: Version,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Extract credentials and CA from formation status
|
||||
creds := statusResp.Credentials
|
||||
if creds == nil {
|
||||
@@ -1760,6 +1841,33 @@ func runAccountList(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
}
|
||||
|
||||
func runCertRenew(cmd *cobra.Command, _ []string) {
|
||||
configDir, _ := cmd.Root().Flags().GetString("config-dir")
|
||||
|
||||
// Verify CA files exist.
|
||||
caCertPath := filepath.Join(configDir, "ca.pem")
|
||||
caKeyPath := filepath.Join(configDir, "ca.key")
|
||||
if !admin.FileExists(caCertPath) || !admin.FileExists(caKeyPath) {
|
||||
fmt.Fprintf(os.Stderr, "Error: CA files not found in %s\nRun 'spx admin init' first.\n", configDir)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
extraIPs, _ := cmd.Flags().GetStringSlice("extra-ip")
|
||||
extraDNS, _ := cmd.Flags().GetStringSlice("extra-dns")
|
||||
|
||||
serverCertPath := filepath.Join(configDir, "server.pem")
|
||||
serverKeyPath := filepath.Join(configDir, "server.key")
|
||||
|
||||
if err := admin.GenerateSignedCertWithDNS(serverCertPath, serverKeyPath, caCertPath, caKeyPath, extraIPs, extraDNS); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error regenerating server certificate: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("✅ Server certificate regenerated with current IPs and hostname")
|
||||
fmt.Printf(" Certificate: %s\n", serverCertPath)
|
||||
fmt.Println("\n⚠️ Restart awsgw and daemon services to pick up the new certificate.")
|
||||
}
|
||||
|
||||
// writeBootstrapResult holds the admin credentials so callers can
|
||||
// write them to ~/.aws/credentials instead of the system credentials.
|
||||
type writeBootstrapResult struct {
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"github.com/mulgadc/predastore/s3"
|
||||
"github.com/mulgadc/spinifex/spinifex/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/service"
|
||||
"github.com/mulgadc/spinifex/spinifex/services/awsgw"
|
||||
"github.com/mulgadc/spinifex/spinifex/services/nats"
|
||||
"github.com/mulgadc/spinifex/spinifex/services/predastore"
|
||||
"github.com/mulgadc/spinifex/spinifex/services/spinifexui"
|
||||
@@ -557,6 +558,7 @@ var awsgwStartCmd = &cobra.Command{
|
||||
// Apply changes back to cluster config
|
||||
clusterConfig.Nodes[clusterConfig.Node] = nodeConfig
|
||||
|
||||
awsgw.SetBuildInfo(Version, Commit)
|
||||
service, err := service.New("awsgw", clusterConfig)
|
||||
|
||||
if err != nil {
|
||||
@@ -711,7 +713,9 @@ var vpcdStartCmd = &cobra.Command{
|
||||
})
|
||||
}
|
||||
|
||||
// Derive OVN chassis names from cluster node names (format: "chassis-{hostname}")
|
||||
// Fallback chassis names derived from config node names. vpcd prefers
|
||||
// discovering chassis from the OVN Southbound DB at startup; these are
|
||||
// only used if that discovery fails.
|
||||
var chassisNames []string
|
||||
for nodeName := range clusterConfig.Nodes {
|
||||
chassisNames = append(chassisNames, "chassis-"+nodeName)
|
||||
|
||||
+23
-17
@@ -89,6 +89,8 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
| `describe-instance-types` | `--filters` (capacity filter only) | `--instance-types`, `--max-results`, `--next-token`, `--dry-run`, all other filters | None | Gateway fans out NATS `ec2.DescribeInstanceTypes` to all nodes → each daemon reports supported types (t3.micro/small/medium/large) with vCPU/memory specs → gateway deduplicates and returns | 1. List all instance types<br>2. Filter by specific type<br>3. Filter with `capacity=true` shows available slots<br>4. Verify vCPU/memory specs match hardware | **DONE** |
|
||||
| `modify-instance-attribute` | `--instance-id`, `--instance-type`, `--user-data` | `--disable-api-termination`, `--ebs-optimized`, `--source-dest-check`, `--instance-initiated-shutdown-behavior`, `--block-device-mappings`, `--groups`, `--ena-support`, `--sriov-net-support` | Instance must be stopped (in NATS KV) | Gateway validates input (exactly one attribute per call, instance ID format) → NATS `ec2.ModifyInstanceAttribute` with `spinifex-workers` queue group → daemon loads stopped instance from JetStream KV → applies attribute change → writes back to KV → returns `{}` on success. **InstanceType**: updates vm.InstanceType, Config, and Instance fields; clears StateReason (enables recovery from instance-type-missing bug). **UserData**: stores decoded content in vm.UserData and re-encodes to base64 for RunInstancesInput (cloud-init on next start). No instance type pre-validation (matches AWS — invalid types accepted, fail at StartInstances time). | 1. Change instance type while stopped<br>2. Change user data while stopped<br>3. Modify running instance (error: NotFound — running instances not in KV)<br>4. Instance not found (error: InvalidInstanceID.NotFound)<br>5. Instance not stopped (error: IncorrectInstanceState)<br>6. Invalid instance type accepted (fails on start with InsufficientInstanceCapacity)<br>7. StateReason cleared on type change (recovery from capacity-unavailable)<br>8. Missing/malformed instance ID (error: InvalidInstanceID.Malformed)<br>9. No attribute set (error: InvalidParameterValue)<br>10. Multiple attributes in one call (error: InvalidParameterValue) | **DONE** |
|
||||
| `get-console-output` | `--instance-id` | `--latest` (always returns latest), `--dry-run` | Instance must be running on a node | Gateway sends NATS `ec2.{instanceId}.GetConsoleOutput` (per-instance topic, routed to owning node) → daemon reads console log file from disk → returns last 64KB base64-encoded with timestamp. Always available regardless of serial console access setting (matches AWS behavior). | 1. Get output from running instance<br>2. Empty log file returns empty output<br>3. Instance not found (error: InvalidInstanceID.NotFound) | **DONE** |
|
||||
| `describe-instance-attribute` | `--instance-id`, `--attribute` (instanceType, userData, instanceInitiatedShutdownBehavior, disableApiTermination, disableApiStop, ebsOptimized, enaSupport, sourceDestCheck, rootDeviceName, kernel, ramdisk) | `--dry-run` | Instance must exist (running or stopped) | Gateway validates input → NATS `ec2.DescribeInstanceAttribute` with `spinifex-workers` queue group → daemon checks running instances first (`d.Instances.VMS`), then stopped instances in JetStream KV → returns single attribute per call (matches AWS behavior). Stored attributes (`instanceType`, `userData`) return real values; unstored attributes return AWS defaults (`instanceInitiatedShutdownBehavior`=stop, `disableApiTermination`=false, etc.) | 1. Get instanceType from running instance<br>2. Get userData from stopped instance<br>3. Get default disableApiTermination<br>4. Invalid attribute name (error)<br>5. Instance not found (error: InvalidInstanceID.NotFound) | **DONE** |
|
||||
| `describe-instance-credit-specifications` | `--instance-ids` | `--filters`, `--max-results`, `--dry-run` | None | Gateway-only stub — returns `CpuCredits: "standard"` for each requested instance ID. No daemon round-trip. T-series credit mode is not persisted. | 1. Get credit spec for T-series instance<br>2. Multiple instance IDs | **DONE** |
|
||||
| `monitor-instances` | — | `--instance-ids` | Instance must exist | Enable basic monitoring (CPU, disk, network) → store metrics in NATS KV → return monitoring state | 1. Enable monitoring<br>2. Verify monitoring state in describe-instances | **NOT STARTED** |
|
||||
| `unmonitor-instances` | — | `--instance-ids` | Instance must exist | Disable detailed monitoring → revert to basic monitoring → return monitoring state | 1. Disable monitoring<br>2. Verify monitoring state reverts in describe-instances | **NOT STARTED** |
|
||||
|
||||
@@ -236,13 +238,16 @@ Placement groups are stored in `spinifex-placement-groups` JetStream KV bucket,
|
||||
|
||||
### EC2 - Elastic IP
|
||||
|
||||
EIP operations are conditionally available — only registered when external IPAM is configured (pool mode). EIPs are stored in `spinifex-eip-allocations` KV bucket. Association publishes `vpc.add-nat`/`vpc.delete-nat` events to vpcd for OVN NAT configuration.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `allocate-address` | — | `--domain` (vpc), `--tag-specifications`, `--dry-run` | None | NATS `ec2.AllocateAddress` → daemon allocates IP from pool → stores in JetStream KV with eipalloc-ID → return AllocationId, PublicIp, Domain | 1. Allocate EIP<br>2. Verify in describe-addresses<br>3. Allocate without --domain uses default | **NOT STARTED** |
|
||||
| `release-address` | — | `--allocation-id`, `--dry-run` | EIP must exist and be disassociated | NATS `ec2.ReleaseAddress` → daemon verifies not associated → release IP back to pool → delete from KV → return success | 1. Release disassociated EIP<br>2. Release associated EIP (error)<br>3. Release non-existent (error) | **NOT STARTED** |
|
||||
| `associate-address` | — | `--allocation-id`, `--instance-id` or `--network-interface-id`, `--dry-run` | EIP and target must exist | NATS `ec2.AssociateAddress` → daemon links EIP to instance/ENI → configure OVS DNAT/SNAT rules → return eipassoc-ID | 1. Associate with instance<br>2. Re-associate (moves EIP)<br>3. Associate non-existent EIP (error) | **NOT STARTED** |
|
||||
| `disassociate-address` | — | `--association-id`, `--dry-run` | Association must exist | NATS `ec2.DisassociateAddress` → daemon unlinks EIP from instance/ENI → remove OVS DNAT/SNAT rules → return success | 1. Disassociate existing<br>2. Disassociate non-existent (error) | **NOT STARTED** |
|
||||
| `describe-addresses` | — | `--allocation-ids`, `--filters`, `--public-ips` | None | NATS `ec2.DescribeAddresses` → daemon lists EIPs from KV → return list with association info | 1. List all EIPs<br>2. Filter by allocation ID<br>3. Filter by public IP | **NOT STARTED** |
|
||||
| `allocate-address` | `--public-ipv4-pool` (optional, specific IPAM pool), `--tag-specifications` | `--domain` (hardcoded vpc), `--dry-run` | EIP service must be initialized (external IPAM configured) | Gateway validates input → NATS `ec2.AllocateAddress` with `spinifex-workers` queue group → daemon allocates IP from external IPAM pool (named or best-matching) → creates EIPRecord with state=allocated in `spinifex-eip-allocations` KV → returns AllocationId, PublicIp, Domain=vpc, PublicIpv4Pool | 1. Allocate EIP from default pool<br>2. Allocate from specific pool<br>3. Pool exhausted (InsufficientAddressCapacity)<br>4. With tag specifications<br>5. Verify in describe-addresses | **DONE** |
|
||||
| `release-address` | `--allocation-id` | `--dry-run` | EIP must exist and be disassociated | Gateway validates AllocationId non-empty → NATS `ec2.ReleaseAddress` → daemon retrieves EIP record from KV → checks state != associated (InvalidAddressLocked if still associated) → releases IP back to IPAM pool → deletes KV record → returns success | 1. Release disassociated EIP<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-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
|
||||
|
||||
@@ -256,29 +261,30 @@ Placement groups are stored in `spinifex-placement-groups` JetStream KV bucket,
|
||||
|
||||
### EC2 - VPC Networking (Core)
|
||||
|
||||
Requires OVN/OVS (`apt install openvswitch-switch ovn-host`). VPC/Subnet/ENI CRUD stores metadata in NATS KV and publishes events to vpcd (the network topology daemon) which translates to OVN logical switches, ports, and routers. Single AZ for Spinifex v1. IPAM uses CAS-based allocation from NATS KV (`spinifex-vpc-ipam` bucket).
|
||||
Requires OVN/OVS (`apt install openvswitch-switch ovn-host`). VPC/Subnet/ENI/SG CRUD stores metadata in NATS KV and publishes events to vpcd (the network topology daemon) which translates to OVN logical switches, ports, routers, and ACLs. Single AZ for Spinifex v1. IPAM uses CAS-based allocation from NATS KV (`spinifex-vpc-ipam` bucket). Security groups stored in `spinifex-vpc-security-groups` KV bucket with optimistic locking on rule updates.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-vpc` | `--cidr-block`, `--tag-specifications` | `--instance-tenancy`, `--dry-run` | None | Gateway validates CidrBlock (required, valid CIDR, /16–/28 prefix) → NATS `ec2.CreateVpc` → daemon allocates unique VNI from `spinifex-vpc-vni-counter` KV → generates vpc-ID → stores VPCRecord in `spinifex-vpc-vpcs` KV → publishes `vpc.create` event to vpcd (creates OVN logical switch) → returns VPC with state=available | 1. Create VPC with /16 CIDR<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** |
|
||||
| `modify-vpc-attribute` | — | `--vpc-id`, `--enable-dns-support`, `--enable-dns-hostnames` | VPC must exist | NATS `ec2.ModifyVpcAttribute` → daemon updates VPC attribute in KV → return success | 1. Enable DNS support<br>2. Enable DNS hostnames<br>3. Missing VPC ID (error) | **NOT STARTED** |
|
||||
| `modify-vpc-attribute` | `--vpc-id`, `--enable-dns-hostnames`, `--enable-dns-support`, `--enable-network-address-usage-metrics` | `--dry-run` | VPC must exist | Gateway validates VpcId non-empty → NATS `ec2.ModifyVpcAttribute` → daemon retrieves VPC record from `spinifex-vpc-vpcs` KV → updates boolean flags if provided → updates KV with optimistic locking → returns empty output | 1. Enable DNS hostnames<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** |
|
||||
| `modify-subnet-attribute` | — | `--subnet-id`, `--map-public-ip-on-launch`, `--assign-ipv6-address-on-creation` | Subnet must exist | NATS `ec2.ModifySubnetAttribute` → daemon updates subnet attribute in KV → return success | 1. Enable auto-assign public IP<br>2. Missing subnet ID (error) | **NOT STARTED** |
|
||||
| `modify-subnet-attribute` | `--subnet-id`, `--map-public-ip-on-launch` | `--assign-ipv6-address-on-creation`, `--dry-run` | Subnet must exist | Gateway validates SubnetId non-empty → NATS `ec2.ModifySubnetAttribute` → daemon retrieves Subnet record from `spinifex-vpc-subnets` KV → updates MapPublicIpOnLaunch flag if provided → updates KV with optimistic locking → returns empty output | 1. Enable auto-assign public IP<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` | VPC must exist | Create security group metadata → create default OVS flow rules (deny all inbound, allow all outbound) → assign sg-ID | 1. Create SG in VPC<br>2. Duplicate name in same VPC (error)<br>3. Verify default rules | **NOT STARTED** |
|
||||
| `delete-security-group` | — | `--group-id` | SG must not be in use by instances | Verify no instances reference SG → remove OVS flow rules → delete metadata | 1. Delete unused SG<br>2. Delete SG in use (error) | **NOT STARTED** |
|
||||
| `describe-security-groups` | — | `--group-ids`, `--group-names`, `--filters`, `--max-results` | None | Query NATS KV for SG metadata → return SG list with rules | 1. List all SGs<br>2. Filter by VPC ID<br>3. Filter by group name | **NOT STARTED** |
|
||||
| `authorize-security-group-ingress` | — | `--group-id`, `--protocol`, `--port`, `--cidr`, `--source-group` | SG must exist | Add inbound rule → create OVS OpenFlow rule → persist to metadata | 1. Allow SSH (port 22) from 0.0.0.0/0<br>2. Allow from specific CIDR<br>3. Duplicate rule (idempotent) | **NOT STARTED** |
|
||||
| `authorize-security-group-egress` | — | `--group-id`, `--protocol`, `--port`, `--cidr` | SG must exist | Add outbound rule → create OVS OpenFlow rule → persist to metadata | 1. Allow HTTPS outbound<br>2. Restrict to specific CIDR | **NOT STARTED** |
|
||||
| `revoke-security-group-ingress` | — | `--group-id`, `--protocol`, `--port`, `--cidr` | SG must exist, rule must exist | Remove inbound rule → delete OVS OpenFlow rule → update metadata | 1. Revoke existing rule<br>2. Revoke non-existent rule (error) | **NOT STARTED** |
|
||||
| `revoke-security-group-egress` | — | `--group-id`, `--protocol`, `--port`, `--cidr` | SG must exist, rule must exist | Remove outbound rule → delete OVS OpenFlow rule → update metadata | 1. Revoke existing rule<br>2. Revoke non-existent rule (error) | **NOT STARTED** |
|
||||
| `create-security-group` | `--group-name`, `--description`, `--vpc-id`, `--tag-specifications` | `--dry-run` | VPC must exist | Gateway validates GroupName + VpcId required → NATS `ec2.CreateSecurityGroup` → daemon verifies VPC exists → checks duplicate name in same VPC → generates sg-ID → creates SecurityGroupRecord in `spinifex-vpc-security-groups` KV with default egress rule (allow all outbound 0.0.0.0/0), no ingress rules → publishes `vpc.create-sg` event to vpcd (OVN ACL configuration) → returns GroupId | 1. Create SG in VPC<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** |
|
||||
| `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** |
|
||||
| `revoke-security-group-egress` | `--group-id`, `--ip-permissions` (matching rules to remove) | `--dry-run` | SG must exist | Gateway validates GroupId → NATS `ec2.RevokeSecurityGroupEgress` → daemon retrieves SG → removes matching rules from EgressRules (set difference) → updates KV with optimistic locking → publishes `vpc.update-sg` event → returns true | 1. Revoke existing rule<br>2. Missing GroupId (MissingParameter)<br>3. Non-existent SG (InvalidGroup.NotFound) | **DONE** |
|
||||
| `create-internet-gateway` | `--tag-specifications` | `--dry-run` | None | NATS `ec2.CreateInternetGateway` → daemon generates igw-ID → creates IGWRecord in `spinifex-igw` KV bucket with state=available (not attached) → returns InternetGateway with empty Attachments | 1. Create IGW<br>2. Verify in describe-internet-gateways<br>3. Tags applied at creation | **DONE** |
|
||||
| `attach-internet-gateway` | `--internet-gateway-id`, `--vpc-id` | `--dry-run` | IGW and VPC must exist | NATS `ec2.AttachInternetGateway` → daemon validates IGW exists and is not already attached (ResourceAlreadyAssociated) → sets VpcId on record → state=attached → publishes `vpc.igw-attach` event to vpcd (creates OVN external switch, gateway port, SNAT rules) | 1. Attach IGW to VPC<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** |
|
||||
@@ -376,7 +382,7 @@ ENIs are managed via `spinifex-vpc-enis` KV bucket with CAS-based IPAM allocatio
|
||||
| `describe-network-interfaces` | `--network-interface-ids`, `--filters` (subnet-id, vpc-id, attachment.instance-id) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeNetworkInterfaces` → daemon lists all keys from `spinifex-vpc-enis` KV → applies filters (subnet-id, vpc-id, attachment.instance-id) → filters by ENI IDs if specified → returns error for non-existent requested IDs | 1. List all ENIs<br>2. Filter by subnet-id<br>3. Filter by vpc-id<br>4. Filter by attachment.instance-id<br>5. Non-existent ENI returns error | **DONE** |
|
||||
| `attach-network-interface` | — | `--network-interface-id`, `--instance-id`, `--device-index` | ENI and instance must exist | Internal operation used by `run-instances --subnet-id`. Marks ENI as "in-use", sets instance attachment, generates attachment ID. No standalone API endpoint yet. | 1. Attach ENI to instance<br>2. Already attached (error)<br>3. Missing device index (error) | **NOT STARTED** (internal only) |
|
||||
| `detach-network-interface` | — | `--attachment-id`, `--force` | ENI must be attached | Internal operation used by terminate-instances. Marks ENI as "available", clears attachment info. No standalone API endpoint yet. | 1. Detach ENI<br>2. Force detach<br>3. Not attached (error) | **NOT STARTED** (internal only) |
|
||||
| `modify-network-interface-attribute` | — | `--network-interface-id`, `--description`, `--source-dest-check`, `--groups` | ENI must exist | NATS `ec2.ModifyNetworkInterfaceAttribute` → daemon updates ENI attributes in KV → return success | 1. Modify description<br>2. Toggle source/dest check<br>3. Update security groups | **NOT STARTED** |
|
||||
| `modify-network-interface-attribute` | — | `--network-interface-id`, `--description`, `--groups` | ENI must exist; at least one attribute required | NATS `ec2.ModifyNetworkInterfaceAttribute` → daemon validates ENI exists → updates description and/or security groups in `spinifex-vpc-enis` KV (optimistic locking) → return success | 1. Modify description<br>2. Update security groups<br>3. No attributes (InvalidParameterValue)<br>4. Non-existent ENI (error) | **DONE** |
|
||||
| `assign-private-ip-addresses` | — | `--network-interface-id`, `--private-ip-addresses` or `--secondary-private-ip-address-count` | ENI must exist | NATS `ec2.AssignPrivateIpAddresses` → daemon allocates IPs from subnet pool → assign to ENI → return assigned IPs | 1. Assign specific IPs<br>2. Assign by count<br>3. Cannot specify both (error) | **NOT STARTED** |
|
||||
| `unassign-private-ip-addresses` | — | `--network-interface-id`, `--private-ip-addresses` | IPs must be assigned to ENI | NATS `ec2.UnassignPrivateIpAddresses` → daemon releases IPs → return success | 1. Unassign secondary IP<br>2. Unassign primary IP (error) | **NOT STARTED** |
|
||||
|
||||
@@ -477,5 +483,5 @@ The gateway evaluates IAM policies on every request (except root user bypass). P
|
||||
| Explicit deny | Deny statements override Allow (deny wins) | **DONE** |
|
||||
| Wildcard matching | `*` matches all, `ec2:*` matches all EC2, `ec2:Describe*` matches prefix | **DONE** |
|
||||
| Account-scoped evaluation | Policies resolved from caller's account context | **DONE** |
|
||||
| EC2 action mapping | 54 EC2 actions mapped to IAM policy strings | **DONE** |
|
||||
| EC2 action mapping | All registered EC2 actions mapped to IAM policy strings | **DONE** |
|
||||
| IAM action mapping | 16 IAM actions mapped to IAM policy strings | **DONE** |
|
||||
|
||||
@@ -32,16 +32,10 @@ Spinifex supports multi-tenant account isolation. Each account gets its own IAM
|
||||
## Create Account
|
||||
|
||||
```bash
|
||||
./bin/spx admin account create --name myteam
|
||||
spx admin account create --name myteam
|
||||
export AWS_PROFILE=spinifex-myteam
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
aws sts get-caller-identity
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
## Credentials Not Working
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
---
|
||||
title: "Creating an Environment"
|
||||
description: "End-to-end guide: user accounts, SSH keys, VPCs, AMIs, and launching instances."
|
||||
category: "Environments"
|
||||
tags:
|
||||
- environment
|
||||
- vpc
|
||||
- ec2
|
||||
- ssh
|
||||
resources:
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
- title: "Debian Cloud Images"
|
||||
url: "https://cloud.debian.org/images/cloud/bookworm/latest/"
|
||||
---
|
||||
|
||||
# Creating an Environment
|
||||
|
||||
> End-to-end guide: user accounts, SSH keys, VPCs, AMIs, and launching instances.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This guide walks through the complete workflow for creating a production-ready Spinifex environment.
|
||||
|
||||
## Instructions
|
||||
|
||||
## 1. Import SSH Key
|
||||
|
||||
```bash
|
||||
aws ec2 import-key-pair --key-name "spinifex-key" --public-key-material fileb://~/.ssh/id_rsa.pub
|
||||
```
|
||||
|
||||
## 2. Create VPC
|
||||
|
||||
```bash
|
||||
aws ec2 create-vpc --cidr-block 10.200.0.0/16
|
||||
export SPINIFEX_VPC="vpc-XXX"
|
||||
|
||||
aws ec2 create-subnet --vpc-id $SPINIFEX_VPC --cidr-block 10.200.1.0/24
|
||||
export SPINIFEX_SUBNET="subnet-XXX"
|
||||
```
|
||||
|
||||
## 3. Import AMI
|
||||
|
||||
```bash
|
||||
./bin/spx admin images list
|
||||
./bin/spx admin images import --name debian-12-x86_64
|
||||
export SPINIFEX_AMI="ami-XXX"
|
||||
```
|
||||
|
||||
## 4. Launch Instance
|
||||
|
||||
```bash
|
||||
aws ec2 run-instances \
|
||||
--image-id $SPINIFEX_AMI \
|
||||
--instance-type t3.small \
|
||||
--key-name spinifex-key \
|
||||
--subnet-id $SPINIFEX_SUBNET \
|
||||
--count 1
|
||||
```
|
||||
|
||||
## 5. Connect via SSH
|
||||
|
||||
```bash
|
||||
ps auxw | grep $INSTANCE_ID
|
||||
# Look for: hostfwd=tcp:127.0.0.1:<port>-:22
|
||||
|
||||
ssh -i ~/.ssh/spinifex-key ec2-user@127.0.0.1 -p <port>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
## Instance Stuck in Pending
|
||||
|
||||
Check the Spinifex daemon and QEMU logs for boot errors:
|
||||
|
||||
```bash
|
||||
ls ~/spinifex/logs/
|
||||
cat ~/spinifex/logs/spinifex.log
|
||||
```
|
||||
|
||||
Verify the AMI exists and architecture matches your host:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-images --image-ids $SPINIFEX_AMI
|
||||
```
|
||||
|
||||
## SSH Connection Refused
|
||||
|
||||
cloud-init takes 30-60 seconds to configure networking and SSH after boot. Wait and retry.
|
||||
|
||||
Verify the SSH port forwarding is active:
|
||||
|
||||
```bash
|
||||
ps auxw | grep hostfwd
|
||||
```
|
||||
|
||||
Ensure you're using the correct key and port:
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/spinifex-key ec2-user@127.0.0.1 -p <port>
|
||||
```
|
||||
@@ -29,6 +29,14 @@ Spinifex's hybrid mode enables bidirectional data synchronization between local
|
||||
|
||||
## Instructions
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Ensure the AWS profile is set:
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
```
|
||||
|
||||
## Push Local to Cloud
|
||||
|
||||
```bash
|
||||
@@ -61,14 +69,3 @@ aws s3 sync s3://local-bucket/ s3://cloud-bucket/ \
|
||||
```
|
||||
|
||||
Only changed or missing files will be transferred on subsequent runs.
|
||||
|
||||
## Permission Errors During Sync
|
||||
|
||||
Verify your AWS credentials are configured for both the source and destination:
|
||||
|
||||
```bash
|
||||
aws sts get-caller-identity --profile spinifex
|
||||
aws sts get-caller-identity --profile default
|
||||
```
|
||||
|
||||
Ensure the IAM user has `s3:GetObject`, `s3:PutObject`, and `s3:ListBucket` permissions on both buckets.
|
||||
|
||||
@@ -53,7 +53,6 @@ When you create an account with `spx admin account create`, Spinifex bootstraps
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex-myteam
|
||||
aws sts get-caller-identity
|
||||
```
|
||||
|
||||
## Users
|
||||
@@ -68,13 +67,13 @@ The response includes the user's ARN, unique ID, and creation date:
|
||||
|
||||
```json
|
||||
{
|
||||
"User": {
|
||||
"UserName": "alice",
|
||||
"UserId": "AIDA1A2B3C4D5E6F7890",
|
||||
"Arn": "arn:aws:iam::000000000001:user/alice",
|
||||
"Path": "/",
|
||||
"CreateDate": "2026-03-24T10:00:00Z"
|
||||
}
|
||||
"User": {
|
||||
"UserName": "alice",
|
||||
"UserId": "AIDA1A2B3C4D5E6F7890",
|
||||
"Arn": "arn:aws:iam::000000000001:user/alice",
|
||||
"Path": "/",
|
||||
"CreateDate": "2026-03-24T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -120,7 +119,6 @@ aws iam detach-user-policy --user-name alice \
|
||||
aws iam delete-user --user-name alice
|
||||
```
|
||||
|
||||
|
||||
## Access Keys
|
||||
|
||||
Access keys are how users authenticate with the AWS CLI. Each user can have up to **2 access keys** at a time, allowing key rotation without downtime.
|
||||
@@ -133,13 +131,13 @@ aws iam create-access-key --user-name alice
|
||||
|
||||
```json
|
||||
{
|
||||
"AccessKey": {
|
||||
"UserName": "alice",
|
||||
"AccessKeyId": "AKIA1A2B3C4D5E6F7890ABCD",
|
||||
"Status": "Active",
|
||||
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"CreateDate": "2026-03-24T10:05:00Z"
|
||||
}
|
||||
"AccessKey": {
|
||||
"UserName": "alice",
|
||||
"AccessKeyId": "AKIA1A2B3C4D5E6F7890ABCD",
|
||||
"Status": "Active",
|
||||
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"CreateDate": "2026-03-24T10:05:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -153,7 +151,6 @@ Add the credentials to `~/.aws/credentials` under a new profile and set `AWS_PRO
|
||||
aws iam list-access-keys --user-name alice
|
||||
```
|
||||
|
||||
|
||||
### Rotate Access Keys
|
||||
|
||||
Create a second key, update your configuration, then delete the old one:
|
||||
@@ -189,7 +186,6 @@ aws iam update-access-key --user-name alice \
|
||||
--status Active
|
||||
```
|
||||
|
||||
|
||||
## Policies
|
||||
|
||||
Policies are JSON documents that define what actions a user can perform on which resources.
|
||||
@@ -198,14 +194,14 @@ Policies are JSON documents that define what actions a user can perform on which
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["ec2:RunInstances", "ec2:DescribeInstances"],
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": ["ec2:RunInstances", "ec2:DescribeInstances"],
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -249,32 +245,31 @@ aws iam create-policy \
|
||||
|
||||
```json
|
||||
{
|
||||
"Policy": {
|
||||
"PolicyName": "EC2ReadOnly",
|
||||
"PolicyId": "ANPA1A2B3C4D5E6F7890",
|
||||
"Arn": "arn:aws:iam::000000000001:policy/EC2ReadOnly",
|
||||
"Path": "/",
|
||||
"DefaultVersionId": "v1",
|
||||
"CreateDate": "2026-03-24T10:10:00Z"
|
||||
}
|
||||
"Policy": {
|
||||
"PolicyName": "EC2ReadOnly",
|
||||
"PolicyId": "ANPA1A2B3C4D5E6F7890",
|
||||
"Arn": "arn:aws:iam::000000000001:policy/EC2ReadOnly",
|
||||
"Path": "/",
|
||||
"DefaultVersionId": "v1",
|
||||
"CreateDate": "2026-03-24T10:10:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### Common Policy Examples
|
||||
|
||||
**Full administrator access:**
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": "*",
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": "*",
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -282,19 +277,19 @@ aws iam create-policy \
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:PutObject",
|
||||
"s3:ListBucket",
|
||||
"s3:DeleteObject"
|
||||
],
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetObject",
|
||||
"s3:PutObject",
|
||||
"s3:ListBucket",
|
||||
"s3:DeleteObject"
|
||||
],
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -302,21 +297,21 @@ aws iam create-policy \
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:RunInstances",
|
||||
"ec2:StartInstances",
|
||||
"ec2:StopInstances",
|
||||
"ec2:TerminateInstances",
|
||||
"ec2:DescribeInstances",
|
||||
"ec2:DescribeImages"
|
||||
],
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"ec2:RunInstances",
|
||||
"ec2:StartInstances",
|
||||
"ec2:StopInstances",
|
||||
"ec2:TerminateInstances",
|
||||
"ec2:DescribeInstances",
|
||||
"ec2:DescribeImages"
|
||||
],
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -324,14 +319,14 @@ aws iam create-policy \
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Action": "ec2:TerminateInstances",
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Deny",
|
||||
"Action": "ec2:TerminateInstances",
|
||||
"Resource": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -352,7 +347,6 @@ aws iam get-policy-version \
|
||||
--version-id v1
|
||||
```
|
||||
|
||||
|
||||
### List Policies
|
||||
|
||||
```bash
|
||||
@@ -368,7 +362,6 @@ aws iam delete-policy \
|
||||
--policy-arn arn:aws:iam::000000000001:policy/EC2ReadOnly
|
||||
```
|
||||
|
||||
|
||||
## Attaching Policies
|
||||
|
||||
Policies have no effect until attached to a user.
|
||||
@@ -380,7 +373,6 @@ aws iam attach-user-policy --user-name alice \
|
||||
--policy-arn arn:aws:iam::000000000001:policy/EC2ReadOnly
|
||||
```
|
||||
|
||||
|
||||
### List a User's Policies
|
||||
|
||||
```bash
|
||||
@@ -453,24 +445,24 @@ AWS_PROFILE=spinifex-carol aws ec2 terminate-instances --instance-ids i-123
|
||||
|
||||
## Command Reference
|
||||
|
||||
| Command | Key Parameters | Notes |
|
||||
|---------|---------------|-------|
|
||||
| `create-user` | `--user-name`, `--path` | Path default: `/` |
|
||||
| `get-user` | `--user-name` | |
|
||||
| `list-users` | `--path-prefix` | |
|
||||
| `delete-user` | `--user-name` | Must have no keys or policies |
|
||||
| `create-access-key` | `--user-name` | Max 2 per user; secret shown once |
|
||||
| `list-access-keys` | `--user-name` | |
|
||||
| `delete-access-key` | `--user-name`, `--access-key-id` | |
|
||||
| `update-access-key` | `--user-name`, `--access-key-id`, `--status` | `Active` or `Inactive` |
|
||||
| `create-policy` | `--policy-name`, `--policy-document` | Max 6144 bytes |
|
||||
| `get-policy` | `--policy-arn` | Metadata only |
|
||||
| `get-policy-version` | `--policy-arn`, `--version-id` | Use `v1`; includes document |
|
||||
| `list-policies` | | |
|
||||
| `delete-policy` | `--policy-arn` | Must not be attached |
|
||||
| `attach-user-policy` | `--user-name`, `--policy-arn` | Idempotent |
|
||||
| `detach-user-policy` | `--user-name`, `--policy-arn` | |
|
||||
| `list-attached-user-policies` | `--user-name` | |
|
||||
| Command | Key Parameters | Notes |
|
||||
| ----------------------------- | -------------------------------------------- | --------------------------------- |
|
||||
| `create-user` | `--user-name`, `--path` | Path default: `/` |
|
||||
| `get-user` | `--user-name` | |
|
||||
| `list-users` | `--path-prefix` | |
|
||||
| `delete-user` | `--user-name` | Must have no keys or policies |
|
||||
| `create-access-key` | `--user-name` | Max 2 per user; secret shown once |
|
||||
| `list-access-keys` | `--user-name` | |
|
||||
| `delete-access-key` | `--user-name`, `--access-key-id` | |
|
||||
| `update-access-key` | `--user-name`, `--access-key-id`, `--status` | `Active` or `Inactive` |
|
||||
| `create-policy` | `--policy-name`, `--policy-document` | Max 6144 bytes |
|
||||
| `get-policy` | `--policy-arn` | Metadata only |
|
||||
| `get-policy-version` | `--policy-arn`, `--version-id` | Use `v1`; includes document |
|
||||
| `list-policies` | | |
|
||||
| `delete-policy` | `--policy-arn` | Must not be attached |
|
||||
| `attach-user-policy` | `--user-name`, `--policy-arn` | Idempotent |
|
||||
| `detach-user-policy` | `--user-name`, `--policy-arn` | |
|
||||
| `list-attached-user-policies` | `--user-name` | |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "Air-Gapped Installation"
|
||||
title: "Air-Gapped Install"
|
||||
description: "Deploy Spinifex in environments without internet connectivity. Covers offline package caching, USB deployment, and package verification."
|
||||
category: "Getting Started"
|
||||
tags:
|
||||
@@ -85,7 +85,7 @@ cp -r /mnt/usb/spinifex-source ~/Development/mulga/spinifex
|
||||
cd ~/Development/mulga/spinifex && make build
|
||||
```
|
||||
|
||||
Then follow the [Source Install](/docs/source-install) guide from Step 3 (Setup OVN) onwards.
|
||||
Then follow the [Source Install](/docs/install-source) guide from Step 3 (Setup OVN) onwards.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "Multi-Node Installation"
|
||||
title: "Multi-Node Install"
|
||||
description: "Deploy Spinifex across multiple servers to create an availability zone with high availability, data durability, and fault tolerance."
|
||||
category: "Getting Started"
|
||||
tags:
|
||||
@@ -42,7 +42,7 @@ A Spinifex cluster distributes services across multiple servers for high availab
|
||||
## Step 1. Install Spinifex on Each Server
|
||||
|
||||
```bash
|
||||
curl https://install.mulgadc.com | bash
|
||||
curl -fsSL https://install.mulgadc.com | bash
|
||||
```
|
||||
|
||||
## Step 2. Set Node IP Variables
|
||||
@@ -13,7 +13,7 @@ resources:
|
||||
url: "https://go.dev/dl/"
|
||||
---
|
||||
|
||||
# Source Install
|
||||
# Source Installation
|
||||
|
||||
> Build Spinifex from source for development, custom builds, or contributing.
|
||||
|
||||
@@ -27,7 +27,7 @@ resources:
|
||||
|
||||
## Overview
|
||||
|
||||
This guide builds Spinifex from source. For production deployments, the [binary installer](/docs/installing-spinifex) is recommended.
|
||||
This guide builds Spinifex from source. For production deployments, the [binary installer](/docs/install) is recommended.
|
||||
|
||||
**Requirements:**
|
||||
|
||||
@@ -57,15 +57,7 @@ cd spinifex
|
||||
|
||||
Confirm `./bin/spx` exists.
|
||||
|
||||
## Step 3. Initialize
|
||||
|
||||
```bash
|
||||
sudo ./bin/spx admin init --region ap-southeast-2 --az ap-southeast-2a --node node1 --nodes 1
|
||||
```
|
||||
|
||||
Save the admin credentials printed during init.
|
||||
|
||||
## Step 4. Setup OVN
|
||||
## Step 3. Setup OVN
|
||||
|
||||
If your WAN interface is already a bridge (e.g. `br-wan`), setup-ovn.sh auto-detects it:
|
||||
|
||||
@@ -83,6 +75,14 @@ If your WAN is a physical NIC, choose one:
|
||||
./scripts/setup-ovn.sh --management --macvlan --wan-iface=enp0s3
|
||||
```
|
||||
|
||||
## Step 4. Initialize
|
||||
|
||||
```bash
|
||||
sudo ./bin/spx admin init --node node1 --nodes 1
|
||||
```
|
||||
|
||||
Save the admin credentials printed during init.
|
||||
|
||||
## Step 5. Start Services
|
||||
|
||||
```bash
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: "Installing Spinifex"
|
||||
title: "Single-Node Install"
|
||||
description: "Install Spinifex on a single server using the binary installer."
|
||||
category: "Getting Started"
|
||||
tags:
|
||||
@@ -31,14 +31,14 @@ resources:
|
||||
|
||||
Spinifex is an open-source infrastructure platform that brings core AWS services — EC2, VPC, EBS, and S3 — to bare-metal, edge, and on-prem environments.
|
||||
|
||||
This guide installs Spinifex on a single server using the binary installer. For multi-server clusters, see [Multi-Node Installation](/docs/installing-spinifex-multi-node). To build from source, see [Source Install](/docs/source-install).
|
||||
This guide installs Spinifex on a single server using the binary installer. For multi-server clusters, see [Multi-Node Install](/docs/install-multi-node). To build from source, see [Source Install](/docs/install-source).
|
||||
|
||||
**Supported Operating Systems:**
|
||||
|
||||
- Ubuntu 22.04 / 24.04 / 25.10
|
||||
- Debian 12 / 13
|
||||
|
||||
**What gets installed:**
|
||||
**What Gets Installed:**
|
||||
|
||||
- Spinifex daemon and CLI
|
||||
- QEMU/KVM for virtual machine management
|
||||
@@ -52,7 +52,7 @@ This guide installs Spinifex on a single server using the binary installer. For
|
||||
## Step 1. Install Spinifex
|
||||
|
||||
```bash
|
||||
curl https://install.mulgadc.com | bash
|
||||
curl -fsSL https://install.mulgadc.com | bash
|
||||
```
|
||||
|
||||
The installer downloads the Spinifex binary and bootstraps all dependencies (QEMU, OVN/OVS, AWS CLI).
|
||||
@@ -70,15 +70,20 @@ If your WAN is a physical NIC, choose one:
|
||||
```bash
|
||||
# Dedicated WAN NIC (not your SSH connection):
|
||||
sudo /usr/local/share/spinifex/setup-ovn.sh --management --wan-bridge=br-wan --wan-iface=eth1
|
||||
|
||||
# Single-NIC host (SSH-safe macvlan):
|
||||
sudo /usr/local/share/spinifex/setup-ovn.sh --management --macvlan --wan-iface=enp0s3
|
||||
```
|
||||
|
||||
**Separating VPC tunnel traffic from WAN:** If you want Geneve tunnel traffic (inter-VM east-west for VPC traffic) to use a dedicated interface instead of the WAN IP, add `--encap-ip=<IP>` to specify the tunnel endpoint address:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/share/spinifex/setup-ovn.sh --management --encap-ip=10.0.0.1
|
||||
```
|
||||
|
||||
This is recommended for production and required in multi-node deployments. See [Multi-Node Install](/docs/install-multi-node) for details.
|
||||
|
||||
## Step 3. Initialize
|
||||
|
||||
```bash
|
||||
sudo spx admin init --region ap-southeast-2 --az ap-southeast-2a --node node1 --nodes 1
|
||||
sudo spx admin init --node node1 --nodes 1
|
||||
```
|
||||
|
||||
This auto-detects your network topology, generates configuration and TLS certificates, installs the CA into the system trust store, and configures AWS CLI credentials. Save the admin credentials printed during init — they will not be shown again.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: "Launching Instances"
|
||||
description: "Launch, manage, and connect to EC2-compatible virtual machines on Spinifex."
|
||||
category: "Environments"
|
||||
category: "Compute"
|
||||
tags:
|
||||
- ec2
|
||||
- instances
|
||||
@@ -20,8 +20,16 @@ resources:
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Launch](#launch)
|
||||
- [Manage](#manage)
|
||||
- [Reboot](#reboot)
|
||||
- [Modify Instance Attributes](#modify-instance-attributes)
|
||||
- [Console Output](#console-output)
|
||||
- [Instance Types](#instance-types)
|
||||
- [SSH (Development)](#ssh-development)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Instance Fails to Boot](#instance-fails-to-boot)
|
||||
- [Cannot SSH Into Instance](#cannot-ssh-into-instance)
|
||||
|
||||
---
|
||||
|
||||
@@ -34,19 +42,29 @@ Spinifex provides EC2-compatible VM management built on QEMU/KVM. Instances supp
|
||||
- `run-instances` — Launch new VMs
|
||||
- `describe-instances` — Query state
|
||||
- `stop-instances` / `start-instances` — Lifecycle
|
||||
- `reboot-instances` — In-place restart (QMP reset)
|
||||
- `terminate-instances` — Permanent removal
|
||||
- `modify-instance-attribute` — Change type, user data, or source/dest check
|
||||
- `get-console-output` — Retrieve serial console log
|
||||
- `describe-instance-types` — List available instance types
|
||||
|
||||
## Instructions
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Ensure the AWS profile is set:
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
```
|
||||
|
||||
## Launch
|
||||
|
||||
```bash
|
||||
aws ec2 run-instances \
|
||||
--image-id $SPINIFEX_AMI \
|
||||
--instance-type t3.small \
|
||||
--key-name spinifex-key \
|
||||
--subnet-id $SPINIFEX_SUBNET \
|
||||
--count 1
|
||||
--key-name spinifex-key
|
||||
|
||||
export INSTANCE_ID="i-XXX"
|
||||
```
|
||||
@@ -58,15 +76,72 @@ aws ec2 describe-instances --instance-ids $INSTANCE_ID
|
||||
aws ec2 stop-instances --instance-ids $INSTANCE_ID
|
||||
aws ec2 start-instances --instance-ids $INSTANCE_ID
|
||||
aws ec2 terminate-instances --instance-ids $INSTANCE_ID
|
||||
aws ec2 reboot-instances --instance-ids $INSTANCE_ID
|
||||
```
|
||||
|
||||
## Modify Instance Attributes
|
||||
|
||||
Change instance type, user data, or source/dest check. Instance type and user data require the instance to be **stopped** first.
|
||||
|
||||
### Change Instance Type
|
||||
|
||||
```bash
|
||||
aws ec2 stop-instances --instance-ids $INSTANCE_ID
|
||||
|
||||
aws ec2 modify-instance-attribute \
|
||||
--instance-id $INSTANCE_ID \
|
||||
--instance-type t3.medium
|
||||
|
||||
aws ec2 start-instances --instance-ids $INSTANCE_ID
|
||||
```
|
||||
|
||||
## Console Output
|
||||
|
||||
Retrieve the serial console log for a running instance. Output is base64-encoded.
|
||||
|
||||
```bash
|
||||
aws ec2 get-console-output --instance-id $INSTANCE_ID
|
||||
```
|
||||
|
||||
Decode the output:
|
||||
|
||||
```bash
|
||||
aws ec2 get-console-output --instance-id $INSTANCE_ID \
|
||||
--query 'Output' --output text | base64 -d
|
||||
```
|
||||
|
||||
## Instance Types
|
||||
|
||||
List instance types available on the current host. The catalog is generated from the host CPU (Intel, AMD, or ARM) and includes burstable (t-family), general purpose (m-family), compute optimised (c-family), and memory optimised (r-family) types.
|
||||
|
||||
```bash
|
||||
aws ec2 describe-instance-types
|
||||
```
|
||||
|
||||
Filter to a specific type:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-instance-types --instance-types t3.micro t3.small
|
||||
```
|
||||
|
||||
Show capacity (how many of each type can still be launched):
|
||||
|
||||
```bash
|
||||
aws ec2 describe-instance-types \
|
||||
--filters Name=capacity,Values=true
|
||||
```
|
||||
|
||||
## SSH (Development)
|
||||
|
||||
In development mode, find the QEMU port forward and connect via localhost:
|
||||
|
||||
```bash
|
||||
ps auxw | grep $INSTANCE_ID
|
||||
ssh -i ~/.ssh/spinifex-key ec2-user@127.0.0.1 -p <port>
|
||||
```
|
||||
|
||||
For production SSH via public IPs, see [Setting Up Your Cluster](/docs/setting-up-your-cluster).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Instance Fails to Boot
|
||||
@@ -81,8 +156,8 @@ aws ec2 describe-images --image-ids $SPINIFEX_AMI
|
||||
If the AMI is for a different architecture (e.g. arm64 on an x86_64 host), import the correct image:
|
||||
|
||||
```bash
|
||||
./bin/spx admin images list
|
||||
./bin/spx admin images import --name debian-12-x86_64
|
||||
spx admin images list
|
||||
spx admin images import --name debian-12-x86_64
|
||||
```
|
||||
|
||||
### Cannot SSH Into Instance
|
||||
|
||||
@@ -37,10 +37,17 @@ Spinifex provides drop-in compatibility with AWS APIs, making it possible to mig
|
||||
|
||||
## Instructions
|
||||
|
||||
## Configure AWS CLI
|
||||
## Prerequisites
|
||||
|
||||
Ensure the AWS profile is set:
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
```
|
||||
|
||||
## Configure AWS CLI
|
||||
|
||||
```bash
|
||||
aws ec2 describe-instances
|
||||
aws s3 ls
|
||||
```
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: "Placement Groups"
|
||||
description: "Create and manage spread and cluster placement groups for hardware-level instance placement control."
|
||||
category: "Compute"
|
||||
tags:
|
||||
- ec2
|
||||
- placement
|
||||
- spread
|
||||
- cluster
|
||||
resources:
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
- title: "AWS Placement Groups"
|
||||
url: "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/placement-groups.html"
|
||||
---
|
||||
|
||||
# Placement Groups
|
||||
|
||||
> Create and manage spread and cluster placement groups for hardware-level instance placement control.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Create](#create)
|
||||
- [Launch Into a Placement Group](#launch-into-a-placement-group)
|
||||
- [Describe](#describe)
|
||||
- [Delete](#delete)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [InsufficientInstanceCapacity Error](#insufficientinstancecapacity-error)
|
||||
- [Cannot Delete Placement Group](#cannot-delete-placement-group)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Placement groups control how Spinifex distributes instances across physical hosts. Two strategies are supported:
|
||||
|
||||
- **Spread** — One instance per physical host, maximising fault isolation
|
||||
- **Cluster** — All instances on the same host, minimising latency
|
||||
|
||||
The `partition` strategy is not supported.
|
||||
|
||||
**Supported operations:**
|
||||
|
||||
- `create-placement-group` — Create a new group
|
||||
- `describe-placement-groups` — Query groups with optional filters
|
||||
- `delete-placement-group` — Remove an empty group
|
||||
|
||||
## Instructions
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Ensure the AWS profile is set:
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
```
|
||||
|
||||
## Create
|
||||
|
||||
```bash
|
||||
aws ec2 create-placement-group \
|
||||
--group-name my-spread-group \
|
||||
--strategy spread
|
||||
```
|
||||
|
||||
```bash
|
||||
aws ec2 create-placement-group \
|
||||
--group-name my-cluster-group \
|
||||
--strategy cluster
|
||||
```
|
||||
|
||||
## Launch Into a Placement Group
|
||||
|
||||
```bash
|
||||
aws ec2 run-instances \
|
||||
--image-id $SPINIFEX_AMI \
|
||||
--instance-type t3.small \
|
||||
--key-name spinifex-key \
|
||||
--placement GroupName=my-spread-group \
|
||||
--count 3
|
||||
```
|
||||
|
||||
## Describe
|
||||
|
||||
```bash
|
||||
aws ec2 describe-placement-groups
|
||||
aws ec2 describe-placement-groups --group-names my-spread-group
|
||||
aws ec2 describe-placement-groups --group-ids pg-abc123
|
||||
aws ec2 describe-placement-groups \
|
||||
--filters Name=strategy,Values=spread
|
||||
```
|
||||
|
||||
## Delete
|
||||
|
||||
The group must have no running instances before it can be deleted:
|
||||
|
||||
```bash
|
||||
aws ec2 terminate-instances --instance-ids $INSTANCE_ID
|
||||
aws ec2 delete-placement-group --group-name my-spread-group
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### InsufficientInstanceCapacity Error
|
||||
|
||||
Not enough distinct physical hosts for a spread launch. Reduce `--count` or terminate existing instances to free host slots:
|
||||
|
||||
```bash
|
||||
spx admin nodes list
|
||||
```
|
||||
|
||||
### Cannot Delete Placement Group
|
||||
|
||||
The group still has running instances. Terminate them first:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-instances \
|
||||
--filters Name=placement-group-name,Values=my-spread-group
|
||||
aws ec2 terminate-instances --instance-ids $INSTANCE_ID
|
||||
```
|
||||
@@ -33,9 +33,9 @@ resources:
|
||||
## Overview
|
||||
|
||||
This guide assumes Spinifex is already installed and running. If not, follow one of the installation guides first:
|
||||
- [Binary Install (Single Node)](/docs/installing-spinifex)
|
||||
- [Multi-Node Installation](/docs/installing-spinifex-multi-node)
|
||||
- [Source Install](/docs/source-install)
|
||||
- [Binary Install (Single Node)](/docs/install)
|
||||
- [Multi-Node Install](/docs/install-multi-node)
|
||||
- [Source Install](/docs/install-source)
|
||||
|
||||
## Instructions
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ resources:
|
||||
|
||||
The `spx` binary is the central administration tool for managing your Spinifex infrastructure. It provides commands for cluster initialization, account management, node operations, VM lifecycle, and service control.
|
||||
|
||||
**Binary location:** `~/Development/mulga/spinifex/bin/spx`
|
||||
**Binary location:** `/usr/local/bin/spx` (binary install) or `~/Development/mulga/spinifex/bin/spx` (source install)
|
||||
|
||||
All services in the Spinifex platform are managed through this single binary.
|
||||
|
||||
@@ -36,14 +36,14 @@ All services in the Spinifex platform are managed through this single binary.
|
||||
## Account Management
|
||||
|
||||
```bash
|
||||
./bin/spx admin account create --name myteam
|
||||
spx admin account create --name myteam
|
||||
export AWS_PROFILE=spinifex-myteam
|
||||
```
|
||||
|
||||
## Node Management
|
||||
|
||||
```bash
|
||||
./bin/spx get nodes
|
||||
spx get nodes
|
||||
```
|
||||
|
||||
```
|
||||
@@ -56,20 +56,20 @@ node3 Ready 127.0.0.3 ap-southeast-2 ap-southeast-2a 2m 0
|
||||
## Monitor Resources
|
||||
|
||||
```bash
|
||||
./bin/spx top nodes
|
||||
spx top nodes
|
||||
```
|
||||
|
||||
## Image Management
|
||||
|
||||
```bash
|
||||
./bin/spx admin images list
|
||||
./bin/spx admin images import --name debian-12-arm64
|
||||
spx admin images list
|
||||
spx admin images import --name debian-12-arm64
|
||||
```
|
||||
|
||||
## Cluster Shutdown
|
||||
|
||||
```bash
|
||||
./bin/spx admin cluster shutdown
|
||||
spx admin cluster shutdown
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
@@ -79,7 +79,7 @@ node3 Ready 127.0.0.3 ap-southeast-2 ap-southeast-2a 2m 0
|
||||
The binary may not be executable. Fix permissions:
|
||||
|
||||
```bash
|
||||
chmod +x ./bin/spx
|
||||
chmod +x /usr/local/bin/spx
|
||||
```
|
||||
|
||||
If you get permission errors during operations, ensure you're running with appropriate privileges. Some OVN and networking commands require `sudo`.
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: "Bastion with Private Subnet"
|
||||
description: "Deploy a bastion host to access an isolated private compute instance with no internet connectivity."
|
||||
category: "Terraform Workbooks"
|
||||
tags:
|
||||
- terraform
|
||||
- bastion
|
||||
- vpc
|
||||
- security
|
||||
- private subnet
|
||||
- workbook
|
||||
resources:
|
||||
- title: "Terraform AWS Provider"
|
||||
url: "https://registry.terraform.io/providers/hashicorp/aws/latest"
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
- title: "OpenTofu"
|
||||
url: "https://opentofu.org/"
|
||||
---
|
||||
|
||||
# Terraform: Bastion with Private Subnet
|
||||
|
||||
> Deploy a bastion host to access an isolated private compute instance with no internet connectivity.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Deploy a VPC with public and private subnets where the private subnet has no internet access. A bastion host in the public subnet is the only way to reach instances in the private subnet. This pattern is used for sensitive workloads that must remain isolated from the internet — the private instance cannot make or receive any connections outside the VPC.
|
||||
|
||||
**Architecture:**
|
||||
|
||||
```
|
||||
WAN ──SSH──▶ Bastion (public subnet)
|
||||
│
|
||||
▼ SSH (private IP)
|
||||
App Server (private subnet, no internet)
|
||||
```
|
||||
|
||||
**What you'll learn:**
|
||||
|
||||
- Creating public and private subnets in a VPC
|
||||
- Isolating compute instances from the internet using route tables and security groups
|
||||
- Using a bastion host as the sole access point to private resources
|
||||
- SSH hopping through a bastion to reach private instances
|
||||
|
||||
**Use cases:**
|
||||
|
||||
- Sensitive data processing that must not have internet egress
|
||||
- Internal services that should only be reachable within the VPC
|
||||
- Compliance workloads requiring network isolation
|
||||
|
||||
**Prerequisites:**
|
||||
|
||||
- Spinifex installed and running (see [Installing Spinifex](/docs/install))
|
||||
- A Debian 12 AMI imported (see [Setting Up Your Cluster](/docs/setting-up-your-cluster))
|
||||
- OpenTofu or Terraform installed
|
||||
- `AWS_PROFILE=spinifex` configured
|
||||
|
||||
## Instructions
|
||||
|
||||
### Step 1. Get the Template
|
||||
|
||||
Clone the Terraform examples from the Spinifex repository:
|
||||
|
||||
```bash
|
||||
git clone --depth 1 --filter=blob:none --sparse https://github.com/mulgadc/spinifex.git spinifex-tf
|
||||
cd spinifex-tf
|
||||
git sparse-checkout set docs/terraform
|
||||
cd docs/terraform/bastion-private-subnet
|
||||
```
|
||||
|
||||
Or create a `main.tf` file and paste the full configuration below.
|
||||
|
||||
<!-- INCLUDE: main.tf lang:hcl -->
|
||||
|
||||
### Step 2. Deploy
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
tofu init
|
||||
tofu apply
|
||||
```
|
||||
|
||||
### Step 3. Connect
|
||||
|
||||
> **Note:** EC2 instances can take 30+ seconds to boot after apply. If SSH is unreachable, wait and retry.
|
||||
|
||||
SSH into the bastion:
|
||||
|
||||
```bash
|
||||
ssh -i bastion-demo.pem ec2-user@<bastion_public_ip>
|
||||
```
|
||||
|
||||
From the bastion, SSH to the private instance. The key is pre-installed at `~/.ssh/bastion-demo.pem` via cloud-init:
|
||||
|
||||
```bash
|
||||
ssh -i ~/.ssh/bastion-demo.pem ec2-user@<private_ip>
|
||||
```
|
||||
|
||||
### Step 4. Verify Isolation
|
||||
|
||||
From the private instance, confirm there is no internet connectivity:
|
||||
|
||||
```bash
|
||||
# This should time out — the private instance has no route to the internet
|
||||
curl --connect-timeout 5 https://deb.debian.org || echo "No internet access (expected)"
|
||||
```
|
||||
|
||||
The private instance can only communicate within the VPC (`10.20.0.0/16`). Its security group restricts egress to VPC-internal traffic only.
|
||||
|
||||
### Clean Up
|
||||
|
||||
```bash
|
||||
tofu destroy
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Cannot SSH to Private Instance
|
||||
|
||||
The private instance has no public IP — it is only reachable from the bastion. SSH to the bastion first, then use the pre-installed key to hop:
|
||||
|
||||
```bash
|
||||
ssh -i bastion-demo.pem ec2-user@<bastion_ip>
|
||||
ssh -i ~/.ssh/bastion-demo.pem ec2-user@<private_ip>
|
||||
```
|
||||
|
||||
If the key is missing on the bastion, check that cloud-init completed successfully:
|
||||
|
||||
```bash
|
||||
sudo journalctl -u cloud-init --no-pager
|
||||
ls -la ~/.ssh/bastion-demo.pem
|
||||
```
|
||||
|
||||
### Private Instance Can Reach the Internet
|
||||
|
||||
If the private instance unexpectedly has internet access, check that its route table has no default route:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-route-tables --profile spinifex
|
||||
```
|
||||
|
||||
The private route table should have no `0.0.0.0/0` route. Also verify the private security group egress is restricted to the VPC CIDR (`10.20.0.0/16`).
|
||||
|
||||
### AMI Not Found
|
||||
|
||||
Ensure you have imported a Debian 12 image:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-images --owners 000000000000 --profile spinifex
|
||||
```
|
||||
@@ -0,0 +1,346 @@
|
||||
# Example 2: Bastion Host with Private Subnet
|
||||
#
|
||||
# Deploys a VPC with both public and private subnets. A bastion host in the
|
||||
# public subnet provides SSH access to an isolated instance in the private
|
||||
# subnet. The private instance has no internet connectivity — ideal for
|
||||
# sensitive workloads that must remain air-gapped from the internet.
|
||||
#
|
||||
# Architecture:
|
||||
#
|
||||
# WAN ──SSH──▶ Bastion (public subnet)
|
||||
# │
|
||||
# ▼ SSH (private IP)
|
||||
# App Server (private subnet, no internet)
|
||||
#
|
||||
# Usage:
|
||||
# export AWS_PROFILE=spinifex
|
||||
# tofu init && tofu apply
|
||||
#
|
||||
# After apply:
|
||||
# # SSH to the bastion
|
||||
# ssh -i bastion-demo.pem ec2-user@<bastion_ip>
|
||||
#
|
||||
# # From the bastion, SSH to the private instance
|
||||
# # (the key is pre-installed at ~/.ssh/bastion-demo.pem via cloud-init)
|
||||
# ssh -i ~/.ssh/bastion-demo.pem ec2-user@<private_ip>
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.6.0"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
tls = {
|
||||
source = "hashicorp/tls"
|
||||
version = ">= 4.0"
|
||||
}
|
||||
local = {
|
||||
source = "hashicorp/local"
|
||||
version = ">= 2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Variables
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "ap-southeast-2"
|
||||
}
|
||||
|
||||
variable "spinifex_endpoint" {
|
||||
type = string
|
||||
default = "https://localhost:9999"
|
||||
description = "Spinifex AWS gateway endpoint"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
endpoints {
|
||||
ec2 = var.spinifex_endpoint
|
||||
iam = var.spinifex_endpoint
|
||||
sts = var.spinifex_endpoint
|
||||
}
|
||||
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_requesting_account_id = true
|
||||
skip_region_validation = true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data sources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
data "aws_availability_zones" "available" {
|
||||
state = "available"
|
||||
}
|
||||
|
||||
data "aws_ami" "debian12" {
|
||||
most_recent = true
|
||||
owners = ["000000000000"]
|
||||
|
||||
filter {
|
||||
name = "name"
|
||||
values = ["*debian-12*"]
|
||||
}
|
||||
|
||||
filter {
|
||||
name = "virtualization-type"
|
||||
values = ["hvm"]
|
||||
}
|
||||
|
||||
filter {
|
||||
name = "root-device-type"
|
||||
values = ["ebs"]
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSH Key Pair (shared by bastion and private instances)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "tls_private_key" "bastion" {
|
||||
algorithm = "ED25519"
|
||||
}
|
||||
|
||||
resource "aws_key_pair" "bastion" {
|
||||
key_name = "bastion-demo"
|
||||
public_key = tls_private_key.bastion.public_key_openssh
|
||||
}
|
||||
|
||||
resource "local_file" "bastion_pem" {
|
||||
filename = "${path.module}/bastion-demo.pem"
|
||||
content = tls_private_key.bastion.private_key_openssh
|
||||
file_permission = "0600"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VPC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_vpc" "main" {
|
||||
cidr_block = "10.20.0.0/16"
|
||||
enable_dns_hostnames = true
|
||||
enable_dns_support = true
|
||||
|
||||
tags = {
|
||||
Name = "bastion-demo-vpc"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internet Gateway — only the public subnet routes through this
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_internet_gateway" "igw" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
tags = {
|
||||
Name = "bastion-demo-igw"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public Subnet — bastion host lives here
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_subnet" "public" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
cidr_block = "10.20.1.0/24"
|
||||
availability_zone = data.aws_availability_zones.available.names[0]
|
||||
map_public_ip_on_launch = true
|
||||
|
||||
tags = {
|
||||
Name = "bastion-demo-public"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_route_table" "public" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
route {
|
||||
cidr_block = "0.0.0.0/0"
|
||||
gateway_id = aws_internet_gateway.igw.id
|
||||
}
|
||||
|
||||
tags = {
|
||||
Name = "bastion-demo-public-rt"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_route_table_association" "public" {
|
||||
subnet_id = aws_subnet.public.id
|
||||
route_table_id = aws_route_table.public.id
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Private Subnet — isolated instances live here (no public IPs, no internet)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_subnet" "private" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
cidr_block = "10.20.2.0/24"
|
||||
availability_zone = data.aws_availability_zones.available.names[0]
|
||||
map_public_ip_on_launch = false
|
||||
|
||||
tags = {
|
||||
Name = "bastion-demo-private"
|
||||
}
|
||||
}
|
||||
|
||||
# Private route table — no default route, no internet access
|
||||
resource "aws_route_table" "private" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
tags = {
|
||||
Name = "bastion-demo-private-rt"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_route_table_association" "private" {
|
||||
subnet_id = aws_subnet.private.id
|
||||
route_table_id = aws_route_table.private.id
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Security Groups
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Bastion: SSH from anywhere
|
||||
resource "aws_security_group" "bastion" {
|
||||
name = "bastion-demo-bastion-sg"
|
||||
description = "Bastion: SSH from WAN"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
ingress {
|
||||
description = "SSH"
|
||||
from_port = 22
|
||||
to_port = 22
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
egress {
|
||||
description = "All outbound"
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
tags = {
|
||||
Name = "bastion-demo-bastion-sg"
|
||||
}
|
||||
}
|
||||
|
||||
# Private instances: SSH only from the bastion security group
|
||||
resource "aws_security_group" "private" {
|
||||
name = "bastion-demo-private-sg"
|
||||
description = "Private: SSH from bastion only"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
ingress {
|
||||
description = "SSH from bastion"
|
||||
from_port = 22
|
||||
to_port = 22
|
||||
protocol = "tcp"
|
||||
security_groups = [aws_security_group.bastion.id]
|
||||
}
|
||||
|
||||
egress {
|
||||
description = "VPC internal only"
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["10.20.0.0/16"]
|
||||
}
|
||||
|
||||
tags = {
|
||||
Name = "bastion-demo-private-sg"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bastion Host (public subnet)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_instance" "bastion" {
|
||||
ami = data.aws_ami.debian12.id
|
||||
instance_type = "t3.small"
|
||||
|
||||
subnet_id = aws_subnet.public.id
|
||||
vpc_security_group_ids = [aws_security_group.bastion.id]
|
||||
key_name = aws_key_pair.bastion.key_name
|
||||
|
||||
associate_public_ip_address = true
|
||||
|
||||
# Copy the SSH private key onto the bastion so you can hop to private instances
|
||||
user_data_base64 = base64encode(<<-USERDATA
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
mkdir -p /home/ec2-user/.ssh
|
||||
cat > /home/ec2-user/.ssh/bastion-demo.pem <<'KEY'
|
||||
${tls_private_key.bastion.private_key_openssh}
|
||||
KEY
|
||||
chmod 600 /home/ec2-user/.ssh/bastion-demo.pem
|
||||
chown -R ec2-user:ec2-user /home/ec2-user/.ssh
|
||||
USERDATA
|
||||
)
|
||||
|
||||
tags = {
|
||||
Name = "bastion-demo-bastion"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Private Instance (private subnet — no public IP, no internet)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_instance" "private" {
|
||||
ami = data.aws_ami.debian12.id
|
||||
instance_type = "t3.small"
|
||||
|
||||
subnet_id = aws_subnet.private.id
|
||||
vpc_security_group_ids = [aws_security_group.private.id]
|
||||
key_name = aws_key_pair.bastion.key_name
|
||||
|
||||
tags = {
|
||||
Name = "bastion-demo-private-app"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outputs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
output "note" {
|
||||
value = "EC2 instances can take 30+ seconds to boot after apply. If SSH is unreachable, wait and retry."
|
||||
}
|
||||
|
||||
output "bastion_public_ip" {
|
||||
value = aws_instance.bastion.public_ip
|
||||
}
|
||||
|
||||
output "private_instance_ip" {
|
||||
value = aws_instance.private.private_ip
|
||||
}
|
||||
|
||||
output "ssh_to_bastion" {
|
||||
description = "SSH to the bastion host"
|
||||
value = "ssh -i bastion-demo.pem ec2-user@${aws_instance.bastion.public_ip}"
|
||||
}
|
||||
|
||||
output "ssh_to_private_from_bastion" {
|
||||
description = "From the bastion, SSH to the private instance (key is pre-installed via cloud-init)"
|
||||
value = "ssh -i ~/.ssh/bastion-demo.pem ec2-user@${aws_instance.private.private_ip}"
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
title: "Nginx Web Server"
|
||||
description: "Deploy a VPC with a public subnet and an EC2 instance running Nginx using Terraform on Spinifex."
|
||||
category: "Terraform Workbooks"
|
||||
tags:
|
||||
- terraform
|
||||
- nginx
|
||||
- ec2
|
||||
- vpc
|
||||
- workbook
|
||||
resources:
|
||||
- title: "Terraform AWS Provider"
|
||||
url: "https://registry.terraform.io/providers/hashicorp/aws/latest"
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
- title: "OpenTofu"
|
||||
url: "https://opentofu.org/"
|
||||
---
|
||||
|
||||
# Terraform: Nginx Web Server
|
||||
|
||||
> Deploy a VPC with a public subnet and an EC2 instance running Nginx using Terraform on Spinifex.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Deploy a complete Nginx web server on Spinifex using Terraform/OpenTofu. This workbook provisions a VPC, public subnet, internet gateway, route table, security group, SSH key pair, and an EC2 instance with cloud-init user-data that installs and starts Nginx.
|
||||
|
||||
**What you'll learn:**
|
||||
|
||||
- Configuring the AWS Terraform provider to target Spinifex
|
||||
- Creating a VPC with public internet access
|
||||
- Provisioning an EC2 instance with cloud-init user-data
|
||||
- Generating SSH key pairs with the TLS provider
|
||||
|
||||
**Prerequisites:**
|
||||
|
||||
- Spinifex installed and running (see [Installing Spinifex](/docs/install))
|
||||
- A Debian 12 AMI imported (see [Setting Up Your Cluster](/docs/setting-up-your-cluster))
|
||||
- OpenTofu or Terraform installed
|
||||
- `AWS_PROFILE=spinifex` configured
|
||||
|
||||
## Instructions
|
||||
|
||||
### Step 1. Get the Template
|
||||
|
||||
Clone the Terraform examples from the Spinifex repository:
|
||||
|
||||
```bash
|
||||
git clone --depth 1 --filter=blob:none --sparse https://github.com/mulgadc/spinifex.git spinifex-tf
|
||||
cd spinifex-tf
|
||||
git sparse-checkout set docs/terraform
|
||||
cd docs/terraform/nginx-webserver
|
||||
```
|
||||
|
||||
Or create a `main.tf` file and paste the full configuration below.
|
||||
|
||||
<!-- INCLUDE: main.tf lang:hcl -->
|
||||
|
||||
### Step 2. Deploy
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
tofu init
|
||||
tofu apply
|
||||
```
|
||||
|
||||
### Step 3. Verify
|
||||
|
||||
> **Note:** EC2 instances can take 30+ seconds to boot after apply. If SSH or HTTP is unreachable, wait and retry.
|
||||
|
||||
After apply completes, use the outputs to test:
|
||||
|
||||
```bash
|
||||
curl http://<public_ip>
|
||||
ssh -i nginx-demo.pem ec2-user@<public_ip>
|
||||
```
|
||||
|
||||
Open the `web_url` output in your browser to see the Nginx welcome page.
|
||||
|
||||
### Clean Up
|
||||
|
||||
```bash
|
||||
tofu destroy
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### AMI Not Found
|
||||
|
||||
Ensure you have imported a Debian 12 image. Check available AMIs:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-images --owners 000000000000 --profile spinifex
|
||||
```
|
||||
|
||||
### Provider Connection Refused
|
||||
|
||||
Verify Spinifex services are running:
|
||||
|
||||
```bash
|
||||
sudo systemctl status spinifex.target
|
||||
curl -k https://localhost:9999/
|
||||
```
|
||||
|
||||
### SSH Connection Timeout
|
||||
|
||||
Check that the security group allows inbound SSH (port 22) and that the instance has a public IP assigned. Verify the instance is running:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-instances --profile spinifex
|
||||
```
|
||||
|
||||
### Nginx Not Responding
|
||||
|
||||
SSH into the instance and check cloud-init logs:
|
||||
|
||||
```bash
|
||||
ssh -i nginx-demo.pem ec2-user@<public_ip>
|
||||
sudo journalctl -u cloud-init --no-pager
|
||||
sudo systemctl status nginx
|
||||
```
|
||||
@@ -0,0 +1,286 @@
|
||||
# Example 1: Nginx Web Server on Spinifex
|
||||
#
|
||||
# Deploys a VPC with a public subnet and an EC2 instance running Nginx.
|
||||
# Demonstrates: VPC, subnet, internet gateway, route table, security group,
|
||||
# key pair, cloud-init user-data, and EC2 instance provisioning.
|
||||
#
|
||||
# Usage:
|
||||
# cd spinifex/scripts/iac/aws/examples/01-nginx-webserver
|
||||
# export AWS_PROFILE=spinifex
|
||||
# tofu init && tofu apply
|
||||
#
|
||||
# After apply:
|
||||
# curl http://<public_ip> # Nginx welcome page
|
||||
# ssh -i nginx-demo.pem ec2-user@<public_ip>
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.6.0"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
tls = {
|
||||
source = "hashicorp/tls"
|
||||
version = ">= 4.0"
|
||||
}
|
||||
local = {
|
||||
source = "hashicorp/local"
|
||||
version = ">= 2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Variables
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "ap-southeast-2"
|
||||
}
|
||||
|
||||
variable "spinifex_endpoint" {
|
||||
type = string
|
||||
default = "https://localhost:9999"
|
||||
description = "Spinifex AWS gateway endpoint"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider — point the AWS provider at Spinifex
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
endpoints {
|
||||
ec2 = var.spinifex_endpoint
|
||||
iam = var.spinifex_endpoint
|
||||
sts = var.spinifex_endpoint
|
||||
}
|
||||
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_requesting_account_id = true
|
||||
skip_region_validation = true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data sources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
data "aws_availability_zones" "available" {
|
||||
state = "available"
|
||||
}
|
||||
|
||||
data "aws_ami" "debian12" {
|
||||
most_recent = true
|
||||
owners = ["000000000000"] # Spinifex system images
|
||||
|
||||
filter {
|
||||
name = "name"
|
||||
values = ["*debian-12*"]
|
||||
}
|
||||
|
||||
filter {
|
||||
name = "virtualization-type"
|
||||
values = ["hvm"]
|
||||
}
|
||||
|
||||
filter {
|
||||
name = "root-device-type"
|
||||
values = ["ebs"]
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSH Key Pair
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "tls_private_key" "nginx" {
|
||||
algorithm = "ED25519"
|
||||
}
|
||||
|
||||
resource "aws_key_pair" "nginx" {
|
||||
key_name = "nginx-demo"
|
||||
public_key = tls_private_key.nginx.public_key_openssh
|
||||
}
|
||||
|
||||
resource "local_file" "nginx_pem" {
|
||||
filename = "${path.module}/nginx-demo.pem"
|
||||
content = tls_private_key.nginx.private_key_openssh
|
||||
file_permission = "0600"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VPC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_vpc" "main" {
|
||||
cidr_block = "10.10.0.0/16"
|
||||
enable_dns_hostnames = true
|
||||
enable_dns_support = true
|
||||
|
||||
tags = {
|
||||
Name = "nginx-demo-vpc"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internet Gateway — gives the public subnet a route to the internet
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_internet_gateway" "igw" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
tags = {
|
||||
Name = "nginx-demo-igw"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public Subnet
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_subnet" "public" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
cidr_block = "10.10.1.0/24"
|
||||
availability_zone = data.aws_availability_zones.available.names[0]
|
||||
map_public_ip_on_launch = true
|
||||
|
||||
tags = {
|
||||
Name = "nginx-demo-public"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route Table — send 0.0.0.0/0 through the internet gateway
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_route_table" "public" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
route {
|
||||
cidr_block = "0.0.0.0/0"
|
||||
gateway_id = aws_internet_gateway.igw.id
|
||||
}
|
||||
|
||||
tags = {
|
||||
Name = "nginx-demo-public-rt"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_route_table_association" "public" {
|
||||
subnet_id = aws_subnet.public.id
|
||||
route_table_id = aws_route_table.public.id
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Security Group — SSH + HTTP inbound, all outbound
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_security_group" "web" {
|
||||
name = "nginx-demo-sg"
|
||||
description = "Allow SSH and HTTP inbound"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
ingress {
|
||||
description = "SSH"
|
||||
from_port = 22
|
||||
to_port = 22
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
ingress {
|
||||
description = "HTTP"
|
||||
from_port = 80
|
||||
to_port = 80
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
egress {
|
||||
description = "All outbound"
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
tags = {
|
||||
Name = "nginx-demo-sg"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EC2 Instance — Debian 12 with Nginx installed via cloud-init
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_instance" "nginx" {
|
||||
ami = data.aws_ami.debian12.id
|
||||
instance_type = "t3.small"
|
||||
|
||||
subnet_id = aws_subnet.public.id
|
||||
vpc_security_group_ids = [aws_security_group.web.id]
|
||||
key_name = aws_key_pair.nginx.key_name
|
||||
|
||||
associate_public_ip_address = true
|
||||
|
||||
user_data_base64 = base64encode(<<-USERDATA
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Install Nginx
|
||||
apt-get update -y
|
||||
apt-get install -y nginx
|
||||
|
||||
# Write a custom landing page
|
||||
cat > /var/www/html/index.html <<'HTML'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Spinifex Demo</title></head>
|
||||
<body style="font-family: sans-serif; max-width: 600px; margin: 80px auto;">
|
||||
<h1>Hello from Spinifex!</h1>
|
||||
<p>This Nginx server was deployed with Terraform on Spinifex infrastructure.</p>
|
||||
<hr>
|
||||
<p><small>Instance provisioned via cloud-init user-data.</small></p>
|
||||
</body>
|
||||
</html>
|
||||
HTML
|
||||
|
||||
# Ensure Nginx is running
|
||||
systemctl enable nginx
|
||||
systemctl restart nginx
|
||||
USERDATA
|
||||
)
|
||||
|
||||
tags = {
|
||||
Name = "nginx-demo"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outputs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
output "note" {
|
||||
value = "EC2 instances can take 30+ seconds to boot after apply. If SSH or HTTP is unreachable, wait and retry."
|
||||
}
|
||||
|
||||
output "instance_id" {
|
||||
value = aws_instance.nginx.id
|
||||
}
|
||||
|
||||
output "public_ip" {
|
||||
value = aws_instance.nginx.public_ip
|
||||
}
|
||||
|
||||
output "ssh_command" {
|
||||
value = "ssh -i nginx-demo.pem ec2-user@${aws_instance.nginx.public_ip}"
|
||||
}
|
||||
|
||||
output "web_url" {
|
||||
value = "http://${aws_instance.nginx.public_ip}"
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
---
|
||||
title: "S3-Backed Web App"
|
||||
description: "Deploy a Flask file-sharing web application backed by S3 (Predastore) using Terraform on Spinifex."
|
||||
category: "Terraform Workbooks"
|
||||
tags:
|
||||
- terraform
|
||||
- s3
|
||||
- predastore
|
||||
- flask
|
||||
- webapp
|
||||
- workbook
|
||||
resources:
|
||||
- title: "Terraform AWS Provider"
|
||||
url: "https://registry.terraform.io/providers/hashicorp/aws/latest"
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
- title: "Predastore (S3)"
|
||||
url: "https://github.com/mulgadc/predastore"
|
||||
- title: "OpenTofu"
|
||||
url: "https://opentofu.org/"
|
||||
---
|
||||
|
||||
# Terraform: S3-Backed Web App
|
||||
|
||||
> Deploy a Flask file-sharing web application backed by S3 (Predastore) using Terraform on Spinifex.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Deploy an EC2 instance running a Flask file-sharing web application backed by S3 (Predastore). Users can upload files through a web form and browse uploaded content — demonstrating Terraform managing both compute and object storage together.
|
||||
|
||||
**Architecture:**
|
||||
|
||||
```
|
||||
Browser ──HTTP──▶ EC2 Instance (Flask webapp, port 80)
|
||||
│
|
||||
▼ S3 API (boto3)
|
||||
Predastore (port 8443)
|
||||
```
|
||||
|
||||
**What you'll learn:**
|
||||
|
||||
- Configuring the AWS provider with both Spinifex and Predastore endpoints
|
||||
- Creating S3 buckets on Predastore via Terraform
|
||||
- Deploying a Python webapp with cloud-init that talks to S3
|
||||
- Passing credentials and configuration to instances via user-data
|
||||
|
||||
**Prerequisites:**
|
||||
|
||||
- Spinifex installed and running (see [Installing Spinifex](/docs/install))
|
||||
- Predastore running (S3 API on port 8443)
|
||||
- A Debian 12 AMI imported (see [Setting Up Your Cluster](/docs/setting-up-your-cluster))
|
||||
- OpenTofu or Terraform installed
|
||||
- `AWS_PROFILE=spinifex` configured
|
||||
- The EC2 instance must be able to reach Predastore — use the host's br-wan IP, not localhost
|
||||
|
||||
## Instructions
|
||||
|
||||
### Step 1. Get the Template
|
||||
|
||||
Clone the Terraform examples from the Spinifex repository:
|
||||
|
||||
```bash
|
||||
git clone --depth 1 --filter=blob:none --sparse https://github.com/mulgadc/spinifex.git spinifex-tf
|
||||
cd spinifex-tf
|
||||
git sparse-checkout set docs/terraform
|
||||
cd docs/terraform/s3-webapp
|
||||
```
|
||||
|
||||
Or create the files manually and paste the full configuration below.
|
||||
|
||||
### Step 2. Create terraform.tfvars
|
||||
|
||||
Before deploying, create a `terraform.tfvars` with your Predastore credentials. The `predastore_host` must be reachable from inside the VPC — use the host's br-wan or LAN IP, not localhost.
|
||||
|
||||
<!-- INCLUDE: terraform.tfvars.example lang:hcl -->
|
||||
|
||||
### Step 3. Create main.tf
|
||||
|
||||
<!-- INCLUDE: main.tf lang:hcl -->
|
||||
|
||||
### Step 4. Deploy
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
tofu init
|
||||
tofu apply
|
||||
```
|
||||
|
||||
### Step 5. Test the Application
|
||||
|
||||
> **Note:** EC2 instances can take 30+ seconds to boot after apply. If SSH or HTTP is unreachable, wait and retry.
|
||||
|
||||
Open the `web_url` output in your browser. You should see the file browser UI. Upload a file and verify it appears in the list.
|
||||
|
||||
```bash
|
||||
# Verify via CLI
|
||||
curl http://<public_ip>
|
||||
|
||||
# Check the S3 bucket directly
|
||||
aws s3 ls s3://webapp-uploads/ --profile spinifex --endpoint-url https://localhost:8443
|
||||
```
|
||||
|
||||
### Clean Up
|
||||
|
||||
```bash
|
||||
tofu destroy
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Predastore Connection Refused from Instance
|
||||
|
||||
The EC2 instance cannot reach `localhost` on the host. Set `predastore_host` to the host's br-wan or LAN IP address:
|
||||
|
||||
```hcl
|
||||
predastore_host = "192.168.1.10:8443"
|
||||
```
|
||||
|
||||
### S3 Bucket Creation Fails
|
||||
|
||||
Verify Predastore is running and accessible:
|
||||
|
||||
```bash
|
||||
curl -k https://localhost:8443/
|
||||
aws s3 ls --profile spinifex --endpoint-url https://localhost:8443
|
||||
```
|
||||
|
||||
### Flask App Not Starting
|
||||
|
||||
SSH into the instance and check the service:
|
||||
|
||||
```bash
|
||||
ssh -i s3-webapp-demo.pem ec2-user@<public_ip>
|
||||
sudo systemctl status s3-webapp
|
||||
sudo journalctl -u s3-webapp --no-pager -n 50
|
||||
```
|
||||
|
||||
### Upload Fails or Files Not Appearing
|
||||
|
||||
Check that the S3 credentials in `/opt/webapp/.env` are correct and that the bucket exists:
|
||||
|
||||
```bash
|
||||
ssh -i s3-webapp-demo.pem ec2-user@<public_ip>
|
||||
cat /opt/webapp/.env
|
||||
/opt/webapp/venv/bin/python -c "import boto3; print('boto3 OK')"
|
||||
```
|
||||
|
||||
### AMI Not Found
|
||||
|
||||
Ensure you have imported a Debian 12 image:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-images --owners 000000000000 --profile spinifex
|
||||
```
|
||||
@@ -0,0 +1,445 @@
|
||||
# Example 3: S3-Backed Web Application
|
||||
#
|
||||
# Deploys an EC2 instance running a simple file-sharing webapp backed by S3
|
||||
# (Predastore). Users can upload files through a web form and browse uploaded
|
||||
# content — demonstrating Terraform managing both compute (Spinifex) and
|
||||
# object storage (Predastore) resources together.
|
||||
#
|
||||
# Architecture:
|
||||
#
|
||||
# Browser ──HTTP──▶ EC2 Instance (Flask webapp, port 80)
|
||||
# │
|
||||
# ▼ S3 API (boto3)
|
||||
# Predastore (port 8443)
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Spinifex services running (gateway on port 9999)
|
||||
# - Predastore running (S3 API on port 8443)
|
||||
# - The EC2 instance must be able to reach the Predastore endpoint.
|
||||
# Set `predastore_host` to the IP reachable from inside the VPC
|
||||
# (e.g. the host's br-wan IP, NOT localhost).
|
||||
#
|
||||
# Usage:
|
||||
# cd spinifex/scripts/iac/aws/examples/03-s3-webapp
|
||||
# export AWS_PROFILE=spinifex
|
||||
# tofu init && tofu apply
|
||||
#
|
||||
# After apply:
|
||||
# curl http://<public_ip> # File browser UI
|
||||
# ssh -i s3-webapp-demo.pem ec2-user@<public_ip>
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.6.0"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
tls = {
|
||||
source = "hashicorp/tls"
|
||||
version = ">= 4.0"
|
||||
}
|
||||
local = {
|
||||
source = "hashicorp/local"
|
||||
version = ">= 2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Variables
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "ap-southeast-2"
|
||||
}
|
||||
|
||||
variable "spinifex_endpoint" {
|
||||
type = string
|
||||
default = "https://localhost:9999"
|
||||
description = "Spinifex AWS gateway endpoint (EC2/IAM)"
|
||||
}
|
||||
|
||||
variable "predastore_endpoint" {
|
||||
type = string
|
||||
default = "https://localhost:8443"
|
||||
description = "Predastore S3 endpoint (for Terraform to create buckets)"
|
||||
}
|
||||
|
||||
variable "predastore_host" {
|
||||
type = string
|
||||
description = "Predastore host:port reachable from inside the VPC (e.g. 192.168.1.10:8443)"
|
||||
}
|
||||
|
||||
variable "s3_access_key" {
|
||||
type = string
|
||||
description = "S3 access key for Predastore"
|
||||
}
|
||||
|
||||
variable "s3_secret_key" {
|
||||
type = string
|
||||
sensitive = true
|
||||
description = "S3 secret key for Predastore"
|
||||
}
|
||||
|
||||
variable "bucket_name" {
|
||||
type = string
|
||||
default = "webapp-uploads"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider — EC2 via Spinifex gateway, S3 via Predastore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
access_key = var.s3_access_key
|
||||
secret_key = var.s3_secret_key
|
||||
|
||||
endpoints {
|
||||
ec2 = var.spinifex_endpoint
|
||||
s3 = var.predastore_endpoint
|
||||
iam = var.spinifex_endpoint
|
||||
sts = var.spinifex_endpoint
|
||||
}
|
||||
|
||||
s3_use_path_style = true
|
||||
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_requesting_account_id = true
|
||||
skip_region_validation = true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data sources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
data "aws_availability_zones" "available" {
|
||||
state = "available"
|
||||
}
|
||||
|
||||
data "aws_ami" "debian12" {
|
||||
most_recent = true
|
||||
owners = ["000000000000"]
|
||||
|
||||
filter {
|
||||
name = "name"
|
||||
values = ["*debian-12*"]
|
||||
}
|
||||
|
||||
filter {
|
||||
name = "virtualization-type"
|
||||
values = ["hvm"]
|
||||
}
|
||||
|
||||
filter {
|
||||
name = "root-device-type"
|
||||
values = ["ebs"]
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S3 Bucket (created on Predastore)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_s3_bucket" "uploads" {
|
||||
bucket = var.bucket_name
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSH Key Pair
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "tls_private_key" "webapp" {
|
||||
algorithm = "ED25519"
|
||||
}
|
||||
|
||||
resource "aws_key_pair" "webapp" {
|
||||
key_name = "s3-webapp-demo"
|
||||
public_key = tls_private_key.webapp.public_key_openssh
|
||||
}
|
||||
|
||||
resource "local_file" "webapp_pem" {
|
||||
filename = "${path.module}/s3-webapp-demo.pem"
|
||||
content = tls_private_key.webapp.private_key_openssh
|
||||
file_permission = "0600"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VPC + Public Subnet
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_vpc" "main" {
|
||||
cidr_block = "10.30.0.0/16"
|
||||
enable_dns_hostnames = true
|
||||
enable_dns_support = true
|
||||
|
||||
tags = {
|
||||
Name = "s3-webapp-demo-vpc"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_internet_gateway" "igw" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
tags = {
|
||||
Name = "s3-webapp-demo-igw"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_subnet" "public" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
cidr_block = "10.30.1.0/24"
|
||||
availability_zone = data.aws_availability_zones.available.names[0]
|
||||
map_public_ip_on_launch = true
|
||||
|
||||
tags = {
|
||||
Name = "s3-webapp-demo-public"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_route_table" "public" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
route {
|
||||
cidr_block = "0.0.0.0/0"
|
||||
gateway_id = aws_internet_gateway.igw.id
|
||||
}
|
||||
|
||||
tags = {
|
||||
Name = "s3-webapp-demo-public-rt"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_route_table_association" "public" {
|
||||
subnet_id = aws_subnet.public.id
|
||||
route_table_id = aws_route_table.public.id
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Security Group — SSH + HTTP inbound, all outbound
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_security_group" "webapp" {
|
||||
name = "s3-webapp-demo-sg"
|
||||
description = "Allow SSH and HTTP inbound"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
ingress {
|
||||
description = "SSH"
|
||||
from_port = 22
|
||||
to_port = 22
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
ingress {
|
||||
description = "HTTP"
|
||||
from_port = 80
|
||||
to_port = 80
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
egress {
|
||||
description = "All outbound"
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
tags = {
|
||||
Name = "s3-webapp-demo-sg"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EC2 Instance — Flask webapp that talks to Predastore S3
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_instance" "webapp" {
|
||||
ami = data.aws_ami.debian12.id
|
||||
instance_type = "t3.small"
|
||||
|
||||
subnet_id = aws_subnet.public.id
|
||||
vpc_security_group_ids = [aws_security_group.webapp.id]
|
||||
key_name = aws_key_pair.webapp.key_name
|
||||
|
||||
associate_public_ip_address = true
|
||||
|
||||
user_data_base64 = base64encode(<<-USERDATA
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Install dependencies
|
||||
apt-get update -y
|
||||
apt-get install -y python3-pip python3-venv
|
||||
|
||||
# Create app directory and virtualenv
|
||||
mkdir -p /opt/webapp
|
||||
python3 -m venv /opt/webapp/venv
|
||||
/opt/webapp/venv/bin/pip install flask boto3
|
||||
|
||||
# Write S3 credentials config
|
||||
cat > /opt/webapp/.env <<'ENVFILE'
|
||||
S3_ENDPOINT=https://${var.predastore_host}
|
||||
S3_BUCKET=${var.bucket_name}
|
||||
S3_ACCESS_KEY=${var.s3_access_key}
|
||||
S3_SECRET_KEY=${var.s3_secret_key}
|
||||
S3_REGION=${var.region}
|
||||
ENVFILE
|
||||
|
||||
# Write the Flask application
|
||||
cat > /opt/webapp/app.py <<'PYEOF'
|
||||
import os, io, urllib3
|
||||
from flask import Flask, request, redirect, url_for, Response
|
||||
|
||||
# Suppress TLS warnings for self-signed certs
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
# Load env
|
||||
env = {}
|
||||
with open("/opt/webapp/.env") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if "=" in line and not line.startswith("#"):
|
||||
k, v = line.split("=", 1)
|
||||
env[k] = v
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=env["S3_ENDPOINT"],
|
||||
aws_access_key_id=env["S3_ACCESS_KEY"],
|
||||
aws_secret_access_key=env["S3_SECRET_KEY"],
|
||||
region_name=env["S3_REGION"],
|
||||
verify=False,
|
||||
config=Config(s3={"addressing_style": "path"}),
|
||||
)
|
||||
|
||||
BUCKET = env["S3_BUCKET"]
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
# List objects in the bucket
|
||||
try:
|
||||
resp = s3.list_objects_v2(Bucket=BUCKET)
|
||||
objects = resp.get("Contents", [])
|
||||
except Exception as e:
|
||||
objects = []
|
||||
|
||||
rows = ""
|
||||
for obj in objects:
|
||||
key = obj["Key"]
|
||||
size = obj["Size"]
|
||||
rows += f'<tr><td><a href="/files/{key}">{key}</a></td><td>{size} bytes</td></tr>\n'
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Spinifex S3 File Browser</title></head>
|
||||
<body style="font-family: sans-serif; max-width: 700px; margin: 40px auto;">
|
||||
<h1>Spinifex S3 File Browser</h1>
|
||||
<p>Bucket: <code>{BUCKET}</code></p>
|
||||
|
||||
<h2>Upload a File</h2>
|
||||
<form method="POST" action="/upload" enctype="multipart/form-data">
|
||||
<input type="file" name="file" required>
|
||||
<button type="submit">Upload</button>
|
||||
</form>
|
||||
|
||||
<h2>Files</h2>
|
||||
<table border="1" cellpadding="6" cellspacing="0" style="border-collapse: collapse;">
|
||||
<tr><th>Key</th><th>Size</th></tr>
|
||||
{rows if rows else '<tr><td colspan="2">No files yet</td></tr>'}
|
||||
</table>
|
||||
|
||||
<hr>
|
||||
<p><small>Powered by Spinifex + Predastore</small></p>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
@app.route("/upload", methods=["POST"])
|
||||
def upload():
|
||||
f = request.files.get("file")
|
||||
if not f or not f.filename:
|
||||
return redirect("/")
|
||||
s3.put_object(Bucket=BUCKET, Key=f.filename, Body=f.read())
|
||||
return redirect("/")
|
||||
|
||||
@app.route("/files/<path:key>")
|
||||
def download(key):
|
||||
try:
|
||||
obj = s3.get_object(Bucket=BUCKET, Key=key)
|
||||
return Response(
|
||||
obj["Body"].read(),
|
||||
headers={"Content-Disposition": f'inline; filename="{key}"'},
|
||||
)
|
||||
except Exception:
|
||||
return "Not found", 404
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=80)
|
||||
PYEOF
|
||||
|
||||
# Create a systemd service so the webapp starts on boot
|
||||
cat > /etc/systemd/system/s3-webapp.service <<'SVCEOF'
|
||||
[Unit]
|
||||
Description=S3 File Browser Webapp
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/opt/webapp/venv/bin/python /opt/webapp/app.py
|
||||
WorkingDirectory=/opt/webapp
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
SVCEOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable s3-webapp
|
||||
systemctl start s3-webapp
|
||||
USERDATA
|
||||
)
|
||||
|
||||
tags = {
|
||||
Name = "s3-webapp-demo"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outputs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
output "note" {
|
||||
value = "EC2 instances can take 30+ seconds to boot after apply. If SSH or HTTP is unreachable, wait and retry."
|
||||
}
|
||||
|
||||
output "instance_id" {
|
||||
value = aws_instance.webapp.id
|
||||
}
|
||||
|
||||
output "public_ip" {
|
||||
value = aws_instance.webapp.public_ip
|
||||
}
|
||||
|
||||
output "bucket_name" {
|
||||
value = aws_s3_bucket.uploads.id
|
||||
}
|
||||
|
||||
output "ssh_command" {
|
||||
value = "ssh -i s3-webapp-demo.pem ec2-user@${aws_instance.webapp.public_ip}"
|
||||
}
|
||||
|
||||
output "web_url" {
|
||||
value = "http://${aws_instance.webapp.public_ip}"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Copy this to terraform.tfvars and fill in your values.
|
||||
#
|
||||
# The predastore_host must be reachable from INSIDE the VPC guest — not
|
||||
# localhost. Use the host's br-wan or LAN IP, e.g. "192.168.1.10:8443".
|
||||
|
||||
predastore_host = "192.168.1.10:8443"
|
||||
s3_access_key = "AKIAIOSFODNN7EXAMPLE"
|
||||
s3_secret_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
|
||||
# Optional overrides:
|
||||
# spinifex_endpoint = "https://localhost:9999"
|
||||
# predastore_endpoint = "https://localhost:8443"
|
||||
# bucket_name = "webapp-uploads"
|
||||
# region = "ap-southeast-2"
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: "VPC Networking"
|
||||
description: "How Spinifex implements AWS-compatible VPC networking with public and private subnets, security groups, and Elastic IPs using OVN."
|
||||
category: "Environments"
|
||||
category: "Compute"
|
||||
tags:
|
||||
- vpc
|
||||
- networking
|
||||
@@ -27,14 +27,14 @@ Instances can operate in two modes: **private** (overlay-only, no WAN access) or
|
||||
|
||||
Spinifex maps AWS VPC concepts directly to OVN constructs:
|
||||
|
||||
| AWS Concept | OVN Construct | What It Does |
|
||||
|-------------|---------------|--------------|
|
||||
| VPC | Logical Router | Isolates tenant networks, routes between subnets |
|
||||
| Subnet | Logical Switch + DHCP | L2 broadcast domain with automatic IP assignment |
|
||||
| ENI | Logical Switch Port | Per-instance network interface with MAC/IP binding |
|
||||
| Internet Gateway | External Switch + NAT | Connects VPC router to physical WAN |
|
||||
| Security Group | Port Group + ACLs | Stateful firewall rules enforced in OVS datapath |
|
||||
| Elastic IP | `dnat_and_snat` NAT rule | Static 1:1 NAT between public and private IP |
|
||||
| AWS Concept | OVN Construct | What It Does |
|
||||
| ---------------- | ------------------------ | -------------------------------------------------- |
|
||||
| VPC | Logical Router | Isolates tenant networks, routes between subnets |
|
||||
| Subnet | Logical Switch + DHCP | L2 broadcast domain with automatic IP assignment |
|
||||
| ENI | Logical Switch Port | Per-instance network interface with MAC/IP binding |
|
||||
| Internet Gateway | External Switch + NAT | Connects VPC router to physical WAN |
|
||||
| Security Group | Port Group + ACLs | Stateful firewall rules enforced in OVS datapath |
|
||||
| Elastic IP | `dnat_and_snat` NAT rule | Static 1:1 NAT between public and private IP |
|
||||
|
||||
## Network Path
|
||||
|
||||
@@ -112,14 +112,14 @@ Inbound: 203.0.113.10 ──→ DNAT ──→ arrives at 10.0.1.5
|
||||
|
||||
## Comparison
|
||||
|
||||
| | Private Subnet | Public Subnet |
|
||||
|---|---|---|
|
||||
| Private IP | Yes | Yes |
|
||||
| Public IP | No | Auto-assigned from pool |
|
||||
| Outbound internet | Only if VPC has IGW (shared SNAT) | Yes (own public IP via SNAT) |
|
||||
| Inbound from WAN | No | Yes (via 1:1 NAT to public IP) |
|
||||
| Instance sees public IP? | N/A | No — only sees private IP |
|
||||
| Elastic IP support | Only if explicitly associated | Yes |
|
||||
| | Private Subnet | Public Subnet |
|
||||
| ------------------------ | --------------------------------- | ------------------------------ |
|
||||
| Private IP | Yes | Yes |
|
||||
| Public IP | No | Auto-assigned from pool |
|
||||
| Outbound internet | Only if VPC has IGW (shared SNAT) | Yes (own public IP via SNAT) |
|
||||
| Inbound from WAN | No | Yes (via 1:1 NAT to public IP) |
|
||||
| Instance sees public IP? | N/A | No — only sees private IP |
|
||||
| Elastic IP support | Only if explicitly associated | Yes |
|
||||
|
||||
## External Connectivity Modes
|
||||
|
||||
@@ -213,14 +213,14 @@ is identical:
|
||||
|
||||
### Choosing Static vs DHCP
|
||||
|
||||
| | Static Range | DHCP Source |
|
||||
|---|---|---|
|
||||
| **Public IPs from** | Admin-defined `range_start`..`range_end` | Router's DHCP server |
|
||||
| **IP predictability** | You know the exact range | Router assigns whatever is available |
|
||||
| **Setup effort** | Must reserve range, shrink router DHCP scope | Just set `source = "dhcp"` |
|
||||
| **Dependency** | None | Requires `dhclient` on host, working router DHCP |
|
||||
| **Best for** | Datacenters, ISP blocks, production | Homelabs, dev environments, shared networks |
|
||||
| **Capacity** | Exact: `range_end - range_start` IPs | Limited by router's DHCP pool size |
|
||||
| | Static Range | DHCP Source |
|
||||
| --------------------- | -------------------------------------------- | ------------------------------------------------ |
|
||||
| **Public IPs from** | Admin-defined `range_start`..`range_end` | Router's DHCP server |
|
||||
| **IP predictability** | You know the exact range | Router assigns whatever is available |
|
||||
| **Setup effort** | Must reserve range, shrink router DHCP scope | Just set `source = "dhcp"` |
|
||||
| **Dependency** | None | Requires `dhclient` on host, working router DHCP |
|
||||
| **Best for** | Datacenters, ISP blocks, production | Homelabs, dev environments, shared networks |
|
||||
| **Capacity** | Exact: `range_end - range_start` IPs | Limited by router's DHCP pool size |
|
||||
|
||||
Both support the same AWS features: public subnets, Elastic IPs, security groups,
|
||||
DescribeInstances showing public IPs.
|
||||
@@ -257,16 +257,16 @@ communicate within their VPC.
|
||||
|
||||
## Mode Comparison
|
||||
|
||||
| Capability | `pool` (static) | `pool` (dhcp) | `nat` | Disabled |
|
||||
|------------|-----------------|---------------|-------|----------|
|
||||
| Outbound internet | Yes | Yes | Yes | No |
|
||||
| Inbound from WAN | Yes (1:1 NAT) | Yes (1:1 NAT) | No | No |
|
||||
| Public subnets | Yes | Yes | No | No |
|
||||
| Auto-assign public IPs | Yes | Yes | No | No |
|
||||
| Elastic IPs | Yes | Yes | No | No |
|
||||
| DescribeInstances shows public IP | Yes | Yes | No | No |
|
||||
| Admin must reserve IP range | Yes | No | No | No |
|
||||
| Needs router DHCP | No | Yes | Optional | No |
|
||||
| Capability | `pool` (static) | `pool` (dhcp) | `nat` | Disabled |
|
||||
| --------------------------------- | --------------- | ------------- | -------- | -------- |
|
||||
| Outbound internet | Yes | Yes | Yes | No |
|
||||
| Inbound from WAN | Yes (1:1 NAT) | Yes (1:1 NAT) | No | No |
|
||||
| Public subnets | Yes | Yes | No | No |
|
||||
| Auto-assign public IPs | Yes | Yes | No | No |
|
||||
| Elastic IPs | Yes | Yes | No | No |
|
||||
| DescribeInstances shows public IP | Yes | Yes | No | No |
|
||||
| Admin must reserve IP range | Yes | No | No | No |
|
||||
| Needs router DHCP | No | Yes | Optional | No |
|
||||
|
||||
If you start with `nat` and later need public subnets, switch to `pool` and
|
||||
define a range (or use `source = "dhcp"`) — no data migration needed.
|
||||
@@ -286,10 +286,10 @@ the best approach:
|
||||
|
||||
Every Spinifex node has two OVS bridges:
|
||||
|
||||
| Bridge | Purpose | Ports |
|
||||
|--------|---------|-------|
|
||||
| `br-int` | VM overlay traffic (Geneve tunnels) | VM TAP devices, tunnel ports |
|
||||
| `br-wan` | WAN uplink for public subnet traffic | macvlan on WAN NIC |
|
||||
| Bridge | Purpose | Ports |
|
||||
| -------- | ------------------------------------ | ---------------------------- |
|
||||
| `br-int` | VM overlay traffic (Geneve tunnels) | VM TAP devices, tunnel ports |
|
||||
| `br-wan` | WAN uplink for public subnet traffic | macvlan on WAN NIC |
|
||||
|
||||
`br-int` is always created by `setup-ovn.sh`. `br-wan` is created only when
|
||||
a WAN bridge is configured (auto-detected or via `--wan-bridge`, required for public subnets / external connectivity).
|
||||
@@ -322,6 +322,7 @@ used for SSH or management. Datacenter servers with separate management +
|
||||
public NICs. Homelab hosts with 2+ NICs.
|
||||
|
||||
**Benefits over macvlan:**
|
||||
|
||||
- **Distributed NAT**: DNAT processed on the VM's own chassis (no hairpin)
|
||||
- **No MAC workarounds**: OVS sees all frames, any MAC
|
||||
- **Host can reach VMs**: No macvlan parent↔child isolation
|
||||
@@ -354,6 +355,7 @@ WAN NIC (host IP — unchanged)
|
||||
edge deployments. Adding the NIC directly to OVS would break host connectivity.
|
||||
|
||||
**Limitations:**
|
||||
|
||||
- Centralized NAT only (macvlan filters unicast not matching its MAC)
|
||||
- Host cannot reach VMs at their public IPs (macvlan isolation)
|
||||
- vpcd must align macvlan MAC with OVN router MAC on startup
|
||||
@@ -365,23 +367,23 @@ sudo setup-ovn.sh --macvlan --wan-iface=eth0
|
||||
|
||||
### Mode Comparison
|
||||
|
||||
| Capability | Direct bridge | Macvlan |
|
||||
|------------|--------------|---------|
|
||||
| NAT mode | Distributed (local DNAT) | Centralized (gateway hairpin) |
|
||||
| Multi-node external perf | Optimal | Hairpin through gateway chassis |
|
||||
| Host → VM public IP | Works | Blocked (kernel isolation) |
|
||||
| MAC alignment needed | No | Yes (fragile startup step) |
|
||||
| Gratuitous ARPs needed | No | Yes (`nat-addresses=router`) |
|
||||
| Works on single-NIC | No (breaks SSH) | Yes |
|
||||
| DHCP lease stability | Clean | Fragile (MAC changes post-lease) |
|
||||
| Capability | Direct bridge | Macvlan |
|
||||
| ------------------------ | ------------------------ | -------------------------------- |
|
||||
| NAT mode | Distributed (local DNAT) | Centralized (gateway hairpin) |
|
||||
| Multi-node external perf | Optimal | Hairpin through gateway chassis |
|
||||
| Host → VM public IP | Works | Blocked (kernel isolation) |
|
||||
| MAC alignment needed | No | Yes (fragile startup step) |
|
||||
| Gratuitous ARPs needed | No | Yes (`nat-addresses=router`) |
|
||||
| Works on single-NIC | No (breaks SSH) | Yes |
|
||||
| DHCP lease stability | Clean | Fragile (MAC changes post-lease) |
|
||||
|
||||
### How setup-ovn.sh Decides
|
||||
|
||||
| Flags | Result |
|
||||
|-------|--------|
|
||||
| `--wan-bridge=br-wan --wan-iface=eth1` | Direct bridge: NIC added to br-wan |
|
||||
| `--macvlan --wan-iface=eth0` | Macvlan: sub-interface created, added to br-wan |
|
||||
| (no WAN bridge) | Only br-int created, no WAN connectivity |
|
||||
| Flags | Result |
|
||||
| -------------------------------------- | ----------------------------------------------- |
|
||||
| `--wan-bridge=br-wan --wan-iface=eth1` | Direct bridge: NIC added to br-wan |
|
||||
| `--macvlan --wan-iface=eth0` | Macvlan: sub-interface created, added to br-wan |
|
||||
| (no WAN bridge) | Only br-int created, no WAN connectivity |
|
||||
|
||||
### Setup (Macvlan Mode)
|
||||
|
||||
@@ -402,12 +404,12 @@ macvlan in `bridge` mode creates a virtual interface that shares the parent NIC'
|
||||
physical wire but has its own MAC address. The Linux kernel blocks direct L2
|
||||
frames between a parent interface and its macvlan children.
|
||||
|
||||
| Path | Works? | Why |
|
||||
|------|--------|-----|
|
||||
| VM → internet | Yes | SNAT through OVN router → macvlan → WAN NIC → WAN |
|
||||
| LAN device → VM public IP | Yes | LAN → WAN NIC → macvlan → br-wan → OVN |
|
||||
| Host → VM public IP | No | macvlan isolation (kernel blocks parent↔child) |
|
||||
| Host → VM private IP | Yes | Overlay via br-int (unrelated to br-wan) |
|
||||
| Path | Works? | Why |
|
||||
| ------------------------- | ------ | ------------------------------------------------- |
|
||||
| VM → internet | Yes | SNAT through OVN router → macvlan → WAN NIC → WAN |
|
||||
| LAN device → VM public IP | Yes | LAN → WAN NIC → macvlan → br-wan → OVN |
|
||||
| Host → VM public IP | No | macvlan isolation (kernel blocks parent↔child) |
|
||||
| Host → VM private IP | Yes | Overlay via br-int (unrelated to br-wan) |
|
||||
|
||||
## Per-Node Configuration
|
||||
|
||||
@@ -496,11 +498,11 @@ spinifex.toml
|
||||
external_mode = "pool" # "pool", "nat", or "" (disabled)
|
||||
```
|
||||
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| `"pool"` | Full public networking — public subnets, auto-assign, Elastic IPs |
|
||||
| `"nat"` | Outbound-only SNAT — all VMs share one external IP |
|
||||
| `""` / omitted | Overlay-only — no external connectivity |
|
||||
| Value | Behavior |
|
||||
| -------------- | ----------------------------------------------------------------- |
|
||||
| `"pool"` | Full public networking — public subnets, auto-assign, Elastic IPs |
|
||||
| `"nat"` | Outbound-only SNAT — all VMs share one external IP |
|
||||
| `""` / omitted | Overlay-only — no external connectivity |
|
||||
|
||||
## IP Pools: network.external_pools
|
||||
|
||||
@@ -523,18 +525,18 @@ dns_servers = ["8.8.8.8"] # DNS for VMs (optional)
|
||||
|
||||
### Field Details
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `name` | Yes | Unique pool name. Used as NATS KV key and in `AllocateAddress`. |
|
||||
| `source` | No | IP source: `"static"` (default) uses `range_start`/`range_end`. `"dhcp"` obtains IPs from the router's DHCP server on each VM launch. |
|
||||
| `range_start` | Static only | First IP in the range. First IP is reserved for OVN gateway SNAT (unless `gateway_ip` overrides). |
|
||||
| `range_end` | Static only | Last IP in the range. |
|
||||
| `gateway` | Yes | Physical router/switch — the WAN default gateway. OVN sets `0.0.0.0/0 → gateway`. |
|
||||
| `gateway_ip` | NAT mode | Static IP for OVN router SNAT. In pool mode, defaults to `range_start` (static) or first DHCP lease (dhcp). In NAT mode, this is the single external IP all VMs share. |
|
||||
| `prefix_len` | Yes | Subnet mask for the external network (e.g., 24 = /24). |
|
||||
| `region` | No | Scopes pool to a region. Instances in this region prefer this pool. |
|
||||
| `az` | No | Scopes pool to an AZ. More specific than region. |
|
||||
| `dns_servers` | No | DNS servers propagated to VMs via OVN DHCP. |
|
||||
| Field | Required | Description |
|
||||
| ------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `name` | Yes | Unique pool name. Used as NATS KV key and in `AllocateAddress`. |
|
||||
| `source` | No | IP source: `"static"` (default) uses `range_start`/`range_end`. `"dhcp"` obtains IPs from the router's DHCP server on each VM launch. |
|
||||
| `range_start` | Static only | First IP in the range. First IP is reserved for OVN gateway SNAT (unless `gateway_ip` overrides). |
|
||||
| `range_end` | Static only | Last IP in the range. |
|
||||
| `gateway` | Yes | Physical router/switch — the WAN default gateway. OVN sets `0.0.0.0/0 → gateway`. |
|
||||
| `gateway_ip` | NAT mode | Static IP for OVN router SNAT. In pool mode, defaults to `range_start` (static) or first DHCP lease (dhcp). In NAT mode, this is the single external IP all VMs share. |
|
||||
| `prefix_len` | Yes | Subnet mask for the external network (e.g., 24 = /24). |
|
||||
| `region` | No | Scopes pool to a region. Instances in this region prefer this pool. |
|
||||
| `az` | No | Scopes pool to an AZ. More specific than region. |
|
||||
| `dns_servers` | No | DNS servers propagated to VMs via OVN DHCP. |
|
||||
|
||||
### Why range_start/range_end Instead of CIDR?
|
||||
|
||||
@@ -561,8 +563,8 @@ ovn_sb_addr = "tcp:10.1.3.181:6642" # OVN Southbound DB
|
||||
external_interface = "eth1" # WAN NIC name
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| Field | Description |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `external_interface` | Physical NIC for WAN traffic. A macvlan sub-interface is created on this NIC for br-wan. Different servers may have different names (eth1, eno2, enp3s0, bond0). |
|
||||
|
||||
## Pool Selection Logic
|
||||
@@ -588,8 +590,12 @@ name. Allocation uses CAS (Compare-And-Set) for lock-free concurrent access:
|
||||
"range_start": "192.168.1.150",
|
||||
"range_end": "192.168.1.250",
|
||||
"allocated": {
|
||||
"192.168.1.150": {"type": "gateway"},
|
||||
"192.168.1.151": {"type": "auto_assign", "eni_id": "eni-abc", "instance_id": "i-123"}
|
||||
"192.168.1.150": { "type": "gateway" },
|
||||
"192.168.1.151": {
|
||||
"type": "auto_assign",
|
||||
"eni_id": "eni-abc",
|
||||
"instance_id": "i-123"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -774,20 +780,21 @@ on the port group control traffic:
|
||||
## Default Security Group
|
||||
|
||||
Every VPC gets a default security group that:
|
||||
|
||||
- Allows all inbound from instances in the same security group
|
||||
- Allows all outbound
|
||||
- Denies all other inbound
|
||||
|
||||
## AWS Rule → OVN ACL Translation
|
||||
|
||||
| AWS Security Group Rule | OVN ACL Match |
|
||||
|------------------------|---------------|
|
||||
| Ingress TCP/22 from 0.0.0.0/0 | `outport == @sg && ip4 && tcp.dst == 22` |
|
||||
| AWS Security Group Rule | OVN ACL Match |
|
||||
| ------------------------------- | ------------------------------------------------------------------ |
|
||||
| Ingress TCP/22 from 0.0.0.0/0 | `outport == @sg && ip4 && tcp.dst == 22` |
|
||||
| Ingress TCP/443 from 10.0.0.0/8 | `outport == @sg && ip4 && tcp.dst == 443 && ip4.src == 10.0.0.0/8` |
|
||||
| Ingress ALL from sg-other | `outport == @sg && ip4 && ip4.src == $sg_other_ip4` |
|
||||
| Ingress ICMP from anywhere | `outport == @sg && ip4 && icmp4` |
|
||||
| Egress ALL to 0.0.0.0/0 | `inport == @sg && ip4` |
|
||||
| Default deny inbound | `outport == @sg && ip4` (priority 900, action=drop) |
|
||||
| Ingress ALL from sg-other | `outport == @sg && ip4 && ip4.src == $sg_other_ip4` |
|
||||
| Ingress ICMP from anywhere | `outport == @sg && ip4 && icmp4` |
|
||||
| Egress ALL to 0.0.0.0/0 | `inport == @sg && ip4` |
|
||||
| Default deny inbound | `outport == @sg && ip4` (priority 900, action=drop) |
|
||||
|
||||
## Example: Allow SSH + HTTP
|
||||
|
||||
@@ -1124,18 +1131,21 @@ ip route show
|
||||
### Instance Has No Public IP
|
||||
|
||||
1. Check subnet has `MapPublicIpOnLaunch`:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-subnets --subnet-ids $SUBNET \
|
||||
--query 'Subnets[0].MapPublicIpOnLaunch'
|
||||
```
|
||||
|
||||
2. Check IGW is attached:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-internet-gateways \
|
||||
--filters Name=attachment.vpc-id,Values=$VPC
|
||||
```
|
||||
|
||||
3. Check external IP pool:
|
||||
|
||||
```bash
|
||||
nats kv get spinifex-external-ipam wan
|
||||
```
|
||||
@@ -1272,6 +1282,7 @@ sudo systemctl start ovn-central ovn-controller
|
||||
`ovn-sbctl show`. Gateway traffic (SNAT/DNAT) doesn't work.
|
||||
|
||||
**Cause:** Three names must align:
|
||||
|
||||
1. OVS `external_ids:system-id` → what ovn-controller registers as
|
||||
2. OVN SB `Chassis.name` → what actually exists in the SB DB
|
||||
3. vpcd's chassis name → `chassis-{nodeName}` from config
|
||||
|
||||
@@ -8,10 +8,10 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/kdomanski/iso9660 v0.4.0
|
||||
github.com/klauspost/cpuid/v2 v2.3.0
|
||||
github.com/mulgadc/predastore v1.1.3
|
||||
github.com/mulgadc/predastore v1.1.4
|
||||
github.com/mulgadc/viperblock v1.1.5
|
||||
github.com/nats-io/nats-server/v2 v2.12.6
|
||||
github.com/nats-io/nats.go v1.49.0
|
||||
github.com/nats-io/nats.go v1.50.0
|
||||
github.com/ovn-kubernetes/libovsdb v0.8.1
|
||||
github.com/pelletier/go-toml/v2 v2.3.0
|
||||
github.com/pterm/pterm v0.12.83
|
||||
|
||||
@@ -255,8 +255,8 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/mulgadc/predastore v1.1.3 h1:WddAOXmGJFQ7tRG+5B3yz/tCX46yW1CmbVL07y+tyBU=
|
||||
github.com/mulgadc/predastore v1.1.3/go.mod h1:YP4TaathWczm9weoI8YomZ1/IgnFB9WJowkHPidh6rw=
|
||||
github.com/mulgadc/predastore v1.1.4 h1:czeGQWWqmmZuGnUuvyrORavySN/SGPTwjUxBd/9swuU=
|
||||
github.com/mulgadc/predastore v1.1.4/go.mod h1:YP4TaathWczm9weoI8YomZ1/IgnFB9WJowkHPidh6rw=
|
||||
github.com/mulgadc/viperblock v1.1.5 h1:ojmoJQrY7VkDGzdQj07w2F7ICRJX9RlGAKyn87Wa55o=
|
||||
github.com/mulgadc/viperblock v1.1.5/go.mod h1:utoIQLwCmPujOBaT0mL5NhW3m2fwjQPQiQRJH6+Wtk8=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
@@ -267,8 +267,8 @@ github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
||||
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
||||
github.com/nats-io/nats-server/v2 v2.12.6 h1:Egbx9Vl7Ch8wTtpXPGqbehkZ+IncKqShUxvrt1+Enc8=
|
||||
github.com/nats-io/nats-server/v2 v2.12.6/go.mod h1:4HPlrvtmSO3yd7KcElDNMx9kv5EBJBnJJzQPptXlheo=
|
||||
github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE=
|
||||
github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw=
|
||||
github.com/nats-io/nats.go v1.50.0 h1:5zAeQrTvyrKrWLJ0fu02W3br8ym57qf7csDzgLOpcds=
|
||||
github.com/nats-io/nats.go v1.50.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
|
||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
# build-alb-image.sh — Thin wrapper for backward compatibility
|
||||
# Delegates to the generic build-system-image.sh with the ALB manifest.
|
||||
exec "$(dirname "$0")/build-system-image.sh" "$(dirname "$0")/images/alb.conf" "$@"
|
||||
Executable
+275
@@ -0,0 +1,275 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# build-system-image.sh — Build a minimal Alpine AMI from a manifest
|
||||
#
|
||||
# Creates a pre-baked Alpine Linux image with custom packages, binaries,
|
||||
# and setup scripts installed, ready for import as a Spinifex AMI.
|
||||
#
|
||||
# Requirements: qemu-nbd, qemu-img, sudo (for mount/chroot), curl
|
||||
# Usage: ./scripts/build-system-image.sh <manifest.conf> [--import]
|
||||
# --import Also import the image as an AMI via spx admin images import
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 <manifest.conf> [--import]"
|
||||
echo ""
|
||||
echo " manifest.conf Path to image manifest (see scripts/images/ for examples)"
|
||||
echo " --import Import the built image as an AMI"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ $# -lt 1 || "$1" == "-h" || "$1" == "--help" ]]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
MANIFEST="$1"
|
||||
shift
|
||||
|
||||
if [[ ! -f "$MANIFEST" ]]; then
|
||||
echo "ERROR: Manifest not found: $MANIFEST"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DO_IMPORT=false
|
||||
if [[ "${1:-}" == "--import" ]]; then
|
||||
DO_IMPORT=true
|
||||
fi
|
||||
|
||||
# Source the manifest
|
||||
# shellcheck source=/dev/null
|
||||
source "$MANIFEST"
|
||||
|
||||
# Validate required manifest fields
|
||||
for field in IMAGE_NAME ALPINE_VERSION IMAGE_SIZE; do
|
||||
if [[ -z "${!field:-}" ]]; then
|
||||
echo "ERROR: Manifest missing required field: $field"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Derived paths
|
||||
BUILD_DIR="/tmp/${IMAGE_NAME}-image-build"
|
||||
ALPINE_IMAGE="generic_alpine-${ALPINE_VERSION}-x86_64-bios-cloudinit-r0.qcow2"
|
||||
ALPINE_URL="https://dl-cdn.alpinelinux.org/alpine/v${ALPINE_VERSION%.*}/releases/cloud/${ALPINE_IMAGE}"
|
||||
OUTPUT_IMAGE="${BUILD_DIR}/${IMAGE_NAME}-alpine.qcow2"
|
||||
OUTPUT_RAW="${BUILD_DIR}/${IMAGE_NAME}-alpine.raw"
|
||||
NBD_DEV="/dev/nbd0"
|
||||
MOUNT_DIR="${BUILD_DIR}/mnt"
|
||||
|
||||
cleanup() {
|
||||
echo "Cleaning up..."
|
||||
sudo umount "${MOUNT_DIR}" 2>/dev/null || true
|
||||
sudo qemu-nbd --disconnect "${NBD_DEV}" 2>/dev/null || true
|
||||
echo "Done."
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "=== System Image Builder ==="
|
||||
echo "Image: ${IMAGE_NAME} — ${IMAGE_DESCRIPTION:-}"
|
||||
echo "Alpine: ${ALPINE_VERSION}"
|
||||
echo "Size: ${IMAGE_SIZE}"
|
||||
echo "Build: ${BUILD_DIR}"
|
||||
echo ""
|
||||
|
||||
# Step 0: Check prerequisites
|
||||
if ! command -v qemu-nbd &>/dev/null; then
|
||||
echo "ERROR: qemu-nbd not found. Install qemu-utils."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v qemu-img &>/dev/null; then
|
||||
echo "ERROR: qemu-img not found. Install qemu-utils."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build binaries if BUILD_COMMANDS is set
|
||||
if [[ -n "${BUILD_COMMANDS:-}" ]]; then
|
||||
echo "Building binaries: ${BUILD_COMMANDS}"
|
||||
if ! (cd "$PROJECT_DIR" && eval "$BUILD_COMMANDS"); then
|
||||
echo "ERROR: BUILD_COMMANDS failed: ${BUILD_COMMANDS}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Verify binaries exist and are statically linked
|
||||
if [[ -n "${INSTALL_BINARIES:-}" ]]; then
|
||||
IFS=' ' read -ra BINARY_PAIRS <<< "$INSTALL_BINARIES"
|
||||
for pair in "${BINARY_PAIRS[@]}"; do
|
||||
src="${pair%%:*}"
|
||||
src_path="${PROJECT_DIR}/${src}"
|
||||
if [[ ! -f "$src_path" ]]; then
|
||||
echo "ERROR: Binary not found: $src_path"
|
||||
exit 1
|
||||
fi
|
||||
if ! file "$src_path" | grep -q "statically linked"; then
|
||||
echo "ERROR: $src_path is not statically linked (Alpine uses musl — dynamic glibc binaries will fail)"
|
||||
echo " Rebuild with: CGO_ENABLED=0 go build ..."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
mkdir -p "$BUILD_DIR" "$MOUNT_DIR"
|
||||
|
||||
# Step 1: Download Alpine cloud image
|
||||
if [[ -f "${BUILD_DIR}/${ALPINE_IMAGE}" ]]; then
|
||||
echo "Alpine image already downloaded."
|
||||
else
|
||||
echo "Downloading Alpine ${ALPINE_VERSION} cloud image..."
|
||||
if ! curl --fail -L -o "${BUILD_DIR}/${ALPINE_IMAGE}" "$ALPINE_URL"; then
|
||||
rm -f "${BUILD_DIR}/${ALPINE_IMAGE}"
|
||||
echo "ERROR: Failed to download Alpine image from $ALPINE_URL"
|
||||
exit 1
|
||||
fi
|
||||
# Verify the download is a valid qcow2 image
|
||||
if ! qemu-img info "${BUILD_DIR}/${ALPINE_IMAGE}" &>/dev/null; then
|
||||
rm -f "${BUILD_DIR}/${ALPINE_IMAGE}"
|
||||
echo "ERROR: Downloaded file is not a valid qcow2 image"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 2: Copy image for customization
|
||||
echo "Copying image for customization..."
|
||||
cp "${BUILD_DIR}/${ALPINE_IMAGE}" "$OUTPUT_IMAGE"
|
||||
|
||||
# Resize the image (Alpine cloud images are ~200MB, need room for packages)
|
||||
qemu-img resize "$OUTPUT_IMAGE" "$IMAGE_SIZE"
|
||||
|
||||
# Step 3: Connect via qemu-nbd
|
||||
echo "Connecting image via qemu-nbd..."
|
||||
sudo modprobe nbd max_part=4 2>/dev/null || true
|
||||
if [[ ! -e "${NBD_DEV}" ]]; then
|
||||
echo "ERROR: ${NBD_DEV} does not exist. Is the nbd kernel module loaded? Try: sudo modprobe nbd"
|
||||
exit 1
|
||||
fi
|
||||
sudo qemu-nbd --disconnect "${NBD_DEV}" 2>/dev/null || true
|
||||
sudo qemu-nbd --connect="${NBD_DEV}" "$OUTPUT_IMAGE"
|
||||
sleep 1
|
||||
|
||||
# Alpine cloud images have ext4 directly on the block device (no partition table).
|
||||
# Resize the filesystem to fill the resized image.
|
||||
echo "Checking filesystem..."
|
||||
sudo e2fsck -f -y "${NBD_DEV}" || {
|
||||
ec=$?
|
||||
if [[ $ec -gt 1 ]]; then
|
||||
echo "ERROR: e2fsck failed with exit code $ec on ${NBD_DEV}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Resizing filesystem..."
|
||||
if ! sudo resize2fs "${NBD_DEV}"; then
|
||||
echo "ERROR: resize2fs failed on ${NBD_DEV}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 4: Mount and customize
|
||||
echo "Mounting root filesystem..."
|
||||
sudo mount "${NBD_DEV}" "$MOUNT_DIR"
|
||||
|
||||
# Set up resolv.conf for DNS inside chroot
|
||||
sudo cp /etc/resolv.conf "${MOUNT_DIR}/etc/resolv.conf"
|
||||
|
||||
# Install packages
|
||||
if [[ -n "${APK_PACKAGES:-}" ]]; then
|
||||
echo "Installing packages: ${APK_PACKAGES}..."
|
||||
sudo chroot "$MOUNT_DIR" /bin/sh -c "
|
||||
set -e
|
||||
# Enable community repo
|
||||
sed -i 's|^#\(.*community\)|\1|' /etc/apk/repositories 2>/dev/null || true
|
||||
|
||||
# Ensure community repo is present
|
||||
if ! grep -q community /etc/apk/repositories; then
|
||||
MIRROR=\$(grep main /etc/apk/repositories | head -1 | sed 's|/main|/community|')
|
||||
echo \"\$MIRROR\" >> /etc/apk/repositories
|
||||
fi
|
||||
|
||||
apk update
|
||||
apk add ${APK_PACKAGES}
|
||||
"
|
||||
fi
|
||||
|
||||
# Enable OpenRC services
|
||||
if [[ -n "${ENABLE_SERVICES:-}" ]]; then
|
||||
echo "Enabling services: ${ENABLE_SERVICES}..."
|
||||
IFS=' ' read -ra SERVICES <<< "$ENABLE_SERVICES"
|
||||
for svc in "${SERVICES[@]}"; do
|
||||
if ! sudo chroot "$MOUNT_DIR" /bin/sh -c "rc-update add ${svc} default"; then
|
||||
echo "ERROR: Failed to enable service '${svc}' — does it exist in the image?"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Step 5: Copy binaries into the image (before setup script, which may reference them)
|
||||
if [[ -n "${INSTALL_BINARIES:-}" ]]; then
|
||||
echo "Installing binaries..."
|
||||
IFS=' ' read -ra BINARY_PAIRS <<< "$INSTALL_BINARIES"
|
||||
for pair in "${BINARY_PAIRS[@]}"; do
|
||||
src="${pair%%:*}"
|
||||
dst="${pair#*:}"
|
||||
src_path="${PROJECT_DIR}/${src}"
|
||||
echo " ${src} -> ${dst}"
|
||||
sudo cp "$src_path" "${MOUNT_DIR}${dst}"
|
||||
sudo chmod 755 "${MOUNT_DIR}${dst}"
|
||||
done
|
||||
fi
|
||||
|
||||
# Run custom setup script inside chroot (after binaries are installed)
|
||||
if [[ -n "${SETUP_SCRIPT:-}" ]]; then
|
||||
setup_path="${PROJECT_DIR}/${SETUP_SCRIPT}"
|
||||
if [[ ! -f "$setup_path" ]]; then
|
||||
echo "ERROR: Setup script not found: $setup_path"
|
||||
exit 1
|
||||
fi
|
||||
echo "Running setup script: ${SETUP_SCRIPT}..."
|
||||
sudo cp "$setup_path" "${MOUNT_DIR}/tmp/setup.sh"
|
||||
sudo chmod 755 "${MOUNT_DIR}/tmp/setup.sh"
|
||||
sudo chroot "$MOUNT_DIR" /tmp/setup.sh
|
||||
sudo rm -f "${MOUNT_DIR}/tmp/setup.sh"
|
||||
fi
|
||||
|
||||
# Step 6: Clean up and unmount
|
||||
echo "Cleaning up image..."
|
||||
sudo chroot "$MOUNT_DIR" /bin/sh -c '
|
||||
apk cache clean 2>/dev/null || true
|
||||
rm -rf /var/cache/apk/* /tmp/*
|
||||
'
|
||||
|
||||
# Restore original resolv.conf (cloud-init will set it on boot)
|
||||
sudo rm -f "${MOUNT_DIR}/etc/resolv.conf"
|
||||
|
||||
echo "Unmounting..."
|
||||
sudo umount "$MOUNT_DIR"
|
||||
sudo qemu-nbd --disconnect "${NBD_DEV}"
|
||||
|
||||
# Step 7: Convert to raw for import
|
||||
echo "Converting to raw format..."
|
||||
qemu-img convert -f qcow2 -O raw "$OUTPUT_IMAGE" "$OUTPUT_RAW"
|
||||
|
||||
echo ""
|
||||
echo "=== Build complete ==="
|
||||
echo " Image: ${IMAGE_NAME}"
|
||||
echo " qcow2: $OUTPUT_IMAGE ($(du -h "$OUTPUT_IMAGE" | cut -f1))"
|
||||
echo " raw: $OUTPUT_RAW ($(du -h "$OUTPUT_RAW" | cut -f1))"
|
||||
echo ""
|
||||
|
||||
if [[ "$DO_IMPORT" == true ]]; then
|
||||
echo "Importing as AMI..."
|
||||
(cd "$PROJECT_DIR" && ./bin/spx admin images import \
|
||||
--file "$OUTPUT_RAW" \
|
||||
--distro alpine \
|
||||
--version "${ALPINE_VERSION}-${IMAGE_NAME}" \
|
||||
--arch x86_64 \
|
||||
--config "$HOME/spinifex/config/spinifex.toml")
|
||||
else
|
||||
echo "To import as AMI, run:"
|
||||
echo " cd $PROJECT_DIR && ./bin/spx admin images import \\"
|
||||
echo " --file $OUTPUT_RAW \\"
|
||||
echo " --distro alpine --version ${ALPINE_VERSION}-${IMAGE_NAME} --arch x86_64 \\"
|
||||
echo " --config \$HOME/spinifex/config/spinifex.toml"
|
||||
fi
|
||||
@@ -0,0 +1,307 @@
|
||||
# Spinifex IaC Compatibility Test — "basic" suite
|
||||
#
|
||||
# Exercises every AWS resource Spinifex currently supports via tofu apply,
|
||||
# WITHOUT route tables, NACLs, or other unimplemented networking.
|
||||
#
|
||||
# Usage:
|
||||
# cd spinifex/scripts/iac/aws/basic
|
||||
# tofu init
|
||||
# tofu plan
|
||||
# tofu apply # full round-trip against a running Spinifex cluster
|
||||
# tofu destroy # clean teardown
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.6.0"
|
||||
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = ">= 5.0"
|
||||
}
|
||||
tls = {
|
||||
source = "hashicorp/tls"
|
||||
version = ">= 4.0"
|
||||
}
|
||||
local = {
|
||||
source = "hashicorp/local"
|
||||
version = ">= 2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Variables
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "ap-southeast-2"
|
||||
}
|
||||
|
||||
variable "mulgaos_endpoint" {
|
||||
type = string
|
||||
default = "https://localhost:9999"
|
||||
description = "Spinifex AWS gateway endpoint"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
|
||||
endpoints {
|
||||
ec2 = var.mulgaos_endpoint
|
||||
s3 = var.mulgaos_endpoint
|
||||
iam = var.mulgaos_endpoint
|
||||
sts = var.mulgaos_endpoint
|
||||
}
|
||||
|
||||
skip_credentials_validation = true
|
||||
skip_metadata_api_check = true
|
||||
skip_requesting_account_id = true
|
||||
skip_region_validation = true
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data sources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
data "aws_availability_zones" "available" {
|
||||
state = "available"
|
||||
}
|
||||
|
||||
data "aws_ami" "debian12" {
|
||||
most_recent = true
|
||||
owners = ["000000000000"]
|
||||
|
||||
filter {
|
||||
name = "name"
|
||||
values = ["*debian-12*"]
|
||||
}
|
||||
|
||||
filter {
|
||||
name = "virtualization-type"
|
||||
values = ["hvm"]
|
||||
}
|
||||
|
||||
filter {
|
||||
name = "root-device-type"
|
||||
values = ["ebs"]
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. VPC + Subnet (no route table needed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_vpc" "test" {
|
||||
cidr_block = "10.99.0.0/16"
|
||||
enable_dns_hostnames = true
|
||||
enable_dns_support = true
|
||||
|
||||
tags = {
|
||||
Name = "spx-basic-test-vpc"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_subnet" "test" {
|
||||
vpc_id = aws_vpc.test.id
|
||||
cidr_block = "10.99.1.0/24"
|
||||
availability_zone = data.aws_availability_zones.available.names[0]
|
||||
map_public_ip_on_launch = true
|
||||
|
||||
tags = {
|
||||
Name = "spx-basic-test-subnet"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Internet Gateway (attach to VPC, but no route table referencing it)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_internet_gateway" "test" {
|
||||
vpc_id = aws_vpc.test.id
|
||||
|
||||
tags = {
|
||||
Name = "spx-basic-test-igw"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Security Group — inbound SSH + HTTP, all outbound
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_security_group" "test" {
|
||||
name = "spx-basic-test-sg"
|
||||
description = "Basic test: SSH + HTTP inbound, all outbound"
|
||||
vpc_id = aws_vpc.test.id
|
||||
|
||||
ingress {
|
||||
description = "SSH"
|
||||
from_port = 22
|
||||
to_port = 22
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
ingress {
|
||||
description = "HTTP"
|
||||
from_port = 80
|
||||
to_port = 80
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
egress {
|
||||
description = "All outbound"
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
tags = {
|
||||
Name = "spx-basic-test-sg"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Key Pair
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "tls_private_key" "test" {
|
||||
algorithm = "ED25519"
|
||||
}
|
||||
|
||||
resource "aws_key_pair" "test" {
|
||||
key_name = "spx-basic-test-key"
|
||||
public_key = tls_private_key.test.public_key_openssh
|
||||
}
|
||||
|
||||
resource "local_file" "test_pem" {
|
||||
filename = "${path.module}/spx-basic-test.pem"
|
||||
content = tls_private_key.test.private_key_openssh
|
||||
file_permission = "0600"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Placement Group
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_placement_group" "test" {
|
||||
name = "spx-basic-test-spread"
|
||||
strategy = "spread"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. EC2 Instance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_instance" "test" {
|
||||
ami = data.aws_ami.debian12.id
|
||||
instance_type = "t3.small"
|
||||
|
||||
subnet_id = aws_subnet.test.id
|
||||
vpc_security_group_ids = [aws_security_group.test.id]
|
||||
key_name = aws_key_pair.test.key_name
|
||||
placement_group = aws_placement_group.test.name
|
||||
|
||||
associate_public_ip_address = true
|
||||
|
||||
tags = {
|
||||
Name = "spx-basic-test-vm"
|
||||
Environment = "test"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. EBS Volume + Attachment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_ebs_volume" "data" {
|
||||
availability_zone = data.aws_availability_zones.available.names[0]
|
||||
size = 10
|
||||
type = "gp3"
|
||||
|
||||
tags = {
|
||||
Name = "spx-basic-test-data-vol"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. EBS Snapshot (of the standalone volume)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_ebs_snapshot" "data" {
|
||||
volume_id = aws_ebs_volume.data.id
|
||||
|
||||
tags = {
|
||||
Name = "spx-basic-test-snapshot"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. Elastic IP + Association
|
||||
# Requires external IPAM (pool mode) — uncomment when available.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# resource "aws_eip" "test" {
|
||||
# domain = "vpc"
|
||||
#
|
||||
# tags = {
|
||||
# Name = "spx-basic-test-eip"
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# resource "aws_eip_association" "test" {
|
||||
# allocation_id = aws_eip.test.id
|
||||
# instance_id = aws_instance.test.id
|
||||
# }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. Standalone Network Interface (not attached — just tests CRUD)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_network_interface" "extra" {
|
||||
subnet_id = aws_subnet.test.id
|
||||
security_groups = [aws_security_group.test.id]
|
||||
|
||||
tags = {
|
||||
Name = "spx-basic-test-eni"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outputs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
output "vpc_id" {
|
||||
value = aws_vpc.test.id
|
||||
}
|
||||
|
||||
output "instance_id" {
|
||||
value = aws_instance.test.id
|
||||
}
|
||||
|
||||
output "instance_public_ip" {
|
||||
value = aws_instance.test.public_ip
|
||||
}
|
||||
|
||||
# output "eip_public_ip" {
|
||||
# value = aws_eip.test.public_ip
|
||||
# }
|
||||
|
||||
output "volume_id" {
|
||||
value = aws_ebs_volume.data.id
|
||||
}
|
||||
|
||||
output "snapshot_id" {
|
||||
value = aws_ebs_snapshot.data.id
|
||||
}
|
||||
|
||||
output "ssh_command" {
|
||||
value = "ssh -i spx-basic-test.pem admin@${aws_instance.test.public_ip}"
|
||||
}
|
||||
@@ -189,12 +189,6 @@ resource "aws_security_group" "web_ssh" {
|
||||
}
|
||||
}
|
||||
|
||||
# 4: Ensure 3 servers on different underlying hardware (spread placement group)
|
||||
resource "aws_placement_group" "spread" {
|
||||
name = "spinifex-test-spread"
|
||||
strategy = "spread"
|
||||
}
|
||||
|
||||
resource "aws_instance" "vm" {
|
||||
count = 3
|
||||
ami = data.aws_ami.debian12.id
|
||||
@@ -205,7 +199,6 @@ resource "aws_instance" "vm" {
|
||||
key_name = aws_key_pair.hive_test.key_name
|
||||
|
||||
associate_public_ip_address = true
|
||||
placement_group = aws_placement_group.spread.name
|
||||
|
||||
tags = {
|
||||
Name = "spinifex-test-vm-${count.index + 1}"
|
||||
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
# alb-setup.sh — ALB-specific chroot setup
|
||||
#
|
||||
# Runs inside the Alpine image chroot after packages are installed.
|
||||
# Sets up HAProxy placeholder config and alb-agent OpenRC init script.
|
||||
|
||||
set -e
|
||||
|
||||
# Create haproxy config directory and placeholder config
|
||||
mkdir -p /etc/haproxy
|
||||
cat > /etc/haproxy/haproxy.cfg <<'EOF'
|
||||
# Placeholder config — replaced by alb-agent on first config push
|
||||
global
|
||||
daemon
|
||||
maxconn 256
|
||||
|
||||
defaults
|
||||
mode http
|
||||
timeout connect 5s
|
||||
timeout client 30s
|
||||
timeout server 30s
|
||||
|
||||
frontend health
|
||||
bind *:8405
|
||||
http-request return status 200 content-type text/plain string "ok"
|
||||
EOF
|
||||
|
||||
# Create alb-agent OpenRC init script
|
||||
mkdir -p /etc/init.d
|
||||
cat > /etc/init.d/alb-agent <<'INITSCRIPT'
|
||||
#!/sbin/openrc-run
|
||||
|
||||
description="ALB NATS Config Agent"
|
||||
command="/usr/local/bin/alb-agent"
|
||||
command_args="--lb-id=${ALB_LB_ID:-unknown} --nats=${ALB_NATS_URL:-nats://10.0.2.2:4222}"
|
||||
command_background=true
|
||||
pidfile="/run/alb-agent.pid"
|
||||
output_log="/var/log/alb-agent.log"
|
||||
error_log="/var/log/alb-agent.log"
|
||||
|
||||
depend() {
|
||||
need net
|
||||
after firewall
|
||||
}
|
||||
INITSCRIPT
|
||||
chmod 755 /etc/init.d/alb-agent
|
||||
@@ -0,0 +1,22 @@
|
||||
# ALB system image manifest
|
||||
# Used by build-system-image.sh to create an Alpine AMI with HAProxy + alb-agent
|
||||
|
||||
IMAGE_NAME="alb"
|
||||
IMAGE_DESCRIPTION="ALB VM with HAProxy and alb-agent"
|
||||
ALPINE_VERSION="3.21.6"
|
||||
IMAGE_SIZE="512M"
|
||||
|
||||
# Packages to install via apk
|
||||
APK_PACKAGES="haproxy curl"
|
||||
|
||||
# Binaries to copy into the image (source:destination)
|
||||
INSTALL_BINARIES="bin/alb-agent:/usr/local/bin/alb-agent"
|
||||
|
||||
# Build command for binaries (run before image build)
|
||||
BUILD_COMMANDS="make build-alb-agent"
|
||||
|
||||
# OpenRC services to enable at boot
|
||||
ENABLE_SERVICES="haproxy"
|
||||
|
||||
# Custom setup script (runs inside chroot after packages are installed)
|
||||
SETUP_SCRIPT="scripts/images/alb-setup.sh"
|
||||
+57
-11
@@ -160,15 +160,12 @@ elif [ -n "$WAN_IFACE" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Chassis ID must match what vpcd derives from the node name in spinifex.toml.
|
||||
# vpcd generates "chassis-{nodeName}" (e.g., chassis-node1) and uses it to schedule
|
||||
# gateway chassis in the NB DB. ovn-controller registers in the SB DB using the OVS
|
||||
# system-id. If they don't match, gateway traffic has no host to schedule to.
|
||||
NODE_NAME="node1"
|
||||
CHASSIS_ID="chassis-${NODE_NAME}"
|
||||
# Chassis ID — let setup-ovn.sh auto-detect from hostname. This ensures the
|
||||
# system-id matches what ovn-controller registers in the SBDB, which is what
|
||||
# vpcd discovers at startup for gateway scheduling.
|
||||
|
||||
echo "Re-initializing OVN (chassis-id: $CHASSIS_ID)"
|
||||
./scripts/setup-ovn.sh --management $SETUP_OVN_FLAGS --chassis-id="$CHASSIS_ID"
|
||||
echo "Re-initializing OVN"
|
||||
./scripts/setup-ovn.sh --management $SETUP_OVN_FLAGS
|
||||
|
||||
echo "Initializing platform"
|
||||
ADMIN_INIT_ARGS="--region $REGION --az ${REGION}a --node node1 --nodes 1"
|
||||
@@ -198,7 +195,6 @@ if [ -n "$WAN_IFACE" ] && [ -n "$WAN_GW" ]; then
|
||||
ADMIN_INIT_ARGS="$ADMIN_INIT_ARGS --external-mode=pool --external-pool=${POOL_START}-${POOL_END} --external-gateway=${WAN_GW} --external-prefix-len=${WAN_PREFIX}"
|
||||
fi
|
||||
fi
|
||||
./bin/spx admin init $ADMIN_INIT_ARGS
|
||||
|
||||
# Re-initialize with --force to regenerate credentials, certs, config, and
|
||||
# update ~/.aws/credentials. Must carry the same args so external networking
|
||||
@@ -212,8 +208,16 @@ if [ ! -f ~/.ssh/spinifex-key.pub ]; then
|
||||
ssh-keygen -t ed25519 -f ~/.ssh/spinifex-key -N ""
|
||||
fi
|
||||
|
||||
# Trust the Spinifex CA certificate so TLS connections to predastore/awsgw work
|
||||
if [ -f "$HOME/spinifex/config/ca.pem" ]; then
|
||||
echo "Adding Spinifex CA certificate to system trust store..."
|
||||
sudo cp "$HOME/spinifex/config/ca.pem" /usr/local/share/ca-certificates/spinifex-ca.crt
|
||||
sudo update-ca-certificates
|
||||
fi
|
||||
|
||||
# Enable pprof for development
|
||||
PPROF_ENABLED=1 PPROF_OUTPUT=/tmp/spinifex-vm.prof ./scripts/start-dev.sh --build
|
||||
#PPROF_ENABLED=1 PPROF_OUTPUT=/tmp/spinifex-vm.prof ./scripts/start-dev.sh --build
|
||||
./scripts/start-dev.sh --build
|
||||
|
||||
export AWS_PROFILE=spinifex
|
||||
|
||||
@@ -252,4 +256,46 @@ fi
|
||||
|
||||
aws ec2 describe-images
|
||||
|
||||
echo "Reset complete, fresh AMI imported, proceed to creating instances"
|
||||
# --- Launch a smoke-test instance ---
|
||||
echo "Launching smoke-test instance..."
|
||||
|
||||
# Pick instance type based on CPU vendor
|
||||
if grep -q 'AuthenticAMD' /proc/cpuinfo; then
|
||||
INSTANCE_TYPE="t3a.small"
|
||||
else
|
||||
INSTANCE_TYPE="t3.small"
|
||||
fi
|
||||
|
||||
# Get the AMI we just imported
|
||||
AMI_ID=$(aws ec2 describe-images --query "Images[0].ImageId" --output text)
|
||||
if [ -z "$AMI_ID" ] || [ "$AMI_ID" = "None" ]; then
|
||||
echo "❌ No AMI found, skipping instance launch"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Find a public subnet (first available)
|
||||
SUBNET_ID=$(aws ec2 describe-subnets \
|
||||
--filters "Name=map-public-ip-on-launch,Values=true" \
|
||||
--query "Subnets[0].SubnetId" --output text 2>/dev/null)
|
||||
# Fallback: just grab the first subnet if no public-tagged one exists
|
||||
if [ -z "$SUBNET_ID" ] || [ "$SUBNET_ID" = "None" ]; then
|
||||
SUBNET_ID=$(aws ec2 describe-subnets --query "Subnets[0].SubnetId" --output text)
|
||||
fi
|
||||
if [ -z "$SUBNET_ID" ] || [ "$SUBNET_ID" = "None" ]; then
|
||||
echo "❌ No subnet found, skipping instance launch"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo " AMI: $AMI_ID"
|
||||
echo " Instance type: $INSTANCE_TYPE"
|
||||
echo " Subnet: $SUBNET_ID"
|
||||
|
||||
INSTANCE_ID=$(aws ec2 run-instances \
|
||||
--image-id "$AMI_ID" \
|
||||
--instance-type "$INSTANCE_TYPE" \
|
||||
--key-name spinifex-key \
|
||||
--subnet-id "$SUBNET_ID" \
|
||||
--count 1 \
|
||||
--query 'Instances[0].InstanceId' --output text)
|
||||
|
||||
echo "✅ Reset complete — instance $INSTANCE_ID launched ($INSTANCE_TYPE)"
|
||||
|
||||
@@ -614,15 +614,16 @@ sudo tee "$LOGROTATE_FILE" >/dev/null <<'LOGROTATE'
|
||||
LOGROTATE
|
||||
echo " logrotate config: $LOGROTATE_FILE (daily, 100M max, 3 rotations, explicit filenames)"
|
||||
|
||||
# --- Step 10: Disable auto-start on boot ---
|
||||
# start-dev.sh / stop-dev.sh manage the OVN lifecycle. Without spinifex services
|
||||
# running, ovn-controller spins in a tight reconnect loop burning CPU.
|
||||
# --- Step 10: Enable auto-start on boot ---
|
||||
# OVN services should start with the system in production. ovn-controller uses
|
||||
# exponential backoff if the SB DB isn't ready yet, so CPU impact is negligible.
|
||||
# In dev, stop-dev.sh explicitly stops OVN when not needed.
|
||||
echo ""
|
||||
echo "Step 10: Disabling OVN auto-start on boot (start-dev.sh will manage lifecycle)..."
|
||||
sudo systemctl disable openvswitch-switch 2>/dev/null || true
|
||||
sudo systemctl disable ovn-controller 2>/dev/null || true
|
||||
echo " openvswitch-switch: disabled on boot"
|
||||
echo " ovn-controller: disabled on boot"
|
||||
echo "Step 10: Enabling OVN auto-start on boot..."
|
||||
sudo systemctl enable openvswitch-switch 2>/dev/null || true
|
||||
sudo systemctl enable ovn-controller 2>/dev/null || true
|
||||
echo " openvswitch-switch: enabled on boot"
|
||||
echo " ovn-controller: enabled on boot"
|
||||
|
||||
# --- Step 11: Health check ---
|
||||
echo ""
|
||||
|
||||
+9
-8
@@ -341,16 +341,17 @@ print_summary() {
|
||||
echo " Next steps:"
|
||||
echo ""
|
||||
echo " 1. Setup OVN networking:"
|
||||
echo " sudo /usr/local/share/spinifex/setup-ovn.sh --management"
|
||||
echo " # If WAN is a physical NIC (not a bridge), add:"
|
||||
echo " # --wan-bridge=br-wan --wan-iface=<WAN_NIC> (dedicated WAN NIC)"
|
||||
echo " # --macvlan --wan-iface=<WAN_NIC> (single-NIC, SSH-safe)"
|
||||
echo " If your WAN interface is already a bridge (e.g. br-wan):"
|
||||
echo " sudo /usr/local/share/spinifex/setup-ovn.sh --management"
|
||||
echo ""
|
||||
echo " 2. Initialize a region:"
|
||||
echo " sudo spx admin init --region <region> --az <az> --node node1 --nodes 1"
|
||||
echo " If your WAN is a physical NIC:"
|
||||
echo " # Dedicated WAN NIC (not your SSH connection):"
|
||||
echo " sudo /usr/local/share/spinifex/setup-ovn.sh --management --wan-bridge=br-wan --wan-iface=eth1"
|
||||
echo " # Single-NIC host (SSH-safe macvlan):"
|
||||
echo " sudo /usr/local/share/spinifex/setup-ovn.sh --management --macvlan --wan-iface=enp0s3"
|
||||
echo ""
|
||||
echo " 2. Setup OVN networking:"
|
||||
echo " sudo /usr/local/share/spinifex/setup-ovn.sh --management"
|
||||
echo " 2. Initialize:"
|
||||
echo " sudo spx admin init --node node1 --nodes 1"
|
||||
echo ""
|
||||
echo " 3. Start services:"
|
||||
echo " sudo systemctl start spinifex.target"
|
||||
|
||||
+103
-22
@@ -427,53 +427,109 @@ func GenerateCACert(caCertPath, caKeyPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DiscoverLocalIPs enumerates all non-loopback network interface addresses on
|
||||
// this machine and returns them as strings. Link-local addresses are excluded.
|
||||
func DiscoverLocalIPs() []string {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
slog.Warn("failed to enumerate network interfaces", "error", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
var ips []string
|
||||
|
||||
for _, iface := range ifaces {
|
||||
// Skip loopback and down interfaces.
|
||||
if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
slog.Debug("failed to get addresses for interface", "iface", iface.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
continue
|
||||
}
|
||||
s := ip.String()
|
||||
if _, ok := seen[s]; !ok {
|
||||
seen[s] = struct{}{}
|
||||
ips = append(ips, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ips
|
||||
}
|
||||
|
||||
// DiscoverHostname returns the system hostname suitable for use as a DNS SAN.
|
||||
// Returns an empty string if the hostname cannot be determined or is "localhost".
|
||||
func DiscoverHostname() string {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
slog.Warn("failed to determine system hostname", "error", err)
|
||||
return ""
|
||||
}
|
||||
if hostname == "" || hostname == "localhost" {
|
||||
return ""
|
||||
}
|
||||
return hostname
|
||||
}
|
||||
|
||||
// GenerateSignedCert generates a server certificate signed by the CA.
|
||||
// extraIPs are additional IP addresses to include in the certificate's SANs.
|
||||
// All non-loopback interface IPs on the local machine are automatically included.
|
||||
func GenerateSignedCert(certPath, keyPath, caCertPath, caKeyPath string, extraIPs ...string) error {
|
||||
// Load CA certificate
|
||||
return GenerateSignedCertWithDNS(certPath, keyPath, caCertPath, caKeyPath, extraIPs, nil)
|
||||
}
|
||||
|
||||
// GenerateSignedCertWithDNS generates a server certificate signed by the CA.
|
||||
// All non-loopback interface IPs and the machine hostname are automatically
|
||||
// included. extraIPs and extraDNS allow adding additional SANs.
|
||||
func GenerateSignedCertWithDNS(certPath, keyPath, caCertPath, caKeyPath string, extraIPs, extraDNS []string) error {
|
||||
caCertPEM, err := os.ReadFile(caCertPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read CA cert: %w", err)
|
||||
}
|
||||
|
||||
caCertBlock, _ := pem.Decode(caCertPEM)
|
||||
if caCertBlock == nil {
|
||||
return fmt.Errorf("failed to decode CA cert PEM")
|
||||
}
|
||||
|
||||
caCert, err := x509.ParseCertificate(caCertBlock.Bytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse CA cert: %w", err)
|
||||
}
|
||||
|
||||
// Load CA private key
|
||||
caKeyPEM, err := os.ReadFile(caKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read CA key: %w", err)
|
||||
}
|
||||
|
||||
caKeyBlock, _ := pem.Decode(caKeyPEM)
|
||||
if caKeyBlock == nil {
|
||||
return fmt.Errorf("failed to decode CA key PEM")
|
||||
}
|
||||
|
||||
caPrivateKey, err := x509.ParsePKCS8PrivateKey(caKeyBlock.Bytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse CA private key: %w", err)
|
||||
}
|
||||
|
||||
caRSAKey, ok := caPrivateKey.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("CA key is not RSA")
|
||||
}
|
||||
|
||||
// Generate server private key
|
||||
serverPrivateKey, err := rsa.GenerateKey(rand.Reader, 4096)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate server private key: %w", err)
|
||||
}
|
||||
|
||||
// Create server certificate template
|
||||
notBefore := time.Now()
|
||||
notAfter := notBefore.Add(365 * 24 * time.Hour) // 1 year for server certs
|
||||
|
||||
@@ -482,20 +538,51 @@ func GenerateSignedCert(certPath, keyPath, caCertPath, caKeyPath string, extraIP
|
||||
return fmt.Errorf("failed to generate serial number: %w", err)
|
||||
}
|
||||
|
||||
// Build IP list: localhost IPs + any extra IPs (e.g., bind IP for multi-node)
|
||||
// Build IP list: localhost IPs + auto-discovered interface IPs + explicit extras.
|
||||
seen := map[string]struct{}{"127.0.0.1": {}, "::1": {}}
|
||||
ipAddresses := []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}
|
||||
addIP := func(s string) {
|
||||
if s == "" || s == "0.0.0.0" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[s]; ok {
|
||||
return
|
||||
}
|
||||
if parsed := net.ParseIP(s); parsed != nil {
|
||||
seen[s] = struct{}{}
|
||||
ipAddresses = append(ipAddresses, parsed)
|
||||
}
|
||||
}
|
||||
for _, ip := range DiscoverLocalIPs() {
|
||||
addIP(ip)
|
||||
}
|
||||
for _, ip := range extraIPs {
|
||||
if ip != "" && ip != "127.0.0.1" && ip != "::1" && ip != "0.0.0.0" {
|
||||
if parsed := net.ParseIP(ip); parsed != nil {
|
||||
ipAddresses = append(ipAddresses, parsed)
|
||||
}
|
||||
addIP(ip)
|
||||
}
|
||||
|
||||
// Build DNS names: always include localhost, plus hostname if available.
|
||||
dnsSeen := map[string]struct{}{"localhost": {}}
|
||||
dnsNames := []string{"localhost"}
|
||||
if hostname := DiscoverHostname(); hostname != "" {
|
||||
if _, ok := dnsSeen[hostname]; !ok {
|
||||
dnsSeen[hostname] = struct{}{}
|
||||
dnsNames = append(dnsNames, hostname)
|
||||
}
|
||||
}
|
||||
for _, dns := range extraDNS {
|
||||
if dns == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := dnsSeen[dns]; !ok {
|
||||
dnsSeen[dns] = struct{}{}
|
||||
dnsNames = append(dnsNames, dns)
|
||||
}
|
||||
}
|
||||
|
||||
serverTemplate := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
CommonName: "localhost",
|
||||
CommonName: "Spinifex Server",
|
||||
Organization: []string{"Spinifex Platform"},
|
||||
},
|
||||
NotBefore: notBefore,
|
||||
@@ -503,39 +590,33 @@ func GenerateSignedCert(certPath, keyPath, caCertPath, caKeyPath string, extraIP
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
DNSNames: []string{"localhost"},
|
||||
DNSNames: dnsNames,
|
||||
IPAddresses: ipAddresses,
|
||||
}
|
||||
|
||||
// Sign the server certificate with the CA
|
||||
serverDerBytes, err := x509.CreateCertificate(rand.Reader, &serverTemplate, caCert, &serverPrivateKey.PublicKey, caRSAKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create server certificate: %w", err)
|
||||
}
|
||||
|
||||
// Write server certificate to file
|
||||
certOut, err := os.Create(certPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create cert file: %w", err)
|
||||
}
|
||||
defer certOut.Close()
|
||||
|
||||
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: serverDerBytes}); err != nil {
|
||||
return fmt.Errorf("failed to write cert: %w", err)
|
||||
}
|
||||
|
||||
// Write server private key to file
|
||||
keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create key file: %w", err)
|
||||
}
|
||||
defer keyOut.Close()
|
||||
|
||||
privBytes, err := x509.MarshalPKCS8PrivateKey(serverPrivateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal private key: %w", err)
|
||||
}
|
||||
|
||||
if err := pem.Encode(keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}); err != nil {
|
||||
return fmt.Errorf("failed to write key: %w", err)
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@ func TestGenerateSignedCert(t *testing.T) {
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, cert.IsCA)
|
||||
assert.Equal(t, "localhost", cert.Subject.CommonName)
|
||||
assert.Equal(t, "Spinifex Server", cert.Subject.CommonName)
|
||||
assert.Contains(t, cert.DNSNames, "localhost")
|
||||
|
||||
hasLoopback := false
|
||||
@@ -346,6 +346,9 @@ func TestGenerateSignedCert(t *testing.T) {
|
||||
}
|
||||
assert.True(t, hasLoopback)
|
||||
|
||||
// Auto-discovered IPs should be present (at least loopback + any interface IPs).
|
||||
assert.GreaterOrEqual(t, len(cert.IPAddresses), 2)
|
||||
|
||||
// Verify against CA
|
||||
caCertPEM, _ := os.ReadFile(caCertPath)
|
||||
caBlock, _ := pem.Decode(caCertPEM)
|
||||
@@ -392,7 +395,19 @@ func TestGenerateSignedCert(t *testing.T) {
|
||||
block, _ := pem.Decode(certPEM)
|
||||
cert, _ := x509.ParseCertificate(block.Bytes)
|
||||
|
||||
assert.Len(t, cert.IPAddresses, 2)
|
||||
// Should have loopback (127.0.0.1, ::1) + auto-discovered interface IPs.
|
||||
// Duplicates (127.0.0.1, ::1) and specials (0.0.0.0, "") must not add extras.
|
||||
// Count unique IPs from a clean run without extras.
|
||||
baseDir := t.TempDir()
|
||||
baseCert := filepath.Join(baseDir, "server.pem")
|
||||
baseKey := filepath.Join(baseDir, "server.key")
|
||||
require.NoError(t, GenerateSignedCert(baseCert, baseKey, caCertPath, caKeyPath))
|
||||
basePEM, _ := os.ReadFile(baseCert)
|
||||
baseBlock, _ := pem.Decode(basePEM)
|
||||
baseParsed, _ := x509.ParseCertificate(baseBlock.Bytes)
|
||||
|
||||
assert.Equal(t, len(baseParsed.IPAddresses), len(cert.IPAddresses),
|
||||
"passing duplicate/special IPs should not add extra entries")
|
||||
})
|
||||
|
||||
t.Run("InvalidCACert", func(t *testing.T) {
|
||||
@@ -406,6 +421,178 @@ func TestGenerateSignedCert(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestDiscoverLocalIPs(t *testing.T) {
|
||||
t.Parallel()
|
||||
ips := DiscoverLocalIPs()
|
||||
// Should find at least one non-loopback IP on any machine with a network interface.
|
||||
assert.NotEmpty(t, ips, "expected at least one non-loopback IP")
|
||||
|
||||
for _, ip := range ips {
|
||||
parsed := net.ParseIP(ip)
|
||||
require.NotNil(t, parsed, "returned IP should be parseable: %s", ip)
|
||||
assert.False(t, parsed.IsLoopback(), "should not include loopback: %s", ip)
|
||||
assert.False(t, parsed.IsLinkLocalUnicast(), "should not include link-local: %s", ip)
|
||||
}
|
||||
|
||||
// No duplicates.
|
||||
seen := make(map[string]struct{})
|
||||
for _, ip := range ips {
|
||||
_, dup := seen[ip]
|
||||
assert.False(t, dup, "duplicate IP: %s", ip)
|
||||
seen[ip] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverHostname(t *testing.T) {
|
||||
t.Parallel()
|
||||
hostname := DiscoverHostname()
|
||||
// We can't assert a specific value, but if non-empty it shouldn't be "localhost".
|
||||
if hostname != "" {
|
||||
assert.NotEqual(t, "localhost", hostname)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSignedCertWithDNS_DedupDNS(t *testing.T) {
|
||||
t.Parallel()
|
||||
caDir := t.TempDir()
|
||||
caCertPath := filepath.Join(caDir, "ca.pem")
|
||||
caKeyPath := filepath.Join(caDir, "ca.key")
|
||||
require.NoError(t, GenerateCACert(caCertPath, caKeyPath))
|
||||
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "server.pem")
|
||||
keyPath := filepath.Join(dir, "server.key")
|
||||
|
||||
// Pass "localhost" as extra DNS — should not duplicate the built-in entry.
|
||||
// Also pass empty strings and duplicates — should be ignored.
|
||||
extraDNS := []string{"localhost", "", "example.local", "example.local", ""}
|
||||
require.NoError(t, GenerateSignedCertWithDNS(certPath, keyPath, caCertPath, caKeyPath, nil, extraDNS))
|
||||
|
||||
certPEM, _ := os.ReadFile(certPath)
|
||||
block, _ := pem.Decode(certPEM)
|
||||
cert, _ := x509.ParseCertificate(block.Bytes)
|
||||
|
||||
// Count occurrences of "localhost" — must be exactly 1.
|
||||
localhostCount := 0
|
||||
exampleCount := 0
|
||||
for _, dns := range cert.DNSNames {
|
||||
if dns == "localhost" {
|
||||
localhostCount++
|
||||
}
|
||||
if dns == "example.local" {
|
||||
exampleCount++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, localhostCount, "localhost should appear exactly once")
|
||||
assert.Equal(t, 1, exampleCount, "example.local should appear exactly once")
|
||||
assert.Contains(t, cert.DNSNames, "example.local")
|
||||
}
|
||||
|
||||
func TestGenerateSignedCertWithDNS_NilSlices(t *testing.T) {
|
||||
t.Parallel()
|
||||
caDir := t.TempDir()
|
||||
caCertPath := filepath.Join(caDir, "ca.pem")
|
||||
caKeyPath := filepath.Join(caDir, "ca.key")
|
||||
require.NoError(t, GenerateCACert(caCertPath, caKeyPath))
|
||||
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "server.pem")
|
||||
keyPath := filepath.Join(dir, "server.key")
|
||||
|
||||
// Both slices nil — should still produce a valid cert with defaults.
|
||||
require.NoError(t, GenerateSignedCertWithDNS(certPath, keyPath, caCertPath, caKeyPath, nil, nil))
|
||||
|
||||
certPEM, _ := os.ReadFile(certPath)
|
||||
block, _ := pem.Decode(certPEM)
|
||||
cert, _ := x509.ParseCertificate(block.Bytes)
|
||||
|
||||
assert.Contains(t, cert.DNSNames, "localhost")
|
||||
assert.GreaterOrEqual(t, len(cert.IPAddresses), 2, "should have at least loopback IPs")
|
||||
}
|
||||
|
||||
func TestGenerateSignedCertWithDNS_InvalidCA(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
|
||||
// Missing CA cert file.
|
||||
err := GenerateSignedCertWithDNS(
|
||||
filepath.Join(dir, "s.pem"), filepath.Join(dir, "s.key"),
|
||||
filepath.Join(dir, "missing-ca.pem"), filepath.Join(dir, "missing-ca.key"),
|
||||
nil, nil,
|
||||
)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "failed to read CA cert")
|
||||
|
||||
// Corrupt CA cert.
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "bad-ca.pem"), []byte("not-pem"), 0600))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "bad-ca.key"), []byte("not-pem"), 0600))
|
||||
err = GenerateSignedCertWithDNS(
|
||||
filepath.Join(dir, "s.pem"), filepath.Join(dir, "s.key"),
|
||||
filepath.Join(dir, "bad-ca.pem"), filepath.Join(dir, "bad-ca.key"),
|
||||
nil, nil,
|
||||
)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGenerateSignedCertWithDNS_ExtraDNS(t *testing.T) {
|
||||
t.Parallel()
|
||||
caDir := t.TempDir()
|
||||
caCertPath := filepath.Join(caDir, "ca.pem")
|
||||
caKeyPath := filepath.Join(caDir, "ca.key")
|
||||
require.NoError(t, GenerateCACert(caCertPath, caKeyPath))
|
||||
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "server.pem")
|
||||
keyPath := filepath.Join(dir, "server.key")
|
||||
|
||||
extraIPs := []string{"10.0.0.42"}
|
||||
extraDNS := []string{"spinifex.local", "node1.spinifex.local"}
|
||||
require.NoError(t, GenerateSignedCertWithDNS(certPath, keyPath, caCertPath, caKeyPath, extraIPs, extraDNS))
|
||||
|
||||
certPEM, _ := os.ReadFile(certPath)
|
||||
block, _ := pem.Decode(certPEM)
|
||||
cert, _ := x509.ParseCertificate(block.Bytes)
|
||||
|
||||
assert.Contains(t, cert.DNSNames, "localhost")
|
||||
assert.Contains(t, cert.DNSNames, "spinifex.local")
|
||||
assert.Contains(t, cert.DNSNames, "node1.spinifex.local")
|
||||
|
||||
hasExtraIP := false
|
||||
for _, ip := range cert.IPAddresses {
|
||||
if ip.Equal(net.ParseIP("10.0.0.42")) {
|
||||
hasExtraIP = true
|
||||
}
|
||||
}
|
||||
assert.True(t, hasExtraIP, "cert should contain extra IP 10.0.0.42")
|
||||
|
||||
info, _ := os.Stat(keyPath)
|
||||
assert.Equal(t, os.FileMode(0600), info.Mode().Perm())
|
||||
}
|
||||
|
||||
func TestGenerateSignedCert_IncludesHostname(t *testing.T) {
|
||||
t.Parallel()
|
||||
caDir := t.TempDir()
|
||||
caCertPath := filepath.Join(caDir, "ca.pem")
|
||||
caKeyPath := filepath.Join(caDir, "ca.key")
|
||||
require.NoError(t, GenerateCACert(caCertPath, caKeyPath))
|
||||
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "server.pem")
|
||||
keyPath := filepath.Join(dir, "server.key")
|
||||
require.NoError(t, GenerateSignedCert(certPath, keyPath, caCertPath, caKeyPath))
|
||||
|
||||
certPEM, _ := os.ReadFile(certPath)
|
||||
block, _ := pem.Decode(certPEM)
|
||||
cert, _ := x509.ParseCertificate(block.Bytes)
|
||||
|
||||
assert.Contains(t, cert.DNSNames, "localhost")
|
||||
hostname := DiscoverHostname()
|
||||
if hostname != "" {
|
||||
assert.Contains(t, cert.DNSNames, hostname,
|
||||
"cert DNS SANs should include the machine hostname")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSelfSignedCert_CreatesValidCert(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultTelemetryURL = "https://install.mulgadc.com/install"
|
||||
|
||||
// TelemetryPayload is the JSON body sent to the telemetry endpoint.
|
||||
type TelemetryPayload struct {
|
||||
MachineID string `json:"machine_id"`
|
||||
Event string `json:"event"` // "init" or "join"
|
||||
Region string `json:"region"`
|
||||
AZ string `json:"az"`
|
||||
Node string `json:"node"`
|
||||
Nodes int `json:"nodes"`
|
||||
BindIP string `json:"bind_ip"`
|
||||
Version string `json:"version"`
|
||||
Arch string `json:"arch"`
|
||||
OS string `json:"os"`
|
||||
ExternalMode string `json:"external_mode,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
|
||||
// URL overrides the telemetry endpoint (for testing only). Not serialized.
|
||||
URL string `json:"-"`
|
||||
}
|
||||
|
||||
// SendTelemetry posts the payload to the telemetry endpoint.
|
||||
// It respects the context deadline and logs at Debug level only.
|
||||
func SendTelemetry(ctx context.Context, payload TelemetryPayload) {
|
||||
if payload.Arch == "" {
|
||||
payload.Arch = runtime.GOARCH
|
||||
}
|
||||
if payload.OS == "" {
|
||||
payload.OS = runtime.GOOS
|
||||
}
|
||||
if payload.Timestamp == "" {
|
||||
payload.Timestamp = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
url := payload.URL
|
||||
if url == "" {
|
||||
url = defaultTelemetryURL
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
slog.Debug("telemetry: failed to marshal payload", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
slog.Debug("telemetry: failed to create request", "error", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Debug("telemetry: request failed", "error", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
slog.Debug("telemetry: sent", "status", resp.StatusCode, "event", payload.Event)
|
||||
}
|
||||
|
||||
// ReadMachineID returns a stable anonymous identifier for this machine.
|
||||
// Reads /etc/machine-id (standard Linux UUID). Falls back to a SHA-256
|
||||
// hash of hostname + first non-loopback MAC address.
|
||||
func ReadMachineID() string {
|
||||
data, err := os.ReadFile("/etc/machine-id")
|
||||
if err == nil {
|
||||
id := strings.TrimSpace(string(data))
|
||||
if id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: hash hostname + MAC
|
||||
hostname, _ := os.Hostname()
|
||||
mac := firstMAC()
|
||||
hash := sha256.Sum256([]byte(hostname + mac))
|
||||
return fmt.Sprintf("%x", hash[:16])
|
||||
}
|
||||
|
||||
// firstMAC returns the MAC address of the first non-loopback, up interface.
|
||||
func firstMAC() string {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
if mac := iface.HardwareAddr.String(); mac != "" {
|
||||
return mac
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReadMachineID_ReturnsNonEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
id := ReadMachineID()
|
||||
assert.NotEmpty(t, id, "machine ID should never be empty")
|
||||
assert.Greater(t, len(id), 8, "machine ID should be a reasonable length")
|
||||
}
|
||||
|
||||
func TestSendTelemetry_PostsCorrectPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
var received TelemetryPayload
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, json.Unmarshal(body, &received))
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
SendTelemetry(ctx, TelemetryPayload{
|
||||
MachineID: "test-machine-123",
|
||||
Event: "init",
|
||||
Region: "ap-southeast-2",
|
||||
AZ: "ap-southeast-2a",
|
||||
Node: "node1",
|
||||
Nodes: 3,
|
||||
BindIP: "10.11.12.1",
|
||||
Version: "v0.5.0",
|
||||
URL: server.URL,
|
||||
})
|
||||
|
||||
assert.Equal(t, "test-machine-123", received.MachineID)
|
||||
assert.Equal(t, "init", received.Event)
|
||||
assert.Equal(t, "ap-southeast-2", received.Region)
|
||||
assert.Equal(t, 3, received.Nodes)
|
||||
assert.Equal(t, "v0.5.0", received.Version)
|
||||
assert.NotEmpty(t, received.Arch, "arch should be auto-filled")
|
||||
assert.NotEmpty(t, received.OS, "os should be auto-filled")
|
||||
assert.NotEmpty(t, received.Timestamp, "timestamp should be auto-filled")
|
||||
}
|
||||
|
||||
func TestSendTelemetry_RespectsTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(10 * time.Second)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
SendTelemetry(ctx, TelemetryPayload{MachineID: "timeout-test", URL: server.URL})
|
||||
elapsed := time.Since(start)
|
||||
|
||||
assert.Less(t, elapsed, 2*time.Second, "should respect context timeout")
|
||||
}
|
||||
|
||||
func TestSendTelemetry_HandlesServerError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
SendTelemetry(ctx, TelemetryPayload{MachineID: "error-test", URL: server.URL})
|
||||
}
|
||||
|
||||
func TestSendTelemetry_HandlesUnreachableServer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
// URL pointing to a port that's not listening
|
||||
SendTelemetry(ctx, TelemetryPayload{MachineID: "unreachable-test", URL: "http://127.0.0.1:1"})
|
||||
}
|
||||
|
||||
func TestFirstMAC_ReturnsNonEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
mac := firstMAC()
|
||||
assert.NotEmpty(t, mac, "expected at least one MAC address")
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
// Package albagent implements the NATS config agent that runs inside ALB VMs.
|
||||
// It subscribes to config updates, writes HAProxy configuration, and reloads
|
||||
// the HAProxy process.
|
||||
package albagent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
const (
|
||||
// Default paths for HAProxy config and PID file.
|
||||
DefaultConfigPath = "/etc/haproxy/haproxy.cfg"
|
||||
DefaultPIDPath = "/run/haproxy.pid"
|
||||
|
||||
// NATS topic patterns.
|
||||
configTopicPrefix = "elbv2.alb."
|
||||
configTopicSuffix = ".config"
|
||||
pingTopicSuffix = ".ping"
|
||||
)
|
||||
|
||||
// Agent manages HAProxy configuration inside an ALB VM.
|
||||
type Agent struct {
|
||||
lbID string
|
||||
natsURL string
|
||||
configPath string
|
||||
pidPath string
|
||||
|
||||
nc *nats.Conn
|
||||
subs []*nats.Subscription
|
||||
mu sync.Mutex
|
||||
|
||||
// For testing: override the reload function.
|
||||
reloadFn func(configPath, pidPath string) error
|
||||
}
|
||||
|
||||
// New creates a new ALB agent for the given load balancer.
|
||||
func New(lbID, natsURL string) (*Agent, error) {
|
||||
if lbID == "" {
|
||||
return nil, fmt.Errorf("lbID is required")
|
||||
}
|
||||
if natsURL == "" {
|
||||
natsURL = "nats://127.0.0.1:4222"
|
||||
}
|
||||
|
||||
return &Agent{
|
||||
lbID: lbID,
|
||||
natsURL: natsURL,
|
||||
configPath: DefaultConfigPath,
|
||||
pidPath: DefaultPIDPath,
|
||||
reloadFn: reloadHAProxy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ConfigTopic returns the NATS topic for config updates.
|
||||
func ConfigTopic(lbID string) string {
|
||||
return configTopicPrefix + lbID + configTopicSuffix
|
||||
}
|
||||
|
||||
// PingTopic returns the NATS topic for health pings.
|
||||
func PingTopic(lbID string) string {
|
||||
return configTopicPrefix + lbID + pingTopicSuffix
|
||||
}
|
||||
|
||||
// Start connects to NATS and subscribes to config and ping topics.
|
||||
func (a *Agent) Start() error {
|
||||
nc, err := nats.Connect(a.natsURL,
|
||||
nats.Name("alb-agent-"+a.lbID),
|
||||
nats.MaxReconnects(-1),
|
||||
nats.ReconnectWait(2*time.Second),
|
||||
nats.DisconnectErrHandler(func(_ *nats.Conn, err error) {
|
||||
slog.Warn("NATS disconnected", "err", err)
|
||||
}),
|
||||
nats.ReconnectHandler(func(_ *nats.Conn) {
|
||||
slog.Info("NATS reconnected")
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to NATS at %s: %w", a.natsURL, err)
|
||||
}
|
||||
a.nc = nc
|
||||
|
||||
// Subscribe to config updates
|
||||
configSub, err := nc.Subscribe(ConfigTopic(a.lbID), a.handleConfig)
|
||||
if err != nil {
|
||||
nc.Close()
|
||||
return fmt.Errorf("subscribe to config topic: %w", err)
|
||||
}
|
||||
|
||||
// Subscribe to health pings (request/reply)
|
||||
pingSub, err := nc.Subscribe(PingTopic(a.lbID), a.handlePing)
|
||||
if err != nil {
|
||||
nc.Close()
|
||||
return fmt.Errorf("subscribe to ping topic: %w", err)
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.subs = append(a.subs, configSub, pingSub)
|
||||
a.mu.Unlock()
|
||||
|
||||
// Flush to ensure the server has processed our subscriptions before
|
||||
// we report that the agent is started and ready.
|
||||
if err := nc.Flush(); err != nil {
|
||||
nc.Close()
|
||||
return fmt.Errorf("flush subscriptions: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("Agent started",
|
||||
"lbId", a.lbID,
|
||||
"configTopic", ConfigTopic(a.lbID),
|
||||
"pingTopic", PingTopic(a.lbID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the agent.
|
||||
func (a *Agent) Stop() {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
for _, sub := range a.subs {
|
||||
if err := sub.Unsubscribe(); err != nil {
|
||||
slog.Warn("Failed to unsubscribe", "err", err)
|
||||
}
|
||||
}
|
||||
a.subs = nil
|
||||
|
||||
if a.nc != nil {
|
||||
a.nc.Close()
|
||||
a.nc = nil
|
||||
}
|
||||
|
||||
slog.Info("Agent stopped", "lbId", a.lbID)
|
||||
}
|
||||
|
||||
// handleConfig processes a config update message. The message payload is the
|
||||
// raw HAProxy config text. It writes the config to disk and reloads HAProxy.
|
||||
func (a *Agent) handleConfig(msg *nats.Msg) {
|
||||
config := string(msg.Data)
|
||||
if config == "" {
|
||||
slog.Warn("Received empty config, ignoring")
|
||||
a.respond(msg, "error", "empty config")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Received config update", "lbId", a.lbID, "size", len(config))
|
||||
|
||||
// Write config to disk
|
||||
if err := WriteConfig(a.configPath, config); err != nil {
|
||||
slog.Error("Failed to write config", "path", a.configPath, "err", err)
|
||||
a.respond(msg, "error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Reload HAProxy
|
||||
if err := a.reloadFn(a.configPath, a.pidPath); err != nil {
|
||||
slog.Error("Failed to reload HAProxy", "err", err)
|
||||
a.respond(msg, "error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Config applied and HAProxy reloaded", "lbId", a.lbID)
|
||||
a.respond(msg, "ok", "")
|
||||
}
|
||||
|
||||
// PingResponse is the JSON response to a health ping.
|
||||
type PingResponse struct {
|
||||
Status string `json:"status"`
|
||||
LBID string `json:"lb_id"`
|
||||
HAProxy bool `json:"haproxy_running"`
|
||||
ConfigAge int64 `json:"config_age_seconds"`
|
||||
}
|
||||
|
||||
// handlePing responds to health check requests.
|
||||
func (a *Agent) handlePing(msg *nats.Msg) {
|
||||
haproxyRunning := isHAProxyRunning(a.pidPath)
|
||||
configAge := configAgeSeconds(a.configPath)
|
||||
|
||||
resp := PingResponse{
|
||||
Status: "ok",
|
||||
LBID: a.lbID,
|
||||
HAProxy: haproxyRunning,
|
||||
ConfigAge: configAge,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal ping response", "err", err)
|
||||
return
|
||||
}
|
||||
if err := msg.Respond(data); err != nil {
|
||||
slog.Warn("Failed to respond to ping", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// respond sends a JSON reply to a NATS message if it has a reply subject.
|
||||
func (a *Agent) respond(msg *nats.Msg, status, errMsg string) {
|
||||
if msg.Reply == "" {
|
||||
return
|
||||
}
|
||||
resp := map[string]string{"status": status}
|
||||
if errMsg != "" {
|
||||
resp["error"] = errMsg
|
||||
}
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal response", "err", err)
|
||||
return
|
||||
}
|
||||
if err := msg.Respond(data); err != nil {
|
||||
slog.Warn("Failed to respond", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteConfig atomically writes an HAProxy config file.
|
||||
// It writes to a temp file first, then renames for atomicity.
|
||||
func WriteConfig(path, content string) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return fmt.Errorf("create config dir: %w", err)
|
||||
}
|
||||
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(content), 0o600); err != nil {
|
||||
return fmt.Errorf("write temp config: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return fmt.Errorf("rename config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reloadHAProxy starts or reloads the HAProxy process.
|
||||
// If HAProxy is running (PID file exists and process alive), it does a
|
||||
// graceful reload with -sf. Otherwise it starts a fresh instance.
|
||||
func reloadHAProxy(configPath, pidPath string) error {
|
||||
// Ensure the stats socket directory exists (the config may reference
|
||||
// /tmp/spinifex-haproxy/ which doesn't exist on fresh Alpine VMs).
|
||||
_ = os.MkdirAll("/tmp/spinifex-haproxy", 0o750)
|
||||
|
||||
oldPID := readPID(pidPath)
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if oldPID > 0 {
|
||||
// Graceful reload: new worker starts, old workers finish in-flight requests
|
||||
cmd = exec.Command("haproxy", "-f", configPath, "-p", pidPath, "-D", "-sf", strconv.Itoa(oldPID))
|
||||
} else {
|
||||
// Fresh start
|
||||
cmd = exec.Command("haproxy", "-f", configPath, "-p", pidPath, "-D")
|
||||
}
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("haproxy: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readPID reads the HAProxy PID from the PID file. Returns 0 if unavailable.
|
||||
func readPID(pidPath string) int {
|
||||
data, err := os.ReadFile(pidPath)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
// Check if process is still alive
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
if err := proc.Signal(syscall.Signal(0)); err != nil {
|
||||
return 0
|
||||
}
|
||||
return pid
|
||||
}
|
||||
|
||||
// isHAProxyRunning checks if HAProxy is running via its PID file.
|
||||
func isHAProxyRunning(pidPath string) bool {
|
||||
return readPID(pidPath) > 0
|
||||
}
|
||||
|
||||
// configAgeSeconds returns how many seconds since the config file was last modified.
|
||||
// Returns -1 if the file doesn't exist.
|
||||
func configAgeSeconds(configPath string) int64 {
|
||||
info, err := os.Stat(configPath)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return int64(time.Since(info.ModTime()).Seconds())
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package albagent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
natsserver "github.com/nats-io/nats-server/v2/server"
|
||||
natstest "github.com/nats-io/nats-server/v2/test"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func startTestNATS(t *testing.T) *natsserver.Server {
|
||||
t.Helper()
|
||||
opts := natstest.DefaultTestOptions
|
||||
opts.Port = -1 // random port
|
||||
ns := natstest.RunServer(&opts)
|
||||
t.Cleanup(ns.Shutdown)
|
||||
return ns
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
agent, err := New("lb-test123", "nats://localhost:4222")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if agent.lbID != "lb-test123" {
|
||||
t.Errorf("lbID = %q, want %q", agent.lbID, "lb-test123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_EmptyLBID(t *testing.T) {
|
||||
_, err := New("", "nats://localhost:4222")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty lbID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_DefaultNATSURL(t *testing.T) {
|
||||
agent, err := New("lb-test", "")
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if agent.natsURL != "nats://127.0.0.1:4222" {
|
||||
t.Errorf("natsURL = %q, want default", agent.natsURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigTopic(t *testing.T) {
|
||||
topic := ConfigTopic("lb-abc123")
|
||||
if topic != "elbv2.alb.lb-abc123.config" {
|
||||
t.Errorf("ConfigTopic = %q, want %q", topic, "elbv2.alb.lb-abc123.config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingTopic(t *testing.T) {
|
||||
topic := PingTopic("lb-abc123")
|
||||
if topic != "elbv2.alb.lb-abc123.ping" {
|
||||
t.Errorf("PingTopic = %q, want %q", topic, "elbv2.alb.lb-abc123.ping")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteConfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "haproxy.cfg")
|
||||
|
||||
content := "global\n log stdout\n"
|
||||
if err := WriteConfig(path, content); err != nil {
|
||||
t.Fatalf("WriteConfig: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if string(data) != content {
|
||||
t.Errorf("config = %q, want %q", string(data), content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteConfig_CreatesDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "subdir", "haproxy.cfg")
|
||||
|
||||
if err := WriteConfig(path, "test"); err != nil {
|
||||
t.Fatalf("WriteConfig: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("config file not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteConfig_Atomic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "haproxy.cfg")
|
||||
|
||||
// Write initial config
|
||||
if err := WriteConfig(path, "initial"); err != nil {
|
||||
t.Fatalf("WriteConfig initial: %v", err)
|
||||
}
|
||||
|
||||
// Overwrite with new config
|
||||
if err := WriteConfig(path, "updated"); err != nil {
|
||||
t.Fatalf("WriteConfig updated: %v", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(path)
|
||||
if string(data) != "updated" {
|
||||
t.Errorf("config = %q, want %q", string(data), "updated")
|
||||
}
|
||||
|
||||
// No temp file left behind
|
||||
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
|
||||
t.Error("temp file should not exist after successful write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigAgeSeconds(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.cfg")
|
||||
|
||||
// Non-existent file
|
||||
if age := configAgeSeconds(path); age != -1 {
|
||||
t.Errorf("age of non-existent file = %d, want -1", age)
|
||||
}
|
||||
|
||||
// Write file and check age
|
||||
os.WriteFile(path, []byte("test"), 0o644)
|
||||
age := configAgeSeconds(path)
|
||||
if age < 0 || age > 2 {
|
||||
t.Errorf("age = %d, expected 0-2 seconds", age)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHAProxyRunning_NoPIDFile(t *testing.T) {
|
||||
if isHAProxyRunning("/nonexistent/haproxy.pid") {
|
||||
t.Error("expected false for non-existent PID file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPID_InvalidContent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pidFile := filepath.Join(dir, "haproxy.pid")
|
||||
|
||||
os.WriteFile(pidFile, []byte("not-a-number\n"), 0o644)
|
||||
if pid := readPID(pidFile); pid != 0 {
|
||||
t.Errorf("readPID = %d, want 0 for invalid content", pid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPID_DeadProcess(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pidFile := filepath.Join(dir, "haproxy.pid")
|
||||
|
||||
// Use a very high PID that's unlikely to be running
|
||||
os.WriteFile(pidFile, []byte("999999999\n"), 0o644)
|
||||
if pid := readPID(pidFile); pid != 0 {
|
||||
t.Errorf("readPID = %d, want 0 for dead process", pid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_StartStop(t *testing.T) {
|
||||
ns := startTestNATS(t)
|
||||
|
||||
agent, err := New("lb-test123", ns.ClientURL())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
if err := agent.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
|
||||
// Verify agent is connected
|
||||
if agent.nc == nil || !agent.nc.IsConnected() {
|
||||
t.Fatal("agent not connected to NATS")
|
||||
}
|
||||
|
||||
agent.Stop()
|
||||
|
||||
if agent.nc != nil {
|
||||
t.Error("nc should be nil after Stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_HandleConfig(t *testing.T) {
|
||||
ns := startTestNATS(t)
|
||||
dir := t.TempDir()
|
||||
|
||||
agent, _ := New("lb-cfg123", ns.ClientURL())
|
||||
agent.configPath = filepath.Join(dir, "haproxy.cfg")
|
||||
agent.pidPath = filepath.Join(dir, "haproxy.pid")
|
||||
|
||||
// Mock reload to avoid needing real HAProxy
|
||||
reloadCalled := false
|
||||
agent.reloadFn = func(configPath, pidPath string) error {
|
||||
reloadCalled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := agent.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
defer agent.Stop()
|
||||
|
||||
// Send a config update via a separate NATS connection
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer nc.Close()
|
||||
|
||||
haproxyConfig := "global\n log stdout\ndefaults\n mode http\n"
|
||||
reply, err := nc.Request(ConfigTopic("lb-cfg123"), []byte(haproxyConfig), 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("Request: %v", err)
|
||||
}
|
||||
|
||||
// Check response
|
||||
var resp map[string]string
|
||||
json.Unmarshal(reply.Data, &resp)
|
||||
if resp["status"] != "ok" {
|
||||
t.Errorf("response status = %q, want %q", resp["status"], "ok")
|
||||
}
|
||||
|
||||
// Verify config was written
|
||||
data, err := os.ReadFile(agent.configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile: %v", err)
|
||||
}
|
||||
if string(data) != haproxyConfig {
|
||||
t.Errorf("written config = %q, want %q", string(data), haproxyConfig)
|
||||
}
|
||||
|
||||
if !reloadCalled {
|
||||
t.Error("reload function was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_HandleConfig_Empty(t *testing.T) {
|
||||
ns := startTestNATS(t)
|
||||
|
||||
agent, _ := New("lb-empty", ns.ClientURL())
|
||||
agent.reloadFn = func(_, _ string) error { return nil }
|
||||
|
||||
if err := agent.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
defer agent.Stop()
|
||||
|
||||
nc, _ := nats.Connect(ns.ClientURL())
|
||||
defer nc.Close()
|
||||
|
||||
reply, err := nc.Request(ConfigTopic("lb-empty"), []byte(""), 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("Request: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
json.Unmarshal(reply.Data, &resp)
|
||||
if resp["status"] != "error" {
|
||||
t.Errorf("response status = %q, want %q for empty config", resp["status"], "error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_HandlePing(t *testing.T) {
|
||||
ns := startTestNATS(t)
|
||||
dir := t.TempDir()
|
||||
|
||||
agent, _ := New("lb-ping123", ns.ClientURL())
|
||||
agent.configPath = filepath.Join(dir, "haproxy.cfg")
|
||||
agent.pidPath = filepath.Join(dir, "haproxy.pid")
|
||||
|
||||
if err := agent.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
defer agent.Stop()
|
||||
|
||||
nc, _ := nats.Connect(ns.ClientURL())
|
||||
defer nc.Close()
|
||||
|
||||
reply, err := nc.Request(PingTopic("lb-ping123"), nil, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("Request: %v", err)
|
||||
}
|
||||
|
||||
var resp PingResponse
|
||||
if err := json.Unmarshal(reply.Data, &resp); err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if resp.Status != "ok" {
|
||||
t.Errorf("status = %q, want %q", resp.Status, "ok")
|
||||
}
|
||||
if resp.LBID != "lb-ping123" {
|
||||
t.Errorf("lb_id = %q, want %q", resp.LBID, "lb-ping123")
|
||||
}
|
||||
if resp.HAProxy {
|
||||
t.Error("haproxy_running should be false (no PID file)")
|
||||
}
|
||||
if resp.ConfigAge != -1 {
|
||||
t.Errorf("config_age = %d, want -1 (no config file)", resp.ConfigAge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_HandleConfig_ReloadError(t *testing.T) {
|
||||
ns := startTestNATS(t)
|
||||
dir := t.TempDir()
|
||||
|
||||
agent, _ := New("lb-err", ns.ClientURL())
|
||||
agent.configPath = filepath.Join(dir, "haproxy.cfg")
|
||||
agent.pidPath = filepath.Join(dir, "haproxy.pid")
|
||||
agent.reloadFn = func(_, _ string) error {
|
||||
return fmt.Errorf("haproxy binary not found")
|
||||
}
|
||||
|
||||
if err := agent.Start(); err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
defer agent.Stop()
|
||||
|
||||
nc, _ := nats.Connect(ns.ClientURL())
|
||||
defer nc.Close()
|
||||
|
||||
reply, err := nc.Request(ConfigTopic("lb-err"), []byte("some config"), 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("Request: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
json.Unmarshal(reply.Data, &resp)
|
||||
if resp["status"] != "error" {
|
||||
t.Errorf("response status = %q, want %q", resp["status"], "error")
|
||||
}
|
||||
if resp["error"] == "" {
|
||||
t.Error("expected error message in response")
|
||||
}
|
||||
}
|
||||
@@ -186,10 +186,20 @@ func setFieldValue(field reflect.Value, value string) error {
|
||||
}
|
||||
|
||||
func setSliceField(field reflect.Value, params map[string]string, prefix string) error {
|
||||
// Find all indexed items for this slice
|
||||
// Find all indexed items for this slice.
|
||||
// Supports both EC2-style (Prefix.N) and IAM/ELBv2-style (Prefix.member.N) formats.
|
||||
indices := make(map[int]bool)
|
||||
useMember := false
|
||||
for key := range params {
|
||||
if strings.HasPrefix(key, prefix+".") {
|
||||
if strings.HasPrefix(key, prefix+".member.") {
|
||||
parts := strings.Split(key[len(prefix)+len(".member."):], ".")
|
||||
if len(parts) > 0 {
|
||||
if idx, err := strconv.Atoi(parts[0]); err == nil {
|
||||
indices[idx] = true
|
||||
useMember = true
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(key, prefix+".") {
|
||||
parts := strings.Split(key[len(prefix)+1:], ".")
|
||||
if len(parts) > 0 {
|
||||
if idx, err := strconv.Atoi(parts[0]); err == nil {
|
||||
@@ -221,7 +231,12 @@ func setSliceField(field reflect.Value, params map[string]string, prefix string)
|
||||
}
|
||||
|
||||
elem := slice.Index(idx - 1)
|
||||
indexPrefix := fmt.Sprintf("%s.%d", prefix, idx)
|
||||
var indexPrefix string
|
||||
if useMember {
|
||||
indexPrefix = fmt.Sprintf("%s.member.%d", prefix, idx)
|
||||
} else {
|
||||
indexPrefix = fmt.Sprintf("%s.%d", prefix, idx)
|
||||
}
|
||||
|
||||
// Handle different element types
|
||||
switch elemType.Kind() {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -509,3 +510,81 @@ func TestQueryParamsToStruct_BlockDeviceMappingsWithIOPS(t *testing.T) {
|
||||
assert.Equal(t, int64(500), aws.Int64Value(bdm.Ebs.Throughput))
|
||||
assert.Equal(t, "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012", aws.StringValue(bdm.Ebs.KmsKeyId))
|
||||
}
|
||||
|
||||
// --- ELBv2 member-style list tests ---
|
||||
|
||||
func TestQueryParamsToStruct_ELBv2RegisterTargets(t *testing.T) {
|
||||
// ELBv2 uses Targets.member.N.Id format (IAM-style query protocol)
|
||||
args := map[string]string{
|
||||
"Action": "RegisterTargets",
|
||||
"TargetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:000000000001:targetgroup/my-tg/tg-abc123",
|
||||
"Targets.member.1.Id": "i-1234567890abcdef0",
|
||||
"Targets.member.2.Id": "i-0987654321fedcba0",
|
||||
}
|
||||
|
||||
input := &elbv2.RegisterTargetsInput{}
|
||||
err := QueryParamsToStruct(args, input)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "arn:aws:elasticloadbalancing:us-east-1:000000000001:targetgroup/my-tg/tg-abc123", aws.StringValue(input.TargetGroupArn))
|
||||
assert.Len(t, input.Targets, 2)
|
||||
assert.Equal(t, "i-1234567890abcdef0", aws.StringValue(input.Targets[0].Id))
|
||||
assert.Equal(t, "i-0987654321fedcba0", aws.StringValue(input.Targets[1].Id))
|
||||
}
|
||||
|
||||
func TestQueryParamsToStruct_ELBv2RegisterTargetsWithPort(t *testing.T) {
|
||||
args := map[string]string{
|
||||
"Action": "RegisterTargets",
|
||||
"TargetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:000000000001:targetgroup/my-tg/tg-abc123",
|
||||
"Targets.member.1.Id": "i-1234567890abcdef0",
|
||||
"Targets.member.1.Port": "8080",
|
||||
}
|
||||
|
||||
input := &elbv2.RegisterTargetsInput{}
|
||||
err := QueryParamsToStruct(args, input)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, input.Targets, 1)
|
||||
assert.Equal(t, "i-1234567890abcdef0", aws.StringValue(input.Targets[0].Id))
|
||||
assert.Equal(t, int64(8080), aws.Int64Value(input.Targets[0].Port))
|
||||
}
|
||||
|
||||
func TestQueryParamsToStruct_ELBv2CreateLoadBalancerMemberSubnets(t *testing.T) {
|
||||
// ELBv2 uses Subnets.member.N format for string slices
|
||||
args := map[string]string{
|
||||
"Action": "CreateLoadBalancer",
|
||||
"Name": "my-alb",
|
||||
"Subnets.member.1": "subnet-abc123",
|
||||
"Subnets.member.2": "subnet-def456",
|
||||
}
|
||||
|
||||
input := &elbv2.CreateLoadBalancerInput{}
|
||||
err := QueryParamsToStruct(args, input)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "my-alb", aws.StringValue(input.Name))
|
||||
assert.Len(t, input.Subnets, 2)
|
||||
assert.Equal(t, "subnet-abc123", aws.StringValue(input.Subnets[0]))
|
||||
assert.Equal(t, "subnet-def456", aws.StringValue(input.Subnets[1]))
|
||||
}
|
||||
|
||||
func TestQueryParamsToStruct_ELBv2CreateListenerWithActions(t *testing.T) {
|
||||
args := map[string]string{
|
||||
"Action": "CreateListener",
|
||||
"LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:000000000001:loadbalancer/app/my-alb/lb-abc123",
|
||||
"Protocol": "HTTP",
|
||||
"Port": "80",
|
||||
"DefaultActions.member.1.Type": "forward",
|
||||
"DefaultActions.member.1.TargetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:000000000001:targetgroup/my-tg/tg-abc123",
|
||||
}
|
||||
|
||||
input := &elbv2.CreateListenerInput{}
|
||||
err := QueryParamsToStruct(args, input)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "HTTP", aws.StringValue(input.Protocol))
|
||||
assert.Equal(t, int64(80), aws.Int64Value(input.Port))
|
||||
assert.Len(t, input.DefaultActions, 1)
|
||||
assert.Equal(t, "forward", aws.StringValue(input.DefaultActions[0].Type))
|
||||
assert.Equal(t, "arn:aws:elasticloadbalancing:us-east-1:000000000001:targetgroup/my-tg/tg-abc123", aws.StringValue(input.DefaultActions[0].TargetGroupArn))
|
||||
}
|
||||
|
||||
@@ -432,6 +432,24 @@ var (
|
||||
ErrorIAMInvalidInput = ErrorInvalidInput // alias for IAM usage
|
||||
ErrorIAMMalformedPolicyDocument = "MalformedPolicyDocument"
|
||||
ErrorAccessDenied = "AccessDenied"
|
||||
|
||||
// ELBv2-specific error codes
|
||||
ErrorELBv2LoadBalancerNotFound = "LoadBalancerNotFound"
|
||||
ErrorELBv2TargetGroupNotFound = "TargetGroupNotFound"
|
||||
ErrorELBv2ListenerNotFound = "ListenerNotFound"
|
||||
ErrorELBv2DuplicateLoadBalancer = "DuplicateLoadBalancerName"
|
||||
ErrorELBv2DuplicateTargetGroup = "DuplicateTargetGroupName"
|
||||
ErrorELBv2DuplicateListener = "DuplicateListener"
|
||||
ErrorELBv2TooManyLoadBalancers = "TooManyLoadBalancers"
|
||||
ErrorELBv2TooManyTargetGroups = "TooManyTargetGroups"
|
||||
ErrorELBv2TooManyListeners = "TooManyListeners"
|
||||
ErrorELBv2TooManyTargets = "TooManyRegistrationsForTargetId"
|
||||
ErrorELBv2InvalidTarget = "InvalidTarget"
|
||||
ErrorELBv2TargetGroupInUse = "ResourceInUse"
|
||||
ErrorELBv2InvalidSecurityGroup = "InvalidSecurityGroup"
|
||||
ErrorELBv2InvalidScheme = "InvalidScheme"
|
||||
ErrorELBv2SubnetNotFound = "SubnetNotFound"
|
||||
ErrorELBv2AvailabilityZoneNotSupported = "AvailabilityZoneNotSupported"
|
||||
)
|
||||
|
||||
// ValidErrorCode returns the error code if it exists in ErrorLookup,
|
||||
@@ -870,4 +888,22 @@ var ErrorLookup = map[string]ErrorMessage{
|
||||
ErrorIAMLimitExceeded: {HTTPCode: 409, Message: "The request was rejected because it attempted to create resources beyond the current AWS account limits."},
|
||||
ErrorIAMMalformedPolicyDocument: {HTTPCode: 400, Message: "The policy document is malformed."},
|
||||
ErrorAccessDenied: {HTTPCode: 403, Message: "User is not authorized to perform this action."},
|
||||
|
||||
// ELBv2 error codes
|
||||
ErrorELBv2LoadBalancerNotFound: {HTTPCode: 400, Message: "One or more load balancers not found."},
|
||||
ErrorELBv2TargetGroupNotFound: {HTTPCode: 400, Message: "One or more target groups not found."},
|
||||
ErrorELBv2ListenerNotFound: {HTTPCode: 400, Message: "One or more listeners not found."},
|
||||
ErrorELBv2DuplicateLoadBalancer: {HTTPCode: 400, Message: "A load balancer with the same name already exists."},
|
||||
ErrorELBv2DuplicateTargetGroup: {HTTPCode: 400, Message: "A target group with the same name already exists."},
|
||||
ErrorELBv2DuplicateListener: {HTTPCode: 400, Message: "A listener with the specified port already exists on this load balancer."},
|
||||
ErrorELBv2TooManyLoadBalancers: {HTTPCode: 400, Message: "You've reached the limit on the number of load balancers for your account."},
|
||||
ErrorELBv2TooManyTargetGroups: {HTTPCode: 400, Message: "You've reached the limit on the number of target groups for your account."},
|
||||
ErrorELBv2TooManyListeners: {HTTPCode: 400, Message: "You've reached the limit on the number of listeners per load balancer."},
|
||||
ErrorELBv2TooManyTargets: {HTTPCode: 400, Message: "You've reached the limit on the number of times a target can be registered with a target group."},
|
||||
ErrorELBv2InvalidTarget: {HTTPCode: 400, Message: "The specified target does not exist, is not in the same VPC as the target group, or has an unsupported instance type."},
|
||||
ErrorELBv2TargetGroupInUse: {HTTPCode: 400, Message: "The target group is currently in use by a listener or rule."},
|
||||
ErrorELBv2InvalidSecurityGroup: {HTTPCode: 400, Message: "The specified security group does not exist."},
|
||||
ErrorELBv2InvalidScheme: {HTTPCode: 400, Message: "The specified scheme is not valid. Specify 'internet-facing' or 'internal'."},
|
||||
ErrorELBv2SubnetNotFound: {HTTPCode: 400, Message: "The specified subnet does not exist."},
|
||||
ErrorELBv2AvailabilityZoneNotSupported: {HTTPCode: 400, Message: "The specified Availability Zone is not supported."},
|
||||
}
|
||||
|
||||
@@ -433,6 +433,23 @@ func TestErrorLookup(t *testing.T) {
|
||||
{code: "LimitExceeded", http: 409, message: "The request was rejected because it attempted to create resources beyond the current AWS account limits."},
|
||||
{code: "MalformedPolicyDocument", http: 400, message: "The policy document is malformed."},
|
||||
{code: "AccessDenied", http: 403, message: "User is not authorized to perform this action."},
|
||||
// ELBv2 error codes
|
||||
{code: "LoadBalancerNotFound", http: 400, message: "One or more load balancers not found."},
|
||||
{code: "TargetGroupNotFound", http: 400, message: "One or more target groups not found."},
|
||||
{code: "ListenerNotFound", http: 400, message: "One or more listeners not found."},
|
||||
{code: "DuplicateLoadBalancerName", http: 400, message: "A load balancer with the same name already exists."},
|
||||
{code: "DuplicateTargetGroupName", http: 400, message: "A target group with the same name already exists."},
|
||||
{code: "DuplicateListener", http: 400, message: "A listener with the specified port already exists on this load balancer."},
|
||||
{code: "TooManyLoadBalancers", http: 400, message: "You've reached the limit on the number of load balancers for your account."},
|
||||
{code: "TooManyTargetGroups", http: 400, message: "You've reached the limit on the number of target groups for your account."},
|
||||
{code: "TooManyListeners", http: 400, message: "You've reached the limit on the number of listeners per load balancer."},
|
||||
{code: "TooManyRegistrationsForTargetId", http: 400, message: "You've reached the limit on the number of times a target can be registered with a target group."},
|
||||
{code: "InvalidTarget", http: 400, message: "The specified target does not exist, is not in the same VPC as the target group, or has an unsupported instance type."},
|
||||
{code: "ResourceInUse", http: 400, message: "The target group is currently in use by a listener or rule."},
|
||||
{code: "InvalidSecurityGroup", http: 400, message: "The specified security group does not exist."},
|
||||
{code: "InvalidScheme", http: 400, message: "The specified scheme is not valid. Specify 'internet-facing' or 'internal'."},
|
||||
{code: "SubnetNotFound", http: 400, message: "The specified subnet does not exist."},
|
||||
{code: "AvailabilityZoneNotSupported", http: 400, message: "The specified Availability Zone is not supported."},
|
||||
}
|
||||
|
||||
if len(ErrorLookup) != len(expected) {
|
||||
|
||||
+141
-9
@@ -38,11 +38,14 @@ import (
|
||||
handlers_ec2_image "github.com/mulgadc/spinifex/spinifex/handlers/ec2/image"
|
||||
handlers_ec2_instance "github.com/mulgadc/spinifex/spinifex/handlers/ec2/instance"
|
||||
handlers_ec2_key "github.com/mulgadc/spinifex/spinifex/handlers/ec2/key"
|
||||
handlers_ec2_natgw "github.com/mulgadc/spinifex/spinifex/handlers/ec2/natgw"
|
||||
handlers_ec2_placementgroup "github.com/mulgadc/spinifex/spinifex/handlers/ec2/placementgroup"
|
||||
handlers_ec2_routetable "github.com/mulgadc/spinifex/spinifex/handlers/ec2/routetable"
|
||||
handlers_ec2_snapshot "github.com/mulgadc/spinifex/spinifex/handlers/ec2/snapshot"
|
||||
handlers_ec2_tags "github.com/mulgadc/spinifex/spinifex/handlers/ec2/tags"
|
||||
handlers_ec2_volume "github.com/mulgadc/spinifex/spinifex/handlers/ec2/volume"
|
||||
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/instancetypes"
|
||||
"github.com/mulgadc/spinifex/spinifex/objectstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/qmp"
|
||||
@@ -111,6 +114,9 @@ type Daemon struct {
|
||||
placementGroupService *handlers_ec2_placementgroup.PlacementGroupServiceImpl
|
||||
vpcService *handlers_ec2_vpc.VPCServiceImpl
|
||||
eipService *handlers_ec2_eip.EIPServiceImpl
|
||||
elbv2Service *handlers_elbv2.ELBv2ServiceImpl
|
||||
routeTableService *handlers_ec2_routetable.RouteTableServiceImpl
|
||||
natGatewayService *handlers_ec2_natgw.NatGatewayServiceImpl
|
||||
externalIPAM *handlers_ec2_vpc.ExternalIPAM
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
@@ -402,6 +408,18 @@ func (d *Daemon) subscribeAll() error {
|
||||
{"ec2.RemoveInstanceFromPlacementGroup", d.handleEC2RemoveInstanceFromPlacementGroup, "spinifex-workers"},
|
||||
{"ec2.ReserveClusterNode", d.handleEC2ReserveClusterNode, "spinifex-workers"},
|
||||
{"ec2.FinalizeClusterInstances", d.handleEC2FinalizeClusterInstances, "spinifex-workers"},
|
||||
{"ec2.CreateNatGateway", d.handleEC2CreateNatGateway, "spinifex-workers"},
|
||||
{"ec2.DeleteNatGateway", d.handleEC2DeleteNatGateway, "spinifex-workers"},
|
||||
{"ec2.DescribeNatGateways", d.handleEC2DescribeNatGateways, "spinifex-workers"},
|
||||
{"ec2.CreateRouteTable", d.handleEC2CreateRouteTable, "spinifex-workers"},
|
||||
{"ec2.DeleteRouteTable", d.handleEC2DeleteRouteTable, "spinifex-workers"},
|
||||
{"ec2.DescribeRouteTables", d.handleEC2DescribeRouteTables, "spinifex-workers"},
|
||||
{"ec2.CreateRoute", d.handleEC2CreateRoute, "spinifex-workers"},
|
||||
{"ec2.DeleteRoute", d.handleEC2DeleteRoute, "spinifex-workers"},
|
||||
{"ec2.ReplaceRoute", d.handleEC2ReplaceRoute, "spinifex-workers"},
|
||||
{"ec2.AssociateRouteTable", d.handleEC2AssociateRouteTable, "spinifex-workers"},
|
||||
{"ec2.DisassociateRouteTable", d.handleEC2DisassociateRouteTable, "spinifex-workers"},
|
||||
{"ec2.ReplaceRouteTableAssociation", d.handleEC2ReplaceRouteTableAssociation, "spinifex-workers"},
|
||||
{"ec2.CreateVpc", d.handleEC2CreateVpc, "spinifex-workers"},
|
||||
{"ec2.DeleteVpc", d.handleEC2DeleteVpc, "spinifex-workers"},
|
||||
{"ec2.DescribeVpcs", d.handleEC2DescribeVpcs, "spinifex-workers"},
|
||||
@@ -409,9 +427,12 @@ func (d *Daemon) subscribeAll() error {
|
||||
{"ec2.DeleteSubnet", d.handleEC2DeleteSubnet, "spinifex-workers"},
|
||||
{"ec2.DescribeSubnets", d.handleEC2DescribeSubnets, "spinifex-workers"},
|
||||
{"ec2.ModifySubnetAttribute", d.handleEC2ModifySubnetAttribute, "spinifex-workers"},
|
||||
{"ec2.ModifyVpcAttribute", d.handleEC2ModifyVpcAttribute, "spinifex-workers"},
|
||||
{"ec2.DescribeVpcAttribute", d.handleEC2DescribeVpcAttribute, "spinifex-workers"},
|
||||
{"ec2.CreateNetworkInterface", d.handleEC2CreateNetworkInterface, "spinifex-workers"},
|
||||
{"ec2.DeleteNetworkInterface", d.handleEC2DeleteNetworkInterface, "spinifex-workers"},
|
||||
{"ec2.DescribeNetworkInterfaces", d.handleEC2DescribeNetworkInterfaces, "spinifex-workers"},
|
||||
{"ec2.ModifyNetworkInterfaceAttribute", d.handleEC2ModifyNetworkInterfaceAttribute, "spinifex-workers"},
|
||||
{"ec2.CreateSecurityGroup", d.handleEC2CreateSecurityGroup, "spinifex-workers"},
|
||||
{"ec2.DeleteSecurityGroup", d.handleEC2DeleteSecurityGroup, "spinifex-workers"},
|
||||
{"ec2.DescribeSecurityGroups", d.handleEC2DescribeSecurityGroups, "spinifex-workers"},
|
||||
@@ -419,12 +440,8 @@ func (d *Daemon) subscribeAll() error {
|
||||
{"ec2.AuthorizeSecurityGroupEgress", d.handleEC2AuthorizeSecurityGroupEgress, "spinifex-workers"},
|
||||
{"ec2.RevokeSecurityGroupIngress", d.handleEC2RevokeSecurityGroupIngress, "spinifex-workers"},
|
||||
{"ec2.RevokeSecurityGroupEgress", d.handleEC2RevokeSecurityGroupEgress, "spinifex-workers"},
|
||||
{"ec2.AllocateAddress", d.handleEC2AllocateAddress, "spinifex-workers"},
|
||||
{"ec2.ReleaseAddress", d.handleEC2ReleaseAddress, "spinifex-workers"},
|
||||
{"ec2.AssociateAddress", d.handleEC2AssociateAddress, "spinifex-workers"},
|
||||
{"ec2.DisassociateAddress", d.handleEC2DisassociateAddress, "spinifex-workers"},
|
||||
{"ec2.DescribeAddresses", d.handleEC2DescribeAddresses, "spinifex-workers"},
|
||||
{"ec2.ModifyInstanceAttribute", d.handleEC2ModifyInstanceAttribute, "spinifex-workers"},
|
||||
{"ec2.DescribeInstanceAttribute", d.handleEC2DescribeInstanceAttribute, "spinifex-workers"},
|
||||
{"ec2.start", d.handleEC2StartStoppedInstance, "spinifex-workers"},
|
||||
{"ec2.terminate", d.handleEC2TerminateStoppedInstance, "spinifex-workers"},
|
||||
{"ec2.DescribeStoppedInstances", d.handleEC2DescribeStoppedInstances, "spinifex-workers"},
|
||||
@@ -438,10 +455,24 @@ func (d *Daemon) subscribeAll() error {
|
||||
{"ec2.GetSerialConsoleAccessStatus", d.handleEC2GetSerialConsoleAccessStatus, "spinifex-workers"},
|
||||
{"ec2.EnableSerialConsoleAccess", d.handleEC2EnableSerialConsoleAccess, "spinifex-workers"},
|
||||
{"ec2.DisableSerialConsoleAccess", d.handleEC2DisableSerialConsoleAccess, "spinifex-workers"},
|
||||
// ELBv2 operations
|
||||
{"elbv2.CreateLoadBalancer", d.handleELBv2CreateLoadBalancer, "spinifex-workers"},
|
||||
{"elbv2.DeleteLoadBalancer", d.handleELBv2DeleteLoadBalancer, "spinifex-workers"},
|
||||
{"elbv2.DescribeLoadBalancers", d.handleELBv2DescribeLoadBalancers, "spinifex-workers"},
|
||||
{"elbv2.CreateTargetGroup", d.handleELBv2CreateTargetGroup, "spinifex-workers"},
|
||||
{"elbv2.DeleteTargetGroup", d.handleELBv2DeleteTargetGroup, "spinifex-workers"},
|
||||
{"elbv2.DescribeTargetGroups", d.handleELBv2DescribeTargetGroups, "spinifex-workers"},
|
||||
{"elbv2.RegisterTargets", d.handleELBv2RegisterTargets, "spinifex-workers"},
|
||||
{"elbv2.DeregisterTargets", d.handleELBv2DeregisterTargets, "spinifex-workers"},
|
||||
{"elbv2.DescribeTargetHealth", d.handleELBv2DescribeTargetHealth, "spinifex-workers"},
|
||||
{"elbv2.CreateListener", d.handleELBv2CreateListener, "spinifex-workers"},
|
||||
{"elbv2.DeleteListener", d.handleELBv2DeleteListener, "spinifex-workers"},
|
||||
{"elbv2.DescribeListeners", d.handleELBv2DescribeListeners, "spinifex-workers"},
|
||||
{fmt.Sprintf("spinifex.admin.%s.health", d.node), d.handleHealthCheck, ""},
|
||||
{"spinifex.nodes.discover", d.handleNodeDiscover, ""},
|
||||
{"spinifex.node.status", d.handleNodeStatus, ""},
|
||||
{"spinifex.node.vms", d.handleNodeVMs, ""},
|
||||
{"spinifex.storage.config", d.handleStorageConfig, ""},
|
||||
// Account creation → create default VPC for new account
|
||||
{"iam.account.created", d.handleAccountCreated, "spinifex-workers"},
|
||||
// Coordinated cluster shutdown phases (fan-out, no queue group)
|
||||
@@ -452,6 +483,19 @@ func (d *Daemon) subscribeAll() error {
|
||||
{"spinifex.cluster.shutdown.infra", d.handleShutdownInfra, ""},
|
||||
}
|
||||
|
||||
// EIP operations require external IPAM (pool mode). Only subscribe when available;
|
||||
// without a subscriber the gateway returns a NATS timeout → clean error to the client.
|
||||
if d.eipService != nil {
|
||||
subs = append(subs,
|
||||
natsSub{"ec2.AllocateAddress", d.handleEC2AllocateAddress, "spinifex-workers"},
|
||||
natsSub{"ec2.ReleaseAddress", d.handleEC2ReleaseAddress, "spinifex-workers"},
|
||||
natsSub{"ec2.AssociateAddress", d.handleEC2AssociateAddress, "spinifex-workers"},
|
||||
natsSub{"ec2.DisassociateAddress", d.handleEC2DisassociateAddress, "spinifex-workers"},
|
||||
natsSub{"ec2.DescribeAddresses", d.handleEC2DescribeAddresses, "spinifex-workers"},
|
||||
natsSub{"ec2.DescribeAddressesAttribute", d.handleEC2DescribeAddressesAttribute, "spinifex-workers"},
|
||||
)
|
||||
}
|
||||
|
||||
for _, s := range subs {
|
||||
var sub *nats.Subscription
|
||||
var err error
|
||||
@@ -547,6 +591,20 @@ func (d *Daemon) Start() error {
|
||||
return fmt.Errorf("failed to initialize VPC service: %w", err)
|
||||
}
|
||||
|
||||
d.routeTableService, err = initServiceWithRetry("RouteTable service", func() (*handlers_ec2_routetable.RouteTableServiceImpl, error) {
|
||||
return handlers_ec2_routetable.NewRouteTableServiceImplWithNATS(d.config, d.natsConn)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize RouteTable service: %w", err)
|
||||
}
|
||||
|
||||
d.natGatewayService, err = initServiceWithRetry("NatGateway service", func() (*handlers_ec2_natgw.NatGatewayServiceImpl, error) {
|
||||
return handlers_ec2_natgw.NewNatGatewayServiceImplWithNATS(d.natsConn)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize NatGateway service: %w", err)
|
||||
}
|
||||
|
||||
// Initialize external IPAM if pool mode is configured (per-VM public IPs).
|
||||
// NAT mode only uses SNAT via a shared gateway IP — no per-VM allocation needed.
|
||||
if d.clusterConfig != nil && d.clusterConfig.Network.ExternalMode == "pool" {
|
||||
@@ -601,6 +659,45 @@ func (d *Daemon) Start() error {
|
||||
return fmt.Errorf("failed to initialize account settings service: %w", err)
|
||||
}
|
||||
|
||||
d.elbv2Service, err = initServiceWithRetry("ELBv2 service", func() (*handlers_elbv2.ELBv2ServiceImpl, error) {
|
||||
return handlers_elbv2.NewELBv2ServiceImplWithNATS(d.config, d.natsConn)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize ELBv2 service: %w", err)
|
||||
}
|
||||
if d.vpcService != nil {
|
||||
d.elbv2Service.SetVPCService(d.vpcService)
|
||||
}
|
||||
|
||||
// Wire ALB VM lifecycle: instance launcher + NATS URL for cloud-init.
|
||||
// In dev mode (user-mode networking), VMs reach the host at 10.0.2.2
|
||||
// (QEMU's default gateway). Replace 0.0.0.0 with this address.
|
||||
d.elbv2Service.SetInstanceLauncher(d)
|
||||
if d.config != nil && d.config.NATS.Host != "" {
|
||||
natsHost := d.config.NATS.Host
|
||||
if d.config.Daemon.DevNetworking && strings.HasPrefix(natsHost, "0.0.0.0") {
|
||||
natsHost = strings.Replace(natsHost, "0.0.0.0", "10.0.2.2", 1)
|
||||
}
|
||||
d.elbv2Service.SetNATSURL("nats://" + natsHost)
|
||||
}
|
||||
// Discover system AMI for ALB VMs. Prefer an image with "alb" in the
|
||||
// name (pre-baked Alpine with HAProxy), fall back to first available.
|
||||
if d.imageService != nil {
|
||||
imagesOut, imgErr := d.imageService.DescribeImages(&ec2.DescribeImagesInput{}, utils.GlobalAccountID)
|
||||
if imgErr == nil && len(imagesOut.Images) > 0 {
|
||||
systemAMI := aws.StringValue(imagesOut.Images[0].ImageId)
|
||||
for _, img := range imagesOut.Images {
|
||||
name := aws.StringValue(img.Name)
|
||||
if strings.Contains(name, "alb") {
|
||||
systemAMI = aws.StringValue(img.ImageId)
|
||||
break
|
||||
}
|
||||
}
|
||||
d.elbv2Service.SetSystemAMI(systemAMI)
|
||||
slog.Info("System AMI set for ALB VMs", "amiId", systemAMI)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure default VPC exists for system and admin accounts
|
||||
// (matches AWS: every account has a default VPC with IGW + default SG)
|
||||
if d.vpcService != nil {
|
||||
@@ -1669,10 +1766,16 @@ func (d *Daemon) stopInstance(instances map[string]*vm.VM, deleteVolume bool) er
|
||||
// Publish vpc.delete-nat to remove dnat_and_snat rule
|
||||
portName := "port-" + instance.ENIId
|
||||
vpcId := ""
|
||||
if instance.Instance != nil && instance.Instance.VpcId != nil {
|
||||
vpcId = *instance.Instance.VpcId
|
||||
logicalIP := ""
|
||||
if instance.Instance != nil {
|
||||
if instance.Instance.VpcId != nil {
|
||||
vpcId = *instance.Instance.VpcId
|
||||
}
|
||||
if instance.Instance.PrivateIpAddress != nil {
|
||||
logicalIP = *instance.Instance.PrivateIpAddress
|
||||
}
|
||||
}
|
||||
d.publishNATEvent("vpc.delete-nat", vpcId, instance.PublicIP, "", portName, "")
|
||||
d.publishNATEvent("vpc.delete-nat", vpcId, instance.PublicIP, logicalIP, portName, "")
|
||||
|
||||
// Release IP back to pool
|
||||
if err := d.externalIPAM.ReleaseIP(instance.PublicIPPool, instance.PublicIP); err != nil {
|
||||
@@ -1756,6 +1859,11 @@ func (d *Daemon) setupShutdown() {
|
||||
}
|
||||
}
|
||||
|
||||
// Stop ELBv2 background goroutines
|
||||
if d.elbv2Service != nil {
|
||||
d.elbv2Service.Close()
|
||||
}
|
||||
|
||||
// Final cleanup
|
||||
for _, sub := range d.natsSubscriptions {
|
||||
// Unsubscribe from each subscription
|
||||
@@ -2208,8 +2316,32 @@ func (d *Daemon) StartInstance(instance *vm.VM) error {
|
||||
if bindIP == "" || bindIP == "0.0.0.0" {
|
||||
bindIP = "127.0.0.1"
|
||||
}
|
||||
netdevVal := fmt.Sprintf("user,id=dev0,hostfwd=tcp:%s:%s-:22", bindIP, sshDebugPort)
|
||||
// Add extra hostfwd rules for system instances (e.g. ALB VMs forwarding HTTP ports)
|
||||
if instance.ExtraHostfwd != nil {
|
||||
for guestPort := range instance.ExtraHostfwd {
|
||||
fwdAddr, fwdErr := viperblock.FindFreePort()
|
||||
if fwdErr != nil {
|
||||
slog.Warn("DEV_NETWORKING: failed to find free port for extra hostfwd", "guestPort", guestPort, "err", fwdErr)
|
||||
continue
|
||||
}
|
||||
_, hostPort, splitErr := net.SplitHostPort(fwdAddr)
|
||||
if splitErr != nil {
|
||||
slog.Warn("DEV_NETWORKING: failed to parse extra hostfwd address", "fwdAddr", fwdAddr, "err", splitErr)
|
||||
continue
|
||||
}
|
||||
hostPortInt, convErr := strconv.Atoi(hostPort)
|
||||
if convErr != nil {
|
||||
slog.Warn("DEV_NETWORKING: failed to convert extra hostfwd port", "hostPort", hostPort, "err", convErr)
|
||||
continue
|
||||
}
|
||||
netdevVal += fmt.Sprintf(",hostfwd=tcp:%s:%s-:%d", bindIP, hostPort, guestPort)
|
||||
instance.ExtraHostfwd[guestPort] = hostPortInt
|
||||
slog.Info("DEV_NETWORKING: extra hostfwd", "guestPort", guestPort, "hostPort", hostPort, "instanceId", instance.ID)
|
||||
}
|
||||
}
|
||||
instance.Config.NetDevs = append(instance.Config.NetDevs, vm.NetDev{
|
||||
Value: fmt.Sprintf("user,id=dev0,hostfwd=tcp:%s:%s-:22", bindIP, sshDebugPort),
|
||||
Value: netdevVal,
|
||||
})
|
||||
devMac := generateDevMAC(instance.ID)
|
||||
instance.Config.Devices = append(instance.Config.Devices, vm.Device{
|
||||
|
||||
@@ -21,3 +21,7 @@ func (d *Daemon) handleEC2DisassociateAddress(msg *nats.Msg) {
|
||||
func (d *Daemon) handleEC2DescribeAddresses(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eipService.DescribeAddresses)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeAddressesAttribute(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.eipService.DescribeAddressesAttribute)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package daemon
|
||||
|
||||
import "github.com/nats-io/nats.go"
|
||||
|
||||
func (d *Daemon) handleELBv2CreateLoadBalancer(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.CreateLoadBalancer)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DeleteLoadBalancer(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DeleteLoadBalancer)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DescribeLoadBalancers(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DescribeLoadBalancers)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2CreateTargetGroup(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.CreateTargetGroup)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DeleteTargetGroup(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DeleteTargetGroup)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DescribeTargetGroups(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DescribeTargetGroups)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2RegisterTargets(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.RegisterTargets)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DeregisterTargets(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DeregisterTargets)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DescribeTargetHealth(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DescribeTargetHealth)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2CreateListener(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.CreateListener)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DeleteListener(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DeleteListener)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleELBv2DescribeListeners(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.elbv2Service.DescribeListeners)
|
||||
}
|
||||
@@ -157,6 +157,20 @@ func (d *Daemon) handleEC2RunInstances(msg *nats.Msg) {
|
||||
continue
|
||||
}
|
||||
|
||||
// When Terraform sets associate_public_ip_address, it sends the subnet
|
||||
// and security groups inside NetworkInterfaces[0] instead of the top-level
|
||||
// fields. Extract them so the rest of the handler works uniformly.
|
||||
if (runInstancesInput.SubnetId == nil || *runInstancesInput.SubnetId == "") &&
|
||||
len(runInstancesInput.NetworkInterfaces) > 0 && runInstancesInput.NetworkInterfaces[0] != nil {
|
||||
nic := runInstancesInput.NetworkInterfaces[0]
|
||||
if nic.SubnetId != nil && *nic.SubnetId != "" {
|
||||
runInstancesInput.SubnetId = nic.SubnetId
|
||||
}
|
||||
if len(runInstancesInput.SecurityGroupIds) == 0 && len(nic.Groups) > 0 {
|
||||
runInstancesInput.SecurityGroupIds = nic.Groups
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve default subnet when none specified (matches AWS behavior)
|
||||
if (runInstancesInput.SubnetId == nil || *runInstancesInput.SubnetId == "") && d.vpcService != nil {
|
||||
defaultSubnet, dsErr := d.vpcService.GetDefaultSubnet(accountID)
|
||||
@@ -182,6 +196,12 @@ func (d *Daemon) handleEC2RunInstances(msg *nats.Msg) {
|
||||
eni := eniOut.NetworkInterface
|
||||
instance.ENIId = *eni.NetworkInterfaceId
|
||||
instance.ENIMac = *eni.MacAddress
|
||||
|
||||
// Mark ENI as attached to this instance so attachment.instance-id
|
||||
// filter works (used by ELBv2 RegisterTargets to resolve target IPs).
|
||||
if _, attachErr := d.vpcService.AttachENI(accountID, instance.ENIId, instance.ID, 0); attachErr != nil {
|
||||
slog.Error("Failed to attach ENI to instance record — ELBv2 target IP resolution will fail", "eniId", instance.ENIId, "instanceId", instance.ID, "err", attachErr)
|
||||
}
|
||||
ec2Instance.SetPrivateIpAddress(*eni.PrivateIpAddress)
|
||||
ec2Instance.SetSubnetId(*runInstancesInput.SubnetId)
|
||||
ec2Instance.SetVpcId(*eni.VpcId)
|
||||
@@ -977,10 +997,16 @@ func (d *Daemon) handleEC2TerminateStoppedInstance(msg *nats.Msg) {
|
||||
if instance.PublicIP != "" && instance.PublicIPPool != "" && d.externalIPAM != nil {
|
||||
portName := "port-" + instance.ENIId
|
||||
vpcId := ""
|
||||
if instance.Instance != nil && instance.Instance.VpcId != nil {
|
||||
vpcId = *instance.Instance.VpcId
|
||||
logicalIP := ""
|
||||
if instance.Instance != nil {
|
||||
if instance.Instance.VpcId != nil {
|
||||
vpcId = *instance.Instance.VpcId
|
||||
}
|
||||
if instance.Instance.PrivateIpAddress != nil {
|
||||
logicalIP = *instance.Instance.PrivateIpAddress
|
||||
}
|
||||
}
|
||||
d.publishNATEvent("vpc.delete-nat", vpcId, instance.PublicIP, "", portName, "")
|
||||
d.publishNATEvent("vpc.delete-nat", vpcId, instance.PublicIP, logicalIP, portName, "")
|
||||
|
||||
if err := d.externalIPAM.ReleaseIP(instance.PublicIPPool, instance.PublicIP); err != nil {
|
||||
slog.Warn("handleEC2TerminateStoppedInstance: failed to release public IP", "ip", instance.PublicIP, "pool", instance.PublicIPPool, "err", err)
|
||||
@@ -1142,6 +1168,18 @@ func (d *Daemon) handleEC2ModifyInstanceAttribute(msg *nats.Msg) {
|
||||
|
||||
instanceID := *input.InstanceId
|
||||
|
||||
// SourceDestCheck is a networking concept that doesn't apply to bare-metal VMs.
|
||||
// Accept the call as a no-op so Terraform and the AWS CLI don't error out.
|
||||
// Unlike InstanceType/UserData, AWS allows this on running instances, so handle
|
||||
// it before the stopped-state gate.
|
||||
if input.SourceDestCheck != nil {
|
||||
slog.Info("handleEC2ModifyInstanceAttribute: accepting SourceDestCheck (no-op on bare metal)", "instanceId", instanceID)
|
||||
if err := msg.Respond([]byte(`{}`)); err != nil {
|
||||
slog.Error("Failed to respond to NATS request", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if d.jsManager == nil {
|
||||
slog.Error("handleEC2ModifyInstanceAttribute: JetStream not available")
|
||||
respondWithError(msg, awserrors.ErrorServerInternal)
|
||||
@@ -1218,6 +1256,123 @@ func (d *Daemon) handleEC2ModifyInstanceAttribute(msg *nats.Msg) {
|
||||
}
|
||||
}
|
||||
|
||||
// handleEC2DescribeInstanceAttribute returns a single requested attribute for an instance.
|
||||
// It checks running instances first (in-memory), then falls back to stopped instances in KV.
|
||||
func (d *Daemon) handleEC2DescribeInstanceAttribute(msg *nats.Msg) {
|
||||
var input ec2.DescribeInstanceAttributeInput
|
||||
if err := json.Unmarshal(msg.Data, &input); err != nil {
|
||||
slog.Error("handleEC2DescribeInstanceAttribute: failed to unmarshal request", "err", err)
|
||||
respondWithError(msg, awserrors.ErrorServerInternal)
|
||||
return
|
||||
}
|
||||
|
||||
if input.InstanceId == nil || *input.InstanceId == "" {
|
||||
slog.Error("handleEC2DescribeInstanceAttribute: missing instance_id")
|
||||
respondWithError(msg, awserrors.ErrorMissingParameter)
|
||||
return
|
||||
}
|
||||
if input.Attribute == nil || *input.Attribute == "" {
|
||||
slog.Error("handleEC2DescribeInstanceAttribute: missing attribute")
|
||||
respondWithError(msg, awserrors.ErrorMissingParameter)
|
||||
return
|
||||
}
|
||||
|
||||
instanceID := *input.InstanceId
|
||||
attribute := *input.Attribute
|
||||
accountID := utils.AccountIDFromMsg(msg)
|
||||
|
||||
// Look up instance: running first, then stopped KV.
|
||||
var instance *vm.VM
|
||||
|
||||
d.Instances.Mu.Lock()
|
||||
if running, ok := d.Instances.VMS[instanceID]; ok {
|
||||
instance = running
|
||||
}
|
||||
d.Instances.Mu.Unlock()
|
||||
|
||||
if instance == nil {
|
||||
if d.jsManager == nil {
|
||||
slog.Error("handleEC2DescribeInstanceAttribute: JetStream not available")
|
||||
respondWithError(msg, awserrors.ErrorServerInternal)
|
||||
return
|
||||
}
|
||||
stopped, err := d.jsManager.LoadStoppedInstance(instanceID)
|
||||
if err != nil {
|
||||
slog.Error("handleEC2DescribeInstanceAttribute: failed to load stopped instance",
|
||||
"instanceId", instanceID, "err", err)
|
||||
respondWithError(msg, awserrors.ErrorServerInternal)
|
||||
return
|
||||
}
|
||||
instance = stopped
|
||||
}
|
||||
|
||||
if instance == nil {
|
||||
slog.Warn("handleEC2DescribeInstanceAttribute: instance not found",
|
||||
"instanceId", instanceID)
|
||||
respondWithError(msg, awserrors.ErrorInvalidInstanceIDNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if !checkInstanceOwnership(msg, instanceID, instance.AccountID) {
|
||||
return
|
||||
}
|
||||
|
||||
output := &ec2.DescribeInstanceAttributeOutput{
|
||||
InstanceId: &instanceID,
|
||||
}
|
||||
|
||||
switch attribute {
|
||||
case ec2.InstanceAttributeNameInstanceType:
|
||||
val := instance.InstanceType
|
||||
output.InstanceType = &ec2.AttributeValue{Value: &val}
|
||||
|
||||
case ec2.InstanceAttributeNameUserData:
|
||||
val := instance.UserData
|
||||
output.UserData = &ec2.AttributeValue{Value: &val}
|
||||
|
||||
case ec2.InstanceAttributeNameDisableApiTermination:
|
||||
val := false
|
||||
output.DisableApiTermination = &ec2.AttributeBooleanValue{Value: &val}
|
||||
|
||||
case ec2.InstanceAttributeNameDisableApiStop:
|
||||
val := false
|
||||
output.DisableApiStop = &ec2.AttributeBooleanValue{Value: &val}
|
||||
|
||||
case ec2.InstanceAttributeNameInstanceInitiatedShutdownBehavior:
|
||||
val := ec2.ShutdownBehaviorStop
|
||||
output.InstanceInitiatedShutdownBehavior = &ec2.AttributeValue{Value: &val}
|
||||
|
||||
case ec2.InstanceAttributeNameEbsOptimized:
|
||||
val := false
|
||||
output.EbsOptimized = &ec2.AttributeBooleanValue{Value: &val}
|
||||
|
||||
case ec2.InstanceAttributeNameEnaSupport:
|
||||
val := true
|
||||
output.EnaSupport = &ec2.AttributeBooleanValue{Value: &val}
|
||||
|
||||
case ec2.InstanceAttributeNameSourceDestCheck:
|
||||
val := true
|
||||
output.SourceDestCheck = &ec2.AttributeBooleanValue{Value: &val}
|
||||
|
||||
case ec2.InstanceAttributeNameGroupSet:
|
||||
if instance.Instance != nil && len(instance.Instance.SecurityGroups) > 0 {
|
||||
output.Groups = instance.Instance.SecurityGroups
|
||||
} else {
|
||||
output.Groups = []*ec2.GroupIdentifier{}
|
||||
}
|
||||
|
||||
default:
|
||||
slog.Warn("handleEC2DescribeInstanceAttribute: unsupported attribute",
|
||||
"instanceId", instanceID, "attribute", attribute)
|
||||
respondWithError(msg, awserrors.ErrorInvalidParameterValue)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("handleEC2DescribeInstanceAttribute: completed",
|
||||
"instanceId", instanceID, "attribute", attribute, "accountID", accountID)
|
||||
respondWithJSON(msg, output)
|
||||
}
|
||||
|
||||
// publishNATEvent publishes a NAT lifecycle event (vpc.add-nat or vpc.delete-nat) to NATS.
|
||||
func (d *Daemon) publishNATEvent(topic, vpcId, externalIP, logicalIP, portName, mac string) {
|
||||
if d.natsConn == nil {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func (d *Daemon) handleEC2CreateNatGateway(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.natGatewayService.CreateNatGateway)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteNatGateway(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.natGatewayService.DeleteNatGateway)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeNatGateways(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.natGatewayService.DescribeNatGateways)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func (d *Daemon) handleEC2CreateRouteTable(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.routeTableService.CreateRouteTable)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteRouteTable(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.routeTableService.DeleteRouteTable)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeRouteTables(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.routeTableService.DescribeRouteTables)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2CreateRoute(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.routeTableService.CreateRoute)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteRoute(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.routeTableService.DeleteRoute)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2ReplaceRoute(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.routeTableService.ReplaceRoute)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2AssociateRouteTable(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.routeTableService.AssociateRouteTable)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DisassociateRouteTable(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.routeTableService.DisassociateRouteTable)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2ReplaceRouteTableAssociation(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.routeTableService.ReplaceRouteTableAssociation)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/types"
|
||||
"github.com/nats-io/nats.go"
|
||||
toml "github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
// predastoreTOML is a minimal representation of the predastore config file,
|
||||
// containing only the fields needed for storage metrics (no credentials).
|
||||
type predastoreTOML struct {
|
||||
RS predastoreRS `toml:"rs"`
|
||||
DB []predastoreDBNode `toml:"db"`
|
||||
Nodes []predastoreNode `toml:"nodes"`
|
||||
Buckets []predastoreBucket `toml:"buckets"`
|
||||
}
|
||||
|
||||
type predastoreRS struct {
|
||||
Data int `toml:"data"`
|
||||
Parity int `toml:"parity"`
|
||||
}
|
||||
|
||||
type predastoreDBNode struct {
|
||||
ID int `toml:"id"`
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
}
|
||||
|
||||
type predastoreNode struct {
|
||||
ID int `toml:"id"`
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
}
|
||||
|
||||
type predastoreBucket struct {
|
||||
Name string `toml:"name"`
|
||||
Type string `toml:"type"`
|
||||
Region string `toml:"region"`
|
||||
}
|
||||
|
||||
// handleStorageConfig responds with parsed predastore config (topology only, no creds).
|
||||
// Used by the gateway to build the GetStorageStatus response.
|
||||
func (d *Daemon) handleStorageConfig(msg *nats.Msg) {
|
||||
configDir := filepath.Dir(d.configPath)
|
||||
predastorePath := filepath.Join(configDir, "predastore", "predastore.toml")
|
||||
|
||||
data, err := os.ReadFile(predastorePath)
|
||||
if err != nil {
|
||||
slog.Debug("handleStorageConfig: failed to read predastore config", "path", predastorePath, "err", err)
|
||||
respondWithError(msg, "InternalError")
|
||||
return
|
||||
}
|
||||
|
||||
var cfg predastoreTOML
|
||||
if err := toml.Unmarshal(data, &cfg); err != nil {
|
||||
slog.Error("handleStorageConfig: failed to parse predastore config", "err", err)
|
||||
respondWithError(msg, "InternalError")
|
||||
return
|
||||
}
|
||||
|
||||
resp := types.StorageConfigResponse{
|
||||
Encoding: types.StorageEncoding{
|
||||
DataShards: cfg.RS.Data,
|
||||
ParityShards: cfg.RS.Parity,
|
||||
},
|
||||
}
|
||||
|
||||
for _, db := range cfg.DB {
|
||||
resp.DBNodes = append(resp.DBNodes, types.StorageDBNode{
|
||||
ID: db.ID,
|
||||
Host: db.Host,
|
||||
Port: db.Port,
|
||||
})
|
||||
}
|
||||
if resp.DBNodes == nil {
|
||||
resp.DBNodes = []types.StorageDBNode{}
|
||||
}
|
||||
|
||||
for _, n := range cfg.Nodes {
|
||||
resp.ShardNodes = append(resp.ShardNodes, types.StorageShardNode{
|
||||
ID: n.ID,
|
||||
Host: n.Host,
|
||||
Port: n.Port,
|
||||
})
|
||||
}
|
||||
if resp.ShardNodes == nil {
|
||||
resp.ShardNodes = []types.StorageShardNode{}
|
||||
}
|
||||
|
||||
for _, b := range cfg.Buckets {
|
||||
resp.Buckets = append(resp.Buckets, types.StorageBucket{
|
||||
Name: b.Name,
|
||||
Type: b.Type,
|
||||
Region: b.Region,
|
||||
})
|
||||
}
|
||||
if resp.Buckets == nil {
|
||||
resp.Buckets = []types.StorageBucket{}
|
||||
}
|
||||
|
||||
respondWithJSON(msg, resp)
|
||||
}
|
||||
@@ -16,10 +16,13 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/config"
|
||||
handlers_ec2_account "github.com/mulgadc/spinifex/spinifex/handlers/ec2/account"
|
||||
handlers_ec2_eigw "github.com/mulgadc/spinifex/spinifex/handlers/ec2/eigw"
|
||||
handlers_ec2_eip "github.com/mulgadc/spinifex/spinifex/handlers/ec2/eip"
|
||||
handlers_ec2_igw "github.com/mulgadc/spinifex/spinifex/handlers/ec2/igw"
|
||||
handlers_ec2_image "github.com/mulgadc/spinifex/spinifex/handlers/ec2/image"
|
||||
handlers_ec2_instance "github.com/mulgadc/spinifex/spinifex/handlers/ec2/instance"
|
||||
handlers_ec2_key "github.com/mulgadc/spinifex/spinifex/handlers/ec2/key"
|
||||
handlers_ec2_placementgroup "github.com/mulgadc/spinifex/spinifex/handlers/ec2/placementgroup"
|
||||
handlers_ec2_routetable "github.com/mulgadc/spinifex/spinifex/handlers/ec2/routetable"
|
||||
handlers_ec2_snapshot "github.com/mulgadc/spinifex/spinifex/handlers/ec2/snapshot"
|
||||
handlers_ec2_tags "github.com/mulgadc/spinifex/spinifex/handlers/ec2/tags"
|
||||
handlers_ec2_volume "github.com/mulgadc/spinifex/spinifex/handlers/ec2/volume"
|
||||
@@ -1733,6 +1736,27 @@ func TestHandleEC2ModifyInstanceAttribute_ChangeUserData(t *testing.T) {
|
||||
assert.Equal(t, "IyEvYmluL2Jhc2g=", *updated.RunInstancesInput.UserData)
|
||||
}
|
||||
|
||||
func TestHandleEC2ModifyInstanceAttribute_SourceDestCheck(t *testing.T) {
|
||||
natsURL := sharedJSNATSURL
|
||||
|
||||
daemon := createFullTestDaemonWithJetStream(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.ModifyInstanceAttribute", "spinifex-workers", daemon.handleEC2ModifyInstanceAttribute)
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
// SourceDestCheck is a no-op that succeeds without requiring a stopped instance
|
||||
// in KV — Terraform sends this on running instances right after creation.
|
||||
input := &ec2.ModifyInstanceAttributeInput{
|
||||
InstanceId: aws.String("i-modify-sdc-001"),
|
||||
SourceDestCheck: &ec2.AttributeBooleanValue{Value: aws.Bool(false)},
|
||||
}
|
||||
reqData, _ := json.Marshal(input)
|
||||
reply, err := natsRequest(daemon.natsConn, "ec2.ModifyInstanceAttribute", reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `{}`, string(reply.Data))
|
||||
}
|
||||
|
||||
func TestHandleEC2ModifyInstanceAttribute_InstanceNotFound(t *testing.T) {
|
||||
natsURL := sharedJSNATSURL
|
||||
|
||||
@@ -1918,6 +1942,373 @@ func TestHandleEC2ModifyInstanceAttribute_InvalidJSON(t *testing.T) {
|
||||
assert.Equal(t, "ServerInternal", errResp["Code"])
|
||||
}
|
||||
|
||||
// --- DescribeInstanceAttribute daemon tests ---
|
||||
|
||||
func TestHandleEC2DescribeInstanceAttribute_RunningInstance_InstanceType(t *testing.T) {
|
||||
natsURL := sharedJSNATSURL
|
||||
|
||||
daemon := createFullTestDaemonWithJetStream(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeInstanceAttribute", "spinifex-workers", daemon.handleEC2DescribeInstanceAttribute)
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
instanceID := "i-describe-run-001"
|
||||
instance := &vm.VM{
|
||||
ID: instanceID,
|
||||
Status: vm.StateRunning,
|
||||
InstanceType: "t3.micro",
|
||||
AccountID: testAccountID,
|
||||
Instance: &ec2.Instance{
|
||||
InstanceId: aws.String(instanceID),
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
},
|
||||
}
|
||||
|
||||
daemon.Instances.Mu.Lock()
|
||||
daemon.Instances.VMS[instanceID] = instance
|
||||
daemon.Instances.Mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
daemon.Instances.Mu.Lock()
|
||||
delete(daemon.Instances.VMS, instanceID)
|
||||
daemon.Instances.Mu.Unlock()
|
||||
})
|
||||
|
||||
input := &ec2.DescribeInstanceAttributeInput{
|
||||
InstanceId: aws.String(instanceID),
|
||||
Attribute: aws.String(ec2.InstanceAttributeNameInstanceType),
|
||||
}
|
||||
reqData, _ := json.Marshal(input)
|
||||
reply, err := natsRequest(daemon.natsConn, "ec2.DescribeInstanceAttribute", reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
var output ec2.DescribeInstanceAttributeOutput
|
||||
err = json.Unmarshal(reply.Data, &output)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, instanceID, *output.InstanceId)
|
||||
require.NotNil(t, output.InstanceType)
|
||||
assert.Equal(t, "t3.micro", *output.InstanceType.Value)
|
||||
}
|
||||
|
||||
func TestHandleEC2DescribeInstanceAttribute_StoppedInstance_InstanceType(t *testing.T) {
|
||||
natsURL := sharedJSNATSURL
|
||||
|
||||
daemon := createFullTestDaemonWithJetStream(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeInstanceAttribute", "spinifex-workers", daemon.handleEC2DescribeInstanceAttribute)
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
instanceID := "i-describe-stop-001"
|
||||
instance := &vm.VM{
|
||||
ID: instanceID,
|
||||
Status: vm.StateStopped,
|
||||
InstanceType: "t3.medium",
|
||||
AccountID: testAccountID,
|
||||
Instance: &ec2.Instance{
|
||||
InstanceId: aws.String(instanceID),
|
||||
InstanceType: aws.String("t3.medium"),
|
||||
},
|
||||
}
|
||||
err = daemon.jsManager.WriteStoppedInstance(instanceID, instance)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = daemon.jsManager.DeleteStoppedInstance(instanceID) })
|
||||
|
||||
input := &ec2.DescribeInstanceAttributeInput{
|
||||
InstanceId: aws.String(instanceID),
|
||||
Attribute: aws.String(ec2.InstanceAttributeNameInstanceType),
|
||||
}
|
||||
reqData, _ := json.Marshal(input)
|
||||
reply, err := natsRequest(daemon.natsConn, "ec2.DescribeInstanceAttribute", reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
var output ec2.DescribeInstanceAttributeOutput
|
||||
err = json.Unmarshal(reply.Data, &output)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, instanceID, *output.InstanceId)
|
||||
require.NotNil(t, output.InstanceType)
|
||||
assert.Equal(t, "t3.medium", *output.InstanceType.Value)
|
||||
}
|
||||
|
||||
func TestHandleEC2DescribeInstanceAttribute_UserData(t *testing.T) {
|
||||
natsURL := sharedJSNATSURL
|
||||
|
||||
daemon := createFullTestDaemonWithJetStream(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeInstanceAttribute", "spinifex-workers", daemon.handleEC2DescribeInstanceAttribute)
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
instanceID := "i-describe-ud-001"
|
||||
instance := &vm.VM{
|
||||
ID: instanceID,
|
||||
Status: vm.StateStopped,
|
||||
InstanceType: "t3.micro",
|
||||
AccountID: testAccountID,
|
||||
UserData: "#!/bin/bash",
|
||||
Instance: &ec2.Instance{
|
||||
InstanceId: aws.String(instanceID),
|
||||
},
|
||||
}
|
||||
err = daemon.jsManager.WriteStoppedInstance(instanceID, instance)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = daemon.jsManager.DeleteStoppedInstance(instanceID) })
|
||||
|
||||
input := &ec2.DescribeInstanceAttributeInput{
|
||||
InstanceId: aws.String(instanceID),
|
||||
Attribute: aws.String(ec2.InstanceAttributeNameUserData),
|
||||
}
|
||||
reqData, _ := json.Marshal(input)
|
||||
reply, err := natsRequest(daemon.natsConn, "ec2.DescribeInstanceAttribute", reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
var output ec2.DescribeInstanceAttributeOutput
|
||||
err = json.Unmarshal(reply.Data, &output)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, output.UserData)
|
||||
assert.Equal(t, "#!/bin/bash", *output.UserData.Value)
|
||||
}
|
||||
|
||||
func TestHandleEC2DescribeInstanceAttribute_DefaultAttribute_DisableApiTermination(t *testing.T) {
|
||||
natsURL := sharedJSNATSURL
|
||||
|
||||
daemon := createFullTestDaemonWithJetStream(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeInstanceAttribute", "spinifex-workers", daemon.handleEC2DescribeInstanceAttribute)
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
instanceID := "i-describe-def-001"
|
||||
instance := &vm.VM{
|
||||
ID: instanceID,
|
||||
Status: vm.StateStopped,
|
||||
InstanceType: "t3.micro",
|
||||
AccountID: testAccountID,
|
||||
Instance: &ec2.Instance{
|
||||
InstanceId: aws.String(instanceID),
|
||||
},
|
||||
}
|
||||
err = daemon.jsManager.WriteStoppedInstance(instanceID, instance)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = daemon.jsManager.DeleteStoppedInstance(instanceID) })
|
||||
|
||||
input := &ec2.DescribeInstanceAttributeInput{
|
||||
InstanceId: aws.String(instanceID),
|
||||
Attribute: aws.String(ec2.InstanceAttributeNameDisableApiTermination),
|
||||
}
|
||||
reqData, _ := json.Marshal(input)
|
||||
reply, err := natsRequest(daemon.natsConn, "ec2.DescribeInstanceAttribute", reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
var output ec2.DescribeInstanceAttributeOutput
|
||||
err = json.Unmarshal(reply.Data, &output)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, output.DisableApiTermination)
|
||||
assert.Equal(t, false, *output.DisableApiTermination.Value)
|
||||
}
|
||||
|
||||
func TestHandleEC2DescribeInstanceAttribute_DefaultAttribute_ShutdownBehavior(t *testing.T) {
|
||||
natsURL := sharedJSNATSURL
|
||||
|
||||
daemon := createFullTestDaemonWithJetStream(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeInstanceAttribute", "spinifex-workers", daemon.handleEC2DescribeInstanceAttribute)
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
instanceID := "i-describe-shut-001"
|
||||
instance := &vm.VM{
|
||||
ID: instanceID,
|
||||
Status: vm.StateStopped,
|
||||
InstanceType: "t3.micro",
|
||||
AccountID: testAccountID,
|
||||
Instance: &ec2.Instance{
|
||||
InstanceId: aws.String(instanceID),
|
||||
},
|
||||
}
|
||||
err = daemon.jsManager.WriteStoppedInstance(instanceID, instance)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = daemon.jsManager.DeleteStoppedInstance(instanceID) })
|
||||
|
||||
input := &ec2.DescribeInstanceAttributeInput{
|
||||
InstanceId: aws.String(instanceID),
|
||||
Attribute: aws.String(ec2.InstanceAttributeNameInstanceInitiatedShutdownBehavior),
|
||||
}
|
||||
reqData, _ := json.Marshal(input)
|
||||
reply, err := natsRequest(daemon.natsConn, "ec2.DescribeInstanceAttribute", reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
var output ec2.DescribeInstanceAttributeOutput
|
||||
err = json.Unmarshal(reply.Data, &output)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, output.InstanceInitiatedShutdownBehavior)
|
||||
assert.Equal(t, "stop", *output.InstanceInitiatedShutdownBehavior.Value)
|
||||
}
|
||||
|
||||
func TestHandleEC2DescribeInstanceAttribute_GroupSet_WithSecurityGroups(t *testing.T) {
|
||||
natsURL := sharedJSNATSURL
|
||||
|
||||
daemon := createFullTestDaemonWithJetStream(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeInstanceAttribute", "spinifex-workers", daemon.handleEC2DescribeInstanceAttribute)
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
instanceID := "i-describe-gs-001"
|
||||
instance := &vm.VM{
|
||||
ID: instanceID,
|
||||
Status: vm.StateRunning,
|
||||
InstanceType: "t3.micro",
|
||||
AccountID: testAccountID,
|
||||
Instance: &ec2.Instance{
|
||||
InstanceId: aws.String(instanceID),
|
||||
SecurityGroups: []*ec2.GroupIdentifier{
|
||||
{GroupId: aws.String("sg-111"), GroupName: aws.String("default")},
|
||||
{GroupId: aws.String("sg-222"), GroupName: aws.String("web")},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
daemon.Instances.Mu.Lock()
|
||||
daemon.Instances.VMS[instanceID] = instance
|
||||
daemon.Instances.Mu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
daemon.Instances.Mu.Lock()
|
||||
delete(daemon.Instances.VMS, instanceID)
|
||||
daemon.Instances.Mu.Unlock()
|
||||
})
|
||||
|
||||
input := &ec2.DescribeInstanceAttributeInput{
|
||||
InstanceId: aws.String(instanceID),
|
||||
Attribute: aws.String(ec2.InstanceAttributeNameGroupSet),
|
||||
}
|
||||
reqData, _ := json.Marshal(input)
|
||||
reply, err := natsRequest(daemon.natsConn, "ec2.DescribeInstanceAttribute", reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
var output ec2.DescribeInstanceAttributeOutput
|
||||
err = json.Unmarshal(reply.Data, &output)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, instanceID, *output.InstanceId)
|
||||
require.Len(t, output.Groups, 2)
|
||||
assert.Equal(t, "sg-111", *output.Groups[0].GroupId)
|
||||
assert.Equal(t, "sg-222", *output.Groups[1].GroupId)
|
||||
}
|
||||
|
||||
func TestHandleEC2DescribeInstanceAttribute_GroupSet_NilInstance(t *testing.T) {
|
||||
natsURL := sharedJSNATSURL
|
||||
|
||||
daemon := createFullTestDaemonWithJetStream(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeInstanceAttribute", "spinifex-workers", daemon.handleEC2DescribeInstanceAttribute)
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
instanceID := "i-describe-gs-nil-001"
|
||||
instance := &vm.VM{
|
||||
ID: instanceID,
|
||||
Status: vm.StateStopped,
|
||||
InstanceType: "t3.micro",
|
||||
AccountID: testAccountID,
|
||||
Instance: nil,
|
||||
}
|
||||
err = daemon.jsManager.WriteStoppedInstance(instanceID, instance)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = daemon.jsManager.DeleteStoppedInstance(instanceID) })
|
||||
|
||||
input := &ec2.DescribeInstanceAttributeInput{
|
||||
InstanceId: aws.String(instanceID),
|
||||
Attribute: aws.String(ec2.InstanceAttributeNameGroupSet),
|
||||
}
|
||||
reqData, _ := json.Marshal(input)
|
||||
reply, err := natsRequest(daemon.natsConn, "ec2.DescribeInstanceAttribute", reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
var output ec2.DescribeInstanceAttributeOutput
|
||||
err = json.Unmarshal(reply.Data, &output)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, instanceID, *output.InstanceId)
|
||||
require.NotNil(t, output.Groups, "Groups should be empty slice, not nil")
|
||||
assert.Empty(t, output.Groups)
|
||||
}
|
||||
|
||||
func TestHandleEC2DescribeInstanceAttribute_InstanceNotFound(t *testing.T) {
|
||||
natsURL := sharedJSNATSURL
|
||||
|
||||
daemon := createFullTestDaemonWithJetStream(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeInstanceAttribute", "spinifex-workers", daemon.handleEC2DescribeInstanceAttribute)
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
input := &ec2.DescribeInstanceAttributeInput{
|
||||
InstanceId: aws.String("i-nonexistent"),
|
||||
Attribute: aws.String(ec2.InstanceAttributeNameInstanceType),
|
||||
}
|
||||
reqData, _ := json.Marshal(input)
|
||||
reply, err := natsRequest(daemon.natsConn, "ec2.DescribeInstanceAttribute", reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
var errResp map[string]any
|
||||
err = json.Unmarshal(reply.Data, &errResp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "InvalidInstanceID.NotFound", errResp["Code"])
|
||||
}
|
||||
|
||||
func TestHandleEC2DescribeInstanceAttribute_UnsupportedAttribute(t *testing.T) {
|
||||
natsURL := sharedJSNATSURL
|
||||
|
||||
daemon := createFullTestDaemonWithJetStream(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeInstanceAttribute", "spinifex-workers", daemon.handleEC2DescribeInstanceAttribute)
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
instanceID := "i-describe-unsup-001"
|
||||
instance := &vm.VM{
|
||||
ID: instanceID,
|
||||
Status: vm.StateStopped,
|
||||
AccountID: testAccountID,
|
||||
Instance: &ec2.Instance{
|
||||
InstanceId: aws.String(instanceID),
|
||||
},
|
||||
}
|
||||
err = daemon.jsManager.WriteStoppedInstance(instanceID, instance)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = daemon.jsManager.DeleteStoppedInstance(instanceID) })
|
||||
|
||||
input := &ec2.DescribeInstanceAttributeInput{
|
||||
InstanceId: aws.String(instanceID),
|
||||
Attribute: aws.String(ec2.InstanceAttributeNameBlockDeviceMapping),
|
||||
}
|
||||
reqData, _ := json.Marshal(input)
|
||||
reply, err := natsRequest(daemon.natsConn, "ec2.DescribeInstanceAttribute", reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
var errResp map[string]any
|
||||
err = json.Unmarshal(reply.Data, &errResp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "InvalidParameterValue", errResp["Code"])
|
||||
}
|
||||
|
||||
func TestHandleEC2DescribeInstanceAttribute_InvalidJSON(t *testing.T) {
|
||||
natsURL := sharedJSNATSURL
|
||||
|
||||
daemon := createFullTestDaemonWithJetStream(t, natsURL)
|
||||
|
||||
sub, err := daemon.natsConn.QueueSubscribe("ec2.DescribeInstanceAttribute", "spinifex-workers", daemon.handleEC2DescribeInstanceAttribute)
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
reply, err := daemon.natsConn.Request("ec2.DescribeInstanceAttribute", []byte(`{invalid`), 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
var errResp map[string]any
|
||||
err = json.Unmarshal(reply.Data, &errResp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "ServerInternal", errResp["Code"])
|
||||
}
|
||||
|
||||
// --- Delegate handler round-trip tests (table-driven) ---
|
||||
// Each of these handlers is a single line delegating to handleNATSRequest.
|
||||
// This test verifies the wiring is correct by sending a NATS request and
|
||||
@@ -3482,3 +3873,505 @@ func TestHandleEC2TerminateStoppedInstance_WritesToTerminatedKV(t *testing.T) {
|
||||
|
||||
_ = daemon.jsManager.DeleteTerminatedInstance(stoppedVM.ID)
|
||||
}
|
||||
|
||||
// --- Bead 5: EIP daemon handler tests ---
|
||||
|
||||
func TestDelegateHandlers_EIP(t *testing.T) {
|
||||
daemon := createVPCTestDaemon(t)
|
||||
|
||||
ns, err := server.NewServer(&server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
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() })
|
||||
|
||||
js, err := nc.JetStream()
|
||||
require.NoError(t, err)
|
||||
ipam, err := handlers_ec2_vpc.NewExternalIPAM(js, []handlers_ec2_vpc.ExternalPoolConfig{
|
||||
{Name: "test-pool", RangeStart: "192.168.100.2", RangeEnd: "192.168.100.254", Gateway: "192.168.100.1", PrefixLen: 24},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
eipSvc, err := handlers_ec2_eip.NewEIPServiceImpl(nc, ipam, daemon.vpcService)
|
||||
require.NoError(t, err)
|
||||
daemon.eipService = eipSvc
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
topic string
|
||||
handler func(*nats.Msg)
|
||||
input any
|
||||
}{
|
||||
{
|
||||
"AllocateAddress",
|
||||
"ec2.test.AllocateAddress",
|
||||
daemon.handleEC2AllocateAddress,
|
||||
&ec2.AllocateAddressInput{},
|
||||
},
|
||||
{
|
||||
"ReleaseAddress",
|
||||
"ec2.test.ReleaseAddress",
|
||||
daemon.handleEC2ReleaseAddress,
|
||||
&ec2.ReleaseAddressInput{AllocationId: aws.String("eipalloc-nonexistent")},
|
||||
},
|
||||
{
|
||||
"AssociateAddress",
|
||||
"ec2.test.AssociateAddress",
|
||||
daemon.handleEC2AssociateAddress,
|
||||
&ec2.AssociateAddressInput{AllocationId: aws.String("eipalloc-nonexistent")},
|
||||
},
|
||||
{
|
||||
"DisassociateAddress",
|
||||
"ec2.test.DisassociateAddress",
|
||||
daemon.handleEC2DisassociateAddress,
|
||||
&ec2.DisassociateAddressInput{AssociationId: aws.String("eipassoc-nonexistent")},
|
||||
},
|
||||
{
|
||||
"DescribeAddresses",
|
||||
"ec2.test.DescribeAddresses",
|
||||
daemon.handleEC2DescribeAddresses,
|
||||
&ec2.DescribeAddressesInput{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sub, err := daemon.natsConn.QueueSubscribe(tt.topic, "spinifex-workers", tt.handler)
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
reqData, err := json.Marshal(tt.input)
|
||||
require.NoError(t, err)
|
||||
|
||||
reply, err := natsRequest(daemon.natsConn, tt.topic, reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, reply)
|
||||
|
||||
var resp json.RawMessage
|
||||
err = json.Unmarshal(reply.Data, &resp)
|
||||
require.NoError(t, err, "EIP response should be valid JSON: %s", string(reply.Data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bead 6: Security Group daemon handler tests ---
|
||||
|
||||
func TestDelegateHandlers_SecurityGroup(t *testing.T) {
|
||||
daemon := createVPCTestDaemon(t)
|
||||
|
||||
// Create a VPC first so SG operations have a valid target
|
||||
createVpcSub, err := daemon.natsConn.QueueSubscribe("ec2.CreateVpc", "spinifex-workers", daemon.handleEC2CreateVpc)
|
||||
require.NoError(t, err)
|
||||
defer createVpcSub.Unsubscribe()
|
||||
|
||||
vpcInput := &ec2.CreateVpcInput{CidrBlock: aws.String("10.50.0.0/16")}
|
||||
reqData, _ := json.Marshal(vpcInput)
|
||||
reply, err := natsRequest(daemon.natsConn, "ec2.CreateVpc", reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
var vpcOut ec2.CreateVpcOutput
|
||||
require.NoError(t, json.Unmarshal(reply.Data, &vpcOut))
|
||||
vpcID := *vpcOut.Vpc.VpcId
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
topic string
|
||||
handler func(*nats.Msg)
|
||||
input any
|
||||
}{
|
||||
{
|
||||
"CreateSecurityGroup",
|
||||
"ec2.test.CreateSecurityGroup",
|
||||
daemon.handleEC2CreateSecurityGroup,
|
||||
&ec2.CreateSecurityGroupInput{
|
||||
GroupName: aws.String("test-sg"),
|
||||
Description: aws.String("test security group"),
|
||||
VpcId: aws.String(vpcID),
|
||||
},
|
||||
},
|
||||
{
|
||||
"DescribeSecurityGroups",
|
||||
"ec2.test.DescribeSecurityGroups",
|
||||
daemon.handleEC2DescribeSecurityGroups,
|
||||
&ec2.DescribeSecurityGroupsInput{},
|
||||
},
|
||||
{
|
||||
"AuthorizeSecurityGroupIngress",
|
||||
"ec2.test.AuthorizeSecurityGroupIngress",
|
||||
daemon.handleEC2AuthorizeSecurityGroupIngress,
|
||||
&ec2.AuthorizeSecurityGroupIngressInput{GroupId: aws.String("sg-nonexistent")},
|
||||
},
|
||||
{
|
||||
"AuthorizeSecurityGroupEgress",
|
||||
"ec2.test.AuthorizeSecurityGroupEgress",
|
||||
daemon.handleEC2AuthorizeSecurityGroupEgress,
|
||||
&ec2.AuthorizeSecurityGroupEgressInput{GroupId: aws.String("sg-nonexistent")},
|
||||
},
|
||||
{
|
||||
"RevokeSecurityGroupIngress",
|
||||
"ec2.test.RevokeSecurityGroupIngress",
|
||||
daemon.handleEC2RevokeSecurityGroupIngress,
|
||||
&ec2.RevokeSecurityGroupIngressInput{GroupId: aws.String("sg-nonexistent")},
|
||||
},
|
||||
{
|
||||
"RevokeSecurityGroupEgress",
|
||||
"ec2.test.RevokeSecurityGroupEgress",
|
||||
daemon.handleEC2RevokeSecurityGroupEgress,
|
||||
&ec2.RevokeSecurityGroupEgressInput{GroupId: aws.String("sg-nonexistent")},
|
||||
},
|
||||
{
|
||||
"DeleteSecurityGroup",
|
||||
"ec2.test.DeleteSecurityGroup",
|
||||
daemon.handleEC2DeleteSecurityGroup,
|
||||
&ec2.DeleteSecurityGroupInput{GroupId: aws.String("sg-nonexistent")},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sub, err := daemon.natsConn.QueueSubscribe(tt.topic, "spinifex-workers", tt.handler)
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
reqData, err := json.Marshal(tt.input)
|
||||
require.NoError(t, err)
|
||||
|
||||
reply, err := natsRequest(daemon.natsConn, tt.topic, reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, reply)
|
||||
|
||||
var resp json.RawMessage
|
||||
err = json.Unmarshal(reply.Data, &resp)
|
||||
require.NoError(t, err, "SG response should be valid JSON: %s", string(reply.Data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bead 7: Route Table daemon handler tests ---
|
||||
|
||||
func TestDelegateHandlers_RouteTable(t *testing.T) {
|
||||
daemon := createVPCTestDaemon(t)
|
||||
|
||||
// Route table service needs its own JetStream for KV buckets
|
||||
ns, err := server.NewServer(&server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
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() })
|
||||
|
||||
rtbSvc, err := handlers_ec2_routetable.NewRouteTableServiceImplWithNATS(daemon.config, nc)
|
||||
require.NoError(t, err)
|
||||
daemon.routeTableService = rtbSvc
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
topic string
|
||||
handler func(*nats.Msg)
|
||||
input any
|
||||
}{
|
||||
{
|
||||
"CreateRouteTable",
|
||||
"ec2.test.CreateRouteTable",
|
||||
daemon.handleEC2CreateRouteTable,
|
||||
&ec2.CreateRouteTableInput{VpcId: aws.String("vpc-nonexistent")},
|
||||
},
|
||||
{
|
||||
"DeleteRouteTable",
|
||||
"ec2.test.DeleteRouteTable",
|
||||
daemon.handleEC2DeleteRouteTable,
|
||||
&ec2.DeleteRouteTableInput{RouteTableId: aws.String("rtb-nonexistent")},
|
||||
},
|
||||
{
|
||||
"DescribeRouteTables",
|
||||
"ec2.test.DescribeRouteTables",
|
||||
daemon.handleEC2DescribeRouteTables,
|
||||
&ec2.DescribeRouteTablesInput{},
|
||||
},
|
||||
{
|
||||
"CreateRoute",
|
||||
"ec2.test.CreateRoute",
|
||||
daemon.handleEC2CreateRoute,
|
||||
&ec2.CreateRouteInput{RouteTableId: aws.String("rtb-nonexistent")},
|
||||
},
|
||||
{
|
||||
"DeleteRoute",
|
||||
"ec2.test.DeleteRoute",
|
||||
daemon.handleEC2DeleteRoute,
|
||||
&ec2.DeleteRouteInput{RouteTableId: aws.String("rtb-nonexistent")},
|
||||
},
|
||||
{
|
||||
"ReplaceRoute",
|
||||
"ec2.test.ReplaceRoute",
|
||||
daemon.handleEC2ReplaceRoute,
|
||||
&ec2.ReplaceRouteInput{RouteTableId: aws.String("rtb-nonexistent")},
|
||||
},
|
||||
{
|
||||
"AssociateRouteTable",
|
||||
"ec2.test.AssociateRouteTable",
|
||||
daemon.handleEC2AssociateRouteTable,
|
||||
&ec2.AssociateRouteTableInput{RouteTableId: aws.String("rtb-nonexistent")},
|
||||
},
|
||||
{
|
||||
"DisassociateRouteTable",
|
||||
"ec2.test.DisassociateRouteTable",
|
||||
daemon.handleEC2DisassociateRouteTable,
|
||||
&ec2.DisassociateRouteTableInput{AssociationId: aws.String("rtbassoc-nonexistent")},
|
||||
},
|
||||
{
|
||||
"ReplaceRouteTableAssociation",
|
||||
"ec2.test.ReplaceRouteTableAssociation",
|
||||
daemon.handleEC2ReplaceRouteTableAssociation,
|
||||
&ec2.ReplaceRouteTableAssociationInput{
|
||||
AssociationId: aws.String("rtbassoc-nonexistent"),
|
||||
RouteTableId: aws.String("rtb-nonexistent"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sub, err := daemon.natsConn.QueueSubscribe(tt.topic, "spinifex-workers", tt.handler)
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
reqData, err := json.Marshal(tt.input)
|
||||
require.NoError(t, err)
|
||||
|
||||
reply, err := natsRequest(daemon.natsConn, tt.topic, reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, reply)
|
||||
|
||||
var resp json.RawMessage
|
||||
err = json.Unmarshal(reply.Data, &resp)
|
||||
require.NoError(t, err, "RouteTable response should be valid JSON: %s", string(reply.Data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bead 8: Placement Group daemon handler tests ---
|
||||
|
||||
func TestDelegateHandlers_PlacementGroup(t *testing.T) {
|
||||
daemon := createTestDaemon(t, sharedNATSURL)
|
||||
|
||||
ns, err := server.NewServer(&server.Options{
|
||||
Host: "127.0.0.1",
|
||||
Port: -1,
|
||||
JetStream: true,
|
||||
StoreDir: t.TempDir(),
|
||||
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() })
|
||||
|
||||
pgSvc, err := handlers_ec2_placementgroup.NewPlacementGroupServiceImplWithNATS(daemon.config, nc)
|
||||
require.NoError(t, err)
|
||||
daemon.placementGroupService = pgSvc
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
topic string
|
||||
handler func(*nats.Msg)
|
||||
input any
|
||||
}{
|
||||
{
|
||||
"CreatePlacementGroup",
|
||||
"ec2.test.CreatePlacementGroup",
|
||||
daemon.handleEC2CreatePlacementGroup,
|
||||
&ec2.CreatePlacementGroupInput{
|
||||
GroupName: aws.String("test-pg"),
|
||||
Strategy: aws.String("spread"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"DescribePlacementGroups",
|
||||
"ec2.test.DescribePlacementGroups",
|
||||
daemon.handleEC2DescribePlacementGroups,
|
||||
&ec2.DescribePlacementGroupsInput{},
|
||||
},
|
||||
{
|
||||
"DeletePlacementGroup",
|
||||
"ec2.test.DeletePlacementGroup",
|
||||
daemon.handleEC2DeletePlacementGroup,
|
||||
&ec2.DeletePlacementGroupInput{GroupName: aws.String("pg-nonexistent")},
|
||||
},
|
||||
{
|
||||
"ReserveSpreadNodes",
|
||||
"ec2.test.ReserveSpreadNodes",
|
||||
daemon.handleEC2ReserveSpreadNodes,
|
||||
&handlers_ec2_placementgroup.ReserveSpreadNodesInput{
|
||||
GroupName: "pg-nonexistent",
|
||||
EligibleNodes: []string{"node-1"},
|
||||
MinCount: 1,
|
||||
MaxCount: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
"FinalizeSpreadInstances",
|
||||
"ec2.test.FinalizeSpreadInstances",
|
||||
daemon.handleEC2FinalizeSpreadInstances,
|
||||
&handlers_ec2_placementgroup.FinalizeSpreadInstancesInput{
|
||||
GroupName: "pg-nonexistent",
|
||||
NodeInstances: map[string][]string{"node-1": {"i-123"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"ReleaseSpreadNodes",
|
||||
"ec2.test.ReleaseSpreadNodes",
|
||||
daemon.handleEC2ReleaseSpreadNodes,
|
||||
&handlers_ec2_placementgroup.ReleaseSpreadNodesInput{
|
||||
GroupName: "pg-nonexistent",
|
||||
Nodes: []string{"node-1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"RemoveInstanceFromPlacementGroup",
|
||||
"ec2.test.RemoveInstanceFromPlacementGroup",
|
||||
daemon.handleEC2RemoveInstanceFromPlacementGroup,
|
||||
&handlers_ec2_placementgroup.RemoveInstanceInput{
|
||||
GroupName: "pg-nonexistent",
|
||||
NodeName: "node-1",
|
||||
InstanceID: "i-123",
|
||||
},
|
||||
},
|
||||
{
|
||||
"ReserveClusterNode",
|
||||
"ec2.test.ReserveClusterNode",
|
||||
daemon.handleEC2ReserveClusterNode,
|
||||
&handlers_ec2_placementgroup.ReserveClusterNodeInput{
|
||||
GroupName: "pg-nonexistent",
|
||||
EligibleNodes: []string{"node-1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"FinalizeClusterInstances",
|
||||
"ec2.test.FinalizeClusterInstances",
|
||||
daemon.handleEC2FinalizeClusterInstances,
|
||||
&handlers_ec2_placementgroup.FinalizeClusterInstancesInput{
|
||||
GroupName: "pg-nonexistent",
|
||||
NodeInstances: map[string][]string{"node-1": {"i-123"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sub, err := daemon.natsConn.QueueSubscribe(tt.topic, "spinifex-workers", tt.handler)
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
reqData, err := json.Marshal(tt.input)
|
||||
require.NoError(t, err)
|
||||
|
||||
reply, err := natsRequest(daemon.natsConn, tt.topic, reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, reply)
|
||||
|
||||
var resp json.RawMessage
|
||||
err = json.Unmarshal(reply.Data, &resp)
|
||||
require.NoError(t, err, "PlacementGroup response should be valid JSON: %s", string(reply.Data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bead 9: VPC attribute daemon handler tests (untested handlers) ---
|
||||
|
||||
func TestDelegateHandlers_VPCAttributes(t *testing.T) {
|
||||
daemon := createVPCTestDaemon(t)
|
||||
|
||||
// Create a VPC first
|
||||
createVpcSub, err := daemon.natsConn.QueueSubscribe("ec2.CreateVpc", "spinifex-workers", daemon.handleEC2CreateVpc)
|
||||
require.NoError(t, err)
|
||||
defer createVpcSub.Unsubscribe()
|
||||
|
||||
vpcInput := &ec2.CreateVpcInput{CidrBlock: aws.String("10.60.0.0/16")}
|
||||
reqData, _ := json.Marshal(vpcInput)
|
||||
reply, err := natsRequest(daemon.natsConn, "ec2.CreateVpc", reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
var vpcOut ec2.CreateVpcOutput
|
||||
require.NoError(t, json.Unmarshal(reply.Data, &vpcOut))
|
||||
vpcID := *vpcOut.Vpc.VpcId
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
topic string
|
||||
handler func(*nats.Msg)
|
||||
input any
|
||||
}{
|
||||
{
|
||||
"ModifySubnetAttribute",
|
||||
"ec2.test.ModifySubnetAttribute",
|
||||
daemon.handleEC2ModifySubnetAttribute,
|
||||
&ec2.ModifySubnetAttributeInput{SubnetId: aws.String("subnet-nonexistent")},
|
||||
},
|
||||
{
|
||||
"ModifyVpcAttribute",
|
||||
"ec2.test.ModifyVpcAttribute",
|
||||
daemon.handleEC2ModifyVpcAttribute,
|
||||
&ec2.ModifyVpcAttributeInput{VpcId: aws.String(vpcID)},
|
||||
},
|
||||
{
|
||||
"DescribeVpcAttribute",
|
||||
"ec2.test.DescribeVpcAttribute",
|
||||
daemon.handleEC2DescribeVpcAttribute,
|
||||
&ec2.DescribeVpcAttributeInput{
|
||||
VpcId: aws.String(vpcID),
|
||||
Attribute: aws.String("enableDnsSupport"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"ModifyNetworkInterfaceAttribute",
|
||||
"ec2.test.ModifyNetworkInterfaceAttribute",
|
||||
daemon.handleEC2ModifyNetworkInterfaceAttribute,
|
||||
&ec2.ModifyNetworkInterfaceAttributeInput{NetworkInterfaceId: aws.String("eni-nonexistent")},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sub, err := daemon.natsConn.QueueSubscribe(tt.topic, "spinifex-workers", tt.handler)
|
||||
require.NoError(t, err)
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
reqData, err := json.Marshal(tt.input)
|
||||
require.NoError(t, err)
|
||||
|
||||
reply, err := natsRequest(daemon.natsConn, tt.topic, reqData, 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, reply)
|
||||
|
||||
var resp json.RawMessage
|
||||
err = json.Unmarshal(reply.Data, &resp)
|
||||
require.NoError(t, err, "VPC attribute response should be valid JSON: %s", string(reply.Data))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,14 @@ func (d *Daemon) handleEC2ModifySubnetAttribute(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.ModifySubnetAttribute)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2ModifyVpcAttribute(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.ModifyVpcAttribute)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeVpcAttribute(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.DescribeVpcAttribute)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2CreateNetworkInterface(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.CreateNetworkInterface)
|
||||
}
|
||||
@@ -46,6 +54,10 @@ func (d *Daemon) handleEC2DeleteNetworkInterface(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.DeleteNetworkInterface)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2ModifyNetworkInterfaceAttribute(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.ModifyNetworkInterfaceAttribute)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeNetworkInterfaces(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.vpcService.DescribeNetworkInterfaces)
|
||||
}
|
||||
@@ -134,6 +146,11 @@ func (d *Daemon) ensureDefaultVPCInfrastructure() {
|
||||
}
|
||||
}
|
||||
|
||||
// Add 0.0.0.0/0 → IGW route to the main route table (if not already present)
|
||||
if d.routeTableService != nil {
|
||||
d.ensureDefaultIGWRoute(accountID, defaultVpcId)
|
||||
}
|
||||
|
||||
// Check if default security group exists for this VPC
|
||||
sgOut, err := d.vpcService.DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{}, accountID)
|
||||
if err != nil {
|
||||
@@ -188,3 +205,67 @@ func (d *Daemon) ensureDefaultVPCInfrastructure() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ensureDefaultIGWRoute adds 0.0.0.0/0 → igw-xxx to the main route table if not present
|
||||
func (d *Daemon) ensureDefaultIGWRoute(accountID, vpcID string) {
|
||||
// Find the main route table for this VPC
|
||||
vpcFilter := "vpc-id"
|
||||
mainFilter := "association.main"
|
||||
trueVal := "true"
|
||||
descOut, err := d.routeTableService.DescribeRouteTables(&ec2.DescribeRouteTablesInput{
|
||||
Filters: []*ec2.Filter{
|
||||
{Name: &vpcFilter, Values: []*string{&vpcID}},
|
||||
{Name: &mainFilter, Values: []*string{&trueVal}},
|
||||
},
|
||||
}, accountID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query main route table for default IGW route", "vpcId", vpcID, "err", err)
|
||||
return
|
||||
}
|
||||
if len(descOut.RouteTables) == 0 {
|
||||
slog.Debug("No main route table found for default IGW route", "vpcId", vpcID, "accountID", accountID)
|
||||
return
|
||||
}
|
||||
|
||||
mainRtb := descOut.RouteTables[0]
|
||||
|
||||
// Check if 0.0.0.0/0 route already exists
|
||||
for _, r := range mainRtb.Routes {
|
||||
if r.DestinationCidrBlock != nil && *r.DestinationCidrBlock == "0.0.0.0/0" {
|
||||
return // Already has a default route
|
||||
}
|
||||
}
|
||||
|
||||
// Find the attached IGW for this VPC
|
||||
igwOut, err := d.igwService.DescribeInternetGateways(&ec2.DescribeInternetGatewaysInput{}, accountID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to query IGWs for default route", "vpcId", vpcID, "err", err)
|
||||
return
|
||||
}
|
||||
var igwID string
|
||||
for _, igw := range igwOut.InternetGateways {
|
||||
for _, att := range igw.Attachments {
|
||||
if att.VpcId != nil && *att.VpcId == vpcID {
|
||||
igwID = *igw.InternetGatewayId
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if igwID == "" {
|
||||
return // No IGW attached
|
||||
}
|
||||
|
||||
// Add the default route
|
||||
dest := "0.0.0.0/0"
|
||||
_, err = d.routeTableService.CreateRoute(&ec2.CreateRouteInput{
|
||||
RouteTableId: mainRtb.RouteTableId,
|
||||
DestinationCidrBlock: &dest,
|
||||
GatewayId: &igwID,
|
||||
}, accountID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to add default IGW route to main route table", "err", err)
|
||||
} else {
|
||||
slog.Info("Added default IGW route to main route table",
|
||||
"routeTableId", *mainRtb.RouteTableId, "igwId", igwID, "vpcId", vpcID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/mulgadc/spinifex/spinifex/vm"
|
||||
)
|
||||
|
||||
// Compile-time check that Daemon implements SystemInstanceLauncher.
|
||||
var _ handlers_elbv2.SystemInstanceLauncher = (*Daemon)(nil)
|
||||
|
||||
// LaunchSystemInstance creates and starts a system-managed VM. The VM is owned
|
||||
// by the system account (GlobalAccountID) and is not visible to customer
|
||||
// DescribeInstances calls.
|
||||
//
|
||||
// This is the internal equivalent of RunInstances — it reuses the same
|
||||
// instance service, resource manager, volume preparation, and QEMU launch
|
||||
// path, but skips the NATS request/response envelope and key pair validation.
|
||||
func (d *Daemon) LaunchSystemInstance(input *handlers_elbv2.SystemInstanceInput) (*handlers_elbv2.SystemInstanceOutput, error) {
|
||||
accountID := utils.GlobalAccountID
|
||||
|
||||
// Validate instance type
|
||||
instanceType, exists := d.resourceMgr.instanceTypes[input.InstanceType]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("unknown instance type: %s", input.InstanceType)
|
||||
}
|
||||
|
||||
// Validate AMI
|
||||
if d.imageService == nil {
|
||||
return nil, fmt.Errorf("image service not initialized")
|
||||
}
|
||||
if _, err := d.imageService.GetAMIConfig(input.ImageID); err != nil {
|
||||
return nil, fmt.Errorf("AMI %s not found: %w", input.ImageID, err)
|
||||
}
|
||||
|
||||
// Allocate resources
|
||||
if err := d.resourceMgr.allocate(instanceType); err != nil {
|
||||
return nil, fmt.Errorf("insufficient capacity for %s: %w", input.InstanceType, err)
|
||||
}
|
||||
|
||||
// Build a RunInstancesInput for the instance service
|
||||
runInput := &ec2.RunInstancesInput{
|
||||
InstanceType: aws.String(input.InstanceType),
|
||||
ImageId: aws.String(input.ImageID),
|
||||
MinCount: aws.Int64(1),
|
||||
MaxCount: aws.Int64(1),
|
||||
}
|
||||
if input.SubnetID != "" {
|
||||
runInput.SubnetId = aws.String(input.SubnetID)
|
||||
}
|
||||
if input.UserData != "" {
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(input.UserData))
|
||||
runInput.UserData = aws.String(encoded)
|
||||
}
|
||||
|
||||
// Create VM via instance service
|
||||
instance, ec2Instance, err := d.instanceService.RunInstance(runInput)
|
||||
if err != nil {
|
||||
d.resourceMgr.deallocate(instanceType)
|
||||
return nil, fmt.Errorf("create instance: %w", err)
|
||||
}
|
||||
|
||||
instance.AccountID = accountID
|
||||
instance.Reservation = &ec2.Reservation{}
|
||||
instance.Reservation.SetReservationId(utils.GenerateResourceID("r"))
|
||||
instance.Reservation.SetOwnerId(accountID)
|
||||
instance.Reservation.Instances = []*ec2.Instance{ec2Instance}
|
||||
|
||||
// Attach ENI — either use pre-created one or auto-create
|
||||
privateIP := ""
|
||||
if input.ENIID != "" {
|
||||
// Use pre-created ENI (e.g. ALB ENI)
|
||||
instance.ENIId = input.ENIID
|
||||
instance.ENIMac = input.ENIMac
|
||||
privateIP = input.ENIIP
|
||||
ec2Instance.SetPrivateIpAddress(privateIP)
|
||||
if input.SubnetID != "" {
|
||||
ec2Instance.SetSubnetId(input.SubnetID)
|
||||
}
|
||||
// Mark ENI as attached to this instance
|
||||
if d.vpcService != nil {
|
||||
if _, attachErr := d.vpcService.AttachENI(accountID, instance.ENIId, instance.ID, 0); attachErr != nil {
|
||||
slog.Warn("LaunchSystemInstance: failed to attach ENI", "eniId", instance.ENIId, "instanceId", instance.ID, "err", attachErr)
|
||||
}
|
||||
}
|
||||
} else if input.SubnetID != "" && d.vpcService != nil {
|
||||
// Auto-create ENI in subnet
|
||||
eniOut, eniErr := d.vpcService.CreateNetworkInterface(&ec2.CreateNetworkInterfaceInput{
|
||||
SubnetId: aws.String(input.SubnetID),
|
||||
Description: aws.String("System interface for " + instance.ID),
|
||||
}, accountID)
|
||||
if eniErr != nil {
|
||||
d.resourceMgr.deallocate(instanceType)
|
||||
return nil, fmt.Errorf("create ENI: %w", eniErr)
|
||||
}
|
||||
eni := eniOut.NetworkInterface
|
||||
instance.ENIId = *eni.NetworkInterfaceId
|
||||
instance.ENIMac = *eni.MacAddress
|
||||
privateIP = *eni.PrivateIpAddress
|
||||
ec2Instance.SetPrivateIpAddress(privateIP)
|
||||
ec2Instance.SetSubnetId(input.SubnetID)
|
||||
ec2Instance.SetVpcId(*eni.VpcId)
|
||||
// Mark ENI as attached
|
||||
if _, attachErr := d.vpcService.AttachENI(accountID, instance.ENIId, instance.ID, 0); attachErr != nil {
|
||||
slog.Warn("LaunchSystemInstance: failed to attach auto-created ENI", "eniId", instance.ENIId, "instanceId", instance.ID, "err", attachErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Add to daemon state so LaunchInstance can find it
|
||||
d.Instances.Mu.Lock()
|
||||
d.Instances.VMS[instance.ID] = instance
|
||||
d.Instances.Mu.Unlock()
|
||||
|
||||
if err := d.WriteState(); err != nil {
|
||||
slog.Warn("LaunchSystemInstance: failed to write state", "instanceId", instance.ID, "err", err)
|
||||
}
|
||||
|
||||
// Subscribe to per-instance NATS topic for terminate commands
|
||||
d.mu.Lock()
|
||||
sub, subErr := d.natsConn.Subscribe(fmt.Sprintf("ec2.cmd.%s", instance.ID), d.handleEC2Events)
|
||||
if subErr != nil {
|
||||
slog.Warn("LaunchSystemInstance: failed to subscribe to instance topic", "instanceId", instance.ID, "err", subErr)
|
||||
} else {
|
||||
d.natsSubscriptions[instance.ID] = sub
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
// Pre-compute dev MAC for dual-NIC cloud-init
|
||||
if d.config.Daemon.DevNetworking && instance.ENIId != "" {
|
||||
instance.DevMAC = generateDevMAC(instance.ID)
|
||||
}
|
||||
|
||||
// Store extra hostfwd ports (filled in by StartInstance with actual host ports)
|
||||
if len(input.HostfwdPorts) > 0 {
|
||||
instance.ExtraHostfwd = make(map[int]int, len(input.HostfwdPorts))
|
||||
for _, port := range input.HostfwdPorts {
|
||||
instance.ExtraHostfwd[port] = 0 // host port assigned in StartInstance
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare volumes (root volume clone from AMI, cloud-init ISO, etc.)
|
||||
volumeInfos, err := d.instanceService.GenerateVolumes(runInput, instance)
|
||||
if err != nil {
|
||||
d.cleanupFailedSystemInstance(instance, instanceType)
|
||||
return nil, fmt.Errorf("generate volumes: %w", err)
|
||||
}
|
||||
|
||||
instance.Instance.BlockDeviceMappings = make([]*ec2.InstanceBlockDeviceMapping, 0, len(volumeInfos))
|
||||
for _, vi := range volumeInfos {
|
||||
mapping := &ec2.InstanceBlockDeviceMapping{}
|
||||
mapping.SetDeviceName(vi.DeviceName)
|
||||
mapping.Ebs = &ec2.EbsInstanceBlockDevice{}
|
||||
mapping.Ebs.SetVolumeId(vi.VolumeId)
|
||||
mapping.Ebs.SetAttachTime(vi.AttachTime)
|
||||
mapping.Ebs.SetDeleteOnTermination(vi.DeleteOnTermination)
|
||||
mapping.Ebs.SetStatus("attached")
|
||||
instance.Instance.BlockDeviceMappings = append(instance.Instance.BlockDeviceMappings, mapping)
|
||||
}
|
||||
|
||||
// Launch QEMU VM
|
||||
if err := d.LaunchInstance(instance); err != nil {
|
||||
d.cleanupFailedSystemInstance(instance, instanceType)
|
||||
return nil, fmt.Errorf("launch instance: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("LaunchSystemInstance completed",
|
||||
"instanceId", instance.ID,
|
||||
"instanceType", input.InstanceType,
|
||||
"privateIp", privateIP,
|
||||
)
|
||||
|
||||
return &handlers_elbv2.SystemInstanceOutput{
|
||||
InstanceID: instance.ID,
|
||||
PrivateIP: privateIP,
|
||||
HostfwdMap: instance.ExtraHostfwd,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TerminateSystemInstance stops and cleans up a system-managed VM.
|
||||
func (d *Daemon) TerminateSystemInstance(instanceID string) error {
|
||||
d.Instances.Mu.Lock()
|
||||
instance, exists := d.Instances.VMS[instanceID]
|
||||
d.Instances.Mu.Unlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("instance %s not found", instanceID)
|
||||
}
|
||||
|
||||
// Transition to shutting-down
|
||||
if err := d.TransitionState(instance, vm.StateShuttingDown); err != nil {
|
||||
return fmt.Errorf("transition to shutting-down: %w", err)
|
||||
}
|
||||
|
||||
// Stop the VM (QEMU shutdown, volume unmount, tap cleanup)
|
||||
if err := d.stopInstance(map[string]*vm.VM{instanceID: instance}, true); err != nil {
|
||||
slog.Error("TerminateSystemInstance: stopInstance failed", "instanceId", instanceID, "err", err)
|
||||
return fmt.Errorf("stop instance: %w", err)
|
||||
}
|
||||
|
||||
if err := d.TransitionState(instance, vm.StateTerminated); err != nil {
|
||||
slog.Warn("TerminateSystemInstance: failed to transition to terminated", "instanceId", instanceID, "err", err)
|
||||
}
|
||||
|
||||
// Write to terminated KV bucket (auto-expires)
|
||||
if d.jsManager != nil {
|
||||
if err := d.jsManager.WriteTerminatedInstance(instanceID, instance); err != nil {
|
||||
slog.Warn("TerminateSystemInstance: failed to write terminated state", "instanceId", instanceID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up local state
|
||||
d.Instances.Mu.Lock()
|
||||
delete(d.Instances.VMS, instanceID)
|
||||
d.Instances.Mu.Unlock()
|
||||
|
||||
// Unsubscribe from per-instance NATS topic
|
||||
d.mu.Lock()
|
||||
if sub, ok := d.natsSubscriptions[instanceID]; ok {
|
||||
if err := sub.Unsubscribe(); err != nil {
|
||||
slog.Warn("TerminateSystemInstance: failed to unsubscribe", "instanceId", instanceID, "err", err)
|
||||
}
|
||||
delete(d.natsSubscriptions, instanceID)
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
// Deallocate resources
|
||||
if it, ok := d.resourceMgr.instanceTypes[instance.InstanceType]; ok {
|
||||
d.resourceMgr.deallocate(it)
|
||||
}
|
||||
|
||||
slog.Info("TerminateSystemInstance completed", "instanceId", instanceID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupFailedSystemInstance handles cleanup when a system instance launch fails
|
||||
// after partial setup (state added, volumes created, etc).
|
||||
func (d *Daemon) cleanupFailedSystemInstance(instance *vm.VM, instanceType *ec2.InstanceTypeInfo) {
|
||||
d.markInstanceFailed(instance, "system_instance_launch_failed")
|
||||
d.resourceMgr.deallocate(instanceType)
|
||||
|
||||
// Clean up ENI if we auto-created one (not pre-created)
|
||||
// Pre-created ENIs are managed by the caller (e.g. ELBv2 service)
|
||||
if instance.ENIId != "" && d.vpcService != nil {
|
||||
// Only clean up ENIs we created (description starts with "System interface")
|
||||
result, err := d.vpcService.DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{
|
||||
NetworkInterfaceIds: []*string{aws.String(instance.ENIId)},
|
||||
}, utils.GlobalAccountID)
|
||||
if err == nil && len(result.NetworkInterfaces) > 0 {
|
||||
desc := aws.StringValue(result.NetworkInterfaces[0].Description)
|
||||
if len(desc) >= 16 && desc[:16] == "System interface" {
|
||||
if _, delErr := d.vpcService.DeleteNetworkInterface(&ec2.DeleteNetworkInterfaceInput{
|
||||
NetworkInterfaceId: aws.String(instance.ENIId),
|
||||
}, utils.GlobalAccountID); delErr != nil {
|
||||
slog.Warn("Failed to cleanup system ENI", "eniId", instance.ENIId, "err", delErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForSystemInstance polls until the instance reaches running state or times out.
|
||||
func (d *Daemon) WaitForSystemInstance(instanceID string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
d.Instances.Mu.Lock()
|
||||
inst, exists := d.Instances.VMS[instanceID]
|
||||
var status vm.InstanceState
|
||||
if exists {
|
||||
status = inst.Status
|
||||
}
|
||||
d.Instances.Mu.Unlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("instance %s disappeared", instanceID)
|
||||
}
|
||||
|
||||
switch status {
|
||||
case vm.StateRunning:
|
||||
return nil
|
||||
case vm.StateError, vm.StateShuttingDown, vm.StateTerminated:
|
||||
return fmt.Errorf("instance %s in terminal state: %s", instanceID, status)
|
||||
}
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("instance %s did not reach running state within %s", instanceID, timeout)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/vm"
|
||||
)
|
||||
|
||||
func TestWaitForSystemInstance_AlreadyRunning(t *testing.T) {
|
||||
d := &Daemon{
|
||||
Instances: vm.Instances{
|
||||
VMS: map[string]*vm.VM{
|
||||
"i-test1": {ID: "i-test1", Status: vm.StateRunning},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := d.WaitForSystemInstance("i-test1", 1*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for running instance, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForSystemInstance_NotFound(t *testing.T) {
|
||||
d := &Daemon{
|
||||
Instances: vm.Instances{
|
||||
VMS: map[string]*vm.VM{},
|
||||
},
|
||||
}
|
||||
|
||||
err := d.WaitForSystemInstance("i-nonexistent", 500*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForSystemInstance_ErrorState(t *testing.T) {
|
||||
d := &Daemon{
|
||||
Instances: vm.Instances{
|
||||
VMS: map[string]*vm.VM{
|
||||
"i-failed": {ID: "i-failed", Status: vm.StateError},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := d.WaitForSystemInstance("i-failed", 1*time.Second)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for failed instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForSystemInstance_TransitionsToRunning(t *testing.T) {
|
||||
inst := &vm.VM{ID: "i-pending", Status: vm.StateProvisioning}
|
||||
d := &Daemon{
|
||||
Instances: vm.Instances{
|
||||
VMS: map[string]*vm.VM{
|
||||
"i-pending": inst,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Transition to running after a short delay
|
||||
go func() {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
d.Instances.Mu.Lock()
|
||||
inst.Status = vm.StateRunning
|
||||
d.Instances.Mu.Unlock()
|
||||
}()
|
||||
|
||||
err := d.WaitForSystemInstance("i-pending", 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("expected instance to reach running, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForSystemInstance_Timeout(t *testing.T) {
|
||||
d := &Daemon{
|
||||
Instances: vm.Instances{
|
||||
VMS: map[string]*vm.VM{
|
||||
"i-stuck": {ID: "i-stuck", Status: vm.StateProvisioning},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := d.WaitForSystemInstance("i-stuck", 600*time.Millisecond)
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error")
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,9 @@ import (
|
||||
gateway_ec2_image "github.com/mulgadc/spinifex/spinifex/gateway/ec2/image"
|
||||
gateway_ec2_instance "github.com/mulgadc/spinifex/spinifex/gateway/ec2/instance"
|
||||
gateway_ec2_key "github.com/mulgadc/spinifex/spinifex/gateway/ec2/key"
|
||||
gateway_ec2_natgw "github.com/mulgadc/spinifex/spinifex/gateway/ec2/natgw"
|
||||
gateway_ec2_placementgroup "github.com/mulgadc/spinifex/spinifex/gateway/ec2/placementgroup"
|
||||
gateway_ec2_routetable "github.com/mulgadc/spinifex/spinifex/gateway/ec2/routetable"
|
||||
gateway_ec2_snapshot "github.com/mulgadc/spinifex/spinifex/gateway/ec2/snapshot"
|
||||
gateway_ec2_tags "github.com/mulgadc/spinifex/spinifex/gateway/ec2/tags"
|
||||
gateway_ec2_volume "github.com/mulgadc/spinifex/spinifex/gateway/ec2/volume"
|
||||
@@ -82,6 +84,12 @@ var ec2Actions = map[string]EC2Handler{
|
||||
"ModifyInstanceAttribute": ec2Handler(func(input *ec2.ModifyInstanceAttributeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_instance.ModifyInstanceAttribute(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeInstanceAttribute": ec2Handler(func(input *ec2.DescribeInstanceAttributeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_instance.DescribeInstanceAttribute(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeInstanceCreditSpecifications": ec2Handler(func(input *ec2.DescribeInstanceCreditSpecificationsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_instance.DescribeInstanceCreditSpecifications(input)
|
||||
}),
|
||||
"CreateKeyPair": ec2Handler(func(input *ec2.CreateKeyPairInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_key.CreateKeyPair(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
@@ -217,6 +225,12 @@ var ec2Actions = map[string]EC2Handler{
|
||||
"DescribeVpcs": ec2Handler(func(input *ec2.DescribeVpcsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_vpc.DescribeVpcs(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"ModifyVpcAttribute": ec2Handler(func(input *ec2.ModifyVpcAttributeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_vpc.ModifyVpcAttribute(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeVpcAttribute": ec2Handler(func(input *ec2.DescribeVpcAttributeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_vpc.DescribeVpcAttribute(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateSubnet": ec2Handler(func(input *ec2.CreateSubnetInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_vpc.CreateSubnet(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
@@ -229,6 +243,33 @@ var ec2Actions = map[string]EC2Handler{
|
||||
"ModifySubnetAttribute": ec2Handler(func(input *ec2.ModifySubnetAttributeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_vpc.ModifySubnetAttribute(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateRouteTable": ec2Handler(func(input *ec2.CreateRouteTableInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_routetable.CreateRouteTable(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteRouteTable": ec2Handler(func(input *ec2.DeleteRouteTableInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_routetable.DeleteRouteTable(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeRouteTables": ec2Handler(func(input *ec2.DescribeRouteTablesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_routetable.DescribeRouteTables(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateRoute": ec2Handler(func(input *ec2.CreateRouteInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_routetable.CreateRoute(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteRoute": ec2Handler(func(input *ec2.DeleteRouteInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_routetable.DeleteRoute(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"ReplaceRoute": ec2Handler(func(input *ec2.ReplaceRouteInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_routetable.ReplaceRoute(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"AssociateRouteTable": ec2Handler(func(input *ec2.AssociateRouteTableInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_routetable.AssociateRouteTable(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DisassociateRouteTable": ec2Handler(func(input *ec2.DisassociateRouteTableInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_routetable.DisassociateRouteTable(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"ReplaceRouteTableAssociation": ec2Handler(func(input *ec2.ReplaceRouteTableAssociationInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_routetable.ReplaceRouteTableAssociation(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateNetworkInterface": ec2Handler(func(input *ec2.CreateNetworkInterfaceInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_vpc.CreateNetworkInterface(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
@@ -238,6 +279,9 @@ var ec2Actions = map[string]EC2Handler{
|
||||
"DescribeNetworkInterfaces": ec2Handler(func(input *ec2.DescribeNetworkInterfacesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_vpc.DescribeNetworkInterfaces(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"ModifyNetworkInterfaceAttribute": ec2Handler(func(input *ec2.ModifyNetworkInterfaceAttributeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_vpc.ModifyNetworkInterfaceAttribute(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateSecurityGroup": ec2Handler(func(input *ec2.CreateSecurityGroupInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_vpc.CreateSecurityGroup(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
@@ -274,6 +318,18 @@ var ec2Actions = map[string]EC2Handler{
|
||||
"DescribeAddresses": ec2Handler(func(input *ec2.DescribeAddressesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_eip.DescribeAddresses(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeAddressesAttribute": ec2Handler(func(input *ec2.DescribeAddressesAttributeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_eip.DescribeAddressesAttribute(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateNatGateway": ec2Handler(func(input *ec2.CreateNatGatewayInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_natgw.CreateNatGateway(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteNatGateway": ec2Handler(func(input *ec2.DeleteNatGatewayInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_natgw.DeleteNatGateway(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeNatGateways": ec2Handler(func(input *ec2.DescribeNatGatewaysInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_natgw.DescribeNatGateways(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
}
|
||||
|
||||
// ec2LocalActions are actions that don't require a NATS connection.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package gateway_ec2_eip
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"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"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// DescribeAddressesAttribute handles the EC2 DescribeAddressesAttribute API call.
|
||||
func DescribeAddressesAttribute(input *ec2.DescribeAddressesAttributeInput, natsConn *nats.Conn, accountID string) (ec2.DescribeAddressesAttributeOutput, error) {
|
||||
var output ec2.DescribeAddressesAttributeOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
svc := handlers_ec2_eip.NewNATSEIPService(natsConn)
|
||||
result, err := svc.DescribeAddressesAttribute(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package gateway_ec2_eip
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const testAccountID = "123456789012"
|
||||
|
||||
// AllocateAddress tests
|
||||
|
||||
func TestAllocateAddress_NilInput(t *testing.T) {
|
||||
_, err := AllocateAddress(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestAllocateAddress_NilNATS(t *testing.T) {
|
||||
_, err := AllocateAddress(&ec2.AllocateAddressInput{}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// AssociateAddress tests
|
||||
|
||||
func TestAssociateAddress_NilInput(t *testing.T) {
|
||||
_, err := AssociateAddress(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestAssociateAddress_NilAllocationId(t *testing.T) {
|
||||
_, err := AssociateAddress(&ec2.AssociateAddressInput{}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestAssociateAddress_EmptyAllocationId(t *testing.T) {
|
||||
_, err := AssociateAddress(&ec2.AssociateAddressInput{AllocationId: aws.String("")}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestAssociateAddress_NilNATS(t *testing.T) {
|
||||
_, err := AssociateAddress(&ec2.AssociateAddressInput{AllocationId: aws.String("eipalloc-123")}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// DescribeAddresses tests
|
||||
|
||||
func TestDescribeAddresses_NilInput(t *testing.T) {
|
||||
_, err := DescribeAddresses(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestDescribeAddresses_NilNATS(t *testing.T) {
|
||||
_, err := DescribeAddresses(&ec2.DescribeAddressesInput{}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// DescribeAddressesAttribute tests
|
||||
|
||||
func TestDescribeAddressesAttribute_NilInput(t *testing.T) {
|
||||
_, err := DescribeAddressesAttribute(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestDescribeAddressesAttribute_NilNATS(t *testing.T) {
|
||||
_, err := DescribeAddressesAttribute(&ec2.DescribeAddressesAttributeInput{}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// DisassociateAddress tests
|
||||
|
||||
func TestDisassociateAddress_NilInput(t *testing.T) {
|
||||
_, err := DisassociateAddress(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestDisassociateAddress_NilAssociationId(t *testing.T) {
|
||||
_, err := DisassociateAddress(&ec2.DisassociateAddressInput{}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestDisassociateAddress_EmptyAssociationId(t *testing.T) {
|
||||
_, err := DisassociateAddress(&ec2.DisassociateAddressInput{AssociationId: aws.String("")}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestDisassociateAddress_NilNATS(t *testing.T) {
|
||||
_, err := DisassociateAddress(&ec2.DisassociateAddressInput{AssociationId: aws.String("eipassoc-123")}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// ReleaseAddress tests
|
||||
|
||||
func TestReleaseAddress_NilInput(t *testing.T) {
|
||||
_, err := ReleaseAddress(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestReleaseAddress_NilAllocationId(t *testing.T) {
|
||||
_, err := ReleaseAddress(&ec2.ReleaseAddressInput{}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestReleaseAddress_EmptyAllocationId(t *testing.T) {
|
||||
_, err := ReleaseAddress(&ec2.ReleaseAddressInput{AllocationId: aws.String("")}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestReleaseAddress_NilNATS(t *testing.T) {
|
||||
_, err := ReleaseAddress(&ec2.ReleaseAddressInput{AllocationId: aws.String("eipalloc-123")}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package gateway_ec2_instance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ValidateDescribeInstanceAttributeInput validates the input for DescribeInstanceAttribute.
|
||||
func ValidateDescribeInstanceAttributeInput(input *ec2.DescribeInstanceAttributeInput) error {
|
||||
if input == nil {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.InstanceId == nil || *input.InstanceId == "" {
|
||||
return errors.New(awserrors.ErrorInvalidInstanceIDMalformed)
|
||||
}
|
||||
if !strings.HasPrefix(*input.InstanceId, "i-") {
|
||||
return errors.New(awserrors.ErrorInvalidInstanceIDMalformed)
|
||||
}
|
||||
if input.Attribute == nil || *input.Attribute == "" {
|
||||
return errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DescribeInstanceAttribute sends a describe-attribute request to the daemon via NATS.
|
||||
func DescribeInstanceAttribute(input *ec2.DescribeInstanceAttributeInput, natsConn *nats.Conn, accountID string) (*ec2.DescribeInstanceAttributeOutput, error) {
|
||||
if err := ValidateDescribeInstanceAttributeInput(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slog.Info("DescribeInstanceAttribute: Processing request", "instance_id", *input.InstanceId, "attribute", *input.Attribute)
|
||||
|
||||
output, err := utils.NATSRequest[ec2.DescribeInstanceAttributeOutput](natsConn, "ec2.DescribeInstanceAttribute", input, 30*time.Second, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slog.Info("DescribeInstanceAttribute: Completed successfully", "instance_id", *input.InstanceId)
|
||||
return output, nil
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package gateway_ec2_instance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"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.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- Validation tests ---
|
||||
|
||||
func TestValidateDescribeInstanceAttributeInput_NilInput(t *testing.T) {
|
||||
err := ValidateDescribeInstanceAttributeInput(nil)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
|
||||
}
|
||||
|
||||
func TestValidateDescribeInstanceAttributeInput_MissingInstanceId(t *testing.T) {
|
||||
err := ValidateDescribeInstanceAttributeInput(&ec2.DescribeInstanceAttributeInput{
|
||||
Attribute: aws.String(ec2.InstanceAttributeNameInstanceType),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidInstanceIDMalformed, err.Error())
|
||||
}
|
||||
|
||||
func TestValidateDescribeInstanceAttributeInput_EmptyInstanceId(t *testing.T) {
|
||||
err := ValidateDescribeInstanceAttributeInput(&ec2.DescribeInstanceAttributeInput{
|
||||
InstanceId: aws.String(""),
|
||||
Attribute: aws.String(ec2.InstanceAttributeNameInstanceType),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidInstanceIDMalformed, err.Error())
|
||||
}
|
||||
|
||||
func TestValidateDescribeInstanceAttributeInput_BadPrefix(t *testing.T) {
|
||||
err := ValidateDescribeInstanceAttributeInput(&ec2.DescribeInstanceAttributeInput{
|
||||
InstanceId: aws.String("x-12345"),
|
||||
Attribute: aws.String(ec2.InstanceAttributeNameInstanceType),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidInstanceIDMalformed, err.Error())
|
||||
}
|
||||
|
||||
func TestValidateDescribeInstanceAttributeInput_MissingAttribute(t *testing.T) {
|
||||
err := ValidateDescribeInstanceAttributeInput(&ec2.DescribeInstanceAttributeInput{
|
||||
InstanceId: aws.String("i-abc123"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorMissingParameter, err.Error())
|
||||
}
|
||||
|
||||
func TestValidateDescribeInstanceAttributeInput_EmptyAttribute(t *testing.T) {
|
||||
err := ValidateDescribeInstanceAttributeInput(&ec2.DescribeInstanceAttributeInput{
|
||||
InstanceId: aws.String("i-abc123"),
|
||||
Attribute: aws.String(""),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorMissingParameter, err.Error())
|
||||
}
|
||||
|
||||
func TestValidateDescribeInstanceAttributeInput_Valid(t *testing.T) {
|
||||
err := ValidateDescribeInstanceAttributeInput(&ec2.DescribeInstanceAttributeInput{
|
||||
InstanceId: aws.String("i-abc123"),
|
||||
Attribute: aws.String(ec2.InstanceAttributeNameInstanceType),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// --- Gateway function tests ---
|
||||
|
||||
func TestDescribeInstanceAttribute_Success(t *testing.T) {
|
||||
_, nc := startTestNATSServer(t)
|
||||
|
||||
nc.QueueSubscribe("ec2.DescribeInstanceAttribute", "spinifex-workers", func(msg *nats.Msg) {
|
||||
var input ec2.DescribeInstanceAttributeInput
|
||||
err := json.Unmarshal(msg.Data, &input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "i-test123", *input.InstanceId)
|
||||
assert.Equal(t, "instanceType", *input.Attribute)
|
||||
|
||||
output := &ec2.DescribeInstanceAttributeOutput{
|
||||
InstanceId: aws.String("i-test123"),
|
||||
InstanceType: &ec2.AttributeValue{Value: aws.String("t3.micro")},
|
||||
}
|
||||
resp, _ := json.Marshal(output)
|
||||
msg.Respond(resp)
|
||||
})
|
||||
|
||||
input := &ec2.DescribeInstanceAttributeInput{
|
||||
InstanceId: aws.String("i-test123"),
|
||||
Attribute: aws.String(ec2.InstanceAttributeNameInstanceType),
|
||||
}
|
||||
|
||||
output, err := DescribeInstanceAttribute(input, nc, "123456789012")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, output)
|
||||
assert.Equal(t, "i-test123", *output.InstanceId)
|
||||
assert.Equal(t, "t3.micro", *output.InstanceType.Value)
|
||||
}
|
||||
|
||||
func TestDescribeInstanceAttribute_DaemonError(t *testing.T) {
|
||||
_, nc := startTestNATSServer(t)
|
||||
|
||||
nc.QueueSubscribe("ec2.DescribeInstanceAttribute", "spinifex-workers", func(msg *nats.Msg) {
|
||||
msg.Respond([]byte(`{"Code":"InvalidInstanceID.NotFound"}`))
|
||||
})
|
||||
|
||||
input := &ec2.DescribeInstanceAttributeInput{
|
||||
InstanceId: aws.String("i-notfound"),
|
||||
Attribute: aws.String(ec2.InstanceAttributeNameInstanceType),
|
||||
}
|
||||
|
||||
_, err := DescribeInstanceAttribute(input, nc, "123456789012")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidInstanceIDNotFound, err.Error())
|
||||
}
|
||||
|
||||
func TestDescribeInstanceAttribute_ValidationFailure(t *testing.T) {
|
||||
_, nc := startTestNATSServer(t)
|
||||
|
||||
_, err := DescribeInstanceAttribute(nil, nc, "123456789012")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package gateway_ec2_instance
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
)
|
||||
|
||||
// DescribeInstanceCreditSpecifications returns CPU credit specifications for T-series instances.
|
||||
// Stub: always returns "standard" mode for each requested instance.
|
||||
func DescribeInstanceCreditSpecifications(input *ec2.DescribeInstanceCreditSpecificationsInput) (*ec2.DescribeInstanceCreditSpecificationsOutput, error) {
|
||||
if input == nil {
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
var specs []*ec2.InstanceCreditSpecification
|
||||
for _, id := range input.InstanceIds {
|
||||
if id != nil && *id != "" {
|
||||
specs = append(specs, &ec2.InstanceCreditSpecification{
|
||||
InstanceId: id,
|
||||
CpuCredits: aws.String("standard"),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return &ec2.DescribeInstanceCreditSpecificationsOutput{
|
||||
InstanceCreditSpecifications: specs,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package gateway_ec2_instance
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDescribeInstanceCreditSpecifications_NilInput(t *testing.T) {
|
||||
out, err := DescribeInstanceCreditSpecifications(nil)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, out)
|
||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
|
||||
}
|
||||
|
||||
func TestDescribeInstanceCreditSpecifications_EmptyInstanceIds(t *testing.T) {
|
||||
out, err := DescribeInstanceCreditSpecifications(&ec2.DescribeInstanceCreditSpecificationsInput{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out.InstanceCreditSpecifications)
|
||||
}
|
||||
|
||||
func TestDescribeInstanceCreditSpecifications_SingleInstance(t *testing.T) {
|
||||
out, err := DescribeInstanceCreditSpecifications(&ec2.DescribeInstanceCreditSpecificationsInput{
|
||||
InstanceIds: []*string{aws.String("i-abc123")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.InstanceCreditSpecifications, 1)
|
||||
assert.Equal(t, "i-abc123", *out.InstanceCreditSpecifications[0].InstanceId)
|
||||
assert.Equal(t, "standard", *out.InstanceCreditSpecifications[0].CpuCredits)
|
||||
}
|
||||
|
||||
func TestDescribeInstanceCreditSpecifications_MultipleInstances(t *testing.T) {
|
||||
out, err := DescribeInstanceCreditSpecifications(&ec2.DescribeInstanceCreditSpecificationsInput{
|
||||
InstanceIds: []*string{aws.String("i-aaa"), aws.String("i-bbb"), aws.String("i-ccc")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.InstanceCreditSpecifications, 3)
|
||||
for _, spec := range out.InstanceCreditSpecifications {
|
||||
assert.Equal(t, "standard", *spec.CpuCredits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeInstanceCreditSpecifications_SkipsNilAndEmpty(t *testing.T) {
|
||||
out, err := DescribeInstanceCreditSpecifications(&ec2.DescribeInstanceCreditSpecificationsInput{
|
||||
InstanceIds: []*string{nil, aws.String(""), aws.String("i-valid"), nil, aws.String("i-also")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.InstanceCreditSpecifications, 2)
|
||||
assert.Equal(t, "i-valid", *out.InstanceCreditSpecifications[0].InstanceId)
|
||||
assert.Equal(t, "i-also", *out.InstanceCreditSpecifications[1].InstanceId)
|
||||
}
|
||||
@@ -113,6 +113,14 @@ func DescribeInstanceTypes(input *ec2.DescribeInstanceTypesInput, natsConn *nats
|
||||
}
|
||||
}
|
||||
|
||||
// Build set of requested instance type names (if any) for filtering.
|
||||
requestedTypes := make(map[string]bool)
|
||||
for _, it := range input.InstanceTypes {
|
||||
if it != nil {
|
||||
requestedTypes[*it] = true
|
||||
}
|
||||
}
|
||||
|
||||
var finalInstanceTypes []*ec2.InstanceTypeInfo
|
||||
if showCapacity {
|
||||
finalInstanceTypes = allInstanceTypes
|
||||
@@ -128,6 +136,17 @@ func DescribeInstanceTypes(input *ec2.DescribeInstanceTypesInput, natsConn *nats
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by requested instance type names if specified.
|
||||
if len(requestedTypes) > 0 {
|
||||
var filtered []*ec2.InstanceTypeInfo
|
||||
for _, it := range finalInstanceTypes {
|
||||
if it != nil && it.InstanceType != nil && requestedTypes[*it.InstanceType] {
|
||||
filtered = append(filtered, it)
|
||||
}
|
||||
}
|
||||
finalInstanceTypes = filtered
|
||||
}
|
||||
|
||||
// Build final aggregated response
|
||||
output := &ec2.DescribeInstanceTypesOutput{
|
||||
InstanceTypes: finalInstanceTypes,
|
||||
|
||||
@@ -35,6 +35,9 @@ func ValidateModifyInstanceAttributeInput(input *ec2.ModifyInstanceAttributeInpu
|
||||
if input.UserData != nil {
|
||||
count++
|
||||
}
|
||||
if input.SourceDestCheck != nil {
|
||||
count++
|
||||
}
|
||||
if count != 1 {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
@@ -95,6 +95,24 @@ func TestValidateModifyInstanceAttributeInput_ValidUserData(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestValidateModifyInstanceAttributeInput_ValidSourceDestCheck(t *testing.T) {
|
||||
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{
|
||||
InstanceId: aws.String("i-abc123"),
|
||||
SourceDestCheck: &ec2.AttributeBooleanValue{Value: aws.Bool(true)},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestValidateModifyInstanceAttributeInput_SourceDestCheckWithOtherAttribute(t *testing.T) {
|
||||
err := ValidateModifyInstanceAttributeInput(&ec2.ModifyInstanceAttributeInput{
|
||||
InstanceId: aws.String("i-abc123"),
|
||||
SourceDestCheck: &ec2.AttributeBooleanValue{Value: aws.Bool(false)},
|
||||
InstanceType: &ec2.AttributeValue{Value: aws.String("t3.micro")},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
|
||||
}
|
||||
|
||||
// --- Gateway function tests ---
|
||||
|
||||
func TestModifyInstanceAttribute_Success(t *testing.T) {
|
||||
@@ -117,6 +135,22 @@ func TestModifyInstanceAttribute_Success(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestModifyInstanceAttribute_SourceDestCheck(t *testing.T) {
|
||||
_, nc := startTestNATSServer(t)
|
||||
|
||||
nc.QueueSubscribe("ec2.ModifyInstanceAttribute", "spinifex-workers", func(msg *nats.Msg) {
|
||||
msg.Respond([]byte(`{}`))
|
||||
})
|
||||
|
||||
input := &ec2.ModifyInstanceAttributeInput{
|
||||
InstanceId: aws.String("i-test123"),
|
||||
SourceDestCheck: &ec2.AttributeBooleanValue{Value: aws.Bool(true)},
|
||||
}
|
||||
|
||||
_, err := ModifyInstanceAttribute(input, nc, "123456789012")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestModifyInstanceAttribute_DaemonError(t *testing.T) {
|
||||
_, nc := startTestNATSServer(t)
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package gateway_ec2_natgw
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_natgw "github.com/mulgadc/spinifex/spinifex/handlers/ec2/natgw"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func CreateNatGateway(input *ec2.CreateNatGatewayInput, natsConn *nats.Conn, accountID string) (ec2.CreateNatGatewayOutput, error) {
|
||||
var output ec2.CreateNatGatewayOutput
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
svc := handlers_ec2_natgw.NewNATSNatGatewayService(natsConn)
|
||||
result, err := svc.CreateNatGateway(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package gateway_ec2_natgw
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_natgw "github.com/mulgadc/spinifex/spinifex/handlers/ec2/natgw"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func DeleteNatGateway(input *ec2.DeleteNatGatewayInput, natsConn *nats.Conn, accountID string) (ec2.DeleteNatGatewayOutput, error) {
|
||||
var output ec2.DeleteNatGatewayOutput
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
svc := handlers_ec2_natgw.NewNATSNatGatewayService(natsConn)
|
||||
result, err := svc.DeleteNatGateway(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package gateway_ec2_natgw
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_natgw "github.com/mulgadc/spinifex/spinifex/handlers/ec2/natgw"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func DescribeNatGateways(input *ec2.DescribeNatGatewaysInput, natsConn *nats.Conn, accountID string) (ec2.DescribeNatGatewaysOutput, error) {
|
||||
var output ec2.DescribeNatGatewaysOutput
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
svc := handlers_ec2_natgw.NewNATSNatGatewayService(natsConn)
|
||||
result, err := svc.DescribeNatGateways(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package gateway_ec2_natgw
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const testAccountID = "123456789012"
|
||||
|
||||
// CreateNatGateway tests
|
||||
|
||||
func TestCreateNatGateway_NilInput(t *testing.T) {
|
||||
_, err := CreateNatGateway(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestCreateNatGateway_NilNATS(t *testing.T) {
|
||||
_, err := CreateNatGateway(&ec2.CreateNatGatewayInput{}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// DeleteNatGateway tests
|
||||
|
||||
func TestDeleteNatGateway_NilInput(t *testing.T) {
|
||||
_, err := DeleteNatGateway(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestDeleteNatGateway_NilNATS(t *testing.T) {
|
||||
_, err := DeleteNatGateway(&ec2.DeleteNatGatewayInput{}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// DescribeNatGateways tests
|
||||
|
||||
func TestDescribeNatGateways_NilInput(t *testing.T) {
|
||||
_, err := DescribeNatGateways(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestDescribeNatGateways_NilNATS(t *testing.T) {
|
||||
_, err := DescribeNatGateways(&ec2.DescribeNatGatewaysInput{}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package gateway_ec2_placementgroup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const testAccountID = "123456789012"
|
||||
|
||||
// CreatePlacementGroup tests
|
||||
|
||||
func TestCreatePlacementGroup_NilInput(t *testing.T) {
|
||||
_, err := CreatePlacementGroup(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestCreatePlacementGroup_NilGroupName(t *testing.T) {
|
||||
_, err := CreatePlacementGroup(&ec2.CreatePlacementGroupInput{
|
||||
Strategy: aws.String("cluster"),
|
||||
}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestCreatePlacementGroup_EmptyGroupName(t *testing.T) {
|
||||
_, err := CreatePlacementGroup(&ec2.CreatePlacementGroupInput{
|
||||
GroupName: aws.String(""),
|
||||
Strategy: aws.String("cluster"),
|
||||
}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestCreatePlacementGroup_NilStrategy(t *testing.T) {
|
||||
_, err := CreatePlacementGroup(&ec2.CreatePlacementGroupInput{
|
||||
GroupName: aws.String("my-group"),
|
||||
}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestCreatePlacementGroup_EmptyStrategy(t *testing.T) {
|
||||
_, err := CreatePlacementGroup(&ec2.CreatePlacementGroupInput{
|
||||
GroupName: aws.String("my-group"),
|
||||
Strategy: aws.String(""),
|
||||
}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestCreatePlacementGroup_NilNATS(t *testing.T) {
|
||||
_, err := CreatePlacementGroup(&ec2.CreatePlacementGroupInput{
|
||||
GroupName: aws.String("my-group"),
|
||||
Strategy: aws.String("cluster"),
|
||||
}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// DeletePlacementGroup tests
|
||||
|
||||
func TestDeletePlacementGroup_NilInput(t *testing.T) {
|
||||
_, err := DeletePlacementGroup(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestDeletePlacementGroup_NilGroupName(t *testing.T) {
|
||||
_, err := DeletePlacementGroup(&ec2.DeletePlacementGroupInput{}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestDeletePlacementGroup_EmptyGroupName(t *testing.T) {
|
||||
_, err := DeletePlacementGroup(&ec2.DeletePlacementGroupInput{
|
||||
GroupName: aws.String(""),
|
||||
}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestDeletePlacementGroup_NilNATS(t *testing.T) {
|
||||
_, err := DeletePlacementGroup(&ec2.DeletePlacementGroupInput{
|
||||
GroupName: aws.String("my-group"),
|
||||
}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// DescribePlacementGroups tests
|
||||
|
||||
func TestDescribePlacementGroups_NilInput(t *testing.T) {
|
||||
_, err := DescribePlacementGroups(nil, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDescribePlacementGroups_NilNATS(t *testing.T) {
|
||||
_, err := DescribePlacementGroups(&ec2.DescribePlacementGroupsInput{}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package gateway_ec2_routetable
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_routetable "github.com/mulgadc/spinifex/spinifex/handlers/ec2/routetable"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func AssociateRouteTable(input *ec2.AssociateRouteTableInput, natsConn *nats.Conn, accountID string) (ec2.AssociateRouteTableOutput, error) {
|
||||
var output ec2.AssociateRouteTableOutput
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
svc := handlers_ec2_routetable.NewNATSRouteTableService(natsConn)
|
||||
result, err := svc.AssociateRouteTable(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package gateway_ec2_routetable
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_routetable "github.com/mulgadc/spinifex/spinifex/handlers/ec2/routetable"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func CreateRoute(input *ec2.CreateRouteInput, natsConn *nats.Conn, accountID string) (ec2.CreateRouteOutput, error) {
|
||||
var output ec2.CreateRouteOutput
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
svc := handlers_ec2_routetable.NewNATSRouteTableService(natsConn)
|
||||
result, err := svc.CreateRoute(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package gateway_ec2_routetable
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_routetable "github.com/mulgadc/spinifex/spinifex/handlers/ec2/routetable"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func CreateRouteTable(input *ec2.CreateRouteTableInput, natsConn *nats.Conn, accountID string) (ec2.CreateRouteTableOutput, error) {
|
||||
var output ec2.CreateRouteTableOutput
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
svc := handlers_ec2_routetable.NewNATSRouteTableService(natsConn)
|
||||
result, err := svc.CreateRouteTable(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package gateway_ec2_routetable
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_routetable "github.com/mulgadc/spinifex/spinifex/handlers/ec2/routetable"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func DeleteRoute(input *ec2.DeleteRouteInput, natsConn *nats.Conn, accountID string) (ec2.DeleteRouteOutput, error) {
|
||||
var output ec2.DeleteRouteOutput
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
svc := handlers_ec2_routetable.NewNATSRouteTableService(natsConn)
|
||||
result, err := svc.DeleteRoute(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package gateway_ec2_routetable
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_routetable "github.com/mulgadc/spinifex/spinifex/handlers/ec2/routetable"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func DeleteRouteTable(input *ec2.DeleteRouteTableInput, natsConn *nats.Conn, accountID string) (ec2.DeleteRouteTableOutput, error) {
|
||||
var output ec2.DeleteRouteTableOutput
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
svc := handlers_ec2_routetable.NewNATSRouteTableService(natsConn)
|
||||
result, err := svc.DeleteRouteTable(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package gateway_ec2_routetable
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_routetable "github.com/mulgadc/spinifex/spinifex/handlers/ec2/routetable"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func DescribeRouteTables(input *ec2.DescribeRouteTablesInput, natsConn *nats.Conn, accountID string) (ec2.DescribeRouteTablesOutput, error) {
|
||||
var output ec2.DescribeRouteTablesOutput
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
svc := handlers_ec2_routetable.NewNATSRouteTableService(natsConn)
|
||||
result, err := svc.DescribeRouteTables(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package gateway_ec2_routetable
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_routetable "github.com/mulgadc/spinifex/spinifex/handlers/ec2/routetable"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func DisassociateRouteTable(input *ec2.DisassociateRouteTableInput, natsConn *nats.Conn, accountID string) (ec2.DisassociateRouteTableOutput, error) {
|
||||
var output ec2.DisassociateRouteTableOutput
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
svc := handlers_ec2_routetable.NewNATSRouteTableService(natsConn)
|
||||
result, err := svc.DisassociateRouteTable(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package gateway_ec2_routetable
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_routetable "github.com/mulgadc/spinifex/spinifex/handlers/ec2/routetable"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func ReplaceRoute(input *ec2.ReplaceRouteInput, natsConn *nats.Conn, accountID string) (ec2.ReplaceRouteOutput, error) {
|
||||
var output ec2.ReplaceRouteOutput
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
svc := handlers_ec2_routetable.NewNATSRouteTableService(natsConn)
|
||||
result, err := svc.ReplaceRoute(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package gateway_ec2_routetable
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_routetable "github.com/mulgadc/spinifex/spinifex/handlers/ec2/routetable"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
func ReplaceRouteTableAssociation(input *ec2.ReplaceRouteTableAssociationInput, natsConn *nats.Conn, accountID string) (ec2.ReplaceRouteTableAssociationOutput, error) {
|
||||
var output ec2.ReplaceRouteTableAssociationOutput
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
svc := handlers_ec2_routetable.NewNATSRouteTableService(natsConn)
|
||||
result, err := svc.ReplaceRouteTableAssociation(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package gateway_ec2_routetable
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const testAccountID = "123456789012"
|
||||
|
||||
// CreateRouteTable tests
|
||||
|
||||
func TestCreateRouteTable_NilInput(t *testing.T) {
|
||||
_, err := CreateRouteTable(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestCreateRouteTable_NilNATS(t *testing.T) {
|
||||
_, err := CreateRouteTable(&ec2.CreateRouteTableInput{}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// DeleteRouteTable tests
|
||||
|
||||
func TestDeleteRouteTable_NilInput(t *testing.T) {
|
||||
_, err := DeleteRouteTable(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestDeleteRouteTable_NilNATS(t *testing.T) {
|
||||
_, err := DeleteRouteTable(&ec2.DeleteRouteTableInput{}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// DescribeRouteTables tests
|
||||
|
||||
func TestDescribeRouteTables_NilInput(t *testing.T) {
|
||||
_, err := DescribeRouteTables(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestDescribeRouteTables_NilNATS(t *testing.T) {
|
||||
_, err := DescribeRouteTables(&ec2.DescribeRouteTablesInput{}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// CreateRoute tests
|
||||
|
||||
func TestCreateRoute_NilInput(t *testing.T) {
|
||||
_, err := CreateRoute(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestCreateRoute_NilNATS(t *testing.T) {
|
||||
_, err := CreateRoute(&ec2.CreateRouteInput{}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// DeleteRoute tests
|
||||
|
||||
func TestDeleteRoute_NilInput(t *testing.T) {
|
||||
_, err := DeleteRoute(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestDeleteRoute_NilNATS(t *testing.T) {
|
||||
_, err := DeleteRoute(&ec2.DeleteRouteInput{}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// ReplaceRoute tests
|
||||
|
||||
func TestReplaceRoute_NilInput(t *testing.T) {
|
||||
_, err := ReplaceRoute(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestReplaceRoute_NilNATS(t *testing.T) {
|
||||
_, err := ReplaceRoute(&ec2.ReplaceRouteInput{}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// AssociateRouteTable tests
|
||||
|
||||
func TestAssociateRouteTable_NilInput(t *testing.T) {
|
||||
_, err := AssociateRouteTable(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestAssociateRouteTable_NilNATS(t *testing.T) {
|
||||
_, err := AssociateRouteTable(&ec2.AssociateRouteTableInput{}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// DisassociateRouteTable tests
|
||||
|
||||
func TestDisassociateRouteTable_NilInput(t *testing.T) {
|
||||
_, err := DisassociateRouteTable(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestDisassociateRouteTable_NilNATS(t *testing.T) {
|
||||
_, err := DisassociateRouteTable(&ec2.DisassociateRouteTableInput{}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// ReplaceRouteTableAssociation tests
|
||||
|
||||
func TestReplaceRouteTableAssociation_NilInput(t *testing.T) {
|
||||
_, err := ReplaceRouteTableAssociation(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestReplaceRouteTableAssociation_NilNATS(t *testing.T) {
|
||||
_, err := ReplaceRouteTableAssociation(&ec2.ReplaceRouteTableAssociationInput{}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package gateway_ec2_vpc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// DescribeVpcAttribute handles the EC2 DescribeVpcAttribute API call
|
||||
func DescribeVpcAttribute(input *ec2.DescribeVpcAttributeInput, natsConn *nats.Conn, accountID string) (ec2.DescribeVpcAttributeOutput, error) {
|
||||
var output ec2.DescribeVpcAttributeOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.VpcId == nil || *input.VpcId == "" {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
if input.Attribute == nil || *input.Attribute == "" {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
svc := handlers_ec2_vpc.NewNATSVPCService(natsConn)
|
||||
result, err := svc.DescribeVpcAttribute(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package gateway_ec2_vpc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// ModifyNetworkInterfaceAttribute handles the EC2 ModifyNetworkInterfaceAttribute API call
|
||||
func ModifyNetworkInterfaceAttribute(input *ec2.ModifyNetworkInterfaceAttributeInput, natsConn *nats.Conn, accountID string) (ec2.ModifyNetworkInterfaceAttributeOutput, error) {
|
||||
var output ec2.ModifyNetworkInterfaceAttributeOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.NetworkInterfaceId == nil || *input.NetworkInterfaceId == "" {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
svc := handlers_ec2_vpc.NewNATSVPCService(natsConn)
|
||||
result, err := svc.ModifyNetworkInterfaceAttribute(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package gateway_ec2_vpc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestModifyNetworkInterfaceAttribute_NilInput(t *testing.T) {
|
||||
_, err := ModifyNetworkInterfaceAttribute(nil, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestModifyNetworkInterfaceAttribute_NilNetworkInterfaceId(t *testing.T) {
|
||||
_, err := ModifyNetworkInterfaceAttribute(&ec2.ModifyNetworkInterfaceAttributeInput{}, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestModifyNetworkInterfaceAttribute_EmptyNetworkInterfaceId(t *testing.T) {
|
||||
_, err := ModifyNetworkInterfaceAttribute(&ec2.ModifyNetworkInterfaceAttributeInput{
|
||||
NetworkInterfaceId: aws.String(""),
|
||||
}, nil, "123456789012")
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestModifyNetworkInterfaceAttribute_NilNATS(t *testing.T) {
|
||||
_, err := ModifyNetworkInterfaceAttribute(&ec2.ModifyNetworkInterfaceAttributeInput{
|
||||
NetworkInterfaceId: aws.String("eni-abc123"),
|
||||
}, nil, "123456789012")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package gateway_ec2_vpc
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// ModifyVpcAttribute handles the EC2 ModifyVpcAttribute API call
|
||||
func ModifyVpcAttribute(input *ec2.ModifyVpcAttributeInput, natsConn *nats.Conn, accountID string) (ec2.ModifyVpcAttributeOutput, error) {
|
||||
var output ec2.ModifyVpcAttributeOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.VpcId == nil || *input.VpcId == "" {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
svc := handlers_ec2_vpc.NewNATSVPCService(natsConn)
|
||||
result, err := svc.ModifyVpcAttribute(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -166,3 +166,223 @@ func TestDescribeNetworkInterfaces_NilNATS(t *testing.T) {
|
||||
_, err = DescribeNetworkInterfaces(&ec2.DescribeNetworkInterfacesInput{}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// ModifyVpcAttribute tests
|
||||
|
||||
func TestModifyVpcAttribute_NilInput(t *testing.T) {
|
||||
_, err := ModifyVpcAttribute(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestModifyVpcAttribute_NilVpcId(t *testing.T) {
|
||||
_, err := ModifyVpcAttribute(&ec2.ModifyVpcAttributeInput{}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestModifyVpcAttribute_EmptyVpcId(t *testing.T) {
|
||||
_, err := ModifyVpcAttribute(&ec2.ModifyVpcAttributeInput{VpcId: aws.String("")}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestModifyVpcAttribute_NilNATS(t *testing.T) {
|
||||
_, err := ModifyVpcAttribute(&ec2.ModifyVpcAttributeInput{VpcId: aws.String("vpc-123")}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// DescribeVpcAttribute tests
|
||||
|
||||
func TestDescribeVpcAttribute_NilInput(t *testing.T) {
|
||||
_, err := DescribeVpcAttribute(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestDescribeVpcAttribute_NilVpcId(t *testing.T) {
|
||||
_, err := DescribeVpcAttribute(&ec2.DescribeVpcAttributeInput{}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestDescribeVpcAttribute_EmptyVpcId(t *testing.T) {
|
||||
_, err := DescribeVpcAttribute(&ec2.DescribeVpcAttributeInput{VpcId: aws.String("")}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestDescribeVpcAttribute_MissingAttribute(t *testing.T) {
|
||||
_, err := DescribeVpcAttribute(&ec2.DescribeVpcAttributeInput{VpcId: aws.String("vpc-123")}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestDescribeVpcAttribute_EmptyAttribute(t *testing.T) {
|
||||
_, err := DescribeVpcAttribute(&ec2.DescribeVpcAttributeInput{VpcId: aws.String("vpc-123"), Attribute: aws.String("")}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestDescribeVpcAttribute_NilNATS(t *testing.T) {
|
||||
_, err := DescribeVpcAttribute(&ec2.DescribeVpcAttributeInput{VpcId: aws.String("vpc-123"), Attribute: aws.String(ec2.VpcAttributeNameEnableDnsSupport)}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// ModifySubnetAttribute tests
|
||||
|
||||
func TestModifySubnetAttribute_NilInput(t *testing.T) {
|
||||
_, err := ModifySubnetAttribute(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestModifySubnetAttribute_NilSubnetId(t *testing.T) {
|
||||
_, err := ModifySubnetAttribute(&ec2.ModifySubnetAttributeInput{}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestModifySubnetAttribute_EmptySubnetId(t *testing.T) {
|
||||
_, err := ModifySubnetAttribute(&ec2.ModifySubnetAttributeInput{SubnetId: aws.String("")}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestModifySubnetAttribute_NilNATS(t *testing.T) {
|
||||
_, err := ModifySubnetAttribute(&ec2.ModifySubnetAttributeInput{SubnetId: aws.String("subnet-123")}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// CreateSecurityGroup tests
|
||||
|
||||
func TestCreateSecurityGroup_NilInput(t *testing.T) {
|
||||
_, err := CreateSecurityGroup(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestCreateSecurityGroup_NilGroupName(t *testing.T) {
|
||||
_, err := CreateSecurityGroup(&ec2.CreateSecurityGroupInput{}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestCreateSecurityGroup_EmptyGroupName(t *testing.T) {
|
||||
_, err := CreateSecurityGroup(&ec2.CreateSecurityGroupInput{GroupName: aws.String("")}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestCreateSecurityGroup_NilNATS(t *testing.T) {
|
||||
_, err := CreateSecurityGroup(&ec2.CreateSecurityGroupInput{GroupName: aws.String("my-sg")}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// DeleteSecurityGroup tests
|
||||
|
||||
func TestDeleteSecurityGroup_NilInput(t *testing.T) {
|
||||
_, err := DeleteSecurityGroup(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestDeleteSecurityGroup_NilGroupId(t *testing.T) {
|
||||
_, err := DeleteSecurityGroup(&ec2.DeleteSecurityGroupInput{}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestDeleteSecurityGroup_EmptyGroupId(t *testing.T) {
|
||||
_, err := DeleteSecurityGroup(&ec2.DeleteSecurityGroupInput{GroupId: aws.String("")}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestDeleteSecurityGroup_NilNATS(t *testing.T) {
|
||||
_, err := DeleteSecurityGroup(&ec2.DeleteSecurityGroupInput{GroupId: aws.String("sg-123")}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// DescribeSecurityGroups tests
|
||||
|
||||
func TestDescribeSecurityGroups_NilInput(t *testing.T) {
|
||||
_, err := DescribeSecurityGroups(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestDescribeSecurityGroups_NilNATS(t *testing.T) {
|
||||
_, err := DescribeSecurityGroups(&ec2.DescribeSecurityGroupsInput{}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// AuthorizeSecurityGroupIngress tests
|
||||
|
||||
func TestAuthorizeSecurityGroupIngress_NilInput(t *testing.T) {
|
||||
_, err := AuthorizeSecurityGroupIngress(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestAuthorizeSecurityGroupIngress_NilGroupId(t *testing.T) {
|
||||
_, err := AuthorizeSecurityGroupIngress(&ec2.AuthorizeSecurityGroupIngressInput{}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestAuthorizeSecurityGroupIngress_EmptyGroupId(t *testing.T) {
|
||||
_, err := AuthorizeSecurityGroupIngress(&ec2.AuthorizeSecurityGroupIngressInput{GroupId: aws.String("")}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestAuthorizeSecurityGroupIngress_NilNATS(t *testing.T) {
|
||||
_, err := AuthorizeSecurityGroupIngress(&ec2.AuthorizeSecurityGroupIngressInput{GroupId: aws.String("sg-123")}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// AuthorizeSecurityGroupEgress tests
|
||||
|
||||
func TestAuthorizeSecurityGroupEgress_NilInput(t *testing.T) {
|
||||
_, err := AuthorizeSecurityGroupEgress(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestAuthorizeSecurityGroupEgress_NilGroupId(t *testing.T) {
|
||||
_, err := AuthorizeSecurityGroupEgress(&ec2.AuthorizeSecurityGroupEgressInput{}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestAuthorizeSecurityGroupEgress_EmptyGroupId(t *testing.T) {
|
||||
_, err := AuthorizeSecurityGroupEgress(&ec2.AuthorizeSecurityGroupEgressInput{GroupId: aws.String("")}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestAuthorizeSecurityGroupEgress_NilNATS(t *testing.T) {
|
||||
_, err := AuthorizeSecurityGroupEgress(&ec2.AuthorizeSecurityGroupEgressInput{GroupId: aws.String("sg-123")}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// RevokeSecurityGroupIngress tests
|
||||
|
||||
func TestRevokeSecurityGroupIngress_NilInput(t *testing.T) {
|
||||
_, err := RevokeSecurityGroupIngress(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestRevokeSecurityGroupIngress_NilGroupId(t *testing.T) {
|
||||
_, err := RevokeSecurityGroupIngress(&ec2.RevokeSecurityGroupIngressInput{}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestRevokeSecurityGroupIngress_EmptyGroupId(t *testing.T) {
|
||||
_, err := RevokeSecurityGroupIngress(&ec2.RevokeSecurityGroupIngressInput{GroupId: aws.String("")}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestRevokeSecurityGroupIngress_NilNATS(t *testing.T) {
|
||||
_, err := RevokeSecurityGroupIngress(&ec2.RevokeSecurityGroupIngressInput{GroupId: aws.String("sg-123")}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// RevokeSecurityGroupEgress tests
|
||||
|
||||
func TestRevokeSecurityGroupEgress_NilInput(t *testing.T) {
|
||||
_, err := RevokeSecurityGroupEgress(nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestRevokeSecurityGroupEgress_NilGroupId(t *testing.T) {
|
||||
_, err := RevokeSecurityGroupEgress(&ec2.RevokeSecurityGroupEgressInput{}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestRevokeSecurityGroupEgress_EmptyGroupId(t *testing.T) {
|
||||
_, err := RevokeSecurityGroupEgress(&ec2.RevokeSecurityGroupEgressInput{GroupId: aws.String("")}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestRevokeSecurityGroupEgress_NilNATS(t *testing.T) {
|
||||
_, err := RevokeSecurityGroupEgress(&ec2.RevokeSecurityGroupEgressInput{GroupId: aws.String("sg-123")}, nil, testAccountID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awsec2query"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
gateway_elbv2 "github.com/mulgadc/spinifex/spinifex/gateway/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
)
|
||||
|
||||
// ELBv2Handler processes parsed query args and returns XML response bytes.
|
||||
type ELBv2Handler func(action string, q map[string]string, gw *GatewayConfig, accountID string) ([]byte, error)
|
||||
|
||||
// elbv2Handler creates a type-safe ELBv2Handler that allocates the typed input struct,
|
||||
// parses query params into it, calls the handler, and marshals the output to XML.
|
||||
// ELBv2 uses the IAM-style XML envelope: <ActionResponse><ActionResult>...</ActionResult></ActionResponse>
|
||||
func elbv2Handler[In any](handler func(*In, *GatewayConfig, string) (any, error)) ELBv2Handler {
|
||||
return func(action string, q map[string]string, gw *GatewayConfig, accountID string) ([]byte, error) {
|
||||
input := new(In)
|
||||
if err := awsec2query.QueryParamsToStruct(q, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output, err := handler(input, gw, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := utils.GenerateIAMXMLPayload(action, output)
|
||||
xmlOutput, err := utils.MarshalToXML(payload)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to marshal response to XML")
|
||||
}
|
||||
return xmlOutput, nil
|
||||
}
|
||||
}
|
||||
|
||||
var elbv2Actions = map[string]ELBv2Handler{
|
||||
"CreateLoadBalancer": elbv2Handler(func(input *elbv2.CreateLoadBalancerInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.CreateLoadBalancer(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteLoadBalancer": elbv2Handler(func(input *elbv2.DeleteLoadBalancerInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.DeleteLoadBalancer(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeLoadBalancers": elbv2Handler(func(input *elbv2.DescribeLoadBalancersInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.DescribeLoadBalancers(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateTargetGroup": elbv2Handler(func(input *elbv2.CreateTargetGroupInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.CreateTargetGroup(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteTargetGroup": elbv2Handler(func(input *elbv2.DeleteTargetGroupInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.DeleteTargetGroup(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeTargetGroups": elbv2Handler(func(input *elbv2.DescribeTargetGroupsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.DescribeTargetGroups(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"RegisterTargets": elbv2Handler(func(input *elbv2.RegisterTargetsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.RegisterTargets(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeregisterTargets": elbv2Handler(func(input *elbv2.DeregisterTargetsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.DeregisterTargets(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeTargetHealth": elbv2Handler(func(input *elbv2.DescribeTargetHealthInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.DescribeTargetHealth(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateListener": elbv2Handler(func(input *elbv2.CreateListenerInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.CreateListener(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteListener": elbv2Handler(func(input *elbv2.DeleteListenerInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.DeleteListener(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeListeners": elbv2Handler(func(input *elbv2.DescribeListenersInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_elbv2.DescribeListeners(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
}
|
||||
|
||||
func (gw *GatewayConfig) ELBv2_Request(w http.ResponseWriter, r *http.Request) error {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
slog.Error("Failed to read ELBv2 request body", "error", err)
|
||||
return errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
queryArgs := ParseAWSQueryArgs(string(body))
|
||||
|
||||
action := queryArgs["Action"]
|
||||
handler, ok := elbv2Actions[action]
|
||||
if !ok {
|
||||
return errors.New(awserrors.ErrorInvalidAction)
|
||||
}
|
||||
|
||||
if err := gw.checkPolicy(r, "elasticloadbalancing", action); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if gw.NATSConn == nil {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
accountID, _ := r.Context().Value(ctxAccountID).(string)
|
||||
if accountID == "" {
|
||||
slog.Error("ELBv2_Request: no account ID in auth context")
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
xmlOutput, err := handler(action, queryArgs, gw, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write(xmlOutput); err != nil {
|
||||
slog.Error("Failed to write ELBv2 response", "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package gateway_elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// CreateListener handles the ELBv2 CreateListener API call.
|
||||
func CreateListener(input *elbv2.CreateListenerInput, natsConn *nats.Conn, accountID string) (elbv2.CreateListenerOutput, error) {
|
||||
var output elbv2.CreateListenerOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.LoadBalancerArn == nil || *input.LoadBalancerArn == "" {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
if len(input.DefaultActions) == 0 {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
|
||||
result, err := svc.CreateListener(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package gateway_elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// CreateLoadBalancer handles the ELBv2 CreateLoadBalancer API call.
|
||||
func CreateLoadBalancer(input *elbv2.CreateLoadBalancerInput, natsConn *nats.Conn, accountID string) (elbv2.CreateLoadBalancerOutput, error) {
|
||||
var output elbv2.CreateLoadBalancerOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.Name == nil || *input.Name == "" {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
|
||||
result, err := svc.CreateLoadBalancer(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package gateway_elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// CreateTargetGroup handles the ELBv2 CreateTargetGroup API call.
|
||||
func CreateTargetGroup(input *elbv2.CreateTargetGroupInput, natsConn *nats.Conn, accountID string) (elbv2.CreateTargetGroupOutput, error) {
|
||||
var output elbv2.CreateTargetGroupOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.Name == nil || *input.Name == "" {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
|
||||
result, err := svc.CreateTargetGroup(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package gateway_elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// DeleteListener handles the ELBv2 DeleteListener API call.
|
||||
func DeleteListener(input *elbv2.DeleteListenerInput, natsConn *nats.Conn, accountID string) (elbv2.DeleteListenerOutput, error) {
|
||||
var output elbv2.DeleteListenerOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.ListenerArn == nil || *input.ListenerArn == "" {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
|
||||
result, err := svc.DeleteListener(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package gateway_elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// DeleteLoadBalancer handles the ELBv2 DeleteLoadBalancer API call.
|
||||
func DeleteLoadBalancer(input *elbv2.DeleteLoadBalancerInput, natsConn *nats.Conn, accountID string) (elbv2.DeleteLoadBalancerOutput, error) {
|
||||
var output elbv2.DeleteLoadBalancerOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.LoadBalancerArn == nil || *input.LoadBalancerArn == "" {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
|
||||
result, err := svc.DeleteLoadBalancer(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package gateway_elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// DeleteTargetGroup handles the ELBv2 DeleteTargetGroup API call.
|
||||
func DeleteTargetGroup(input *elbv2.DeleteTargetGroupInput, natsConn *nats.Conn, accountID string) (elbv2.DeleteTargetGroupOutput, error) {
|
||||
var output elbv2.DeleteTargetGroupOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.TargetGroupArn == nil || *input.TargetGroupArn == "" {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
|
||||
result, err := svc.DeleteTargetGroup(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package gateway_elbv2
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// DeregisterTargets handles the ELBv2 DeregisterTargets API call.
|
||||
func DeregisterTargets(input *elbv2.DeregisterTargetsInput, natsConn *nats.Conn, accountID string) (elbv2.DeregisterTargetsOutput, error) {
|
||||
var output elbv2.DeregisterTargetsOutput
|
||||
|
||||
if input == nil {
|
||||
return output, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.TargetGroupArn == nil || *input.TargetGroupArn == "" {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
if len(input.Targets) == 0 {
|
||||
return output, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
svc := handlers_elbv2.NewNATSELBv2Service(natsConn)
|
||||
result, err := svc.DeregisterTargets(input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user