-
Notifications
You must be signed in to change notification settings - Fork 0
/
setting.go
441 lines (385 loc) · 10.6 KB
/
setting.go
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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
package setting
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/KarlGW/azcfg/auth"
"github.com/KarlGW/azcfg/azure/cloud"
"github.com/KarlGW/azcfg/internal/httpr"
"github.com/KarlGW/azcfg/internal/request"
"github.com/KarlGW/azcfg/internal/secret"
"github.com/KarlGW/azcfg/version"
)
const (
// apiVersion is the API version for the App Config REST API endpoint.
apiVersion = "1.0"
// defaultConcurrency is the default concurrency set on the client.
defaultConcurrency = 10
// defaultTimeout is the default timeout set on the client.
defaultTimeout = 30 * time.Second
)
const (
// keyVaultReferenceContentType is the content type for a key vault reference.
keyVaultReferenceContentType = "application/vnd.microsoft.appconfig.keyvaultref+json;charset=utf-8"
)
// Setting represents a setting as returned from the App Config REST API.
type Setting struct {
ContentType string `json:"content_type"`
Value string `json:"value"`
Label string `json:"label"`
}
// AccessKey contains the id and secret for access key
// authentication.
type AccessKey struct {
ID string
Secret string
}
// Secret represents a secret as returned from the Key Vault REST API.
type Secret = secret.Secret
// secretClient is the interface that wraps around method Get, Vault and
// SetVault.
type secretClient interface {
Get(ctx context.Context, name string, options ...secret.Option) (Secret, error)
Vault() string
SetVault(vault string)
}
// Client contains methods to call the Azure App Config REST API and
// base settings for handling the requests.
type Client struct {
c request.Client
cred auth.Credential
accessKey AccessKey
sc secretClient
cloud cloud.Cloud
scope string
baseURL string
userAgent string
label string
labels map[string]string
retryPolicy httpr.RetryPolicy
concurrency int
timeout time.Duration
mu sync.RWMutex
}
// ClientOption is a function that sets options to *Client.
type ClientOption func(c *Client)
// NewClient creates and returns a new *Client.
func NewClient(appConfiguration string, cred auth.Credential, options ...ClientOption) (*Client, error) {
if len(appConfiguration) == 0 {
return nil, errors.New("empty app configuration name")
}
if cred == nil {
return nil, errors.New("nil credential")
}
c := newClient(appConfiguration, options...)
c.cred = cred
return c, nil
}
// NewClientWithAccessKey creates and returns a new *Client with the provided
// access key.
func NewClientWithAccessKey(appConfiguration string, key AccessKey, options ...ClientOption) (*Client, error) {
if len(appConfiguration) == 0 {
return nil, errors.New("empty app configuration name")
}
if len(key.ID) == 0 {
return nil, errors.New("empty access key ID")
}
if len(key.Secret) == 0 {
return nil, errors.New("empty access key secret")
}
c := newClient(appConfiguration, options...)
c.accessKey = AccessKey{
ID: key.ID,
Secret: key.Secret,
}
return c, nil
}
// NewClientWithConnectionString creates and returns a new *Client with the provided
// connection string.
func NewClientWithConnectionString(connectionString string, options ...ClientOption) (*Client, error) {
appConfiguration, key, err := parseConnectionString(connectionString)
if err != nil {
return nil, err
}
return NewClientWithAccessKey(appConfiguration, key, options...)
}
// newClient creates and returns a new Client.
func newClient(appConfiguration string, options ...ClientOption) *Client {
c := &Client{
cloud: cloud.AzurePublic,
userAgent: "azcfg/" + version.Version(),
concurrency: defaultConcurrency,
timeout: defaultTimeout,
}
for _, option := range options {
option(c)
}
if len(c.baseURL) == 0 {
c.baseURL = endpoint(c.cloud, appConfiguration)
}
if len(c.scope) == 0 {
c.scope = scope(c.cloud)
}
if c.c == nil {
c.c = httpr.NewClient(
httpr.WithTimeout(c.timeout),
httpr.WithRetryPolicy(c.retryPolicy),
)
}
return c
}
// Options for client operations.
type Options struct {
Labels map[string]string
Label string
}
// Option is a function that sets options for client operations.
type Option func(o *Options)
// GetSettings get settings (key-values) by keys.
func (c *Client) GetSettings(ctx context.Context, keys []string, options ...Option) (map[string]Setting, error) {
return c.getSettings(ctx, keys, options...)
}
// Get a setting.
func (c *Client) Get(ctx context.Context, key string, options ...Option) (Setting, error) {
opts := Options{
Label: c.label,
Labels: c.labels,
}
for _, option := range options {
option(&opts)
}
var label string
if len(opts.Labels) > 0 {
var ok bool
label, ok = opts.Labels[key]
if !ok {
label = opts.Label
}
} else {
label = opts.Label
}
u := fmt.Sprintf("%s/%s?api-version=%s", c.baseURL, key, apiVersion)
if len(label) > 0 {
u += "&label=" + label
}
headers := http.Header{
"User-Agent": []string{c.userAgent},
}
if c.cred != nil {
token, err := c.cred.Token(ctx, auth.WithScope(c.scope))
if err != nil {
return Setting{}, err
}
headers.Add("Authorization", "Bearer "+token.AccessToken)
} else if c.accessKey != (AccessKey{}) {
err := addHMACAuthenticationHeaders(
headers,
c.accessKey,
http.MethodGet,
u,
time.Now().UTC(),
[]byte(""),
)
if err != nil {
return Setting{}, err
}
} else {
return Setting{}, errors.New("no credential provided")
}
resp, err := request.Do(ctx, c.c, headers, http.MethodGet, u, nil)
if err != nil {
return Setting{}, err
}
if resp.StatusCode != http.StatusOK {
var settingErr settingError
if len(resp.Body) > 0 {
if err := json.Unmarshal(resp.Body, &settingErr); err != nil {
return Setting{}, err
}
}
if len(settingErr.Detail) == 0 {
settingErr = newSettingError(resp.StatusCode)
} else {
settingErr.StatusCode = resp.StatusCode
}
return Setting{}, settingErr
}
var setting Setting
if err := json.Unmarshal(resp.Body, &setting); err != nil {
return setting, err
}
if setting.ContentType != keyVaultReferenceContentType {
return setting, nil
}
var reference struct {
URI string `json:"uri"`
}
if err := json.Unmarshal([]byte(setting.Value), &reference); err != nil {
return setting, err
}
secret, err := c.getSecret(ctx, reference.URI)
if err != nil {
return Setting{}, err
}
setting.Value = secret.Value
return setting, nil
}
// getSecret gets a secret from the provided URI.
func (c *Client) getSecret(ctx context.Context, uri string) (Secret, error) {
c.mu.Lock()
defer c.mu.Unlock()
v, s, err := vaultAndSecret(uri)
if err != nil {
return Secret{}, err
}
if c.sc == nil {
c.sc, err = newSecretClient(v, c.cred,
secret.WithConcurrency(c.concurrency),
secret.WithTimeout(c.timeout),
secret.WithRetryPolicy(c.retryPolicy),
)
if err != nil {
return Secret{}, err
}
} else {
if c.sc.Vault() != v {
c.sc.SetVault(v)
}
}
sec, err := c.sc.Get(ctx, s)
if err != nil {
return Secret{}, err
}
return sec, nil
}
// settingResult contains result from retrieving settings. Should
// be used with a channel for handling results and errors.
type settingResult struct {
setting Setting
err error
key string
}
// getSettings gets settings by the provided keys and returns them as a map[string]Setting
// where setting key is the key.
func (c *Client) getSettings(ctx context.Context, keys []string, options ...Option) (map[string]Setting, error) {
keysCh := make(chan string)
srCh := make(chan settingResult)
go func() {
for _, key := range keys {
keysCh <- key
}
close(keysCh)
}()
concurrency := c.concurrency
if len(keys) < concurrency {
concurrency = len(keys)
}
var wg sync.WaitGroup
for i := 1; i <= concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case key, ok := <-keysCh:
if !ok {
return
}
sr := settingResult{key: key}
setting, err := c.Get(ctx, key, options...)
if err != nil && !isSettingNotFound(err) {
sr.err = err
srCh <- sr
return
}
sr.setting = setting
srCh <- sr
case <-ctx.Done():
srCh <- settingResult{err: ctx.Err()}
return
}
}
}()
}
go func() {
wg.Wait()
close(srCh)
}()
settings := make(map[string]Setting)
for sr := range srCh {
if sr.err != nil {
return nil, sr.err
}
settings[sr.key] = sr.setting
}
return settings, nil
}
// vaultAndSecret returns the vault and secret from the provided URL.
func vaultAndSecret(rawURL string) (string, string, error) {
u, err := url.Parse(rawURL)
if err != nil || len(u.Scheme) == 0 {
return "", "", errors.New("failed to parse secret URL for setting")
}
vault := strings.Split(u.Hostname(), ".")[0]
secret := strings.TrimPrefix(u.Path, "/secrets/")
return vault, secret, nil
}
// parseConnectionString parses the connection string and returns the app configuration
// name and access key.
func parseConnectionString(connectionString string) (string, AccessKey, error) {
parts := strings.Split(connectionString, ";")
if len(parts) != 3 {
return "", AccessKey{}, fmt.Errorf("%w: invalid connection string format", ErrParseConnectionString)
}
var appConfiguration, id, secret string
for _, part := range parts {
kv := strings.SplitN(part, "=", 2)
if len(kv) != 2 {
return "", AccessKey{}, fmt.Errorf("%w: missing key or value", ErrParseConnectionString)
}
if strings.ToLower(kv[0]) == "endpoint" {
u, err := url.Parse(kv[1])
if err != nil {
return "", AccessKey{}, fmt.Errorf("%w: %s", ErrParseConnectionString, err)
}
if len(u.Host) == 0 {
return "", AccessKey{}, fmt.Errorf("%w: invalid endpoint", ErrParseConnectionString)
}
appConfiguration = strings.Split(u.Host, ".")[0]
} else if strings.ToLower(kv[0]) == "id" {
id = kv[1]
} else if strings.ToLower(kv[0]) == "secret" {
secret = kv[1]
}
}
return appConfiguration, AccessKey{ID: id, Secret: secret}, nil
}
// uri returns the base URI for the provided cloud.
func uri(c cloud.Cloud) string {
switch c {
case cloud.AzurePublic:
return "azconfig.io"
case cloud.AzureGovernment:
return "azconfig.azure.us"
case cloud.AzureChina:
return "azconfig.azure.cn"
}
return ""
}
// endpoint returns the base endpoint for the provided cloud.
func endpoint(cloud cloud.Cloud, appConfiguration string) string {
return fmt.Sprintf("https://%s.%s/kv", appConfiguration, uri(cloud))
}
// scope returns the scope for the provided cloud.
func scope(cloud cloud.Cloud) string {
return fmt.Sprintf("https://%s/.default", uri(cloud))
}
var newSecretClient = func(vault string, cred auth.Credential, options ...secret.ClientOption) (secretClient, error) {
return secret.NewClient(vault, cred, options...)
}