summaryrefslogtreecommitdiff
path: root/gcp/meta.go
blob: 872c9359f4334f1e792de9f68840e231dd4797af (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
package gcp

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/hairyhenderson/gomplate/v4/env"
)

// DefaultEndpoint is the DNS name for the default GCP compute instance metadata service.
var DefaultEndpoint = "http://metadata.google.internal"

var (
	// co is a ClientOptions populated from the environment.
	co ClientOptions
	// coInit ensures that `co` is only set once.
	coInit sync.Once
)

// ClientOptions contains various user-specifiable options for a MetaClient.
type ClientOptions struct {
	Timeout time.Duration
}

// GetClientOptions - Centralised reading of GCP_TIMEOUT
// ... but cannot use in vault/auth.go as different strconv.Atoi error handling
func GetClientOptions() ClientOptions {
	coInit.Do(func() {
		timeout := env.Getenv("GCP_TIMEOUT")
		if timeout == "" {
			timeout = "500"
		}

		t, err := strconv.Atoi(timeout)
		if err != nil {
			panic(fmt.Errorf("invalid GCP_TIMEOUT value '%s' - must be an integer: %w", timeout, err))
		}

		co.Timeout = time.Duration(t) * time.Millisecond
	})
	return co
}

// MetaClient is used to access metadata accessible via the GCP compute instance
// metadata service version 1.
type MetaClient struct {
	ctx      context.Context
	client   *http.Client
	cache    map[string]string
	endpoint string
	options  ClientOptions
}

// NewMetaClient constructs a new MetaClient with the given ClientOptions. If the environment
// contains a variable named `GCP_META_ENDPOINT`, the client will address that, if not the
// value of `DefaultEndpoint` is used.
func NewMetaClient(ctx context.Context, options ClientOptions) *MetaClient {
	endpoint := env.Getenv("GCP_META_ENDPOINT")
	if endpoint == "" {
		endpoint = DefaultEndpoint
	}

	return &MetaClient{
		ctx:      ctx,
		cache:    make(map[string]string),
		endpoint: endpoint,
		options:  options,
	}
}

// Meta retrieves a value from the GCP Instance Metadata Service, returning the given default
// if the service is unavailable or the requested URL does not exist.
func (c *MetaClient) Meta(key string, def ...string) (string, error) {
	url := c.endpoint + "/computeMetadata/v1/instance/" + key
	return c.retrieveMetadata(c.ctx, url, def...)
}

// retrieveMetadata executes an HTTP request to the GCP Instance Metadata Service with the
// correct headers set, and extracts the returned value.
func (c *MetaClient) retrieveMetadata(ctx context.Context, url string, def ...string) (string, error) {
	if value, ok := c.cache[url]; ok {
		return value, nil
	}

	if c.client == nil {
		timeout := c.options.Timeout
		if timeout == 0 {
			timeout = 500 * time.Millisecond
		}
		c.client = &http.Client{Timeout: timeout}
	}

	request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return returnDefault(def), nil
	}
	request.Header.Add("Metadata-Flavor", "Google")

	resp, err := c.client.Do(request)
	if err != nil {
		return returnDefault(def), nil
	}

	defer resp.Body.Close()
	if resp.StatusCode > 399 {
		return returnDefault(def), nil
	}

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", fmt.Errorf("failed to read response body from %s: %w", url, err)
	}
	value := strings.TrimSpace(string(body))
	c.cache[url] = value

	return value, nil
}

// returnDefault returns the first element of the given slice (often taken from varargs)
// if there is one, or returns an empty string if the slice has no elements.
func returnDefault(def []string) string {
	if len(def) > 0 {
		return def[0]
	}
	return ""
}