github.com/hernad/nomad@v1.6.112/command/var_put.go (about)

     1  // Copyright (c) HashiCorp, Inc.
     2  // SPDX-License-Identifier: MPL-2.0
     3  
     4  package command
     5  
     6  import (
     7  	"encoding/json"
     8  	"errors"
     9  	"fmt"
    10  	"io"
    11  	"os"
    12  	"path/filepath"
    13  	"regexp"
    14  	"strings"
    15  
    16  	multierror "github.com/hashicorp/go-multierror"
    17  	"github.com/hashicorp/go-set"
    18  	"github.com/hashicorp/hcl"
    19  	"github.com/hashicorp/hcl/hcl/ast"
    20  	"github.com/hernad/nomad/api"
    21  	"github.com/hernad/nomad/helper"
    22  	"github.com/mitchellh/cli"
    23  	"github.com/mitchellh/mapstructure"
    24  	"github.com/posener/complete"
    25  	"golang.org/x/exp/slices"
    26  )
    27  
    28  // Detect characters that are not valid identifiers to emit a warning when they
    29  // are used in as a variable key.
    30  var invalidIdentifier = regexp.MustCompile(`[^_\pN\pL]`)
    31  
    32  type VarPutCommand struct {
    33  	Meta
    34  
    35  	contents  []byte
    36  	inFmt     string
    37  	outFmt    string
    38  	tmpl      string
    39  	testStdin io.Reader // for tests
    40  	verbose   func(string)
    41  }
    42  
    43  func (c *VarPutCommand) Help() string {
    44  	helpText := `
    45  Usage:
    46  nomad var put [options] <variable spec file reference> [<key>=<value>]...
    47  nomad var put [options] <path to store variable> [<variable spec file reference>] [<key>=<value>]...
    48  
    49    The 'var put' command is used to create or update an existing variable.
    50    Variable metadata and items can be supplied using a variable specification,
    51    by using command arguments, or by a combination of the two techniques.
    52  
    53    An entire variable specification can be provided to the command via standard
    54    input (stdin) by setting the first argument to "-" or from a file by using an
    55    @-prefixed path to a variable specification file. When providing variable
    56    data via stdin, you must provide the "-in" flag with the format of the
    57    specification, either "hcl" or "json"
    58  
    59    Items to be stored in the variable can be supplied using the specification,
    60    as a series of key-value pairs, or both. The value for a key-value pair can
    61    be a string, an @-prefixed file reference, or a '-' to get the value from
    62    stdin. Item values provided from file references or stdin are consumed as-is
    63    with no additional processing and do not require the input format to be
    64    specified.
    65  
    66    Values supplied as command line arguments supersede values provided in
    67    any variable specification piped into the command or loaded from file.
    68  
    69    If ACLs are enabled, this command requires the 'variables:write' capability
    70    for the destination namespace and path.
    71  
    72  General Options:
    73  
    74    ` + generalOptionsUsage(usageOptsDefault) + `
    75  
    76  Apply Options:
    77  
    78    -check-index
    79       If set, the variable is only acted upon if the server-side version's index
    80       matches the provided value. When a variable specification contains
    81       a modify index, that modify index is used as the check-index for the
    82       check-and-set operation and can be overridden using this flag.
    83  
    84    -force
    85       Perform this operation regardless of the state or index of the variable
    86       on the server-side.
    87  
    88    -in (hcl | json)
    89       Parser to use for data supplied via standard input or when the variable
    90       specification's type can not be known using the file extension. Defaults
    91       to "json".
    92  
    93    -out (go-template | hcl | json | none | table)
    94       Format to render created or updated variable. Defaults to "none" when
    95       stdout is a terminal and "json" when the output is redirected.
    96  
    97    -template
    98       Template to render output with. Required when format is "go-template",
    99       invalid for other formats.
   100  
   101    -verbose
   102       Provides additional information via standard error to preserve standard
   103       output (stdout) for redirected output.
   104  
   105  `
   106  	return strings.TrimSpace(helpText)
   107  }
   108  
   109  func (c *VarPutCommand) AutocompleteFlags() complete.Flags {
   110  	return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient),
   111  		complete.Flags{
   112  			"-in":  complete.PredictSet("hcl", "json"),
   113  			"-out": complete.PredictSet("none", "hcl", "json", "go-template", "table"),
   114  		},
   115  	)
   116  }
   117  
   118  func (c *VarPutCommand) AutocompleteArgs() complete.Predictor {
   119  	return VariablePathPredictor(c.Meta.Client)
   120  }
   121  
   122  func (c *VarPutCommand) Synopsis() string {
   123  	return "Create or update a variable"
   124  }
   125  
   126  func (c *VarPutCommand) Name() string { return "var put" }
   127  
   128  func (c *VarPutCommand) Run(args []string) int {
   129  	var force, enforce, doVerbose bool
   130  	var path, checkIndexStr string
   131  	var checkIndex uint64
   132  	var err error
   133  
   134  	flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
   135  	flags.Usage = func() { c.Ui.Output(c.Help()) }
   136  
   137  	flags.BoolVar(&force, "force", false, "")
   138  	flags.BoolVar(&doVerbose, "verbose", false, "")
   139  	flags.StringVar(&checkIndexStr, "check-index", "", "")
   140  	flags.StringVar(&c.inFmt, "in", "json", "")
   141  	flags.StringVar(&c.tmpl, "template", "", "")
   142  
   143  	if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
   144  		flags.StringVar(&c.outFmt, "out", "none", "")
   145  	} else {
   146  		flags.StringVar(&c.outFmt, "out", "json", "")
   147  	}
   148  
   149  	if err := flags.Parse(args); err != nil {
   150  		c.Ui.Error(commandErrorText(c))
   151  		return 1
   152  	}
   153  
   154  	args = flags.Args()
   155  
   156  	// Manage verbose output
   157  	verbose := func(_ string) {} //no-op
   158  	if doVerbose {
   159  		verbose = func(msg string) {
   160  			c.Ui.Warn(msg)
   161  		}
   162  	}
   163  	c.verbose = verbose
   164  
   165  	// Parse the check-index
   166  	checkIndex, enforce, err = parseCheckIndex(checkIndexStr)
   167  	if err != nil {
   168  		c.Ui.Error(fmt.Sprintf("Error parsing check-index value %q: %v", checkIndexStr, err))
   169  		return 1
   170  	}
   171  
   172  	if c.Meta.namespace == "*" {
   173  		c.Ui.Error(errWildcardNamespaceNotAllowed)
   174  		return 1
   175  	}
   176  
   177  	// Pull our fake stdin if needed
   178  	stdin := (io.Reader)(os.Stdin)
   179  	if c.testStdin != nil {
   180  		stdin = c.testStdin
   181  	}
   182  
   183  	switch {
   184  	case len(args) < 1:
   185  		c.Ui.Error(fmt.Sprintf("Not enough arguments (expected >1, got %d)", len(args)))
   186  		c.Ui.Error(commandErrorText(c))
   187  		return 1
   188  	case len(args) == 1 && !isArgStdinRef(args[0]) && !isArgFileRef(args[0]):
   189  		c.Ui.Error("Must supply data")
   190  		c.Ui.Error(commandErrorText(c))
   191  		return 1
   192  	}
   193  
   194  	if err = c.validateInputFlag(); err != nil {
   195  		c.Ui.Error(err.Error())
   196  		c.Ui.Error(commandErrorText(c))
   197  		return 1
   198  	}
   199  
   200  	if err := c.validateOutputFlag(); err != nil {
   201  		c.Ui.Error(err.Error())
   202  		c.Ui.Error(commandErrorText(c))
   203  		return 1
   204  	}
   205  
   206  	arg := args[0]
   207  	switch {
   208  	// Handle first argument: can be -, @file, «var path»
   209  	case isArgStdinRef(arg):
   210  
   211  		// read the specification into memory from stdin
   212  		stat, _ := os.Stdin.Stat()
   213  		if (stat.Mode() & os.ModeCharDevice) == 0 {
   214  			c.contents, err = io.ReadAll(os.Stdin)
   215  			if err != nil {
   216  				c.Ui.Error(fmt.Sprintf("Error reading from stdin: %s", err))
   217  				return 1
   218  			}
   219  		}
   220  		verbose(fmt.Sprintf("Reading whole %s variable specification from stdin", strings.ToUpper(c.inFmt)))
   221  
   222  	case isArgFileRef(arg):
   223  		// ArgFileRefs start with "@" so we need to peel that off
   224  		// detect format based on file extension
   225  		specPath := arg[1:]
   226  		err = c.setParserForFileArg(specPath)
   227  		if err != nil {
   228  			c.Ui.Error(err.Error())
   229  			return 1
   230  		}
   231  		verbose(fmt.Sprintf("Reading whole %s variable specification from %q", strings.ToUpper(c.inFmt), specPath))
   232  		c.contents, err = os.ReadFile(specPath)
   233  		if err != nil {
   234  			c.Ui.Error(fmt.Sprintf("Error reading %q: %s", specPath, err))
   235  			return 1
   236  		}
   237  	default:
   238  		path = sanitizePath(arg)
   239  		verbose(fmt.Sprintf("Writing to path %q", path))
   240  	}
   241  
   242  	args = args[1:]
   243  	switch {
   244  	// Handle second argument: can be -, @file, or kv
   245  	case len(args) == 0:
   246  		// no-op
   247  	case isArgStdinRef(args[0]):
   248  		verbose(fmt.Sprintf("Creating variable %q using specification from stdin", path))
   249  		stat, _ := os.Stdin.Stat()
   250  		if (stat.Mode() & os.ModeCharDevice) == 0 {
   251  			c.contents, err = io.ReadAll(os.Stdin)
   252  			if err != nil {
   253  				c.Ui.Error(fmt.Sprintf("Error reading from stdin: %s", err))
   254  				return 1
   255  			}
   256  		}
   257  		args = args[1:]
   258  
   259  	case isArgFileRef(args[0]):
   260  		arg := args[0]
   261  		err = c.setParserForFileArg(arg)
   262  		if err != nil {
   263  			c.Ui.Error(err.Error())
   264  			return 1
   265  		}
   266  		verbose(fmt.Sprintf("Creating variable %q from specification file %q", path, arg))
   267  		fPath := arg[1:]
   268  		c.contents, err = os.ReadFile(fPath)
   269  		if err != nil {
   270  			c.Ui.Error(fmt.Sprintf("error reading %q: %s", fPath, err))
   271  			return 1
   272  		}
   273  		args = args[1:]
   274  	default:
   275  		// no-op - should be KV arg
   276  	}
   277  
   278  	sv, err := c.makeVariable(path)
   279  	if err != nil {
   280  		c.Ui.Error(fmt.Sprintf("Failed to parse variable data: %s", err))
   281  		return 1
   282  	}
   283  
   284  	var warnings *multierror.Error
   285  	if len(args) > 0 {
   286  		data, err := parseArgsData(stdin, args)
   287  		if err != nil {
   288  			c.Ui.Error(fmt.Sprintf("Failed to parse K=V data: %s", err))
   289  			return 1
   290  		}
   291  
   292  		for k, v := range data {
   293  			vs := v.(string)
   294  			if vs == "" {
   295  				if _, ok := sv.Items[k]; ok {
   296  					verbose(fmt.Sprintf("Removed item %q", k))
   297  					delete(sv.Items, k)
   298  				} else {
   299  					verbose(fmt.Sprintf("Item %q does not exist, continuing...", k))
   300  				}
   301  				continue
   302  			}
   303  			if err := warnInvalidIdentifier(k); err != nil {
   304  				warnings = multierror.Append(warnings, err)
   305  			}
   306  			sv.Items[k] = vs
   307  		}
   308  	}
   309  	// Get the HTTP client
   310  	client, err := c.Meta.Client()
   311  	if err != nil {
   312  		c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
   313  		return 1
   314  	}
   315  
   316  	if enforce {
   317  		sv.ModifyIndex = checkIndex
   318  	}
   319  
   320  	if force {
   321  		sv, _, err = client.Variables().Update(sv, nil)
   322  	} else {
   323  		sv, _, err = client.Variables().CheckedUpdate(sv, nil)
   324  	}
   325  	if err != nil {
   326  		if handled := handleCASError(err, c); handled {
   327  			return 1
   328  		}
   329  		c.Ui.Error(fmt.Sprintf("Error creating variable: %s", err))
   330  		return 1
   331  	}
   332  
   333  	successMsg := fmt.Sprintf(
   334  		"Created variable %q with modify index %v", sv.Path, sv.ModifyIndex)
   335  
   336  	if warnings != nil {
   337  		c.Ui.Warn(c.FormatWarnings(
   338  			"Variable",
   339  			helper.MergeMultierrorWarnings(warnings),
   340  		))
   341  	}
   342  
   343  	var out string
   344  	switch c.outFmt {
   345  	case "json":
   346  		out = sv.AsPrettyJSON()
   347  	case "hcl":
   348  		out = renderAsHCL(sv)
   349  	case "go-template":
   350  		if out, err = renderWithGoTemplate(sv, c.tmpl); err != nil {
   351  			c.Ui.Error(err.Error())
   352  			return 1
   353  		}
   354  	case "table":
   355  		// the renderSVAsUiTable func writes directly to the ui and doesn't error.
   356  		verbose(successMsg)
   357  		renderSVAsUiTable(sv, c)
   358  		return 0
   359  	default:
   360  		c.Ui.Output(successMsg)
   361  		return 0
   362  	}
   363  	verbose(successMsg)
   364  	c.Ui.Output(out)
   365  	return 0
   366  }
   367  
   368  // makeVariable creates a variable based on whether or not there is data in
   369  // content and the format is set.
   370  func (c *VarPutCommand) makeVariable(path string) (*api.Variable, error) {
   371  	var err error
   372  	out := new(api.Variable)
   373  	if len(c.contents) == 0 {
   374  		out.Path = path
   375  		out.Namespace = c.Meta.namespace
   376  		out.Items = make(map[string]string)
   377  		return out, nil
   378  	}
   379  	switch c.inFmt {
   380  	case "json":
   381  		err = json.Unmarshal(c.contents, out)
   382  		if err != nil {
   383  			return nil, fmt.Errorf("error unmarshaling json: %w", err)
   384  		}
   385  	case "hcl":
   386  		out, err = parseVariableSpec(c.contents, c.verbose)
   387  		if err != nil {
   388  			return nil, fmt.Errorf("error parsing hcl: %w", err)
   389  		}
   390  	case "":
   391  		return nil, errors.New("format flag required")
   392  	default:
   393  		return nil, fmt.Errorf("unknown format flag value")
   394  	}
   395  
   396  	// Handle cases where values are provided by CLI flags that modify the
   397  	// the created variable. Typical of a "copy" operation, it is a convenience
   398  	// to reset the Create and Modify metadata to zero.
   399  	var resetIndex bool
   400  
   401  	// Step on the namespace in the object if one is provided by flag
   402  	if c.Meta.namespace != "" && c.Meta.namespace != out.Namespace {
   403  		out.Namespace = c.Meta.namespace
   404  		resetIndex = true
   405  	}
   406  
   407  	// Step on the path in the object if one is provided by argument.
   408  	if path != "" && path != out.Path {
   409  		out.Path = path
   410  		resetIndex = true
   411  	}
   412  
   413  	if resetIndex {
   414  		out.CreateIndex = 0
   415  		out.CreateTime = 0
   416  		out.ModifyIndex = 0
   417  		out.ModifyTime = 0
   418  	}
   419  	return out, nil
   420  }
   421  
   422  // parseVariableSpec is used to parse the variable specification
   423  // from HCL
   424  func parseVariableSpec(input []byte, verbose func(string)) (*api.Variable, error) {
   425  	root, err := hcl.ParseBytes(input)
   426  	if err != nil {
   427  		return nil, err
   428  	}
   429  
   430  	// Top-level item should be a list
   431  	list, ok := root.Node.(*ast.ObjectList)
   432  	if !ok {
   433  		return nil, fmt.Errorf("error parsing: root should be an object")
   434  	}
   435  
   436  	var out api.Variable
   437  	if err := parseVariableSpecImpl(&out, list); err != nil {
   438  		return nil, err
   439  	}
   440  	return &out, nil
   441  }
   442  
   443  // parseVariableSpecImpl parses the variable taking as input the AST tree
   444  func parseVariableSpecImpl(result *api.Variable, list *ast.ObjectList) error {
   445  	// Decode the full thing into a map[string]interface for ease
   446  	var m map[string]interface{}
   447  	if err := hcl.DecodeObject(&m, list); err != nil {
   448  		return err
   449  	}
   450  
   451  	// Check for invalid keys
   452  	valid := []string{
   453  		"namespace",
   454  		"path",
   455  		"create_index",
   456  		"modify_index",
   457  		"create_time",
   458  		"modify_time",
   459  		"items",
   460  	}
   461  	if err := helper.CheckHCLKeys(list, valid); err != nil {
   462  		return err
   463  	}
   464  
   465  	for _, index := range []string{"create_index", "modify_index"} {
   466  		if value, ok := m[index]; ok {
   467  			vInt, ok := value.(int)
   468  			if !ok {
   469  				return fmt.Errorf("%s must be integer; got (%T) %[2]v", index, value)
   470  			}
   471  			idx := uint64(vInt)
   472  			n := strings.ReplaceAll(strings.Title(strings.ReplaceAll(index, "_", " ")), " ", "")
   473  			m[n] = idx
   474  			delete(m, index)
   475  		}
   476  	}
   477  
   478  	for _, index := range []string{"create_time", "modify_time"} {
   479  		if value, ok := m[index]; ok {
   480  			vInt, ok := value.(int)
   481  			if !ok {
   482  				return fmt.Errorf("%s must be a int64; got a (%T) %[2]v", index, value)
   483  			}
   484  			n := strings.ReplaceAll(strings.Title(strings.ReplaceAll(index, "_", " ")), " ", "")
   485  			m[n] = vInt
   486  			delete(m, index)
   487  		}
   488  	}
   489  
   490  	// Decode the rest
   491  	if err := mapstructure.WeakDecode(m, result); err != nil {
   492  		return err
   493  	}
   494  
   495  	return nil
   496  }
   497  
   498  func isArgFileRef(a string) bool {
   499  	return strings.HasPrefix(a, "@") && !strings.HasPrefix(a, "\\@")
   500  }
   501  
   502  func isArgStdinRef(a string) bool {
   503  	return a == "-"
   504  }
   505  
   506  // sanitizePath removes any leading or trailing things from a "path".
   507  func sanitizePath(s string) string {
   508  	return strings.Trim(strings.TrimSpace(s), "/")
   509  }
   510  
   511  // parseArgsData parses the given args in the format key=value into a map of
   512  // the provided arguments. The given reader can also supply key=value pairs.
   513  func parseArgsData(stdin io.Reader, args []string) (map[string]interface{}, error) {
   514  	builder := &KVBuilder{Stdin: stdin}
   515  	if err := builder.Add(args...); err != nil {
   516  		return nil, err
   517  	}
   518  	return builder.Map(), nil
   519  }
   520  
   521  func (c *VarPutCommand) GetConcurrentUI() cli.ConcurrentUi {
   522  	return cli.ConcurrentUi{Ui: c.Ui}
   523  }
   524  
   525  func (c *VarPutCommand) setParserForFileArg(arg string) error {
   526  	switch filepath.Ext(arg) {
   527  	case ".json":
   528  		c.inFmt = "json"
   529  	case ".hcl":
   530  		c.inFmt = "hcl"
   531  	default:
   532  		return fmt.Errorf("Unable to determine format of %s; Use the -in flag to specify it.", arg)
   533  	}
   534  	return nil
   535  }
   536  
   537  func (c *VarPutCommand) validateInputFlag() error {
   538  	switch c.inFmt {
   539  	case "hcl", "json":
   540  		return nil
   541  	default:
   542  		return errors.New(errInvalidInFormat)
   543  	}
   544  }
   545  
   546  func (c *VarPutCommand) validateOutputFlag() error {
   547  	if c.outFmt != "go-template" && c.tmpl != "" {
   548  		return errors.New(errUnexpectedTemplate)
   549  	}
   550  	switch c.outFmt {
   551  	case "none", "json", "hcl", "table":
   552  		return nil
   553  	case "go-template":
   554  		if c.tmpl == "" {
   555  			return errors.New(errMissingTemplate)
   556  		}
   557  		return nil
   558  	default:
   559  		return errors.New(errInvalidOutFormat)
   560  	}
   561  }
   562  
   563  func warnInvalidIdentifier(in string) error {
   564  	invalid := invalidIdentifier.FindAllString(in, -1)
   565  	if len(invalid) == 0 {
   566  		return nil
   567  	}
   568  
   569  	// Use %s instead of %q to avoid escaping characters.
   570  	return fmt.Errorf(
   571  		`"%s" contains characters %s that require the 'index' function for direct access in templates`,
   572  		in,
   573  		formatInvalidVarKeyChars(invalid),
   574  	)
   575  }
   576  
   577  func formatInvalidVarKeyChars(invalid []string) string {
   578  	// Deduplicate characters
   579  	chars := set.From(invalid)
   580  
   581  	// Sort the characters for output
   582  	charList := make([]string, 0, chars.Size())
   583  	for _, k := range chars.List() {
   584  		// Use %s instead of %q to avoid escaping characters.
   585  		charList = append(charList, fmt.Sprintf(`"%s"`, k))
   586  	}
   587  	slices.Sort(charList)
   588  
   589  	// Build string
   590  	return fmt.Sprintf("[%s]", strings.Join(charList, ","))
   591  }