//go:build e2e package iam import ( "strings" "testing" "time" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/iam" "github.com/mulgadc/spinifex/tests/e2e/harness" "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. // // 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. // // Nothing here was ported down to the integration tier: its role/profile // create+attach setup (CreateRole, AttachRolePolicy, CreateInstanceProfile x2, // AddRoleToInstanceProfile) is scaffolding, not an assertion under test — the // same IAM CRUD, including the AdministratorAccess-doesn't-pre-seed wrinkle, // is already asserted by tests/integration's TestIAMRolesAndProfiles. The // association lifecycle itself (Associate/AlreadyAssociated/Replace/ // Disassociate/DeleteInstanceProfile-while-bound) is implemented in // InstanceServiceImpl (domains/ec2/instance/service_impl.go) against a real // *vm.VM's mutable IamInstanceProfileArn/AssociationId fields — daemon-side // state a live guest actually carries. A static stub could only stand in for // it by modelling the whole instance lifecycle, which is why this stays live // rather than moving down. The pure gateway-side half of that same code path // (profile resolution, PassRole enforcement, ID enrichment, NATS error // mapping in domains/ec2/awsapi/instance/IamInstanceProfileAssociation.go) is // already exhaustively unit-tested in // IamInstanceProfileAssociation_test.go, and RunInstances' iam:PassRole gate // specifically is covered by tests/integration's // TestRunInstances_DeniedWithoutPassRole. The `require.Equal(t, "running", …)` // singleton guard below is a precondition check, not a wait — nothing here // blocks on a state transition that a static stub couldn't already satisfy, // so there is no incidental wait to drop. func runIAMInstanceProfileAssociation(t *testing.T, fix *Fixture) { harness.Phase(t, "Single — EC2 IAM Instance Profile Association") adminAccount := harness.IAMAccountID(t, fix.AWS) adminPolicyARN := harness.IAMPolicyARN(adminAccount, singleIAMProfileAdminPolicy) // Defensive cleanup of stale state from a prior run. 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. inst, _ := needInstance(t, fix) singletonID := aws.StringValue(inst.InstanceId) require.Equal(t, "running", aws.StringValue(inst.State.Name), "singleton must be running for IAM association lifecycle test") // 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(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(singleIAMProfileRole), PolicyArn: aws.String(adminPolicyARN), }) require.NoError(t, err, "attach AdministratorAccess to role") 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(singleIAMProfileRole), }) require.NoError(t, err, "add-role-to-instance-profile %s", p) } // Register fixture-level cleanup. LIFO order: profile/role teardown runs // AFTER the ephemeral VM auto-terminates and AFTER any singleton-VM // disassociate this test ends with. The cleanup also tolerates partial // state from a mid-test failure. fix.Harness.RegisterCleanup(func() { // Disassociate anything still bound to the singleton. assocs, err := fix.AWS.EC2.DescribeIamInstanceProfileAssociations( &ec2.DescribeIamInstanceProfileAssociationsInput{ Filters: []*ec2.Filter{{ Name: aws.String("instance-id"), Values: []*string{aws.String(singletonID)}, }}, }) if err == nil { for _, a := range assocs.IamInstanceProfileAssociations { _, _ = fix.AWS.EC2.DisassociateIamInstanceProfile( &ec2.DisassociateIamInstanceProfileInput{AssociationId: a.AssociationId}) } } harness.IAMDeleteRoleAndProfilesBestEffort(fix.AWS, singleIAMProfileRole, []string{singleIAMProfileApp, singleIAMProfileOther}, adminPolicyARN) }) // ---- Associate (post-launch) on singleton ---- 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(singleIAMProfileApp)}, }) require.NoError(t, err, "associate-iam-instance-profile") require.NotNil(t, assocOut.IamInstanceProfileAssociation, "missing association in response") assocID := aws.StringValue(assocOut.IamInstanceProfileAssociation.AssociationId) require.True(t, strings.HasPrefix(assocID, "iip-assoc-"), "AssociationId %q must use iip-assoc- prefix per AWS shape", assocID) harness.Detail(t, "association", assocID) // Associate again — must surface IamInstanceProfileAlreadyAssociated. harness.Step(t, "associate-iam-instance-profile twice (expect IamInstanceProfileAlreadyAssociated)") harness.ExpectError(t, "IamInstanceProfileAlreadyAssociated", func() error { _, e := fix.AWS.EC2.AssociateIamInstanceProfile(&ec2.AssociateIamInstanceProfileInput{ InstanceId: aws.String(singletonID), IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(singleIAMProfileApp)}, }) return e }) // DescribeInstances must surface the bound profile ARN. harness.Step(t, "describe-instances should expose IamInstanceProfile.Arn") desc, err := fix.AWS.EC2.DescribeInstances(&ec2.DescribeInstancesInput{ InstanceIds: []*string{aws.String(singletonID)}, }) require.NoError(t, err, "describe-instances") require.NotEmpty(t, desc.Reservations) require.NotEmpty(t, desc.Reservations[0].Instances) 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/"+singleIAMProfileApp), "unexpected IamInstanceProfile.Arn %q", aws.StringValue(ip.Arn)) // DescribeIamInstanceProfileAssociations by id. harness.Step(t, "describe-iam-instance-profile-associations --association-ids %s", assocID) descAssoc, err := fix.AWS.EC2.DescribeIamInstanceProfileAssociations( &ec2.DescribeIamInstanceProfileAssociationsInput{ AssociationIds: []*string{aws.String(assocID)}, }) require.NoError(t, err, "describe-iam-instance-profile-associations") require.Len(t, descAssoc.IamInstanceProfileAssociations, 1) require.Equal(t, singletonID, aws.StringValue(descAssoc.IamInstanceProfileAssociations[0].InstanceId)) // DeleteInstanceProfile is refused while the profile is bound to a live // instance — the gateway live-instance fan-out gate from iam-roles-v1-ec2. 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(singleIAMProfileApp), }) return e }) // Replace → other-profile, must mint a new assoc id. 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(singleIAMProfileOther)}, }) require.NoError(t, err, "replace-iam-instance-profile-association") newAssocID := aws.StringValue(replaceOut.IamInstanceProfileAssociation.AssociationId) require.True(t, strings.HasPrefix(newAssocID, "iip-assoc-")) require.NotEqual(t, assocID, newAssocID, "Replace must mint a fresh AssociationId") // Replace with the stale id surfaces NoSuchAssociation. harness.Step(t, "replace with stale id (expect NoSuchAssociation)") harness.ExpectError(t, "NoSuchAssociation", func() error { _, e := fix.AWS.EC2.ReplaceIamInstanceProfileAssociation( &ec2.ReplaceIamInstanceProfileAssociationInput{ AssociationId: aws.String(assocID), IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(singleIAMProfileApp)}, }) return e }) // Disassociate. harness.Step(t, "disassociate-iam-instance-profile %s", newAssocID) _, err = fix.AWS.EC2.DisassociateIamInstanceProfile(&ec2.DisassociateIamInstanceProfileInput{ AssociationId: aws.String(newAssocID), }) require.NoError(t, err, "disassociate-iam-instance-profile") // Disassociate again with stale id. harness.ExpectError(t, "NoSuchAssociation", func() error { _, e := fix.AWS.EC2.DisassociateIamInstanceProfile(&ec2.DisassociateIamInstanceProfileInput{ AssociationId: aws.String(newAssocID), }) return e }) // DescribeInstances no longer carries the profile. descAfter, err := fix.AWS.EC2.DescribeInstances(&ec2.DescribeInstancesInput{ InstanceIds: []*string{aws.String(singletonID)}, }) require.NoError(t, err, "describe-instances after disassociate") if ip := descAfter.Reservations[0].Instances[0].IamInstanceProfile; ip != nil { require.Empty(t, aws.StringValue(ip.Arn), "DescribeInstances must clear IamInstanceProfile.Arn after Disassociate") } // ---- RunInstances --iam-instance-profile + auto-disassociate on terminate ---- // 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, singleIAMProfileApp) defer func() { // Belt-and-braces: even if the auto-disassociate assertion fails, we // must not leave a VM running. Terminate is idempotent. _, _ = fix.AWS.EC2.TerminateInstances(&ec2.TerminateInstancesInput{ InstanceIds: []*string{aws.String(ephID)}, }) }() // Lookup the association the daemon synthesised at launch. ephAssocs, err := fix.AWS.EC2.DescribeIamInstanceProfileAssociations( &ec2.DescribeIamInstanceProfileAssociationsInput{ Filters: []*ec2.Filter{{ Name: aws.String("instance-id"), Values: []*string{aws.String(ephID)}, }}, }) require.NoError(t, err, "describe-iam-instance-profile-associations --instance-id %s", ephID) require.Len(t, ephAssocs.IamInstanceProfileAssociations, 1, "RunInstances --iam-instance-profile must auto-create an association") ephAssocID := aws.StringValue(ephAssocs.IamInstanceProfileAssociations[0].AssociationId) require.True(t, strings.HasPrefix(ephAssocID, "iip-assoc-")) // DescribeInstances on the ephemeral VM surfaces the profile. ephDesc, err := fix.AWS.EC2.DescribeInstances(&ec2.DescribeInstancesInput{ InstanceIds: []*string{aws.String(ephID)}, }) require.NoError(t, err, "describe-instances ephemeral") require.NotEmpty(t, ephDesc.Reservations) require.NotEmpty(t, ephDesc.Reservations[0].Instances) require.NotNil(t, ephDesc.Reservations[0].Instances[0].IamInstanceProfile, "RunInstances --iam-instance-profile must be visible on DescribeInstances") // Terminate → wait for auto-disassociate. harness.Step(t, "terminate ephemeral %s (auto-disassociate)", ephID) _, err = fix.AWS.EC2.TerminateInstances(&ec2.TerminateInstancesInput{ InstanceIds: []*string{aws.String(ephID)}, }) require.NoError(t, err, "terminate-instances ephemeral") harness.WaitForInstanceState(t, fix.AWS, ephID, "terminated") // Allow a brief window for the daemon's auto-disassociate to drain to KV. // The disassociate is synchronous on the same handler that flips state, so // the wait above is usually sufficient — bound a short retry just in case. require.Eventually(t, func() bool { _, e := fix.AWS.EC2.DisassociateIamInstanceProfile(&ec2.DisassociateIamInstanceProfileInput{ AssociationId: aws.String(ephAssocID), }) return harness.ErrorCodeIs(e, "NoSuchAssociation") }, 30*time.Second, 1*time.Second, "terminated instance %s must auto-disassociate %s", ephID, ephAssocID) } // runInstanceWithProfile launches a single short-lived VM mirroring src's // launch attributes, with the named instance profile attached. Polls to // running; returns the new instance ID. Caller owns terminate. func runInstanceWithProfile(t *testing.T, fix *Fixture, src *ec2.Instance, profileName string) string { t.Helper() require.NotEmpty(t, src.SecurityGroups, "source instance has no security groups") out, err := fix.AWS.EC2.RunInstances(&ec2.RunInstancesInput{ ImageId: src.ImageId, InstanceType: src.InstanceType, KeyName: src.KeyName, SubnetId: src.SubnetId, SecurityGroupIds: []*string{src.SecurityGroups[0].GroupId}, MinCount: aws.Int64(1), MaxCount: aws.Int64(1), IamInstanceProfile: &ec2.IamInstanceProfileSpecification{ Name: aws.String(profileName), }, }) require.NoError(t, err, "run-instances --iam-instance-profile") require.NotEmpty(t, out.Instances, "RunInstances returned 0 instances") id := aws.StringValue(out.Instances[0].InstanceId) require.True(t, strings.HasPrefix(id, "i-"), "unexpected InstanceId %q", id) harness.WaitForInstanceState(t, fix.AWS, id, "running") return id }