Compare commits

..

9 Commits

Author SHA1 Message Date
Julian Sommer 3b7b781bdf release: update go.mod for v1.12.1 2026-07-15 09:29:22 +10:00
Josh Siviour 92b775951b fix(network): poll second-VPC transit SNAT in natuplink + log AddSNAT install (#541)
Phase 8 of TestNATUplink checked the second VPC router's lr-nat-list with a
single non-polling assert; the igw-default-snat row commits a beat after the
gateway LRP appears, so the read raced the OVN commit and intermittently saw
an empty list (flaky CI red). Wrap it in EventuallyErr, matching the sibling
LRP and host-route polls in the same phase.

AddSNAT logged only on the idempotent-skip path, so the first install was
silent and RCA could not tell a late SNAT from a missing one. Emit an install
log keyed by router + vpc_cidr + external_ip.
2026-07-15 09:26:15 +10:00
Josh Siviour 0a8e6bca15 fix(admin): always pin mgmt-bridge IP in server cert SAN (#540)
Server-cert IP SANs were built solely from live interface enumeration
(DiscoverLocalIPs) plus bindIP, so the canonical mgmt-bridge IP entered
the SAN set only when br-mgmt happened to be up at cert-generation time.
On a host where br-mgmt is down when the cert is minted, 10.15.8.1 was
silently dropped and the EKS control-plane publish to https://10.15.8.1
failed cert verify, leaving the cluster stuck CREATING.

Pin config.DefaultMgmtBridgeIP into the extra SANs for both the init and
join cert paths so publish succeeds regardless of interface state at
generation time. Add a regression test asserting the SAN is present when
br-mgmt is not a live interface.
2026-07-15 09:00:35 +10:00
Josh Siviour d3ac8c0656 test(eks): in-cluster IRSA pod e2e (AssumeRoleWithWebIdentity) (#535)
Add an IRSAPod subtest that applies a real pod wired for IRSA — a projected
ServiceAccount token (aud sts.amazonaws.com) plus the AWS_ROLE_ARN /
AWS_WEB_IDENTITY_TOKEN_FILE / AWS_ENDPOINT_URL_STS / AWS_CA_BUNDLE env the
addon-sync agent injects — and asserts the pod's in-cluster
aws sts get-caller-identity resolves to the assumed IRSA role. This closes the
gap left by IRSAWebIdentity, which only exchanges the token host-side.

Extract registerOIDCRole (shared with IRSAWebIdentity); the pod runs on a
1-node worker nodegroup (the control-plane node is CriticalAddonsOnly-tainted)
and reaches awsgw STS over the worker subnet NAT.
2026-07-15 08:31:09 +10:00
joshsiv-mulga 1281381096 fix(ci): publish-system-image.sh honors SYSTEM_IMAGE_BUILD_DIR
The build script derives BUILD_DIR from SYSTEM_IMAGE_BUILD_DIR (the AMI
publish workflow points it off tmpfs so the 16G Ubuntu GPU raw fits), but
the publish step hardcoded /tmp and could not find the freshly built raw.
GPU legs failed 'Raw image not found'; Alpine legs silently reused stale
pre-existing /tmp raws on the persistent runner. Mirror the build script's
derivation so publish looks where the build actually wrote.
2026-07-15 07:54:21 +10:00
Josh Siviour b41a193cbc fix(eks): make provider-id.sh the single k3s node-label authority for nodegroup + GPU labels (#539) 2026-07-15 00:39:13 +10:00
Julian Sommer 53f636692d fix(network): scrub stale dnat_and_snat by logical IP on EIP bind (#538) 2026-07-14 16:17:59 +10:00
Julian Sommer b40d538c61 refactor(admin): remove dead --no-external flag from spx admin init (#537) 2026-07-14 15:50:13 +10:00
joshsiv-mulga b147e2b869 ci: make Setup Go Workspace idempotent on ci-single runner
The publish-eks-ami matrix fans out four legs on the single persistent
ci-single runner, all reusing one workspace root. go work init is
create-only and checkout never scrubs the root go.work, so legs after the
first fail with 'go.work already exists'. Remove any stale go.work before
init so each leg re-inits cleanly.
2026-07-14 15:35:55 +10:00
22 changed files with 631 additions and 176 deletions
+7 -1
View File
@@ -105,7 +105,13 @@ jobs:
cache: false
- name: Setup Go Workspace
run: go work init ./spinifex ./viperblock ./predastore
# Idempotent: the ci-single runner reuses one workspace across matrix
# legs and runs, and checkout never scrubs the root go.work. Remove any
# stale file so each leg re-inits cleanly instead of failing on
# "go.work already exists".
run: |
rm -f go.work go.work.sum
go work init ./spinifex ./viperblock ./predastore
- name: Install build + upload tooling
run: |
+43 -48
View File
@@ -334,7 +334,6 @@ func init() {
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)")
adminInitCmd.Flags().Int("external-prefix-len", 24, "External pool subnet prefix length (auto-detected)")
adminInitCmd.Flags().Bool("no-external", false, "Disable external networking (overlay-only, no internet for VMs)")
adminInitCmd.Flags().Bool("gpu-passthrough", false, "Enable VFIO GPU passthrough (sets gpu_passthrough = true in daemon config)")
adminInitCmd.Flags().Bool("ipsec", true, "Encrypt intra-AZ Geneve via OVN native IPsec (cluster-wide); disable only for trusted single-rack lab")
@@ -1018,7 +1017,6 @@ func runAdminInit(cmd *cobra.Command, args []string) {
externalGateway, _ := cmd.Flags().GetString("external-gateway")
externalPrefixLen, _ := cmd.Flags().GetInt("external-prefix-len")
gatewayIP, _ := cmd.Flags().GetString("gateway-ip")
noExternal, _ := cmd.Flags().GetBool("no-external")
gpuPassthrough, _ := cmd.Flags().GetBool("gpu-passthrough")
ipsecEnabled, _ := cmd.Flags().GetBool("ipsec")
@@ -1051,59 +1049,56 @@ func runAdminInit(cmd *cobra.Command, args []string) {
// Auto-detect network topology
var poolStart, poolEnd string
var detectedNet *admin.DetectedNetwork
if !noExternal {
detected, err := admin.DetectNetwork()
if err != nil {
fmt.Fprintf(os.Stderr, "⚠️ Network auto-detection failed: %v\n", err)
fmt.Fprintf(os.Stderr, " Use --no-external to skip, or specify flags manually.\n")
detected, err := admin.DetectNetwork()
if err != nil {
fmt.Fprintf(os.Stderr, "⚠️ Network auto-detection failed: %v\n", err)
fmt.Fprintf(os.Stderr, " Use --external-mode=nat for outbound-only VMs on a non-bridgeable uplink, or specify --external-* flags manually.\n")
} else {
detectedNet = detected
// Print detected topology
fmt.Println("\n🔍 Detected network topology:")
fmt.Printf(" %-14s %-18s %-20s %-16s %s\n", "Interface", "IP", "Subnet", "Gateway", "Role")
for _, iface := range detected.Interfaces {
gw := "—"
if iface.Gateway != "" {
gw = iface.Gateway
}
fmt.Printf(" %-14s %-18s %-20s %-16s %s\n", iface.Name, iface.IP, iface.Subnet, gw, strings.ToUpper(iface.Role))
}
if detected.LANCount == 0 {
fmt.Println("\n Mode: single-NIC (veth-bridged external)")
} else {
detectedNet = detected
fmt.Printf("\n Mode: %d LAN + 1 WAN (veth-bridged external)\n", detected.LANCount)
}
// Print detected topology
fmt.Println("\n🔍 Detected network topology:")
fmt.Printf(" %-14s %-18s %-20s %-16s %s\n", "Interface", "IP", "Subnet", "Gateway", "Role")
for _, iface := range detected.Interfaces {
gw := "—"
if iface.Gateway != "" {
gw = iface.Gateway
}
fmt.Printf(" %-14s %-18s %-20s %-16s %s\n", iface.Name, iface.IP, iface.Subnet, gw, strings.ToUpper(iface.Role))
// Apply auto-detected values when flags not explicitly set
if detected.WAN != nil {
if externalIface == "" {
externalIface = detected.WAN.Name
}
if detected.LANCount == 0 {
fmt.Println("\n Mode: single-NIC (veth-bridged external)")
} else {
fmt.Printf("\n Mode: %d LAN + 1 WAN (veth-bridged external)\n", detected.LANCount)
if externalGateway == "" {
externalGateway = detected.WAN.Gateway
}
if !cmd.Flags().Changed("external-prefix-len") {
externalPrefixLen = detected.WAN.PrefixLen
}
// Apply auto-detected values when flags not explicitly set
if detected.WAN != nil {
if externalIface == "" {
externalIface = detected.WAN.Name
}
if externalGateway == "" {
externalGateway = detected.WAN.Gateway
}
if !cmd.Flags().Changed("external-prefix-len") {
externalPrefixLen = detected.WAN.PrefixLen
}
// Default mode: always "pool". Source defaults to "static"; if
// --external-pool is omitted the validator below will error with
// a SuggestPoolRange hint.
if externalMode == "" && !cmd.Flags().Changed("external-mode") {
if isNonBridgeableUplink(detected.WAN.Name) {
fmt.Fprintf(os.Stderr, "\n❌ Detected WAN interface %s cannot be bridged (WiFi/cellular/PPP).\n", detected.WAN.Name)
fmt.Fprintf(os.Stderr, " Use routed NAT mode instead (outbound-only VM networking):\n")
fmt.Fprintf(os.Stderr, " ./scripts/setup-ovn.sh --management --nat-uplink\n")
fmt.Fprintf(os.Stderr, " spx admin init --external-mode=nat\n")
os.Exit(1)
}
externalMode = "pool"
// Default mode: always "pool". Source defaults to "static"; if
// --external-pool is omitted the validator below will error with
// a SuggestPoolRange hint.
if externalMode == "" && !cmd.Flags().Changed("external-mode") {
if isNonBridgeableUplink(detected.WAN.Name) {
fmt.Fprintf(os.Stderr, "\n❌ Detected WAN interface %s cannot be bridged (WiFi/cellular/PPP).\n", detected.WAN.Name)
fmt.Fprintf(os.Stderr, " Use routed NAT mode instead (outbound-only VM networking):\n")
fmt.Fprintf(os.Stderr, " ./scripts/setup-ovn.sh --management --nat-uplink\n")
fmt.Fprintf(os.Stderr, " spx admin init --external-mode=nat\n")
os.Exit(1)
}
externalMode = "pool"
}
}
}
// Validate external networking flags
if externalMode != "" && externalMode != "pool" && externalMode != "nat" {
fmt.Fprintf(os.Stderr, "❌ Error: --external-mode must be 'pool', 'nat', or empty, got: %s\n", externalMode)
@@ -1209,8 +1204,8 @@ func runAdminInit(cmd *cobra.Command, args []string) {
os.Exit(1)
}
// Resolve the off-host advertise IP. DetectNetwork may have been skipped
// when --no-external is set; detect lazily if we need the WAN IP.
// Resolve the off-host advertise IP. DetectNetwork may have failed earlier;
// detect lazily if we still need the WAN IP.
if advertiseFlag == "" && (bindIP == "0.0.0.0" || bindIP == "127.0.0.1") && detectedNet == nil {
if d, derr := admin.DetectNetwork(); derr == nil {
detectedNet = d
+1 -1
View File
@@ -54,7 +54,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| Command | Flags | Description |
|---------|-------|-------------|
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--services`, `--formation-timeout`, `--token-ttl`, `--force`, `--external-mode` (`pool` \| `nat` routed NAT for non-bridgeable uplinks, single-node; pair with `setup-ovn.sh --nat-uplink`; add `--external-pool start-end --external-gateway <ip>` or `--external-source=dhcp [--external-bind-bridge <iface>]` for a public pool with full EIP support; note: system instances (ECS/EKS/load-balancer agents) are unsupported in nat v1 — they require `pool` mode), `--no-external` | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → generates join token (written to `join-token` file, displayed in join command) → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/`. `--force` on an already-initialized node is idempotent for crypto: it preserves the existing keys (master.key, predastore/viperblock encryption keys), system + admin credentials, and CA, and refreshes only the config files and the CA-signed server certificate (so a changed bind IP / SANs is picked up without breaking already-joined nodes or CA-baked AMIs). A genuine clean slate requires removing the data dirs first. |
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--services`, `--formation-timeout`, `--token-ttl`, `--force`, `--external-mode` (`pool` \| `nat` routed NAT for non-bridgeable uplinks, single-node; pair with `setup-ovn.sh --nat-uplink`; add `--external-pool start-end --external-gateway <ip>` or `--external-source=dhcp [--external-bind-bridge <iface>]` for a public pool with full EIP support; note: system instances (ECS/EKS/load-balancer agents) are unsupported in nat v1 — they require `pool` mode) | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → generates join token (written to `join-token` file, displayed in join command) → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/`. `--force` on an already-initialized node is idempotent for crypto: it preserves the existing keys (master.key, predastore/viperblock encryption keys), system + admin credentials, and CA, and refreshes only the config files and the CA-signed server certificate (so a changed bind IP / SANs is picked up without breaking already-joined nodes or CA-baked AMIs). A genuine clean slate requires removing the data dirs first. |
| `spx admin join` | `--host` (required), `--node` (required), `--token` (required), `--bind`, `--port`, `--region`, `--az`, `--cluster-bind`, `--cluster-routes`, `--data-dir`, `--services` | Connects to leader node with join token (Authorization: Bearer header) → retrieves cluster configuration → configures local node to join cluster and participate in distributed operations |
### Version
+2 -2
View File
@@ -18,8 +18,8 @@ require (
github.com/google/uuid v1.6.0
github.com/insomniacslk/dhcp v0.0.0-20260407060928-11b94ed970f2
github.com/klauspost/cpuid/v2 v2.4.0
github.com/mulgadc/predastore v1.12.0
github.com/mulgadc/viperblock v1.12.0
github.com/mulgadc/predastore v1.12.1
github.com/mulgadc/viperblock v1.12.1
github.com/nats-io/nats-server/v2 v2.14.3
github.com/nats-io/nats.go v1.52.0
github.com/opencontainers/runtime-spec v1.3.0
+4 -4
View File
@@ -1315,10 +1315,10 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/mulgadc/predastore v1.12.0 h1:JmoF//cIr3DgJhGypNVVLtaBZGCVUDzOo6z63407x54=
github.com/mulgadc/predastore v1.12.0/go.mod h1:kgTncuCaB11e3eCQeZF0ydEFG0biuOlrZ4DlHNzwvZY=
github.com/mulgadc/viperblock v1.12.0 h1:1PYG7GQ1cBbHbLI7QB7IzVu6EeKhrtcAJ+VAvNczjs8=
github.com/mulgadc/viperblock v1.12.0/go.mod h1:5MGciKEvqpwLv2pdjk9j7PR8NHtXPs4pJzJi4UJojeU=
github.com/mulgadc/predastore v1.12.1 h1:mkJqmUbxfI3kBRmtBecjvBsRLFpnKARGWtmx4b9XKKg=
github.com/mulgadc/predastore v1.12.1/go.mod h1:kgTncuCaB11e3eCQeZF0ydEFG0biuOlrZ4DlHNzwvZY=
github.com/mulgadc/viperblock v1.12.1 h1:DWMbzPhIkYzdhOy4sn/D0/BJWJ5uhBoKWbVMfS7DMl4=
github.com/mulgadc/viperblock v1.12.1/go.mod h1:5MGciKEvqpwLv2pdjk9j7PR8NHtXPs4pJzJi4UJojeU=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
@@ -20,8 +20,9 @@ TasksMax=infinity
LimitNOFILE=1048576
LimitNPROC=infinity
LimitCORE=infinity
# Cloud-init drops K3S_URL/K3S_TOKEN/K3S_NODE_NAME/K3S_NODE_LABEL here at
# RunInstances time per the Nodegroup bootstrap flow.
# Cloud-init drops K3S_URL/K3S_TOKEN/K3S_NODE_NAME (plus SPINIFEX_NODEGROUP and,
# for GPU workers, SPINIFEX_GPU_NODE) here at RunInstances time per the Nodegroup
# bootstrap flow; mulga-eks-provider-id reads the SPINIFEX_* vars for node-labels.
EnvironmentFile=-/etc/spinifex-eks/agent.env
# Tee k3s (and its embedded kubelet's klog) stderr to the serial console as well
# as journald: Ubuntu logs to journald only, which is off-box-invisible on a
@@ -1,6 +1,13 @@
[Unit]
Description=Set K3s provider-id + topology labels from IMDS for EBS CSI
After=network-online.target
# After cloud-config.service so cloud-init has written /etc/spinifex-eks/agent.env
# (write_files completes by the config stage): this script folds the nodegroup +
# GPU node-labels (read from agent.env) into the same node-label list, so agent.env
# must exist before it runs. NOT cloud-final.service: that unit is ordered after
# multi-user.target and k3s-agent.service (ordered After this unit) is
# WantedBy=multi-user.target, so chaining onto cloud-final forms an ordering cycle
# that makes systemd skip both this unit and k3s-agent, so the worker never joins.
After=network-online.target cloud-config.service
Wants=network-online.target
Before=k3s-agent.service
@@ -1,6 +1,7 @@
# nvidia-device-plugin 0.17.4 — CDI-mode DaemonSet exposing nvidia.com/gpu
# allocatable on GPU-tainted nodes. Gated to GPU nodes via nodeSelector on the
# K3S_NODE_LABEL nvidia.com/gpu.present=true (nodegroup_userdata.go); inert
# node-label nvidia.com/gpu.present=true (written by mulga-eks-provider-id from
# the SPINIFEX_GPU_NODE agent.env signal); inert
# elsewhere. --device-list-strategy=cdi-cri makes the plugin generate a CDI spec
# for GPU workloads (containerd 2.x resolves the CRI-passed CDI devices; no
# enable_cdi drop-in needed on k3s v1.32.5). The plugin's OWN container needs
@@ -2,14 +2,16 @@
# mulga-eks-provider-id — boot oneshot that writes a K3s drop-in from IMDS
# before K3s starts: the kubelet provider-id plus region/zone topology labels,
# the node metadata the aws-ebs-csi-driver needs for NodeGetInfo + provisioning.
# Ordered after local (which installs the IMDS on-link route) and before both
# K3s roles. Role-agnostic: enabled on every node (server + agent).
# Also folds the worker's nodegroup + GPU node-labels (from agent.env) into the
# same node-label list, so it must run after cloud-init has written agent.env
# (cloud-config/cloud-final stages, same as eks-node-role) and before the role
# selector starts K3s. Role-agnostic: enabled on every node (server + agent).
description="Set K3s provider-id + topology labels from IMDS for EBS CSI"
depend() {
after net local
before k3s k3s-agent
after net local cloud-config cloud-final
before k3s k3s-agent eks-node-role
}
start() {
@@ -17,13 +17,22 @@ set -eu
# family"). This drop-in supplies all four; region is derived from the AZ
# (trailing zone letters stripped).
#
# K3s merges /etc/rancher/k3s/config.yaml.d/*.yaml over config.yaml; list keys
# such as kubelet-arg are concatenated, so this adds the flag without touching
# the cloud-init-written main config. Role-agnostic: runs on server + agent.
# This drop-in is also the SINGLE writer of the node-label list. K3s replaces
# (not merges) node-label across config.yaml.d files, so the last-sorted file
# wins outright and any other node-label drop-in would silently wipe the rest.
# The worker's nodegroup label (and, on GPU workers, the nvidia.com/gpu.present
# label + NoSchedule taint) therefore ride here too, read from agent.env, so
# they share the one list with the topology labels. On the K3s server there is
# no agent.env, so only the topology labels are written — behavior unchanged.
#
# kubelet-arg lists ARE concatenated by k3s across drop-ins, so the provider-id
# flag coexists with other files' kubelet-arg without touching the main config.
# Role-agnostic: runs on server + agent.
IMDS="http://169.254.169.254/latest"
DROPIN_DIR=/etc/rancher/k3s/config.yaml.d
DROPIN="${DROPIN_DIR}/10-provider-id.yaml"
AGENT_ENV=/etc/spinifex-eks/agent.env
LOGTAG="mulga-eks-provider-id"
log() { echo "[${LOGTAG}] $*"; }
@@ -79,13 +88,32 @@ case "${itype}" in
*) itype="m5.large" ;;
esac
# Fold the worker's nodegroup + GPU node-labels into the same list. agent.env is
# written by cloud-init and absent on the server; source it only if present.
nodegroup=""
gpu_node=""
if [ -f "${AGENT_ENV}" ]; then
# shellcheck disable=SC1090
. "${AGENT_ENV}"
nodegroup="${SPINIFEX_NODEGROUP:-}"
gpu_node="${SPINIFEX_GPU_NODE:-}"
fi
mkdir -p "${DROPIN_DIR}"
cat > "${DROPIN}" <<EOF
kubelet-arg:
- "provider-id=aws:///${az}/${iid}"
node-label:
- "topology.kubernetes.io/region=${region}"
- "topology.kubernetes.io/zone=${az}"
- "node.kubernetes.io/instance-type=${itype}"
EOF
log "wrote provider-id aws:///${az}/${iid}, region=${region} zone=${az} type=${itype}"
{
echo "kubelet-arg:"
echo " - \"provider-id=aws:///${az}/${iid}\""
echo "node-label:"
echo " - \"topology.kubernetes.io/region=${region}\""
echo " - \"topology.kubernetes.io/zone=${az}\""
echo " - \"node.kubernetes.io/instance-type=${itype}\""
if [ -n "${nodegroup}" ]; then
echo " - \"eks.amazonaws.com/nodegroup=${nodegroup}\""
fi
if [ "${gpu_node}" = "true" ]; then
echo " - \"nvidia.com/gpu.present=true\""
echo "node-taint:"
echo " - \"nvidia.com/gpu=present:NoSchedule\""
fi
} > "${DROPIN}"
log "wrote provider-id aws:///${az}/${iid}, region=${region} zone=${az} type=${itype} nodegroup=${nodegroup:-<none>} gpu=${gpu_node:-false}"
+4 -1
View File
@@ -67,7 +67,10 @@ if [[ -z "${IMAGE_NAME:-}" ]]; then
fi
# Mirror build-system-image.sh's derived paths so we reuse its cached raw.
BUILD_DIR="/tmp/${IMAGE_NAME}-image-build"
# Honor SYSTEM_IMAGE_BUILD_DIR exactly as the build script does — the workflow
# points it off tmpfs so the 16G Ubuntu GPU raw fits, and the publish step must
# look where the build actually wrote, not a hardcoded /tmp.
BUILD_DIR="${SYSTEM_IMAGE_BUILD_DIR:-/tmp}/${IMAGE_NAME}-image-build"
RAW_IMAGE="${BUILD_DIR}/${IMAGE_NAME}-${DISTRO}.raw"
OBJECT_BASE="${AMI_NAME:-$IMAGE_NAME}-${ARCH}"
+8 -2
View File
@@ -21,6 +21,7 @@ import (
"text/template"
"time"
"github.com/mulgadc/spinifex/spinifex/config"
toml "github.com/pelletier/go-toml/v2"
"gopkg.in/ini.v1"
)
@@ -219,7 +220,10 @@ func GenerateCertificatesIfNeeded(configDir string, force bool, bindIP string, a
if force || !FileExists(serverCertPath) || !FileExists(serverKeyPath) {
extraDNS := AWSGWServiceDNSNames(awsRegion, internalSuffix)
if err := GenerateSignedCert(serverCertPath, serverKeyPath, caCertPath, caKeyPath, []string{bindIP}, extraDNS); err != nil {
// Always pin the canonical mgmt-bridge IP; the control plane publishes to
// it regardless of whether br-mgmt is up when this cert is minted.
extraIPs := []string{bindIP, config.DefaultMgmtBridgeIP}
if err := GenerateSignedCert(serverCertPath, serverKeyPath, caCertPath, caKeyPath, extraIPs, extraDNS); err != nil {
fmt.Fprintf(os.Stderr, "Error generating server certificate: %v\n", err)
os.Exit(1)
}
@@ -247,7 +251,9 @@ func GenerateServerCertOnly(configDir string, bindIP, awsRegion, internalSuffix
}
extraDNS := AWSGWServiceDNSNames(awsRegion, internalSuffix)
return GenerateSignedCert(serverCertPath, serverKeyPath, caCertPath, caKeyPath, []string{bindIP}, extraDNS)
// Always pin the canonical mgmt-bridge IP (see GenerateCertificatesIfNeeded).
extraIPs := []string{bindIP, config.DefaultMgmtBridgeIP}
return GenerateSignedCert(serverCertPath, serverKeyPath, caCertPath, caKeyPath, extraIPs, extraDNS)
}
func CreateServiceDirectories(spxRoot string) {
+33
View File
@@ -11,10 +11,28 @@ import (
"strings"
"testing"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// certHasIP reports whether a PEM-encoded cert carries ip as an IP SAN.
func certHasIP(t *testing.T, certPath, ip string) bool {
t.Helper()
certPEM, err := os.ReadFile(certPath)
require.NoError(t, err)
block, _ := pem.Decode(certPEM)
require.NotNil(t, block)
cert, err := x509.ParseCertificate(block.Bytes)
require.NoError(t, err)
for _, got := range cert.IPAddresses {
if got.Equal(net.ParseIP(ip)) {
return true
}
}
return false
}
// --- Key / Token generation ---
func TestGenerateAWSAccessKey_Format(t *testing.T) {
@@ -672,6 +690,17 @@ func TestGenerateCertificatesIfNeeded(t *testing.T) {
_, err = srvCert.Verify(x509.VerifyOptions{Roots: pool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}})
assert.NoError(t, err, "re-signed server cert must verify against preserved CA")
})
// The mgmt-bridge IP must be a SAN even though br-mgmt is not a live
// interface here (interface enumeration cannot discover it), mirroring a
// host where br-mgmt is down when the cert is minted. Without the explicit
// pin the control-plane publish to https://<mgmt-ip> would fail cert verify.
t.Run("MgmtBridgeIPAlwaysInSAN", func(t *testing.T) {
certDir := t.TempDir()
GenerateCertificatesIfNeeded(certDir, false, "10.0.0.5", "us-east-1", "spinifex.internal")
assert.True(t, certHasIP(t, filepath.Join(certDir, "server.pem"), config.DefaultMgmtBridgeIP),
"server cert must carry the canonical mgmt-bridge IP SAN regardless of br-mgmt state")
})
}
func TestGenerateServerCertOnly(t *testing.T) {
@@ -708,6 +737,10 @@ func TestGenerateServerCertOnly(t *testing.T) {
// AWS-parity ECR SANs must be present.
assert.Contains(t, cert.DNSNames, "ecr.us-east-1.spinifex.internal")
assert.Contains(t, cert.DNSNames, "*.dkr.ecr.us-east-1.spinifex.internal")
// Joining nodes must also pin the mgmt-bridge IP regardless of br-mgmt state.
assert.True(t, certHasIP(t, filepath.Join(dir, "server.pem"), config.DefaultMgmtBridgeIP),
"server cert must carry the canonical mgmt-bridge IP SAN")
})
t.Run("MissingCA", func(t *testing.T) {
+7
View File
@@ -27,6 +27,13 @@ const (
DefaultAWSInternalSuffix = "spinifex.internal"
)
// DefaultMgmtBridgeIP is the canonical br-mgmt host address the control plane
// advertises: the EKS gateway URL, predastore endpoint, and node-group userdata
// all target it. Kept in sync with setup-ovn.sh MGMT_CIDR. Server certs must
// always carry this as a SAN so publish succeeds even when br-mgmt happens to be
// down at cert-generation time (interface enumeration would otherwise miss it).
const DefaultMgmtBridgeIP = "10.15.8.1"
// AWSConfig holds cluster-wide AWS-parity settings shared across services.
// Region scopes the default AWS region; InternalSuffix is the internal DNS
// suffix used to build service endpoints (e.g. ecr.{region}.{suffix}).
+1 -1
View File
@@ -72,7 +72,7 @@ const (
k3sSnapshotEnvPath = "/etc/spinifex-eks/etcd-snapshot.env"
// agentEnvPath is the env file the k3s-agent OpenRC service sources for
// K3S_URL/K3S_TOKEN/K3S_NODE_NAME/K3S_NODE_LABEL.
// K3S_URL/K3S_TOKEN/K3S_NODE_NAME.
agentEnvPath = "/etc/spinifex-eks/agent.env"
// k3sGatewayCAPath is the on-VM gateway TLS CA cert PEM path.
+1 -1
View File
@@ -286,7 +286,7 @@ func TestBuildAgentUserData_Shape(t *testing.T) {
assert.Contains(t, ud, "K3S_URL=https://10.254.0.9:443")
assert.Contains(t, ud, "K3S_TOKEN=K10secret::server:xyz")
assert.Contains(t, ud, "K3S_NODE_NAME=c1-ng1-abc123de")
assert.Contains(t, ud, "eks.amazonaws.com/nodegroup=ng1")
assert.Contains(t, ud, "SPINIFEX_NODEGROUP=ng1")
assert.Contains(t, ud, agentEnvPath)
// IMDS is served at the host tap, so no in-guest on-link route is emitted.
assert.NotContains(t, ud, "169.254.169.254")
+17 -31
View File
@@ -61,9 +61,12 @@ func buildAgentUserData(in agentUserDataInput) string {
}, "\n")
// The ECR credential provider reads /etc/spinifex-eks/agent.env, so the EKS_*
// settings live alongside the K3s join vars here. The nodegroup label rides a
// node-label config drop-in below, not K3S_NODE_LABEL (k3s has no such env).
agentBody := strings.Join([]string{
// settings live alongside the K3s join vars here. SPINIFEX_NODEGROUP (and
// SPINIFEX_GPU_NODE for GPU workers) are consumed by mulga-eks-provider-id.sh,
// the single writer of the k3s node-label list: k3s replaces (not merges) the
// node-label list across config.yaml.d files, so all node-labels must come
// from one drop-in or the last-sorted file silently wipes the rest.
agentLines := []string{
"K3S_URL=" + k3sURL,
"K3S_TOKEN=" + in.JoinToken,
"K3S_NODE_NAME=" + in.NodeName,
@@ -71,7 +74,12 @@ func buildAgentUserData(in agentUserDataInput) string {
"EKS_GATEWAY_CA=" + k3sGatewayCAPath,
"EKS_REGION=" + in.Region,
"EKS_ACCOUNT_ID=" + in.AccountID,
}, "\n")
"SPINIFEX_NODEGROUP=" + in.NodegroupName,
}
if in.GPUEnabled {
agentLines = append(agentLines, "SPINIFEX_GPU_NODE=true")
}
agentBody := strings.Join(agentLines, "\n")
// The mirror endpoint is dialed by IP when the gateway IP is known: the gateway
// cert carries an IP SAN, so TLS validates without DNS, and there is no internal
@@ -124,42 +132,20 @@ func buildAgentUserData(in agentUserDataInput) string {
)
credProviderConfig := strings.Join(credLines, "\n")
// The eks.amazonaws.com/nodegroup label must ride a k3s node-label config
// drop-in: k3s has no K3S_NODE_LABEL env, so agent.env cannot set it. The
// nodegroup readiness gate (waitWorkersReady) buckets Ready nodes by this
// label, so a worker without it never counts toward its nodegroup's ACTIVE
// transition. k3s concatenates node-label lists across config.yaml.d drop-ins.
nodegroupLabelDropin := strings.Join([]string{
"node-label:",
" - \"eks.amazonaws.com/nodegroup=" + in.NodegroupName + "\"",
}, "\n")
// buildAgentUserData writes NO config.yaml.d node-label/node-taint drop-in:
// mulga-eks-provider-id.sh (AMI-baked) is the sole node-label writer. It reads
// SPINIFEX_NODEGROUP/SPINIFEX_GPU_NODE from agent.env and folds the nodegroup
// label (plus nvidia.com/gpu.present + the GPU NoSchedule taint) into the same
// 10-provider-id.yaml node-label list it writes for the EBS CSI topology labels.
files := []userDataFile{
{Path: k3sFirstBootEnvPath, Perms: "0600", Body: envBody},
{Path: agentEnvPath, Perms: "0600", Body: agentBody},
{Path: k3sGatewayCAPath, Perms: "0644", Body: strings.TrimRight(in.GatewayCACert, "\n")},
{Path: "/etc/rancher/k3s/registries.yaml", Perms: "0644", Body: registriesYAML},
{Path: "/etc/rancher/k3s/config.yaml.d/20-ecr-credential-provider.yaml", Perms: "0644", Body: credProviderDropin},
{Path: "/etc/rancher/k3s/config.yaml.d/15-nodegroup-label.yaml", Perms: "0644", Body: nodegroupLabelDropin},
{Path: "/etc/spinifex-eks/credential-provider-config.yaml", Perms: "0644", Body: credProviderConfig},
}
// GPU nodes advertise nvidia.com/gpu.present=true (so the device-plugin
// DaemonSet nodeSelector matches) and default to NoSchedule so ordinary
// workloads don't consume scarce GPU capacity. Both ride a separate
// node-label/node-taint drop-in; k3s merges it over the nodegroup-label one.
if in.GPUEnabled {
gpuDropin := strings.Join([]string{
"node-label:",
" - \"nvidia.com/gpu.present=true\"",
"node-taint:",
" - \"nvidia.com/gpu=present:NoSchedule\"",
}, "\n")
files = append(files, userDataFile{
Path: "/etc/rancher/k3s/config.yaml.d/20-gpu.yaml", Perms: "0644", Body: gpuDropin,
})
}
var buf strings.Builder
buf.WriteString("#cloud-config\n")
@@ -96,31 +96,36 @@ func baseTestInput() agentUserDataInput {
}
}
func TestBuildAgentUserData_GPUEnabledAddsLabelAndTaint(t *testing.T) {
func TestBuildAgentUserData_GPUEnabledSignalsNodegroupAndGPU(t *testing.T) {
in := baseTestInput()
in.GPUEnabled = true
in.GPUVendor = "nvidia"
got := buildAgentUserData(in)
// The label must ride a node-label config drop-in, not K3S_NODE_LABEL (k3s
// has no such env var — it would be a no-op and the device-plugin
// nodeSelector would never match).
if !strings.Contains(got, "/etc/rancher/k3s/config.yaml.d/20-gpu.yaml") {
t.Errorf("expected GPU drop-in path, got:\n%s", got)
// buildAgentUserData writes NO node-label/node-taint config drop-in:
// mulga-eks-provider-id.sh is the single node-label writer (k3s replaces,
// not merges, node-label across config.yaml.d files). The worker signals its
// nodegroup + GPU role via agent.env; the AMI script folds the labels + taint
// into the one node-label list it already writes for the topology labels.
if strings.Contains(got, "config.yaml.d/10-nodegroup.yaml") {
t.Errorf("must not write a nodegroup node-label drop-in, got:\n%s", got)
}
if !strings.Contains(got, "node-label:") || !strings.Contains(got, "nvidia.com/gpu.present=true") {
t.Errorf("expected GPU node-label drop-in value, got:\n%s", got)
if strings.Contains(got, "node-label:") || strings.Contains(got, "node-taint:") {
t.Errorf("user-data must not carry any node-label/node-taint drop-in, got:\n%s", got)
}
if !strings.Contains(got, "nvidia.com/gpu=present:NoSchedule") {
t.Errorf("expected GPU taint value, got:\n%s", got)
if !strings.Contains(got, "SPINIFEX_NODEGROUP=ng-1") {
t.Errorf("expected SPINIFEX_NODEGROUP in agent.env, got:\n%s", got)
}
if strings.Contains(got, "K3S_NODE_LABEL=eks.amazonaws.com/nodegroup=ng-1,nvidia.com/gpu.present=true") {
t.Errorf("GPU label must not ride the no-op K3S_NODE_LABEL env, got:\n%s", got)
if !strings.Contains(got, "SPINIFEX_GPU_NODE=true") {
t.Errorf("expected SPINIFEX_GPU_NODE=true in agent.env for a GPU nodegroup, got:\n%s", got)
}
if strings.Contains(got, "K3S_NODE_LABEL") {
t.Errorf("label must not ride the no-op K3S_NODE_LABEL env, got:\n%s", got)
}
}
func TestBuildAgentUserData_NonGPUHasNoLabelOrTaint(t *testing.T) {
func TestBuildAgentUserData_NonGPUSignalsNodegroupOnly(t *testing.T) {
in := baseTestInput()
got := buildAgentUserData(in)
@@ -128,17 +133,17 @@ func TestBuildAgentUserData_NonGPUHasNoLabelOrTaint(t *testing.T) {
if strings.Contains(got, "nvidia.com/gpu") {
t.Errorf("non-GPU nodegroup user-data must not reference nvidia.com/gpu, got:\n%s", got)
}
if strings.Contains(got, "20-gpu.yaml") {
t.Errorf("non-GPU nodegroup user-data must not carry the GPU drop-in, got:\n%s", got)
if strings.Contains(got, "SPINIFEX_GPU_NODE") {
t.Errorf("non-GPU nodegroup must not set SPINIFEX_GPU_NODE, got:\n%s", got)
}
// The nodegroup label must ride a node-label config drop-in, not the no-op
// K3S_NODE_LABEL env — waitWorkersReady buckets Ready nodes by this label,
// so a worker missing it never counts toward its nodegroup's ACTIVE gate.
if !strings.Contains(got, "/etc/rancher/k3s/config.yaml.d/15-nodegroup-label.yaml") {
t.Errorf("expected nodegroup-label drop-in path, got:\n%s", got)
if strings.Contains(got, "node-label:") || strings.Contains(got, "node-taint:") {
t.Errorf("user-data must not carry any node-label/node-taint drop-in, got:\n%s", got)
}
if !strings.Contains(got, `"eks.amazonaws.com/nodegroup=ng-1"`) {
t.Errorf("expected nodegroup label in the node-label drop-in, got:\n%s", got)
// The worker signals its nodegroup via agent.env; mulga-eks-provider-id.sh
// turns it into the eks.amazonaws.com/nodegroup label that gates the
// nodegroup reaching ACTIVE (waitWorkersReady buckets Ready nodes by it).
if !strings.Contains(got, "SPINIFEX_NODEGROUP=ng-1") {
t.Errorf("expected SPINIFEX_NODEGROUP in agent.env, got:\n%s", got)
}
if strings.Contains(got, "K3S_NODE_LABEL") {
t.Errorf("nodegroup label must not ride the no-op K3S_NODE_LABEL env, got:\n%s", got)
+15
View File
@@ -289,6 +289,15 @@ func (m *natManager) AddEIP(ctx context.Context, eip EIPSpec) error {
slog.Info("policy: cleaned stale dnat_and_snat rules before AddEIP", "external_ip", eip.ExternalIP, "removed", removed)
}
// Scrub any predecessor row sharing this private IP under a different external
// IP: dnat_and_snat is 1:1 per private IP, and a stale row's SNAT half (keyed on
// logical_ip) blackholes the new EIP. Router-scoped — private IPs repeat per VPC.
if err := m.ovn.DeleteNAT(ctx, router, "dnat_and_snat", eip.LogicalIP); err != nil &&
!errors.Is(err, ovn.ErrNATNotFound) {
slog.Warn("policy: stale logical-IP NAT cleanup failed before AddEIP",
"logical_ip", eip.LogicalIP, "err", err)
}
if err := m.ovn.AddNAT(ctx, router, natRule); err != nil {
return fmt.Errorf("add dnat_and_snat %s -> %s on %s: %w", eip.LogicalIP, eip.ExternalIP, router, err)
}
@@ -570,6 +579,12 @@ func (m *natManager) AddSNAT(ctx context.Context, vpcID, vpcCIDR, externalIP str
if err := m.ovn.AddNAT(ctx, router, snatRule); err != nil {
return fmt.Errorf("add IGW snat %s -> %s on %s: %w", vpcCIDR, externalIP, router, err)
}
// Log the first install so RCA can pin the commit timestamp; without this
// only later reconcile passes emit "idempotent skip", masking whether a
// missing snat was programmed late or never programmed at all.
slog.Info("policy: AddSNAT installed",
"router", router, "vpc_cidr", vpcCIDR, "external_ip", externalIP)
return nil
}
+79
View File
@@ -380,6 +380,85 @@ func TestNATManager_AddEIP_IdempotentSkip_SameOwner_Centralized(t *testing.T) {
assert.Equal(t, 1, barrierCalls, "same-ENI re-add must not fire the barrier")
}
// A successor reusing a recycled private IP with a NEW external IP must leave
// exactly one dnat_and_snat for that private IP, pointing at the new EIP. The
// predecessor's old-EIP row survives its (lost) DeleteEIP and shares the private
// IP; its SNAT half (keyed on logical_ip) would blackhole the successor's new EIP.
// Sibling of the same-external-IP re-point (RePointsOnOwnerChange) and the
// same-external/different-private scrub (RemovesStaleRuleOnOtherRouter).
func TestNATManager_AddEIP_ScrubsPredecessorSharingPrivateIP(t *testing.T) {
const privateIP = "172.31.0.5"
for _, tc := range []struct {
name string
mode NATMode
mac string
}{
{"centralized", NATModeCentralized, ""},
{"distributed", NATModeDistributed, "bb:bb:bb:bb:bb:bb"},
} {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
m := mock.New()
seedRouter(t, m, "vpc-1")
seedGatewayPort(t, m, "vpc-1", "aa:bb:cc:00:00:01")
nm, err := NewNATManager(m, tc.mode)
require.NoError(t, err)
// Predecessor terminated but its dnat_and_snat (old EIP -> private IP) survives.
require.NoError(t, nm.AddEIP(ctx, EIPSpec{
VPCID: "vpc-1", ExternalIP: "192.168.0.213", LogicalIP: privateIP,
PortName: "port-eni-old", MAC: tc.mac,
}))
// Successor recycles the private IP but binds a NEW EIP.
require.NoError(t, nm.AddEIP(ctx, EIPSpec{
VPCID: "vpc-1", ExternalIP: "192.168.0.214", LogicalIP: privateIP,
PortName: "port-eni-new", MAC: tc.mac,
}))
require.Equal(t, 1, countNAT(m, "dnat_and_snat", privateIP),
"exactly one dnat_and_snat may exist for a recycled private IP")
got := findNAT(m, "dnat_and_snat", privateIP)
require.NotNil(t, got)
assert.Equal(t, "192.168.0.214", got.ExternalIP,
"row must point at the successor's new EIP, not the predecessor's stale one")
assert.Equal(t, "port-eni-new", got.ExternalIDs["spinifex:logical_port"])
})
}
}
// The logical-IP scrub is router-scoped: private IPs repeat across VPCs, so a
// same-private-IP row on another VPC's router is a legitimate separate EIP and
// must not be touched.
func TestNATManager_AddEIP_LeavesSamePrivateIPOnOtherRouterIntact(t *testing.T) {
ctx := context.Background()
m := mock.New()
seedRouter(t, m, "vpc-a")
seedRouter(t, m, "vpc-b")
nm, err := NewNATManager(m, NATModeDistributed)
require.NoError(t, err)
require.NoError(t, nm.AddEIP(ctx, EIPSpec{
VPCID: "vpc-a", ExternalIP: "192.168.0.10", LogicalIP: "172.31.0.5",
PortName: "port-a", MAC: "aa:aa:aa:aa:aa:aa",
}))
// A different instance in vpc-b that happens to reuse the same private IP.
require.NoError(t, nm.AddEIP(ctx, EIPSpec{
VPCID: "vpc-b", ExternalIP: "192.168.0.11", LogicalIP: "172.31.0.5",
PortName: "port-b", MAC: "bb:bb:bb:bb:bb:bb",
}))
byExt := map[string]*nbdb.NAT{}
for _, n := range m.NATs {
if n.Type == "dnat_and_snat" && n.LogicalIP == "172.31.0.5" {
byExt[n.ExternalIP] = n
}
}
assert.Len(t, byExt, 2,
"same private IP on a different VPC router is a separate EIP and must survive")
assert.Contains(t, byExt, "192.168.0.10", "vpc-a's row must survive")
assert.Contains(t, byExt, "192.168.0.11", "vpc-b's row must survive")
}
func TestNATManager_PruneOrphanEIPs(t *testing.T) {
ctx := context.Background()
m := mock.New()
+312 -39
View File
@@ -4,8 +4,10 @@ package eks
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
@@ -214,6 +216,11 @@ func TestEKS(t *testing.T) {
runIRSAWebIdentity(t, c, env, artifacts, fx)
})
t.Run("IRSAPod", func(t *testing.T) {
requireClusterReady(t, fx)
runIRSAPod(t, c, env, artifacts, fx)
})
t.Run("EBSCSIVolume", func(t *testing.T) {
requireClusterReady(t, fx)
runEBSCSIVolume(t, c, env, artifacts, fx)
@@ -237,47 +244,15 @@ func TestEKS(t *testing.T) {
func runIRSAWebIdentity(t *testing.T, c *harness.AWSClient, env *harness.Env, artifacts string, fx *clusterFixture) {
require.NotNil(t, fx.Cluster.Identity, "ACTIVE cluster must expose Identity")
require.NotNil(t, fx.Cluster.Identity.Oidc, "ACTIVE cluster must expose Identity.Oidc")
issuer := aws.StringValue(fx.Cluster.Identity.Oidc.Issuer)
require.NotEmpty(t, issuer, "cluster OIDC issuer must be published")
t.Logf("OIDC issuer: %s", issuer)
// 1) Register the cluster's OIDC provider in the caller account so the STS
// handler will accept tokens carrying this issuer.
oidcOut, err := c.IAM.CreateOpenIDConnectProvider(&iam.CreateOpenIDConnectProviderInput{
Url: aws.String(issuer),
ClientIDList: aws.StringSlice([]string{"sts.amazonaws.com"}),
ThumbprintList: aws.StringSlice([]string{"0000000000000000000000000000000000000000"}),
})
require.NoError(t, err, "create-open-id-connect-provider")
providerArn := aws.StringValue(oidcOut.OpenIDConnectProviderArn)
require.NotEmpty(t, providerArn, "OIDC provider ARN empty")
t.Cleanup(func() {
_, _ = c.IAM.DeleteOpenIDConnectProvider(&iam.DeleteOpenIDConnectProviderInput{
OpenIDConnectProviderArn: aws.String(providerArn),
})
})
// 1) Register the cluster's OIDC provider in the caller account and create a
// role whose trust policy federates it, so the STS handler accepts
// web-identity tokens carrying this issuer.
providerArn, roleArn, roleName := registerOIDCRole(t, c, fx, "irsa", "E2E IRSA web-identity role")
t.Logf("OIDC provider: %s", providerArn)
// 2) Create a role whose trust policy federates the OIDC provider. No
// Condition block — the Federated principal + AssumeRoleWithWebIdentity
// action are sufficient to grant, which keeps the test independent of the
// condition-key issuer-prefix format.
roleName := fmt.Sprintf("%s-irsa", fx.ClusterName)
trustPolicy := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, providerArn)
roleOut, err := c.IAM.CreateRole(&iam.CreateRoleInput{
RoleName: aws.String(roleName),
AssumeRolePolicyDocument: aws.String(trustPolicy),
Description: aws.String("E2E IRSA web-identity role"),
})
require.NoError(t, err, "create-role")
roleArn := aws.StringValue(roleOut.Role.Arn)
require.NotEmpty(t, roleArn, "role ARN empty")
t.Cleanup(func() {
_, _ = c.IAM.DeleteRole(&iam.DeleteRoleInput{RoleName: aws.String(roleName)})
})
t.Logf("role: %s", roleArn)
// 3) Mint a ServiceAccount token bound to sts.amazonaws.com. `kubectl create
// 2) Mint a ServiceAccount token bound to sts.amazonaws.com. `kubectl create
// token` uses the TokenRequest API — no pod required. The token's iss is
// the cluster OIDC issuer and aud includes sts.amazonaws.com (k3s is wired
// with --service-account-issuer / --api-audiences at CreateCluster).
@@ -289,7 +264,7 @@ func runIRSAWebIdentity(t *testing.T, c *harness.AWSClient, env *harness.Env, ar
token := strings.TrimSpace(tokenOut)
require.Equal(t, 2, strings.Count(token, "."), "web-identity token must be a JWT (3 dot-separated parts)")
// 4) Exchange the token. AssumeRoleWithWebIdentity is anonymous (the SDK
// 3) Exchange the token. AssumeRoleWithWebIdentity is anonymous (the SDK
// strips SigV4 for this op); the JWT is the identity.
const sessionName = "e2e-irsa"
var assumeOut *sts.AssumeRoleWithWebIdentityOutput
@@ -311,7 +286,7 @@ func runIRSAWebIdentity(t *testing.T, c *harness.AWSClient, env *harness.Env, ar
require.True(t, strings.HasPrefix(aws.StringValue(assumeOut.Credentials.AccessKeyId), "ASIA"),
"web-identity credentials must be temporary (ASIA…)")
// 5) The returned temporary credentials must be usable: GetCallerIdentity
// 4) The returned temporary credentials must be usable: GetCallerIdentity
// must resolve to the assumed-role principal.
sessClient := harness.NewAWSClientWithSessionCreds(t, env,
aws.StringValue(assumeOut.Credentials.AccessKeyId),
@@ -325,6 +300,304 @@ func runIRSAWebIdentity(t *testing.T, c *harness.AWSClient, env *harness.Env, ar
t.Logf("GetCallerIdentity (web-identity creds): %s", aws.StringValue(ident.Arn))
}
// registerOIDCRole registers the cluster's OIDC provider in the caller account
// and creates a role whose trust policy federates it — the shared setup every
// IRSA scenario needs before a web-identity token can be exchanged. No
// Condition block on the trust policy — the Federated principal +
// AssumeRoleWithWebIdentity action are sufficient to grant, which keeps
// callers independent of the condition-key issuer-prefix format. Both
// resources are torn down via t.Cleanup (LIFO: role before provider).
func registerOIDCRole(t *testing.T, c *harness.AWSClient, fx *clusterFixture, roleNameSuffix, description string) (providerArn, roleArn, roleName string) {
t.Helper()
require.NotNil(t, fx.Cluster.Identity, "ACTIVE cluster must expose Identity")
require.NotNil(t, fx.Cluster.Identity.Oidc, "ACTIVE cluster must expose Identity.Oidc")
issuer := aws.StringValue(fx.Cluster.Identity.Oidc.Issuer)
require.NotEmpty(t, issuer, "cluster OIDC issuer must be published")
oidcOut, err := c.IAM.CreateOpenIDConnectProvider(&iam.CreateOpenIDConnectProviderInput{
Url: aws.String(issuer),
ClientIDList: aws.StringSlice([]string{"sts.amazonaws.com"}),
ThumbprintList: aws.StringSlice([]string{"0000000000000000000000000000000000000000"}),
})
require.NoError(t, err, "create-open-id-connect-provider")
providerArn = aws.StringValue(oidcOut.OpenIDConnectProviderArn)
require.NotEmpty(t, providerArn, "OIDC provider ARN empty")
t.Cleanup(func() {
_, _ = c.IAM.DeleteOpenIDConnectProvider(&iam.DeleteOpenIDConnectProviderInput{
OpenIDConnectProviderArn: aws.String(providerArn),
})
})
roleName = fmt.Sprintf("%s-%s", fx.ClusterName, roleNameSuffix)
trustPolicy := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity"}]}`, providerArn)
roleOut, err := c.IAM.CreateRole(&iam.CreateRoleInput{
RoleName: aws.String(roleName),
AssumeRolePolicyDocument: aws.String(trustPolicy),
Description: aws.String(description),
})
require.NoError(t, err, "create-role")
roleArn = aws.StringValue(roleOut.Role.Arn)
require.NotEmpty(t, roleArn, "role ARN empty")
t.Cleanup(func() {
_, _ = c.IAM.DeleteRole(&iam.DeleteRoleInput{RoleName: aws.String(roleName)})
})
return providerArn, roleArn, roleName
}
// runIRSAPod exercises the in-cluster IRSA path a real workload depends on:
// spinifex ships no pod-identity webhook, so a pod must wire the projected SA
// token + AWS_* env explicitly (mirroring
// scripts/images/eks-node/mulga-eks-addon-sync.sh's {{IRSA_ENV}}/{{IRSA_VOLUME}}
// blocks). The pod runs `aws sts get-caller-identity` against the awsgw STS
// endpoint from inside the cluster; the returned ARN must resolve to the
// assumed IRSA role, proving token mount + env wiring + in-cluster egress all
// work together (unlike IRSAWebIdentity, which only proves the API exchange).
func runIRSAPod(t *testing.T, c *harness.AWSClient, env *harness.Env, artifacts string, fx *clusterFixture) {
require.NotNil(t, fx.Cluster.Identity, "ACTIVE cluster must expose Identity")
require.NotNil(t, fx.Cluster.Identity.Oidc, "ACTIVE cluster must expose Identity.Oidc")
issuer := aws.StringValue(fx.Cluster.Identity.Oidc.Issuer)
_, roleArn, roleName := registerOIDCRole(t, c, fx, "irsa-pod", "E2E IRSA pod role")
t.Logf("IRSA pod role: %s", roleArn)
// awsgw enforces IAM on assumed-role STS calls, so the role needs a
// permission policy allowing GetCallerIdentity — an AWS-managed ARN is
// opaque and grants nothing, so use a customer-managed policy with an
// explicit allow (same pattern as the EBS CSI role below).
const stsPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"sts:GetCallerIdentity","Resource":"*"}]}`
polOut, err := c.IAM.CreatePolicy(&iam.CreatePolicyInput{
PolicyName: aws.String(roleName + "-policy"),
PolicyDocument: aws.String(stsPolicy),
})
require.NoError(t, err, "create-policy")
policyArn := aws.StringValue(polOut.Policy.Arn)
t.Cleanup(func() {
_, _ = c.IAM.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: aws.String(policyArn)})
})
_, err = c.IAM.AttachRolePolicy(&iam.AttachRolePolicyInput{
RoleName: aws.String(roleName),
PolicyArn: aws.String(policyArn),
})
require.NoError(t, err, "attach-role-policy")
t.Cleanup(func() {
_, _ = c.IAM.DetachRolePolicy(&iam.DetachRolePolicyInput{
RoleName: aws.String(roleName),
PolicyArn: aws.String(policyArn),
})
})
// gatewayBaseURL is the issuer with its /oidc/eks/<region>/<acct>/<cluster>
// path stripped — the customer-facing WAN advertise addr (:9999) in-cluster
// pods reach, exactly what the addon-sync agent injects as
// AWS_ENDPOINT_URL_STS.
u, err := url.Parse(issuer)
require.NoErrorf(t, err, "parse issuer URL %q", issuer)
require.NotEmpty(t, u.Scheme, "issuer URL must carry a scheme")
require.NotEmpty(t, u.Host, "issuer URL must carry a host")
gatewayBaseURL := u.Scheme + "://" + u.Host
t.Logf("gateway base URL: %s", gatewayBaseURL)
region := envOr("SPINIFEX_AWS_REGION", "ap-southeast-2")
kcPath := writeKubeconfig(t, artifacts, fx.Cluster)
kc := harness.NewKubectl(t, kcPath, getTokenEnv(t, env))
// The control-plane node carries CriticalAddonsOnly=true:NoExecute (EKS
// parity: k3s_server_vm.go taints it to keep user workloads off), so this
// pod needs a customer worker node like EBSCSIVolume — there is no
// untainted node to land on. A 1-node nodegroup is created and the pod is
// pinned to it via the eks.amazonaws.com/nodegroup label the worker
// registers under (never set on the control-plane node).
//
// CreateNodegroup's worker launch attaches an instance profile for the
// declared NodeRole (ensureNodeInstanceProfile in nodegroup.go), which
// requires the role to actually exist in IAM — real EKS has the same
// prerequisite (the customer creates the node instance role before
// CreateNodegroup). Standard EC2-service trust policy, no permission
// policy needed: the pod pulls from public.ecr.aws directly, never
// touching the node's own instance-profile credentials.
nodeRoleName := fx.ClusterName + "-node"
const ec2TrustPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}`
nodeRoleOut, err := c.IAM.CreateRole(&iam.CreateRoleInput{
RoleName: aws.String(nodeRoleName),
AssumeRolePolicyDocument: aws.String(ec2TrustPolicy),
Description: aws.String("E2E IRSA pod nodegroup worker role"),
})
require.NoError(t, err, "create-node-role")
nodeRoleArn := aws.StringValue(nodeRoleOut.Role.Arn)
t.Cleanup(func() {
_, _ = c.IAM.DeleteRole(&iam.DeleteRoleInput{RoleName: aws.String(nodeRoleName)})
})
const nodegroup = "irsa-pod-e2e-ng"
harness.Phase(t, "Creating worker nodegroup %s", nodegroup)
// e2e:allow-create — the worker nodegroup is the subject under test (customer-space node the IRSA pod schedules onto).
_, err = c.EKS.CreateNodegroup(&eks.CreateNodegroupInput{
ClusterName: aws.String(fx.ClusterName),
NodegroupName: aws.String(nodegroup),
Subnets: aws.StringSlice([]string{fx.SubnetID}),
NodeRole: aws.String(nodeRoleArn),
ScalingConfig: &eks.NodegroupScalingConfig{
MinSize: aws.Int64(1),
MaxSize: aws.Int64(1),
DesiredSize: aws.Int64(1),
},
})
require.NoError(t, err, "create-nodegroup")
t.Cleanup(func() {
_, _ = c.EKS.DeleteNodegroup(&eks.DeleteNodegroupInput{
ClusterName: aws.String(fx.ClusterName),
NodegroupName: aws.String(nodegroup),
})
})
harness.EventuallyErr(t, func() error {
out, derr := c.EKS.DescribeNodegroup(&eks.DescribeNodegroupInput{
ClusterName: aws.String(fx.ClusterName),
NodegroupName: aws.String(nodegroup),
})
if derr != nil {
return fmt.Errorf("describe-nodegroup: %w", derr)
}
if s := aws.StringValue(out.Nodegroup.Status); s != eks.NodegroupStatusActive {
return fmt.Errorf("nodegroup status %q, want ACTIVE", s)
}
return nil
}, 8*time.Minute, 10*time.Second)
// Deliver the awsgw CA into the pod via a ConfigMap — the same CA the host
// already trusts for awsgw (harness.ResolveCACert), mounted read-only.
caPath, err := harness.ResolveCACert(env)
require.NoError(t, err, "resolve gateway CA cert")
const caConfigMap = "irsa-pod-gateway-ca"
out, err := kc.Run(30*time.Second, "create", "configmap", caConfigMap,
"-n", "default", "--from-file=ca.pem="+caPath)
require.NoErrorf(t, err, "create gateway-ca configmap:\n%s", out)
t.Cleanup(func() {
_, _ = kc.Run(30*time.Second, "delete", "configmap", caConfigMap, "-n", "default", "--ignore-not-found")
})
const sa = "irsa-pod-e2e"
const podName = "irsa-pod-e2e"
nodeSelector := fmt.Sprintf(" nodeSelector:\n eks.amazonaws.com/nodegroup: %s\n", nodegroup)
manifestPath := filepath.Join(artifacts, "irsa-pod.yaml")
require.NoError(t, os.WriteFile(manifestPath,
[]byte(irsaPodManifest(sa, podName, roleArn, gatewayBaseURL, region, caConfigMap, nodeSelector)), 0o600))
t.Cleanup(func() {
_, _ = kc.Run(60*time.Second, "delete", "-f", manifestPath, "--ignore-not-found", "--wait=false")
})
harness.OnFailure(t, func() {
dumps := map[string][]string{
"irsa-pod-describe.txt": {"-n", "default", "describe", "pod", podName},
"irsa-pod-events.txt": {"-n", "default", "get", "events", "--sort-by", ".lastTimestamp"},
"irsa-pod-nodes.txt": {"get", "nodes", "-o", "wide", "--show-labels"},
}
for name, args := range dumps {
out, _ := kc.Run(45*time.Second, args...)
harness.DumpFile(t, artifacts, name, []byte(out))
}
errOut, _ := kc.Run(30*time.Second, "-n", "default", "exec", podName, "--", "cat", "/tmp/err.txt")
harness.DumpFile(t, artifacts, "irsa-pod-err.txt", []byte(errOut))
})
harness.Phase(t, "Applying IRSA pod")
out, err = kc.Run(60*time.Second, "apply", "-f", manifestPath)
require.NoErrorf(t, err, "apply irsa pod:\n%s", out)
waitPodReady(t, kc, podName)
// aws sts get-caller-identity writes JSON to /tmp/out.json inside the pod;
// poll (not just wait-once) since network egress + the STS round trip can
// outlast the container's Ready flip (Ready only means the shell started).
var ident struct {
UserId string `json:"UserId"`
Account string `json:"Account"`
Arn string `json:"Arn"`
}
harness.EventuallyErr(t, func() error {
raw, err := kc.Run(30*time.Second, "-n", "default", "exec", podName, "--", "cat", "/tmp/out.json")
if err != nil {
return fmt.Errorf("exec cat out.json: %v\n%s", err, raw)
}
if jerr := json.Unmarshal([]byte(raw), &ident); jerr != nil {
return fmt.Errorf("parse out.json: %v\nraw: %s", jerr, raw)
}
return nil
}, 3*time.Minute, 5*time.Second)
t.Logf("in-cluster GetCallerIdentity: %+v", ident)
assert.Equal(t, fx.AccountID, ident.Account, "caller account must match")
assert.Contains(t, ident.Arn, "assumed-role/"+roleName,
"caller ARN must reflect the assumed IRSA role")
}
// irsaPodManifest renders a ServiceAccount (carrying the decorative
// eks.amazonaws.com/role-arn annotation) plus a pod wired for IRSA exactly as
// mulga-eks-addon-sync.sh's {{IRSA_ENV}}/{{IRSA_VOLUME}}/{{IRSA_VOLUME_MOUNT}}
// blocks would: a projected SA token (audience sts.amazonaws.com) mounted at
// the well-known eks.amazonaws.com path, the AWS_* env pointing the SDK at the
// awsgw STS endpoint, and the gateway CA trusted via ConfigMap. extraSpec is
// injected verbatim into the Pod spec (e.g. a nodeSelector), or "" for none.
func irsaPodManifest(sa, pod, roleArn, gatewayBaseURL, region, caConfigMap, extraSpec string) string {
return fmt.Sprintf(`apiVersion: v1
kind: ServiceAccount
metadata:
name: %[1]s
namespace: default
annotations:
eks.amazonaws.com/role-arn: %[3]q
---
apiVersion: v1
kind: Pod
metadata:
name: %[2]s
namespace: default
spec:
serviceAccountName: %[1]s
restartPolicy: Never
%[7]s containers:
- name: aws-cli
image: public.ecr.aws/aws-cli/aws-cli:latest
command: ["sh", "-c", "aws sts get-caller-identity --output json >/tmp/out.json 2>/tmp/err.txt; sleep 3600"]
env:
- name: AWS_ROLE_ARN
value: %[3]q
- name: AWS_WEB_IDENTITY_TOKEN_FILE
value: /var/run/secrets/eks.amazonaws.com/serviceaccount/token
- name: AWS_STS_REGIONAL_ENDPOINTS
value: regional
- name: AWS_REGION
value: %[5]q
- name: AWS_DEFAULT_REGION
value: %[5]q
- name: AWS_ENDPOINT_URL_STS
value: %[4]q
- name: AWS_CA_BUNDLE
value: /etc/spinifex/gateway-ca/ca.pem
- name: SSL_CERT_FILE
value: /etc/spinifex/gateway-ca/ca.pem
volumeMounts:
- name: aws-iam-token
mountPath: /var/run/secrets/eks.amazonaws.com/serviceaccount
readOnly: true
- name: gateway-ca
mountPath: /etc/spinifex/gateway-ca
readOnly: true
volumes:
- name: aws-iam-token
projected:
defaultMode: 420
sources:
- serviceAccountToken:
audience: sts.amazonaws.com
expirationSeconds: 86400
path: token
- name: gateway-ca
configMap:
name: %[6]s
`, sa, pod, roleArn, gatewayBaseURL, region, caConfigMap, extraSpec)
}
// runEBSCSIVolume exercises the full EBS CSI data path on the live cluster:
// install the aws-ebs-csi-driver managed addon bound to an IRSA role, then
// drive a default gp3 PVC -> Pod -> CreateVolume(Viperblock)/AttachVolume
+11 -3
View File
@@ -487,9 +487,17 @@ func phaseUniqueTransitIP(t *testing.T, fix *fixture, defaultGwIP string) {
assert.NotEqualf(t, defaultGwIP, gwIP,
"each VPC gateway LRP must get a unique transit IP (both got %s)", gwIP)
natList := harness.OvnNbctl(t, "lr-nat-list", "vpc-"+vpcID)
assert.Containsf(t, natList, secondVPCCIDR,
"second VPC router missing snat for %s\n%s", secondVPCCIDR, natList)
// The IGW default snat commits a beat after the gateway LRP appears, so
// poll lr-nat-list instead of reading it once — a single-shot check races
// the OVN commit and intermittently sees an empty list.
harness.Step(t, "second VPC transit snat programmed")
harness.EventuallyErr(t, func() error {
natList := harness.OvnNbctl(t, "lr-nat-list", "vpc-"+vpcID)
if !strings.Contains(natList, secondVPCCIDR) {
return fmt.Errorf("second VPC router missing snat for %s\n%s", secondVPCCIDR, natList)
}
return nil
}, 2*time.Minute, 3*time.Second)
harness.Step(t, "host ingress route for second VPC installed on attach")
harness.EventuallyErr(t, func() error {