feat: split IAM e2e (#372)

This commit is contained in:
Julian Sommer
2026-06-17 15:06:16 +10:00
committed by GitHub
parent b99b46c18c
commit eced3f733a
19 changed files with 726 additions and 331 deletions
+3 -3
View File
@@ -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"
+9 -6
View File
@@ -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"
+8
View File
@@ -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
View File
@@ -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/...
+70
View File
@@ -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)})
}
@@ -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()
+230
View File
@@ -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
}
+82
View File
@@ -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")
@@ -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.
+108
View File
@@ -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"),
})
+82
View File
@@ -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 -7
View File
@@ -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
+14 -4
View File
@@ -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
+2 -63
View File
@@ -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))