open-nomad/command/helpers_test.go

736 lines
15 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package command
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"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/mitchellh/cli"
"github.com/shoenig/test/must"
"github.com/stretchr/testify/require"
)
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_LocalFile_InvalidHCL2 asserts that a custom message is emited
// if the file is a valid HCL1 but not HCL2
func TestJobGetter_LocalFile_InvalidHCL2(t *testing.T) {
ci.Parallel(t)
cases := []struct {
name string
hcl string
expectHCL1Message bool
}{
{
"invalid HCL",
"nothing",
false,
},
{
"invalid HCL2",
`job "example" {
meta { "key.with.dot" = "b" }
}`,
true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
fh, err := os.CreateTemp("", "nomad")
require.NoError(t, err)
defer os.Remove(fh.Name())
defer fh.Close()
_, err = fh.WriteString(c.hcl)
require.NoError(t, err)
j := &JobGetter{}
_, _, err = j.ApiJob(fh.Name())
require.Error(t, err)
exptMessage := "Failed to parse using HCL 2. Use the HCL 1"
if c.expectHCL1Message {
require.Contains(t, err.Error(), exptMessage)
} else {
require.NotContains(t, err.Error(), exptMessage)
}
})
}
}
// 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")
require.NoError(t, err)
defer os.Remove(hclf.Name())
defer hclf.Close()
_, err = hclf.WriteString(hcl)
require.NoError(t, err)
vf, err := os.CreateTemp("", "var.hcl")
require.NoError(t, err)
defer os.Remove(vf.Name())
defer vf.Close()
_, err = vf.WriteString(fileVars + "\n")
require.NoError(t, err)
jg := &JobGetter{
Vars: cliArgs,
VarFiles: []string{vf.Name()},
Strict: true,
}
_, j, err := jg.Get(hclf.Name())
require.NoError(t, err)
require.NotNil(t, j)
require.Equal(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")
require.NoError(t, err)
defer os.Remove(hclf.Name())
defer hclf.Close()
_, err = hclf.WriteString(hcl)
require.NoError(t, err)
vf, err := os.CreateTemp("", "var.hcl")
require.NoError(t, err)
defer os.Remove(vf.Name())
defer vf.Close()
_, err = vf.WriteString(fileVars + "\n")
require.NoError(t, err)
jg := &JobGetter{
Vars: cliArgs,
VarFiles: []string{vf.Name()},
Strict: false,
}
_, j, err := jg.Get(hclf.Name())
require.NoError(t, err)
require.NotNil(t, j)
require.Equal(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
}{
{
"StrictAndHCL1",
JobGetter{
HCL1: true,
Strict: true,
},
"HCLv1 and HCLv2 strict",
},
{
"JSONandHCL1",
JobGetter{
HCL1: true,
JSON: true,
},
"HCL and JSON",
},
{
"VarsAndHCL1",
JobGetter{
HCL1: true,
Vars: []string{"foo"},
},
"variables with HCLv1",
},
{
"VarFilesAndHCL1",
JobGetter{
HCL1: true,
VarFiles: []string{"foo.var"},
},
"variables with HCLv1",
},
{
"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 "":
require.NoError(t, err)
default:
require.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))
require.NoError(t, err)
require.Equal(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")
require.Equal(t, partialAcc[:lastNL+1], errBuf.String())
}
require.Empty(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"
require.Equal(t, expectedErr, errBuf.String())
// close emits the final line
err := w.Close()
require.NoError(t, err)
expectedErr += "and thensome more\n"
require.Equal(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)
})
}