Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add imds client #2537

Merged
merged 2 commits into from
Jan 24, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions cns/imds/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright 2024 Microsoft. All rights reserved.
// MIT License

package imds

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"

"github.com/avast/retry-go/v4"
"github.com/pkg/errors"
)

// see docs for IMDS here: https://learn.microsoft.com/en-us/azure/virtual-machines/instance-metadata-service

// Client returns metadata about the VM by querying IMDS
type Client struct {
cli *http.Client
config clientConfig
}

// clientConfig holds config options for a Client
type clientConfig struct {
endpoint string
retryAttempts uint
}

type clientOption func(*clientConfig)

// Endpoint overrides the default endpoint for a Client
func Endpoint(url string) clientOption {

Check failure on line 34 in cns/imds/client.go

View workflow job for this annotation

GitHub Actions / Lint (1.21.x, ubuntu-latest)

importShadow: shadow of imported package 'url' (gocritic)

Check failure on line 34 in cns/imds/client.go

View workflow job for this annotation

GitHub Actions / Lint (1.21.x, windows-latest)

importShadow: shadow of imported package 'url' (gocritic)
return func(c *clientConfig) {
c.endpoint = url
}
}

// RetryAttempts overrides the default retry attempts for the client
func RetryAttempts(attempts uint) clientOption {

Check warning on line 41 in cns/imds/client.go

View workflow job for this annotation

GitHub Actions / Lint (1.21.x, ubuntu-latest)

unexported-return: exported func RetryAttempts returns unexported type imds.clientOption, which can be annoying to use (revive)

Check warning on line 41 in cns/imds/client.go

View workflow job for this annotation

GitHub Actions / Lint (1.21.x, windows-latest)

unexported-return: exported func RetryAttempts returns unexported type imds.clientOption, which can be annoying to use (revive)
return func(c *clientConfig) {
c.retryAttempts = attempts
}
}

const (
vmUniqueIDProperty = "vmId"
imdsComputePath = "/metadata/instance/compute?api-version=2021-01-01&format=json"
metadataHeaderKey = "Metadata"
metadataHeaderValue = "true"
defaultRetryAttempts = 10
defaultIMDSEndpoint = "http://169.254.169.254"
)

var ErrVMUniqueIDNotFound = errors.New("vm unique ID not found")

// NewClient creates a new imds client
func NewClient(opts ...clientOption) *Client {
config := clientConfig{
endpoint: defaultIMDSEndpoint,
}

for _, o := range opts {
o(&config)
}

return &Client{
cli: &http.Client{},
config: config,
}
}

func (c *Client) GetVMUniqueID(ctx context.Context) (string, error) {
var vmUniqueID string
err := retry.Do(func() error {
computeDoc, err := c.getInstanceComputeMetadata(ctx)
if err != nil {
return errors.Wrap(err, "error getting IMDS compute metadata")
}
vmUniqueIDUntyped := computeDoc[vmUniqueIDProperty]
var ok bool
vmUniqueID, ok = vmUniqueIDUntyped.(string)
if !ok {
return errors.New("unable to parse IMDS compute metadata, vmId property is not a string")
}
return nil
}, retry.Context(ctx), retry.Attempts(c.config.retryAttempts), retry.DelayType(retry.BackOffDelay))
if err != nil {
return "", errors.Wrap(err, "exhausted retries querying IMDS compute metadata")
}

if vmUniqueID == "" {
return "", ErrVMUniqueIDNotFound
}

return vmUniqueID, nil
}

func (c *Client) getInstanceComputeMetadata(ctx context.Context) (map[string]any, error) {
url, err := url.JoinPath(c.config.endpoint, imdsComputePath)

Check failure on line 101 in cns/imds/client.go

View workflow job for this annotation

GitHub Actions / Lint (1.21.x, ubuntu-latest)

importShadow: shadow of imported package 'url' (gocritic)

Check failure on line 101 in cns/imds/client.go

View workflow job for this annotation

GitHub Actions / Lint (1.21.x, windows-latest)

importShadow: shadow of imported package 'url' (gocritic)
if err != nil {
return nil, errors.Wrap(err, "unable to build path to IMDS compute metadata")
}

req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)

Check failure on line 106 in cns/imds/client.go

View workflow job for this annotation

GitHub Actions / Lint (1.21.x, ubuntu-latest)

httpNoBody: http.NoBody should be preferred to the nil request body (gocritic)

