client.go 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. /*
  2. Copyright © 2025 ESO Maintainer Team
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. https://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package client
  14. import (
  15. "bytes"
  16. "crypto/tls"
  17. "encoding/json"
  18. "fmt"
  19. "io"
  20. "net/http"
  21. "net/url"
  22. "strings"
  23. "time"
  24. )
  25. type DopplerClient struct {
  26. baseURL *url.URL
  27. DopplerToken string
  28. VerifyTLS bool
  29. UserAgent string
  30. }
  31. type queryParams map[string]string
  32. type headers map[string]string
  33. type httpRequestBody []byte
  34. type Secrets map[string]string
  35. type Change struct {
  36. Name string `json:"name"`
  37. OriginalName string `json:"originalName"`
  38. Value *string `json:"value"`
  39. ShouldDelete bool `json:"shouldDelete,omitempty"`
  40. }
  41. type APIError struct {
  42. Err error
  43. Message string
  44. Data string
  45. }
  46. type apiResponse struct {
  47. HTTPResponse *http.Response
  48. Body []byte
  49. }
  50. type apiErrorResponse struct {
  51. Messages []string
  52. Success bool
  53. }
  54. type SecretRequest struct {
  55. Name string
  56. Project string
  57. Config string
  58. }
  59. type SecretsRequest struct {
  60. Project string
  61. Config string
  62. NameTransformer string
  63. Format string
  64. ETag string // Specifying an ETag implies that the caller has implemented response caching
  65. }
  66. type UpdateSecretsRequest struct {
  67. Secrets Secrets `json:"secrets,omitempty"`
  68. ChangeRequests []Change `json:"change_requests,omitempty"`
  69. Project string `json:"project,omitempty"`
  70. Config string `json:"config,omitempty"`
  71. }
  72. type secretResponseBody struct {
  73. Name string `json:"name,omitempty"`
  74. Value struct {
  75. Raw *string `json:"raw"`
  76. Computed *string `json:"computed"`
  77. } `json:"value,omitempty"`
  78. Messages *[]string `json:"messages,omitempty"`
  79. Success bool `json:"success"`
  80. }
  81. type SecretResponse struct {
  82. Name string
  83. Value string
  84. }
  85. type SecretsResponse struct {
  86. Secrets Secrets
  87. Body []byte
  88. Modified bool
  89. ETag string
  90. }
  91. func NewDopplerClient(dopplerToken string) (*DopplerClient, error) {
  92. client := &DopplerClient{
  93. DopplerToken: dopplerToken,
  94. VerifyTLS: true,
  95. UserAgent: "doppler-external-secrets",
  96. }
  97. if err := client.SetBaseURL("https://api.doppler.com"); err != nil {
  98. return nil, &APIError{Err: err, Message: "setting base URL failed"}
  99. }
  100. return client, nil
  101. }
  102. func (c *DopplerClient) BaseURL() *url.URL {
  103. u := *c.baseURL
  104. return &u
  105. }
  106. func (c *DopplerClient) SetBaseURL(urlStr string) error {
  107. baseURL, err := url.Parse(strings.TrimSuffix(urlStr, "/"))
  108. if err != nil {
  109. return err
  110. }
  111. if baseURL.Scheme == "" {
  112. baseURL.Scheme = "https"
  113. }
  114. c.baseURL = baseURL
  115. return nil
  116. }
  117. func (c *DopplerClient) Authenticate() error {
  118. // Choose projects as a lightweight endpoint for testing authentication
  119. if _, err := c.performRequest("/v3/projects", "GET", headers{}, queryParams{}, httpRequestBody{}); err != nil {
  120. return err
  121. }
  122. return nil
  123. }
  124. func (c *DopplerClient) GetSecret(request SecretRequest) (*SecretResponse, error) {
  125. params := request.buildQueryParams(request.Name)
  126. response, err := c.performRequest("/v3/configs/config/secret", "GET", headers{}, params, httpRequestBody{})
  127. if err != nil {
  128. return nil, err
  129. }
  130. var data secretResponseBody
  131. if err := json.Unmarshal(response.Body, &data); err != nil {
  132. return nil, &APIError{Err: err, Message: "unable to unmarshal secret payload", Data: string(response.Body)}
  133. }
  134. if data.Value.Computed == nil {
  135. return nil, &APIError{Message: fmt.Sprintf("secret '%s' not found", request.Name)}
  136. }
  137. return &SecretResponse{Name: data.Name, Value: *data.Value.Computed}, nil
  138. }
  139. // GetSecrets should only have an ETag supplied if Secrets are cached as SecretsResponse.Secrets will be nil if 304 (not modified) returned.
  140. func (c *DopplerClient) GetSecrets(request SecretsRequest) (*SecretsResponse, error) {
  141. headers := headers{}
  142. if request.ETag != "" {
  143. headers["if-none-match"] = request.ETag
  144. }
  145. if request.Format != "" && request.Format != "json" {
  146. headers["accept"] = "text/plain"
  147. }
  148. params := request.buildQueryParams()
  149. response, apiErr := c.performRequest("/v3/configs/config/secrets/download", "GET", headers, params, httpRequestBody{})
  150. if apiErr != nil {
  151. return nil, apiErr
  152. }
  153. if response.HTTPResponse.StatusCode == 304 {
  154. return &SecretsResponse{Modified: false, Secrets: nil, ETag: request.ETag}, nil
  155. }
  156. eTag := response.HTTPResponse.Header.Get("etag")
  157. // Format defeats JSON parsing
  158. if request.Format != "" {
  159. return &SecretsResponse{Modified: true, Body: response.Body, ETag: eTag}, nil
  160. }
  161. var secrets Secrets
  162. if err := json.Unmarshal(response.Body, &secrets); err != nil {
  163. return nil, &APIError{Err: err, Message: "unable to unmarshal secrets payload"}
  164. }
  165. return &SecretsResponse{Modified: true, Secrets: secrets, Body: response.Body, ETag: eTag}, nil
  166. }
  167. func (c *DopplerClient) UpdateSecrets(request UpdateSecretsRequest) error {
  168. body, jsonErr := json.Marshal(request)
  169. if jsonErr != nil {
  170. return &APIError{Err: jsonErr, Message: "unable to unmarshal update secrets payload"}
  171. }
  172. _, err := c.performRequest("/v3/configs/config/secrets", "POST", headers{}, queryParams{}, body)
  173. if err != nil {
  174. return err
  175. }
  176. return nil
  177. }
  178. func (r *SecretRequest) buildQueryParams(name string) queryParams {
  179. params := queryParams{}
  180. params["name"] = name
  181. if r.Project != "" {
  182. params["project"] = r.Project
  183. }
  184. if r.Config != "" {
  185. params["config"] = r.Config
  186. }
  187. return params
  188. }
  189. func (r *SecretsRequest) buildQueryParams() queryParams {
  190. params := queryParams{}
  191. if r.Project != "" {
  192. params["project"] = r.Project
  193. }
  194. if r.Config != "" {
  195. params["config"] = r.Config
  196. }
  197. if r.NameTransformer != "" {
  198. params["name_transformer"] = r.NameTransformer
  199. }
  200. if r.Format != "" {
  201. params["format"] = r.Format
  202. }
  203. return params
  204. }
  205. func (c *DopplerClient) performRequest(path, method string, headers headers, params queryParams, body httpRequestBody) (*apiResponse, error) {
  206. urlStr := c.BaseURL().String() + path
  207. reqURL, err := url.Parse(urlStr)
  208. if err != nil {
  209. return nil, &APIError{Err: err, Message: fmt.Sprintf("invalid API URL: %s", urlStr)}
  210. }
  211. var bodyReader io.Reader
  212. if body != nil {
  213. bodyReader = bytes.NewReader(body)
  214. } else {
  215. bodyReader = http.NoBody
  216. }
  217. req, err := http.NewRequest(method, reqURL.String(), bodyReader)
  218. if err != nil {
  219. return nil, &APIError{Err: err, Message: "unable to form HTTP request"}
  220. }
  221. if method == "POST" && req.Header.Get("content-type") == "" {
  222. req.Header.Set("content-type", "application/json")
  223. }
  224. if req.Header.Get("accept") == "" {
  225. req.Header.Set("accept", "application/json")
  226. }
  227. req.Header.Set("user-agent", c.UserAgent)
  228. req.SetBasicAuth(c.DopplerToken, "")
  229. for key, value := range headers {
  230. req.Header.Set(key, value)
  231. }
  232. query := req.URL.Query()
  233. for key, value := range params {
  234. query.Add(key, value)
  235. }
  236. req.URL.RawQuery = query.Encode()
  237. httpClient := &http.Client{Timeout: 10 * time.Second}
  238. tlsConfig := &tls.Config{
  239. MinVersion: tls.VersionTLS12,
  240. }
  241. if !c.VerifyTLS {
  242. tlsConfig.InsecureSkipVerify = true
  243. }
  244. httpClient.Transport = &http.Transport{
  245. DisableKeepAlives: true,
  246. TLSClientConfig: tlsConfig,
  247. }
  248. r, err := httpClient.Do(req)
  249. if err != nil {
  250. return nil, &APIError{Err: err, Message: "unable to load response"}
  251. }
  252. defer func() {
  253. _ = r.Body.Close()
  254. }()
  255. bodyResponse, err := io.ReadAll(r.Body)
  256. if err != nil {
  257. return &apiResponse{HTTPResponse: r, Body: nil}, &APIError{Err: err, Message: "unable to read entire response body"}
  258. }
  259. response := &apiResponse{HTTPResponse: r, Body: bodyResponse}
  260. success := isSuccess(r.StatusCode)
  261. if !success {
  262. if contentType := r.Header.Get("content-type"); strings.HasPrefix(contentType, "application/json") {
  263. var errResponse apiErrorResponse
  264. err := json.Unmarshal(bodyResponse, &errResponse)
  265. if err != nil {
  266. return response, &APIError{Err: err, Message: "unable to unmarshal error JSON payload"}
  267. }
  268. return response, &APIError{Err: nil, Message: strings.Join(errResponse.Messages, "\n")}
  269. }
  270. return nil, &APIError{Err: fmt.Errorf("%d status code; %d bytes", r.StatusCode, len(bodyResponse)), Message: "unable to load response"}
  271. }
  272. if success && err != nil {
  273. return nil, &APIError{Err: err, Message: "unable to load data from successful response"}
  274. }
  275. return response, nil
  276. }
  277. func isSuccess(statusCode int) bool {
  278. return (statusCode >= 200 && statusCode <= 299) || (statusCode >= 300 && statusCode <= 399)
  279. }
  280. func (e *APIError) Error() string {
  281. message := fmt.Sprintf("Doppler API Client Error: %s", e.Message)
  282. if underlyingError := e.Err; underlyingError != nil {
  283. message = fmt.Sprintf("%s\n%s", message, underlyingError.Error())
  284. }
  285. if e.Data != "" {
  286. message = fmt.Sprintf("%s\nData: %s", message, e.Data)
  287. }
  288. return message
  289. }