Vars: CLI commands for `var get`, `var put`, `var purge` (#14400)

* Includes updates to `var init`
This commit is contained in:
Charlie Voiselle 2022-09-09 17:55:20 -04:00 committed by GitHub
parent e5454362dc
commit b55112714f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1791 additions and 67 deletions

View File

@ -313,36 +313,36 @@ func (sv *Variables) writeChecked(endpoint string, in *Variable, out *Variable,
// encrypted Nomad backend.
type Variable struct {
// Namespace is the Nomad namespace associated with the variable
Namespace string
Namespace string `hcl:"namespace"`
// Path is the path to the variable
Path string
Path string `hcl:"path"`
// Raft indexes to track creation and modification
CreateIndex uint64
ModifyIndex uint64
CreateIndex uint64 `hcl:"create_index"`
ModifyIndex uint64 `hcl:"modify_index"`
// Times provided as a convenience for operators expressed time.UnixNanos
CreateTime int64
ModifyTime int64
CreateTime int64 `hcl:"create_time"`
ModifyTime int64 `hcl:"modify_time"`
Items VariableItems
Items VariableItems `hcl:"items"`
}
// VariableMetadata specifies the metadata for a variable and
// is used as the list object
type VariableMetadata struct {
// Namespace is the Nomad namespace associated with the variable
Namespace string
Namespace string `hcl:"namespace"`
// Path is the path to the variable
Path string
Path string `hcl:"path"`
// Raft indexes to track creation and modification
CreateIndex uint64
ModifyIndex uint64
CreateIndex uint64 `hcl:"create_index"`
ModifyIndex uint64 `hcl:"modify_index"`
// Times provided as a convenience for operators expressed time.UnixNanos
CreateTime int64
ModifyTime int64
CreateTime int64 `hcl:"create_time"`
ModifyTime int64 `hcl:"modify_time"`
}
type VariableItems map[string]string

View File

@ -936,8 +936,8 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory {
Meta: meta,
}, nil
},
"var list": func() (cli.Command, error) {
return &VarListCommand{
"var purge": func() (cli.Command, error) {
return &VarPurgeCommand{
Meta: meta,
}, nil
},
@ -946,6 +946,21 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory {
Meta: meta,
}, nil
},
"var list": func() (cli.Command, error) {
return &VarListCommand{
Meta: meta,
}, nil
},
"var put": func() (cli.Command, error) {
return &VarPutCommand{
Meta: meta,
}, nil
},
"var get": func() (cli.Command, error) {
return &VarGetCommand{
Meta: meta,
}, nil
},
"version": func() (cli.Command, error) {
return &VersionCommand{
Version: version.GetVersion(),

View File

@ -1,10 +1,23 @@
package command
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"os"
"sort"
"strings"
"text/template"
"time"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/api/contexts"
"github.com/mitchellh/cli"
"github.com/mitchellh/colorstring"
"github.com/mitchellh/mapstructure"
"github.com/posener/complete"
)
@ -41,6 +54,10 @@ Usage: nomad var <subcommand> [options] [args]
$ nomad var list <prefix>
Purge a variable:
$ nomad var purge <path>
Please see the individual subcommand help for detailed usage information.
`
@ -72,3 +89,251 @@ func VariablePathPredictor(factory ApiClientFactory) complete.Predictor {
return resp.Matches[contexts.Variables]
})
}
type VarUI interface {
GetConcurrentUI() cli.ConcurrentUi
Colorize() *colorstring.Colorize
}
// renderSVAsUiTable prints a variable as a table. It needs access to the
// command to get access to colorize and the UI itself. Commands that call it
// need to implement the VarUI interface.
func renderSVAsUiTable(sv *api.Variable, c VarUI) {
meta := []string{
fmt.Sprintf("Namespace|%s", sv.Namespace),
fmt.Sprintf("Path|%s", sv.Path),
fmt.Sprintf("Create Time|%v", formatUnixNanoTime(sv.ModifyTime)),
}
if sv.CreateTime != sv.ModifyTime {
meta = append(meta, fmt.Sprintf("Modify Time|%v", time.Unix(0, sv.ModifyTime)))
}
meta = append(meta, fmt.Sprintf("Check Index|%v", sv.ModifyIndex))
ui := c.GetConcurrentUI()
ui.Output(formatKV(meta))
ui.Output(c.Colorize().Color("\n[bold]Items[reset]"))
items := make([]string, 0, len(sv.Items))
keys := make([]string, 0, len(sv.Items))
for k := range sv.Items {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
items = append(items, fmt.Sprintf("%s|%s", k, sv.Items[k]))
}
ui.Output(formatKV(items))
}
func renderAsHCL(sv *api.Variable) string {
const tpl = `
namespace = "{{.Namespace}}"
path = "{{.Path}}"
create_index = {{.CreateIndex}} # Set by server
modify_index = {{.ModifyIndex}} # Set by server; consulted for check-and-set
create_time = {{.CreateTime}} # Set by server
modify_time = {{.ModifyTime}} # Set by server
items = {
{{- $PAD := 0 -}}{{- range $k,$v := .Items}}{{if gt (len $k) $PAD}}{{$PAD = (len $k)}}{{end}}{{end -}}
{{- $FMT := printf " %%%vs = %%q\n" $PAD}}
{{range $k,$v := .Items}}{{printf $FMT $k $v}}{{ end -}}
}
`
out, err := renderWithGoTemplate(sv, tpl)
if err != nil {
// Any errors in this should be caught as test panics.
// If we ship with one, the worst case is that it panics a single
// run of the CLI and only for output of variables in HCL.
panic(err)
}
return out
}
func renderWithGoTemplate(sv *api.Variable, tpl string) (string, error) {
//TODO: Enhance this to take a template as an @-aliased filename too
t := template.Must(template.New("var").Parse(tpl))
var out bytes.Buffer
if err := t.Execute(&out, sv); err != nil {
return "", err
}
result := out.String()
return result, nil
}
// KVBuilder is a struct to build a key/value mapping based on a list
// of "k=v" pairs, where the value might come from stdin, a file, etc.
type KVBuilder struct {
Stdin io.Reader
result map[string]interface{}
stdin bool
}
// Map returns the built map.
func (b *KVBuilder) Map() map[string]interface{} {
return b.result
}
// Add adds to the mapping with the given args.
func (b *KVBuilder) Add(args ...string) error {
for _, a := range args {
if err := b.add(a); err != nil {
return fmt.Errorf("invalid key/value pair %q: %w", a, err)
}
}
return nil
}
func (b *KVBuilder) add(raw string) error {
// Regardless of validity, make sure we make our result
if b.result == nil {
b.result = make(map[string]interface{})
}
// Empty strings are fine, just ignored
if raw == "" {
return nil
}
// Split into key/value
parts := strings.SplitN(raw, "=", 2)
// If the arg is exactly "-", then we need to read from stdin
// and merge the results into the resulting structure.
if len(parts) == 1 {
if raw == "-" {
if b.Stdin == nil {
return fmt.Errorf("stdin is not supported")
}
if b.stdin {
return fmt.Errorf("stdin already consumed")
}
b.stdin = true
return b.addReader(b.Stdin)
}
// If the arg begins with "@" then we need to read a file directly
if raw[0] == '@' {
f, err := os.Open(raw[1:])
if err != nil {
return err
}
defer f.Close()
return b.addReader(f)
}
}
if len(parts) != 2 {
return fmt.Errorf("format must be key=value")
}
key, value := parts[0], parts[1]
if len(value) > 0 {
if value[0] == '@' {
contents, err := ioutil.ReadFile(value[1:])
if err != nil {
return fmt.Errorf("error reading file: %w", err)
}
value = string(contents)
} else if value[0] == '\\' && value[1] == '@' {
value = value[1:]
} else if value == "-" {
if b.Stdin == nil {
return fmt.Errorf("stdin is not supported")
}
if b.stdin {
return fmt.Errorf("stdin already consumed")
}
b.stdin = true
var buf bytes.Buffer
if _, err := io.Copy(&buf, b.Stdin); err != nil {
return err
}
value = buf.String()
}
}
// Repeated keys will be converted into a slice
if existingValue, ok := b.result[key]; ok {
var sliceValue []interface{}
if err := mapstructure.WeakDecode(existingValue, &sliceValue); err != nil {
return err
}
sliceValue = append(sliceValue, value)
b.result[key] = sliceValue
return nil
}
b.result[key] = value
return nil
}
func (b *KVBuilder) addReader(r io.Reader) error {
if r == nil {
return fmt.Errorf("'io.Reader' being decoded is nil")
}
dec := json.NewDecoder(r)
// While decoding JSON values, interpret the integer values as
// `json.Number`s instead of `float64`.
dec.UseNumber()
return dec.Decode(&b.result)
}
// handleCASError provides consistent output for operations that result in a
// check-and-set error
func handleCASError(err error, c VarUI) (handled bool) {
ui := c.GetConcurrentUI()
var cErr api.ErrCASConflict
if errors.As(err, &cErr) {
lastUpdate := ""
if cErr.Conflict.ModifyIndex > 0 {
lastUpdate = fmt.Sprintf(
tidyRawString(msgfmtCASConflictLastAccess),
formatUnixNanoTime(cErr.Conflict.ModifyTime))
}
ui.Error(c.Colorize().Color("\n[bold][underline]Check-and-Set conflict[reset]\n"))
ui.Warn(
wrapAndPrepend(
c.Colorize().Color(
fmt.Sprintf(
tidyRawString(msgfmtCASMismatch),
cErr.CheckIndex,
cErr.Conflict.ModifyIndex,
lastUpdate),
),
80, " ") + "\n",
)
handled = true
}
return
}
const (
errMissingTemplate = `A template must be supplied using '-template' when using go-template formatting`
errUnexpectedTemplate = `The '-template' flag is only valid when using 'go-template' formatting`
errVariableNotFound = `Variable not found`
errInvalidInFormat = `Invalid value for "-in"; valid values are [hcl, json]`
errInvalidOutFormat = `Invalid value for "-out"; valid values are [go-template, hcl, json, none, table]`
errWildcardNamespaceNotAllowed = `The wildcard namespace ("*") is not valid for this command.`
msgfmtCASMismatch = `
Your provided check-index [green](%v)[yellow] does not match the
server-side index [green](%v)[yellow].
%s
If you are sure you want to perform this operation, add the [green]-force[yellow] or
[green]-check-index=%[2]v[yellow] flag before the positional arguments.`
msgfmtCASConflictLastAccess = `
The server-side item was last updated on [green]%s[yellow].
`
)

187
command/var_get.go Normal file
View File

@ -0,0 +1,187 @@
package command
import (
"errors"
"fmt"
"os"
"strings"
"github.com/hashicorp/nomad/api"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)
type VarGetCommand struct {
Meta
outFmt string
tmpl string
}
func (c *VarGetCommand) Help() string {
helpText := `
Usage: nomad var get [options] <path>
The 'var get' command is used to get the contents of an existing variable.
If ACLs are enabled, this command requires a token with the 'variables:read'
capability for the target variable's namespace.
General Options:
` + generalOptionsUsage(usageOptsDefault) + `
Read Options:
-item <item key>
Print only the value of the given item. Specifying this option will
take precedence over other formatting directives. The result will not
have a trailing newline making it ideal for piping to other processes.
-out ( go-template | hcl | json | none | table )
Format to render the variable in. When using "go-template", you must
provide the template content with the "-template" option. Defaults
to "table" when stdout is a terminal and to "json" when stdout is
redirected.
-template
Template to render output with. Required when output is "go-template".
`
return strings.TrimSpace(helpText)
}
func (c *VarGetCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-out": complete.PredictSet("go-template", "hcl", "json", "none", "table"),
"-template": complete.PredictAnything,
},
)
}
func (c *VarGetCommand) AutocompleteArgs() complete.Predictor {
return VariablePathPredictor(c.Meta.Client)
}
func (c *VarGetCommand) Synopsis() string {
return "Read a variable"
}
func (c *VarGetCommand) Name() string { return "var read" }
func (c *VarGetCommand) Run(args []string) int {
var out, item string
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.StringVar(&item, "item", "", "")
flags.StringVar(&c.tmpl, "template", "", "")
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
flags.StringVar(&c.outFmt, "out", "table", "")
} else {
flags.StringVar(&c.outFmt, "out", "json", "")
}
if err := flags.Parse(args); err != nil {
return 1
}
// Check that we got one argument
args = flags.Args()
if len(args) != 1 {
c.Ui.Error("This command takes one argument: <path>")
c.Ui.Error(commandErrorText(c))
return 1
}
if err := c.validateOutputFlag(); err != nil {
c.Ui.Error(err.Error())
c.Ui.Error(commandErrorText(c))
return 1
}
if c.Meta.namespace == "*" {
c.Ui.Error(errWildcardNamespaceNotAllowed)
return 1
}
path := args[0]
// Get the HTTP client
client, err := c.Meta.Client()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
return 1
}
qo := &api.QueryOptions{
Namespace: c.Meta.namespace,
}
sv, _, err := client.Variables().Read(path, qo)
if err != nil {
if err.Error() == "variable not found" {
c.Ui.Warn(errVariableNotFound)
return 1
}
c.Ui.Error(fmt.Sprintf("Error retrieving variable: %s", err))
return 1
}
// If the user provided an item key, return that value instead of the whole
// object
if item != "" {
if v, ok := sv.Items[item]; ok {
fmt.Print(v)
return 0
} else {
c.Ui.Error(fmt.Sprintf("Variable does not contain %q item", args[1]))
return 1
}
}
// Output whole object
switch c.outFmt {
case "json":
out = sv.AsPrettyJSON()
case "hcl":
out = renderAsHCL(sv)
case "go-template":
if out, err = renderWithGoTemplate(sv, c.tmpl); err != nil {
c.Ui.Error(err.Error())
return 1
}
case "none":
// exit without more output
return 0
default:
// the renderSVAsUiTable func writes directly to the ui and doesn't error.
renderSVAsUiTable(sv, c)
return 0
}
c.Ui.Output(out)
return 0
}
func (c *VarGetCommand) validateOutputFlag() error {
if c.outFmt != "go-template" && c.tmpl != "" {
return errors.New(errUnexpectedTemplate)
}
switch c.outFmt {
case "hcl", "json", "none", "table":
return nil
case "go-template": //noop - needs more validation
if c.tmpl == "" {
return errors.New(errMissingTemplate)
}
return nil
default:
return errors.New(errInvalidOutFormat)
}
}
func (c *VarGetCommand) GetConcurrentUI() cli.ConcurrentUi {
return cli.ConcurrentUi{Ui: c.Ui}
}

201
command/var_get_test.go Normal file
View File

@ -0,0 +1,201 @@
package command
import (
"fmt"
"strings"
"testing"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/ci"
"github.com/mitchellh/cli"
"github.com/posener/complete"
"github.com/stretchr/testify/require"
)
func TestVarGetCommand_Implements(t *testing.T) {
ci.Parallel(t)
var _ cli.Command = &VarGetCommand{}
}
func TestVarGetCommand_Fails(t *testing.T) {
ci.Parallel(t)
t.Run("bad_args", func(t *testing.T) {
ci.Parallel(t)
ui := cli.NewMockUi()
cmd := &VarGetCommand{Meta: Meta{Ui: ui}}
code := cmd.Run([]string{"some", "bad", "args"})
out := ui.ErrorWriter.String()
require.Equal(t, 1, code, "expected exit code 1, got: %d")
require.Contains(t, out, commandErrorText(cmd), "expected help output, got: %s", out)
})
t.Run("bad_address", func(t *testing.T) {
ci.Parallel(t)
ui := cli.NewMockUi()
cmd := &VarGetCommand{Meta: Meta{Ui: ui}}
code := cmd.Run([]string{"-address=nope", "foo"})
out := ui.ErrorWriter.String()
require.Equal(t, 1, code, "expected exit code 1, got: %d")
require.Contains(t, ui.ErrorWriter.String(), "retrieving variable", "connection error, got: %s", out)
require.Zero(t, ui.OutputWriter.String())
})
t.Run("missing_template", func(t *testing.T) {
ci.Parallel(t)
ui := cli.NewMockUi()
cmd := &VarGetCommand{Meta: Meta{Ui: ui}}
code := cmd.Run([]string{`-out=go-template`, "foo"})
out := strings.TrimSpace(ui.ErrorWriter.String())
require.Equal(t, 1, code, "expected exit code 1, got: %d", code)
require.Equal(t, errMissingTemplate+"\n"+commandErrorText(cmd), out)
require.Zero(t, ui.OutputWriter.String())
})
t.Run("unexpected_template", func(t *testing.T) {
ci.Parallel(t)
ui := cli.NewMockUi()
cmd := &VarGetCommand{Meta: Meta{Ui: ui}}
code := cmd.Run([]string{`-out=json`, `-template="bad"`, "foo"})
out := strings.TrimSpace(ui.ErrorWriter.String())
require.Equal(t, 1, code, "expected exit code 1, got: %d", code)
require.Equal(t, errUnexpectedTemplate+"\n"+commandErrorText(cmd), out)
require.Zero(t, ui.OutputWriter.String())
})
}
func TestVarGetCommand(t *testing.T) {
ci.Parallel(t)
// Create a server
srv, client, url := testServer(t, true, nil)
t.Cleanup(func() {
srv.Shutdown()
})
testCases := []struct {
name string
format string
template string
expected string
testPath string // defaulted to "test/var" in code; used for not-found
exitCode int
isError bool
}{
{
name: "json",
format: "json",
},
{
name: "table",
format: "table",
},
{
name: "go-template",
format: "go-template",
template: `{{.Namespace}}.{{.Path}}`,
expected: "TestVarGetCommand-2-go-template.test/var",
},
{
name: "not-found",
format: "json",
expected: errVariableNotFound,
testPath: "not-found",
isError: true,
exitCode: 1,
},
}
for i, tc := range testCases {
t.Run(fmt.Sprintf("%v_%s", i, tc.name), func(t *testing.T) {
tc := tc
ci.Parallel(t)
var err error
// Create a namespace for the test case
testNS := strings.Map(validNS, t.Name())
_, err = client.Namespaces().Register(&api.Namespace{Name: testNS}, nil)
require.NoError(t, err)
t.Cleanup(func() {
client.Namespaces().Delete(testNS, nil)
})
// Create a var to get
sv := testVariable()
sv.Namespace = testNS
sv, _, err = client.Variables().Create(sv, nil)
require.NoError(t, err)
t.Cleanup(func() {
_, _ = client.Variables().Delete(sv.Path, nil)
})
// Build and run the command
ui := cli.NewMockUi()
cmd := &VarGetCommand{Meta: Meta{Ui: ui}}
args := []string{
"-address=" + url,
"-namespace=" + testNS,
"-out=" + tc.format,
}
if tc.template != "" {
args = append(args, "-template="+tc.template)
}
args = append(args, sv.Path)
if tc.testPath != "" {
// replace path with test case override
args[len(args)-1] = tc.testPath
}
code := cmd.Run(args)
// Check the output
require.Equal(t, tc.exitCode, code, "expected exit %v, got: %d; %v", tc.exitCode, code, ui.ErrorWriter.String())
if tc.isError {
require.Equal(t, tc.expected, strings.TrimSpace(ui.ErrorWriter.String()))
return
}
switch tc.format {
case "json":
require.Equal(t, sv.AsPrettyJSON(), strings.TrimSpace(ui.OutputWriter.String()))
case "table":
out := ui.OutputWriter.String()
outs := strings.Split(out, "\n")
require.Len(t, outs, 9)
require.Equal(t, "Namespace = "+testNS, outs[0])
require.Equal(t, "Path = test/var", outs[1])
case "go-template":
require.Equal(t, tc.expected, strings.TrimSpace(ui.OutputWriter.String()))
default:
t.Fatalf("invalid format: %q", tc.format)
}
})
}
t.Run("Autocomplete", func(t *testing.T) {
ci.Parallel(t)
_, client, url, shutdownFn := testAPIClient(t)
defer shutdownFn()
ui := cli.NewMockUi()
cmd := &VarGetCommand{Meta: Meta{Ui: ui, flagAddress: url}}
// Create a var
testNS := strings.Map(validNS, t.Name())
_, err := client.Namespaces().Register(&api.Namespace{Name: testNS}, nil)
require.NoError(t, err)
t.Cleanup(func() { client.Namespaces().Delete(testNS, nil) })
sv := testVariable()
sv.Path = "special/variable"
sv.Namespace = t.Name()
sv, _, err = client.Variables().Create(sv, nil)
require.NoError(t, err)
t.Cleanup(func() { client.Variables().Delete(sv.Path, nil) })
args := complete.Args{Last: "s"}
predictor := cmd.AutocompleteArgs()
res := predictor.Predict(args)
require.Equal(t, 1, len(res))
require.Equal(t, sv.Path, res[0])
})
}
func validNS(r rune) rune {
if r == '/' || r == '_' {
return '-'
}
return r
}

View File

@ -1,8 +1,9 @@
package command
import (
"errors"
"fmt"
"io/ioutil"
"io/fs"
"os"
"regexp"
"strings"
@ -30,17 +31,16 @@ func (c *VarInitCommand) Help() string {
helpText := `
Usage: nomad var init <filename>
Creates an example variable specification file that can be used as a
starting point to customize further. If no filename is given, the default of
"spec.nsv.hcl" or "spec.nsv.json" will be used.
Creates an example variable specification file that can be used as a starting
point to customize further. When no filename is supplied, a default filename
of "spec.nsv.hcl" or "spec.nsv.json" will be used depending on the output
format.
Init Options:
-json
Create an example JSON variable specification.
-out (hcl | json)
Format of generated variable specification. Defaults to "hcl".
-q
Suppress non-error output
`
return strings.TrimSpace(helpText)
}
@ -51,7 +51,7 @@ func (c *VarInitCommand) Synopsis() string {
func (c *VarInitCommand) AutocompleteFlags() complete.Flags {
return complete.Flags{
"-json": complete.PredictNothing,
"-out": complete.PredictSet("hcl", "json"),
}
}
@ -62,13 +62,12 @@ func (c *VarInitCommand) AutocompleteArgs() complete.Predictor {
func (c *VarInitCommand) Name() string { return "var init" }
func (c *VarInitCommand) Run(args []string) int {
var jsonOutput bool
var outFmt string
var quiet bool
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.BoolVar(&jsonOutput, "json", false, "")
flags.BoolVar(&quiet, "q", false, "")
flags.StringVar(&outFmt, "out", "hcl", "")
if err := flags.Parse(args); err != nil {
return 1
@ -81,30 +80,33 @@ func (c *VarInitCommand) Run(args []string) int {
c.Ui.Error(commandErrorText(c))
return 1
}
fileName := DefaultHclVarInitName
fileContent := defaultHclVarSpec
if jsonOutput {
var fileName, fileContent string
switch outFmt {
case "hcl":
fileName = DefaultHclVarInitName
fileContent = defaultHclVarSpec
case "json":
fileName = DefaultJsonVarInitName
fileContent = defaultJsonVarSpec
}
if len(args) == 1 {
fileName = args[0]
}
// Check if the file already exists
_, err := os.Stat(fileName)
if err != nil && !os.IsNotExist(err) {
c.Ui.Error(fmt.Sprintf("Failed to stat %q: %v", fileName, err))
if err == nil {
c.Ui.Error(fmt.Sprintf("File %q already exists", fileName))
return 1
}
if !os.IsNotExist(err) {
c.Ui.Error(fmt.Sprintf("File %q already exists", fileName))
if err != nil && !errors.Is(err, fs.ErrNotExist) {
c.Ui.Error(fmt.Sprintf("Failed to stat %q: %v", fileName, err))
return 1
}
// Write out the example
err = ioutil.WriteFile(fileName, []byte(fileContent), 0660)
err = os.WriteFile(fileName, []byte(fileContent), 0660)
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to write %q: %v", fileName, err))
return 1
@ -112,7 +114,10 @@ func (c *VarInitCommand) Run(args []string) int {
// Success
if !quiet {
c.Ui.Warn(WrapAndPrepend(TidyRawString(msgWarnKeys), 70, ""))
if outFmt == "json" {
c.Ui.Info(wrapString(tidyRawString(strings.ReplaceAll(msgOnlyItemsRequired, "items", "Items")), 70))
c.Ui.Warn(wrapString(tidyRawString(strings.ReplaceAll(msgWarnKeys, "items", "Items")), 70))
}
c.Ui.Output(fmt.Sprintf("Example variable specification written to %s", fileName))
}
return 0
@ -123,6 +128,11 @@ const (
REMINDER: While keys in the items map can contain dots, using them in
templates is easier when they do not. As a best practice, avoid dotted
keys when possible.`
msgOnlyItemsRequired = `
The items map is the only strictly required part of a variable
specification, since path and namespace can be set via other means. It
contains the sensitive material to encrypt and store as a Nomad variable.
The entire items map is encrypted and decrypted as a single unit.`
)
var defaultHclVarSpec = strings.TrimSpace(`
@ -132,16 +142,14 @@ var defaultHclVarSpec = strings.TrimSpace(`
# HTTP API endpoint
# path = "path/to/variable"
# The Namespace to write the variable can be included in the specification
# and is the highest precedence way to set the namespace value.
# The Namespace to write the variable can be included in the specification. This
# value can be overridden by specifying the "-namespace" flag on the "put"
# command.
# namespace = "default"
# The items map is the only strictly required part of a variable
# specification, since path and namespace can be set via other means. It
# contains the sensitive material to encrypt and store as a Nomad secure
# variable. The entire items map is encrypted and decrypted as a single unit.
`+makeHCLComment(msgOnlyItemsRequired)+`
`+warnInHCLFile()+`
`+makeHCLComment(msgWarnKeys)+`
items {
key1 = "value 1"
key2 = "value 2"
@ -150,6 +158,8 @@ items {
var defaultJsonVarSpec = strings.TrimSpace(`
{
"Namespace": "default",
"Path": "path/to/variable",
"Items": {
"key1": "value 1",
"key2": "value 2"
@ -157,34 +167,39 @@ var defaultJsonVarSpec = strings.TrimSpace(`
}
`) + "\n"
func warnInHCLFile() string {
return WrapAndPrepend(TidyRawString(msgWarnKeys), 70, "# ")
// makeHCLComment is a helper function that will take the contents of a raw
// string, tidy them, wrap them to 68 characters and add a leading comment
// marker plus a space.
func makeHCLComment(in string) string {
return wrapAndPrepend(tidyRawString(in), 70, "# ")
}
// WrapString is a convienience func to abstract away the word wrapping
// wrapString is a convenience func to abstract away the word wrapping
// implementation
func WrapString(input string, lineLen int) string {
func wrapString(input string, lineLen int) string {
return wordwrap.String(input, lineLen)
}
// WrapAndPrepend will word wrap the input string to lineLen characters and
// wrapAndPrepend will word wrap the input string to lineLen characters and
// prepend the provided prefix to every line. The total length of each returned
// line will be at most len(input[line])+len(prefix)
func WrapAndPrepend(input string, lineLen int, prefix string) string {
ss := strings.Split(wordwrap.String(input, lineLen), "\n")
func wrapAndPrepend(input string, lineLen int, prefix string) string {
ss := strings.Split(wrapString(input, lineLen-len(prefix)), "\n")
prefixStringList(ss, prefix)
return strings.Join(ss, "\n")
}
// TidyRawString will convert a wrapped and indented raw string into a single
// tidyRawString will convert a wrapped and indented raw string into a single
// long string suitable for rewrapping with another tool. It trims leading and
// trailing whitespace and then consume groups of tabs, newlines, and spaces
// replacing them with a single space
func TidyRawString(raw string) string {
func tidyRawString(raw string) string {
re := regexp.MustCompile("[\t\n ]+")
return re.ReplaceAllString(strings.TrimSpace(raw), " ")
}
// prefixStringList is a helper function that prepends each item in a slice of
// string with a provided prefix.
func prefixStringList(ss []string, prefix string) []string {
for i, s := range ss {
ss[i] = prefix + s

120
command/var_init_test.go Normal file
View File

@ -0,0 +1,120 @@
package command
import (
"os"
"path"
"testing"
"github.com/hashicorp/nomad/ci"
"github.com/mitchellh/cli"
"github.com/stretchr/testify/require"
)
func TestVarInitCommand_Implements(t *testing.T) {
ci.Parallel(t)
var _ cli.Command = &VarInitCommand{}
}
func TestVarInitCommand_Run(t *testing.T) {
ci.Parallel(t)
dir := t.TempDir()
origDir, err := os.Getwd()
require.NoError(t, err)
err = os.Chdir(dir)
require.NoError(t, err)
t.Cleanup(func() { os.Chdir(origDir) })
t.Run("hcl", func(t *testing.T) {
ci.Parallel(t)
dir := dir
ui := cli.NewMockUi()
cmd := &VarInitCommand{Meta: Meta{Ui: ui}}
// Fails on misuse
ec := cmd.Run([]string{"some", "bad", "args"})
require.Equal(t, 1, ec)
require.Contains(t, ui.ErrorWriter.String(), commandErrorText(cmd))
require.Empty(t, ui.OutputWriter.String())
reset(ui)
// Works if the file doesn't exist
ec = cmd.Run([]string{"-out", "hcl"})
require.Empty(t, ui.ErrorWriter.String())
require.Equal(t, "Example variable specification written to spec.nsv.hcl\n", ui.OutputWriter.String())
require.Zero(t, ec)
reset(ui)
t.Cleanup(func() { os.Remove(path.Join(dir, "spec.nsv.hcl")) })
content, err := os.ReadFile(DefaultHclVarInitName)
require.NoError(t, err)
require.Equal(t, defaultHclVarSpec, string(content))
// Fails if the file exists
ec = cmd.Run([]string{"-out", "hcl"})
require.Contains(t, ui.ErrorWriter.String(), "exists")
require.Empty(t, ui.OutputWriter.String())
require.Equal(t, 1, ec)
reset(ui)
// Works if file is passed
ec = cmd.Run([]string{"-out", "hcl", "myTest.hcl"})
require.Empty(t, ui.ErrorWriter.String())
require.Equal(t, "Example variable specification written to myTest.hcl\n", ui.OutputWriter.String())
require.Zero(t, ec)
reset(ui)
t.Cleanup(func() { os.Remove(path.Join(dir, "myTest.hcl")) })
content, err = os.ReadFile("myTest.hcl")
require.NoError(t, err)
require.Equal(t, defaultHclVarSpec, string(content))
})
t.Run("json", func(t *testing.T) {
ci.Parallel(t)
dir := dir
ui := cli.NewMockUi()
cmd := &VarInitCommand{Meta: Meta{Ui: ui}}
// Fails on misuse
code := cmd.Run([]string{"some", "bad", "args"})
require.Equal(t, 1, code)
require.Contains(t, ui.ErrorWriter.String(), "This command takes no arguments or one")
require.Empty(t, ui.OutputWriter.String())
reset(ui)
// Works if the file doesn't exist
code = cmd.Run([]string{"-out", "json"})
require.Contains(t, ui.ErrorWriter.String(), "REMINDER: While keys")
require.Contains(t, ui.OutputWriter.String(), "Example variable specification written to spec.nsv.json\n")
require.Zero(t, code)
reset(ui)
t.Cleanup(func() { os.Remove(path.Join(dir, "spec.nsv.json")) })
content, err := os.ReadFile(DefaultJsonVarInitName)
require.NoError(t, err)
require.Equal(t, defaultJsonVarSpec, string(content))
// Fails if the file exists
code = cmd.Run([]string{"-out", "json"})
require.Contains(t, ui.ErrorWriter.String(), "exists")
require.Empty(t, ui.OutputWriter.String())
require.Equal(t, 1, code)
reset(ui)
// Works if file is passed
code = cmd.Run([]string{"-out", "json", "myTest.json"})
require.Contains(t, ui.ErrorWriter.String(), "REMINDER: While keys")
require.Contains(t, ui.OutputWriter.String(), "Example variable specification written to myTest.json\n")
require.Zero(t, code)
reset(ui)
t.Cleanup(func() { os.Remove(path.Join(dir, "myTest.json")) })
content, err = os.ReadFile("myTest.json")
require.NoError(t, err)
require.Equal(t, defaultJsonVarSpec, string(content))
})
}
func reset(ui *cli.MockUi) {
ui.OutputWriter.Reset()
ui.ErrorWriter.Reset()
}

View File

@ -4,7 +4,6 @@ import (
"fmt"
"sort"
"strings"
"time"
"github.com/hashicorp/nomad/api"
"github.com/posener/complete"
@ -27,8 +26,8 @@ Usage: nomad var list [options] <prefix>
List is used to list available variables. Supplying an optional prefix,
filters the list to variables having a path starting with the prefix.
If ACLs are enabled, this command will return only variables stored at
namespaced paths where the token has the ` + "`read`" + ` capability.
If ACLs are enabled, this command will only return variables stored in
namespaces where the token has the 'variables:list' capability.
General Options:
@ -55,7 +54,7 @@ List Options:
-q
Output matching variable paths with no additional information.
This option overrides the ` + "`-t`" + ` option.
This option overrides the '-t' option.
`
return strings.TrimSpace(helpText)
}
@ -222,7 +221,7 @@ func formatVarStubs(vars []*api.VariableMetadata) string {
rows[i+1] = fmt.Sprintf("%s|%s|%s",
sv.Namespace,
sv.Path,
time.Unix(0, sv.ModifyTime),
formatUnixNanoTime(sv.ModifyTime),
)
}
return formatList(rows)

View File

@ -5,7 +5,6 @@ import (
"fmt"
"strings"
"testing"
"time"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/ci"
@ -19,7 +18,7 @@ func TestVarListCommand_Implements(t *testing.T) {
}
// TestVarListCommand_Offline contains all of the tests that do not require a
// testagent to complete
// testServer to complete
func TestVarListCommand_Offline(t *testing.T) {
ci.Parallel(t)
ui := cli.NewMockUi()
@ -95,8 +94,8 @@ func TestVarListCommand_Offline(t *testing.T) {
}
}
// TestVarListCommand_Online contains all of the tests that use a testagent.
// They reuse the same testagent so that they can run in parallel and minimize
// TestVarListCommand_Online contains all of the tests that use a testServer.
// They reuse the same testServer so that they can run in parallel and minimize
// test startup time costs.
func TestVarListCommand_Online(t *testing.T) {
ci.Parallel(t)
@ -161,7 +160,7 @@ func TestVarListCommand_Online(t *testing.T) {
"Namespace|Path|Last Updated",
fmt.Sprintf(
"default|a/b/c/d|%s",
time.Unix(0, variables.HavingPrefix("a/b/c/d")[0].ModifyTime),
formatUnixNanoTime(variables.HavingPrefix("a/b/c/d")[0].ModifyTime),
),
},
),

121
command/var_purge.go Normal file
View File

@ -0,0 +1,121 @@
package command
import (
"errors"
"fmt"
"strconv"
"strings"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)
type VarPurgeCommand struct {
Meta
}
func (c *VarPurgeCommand) Help() string {
helpText := `
Usage: nomad var purge [options] <path>
Purge is used to permanently delete an existing variable.
If ACLs are enabled, this command requires a token with the 'variables:destroy'
capability for the target variable's namespace.
General Options:
` + generalOptionsUsage(usageOptsDefault) + `
Purge Options:
-check-index
If set, the variable is only acted upon if the server side version's modify
index matches the provided value.
`
return strings.TrimSpace(helpText)
}
func (c *VarPurgeCommand) AutocompleteFlags() complete.Flags {
return c.Meta.AutocompleteFlags(FlagSetClient)
}
func (c *VarPurgeCommand) AutocompleteArgs() complete.Predictor {
return VariablePathPredictor(c.Meta.Client)
}
func (c *VarPurgeCommand) Synopsis() string {
return "Purge a variable"
}
func (c *VarPurgeCommand) Name() string { return "var purge" }
func (c *VarPurgeCommand) Run(args []string) int {
var checkIndexStr string
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.StringVar(&checkIndexStr, "check-index", "", "")
if err := flags.Parse(args); err != nil {
return 1
}
// Check that we got one argument
args = flags.Args()
if l := len(args); l != 1 {
c.Ui.Error("This command takes one argument: <path>")
c.Ui.Error(commandErrorText(c))
return 1
}
// Parse the check-index
checkIndex, enforce, err := parseCheckIndex(checkIndexStr)
if err != nil {
switch {
case errors.Is(err, strconv.ErrRange):
c.Ui.Error(fmt.Sprintf("Invalid -check-index value %q: out of range for uint64", checkIndexStr))
case errors.Is(err, strconv.ErrSyntax):
c.Ui.Error(fmt.Sprintf("Invalid -check-index value %q: not parsable as uint64", checkIndexStr))
default:
c.Ui.Error(fmt.Sprintf("Error parsing -check-index value %q: %v", checkIndexStr, err))
}
return 1
}
if c.Meta.namespace == "*" {
c.Ui.Error(errWildcardNamespaceNotAllowed)
return 1
}
path := args[0]
// Get the HTTP client
client, err := c.Meta.Client()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
return 1
}
if enforce {
_, err = client.Variables().CheckedDelete(path, checkIndex, nil)
} else {
_, err = client.Variables().Delete(path, nil)
}
if err != nil {
if handled := handleCASError(err, c); handled {
return 1
}
c.Ui.Error(fmt.Sprintf("Error purging variable: %s", err))
return 1
}
c.Ui.Output(fmt.Sprintf("Successfully purged variable %q!", path))
return 0
}
func (c *VarPurgeCommand) GetConcurrentUI() cli.ConcurrentUi {
return cli.ConcurrentUi{Ui: c.Ui}
}

133
command/var_purge_test.go Normal file
View File

@ -0,0 +1,133 @@
package command
import (
"fmt"
"strings"
"testing"
"github.com/hashicorp/nomad/ci"
"github.com/mitchellh/cli"
"github.com/posener/complete"
"github.com/stretchr/testify/require"
)
func TestVarPurgeCommand_Implements(t *testing.T) {
ci.Parallel(t)
var _ cli.Command = &VarPurgeCommand{}
}
func TestVarPurgeCommand_Fails(t *testing.T) {
ci.Parallel(t)
t.Run("bad_args", func(t *testing.T) {
ci.Parallel(t)
ui := cli.NewMockUi()
cmd := &VarPurgeCommand{Meta: Meta{Ui: ui}}
code := cmd.Run([]string{"some", "bad", "args"})
out := ui.ErrorWriter.String()
require.Equal(t, 1, code, "expected exit code 1, got: %d")
require.Contains(t, out, commandErrorText(cmd), "expected help output, got: %s", out)
})
t.Run("bad_address", func(t *testing.T) {
ci.Parallel(t)
ui := cli.NewMockUi()
cmd := &VarPurgeCommand{Meta: Meta{Ui: ui}}
code := cmd.Run([]string{"-address=nope", "foo"})
out := ui.ErrorWriter.String()
require.Equal(t, 1, code, "expected exit code 1, got: %d")
require.Contains(t, ui.ErrorWriter.String(), "purging variable", "connection error, got: %s", out)
require.Zero(t, ui.OutputWriter.String())
})
t.Run("bad_check_index/syntax", func(t *testing.T) {
ci.Parallel(t)
ui := cli.NewMockUi()
cmd := &VarPurgeCommand{Meta: Meta{Ui: ui}}
code := cmd.Run([]string{`-check-index=a`, "foo"})
out := strings.TrimSpace(ui.ErrorWriter.String())
require.Equal(t, 1, code, "expected exit code 1, got: %d", code)
require.Equal(t, `Invalid -check-index value "a": not parsable as uint64`, out)
require.Zero(t, ui.OutputWriter.String())
})
t.Run("bad_check_index/range", func(t *testing.T) {
ci.Parallel(t)
ui := cli.NewMockUi()
cmd := &VarPurgeCommand{Meta: Meta{Ui: ui}}
code := cmd.Run([]string{`-check-index=18446744073709551616`, "foo"})
out := strings.TrimSpace(ui.ErrorWriter.String())
require.Equal(t, 1, code, "expected exit code 1, got: %d", code)
require.Equal(t, `Invalid -check-index value "18446744073709551616": out of range for uint64`, out)
require.Zero(t, ui.OutputWriter.String())
})
}
func TestVarPurgeCommand_Online(t *testing.T) {
ci.Parallel(t)
// Create a server
srv, client, url := testServer(t, true, nil)
t.Cleanup(func() {
srv.Shutdown()
})
t.Run("unchecked", func(t *testing.T) {
ui := cli.NewMockUi()
cmd := &VarPurgeCommand{Meta: Meta{Ui: ui}}
// Create a var to delete
sv := testVariable()
_, _, err := client.Variables().Create(sv, nil)
require.NoError(t, err)
t.Cleanup(func() { _, _ = client.Variables().Delete(sv.Path, nil) })
// Delete the variable
code := cmd.Run([]string{"-address=" + url, sv.Path})
require.Equal(t, 0, code, "expected exit 0, got: %d; %v", code, ui.ErrorWriter.String())
vars, _, err := client.Variables().List(nil)
require.NoError(t, err)
require.Len(t, vars, 0)
})
t.Run("unchecked", func(t *testing.T) {
ci.Parallel(t)
ui := cli.NewMockUi()
cmd := &VarPurgeCommand{Meta: Meta{Ui: ui}}
// Create a var to delete
sv := testVariable()
sv, _, err := client.Variables().Create(sv, nil)
require.NoError(t, err)
// Delete a variable
code := cmd.Run([]string{"-address=" + url, "-check-index=1", sv.Path})
stderr := ui.ErrorWriter.String()
require.Equal(t, 1, code, "expected exit 1, got: %d; %v", code, stderr)
require.Contains(t, stderr, "\nCheck-and-Set conflict\n\n Your provided check-index (1)")
code = cmd.Run([]string{"-address=" + url, fmt.Sprintf("-check-index=%v", sv.ModifyIndex), sv.Path})
require.Equal(t, 0, code, "expected exit 0, got: %d; %v", code, ui.ErrorWriter.String())
vars, _, err := client.Variables().List(nil)
require.NoError(t, err)
require.Len(t, vars, 0)
})
t.Run("autocompleteArgs", func(t *testing.T) {
ci.Parallel(t)
ui := cli.NewMockUi()
cmd := &VarPurgeCommand{Meta: Meta{Ui: ui, flagAddress: url}}
// Create a var
sv := testVariable()
sv.Path = "autocomplete/test"
_, _, err := client.Variables().Create(sv, nil)
require.NoError(t, err)
t.Cleanup(func() { client.Variables().Delete(sv.Path, nil) })
args := complete.Args{Last: "aut"}
predictor := cmd.AutocompleteArgs()
res := predictor.Predict(args)
require.Equal(t, 1, len(res))
require.Equal(t, sv.Path, res[0])
})
}

523
command/var_put.go Normal file
View File

@ -0,0 +1,523 @@
package command
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/hashicorp/hcl"
"github.com/hashicorp/hcl/hcl/ast"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/helper"
"github.com/mitchellh/cli"
"github.com/mitchellh/mapstructure"
"github.com/posener/complete"
)
type VarPutCommand struct {
Meta
contents []byte
inFmt string
outFmt string
tmpl string
testStdin io.Reader // for tests
verbose func(string)
}
func (c *VarPutCommand) Help() string {
helpText := `
Usage:
nomad var put [options] <variable spec file reference> [<key>=<value>]...
nomad var put [options] <path to store variable> [<variable spec file reference>] [<key>=<value>]...
The 'var put' command is used to create or update an existing variable.
Variable metadata and items can be supplied using a variable specification,
by using command arguments, or by a combination of the two techniques.
An entire variable specification can be provided to the command via standard
input (stdin) by setting the first argument to "-" or from a file by using an
@-prefixed path to a variable specification file. When providing variable
data via stdin, you must provide the "-in" flag with the format of the
specification, either "hcl" or "json"
Items to be stored in the variable can be supplied using the specification,
as a series of key-value pairs, or both. The value for a key-value pair can
be a string, an @-prefixed file reference, or a '-' to get the value from
stdin. Item values provided from file references or stdin are consumed as-is
with no additional processing and do not require the input format to be
specified.
Values supplied as command line arguments supersede values provided in the
any variable specification piped into the command or loaded from file.
If ACLs are enabled, this command requires the 'variables:write' capability
for the destination namespace.
General Options:
` + generalOptionsUsage(usageOptsDefault) + `
Apply Options:
-check-index
If set, the variable is only acted upon if the server-side version's index
matches the provided value. When a variable specification contains
a modify index, that modify index is used as the check-index for the
check-and-set operation and can be overridden using this flag.
-force
Perform this operation regardless of the state or index of the variable
on the server-side.
-in (hcl | json)
Parser to use for data supplied via standard input or when the variable
specification's type can not be known using the file extension. Defaults
to "json".
-out (go-template | hcl | json | none | table)
Format to render created or updated variable. Defaults to "none" when
stdout is a terminal and "json" when the output is redirected.
-template
Template to render output with. Required when format is "go-template",
invalid for other formats.
-verbose
Provides additional information via standard error to preserve standard
output (stdout) for redirected output.
`
return strings.TrimSpace(helpText)
}
func (c *VarPutCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-in": complete.PredictSet("hcl", "json"),
"-out": complete.PredictSet("none", "hcl", "json", "go-template", "table"),
},
)
}
func (c *VarPutCommand) AutocompleteArgs() complete.Predictor {
return VariablePathPredictor(c.Meta.Client)
}
func (c *VarPutCommand) Synopsis() string {
return "Create or update a variable"
}
func (c *VarPutCommand) Name() string { return "var put" }
func (c *VarPutCommand) Run(args []string) int {
var force, enforce, doVerbose bool
var path, checkIndexStr string
var checkIndex uint64
var err error
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.BoolVar(&force, "force", false, "")
flags.BoolVar(&doVerbose, "verbose", false, "")
flags.StringVar(&checkIndexStr, "check-index", "", "")
flags.StringVar(&c.inFmt, "in", "json", "")
flags.StringVar(&c.tmpl, "template", "", "")
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
flags.StringVar(&c.outFmt, "out", "none", "")
} else {
flags.StringVar(&c.outFmt, "out", "json", "")
}
if err := flags.Parse(args); err != nil {
c.Ui.Error(commandErrorText(c))
return 1
}
args = flags.Args()
// Manage verbose output
verbose := func(_ string) {} //no-op
if doVerbose {
verbose = func(msg string) {
c.Ui.Warn(msg)
}
}
c.verbose = verbose
// Parse the check-index
checkIndex, enforce, err = parseCheckIndex(checkIndexStr)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error parsing check-index value %q: %v", checkIndexStr, err))
return 1
}
if c.Meta.namespace == "*" {
c.Ui.Error(errWildcardNamespaceNotAllowed)
return 1
}
// Pull our fake stdin if needed
stdin := (io.Reader)(os.Stdin)
if c.testStdin != nil {
stdin = c.testStdin
}
switch {
case len(args) < 1:
c.Ui.Error(fmt.Sprintf("Not enough arguments (expected >1, got %d)", len(args)))
c.Ui.Error(commandErrorText(c))
return 1
case len(args) == 1 && !isArgStdinRef(args[0]) && !isArgFileRef(args[0]):
c.Ui.Error("Must supply data")
c.Ui.Error(commandErrorText(c))
return 1
}
if err = c.validateInputFlag(); err != nil {
c.Ui.Error(err.Error())
c.Ui.Error(commandErrorText(c))
return 1
}
if err := c.validateOutputFlag(); err != nil {
c.Ui.Error(err.Error())
c.Ui.Error(commandErrorText(c))
return 1
}
arg := args[0]
switch {
// Handle first argument: can be -, @file, «var path»
case isArgStdinRef(arg):
// read the specification into memory from stdin
stat, _ := os.Stdin.Stat()
if (stat.Mode() & os.ModeCharDevice) == 0 {
c.contents, err = io.ReadAll(os.Stdin)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error reading from stdin: %s", err))
return 1
}
}
verbose(fmt.Sprintf("Reading whole %s variable specification from stdin", strings.ToUpper(c.inFmt)))
case isArgFileRef(arg):
// ArgFileRefs start with "@" so we need to peel that off
// detect format based on file extension
specPath := arg[1:]
switch filepath.Ext(specPath) {
case ".json":
c.inFmt = "json"
case ".hcl":
c.inFmt = "hcl"
default:
c.Ui.Error(fmt.Sprintf("Unable to determine format of %s; Use the -in flag to specify it.", specPath))
return 1
}
verbose(fmt.Sprintf("Reading whole %s variable specification from %q", strings.ToUpper(c.inFmt), specPath))
c.contents, err = os.ReadFile(specPath)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error reading %q: %s", specPath, err))
return 1
}
default:
path = sanitizePath(arg)
verbose(fmt.Sprintf("Writing to path %q", path))
}
args = args[1:]
switch {
// Handle second argument: can be -, @file, or kv
case len(args) == 0:
// no-op
case isArgStdinRef(args[0]):
verbose(fmt.Sprintf("Creating variable %q using specification from stdin", path))
stat, _ := os.Stdin.Stat()
if (stat.Mode() & os.ModeCharDevice) == 0 {
c.contents, err = io.ReadAll(os.Stdin)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error reading from stdin: %s", err))
return 1
}
}
args = args[1:]
case isArgFileRef(args[0]):
arg := args[0]
verbose(fmt.Sprintf("Creating variable %q from specification file %q", path, arg))
fPath := arg[1:]
c.contents, err = os.ReadFile(fPath)
if err != nil {
c.Ui.Error(fmt.Sprintf("error reading %q: %s", fPath, err))
return 1
}
args = args[1:]
default:
// no-op - should be KV arg
}
sv, err := c.makeVariable(path)
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to parse variable data: %s", err))
return 1
}
if len(args) > 0 {
data, err := parseArgsData(stdin, args)
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to parse K=V data: %s", err))
return 1
}
for k, v := range data {
vs := v.(string)
if vs == "" {
if _, ok := sv.Items[k]; ok {
verbose(fmt.Sprintf("Removed item %q", k))
delete(sv.Items, k)
} else {
verbose(fmt.Sprintf("Item %q does not exist, continuing...", k))
}
continue
}
sv.Items[k] = vs
}
}
// Get the HTTP client
client, err := c.Meta.Client()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
return 1
}
if enforce {
sv.ModifyIndex = checkIndex
}
if force {
sv, _, err = client.Variables().Update(sv, nil)
} else {
sv, _, err = client.Variables().CheckedUpdate(sv, nil)
}
if err != nil {
if handled := handleCASError(err, c); handled {
return 1
}
c.Ui.Error(fmt.Sprintf("Error creating variable: %s", err))
return 1
}
verbose(fmt.Sprintf("Created variable %q with modify index %v", sv.Path, sv.ModifyIndex))
var out string
switch c.outFmt {
case "json":
out = sv.AsPrettyJSON()
case "hcl":
out = renderAsHCL(sv)
case "go-template":
if out, err = renderWithGoTemplate(sv, c.tmpl); err != nil {
c.Ui.Error(err.Error())
return 1
}
case "table":
// the renderSVAsUiTable func writes directly to the ui and doesn't error.
renderSVAsUiTable(sv, c)
return 0
default:
return 0
}
c.Ui.Output(out)
return 0
}
// makeVariable creates a variable based on whether or not there is data in
// content and the format is set.
func (c *VarPutCommand) makeVariable(path string) (*api.Variable, error) {
var err error
out := new(api.Variable)
if len(c.contents) == 0 {
out.Path = path
out.Namespace = c.Meta.namespace
out.Items = make(map[string]string)
return out, nil
}
switch c.inFmt {
case "json":
err = json.Unmarshal(c.contents, out)
if err != nil {
return nil, fmt.Errorf("error unmarshaling json: %w", err)
}
case "hcl":
out, err = parseVariableSpec(c.contents, c.verbose)
if err != nil {
return nil, fmt.Errorf("error parsing hcl: %w", err)
}
case "":
return nil, errors.New("format flag required")
default:
return nil, fmt.Errorf("unknown format flag value")
}
// Handle cases where values are provided by CLI flags that modify the
// the created variable. Typical of a "copy" operation, it is a convenience
// to reset the Create and Modify metadata to zero.
var resetIndex bool
// Step on the namespace in the object if one is provided by flag
if c.Meta.namespace != "" && c.Meta.namespace != out.Namespace {
out.Namespace = c.Meta.namespace
resetIndex = true
}
// Step on the path in the object if one is provided by argument.
if path != "" && path != out.Path {
out.Path = path
resetIndex = true
}
if resetIndex {
out.CreateIndex = 0
out.CreateTime = 0
out.ModifyIndex = 0
out.ModifyTime = 0
}
return out, nil
}
// parseVariableSpec is used to parse the variable specification
// from HCL
func parseVariableSpec(input []byte, verbose func(string)) (*api.Variable, error) {
root, err := hcl.ParseBytes(input)
if err != nil {
return nil, err
}
// Top-level item should be a list
list, ok := root.Node.(*ast.ObjectList)
if !ok {
return nil, fmt.Errorf("error parsing: root should be an object")
}
var out api.Variable
if err := parseVariableSpecImpl(&out, list); err != nil {
return nil, err
}
return &out, nil
}
// parseVariableSpecImpl parses the variable taking as input the AST tree
func parseVariableSpecImpl(result *api.Variable, list *ast.ObjectList) error {
// Decode the full thing into a map[string]interface for ease
var m map[string]interface{}
if err := hcl.DecodeObject(&m, list); err != nil {
return err
}
// Check for invalid keys
valid := []string{
"namespace",
"path",
"create_index",
"modify_index",
"create_time",
"modify_time",
"items",
}
if err := helper.CheckHCLKeys(list, valid); err != nil {
return err
}
for _, index := range []string{"create_index", "modify_index"} {
if value, ok := m[index]; ok {
vInt, ok := value.(int)
if !ok {
return fmt.Errorf("%s must be integer; got (%T) %[2]v", index, value)
}
idx := uint64(vInt)
n := strings.ReplaceAll(strings.Title(strings.ReplaceAll(index, "_", " ")), " ", "")
m[n] = idx
delete(m, index)
}
}
for _, index := range []string{"create_time", "modify_time"} {
if value, ok := m[index]; ok {
vInt, ok := value.(int)
if !ok {
return fmt.Errorf("%s must be a int64; got a (%T) %[2]v", index, value)
}
n := strings.ReplaceAll(strings.Title(strings.ReplaceAll(index, "_", " ")), " ", "")
m[n] = vInt
delete(m, index)
}
}
// Decode the rest
if err := mapstructure.WeakDecode(m, result); err != nil {
return err
}
return nil
}
func isArgFileRef(a string) bool {
return strings.HasPrefix(a, "@") && !strings.HasPrefix(a, "\\@")
}
func isArgStdinRef(a string) bool {
return a == "-"
}
// sanitizePath removes any leading or trailing things from a "path".
func sanitizePath(s string) string {
return strings.Trim(strings.TrimSpace(s), "/")
}
// parseArgsData parses the given args in the format key=value into a map of
// the provided arguments. The given reader can also supply key=value pairs.
func parseArgsData(stdin io.Reader, args []string) (map[string]interface{}, error) {
builder := &KVBuilder{Stdin: stdin}
if err := builder.Add(args...); err != nil {
return nil, err
}
return builder.Map(), nil
}
func (c *VarPutCommand) GetConcurrentUI() cli.ConcurrentUi {
return cli.ConcurrentUi{Ui: c.Ui}
}
func (c *VarPutCommand) validateInputFlag() error {
switch c.inFmt {
case "hcl", "json":
return nil
default:
return errors.New(errInvalidInFormat)
}
}
func (c *VarPutCommand) validateOutputFlag() error {
if c.outFmt != "go-template" && c.tmpl != "" {
return errors.New(errUnexpectedTemplate)
}
switch c.outFmt {
case "none", "json", "hcl", "table":
return nil
case "go-template":
if c.tmpl == "" {
return errors.New(errMissingTemplate)
}
return nil
default:
return errors.New(errInvalidOutFormat)
}
}

