ba728f8f97
* api: enable support for setting original source alongside job This PR adds support for setting job source material along with the registration of a job. This includes a new HTTP endpoint and a new RPC endpoint for making queries for the original source of a job. The HTTP endpoint is /v1/job/<id>/submission?version=<version> and the RPC method is Job.GetJobSubmission. The job source (if submitted, and doing so is always optional), is stored in the job_submission memdb table, separately from the actual job. This way we do not incur overhead of reading the large string field throughout normal job operations. The server config now includes job_max_source_size for configuring the maximum size the job source may be, before the server simply drops the source material. This should help prevent Bad Things from happening when huge jobs are submitted. If the value is set to 0, all job source material will be dropped. * api: avoid writing var content to disk for parsing * api: move submission validation into RPC layer * api: return an error if updating a job submission without namespace or job id * api: be exact about the job index we associate a submission with (modify) * api: reword api docs scheduling * api: prune all but the last 6 job submissions * api: protect against nil job submission in job validation * api: set max job source size in test server * api: fixups from pr
649 lines
13 KiB
Go
649 lines
13 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
package command
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/hashicorp/nomad/api"
|
|
"github.com/hashicorp/nomad/ci"
|
|
"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"
|
|
}
|
|
}
|
|
}`
|
|
)
|
|
|
|
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"),
|
|
},
|
|
|
|
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_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)
|
|
})
|
|
}
|