Skip to content

Commit ab5dfe0

Browse files
authored
feat: add imds client (#2537)
* feat: add imds client * fix: lint
1 parent fbac976 commit ab5dfe0

File tree

3 files changed

+332
-0
lines changed

3 files changed

+332
-0
lines changed

cns/imds/client.go

+132
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright 2024 Microsoft. All rights reserved.
2+
// MIT License
3+
4+
package imds
5+
6+
import (
7+
"context"
8+
"encoding/json"
9+
"net/http"
10+
"net/url"
11+
12+
"github.com/avast/retry-go/v4"
13+
"github.com/pkg/errors"
14+
)
15+
16+
// see docs for IMDS here: https://learn.microsoft.com/en-us/azure/virtual-machines/instance-metadata-service
17+
18+
// Client returns metadata about the VM by querying IMDS
19+
type Client struct {
20+
cli *http.Client
21+
config clientConfig
22+
}
23+
24+
// clientConfig holds config options for a Client
25+
type clientConfig struct {
26+
endpoint string
27+
retryAttempts uint
28+
}
29+
30+
type ClientOption func(*clientConfig)
31+
32+
// Endpoint overrides the default endpoint for a Client
33+
func Endpoint(endpoint string) ClientOption {
34+
return func(c *clientConfig) {
35+
c.endpoint = endpoint
36+
}
37+
}
38+
39+
// RetryAttempts overrides the default retry attempts for the client
40+
func RetryAttempts(attempts uint) ClientOption {
41+
return func(c *clientConfig) {
42+
c.retryAttempts = attempts
43+
}
44+
}
45+
46+
const (
47+
vmUniqueIDProperty = "vmId"
48+
imdsComputePath = "/metadata/instance/compute?api-version=2021-01-01&format=json"
49+
metadataHeaderKey = "Metadata"
50+
metadataHeaderValue = "true"
51+
defaultRetryAttempts = 10
52+
defaultIMDSEndpoint = "http://169.254.169.254"
53+
)
54+
55+
var (
56+
ErrVMUniqueIDNotFound = errors.New("vm unique ID not found")
57+
ErrUnexpectedStatusCode = errors.New("imds returned an unexpected status code")
58+
)
59+
60+
// NewClient creates a new imds client
61+
func NewClient(opts ...ClientOption) *Client {
62+
config := clientConfig{
63+
endpoint: defaultIMDSEndpoint,
64+
}
65+
66+
for _, o := range opts {
67+
o(&config)
68+
}
69+
70+
return &Client{
71+
cli: &http.Client{},
72+
config: config,
73+
}
74+
}
75+
76+
func (c *Client) GetVMUniqueID(ctx context.Context) (string, error) {
77+
var vmUniqueID string
78+
err := retry.Do(func() error {
79+
computeDoc, err := c.getInstanceComputeMetadata(ctx)
80+
if err != nil {
81+
return errors.Wrap(err, "error getting IMDS compute metadata")
82+
}
83+
vmUniqueIDUntyped := computeDoc[vmUniqueIDProperty]
84+
var ok bool
85+
vmUniqueID, ok = vmUniqueIDUntyped.(string)
86+
if !ok {
87+
return errors.New("unable to parse IMDS compute metadata, vmId property is not a string")
88+
}
89+
return nil
90+
}, retry.Context(ctx), retry.Attempts(c.config.retryAttempts), retry.DelayType(retry.BackOffDelay))
91+
if err != nil {
92+
return "", errors.Wrap(err, "exhausted retries querying IMDS compute metadata")
93+
}
94+
95+
if vmUniqueID == "" {
96+
return "", ErrVMUniqueIDNotFound
97+
}
98+
99+
return vmUniqueID, nil
100+
}
101+
102+
func (c *Client) getInstanceComputeMetadata(ctx context.Context) (map[string]any, error) {
103+
imdsComputeURL, err := url.JoinPath(c.config.endpoint, imdsComputePath)
104+
if err != nil {
105+
return nil, errors.Wrap(err, "unable to build path to IMDS compute metadata")
106+
}
107+
108+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imdsComputeURL, http.NoBody)
109+
if err != nil {
110+
return nil, errors.Wrap(err, "error building IMDS http request")
111+
}
112+
113+
// IMDS requires the "Metadata: true" header
114+
req.Header.Add(metadataHeaderKey, metadataHeaderValue)
115+
116+
resp, err := c.cli.Do(req)
117+
if err != nil {
118+
return nil, errors.Wrap(err, "error querying IMDS")
119+
}
120+
defer resp.Body.Close()
121+
122+
if resp.StatusCode != http.StatusOK {
123+
return nil, errors.Wrapf(ErrUnexpectedStatusCode, "unexpected status code %d", resp.StatusCode)
124+
}
125+
126+
var m map[string]any
127+
if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
128+
return nil, errors.Wrap(err, "error decoding IMDS response as json")
129+
}
130+
131+
return m, nil
132+
}

