mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
feat: IMDS version discovery, instance-identity document, and cheap metadata routes (#382)
This commit is contained in:
+13
-6
@@ -593,17 +593,25 @@ curl -i http://169.254.169.254/latest/meta-data/instance-id
|
||||
| Path | Method | Source | Status |
|
||||
|------|--------|--------|--------|
|
||||
| `/latest/api/token` | PUT | Issues an ENI-bound IMDSv2 token; `X-aws-ec2-metadata-token-ttl-seconds` ∈ [1, 21600] required | **DONE** |
|
||||
| `/` | GET | Supported API-version list (`2021-07-15`, `latest`); token-gated | **DONE** |
|
||||
| `/latest` | GET | Top-level tree listing (`dynamic`, `meta-data`, `user-data`) | **DONE** |
|
||||
| `/<date>/...` | GET/PUT | Any dated API version aliases to `/latest` (cloud-init parity) | **DONE** |
|
||||
| `/latest/meta-data/` | GET | Directory listing of supported children | **DONE** |
|
||||
| `/latest/meta-data/instance-id` | GET | `vm.ID` | **DONE** |
|
||||
| `/latest/meta-data/instance-type` | GET | `vm.InstanceType` | **DONE** |
|
||||
| `/latest/meta-data/ami-id` | GET | launch `ImageId` | **DONE** |
|
||||
| `/latest/meta-data/ami-launch-index` | GET | Per-instance launch index (`0..n-1`), contiguous on partial failure | **DONE** |
|
||||
| `/latest/meta-data/reservation-id` | GET | `DescribeInstances` `Reservation.ReservationId` | **DONE** |
|
||||
| `/latest/meta-data/instance-life-cycle` | GET | `on-demand` (Spot not modelled) | **DONE** |
|
||||
| `/latest/meta-data/local-ipv4` | GET | `ENIRecord.PrivateIpAddress` (== request source IP) | **DONE** |
|
||||
| `/latest/meta-data/public-ipv4` | GET | EIP, else instance public IP; empty body if none | **DONE** |
|
||||
| `/latest/meta-data/public-hostname` | GET | Mirrors `public-ipv4`; 404 when no public IP | **DONE** |
|
||||
| `/latest/meta-data/mac` | GET | `ENIRecord.MacAddress` | **DONE** |
|
||||
| `/latest/meta-data/security-groups` | GET | `ENIRecord.SecurityGroupIds`, newline-separated | **DONE** |
|
||||
| `/latest/meta-data/hostname`, `/local-hostname` | GET | Synthesised `ip-<dashed-ip>.<region>.compute.internal` | **DONE** |
|
||||
| `/latest/meta-data/placement/availability-zone` | GET | `ENIRecord.AvailabilityZone` | **DONE** |
|
||||
| `/latest/meta-data/placement/region` | GET | Derived from AZ (trailing letter stripped) | **DONE** |
|
||||
| `/latest/meta-data/services/{domain,partition}` | GET | Static: `amazonaws.com` / `aws` | **DONE** |
|
||||
| `/latest/meta-data/iam/info` | GET | `{InstanceProfileArn, InstanceProfileId}`; 404 if no profile | **DONE** |
|
||||
| `/latest/meta-data/iam/security-credentials/` | GET | Role name(s) under the profile, one per line; empty body if none | **DONE** |
|
||||
| `/latest/meta-data/iam/security-credentials/<role>` | GET | STS `AssumeRoleForInstance` → ASIA-prefixed temporary credential JSON | **DONE** |
|
||||
@@ -611,16 +619,15 @@ curl -i http://169.254.169.254/latest/meta-data/instance-id
|
||||
| `/latest/meta-data/public-keys/0/` | GET | `openssh-key` (format list for index 0) | **DONE** |
|
||||
| `/latest/meta-data/public-keys/0/openssh-key` | GET | Launch SSH public key, live-fetched from the key store; 404 if the key was deleted, 500 on backend fault | **DONE** |
|
||||
| `/latest/user-data` | GET | `vm.UserData`; 404 if none | **DONE** |
|
||||
| `/latest/dynamic/instance-identity/document` (+ `signature`, `pkcs7`, `rsa2048`) | GET | Needs a per-cluster signing key | **NOT STARTED** (404; lands with EKS IRSA) |
|
||||
| `/latest/dynamic` | GET | Lists `instance-identity/` | **DONE** |
|
||||
| `/latest/dynamic/instance-identity` | GET | Lists `document` (signed forms listed when the signing key lands) | **DONE** |
|
||||
| `/latest/dynamic/instance-identity/document` | GET | Unsigned identity document from resolved ENI + instance facts | **DONE** |
|
||||
| `/latest/dynamic/instance-identity/{signature,pkcs7,rsa2048}` | GET | Signed forms; need a per-cluster signing key | **NOT STARTED** (404; lands with EKS IRSA) |
|
||||
| `/latest/meta-data/network/interfaces/macs/<mac>/...` | GET | Multi-ENI rendering (subnet-id, vpc-id, ipv4s, security-group-ids, …) | **NOT STARTED** (404) |
|
||||
| `/latest/meta-data/tags/instance/<key>` | GET | Instance-tag metadata; gated on `InstanceMetadataTags` enablement | **NOT STARTED** (404) |
|
||||
| `/latest/meta-data/public-hostname` | GET | No public DNS today; would mirror `public-ipv4` | **NOT STARTED** (404) |
|
||||
| `/latest/meta-data/reservation-id` | GET | Available on the stored `Reservation`; cheap, not yet surfaced | **NOT STARTED** (404) |
|
||||
| `/latest/meta-data/ami-launch-index` | GET | `0` for single-instance launches; trivial once multi-count is exercised | **NOT STARTED** (404) |
|
||||
| `/latest/meta-data/block-device-mapping/...` | GET | `ami`/`root`/`ebsN`/`ephemeralN` device map | **NOT STARTED** (404) |
|
||||
| `/latest/meta-data/placement/{group-name,partition-number,availability-zone-id,host-id}` | GET | Placement extras beyond `availability-zone`/`region` | **NOT STARTED** (404) |
|
||||
| `/latest/meta-data/instance-life-cycle`, `/instance-action` | GET | `on-demand`/`spot`; `none` unless interruptible instances ship | **NOT STARTED** (404) |
|
||||
| `/latest/meta-data/services/{domain,partition}` | GET | Static per-deployment values | **NOT STARTED** (404) |
|
||||
| `/latest/meta-data/instance-action` | GET | `none` unless interruptible instances ship | **NOT STARTED** (404) |
|
||||
| `/latest/meta-data/spot/{instance-action,termination-time}` | GET | Only meaningful once Spot is modelled | **NOT STARTED** (404) |
|
||||
|
||||
---
|
||||
|
||||
@@ -507,6 +507,12 @@ func (s *InstanceServiceImpl) RunInstance(input *ec2.RunInstancesInput) (*vm.VM,
|
||||
ec2Instance.State.SetCode(0)
|
||||
ec2Instance.State.SetName("pending")
|
||||
|
||||
// Project the instance type's architecture onto the customer instance so it
|
||||
// flows to DescribeInstances and the IMDS identity document for free.
|
||||
if arch := instanceArchitecture(s.instanceTypes[*input.InstanceType]); arch != "" {
|
||||
ec2Instance.SetArchitecture(arch)
|
||||
}
|
||||
|
||||
// IAM instance profile attached at launch: gateway has already resolved
|
||||
// the reference to a canonical ARN and enforced iam:PassRole; here we
|
||||
// just persist it on the VM and generate the association ID. Id is left
|
||||
@@ -680,6 +686,7 @@ func (s *InstanceServiceImpl) PrepareRunInstances(input *ec2.RunInstancesInput,
|
||||
s.resourceMgr.Deallocate(instanceType)
|
||||
continue
|
||||
}
|
||||
ec2Instance.SetAmiLaunchIndex(int64(len(allEC2Instances)))
|
||||
instances = append(instances, instance)
|
||||
allEC2Instances = append(allEC2Instances, ec2Instance)
|
||||
continue
|
||||
@@ -809,6 +816,7 @@ func (s *InstanceServiceImpl) PrepareRunInstances(input *ec2.RunInstancesInput,
|
||||
}
|
||||
}
|
||||
|
||||
ec2Instance.SetAmiLaunchIndex(int64(len(allEC2Instances)))
|
||||
instances = append(instances, instance)
|
||||
allEC2Instances = append(allEC2Instances, ec2Instance)
|
||||
}
|
||||
|
||||
@@ -112,6 +112,33 @@ func TestRunInstance_Success(t *testing.T) {
|
||||
assert.NotNil(t, ec2Instance.LaunchTime)
|
||||
}
|
||||
|
||||
// Architecture is projected onto the customer instance from the instance type at
|
||||
// launch, so it flows to DescribeInstances and the IMDS identity document. Guards
|
||||
// the platform-wide describe-instances change, not just IMDS.
|
||||
func TestRunInstance_ArchitecturePopulated(t *testing.T) {
|
||||
instanceTypes := map[string]*ec2.InstanceTypeInfo{
|
||||
"t3.micro": {
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
ProcessorInfo: &ec2.ProcessorInfo{SupportedArchitectures: []*string{aws.String("x86_64")}},
|
||||
},
|
||||
"t4g.micro": {
|
||||
InstanceType: aws.String("t4g.micro"),
|
||||
ProcessorInfo: &ec2.ProcessorInfo{SupportedArchitectures: []*string{aws.String("arm64")}},
|
||||
},
|
||||
}
|
||||
svc := &InstanceServiceImpl{instanceTypes: instanceTypes}
|
||||
|
||||
for typ, wantArch := range map[string]string{"t3.micro": "x86_64", "t4g.micro": "arm64"} {
|
||||
_, ec2Instance, err := svc.RunInstance(&ec2.RunInstancesInput{
|
||||
ImageId: aws.String("ami-0abcdef1234567890"),
|
||||
InstanceType: aws.String(typ),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ec2Instance.Architecture, "type=%s", typ)
|
||||
assert.Equal(t, wantArch, *ec2Instance.Architecture, "type=%s", typ)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInstance_WithIamInstanceProfile(t *testing.T) {
|
||||
const profileARN = "arn:aws:iam::111122223333:instance-profile/app-profile"
|
||||
svc := &InstanceServiceImpl{
|
||||
@@ -2469,6 +2496,71 @@ func TestPrepareRunInstances_HappyPathNoENI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareRunInstances_AmiLaunchIndexContiguous pins that ami-launch-index is
|
||||
// assigned per successful launch (0..n-1) so it flows to DescribeInstances and the
|
||||
// IMDS identity document. Survivors of a mid-loop failure stay contiguous (no gap).
|
||||
func TestPrepareRunInstances_AmiLaunchIndexContiguous(t *testing.T) {
|
||||
t.Run("count_3_no_eni", func(t *testing.T) {
|
||||
types, _ := defaultPrepareInstanceTypes()
|
||||
prov := &fakeResourceCapacityProvider{
|
||||
instanceTypes: types,
|
||||
canAllocFn: func(_ *ec2.InstanceTypeInfo, count int) int { return count },
|
||||
}
|
||||
svc := &InstanceServiceImpl{
|
||||
config: &config.Config{},
|
||||
instanceTypes: types,
|
||||
amiLoader: &fakeAMILoader{byID: map[string]viperblock.AMIMetadata{
|
||||
"ami-1": {ImageOwnerAlias: "acc"},
|
||||
}},
|
||||
resourceMgr: prov,
|
||||
}
|
||||
reservation, instances, _, err := svc.PrepareRunInstances(&ec2.RunInstancesInput{
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
ImageId: aws.String("ami-1"),
|
||||
MinCount: aws.Int64(3),
|
||||
MaxCount: aws.Int64(3),
|
||||
}, "acc")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, instances, 3)
|
||||
require.Len(t, reservation.Instances, 3)
|
||||
for i, inst := range reservation.Instances {
|
||||
assert.Equal(t, int64(i), aws.Int64Value(inst.AmiLaunchIndex))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mid_loop_failure_stays_contiguous", func(t *testing.T) {
|
||||
// Second of three ENI creates fails, so the second instance never
|
||||
// appends; the third fills index 1 (not 2), leaving no gap.
|
||||
eni := &fakeENICreator{
|
||||
defaultSubnet: &SubnetInfo{SubnetID: "subnet-1", VpcID: "vpc-1"},
|
||||
createOut: &ec2.CreateNetworkInterfaceOutput{
|
||||
NetworkInterface: &ec2.NetworkInterface{
|
||||
NetworkInterfaceId: aws.String("eni-1"),
|
||||
MacAddress: aws.String("aa:bb:cc:dd:ee:ff"),
|
||||
PrivateIpAddress: aws.String("10.0.0.10"),
|
||||
VpcId: aws.String("vpc-1"),
|
||||
},
|
||||
},
|
||||
createErr: errors.New("eni create failed"),
|
||||
createErrOnCall: 2,
|
||||
}
|
||||
svc, _ := prepareSvcWithENI(t, eni, nil)
|
||||
|
||||
reservation, instances, _, err := svc.PrepareRunInstances(&ec2.RunInstancesInput{
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
ImageId: aws.String("ami-1"),
|
||||
SubnetId: aws.String("subnet-1"),
|
||||
MinCount: aws.Int64(2),
|
||||
MaxCount: aws.Int64(3),
|
||||
}, "acc")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, instances, 2)
|
||||
require.Len(t, reservation.Instances, 2)
|
||||
assert.Equal(t, int64(0), aws.Int64Value(reservation.Instances[0].AmiLaunchIndex))
|
||||
assert.Equal(t, int64(1), aws.Int64Value(reservation.Instances[1].AmiLaunchIndex))
|
||||
})
|
||||
}
|
||||
|
||||
// TestPrepareRunInstances_BootModePropagated pins that the AMI's BootMode
|
||||
// flows onto every prepared VM, so the launch path picks UEFI vs BIOS without
|
||||
// a second AMI lookup. Empty AMI BootMode (legacy) flows through as empty.
|
||||
@@ -2581,18 +2673,19 @@ func TestStopOrTerminateInstance_TerminationProtection(t *testing.T) {
|
||||
}
|
||||
|
||||
type fakeENICreator struct {
|
||||
defaultSubnet *SubnetInfo
|
||||
subnet *SubnetInfo
|
||||
getENIByID map[string]*ENIInfo
|
||||
getENIErr error
|
||||
createOut *ec2.CreateNetworkInterfaceOutput
|
||||
createCalls int
|
||||
createErr error
|
||||
attachErr error
|
||||
attachCalls int
|
||||
updateCalls int
|
||||
clearCalls int // updateCalls where publicIP is ""
|
||||
detachCalls int
|
||||
defaultSubnet *SubnetInfo
|
||||
subnet *SubnetInfo
|
||||
getENIByID map[string]*ENIInfo
|
||||
getENIErr error
|
||||
createOut *ec2.CreateNetworkInterfaceOutput
|
||||
createCalls int
|
||||
createErr error
|
||||
createErrOnCall int // 1-based call index to fail; 0 fails every call when createErr is set
|
||||
attachErr error
|
||||
attachCalls int
|
||||
updateCalls int
|
||||
clearCalls int // updateCalls where publicIP is ""
|
||||
detachCalls int
|
||||
}
|
||||
|
||||
func (f *fakeENICreator) GetDefaultSubnet(_ string) (*SubnetInfo, error) {
|
||||
@@ -2625,7 +2718,7 @@ func (f *fakeENICreator) GetENI(_, eniID string) (*ENIInfo, error) {
|
||||
|
||||
func (f *fakeENICreator) CreateNetworkInterface(_ *ec2.CreateNetworkInterfaceInput, _ string) (*ec2.CreateNetworkInterfaceOutput, error) {
|
||||
f.createCalls++
|
||||
if f.createErr != nil {
|
||||
if f.createErr != nil && (f.createErrOnCall == 0 || f.createErrOnCall == f.createCalls) {
|
||||
return nil, f.createErr
|
||||
}
|
||||
return f.createOut, nil
|
||||
|
||||
@@ -2,3 +2,11 @@ package handlers_imds
|
||||
|
||||
// MetaDataServerIP is the standard EC2 link-local metadata address.
|
||||
const MetaDataServerIP = "169.254.169.254"
|
||||
|
||||
// pinnedVersion is the dated IMDS API version advertised by GET /.
|
||||
const pinnedVersion = "2021-07-15"
|
||||
|
||||
// supportedVersions is the GET / listing. It is advertised only; normalizeVersion
|
||||
// additionally accepts any dated-version prefix, so we honour more versions than
|
||||
// we list — harmless because every version maps to the same /latest tree.
|
||||
var supportedVersions = []string{pinnedVersion, "latest"}
|
||||
|
||||
@@ -27,15 +27,19 @@ func (l *natsInstanceLookup) describe(accountID, instanceID string) (*instanceFa
|
||||
return nil, fmt.Errorf("describe instance %s: %w", instanceID, err)
|
||||
}
|
||||
|
||||
inst := firstInstance(out)
|
||||
res, inst := firstReservationInstance(out)
|
||||
if inst == nil {
|
||||
return nil, nil // terminating or not yet visible; treat as miss
|
||||
}
|
||||
|
||||
facts := &instanceFacts{
|
||||
instanceType: aws.StringValue(inst.InstanceType),
|
||||
imageID: aws.StringValue(inst.ImageId),
|
||||
keyName: aws.StringValue(inst.KeyName),
|
||||
instanceType: aws.StringValue(inst.InstanceType),
|
||||
imageID: aws.StringValue(inst.ImageId),
|
||||
keyName: aws.StringValue(inst.KeyName),
|
||||
architecture: aws.StringValue(inst.Architecture),
|
||||
amiLaunchIndex: aws.Int64Value(inst.AmiLaunchIndex),
|
||||
reservationID: aws.StringValue(res.ReservationId),
|
||||
pendingTime: aws.TimeValue(inst.LaunchTime),
|
||||
}
|
||||
if inst.IamInstanceProfile != nil {
|
||||
facts.iamInstanceProfileArn = aws.StringValue(inst.IamInstanceProfile.Arn)
|
||||
@@ -62,11 +66,13 @@ func (l *natsInstanceLookup) userData(accountID, instanceID string) []byte {
|
||||
return decoded
|
||||
}
|
||||
|
||||
// firstInstance returns the first instance in a DescribeInstances response, or
|
||||
// nil when the response carries none.
|
||||
func firstInstance(out *ec2.DescribeInstancesOutput) *ec2.Instance {
|
||||
// firstReservationInstance returns the first instance in a DescribeInstances
|
||||
// response along with its owning reservation, or (nil, nil) when none. The
|
||||
// reservation is always non-nil when the instance is, since an instance is only
|
||||
// returned from a non-nil reservation that contains it.
|
||||
func firstReservationInstance(out *ec2.DescribeInstancesOutput) (*ec2.Reservation, *ec2.Instance) {
|
||||
if out == nil {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
for _, res := range out.Reservations {
|
||||
if res == nil {
|
||||
@@ -74,9 +80,9 @@ func firstInstance(out *ec2.DescribeInstancesOutput) *ec2.Instance {
|
||||
}
|
||||
for _, inst := range res.Instances {
|
||||
if inst != nil {
|
||||
return inst
|
||||
return res, inst
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -9,25 +9,34 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFirstInstance_NilAndEmpty(t *testing.T) {
|
||||
assert.Nil(t, firstInstance(nil))
|
||||
assert.Nil(t, firstInstance(&ec2.DescribeInstancesOutput{}))
|
||||
assert.Nil(t, firstInstance(&ec2.DescribeInstancesOutput{
|
||||
Reservations: []*ec2.Reservation{nil, {Instances: []*ec2.Instance{nil}}},
|
||||
}))
|
||||
func TestFirstReservationInstance_NilAndEmpty(t *testing.T) {
|
||||
for _, out := range []*ec2.DescribeInstancesOutput{
|
||||
nil,
|
||||
{},
|
||||
{Reservations: []*ec2.Reservation{nil, {Instances: []*ec2.Instance{nil}}}},
|
||||
} {
|
||||
res, inst := firstReservationInstance(out)
|
||||
assert.Nil(t, res)
|
||||
assert.Nil(t, inst)
|
||||
}
|
||||
}
|
||||
|
||||
// firstInstance skips nil reservations and nil instance slots and returns the
|
||||
// first concrete instance it finds.
|
||||
func TestFirstInstance_SkipsNilsAndReturnsFirst(t *testing.T) {
|
||||
// firstReservationInstance skips nil reservations and nil instance slots and
|
||||
// returns the first concrete instance with its owning reservation.
|
||||
func TestFirstReservationInstance_SkipsNilsAndReturnsFirst(t *testing.T) {
|
||||
out := &ec2.DescribeInstancesOutput{
|
||||
Reservations: []*ec2.Reservation{
|
||||
nil,
|
||||
{Instances: []*ec2.Instance{nil}},
|
||||
{Instances: []*ec2.Instance{{InstanceId: aws.String("i-first")}, {InstanceId: aws.String("i-second")}}},
|
||||
{
|
||||
ReservationId: aws.String("r-123"),
|
||||
Instances: []*ec2.Instance{{InstanceId: aws.String("i-first")}, {InstanceId: aws.String("i-second")}},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := firstInstance(out)
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, "i-first", aws.StringValue(got.InstanceId))
|
||||
res, inst := firstReservationInstance(out)
|
||||
require.NotNil(t, inst)
|
||||
assert.Equal(t, "i-first", aws.StringValue(inst.InstanceId))
|
||||
require.NotNil(t, res)
|
||||
assert.Equal(t, "r-123", aws.StringValue(res.ReservationId))
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -26,6 +27,14 @@ const (
|
||||
prefixPublicKeys = "/latest/meta-data/public-keys/"
|
||||
pathPublicKeysDir = "/latest/meta-data/public-keys"
|
||||
|
||||
prefixDynamic = "/latest/dynamic"
|
||||
pathIdentityDir = "/latest/dynamic/instance-identity"
|
||||
pathIdentityDocument = "/latest/dynamic/instance-identity/document"
|
||||
|
||||
// identityDocSchemaVersion is the identity-document schema version, distinct
|
||||
// from the IMDS API version (pinnedVersion).
|
||||
identityDocSchemaVersion = "2017-09-30"
|
||||
|
||||
hdrToken = "X-Aws-Ec2-Metadata-Token" //nolint:gosec // HTTP header name, not a credential
|
||||
hdrTokenTTL = "X-Aws-Ec2-Metadata-Token-Ttl-Seconds" //nolint:gosec // HTTP header name, not a credential
|
||||
|
||||
@@ -43,6 +52,26 @@ func rejectForwarded(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// dateVersion matches a dated IMDS API-version segment, e.g. 2021-03-23.
|
||||
var dateVersion = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||||
|
||||
// normalizeVersion rewrites any dated API-version prefix to the canonical /latest
|
||||
// tree so one dispatch table serves every version a client probes — cloud-init
|
||||
// probes its own hardcoded dated versions, not the GET / listing. latest is left
|
||||
// untouched; a bare GET / is not date-shaped, so it falls through to the listing.
|
||||
func normalizeVersion(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seg, rest, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
|
||||
if dateVersion.MatchString(seg) {
|
||||
r.URL.Path = "/latest"
|
||||
if rest != "" {
|
||||
r.URL.Path += "/" + rest
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// handleToken serves PUT /latest/api/token, issuing a fresh ENI-bound IMDSv2 token.
|
||||
func (s *IMDSServiceImpl) handleToken(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
@@ -136,14 +165,32 @@ func (s *IMDSServiceImpl) dispatch(w http.ResponseWriter, r *http.Request, eni *
|
||||
}
|
||||
|
||||
switch path {
|
||||
case "/":
|
||||
writeText(w, strings.Join(supportedVersions, "\n"))
|
||||
case "/latest", "/latest/":
|
||||
writeText(w, "dynamic\nmeta-data\nuser-data")
|
||||
case prefixDynamic, prefixDynamic + "/":
|
||||
writeText(w, "instance-identity/")
|
||||
case pathIdentityDir, pathIdentityDir + "/":
|
||||
writeText(w, "document") // signed forms (pkcs7/rsa2048/signature) listed when the signing key lands
|
||||
case pathIdentityDocument:
|
||||
s.serveInstanceIdentityDocument(w, eni)
|
||||
case pathMetaDataRoot, prefixMetaData:
|
||||
writeText(w, "ami-id\nhostname\niam/\ninstance-id\ninstance-type\nlocal-hostname\nlocal-ipv4\nmac\nplacement/\npublic-ipv4\npublic-keys/\nsecurity-groups")
|
||||
writeText(w, "ami-id\nami-launch-index\nhostname\niam/\ninstance-id\ninstance-life-cycle\ninstance-type\nlocal-hostname\nlocal-ipv4\nmac\nplacement/\npublic-hostname\npublic-ipv4\npublic-keys/\nreservation-id\nsecurity-groups\nservices/")
|
||||
case prefixMetaData + "instance-id":
|
||||
writeText(w, eni.instanceID)
|
||||
case prefixMetaData + "instance-life-cycle":
|
||||
writeText(w, "on-demand") // Spot is not modelled yet
|
||||
case prefixMetaData + "local-ipv4":
|
||||
writeText(w, eni.privateIP)
|
||||
case prefixMetaData + "public-ipv4":
|
||||
writeText(w, eni.publicIP)
|
||||
case prefixMetaData + "public-hostname":
|
||||
if eni.publicIP == "" {
|
||||
w.WriteHeader(http.StatusNotFound) // no public IP → no public hostname
|
||||
return
|
||||
}
|
||||
writeText(w, eni.publicIP) // mirror public-ipv4 until public DNS exists
|
||||
case prefixMetaData + "mac":
|
||||
writeText(w, eni.mac)
|
||||
case prefixMetaData + "security-groups":
|
||||
@@ -160,6 +207,18 @@ func (s *IMDSServiceImpl) dispatch(w http.ResponseWriter, r *http.Request, eni *
|
||||
s.serveInstanceField(w, eni, func(i *instanceFacts) string { return i.instanceType })
|
||||
case prefixMetaData + "ami-id":
|
||||
s.serveInstanceField(w, eni, func(i *instanceFacts) string { return i.imageID })
|
||||
case prefixMetaData + "ami-launch-index":
|
||||
s.serveInstanceField(w, eni, func(i *instanceFacts) string {
|
||||
return strconv.FormatInt(i.amiLaunchIndex, 10)
|
||||
})
|
||||
case prefixMetaData + "reservation-id":
|
||||
s.serveInstanceField(w, eni, func(i *instanceFacts) string { return i.reservationID })
|
||||
case prefixMetaData + "services", prefixMetaData + "services/":
|
||||
writeText(w, "domain\npartition")
|
||||
case prefixMetaData + "services/domain":
|
||||
writeText(w, "amazonaws.com")
|
||||
case prefixMetaData + "services/partition":
|
||||
writeText(w, "aws")
|
||||
case prefixMetaData + "iam", prefixMetaData + "iam/":
|
||||
writeText(w, "info\nsecurity-credentials/")
|
||||
case prefixMetaData + "iam/info":
|
||||
@@ -185,6 +244,50 @@ func (s *IMDSServiceImpl) serveInstanceField(w http.ResponseWriter, eni *eniFact
|
||||
writeText(w, field(inst))
|
||||
}
|
||||
|
||||
// instanceIdentityDocument is the unsigned EC2 instance-identity document. nil
|
||||
// slices and *string fields marshal to JSON null, matching AWS's billingProducts,
|
||||
// kernelId, and related fields, which Spinifex does not model.
|
||||
type instanceIdentityDocument struct {
|
||||
AccountID string `json:"accountId"`
|
||||
Architecture string `json:"architecture"`
|
||||
AvailabilityZone string `json:"availabilityZone"`
|
||||
BillingProducts []string `json:"billingProducts"`
|
||||
DevpayProductCodes []string `json:"devpayProductCodes"`
|
||||
MarketplaceProductCodes []string `json:"marketplaceProductCodes"`
|
||||
ImageID string `json:"imageId"`
|
||||
InstanceID string `json:"instanceId"`
|
||||
InstanceType string `json:"instanceType"`
|
||||
KernelID *string `json:"kernelId"`
|
||||
PendingTime string `json:"pendingTime"`
|
||||
PrivateIP string `json:"privateIp"`
|
||||
RamdiskID *string `json:"ramdiskId"`
|
||||
Region string `json:"region"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// serveInstanceIdentityDocument writes the unsigned instance-identity document.
|
||||
// The signed forms (pkcs7/rsa2048/signature) need a per-cluster signing key and
|
||||
// are deferred. 404s when the instance is no longer visible.
|
||||
func (s *IMDSServiceImpl) serveInstanceIdentityDocument(w http.ResponseWriter, eni *eniFacts) {
|
||||
inst := s.instanceFor(w, eni)
|
||||
if inst == nil {
|
||||
return
|
||||
}
|
||||
doc := instanceIdentityDocument{
|
||||
AccountID: eni.accountID,
|
||||
Architecture: inst.architecture,
|
||||
AvailabilityZone: eni.availabilityZone,
|
||||
ImageID: inst.imageID,
|
||||
InstanceID: eni.instanceID,
|
||||
InstanceType: inst.instanceType,
|
||||
PendingTime: inst.pendingTime.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
PrivateIP: eni.privateIP,
|
||||
Region: regionFromAZ(eni.availabilityZone),
|
||||
Version: identityDocSchemaVersion,
|
||||
}
|
||||
writeIdentityDocument(w, doc)
|
||||
}
|
||||
|
||||
// serveUserData writes the instance's user-data, or 404 if absent.
|
||||
func (s *IMDSServiceImpl) serveUserData(w http.ResponseWriter, eni *eniFacts) {
|
||||
inst := s.instanceFor(w, eni)
|
||||
@@ -386,6 +489,18 @@ func writeText(w http.ResponseWriter, body string) {
|
||||
_, _ = io.WriteString(w, body)
|
||||
}
|
||||
|
||||
// writeIdentityDocument writes the identity document pretty-printed as text/plain,
|
||||
// mirroring the AWS IMDS response (2-space indent, nil fields rendered as null).
|
||||
func writeIdentityDocument(w http.ResponseWriter, doc instanceIdentityDocument) {
|
||||
data, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,9 +2,11 @@ package handlers_imds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -228,12 +230,153 @@ func TestHTTP_ENIMissReturns404(t *testing.T) {
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code)
|
||||
}
|
||||
|
||||
// ----- version discovery -------------------------------------------------
|
||||
|
||||
// GET / returns the advertised version list (token-gated, IMDSv2-only).
|
||||
func TestHTTP_RootVersionListing(t *testing.T) {
|
||||
svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{})
|
||||
h := svc.httpHandler()
|
||||
token := issueToken(t, h)
|
||||
rec := get(t, h, "/", token)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "2021-07-15\nlatest", rec.Body.String())
|
||||
}
|
||||
|
||||
// Version discovery is not a tokenless side channel: GET / without a token is 401.
|
||||
func TestHTTP_RootVersionListingTokenless401(t *testing.T) {
|
||||
svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{})
|
||||
h := svc.httpHandler()
|
||||
rec := get(t, h, "/", "")
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
assert.Empty(t, rec.Body.String())
|
||||
}
|
||||
|
||||
// GET /latest lists the top-level tree.
|
||||
func TestHTTP_LatestListing(t *testing.T) {
|
||||
svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{})
|
||||
h := svc.httpHandler()
|
||||
token := issueToken(t, h)
|
||||
rec := get(t, h, "/latest", token)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "dynamic\nmeta-data\nuser-data", rec.Body.String())
|
||||
}
|
||||
|
||||
// Any dated API-version prefix aliases to the /latest tree — including a version
|
||||
// cloud-init probes (2021-03-23) that we do not advertise — while a non-date
|
||||
// first segment is not rewritten and stays 404. The dated token PUT works too.
|
||||
func TestHTTP_DatedVersionAlias(t *testing.T) {
|
||||
svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{})
|
||||
h := svc.httpHandler()
|
||||
|
||||
// PUT /<date>/api/token issues a token like /latest/api/token does.
|
||||
put := httptest.NewRequest(http.MethodPut, "http://"+MetaDataServerIP+"/2021-07-15/api/token", nil)
|
||||
put.RemoteAddr = testIP + ":50000"
|
||||
put = put.WithContext(context.WithValue(put.Context(), ctxKeyVPCID, testVPC))
|
||||
put.Header.Set(hdrTokenTTL, "60")
|
||||
putRec := httptest.NewRecorder()
|
||||
h.ServeHTTP(putRec, put)
|
||||
require.Equal(t, http.StatusOK, putRec.Code)
|
||||
token := putRec.Body.String()
|
||||
require.NotEmpty(t, token)
|
||||
|
||||
want := get(t, h, prefixMetaData+"instance-id", token).Body.String()
|
||||
|
||||
// Advertised and non-advertised dated versions both resolve to /latest.
|
||||
for _, date := range []string{"2021-07-15", "2021-03-23"} {
|
||||
rec := get(t, h, "/"+date+"/meta-data/instance-id", token)
|
||||
assert.Equal(t, http.StatusOK, rec.Code, "date=%s", date)
|
||||
assert.Equal(t, want, rec.Body.String(), "date=%s", date)
|
||||
}
|
||||
|
||||
// A non-date first segment is left alone and 404s.
|
||||
rec := get(t, h, "/bogus/meta-data/instance-id", token)
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code)
|
||||
}
|
||||
|
||||
// ----- dynamic instance-identity -----------------------------------------
|
||||
|
||||
// /latest/dynamic lists instance-identity/; /latest/dynamic/instance-identity
|
||||
// advertises only document (the signed forms are deferred).
|
||||
func TestHTTP_DynamicListings(t *testing.T) {
|
||||
svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{})
|
||||
h := svc.httpHandler()
|
||||
token := issueToken(t, h)
|
||||
cases := []struct{ path, want string }{
|
||||
{prefixDynamic, "instance-identity/"},
|
||||
{prefixDynamic + "/", "instance-identity/"},
|
||||
{pathIdentityDir, "document"},
|
||||
{pathIdentityDir + "/", "document"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
rec := get(t, h, c.path, token)
|
||||
assert.Equal(t, http.StatusOK, rec.Code, "path=%s", c.path)
|
||||
assert.Equal(t, c.want, rec.Body.String(), "path=%s", c.path)
|
||||
}
|
||||
}
|
||||
|
||||
// The unsigned identity document resolves every field from eni + instance facts;
|
||||
// fields Spinifex does not model (billingProducts, kernelId, ...) are JSON null.
|
||||
func TestHTTP_InstanceIdentityDocument(t *testing.T) {
|
||||
pending := time.Date(2026, 6, 18, 1, 2, 3, 0, time.UTC)
|
||||
res := &fakeResolver{
|
||||
eni: testENI(),
|
||||
inst: &instanceFacts{instanceType: "t3.micro", imageID: "ami-12345", architecture: "x86_64", pendingTime: pending},
|
||||
}
|
||||
svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{})
|
||||
h := svc.httpHandler()
|
||||
token := issueToken(t, h)
|
||||
|
||||
rec := get(t, h, pathIdentityDocument, token)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var doc map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &doc))
|
||||
for k, want := range map[string]any{
|
||||
"accountId": "111122223333",
|
||||
"architecture": "x86_64",
|
||||
"availabilityZone": "ap-southeast-2a",
|
||||
"region": "ap-southeast-2",
|
||||
"imageId": "ami-12345",
|
||||
"instanceId": "i-0123456789",
|
||||
"instanceType": "t3.micro",
|
||||
"privateIp": "10.0.1.5",
|
||||
"pendingTime": "2026-06-18T01:02:03Z",
|
||||
"version": "2017-09-30",
|
||||
} {
|
||||
assert.Equal(t, want, doc[k], "field=%s", k)
|
||||
}
|
||||
|
||||
// Unmodelled fields marshal to JSON null, not absent and not "".
|
||||
for _, k := range []string{"billingProducts", "devpayProductCodes", "marketplaceProductCodes", "kernelId", "ramdiskId"} {
|
||||
v, ok := doc[k]
|
||||
assert.True(t, ok, "field=%s present", k)
|
||||
assert.Nil(t, v, "field=%s null", k)
|
||||
}
|
||||
}
|
||||
|
||||
// An instance that is no longer visible (terminating/invisible) is a 404, not a
|
||||
// document with empty fields.
|
||||
func TestHTTP_InstanceIdentityDocumentInvisible404(t *testing.T) {
|
||||
res := &fakeResolver{eni: testENI(), inst: nil}
|
||||
svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{})
|
||||
h := svc.httpHandler()
|
||||
token := issueToken(t, h)
|
||||
rec := get(t, h, pathIdentityDocument, token)
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code)
|
||||
}
|
||||
|
||||
// ----- metadata surface --------------------------------------------------
|
||||
|
||||
func TestHTTP_MetadataPaths(t *testing.T) {
|
||||
res := &fakeResolver{
|
||||
eni: testENI(),
|
||||
inst: &instanceFacts{instanceType: "t3.micro", imageID: "ami-12345", userData: []byte("#!/bin/sh\necho hi")},
|
||||
eni: testENI(),
|
||||
inst: &instanceFacts{
|
||||
instanceType: "t3.micro",
|
||||
imageID: "ami-12345",
|
||||
reservationID: "r-0abc123",
|
||||
amiLaunchIndex: 3,
|
||||
userData: []byte("#!/bin/sh\necho hi"),
|
||||
},
|
||||
sgNames: map[string]string{"sg-1": "web-sg", "sg-2": "db-sg"},
|
||||
}
|
||||
svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{})
|
||||
@@ -244,14 +387,22 @@ func TestHTTP_MetadataPaths(t *testing.T) {
|
||||
{prefixMetaData + "instance-id", "i-0123456789"},
|
||||
{prefixMetaData + "instance-type", "t3.micro"},
|
||||
{prefixMetaData + "ami-id", "ami-12345"},
|
||||
{prefixMetaData + "ami-launch-index", "3"},
|
||||
{prefixMetaData + "reservation-id", "r-0abc123"},
|
||||
{prefixMetaData + "instance-life-cycle", "on-demand"},
|
||||
{prefixMetaData + "local-ipv4", "10.0.1.5"},
|
||||
{prefixMetaData + "public-ipv4", "203.0.113.7"},
|
||||
{prefixMetaData + "public-hostname", "203.0.113.7"},
|
||||
{prefixMetaData + "mac", "02:11:22:33:44:55"},
|
||||
{prefixMetaData + "placement/availability-zone", "ap-southeast-2a"},
|
||||
{prefixMetaData + "placement/region", "ap-southeast-2"},
|
||||
{prefixMetaData + "security-groups", "web-sg\ndb-sg"},
|
||||
{prefixMetaData + "hostname", "ip-10-0-1-5.ap-southeast-2.compute.internal"},
|
||||
{prefixMetaData + "local-hostname", "ip-10-0-1-5.ap-southeast-2.compute.internal"},
|
||||
{prefixMetaData + "services", "domain\npartition"},
|
||||
{prefixMetaData + "services/", "domain\npartition"},
|
||||
{prefixMetaData + "services/domain", "amazonaws.com"},
|
||||
{prefixMetaData + "services/partition", "aws"},
|
||||
{pathUserData, "#!/bin/sh\necho hi"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
@@ -261,15 +412,44 @@ func TestHTTP_MetadataPaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// An instance with no public IP has no public hostname: 404, not an empty 200.
|
||||
func TestHTTP_PublicHostnameAbsent404(t *testing.T) {
|
||||
eni := testENI()
|
||||
eni.publicIP = ""
|
||||
svc, _ := newTestService(&fakeResolver{eni: eni}, &fakeIAM{}, &fakeAssumer{})
|
||||
h := svc.httpHandler()
|
||||
token := issueToken(t, h)
|
||||
rec := get(t, h, prefixMetaData+"public-hostname", token)
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code)
|
||||
}
|
||||
|
||||
// The meta-data root lists every served child, alphabetically, to match AWS.
|
||||
func TestHTTP_DirectoryListing(t *testing.T) {
|
||||
svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{})
|
||||
h := svc.httpHandler()
|
||||
token := issueToken(t, h)
|
||||
rec := get(t, h, pathMetaDataRoot, token)
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Contains(t, rec.Body.String(), "instance-id")
|
||||
assert.Contains(t, rec.Body.String(), "iam/")
|
||||
assert.Contains(t, rec.Body.String(), "public-keys/")
|
||||
want := strings.Join([]string{
|
||||
"ami-id",
|
||||
"ami-launch-index",
|
||||
"hostname",
|
||||
"iam/",
|
||||
"instance-id",
|
||||
"instance-life-cycle",
|
||||
"instance-type",
|
||||
"local-hostname",
|
||||
"local-ipv4",
|
||||
"mac",
|
||||
"placement/",
|
||||
"public-hostname",
|
||||
"public-ipv4",
|
||||
"public-keys/",
|
||||
"reservation-id",
|
||||
"security-groups",
|
||||
"services/",
|
||||
}, "\n")
|
||||
assert.Equal(t, want, rec.Body.String())
|
||||
}
|
||||
|
||||
func TestHTTP_UserDataAbsent404(t *testing.T) {
|
||||
@@ -287,7 +467,9 @@ func TestHTTP_OutOfScopePaths404(t *testing.T) {
|
||||
h := svc.httpHandler()
|
||||
token := issueToken(t, h)
|
||||
for _, p := range []string{
|
||||
"/latest/dynamic/instance-identity/document",
|
||||
pathIdentityDir + "/pkcs7", // signed forms stay deferred until the signing key lands
|
||||
pathIdentityDir + "/rsa2048", //
|
||||
pathIdentityDir + "/signature", //
|
||||
prefixMetaData + "network/interfaces/macs",
|
||||
prefixMetaData + "nonsense",
|
||||
} {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
@@ -33,6 +34,10 @@ type instanceFacts struct {
|
||||
imageID string
|
||||
iamInstanceProfileArn string
|
||||
keyName string
|
||||
architecture string
|
||||
reservationID string
|
||||
amiLaunchIndex int64
|
||||
pendingTime time.Time
|
||||
userData []byte
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ func (s *IMDSServiceImpl) httpHandler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(pathToken, s.handleToken)
|
||||
mux.HandleFunc("/", s.handleMetadata)
|
||||
return rejectForwarded(mux)
|
||||
return rejectForwarded(normalizeVersion(mux))
|
||||
}
|
||||
|
||||
// Run starts the bind manager (sync + watch) and token-sweep ticker, blocks until ctx is done.
|
||||
|
||||
@@ -199,6 +199,32 @@ func runIMDS(t *testing.T, fix *Fixture) {
|
||||
require.NotEmpty(t, imdsGet(t, tgtX, tokenX, "/latest/meta-data/placement/availability-zone"),
|
||||
"placement/availability-zone must be populated")
|
||||
|
||||
// Cheap one-field/static leaves: a real reservation id, the static
|
||||
// instance-life-cycle, and the services subtree.
|
||||
require.True(t, strings.HasPrefix(imdsGet(t, tgtX, tokenX, "/latest/meta-data/reservation-id"), "r-"),
|
||||
"reservation-id must be served as an r-… identifier")
|
||||
require.Equal(t, "on-demand", imdsGet(t, tgtX, tokenX, "/latest/meta-data/instance-life-cycle"),
|
||||
"instance-life-cycle must be on-demand (Spot not modelled)")
|
||||
require.Equal(t, "aws", imdsGet(t, tgtX, tokenX, "/latest/meta-data/services/partition"),
|
||||
"services/partition must be aws")
|
||||
require.Equal(t, "amazonaws.com", imdsGet(t, tgtX, tokenX, "/latest/meta-data/services/domain"),
|
||||
"services/domain must be amazonaws.com")
|
||||
|
||||
// public-hostname mirrors public-ipv4 when the instance has one, else 404. In
|
||||
// pool mode the probe subnet maps a public IP on launch; in dev_networking it
|
||||
// has none, so the two modes exercise the two branches.
|
||||
if fix.PoolMode {
|
||||
pubIP := imdsGet(t, tgtX, tokenX, "/latest/meta-data/public-ipv4")
|
||||
require.NotEmpty(t, pubIP, "pool-mode VM must have a public IP")
|
||||
require.Equal(t, pubIP, imdsGet(t, tgtX, tokenX, "/latest/meta-data/public-hostname"),
|
||||
"public-hostname must mirror public-ipv4")
|
||||
} else {
|
||||
require.Equal(t, "404",
|
||||
imdsCode(tgtX, fmt.Sprintf(`-H "X-aws-ec2-metadata-token: %s"`, tokenX),
|
||||
"/latest/meta-data/public-hostname"),
|
||||
"no public IP → public-hostname must 404")
|
||||
}
|
||||
|
||||
// iam/info → InstanceProfileArn ends with the bound profile.
|
||||
iamInfo := imdsGet(t, tgtX, tokenX, "/latest/meta-data/iam/info")
|
||||
require.Containsf(t, iamInfo, ":instance-profile/"+imdsProfileName,
|
||||
@@ -229,6 +255,38 @@ func runIMDS(t *testing.T, fix *Fixture) {
|
||||
require.Contains(t, imdsGet(t, tgtX, tokenX, "/latest/user-data"), imdsUDMarker,
|
||||
"user-data must round-trip the launch user-data")
|
||||
|
||||
// --- Version discovery + dated-version alias (cloud-init parity) ----------
|
||||
// cloud-init's EC2 datasource probes its OWN hardcoded dated versions
|
||||
// (e.g. 2021-03-23), not the GET / listing, so any dated prefix must alias to
|
||||
// /latest. Prove that aliasing resolves in-guest.
|
||||
harness.Step(t, "version discovery: GET /, GET /latest, dated-version alias")
|
||||
require.Contains(t, imdsGet(t, tgtX, tokenX, "/"), "latest",
|
||||
"GET / must advertise the supported version list")
|
||||
require.Equal(t, "dynamic\nmeta-data\nuser-data", imdsGet(t, tgtX, tokenX, "/latest"),
|
||||
"GET /latest must list the top-level tree")
|
||||
require.Equal(t, idX, imdsGet(t, tgtX, tokenX, "/2021-03-23/meta-data/instance-id"),
|
||||
"a dated API version must alias to /latest (cloud-init parity)")
|
||||
|
||||
// --- Dynamic instance-identity document ----------------------------------
|
||||
// The unsigned document is consumed by real SDKs in-guest; assert the fields
|
||||
// resolved from ENI + instance facts, including the launch-time architecture.
|
||||
harness.Step(t, "GET /latest/dynamic/instance-identity/document")
|
||||
_, wantArch := needInstanceTypeArch(t, fix)
|
||||
docBody := imdsGet(t, tgtX, tokenX, "/latest/dynamic/instance-identity/document")
|
||||
var idDoc struct {
|
||||
InstanceID string `json:"instanceId"`
|
||||
AccountID string `json:"accountId"`
|
||||
Region string `json:"region"`
|
||||
Architecture string `json:"architecture"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(docBody), &idDoc),
|
||||
"identity document must be valid JSON: %s", docBody)
|
||||
require.Equal(t, idX, idDoc.InstanceID, "identity document instanceId mismatch")
|
||||
require.Equal(t, adminAccount, idDoc.AccountID, "identity document accountId mismatch")
|
||||
require.NotEmpty(t, idDoc.Region, "identity document region must be populated")
|
||||
require.Equal(t, wantArch, idDoc.Architecture,
|
||||
"identity document architecture must match the launch instance type")
|
||||
|
||||
// --- Instance-role credentials + wire round-trip -------------------------
|
||||
harness.Step(t, "GET security-credentials/%s → ASIA creds", imdsRoleName)
|
||||
credBody := imdsGet(t, tgtX, tokenX, "/latest/meta-data/iam/security-credentials/"+imdsRoleName)
|
||||
|
||||
Reference in New Issue
Block a user