1
0
Fork 0
mirror of https://github.com/external-secrets/external-secrets.git synced 2024-12-15 17:51:01 +00:00
external-secrets/pkg/provider/doppler/client/client.go
Ryan Blunden f01e13f21b
Add Doppler provider (#1573)
* Add Doppler provider

Signed-off-by: Ryan Blunden <ryan.blunden@doppler.com>
2022-09-23 22:47:25 +02:00

348 lines
8.7 KiB
Go

/*
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package client
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type DopplerClient struct {
baseURL *url.URL
DopplerToken string
VerifyTLS bool
UserAgent string
}
type queryParams map[string]string
type headers map[string]string
type httpRequestBody []byte
type Secrets map[string]string
type RawSecrets map[string]*interface{}
type APIError struct {
Err error
Message string
Data string
}
type apiResponse struct {
HTTPResponse *http.Response
Body []byte
}
type apiErrorResponse struct {
Messages []string
Success bool
}
type SecretRequest struct {
Name string
Project string
Config string
}
type SecretsRequest struct {
Project string
Config string
NameTransformer string
Format string
ETag string // Specifying an ETag implies that the caller has implemented response caching
}
type UpdateSecretsRequest struct {
Secrets RawSecrets `json:"secrets,omitempty"`
Project string `json:"project,omitempty"`
Config string `json:"config,omitempty"`
}
type secretResponseBody struct {
Name string `json:"name,omitempty"`
Value struct {
Raw *string `json:"raw"`
Computed *string `json:"computed"`
} `json:"value,omitempty"`
Messages *[]string `json:"messages,omitempty"`
Success bool `json:"success"`
}
type SecretResponse struct {
Name string
Value string
}
type SecretsResponse struct {
Secrets Secrets
Body []byte
Modified bool
ETag string
}
func NewDopplerClient(dopplerToken string) (*DopplerClient, error) {
client := &DopplerClient{
DopplerToken: dopplerToken,
VerifyTLS: true,
UserAgent: "doppler-external-secrets",
}
if err := client.SetBaseURL("https://api.doppler.com"); err != nil {
return nil, &APIError{Err: err, Message: "setting base URL failed"}
}
return client, nil
}
func (c *DopplerClient) BaseURL() *url.URL {
u := *c.baseURL
return &u
}
func (c *DopplerClient) SetBaseURL(urlStr string) error {
baseURL, err := url.Parse(strings.TrimSuffix(urlStr, "/"))
if err != nil {
return err
}
if baseURL.Scheme == "" {
baseURL.Scheme = "https"
}
c.baseURL = baseURL
return nil
}
func (c *DopplerClient) Authenticate() error {
// Choose projects as a lightweight endpoint for testing authentication
if _, err := c.performRequest("/v3/projects", "GET", headers{}, queryParams{}, httpRequestBody{}); err != nil {
return err
}
return nil
}
func (c *DopplerClient) GetSecret(request SecretRequest) (*SecretResponse, error) {
params := request.buildQueryParams(request.Name)
response, err := c.performRequest("/v3/configs/config/secret", "GET", headers{}, params, httpRequestBody{})
if err != nil {
return nil, err
}
var data secretResponseBody
if err := json.Unmarshal(response.Body, &data); err != nil {
return nil, &APIError{Err: err, Message: "unable to unmarshal secret payload", Data: string(response.Body)}
}
if data.Value.Computed == nil {
return nil, &APIError{Message: fmt.Sprintf("secret '%s' not found", request.Name)}
}
return &SecretResponse{Name: data.Name, Value: *data.Value.Computed}, nil
}
// GetSecrets should only have an ETag supplied if Secrets are cached as SecretsResponse.Secrets will be nil if 304 (not modified) returned.
func (c *DopplerClient) GetSecrets(request SecretsRequest) (*SecretsResponse, error) {
headers := headers{}
if request.ETag != "" {
headers["if-none-match"] = request.ETag
}
if request.Format != "" && request.Format != "json" {
headers["accept"] = "text/plain"
}
params := request.buildQueryParams()
response, apiErr := c.performRequest("/v3/configs/config/secrets/download", "GET", headers, params, httpRequestBody{})
if apiErr != nil {
return nil, apiErr
}
if response.HTTPResponse.StatusCode == 304 {
return &SecretsResponse{Modified: false, Secrets: nil, ETag: request.ETag}, nil
}
eTag := response.HTTPResponse.Header.Get("etag")
// Format defeats JSON parsing
if request.Format != "" {
return &SecretsResponse{Modified: true, Body: response.Body, ETag: eTag}, nil
}
var secrets Secrets
if err := json.Unmarshal(response.Body, &secrets); err != nil {
return nil, &APIError{Err: err, Message: "unable to unmarshal secrets payload"}
}
return &SecretsResponse{Modified: true, Secrets: secrets, Body: response.Body, ETag: eTag}, nil
}
func (c *DopplerClient) UpdateSecrets(request UpdateSecretsRequest) error {
body, jsonErr := json.Marshal(request)
if jsonErr != nil {
return &APIError{Err: jsonErr, Message: "unable to unmarshal update secrets payload"}
}
_, err := c.performRequest("/v3/configs/config/secrets", "POST", headers{}, queryParams{}, body)
if err != nil {
return err
}
return nil
}
func (r *SecretRequest) buildQueryParams(name string) queryParams {
params := queryParams{}
params["name"] = name
if r.Project != "" {
params["project"] = r.Project
}
if r.Config != "" {
params["config"] = r.Config
}
return params
}
func (r *SecretsRequest) buildQueryParams() queryParams {
params := queryParams{}
if r.Project != "" {
params["project"] = r.Project
}
if r.Config != "" {
params["config"] = r.Config
}
if r.NameTransformer != "" {
params["name_transformer"] = r.NameTransformer
}
if r.Format != "" {
params["format"] = r.Format
}
return params
}
func (c *DopplerClient) performRequest(path, method string, headers headers, params queryParams, body httpRequestBody) (*apiResponse, error) {
urlStr := c.BaseURL().String() + path
reqURL, err := url.Parse(urlStr)
if err != nil {
return nil, &APIError{Err: err, Message: fmt.Sprintf("invalid API URL: %s", urlStr)}
}
var bodyReader io.Reader
if body != nil {
bodyReader = bytes.NewReader(body)
} else {
bodyReader = http.NoBody
}
req, err := http.NewRequest(method, reqURL.String(), bodyReader)
if err != nil {
return nil, &APIError{Err: err, Message: "unable to form HTTP request"}
}
if method == "POST" && req.Header.Get("content-type") == "" {
req.Header.Set("content-type", "application/json")
}
if req.Header.Get("accept") == "" {
req.Header.Set("accept", "application/json")
}
req.Header.Set("user-agent", c.UserAgent)
req.SetBasicAuth(c.DopplerToken, "")
for key, value := range headers {
req.Header.Set(key, value)
}
query := req.URL.Query()
for key, value := range params {
query.Add(key, value)
}
req.URL.RawQuery = query.Encode()
httpClient := &http.Client{Timeout: 10 * time.Second}
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
}
if !c.VerifyTLS {
tlsConfig.InsecureSkipVerify = true
}
httpClient.Transport = &http.Transport{
DisableKeepAlives: true,
TLSClientConfig: tlsConfig,
}
r, err := httpClient.Do(req)
if err != nil {
return nil, &APIError{Err: err, Message: "unable to load response"}
}
defer r.Body.Close()
bodyResponse, err := io.ReadAll(r.Body)
if err != nil {
return &apiResponse{HTTPResponse: r, Body: nil}, &APIError{Err: err, Message: "unable to read entire response body"}
}
response := &apiResponse{HTTPResponse: r, Body: bodyResponse}
success := isSuccess(r.StatusCode)
if !success {
if contentType := r.Header.Get("content-type"); strings.HasPrefix(contentType, "application/json") {
var errResponse apiErrorResponse
err := json.Unmarshal(bodyResponse, &errResponse)
if err != nil {
return response, &APIError{Err: err, Message: "unable to unmarshal error JSON payload"}
}
return response, &APIError{Err: nil, Message: strings.Join(errResponse.Messages, "\n")}
}
return nil, &APIError{Err: fmt.Errorf("%d status code; %d bytes", r.StatusCode, len(bodyResponse)), Message: "unable to load response"}
}
if success && err != nil {
return nil, &APIError{Err: err, Message: "unable to load data from successful response"}
}
return response, nil
}
func isSuccess(statusCode int) bool {
return (statusCode >= 200 && statusCode <= 299) || (statusCode >= 300 && statusCode <= 399)
}
func (e *APIError) Error() string {
message := fmt.Sprintf("Doppler API Client Error: %s", e.Message)
if underlyingError := e.Err; underlyingError != nil {
message = fmt.Sprintf("%s\n%s", message, underlyingError.Error())
}
if e.Data != "" {
message = fmt.Sprintf("%s\nData: %s", message, e.Data)
}
return message
}