Check failure on line 106 in cns/imds/client.go

View workflow job for this annotation

GitHub Actions / Lint (1.21.x, windows-latest)

httpNoBody: http.NoBody should be preferred to the nil request body (gocritic)
if err != nil {
return nil, errors.Wrap(err, "error building IMDS http request")
}

// IMDS requires the "Metadata: true" header
req.Header.Add(metadataHeaderKey, metadataHeaderValue)

resp, err := c.cli.Do(req)

Check failure on line 114 in cns/imds/client.go

View workflow job for this annotation

GitHub Actions / Lint (1.21.x, ubuntu-latest)

response body must be closed (bodyclose)

Check failure on line 114 in cns/imds/client.go

View workflow job for this annotation

GitHub Actions / Lint (1.21.x, windows-latest)

response body must be closed (bodyclose)
if err != nil {
return nil, errors.Wrap(err, "error querying IMDS")
}

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("received unexpected status code %d from IMDS", resp.StatusCode)

Check failure on line 120 in cns/imds/client.go

View workflow job for this annotation

GitHub Actions / Lint (1.21.x, ubuntu-latest)

err113: do not define dynamic errors, use wrapped static errors instead: "fmt.Errorf(\"received unexpected status code %d from IMDS\", resp.StatusCode)" (goerr113)

Check failure on line 120 in cns/imds/client.go

View workflow job for this annotation

GitHub Actions / Lint (1.21.x, windows-latest)

err113: do not define dynamic errors, use wrapped static errors instead: "fmt.Errorf(\"received unexpected status code %d from IMDS\", resp.StatusCode)" (goerr113)
}

var m map[string]any
if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
return nil, errors.Wrap(err, "error decoding IMDS response as json")
}

return m, nil
}
69 changes: 69 additions & 0 deletions cns/imds/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright 2024 Microsoft. All rights reserved.
// MIT License

package imds_test

import (
"context"
"net/http"
"net/http/httptest"
"os"
"testing"

"github.com/Azure/azure-container-networking/cns/imds"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestGetVMUniqueID(t *testing.T) {
computeMetadata, err := os.ReadFile("testdata/computeMetadata.json")
require.NoError(t, err, "error reading testdata compute metadata file")

mockIMDSServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// request header "Metadata: true" must be present
metadataHeader := r.Header.Get("Metadata")
assert.Equal(t, "true", metadataHeader)
w.WriteHeader(http.StatusOK)
_, err := w.Write(computeMetadata)

Check failure on line 27 in cns/imds/client_test.go

View workflow job for this annotation

GitHub Actions / Lint (1.21.x, ubuntu-latest)

shadow: declaration of "err" shadows declaration at line 19 (govet)

Check failure on line 27 in cns/imds/client_test.go

View workflow job for this annotation

GitHub Actions / Lint (1.21.x, windows-latest)

shadow: declaration of "err" shadows declaration at line 19 (govet)
require.NoError(t, err, "error writing response")
}))
defer mockIMDSServer.Close()

imdsClient := imds.NewClient(imds.Endpoint(mockIMDSServer.URL))
vmUniqueID, err := imdsClient.GetVMUniqueID(context.Background())
require.NoError(t, err, "error querying testserver")

require.Equal(t, "55b8499d-9b42-4f85-843f-24ff69f4a643", vmUniqueID)
}

func TestGetVMUniqueIDInvalidEndpoint(t *testing.T) {
imdsClient := imds.NewClient(imds.Endpoint(string([]byte{0x7f})), imds.RetryAttempts(1))
_, err := imdsClient.GetVMUniqueID(context.Background())
require.Error(t, err, "expected invalid path")
}

func TestIMDSInternalServerError(t *testing.T) {
mockIMDSServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// request header "Metadata: true" must be present
w.WriteHeader(http.StatusInternalServerError)
}))
defer mockIMDSServer.Close()

imdsClient := imds.NewClient(imds.Endpoint(mockIMDSServer.URL), imds.RetryAttempts(1))

_, err := imdsClient.GetVMUniqueID(context.Background())
require.Error(t, err, "expected internal server error")
}

func TestIMDSInvalidJSON(t *testing.T) {
mockIMDSServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("not json"))

Check failure on line 61 in cns/imds/client_test.go

View workflow job for this annotation

GitHub Actions / Lint (1.21.x, ubuntu-latest)

