diff --git a/README.md b/README.md index 6205180..0bb2d63 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,93 @@ This package allows Go projects to easily interact with the [Linode Metadata Service](https://www.linode.com/docs/products/compute/compute-instances/guides/metadata/?tabs=linode-api). +## Getting Started + +### Prerequisites + +- Go >= 1.20 +- A running [Linode Instance](https://www.linode.com/docs/api/linode-instances/) + +### Installation + +```bash +go get github.com/linode/go-metadata +``` + +### Basic Example + +The follow sample shows a simple Go project that initializes a new metadata client and retrieves various information +about the current Linode. + +```go +package main + +import ( + "context" + "fmt" + "log" + + metadata "github.com/linode/go-metadata" +) + +func main() { + // Create a new client + client, err := metadata.NewClient(context.Background()) + if err != nil { + log.Fatal(err) + } + + // Retrieve metadata about the current instance from the metadata API + instanceInfo, err := client.GetInstance(context.Background()) + if err != nil { + log.Fatal(err) + } + + fmt.Println("Instance Label:", instanceInfo.Label) +} +``` + +### Without Token Management + +By default, metadata API tokens are automatically generated and refreshed without any user intervention. +If you would like to manage API tokens yourself, this functionality can be disabled: + +```go +package main + +import ( + "context" + "fmt" + "log" + "os" + + metadata "github.com/linode/go-metadata" +) + +func main() { + // Get a token from the environment + token := os.Getenv("LINODE_METADATA_TOKEN") + + // Create a new client + client, err := metadata.NewClient( + context.Background(), + metadata.ClientWithoutManagedToken(), + metadata.ClientWithToken(token), + ) + if err != nil { + log.Fatal(err) + } + + // Retrieve metadata about the current instance from the metadata API + instanceInfo, err := client.GetInstance(context.Background()) + if err != nil { + log.Fatal(err) + } + + fmt.Println("Instance Label:", instanceInfo.Label) +} +``` + ## Documentation See [godoc](https://pkg.go.dev/github.com/linode/go-metadata) for a complete documentation reference. diff --git a/client.go b/client.go index e2f9175..0879c4c 100644 --- a/client.go +++ b/client.go @@ -9,23 +9,13 @@ import ( "os" "path" "strconv" + "time" ) const APIHost = "169.254.169.254" const APIProto = "http" const APIVersion = "v1" -type ClientCreateOptions struct { - HTTPClient *http.Client - - BaseURLOverride string - VersionOverride string - - UserAgentPrefix string - - DisableTokenInit bool -} - type Client struct { resty *resty.Client @@ -33,35 +23,49 @@ type Client struct { apiProtocol string apiVersion string userAgent string + + managedToken bool + managedTokenOpts []TokenOption + managedTokenExpiry time.Time } -func NewClient(ctx context.Context, opts *ClientCreateOptions) (*Client, error) { +// NewClient creates a new Metadata API client configured +// with the given options. +func NewClient(ctx context.Context, opts ...ClientOption) (*Client, error) { + clientOpts := clientCreateConfig{ + HTTPClient: nil, + BaseURLOverride: "", + VersionOverride: "", + UserAgentPrefix: "", + ManagedToken: true, + StartingToken: "", + } + + for _, opt := range opts { + opt(&clientOpts) + } + var result Client - shouldUseHTTPClient := false - shouldSkipTokenGeneration := false - // We might need to move the version to a subpackage to prevent a cyclic dependency - userAgent := DefaultUserAgent + result.managedToken = clientOpts.ManagedToken + result.managedTokenOpts = clientOpts.ManagedTokenOpts - if opts != nil { - shouldUseHTTPClient = opts.HTTPClient != nil - shouldSkipTokenGeneration = opts.DisableTokenInit + userAgent := DefaultUserAgent - if opts.BaseURLOverride != "" { - result.SetBaseURL(opts.BaseURLOverride) - } + if clientOpts.BaseURLOverride != "" { + result.SetBaseURL(clientOpts.BaseURLOverride) + } - if opts.VersionOverride != "" { - result.SetVersion(opts.VersionOverride) - } + if clientOpts.VersionOverride != "" { + result.SetVersion(clientOpts.VersionOverride) + } - if opts.UserAgentPrefix != "" { - userAgent = fmt.Sprintf("%s %s", opts.UserAgentPrefix, userAgent) - } + if clientOpts.UserAgentPrefix != "" { + userAgent = fmt.Sprintf("%s %s", clientOpts.UserAgentPrefix, userAgent) } - if shouldUseHTTPClient { - result.resty = resty.NewWithClient(opts.HTTPClient) + if clientOpts.HTTPClient != nil { + result.resty = resty.NewWithClient(clientOpts.HTTPClient) } else { result.resty = resty.New() } @@ -76,33 +80,22 @@ func NewClient(ctx context.Context, opts *ClientCreateOptions) (*Client, error) result.updateHostURL() - result.SetUserAgent(userAgent) + result.setUserAgent(userAgent) - if !shouldSkipTokenGeneration { - if _, err := result.RefreshToken(ctx); err != nil { + if clientOpts.ManagedToken && clientOpts.StartingToken == "" { + if _, err := result.RefreshToken(ctx, result.managedTokenOpts...); err != nil { return nil, fmt.Errorf("failed to refresh metadata token: %s", err) } + } else if clientOpts.StartingToken != "" { + result.UseToken(clientOpts.StartingToken) } - return &result, nil -} + result.resty.OnBeforeRequest(result.middlewareTokenRefresh) -func (c *Client) UseToken(token string) *Client { - c.resty.SetHeader("Metadata-Token", token) - return c -} - -func (c *Client) RefreshToken(ctx context.Context) (*Client, error) { - token, err := c.GenerateToken(ctx, GenerateTokenOptions{}) - if err != nil { - return nil, fmt.Errorf("failed to generate metadata token: %w", err) - } - - c.UseToken(token.Token) - - return c, nil + return &result, nil } +// SetBaseURL configures the target URL for metadata API this client accesses. func (c *Client) SetBaseURL(baseURL string) *Client { baseURLPath, _ := url.Parse(baseURL) @@ -114,6 +107,7 @@ func (c *Client) SetBaseURL(baseURL string) *Client { return c } +// SetVersion configures the target metadata API version for this client. func (c *Client) SetVersion(version string) *Client { c.apiVersion = version @@ -142,17 +136,118 @@ func (c *Client) updateHostURL() { c.resty.SetBaseURL(fmt.Sprintf("%s://%s/%s", apiProto, baseURL, apiVersion)) } +// middlewareTokenRefresh handles automatically refreshing managed tokens. +func (c *Client) middlewareTokenRefresh(rc *resty.Client, r *resty.Request) error { + // Don't run this middleware when generating tokens + if r.URL == "token" { + return nil + } + + if !c.managedToken || time.Now().Before(c.managedTokenExpiry) { + return nil + } + + // Token needs to be refreshed + if _, err := c.RefreshToken(r.Context(), c.managedTokenOpts...); err != nil { + return err + } + + return nil +} + // R wraps resty's R method func (c *Client) R(ctx context.Context) *resty.Request { return c.resty.R(). ExpectContentType("application/json"). SetHeader("Content-Type", "application/json"). - SetContext(ctx) + SetContext(ctx). + SetError(APIError{}) } -func (c *Client) SetUserAgent(userAgent string) *Client { +func (c *Client) setUserAgent(userAgent string) *Client { c.userAgent = userAgent c.resty.SetHeader("User-Agent", c.userAgent) return c } + +type clientCreateConfig struct { + HTTPClient *http.Client + + BaseURLOverride string + VersionOverride string + + UserAgentPrefix string + + ManagedToken bool + ManagedTokenOpts []TokenOption + + StartingToken string +} + +// ClientOption is an option that can be used +// during client creation. +type ClientOption func(options *clientCreateConfig) + +// ClientWithHTTPClient configures the underlying HTTP client +// to communicate with the Metadata API. +func ClientWithHTTPClient(client *http.Client) ClientOption { + return func(options *clientCreateConfig) { + options.HTTPClient = client + } +} + +// ClientWithBaseURL configures the target host of the +// Metadata API this client points to. +// Default: "169.254.169.254" +func ClientWithBaseURL(baseURL string) ClientOption { + return func(options *clientCreateConfig) { + options.BaseURLOverride = baseURL + } +} + +// ClientWithVersion configures the Metadata API version this +// client should target. +// Default: "v1" +func ClientWithVersion(version string) ClientOption { + return func(options *clientCreateConfig) { + options.VersionOverride = version + } +} + +// ClientWithUAPrefix configures the prefix for user agents +// on API requests made by this client. +func ClientWithUAPrefix(uaPrefix string) ClientOption { + return func(options *clientCreateConfig) { + options.UserAgentPrefix = uaPrefix + } +} + +// ClientWithManagedToken configures the metadata client +// to automatically generate and refresh the API token +// for the Metadata client. +func ClientWithManagedToken(opts ...TokenOption) ClientOption { + return func(options *clientCreateConfig) { + options.ManagedToken = true + options.ManagedTokenOpts = opts + } +} + +// ClientWithoutManagedToken configures the metadata client +// to disable automatic token management. +func ClientWithoutManagedToken() ClientOption { + return func(options *clientCreateConfig) { + options.ManagedToken = false + } +} + +// ClientWithToken configures the starting token +// for the metadata client. +// If this option is specified and managed tokens +// are enabled for a client, the client will not +// generate an initial Metadata API token. +func ClientWithToken(token string) ClientOption { + return func(options *clientCreateConfig) { + options.StartingToken = token + } +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..5c1a36d --- /dev/null +++ b/errors.go @@ -0,0 +1,73 @@ +package metadata + +import ( + "fmt" + "github.com/go-resty/resty/v2" + "net/http" + "strings" +) + +// APIError is the error-set returned by the Linode API when presented with an invalid request +type APIError struct { + Errors []APIErrorReason `json:"errors"` +} + +func (e APIError) Error() string { + result := make([]string, len(e.Errors)) + + for i, msg := range e.Errors { + result[i] = msg.Error() + } + + return strings.Join(result, "; ") +} + +// APIErrorReason is an individual invalid request message returned by the Linode API +type APIErrorReason struct { + Reason string `json:"reason"` + Field string `json:"field"` +} + +func (r APIErrorReason) Error() string { + if len(r.Field) == 0 { + return r.Reason + } + + return fmt.Sprintf("[%s] %s", r.Field, r.Reason) +} + +// Error wraps the LinodeGo error with the relevant http.Response +type Error struct { + Response *http.Response + Code int + Message string +} + +func (g Error) Error() string { + return fmt.Sprintf("[%03d] %s", g.Code, g.Message) +} + +func coupleAPIErrors(r *resty.Response, err error) (*resty.Response, error) { + if err != nil { + return nil, Error{Message: err.Error()} + } + + if r.Error() == nil { + return r, nil + } + + apiError, ok := r.Error().(*APIError) + if !ok { + return nil, fmt.Errorf("returned error type is not *APIError") + } + + if len(apiError.Errors) == 0 { + return r, nil + } + + return nil, &Error{ + Code: r.RawResponse.StatusCode, + Message: apiError.Error(), + Response: r.RawResponse, + } +} diff --git a/hack/run-e2e.yml b/hack/run-e2e.yml index 26a2d2c..38a82a2 100644 --- a/hack/run-e2e.yml +++ b/hack/run-e2e.yml @@ -33,6 +33,8 @@ booted: true authorized_keys: - "{{ lookup('file', ssh_pubkey_path) }}" + metadata: + user_data: foobar state: present register: create_inst diff --git a/instance.go b/instance.go index e61e527..4f9cf2c 100644 --- a/instance.go +++ b/instance.go @@ -26,10 +26,11 @@ type InstanceData struct { Backups InstanceBackupsData `json:"backups"` } +// GetInstance gets various information about the current instance. func (c *Client) GetInstance(ctx context.Context) (*InstanceData, error) { req := c.R(ctx).SetResult(&InstanceData{}) - resp, err := req.Get("instance") + resp, err := coupleAPIErrors(req.Get("instance")) if err != nil { return nil, err } diff --git a/network.go b/network.go index 27038f8..bba7710 100644 --- a/network.go +++ b/network.go @@ -5,7 +5,7 @@ import ( "net/netip" ) -type VLANData struct { +type InterfaceData struct { Label string `json:"label"` Purpose string `json:"purpose"` IPAMAddress netip.Prefix `json:"ipam_address"` @@ -25,15 +25,15 @@ type IPv6Data struct { } type NetworkData struct { - VLANs []VLANData `json:"vlans"` - IPv4 IPv4Data `json:"ipv4"` - IPv6 IPv6Data `json:"ipv6"` + Interfaces []InterfaceData `json:"interfaces"` + IPv4 IPv4Data `json:"ipv4"` + IPv6 IPv6Data `json:"ipv6"` } func (c *Client) GetNetwork(ctx context.Context) (*NetworkData, error) { req := c.R(ctx).SetResult(&NetworkData{}) - resp, err := req.Get("network") + resp, err := coupleAPIErrors(req.Get("network")) if err != nil { return nil, err } diff --git a/sshkeys.go b/sshkeys.go index 848aed4..e8c8f86 100644 --- a/sshkeys.go +++ b/sshkeys.go @@ -10,10 +10,11 @@ type SSHKeysData struct { Users SSHKeysUserData `json:"users"` } +// GetSSHKeys gets all SSH keys for the current instance. func (c *Client) GetSSHKeys(ctx context.Context) (*SSHKeysData, error) { req := c.R(ctx).SetResult(&SSHKeysData{}) - resp, err := req.Get("ssh-keys") + resp, err := coupleAPIErrors(req.Get("ssh-keys")) if err != nil { return nil, err } diff --git a/test/integration/Makefile b/test/integration/Makefile index ffa01fc..721f718 100644 --- a/test/integration/Makefile +++ b/test/integration/Makefile @@ -1,2 +1,2 @@ e2e-local: - go test -v --tags=integration ./... + go test -v ./... diff --git a/test/integration/client_test.go b/test/integration/client_test.go new file mode 100644 index 0000000..10620e4 --- /dev/null +++ b/test/integration/client_test.go @@ -0,0 +1,44 @@ +package integration + +import ( + "context" + "github.com/linode/go-metadata" + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +func TestClient_UnmanagedTokenExpired(t *testing.T) { + mdClient, err := metadata.NewClient( + context.Background(), + metadata.ClientWithoutManagedToken(), + ) + assert.NoError(t, err) + + _, err = mdClient.RefreshToken( + context.Background(), + metadata.TokenWithExpiry(1), + ) + assert.NoError(t, err) + + // Hack to wait for token expiry + time.Sleep(time.Second * 2) + + // We expect this to fail because the token has expired + _, err = mdClient.GetInstance(context.Background()) + assert.Error(t, err) +} + +func TestClient_ManagedTokenRefresh(t *testing.T) { + mdClient, err := metadata.NewClient(context.Background(), metadata.ClientWithManagedToken( + metadata.TokenWithExpiry(1), + )) + assert.NoError(t, err) + + // Hack to wait for token expiry + time.Sleep(time.Second * 2) + + // Token should have automatically refreshed + _, err = mdClient.GetInstance(context.Background()) + assert.NoError(t, err) +} diff --git a/test/integration/helper_e2e_test.go b/test/integration/helper_test.go similarity index 85% rename from test/integration/helper_e2e_test.go rename to test/integration/helper_test.go index 96aa837..6c16260 100644 --- a/test/integration/helper_e2e_test.go +++ b/test/integration/helper_test.go @@ -1,5 +1,3 @@ -//go:build integration - package integration import ( @@ -19,7 +17,9 @@ func init() { log.Fatal("LINODE_TOKEN must be specified to run the E2E test suite") } - mdsClient, err := metadata.NewClient(context.Background(), nil) + mdsClient, err := metadata.NewClient( + context.Background(), + ) if err != nil { log.Fatalf("failed to create client: %s", err) } diff --git a/test/integration/instance_e2e_test.go b/test/integration/instance_test.go similarity index 91% rename from test/integration/instance_e2e_test.go rename to test/integration/instance_test.go index b8bbc93..031952b 100644 --- a/test/integration/instance_e2e_test.go +++ b/test/integration/instance_test.go @@ -1,5 +1,3 @@ -//go:build integration - package integration import ( @@ -9,6 +7,8 @@ import ( ) func TestGetInstance(t *testing.T) { + t.Parallel() + mdInst, err := metadataClient.GetInstance(context.Background()) assert.NoError(t, err) @@ -21,7 +21,6 @@ func TestGetInstance(t *testing.T) { assert.Equal(t, apiInst.Region, mdInst.Region) assert.Equal(t, apiInst.Type, mdInst.Type) assert.Equal(t, apiInst.Tags, mdInst.Tags) - assert.Equal(t, apiInst.HostUUID, mdInst.HostUUID) assert.Equal(t, apiInst.Specs.Disk, mdInst.Specs.Disk) assert.Equal(t, apiInst.Specs.Memory, mdInst.Specs.Memory) diff --git a/test/integration/userdata_test.go b/test/integration/userdata_test.go new file mode 100644 index 0000000..9674b65 --- /dev/null +++ b/test/integration/userdata_test.go @@ -0,0 +1,14 @@ +package integration + +import ( + "context" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestGetUserData(t *testing.T) { + t.Parallel() + + _, err := metadataClient.GetUserData(context.Background()) + assert.NoError(t, err) +} diff --git a/token.go b/token.go index 56beddf..e69ff12 100644 --- a/token.go +++ b/token.go @@ -2,46 +2,72 @@ package metadata import ( "context" - "math" + "fmt" "strconv" "time" ) -type GenerateTokenOptions struct { - ExpirySeconds int +type TokenOption func(opts *tokenCreateOpts) + +// TokenWithExpiry configures the expiry in seconds for a token. +// Default: 3600 +func TokenWithExpiry(seconds int) TokenOption { + return func(opts *tokenCreateOpts) { + opts.ExpirySeconds = seconds + } } -type TokenData struct { - Token string `json:"token"` +type tokenCreateOpts struct { ExpirySeconds int - Created time.Time } -func (t *TokenData) IsExpired() bool { - return int(math.Ceil(time.Since(t.Created).Seconds())) > t.ExpirySeconds -} +// GenerateToken generates a token to access the Metadata API. +func (c *Client) GenerateToken(ctx context.Context, opts ...TokenOption) (string, error) { + // Handle create options + createOpts := tokenCreateOpts{ + ExpirySeconds: 3600, + } + + for _, opt := range opts { + opt(&createOpts) + } -func (c *Client) GenerateToken(ctx context.Context, opts GenerateTokenOptions) (*TokenData, error) { - // Temporary override so things don't break req := c.R(ctx). - ExpectContentType("text/plain"). - SetHeader("Content-Type", "text/plain") + SetResult(&[]string{}). + SetHeader("Metadata-Token-Expiry-Seconds", strconv.Itoa(createOpts.ExpirySeconds)) - tokenExpirySeconds := 3600 - if opts.ExpirySeconds != 0 { - tokenExpirySeconds = opts.ExpirySeconds + resp, err := req.Put("token") + if err != nil { + return "", err } - req.SetHeader("Metadata-Token-Expiry-Seconds", strconv.Itoa(tokenExpirySeconds)) + result := *resp.Result().(*[]string) - resp, err := req.Put("token") + if len(result) < 1 { + return "", fmt.Errorf("no token returned from API") + } + + return result[0], nil +} + +// UseToken applies the given token to this Metadata client. +func (c *Client) UseToken(token string) *Client { + c.resty.SetHeader("Metadata-Token", token) + return c +} + +// RefreshToken generates and applies a new token for this client. +func (c *Client) RefreshToken(ctx context.Context, opts ...TokenOption) (*Client, error) { + creationTime := time.Now() + + token, err := c.GenerateToken(ctx, opts...) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to generate metadata token: %w", err) } - return &TokenData{ - Token: resp.String(), - ExpirySeconds: tokenExpirySeconds, - Created: time.Now(), - }, nil + c.UseToken(token) + + c.managedTokenExpiry = creationTime + + return c, nil } diff --git a/userdata.go b/userdata.go index 3196e51..dcc666e 100644 --- a/userdata.go +++ b/userdata.go @@ -1,17 +1,26 @@ package metadata -import "context" +import ( + "context" + "encoding/base64" + "fmt" +) +// GetUserData returns the user data for the current instance. +// NOTE: The result of this endpoint is automatically decoded from base64. func (c *Client) GetUserData(ctx context.Context) (string, error) { - // Getting user-data requires the text/plain content type - req := c.R(ctx). - ExpectContentType("text/plain"). - SetHeader("Content-Type", "text/plain") + req := c.R(ctx) - resp, err := req.Get("user-data") + resp, err := coupleAPIErrors(req.Get("user-data")) if err != nil { return "", err } - return resp.String(), nil + // user-data is returned as a raw string + decodedBytes, err := base64.StdEncoding.DecodeString(resp.String()) + if err != nil { + return "", fmt.Errorf("failed to decode user-data: %w", err) + } + + return string(decodedBytes), nil }