diff --git a/.github/workflows/e2e-suites.yml b/.github/workflows/e2e-suites.yml index a73ce6d41..eee6c06d7 100644 --- a/.github/workflows/e2e-suites.yml +++ b/.github/workflows/e2e-suites.yml @@ -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" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 8e8ad8e70..58dbe51d7 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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" diff --git a/docs/service-interfaces.yaml b/docs/service-interfaces.yaml index 48062e8d8..eea6c10cb 100644 --- a/docs/service-interfaces.yaml +++ b/docs/service-interfaces.yaml @@ -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] diff --git a/tests/e2e/Makefile b/tests/e2e/Makefile index 301612feb..055336de5 100644 --- a/tests/e2e/Makefile +++ b/tests/e2e/Makefile @@ -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/.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/... diff --git a/tests/e2e/harness/iam.go b/tests/e2e/harness/iam.go new file mode 100644 index 000000000..8885773a2 --- /dev/null +++ b/tests/e2e/harness/iam.go @@ -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:::role/. +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)}) +} diff --git a/tests/e2e/single/assumed_role_enforcement_test.go b/tests/e2e/iam/assumed_role_enforcement_test.go similarity index 92% rename from tests/e2e/single/assumed_role_enforcement_test.go rename to tests/e2e/iam/assumed_role_enforcement_test.go index eab9d0648..25ff9a22f 100644 --- a/tests/e2e/single/assumed_role_enforcement_test.go +++ b/tests/e2e/iam/assumed_role_enforcement_test.go @@ -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() diff --git a/tests/e2e/iam/datapath_helpers_test.go b/tests/e2e/iam/datapath_helpers_test.go new file mode 100644 index 000000000..0f57197bf --- /dev/null +++ b/tests/e2e/iam/datapath_helpers_test.go @@ -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 +} diff --git a/tests/e2e/iam/helpers_test.go b/tests/e2e/iam/helpers_test.go new file mode 100644 index 000000000..59cd43525 --- /dev/null +++ b/tests/e2e/iam/helpers_test.go @@ -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 +} diff --git a/tests/e2e/single/iam_helpers_test.go b/tests/e2e/iam/iam_helpers_test.go similarity index 86% rename from tests/e2e/single/iam_helpers_test.go rename to tests/e2e/iam/iam_helpers_test.go index 77d5070a2..478d99def 100644 --- a/tests/e2e/single/iam_helpers_test.go +++ b/tests/e2e/iam/iam_helpers_test.go @@ -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)} diff --git a/tests/e2e/single/iam_roles_test.go b/tests/e2e/iam/iam_roles_test.go similarity index 87% rename from tests/e2e/single/iam_roles_test.go rename to tests/e2e/iam/iam_roles_test.go index 0fb8a2f29..6ee3e4c52 100644 --- a/tests/e2e/single/iam_roles_test.go +++ b/tests/e2e/iam/iam_roles_test.go @@ -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:::role/. -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)}) -} diff --git a/tests/e2e/single/iam_test.go b/tests/e2e/iam/iam_test.go similarity index 83% rename from tests/e2e/single/iam_test.go rename to tests/e2e/iam/iam_test.go index 31ceda871..f1b2c124a 100644 --- a/tests/e2e/single/iam_test.go +++ b/tests/e2e/iam/iam_test.go @@ -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 -} diff --git a/tests/e2e/single/imds_test.go b/tests/e2e/iam/imds_test.go similarity index 97% rename from tests/e2e/single/imds_test.go rename to tests/e2e/iam/imds_test.go index 704ae7bbd..7826eb983 100644 --- a/tests/e2e/single/imds_test.go +++ b/tests/e2e/iam/imds_test.go @@ -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- 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") diff --git a/tests/e2e/single/instance_iam_profile_test.go b/tests/e2e/iam/instance_iam_profile_test.go similarity index 85% rename from tests/e2e/single/instance_iam_profile_test.go rename to tests/e2e/iam/instance_iam_profile_test.go index 483cd514d..1ac98955e 100644 --- a/tests/e2e/single/instance_iam_profile_test.go +++ b/tests/e2e/iam/instance_iam_profile_test.go @@ -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. diff --git a/tests/e2e/iam/main_test.go b/tests/e2e/iam/main_test.go new file mode 100644 index 000000000..e4cd64d93 --- /dev/null +++ b/tests/e2e/iam/main_test.go @@ -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) +} diff --git a/tests/e2e/single/sts_test.go b/tests/e2e/iam/sts_test.go similarity index 98% rename from tests/e2e/single/sts_test.go rename to tests/e2e/iam/sts_test.go index 8007007f2..9f4fa3774 100644 --- a/tests/e2e/single/sts_test.go +++ b/tests/e2e/iam/sts_test.go @@ -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:::role/") // 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"), }) diff --git a/tests/e2e/iam/tests_test.go b/tests/e2e/iam/tests_test.go new file mode 100644 index 000000000..ce358afc9 --- /dev/null +++ b/tests/e2e/iam/tests_test.go @@ -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 _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)) +} diff --git a/tests/e2e/manifest-lint/baseline.txt b/tests/e2e/manifest-lint/baseline.txt index 1c98f9368..2014e7cab 100644 --- a/tests/e2e/manifest-lint/baseline.txt +++ b/tests/e2e/manifest-lint/baseline.txt @@ -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 diff --git a/tests/e2e/single/discovery_test.go b/tests/e2e/single/discovery_test.go index f4fe34c93..3bb2b5d4b 100644 --- a/tests/e2e/single/discovery_test.go +++ b/tests/e2e/single/discovery_test.go @@ -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 diff --git a/tests/e2e/single/tests_test.go b/tests/e2e/single/tests_test.go index f365396dd..98588c34f 100644 --- a/tests/e2e/single/tests_test.go +++ b/tests/e2e/single/tests_test.go @@ -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))