package linodego import ( "bytes" "context" "encoding/json" "fmt" "io" "log" "net/http" "net/url" "os" "path" "path/filepath" "reflect" "regexp" "strconv" "strings" "sync" "text/template" "time" "github.com/go-resty/resty/v2" ) const ( // APIConfigEnvVar environment var to get path to Linode config APIConfigEnvVar = "LINODE_CONFIG" // APIConfigProfileEnvVar specifies the profile to use when loading from a Linode config APIConfigProfileEnvVar = "LINODE_PROFILE" // APIHost Linode API hostname APIHost = "api.linode.com" // APIHostVar environment var to check for alternate API URL APIHostVar = "LINODE_URL" // APIHostCert environment var containing path to CA cert to validate against. // Note that the custom CA cannot be configured together with a custom HTTP Transport. APIHostCert = "LINODE_CA" // APIVersion Linode API version APIVersion = "v4" // APIVersionVar environment var to check for alternate API Version APIVersionVar = "LINODE_API_VERSION" // APIProto connect to API with http(s) APIProto = "https" // APIEnvVar environment var to check for API token APIEnvVar = "LINODE_TOKEN" // APISecondsPerPoll how frequently to poll for new Events or Status in WaitFor functions APISecondsPerPoll = 3 // APIRetryMaxWaitTime is the maximum wait time for retries APIRetryMaxWaitTime = time.Duration(30) * time.Second APIDefaultCacheExpiration = time.Minute * 15 ) //nolint:unused var ( reqLogTemplate = template.Must(template.New("request").Parse(`Sending request: Method: {{.Method}} URL: {{.URL}} Headers: {{.Headers}} Body: {{.Body}}`)) respLogTemplate = template.Must(template.New("response").Parse(`Received response: Status: {{.Status}} Headers: {{.Headers}} Body: {{.Body}}`)) ) var envDebug = false // Client is a wrapper around the Resty client type Client struct { resty *resty.Client userAgent string debug bool retryConditionals []RetryConditional pollInterval time.Duration baseURL string apiVersion string apiProto string selectedProfile string loadedProfile string configProfiles map[string]ConfigProfile // Fields for caching endpoint responses shouldCache bool cacheExpiration time.Duration cachedEntries map[string]clientCacheEntry cachedEntryLock *sync.RWMutex } type EnvDefaults struct { Token string Profile string } type clientCacheEntry struct { Created time.Time Data any // If != nil, use this instead of the // global expiry ExpiryOverride *time.Duration } type ( Request = resty.Request Response = resty.Response Logger = resty.Logger ) func init() { // Whether we will enable Resty debugging output if apiDebug, ok := os.LookupEnv("LINODE_DEBUG"); ok { if parsed, err := strconv.ParseBool(apiDebug); err == nil { envDebug = parsed log.Println("[INFO] LINODE_DEBUG being set to", envDebug) } else { log.Println("[WARN] LINODE_DEBUG should be an integer, 0 or 1") } } } // NewClient factory to create new Client struct func NewClient(hc *http.Client) (client Client) { if hc != nil { client.resty = resty.NewWithClient(hc) } else { client.resty = resty.New() } client.shouldCache = true client.cacheExpiration = APIDefaultCacheExpiration client.cachedEntries = make(map[string]clientCacheEntry) client.cachedEntryLock = &sync.RWMutex{} client.SetUserAgent(DefaultUserAgent) baseURL, baseURLExists := os.LookupEnv(APIHostVar) if baseURLExists { client.SetBaseURL(baseURL) } apiVersion, apiVersionExists := os.LookupEnv(APIVersionVar) if apiVersionExists { client.SetAPIVersion(apiVersion) } else { client.SetAPIVersion(APIVersion) } certPath, certPathExists := os.LookupEnv(APIHostCert) if certPathExists && !hasCustomTransport(hc) { cert, err := os.ReadFile(filepath.Clean(certPath)) if err != nil { log.Fatalf("[ERROR] Error when reading cert at %s: %s\n", certPath, err.Error()) } client.SetRootCertificate(certPath) if envDebug { log.Printf("[DEBUG] Set API root certificate to %s with contents %s\n", certPath, cert) } } client. SetRetryWaitTime(APISecondsPerPoll * time.Second). SetPollDelay(APISecondsPerPoll * time.Second). SetRetries(). SetDebug(envDebug). enableLogSanitization() return client } // NewClientFromEnv creates a Client and initializes it with values // from the LINODE_CONFIG file and the LINODE_TOKEN environment variable. func NewClientFromEnv(hc *http.Client) (*Client, error) { client := NewClient(hc) // Users are expected to chain NewClient(...) and LoadConfig(...) to customize these options configPath, err := resolveValidConfigPath() if err != nil { return nil, err } // Populate the token from the environment. // Tokens should be first priority to maintain backwards compatibility if token, ok := os.LookupEnv(APIEnvVar); ok && token != "" { client.SetToken(token) return &client, nil } if p, ok := os.LookupEnv(APIConfigEnvVar); ok { configPath = p } else if !ok && configPath == "" { return nil, fmt.Errorf("no linode config file or token found") } configProfile := DefaultConfigProfile if p, ok := os.LookupEnv(APIConfigProfileEnvVar); ok { configProfile = p } client.selectedProfile = configProfile // We should only load the config if the config file exists if _, err = os.Stat(configPath); err != nil { return nil, fmt.Errorf("error loading config file %s: %w", configPath, err) } err = client.preLoadConfig(configPath) return &client, err } // SetUserAgent sets a custom user-agent for HTTP requests func (c *Client) SetUserAgent(ua string) *Client { c.userAgent = ua c.resty.SetHeader("User-Agent", c.userAgent) return c } type RequestParams struct { Body any Response any } // Generic helper to execute HTTP requests using the net/http package // // nolint:unused, funlen, gocognit func (c *httpClient) doRequest(ctx context.Context, method, url string, params RequestParams) error { var ( req *http.Request bodyBuffer *bytes.Buffer resp *http.Response err error ) for range httpDefaultRetryCount { req, bodyBuffer, err = c.createRequest(ctx, method, url, params) if err != nil { return err } if err = c.applyBeforeRequest(req); err != nil { return err } if c.debug && c.logger != nil { c.logRequest(req, method, url, bodyBuffer) } processResponse := func() error { defer func() { closeErr := resp.Body.Close() if closeErr != nil && err == nil { err = closeErr } }() if err = c.checkHTTPError(resp); err != nil { return err } if c.debug && c.logger != nil { var logErr error resp, logErr = c.logResponse(resp) if logErr != nil { return logErr } } if params.Response != nil { if err = c.decodeResponseBody(resp, params.Response); err != nil { return err } } // Apply after-response mutations if err = c.applyAfterResponse(resp); err != nil { return err } return nil } resp, err = c.sendRequest(req) if err == nil { if err = processResponse(); err == nil { return nil } } if !c.shouldRetry(resp, err) { break } retryAfter, retryErr := c.retryAfter(resp) if retryErr != nil { return retryErr } // Sleep for the specified duration before retrying. // If retryAfter is 0 (i.e., Retry-After header is not found), // no delay is applied. time.Sleep(retryAfter) } return err } // nolint:unused func (c *httpClient) shouldRetry(resp *http.Response, err error) bool { for _, retryConditional := range c.retryConditionals { if retryConditional(resp, err) { return true } } return false } // nolint:unused func (c *httpClient) createRequest(ctx context.Context, method, url string, params RequestParams) (*http.Request, *bytes.Buffer, error) { var ( bodyReader io.Reader bodyBuffer *bytes.Buffer ) if params.Body != nil { bodyBuffer = new(bytes.Buffer) if err := json.NewEncoder(bodyBuffer).Encode(params.Body); err != nil { if c.debug && c.logger != nil { c.logger.Errorf("failed to encode body: %v", err) } return nil, nil, fmt.Errorf("failed to encode body: %w", err) } bodyReader = bodyBuffer } req, err := http.NewRequestWithContext(ctx, method, url, bodyReader) if err != nil { if c.debug && c.logger != nil { c.logger.Errorf("failed to create request: %v", err) } return nil, nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") if c.userAgent != "" { req.Header.Set("User-Agent", c.userAgent) } return req, bodyBuffer, nil } // nolint:unused func (c *httpClient) applyBeforeRequest(req *http.Request) error { for _, mutate := range c.onBeforeRequest { if err := mutate(req); err != nil { if c.debug && c.logger != nil { c.logger.Errorf("failed to mutate before request: %v", err) } return fmt.Errorf("failed to mutate before request: %w", err) } } return nil } // nolint:unused func (c *httpClient) applyAfterResponse(resp *http.Response) error { for _, mutate := range c.onAfterResponse { if err := mutate(resp); err != nil { if c.debug && c.logger != nil { c.logger.Errorf("failed to mutate after response: %v", err) } return fmt.Errorf("failed to mutate after response: %w", err) } } return nil } // nolint:unused func (c *httpClient) logRequest(req *http.Request, method, url string, bodyBuffer *bytes.Buffer) { var reqBody string if bodyBuffer != nil { reqBody = bodyBuffer.String() } else { reqBody = "nil" } var logBuf bytes.Buffer err := reqLogTemplate.Execute(&logBuf, map[string]any{ "Method": method, "URL": url, "Headers": req.Header, "Body": reqBody, }) if err == nil { c.logger.Debugf(logBuf.String()) } } // nolint:unused func (c *httpClient) sendRequest(req *http.Request) (*http.Response, error) { resp, err := c.httpClient.Do(req) if err != nil { if c.debug && c.logger != nil { c.logger.Errorf("failed to send request: %v", err) } return nil, fmt.Errorf("failed to send request: %w", err) } return resp, nil } // nolint:unused func (c *httpClient) checkHTTPError(resp *http.Response) error { _, err := coupleAPIErrorsHTTP(resp, nil) if err != nil { if c.debug && c.logger != nil { c.logger.Errorf("received HTTP error: %v", err) } return err } return nil } // nolint:unused func (c *httpClient) logResponse(resp *http.Response) (*http.Response, error) { var respBody bytes.Buffer if _, err := io.Copy(&respBody, resp.Body); err != nil { c.logger.Errorf("failed to read response body: %v", err) } var logBuf bytes.Buffer err := respLogTemplate.Execute(&logBuf, map[string]any{ "Status": resp.Status, "Headers": resp.Header, "Body": respBody.String(), }) if err == nil { c.logger.Debugf(logBuf.String()) } resp.Body = io.NopCloser(bytes.NewReader(respBody.Bytes())) return resp, nil } // nolint:unused func (c *httpClient) decodeResponseBody(resp *http.Response, response any) error { if err := json.NewDecoder(resp.Body).Decode(response); err != nil { if c.debug && c.logger != nil { c.logger.Errorf("failed to decode response: %v", err) } return fmt.Errorf("failed to decode response: %w", 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). SetError(APIError{}) } // SetDebug sets the debug on resty's client func (c *Client) SetDebug(debug bool) *Client { c.debug = debug c.resty.SetDebug(debug) return c } // SetLogger allows the user to override the output // logger for debug logs. func (c *Client) SetLogger(logger Logger) *Client { c.resty.SetLogger(logger) return c } //nolint:unused func (c *httpClient) httpSetDebug(debug bool) *httpClient { c.debug = debug return c } //nolint:unused func (c *httpClient) httpSetLogger(logger httpLogger) *httpClient { c.logger = logger return c } // OnBeforeRequest adds a handler to the request body to run before the request is sent func (c *Client) OnBeforeRequest(m func(request *Request) error) { c.resty.OnBeforeRequest(func(_ *resty.Client, req *resty.Request) error { return m(req) }) } // OnAfterResponse adds a handler to the request body to run before the request is sent func (c *Client) OnAfterResponse(m func(response *Response) error) { c.resty.OnAfterResponse(func(_ *resty.Client, req *resty.Response) error { return m(req) }) } // nolint:unused func (c *httpClient) httpOnBeforeRequest(m func(*http.Request) error) *httpClient { c.onBeforeRequest = append(c.onBeforeRequest, m) return c } // nolint:unused func (c *httpClient) httpOnAfterResponse(m func(*http.Response) error) *httpClient { c.onAfterResponse = append(c.onAfterResponse, m) return c } // UseURL parses the individual components of the given API URL and configures the client // accordingly. For example, a valid URL. // For example: // // client.UseURL("https://api.test.linode.com/v4beta") func (c *Client) UseURL(apiURL string) (*Client, error) { parsedURL, err := url.Parse(apiURL) if err != nil { return nil, fmt.Errorf("failed to parse URL: %w", err) } if parsedURL.Scheme == "" || parsedURL.Host == "" { return nil, fmt.Errorf("need both scheme and host in API URL, got %q", apiURL) } // Create a new URL excluding the path to use as the base URL baseURL := &url.URL{ Host: parsedURL.Host, Scheme: parsedURL.Scheme, } c.SetBaseURL(baseURL.String()) versionMatches := regexp.MustCompile(`/v[a-zA-Z0-9]+`).FindAllString(parsedURL.Path, -1) // Only set the version if a version is found in the URL, else use the default if len(versionMatches) > 0 { c.SetAPIVersion( strings.Trim(versionMatches[len(versionMatches)-1], "/"), ) } return c, nil } // SetBaseURL sets the base URL of the Linode v4 API (https://api.linode.com/v4) func (c *Client) SetBaseURL(baseURL string) *Client { baseURLPath, _ := url.Parse(baseURL) c.baseURL = path.Join(baseURLPath.Host, baseURLPath.Path) c.apiProto = baseURLPath.Scheme c.updateHostURL() return c } // SetAPIVersion sets the version of the API to interface with func (c *Client) SetAPIVersion(apiVersion string) *Client { c.apiVersion = apiVersion c.updateHostURL() return c } // SetRootCertificate adds a root certificate to the underlying TLS client config func (c *Client) SetRootCertificate(path string) *Client { c.resty.SetRootCertificate(path) return c } // SetToken sets the API token for all requests from this client // Only necessary if you haven't already provided the http client to NewClient() configured with the token. func (c *Client) SetToken(token string) *Client { c.resty.SetHeader("Authorization", fmt.Sprintf("Bearer %s", token)) return c } // SetRetries adds retry conditions for "Linode Busy." errors and 429s. func (c *Client) SetRetries() *Client { c. addRetryConditional(linodeBusyRetryCondition). addRetryConditional(tooManyRequestsRetryCondition). addRetryConditional(serviceUnavailableRetryCondition). addRetryConditional(requestTimeoutRetryCondition). addRetryConditional(requestGOAWAYRetryCondition). addRetryConditional(requestNGINXRetryCondition). SetRetryMaxWaitTime(APIRetryMaxWaitTime) configureRetries(c) return c } // AddRetryCondition adds a RetryConditional function to the Client func (c *Client) AddRetryCondition(retryCondition RetryConditional) *Client { c.resty.AddRetryCondition(resty.RetryConditionFunc(retryCondition)) return c } // InvalidateCache clears all cached responses for all endpoints. func (c *Client) InvalidateCache() { c.cachedEntryLock.Lock() defer c.cachedEntryLock.Unlock() // GC will handle the old map c.cachedEntries = make(map[string]clientCacheEntry) } // InvalidateCacheEndpoint invalidates a single cached endpoint. func (c *Client) InvalidateCacheEndpoint(endpoint string) error { u, err := url.Parse(endpoint) if err != nil { return fmt.Errorf("failed to parse URL for caching: %w", err) } c.cachedEntryLock.Lock() defer c.cachedEntryLock.Unlock() delete(c.cachedEntries, u.Path) return nil } // SetGlobalCacheExpiration sets the desired time for any cached response // to be valid for. func (c *Client) SetGlobalCacheExpiration(expiryTime time.Duration) { c.cacheExpiration = expiryTime } // UseCache sets whether response caching should be used func (c *Client) UseCache(value bool) { c.shouldCache = value } // SetRetryMaxWaitTime sets the maximum delay before retrying a request. func (c *Client) SetRetryMaxWaitTime(maxWaitTime time.Duration) *Client { c.resty.SetRetryMaxWaitTime(maxWaitTime) return c } // SetRetryWaitTime sets the default (minimum) delay before retrying a request. func (c *Client) SetRetryWaitTime(minWaitTime time.Duration) *Client { c.resty.SetRetryWaitTime(minWaitTime) return c } // SetRetryAfter sets the callback function to be invoked with a failed request // to determine wben it should be retried. func (c *Client) SetRetryAfter(callback RetryAfter) *Client { c.resty.SetRetryAfter(resty.RetryAfterFunc(callback)) return c } // SetRetryCount sets the maximum retry attempts before aborting. func (c *Client) SetRetryCount(count int) *Client { c.resty.SetRetryCount(count) return c } // SetPollDelay sets the number of milliseconds to wait between events or status polls. // Affects all WaitFor* functions and retries. func (c *Client) SetPollDelay(delay time.Duration) *Client { c.pollInterval = delay return c } // GetPollDelay gets the number of milliseconds to wait between events or status polls. // Affects all WaitFor* functions and retries. func (c *Client) GetPollDelay() time.Duration { return c.pollInterval } // SetHeader sets a custom header to be used in all API requests made with the current // client. // NOTE: Some headers may be overridden by the individual request functions. func (c *Client) SetHeader(name, value string) { c.resty.SetHeader(name, value) } func (c *Client) addRetryConditional(retryConditional RetryConditional) *Client { c.retryConditionals = append(c.retryConditionals, retryConditional) return c } func (c *Client) addCachedResponse(endpoint string, response any, expiry *time.Duration) { if !c.shouldCache { return } responseValue := reflect.ValueOf(response) entry := clientCacheEntry{ Created: time.Now(), ExpiryOverride: expiry, } switch responseValue.Kind() { case reflect.Ptr: // We want to automatically deref pointers to // avoid caching mutable data. entry.Data = responseValue.Elem().Interface() default: entry.Data = response } c.cachedEntryLock.Lock() defer c.cachedEntryLock.Unlock() c.cachedEntries[endpoint] = entry } func (c *Client) getCachedResponse(endpoint string) any { if !c.shouldCache { return nil } c.cachedEntryLock.RLock() // Hacky logic to dynamically RUnlock // only if it is still locked by the // end of the function. // This is necessary as we take write // access if the entry has expired. rLocked := true defer func() { if rLocked { c.cachedEntryLock.RUnlock() } }() entry, ok := c.cachedEntries[endpoint] if !ok { return nil } // Handle expired entries elapsedTime := time.Since(entry.Created) hasExpired := elapsedTime > c.cacheExpiration if entry.ExpiryOverride != nil { hasExpired = elapsedTime > *entry.ExpiryOverride } if hasExpired { // We need to give up our read access and request read-write access c.cachedEntryLock.RUnlock() rLocked = false c.cachedEntryLock.Lock() defer c.cachedEntryLock.Unlock() delete(c.cachedEntries, endpoint) return nil } return c.cachedEntries[endpoint].Data } func (c *Client) updateHostURL() { apiProto := APIProto baseURL := APIHost apiVersion := APIVersion if c.baseURL != "" { baseURL = c.baseURL } if c.apiVersion != "" { apiVersion = c.apiVersion } if c.apiProto != "" { apiProto = c.apiProto } c.resty.SetBaseURL( fmt.Sprintf( "%s://%s/%s", apiProto, baseURL, url.PathEscape(apiVersion), ), ) } func (c *Client) enableLogSanitization() *Client { c.resty.OnRequestLog(func(r *resty.RequestLog) error { // masking authorization header r.Header.Set("Authorization", "Bearer *******************************") return nil }) return c } func (c *Client) preLoadConfig(configPath string) error { if envDebug { log.Printf("[INFO] Loading profile from %s\n", configPath) } if err := c.LoadConfig(&LoadConfigOptions{ Path: configPath, SkipLoadProfile: true, }); err != nil { return err } // We don't want to load the profile until the user is actually making requests c.OnBeforeRequest(func(_ *Request) error { if c.loadedProfile != c.selectedProfile { if err := c.UseProfile(c.selectedProfile); err != nil { return err } } return nil }) return nil } func copyBool(bPtr *bool) *bool { if bPtr == nil { return nil } t := *bPtr return &t } func copyInt(iPtr *int) *int { if iPtr == nil { return nil } t := *iPtr return &t } func copyString(sPtr *string) *string { if sPtr == nil { return nil } t := *sPtr return &t } // copyValue returns a pointer to a new value copied from the value // at the given pointer. func copyValue[T any](ptr *T) *T { if ptr == nil { return nil } t := *ptr return &t } func copyTime(tPtr *time.Time) *time.Time { if tPtr == nil { return nil } t := *tPtr return &t } func generateListCacheURL(endpoint string, opts *ListOptions) (string, error) { if opts == nil { return endpoint, nil } hashedOpts, err := opts.Hash() if err != nil { return endpoint, err } return fmt.Sprintf("%s:%s", endpoint, hashedOpts), nil } func hasCustomTransport(hc *http.Client) bool { if hc == nil || hc.Transport == nil { return false } if _, ok := hc.Transport.(*http.Transport); !ok { log.Println("[WARN] Custom transport is not allowed with a custom root CA.") return true } return false }