Error return value of `w.Write` is not checked (errcheck)

Check failure on line 61 in cns/imds/client_test.go

View workflow job for this annotation

GitHub Actions / Lint (1.21.x, windows-latest)

Error return value of `w.Write` is not checked (errcheck)
}))
defer mockIMDSServer.Close()

imdsClient := imds.NewClient(imds.Endpoint(mockIMDSServer.URL), imds.RetryAttempts(1))

_, err := imdsClient.GetVMUniqueID(context.Background())
require.Error(t, err, "expected json decoding error")
}
130 changes: 130 additions & 0 deletions cns/imds/testdata/computeMetadata.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
{
"azEnvironment": "AzurePublicCloud",
"customData": "",
"evictionPolicy": "",
"isHostCompatibilityLayerVm": "false",
"licenseType": "",
"location": "westus2",
"name": "aks-nodepool1-25781205-vmss_0",
"offer": "",
"osProfile": {
"adminUsername": "azureuser",
"computerName": "aks-nodepool1-25781205-vmss000000",
"disablePasswordAuthentication": "true"
},
"osType": "Linux",
"placementGroupId": "078e7cc3-c76f-46d2-91ff-64c4dbdd0d7c",
"plan": {
"name": "",
"product": "",
"publisher": ""
},
"platformFaultDomain": "0",
"platformUpdateDomain": "0",
"priority": "",
"provider": "Microsoft.Compute",
"publicKeys": [],
"publisher": "",
"resourceGroupName": "MC_matlong-test-imds_matlong-test-imds_westus2",
"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",
"securityProfile": {
"secureBootEnabled": "false",
"virtualTpmEnabled": "false"
},
"sku": "",
"storageProfile": {
"dataDisks": [],
"imageReference": {
"id": "/subscriptions/109a5e88-712a-48ae-9078-9ca8b3c81345/resourceGroups/AKS-Ubuntu/providers/Microsoft.Compute/galleries/AKSUbuntu/images/2204gen2containerd/versions/202401.09.0",
"offer": "",
"publisher": "",
"sku": "",
"version": ""
},
"osDisk": {
"caching": "ReadWrite",
"createOption": "FromImage",
"diffDiskSettings": {
"option": ""
},
"diskSizeGB": "128",
"encryptionSettings": {
"enabled": "false"
},
"image": {
"uri": ""
},
"managedDisk": {
"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",
"storageAccountType": "Premium_LRS"
},
"name": "aks-nodepool1-257812aks-nodepool1-2578120disk1_7f5fbaaac4d7480b9f78b5da0206643c",
"osType": "Linux",
"vhd": {
"uri": ""
},
"writeAcceleratorEnabled": "false"
},
"resourceDisk": {
"size": "14336"
}
},
"subscriptionId": "9b8218f9-902a-4d20-a65c-e98acec5362f",
"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",
"tagsList": [
{
"name": "aks-managed-azure-cni-overlay",
"value": "true"
},
{
"name": "aks-managed-consolidated-additional-properties",
"value": "d9dfbd53-b974-11ee-bba9-b29ebea78e50"
},
{
"name": "aks-managed-coordination",
"value": "true"
},
{
"name": "aks-managed-createOperationID",
"value": "f9031ed6-4bd9-47e8-908f-90c6429c0790"
},
{
"name": "aks-managed-creationSource",
"value": "vmssclient-aks-nodepool1-25781205-vmss"
},
{
"name": "aks-managed-kubeletIdentityClientID",
"value": "58ada700-3bba-437e-b6a2-71df9a5148b5"
},
{
"name": "aks-managed-orchestrator",
"value": "Kubernetes:1.27.7"
},
{
"name": "aks-managed-poolName",
"value": "nodepool1"
},
{
"name": "aks-managed-resourceNameSuffix",
"value": "22973824"
},
{
"name": "aks-managed-ssh-access",
"value": "LocalUser"
},
{
"name": "azsecpack",
"value": "nonprod"
},
{
"name": "platformsettings.host_environment.service.platform_optedin_for_rootcerts",
"value": "true"
}
],
"userData": "",
"version": "202401.09.0",
"vmId": "55b8499d-9b42-4f85-843f-24ff69f4a643",
"vmScaleSetName": "aks-nodepool1-25781205-vmss",
"vmSize": "Standard_DS2_v2",
"zone": ""
}
Loading