mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
feat: split IAM e2e (#372)
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
# Restructures the looped e2e.yml into env-grouped job blocks so each suite
|
||||
# surfaces as its own node in the Actions graph (mulga-siv-283):
|
||||
#
|
||||
# single_provision ─┬─ single_suite (matrix: single, cert, lb, eks)
|
||||
# single_provision ─┬─ single_suite (matrix: single, cert, lb, eks, iam)
|
||||
# └─ single_teardown (if: always)
|
||||
# multi_provision ─┬─ multi_suite (matrix: multinode)
|
||||
# └─ multi_teardown (if: always)
|
||||
@@ -27,7 +27,7 @@ on:
|
||||
single_suites:
|
||||
description: "Single-node suites to run as matrix legs"
|
||||
required: false
|
||||
default: "single cert lb eks"
|
||||
default: "single cert lb eks iam"
|
||||
run_multinode:
|
||||
description: "Also run the multinode env block"
|
||||
type: boolean
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
- name: Compute suite matrix
|
||||
id: suites
|
||||
run: |
|
||||
LIST="${{ inputs.single_suites || 'single cert lb eks' }}"
|
||||
LIST="${{ inputs.single_suites || 'single cert lb eks iam' }}"
|
||||
echo "list=${LIST}" >> "$GITHUB_OUTPUT"
|
||||
JSON=$(printf '%s\n' $LIST | jq -R . | jq -cs .)
|
||||
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -15,9 +15,9 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
suites:
|
||||
description: "Space-separated Make targets to run (e.g. 'e2e-single e2e-multinode e2e-cert e2e-lb e2e-eks')"
|
||||
description: "Space-separated Make targets to run (e.g. 'e2e-single e2e-multinode e2e-cert e2e-lb e2e-eks e2e-iam')"
|
||||
required: false
|
||||
default: "e2e-single e2e-multinode e2e-cert e2e-lb"
|
||||
default: "e2e-single e2e-multinode e2e-cert e2e-lb e2e-iam"
|
||||
test_run:
|
||||
description: "Optional regex for -test.run (e.g. '^TestMultinodeIPSec$'). Empty = run all tests in suite."
|
||||
required: false
|
||||
@@ -29,7 +29,7 @@ permissions:
|
||||
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
SUITES: ${{ inputs.suites || 'e2e-single e2e-multinode e2e-cert e2e-lb' }}
|
||||
SUITES: ${{ inputs.suites || 'e2e-single e2e-multinode e2e-cert e2e-lb e2e-iam' }}
|
||||
TEST_RUN: ${{ inputs.test_run || '' }}
|
||||
|
||||
jobs:
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
# suites='e2e-multinode' only). Push triggers and the default SUITES list
|
||||
# both include single/cert/lb so the inputs.suites null-check keeps the
|
||||
# job running on plain PR pushes.
|
||||
if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-single') || contains(github.event.inputs.suites, 'e2e-cert') || contains(github.event.inputs.suites, 'e2e-lb') || contains(github.event.inputs.suites, 'e2e-eks')
|
||||
if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-single') || contains(github.event.inputs.suites, 'e2e-cert') || contains(github.event.inputs.suites, 'e2e-lb') || contains(github.event.inputs.suites, 'e2e-eks') || contains(github.event.inputs.suites, 'e2e-iam')
|
||||
runs-on: [self-hosted, ci-single]
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
@@ -590,12 +590,15 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
|
||||
# single + iam are single-node-only suites — their fixtures skip
|
||||
# unless SPINIFEX_MODE=single, so building/running them on multinode
|
||||
# is wasted work.
|
||||
# eks is excluded on multinode: EKS KV is a clustered (Raft) JetStream
|
||||
# stream and create-cluster needs an elected leader, but multinode
|
||||
# JetStream clustering isn't solid yet (mulga-omq/z2z/5yg). EKS is
|
||||
# validated single-node only for now; re-enable here once clustered
|
||||
# JetStream + multinode EKS are a deliberate target.
|
||||
SUITES_BUILD=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(single|eks)$' | tr '\n' ' ')
|
||||
SUITES_BUILD=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(single|eks|iam)$' | tr '\n' ' ')
|
||||
if [ -z "$(echo "${SUITES_BUILD}" | tr -d ' ')" ]; then
|
||||
echo "no multinode-eligible suites requested (SUITES=${SUITES}); nothing to build"
|
||||
exit 0
|
||||
@@ -678,7 +681,7 @@ jobs:
|
||||
id: e2e
|
||||
run: |
|
||||
set +e
|
||||
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(single|eks)$' | tr '\n' ' ')
|
||||
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(single|eks|iam)$' | tr '\n' ' ')
|
||||
if [ -z "$(echo "${SUITES_RUN}" | tr -d ' ')" ]; then
|
||||
echo "no multinode-eligible suites requested (SUITES=${SUITES}); skipping"
|
||||
echo "rc=0" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -284,6 +284,14 @@ suites:
|
||||
# into per-test fixture-driven coverage so the selector can pick
|
||||
# subtests, not just whole suites.
|
||||
|
||||
e2e-iam:
|
||||
path: tests/e2e/iam
|
||||
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
|
||||
# "Everything IAM": the IAM/STS control plane plus the instance-identity
|
||||
# tests (instance-profile association + IMDS), which boot guest VMs — OVN
|
||||
# via vpcd, AMI via predastore, root volume via viperblockd, lifecycle via
|
||||
# spinifex_daemon. Single-mode, VM-booting leg.
|
||||
|
||||
e2e-ddil:
|
||||
path: tests/e2e/ddil
|
||||
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
|
||||
|
||||
+4
-3
@@ -1,4 +1,4 @@
|
||||
PKG ?= ./cert/... ./lb/... ./eks/... ./multinode/... ./baremetal/... ./reboot/... ./tofu/...
|
||||
PKG ?= ./cert/... ./lb/... ./eks/... ./iam/... ./multinode/... ./baremetal/... ./reboot/... ./tofu/...
|
||||
ARTIFACT_DIR ?= /tmp/spinifex-e2e-artifacts
|
||||
TIMEOUT ?= 30m
|
||||
MODE ?= single
|
||||
@@ -11,9 +11,9 @@ SUITE_TAG ?= $(notdir $(patsubst %/...,%,$(firstword $(PKG))))
|
||||
# named suite into BIN_DIR/<suite>.test so CI can ship binaries (not source +
|
||||
# go toolchain) to the remote VM.
|
||||
BIN_DIR ?= $(CURDIR)/_bin
|
||||
SUITES_BUILD ?= cert lb eks single gpu
|
||||
SUITES_BUILD ?= cert lb eks single iam gpu
|
||||
|
||||
.PHONY: e2e e2e-cert e2e-lb e2e-eks e2e-single e2e-multinode e2e-bm e2e-reboot e2e-tofu e2e-gpu e2e-all clean e2e-build
|
||||
.PHONY: e2e e2e-cert e2e-lb e2e-eks e2e-single e2e-iam e2e-multinode e2e-bm e2e-reboot e2e-tofu e2e-gpu e2e-all clean e2e-build
|
||||
|
||||
ifeq ($(GOTESTSUM),1)
|
||||
E2E_CMD = gotestsum \
|
||||
@@ -36,6 +36,7 @@ e2e-cert: ; $(MAKE) e2e PKG=./cert/...
|
||||
e2e-lb: ; $(MAKE) e2e PKG=./lb/...
|
||||
e2e-eks: ; $(MAKE) e2e PKG=./eks/...
|
||||
e2e-single: ; $(MAKE) e2e PKG=./single/...
|
||||
e2e-iam: ; $(MAKE) e2e PKG=./iam/...
|
||||
e2e-multinode: ; $(MAKE) e2e PKG=./multinode/... MODE=multinode
|
||||
e2e-bm: ; $(MAKE) e2e PKG=./baremetal/... MODE=baremetal
|
||||
e2e-reboot: ; $(MAKE) e2e PKG=./reboot/...
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//go:build e2e
|
||||
|
||||
package harness
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/aws/aws-sdk-go/service/sts"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// IAMRoleARN constructs arn:aws:iam::<account>:role/<name>.
|
||||
func IAMRoleARN(account, name string) string {
|
||||
return "arn:aws:iam::" + account + ":role/" + name
|
||||
}
|
||||
|
||||
// IAMPolicyARN builds the canonical policy ARN for a given account + policy
|
||||
// "key" (a bare name, or "path/name" without the leading slash).
|
||||
func IAMPolicyARN(account, key string) string {
|
||||
return "arn:aws:iam::" + account + ":policy/" + key
|
||||
}
|
||||
|
||||
// IAMAccountID returns the active account ID via STS GetCallerIdentity. Unlike
|
||||
// deriving it from a created IAM user's ARN, this is side-effect-free, so suites
|
||||
// sharing one account can call it concurrently without minting colliding users.
|
||||
func IAMAccountID(t *testing.T, c *AWSClient) string {
|
||||
t.Helper()
|
||||
out, err := c.STS.GetCallerIdentity(&sts.GetCallerIdentityInput{})
|
||||
require.NoError(t, err, "sts:GetCallerIdentity")
|
||||
require.NotEmpty(t, aws.StringValue(out.Account), "empty account in GetCallerIdentity response")
|
||||
return aws.StringValue(out.Account)
|
||||
}
|
||||
|
||||
// IAMDeleteRoleAndProfilesBestEffort tears down every fragment of a role +
|
||||
// instance-profile graph a suite might have left behind. Each step swallows
|
||||
// errors so a missing fragment never cascades; usable both as a pre-test sweep
|
||||
// and as a fixture-teardown cleanup. Order matters: unbind role-from-profile
|
||||
// before deleting the profile, detach policies before deleting the role.
|
||||
func IAMDeleteRoleAndProfilesBestEffort(c *AWSClient, roleName string, profileNames []string, policyARNs ...string) {
|
||||
for _, p := range profileNames {
|
||||
_, _ = c.IAM.RemoveRoleFromInstanceProfile(&iam.RemoveRoleFromInstanceProfileInput{
|
||||
InstanceProfileName: aws.String(p),
|
||||
RoleName: aws.String(roleName),
|
||||
})
|
||||
_, _ = c.IAM.DeleteInstanceProfile(&iam.DeleteInstanceProfileInput{
|
||||
InstanceProfileName: aws.String(p),
|
||||
})
|
||||
}
|
||||
for _, arn := range policyARNs {
|
||||
_, _ = c.IAM.DetachRolePolicy(&iam.DetachRolePolicyInput{
|
||||
RoleName: aws.String(roleName),
|
||||
PolicyArn: aws.String(arn),
|
||||
})
|
||||
}
|
||||
// Defensive: pull any other attached policies so the final DeleteRole isn't
|
||||
// blocked by a stray attach from a partial run.
|
||||
if attached, err := c.IAM.ListAttachedRolePolicies(&iam.ListAttachedRolePoliciesInput{
|
||||
RoleName: aws.String(roleName),
|
||||
}); err == nil {
|
||||
for _, p := range attached.AttachedPolicies {
|
||||
_, _ = c.IAM.DetachRolePolicy(&iam.DetachRolePolicyInput{
|
||||
RoleName: aws.String(roleName),
|
||||
PolicyArn: p.PolicyArn,
|
||||
})
|
||||
}
|
||||
}
|
||||
_, _ = c.IAM.DeleteRole(&iam.DeleteRoleInput{RoleName: aws.String(roleName)})
|
||||
}
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
//go:build e2e
|
||||
|
||||
package single
|
||||
package iam
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -28,13 +28,13 @@ const (
|
||||
// live session is allowed once a policy is attached (policies resolved per request).
|
||||
func runAssumedRoleControlPlaneEnforcement(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "Single — Assumed-role control-plane enforcement")
|
||||
adminAccount := iamEnsureAdminAccountID(t, fix)
|
||||
roleARN := iamRoleARN(adminAccount, areRoleName)
|
||||
policyARN := iamPolicyARN(adminAccount, arePolicyName)
|
||||
adminAccount := harness.IAMAccountID(t, fix.AWS)
|
||||
roleARN := harness.IAMRoleARN(adminAccount, areRoleName)
|
||||
policyARN := harness.IAMPolicyARN(adminAccount, arePolicyName)
|
||||
|
||||
// Pre-sweep in case a previous run left fragments.
|
||||
sweep := func() {
|
||||
iamDeleteRoleAndProfilesBestEffort(fix, areRoleName, nil, policyARN)
|
||||
harness.IAMDeleteRoleAndProfilesBestEffort(fix.AWS, areRoleName, nil, policyARN)
|
||||
_, _ = fix.AWS.IAM.DeletePolicy(&iam.DeletePolicyInput{PolicyArn: aws.String(policyARN)})
|
||||
}
|
||||
sweep()
|
||||
@@ -0,0 +1,230 @@
|
||||
//go:build e2e
|
||||
|
||||
package iam
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/tests/e2e/harness"
|
||||
)
|
||||
|
||||
// SSH / datapath helpers ported from the single suite so the IMDS and
|
||||
// instance-profile tests run self-contained inside iam. The single suite keeps
|
||||
// its own copy — other single tests still use these. They are separate test
|
||||
// binaries (separate processes), so the sticky-skip state below is per-suite.
|
||||
|
||||
// sshDatapathBroken is set the first time an SSH probe in this suite times out.
|
||||
// Downstream SSH-dependent assertions call requireSSHHealthy(t) and skip rather
|
||||
// than re-running the same multi-minute timeout against the same broken datapath.
|
||||
var sshDatapathBroken atomic.Bool
|
||||
|
||||
// sshReadyBudget bounds the SSH-handshake probes. Widen via
|
||||
// SPINIFEX_SSH_READY_TIMEOUT (Go duration); default 3m bounds shared runners.
|
||||
var sshReadyBudget = resolveSSHReadyBudget(3 * time.Minute)
|
||||
|
||||
func resolveSSHReadyBudget(def time.Duration) time.Duration {
|
||||
if v := os.Getenv("SPINIFEX_SSH_READY_TIMEOUT"); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil && d > 0 {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// requireSSHHealthy skips the calling test if a prior SSH probe in this suite
|
||||
// has already failed, cutting the cascade cost of a broken datapath.
|
||||
func requireSSHHealthy(t *testing.T) {
|
||||
t.Helper()
|
||||
if sshDatapathBroken.Load() {
|
||||
t.Skipf("skipping: earlier SSH probe failed; " +
|
||||
"not retrying to keep suite time bounded")
|
||||
}
|
||||
}
|
||||
|
||||
// waitForSSHReady probes a full SSH handshake (BatchMode + ConnectTimeout)
|
||||
// against host:port, retrying until the daemon completes banner exchange.
|
||||
// TCP-port reachability alone is insufficient — sshd accepts the connect
|
||||
// while pam/cloud-init are still finishing, and the first real runSSH then
|
||||
// hits "Connection timed out during banner exchange".
|
||||
func waitForSSHReady(t *testing.T, host string, port int, keyPath string) {
|
||||
t.Helper()
|
||||
requireSSHHealthy(t)
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
harness.Step(t, "waiting for SSH handshake %s", addr)
|
||||
if !trySSHReady(host, port, keyPath, sshReadyBudget) {
|
||||
sshDatapathBroken.Store(true)
|
||||
t.Fatalf("Eventually: condition not met within %s: "+
|
||||
"[SSH handshake %s never completed] "+
|
||||
"(sticky-skip enabled for downstream)", sshReadyBudget, addr)
|
||||
}
|
||||
}
|
||||
|
||||
// waitForSSHHandshake is an alias kept for the imds_test.go call site.
|
||||
func waitForSSHHandshake(t *testing.T, host string, port int, keyPath string) {
|
||||
waitForSSHReady(t, host, port, keyPath)
|
||||
}
|
||||
|
||||
// trySSHReady mirrors waitForSSHReady but returns reachability as a bool
|
||||
// instead of t.Fatal-ing on timeout.
|
||||
func trySSHReady(host string, port int, keyPath string, timeout time.Duration) bool {
|
||||
if sshDatapathBroken.Load() {
|
||||
return false
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
cmd := exec.Command("ssh",
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "LogLevel=ERROR",
|
||||
"-o", "ConnectTimeout=3",
|
||||
"-o", "BatchMode=yes",
|
||||
"-p", strconv.Itoa(port),
|
||||
"-i", keyPath,
|
||||
"ec2-user@"+host,
|
||||
"true")
|
||||
if cmd.Run() == nil {
|
||||
return true
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return false
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// runSSH is a thin wrapper around `ssh` matching the option set used by
|
||||
// harness.LsblkRootGiB. Returns stdout. t.Fatal on non-zero exit so callers
|
||||
// can chain assertions on the output without nil-checking err.
|
||||
func runSSH(t *testing.T, tgt harness.SSHTarget, command string) string {
|
||||
t.Helper()
|
||||
args := []string{
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "LogLevel=ERROR",
|
||||
"-o", "ConnectTimeout=5",
|
||||
"-o", "BatchMode=yes",
|
||||
"-p", strconv.Itoa(tgt.Port),
|
||||
"-i", tgt.KeyPath,
|
||||
tgt.User + "@" + tgt.Host,
|
||||
command,
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd := exec.Command("ssh", args...)
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("ssh %s@%s:%d %q failed: %v\nstderr: %s",
|
||||
tgt.User, tgt.Host, tgt.Port, command, err, stderr.String())
|
||||
}
|
||||
return stdout.String()
|
||||
}
|
||||
|
||||
// runSSHCombined runs `command` over SSH against tgt and returns combined
|
||||
// stdout+stderr regardless of exit status. Unlike runSSH it does not t.Fatal
|
||||
// on non-zero exit — needed for probes (e.g. curl status codes) where a
|
||||
// non-zero exit is an expected outcome.
|
||||
func runSSHCombined(tgt harness.SSHTarget, command string) (string, error) {
|
||||
args := []string{
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", "LogLevel=ERROR",
|
||||
"-o", "ConnectTimeout=5",
|
||||
"-o", "BatchMode=yes",
|
||||
"-p", strconv.Itoa(tgt.Port),
|
||||
"-i", tgt.KeyPath,
|
||||
tgt.User + "@" + tgt.Host,
|
||||
command,
|
||||
}
|
||||
var combined bytes.Buffer
|
||||
cmd := exec.Command("ssh", args...)
|
||||
cmd.Stdout = &combined
|
||||
cmd.Stderr = &combined
|
||||
err := cmd.Run()
|
||||
return combined.String(), err
|
||||
}
|
||||
|
||||
// primaryENI returns the NetworkInterfaceId of an instance's first ENI.
|
||||
// t.Fatal if the instance has no ENI — every running EC2 instance must.
|
||||
func primaryENI(t *testing.T, inst *ec2.Instance) string {
|
||||
t.Helper()
|
||||
if len(inst.NetworkInterfaces) == 0 {
|
||||
t.Fatalf("instance %s has no NetworkInterfaces", aws.StringValue(inst.InstanceId))
|
||||
}
|
||||
eni := aws.StringValue(inst.NetworkInterfaces[0].NetworkInterfaceId)
|
||||
if eni == "" {
|
||||
t.Fatalf("instance %s primary ENI has empty NetworkInterfaceId", aws.StringValue(inst.InstanceId))
|
||||
}
|
||||
return eni
|
||||
}
|
||||
|
||||
// waitForInstanceStateSoft is the cleanup-time analogue of
|
||||
// harness.WaitForInstanceState — no t.Fatal, just polls and returns.
|
||||
func waitForInstanceStateSoft(c *harness.AWSClient, id, target string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
out, err := c.EC2.DescribeInstances(&ec2.DescribeInstancesInput{
|
||||
InstanceIds: []*string{aws.String(id)},
|
||||
})
|
||||
if err == nil && len(out.Reservations) > 0 && len(out.Reservations[0].Instances) > 0 {
|
||||
if aws.StringValue(out.Reservations[0].Instances[0].State.Name) == target {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("instance %s did not reach %s within %s", id, target, timeout)
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// detectPoolMode reads external_mode from spinifex.toml. Defaults to false
|
||||
// (dev_networking) which is the single-node CI fixture. Any non-empty
|
||||
// external_mode value ("pool" / "nat") means external IPAM is in play, which
|
||||
// gates the IMDS probe-VPC public-IP path.
|
||||
func detectPoolMode(env *harness.Env) bool {
|
||||
cfg := os.ExpandEnv("$HOME/spinifex/config/spinifex.toml")
|
||||
if env.ConfigDir != "" {
|
||||
cfg = filepath.Join(env.ConfigDir, "spinifex.toml")
|
||||
}
|
||||
f, err := os.Open(cfg)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
inNetwork := false
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.HasPrefix(line, "[") {
|
||||
inNetwork = line == "[network]"
|
||||
continue
|
||||
}
|
||||
if !inNetwork {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, "external_mode") {
|
||||
continue
|
||||
}
|
||||
// external_mode = "pool" — quoted value, anything non-empty == pool mode.
|
||||
if i := bytes.IndexByte([]byte(line), '='); i >= 0 {
|
||||
val := strings.TrimSpace(line[i+1:])
|
||||
val = strings.Trim(val, "\"'")
|
||||
return val != ""
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//go:build e2e
|
||||
|
||||
package iam
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/tests/e2e/harness"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Instance-bootstrap helpers wrapping harness.Discover*/Ensure* so the IMDS and
|
||||
// instance-profile tests can self-bootstrap their VM prerequisites. Results are
|
||||
// memoized by harness.Fixture. The single suite keeps its own copy of these —
|
||||
// other single tests still use them.
|
||||
|
||||
// needInstanceTypeArch returns the nano instance type and its architecture.
|
||||
func needInstanceTypeArch(t *testing.T, fix *Fixture) (instanceType, arch string) {
|
||||
t.Helper()
|
||||
return harness.DiscoverNanoInstanceType(t, fix.Harness)
|
||||
}
|
||||
|
||||
// needAMI returns the discovered architecture-appropriate Ubuntu AMI ID.
|
||||
func needAMI(t *testing.T, fix *Fixture) string {
|
||||
t.Helper()
|
||||
_, arch := needInstanceTypeArch(t, fix)
|
||||
return harness.DiscoverUbuntuAMI(t, fix.Harness, arch)
|
||||
}
|
||||
|
||||
// needKeyPair returns the memoized primary EC2 key pair name + PEM path.
|
||||
func needKeyPair(t *testing.T, fix *Fixture) (name, pemPath string) {
|
||||
t.Helper()
|
||||
return harness.EnsureKeyPair(t, fix.Harness, fix.TmpDir)
|
||||
}
|
||||
|
||||
// needInstance bootstraps the suite's primary running instance and returns
|
||||
// the *ec2.Instance plus its root volume ID. Idempotent via harness.EnsureInstance.
|
||||
func needInstance(t *testing.T, fix *Fixture) (inst *ec2.Instance, rootVolumeID string) {
|
||||
t.Helper()
|
||||
instType, _ := needInstanceTypeArch(t, fix)
|
||||
keyName, _ := needKeyPair(t, fix)
|
||||
ami := needAMI(t, fix)
|
||||
vpc := harness.EnsureDefaultVPC(t, fix.Harness)
|
||||
require.NotEmpty(t, vpc.SGID, "default SG ID required")
|
||||
harness.AuthorizeSSHIngress(t, fix.AWS, vpc.SGID)
|
||||
|
||||
instanceID := harness.EnsureInstance(t, fix.Harness, harness.InstanceSpec{
|
||||
AMIID: ami,
|
||||
InstanceType: instType,
|
||||
KeyName: keyName,
|
||||
SubnetID: vpc.SubnetID,
|
||||
SGID: vpc.SGID,
|
||||
})
|
||||
require.NotEmpty(t, instanceID, "EnsureInstance returned empty InstanceId")
|
||||
|
||||
descOut, err := fix.AWS.EC2.DescribeInstances(&ec2.DescribeInstancesInput{
|
||||
InstanceIds: []*string{aws.String(instanceID)},
|
||||
})
|
||||
require.NoError(t, err, "describe-instances %s", instanceID)
|
||||
require.NotEmpty(t, descOut.Reservations, "no reservations for %s", instanceID)
|
||||
require.NotEmpty(t, descOut.Reservations[0].Instances, "no instances for %s", instanceID)
|
||||
inst = descOut.Reservations[0].Instances[0]
|
||||
|
||||
// Match BDM by RootDeviceName; fall back to first BDM if empty.
|
||||
rootDev := aws.StringValue(inst.RootDeviceName)
|
||||
for _, bdm := range inst.BlockDeviceMappings {
|
||||
if rootDev != "" && aws.StringValue(bdm.DeviceName) != rootDev {
|
||||
continue
|
||||
}
|
||||
if bdm.Ebs != nil {
|
||||
rootVolumeID = aws.StringValue(bdm.Ebs.VolumeId)
|
||||
break
|
||||
}
|
||||
}
|
||||
if rootVolumeID == "" && len(inst.BlockDeviceMappings) > 0 && inst.BlockDeviceMappings[0].Ebs != nil {
|
||||
rootVolumeID = aws.StringValue(inst.BlockDeviceMappings[0].Ebs.VolumeId)
|
||||
}
|
||||
require.NotEmpty(t, rootVolumeID, "could not resolve root volume from BlockDeviceMappings")
|
||||
return inst, rootVolumeID
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build e2e
|
||||
|
||||
package single
|
||||
package iam
|
||||
|
||||
import (
|
||||
"sync"
|
||||
@@ -36,10 +36,6 @@ var (
|
||||
iamCharlieKeyID string
|
||||
iamCharlieKeySecret string
|
||||
iamCharlieKeyErr error
|
||||
|
||||
iamAdminAccountOnce sync.Once
|
||||
iamAdminAccountID string
|
||||
iamAdminAccountErr error
|
||||
)
|
||||
|
||||
// iamEnsureAlice ensures the canonical "alice" IAM user exists. The user is
|
||||
@@ -129,26 +125,6 @@ func iamEnsureCharlieKey(t *testing.T, fix *Fixture) (keyID, secret string) {
|
||||
return iamCharlieKeyID, iamCharlieKeySecret
|
||||
}
|
||||
|
||||
// iamEnsureAdminAccountID returns the active account ID, derived from the
|
||||
// ARN of a probe IAM resource. Uses GetUser on the canonical alice user so
|
||||
// no policy churn is needed; alice is created on first call.
|
||||
func iamEnsureAdminAccountID(t *testing.T, fix *Fixture) string {
|
||||
t.Helper()
|
||||
iamEnsureAlice(t, fix)
|
||||
iamAdminAccountOnce.Do(func() {
|
||||
out, err := fix.AWS.IAM.GetUser(&iam.GetUserInput{UserName: aws.String(iamUserAlice)})
|
||||
if err != nil {
|
||||
iamAdminAccountErr = err
|
||||
return
|
||||
}
|
||||
iamAdminAccountID = iamAccountFromARN(t, aws.StringValue(out.User.Arn))
|
||||
})
|
||||
if iamAdminAccountErr != nil {
|
||||
t.Fatalf("iamEnsureAdminAccountID: %v", iamAdminAccountErr)
|
||||
}
|
||||
return iamAdminAccountID
|
||||
}
|
||||
|
||||
// iamCreateUserIdempotent creates a user, treating EntityAlreadyExists as success.
|
||||
func iamCreateUserIdempotent(fix *Fixture, name, path string) error {
|
||||
input := &iam.CreateUserInput{UserName: aws.String(name)}
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build e2e
|
||||
|
||||
package single
|
||||
package iam
|
||||
|
||||
import (
|
||||
"testing"
|
||||
@@ -32,17 +32,17 @@ const (
|
||||
// which recreates the same names.
|
||||
func runIAMRolesAndProfiles(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "Single — IAM Roles & Instance Profiles")
|
||||
adminAccount := iamEnsureAdminAccountID(t, fix)
|
||||
roleARN := iamRoleARN(adminAccount, iamRoleAppName)
|
||||
adminPolicyARN := iamPolicyARN(adminAccount, iamPolicyAdministrator)
|
||||
adminAccount := harness.IAMAccountID(t, fix.AWS)
|
||||
roleARN := harness.IAMRoleARN(adminAccount, iamRoleAppName)
|
||||
adminPolicyARN := harness.IAMPolicyARN(adminAccount, iamPolicyAdministrator)
|
||||
|
||||
// Defensive sweep — previous failed run may have left fragments. Order
|
||||
// matters: detach role from profile before delete-instance-profile,
|
||||
// detach policy from role before delete-role.
|
||||
iamDeleteRoleAndProfilesBestEffort(fix, iamRoleAppName,
|
||||
harness.IAMDeleteRoleAndProfilesBestEffort(fix.AWS, iamRoleAppName,
|
||||
[]string{iamProfileAppName, iamProfileOtherName, iamProfileEngName}, adminPolicyARN)
|
||||
fix.Harness.RegisterCleanup(func() {
|
||||
iamDeleteRoleAndProfilesBestEffort(fix, iamRoleAppName,
|
||||
harness.IAMDeleteRoleAndProfilesBestEffort(fix.AWS, iamRoleAppName,
|
||||
[]string{iamProfileAppName, iamProfileOtherName, iamProfileEngName}, adminPolicyARN)
|
||||
})
|
||||
|
||||
@@ -159,7 +159,7 @@ func runIAMRolesAndProfiles(t *testing.T, fix *Fixture) {
|
||||
harness.ExpectError(t, "NoSuchEntity", func() error {
|
||||
_, e := fix.AWS.IAM.AttachRolePolicy(&iam.AttachRolePolicyInput{
|
||||
RoleName: aws.String(iamRoleAppName),
|
||||
PolicyArn: aws.String(iamPolicyARN(adminAccount, "Ghost")),
|
||||
PolicyArn: aws.String(harness.IAMPolicyARN(adminAccount, "Ghost")),
|
||||
})
|
||||
return e
|
||||
})
|
||||
@@ -329,44 +329,3 @@ func runIAMRolesAndProfiles(t *testing.T, fix *Fixture) {
|
||||
return e
|
||||
})
|
||||
}
|
||||
|
||||
// iamRoleARN constructs arn:aws:iam::<acct>:role/<name>.
|
||||
func iamRoleARN(account, name string) string {
|
||||
return "arn:aws:iam::" + account + ":role/" + name
|
||||
}
|
||||
|
||||
// iamDeleteRoleAndProfilesBestEffort tears down every fragment of a role +
|
||||
// profile graph the suite might have left behind. Each step swallows errors
|
||||
// so a missing fragment doesn't cascade. Used both as a pre-test sweep and
|
||||
// as a fixture-teardown cleanup.
|
||||
func iamDeleteRoleAndProfilesBestEffort(fix *Fixture, roleName string, profileNames []string, policyARNs ...string) {
|
||||
for _, p := range profileNames {
|
||||
// Drop role-from-profile binding (idempotent).
|
||||
_, _ = fix.AWS.IAM.RemoveRoleFromInstanceProfile(&iam.RemoveRoleFromInstanceProfileInput{
|
||||
InstanceProfileName: aws.String(p),
|
||||
RoleName: aws.String(roleName),
|
||||
})
|
||||
_, _ = fix.AWS.IAM.DeleteInstanceProfile(&iam.DeleteInstanceProfileInput{
|
||||
InstanceProfileName: aws.String(p),
|
||||
})
|
||||
}
|
||||
for _, arn := range policyARNs {
|
||||
_, _ = fix.AWS.IAM.DetachRolePolicy(&iam.DetachRolePolicyInput{
|
||||
RoleName: aws.String(roleName),
|
||||
PolicyArn: aws.String(arn),
|
||||
})
|
||||
}
|
||||
// Defensive: pull any other attached policies we don't know about so the
|
||||
// final DeleteRole isn't blocked by a stray attach from a partial run.
|
||||
if attached, err := fix.AWS.IAM.ListAttachedRolePolicies(&iam.ListAttachedRolePoliciesInput{
|
||||
RoleName: aws.String(roleName),
|
||||
}); err == nil {
|
||||
for _, p := range attached.AttachedPolicies {
|
||||
_, _ = fix.AWS.IAM.DetachRolePolicy(&iam.DetachRolePolicyInput{
|
||||
RoleName: aws.String(roleName),
|
||||
PolicyArn: p.PolicyArn,
|
||||
})
|
||||
}
|
||||
}
|
||||
_, _ = fix.AWS.IAM.DeleteRole(&iam.DeleteRoleInput{RoleName: aws.String(roleName)})
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build e2e
|
||||
|
||||
package single
|
||||
package iam
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -81,24 +81,13 @@ const (
|
||||
"Resource": "*"
|
||||
}]
|
||||
}`
|
||||
|
||||
iamDocEC2ReadOnlyV2 = `{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Action": ["ec2:DescribeInstances", "ec2:DescribeVolumes", "ec2:DescribeVpcs", "ec2:DescribeImages"],
|
||||
"Resource": "*"
|
||||
}]
|
||||
}`
|
||||
)
|
||||
|
||||
// runIAMUserCRUD ports run-e2e.sh IAM Phase 1 (~1405–1459):
|
||||
// CreateUser, GetUser, ListUsers, UpdateUser (rename+path), DeleteUser
|
||||
// plus the EntityAlreadyExists / NoSuchEntity negative paths. Each
|
||||
// created user is scheduled for cleanup so a mid-phase failure can't
|
||||
// poison later runs. UpdateUser exercises an out-of-band rename +
|
||||
// rename-back so Phase 2+ continue to find "alice" by the well-known
|
||||
// name.
|
||||
// CreateUser, GetUser, ListUsers, DeleteUser plus the
|
||||
// EntityAlreadyExists / NoSuchEntity negative paths. Each created user
|
||||
// is scheduled for cleanup so a mid-phase failure can't poison later
|
||||
// runs.
|
||||
func runIAMUserCRUD(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "Single — IAM User CRUD")
|
||||
|
||||
@@ -169,34 +158,6 @@ func runIAMUserCRUD(t *testing.T, fix *Fixture) {
|
||||
require.Len(t, eng.Users, 1,
|
||||
"expected 1 user under %s, got %d (names=%v)", iamUserBobPath, len(eng.Users), iamUserNames(eng.Users))
|
||||
|
||||
// UpdateUser rename+path round-trip: daemon does not implement the
|
||||
// UpdateUser action yet (returns InvalidAction). Skip-gate the block
|
||||
// so the rest of Phase 1 still runs; flip to a real assertion once
|
||||
// the daemon handler lands.
|
||||
t.Run("UpdateUser", func(t *testing.T) {
|
||||
t.Skip("daemon UpdateUser handler not implemented — InvalidAction")
|
||||
const renamed = "alice-renamed"
|
||||
const renamedPath = "/staff/"
|
||||
harness.Step(t, "update-user %q -> %q path=%q", iamUserAlice, renamed, renamedPath)
|
||||
_, err := fix.AWS.IAM.UpdateUser(&iam.UpdateUserInput{
|
||||
UserName: aws.String(iamUserAlice),
|
||||
NewUserName: aws.String(renamed),
|
||||
NewPath: aws.String(renamedPath),
|
||||
})
|
||||
require.NoError(t, err, "update-user rename+path")
|
||||
check, err := fix.AWS.IAM.GetUser(&iam.GetUserInput{UserName: aws.String(renamed)})
|
||||
require.NoError(t, err, "get-user after rename")
|
||||
assert.Equal(t, renamedPath, aws.StringValue(check.User.Path), "path not updated")
|
||||
|
||||
harness.Step(t, "update-user %q -> %q (restore)", renamed, iamUserAlice)
|
||||
_, err = fix.AWS.IAM.UpdateUser(&iam.UpdateUserInput{
|
||||
UserName: aws.String(renamed),
|
||||
NewUserName: aws.String(iamUserAlice),
|
||||
NewPath: aws.String("/"),
|
||||
})
|
||||
require.NoError(t, err, "update-user restore")
|
||||
})
|
||||
|
||||
// DeleteUser idempotency: create a throwaway user, delete it, then
|
||||
// delete it again and assert the second delete surfaces NoSuchEntity
|
||||
// rather than silently succeeding (matches AWS semantics).
|
||||
@@ -315,25 +276,28 @@ func runIAMAccessKeyLifecycle(t *testing.T, fix *Fixture) {
|
||||
require.Len(t, after.AccessKeyMetadata, 1, "alice should have 1 key after delete")
|
||||
}
|
||||
|
||||
// runIAMUserAuthentication ports IAM Phase 3 (~1538–1579):
|
||||
// build a scoped AWS client with alice's key and confirm signing
|
||||
// works (active key) / fails (deactivated key, bad secret, bogus ID).
|
||||
// Also creates bob's key so Phase 5 enforcement / Phase 7 cleanup
|
||||
// can use it.
|
||||
// runIAMUserAuthentication ports IAM Phase 3 (~1538–1579): build a scoped
|
||||
// AWS client with alice's key and confirm signing is honoured (active key)
|
||||
// / rejected (deactivated key, bad secret, bogus ID). Alice has no policy in
|
||||
// this phase, so the authenticated call is authz-denied — AccessDenied (not
|
||||
// an authn error) proves the active key signed and was accepted. Also creates
|
||||
// bob's key so Phase 5 enforcement / Phase 7 cleanup can use it.
|
||||
func runIAMUserAuthentication(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "Single — IAM User Authentication")
|
||||
// Daemon does not yet honour active-key signatures created via the IAM
|
||||
// API — every scoped DescribeInstances returns 403.
|
||||
t.Skip("daemon IAM signature/principal lookup not implemented")
|
||||
aliceKeyID, aliceSecret := iamEnsureAliceKey(t, fix)
|
||||
|
||||
harness.Step(t, "scoped client (alice) describe-instances — active key OK")
|
||||
// Active, correctly-signed key with no policy: the request authenticates
|
||||
// then default-denies. AccessDenied (vs the InvalidClientTokenId /
|
||||
// SignatureDoesNotMatch below) is the signal that authn succeeded.
|
||||
harness.Step(t, "scoped client (alice) describe-instances — active key authenticates (AccessDenied, no policy)")
|
||||
aliceCli := harness.NewAWSClientWithCreds(t, fix.Env, aliceKeyID, aliceSecret)
|
||||
_, err := aliceCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{})
|
||||
require.NoError(t, err, "alice describe-instances with active key")
|
||||
harness.ExpectError(t, "AccessDenied", func() error {
|
||||
_, e := aliceCli.EC2.DescribeInstances(&ec2.DescribeInstancesInput{})
|
||||
return e
|
||||
})
|
||||
|
||||
harness.Step(t, "deactivate alice key, expect InvalidClientTokenId")
|
||||
_, err = fix.AWS.IAM.UpdateAccessKey(&iam.UpdateAccessKeyInput{
|
||||
_, err := fix.AWS.IAM.UpdateAccessKey(&iam.UpdateAccessKeyInput{
|
||||
UserName: aws.String(iamUserAlice),
|
||||
AccessKeyId: aws.String(aliceKeyID),
|
||||
Status: aws.String(iam.StatusTypeInactive),
|
||||
@@ -375,11 +339,9 @@ func runIAMUserAuthentication(t *testing.T, fix *Fixture) {
|
||||
}
|
||||
|
||||
// runIAMPolicyCRUD ports IAM Phase 4 (~1583–1698):
|
||||
// CreatePolicy (5 variants), CreatePolicyVersion / ListPolicyVersions /
|
||||
// SetDefaultPolicyVersion / DeletePolicyVersion (extends bash, per
|
||||
// task spec), GetPolicy, GetPolicyVersion, ListPolicies plus
|
||||
// EntityAlreadyExists / MalformedPolicyDocument / NoSuchEntity errors.
|
||||
// Captures the admin account ID into the Fixture for Phase 5–7.
|
||||
// CreatePolicy (5 variants), GetPolicy, GetPolicyVersion, ListPolicies
|
||||
// plus EntityAlreadyExists / MalformedPolicyDocument / NoSuchEntity
|
||||
// errors. Captures the admin account ID into the Fixture for Phase 5–7.
|
||||
func runIAMPolicyCRUD(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "Single — IAM Policy CRUD")
|
||||
|
||||
@@ -416,7 +378,7 @@ func runIAMPolicyCRUD(t *testing.T, fix *Fixture) {
|
||||
if p.path != "" {
|
||||
key = p.path[1:] + p.name
|
||||
}
|
||||
iamDeletePolicyBestEffort(fix, iamPolicyARN(adminAccount, key))
|
||||
iamDeletePolicyBestEffort(fix, harness.IAMPolicyARN(adminAccount, key))
|
||||
}
|
||||
})
|
||||
harness.Detail(t, "policy", iamPolicyEC2ReadOnly, "arn", ec2roArn, "account", adminAccount)
|
||||
@@ -490,7 +452,7 @@ func runIAMPolicyCRUD(t *testing.T, fix *Fixture) {
|
||||
require.Equal(t, iamPolicyEC2ReadOnly, aws.StringValue(got.Policy.PolicyName))
|
||||
|
||||
harness.Step(t, "get-policy nonexistent (expect NoSuchEntity)")
|
||||
ghostArn := iamPolicyARN(adminAccount, "Ghost")
|
||||
ghostArn := harness.IAMPolicyARN(adminAccount, "Ghost")
|
||||
harness.ExpectError(t, "NoSuchEntity", func() error {
|
||||
_, e := fix.AWS.IAM.GetPolicy(&iam.GetPolicyInput{PolicyArn: aws.String(ghostArn)})
|
||||
return e
|
||||
@@ -504,47 +466,6 @@ func runIAMPolicyCRUD(t *testing.T, fix *Fixture) {
|
||||
require.NoError(t, err, "get-policy-version v1")
|
||||
require.Equal(t, "v1", aws.StringValue(pv.PolicyVersion.VersionId))
|
||||
|
||||
// Policy version lifecycle — extends the bash driver (task spec).
|
||||
// Round-trip: create v2 (as default), list, set v1 default, delete v2.
|
||||
// Daemon does not implement CreatePolicyVersion / ListPolicyVersions /
|
||||
// SetDefaultPolicyVersion / DeletePolicyVersion yet (InvalidAction).
|
||||
// Skip-gate until the handlers land.
|
||||
t.Run("PolicyVersionLifecycle", func(t *testing.T) {
|
||||
t.Skip("daemon CreatePolicyVersion handler not implemented — InvalidAction")
|
||||
harness.Step(t, "create-policy-version %s v2 (default)", iamPolicyEC2ReadOnly)
|
||||
cv, err := fix.AWS.IAM.CreatePolicyVersion(&iam.CreatePolicyVersionInput{
|
||||
PolicyArn: aws.String(ec2roArn),
|
||||
PolicyDocument: aws.String(iamDocEC2ReadOnlyV2),
|
||||
SetAsDefault: aws.Bool(true),
|
||||
})
|
||||
require.NoError(t, err, "create-policy-version v2")
|
||||
v2 := aws.StringValue(cv.PolicyVersion.VersionId)
|
||||
require.NotEmpty(t, v2, "empty new VersionId")
|
||||
harness.Detail(t, "new_version", v2)
|
||||
|
||||
harness.Step(t, "list-policy-versions %s", iamPolicyEC2ReadOnly)
|
||||
versions, err := fix.AWS.IAM.ListPolicyVersions(&iam.ListPolicyVersionsInput{
|
||||
PolicyArn: aws.String(ec2roArn),
|
||||
})
|
||||
require.NoError(t, err, "list-policy-versions")
|
||||
require.GreaterOrEqual(t, len(versions.Versions), 2,
|
||||
"expected >=2 versions, got %d", len(versions.Versions))
|
||||
|
||||
harness.Step(t, "set-default-policy-version %s v1", iamPolicyEC2ReadOnly)
|
||||
_, err = fix.AWS.IAM.SetDefaultPolicyVersion(&iam.SetDefaultPolicyVersionInput{
|
||||
PolicyArn: aws.String(ec2roArn),
|
||||
VersionId: aws.String("v1"),
|
||||
})
|
||||
require.NoError(t, err, "set-default-policy-version v1")
|
||||
|
||||
harness.Step(t, "delete-policy-version %s %s", iamPolicyEC2ReadOnly, v2)
|
||||
_, err = fix.AWS.IAM.DeletePolicyVersion(&iam.DeletePolicyVersionInput{
|
||||
PolicyArn: aws.String(ec2roArn),
|
||||
VersionId: aws.String(v2),
|
||||
})
|
||||
require.NoError(t, err, "delete-policy-version %s", v2)
|
||||
})
|
||||
|
||||
harness.Step(t, "list-policies (>=5)")
|
||||
all, err := fix.AWS.IAM.ListPolicies(&iam.ListPoliciesInput{})
|
||||
require.NoError(t, err, "list-policies")
|
||||
@@ -557,20 +478,17 @@ func runIAMPolicyCRUD(t *testing.T, fix *Fixture) {
|
||||
// (default deny / explicit allow / wildcard / explicit deny / root
|
||||
// bypass / prefix wildcard / FullAdmin), DetachUserPolicy.
|
||||
func runIAMPolicyAttachmentEnforcement(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "Single — IAM Policy Attachment IAM Phase 5 — Policy Attachment & Enforcement Enforcement")
|
||||
// Enforcement depends on scoped-credential signing which the daemon
|
||||
// doesn't honour yet.
|
||||
t.Skip("daemon IAM scoped-credential enforcement not implemented")
|
||||
adminAccount := iamEnsureAdminAccountID(t, fix)
|
||||
harness.Phase(t, "Single — IAM Policy Attachment & Enforcement")
|
||||
adminAccount := harness.IAMAccountID(t, fix.AWS)
|
||||
aliceKeyID, aliceSecret := iamEnsureAliceKey(t, fix)
|
||||
bobKeyID, bobSecret := iamEnsureBobKey(t, fix)
|
||||
charlieKeyID, charlieSecret := iamEnsureCharlieKey(t, fix)
|
||||
|
||||
ec2roArn := iamPolicyARN(adminAccount, iamPolicyEC2ReadOnly)
|
||||
iamroArn := iamPolicyARN(adminAccount, iamPolicyIAMReadOnly)
|
||||
denyArn := iamPolicyARN(adminAccount, iamPolicyDenyTerminate)
|
||||
descAllArn := iamPolicyARN(adminAccount, iamPolicyEC2DescribeAll)
|
||||
fullAdminArn := iamPolicyARN(adminAccount, iamPolicyFullAdminPath[1:]+iamPolicyFullAdmin)
|
||||
ec2roArn := harness.IAMPolicyARN(adminAccount, iamPolicyEC2ReadOnly)
|
||||
iamroArn := harness.IAMPolicyARN(adminAccount, iamPolicyIAMReadOnly)
|
||||
denyArn := harness.IAMPolicyARN(adminAccount, iamPolicyDenyTerminate)
|
||||
descAllArn := harness.IAMPolicyARN(adminAccount, iamPolicyEC2DescribeAll)
|
||||
fullAdminArn := harness.IAMPolicyARN(adminAccount, iamPolicyFullAdminPath[1:]+iamPolicyFullAdmin)
|
||||
|
||||
harness.Detail(t, "charlie_key", charlieKeyID)
|
||||
aliceCli := harness.NewAWSClientWithCreds(t, fix.Env, aliceKeyID, aliceSecret)
|
||||
@@ -617,7 +535,7 @@ func runIAMPolicyAttachmentEnforcement(t *testing.T, fix *Fixture) {
|
||||
harness.ExpectError(t, "NoSuchEntity", func() error {
|
||||
_, e := fix.AWS.IAM.AttachUserPolicy(&iam.AttachUserPolicyInput{
|
||||
UserName: aws.String(iamUserAlice),
|
||||
PolicyArn: aws.String(iamPolicyARN(adminAccount, "Ghost")),
|
||||
PolicyArn: aws.String(harness.IAMPolicyARN(adminAccount, "Ghost")),
|
||||
})
|
||||
return e
|
||||
})
|
||||
@@ -730,15 +648,12 @@ func runIAMPolicyAttachmentEnforcement(t *testing.T, fix *Fixture) {
|
||||
// still-attached policy must surface DeleteConflict; after detach +
|
||||
// delete the policy is gone (NoSuchEntity).
|
||||
func runIAMPolicyLifecycle(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "Single — IAM Policy Lifecycle (Detach IAM Phase 6 — Policy Lifecycle (Detach & Delete) Delete)")
|
||||
// DetachUserPolicy returns 404 NoSuchEntity even for confirmed attachments —
|
||||
// the daemon's attachment ledger isn't persisted across the API boundary.
|
||||
t.Skip("daemon DetachUserPolicy not implemented")
|
||||
adminAccount := iamEnsureAdminAccountID(t, fix)
|
||||
harness.Phase(t, "Single — IAM Policy Lifecycle (Detach & Delete)")
|
||||
adminAccount := harness.IAMAccountID(t, fix.AWS)
|
||||
aliceKeyID, aliceSecret := iamEnsureAliceKey(t, fix)
|
||||
|
||||
descAllArn := iamPolicyARN(adminAccount, iamPolicyEC2DescribeAll)
|
||||
denyArn := iamPolicyARN(adminAccount, iamPolicyDenyTerminate)
|
||||
descAllArn := harness.IAMPolicyARN(adminAccount, iamPolicyEC2DescribeAll)
|
||||
denyArn := harness.IAMPolicyARN(adminAccount, iamPolicyDenyTerminate)
|
||||
|
||||
aliceCli := harness.NewAWSClientWithCreds(t, fix.Env, aliceKeyID, aliceSecret)
|
||||
|
||||
@@ -785,7 +700,7 @@ func runIAMCleanup(t *testing.T, fix *Fixture) {
|
||||
|
||||
// Resolve account ID before deleting users — iamEnsureAdminAccountID
|
||||
// probes via GetUser(alice), and the user is gone after the loop below.
|
||||
adminAccount := iamEnsureAdminAccountID(t, fix)
|
||||
adminAccount := harness.IAMAccountID(t, fix.AWS)
|
||||
|
||||
// Users: delete their keys first, then the user. iamDeleteUserBestEffort
|
||||
// handles already-deleted resources gracefully.
|
||||
@@ -819,7 +734,7 @@ func runIAMCleanup(t *testing.T, fix *Fixture) {
|
||||
// stripping the leading '/' matches iamPolicyARN's expectation.
|
||||
key = p.path[1:] + p.name
|
||||
}
|
||||
arn := iamPolicyARN(adminAccount, key)
|
||||
arn := harness.IAMPolicyARN(adminAccount, key)
|
||||
harness.Step(t, "cleanup policy %s", arn)
|
||||
iamDeletePolicyBestEffort(fix, arn)
|
||||
}
|
||||
@@ -945,9 +860,3 @@ func iamAccountFromARN(t *testing.T, arn string) string {
|
||||
require.NotEmpty(t, parts[4], "empty account-id in ARN %q", arn)
|
||||
return parts[4]
|
||||
}
|
||||
|
||||
// iamPolicyARN builds the canonical policy ARN for a given account +
|
||||
// policy "key" (name or "path/name" without the leading slash).
|
||||
func iamPolicyARN(account, key string) string {
|
||||
return "arn:aws:iam::" + account + ":policy/" + key
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build e2e
|
||||
|
||||
package single
|
||||
package iam
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
@@ -27,6 +27,12 @@ const (
|
||||
imdsRoleName = "imds-e2e-role"
|
||||
imdsProfileName = "imds-e2e-profile"
|
||||
|
||||
// imdsAdminPolicyName is the bootstrap managed policy attached to the IMDS
|
||||
// role so the minted instance-role creds round-trip a real API call.
|
||||
imdsAdminPolicyName = "AdministratorAccess"
|
||||
// imdsTrustPolicyEC2 lets ec2.amazonaws.com assume the IMDS instance role.
|
||||
imdsTrustPolicyEC2 = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}`
|
||||
|
||||
// metaURL is the AWS-compatible link-local IMDS endpoint, answered L2 on each
|
||||
// subnet's own switch from the host via SO_BINDTODEVICE on the
|
||||
// imds-h-<shortSubnetID> veth (no router, no proxy-ARP, no /32 route).
|
||||
@@ -87,7 +93,7 @@ func runIMDS(t *testing.T, fix *Fixture) {
|
||||
harness.SkipIfNoOVN(t)
|
||||
requireSSHHealthy(t)
|
||||
|
||||
adminAccount := iamEnsureAdminAccountID(t, fix)
|
||||
adminAccount := harness.IAMAccountID(t, fix.AWS)
|
||||
keyName, keyPath := needKeyPair(t, fix)
|
||||
imdsEnsureRoleProfile(t, fix, adminAccount)
|
||||
|
||||
@@ -525,16 +531,16 @@ func imdsAttachInternet(t *testing.T, c *harness.AWSClient, vpcID, subnetID stri
|
||||
// sweeps any residue from a prior run first.
|
||||
func imdsEnsureRoleProfile(t *testing.T, fix *Fixture, adminAccount string) {
|
||||
t.Helper()
|
||||
adminPolicyARN := iamPolicyARN(adminAccount, iamPolicyAdministrator)
|
||||
iamDeleteRoleAndProfilesBestEffort(fix, imdsRoleName, []string{imdsProfileName}, adminPolicyARN)
|
||||
adminPolicyARN := harness.IAMPolicyARN(adminAccount, imdsAdminPolicyName)
|
||||
harness.IAMDeleteRoleAndProfilesBestEffort(fix.AWS, imdsRoleName, []string{imdsProfileName}, adminPolicyARN)
|
||||
fix.Harness.RegisterCleanup(func() {
|
||||
iamDeleteRoleAndProfilesBestEffort(fix, imdsRoleName, []string{imdsProfileName}, adminPolicyARN)
|
||||
harness.IAMDeleteRoleAndProfilesBestEffort(fix.AWS, imdsRoleName, []string{imdsProfileName}, adminPolicyARN)
|
||||
})
|
||||
|
||||
harness.Step(t, "create-role %q (trust=ec2.amazonaws.com) + profile %q", imdsRoleName, imdsProfileName)
|
||||
_, err := fix.AWS.IAM.CreateRole(&iam.CreateRoleInput{
|
||||
RoleName: aws.String(imdsRoleName),
|
||||
AssumeRolePolicyDocument: aws.String(iamTrustPolicyEC2Standard),
|
||||
AssumeRolePolicyDocument: aws.String(imdsTrustPolicyEC2),
|
||||
Description: aws.String("E2E IMDS instance-role credentials"),
|
||||
})
|
||||
require.NoError(t, err, "create-role")
|
||||
+36
-24
@@ -1,6 +1,6 @@
|
||||
//go:build e2e
|
||||
|
||||
package single
|
||||
package iam
|
||||
|
||||
import (
|
||||
"strings"
|
||||
@@ -14,24 +14,36 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// IAM identifiers owned by this test. They use a single-iamprofile- namespace
|
||||
// distinct from the control-plane iam suite's app-role/app-profile so the two
|
||||
// suites can run concurrently against the same account without colliding on
|
||||
// account-flat IAM names.
|
||||
const (
|
||||
singleIAMProfileRole = "single-iamprofile-role"
|
||||
singleIAMProfileApp = "single-iamprofile-profile"
|
||||
singleIAMProfileOther = "single-iamprofile-other-profile"
|
||||
singleIAMProfileAdminPolicy = "AdministratorAccess"
|
||||
singleIAMProfileTrustPolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}`
|
||||
)
|
||||
|
||||
// runIAMInstanceProfileAssociation ports run-e2e.sh IAM Phase 9: EC2 IAM
|
||||
// instance-profile association lifecycle. Reuses the singleton VM for the
|
||||
// post-launch Associate/Replace/Disassociate cycle, then launches a single
|
||||
// short-lived VM whose sole purpose is to exercise (a) RunInstances with
|
||||
// --iam-instance-profile and (b) auto-disassociate on terminate.
|
||||
//
|
||||
// Pre-state: TestIAMRolesAndProfiles already tore everything down — this
|
||||
// test recreates the role + two profiles, asserts the EC2 surface, then
|
||||
// cleans up so subsequent tests see no residue.
|
||||
// Self-bootstrapping: it creates its own role + two profiles under the
|
||||
// single-iamprofile- namespace, asserts the EC2 surface, then cleans up so
|
||||
// subsequent tests see no residue.
|
||||
func runIAMInstanceProfileAssociation(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "Single — EC2 IAM Instance Profile Association")
|
||||
|
||||
adminAccount := iamEnsureAdminAccountID(t, fix)
|
||||
adminPolicyARN := iamPolicyARN(adminAccount, iamPolicyAdministrator)
|
||||
adminAccount := harness.IAMAccountID(t, fix.AWS)
|
||||
adminPolicyARN := harness.IAMPolicyARN(adminAccount, singleIAMProfileAdminPolicy)
|
||||
|
||||
// Defensive cleanup of stale state from a prior run.
|
||||
iamDeleteRoleAndProfilesBestEffort(fix, iamRoleAppName,
|
||||
[]string{iamProfileAppName, iamProfileOtherName}, adminPolicyARN)
|
||||
harness.IAMDeleteRoleAndProfilesBestEffort(fix.AWS, singleIAMProfileRole,
|
||||
[]string{singleIAMProfileApp, singleIAMProfileOther}, adminPolicyARN)
|
||||
|
||||
// Singleton instance — reused for the post-launch Associate/Replace
|
||||
// cycle. Must be running before we proceed.
|
||||
@@ -43,27 +55,27 @@ func runIAMInstanceProfileAssociation(t *testing.T, fix *Fixture) {
|
||||
// Build role + two profiles. The second profile is the Replace target.
|
||||
harness.Step(t, "setup: create-role + 2 instance profiles")
|
||||
_, err := fix.AWS.IAM.CreateRole(&iam.CreateRoleInput{
|
||||
RoleName: aws.String(iamRoleAppName),
|
||||
AssumeRolePolicyDocument: aws.String(iamTrustPolicyEC2Standard),
|
||||
RoleName: aws.String(singleIAMProfileRole),
|
||||
AssumeRolePolicyDocument: aws.String(singleIAMProfileTrustPolicy),
|
||||
})
|
||||
require.NoError(t, err, "create-role")
|
||||
|
||||
// Even though root bypasses iam:PassRole, attach AdministratorAccess
|
||||
// to match production usage and exercise the PassRole code path.
|
||||
_, err = fix.AWS.IAM.AttachRolePolicy(&iam.AttachRolePolicyInput{
|
||||
RoleName: aws.String(iamRoleAppName),
|
||||
RoleName: aws.String(singleIAMProfileRole),
|
||||
PolicyArn: aws.String(adminPolicyARN),
|
||||
})
|
||||
require.NoError(t, err, "attach AdministratorAccess to role")
|
||||
|
||||
for _, p := range []string{iamProfileAppName, iamProfileOtherName} {
|
||||
for _, p := range []string{singleIAMProfileApp, singleIAMProfileOther} {
|
||||
_, err = fix.AWS.IAM.CreateInstanceProfile(&iam.CreateInstanceProfileInput{
|
||||
InstanceProfileName: aws.String(p),
|
||||
})
|
||||
require.NoError(t, err, "create-instance-profile %s", p)
|
||||
_, err = fix.AWS.IAM.AddRoleToInstanceProfile(&iam.AddRoleToInstanceProfileInput{
|
||||
InstanceProfileName: aws.String(p),
|
||||
RoleName: aws.String(iamRoleAppName),
|
||||
RoleName: aws.String(singleIAMProfileRole),
|
||||
})
|
||||
require.NoError(t, err, "add-role-to-instance-profile %s", p)
|
||||
}
|
||||
@@ -86,15 +98,15 @@ func runIAMInstanceProfileAssociation(t *testing.T, fix *Fixture) {
|
||||
&ec2.DisassociateIamInstanceProfileInput{AssociationId: a.AssociationId})
|
||||
}
|
||||
}
|
||||
iamDeleteRoleAndProfilesBestEffort(fix, iamRoleAppName,
|
||||
[]string{iamProfileAppName, iamProfileOtherName}, adminPolicyARN)
|
||||
harness.IAMDeleteRoleAndProfilesBestEffort(fix.AWS, singleIAMProfileRole,
|
||||
[]string{singleIAMProfileApp, singleIAMProfileOther}, adminPolicyARN)
|
||||
})
|
||||
|
||||
// ---- Associate (post-launch) on singleton ----
|
||||
harness.Step(t, "associate-iam-instance-profile %s -> %s", iamProfileAppName, singletonID)
|
||||
harness.Step(t, "associate-iam-instance-profile %s -> %s", singleIAMProfileApp, singletonID)
|
||||
assocOut, err := fix.AWS.EC2.AssociateIamInstanceProfile(&ec2.AssociateIamInstanceProfileInput{
|
||||
InstanceId: aws.String(singletonID),
|
||||
IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(iamProfileAppName)},
|
||||
IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(singleIAMProfileApp)},
|
||||
})
|
||||
require.NoError(t, err, "associate-iam-instance-profile")
|
||||
require.NotNil(t, assocOut.IamInstanceProfileAssociation, "missing association in response")
|
||||
@@ -108,7 +120,7 @@ func runIAMInstanceProfileAssociation(t *testing.T, fix *Fixture) {
|
||||
harness.ExpectError(t, "IamInstanceProfileAlreadyAssociated", func() error {
|
||||
_, e := fix.AWS.EC2.AssociateIamInstanceProfile(&ec2.AssociateIamInstanceProfileInput{
|
||||
InstanceId: aws.String(singletonID),
|
||||
IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(iamProfileAppName)},
|
||||
IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(singleIAMProfileApp)},
|
||||
})
|
||||
return e
|
||||
})
|
||||
@@ -124,7 +136,7 @@ func runIAMInstanceProfileAssociation(t *testing.T, fix *Fixture) {
|
||||
ip := desc.Reservations[0].Instances[0].IamInstanceProfile
|
||||
require.NotNil(t, ip, "DescribeInstances must populate IamInstanceProfile when bound")
|
||||
require.True(t,
|
||||
strings.HasSuffix(aws.StringValue(ip.Arn), ":instance-profile/"+iamProfileAppName),
|
||||
strings.HasSuffix(aws.StringValue(ip.Arn), ":instance-profile/"+singleIAMProfileApp),
|
||||
"unexpected IamInstanceProfile.Arn %q", aws.StringValue(ip.Arn))
|
||||
|
||||
// DescribeIamInstanceProfileAssociations by id.
|
||||
@@ -143,17 +155,17 @@ func runIAMInstanceProfileAssociation(t *testing.T, fix *Fixture) {
|
||||
harness.Step(t, "delete-instance-profile while bound (expect DeleteConflict)")
|
||||
harness.ExpectError(t, "DeleteConflict", func() error {
|
||||
_, e := fix.AWS.IAM.DeleteInstanceProfile(&iam.DeleteInstanceProfileInput{
|
||||
InstanceProfileName: aws.String(iamProfileAppName),
|
||||
InstanceProfileName: aws.String(singleIAMProfileApp),
|
||||
})
|
||||
return e
|
||||
})
|
||||
|
||||
// Replace → other-profile, must mint a new assoc id.
|
||||
harness.Step(t, "replace-iam-instance-profile-association -> %s", iamProfileOtherName)
|
||||
harness.Step(t, "replace-iam-instance-profile-association -> %s", singleIAMProfileOther)
|
||||
replaceOut, err := fix.AWS.EC2.ReplaceIamInstanceProfileAssociation(
|
||||
&ec2.ReplaceIamInstanceProfileAssociationInput{
|
||||
AssociationId: aws.String(assocID),
|
||||
IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(iamProfileOtherName)},
|
||||
IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(singleIAMProfileOther)},
|
||||
})
|
||||
require.NoError(t, err, "replace-iam-instance-profile-association")
|
||||
newAssocID := aws.StringValue(replaceOut.IamInstanceProfileAssociation.AssociationId)
|
||||
@@ -166,7 +178,7 @@ func runIAMInstanceProfileAssociation(t *testing.T, fix *Fixture) {
|
||||
_, e := fix.AWS.EC2.ReplaceIamInstanceProfileAssociation(
|
||||
&ec2.ReplaceIamInstanceProfileAssociationInput{
|
||||
AssociationId: aws.String(assocID),
|
||||
IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(iamProfileAppName)},
|
||||
IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(singleIAMProfileApp)},
|
||||
})
|
||||
return e
|
||||
})
|
||||
@@ -200,7 +212,7 @@ func runIAMInstanceProfileAssociation(t *testing.T, fix *Fixture) {
|
||||
// Reuse the singleton's launch attributes so we don't need to rediscover
|
||||
// AMI / type / key / subnet / SG — guarantees the ephemeral VM lands on a
|
||||
// known-good combination on the local single-node fixture.
|
||||
ephID := runInstanceWithProfile(t, fix, inst, iamProfileAppName)
|
||||
ephID := runInstanceWithProfile(t, fix, inst, singleIAMProfileApp)
|
||||
defer func() {
|
||||
// Belt-and-braces: even if the auto-disassociate assertion fails, we
|
||||
// must not leave a VM running. Terminate is idempotent.
|
||||
@@ -0,0 +1,108 @@
|
||||
//go:build e2e
|
||||
|
||||
// Package iam is the standalone "everything IAM" E2E suite, split out of the
|
||||
// single-node suite so it builds and runs as its own iam.test binary. Every
|
||||
// entry point is a top-level Test* in tests_test.go. The control-plane phases
|
||||
// (IAM/STS CRUD) are zero-VM; the instance-identity tail (instance-profile
|
||||
// association + IMDS) boots guest VMs, so the suite is a single-mode,
|
||||
// VM-booting matrix leg covering the awsgw IAM/STS surface plus the
|
||||
// instance-profile + IMDS datapath.
|
||||
package iam
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/tests/e2e/harness"
|
||||
)
|
||||
|
||||
// Package-scoped singleton fixture. Initialised lazily by the first call to
|
||||
// requireIAMFixture. TestMain drains its cleanup chain after m.Run() so every
|
||||
// resource ensured during the run is torn down at process exit, regardless of
|
||||
// which Test* created it.
|
||||
var (
|
||||
pkgFixOnce sync.Once
|
||||
pkgFix *Fixture
|
||||
pkgFixErr error
|
||||
)
|
||||
|
||||
// TestMain owns process-level lifecycle for the singleton fixture. The
|
||||
// singleton itself is built lazily by requireIAMFixture so a test run with
|
||||
// SPINIFEX_E2E unset stays cheap (no AWS dial, no temp dir).
|
||||
func TestMain(m *testing.M) {
|
||||
code := m.Run()
|
||||
if pkgFix != nil {
|
||||
if pkgFix.Harness != nil {
|
||||
pkgFix.Harness.Close()
|
||||
}
|
||||
if pkgFix.TmpDir != "" {
|
||||
_ = os.RemoveAll(pkgFix.TmpDir)
|
||||
}
|
||||
}
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// requireIAMFixture returns the package-scoped Fixture singleton, building it on
|
||||
// first call. Skips the calling test if SPINIFEX_E2E is unset or the cluster
|
||||
// mode is not single-node. Fails the test if init itself errors (e.g. AWS
|
||||
// client construction). PoolMode is detected so the instance-identity tests can
|
||||
// gate their probe-VPC public-IP path.
|
||||
func requireIAMFixture(t *testing.T) *Fixture {
|
||||
t.Helper()
|
||||
pkgFixOnce.Do(func() {
|
||||
if os.Getenv("SPINIFEX_E2E") == "" {
|
||||
return
|
||||
}
|
||||
env := harness.LoadEnv(t)
|
||||
if env.Mode != harness.ModeSingle {
|
||||
return
|
||||
}
|
||||
awsCli := harness.NewAWSClient(t, env)
|
||||
h, herr := harness.NewProcessFixture(awsCli)
|
||||
if herr != nil {
|
||||
pkgFixErr = herr
|
||||
return
|
||||
}
|
||||
tmpDir, terr := os.MkdirTemp("", "iam-pkgfix-*")
|
||||
if terr != nil {
|
||||
pkgFixErr = terr
|
||||
return
|
||||
}
|
||||
pkgFix = &Fixture{
|
||||
Env: env,
|
||||
AWS: awsCli,
|
||||
Harness: h,
|
||||
TmpDir: tmpDir,
|
||||
PoolMode: detectPoolMode(env),
|
||||
}
|
||||
})
|
||||
if pkgFixErr != nil {
|
||||
t.Fatalf("iam fixture init failed: %v", pkgFixErr)
|
||||
}
|
||||
if pkgFix == nil {
|
||||
t.Skip("iam fixture unavailable (SPINIFEX_E2E unset or mode != single)")
|
||||
}
|
||||
return pkgFix
|
||||
}
|
||||
|
||||
// Fixture carries the per-process state shared across every Test* in this
|
||||
// package. Only environment-level slots — every per-phase resource ID is
|
||||
// memoized on Harness (harness.Fixture) or the package-local iamEnsure* helpers.
|
||||
type Fixture struct {
|
||||
Env *harness.Env
|
||||
AWS *harness.AWSClient
|
||||
Harness *harness.Fixture // memoized Ensure* fixture; spans the whole process.
|
||||
TmpDir string // package-scoped scratch dir; survives every Test* in the package.
|
||||
PoolMode bool // external IPAM in play; gates the IMDS probe-VPC public-IP path.
|
||||
}
|
||||
|
||||
// ArtifactDir returns the artifact directory for the *currently running* test.
|
||||
// It must be derived per-call from the live t (not memoized on the singleton
|
||||
// Fixture): the process fixture is built lazily during whichever test runs
|
||||
// first, so a stored dir would freeze to that test's name and every later test
|
||||
// would write into a stale — and, once that test passes, pruned — directory.
|
||||
func (f *Fixture) ArtifactDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
return harness.ArtifactDir(t, f.Env)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build e2e
|
||||
|
||||
package single
|
||||
package iam
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -54,7 +54,7 @@ const (
|
||||
// blocks (Condition / NotPrincipal / NotAction) via the SDK.
|
||||
func runSTS(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "Single — STS AssumeRole + GetCallerIdentity")
|
||||
adminAccount := iamEnsureAdminAccountID(t, fix)
|
||||
adminAccount := harness.IAMAccountID(t, fix.AWS)
|
||||
|
||||
// Bootstrap-creds GetCallerIdentity — sanity check the live endpoint and
|
||||
// capture the active ARN for the detail log so failures elsewhere are
|
||||
@@ -151,11 +151,11 @@ func runSTS(t *testing.T, fix *Fixture) {
|
||||
stsRoleNameForbidNotAction,
|
||||
}
|
||||
for _, name := range stsRoles {
|
||||
iamDeleteRoleAndProfilesBestEffort(fix, name, nil)
|
||||
harness.IAMDeleteRoleAndProfilesBestEffort(fix.AWS, name, nil)
|
||||
}
|
||||
fix.Harness.RegisterCleanup(func() {
|
||||
for _, name := range stsRoles {
|
||||
iamDeleteRoleAndProfilesBestEffort(fix, name, nil)
|
||||
harness.IAMDeleteRoleAndProfilesBestEffort(fix.AWS, name, nil)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -167,7 +167,7 @@ func runSTS(t *testing.T, fix *Fixture) {
|
||||
})
|
||||
require.NoError(t, err, "create-role")
|
||||
roleARN := aws.StringValue(createOut.Role.Arn)
|
||||
require.Equal(t, iamRoleARN(adminAccount, stsRoleName), roleARN,
|
||||
require.Equal(t, harness.IAMRoleARN(adminAccount, stsRoleName), roleARN,
|
||||
"role ARN must follow arn:aws:iam::<acct>:role/<name>")
|
||||
|
||||
// Happy path. Verify the wire-format invariants: ASIA prefix, non-empty
|
||||
@@ -375,7 +375,7 @@ func runSTS(t *testing.T, fix *Fixture) {
|
||||
// → trust-policy match → new ASIA mint) is not.
|
||||
chainedTrustPolicy := fmt.Sprintf(
|
||||
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":%q},"Action":"sts:AssumeRole"}]}`,
|
||||
iamRoleARN(adminAccount, stsRoleName),
|
||||
harness.IAMRoleARN(adminAccount, stsRoleName),
|
||||
)
|
||||
harness.Step(t, "create-role %q (trust=role/%s for chained assume)",
|
||||
stsRoleNameChain, stsRoleName)
|
||||
@@ -445,7 +445,7 @@ func runSTS(t *testing.T, fix *Fixture) {
|
||||
harness.Step(t, "assume-role missing role (expect NoSuchEntity)")
|
||||
harness.ExpectError(t, "NoSuchEntity", func() error {
|
||||
_, e := fix.AWS.STS.AssumeRole(&sts.AssumeRoleInput{
|
||||
RoleArn: aws.String(iamRoleARN(adminAccount,
|
||||
RoleArn: aws.String(harness.IAMRoleARN(adminAccount,
|
||||
"sts-e2e-no-such-role")),
|
||||
RoleSessionName: aws.String("ghost"),
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
//go:build e2e
|
||||
|
||||
package iam
|
||||
|
||||
import "testing"
|
||||
|
||||
// Top-level Test* entry points for the IAM/STS + instance-identity suite. Each
|
||||
// delegates to a runX function in the matching <name>_test.go file. Kept as
|
||||
// separate top-level Test* (not one inline Test) so `go test -run TestX` and the
|
||||
// suite selector can address each individually.
|
||||
//
|
||||
// Execution is sequential in source order: these tests share the package-scoped
|
||||
// IAM state (users/policies/roles are account-flat), and TestIAMCleanup tears
|
||||
// down the user/policy graph the earlier phases build. The parallelism win of
|
||||
// the split is the suite-as-matrix-leg, not intra-suite concurrency — sub-tests
|
||||
// inside each runIAM* still fan out with t.Parallel where it is safe.
|
||||
//
|
||||
// The final two entries (TestIAMInstanceProfileAssociation, TestIMDS) boot guest
|
||||
// VMs. They self-bootstrap their own roles/profiles/VPCs under collision-safe
|
||||
// namespaces and run last, after the control-plane phases.
|
||||
|
||||
func TestIAMUserCRUD(t *testing.T) {
|
||||
runIAMUserCRUD(t, requireIAMFixture(t))
|
||||
}
|
||||
|
||||
func TestIAMAccessKeyLifecycle(t *testing.T) {
|
||||
runIAMAccessKeyLifecycle(t, requireIAMFixture(t))
|
||||
}
|
||||
|
||||
func TestIAMUserAuthentication(t *testing.T) {
|
||||
runIAMUserAuthentication(t, requireIAMFixture(t))
|
||||
}
|
||||
|
||||
func TestIAMPolicyCRUD(t *testing.T) {
|
||||
runIAMPolicyCRUD(t, requireIAMFixture(t))
|
||||
}
|
||||
|
||||
func TestIAMPolicyAttachmentEnforcement(t *testing.T) {
|
||||
runIAMPolicyAttachmentEnforcement(t, requireIAMFixture(t))
|
||||
}
|
||||
|
||||
func TestIAMPolicyLifecycle(t *testing.T) {
|
||||
runIAMPolicyLifecycle(t, requireIAMFixture(t))
|
||||
}
|
||||
|
||||
func TestIAMCleanup(t *testing.T) {
|
||||
runIAMCleanup(t, requireIAMFixture(t))
|
||||
}
|
||||
|
||||
func TestIAMRolesAndProfiles(t *testing.T) {
|
||||
runIAMRolesAndProfiles(t, requireIAMFixture(t))
|
||||
}
|
||||
|
||||
// TestSTSAssumeRoleAndGetCallerIdentity is sequential to avoid racing trust-policy
|
||||
// mutations against a parallel AssumeRole.
|
||||
func TestSTSAssumeRoleAndGetCallerIdentity(t *testing.T) {
|
||||
runSTS(t, requireIAMFixture(t))
|
||||
}
|
||||
|
||||
// TestAssumedRoleControlPlaneEnforcement verifies a zero-policy assumed-role is
|
||||
// denied and permitted once a policy is attached. Sequential to avoid racing
|
||||
// the mid-test grant.
|
||||
func TestAssumedRoleControlPlaneEnforcement(t *testing.T) {
|
||||
runAssumedRoleControlPlaneEnforcement(t, requireIAMFixture(t))
|
||||
}
|
||||
|
||||
// TestIAMInstanceProfileAssociation exercises the EC2 IAM instance-profile
|
||||
// association lifecycle (associate/replace/disassociate, RunInstances
|
||||
// --iam-instance-profile, auto-disassociate on terminate). It boots its own
|
||||
// instance and self-bootstraps a role + two profiles under the
|
||||
// single-iamprofile- namespace, distinct from this suite's app-role/app-profile.
|
||||
// Sequential: it boots VMs and mutates instance state.
|
||||
func TestIAMInstanceProfileAssociation(t *testing.T) {
|
||||
runIAMInstanceProfileAssociation(t, requireIAMFixture(t))
|
||||
}
|
||||
|
||||
// TestIMDS exercises IMDSv2 end-to-end: token issuance, metadata surface,
|
||||
// instance-role credentials, OVN datapath, and cross-VPC isolation. Sequential
|
||||
// because it launches profile-bound VMs and creates fresh VPCs (imds-e2e-*).
|
||||
func TestIMDS(t *testing.T) {
|
||||
runIMDS(t, requireIAMFixture(t))
|
||||
}
|
||||
@@ -7,6 +7,13 @@ create tests/e2e/eks/eks_test.go CreateVpc #1
|
||||
create tests/e2e/gpu/gpu_test.go RunInstances #1
|
||||
create tests/e2e/gpu/gpu_test.go RunInstances #2
|
||||
create tests/e2e/gpu/helpers_test.go RunInstances #1
|
||||
create tests/e2e/iam/iam_test.go CreateKeyPair #1
|
||||
create tests/e2e/iam/imds_test.go CreateInternetGateway #1
|
||||
create tests/e2e/iam/imds_test.go CreateRouteTable #1
|
||||
create tests/e2e/iam/imds_test.go CreateSubnet #1
|
||||
create tests/e2e/iam/imds_test.go CreateVpc #1
|
||||
create tests/e2e/iam/imds_test.go RunInstances #1
|
||||
create tests/e2e/iam/instance_iam_profile_test.go RunInstances #1
|
||||
create tests/e2e/lb/lb_test.go CreateInternetGateway #1
|
||||
create tests/e2e/lb/lb_test.go CreateKeyPair #1
|
||||
create tests/e2e/lb/lb_test.go CreateListener #1
|
||||
@@ -72,13 +79,6 @@ create tests/e2e/single/baseline_test.go CreateVpc #1
|
||||
create tests/e2e/single/baseline_test.go RunInstances #1
|
||||
create tests/e2e/single/datapath_test.go CreateSecurityGroup #1
|
||||
create tests/e2e/single/datapath_test.go RunInstances #1
|
||||
create tests/e2e/single/iam_test.go CreateKeyPair #1
|
||||
create tests/e2e/single/imds_test.go CreateInternetGateway #1
|
||||
create tests/e2e/single/imds_test.go CreateRouteTable #1
|
||||
create tests/e2e/single/imds_test.go CreateSubnet #1
|
||||
create tests/e2e/single/imds_test.go CreateVpc #1
|
||||
create tests/e2e/single/imds_test.go RunInstances #1
|
||||
create tests/e2e/single/instance_iam_profile_test.go RunInstances #1
|
||||
create tests/e2e/single/keypair_test.go ImportKeyPair #1
|
||||
create tests/e2e/single/lifecycle_test.go CreateVolume #1
|
||||
create tests/e2e/single/lifecycle_test.go RunInstances #1
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -90,7 +91,9 @@ func runEnvironment(t *testing.T, fix *Fixture) {
|
||||
|
||||
// runClusterStatsCLI exercises the spx CLI cluster surface (`get nodes`,
|
||||
// `top nodes`, `get vms`). Single-node only — multinode mode is tested by a
|
||||
// different scenario. Maps to run-e2e.sh ~95–127.
|
||||
// different scenario. The `get vms` baseline is concurrency-tolerant: it asserts
|
||||
// the empty state only when no instance rows are present, so suites booting VMs
|
||||
// in parallel (iam/lb) don't break it. Maps to run-e2e.sh ~95–127.
|
||||
func runClusterStatsCLI(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "Single — Cluster Stats CLI")
|
||||
if fix.Env.Mode != harness.ModeSingle {
|
||||
@@ -108,10 +111,17 @@ func runClusterStatsCLI(t *testing.T, fix *Fixture) {
|
||||
// actually rendered.
|
||||
assert.Contains(t, top, "0/", "spx top nodes should report resource stats\n%s", top)
|
||||
|
||||
harness.Step(t, "spx get vms (empty before any launches)")
|
||||
// spx get vms is node-wide: a hard "No VMs found" baseline breaks once the
|
||||
// iam/lb suites boot VMs concurrently. Assert the empty state only when no
|
||||
// instance rows are present; otherwise log (the CLI is still exercised).
|
||||
harness.Step(t, "spx get vms (baseline; tolerant of concurrent suites)")
|
||||
vms := harness.SpxGetVMs(t)
|
||||
assert.Contains(t, vms, "No VMs found",
|
||||
"spx get vms should be empty before Phase 5\n%s", vms)
|
||||
if strings.Contains(vms, "i-") {
|
||||
t.Logf("spx get vms lists instance-like rows at baseline (may be from concurrent suites):\n%s", vms)
|
||||
} else {
|
||||
assert.Contains(t, vms, "No VMs found",
|
||||
"spx get vms with no instances present should report the empty state\n%s", vms)
|
||||
}
|
||||
}
|
||||
|
||||
// runDiscovery re-asserts describe-regions / describe-availability-zones
|
||||
|
||||
@@ -69,7 +69,8 @@ func TestReplaceRouteConvergence(t *testing.T) {
|
||||
|
||||
// --- Sequential: singleton VM lifecycle ---
|
||||
|
||||
// TestClusterStatsCLI asserts 0-VM baseline state before any instance launches.
|
||||
// TestClusterStatsCLI exercises the spx cluster CLI; its get-vms baseline is
|
||||
// concurrency-tolerant (other suites may have VMs on the node).
|
||||
func TestClusterStatsCLI(t *testing.T) {
|
||||
runClusterStatsCLI(t, requireSingleNodeFixture(t))
|
||||
}
|
||||
@@ -185,68 +186,6 @@ func TestSGToSGDatapath(t *testing.T) {
|
||||
runSGToSGDatapath(t, requireSingleNodeFixture(t))
|
||||
}
|
||||
|
||||
// IAM Tests share the package-scoped IAM fixture; sub-tests inside each
|
||||
// runIAM* already use t.Parallel for fan-out.
|
||||
func TestIAMUserCRUD(t *testing.T) {
|
||||
runIAMUserCRUD(t, requireSingleNodeFixture(t))
|
||||
}
|
||||
|
||||
func TestIAMAccessKeyLifecycle(t *testing.T) {
|
||||
runIAMAccessKeyLifecycle(t, requireSingleNodeFixture(t))
|
||||
}
|
||||
|
||||
func TestIAMUserAuthentication(t *testing.T) {
|
||||
runIAMUserAuthentication(t, requireSingleNodeFixture(t))
|
||||
}
|
||||
|
||||
func TestIAMPolicyCRUD(t *testing.T) {
|
||||
runIAMPolicyCRUD(t, requireSingleNodeFixture(t))
|
||||
}
|
||||
|
||||
func TestIAMPolicyAttachmentEnforcement(t *testing.T) {
|
||||
runIAMPolicyAttachmentEnforcement(t, requireSingleNodeFixture(t))
|
||||
}
|
||||
|
||||
func TestIAMPolicyLifecycle(t *testing.T) {
|
||||
runIAMPolicyLifecycle(t, requireSingleNodeFixture(t))
|
||||
}
|
||||
|
||||
func TestIAMCleanup(t *testing.T) {
|
||||
runIAMCleanup(t, requireSingleNodeFixture(t))
|
||||
}
|
||||
|
||||
// TestIAMRolesAndProfiles + TestIAMInstanceProfileAssociation are sequential:
|
||||
// the second recreates role/profile names the first tore down.
|
||||
func TestIAMRolesAndProfiles(t *testing.T) {
|
||||
runIAMRolesAndProfiles(t, requireSingleNodeFixture(t))
|
||||
}
|
||||
|
||||
// TestIAMInstanceProfileAssociation mutates the singleton VM, so it cannot
|
||||
// share the parallel bucket.
|
||||
func TestIAMInstanceProfileAssociation(t *testing.T) {
|
||||
runIAMInstanceProfileAssociation(t, requireSingleNodeFixture(t))
|
||||
}
|
||||
|
||||
// TestSTSAssumeRoleAndGetCallerIdentity is sequential to avoid racing trust-policy
|
||||
// mutations against a parallel AssumeRole.
|
||||
func TestSTSAssumeRoleAndGetCallerIdentity(t *testing.T) {
|
||||
runSTS(t, requireSingleNodeFixture(t))
|
||||
}
|
||||
|
||||
// TestAssumedRoleControlPlaneEnforcement verifies a zero-policy assumed-role
|
||||
// is denied and permitted once a policy is attached. Sequential to avoid
|
||||
// racing the mid-test grant.
|
||||
func TestAssumedRoleControlPlaneEnforcement(t *testing.T) {
|
||||
runAssumedRoleControlPlaneEnforcement(t, requireSingleNodeFixture(t))
|
||||
}
|
||||
|
||||
// TestIMDS exercises IMDSv2 end-to-end: token issuance, metadata surface,
|
||||
// instance-role credentials, OVN datapath, and cross-VPC isolation. Sequential
|
||||
// because it launches profile-bound VMs and creates a fresh VPC.
|
||||
func TestIMDS(t *testing.T) {
|
||||
runIMDS(t, requireSingleNodeFixture(t))
|
||||
}
|
||||
|
||||
// TestFinalClusterStats runs as the last sequential test.
|
||||
func TestFinalClusterStats(t *testing.T) {
|
||||
runFinalClusterStats(t, requireSingleNodeFixture(t))
|
||||
|
||||
Reference in New Issue
Block a user