Files
nomad/command/meta_test.go
Phil Renaud 35e1ea4328 [cli] UI URL hints for common CLI commands (#24454)
* Basic implementation for server members and node status

* Commands for alloc status and job status

* -ui flag for most commands

* url hints for variables

* url hints for job dispatch, evals, and deployments

* agent config ui.cli_url_links to disable

* Fix an issue where path prefix was presumed for variables

* driver uncomment and general cleanup

* -ui flag on the generic status endpoint

* Job run command gets namespaces, and no longer gets ui hints for --output flag

* Dispatch command hints get a namespace, and bunch o tests

* Lots of tests depend on specific output, so let's not mess with them

* figured out what flagAddress is all about for testServer, oof

* Parallel outside of test instances

* Browser-opening test, sorta

* Env var for disabling/enabling CLI hints

* Addressing a few PR comments

* CLI docs available flags now all have -ui

* PR comments addressed; switched the env var to be consistent and scrunched monitor-adjacent hints a bit more

* ui.Output -> ui.Warn; moves hints from stdout to stderr

* isTerminal check and parseBool on command option

* terminal.IsTerminal check removed for test-runner-not-being-terminal reasons
2025-03-07 13:23:35 -05:00

597 lines
13 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package command
import (
"flag"
"fmt"
"os"
"reflect"
"sort"
"testing"
"github.com/creack/pty"
"github.com/hashicorp/cli"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/ci"
"github.com/hashicorp/nomad/command/agent"
"github.com/hashicorp/nomad/helper/pointer"
"github.com/shoenig/test/must"
)
func TestMeta_FlagSet(t *testing.T) {
ci.Parallel(t)
cases := []struct {
Flags FlagSetFlags
Expected []string
}{
{
FlagSetNone,
[]string{},
},
{
FlagSetClient,
[]string{
"address",
"no-color",
"force-color",
"region",
"namespace",
"ca-cert",
"ca-path",
"client-cert",
"client-key",
"insecure",
"tls-server-name",
"tls-skip-verify",
"token",
},
},
}
for i, tc := range cases {
var m Meta
fs := m.FlagSet("foo", tc.Flags)
actual := make([]string, 0, 0)
fs.VisitAll(func(f *flag.Flag) {
actual = append(actual, f.Name)
})
sort.Strings(actual)
sort.Strings(tc.Expected)
if !reflect.DeepEqual(actual, tc.Expected) {
t.Fatalf("%d: flags: %#v\n\nExpected: %#v\nGot: %#v",
i, tc.Flags, tc.Expected, actual)
}
}
}
func TestMeta_Colorize(t *testing.T) {
type testCaseSetupFn func(*testing.T, *Meta)
cases := []struct {
Name string
SetupFn testCaseSetupFn
ExpectColor bool
}{
{
Name: "disable colors if UI is not colored",
ExpectColor: false,
},
{
Name: "colors if UI is colored",
SetupFn: func(t *testing.T, m *Meta) {
m.Ui = &cli.ColoredUi{}
},
ExpectColor: true,
},
{
Name: "disable colors via CLI flag",
SetupFn: func(t *testing.T, m *Meta) {
m.SetupUi([]string{"-no-color"})
},
ExpectColor: false,
},
{
Name: "disable colors via env var",
SetupFn: func(t *testing.T, m *Meta) {
t.Setenv(EnvNomadCLINoColor, "1")
m.SetupUi([]string{})
},
ExpectColor: false,
},
{
Name: "force colors via CLI flag",
SetupFn: func(t *testing.T, m *Meta) {
m.SetupUi([]string{"-force-color"})
},
ExpectColor: true,
},
{
Name: "force colors via env var",
SetupFn: func(t *testing.T, m *Meta) {
t.Setenv(EnvNomadCLIForceColor, "1")
m.SetupUi([]string{})
},
ExpectColor: true,
},
{
Name: "no color take predecence over force color via CLI flag",
SetupFn: func(t *testing.T, m *Meta) {
m.SetupUi([]string{"-no-color", "-force-color"})
},
ExpectColor: false,
},
{
Name: "no color take predecence over force color via env var",
SetupFn: func(t *testing.T, m *Meta) {
t.Setenv(EnvNomadCLINoColor, "1")
m.SetupUi([]string{"-force-color"})
},
ExpectColor: false,
},
}
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
// Create fake test terminal.
_, tty, err := pty.Open()
must.NoError(t, err)
defer tty.Close()
oldStdout := os.Stdout
defer func() { os.Stdout = oldStdout }()
os.Stdout = tty
// Make sure color related environment variables are clean.
t.Setenv(EnvNomadCLIForceColor, "")
t.Setenv(EnvNomadCLINoColor, "")
// Run test case.
m := &Meta{}
if tc.SetupFn != nil {
tc.SetupFn(t, m)
}
must.Eq(t, !tc.ExpectColor, m.Colorize().Disable)
})
}
}
func TestMeta_JobByPrefix(t *testing.T) {
ci.Parallel(t)
srv, client, _ := testServer(t, true, nil)
defer srv.Shutdown()
// Wait for a node to be ready
waitForNodes(t, client)
ui := cli.NewMockUi()
meta := &Meta{Ui: ui, namespace: api.AllNamespacesNamespace}
client.SetNamespace(api.AllNamespacesNamespace)
jobs := []struct {
namespace string
id string
}{
{namespace: "default", id: "example"},
{namespace: "default", id: "job"},
{namespace: "default", id: "job-1"},
{namespace: "default", id: "job-2"},
{namespace: "prod", id: "job-1"},
}
for _, j := range jobs {
job := testJob(j.id)
job.Namespace = pointer.Of(j.namespace)
_, err := client.Namespaces().Register(&api.Namespace{Name: j.namespace}, nil)
must.NoError(t, err)
w := &api.WriteOptions{Namespace: j.namespace}
resp, _, err := client.Jobs().Register(job, w)
must.NoError(t, err)
code := waitForSuccess(ui, client, fullId, t, resp.EvalID)
must.Zero(t, code)
}
testCases := []struct {
name string
prefix string
filterFunc JobByPrefixFilterFunc
expectedError string
}{
{
name: "exact match",
prefix: "job",
},
{
name: "partial match",
prefix: "exam",
},
{
name: "match with filter",
prefix: "job-",
filterFunc: func(j *api.JobListStub) bool {
// Filter out jobs with "job-" so that only "job-2" matches.
return j.ID == "job-2"
},
},
{
name: "multiple matches",
prefix: "job-",
expectedError: "matched multiple jobs",
},
{
name: "no match",
prefix: "not-found",
expectedError: "No job(s) with prefix or ID",
},
{
name: "multiple matches across namespaces",
prefix: "job-1",
expectedError: "matched multiple jobs",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
job, err := meta.JobByPrefix(client, tc.prefix, tc.filterFunc)
if tc.expectedError != "" {
must.Nil(t, job)
must.ErrorContains(t, err, tc.expectedError)
} else {
must.NoError(t, err)
must.NotNil(t, job)
must.StrContains(t, *job.ID, tc.prefix)
}
})
}
}
func TestMeta_ShowUIPath(t *testing.T) {
ci.Parallel(t)
// Create a test server with UI enabled but CLI URL links disabled
server, client, url := testServer(t, true, func(c *agent.Config) {
c.UI.ShowCLIHints = pointer.Of(true)
})
defer server.Shutdown()
waitForNodes(t, client)
m := &Meta{
Ui: cli.NewMockUi(),
flagAddress: url,
}
cases := []struct {
name string
context UIHintContext
expectedURL string
expectedOpened bool
}{
{
name: "server members",
context: UIHintContext{
Command: "server members",
},
expectedURL: url + "/ui/servers",
},
{
name: "node status (many)",
context: UIHintContext{
Command: "node status",
},
expectedURL: url + "/ui/clients",
},
{
name: "node status (single)",
context: UIHintContext{
Command: "node status single",
PathParams: map[string]string{
"nodeID": "node-1",
},
},
expectedURL: url + "/ui/clients/node-1",
},
{
name: "job status (many)",
context: UIHintContext{
Command: "job status",
},
expectedURL: url + "/ui/jobs",
},
{
name: "job status (single)",
context: UIHintContext{
Command: "job status single",
PathParams: map[string]string{
"jobID": "example-job",
"namespace": "default",
},
},
expectedURL: url + "/ui/jobs/example-job@default",
},
{
name: "job run (default ns)",
context: UIHintContext{
Command: "job run",
PathParams: map[string]string{
"jobID": "example-job",
"namespace": "default",
},
},
expectedURL: url + "/ui/jobs/example-job@default",
},
{
name: "job run (non-default ns)",
context: UIHintContext{
Command: "job run",
PathParams: map[string]string{
"jobID": "example-job",
"namespace": "prod",
},
},
expectedURL: url + "/ui/jobs/example-job@prod",
},
{
name: "job dispatch (default ns)",
context: UIHintContext{
Command: "job dispatch",
PathParams: map[string]string{
"dispatchID": "dispatch-1",
"namespace": "default",
},
},
expectedURL: url + "/ui/jobs/dispatch-1@default",
},
{
name: "job dispatch (non-default ns)",
context: UIHintContext{
Command: "job dispatch",
PathParams: map[string]string{
"dispatchID": "dispatch-1",
"namespace": "toronto",
},
},
expectedURL: url + "/ui/jobs/dispatch-1@toronto",
},
{
name: "eval list",
context: UIHintContext{
Command: "eval list",
},
expectedURL: url + "/ui/evaluations",
},
{
name: "eval status",
context: UIHintContext{
Command: "eval status",
PathParams: map[string]string{
"evalID": "eval-1",
},
},
expectedURL: url + "/ui/evaluations?currentEval=eval-1",
},
{
name: "deployment status",
context: UIHintContext{
Command: "deployment status",
PathParams: map[string]string{
"jobID": "example-job",
},
},
expectedURL: url + "/ui/jobs/example-job/deployments",
},
{
name: "var list (root)",
context: UIHintContext{
Command: "var list",
},
expectedURL: url + "/ui/variables",
},
{
name: "var list (path)",
context: UIHintContext{
Command: "var list prefix",
PathParams: map[string]string{
"prefix": "foo",
},
},
expectedURL: url + "/ui/variables/path/foo",
},
{
name: "var get",
context: UIHintContext{
Command: "var get",
PathParams: map[string]string{
"path": "foo",
"namespace": "default",
},
},
expectedURL: url + "/ui/variables/var/foo@default",
},
{
name: "var put",
context: UIHintContext{
Command: "var put",
PathParams: map[string]string{
"path": "foo",
"namespace": "default",
},
},
expectedURL: url + "/ui/variables/var/foo@default",
},
{
name: "alloc status",
context: UIHintContext{
Command: "alloc status",
PathParams: map[string]string{
"allocID": "alloc-1",
},
},
expectedURL: url + "/ui/allocations/alloc-1",
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
route := CommandUIRoutes[tc.context.Command]
expectedHint := fmt.Sprintf("\n\n==> %s in the Web UI: %s", route.Description, tc.expectedURL)
hint, err := m.showUIPath(tc.context)
must.NoError(t, err)
must.Eq(t, expectedHint, hint)
})
}
}
func TestMeta_ShowUIPath_ShowCLIHintsEnabled(t *testing.T) {
ci.Parallel(t)
// Create a test server with UI enabled and CLI URL links enabled
server, client, url := testServer(t, true, func(c *agent.Config) {
c.UI.ShowCLIHints = pointer.Of(true)
})
defer server.Shutdown()
waitForNodes(t, client)
m := &Meta{
Ui: cli.NewMockUi(),
flagAddress: url,
}
hint, err := m.showUIPath(UIHintContext{
Command: "job status",
})
must.NoError(t, err)
must.StrContains(t, hint, url+"/ui/jobs")
}
func TestMeta_ShowUIPath_ShowCLIHintsDisabled(t *testing.T) {
ci.Parallel(t)
// Create a test server with UI enabled and CLI URL links disabled
server, client, url := testServer(t, true, func(c *agent.Config) {
c.UI.ShowCLIHints = pointer.Of(false)
})
defer server.Shutdown()
waitForNodes(t, client)
m := &Meta{
Ui: cli.NewMockUi(),
flagAddress: url,
}
hint, err := m.showUIPath(UIHintContext{
Command: "job status",
})
must.NoError(t, err)
must.StrNotContains(t, hint, url+"/ui/jobs")
}
func TestMeta_ShowUIPath_EnvVarOverride(t *testing.T) {
testCases := []struct {
name string
envValue string
serverEnabled bool
expectHints bool
}{
{
name: "env var true overrides server false",
envValue: "true",
serverEnabled: false,
expectHints: true,
},
{
name: "env var false overrides server true",
envValue: "false",
serverEnabled: true,
expectHints: false,
},
{
name: "empty env var falls back to server true",
envValue: "",
serverEnabled: true,
expectHints: true,
},
{
name: "empty env var falls back to server false",
envValue: "",
serverEnabled: false,
expectHints: false,
},
}
for _, tc := range testCases {
tc := tc // capture range variable
t.Run(tc.name, func(t *testing.T) {
// Set environment variable
if tc.envValue != "" {
t.Setenv("NOMAD_CLI_SHOW_HINTS", tc.envValue)
}
// Create a test server with UI enabled and CLI hints as per test case
server, client, url := testServer(t, true, func(c *agent.Config) {
c.UI.ShowCLIHints = pointer.Of(tc.serverEnabled)
})
defer server.Shutdown()
waitForNodes(t, client)
m := &Meta{
Ui: cli.NewMockUi(),
flagAddress: url,
}
m.SetupUi([]string{})
hint, err := m.showUIPath(UIHintContext{
Command: "job status",
})
must.NoError(t, err)
if tc.expectHints {
must.StrContains(t, hint, url+"/ui/jobs")
} else {
must.StrNotContains(t, hint, url+"/ui/jobs")
}
})
}
}
func TestMeta_ShowUIPath_BrowserOpening(t *testing.T) {
ci.Parallel(t)
server, client, url := testServer(t, true, func(c *agent.Config) {
c.UI.ShowCLIHints = pointer.Of(true)
})
defer server.Shutdown()
waitForNodes(t, client)
ui := cli.NewMockUi()
m := &Meta{
Ui: ui,
flagAddress: url,
}
hint, err := m.showUIPath(UIHintContext{
Command: "job status",
OpenURL: true,
})
must.NoError(t, err)
must.StrContains(t, hint, url+"/ui/jobs")
// Not a perfect test, but it's a start: make sure showUIPath isn't warning about
// being unable to open the browser.
must.StrNotContains(t, ui.ErrorWriter.String(), "Failed to open browser")
}