-
Notifications
You must be signed in to change notification settings - Fork 2k
/
operator_api.go
484 lines (392 loc) · 13.2 KB
/
operator_api.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
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package command
import (
"bytes"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/hashicorp/go-cleanhttp"
"github.com/hashicorp/nomad/api"
"github.com/posener/complete"
)
// Stdin represents the system's standard input, but it's declared as a
// variable here to allow tests to override it with a regular file.
var Stdin = os.Stdin
type OperatorAPICommand struct {
Meta
verboseFlag bool
method string
body io.Reader
}
func (*OperatorAPICommand) Help() string {
helpText := `
Usage: nomad operator api [options] <path>
api is a utility command for accessing Nomad's HTTP API and is inspired by
the popular curl command line tool. Nomad's operator api command populates
Nomad's standard environment variables into their appropriate HTTP headers.
If the 'path' does not begin with "http" then $NOMAD_ADDR will be used.
The 'path' can be in one of the following forms:
/v1/allocations <- API Paths must start with a /
localhost:4646/v1/allocations <- Scheme will be inferred
https://localhost:4646/v1/allocations <- Scheme will be https://
Note that this command does not always match the popular curl program's
behavior. Instead Nomad's operator api command is optimized for common Nomad
HTTP API operations.
General Options:
` + generalOptionsUsage(usageOptsDefault) + `
Operator API Specific Options:
-dryrun
Output equivalent curl command to stdout and exit.
HTTP Basic Auth will never be output. If the $NOMAD_HTTP_AUTH environment
variable is set, it will be referenced in the appropriate curl flag in the
output.
ACL tokens set via the $NOMAD_TOKEN environment variable will only be
referenced by environment variable as with HTTP Basic Auth above. However
if the -token flag is explicitly used, the token will also be included in
the output.
-filter <query>
Specifies an expression used to filter query results.
-H <Header>
Adds an additional HTTP header to the request. May be specified more than
once. These headers take precedence over automatically set ones such as
X-Nomad-Token.
-verbose
Output extra information to stderr similar to curl's --verbose flag.
-X <HTTP Method>
HTTP method of request. If there is data piped to stdin, then the method
defaults to POST. Otherwise the method defaults to GET.
`
return strings.TrimSpace(helpText)
}
func (*OperatorAPICommand) Synopsis() string {
return "Query Nomad's HTTP API"
}
func (c *OperatorAPICommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-dryrun": complete.PredictNothing,
})
}
func (c *OperatorAPICommand) AutocompleteArgs() complete.Predictor {
//TODO(schmichael) wouldn't it be cool to build path autocompletion off
// of our http mux?
return complete.PredictNothing
}
func (*OperatorAPICommand) Name() string { return "operator api" }
func (c *OperatorAPICommand) Run(args []string) int {
var dryrun bool
var filter string
headerFlags := newHeaderFlags()
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.BoolVar(&dryrun, "dryrun", false, "")
flags.StringVar(&filter, "filter", "", "")
flags.BoolVar(&c.verboseFlag, "verbose", false, "")
flags.StringVar(&c.method, "X", "", "")
flags.Var(headerFlags, "H", "")
if err := flags.Parse(args); err != nil {
c.Ui.Error(fmt.Sprintf("Error parsing flags: %v", err))
return 1
}
args = flags.Args()
if len(args) < 1 {
c.Ui.Error("A path or URL is required")
c.Ui.Error(commandErrorText(c))
return 1
}
if n := len(args); n > 1 {
c.Ui.Error(fmt.Sprintf("operator api accepts exactly 1 argument, but %d arguments were found", n))
c.Ui.Error(commandErrorText(c))
return 1
}
// By default verbose func is a noop
verbose := func(string, ...interface{}) {}
if c.verboseFlag {
verbose = func(format string, a ...interface{}) {
// Use Warn instead of Info because Info goes to stdout
c.Ui.Warn(fmt.Sprintf(format, a...))
}
}
// Opportunistically read from stdin and POST unless method has been
// explicitly set.
stat, _ := Stdin.Stat()
if (stat.Mode() & os.ModeCharDevice) == 0 {
verbose("* Reading request body from stdin.")
// Load stdin into a *bytes.Reader so that http.NewRequest can set the
// correct Content-Length value.
b, err := io.ReadAll(Stdin)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error reading stdin: %v", err))
return 1
}
c.body = bytes.NewReader(b)
if c.method == "" {
c.method = "POST"
}
} else if c.method == "" {
c.method = "GET"
}
config := c.clientConfig()
// NewClient mutates or validates Config.Address, so call it to match
// the behavior of other commands.
_, err := api.NewClient(config)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error initializing client: %v", err))
return 1
}
path, err := pathToURL(config, args[0])
if err != nil {
c.Ui.Error(fmt.Sprintf("Error turning path into URL: %v", err))
return 1
}
// Set Filter query param
if filter != "" {
q := path.Query()
q.Set("filter", filter)
path.RawQuery = q.Encode()
}
if dryrun {
out, err := c.apiToCurl(config, headerFlags.headers, path)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error creating curl command: %v", err))
return 1
}
c.Ui.Output(out)
return 0
}
// Re-implement a big chunk of api/api.go since we don't export it.
client := cleanhttp.DefaultClient()
transport := client.Transport.(*http.Transport)
transport.TLSHandshakeTimeout = 10 * time.Second
transport.TLSClientConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
}
if err := api.ConfigureTLS(client, config.TLSConfig); err != nil {
c.Ui.Error(fmt.Sprintf("Error configuring TLS: %v", err))
return 1
}
setQueryParams(config, path)
verbose("> %s %s", c.method, path)
req, err := http.NewRequest(c.method, path.String(), c.body)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error making request: %v", err))
return 1
}
// Set headers from command line
req.Header = headerFlags.headers
// Add token header if it doesn't already exist and is set
if req.Header.Get("X-Nomad-Token") == "" && config.SecretID != "" {
req.Header.Set("X-Nomad-Token", config.SecretID)
}
// Configure HTTP basic authentication if set
if path.User != nil {
username := path.User.Username()
password, _ := path.User.Password()
req.SetBasicAuth(username, password)
} else if config.HttpAuth != nil {
req.SetBasicAuth(config.HttpAuth.Username, config.HttpAuth.Password)
}
for k, vals := range req.Header {
for _, v := range vals {
verbose("> %s: %s", k, v)
}
}
verbose("* Sending request and receiving response...")
// Do the request!
resp, err := client.Do(req)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error performing request: %v", err))
return 1
}
defer resp.Body.Close()
verbose("< %s %s", resp.Proto, resp.Status)
for k, vals := range resp.Header {
for _, v := range vals {
verbose("< %s: %s", k, v)
}
}
n, err := io.Copy(os.Stdout, resp.Body)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error reading response after %d bytes: %v", n, err))
return 1
}
if len(resp.Trailer) > 0 {
verbose("* Trailer Headers")
for k, vals := range resp.Trailer {
for _, v := range vals {
verbose("< %s: %s", k, v)
}
}
}
return 0
}
// setQueryParams converts API configuration to query parameters. Updates path
// parameter in place.
func setQueryParams(config *api.Config, path *url.URL) {
queryParams := path.Query()
// Prefer region explicitly set in path, otherwise fallback to config
// if one is set.
if queryParams.Get("region") == "" && config.Region != "" {
queryParams["region"] = []string{config.Region}
}
// Prefer namespace explicitly set in path, otherwise fallback to
// config if one is set.
if queryParams.Get("namespace") == "" && config.Namespace != "" {
queryParams["namespace"] = []string{config.Namespace}
}
// Re-encode query parameters
path.RawQuery = queryParams.Encode()
}
// apiToCurl converts a Nomad HTTP API config and path to its corresponding
// curl command or returns an error.
func (c *OperatorAPICommand) apiToCurl(config *api.Config, headers http.Header, path *url.URL) (string, error) {
parts := []string{"curl"}
if c.verboseFlag {
parts = append(parts, "--verbose")
}
if c.method != "" {
parts = append(parts, "-X "+c.method)
}
if c.body != nil {
parts = append(parts, "--data-binary @-")
}
if config.TLSConfig != nil {
parts = tlsToCurl(parts, config.TLSConfig)
// If a TLS server name is set we must alter the URL and use
// curl's --connect-to flag.
if v := config.TLSConfig.TLSServerName; v != "" {
pathHost, port, err := net.SplitHostPort(path.Host)
if err != nil {
return "", fmt.Errorf("error determining port: %v", err)
}
// curl uses the url for SNI so override it with the
// configured server name
path.Host = net.JoinHostPort(v, port)
// curl uses --connect-to to allow specifying a
// different connection address for the hostname in the
// path. The format is:
// logical-host:logical-port:actual-host:actual-port
// Ports will always match since only the hostname is
// overridden for SNI.
parts = append(parts, fmt.Sprintf(`--connect-to "%s:%s:%s:%s"`,
v, port, pathHost, port))
}
}
// Add headers
for k, vals := range headers {
for _, v := range vals {
parts = append(parts, fmt.Sprintf(`-H '%s: %s'`, k, v))
}
}
// Only write NOMAD_TOKEN to stdout if it was specified via -token.
// Otherwise output a static string that references the ACL token
// environment variable.
if headers.Get("X-Nomad-Token") == "" {
if c.Meta.token != "" {
parts = append(parts, fmt.Sprintf(`-H 'X-Nomad-Token: %s'`, c.Meta.token))
} else if v := os.Getenv("NOMAD_TOKEN"); v != "" {
parts = append(parts, `-H "X-Nomad-Token: ${NOMAD_TOKEN}"`)
}
}
// Never write http auth to stdout. Instead output a static string that
// references the HTTP auth environment variable.
if auth := os.Getenv("NOMAD_HTTP_AUTH"); auth != "" {
parts = append(parts, `-u "$NOMAD_HTTP_AUTH"`)
}
setQueryParams(config, path)
parts = append(parts, path.String())
return strings.Join(parts, " \\\n "), nil
}
// tlsToCurl converts TLS configuration to their corresponding curl flags.
func tlsToCurl(parts []string, tlsConfig *api.TLSConfig) []string {
if v := tlsConfig.CACert; v != "" {
parts = append(parts, fmt.Sprintf(`--cacert "%s"`, v))
}
if v := tlsConfig.CAPath; v != "" {
parts = append(parts, fmt.Sprintf(`--capath "%s"`, v))
}
if v := tlsConfig.ClientCert; v != "" {
parts = append(parts, fmt.Sprintf(`--cert "%s"`, v))
}
if v := tlsConfig.ClientKey; v != "" {
parts = append(parts, fmt.Sprintf(`--key "%s"`, v))
}
// TLSServerName has already been configured as it may change the path.
if tlsConfig.Insecure {
parts = append(parts, `--insecure`)
}
return parts
}
// pathToURL converts a curl path argument to URL. Paths without a host are
// prefixed with $NOMAD_ADDR or http://127.0.0.1:4646.
//
// Callers should pass a config generated by Meta.clientConfig which ensures
// all default values are set correctly. Failure to do so will likely result in
// a nil-pointer.
func pathToURL(config *api.Config, path string) (*url.URL, error) {
// If the scheme is missing from the path, it likely means the path is just
// the HTTP handler path. Attempt to infer this.
if !strings.HasPrefix(path, "http://") && !strings.HasPrefix(path, "https://") {
scheme := "http"
// If the user has set any TLS configuration value, this is a good sign
// Nomad is running with TLS enabled. Otherwise, use the address within
// the config to identify a scheme.
if config.TLSConfig.CACert != "" ||
config.TLSConfig.CAPath != "" ||
config.TLSConfig.ClientCert != "" ||
config.TLSConfig.TLSServerName != "" ||
config.TLSConfig.Insecure {
// TLS configured, but scheme not set. Assume https.
scheme = "https"
} else if config.Address != "" {
confURL, err := url.Parse(config.Address)
if err != nil {
return nil, fmt.Errorf("unable to parse configured address: %v", err)
}
// Ensure we only overwrite the set scheme value if the parsing
// identified a valid scheme.
if confURL.Scheme == "http" || confURL.Scheme == "https" {
scheme = confURL.Scheme
}
}
path = fmt.Sprintf("%s://%s", scheme, path)
}
u, err := url.Parse(path)
if err != nil {
return nil, err
}
// If URL.Host is empty, use defaults from client config.
if u.Host == "" {
confURL, err := url.Parse(config.Address)
if err != nil {
return nil, fmt.Errorf("Unable to parse configured address: %v", err)
}
u.Host = confURL.Host
}
return u, nil
}
// headerFlags is a flag.Value implementation for collecting multiple -H flags.
type headerFlags struct {
headers http.Header
}
func newHeaderFlags() *headerFlags {
return &headerFlags{
headers: make(http.Header),
}
}
func (*headerFlags) String() string { return "" }
func (h *headerFlags) Set(v string) error {
parts := strings.SplitN(v, ":", 2)
if len(parts) != 2 {
return fmt.Errorf("Headers must be in the form 'Key: Value' but found: %q", v)
}
h.headers.Add(parts[0], strings.TrimSpace(parts[1]))
return nil
}