mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-19 10:52:04 +00:00
280 lines
13 KiB
Go
280 lines
13 KiB
Go
//go:build e2e
|
|
|
|
package single
|
|
|
|
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"
|
|
)
|
|
|
|
// 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.
|
|
func runIAMInstanceProfileAssociation(t *testing.T, fix *Fixture) {
|
|
harness.Phase(t, "Single — EC2 IAM Instance Profile Association")
|
|
|
|
adminAccount := iamEnsureAdminAccountID(t, fix)
|
|
adminPolicyARN := iamPolicyARN(adminAccount, iamPolicyAdministrator)
|
|
|
|
// Defensive cleanup of stale state from a prior run.
|
|
iamDeleteRoleAndProfilesBestEffort(fix, iamRoleAppName,
|
|
[]string{iamProfileAppName, iamProfileOtherName}, 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(iamRoleAppName),
|
|
AssumeRolePolicyDocument: aws.String(iamTrustPolicyEC2Standard),
|
|
})
|
|
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),
|
|
PolicyArn: aws.String(adminPolicyARN),
|
|
})
|
|
require.NoError(t, err, "attach AdministratorAccess to role")
|
|
|
|
for _, p := range []string{iamProfileAppName, iamProfileOtherName} {
|
|
_, 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),
|
|
})
|
|
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})
|
|
}
|
|
}
|
|
iamDeleteRoleAndProfilesBestEffort(fix, iamRoleAppName,
|
|
[]string{iamProfileAppName, iamProfileOtherName}, adminPolicyARN)
|
|
})
|
|
|
|
// ---- Associate (post-launch) on singleton ----
|
|
harness.Step(t, "associate-iam-instance-profile %s -> %s", iamProfileAppName, singletonID)
|
|
assocOut, err := fix.AWS.EC2.AssociateIamInstanceProfile(&ec2.AssociateIamInstanceProfileInput{
|
|
InstanceId: aws.String(singletonID),
|
|
IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(iamProfileAppName)},
|
|
})
|
|
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(iamProfileAppName)},
|
|
})
|
|
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/"+iamProfileAppName),
|
|
"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(iamProfileAppName),
|
|
})
|
|
return e
|
|
})
|
|
|
|
// Replace → other-profile, must mint a new assoc id.
|
|
harness.Step(t, "replace-iam-instance-profile-association -> %s", iamProfileOtherName)
|
|
replaceOut, err := fix.AWS.EC2.ReplaceIamInstanceProfileAssociation(
|
|
&ec2.ReplaceIamInstanceProfileAssociationInput{
|
|
AssociationId: aws.String(assocID),
|
|
IamInstanceProfile: &ec2.IamInstanceProfileSpecification{Name: aws.String(iamProfileOtherName)},
|
|
})
|
|
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(iamProfileAppName)},
|
|
})
|
|
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, iamProfileAppName)
|
|
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
|
|
}
|