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  }