github.com/hairyhenderson/gomplate/v4@v4.0.0-pre-2.0.20240520121557-362f058f0c93/gcp/meta.go (about)

     1  package gcp
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"io"
     7  	"net/http"
     8  	"strconv"
     9  	"strings"
    10  	"sync"
    11  	"time"
    12  
    13  	"github.com/hairyhenderson/gomplate/v4/env"
    14  )
    15  
    16  // DefaultEndpoint is the DNS name for the default GCP compute instance metadata service.
    17  var DefaultEndpoint = "http://metadata.google.internal"
    18  
    19  var (
    20  	// co is a ClientOptions populated from the environment.
    21  	co ClientOptions
    22  	// coInit ensures that `co` is only set once.
    23  	coInit sync.Once
    24  )
    25  
    26  // ClientOptions contains various user-specifiable options for a MetaClient.
    27  type ClientOptions struct {
    28  	Timeout time.Duration
    29  }
    30  
    31  // GetClientOptions - Centralised reading of GCP_TIMEOUT
    32  // ... but cannot use in vault/auth.go as different strconv.Atoi error handling
    33  func GetClientOptions() ClientOptions {
    34  	coInit.Do(func() {
    35  		timeout := env.Getenv("GCP_TIMEOUT")
    36  		if timeout == "" {
    37  			timeout = "500"
    38  		}
    39  
    40  		t, err := strconv.Atoi(timeout)
    41  		if err != nil {
    42  			panic(fmt.Errorf("invalid GCP_TIMEOUT value '%s' - must be an integer: %w", timeout, err))
    43  		}
    44  
    45  		co.Timeout = time.Duration(t) * time.Millisecond
    46  	})
    47  	return co
    48  }
    49  
    50  // MetaClient is used to access metadata accessible via the GCP compute instance
    51  // metadata service version 1.
    52  type MetaClient struct {
    53  	ctx      context.Context
    54  	client   *http.Client
    55  	cache    map[string]string
    56  	endpoint string
    57  	options  ClientOptions
    58  }
    59  
    60  // NewMetaClient constructs a new MetaClient with the given ClientOptions. If the environment
    61  // contains a variable named `GCP_META_ENDPOINT`, the client will address that, if not the
    62  // value of `DefaultEndpoint` is used.
    63  func NewMetaClient(ctx context.Context, options ClientOptions) *MetaClient {
    64  	endpoint := env.Getenv("GCP_META_ENDPOINT")
    65  	if endpoint == "" {
    66  		endpoint = DefaultEndpoint
    67  	}
    68  
    69  	return &MetaClient{
    70  		ctx:      ctx,
    71  		cache:    make(map[string]string),
    72  		endpoint: endpoint,
    73  		options:  options,
    74  	}
    75  }
    76  
    77  // Meta retrieves a value from the GCP Instance Metadata Service, returning the given default
    78  // if the service is unavailable or the requested URL does not exist.
    79  func (c *MetaClient) Meta(key string, def ...string) (string, error) {
    80  	url := c.endpoint + "/computeMetadata/v1/instance/" + key
    81  	return c.retrieveMetadata(c.ctx, url, def...)
    82  }
    83  
    84  // retrieveMetadata executes an HTTP request to the GCP Instance Metadata Service with the
    85  // correct headers set, and extracts the returned value.
    86  func (c *MetaClient) retrieveMetadata(ctx context.Context, url string, def ...string) (string, error) {
    87  	if value, ok := c.cache[url]; ok {
    88  		return value, nil
    89  	}
    90  
    91  	if c.client == nil {
    92  		timeout := c.options.Timeout
    93  		if timeout == 0 {
    94  			timeout = 500 * time.Millisecond
    95  		}
    96  		c.client = &http.Client{Timeout: timeout}
    97  	}
    98  
    99  	request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
   100  	if err != nil {
   101  		return returnDefault(def), nil
   102  	}
   103  	request.Header.Add("Metadata-Flavor", "Google")
   104  
   105  	resp, err := c.client.Do(request)
   106  	if err != nil {
   107  		return returnDefault(def), nil
   108  	}
   109  
   110  	defer resp.Body.Close()
   111  	if resp.StatusCode > 399 {
   112  		return returnDefault(def), nil
   113  	}
   114  
   115  	body, err := io.ReadAll(resp.Body)
   116  	if err != nil {
   117  		return "", fmt.Errorf("failed to read response body from %s: %w", url, err)
   118  	}
   119  	value := strings.TrimSpace(string(body))
   120  	c.cache[url] = value
   121  
   122  	return value, nil
   123  }
   124  
   125  // returnDefault returns the first element of the given slice (often taken from varargs)
   126  // if there is one, or returns an empty string if the slice has no elements.
   127  func returnDefault(def []string) string {
   128  	if len(def) > 0 {
   129  		return def[0]
   130  	}
   131  	return ""
   132  }