diff --git a/.changelog/16380.txt b/.changelog/16380.txt new file mode 100644 index 000000000..034d89701 --- /dev/null +++ b/.changelog/16380.txt @@ -0,0 +1,3 @@ +``release-note:improvement +cli: Remove requirement for `list-jobs` capability on several job subcommands that didn't strictly needed it +``` diff --git a/command/job_allocs.go b/command/job_allocs.go index 587d79e58..124468da7 100644 --- a/command/job_allocs.go +++ b/command/job_allocs.go @@ -19,8 +19,9 @@ Usage: nomad job allocs [options] Display allocations for a particular job. - When ACLs are enabled, this command requires a token with the 'read-job' and - 'list-jobs' capabilities for the job's namespace. + When ACLs are enabled, this command requires a token with the 'read-job' + capability for the job's namespace. The 'list-jobs' capability is required to + run the command with a job prefix instead of the exact job ID. General Options: diff --git a/command/job_allocs_test.go b/command/job_allocs_test.go index 9d322d124..3c8f9a6ae 100644 --- a/command/job_allocs_test.go +++ b/command/job_allocs_test.go @@ -3,11 +3,14 @@ package command import ( "testing" + "github.com/hashicorp/nomad/api" "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/command/agent" "github.com/hashicorp/nomad/nomad/mock" "github.com/hashicorp/nomad/nomad/structs" "github.com/mitchellh/cli" "github.com/posener/complete" + "github.com/shoenig/test/must" "github.com/stretchr/testify/require" ) @@ -172,3 +175,116 @@ func TestJobAllocsCommand_AutocompleteArgs(t *testing.T) { require.Equal(t, 1, len(res)) require.Equal(t, j.ID, res[0]) } + +func TestJobAllocsCommand_ACL(t *testing.T) { + ci.Parallel(t) + + // Start server with ACL enabled. + srv, _, url := testServer(t, true, func(c *agent.Config) { + c.ACL.Enabled = true + }) + defer srv.Shutdown() + + // Create a job with an alloc. + job := mock.Job() + state := srv.Agent.Server().State() + err := state.UpsertJob(structs.MsgTypeTestSetup, 100, job) + must.NoError(t, err) + + a := mock.Alloc() + a.Job = job + a.JobID = job.ID + a.TaskGroup = job.TaskGroups[0].Name + a.Metrics = &structs.AllocMetric{} + a.DesiredStatus = structs.AllocDesiredStatusRun + a.ClientStatus = structs.AllocClientStatusRunning + err = state.UpsertAllocs(structs.MsgTypeTestSetup, 200, []*structs.Allocation{a}) + must.NoError(t, err) + + testCases := []struct { + name string + jobPrefix bool + aclPolicy string + expectedErr string + expectedOut string + }{ + { + name: "no token", + aclPolicy: "", + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "missing read-job", + aclPolicy: ` +namespace "default" { + capabilities = ["alloc-lifecycle"] +} +`, + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "read-job allowed", + aclPolicy: ` +namespace "default" { + capabilities = ["read-job"] +} +`, + }, + { + name: "job prefix requires list-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job"] +} +`, + expectedOut: "No allocations", + }, + { + name: "job prefix works with list-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job", "list-jobs"] +} +`, + }, + } + + for i, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobAllocsCommand{Meta: Meta{Ui: ui}} + args := []string{ + "-address", url, + } + + if tc.aclPolicy != "" { + // Create ACL token with test case policy and add it to the + // command. + policyName := nonAlphaNum.ReplaceAllString(tc.name, "-") + token := mock.CreatePolicyAndToken(t, state, uint64(302+i), policyName, tc.aclPolicy) + args = append(args, "-token", token.SecretID) + } + + // Add job ID or job ID prefix to the command. + if tc.jobPrefix { + args = append(args, job.ID[:3]) + } else { + args = append(args, job.ID) + } + + // Run command. + code := cmd.Run(args) + if tc.expectedErr == "" { + must.Zero(t, code) + } else { + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), tc.expectedErr) + } + if tc.expectedOut != "" { + must.StrContains(t, ui.OutputWriter.String(), tc.expectedOut) + } + }) + } +} diff --git a/command/job_deployments.go b/command/job_deployments.go index 45c7c0fd8..e3a043383 100644 --- a/command/job_deployments.go +++ b/command/job_deployments.go @@ -19,8 +19,9 @@ Usage: nomad job deployments [options] Deployments is used to display the deployments for a particular job. - When ACLs are enabled, this command requires a token with the 'read-job' and - 'list-jobs' capabilities for the job's namespace. + When ACLs are enabled, this command requires a token with the 'read-job' + capability for the job's namespace. The 'list-jobs' capability is required to + run the command with a job prefix instead of the exact job ID. General Options: diff --git a/command/job_deployments_test.go b/command/job_deployments_test.go index 97ee41cec..e09317072 100644 --- a/command/job_deployments_test.go +++ b/command/job_deployments_test.go @@ -4,11 +4,14 @@ import ( "strings" "testing" + "github.com/hashicorp/nomad/api" "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/command/agent" "github.com/hashicorp/nomad/nomad/mock" "github.com/hashicorp/nomad/nomad/structs" "github.com/mitchellh/cli" "github.com/posener/complete" + "github.com/shoenig/test/must" "github.com/stretchr/testify/assert" ) @@ -152,3 +155,112 @@ func TestJobDeploymentsCommand_AutocompleteArgs(t *testing.T) { assert.Equal(1, len(res)) assert.Equal(j.ID, res[0]) } + +func TestJobDeploymentsCommand_ACL(t *testing.T) { + ci.Parallel(t) + + // Start server with ACL enabled. + srv, _, url := testServer(t, true, func(c *agent.Config) { + c.ACL.Enabled = true + }) + defer srv.Shutdown() + + // Create a job with a deployment. + job := mock.Job() + state := srv.Agent.Server().State() + err := state.UpsertJob(structs.MsgTypeTestSetup, 100, job) + must.NoError(t, err) + + d := mock.Deployment() + d.JobID = job.ID + d.JobCreateIndex = job.CreateIndex + err = state.UpsertDeployment(101, d) + must.NoError(t, err) + + testCases := []struct { + name string + jobPrefix bool + aclPolicy string + expectedErr string + expectedOut string + }{ + { + name: "no token", + aclPolicy: "", + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "missing read-job", + aclPolicy: ` +namespace "default" { + capabilities = ["alloc-lifecycle"] +} +`, + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "read-job allowed", + aclPolicy: ` +namespace "default" { + capabilities = ["read-job"] +} +`, + }, + { + name: "job prefix requires list-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job"] +} +`, + expectedOut: "No deployments", + }, + { + name: "job prefix works with list-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job", "list-jobs"] +} +`, + }, + } + + for i, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobDeploymentsCommand{Meta: Meta{Ui: ui}} + args := []string{ + "-address", url, + } + + if tc.aclPolicy != "" { + // Create ACL token with test case policy and add it to the + // command. + policyName := nonAlphaNum.ReplaceAllString(tc.name, "-") + token := mock.CreatePolicyAndToken(t, state, uint64(302+i), policyName, tc.aclPolicy) + args = append(args, "-token", token.SecretID) + } + + // Add job ID or job ID prefix to the command. + if tc.jobPrefix { + args = append(args, job.ID[:3]) + } else { + args = append(args, job.ID) + } + + // Run command. + code := cmd.Run(args) + if tc.expectedErr == "" { + must.Zero(t, code) + } else { + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), tc.expectedErr) + } + if tc.expectedOut != "" { + must.StrContains(t, ui.OutputWriter.String(), tc.expectedOut) + } + }) + } +} diff --git a/command/job_dispatch.go b/command/job_dispatch.go index df31b6379..aa7c2d190 100644 --- a/command/job_dispatch.go +++ b/command/job_dispatch.go @@ -33,7 +33,10 @@ Usage: nomad job dispatch [options] [input source] detach flag. When ACLs are enabled, this command requires a token with the 'dispatch-job' - capability for the job's namespace. + capability for the job's namespace. The 'list-jobs' capability is required to + run the command with a job prefix instead of the exact job ID. The 'read-job' + capability is required to monitor the resulting evaluation when -detach is + not used. General Options: diff --git a/command/job_dispatch_test.go b/command/job_dispatch_test.go index d72af9ead..1f3e2a52a 100644 --- a/command/job_dispatch_test.go +++ b/command/job_dispatch_test.go @@ -4,11 +4,14 @@ import ( "strings" "testing" + "github.com/hashicorp/nomad/api" "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/command/agent" "github.com/hashicorp/nomad/nomad/mock" "github.com/hashicorp/nomad/nomad/structs" "github.com/mitchellh/cli" "github.com/posener/complete" + "github.com/shoenig/test/must" "github.com/stretchr/testify/require" ) @@ -85,3 +88,123 @@ func TestJobDispatchCommand_AutocompleteArgs(t *testing.T) { require.Equal(t, 1, len(res)) require.Equal(t, j1.ID, res[0]) } + +func TestJobDispatchCommand_ACL(t *testing.T) { + ci.Parallel(t) + + // Start server with ACL enabled. + srv, _, url := testServer(t, true, func(c *agent.Config) { + c.ACL.Enabled = true + }) + defer srv.Shutdown() + + // Create a parameterized job. + job := mock.MinJob() + job.Type = "batch" + job.ParameterizedJob = &structs.ParameterizedJobConfig{} + state := srv.Agent.Server().State() + err := state.UpsertJob(structs.MsgTypeTestSetup, 100, job) + must.NoError(t, err) + + testCases := []struct { + name string + jobPrefix bool + aclPolicy string + expectedErr string + }{ + { + name: "no token", + aclPolicy: "", + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "missing dispatch-job", + aclPolicy: ` +namespace "default" { + capabilities = ["read-job"] +} +`, + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "dispatch-job allowed but can't monitor eval without read-job", + aclPolicy: ` +namespace "default" { + capabilities = ["dispatch-job"] +} +`, + expectedErr: "No evaluation with id", + }, + { + name: "dispatch-job allowed and can monitor eval with read-job", + aclPolicy: ` +namespace "default" { + capabilities = ["dispatch-job", "read-job"] +} +`, + }, + { + name: "job prefix requires list-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["dispatch-job"] +} +`, + expectedErr: "job not found", + }, + { + name: "job prefix works with list-job but can't monitor eval without read-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["dispatch-job", "list-jobs"] +} +`, + expectedErr: "No evaluation with id", + }, + { + name: "job prefix works with list-job and can monitor eval with read-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job", "dispatch-job", "list-jobs"] +} +`, + }, + } + + for i, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobDispatchCommand{Meta: Meta{Ui: ui}} + args := []string{ + "-address", url, + } + + if tc.aclPolicy != "" { + // Create ACL token with test case policy and add it to the + // command. + policyName := nonAlphaNum.ReplaceAllString(tc.name, "-") + token := mock.CreatePolicyAndToken(t, state, uint64(302+i), policyName, tc.aclPolicy) + args = append(args, "-token", token.SecretID) + } + + // Add job ID or job ID prefix to the command. + if tc.jobPrefix { + args = append(args, job.ID[:3]) + } else { + args = append(args, job.ID) + } + + // Run command. + code := cmd.Run(args) + if tc.expectedErr == "" { + must.Zero(t, code) + } else { + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), tc.expectedErr) + } + }) + } +} diff --git a/command/job_eval.go b/command/job_eval.go index 26cdd0583..70e3d6c5a 100644 --- a/command/job_eval.go +++ b/command/job_eval.go @@ -23,8 +23,9 @@ Usage: nomad job eval [options] operators to force the scheduler to create new allocations under certain scenarios. - When ACLs are enabled, this command requires a token with the 'submit-job' - capability for the job's namespace. + When ACLs are enabled, this command requires a token with the 'read-job' + capability for the job's namespace. The 'list-jobs' capability is required to + run the command with a job prefix instead of the exact job ID. General Options: diff --git a/command/job_eval_test.go b/command/job_eval_test.go index 74f627601..db7924139 100644 --- a/command/job_eval_test.go +++ b/command/job_eval_test.go @@ -5,12 +5,15 @@ import ( "strings" "testing" + "github.com/hashicorp/nomad/api" "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/command/agent" "github.com/hashicorp/nomad/nomad/mock" "github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/testutil" "github.com/mitchellh/cli" "github.com/posener/complete" + "github.com/shoenig/test/must" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -125,3 +128,102 @@ func TestJobEvalCommand_AutocompleteArgs(t *testing.T) { assert.Equal(1, len(res)) assert.Equal(j.ID, res[0]) } + +func TestJobEvalCommand_ACL(t *testing.T) { + ci.Parallel(t) + + // Start server with ACL enabled. + srv, _, url := testServer(t, true, func(c *agent.Config) { + c.ACL.Enabled = true + }) + defer srv.Shutdown() + + // Create a job. + job := mock.MinJob() + state := srv.Agent.Server().State() + err := state.UpsertJob(structs.MsgTypeTestSetup, 100, job) + must.NoError(t, err) + + testCases := []struct { + name string + jobPrefix bool + aclPolicy string + expectedErr string + }{ + { + name: "no token", + aclPolicy: "", + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "missing read-job", + aclPolicy: ` +namespace "default" { + capabilities = ["list-jobs"] +} +`, + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "read-job allowed", + aclPolicy: ` +namespace "default" { + capabilities = ["read-job"] +} +`, + }, + { + name: "job prefix requires list-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job"] +} +`, + expectedErr: "job not found", + }, + { + name: "job prefix works with list-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job", "list-jobs"] +} +`, + }, + } + + for i, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobEvalCommand{Meta: Meta{Ui: ui}} + args := []string{ + "-address", url, + } + + if tc.aclPolicy != "" { + // Create ACL token with test case policy and add it to the + // command. + policyName := nonAlphaNum.ReplaceAllString(tc.name, "-") + token := mock.CreatePolicyAndToken(t, state, uint64(302+i), policyName, tc.aclPolicy) + args = append(args, "-token", token.SecretID) + } + + // Add job ID or job ID prefix to the command. + if tc.jobPrefix { + args = append(args, job.ID[:3]) + } else { + args = append(args, job.ID) + } + + // Run command. + code := cmd.Run(args) + if tc.expectedErr == "" { + must.Zero(t, code) + } else { + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), tc.expectedErr) + } + }) + } +} diff --git a/command/job_history.go b/command/job_history.go index 273634f6a..4c35135cf 100644 --- a/command/job_history.go +++ b/command/job_history.go @@ -26,8 +26,9 @@ Usage: nomad job history [options] the changes that occurred to the job as well as deciding job versions to revert to. - When ACLs are enabled, this command requires a token with the 'read-job' and - 'list-jobs' capabilities for the job's namespace. + When ACLs are enabled, this command requires a token with the 'read-job' + capability for the job's namespace. The 'list-jobs' capability is required to + run the command with a job prefix instead of the exact job ID. General Options: diff --git a/command/job_history_test.go b/command/job_history_test.go index ad0f43753..69ede9f6a 100644 --- a/command/job_history_test.go +++ b/command/job_history_test.go @@ -4,11 +4,14 @@ import ( "strings" "testing" + "github.com/hashicorp/nomad/api" "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/command/agent" "github.com/hashicorp/nomad/nomad/mock" "github.com/hashicorp/nomad/nomad/structs" "github.com/mitchellh/cli" "github.com/posener/complete" + "github.com/shoenig/test/must" "github.com/stretchr/testify/assert" ) @@ -63,3 +66,102 @@ func TestJobHistoryCommand_AutocompleteArgs(t *testing.T) { assert.Equal(1, len(res)) assert.Equal(j.ID, res[0]) } + +func TestJobHistoryCommand_ACL(t *testing.T) { + ci.Parallel(t) + + // Start server with ACL enabled. + srv, _, url := testServer(t, true, func(c *agent.Config) { + c.ACL.Enabled = true + }) + defer srv.Shutdown() + + // Create a job. + job := mock.MinJob() + state := srv.Agent.Server().State() + err := state.UpsertJob(structs.MsgTypeTestSetup, 100, job) + must.NoError(t, err) + + testCases := []struct { + name string + jobPrefix bool + aclPolicy string + expectedErr string + }{ + { + name: "no token", + aclPolicy: "", + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "missing read-job", + aclPolicy: ` +namespace "default" { + capabilities = ["list-jobs"] +} +`, + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "read-job allowed", + aclPolicy: ` +namespace "default" { + capabilities = ["read-job"] +} +`, + }, + { + name: "job prefix requires list-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job"] +} +`, + expectedErr: "job versions not found", + }, + { + name: "job prefix works with list-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job", "list-jobs"] +} +`, + }, + } + + for i, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobHistoryCommand{Meta: Meta{Ui: ui}} + args := []string{ + "-address", url, + } + + if tc.aclPolicy != "" { + // Create ACL token with test case policy and add it to the + // command. + policyName := nonAlphaNum.ReplaceAllString(tc.name, "-") + token := mock.CreatePolicyAndToken(t, state, uint64(302+i), policyName, tc.aclPolicy) + args = append(args, "-token", token.SecretID) + } + + // Add job ID or job ID prefix to the command. + if tc.jobPrefix { + args = append(args, job.ID[:3]) + } else { + args = append(args, job.ID) + } + + // Run command. + code := cmd.Run(args) + if tc.expectedErr == "" { + must.Zero(t, code) + } else { + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), tc.expectedErr) + } + }) + } +} diff --git a/command/job_inspect.go b/command/job_inspect.go index 16a32bcbd..7ca8aeb14 100644 --- a/command/job_inspect.go +++ b/command/job_inspect.go @@ -20,8 +20,9 @@ Alias: nomad inspect Inspect is used to see the specification of a submitted job. - When ACLs are enabled, this command requires a token with the 'read-job' and - 'list-jobs' capabilities for the job's namespace. + When ACLs are enabled, this command requires a token with the 'read-job' + capability for the job's namespace. The 'list-jobs' capability is required to + run the command with a job prefix instead of the exact job ID. General Options: diff --git a/command/job_inspect_test.go b/command/job_inspect_test.go index dbbdcf08c..182b1c4a6 100644 --- a/command/job_inspect_test.go +++ b/command/job_inspect_test.go @@ -4,11 +4,14 @@ import ( "strings" "testing" + "github.com/hashicorp/nomad/api" "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/command/agent" "github.com/hashicorp/nomad/nomad/mock" "github.com/hashicorp/nomad/nomad/structs" "github.com/mitchellh/cli" "github.com/posener/complete" + "github.com/shoenig/test/must" "github.com/stretchr/testify/assert" ) @@ -83,3 +86,102 @@ func TestInspectCommand_AutocompleteArgs(t *testing.T) { assert.Equal(1, len(res)) assert.Equal(j.ID, res[0]) } + +func TestJobInspectCommand_ACL(t *testing.T) { + ci.Parallel(t) + + // Start server with ACL enabled. + srv, _, url := testServer(t, true, func(c *agent.Config) { + c.ACL.Enabled = true + }) + defer srv.Shutdown() + + // Create a job + job := mock.MinJob() + state := srv.Agent.Server().State() + err := state.UpsertJob(structs.MsgTypeTestSetup, 100, job) + must.NoError(t, err) + + testCases := []struct { + name string + jobPrefix bool + aclPolicy string + expectedErr string + }{ + { + name: "no token", + aclPolicy: "", + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "missing read-job", + aclPolicy: ` +namespace "default" { + capabilities = ["list-jobs"] +} +`, + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "read-job allowed", + aclPolicy: ` +namespace "default" { + capabilities = ["read-job"] +} +`, + }, + { + name: "job prefix requires list-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job"] +} +`, + expectedErr: "job not found", + }, + { + name: "job prefix works with list-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job", "list-jobs"] +} +`, + }, + } + + for i, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobInspectCommand{Meta: Meta{Ui: ui}} + args := []string{ + "-address", url, + } + + if tc.aclPolicy != "" { + // Create ACL token with test case policy and add it to the + // command. + policyName := nonAlphaNum.ReplaceAllString(tc.name, "-") + token := mock.CreatePolicyAndToken(t, state, uint64(302+i), policyName, tc.aclPolicy) + args = append(args, "-token", token.SecretID) + } + + // Add job ID or job ID prefix to the command. + if tc.jobPrefix { + args = append(args, job.ID[:3]) + } else { + args = append(args, job.ID) + } + + // Run command. + code := cmd.Run(args) + if tc.expectedErr == "" { + must.Zero(t, code) + } else { + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), tc.expectedErr) + } + }) + } +} diff --git a/command/job_periodic_force.go b/command/job_periodic_force.go index 720dd83cb..8c18ed8c8 100644 --- a/command/job_periodic_force.go +++ b/command/job_periodic_force.go @@ -22,7 +22,10 @@ Usage: nomad job periodic force prohibit_overlap setting. When ACLs are enabled, this command requires a token with the 'submit-job' - and 'list-jobs' capabilities for the job's namespace. + capability for the job's namespace. The 'list-jobs' capability is required to + run the command with a job prefix instead of the exact job ID. The 'read-job' + capability is required to monitor the resulting evaluation when -detach is + not used. General Options: diff --git a/command/job_periodic_force_test.go b/command/job_periodic_force_test.go index 7f892a0b6..b21c6ad4c 100644 --- a/command/job_periodic_force_test.go +++ b/command/job_periodic_force_test.go @@ -6,12 +6,14 @@ import ( "github.com/hashicorp/nomad/api" "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/command/agent" "github.com/hashicorp/nomad/helper/pointer" "github.com/hashicorp/nomad/nomad/mock" "github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/testutil" "github.com/mitchellh/cli" "github.com/posener/complete" + "github.com/shoenig/test/must" "github.com/stretchr/testify/require" ) @@ -246,3 +248,132 @@ func TestJobPeriodicForceCommand_SuccessfulIfJobIDEqualsPrefix(t *testing.T) { require.Contains(t, out, "Monitoring evaluation") require.Contains(t, out, "finished with status \"complete\"") } + +func TestJobPeriodicForceCommand_ACL(t *testing.T) { + ci.Parallel(t) + + // Start server with ACL enabled. + srv, client, url := testServer(t, true, func(c *agent.Config) { + c.ACL.Enabled = true + }) + defer srv.Shutdown() + client.SetSecretID(srv.RootToken.SecretID) + + // Create a periodic job. + jobID := "test_job_periodic_force_acl" + job := testJob(jobID) + job.Periodic = &api.PeriodicConfig{ + SpecType: pointer.Of(api.PeriodicSpecCron), + Spec: pointer.Of("*/15 * * * * *"), + } + + rootTokenOpts := &api.WriteOptions{ + AuthToken: srv.RootToken.SecretID, + } + _, _, err := client.Jobs().Register(job, rootTokenOpts) + must.NoError(t, err) + + testCases := []struct { + name string + jobPrefix bool + aclPolicy string + expectedErr string + }{ + { + name: "no token", + aclPolicy: "", + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "missing submit-job", + aclPolicy: ` +namespace "default" { + capabilities = ["list-jobs"] +} +`, + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "submit-job allowed but can't monitor eval without read-job", + aclPolicy: ` +namespace "default" { + capabilities = ["submit-job"] +} +`, + expectedErr: "No evaluation with id", + }, + { + name: "submit-job allowed and can monitor eval with read-job", + aclPolicy: ` +namespace "default" { + capabilities = ["submit-job", "read-job"] +} +`, + }, + { + name: "job prefix requires list-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["submit-job"] +} +`, + expectedErr: "job not found", + }, + { + name: "job prefix works with list-job but can't monitor eval without read-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["submit-job", "list-jobs"] +} +`, + expectedErr: "No evaluation with id", + }, + { + name: "job prefix works with list-job and can monitor eval with read-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job", "submit-job", "list-jobs"] +} +`, + }, + } + + for i, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobPeriodicForceCommand{Meta: Meta{Ui: ui}} + args := []string{ + "-address", url, + } + + if tc.aclPolicy != "" { + state := srv.Agent.Server().State() + + // Create ACL token with test case policy and add it to the + // command. + policyName := nonAlphaNum.ReplaceAllString(tc.name, "-") + token := mock.CreatePolicyAndToken(t, state, uint64(302+i), policyName, tc.aclPolicy) + args = append(args, "-token", token.SecretID) + } + + // Add job ID or job ID prefix to the command. + if tc.jobPrefix { + args = append(args, jobID[:3]) + } else { + args = append(args, jobID) + } + + // Run command. + code := cmd.Run(args) + if tc.expectedErr == "" { + must.Zero(t, code) + } else { + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), tc.expectedErr) + } + }) + } +} diff --git a/command/job_promote.go b/command/job_promote.go index 2b63933be..74e053cf9 100644 --- a/command/job_promote.go +++ b/command/job_promote.go @@ -28,7 +28,9 @@ Usage: nomad job promote [options] "nomad job revert" command. When ACLs are enabled, this command requires a token with the 'submit-job', - 'list-jobs', and 'read-job' capabilities for the job's namespace. + and 'read-job' capabilities for the job's namespace. The 'list-jobs' + capability is required to run the command with a job prefix instead of the + exact job ID. General Options: diff --git a/command/job_promote_test.go b/command/job_promote_test.go index cc69336b2..c6ebe00c2 100644 --- a/command/job_promote_test.go +++ b/command/job_promote_test.go @@ -4,8 +4,11 @@ import ( "strings" "testing" + "github.com/hashicorp/nomad/api" "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/command/agent" "github.com/hashicorp/nomad/nomad/structs" + "github.com/shoenig/test/must" "github.com/hashicorp/nomad/nomad/mock" "github.com/mitchellh/cli" @@ -64,3 +67,119 @@ func TestJobPromoteCommand_AutocompleteArgs(t *testing.T) { assert.Equal(1, len(res)) assert.Equal(j.ID, res[0]) } + +func TestJobPromoteCommand_ACL(t *testing.T) { + ci.Parallel(t) + + // Start server with ACL enabled. + srv, _, url := testServer(t, true, func(c *agent.Config) { + c.ACL.Enabled = true + }) + defer srv.Shutdown() + + // Create a job. + job := mock.MinJob() + state := srv.Agent.Server().State() + err := state.UpsertJob(structs.MsgTypeTestSetup, 100, job) + must.NoError(t, err) + + testCases := []struct { + name string + jobPrefix bool + aclPolicy string + expectedErr string + }{ + { + name: "no token", + aclPolicy: "", + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "missing submit-job", + aclPolicy: ` +namespace "default" { + capabilities = ["read-job"] +} +`, + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "missing read-job", + aclPolicy: ` +namespace "default" { + capabilities = ["submit-job"] +} +`, + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "read-job and submit-job allowed", + aclPolicy: ` +namespace "default" { + capabilities = ["read-job", "submit-job"] +} +`, + }, + { + name: "job prefix requires list-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job", "submit-job",] +} +`, + expectedErr: "no deployment to promote", + }, + { + name: "job prefix works with list-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job", "submit-job", "list-jobs"] +} +`, + }, + } + + for i, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobPromoteCommand{Meta: Meta{Ui: ui}} + args := []string{ + "-address", url, + "-detach", + } + + // Create deployment to promote. + d := mock.Deployment() + d.JobID = job.ID + d.JobCreateIndex = job.CreateIndex + err = state.UpsertDeployment(uint64(301+i), d) + must.NoError(t, err) + + if tc.aclPolicy != "" { + // Create ACL token with test case policy and add it to the + // command. + policyName := nonAlphaNum.ReplaceAllString(tc.name, "-") + token := mock.CreatePolicyAndToken(t, state, uint64(302+i), policyName, tc.aclPolicy) + args = append(args, "-token", token.SecretID) + } + + // Add job ID or job ID prefix to the command. + if tc.jobPrefix { + args = append(args, job.ID[:3]) + } else { + args = append(args, job.ID) + } + + // Run command. + code := cmd.Run(args) + if tc.expectedErr == "" { + must.Zero(t, code) + } else { + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), tc.expectedErr) + } + }) + } +} diff --git a/command/job_revert.go b/command/job_revert.go index fa2198b56..87a7ab808 100644 --- a/command/job_revert.go +++ b/command/job_revert.go @@ -22,7 +22,10 @@ Usage: nomad job revert [options] versions to revert to can be found using "nomad job history" command. When ACLs are enabled, this command requires a token with the 'submit-job' - and 'list-jobs' capabilities for the job's namespace. + capability for the job's namespace. The 'list-jobs' capability is required to + run the command with a job prefix instead of the exact job ID. The 'read-job' + capability is required to monitor the resulting evaluation when -detach is + not used. General Options: diff --git a/command/job_revert_test.go b/command/job_revert_test.go index d906707c2..3c8e644f1 100644 --- a/command/job_revert_test.go +++ b/command/job_revert_test.go @@ -4,11 +4,14 @@ import ( "strings" "testing" + "github.com/hashicorp/nomad/api" "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/command/agent" "github.com/hashicorp/nomad/nomad/mock" structs "github.com/hashicorp/nomad/nomad/structs" "github.com/mitchellh/cli" "github.com/posener/complete" + "github.com/shoenig/test/must" "github.com/stretchr/testify/assert" ) @@ -63,3 +66,136 @@ func TestJobRevertCommand_AutocompleteArgs(t *testing.T) { assert.Equal(1, len(res)) assert.Equal(j.ID, res[0]) } + +func TestJobRevertCommand_ACL(t *testing.T) { + ci.Parallel(t) + + // Start server with ACL enabled. + srv, client, url := testServer(t, true, func(c *agent.Config) { + c.ACL.Enabled = true + }) + defer srv.Shutdown() + + testCases := []struct { + name string + jobPrefix bool + aclPolicy string + expectedErr string + }{ + { + name: "no token", + aclPolicy: "", + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "missing submit-job", + aclPolicy: ` +namespace "default" { + capabilities = ["read-job"] +} +`, + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "submit-job allowed but can't monitor eval without read-job", + aclPolicy: ` +namespace "default" { + capabilities = ["submit-job"] +} +`, + expectedErr: "No evaluation with id", + }, + { + name: "submit-job allowed and can monitor eval with read-job", + aclPolicy: ` +namespace "default" { + capabilities = ["read-job", "submit-job"] +} +`, + }, + { + name: "job prefix requires list-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["submit-job"] +} +`, + expectedErr: "not found", + }, + { + name: "job prefix works with list-job but can't monitor eval without read-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["submit-job", "list-jobs"] +} +`, + expectedErr: "No evaluation with id", + }, + { + name: "job prefix works with list-job and can monitor eval with read-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job", "submit-job", "list-jobs"] +} +`, + }, + } + + for i, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobRevertCommand{Meta: Meta{Ui: ui}} + args := []string{ + "-address", url, + } + + // Create a job. + job := mock.MinJob() + state := srv.Agent.Server().State() + err := state.UpsertJob(structs.MsgTypeTestSetup, uint64(300+i), job) + must.NoError(t, err) + defer func() { + client.Jobs().Deregister(job.ID, true, &api.WriteOptions{ + AuthToken: srv.RootToken.SecretID, + }) + }() + + // Modify job to create new version. + newJob := job.Copy() + newJob.Meta = map[string]string{ + "test": tc.name, + } + newJob.Version = uint64(i) + err = state.UpsertJob(structs.MsgTypeTestSetup, uint64(301+i), newJob) + must.NoError(t, err) + + if tc.aclPolicy != "" { + // Create ACL token with test case policy and add it to the + // command. + policyName := nonAlphaNum.ReplaceAllString(tc.name, "-") + token := mock.CreatePolicyAndToken(t, state, uint64(302+i), policyName, tc.aclPolicy) + args = append(args, "-token", token.SecretID) + } + + // Add job ID or job ID prefix to the command. + if tc.jobPrefix { + args = append(args, job.ID[:3]) + } else { + args = append(args, job.ID) + } + + // Run command reverting job to version 0. + args = append(args, "0") + code := cmd.Run(args) + if tc.expectedErr == "" { + must.Zero(t, code) + } else { + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), tc.expectedErr) + } + }) + } +} diff --git a/command/job_scale.go b/command/job_scale.go index 2dabe11e4..46e652c5f 100644 --- a/command/job_scale.go +++ b/command/job_scale.go @@ -32,8 +32,12 @@ Usage: nomad job scale [options] [] onto nodes. The monitor will end once job placement is done. It is safe to exit the monitor early using ctrl+c. - When ACLs are enabled, this command requires a token with the 'scale-job' - capability for the job's namespace. + When ACLs are enabled, this command requires a token with the + 'read-job-scaling' and either the 'scale-job' or 'submit-job' capabilities + for the job's namespace. The 'list-jobs' capability is required to run the + command with a job prefix instead of the exact job ID. The 'read-job' + capability is required to monitor the resulting evaluation when -detach is + not used. General Options: diff --git a/command/job_scale_test.go b/command/job_scale_test.go index a7fd2a461..c4eb3a2af 100644 --- a/command/job_scale_test.go +++ b/command/job_scale_test.go @@ -7,9 +7,13 @@ import ( "github.com/hashicorp/nomad/api" "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/command/agent" "github.com/hashicorp/nomad/helper/pointer" + "github.com/hashicorp/nomad/nomad/mock" + "github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/testutil" "github.com/mitchellh/cli" + "github.com/shoenig/test/must" ) func TestJobScaleCommand_SingleGroup(t *testing.T) { @@ -122,3 +126,153 @@ func TestJobScaleCommand_MultiGroup(t *testing.T) { t.Fatalf("Expected Evaluation ID within output: %v", out) } } + +func TestJobScaleCommand_ACL(t *testing.T) { + ci.Parallel(t) + + // Start server with ACL enabled. + srv, client, url := testServer(t, true, func(c *agent.Config) { + c.ACL.Enabled = true + }) + defer srv.Shutdown() + + testCases := []struct { + name string + jobPrefix bool + aclPolicy string + expectedErr string + }{ + { + name: "no token", + aclPolicy: "", + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "missing scale-job or job-submit", + aclPolicy: ` +namespace "default" { + capabilities = ["read-job-scaling"] +} +`, + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "missing read-job-scaling", + aclPolicy: ` +namespace "default" { + capabilities = ["scale-job"] +} +`, + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "read-job-scaling and scale-job allowed but can't monitor eval without read-job", + aclPolicy: ` +namespace "default" { + capabilities = ["read-job-scaling", "scale-job"] +} +`, + expectedErr: "No evaluation with id", + }, + { + name: "read-job-scaling and submit-job allowed but can't monitor eval without read-job", + aclPolicy: ` +namespace "default" { + capabilities = ["read-job-scaling", "submit-job"] +} +`, + expectedErr: "No evaluation with id", + }, + { + name: "read-job-scaling and scale-job allowed and can monitor eval with read-job", + aclPolicy: ` +namespace "default" { + capabilities = ["read-job", "read-job-scaling", "scale-job"] +} +`, + }, + { + name: "read-job-scaling and submit-job allowed and can monitor eval with read-job", + aclPolicy: ` +namespace "default" { + capabilities = ["read-job", "read-job-scaling", "submit-job"] +} +`, + }, + { + name: "job prefix requires list-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job-scaling", "scale-job"] +} +`, + expectedErr: "job not found", + }, + { + name: "job prefix works with list-job but can't monitor eval without read-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job-scaling", "scale-job", "list-jobs"] +} +`, + expectedErr: "No evaluation with id", + }, + { + name: "job prefix works with list-job and can monitor eval with read-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job", "read-job-scaling", "scale-job", "list-jobs"] +} +`, + }, + } + + for i, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobScaleCommand{Meta: Meta{Ui: ui}} + args := []string{ + "-address", url, + } + + // Create a job. + job := mock.MinJob() + state := srv.Agent.Server().State() + err := state.UpsertJob(structs.MsgTypeTestSetup, uint64(300+i), job) + must.NoError(t, err) + defer func() { + client.Jobs().Deregister(job.ID, true, &api.WriteOptions{ + AuthToken: srv.RootToken.SecretID, + }) + }() + + if tc.aclPolicy != "" { + // Create ACL token with test case policy and add it to the + // command. + policyName := nonAlphaNum.ReplaceAllString(tc.name, "-") + token := mock.CreatePolicyAndToken(t, state, uint64(302+i), policyName, tc.aclPolicy) + args = append(args, "-token", token.SecretID) + } + + // Add job ID or job ID prefix to the command. + if tc.jobPrefix { + args = append(args, job.ID[:3]) + } else { + args = append(args, job.ID) + } + + // Run command scaling job to 2. + args = append(args, "2") + code := cmd.Run(args) + if tc.expectedErr == "" { + must.Zero(t, code) + } else { + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), tc.expectedErr) + } + }) + } +} diff --git a/command/job_scaling_events.go b/command/job_scaling_events.go index b1fad6d31..9fdb91d23 100644 --- a/command/job_scaling_events.go +++ b/command/job_scaling_events.go @@ -27,8 +27,10 @@ Usage: nomad job scaling-events [options] List the scaling events for the specified job. - When ACLs are enabled, this command requires a token with the - 'read-job-scaling' capability for the job's namespace. + When ACLs are enabled, this command requires a token with either the + 'read-job' or 'read-job-scaling' capability for the job's namespace. The + 'list-jobs' capability is required to run the command with a job prefix + instead of the exact job ID. General Options: diff --git a/command/job_scaling_events_test.go b/command/job_scaling_events_test.go index eb062d277..020b46557 100644 --- a/command/job_scaling_events_test.go +++ b/command/job_scaling_events_test.go @@ -5,10 +5,15 @@ import ( "strings" "testing" + "github.com/hashicorp/nomad/api" "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/command/agent" "github.com/hashicorp/nomad/helper/pointer" + "github.com/hashicorp/nomad/nomad/mock" + "github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/testutil" "github.com/mitchellh/cli" + "github.com/shoenig/test/must" ) func TestJobScalingEventsCommand_Run(t *testing.T) { @@ -89,3 +94,110 @@ func TestJobScalingEventsCommand_Run(t *testing.T) { t.Fatalf("Expected to verbose table headers: %v", out) } } + +func TestJobScalingEventsCommand_ACL(t *testing.T) { + ci.Parallel(t) + + // Start server with ACL enabled. + srv, _, url := testServer(t, true, func(c *agent.Config) { + c.ACL.Enabled = true + }) + defer srv.Shutdown() + + // Create a job. + job := mock.MinJob() + state := srv.Agent.Server().State() + err := state.UpsertJob(structs.MsgTypeTestSetup, 100, job) + must.NoError(t, err) + + testCases := []struct { + name string + jobPrefix bool + aclPolicy string + expectedErr string + }{ + { + name: "no token", + aclPolicy: "", + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "missing read-job or read-job-scaling", + aclPolicy: ` +namespace "default" { + capabilities = ["submit-job"] +} +`, + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "read-job-scaling allowed", + aclPolicy: ` +namespace "default" { + capabilities = ["read-job-scaling"] +} +`, + }, + { + name: "read-job allowed", + aclPolicy: ` +namespace "default" { + capabilities = ["read-job"] +} +`, + }, + { + name: "job prefix requires list-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job-scaling"] +} +`, + expectedErr: "job not found", + }, + { + name: "job prefix works with list-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job-scaling","list-jobs"] +} +`, + }, + } + + for i, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobScalingEventsCommand{Meta: Meta{Ui: ui}} + args := []string{ + "-address", url, + } + + if tc.aclPolicy != "" { + // Create ACL token with test case policy and add it to the + // command. + policyName := nonAlphaNum.ReplaceAllString(tc.name, "-") + token := mock.CreatePolicyAndToken(t, state, uint64(302+i), policyName, tc.aclPolicy) + args = append(args, "-token", token.SecretID) + } + + // Add job ID or job ID prefix to the command. + if tc.jobPrefix { + args = append(args, job.ID[:3]) + } else { + args = append(args, job.ID) + } + + // Run command. + code := cmd.Run(args) + if tc.expectedErr == "" { + must.Zero(t, code) + } else { + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), tc.expectedErr) + } + }) + } +} diff --git a/command/job_status.go b/command/job_status.go index 7d842ba4b..4040fc835 100644 --- a/command/job_status.go +++ b/command/job_status.go @@ -32,8 +32,9 @@ Usage: nomad status [options] Display status information about a job. If no job ID is given, a list of all known jobs will be displayed. - When ACLs are enabled, this command requires a token with the 'read-job' and - 'list-jobs' capabilities for the job's namespace. + When ACLs are enabled, this command requires a token with the 'read-job' + capability for the job's namespace. The 'list-jobs' capability is required to + run the command with a job prefix instead of the exact job ID. General Options: diff --git a/command/job_status_test.go b/command/job_status_test.go index 5c0aac2b2..70475c6c7 100644 --- a/command/job_status_test.go +++ b/command/job_status_test.go @@ -15,6 +15,7 @@ import ( "github.com/hashicorp/nomad/testutil" "github.com/mitchellh/cli" "github.com/posener/complete" + "github.com/shoenig/test/must" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -392,6 +393,105 @@ func TestJobStatusCommand_RescheduleEvals(t *testing.T) { require.Contains(out, e.ID[:8]) } +func TestJobStatusCommand_ACL(t *testing.T) { + ci.Parallel(t) + + // Start server with ACL enabled. + srv, _, url := testServer(t, true, func(c *agent.Config) { + c.ACL.Enabled = true + }) + defer srv.Shutdown() + + // Create a job. + job := mock.MinJob() + state := srv.Agent.Server().State() + err := state.UpsertJob(structs.MsgTypeTestSetup, 100, job) + must.NoError(t, err) + + testCases := []struct { + name string + jobPrefix bool + aclPolicy string + expectedErr string + }{ + { + name: "no token", + aclPolicy: "", + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "missing read-job", + aclPolicy: ` +namespace "default" { + capabilities = ["submit-job"] +} +`, + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "read-job allowed", + aclPolicy: ` +namespace "default" { + capabilities = ["read-job"] +} +`, + }, + { + name: "job prefix requires list-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job"] +} +`, + expectedErr: "job not found", + }, + { + name: "job prefix works with list-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job", "list-jobs"] +} +`, + }, + } + + for i, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobStatusCommand{Meta: Meta{Ui: ui}} + args := []string{ + "-address", url, + } + + if tc.aclPolicy != "" { + // Create ACL token with test case policy and add it to the + // command. + policyName := nonAlphaNum.ReplaceAllString(tc.name, "-") + token := mock.CreatePolicyAndToken(t, state, uint64(302+i), policyName, tc.aclPolicy) + args = append(args, "-token", token.SecretID) + } + + // Add job ID or job ID prefix to the command. + if tc.jobPrefix { + args = append(args, job.ID[:3]) + } else { + args = append(args, job.ID) + } + code := cmd.Run(args) + + // Run command. + if tc.expectedErr == "" { + must.Zero(t, code) + } else { + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), tc.expectedErr) + } + }) + } +} + func waitForSuccess(ui cli.Ui, client *api.Client, length int, t *testing.T, evalId string) int { mon := newMonitor(ui, client, length) monErr := mon.monitor(evalId) diff --git a/command/job_stop.go b/command/job_stop.go index 99d1eeecd..b97c50be7 100644 --- a/command/job_stop.go +++ b/command/job_stop.go @@ -25,8 +25,10 @@ Alias: nomad stop allocations and completes shutting down. It is safe to exit the monitor early using ctrl+c. - When ACLs are enabled, this command requires a token with the 'submit-job', - 'read-job', and 'list-jobs' capabilities for the job's namespace. + When ACLs are enabled, this command requires a token with the 'submit-job' + and 'read-job' capabilities for the job's namespace. The 'list-jobs' + capability is required to run the command with job prefixes instead of exact + job IDs. General Options: diff --git a/command/job_stop_test.go b/command/job_stop_test.go index 331124697..454094b1d 100644 --- a/command/job_stop_test.go +++ b/command/job_stop_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "testing" + "github.com/hashicorp/nomad/api" "github.com/hashicorp/nomad/ci" "github.com/hashicorp/nomad/command/agent" "github.com/hashicorp/nomad/helper/pointer" @@ -148,3 +149,117 @@ func TestStopCommand_AutocompleteArgs(t *testing.T) { must.Len(t, 1, res) must.Eq(t, j.ID, res[0]) } + +func TestJobStopCommand_ACL(t *testing.T) { + ci.Parallel(t) + + // Start server with ACL enabled. + srv, client, url := testServer(t, true, func(c *agent.Config) { + c.ACL.Enabled = true + }) + defer srv.Shutdown() + + testCases := []struct { + name string + jobPrefix bool + aclPolicy string + expectedErr string + }{ + { + name: "no token", + aclPolicy: "", + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "missing submit-job", + aclPolicy: ` +namespace "default" { + capabilities = ["read-job"] +} +`, + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "missing read-job", + aclPolicy: ` +namespace "default" { + capabilities = ["submit-job"] +} +`, + expectedErr: api.PermissionDeniedErrorContent, + }, + { + name: "read-job and submit-job allowed", + aclPolicy: ` +namespace "default" { + capabilities = ["read-job", "submit-job"] +} +`, + }, + { + name: "job prefix requires list-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["read-job", "submit-job"] +} +`, + expectedErr: "job not found", + }, + { + name: "job prefix works with list-job", + jobPrefix: true, + aclPolicy: ` +namespace "default" { + capabilities = ["list-jobs", "read-job", "submit-job"] +} +`, + }, + } + + for i, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ui := cli.NewMockUi() + cmd := &JobStopCommand{Meta: Meta{Ui: ui}} + args := []string{ + "-address", url, + "-yes", + } + + // Create a job. + job := mock.MinJob() + state := srv.Agent.Server().State() + err := state.UpsertJob(structs.MsgTypeTestSetup, uint64(300+i), job) + must.NoError(t, err) + defer func() { + client.Jobs().Deregister(job.ID, true, &api.WriteOptions{ + AuthToken: srv.RootToken.SecretID, + }) + }() + + if tc.aclPolicy != "" { + // Create ACL token with test case policy and add it to the + // command. + policyName := nonAlphaNum.ReplaceAllString(tc.name, "-") + token := mock.CreatePolicyAndToken(t, state, uint64(302+i), policyName, tc.aclPolicy) + args = append(args, "-token", token.SecretID) + } + + // Add job ID or job ID prefix to the command. + if tc.jobPrefix { + args = append(args, job.ID[:3]) + } else { + args = append(args, job.ID) + } + + // Run command. + code := cmd.Run(args) + if tc.expectedErr == "" { + must.Zero(t, code) + } else { + must.One(t, code) + must.StrContains(t, ui.ErrorWriter.String(), tc.expectedErr) + } + }) + } +} diff --git a/command/meta.go b/command/meta.go index a49560fc1..06aa63e22 100644 --- a/command/meta.go +++ b/command/meta.go @@ -260,13 +260,17 @@ func (m *Meta) JobByPrefix(client *api.Client, prefix string, filter JobByPrefix return job, nil } -// JobIDByPrefix returns the job that best matches the given prefix and its -// namespace. Returns an error if there are no matches or if there are more -// than one exact match across namespaces. +// JobIDByPrefix provides best effort match for the given job prefix. +// Returns the prefix itself if job prefix search is not allowed and an error +// if there are no matches or if there are more than one exact match across +// namespaces. func (m *Meta) JobIDByPrefix(client *api.Client, prefix string, filter JobByPrefixFilterFunc) (string, string, error) { // Search job by prefix. Return an error if there is not an exact match. jobs, _, err := client.Jobs().PrefixList(prefix) if err != nil { + if strings.Contains(err.Error(), api.PermissionDeniedErrorContent) { + return prefix, "", nil + } return "", "", fmt.Errorf("Error querying job prefix %q: %s", prefix, err) } diff --git a/command/testing_test.go b/command/testing_test.go index 36d79c433..b1ffc23fd 100644 --- a/command/testing_test.go +++ b/command/testing_test.go @@ -3,6 +3,7 @@ package command import ( "fmt" "os" + "regexp" "testing" "time" @@ -14,6 +15,8 @@ import ( "github.com/shoenig/test/must" ) +var nonAlphaNum = regexp.MustCompile(`[^a-zA-Z0-9]+`) + func testServer(t *testing.T, runClient bool, cb func(*agent.Config)) (*agent.TestAgent, *api.Client, string) { // Make a new test server a := agent.NewTestAgent(t, t.Name(), func(config *agent.Config) { diff --git a/website/content/docs/commands/job/allocs.mdx b/website/content/docs/commands/job/allocs.mdx index 3b2d7e3a7..72f2e9a44 100644 --- a/website/content/docs/commands/job/allocs.mdx +++ b/website/content/docs/commands/job/allocs.mdx @@ -19,8 +19,9 @@ nomad job allocs [options] The `job allocs` command requires a single argument, the job ID or an ID prefix of a job to display the list of allocations for. -When ACLs are enabled, this command requires a token with the `read-job` and -`list-jobs` capabilities for the job's namespace. +When ACLs are enabled, this command requires a token with the `read-job` +capability for the job's namespace. The `list-jobs` capability is required to +run the command with a job prefix instead of the exact job ID. ## General Options diff --git a/website/content/docs/commands/job/deployments.mdx b/website/content/docs/commands/job/deployments.mdx index 77fe84563..ccfcf28be 100644 --- a/website/content/docs/commands/job/deployments.mdx +++ b/website/content/docs/commands/job/deployments.mdx @@ -19,8 +19,9 @@ nomad job deployments [options] The `job deployments` command requires a single argument, the job ID or an ID prefix of a job to display the list of deployments for. -When ACLs are enabled, this command requires a token with the `read-job` and -`list-jobs` capabilities for the job's namespace. +When ACLs are enabled, this command requires a token with the `read-job` +capability for the job's namespace. The `list-jobs` capability is required to +run the command with a job prefix instead of the exact job ID. ## General Options diff --git a/website/content/docs/commands/job/dispatch.mdx b/website/content/docs/commands/job/dispatch.mdx index 31c11555c..a865780b2 100644 --- a/website/content/docs/commands/job/dispatch.mdx +++ b/website/content/docs/commands/job/dispatch.mdx @@ -45,7 +45,10 @@ exhaustion, etc), then the exit code will be 2. Any other errors, including client connection issues or internal errors, are indicated by exit code 1. When ACLs are enabled, this command requires a token with the `dispatch-job` -capability for the job's namespace. +capability for the job's namespace. The `list-jobs` capability is required to +run the command with a job prefix instead of the exact job ID. The `read-job` +capability is required to monitor the resulting evaluation when `-detach` is +not used. See the [multiregion] documentation for additional considerations when dispatching parameterized jobs. diff --git a/website/content/docs/commands/job/eval.mdx b/website/content/docs/commands/job/eval.mdx index 3f22111fd..05199a549 100644 --- a/website/content/docs/commands/job/eval.mdx +++ b/website/content/docs/commands/job/eval.mdx @@ -20,8 +20,9 @@ The `job eval` command requires a single argument, specifying the job ID to evaluate. If there is an exact match based on the provided job ID, then the job will be evaluated, forcing a scheduler run. -When ACLs are enabled, this command requires a token with the `submit-job` -capability for the job's namespace. +When ACLs are enabled, this command requires a token with the `read-job` +capability for the job's namespace. The `list-jobs` capability is required to +run the command with a job prefix instead of the exact job ID. ## General Options diff --git a/website/content/docs/commands/job/history.mdx b/website/content/docs/commands/job/history.mdx index 2252b7e93..9544f6414 100644 --- a/website/content/docs/commands/job/history.mdx +++ b/website/content/docs/commands/job/history.mdx @@ -21,8 +21,9 @@ nomad job history [options] The `job history` command requires a single argument, the job ID or an ID prefix of a job to display the history for. -When ACLs are enabled, this command requires a token with the `read-job` and -`list-jobs` capabilities for the job's namespace. +When ACLs are enabled, this command requires a token with the `read-job` +capability for the job's namespace. The `list-jobs` capability is required to +run the command with a job prefix instead of the exact job ID. ## General Options diff --git a/website/content/docs/commands/job/inspect.mdx b/website/content/docs/commands/job/inspect.mdx index e181e6193..a96593f77 100644 --- a/website/content/docs/commands/job/inspect.mdx +++ b/website/content/docs/commands/job/inspect.mdx @@ -22,8 +22,9 @@ will retrieve the JSON version of the job. This JSON is valid to be submitted to the [Job HTTP API]. This command is useful to inspect what version of a job Nomad is running. -When ACLs are enabled, this command requires a token with the `read-job` and -`list-jobs` capabilities for the job's namespace. +When ACLs are enabled, this command requires a token with the `read-job` +capability for the job's namespace. The `list-jobs` capability is required to +run the command with a job prefix instead of the exact job ID. ## General Options diff --git a/website/content/docs/commands/job/periodic-force.mdx b/website/content/docs/commands/job/periodic-force.mdx index 2ace88951..8aca82eb8 100644 --- a/website/content/docs/commands/job/periodic-force.mdx +++ b/website/content/docs/commands/job/periodic-force.mdx @@ -27,7 +27,10 @@ placement information for the forced evaluation. The monitor will exit after scheduling has finished or failed. When ACLs are enabled, this command requires a token with the `submit-job` -and `list-jobs` capabilities for the job's namespace. +capability for the job's namespace. The `list-jobs` capability is required to +run the command with a job prefix instead of the exact job ID. The `read-job` +capability is required to monitor the resulting evaluation when `-detach` is +not used. ## General Options diff --git a/website/content/docs/commands/job/promote.mdx b/website/content/docs/commands/job/promote.mdx index 658422494..28dab1fc7 100644 --- a/website/content/docs/commands/job/promote.mdx +++ b/website/content/docs/commands/job/promote.mdx @@ -28,7 +28,9 @@ promotes all task groups. The group flag can be specified multiple times to select particular groups to promote. When ACLs are enabled, this command requires a token with the `submit-job`, -`list-jobs`, and `read-job` capabilities for the job's namespace. +and `read-job` capabilities for the job's namespace. The `list-jobs` +capability is required to run the command with a job prefix instead of the +exact job ID. ## General Options diff --git a/website/content/docs/commands/job/revert.mdx b/website/content/docs/commands/job/revert.mdx index 3c7dfc8b2..f0bd4efa6 100644 --- a/website/content/docs/commands/job/revert.mdx +++ b/website/content/docs/commands/job/revert.mdx @@ -35,7 +35,10 @@ The `job revert` command requires two inputs, the job ID and the version of that job to revert to. When ACLs are enabled, this command requires a token with the `submit-job` -and `list-jobs` capabilities for the job's namespace. +capability for the job's namespace. The `list-jobs` capability is required to +run the command with a job prefix instead of the exact job ID. The `read-job` +capability is required to monitor the resulting evaluation when `-detach` is +not used. ## General Options diff --git a/website/content/docs/commands/job/scale.mdx b/website/content/docs/commands/job/scale.mdx index bb4c10d79..f3171dc5f 100644 --- a/website/content/docs/commands/job/scale.mdx +++ b/website/content/docs/commands/job/scale.mdx @@ -28,8 +28,12 @@ Scale will issue a request to update the matched job and then invoke an interact monitor that exits automatically once the scheduler has processed the request. It is safe to exit the monitor early using ctrl+c. -When ACLs are enabled, this command requires a token with the `scale-job` -capability for the job's namespace. +When ACLs are enabled, this command requires a token with the +`read-job-scaling` and either the `scale-job` or `submit-job` capabilities +for the job's namespace. The `list-jobs` capability is required to run the +command with a job prefix instead of the exact job ID. The `read-job` +capability is required to monitor the resulting evaluation when `-detach` is +not used. ## General Options diff --git a/website/content/docs/commands/job/scaling-events.mdx b/website/content/docs/commands/job/scaling-events.mdx index 98ecb45dd..dd639766f 100644 --- a/website/content/docs/commands/job/scaling-events.mdx +++ b/website/content/docs/commands/job/scaling-events.mdx @@ -19,8 +19,10 @@ nomad job scaling-events [options] The `job scaling-events` command requires a single argument, a submitted job's ID, and will output the stored scaling events for the job if there are any. -When ACLs are enabled, this command requires a token with the -`read-job-scaling` capability for the job's namespace. +When ACLs are enabled, this command requires a token with either the +`read-job` or `read-job-scaling` capability for the job's namespace. The +`list-jobs` capability is required to run the command with a job prefix +instead of the exact job ID. ## General Options diff --git a/website/content/docs/commands/job/status.mdx b/website/content/docs/commands/job/status.mdx index e28450bad..5842f8059 100644 --- a/website/content/docs/commands/job/status.mdx +++ b/website/content/docs/commands/job/status.mdx @@ -26,8 +26,9 @@ shows allocation modification time in addition to create time. When the `-verbose` flag is not set, allocation creation and modify times are shown in a shortened relative time format like `5m ago`. -When ACLs are enabled, this command requires a token with the `read-job` and -`list-jobs` capabilities for the job's namespace. +When ACLs are enabled, this command requires a token with the `read-job` +capability for the job's namespace. The `list-jobs` capability is required to +run the command with a job prefix instead of the exact job ID. ## General Options diff --git a/website/content/docs/commands/job/stop.mdx b/website/content/docs/commands/job/stop.mdx index 3e5f91aa5..ad19f5afa 100644 --- a/website/content/docs/commands/job/stop.mdx +++ b/website/content/docs/commands/job/stop.mdx @@ -27,8 +27,10 @@ Stop will issue a request to deregister the matched jobs and then invoke an interactive monitor that exits automatically once the scheduler has processed the requests. It is safe to exit the monitor early using ctrl+c. -When ACLs are enabled, this command requires a token with the `submit-job`, -`read-job`, and `list-jobs` capabilities for the job's namespace. +When ACLs are enabled, this command requires a token with the `submit-job` +and `read-job` capabilities for the job's namespace. The `list-jobs` +capability is required to run the command with job prefixes instead of exact +job IDs. ## General Options