From 98db2c4e0c9368f6e0137925fb2d835308ca164c Mon Sep 17 00:00:00 2001 From: Julian Sommer <115516918+juliansommer@users.noreply.github.com> Date: Wed, 24 Jun 2026 09:27:40 +1000 Subject: [PATCH] feat(imds): serve network/interfaces/macs/ subtree (single-NIC) (#410) --- docs/COMMANDS.md | 2 +- spinifex/handlers/imds/metadata.go | 120 ++++++++++++++- spinifex/handlers/imds/metadata_test.go | 193 ++++++++++++++++++++++-- spinifex/handlers/imds/resolver.go | 44 +++++- spinifex/handlers/imds/service.go | 23 ++- 5 files changed, 365 insertions(+), 17 deletions(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 363306f2a..acff9532b 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -628,7 +628,7 @@ curl -i http://169.254.169.254/latest/meta-data/instance-id | `/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//...` | GET | Multi-ENI rendering (subnet-id, vpc-id, ipv4s, security-group-ids, …) | **NOT STARTED** (404) | +| `/latest/meta-data/network/interfaces/macs//...` | GET | Primary ENI subtree: `mac`, `device-number`, `interface-id`, `owner-id`, `subnet-id`, `vpc-id`, `local-ipv4s`, `local-hostname`, `security-group-ids`, `security-groups`, `subnet-ipv4-cidr-block`, `vpc-ipv4-cidr-block(s)`, and `public-ipv4s`/`public-hostname` when an EIP is attached | **DONE** (single-NIC; multi-ENI deferred) | | `/latest/meta-data/tags/instance/` | GET | Instance-tag metadata; gated on `InstanceMetadataTags` enablement | **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) | diff --git a/spinifex/handlers/imds/metadata.go b/spinifex/handlers/imds/metadata.go index 58d3d3604..cc12449bd 100644 --- a/spinifex/handlers/imds/metadata.go +++ b/spinifex/handlers/imds/metadata.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "regexp" + "sort" "strconv" "strings" "time" @@ -25,6 +26,7 @@ const ( pathSecurityCredsDir = "/latest/meta-data/iam/security-credentials" //nolint:gosec // URL path, not a credential prefixPublicKeys = "/latest/meta-data/public-keys/" pathPublicKeysDir = "/latest/meta-data/public-keys" + prefixNetworkMacs = "/latest/meta-data/network/interfaces/macs/" prefixDynamic = "/latest/dynamic" pathIdentityDir = "/latest/dynamic/instance-identity" @@ -148,6 +150,11 @@ func (s *IMDSServiceImpl) dispatch(w http.ResponseWriter, r *http.Request, eni * return } + if sub, ok := strings.CutPrefix(path, prefixNetworkMacs); ok { + s.serveNetworkInterface(w, eni, sub) // sub: "", "", "/", "/" + return + } + switch path { case "/": writeText(w, strings.Join(supportedVersions, "\n")) @@ -160,7 +167,7 @@ func (s *IMDSServiceImpl) dispatch(w http.ResponseWriter, r *http.Request, eni * case pathIdentityDocument: s.serveInstanceIdentityDocument(w, eni) case pathMetaDataRoot, prefixMetaData: - 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/") + writeText(w, "ami-id\nami-launch-index\nhostname\niam/\ninstance-id\ninstance-life-cycle\ninstance-type\nlocal-hostname\nlocal-ipv4\nmac\nnetwork/\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": @@ -177,6 +184,12 @@ func (s *IMDSServiceImpl) dispatch(w http.ResponseWriter, r *http.Request, eni * writeText(w, eni.publicIP) // mirror public-ipv4 until public DNS exists case prefixMetaData + "mac": writeText(w, eni.mac) + case prefixMetaData + "network", prefixMetaData + "network/": + writeText(w, "interfaces/") + case prefixMetaData + "network/interfaces", prefixMetaData + "network/interfaces/": + writeText(w, "macs/") + case prefixMetaData + "network/interfaces/macs": + writeText(w, eni.mac+"/") case prefixMetaData + "security-groups": writeText(w, strings.Join(s.resolver.resolveSGNames(eni.accountID, eni.securityGroupIDs), "\n")) case prefixMetaData + "hostname", prefixMetaData + "local-hostname": @@ -388,6 +401,111 @@ func (s *IMDSServiceImpl) servePublicKeys(w http.ResponseWriter, eni *eniFacts, } } +// serveNetworkInterface serves the single primary ENI's subtree under +// /network/interfaces/macs/. sub is the path after the macs/ prefix. An empty sub +// is the macs/ directory listing; a MAC that is not the caller's is 404, since the +// per-tap responder only ever resolves its own ENI (single-NIC; multi-ENI deferred). +func (s *IMDSServiceImpl) serveNetworkInterface(w http.ResponseWriter, eni *eniFacts, sub string) { + if sub == "" { + writeText(w, eni.mac+"/") + return + } + mac, key, _ := strings.Cut(sub, "/") + if mac != eni.mac { + w.WriteHeader(http.StatusNotFound) + return + } + switch key { + case "": // "" or "/" — the per-interface key listing + keys, err := s.macKeys(eni) + if err != nil { + slog.Error("IMDS: network-interface listing failed", "account_id", eni.accountID, "err", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + writeText(w, strings.Join(keys, "\n")) + case "mac": + writeText(w, eni.mac) + case "device-number": + writeText(w, "0") // primary ENI + case "interface-id": + writeText(w, eni.eniID) + case "owner-id": + writeText(w, eni.accountID) + case "subnet-id": + writeText(w, eni.subnetID) + case "vpc-id": + writeText(w, eni.vpcID) + case "local-ipv4s": + writeText(w, eni.privateIP) + case "local-hostname": + writeText(w, synthHostname(eni.privateIP, regionFromAZ(eni.availabilityZone))) + case "security-group-ids": + writeText(w, strings.Join(eni.securityGroupIDs, "\n")) + case "security-groups": + writeText(w, strings.Join(s.resolver.resolveSGNames(eni.accountID, eni.securityGroupIDs), "\n")) + case "subnet-ipv4-cidr-block": + s.serveCIDR(w, eni.accountID, eni.subnetID, s.resolver.resolveSubnetCIDR) + case "vpc-ipv4-cidr-block", "vpc-ipv4-cidr-blocks": + s.serveCIDR(w, eni.accountID, eni.vpcID, s.resolver.resolveVPCCIDR) + case "public-ipv4s", "public-hostname": + if eni.publicIP == "" { + w.WriteHeader(http.StatusNotFound) + return + } + writeText(w, eni.publicIP) + default: + w.WriteHeader(http.StatusNotFound) + } +} + +// macKeys lists exactly the leaf keys served under macs// so cloud-init's +// recursive crawl never lists a key that 404s: CIDR keys appear only when the CIDR +// resolves, public keys only with a public IP. A resolver fault is propagated so the +// listing 500s like the leaf, never silently dropping a key on a transient KV blip. +func (s *IMDSServiceImpl) macKeys(eni *eniFacts) ([]string, error) { + keys := []string{ + "device-number", "interface-id", "local-hostname", "local-ipv4s", + "mac", "owner-id", "security-group-ids", "security-groups", + "subnet-id", "vpc-id", + } + subnetCIDR, err := s.resolver.resolveSubnetCIDR(eni.accountID, eni.subnetID) + if err != nil { + return nil, err + } + if subnetCIDR != "" { + keys = append(keys, "subnet-ipv4-cidr-block") + } + vpcCIDR, err := s.resolver.resolveVPCCIDR(eni.accountID, eni.vpcID) + if err != nil { + return nil, err + } + if vpcCIDR != "" { + keys = append(keys, "vpc-ipv4-cidr-block", "vpc-ipv4-cidr-blocks") + } + if eni.publicIP != "" { + keys = append(keys, "public-hostname", "public-ipv4s") + } + sort.Strings(keys) + return keys, nil +} + +// serveCIDR resolves and writes a subnet/VPC CIDR: 404 on miss, 500 on a backend +// fault, so a guest never renders network config from an empty CIDR. +func (s *IMDSServiceImpl) serveCIDR(w http.ResponseWriter, accountID, id string, resolve func(string, string) (string, error)) { + cidr, err := resolve(accountID, id) + if err != nil { + slog.Error("IMDS: network-interface CIDR resolution failed", "account_id", accountID, "id", id, "err", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + if cidr == "" { + w.WriteHeader(http.StatusNotFound) + return + } + writeText(w, cidr) +} + // instanceFor resolves the instance record for an ENI, writing the appropriate // HTTP error and returning nil on miss/failure so callers can early-return. func (s *IMDSServiceImpl) instanceFor(w http.ResponseWriter, eni *eniFacts) *instanceFacts { diff --git a/spinifex/handlers/imds/metadata_test.go b/spinifex/handlers/imds/metadata_test.go index 696fb155a..f6d9c4c5b 100644 --- a/spinifex/handlers/imds/metadata_test.go +++ b/spinifex/handlers/imds/metadata_test.go @@ -22,11 +22,15 @@ import ( // ----- fakes ------------------------------------------------------------- type fakeResolver struct { - eni *eniFacts - eniErr error - inst *instanceFacts - instErr error - sgNames map[string]string // sg-id → group name; absent IDs fall back to the ID + eni *eniFacts + eniErr error + inst *instanceFacts + instErr error + sgNames map[string]string // sg-id → group name; absent IDs fall back to the ID + subnetCIDR string + subnetErr error + vpcCIDR string + vpcErr error } func (f *fakeResolver) resolveENIByID(_ string) (*eniFacts, error) { return f.eni, f.eniErr } @@ -46,6 +50,11 @@ func (f *fakeResolver) resolveSGNames(_ string, sgIDs []string) []string { return out } +func (f *fakeResolver) resolveSubnetCIDR(_, _ string) (string, error) { + return f.subnetCIDR, f.subnetErr +} +func (f *fakeResolver) resolveVPCCIDR(_, _ string) (string, error) { return f.vpcCIDR, f.vpcErr } + type fakeIAM struct { profile *handlers_iam.InstanceProfile profileErr error @@ -445,6 +454,7 @@ func TestHTTP_DirectoryListing(t *testing.T) { "local-hostname", "local-ipv4", "mac", + "network/", "placement/", "public-hostname", "public-ipv4", @@ -471,10 +481,12 @@ func TestHTTP_OutOfScopePaths404(t *testing.T) { h := withTapENI(svc.httpHandler(), testENI()) token := issueToken(t, h) for _, p := range []string{ - pathIdentityDir + "/pkcs7", // signed forms stay deferred until the signing key lands - pathIdentityDir + "/rsa2048", // - pathIdentityDir + "/signature", // - prefixMetaData + "network/interfaces/macs", + pathIdentityDir + "/pkcs7", // signed forms stay deferred until the signing key lands + pathIdentityDir + "/rsa2048", // + pathIdentityDir + "/signature", // + prefixNetworkMacs + testENI().mac + "/ipv4-associations", // IPv6/associations stay deferred + prefixNetworkMacs + testENI().mac + "/ipv6s", // + prefixNetworkMacs + "02:00:00:00:00:00/subnet-id", // a MAC that is not the caller's prefixMetaData + "nonsense", } { rec := get(t, h, p, token) @@ -482,6 +494,169 @@ func TestHTTP_OutOfScopePaths404(t *testing.T) { } } +// ----- network interfaces ------------------------------------------------ + +const ( + testSubnetCIDR = "10.0.1.0/24" + testVPCCIDR = "10.0.0.0/16" +) + +// macPath builds a /network/interfaces/macs/[/] request path. +func macPath(mac, key string) string { + p := prefixNetworkMacs + mac + if key != "" { + p += "/" + key + } + return p +} + +// The intermediate directory listings walk down to the caller's single MAC. +func TestHTTP_NetworkInterfacesListings(t *testing.T) { + svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{}) + h := withTapENI(svc.httpHandler(), testENI()) + token := issueToken(t, h) + mac := testENI().mac + cases := []struct{ path, want string }{ + {prefixMetaData + "network", "interfaces/"}, + {prefixMetaData + "network/", "interfaces/"}, + {prefixMetaData + "network/interfaces", "macs/"}, + {prefixMetaData + "network/interfaces/", "macs/"}, + {prefixMetaData + "network/interfaces/macs", mac + "/"}, + {prefixMetaData + "network/interfaces/macs/", mac + "/"}, + } + 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) + } +} + +// macs/ and macs// list exactly the leaves served — CIDR and public keys +// included only when they resolve, so cloud-init's crawl never lists a key that 404s. +func TestHTTP_NetworkInterfaceMacDirListing(t *testing.T) { + res := &fakeResolver{eni: testENI(), subnetCIDR: testSubnetCIDR, vpcCIDR: testVPCCIDR} + svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{}) + h := withTapENI(svc.httpHandler(), testENI()) + token := issueToken(t, h) + want := strings.Join([]string{ + "device-number", + "interface-id", + "local-hostname", + "local-ipv4s", + "mac", + "owner-id", + "public-hostname", + "public-ipv4s", + "security-group-ids", + "security-groups", + "subnet-id", + "subnet-ipv4-cidr-block", + "vpc-id", + "vpc-ipv4-cidr-block", + "vpc-ipv4-cidr-blocks", + }, "\n") + mac := testENI().mac + for _, p := range []string{macPath(mac, ""), macPath(mac, "") + "/"} { + rec := get(t, h, p, token) + assert.Equal(t, http.StatusOK, rec.Code, "path=%s", p) + assert.Equal(t, want, rec.Body.String(), "path=%s", p) + } +} + +// Every served leaf resolves from eni + resolver facts. +func TestHTTP_NetworkInterfaceLeaves(t *testing.T) { + res := &fakeResolver{ + eni: testENI(), + sgNames: map[string]string{"sg-1": "web-sg", "sg-2": "db-sg"}, + subnetCIDR: testSubnetCIDR, + vpcCIDR: testVPCCIDR, + } + svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{}) + h := withTapENI(svc.httpHandler(), testENI()) + token := issueToken(t, h) + mac := testENI().mac + cases := []struct{ key, want string }{ + {"mac", mac}, + {"device-number", "0"}, + {"interface-id", "eni-aaa"}, + {"owner-id", "111122223333"}, + {"subnet-id", "subnet-1"}, + {"vpc-id", testVPC}, + {"local-ipv4s", testIP}, + {"local-hostname", "ip-10-0-1-5.ap-southeast-2.compute.internal"}, + {"security-group-ids", "sg-1\nsg-2"}, + {"security-groups", "web-sg\ndb-sg"}, + {"subnet-ipv4-cidr-block", testSubnetCIDR}, + {"vpc-ipv4-cidr-block", testVPCCIDR}, + {"vpc-ipv4-cidr-blocks", testVPCCIDR}, + {"public-ipv4s", "203.0.113.7"}, + {"public-hostname", "203.0.113.7"}, + } + for _, c := range cases { + rec := get(t, h, macPath(mac, c.key), token) + assert.Equal(t, http.StatusOK, rec.Code, "key=%s", c.key) + assert.Equal(t, c.want, rec.Body.String(), "key=%s", c.key) + } +} + +// With no public IP, public-ipv4s and public-hostname 404 and drop from the listing. +func TestHTTP_NetworkInterfacePublicAbsent(t *testing.T) { + eni := testENI() + eni.publicIP = "" + res := &fakeResolver{eni: eni, subnetCIDR: testSubnetCIDR, vpcCIDR: testVPCCIDR} + svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{}) + h := withTapENI(svc.httpHandler(), eni) + token := issueToken(t, h) + + listing := get(t, h, macPath(eni.mac, ""), token).Body.String() + assert.NotContains(t, listing, "public-ipv4s") + assert.NotContains(t, listing, "public-hostname") + + for _, key := range []string{"public-ipv4s", "public-hostname"} { + rec := get(t, h, macPath(eni.mac, key), token) + assert.Equal(t, http.StatusNotFound, rec.Code, "key=%s", key) + } +} + +// On a CIDR miss the CIDR leaves 404 and drop from the listing; a resolver error +// is a 500 on both the leaf and the listing, never an empty/dropped CIDR a guest +// would mis-render into its network config. +func TestHTTP_NetworkInterfaceCidrMiss(t *testing.T) { + res := &fakeResolver{eni: testENI()} // no canned CIDRs → miss + svc, _ := newTestService(res, &fakeIAM{}, &fakeAssumer{}) + h := withTapENI(svc.httpHandler(), testENI()) + token := issueToken(t, h) + mac := testENI().mac + + listing := get(t, h, macPath(mac, ""), token).Body.String() + assert.NotContains(t, listing, "subnet-ipv4-cidr-block") + assert.NotContains(t, listing, "vpc-ipv4-cidr-block") + for _, key := range []string{"subnet-ipv4-cidr-block", "vpc-ipv4-cidr-block", "vpc-ipv4-cidr-blocks"} { + rec := get(t, h, macPath(mac, key), token) + assert.Equal(t, http.StatusNotFound, rec.Code, "key=%s", key) + } + + errRes := &fakeResolver{eni: testENI(), subnetErr: errors.New("kv unavailable"), vpcErr: errors.New("kv unavailable")} + errSvc, _ := newTestService(errRes, &fakeIAM{}, &fakeAssumer{}) + errH := withTapENI(errSvc.httpHandler(), testENI()) + errToken := issueToken(t, errH) + for _, key := range []string{"subnet-ipv4-cidr-block", "vpc-ipv4-cidr-block", "vpc-ipv4-cidr-blocks"} { + rec := get(t, errH, macPath(mac, key), errToken) + assert.Equal(t, http.StatusInternalServerError, rec.Code, "key=%s", key) + } + listingRec := get(t, errH, macPath(mac, ""), errToken) + assert.Equal(t, http.StatusInternalServerError, listingRec.Code, "listing under resolver error") +} + +// A MAC that is not the caller's is 404: the per-tap responder only serves its own ENI. +func TestHTTP_NetworkInterfaceForeignMac404(t *testing.T) { + svc, _ := newTestService(&fakeResolver{eni: testENI()}, &fakeIAM{}, &fakeAssumer{}) + h := withTapENI(svc.httpHandler(), testENI()) + token := issueToken(t, h) + rec := get(t, h, macPath("02:00:00:00:00:00", "subnet-id"), token) + assert.Equal(t, http.StatusNotFound, rec.Code) +} + // ----- public keys ------------------------------------------------------- func TestHTTP_PublicKeys(t *testing.T) { diff --git a/spinifex/handlers/imds/resolver.go b/spinifex/handlers/imds/resolver.go index 32e752417..8e2014238 100644 --- a/spinifex/handlers/imds/resolver.go +++ b/spinifex/handlers/imds/resolver.go @@ -14,6 +14,8 @@ import ( // kvBucketENIs and kvBucketSecurityGroups are duplicated as literals to avoid an import cycle. const kvBucketENIs = "spinifex-vpc-enis" const kvBucketSecurityGroups = "spinifex-vpc-security-groups" +const kvBucketSubnets = "spinifex-vpc-subnets" +const kvBucketVPCs = "spinifex-vpc-vpcs" // eniFacts is the ENI fields served by the IMDS metadata surface, read live from the ENI record. type eniFacts struct { @@ -68,9 +70,11 @@ type eniRecord struct { // metadataResolver resolves a tap's ENI ID to ENI + instance facts. // Resolution chain: eniID → ENIRecord (account recovered from the bucket key) → instanceFacts. type metadataResolver struct { - eniKV nats.KeyValue // spinifex-vpc-enis - sgKV nats.KeyValue // spinifex-vpc-security-groups (nil-safe: degrades to IDs) - lookup instanceLookup + eniKV nats.KeyValue // spinifex-vpc-enis + sgKV nats.KeyValue // spinifex-vpc-security-groups (nil-safe: degrades to IDs) + subnetKV nats.KeyValue // spinifex-vpc-subnets (nil-safe: CIDR leaf 404s) + vpcKV nats.KeyValue // spinifex-vpc-vpcs (nil-safe: CIDR leaf 404s) + lookup instanceLookup } var _ eniResolver = (*metadataResolver)(nil) @@ -178,3 +182,37 @@ func (r *metadataResolver) resolveSGNames(accountID string, sgIDs []string) []st } return names } + +// cidrRecord is the subnet/VPC record subset the network-interfaces subtree reads. +type cidrRecord struct { + CidrBlock string `json:"cidr_block"` +} + +func (r *metadataResolver) resolveSubnetCIDR(accountID, subnetID string) (string, error) { + return cidrFromKV(r.subnetKV, accountID, subnetID) +} + +func (r *metadataResolver) resolveVPCCIDR(accountID, vpcID string) (string, error) { + return cidrFromKV(r.vpcKV, accountID, vpcID) +} + +// cidrFromKV reads cidr_block from an account-scoped record. ("", nil) on a nil +// bucket or key miss; the error on any other fault so the leaf 500s rather than +// serving an empty CIDR a guest would mis-render into its network config. +func cidrFromKV(kv nats.KeyValue, accountID, id string) (string, error) { + if kv == nil || id == "" { + return "", nil + } + entry, err := kv.Get(accountID + "." + id) + if err != nil { + if errors.Is(err, nats.ErrKeyNotFound) { + return "", nil + } + return "", fmt.Errorf("get cidr record %s.%s: %w", accountID, id, err) + } + var rec cidrRecord + if err := json.Unmarshal(entry.Value(), &rec); err != nil { + return "", fmt.Errorf("unmarshal cidr record %s.%s: %w", accountID, id, err) + } + return rec.CidrBlock, nil +} diff --git a/spinifex/handlers/imds/service.go b/spinifex/handlers/imds/service.go index ade3889fb..5a0972f6c 100644 --- a/spinifex/handlers/imds/service.go +++ b/spinifex/handlers/imds/service.go @@ -49,6 +49,8 @@ type eniResolver interface { resolveENIByID(eniID string) (*eniFacts, error) resolveInstance(eni *eniFacts) (*instanceFacts, error) resolveSGNames(accountID string, sgIDs []string) []string + resolveSubnetCIDR(accountID, subnetID string) (string, error) + resolveVPCCIDR(accountID, vpcID string) (string, error) } // IMDSServiceImpl is the in-process IMDS implementation. It runs one per-tap @@ -101,11 +103,26 @@ func NewIMDSServiceImpl(natsConn *nats.Conn, sts stsAssumer, iamSvc profileLooku sgKV = nil } + // Open subnet/VPC buckets best-effort; the network-interfaces CIDR leaves 404 + // (and drop from the listing) when a bucket is unavailable, IMDS still starts. + subnetKV, err := js.KeyValue(kvBucketSubnets) + if err != nil { + slog.Warn("IMDS: subnet bucket unavailable, network-interfaces subnet CIDR will 404", "bucket", kvBucketSubnets, "err", err) + subnetKV = nil + } + vpcKV, err := js.KeyValue(kvBucketVPCs) + if err != nil { + slog.Warn("IMDS: vpc bucket unavailable, network-interfaces VPC CIDR will 404", "bucket", kvBucketVPCs, "err", err) + vpcKV = nil + } + svc := &IMDSServiceImpl{ resolver: &metadataResolver{ - eniKV: eniKV, - sgKV: sgKV, - lookup: &natsInstanceLookup{nc: natsConn, expectedNodes: expectedNodes}, + eniKV: eniKV, + sgKV: sgKV, + subnetKV: subnetKV, + vpcKV: vpcKV, + lookup: &natsInstanceLookup{nc: natsConn, expectedNodes: expectedNodes}, }, tokens: newTokenStore(), creds: newCredCache(sts),