123
command/var_put_test.go Normal file
View File

@ -0,0 +1,123 @@
package command
import (
"encoding/json"
"strings"
"testing"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/ci"
"github.com/mitchellh/cli"
"github.com/posener/complete"
"github.com/stretchr/testify/require"
)
func TestVarPutCommand_Implements(t *testing.T) {
ci.Parallel(t)
var _ cli.Command = &VarPutCommand{}
}
func TestVarPutCommand_Fails(t *testing.T) {
ci.Parallel(t)
t.Run("bad_args", func(t *testing.T) {
ci.Parallel(t)
ui := cli.NewMockUi()
cmd := &VarPutCommand{Meta: Meta{Ui: ui}}
code := cmd.Run([]string{"-bad-flag"})
out := ui.ErrorWriter.String()
require.Equal(t, 1, code, "expected exit code 1, got: %d")
require.Contains(t, out, commandErrorText(cmd), "expected help output, got: %s", out)
})
t.Run("bad_address", func(t *testing.T) {
ci.Parallel(t)
ui := cli.NewMockUi()
cmd := &VarPutCommand{Meta: Meta{Ui: ui}}
code := cmd.Run([]string{"-address=nope", "foo", "-"})
out := ui.ErrorWriter.String()
require.Equal(t, 1, code, "expected exit code 1, got: %d")
require.Contains(t, out, "Error creating variable", "expected error creating variable, got: %s", out)
})
t.Run("missing_template", func(t *testing.T) {
ci.Parallel(t)
ui := cli.NewMockUi()
cmd := &VarPutCommand{Meta: Meta{Ui: ui}}
code := cmd.Run([]string{`-out=go-template`, "foo", "-"})
out := strings.TrimSpace(ui.ErrorWriter.String())
require.Equal(t, 1, code, "expected exit code 1, got: %d", code)
require.Equal(t, errMissingTemplate+"\n"+commandErrorText(cmd), out)
})
t.Run("unexpected_template", func(t *testing.T) {
ci.Parallel(t)
ui := cli.NewMockUi()
cmd := &VarPutCommand{Meta: Meta{Ui: ui}}
code := cmd.Run([]string{`-out=json`, `-template="bad"`, "foo", "-"})
out := strings.TrimSpace(ui.ErrorWriter.String())
require.Equal(t, 1, code, "expected exit code 1, got: %d", code)
require.Equal(t, errUnexpectedTemplate+"\n"+commandErrorText(cmd), out)
})
t.Run("bad_in", func(t *testing.T) {
ci.Parallel(t)
ui := cli.NewMockUi()
cmd := &VarPutCommand{Meta: Meta{Ui: ui}}
code := cmd.Run([]string{`-in=bad`, "foo", "-"})
out := strings.TrimSpace(ui.ErrorWriter.String())
require.Equal(t, 1, code, "expected exit code 1, got: %d", code)
require.Equal(t, errInvalidInFormat+"\n"+commandErrorText(cmd), out)
})
t.Run("wildcard_namespace", func(t *testing.T) {
ci.Parallel(t)
ui := cli.NewMockUi()
cmd := &VarPutCommand{Meta: Meta{Ui: ui}}
code := cmd.Run([]string{`-namespace=*`, "foo", "-"})
out := strings.TrimSpace(ui.ErrorWriter.String())
require.Equal(t, 1, code, "expected exit code 1, got: %d", code)
require.Equal(t, errWildcardNamespaceNotAllowed, out)
})
}
func TestVarPutCommand_GoodJson(t *testing.T) {
ci.Parallel(t)
// Create a server
srv, client, url := testServer(t, true, nil)
defer srv.Shutdown()
ui := cli.NewMockUi()
cmd := &VarPutCommand{Meta: Meta{Ui: ui}}
// Get the variable
code := cmd.Run([]string{"-address=" + url, "-out=json", "test/var", "k1=v1", "k2=v2"})
require.Equal(t, 0, code, "expected exit 0, got: %d; %v", code, ui.ErrorWriter.String())
t.Cleanup(func() {
_, _ = client.Variables().Delete("test/var", nil)
})
var outVar api.Variable
b := ui.OutputWriter.Bytes()
err := json.Unmarshal(b, &outVar)
require.NoError(t, err, "error unmarshaling json: %v\nb: %s", err, b)
require.Equal(t, "default", outVar.Namespace)
require.Equal(t, "test/var", outVar.Path)
require.Equal(t, api.VariableItems{"k1": "v1", "k2": "v2"}, outVar.Items)
}
func TestVarPutCommand_AutocompleteArgs(t *testing.T) {
ci.Parallel(t)
_, client, url, shutdownFn := testAPIClient(t)
defer shutdownFn()
ui := cli.NewMockUi()
cmd := &VarPutCommand{Meta: Meta{Ui: ui, flagAddress: url}}
// Create a var
sv := testVariable()
_, _, err := client.Variables().Create(sv, nil)
require.NoError(t, err)
args := complete.Args{Last: "t"}
predictor := cmd.AutocompleteArgs()
res := predictor.Predict(args)
require.Equal(t, 1, len(res))
require.Equal(t, sv.Path, res[0])
}

26
command/var_test.go Normal file
View File

@ -0,0 +1,26 @@
package command
import (
"testing"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/command/agent"
)
// testVariable returns a test variable spec
func testVariable() *api.Variable {
return &api.Variable{
Namespace: "default",
Path: "test/var",
Items: map[string]string{
"keyA": "valueA",
"keyB": "valueB",
},
}
}
func testAPIClient(t *testing.T) (srv *agent.TestAgent, client *api.Client, url string, shutdownFn func() error) {
srv, client, url = testServer(t, true, nil)
shutdownFn = srv.Shutdown
return
}

View File

@ -237,7 +237,7 @@ func (sv *Variables) Read(args *structs.VariablesReadRequest, reply *structs.Var
reply.Data = &ov
reply.Index = out.ModifyIndex
} else {
sv.srv.replySetIndex(state.TableVariables, &reply.QueryMeta)
sv.srv.setReplyQueryMeta(s, state.TableVariables, &reply.QueryMeta)
}
return nil
}}
@ -266,9 +266,6 @@ func (sv *Variables) List(
if err != nil {
return err
}
if err != nil {
return err
}
// Set up and return the blocking query.
return sv.srv.blockingRPC(&blockingOptions{