Files
nomad/command/helpers_test.go
Tim Gross 4cdfa19b1e volume status: default type to show both DHV and CSI volumes (#25185)
The `-type` option for `volume status` is a UX papercut because for many
clusters there will be only one sort of volume in use. Update the CLI so that
the default behavior is to query CSI and/or DHV.

This behavior is subtly different when the user provides an ID or not. If the
user doesn't provide an ID, we query both CSI and DHV and show both tables. If
the user provides an ID, we query DHV first and then CSI, and show only the
appropriate volume. Because DHV IDs are UUIDs, we're sure we won't have
collisions between the two. We only show errors if both queries return an error.

Fixes: https://hashicorp.atlassian.net/browse/NET-12214
2025-02-24 11:38:07 -05:00

725 lines
16 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package command
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/hashicorp/cli"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/ci"
"github.com/hashicorp/nomad/client/testutil"
"github.com/hashicorp/nomad/helper/flatmap"
"github.com/hashicorp/nomad/helper/pointer"
"github.com/kr/pretty"
"github.com/shoenig/test/must"
)
func TestHelpers_FormatKV(t *testing.T) {
ci.Parallel(t)
in := []string{"alpha|beta", "charlie|delta", "echo|"}
out := formatKV(in)
expect := "alpha = beta\n"
expect += "charlie = delta\n"
expect += "echo = <none>"
if out != expect {
t.Fatalf("expect: %s, got: %s", expect, out)
}
}
func TestHelpers_FormatList(t *testing.T) {
ci.Parallel(t)
in := []string{"alpha|beta||delta"}
out := formatList(in)
expect := "alpha beta <none> delta"
if out != expect {
t.Fatalf("expect: %s, got: %s", expect, out)
}
}
func TestHelpers_NodeID(t *testing.T) {
ci.Parallel(t)
srv, _, _ := testServer(t, false, nil)
defer srv.Shutdown()
meta := Meta{Ui: cli.NewMockUi()}
client, err := meta.Client()
if err != nil {
t.FailNow()
}
// This is because there is no client
if _, err := getLocalNodeID(client); err == nil {
t.Fatalf("getLocalNodeID() should fail")
}
}
func TestHelpers_LineLimitReader_NoTimeLimit(t *testing.T) {
ci.Parallel(t)
helloString := `hello
world
this
is
a
test`
noLines := "jskdfhjasdhfjkajkldsfdlsjkahfkjdsafa"
cases := []struct {
Input string
Output string
Lines int
SearchLimit int
}{
{
Input: helloString,
Output: helloString,
Lines: 6,
SearchLimit: 1000,
},
{
Input: helloString,
Output: `world
this
is
a
test`,
Lines: 5,
SearchLimit: 1000,
},
{
Input: helloString,
Output: `test`,
Lines: 1,
SearchLimit: 1000,
},
{
Input: helloString,
Output: "",
Lines: 0,
SearchLimit: 1000,
},
{
Input: helloString,
Output: helloString,
Lines: 6,
SearchLimit: 1, // Exceed the limit
},
{
Input: noLines,
Output: noLines,
Lines: 10,
SearchLimit: 1000,
},
{
Input: noLines,
Output: noLines,
Lines: 10,
SearchLimit: 2,
},
}
for i, c := range cases {
in := io.NopCloser(strings.NewReader(c.Input))
limit := NewLineLimitReader(in, c.Lines, c.SearchLimit, 0)
outBytes, err := io.ReadAll(limit)
if err != nil {
t.Fatalf("case %d failed: %v", i, err)
}
out := string(outBytes)
if out != c.Output {
t.Fatalf("case %d: got %q; want %q", i, out, c.Output)
}
}
}
type testReadCloser struct {
data chan []byte
}
func (t *testReadCloser) Read(p []byte) (n int, err error) {
select {
case b, ok := <-t.data:
if !ok {
return 0, io.EOF
}
return copy(p, b), nil
case <-time.After(10 * time.Millisecond):
return 0, nil
}
}
func (t *testReadCloser) Close() error {
close(t.data)
return nil
}
func TestHelpers_LineLimitReader_TimeLimit(t *testing.T) {
ci.Parallel(t)
// Create the test reader
in := &testReadCloser{data: make(chan []byte)}
// Set up the reader such that it won't hit the line/buffer limit and could
// only terminate if it hits the time limit
limit := NewLineLimitReader(in, 1000, 1000, 100*time.Millisecond)
expected := []byte("hello world")
errCh := make(chan error)
resultCh := make(chan []byte)
go func() {
defer close(resultCh)
defer close(errCh)
outBytes, err := io.ReadAll(limit)
if err != nil {
errCh <- fmt.Errorf("ReadAll failed: %v", err)
return
}
resultCh <- outBytes
}()
// Send the data
in.data <- expected
in.Close()
select {
case err := <-errCh:
if err != nil {
t.Fatalf("ReadAll: %v", err)
}
case outBytes := <-resultCh:
if !reflect.DeepEqual(outBytes, expected) {
t.Fatalf("got:%s, expected,%s", string(outBytes), string(expected))
}
case <-time.After(1 * time.Second):
t.Fatalf("did not exit by time limit")
}
}
const (
job1 = `job "job1" {
type = "service"
datacenters = ["dc1"]
group "group1" {
count = 1
task "task1" {
driver = "exec"
resources {}
}
restart {
attempts = 10
mode = "delay"
interval = "15s"
render_templates = false
}
}
}`
)
var (
expectedApiJob = &api.Job{
ID: pointer.Of("job1"),
Name: pointer.Of("job1"),
Type: pointer.Of("service"),
Datacenters: []string{"dc1"},
TaskGroups: []*api.TaskGroup{
{
Name: pointer.Of("group1"),
Count: pointer.Of(1),
RestartPolicy: &api.RestartPolicy{
Attempts: pointer.Of(10),
Interval: pointer.Of(15 * time.Second),
Mode: pointer.Of("delay"),
RenderTemplates: pointer.Of(false),
},
Tasks: []*api.Task{
{
Driver: "exec",
Name: "task1",
Resources: &api.Resources{},
},
},
},
},
}
)
// Test APIJob with local jobfile
func TestJobGetter_LocalFile(t *testing.T) {
ci.Parallel(t)
fh, err := os.CreateTemp("", "nomad")
if err != nil {
t.Fatalf("err: %s", err)
}
defer os.Remove(fh.Name())
_, err = fh.WriteString(job1)
if err != nil {
t.Fatalf("err: %s", err)
}
j := &JobGetter{}
_, aj, err := j.ApiJob(fh.Name())
if err != nil {
t.Fatalf("err: %s", err)
}
if !reflect.DeepEqual(expectedApiJob, aj) {
eflat := flatmap.Flatten(expectedApiJob, nil, false)
aflat := flatmap.Flatten(aj, nil, false)
t.Fatalf("got:\n%v\nwant:\n%v", aflat, eflat)
}
}
// TestJobGetter_HCL2_Variables asserts variable arguments from CLI
// and varfiles are both honored
func TestJobGetter_HCL2_Variables(t *testing.T) {
hcl := `
variables {
var1 = "default-val"
var2 = "default-val"
var3 = "default-val"
var4 = "default-val"
}
job "example" {
datacenters = ["${var.var1}", "${var.var2}", "${var.var3}", "${var.var4}"]
}
`
t.Setenv("NOMAD_VAR_var4", "from-envvar")
cliArgs := []string{`var2=from-cli`}
fileVars := `var3 = "from-varfile"`
expected := []string{"default-val", "from-cli", "from-varfile", "from-envvar"}
hclf, err := os.CreateTemp("", "hcl")
must.NoError(t, err)
defer os.Remove(hclf.Name())
defer hclf.Close()
_, err = hclf.WriteString(hcl)
must.NoError(t, err)
vf, err := os.CreateTemp("", "var.hcl")
must.NoError(t, err)
defer os.Remove(vf.Name())
defer vf.Close()
_, err = vf.WriteString(fileVars + "\n")
must.NoError(t, err)
jg := &JobGetter{
Vars: cliArgs,
VarFiles: []string{vf.Name()},
Strict: true,
}
_, j, err := jg.Get(hclf.Name())
must.NoError(t, err)
must.NotNil(t, j)
must.Eq(t, expected, j.Datacenters)
}
func TestJobGetter_HCL2_Variables_StrictFalse(t *testing.T) {
hcl := `
variables {
var1 = "default-val"
var2 = "default-val"
var3 = "default-val"
var4 = "default-val"
}
job "example" {
datacenters = ["${var.var1}", "${var.var2}", "${var.var3}", "${var.var4}"]
}
`
t.Setenv("NOMAD_VAR_var4", "from-envvar")
// Both the CLI and var file contain variables that are not used with the
// template and therefore would error, if hcl2-strict was true.
cliArgs := []string{`var2=from-cli`, `unsedVar1=from-cli`}
fileVars := `
var3 = "from-varfile"
unsedVar2 = "from-varfile"
`
expected := []string{"default-val", "from-cli", "from-varfile", "from-envvar"}
hclf, err := os.CreateTemp("", "hcl")
must.NoError(t, err)
defer os.Remove(hclf.Name())
defer hclf.Close()
_, err = hclf.WriteString(hcl)
must.NoError(t, err)
vf, err := os.CreateTemp("", "var.hcl")
must.NoError(t, err)
defer os.Remove(vf.Name())
defer vf.Close()
_, err = vf.WriteString(fileVars + "\n")
must.NoError(t, err)
jg := &JobGetter{
Vars: cliArgs,
VarFiles: []string{vf.Name()},
Strict: false,
}
_, j, err := jg.Get(hclf.Name())
must.NoError(t, err)
must.NotNil(t, j)
must.Eq(t, expected, j.Datacenters)
}
// Test StructJob with jobfile from HTTP Server
func TestJobGetter_HTTPServer(t *testing.T) {
ci.Parallel(t)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, job1)
})
go http.ListenAndServe("127.0.0.1:12345", nil)
// Wait until HTTP Server starts certainly
time.Sleep(100 * time.Millisecond)
j := &JobGetter{}
_, aj, err := j.ApiJob("http://127.0.0.1:12345/")
if err != nil {
t.Fatalf("err: %s", err)
}
if !reflect.DeepEqual(expectedApiJob, aj) {
for _, d := range pretty.Diff(expectedApiJob, aj) {
t.Logf(d)
}
t.Fatalf("Unexpected file")
}
}
func TestJobGetter_Validate(t *testing.T) {
cases := []struct {
name string
jg JobGetter
errContains string
}{
{
"VarsAndJSON",
JobGetter{
JSON: true,
Vars: []string{"foo"},
},
"variables with JSON",
},
{
"VarFilesAndJSON",
JobGetter{
JSON: true,
VarFiles: []string{"foo.var"},
},
"variables with JSON files",
},
{
"JSON_OK",
JobGetter{
JSON: true,
},
"",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.jg.Validate()
switch tc.errContains {
case "":
must.NoError(t, err)
default:
must.ErrorContains(t, err, tc.errContains)
}
})
}
}
func TestPrettyTimeDiff(t *testing.T) {
// Grab the time and truncate to the nearest second. This allows our tests
// to be deterministic since we don't have to worry about rounding.
now := time.Now().Truncate(time.Second)
test_cases := []struct {
t1 time.Time
t2 time.Time
exp string
}{
{now, time.Unix(0, 0), ""}, // This is the upgrade path case
{now, now.Add(-10 * time.Millisecond), "0s ago"},
{now, now.Add(-740 * time.Second), "12m20s ago"},
{now, now.Add(-12 * time.Minute), "12m ago"},
{now, now.Add(-60 * time.Minute), "1h ago"},
{now, now.Add(-80 * time.Minute), "1h20m ago"},
{now, now.Add(-6 * time.Hour), "6h ago"},
{now.Add(-6 * time.Hour), now, "6h from now"},
{now, now.Add(-22165 * time.Second), "6h9m ago"},
{now, now.Add(-100 * time.Hour), "4d4h ago"},
{now, now.Add(-438000 * time.Minute), "10mo4d ago"},
{now, now.Add(-20460 * time.Hour), "2y4mo ago"},
}
for _, tc := range test_cases {
t.Run(tc.exp, func(t *testing.T) {
out := prettyTimeDiff(tc.t2, tc.t1)
if out != tc.exp {
t.Fatalf("expected :%v but got :%v", tc.exp, out)
}
})
}
var t1 time.Time
out := prettyTimeDiff(t1, time.Now())
if out != "" {
t.Fatalf("Expected empty output but got:%v", out)
}
}
// TestUiErrorWriter asserts that writer buffers and
func TestUiErrorWriter(t *testing.T) {
ci.Parallel(t)
var outBuf, errBuf bytes.Buffer
ui := &cli.BasicUi{
Writer: &outBuf,
ErrorWriter: &errBuf,
}
w := &uiErrorWriter{ui: ui}
inputs := []string{
"some line\n",
"multiple\nlines\r\nhere",
" with followup\nand",
" more lines ",
" without new line ",
"until here\nand then",
"some more",
}
partialAcc := ""
for _, in := range inputs {
n, err := w.Write([]byte(in))
must.NoError(t, err)
must.Eq(t, len(in), n)
// assert that writer emits partial result until last new line
partialAcc += strings.ReplaceAll(in, "\r\n", "\n")
lastNL := strings.LastIndex(partialAcc, "\n")
must.Eq(t, partialAcc[:lastNL+1], errBuf.String())
}
must.Eq(t, "", outBuf.String())
// note that the \r\n got replaced by \n
expectedErr := "some line\nmultiple\nlines\nhere with followup\nand more lines without new line until here\n"
must.Eq(t, expectedErr, errBuf.String())
// close emits the final line
err := w.Close()
must.NoError(t, err)
expectedErr += "and thensome more\n"
must.Eq(t, expectedErr, errBuf.String())
}
func Test_extractVarFiles(t *testing.T) {
ci.Parallel(t)
t.Run("none", func(t *testing.T) {
result, err := extractVarFiles(nil)
must.NoError(t, err)
must.Eq(t, "", result)
})
t.Run("files", func(t *testing.T) {
d := t.TempDir()
fileOne := filepath.Join(d, "one.hcl")
fileTwo := filepath.Join(d, "two.hcl")
must.NoError(t, os.WriteFile(fileOne, []byte(`foo = "bar"`), 0o644))
must.NoError(t, os.WriteFile(fileTwo, []byte(`baz = 42`), 0o644))
result, err := extractVarFiles([]string{fileOne, fileTwo})
must.NoError(t, err)
must.Eq(t, "foo = \"bar\"\nbaz = 42\n", result)
})
t.Run("unreadble", func(t *testing.T) {
testutil.RequireNonRoot(t)
d := t.TempDir()
fileOne := filepath.Join(d, "one.hcl")
must.NoError(t, os.WriteFile(fileOne, []byte(`foo = "bar"`), 0o200))
_, err := extractVarFiles([]string{fileOne})
must.ErrorContains(t, err, "permission denied")
})
}
func Test_extractVarFlags(t *testing.T) {
ci.Parallel(t)
t.Run("nil", func(t *testing.T) {
result := extractVarFlags(nil)
must.MapEmpty(t, result)
})
t.Run("complete", func(t *testing.T) {
result := extractVarFlags([]string{"one=1", "two=2", "three"})
must.Eq(t, map[string]string{
"one": "1",
"two": "2",
"three": "",
}, result)
})
}
func Test_extractJobSpecEnvVars(t *testing.T) {
ci.Parallel(t)
t.Run("nil", func(t *testing.T) {
must.MapEmpty(t, extractJobSpecEnvVars(nil))
})
t.Run("complete", func(t *testing.T) {
result := extractJobSpecEnvVars([]string{
"NOMAD_VAR_count=13",
"GOPATH=/Users/jrasell/go",
"NOMAD_VAR_image=redis:7",
})
must.Eq(t, map[string]string{
"count": "13",
"image": "redis:7",
}, result)
})
t.Run("whitespace", func(t *testing.T) {
result := extractJobSpecEnvVars([]string{
"NOMAD_VAR_count = 13",
"GOPATH = /Users/jrasell/go",
})
must.Eq(t, map[string]string{
"count ": " 13",
}, result)
})
t.Run("empty key", func(t *testing.T) {
result := extractJobSpecEnvVars([]string{
"NOMAD_VAR_=13",
"=/Users/jrasell/go",
})
must.Eq(t, map[string]string{}, result)
})
t.Run("empty value", func(t *testing.T) {
result := extractJobSpecEnvVars([]string{
"NOMAD_VAR_count=",
"GOPATH=",
})
must.Eq(t, map[string]string{
"count": "",
}, result)
})
}
// TestHelperGetByPrefix exercises the generic getByPrefix function used by
// commands to find a single match by prefix or return matching results if there
// are multiple
func TestHelperGetByPrefix(t *testing.T) {
type testStub struct{ ID string }
testCases := []struct {
name string
queryObjs []*testStub
queryErr error
queryPrefix string
expectMatch *testStub
expectPossible []*testStub
expectErr string
}{
{
name: "query error",
queryErr: errors.New("foo"),
expectErr: "error querying stubs: foo",
},
{
name: "multiple prefix matches with exact match",
queryObjs: []*testStub{
{ID: "testing"},
{ID: "testing123"},
},
queryPrefix: "testing",
expectMatch: &testStub{ID: "testing"},
},
{
name: "multiple prefix matches no exact match",
queryObjs: []*testStub{
{ID: "testing"},
{ID: "testing123"},
},
queryPrefix: "test",
expectPossible: []*testStub{{ID: "testing"}, {ID: "testing123"}},
},
{
name: "no matches",
queryObjs: []*testStub{},
queryPrefix: "test",
expectErr: "no stubs with prefix or ID \"test\" found",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
match, possible, err := getByPrefix[testStub]("stubs",
func(*api.QueryOptions) ([]*testStub, *api.QueryMeta, error) {
return tc.queryObjs, nil, tc.queryErr
},
func(stub *testStub, prefix string) bool { return stub.ID == prefix },
&api.QueryOptions{Prefix: tc.queryPrefix})
if tc.expectErr != "" {
must.EqError(t, err, tc.expectErr)
} else {
must.NoError(t, err)
must.Eq(t, tc.expectMatch, match, must.Sprint("expected exact match"))
must.Eq(t, tc.expectPossible, possible, must.Sprint("expected prefix matches"))
}
})
}
}