github.com/anth0d/nomad@v0.0.0-20221214183521-ae3a0a2cad06/command/operator_api.go (about) 1 package command 2 3 import ( 4 "bytes" 5 "crypto/tls" 6 "fmt" 7 "io" 8 "io/ioutil" 9 "net" 10 "net/http" 11 "net/url" 12 "os" 13 "strings" 14 "time" 15 16 "github.com/hashicorp/go-cleanhttp" 17 "github.com/hashicorp/nomad/api" 18 "github.com/posener/complete" 19 ) 20 21 // Stdin represents the system's standard input, but it's declared as a 22 // variable here to allow tests to override it with a regular file. 23 var Stdin = os.Stdin 24 25 type OperatorAPICommand struct { 26 Meta 27 28 verboseFlag bool 29 method string 30 body io.Reader 31 } 32 33 func (*OperatorAPICommand) Help() string { 34 helpText := ` 35 Usage: nomad operator api [options] <path> 36 37 api is a utility command for accessing Nomad's HTTP API and is inspired by 38 the popular curl command line tool. Nomad's operator api command populates 39 Nomad's standard environment variables into their appropriate HTTP headers. 40 If the 'path' does not begin with "http" then $NOMAD_ADDR will be used. 41 42 The 'path' can be in one of the following forms: 43 44 /v1/allocations <- API Paths must start with a / 45 localhost:4646/v1/allocations <- Scheme will be inferred 46 https://localhost:4646/v1/allocations <- Scheme will be https:// 47 48 Note that this command does not always match the popular curl program's 49 behavior. Instead Nomad's operator api command is optimized for common Nomad 50 HTTP API operations. 51 52 General Options: 53 54 ` + generalOptionsUsage(usageOptsDefault) + ` 55 56 Operator API Specific Options: 57 58 -dryrun 59 Output equivalent curl command to stdout and exit. 60 HTTP Basic Auth will never be output. If the $NOMAD_HTTP_AUTH environment 61 variable is set, it will be referenced in the appropriate curl flag in the 62 output. 63 ACL tokens set via the $NOMAD_TOKEN environment variable will only be 64 referenced by environment variable as with HTTP Basic Auth above. However 65 if the -token flag is explicitly used, the token will also be included in 66 the output. 67 68 -filter <query> 69 Specifies an expression used to filter query results. 70 71 -H <Header> 72 Adds an additional HTTP header to the request. May be specified more than 73 once. These headers take precedence over automatically set ones such as 74 X-Nomad-Token. 75 76 -verbose 77 Output extra information to stderr similar to curl's --verbose flag. 78 79 -X <HTTP Method> 80 HTTP method of request. If there is data piped to stdin, then the method 81 defaults to POST. Otherwise the method defaults to GET. 82 ` 83 84 return strings.TrimSpace(helpText) 85 } 86 87 func (*OperatorAPICommand) Synopsis() string { 88 return "Query Nomad's HTTP API" 89 } 90 91 func (c *OperatorAPICommand) AutocompleteFlags() complete.Flags { 92 return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient), 93 complete.Flags{ 94 "-dryrun": complete.PredictNothing, 95 }) 96 } 97 98 func (c *OperatorAPICommand) AutocompleteArgs() complete.Predictor { 99 //TODO(schmichael) wouldn't it be cool to build path autocompletion off 100 // of our http mux? 101 return complete.PredictNothing 102 } 103 104 func (*OperatorAPICommand) Name() string { return "operator api" } 105 106 func (c *OperatorAPICommand) Run(args []string) int { 107 var dryrun bool 108 var filter string 109 headerFlags := newHeaderFlags() 110 111 flags := c.Meta.FlagSet(c.Name(), FlagSetClient) 112 flags.Usage = func() { c.Ui.Output(c.Help()) } 113 flags.BoolVar(&dryrun, "dryrun", false, "") 114 flags.StringVar(&filter, "filter", "", "") 115 flags.BoolVar(&c.verboseFlag, "verbose", false, "") 116 flags.StringVar(&c.method, "X", "", "") 117 flags.Var(headerFlags, "H", "") 118 119 if err := flags.Parse(args); err != nil { 120 c.Ui.Error(fmt.Sprintf("Error parsing flags: %v", err)) 121 return 1 122 } 123 args = flags.Args() 124 125 if len(args) < 1 { 126 c.Ui.Error("A path or URL is required") 127 c.Ui.Error(commandErrorText(c)) 128 return 1 129 } 130 131 if n := len(args); n > 1 { 132 c.Ui.Error(fmt.Sprintf("operator api accepts exactly 1 argument, but %d arguments were found", n)) 133 c.Ui.Error(commandErrorText(c)) 134 return 1 135 } 136 137 // By default verbose func is a noop 138 verbose := func(string, ...interface{}) {} 139 if c.verboseFlag { 140 verbose = func(format string, a ...interface{}) { 141 // Use Warn instead of Info because Info goes to stdout 142 c.Ui.Warn(fmt.Sprintf(format, a...)) 143 } 144 } 145 146 // Opportunistically read from stdin and POST unless method has been 147 // explicitly set. 148 stat, _ := Stdin.Stat() 149 if (stat.Mode() & os.ModeCharDevice) == 0 { 150 verbose("* Reading request body from stdin.") 151 152 // Load stdin into a *bytes.Reader so that http.NewRequest can set the 153 // correct Content-Length value. 154 b, err := ioutil.ReadAll(Stdin) 155 if err != nil { 156 c.Ui.Error(fmt.Sprintf("Error reading stdin: %v", err)) 157 return 1 158 } 159 c.body = bytes.NewReader(b) 160 if c.method == "" { 161 c.method = "POST" 162 } 163 } else if c.method == "" { 164 c.method = "GET" 165 } 166 167 config := c.clientConfig() 168 169 // NewClient mutates or validates Config.Address, so call it to match 170 // the behavior of other commands. 171 _, err := api.NewClient(config) 172 if err != nil { 173 c.Ui.Error(fmt.Sprintf("Error initializing client: %v", err)) 174 return 1 175 } 176 177 path, err := pathToURL(config, args[0]) 178 if err != nil { 179 c.Ui.Error(fmt.Sprintf("Error turning path into URL: %v", err)) 180 return 1 181 } 182 183 // Set Filter query param 184 if filter != "" { 185 q := path.Query() 186 q.Set("filter", filter) 187 path.RawQuery = q.Encode() 188 } 189 190 if dryrun { 191 out, err := c.apiToCurl(config, headerFlags.headers, path) 192 if err != nil { 193 c.Ui.Error(fmt.Sprintf("Error creating curl command: %v", err)) 194 return 1 195 } 196 c.Ui.Output(out) 197 return 0 198 } 199 200 // Re-implement a big chunk of api/api.go since we don't export it. 201 client := cleanhttp.DefaultClient() 202 transport := client.Transport.(*http.Transport) 203 transport.TLSHandshakeTimeout = 10 * time.Second 204 transport.TLSClientConfig = &tls.Config{ 205 MinVersion: tls.VersionTLS12, 206 } 207 208 if err := api.ConfigureTLS(client, config.TLSConfig); err != nil { 209 c.Ui.Error(fmt.Sprintf("Error configuring TLS: %v", err)) 210 return 1 211 } 212 213 setQueryParams(config, path) 214 215 verbose("> %s %s", c.method, path) 216 217 req, err := http.NewRequest(c.method, path.String(), c.body) 218 if err != nil { 219 c.Ui.Error(fmt.Sprintf("Error making request: %v", err)) 220 return 1 221 } 222 223 // Set headers from command line 224 req.Header = headerFlags.headers 225 226 // Add token header if it doesn't already exist and is set 227 if req.Header.Get("X-Nomad-Token") == "" && config.SecretID != "" { 228 req.Header.Set("X-Nomad-Token", config.SecretID) 229 } 230 231 // Configure HTTP basic authentication if set 232 if path.User != nil { 233 username := path.User.Username() 234 password, _ := path.User.Password() 235 req.SetBasicAuth(username, password) 236 } else if config.HttpAuth != nil { 237 req.SetBasicAuth(config.HttpAuth.Username, config.HttpAuth.Password) 238 } 239 240 for k, vals := range req.Header { 241 for _, v := range vals { 242 verbose("> %s: %s", k, v) 243 } 244 } 245 246 verbose("* Sending request and receiving response...") 247 248 // Do the request! 249 resp, err := client.Do(req) 250 if err != nil { 251 c.Ui.Error(fmt.Sprintf("Error performing request: %v", err)) 252 return 1 253 } 254 defer resp.Body.Close() 255 256 verbose("< %s %s", resp.Proto, resp.Status) 257 for k, vals := range resp.Header { 258 for _, v := range vals { 259 verbose("< %s: %s", k, v) 260 } 261 } 262 263 n, err := io.Copy(os.Stdout, resp.Body) 264 if err != nil { 265 c.Ui.Error(fmt.Sprintf("Error reading response after %d bytes: %v", n, err)) 266 return 1 267 } 268 269 if len(resp.Trailer) > 0 { 270 verbose("* Trailer Headers") 271 for k, vals := range resp.Trailer { 272 for _, v := range vals { 273 verbose("< %s: %s", k, v) 274 } 275 } 276 } 277 278 return 0 279 } 280 281 // setQueryParams converts API configuration to query parameters. Updates path 282 // parameter in place. 283 func setQueryParams(config *api.Config, path *url.URL) { 284 queryParams := path.Query() 285 286 // Prefer region explicitly set in path, otherwise fallback to config 287 // if one is set. 288 if queryParams.Get("region") == "" && config.Region != "" { 289 queryParams["region"] = []string{config.Region} 290 } 291 292 // Prefer namespace explicitly set in path, otherwise fallback to 293 // config if one is set. 294 if queryParams.Get("namespace") == "" && config.Namespace != "" { 295 queryParams["namespace"] = []string{config.Namespace} 296 } 297 298 // Re-encode query parameters 299 path.RawQuery = queryParams.Encode() 300 } 301 302 // apiToCurl converts a Nomad HTTP API config and path to its corresponding 303 // curl command or returns an error. 304 func (c *OperatorAPICommand) apiToCurl(config *api.Config, headers http.Header, path *url.URL) (string, error) { 305 parts := []string{"curl"} 306 307 if c.verboseFlag { 308 parts = append(parts, "--verbose") 309 } 310 311 if c.method != "" { 312 parts = append(parts, "-X "+c.method) 313 } 314 315 if c.body != nil { 316 parts = append(parts, "--data-binary @-") 317 } 318 319 if config.TLSConfig != nil { 320 parts = tlsToCurl(parts, config.TLSConfig) 321 322 // If a TLS server name is set we must alter the URL and use 323 // curl's --connect-to flag. 324 if v := config.TLSConfig.TLSServerName; v != "" { 325 pathHost, port, err := net.SplitHostPort(path.Host) 326 if err != nil { 327 return "", fmt.Errorf("error determining port: %v", err) 328 } 329 330 // curl uses the url for SNI so override it with the 331 // configured server name 332 path.Host = net.JoinHostPort(v, port) 333 334 // curl uses --connect-to to allow specifying a 335 // different connection address for the hostname in the 336 // path. The format is: 337 // logical-host:logical-port:actual-host:actual-port 338 // Ports will always match since only the hostname is 339 // overridden for SNI. 340 parts = append(parts, fmt.Sprintf(`--connect-to "%s:%s:%s:%s"`, 341 v, port, pathHost, port)) 342 } 343 } 344 345 // Add headers 346 for k, vals := range headers { 347 for _, v := range vals { 348 parts = append(parts, fmt.Sprintf(`-H '%s: %s'`, k, v)) 349 } 350 } 351 352 // Only write NOMAD_TOKEN to stdout if it was specified via -token. 353 // Otherwise output a static string that references the ACL token 354 // environment variable. 355 if headers.Get("X-Nomad-Token") == "" { 356 if c.Meta.token != "" { 357 parts = append(parts, fmt.Sprintf(`-H 'X-Nomad-Token: %s'`, c.Meta.token)) 358 } else if v := os.Getenv("NOMAD_TOKEN"); v != "" { 359 parts = append(parts, `-H "X-Nomad-Token: ${NOMAD_TOKEN}"`) 360 } 361 } 362 363 // Never write http auth to stdout. Instead output a static string that 364 // references the HTTP auth environment variable. 365 if auth := os.Getenv("NOMAD_HTTP_AUTH"); auth != "" { 366 parts = append(parts, `-u "$NOMAD_HTTP_AUTH"`) 367 } 368 369 setQueryParams(config, path) 370 371 parts = append(parts, path.String()) 372 373 return strings.Join(parts, " \\\n "), nil 374 } 375 376 // tlsToCurl converts TLS configuration to their corresponding curl flags. 377 func tlsToCurl(parts []string, tlsConfig *api.TLSConfig) []string { 378 if v := tlsConfig.CACert; v != "" { 379 parts = append(parts, fmt.Sprintf(`--cacert "%s"`, v)) 380 } 381 382 if v := tlsConfig.CAPath; v != "" { 383 parts = append(parts, fmt.Sprintf(`--capath "%s"`, v)) 384 } 385 386 if v := tlsConfig.ClientCert; v != "" { 387 parts = append(parts, fmt.Sprintf(`--cert "%s"`, v)) 388 } 389 390 if v := tlsConfig.ClientKey; v != "" { 391 parts = append(parts, fmt.Sprintf(`--key "%s"`, v)) 392 } 393 394 // TLSServerName has already been configured as it may change the path. 395 396 if tlsConfig.Insecure { 397 parts = append(parts, `--insecure`) 398 } 399 400 return parts 401 } 402 403 // pathToURL converts a curl path argument to URL. Paths without a host are 404 // prefixed with $NOMAD_ADDR or http://127.0.0.1:4646. 405 // 406 // Callers should pass a config generated by Meta.clientConfig which ensures 407 // all default values are set correctly. Failure to do so will likely result in 408 // a nil-pointer. 409 func pathToURL(config *api.Config, path string) (*url.URL, error) { 410 411 // If the scheme is missing from the path, it likely means the path is just 412 // the HTTP handler path. Attempt to infer this. 413 if !strings.HasPrefix(path, "http://") && !strings.HasPrefix(path, "https://") { 414 scheme := "http" 415 416 // If the user has set any TLS configuration value, this is a good sign 417 // Nomad is running with TLS enabled. Otherwise, use the address within 418 // the config to identify a scheme. 419 if config.TLSConfig.CACert != "" || 420 config.TLSConfig.CAPath != "" || 421 config.TLSConfig.ClientCert != "" || 422 config.TLSConfig.TLSServerName != "" || 423 config.TLSConfig.Insecure { 424 425 // TLS configured, but scheme not set. Assume https. 426 scheme = "https" 427 } else if config.Address != "" { 428 429 confURL, err := url.Parse(config.Address) 430 if err != nil { 431 return nil, fmt.Errorf("unable to parse configured address: %v", err) 432 } 433 434 // Ensure we only overwrite the set scheme value if the parsing 435 // identified a valid scheme. 436 if confURL.Scheme == "http" || confURL.Scheme == "https" { 437 scheme = confURL.Scheme 438 } 439 } 440 441 path = fmt.Sprintf("%s://%s", scheme, path) 442 } 443 444 u, err := url.Parse(path) 445 if err != nil { 446 return nil, err 447 } 448 449 // If URL.Host is empty, use defaults from client config. 450 if u.Host == "" { 451 confURL, err := url.Parse(config.Address) 452 if err != nil { 453 return nil, fmt.Errorf("Unable to parse configured address: %v", err) 454 } 455 u.Host = confURL.Host 456 } 457 458 return u, nil 459 } 460 461 // headerFlags is a flag.Value implementation for collecting multiple -H flags. 462 type headerFlags struct { 463 headers http.Header 464 } 465 466 func newHeaderFlags() *headerFlags { 467 return &headerFlags{ 468 headers: make(http.Header), 469 } 470 } 471 472 func (*headerFlags) String() string { return "" } 473 474 func (h *headerFlags) Set(v string) error { 475 parts := strings.SplitN(v, ":", 2) 476 if len(parts) != 2 { 477 return fmt.Errorf("Headers must be in the form 'Key: Value' but found: %q", v) 478 } 479 480 h.headers.Add(parts[0], strings.TrimSpace(parts[1])) 481 return nil 482 }