cns/imds/client_test.go

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright 2024 Microsoft. All rights reserved.
2+
// MIT License
3+
4+
package imds_test
5+
6+
import (
7+
"context"
8+
"net/http"
9+
"net/http/httptest"
10+
"os"
11+
"testing"
12+
13+
"github.com/Azure/azure-container-networking/cns/imds"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func TestGetVMUniqueID(t *testing.T) {
19+
computeMetadata, err := os.ReadFile("testdata/computeMetadata.json")
20+
require.NoError(t, err, "error reading testdata compute metadata file")
21+
22+
mockIMDSServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
23+
// request header "Metadata: true" must be present
24+
metadataHeader := r.Header.Get("Metadata")
25+
assert.Equal(t, "true", metadataHeader)
26+
w.WriteHeader(http.StatusOK)
27+
_, writeErr := w.Write(computeMetadata)
28+
require.NoError(t, writeErr, "error writing response")
29+
}))
30+
defer mockIMDSServer.Close()
31+
32+
imdsClient := imds.NewClient(imds.Endpoint(mockIMDSServer.URL))
33+
vmUniqueID, err := imdsClient.GetVMUniqueID(context.Background())
34+
require.NoError(t, err, "error querying testserver")
35+
36+
require.Equal(t, "55b8499d-9b42-4f85-843f-24ff69f4a643", vmUniqueID)
37+
}
38+
39+
func TestGetVMUniqueIDInvalidEndpoint(t *testing.T) {
40+
imdsClient := imds.NewClient(imds.Endpoint(string([]byte{0x7f})), imds.RetryAttempts(1))
41+
_, err := imdsClient.GetVMUniqueID(context.Background())
42+
require.Error(t, err, "expected invalid path")
43+
}
44+
45+
func TestIMDSInternalServerError(t *testing.T) {
46+
mockIMDSServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
47+
// request header "Metadata: true" must be present
48+
w.WriteHeader(http.StatusInternalServerError)
49+
}))
50+
defer mockIMDSServer.Close()
51+
52+
imdsClient := imds.NewClient(imds.Endpoint(mockIMDSServer.URL), imds.RetryAttempts(1))
53+
54+
_, err := imdsClient.GetVMUniqueID(context.Background())
55+
require.ErrorIs(t, err, imds.ErrUnexpectedStatusCode, "expected internal server error")
56+
}
57+
58+
func TestIMDSInvalidJSON(t *testing.T) {
59+
mockIMDSServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
60+
w.WriteHeader(http.StatusOK)
61+
_, err := w.Write([]byte("not json"))
62+
require.NoError(t, err)
63+
}))
64+
defer mockIMDSServer.Close()
65+
66+
imdsClient := imds.NewClient(imds.Endpoint(mockIMDSServer.URL), imds.RetryAttempts(1))
67+
68+
_, err := imdsClient.GetVMUniqueID(context.Background())
69+
require.Error(t, err, "expected json decoding error")
70+
}
+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
{
2+
"azEnvironment": "AzurePublicCloud",
3+
"customData": "",
4+
"evictionPolicy": "",
5+
"isHostCompatibilityLayerVm": "false",
6+
"licenseType": "",
7+
"location": "westus2",
8+
"name": "aks-nodepool1-25781205-vmss_0",
9+
"offer": "",
10+
"osProfile": {
11+
"adminUsername": "azureuser",
12+
"computerName": "aks-nodepool1-25781205-vmss000000",
13+
"disablePasswordAuthentication": "true"
14+
},
15+
"osType": "Linux",
16+
"placementGroupId": "078e7cc3-c76f-46d2-91ff-64c4dbdd0d7c",
17+
"plan": {
18+
"name": "",
19+
"product": "",
20+
"publisher": ""
21+
},
22+
"platformFaultDomain": "0",
23+
"platformUpdateDomain": "0",
24+
"priority": "",
25+
"provider": "Microsoft.Compute",
26+
"publicKeys": [],
27+
"publisher": "",
28+
"resourceGroupName": "MC_matlong-test-imds_matlong-test-imds_westus2",
29+
"resourceId": "/subscriptions/9b8218f9-902a-4d20-a65c-e98acec5362f/resourceGroups/MC_matlong-test-imds_matlong-test-imds_westus2/providers/Microsoft.Compute/virtualMachineScaleSets/aks-nodepool1-25781205-vmss/virtualMachines/0",
30+
"securityProfile": {
31+
"secureBootEnabled": "false",
32+
"virtualTpmEnabled": "false"
33+
},
34+
"sku": "",
35+
"storageProfile": {
36+
"dataDisks": [],
37+
"imageReference": {
38+
"id": "/subscriptions/109a5e88-712a-48ae-9078-9ca8b3c81345/resourceGroups/AKS-Ubuntu/providers/Microsoft.Compute/galleries/AKSUbuntu/images/2204gen2containerd/versions/202401.09.0",
39+
"offer": "",
40+
"publisher": "",
41+
"sku": "",
42+
"version": ""
43+
},
44+
"osDisk": {
45+
"caching": "ReadWrite",
46+
"createOption": "FromImage",
47+
"diffDiskSettings": {
48+
"option": ""
49+
},
50+
"diskSizeGB": "128",
51+
"encryptionSettings": {
52+
"enabled": "false"
53+
},
54+
"image": {
55+
"uri": ""
56+
},
57+
"managedDisk": {
58+
"id": "/subscriptions/9b8218f9-902a-4d20-a65c-e98acec5362f/resourceGroups/MC_matlong-test-imds_matlong-test-imds_westus2/providers/Microsoft.Compute/disks/aks-nodepool1-257812aks-nodepool1-2578120disk1_7f5fbaaac4d7480b9f78b5da0206643c",
59+
"storageAccountType": "Premium_LRS"
60+
},
61+
"name": "aks-nodepool1-257812aks-nodepool1-2578120disk1_7f5fbaaac4d7480b9f78b5da0206643c",
62+
"osType": "Linux",
63+
"vhd": {
64+
"uri": ""
65+
},
66+
"writeAcceleratorEnabled": "false"
67+
},
68+
"resourceDisk": {
69+
"size": "14336"
70+
}
71+
},
72+
"subscriptionId": "9b8218f9-902a-4d20-a65c-e98acec5362f",
73+
"tags": "aks-managed-azure-cni-overlay:true;aks-managed-consolidated-additional-properties:d9dfbd53-b974-11ee-bba9-b29ebea78e50;aks-managed-coordination:true;aks-managed-createOperationID:f9031ed6-4bd9-47e8-908f-90c6429c0790;aks-managed-creationSource:vmssclient-aks-nodepool1-25781205-vmss;aks-managed-kubeletIdentityClientID:58ada700-3bba-437e-b6a2-71df9a5148b5;aks-managed-orchestrator:Kubernetes:1.27.7;aks-managed-poolName:nodepool1;aks-managed-resourceNameSuffix:22973824;aks-managed-ssh-access:LocalUser;azsecpack:nonprod;platformsettings.host_environment.service.platform_optedin_for_rootcerts:true",
74+
"tagsList": [
75+
{
76+
"name": "aks-managed-azure-cni-overlay",
77+
"value": "true"
78+
},
79+
{
80+
"name": "aks-managed-consolidated-additional-properties",
81+
"value": "d9dfbd53-b974-11ee-bba9-b29ebea78e50"
82+
},
83+
{
84+
"name": "aks-managed-coordination",
85+
"value": "true"
86+
},
87+
{
88+
"name": "aks-managed-createOperationID",
89+
"value": "f9031ed6-4bd9-47e8-908f-90c6429c0790"
90+
},
91+
{
92+
"name": "aks-managed-creationSource",
93+
"value": "vmssclient-aks-nodepool1-25781205-vmss"
94+
},
95+
{
96+
"name": "aks-managed-kubeletIdentityClientID",
97+
"value": "58ada700-3bba-437e-b6a2-71df9a5148b5"
98+
},
99+
{
100+
"name": "aks-managed-orchestrator",
101+
"value": "Kubernetes:1.27.7"
102+
},
103+
{
104+
"name": "aks-managed-poolName",
105+
"value": "nodepool1"
106+
},
107+
{
108+
"name": "aks-managed-resourceNameSuffix",
109+
"value": "22973824"
110+
},
111+
{
112+
"name": "aks-managed-ssh-access",
113+
"value": "LocalUser"
114+
},
115+
{
116+
"name": "azsecpack",
117+
"value": "nonprod"
118+
},
119+
{
120+
"name": "platformsettings.host_environment.service.platform_optedin_for_rootcerts",
121+
"value": "true"
122+
}
123+
],
124+
"userData": "",
125+
"version": "202401.09.0",
126+
"vmId": "55b8499d-9b42-4f85-843f-24ff69f4a643",
127+
"vmScaleSetName": "aks-nodepool1-25781205-vmss",
128+
"vmSize": "Standard_DS2_v2",
129+
"zone": ""
130+
}

0 commit comments

Comments
 (0)