diff --git a/command/acl_role.go b/command/acl_role.go new file mode 100644 index 000000000..284c59b9c --- /dev/null +++ b/command/acl_role.go @@ -0,0 +1,102 @@ +package command + +import ( + "fmt" + "sort" + "strings" + + "github.com/hashicorp/nomad/api" + "github.com/mitchellh/cli" +) + +// Ensure ACLRoleCommand satisfies the cli.Command interface. +var _ cli.Command = &ACLRoleCommand{} + +// ACLRoleCommand implements cli.Command. +type ACLRoleCommand struct { + Meta +} + +// Help satisfies the cli.Command Help function. +func (a *ACLRoleCommand) Help() string { + helpText := ` +Usage: nomad acl role [options] [args] + + This command groups subcommands for interacting with ACL roles. Nomad's ACL + system can be used to control access to data and APIs. ACL roles are + associated with one or more ACL policies which grant specific capabilities. + For a full guide see: https://www.nomadproject.io/guides/acl.html + + Create an ACL role: + + $ nomad acl role create -name="name" -policy-name="policy-name" + + List all ACL roles: + + $ nomad acl role list + + Lookup a specific ACL role: + + $ nomad acl role info + + Update an ACL role: + + $ nomad acl role update -name="updated-name" + + Delete an ACL role: + + $ nomad acl role delete + + Please see the individual subcommand help for detailed usage information. +` + return strings.TrimSpace(helpText) +} + +// Synopsis satisfies the cli.Command Synopsis function. +func (a *ACLRoleCommand) Synopsis() string { return "Interact with ACL roles" } + +// Name returns the name of this command. +func (a *ACLRoleCommand) Name() string { return "acl role" } + +// Run satisfies the cli.Command Run function. +func (a *ACLRoleCommand) Run(_ []string) int { return cli.RunResultHelp } + +// formatACLRole formats and converts the ACL role API object into a string KV +// representation suitable for console output. +func formatACLRole(aclRole *api.ACLRole) string { + return formatKV([]string{ + fmt.Sprintf("ID|%s", aclRole.ID), + fmt.Sprintf("Name|%s", aclRole.Name), + fmt.Sprintf("Description|%s", aclRole.Description), + fmt.Sprintf("Policies|%s", strings.Join(aclRolePolicyLinkToStringList(aclRole.Policies), ",")), + fmt.Sprintf("Create Index|%d", aclRole.CreateIndex), + fmt.Sprintf("Modify Index|%d", aclRole.ModifyIndex), + }) +} + +// aclRolePolicyLinkToStringList converts an array of ACL role policy links to +// an array of string policy names. The returned array will be sorted. +func aclRolePolicyLinkToStringList(policyLinks []*api.ACLRolePolicyLink) []string { + policies := make([]string, len(policyLinks)) + for i, policy := range policyLinks { + policies[i] = policy.Name + } + sort.Strings(policies) + return policies +} + +// aclRolePolicyNamesToPolicyLinks takes a list of policy names as a string +// array and converts this to an array of ACL role policy links. Any duplicate +// names are removed. +func aclRolePolicyNamesToPolicyLinks(policyNames []string) []*api.ACLRolePolicyLink { + var policyLinks []*api.ACLRolePolicyLink + keys := make(map[string]struct{}) + + for _, policyName := range policyNames { + if _, ok := keys[policyName]; !ok { + policyLinks = append(policyLinks, &api.ACLRolePolicyLink{Name: policyName}) + keys[policyName] = struct{}{} + } + } + return policyLinks +} diff --git a/command/acl_role_create.go b/command/acl_role_create.go new file mode 100644 index 000000000..f1aa8482b --- /dev/null +++ b/command/acl_role_create.go @@ -0,0 +1,148 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/hashicorp/nomad/api" + "github.com/mitchellh/cli" + "github.com/posener/complete" +) + +// Ensure ACLRoleCreateCommand satisfies the cli.Command interface. +var _ cli.Command = &ACLRoleCreateCommand{} + +// ACLRoleCreateCommand implements cli.Command. +type ACLRoleCreateCommand struct { + Meta + + name string + description string + policyNames []string + json bool + tmpl string +} + +// Help satisfies the cli.Command Help function. +func (a *ACLRoleCreateCommand) Help() string { + helpText := ` +Usage: nomad acl token create [options] + + Create is used to create new ACL roles. Use requires a management token. + +General Options: + + ` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace) + ` + +ACL Create Options: + + -name + Sets the human readable name for the ACL role. The name must be between + 1-128 characters and is a required parameter. + + -description + A free form text description of the role that must not exceed 256 + characters. + + -policy-name + Specifies a policy to associate with the role identified by their name. This + flag can be specified multiple times and must be specified at least once. + + -json + Output the ACL role in a JSON format. + + -t + Format and display the ACL role using a Go template. +` + return strings.TrimSpace(helpText) +} + +func (a *ACLRoleCreateCommand) AutocompleteFlags() complete.Flags { + return mergeAutocompleteFlags(a.Meta.AutocompleteFlags(FlagSetClient), + complete.Flags{ + "-name": complete.PredictAnything, + "-description": complete.PredictAnything, + "-policy-name": complete.PredictAnything, + "-json": complete.PredictNothing, + "-t": complete.PredictAnything, + }) +} + +func (a *ACLRoleCreateCommand) AutocompleteArgs() complete.Predictor { return complete.PredictNothing } + +// Synopsis satisfies the cli.Command Synopsis function. +func (a *ACLRoleCreateCommand) Synopsis() string { return "Create a new ACL role" } + +// Name returns the name of this command. +func (a *ACLRoleCreateCommand) Name() string { return "acl role create" } + +// Run satisfies the cli.Command Run function. +func (a *ACLRoleCreateCommand) Run(args []string) int { + + flags := a.Meta.FlagSet(a.Name(), FlagSetClient) + flags.Usage = func() { a.Ui.Output(a.Help()) } + flags.StringVar(&a.name, "name", "", "") + flags.StringVar(&a.description, "description", "", "") + flags.Var((funcVar)(func(s string) error { + a.policyNames = append(a.policyNames, s) + return nil + }), "policy-name", "") + flags.BoolVar(&a.json, "json", false, "") + flags.StringVar(&a.tmpl, "t", "", "") + if err := flags.Parse(args); err != nil { + return 1 + } + + // Check that we got no arguments. + if len(flags.Args()) != 0 { + a.Ui.Error("This command takes no arguments") + a.Ui.Error(commandErrorText(a)) + return 1 + } + + // Perform some basic validation on the submitted role information to avoid + // sending API and RPC requests which will fail basic validation. + if a.name == "" { + a.Ui.Error("ACL role name must be specified using the -name flag") + return 1 + } + if len(a.policyNames) < 1 { + a.Ui.Error("At least one policy name must be specified using the -policy-name flag") + return 1 + } + + // Set up the ACL with the passed parameters. + aclRole := api.ACLRole{ + Name: a.name, + Description: a.description, + Policies: aclRolePolicyNamesToPolicyLinks(a.policyNames), + } + + // Get the HTTP client. + client, err := a.Meta.Client() + if err != nil { + a.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + // Create the ACL role via the API. + role, _, err := client.ACLRoles().Create(&aclRole, nil) + if err != nil { + a.Ui.Error(fmt.Sprintf("Error creating ACL role: %s", err)) + return 1 + } + + if a.json || len(a.tmpl) > 0 { + out, err := Format(a.json, a.tmpl, role) + if err != nil { + a.Ui.Error(err.Error()) + return 1 + } + + a.Ui.Output(out) + return 0 + } + + a.Ui.Output(formatACLRole(role)) + return 0 +} diff --git a/command/acl_role_create_test.go b/command/acl_role_create_test.go new file mode 100644 index 000000000..6b107df3a --- /dev/null +++ b/command/acl_role_create_test.go @@ -0,0 +1,80 @@ +package command + +import ( + "testing" + + "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/command/agent" + "github.com/hashicorp/nomad/nomad/structs" + "github.com/hashicorp/nomad/testutil" + "github.com/mitchellh/cli" + "github.com/stretchr/testify/require" +) + +func TestACLRoleCreateCommand_Run(t *testing.T) { + ci.Parallel(t) + + // Build a test server with ACLs enabled. + srv, _, url := testServer(t, false, func(c *agent.Config) { + c.ACL.Enabled = true + }) + defer srv.Shutdown() + + // Wait for the server to start fully and ensure we have a bootstrap token. + testutil.WaitForLeader(t, srv.Agent.RPC) + rootACLToken := srv.RootToken + require.NotNil(t, rootACLToken) + + ui := cli.NewMockUi() + cmd := &ACLRoleCreateCommand{ + Meta: Meta{ + Ui: ui, + flagAddress: url, + }, + } + + // Test the basic validation on the command. + require.Equal(t, 1, cmd.Run([]string{"-address=" + url, "this-command-does-not-take-args"})) + require.Contains(t, ui.ErrorWriter.String(), "This command takes no arguments") + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() + + require.Equal(t, 1, cmd.Run([]string{"-address=" + url})) + require.Contains(t, ui.ErrorWriter.String(), "ACL role name must be specified using the -name flag") + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() + + require.Equal(t, 1, cmd.Run([]string{"-address=" + url, `-name="foobar"`})) + require.Contains(t, ui.ErrorWriter.String(), "At least one policy name must be specified using the -policy-name flag") + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() + + // Create an ACL policy that can be referenced within the ACL role. + aclPolicy := structs.ACLPolicy{ + Name: "acl-role-cli-test-policy", + Rules: `namespace "default" { + policy = "read" + } + `, + } + err := srv.Agent.Server().State().UpsertACLPolicies( + structs.MsgTypeTestSetup, 10, []*structs.ACLPolicy{&aclPolicy}) + require.NoError(t, err) + + // Create an ACL role. + args := []string{ + "-address=" + url, "-token=" + rootACLToken.SecretID, "-name=acl-role-cli-test", + "-policy-name=acl-role-cli-test-policy", "-description=acl-role-all-the-things", + } + require.Equal(t, 0, cmd.Run(args)) + s := ui.OutputWriter.String() + require.Contains(t, s, "Name = acl-role-cli-test") + require.Contains(t, s, "Description = acl-role-all-the-things") + require.Contains(t, s, "Policies = acl-role-cli-test-policy") + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() +} diff --git a/command/acl_role_delete.go b/command/acl_role_delete.go new file mode 100644 index 000000000..d7b472297 --- /dev/null +++ b/command/acl_role_delete.go @@ -0,0 +1,83 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/mitchellh/cli" + "github.com/posener/complete" +) + +// Ensure ACLRoleDeleteCommand satisfies the cli.Command interface. +var _ cli.Command = &ACLRoleDeleteCommand{} + +// ACLRoleDeleteCommand implements cli.Command. +type ACLRoleDeleteCommand struct { + Meta +} + +// Help satisfies the cli.Command Help function. +func (a *ACLRoleDeleteCommand) Help() string { + helpText := ` +Usage: nomad acl role delete + + Delete is used to delete an existing ACL role. Use requires a management + token. + +General Options: + + ` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace) + + return strings.TrimSpace(helpText) +} + +func (a *ACLRoleDeleteCommand) AutocompleteFlags() complete.Flags { + return mergeAutocompleteFlags(a.Meta.AutocompleteFlags(FlagSetClient), + complete.Flags{}) +} + +func (a *ACLRoleDeleteCommand) AutocompleteArgs() complete.Predictor { return complete.PredictNothing } + +// Synopsis satisfies the cli.Command Synopsis function. +func (a *ACLRoleDeleteCommand) Synopsis() string { return "Delete an existing ACL role" } + +// Name returns the name of this command. +func (a *ACLRoleDeleteCommand) Name() string { return "acl token delete" } + +// Run satisfies the cli.Command Run function. +func (a *ACLRoleDeleteCommand) Run(args []string) int { + + flags := a.Meta.FlagSet(a.Name(), FlagSetClient) + flags.Usage = func() { a.Ui.Output(a.Help()) } + + if err := flags.Parse(args); err != nil { + return 1 + } + + // Check that the last argument is the role ID to delete. + if len(flags.Args()) != 1 { + a.Ui.Error("This command takes one argument: ") + a.Ui.Error(commandErrorText(a)) + return 1 + } + + aclRoleID := flags.Args()[0] + + // Get the HTTP client. + client, err := a.Meta.Client() + if err != nil { + a.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + // Delete the specified ACL role. + _, err = client.ACLRoles().Delete(aclRoleID, nil) + if err != nil { + a.Ui.Error(fmt.Sprintf("Error deleting ACL role: %s", err)) + return 1 + } + + // Give some feedback to indicate the deletion was successful. + a.Ui.Output(fmt.Sprintf("ACL role %s successfully deleted", aclRoleID)) + return 0 +} diff --git a/command/acl_role_delete_test.go b/command/acl_role_delete_test.go new file mode 100644 index 000000000..0afe31494 --- /dev/null +++ b/command/acl_role_delete_test.go @@ -0,0 +1,77 @@ +package command + +import ( + "testing" + + "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/command/agent" + "github.com/hashicorp/nomad/helper/uuid" + "github.com/hashicorp/nomad/nomad/structs" + "github.com/hashicorp/nomad/testutil" + "github.com/mitchellh/cli" + "github.com/stretchr/testify/require" +) + +func TestACLRoleDeleteCommand_Run(t *testing.T) { + ci.Parallel(t) + + // Build a test server with ACLs enabled. + srv, _, url := testServer(t, false, func(c *agent.Config) { + c.ACL.Enabled = true + }) + defer srv.Shutdown() + + // Wait for the server to start fully and ensure we have a bootstrap token. + testutil.WaitForLeader(t, srv.Agent.RPC) + rootACLToken := srv.RootToken + require.NotNil(t, rootACLToken) + + ui := cli.NewMockUi() + cmd := &ACLRoleDeleteCommand{ + Meta: Meta{ + Ui: ui, + flagAddress: url, + }, + } + + // Try and delete more than one ACL role. + code := cmd.Run([]string{"-address=" + url, "acl-role-1", "acl-role-2"}) + require.Equal(t, 1, code) + require.Contains(t, ui.ErrorWriter.String(), "This command takes one argument") + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() + + // Try deleting a role that does not exist. + require.Equal(t, 1, cmd.Run([]string{"-address=" + url, "-token=" + rootACLToken.SecretID, "acl-role-1"})) + require.Contains(t, ui.ErrorWriter.String(), "ACL role not found") + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() + + // Create an ACL policy that can be referenced within the ACL role. + aclPolicy := structs.ACLPolicy{ + Name: "acl-role-cli-test", + Rules: `namespace "default" { + policy = "read" + } + `, + } + err := srv.Agent.Server().State().UpsertACLPolicies( + structs.MsgTypeTestSetup, 10, []*structs.ACLPolicy{&aclPolicy}) + require.NoError(t, err) + + // Create an ACL role referencing the previously created policy. + aclRole := structs.ACLRole{ + ID: uuid.Generate(), + Name: "acl-role-cli-test", + Policies: []*structs.ACLRolePolicyLink{{Name: aclPolicy.Name}}, + } + err = srv.Agent.Server().State().UpsertACLRoles( + structs.MsgTypeTestSetup, 20, []*structs.ACLRole{&aclRole}) + require.NoError(t, err) + + // Delete the existing ACL role. + require.Equal(t, 0, cmd.Run([]string{"-address=" + url, "-token=" + rootACLToken.SecretID, aclRole.ID})) + require.Contains(t, ui.OutputWriter.String(), "successfully deleted") +} diff --git a/command/acl_role_info.go b/command/acl_role_info.go new file mode 100644 index 000000000..897c4635d --- /dev/null +++ b/command/acl_role_info.go @@ -0,0 +1,121 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/hashicorp/nomad/api" + "github.com/mitchellh/cli" + "github.com/posener/complete" +) + +// Ensure ACLRoleInfoCommand satisfies the cli.Command interface. +var _ cli.Command = &ACLRoleInfoCommand{} + +// ACLRoleInfoCommand implements cli.Command. +type ACLRoleInfoCommand struct { + Meta + + byName bool + json bool + tmpl string +} + +// Help satisfies the cli.Command Help function. +func (a *ACLRoleInfoCommand) Help() string { + helpText := ` +Usage: nomad acl role info [options] + + Info is used to fetch information on an existing ACL roles. Requires a + management token. + +General Options: + + ` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace) + ` + +ACL Info Options: + + -by-name + Look up the ACL role using its name as the identifier. The command defaults + to expecting the ACL ID as the argument. + + -json + Output the ACL role in a JSON format. + + -t + Format and display the ACL role using a Go template. +` + + return strings.TrimSpace(helpText) +} + +func (a *ACLRoleInfoCommand) AutocompleteFlags() complete.Flags { + return mergeAutocompleteFlags(a.Meta.AutocompleteFlags(FlagSetClient), + complete.Flags{ + "-by-name": complete.PredictNothing, + "-json": complete.PredictNothing, + "-t": complete.PredictAnything, + }) +} + +func (a *ACLRoleInfoCommand) AutocompleteArgs() complete.Predictor { return complete.PredictNothing } + +// Synopsis satisfies the cli.Command Synopsis function. +func (a *ACLRoleInfoCommand) Synopsis() string { return "Fetch information on an existing ACL role" } + +// Name returns the name of this command. +func (a *ACLRoleInfoCommand) Name() string { return "acl role info" } + +// Run satisfies the cli.Command Run function. +func (a *ACLRoleInfoCommand) Run(args []string) int { + + flags := a.Meta.FlagSet(a.Name(), FlagSetClient) + flags.Usage = func() { a.Ui.Output(a.Help()) } + flags.BoolVar(&a.byName, "by-name", false, "") + flags.BoolVar(&a.json, "json", false, "") + flags.StringVar(&a.tmpl, "t", "", "") + + if err := flags.Parse(args); err != nil { + return 1 + } + + // Check that we have exactly one argument. + if len(flags.Args()) != 1 { + a.Ui.Error("This command takes one argument: ") + a.Ui.Error(commandErrorText(a)) + return 1 + } + + // Get the HTTP client. + client, err := a.Meta.Client() + if err != nil { + a.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + var ( + aclRole *api.ACLRole + apiErr error + ) + + aclRoleID := flags.Args()[0] + + // Use the correct API call depending on whether the lookup is by the name + // or the ID. + switch a.byName { + case true: + aclRole, _, apiErr = client.ACLRoles().GetByName(aclRoleID, nil) + default: + aclRole, _, apiErr = client.ACLRoles().Get(aclRoleID, nil) + } + + // Handle any error from the API. + if apiErr != nil { + a.Ui.Error(fmt.Sprintf("Error reading ACL role: %s", apiErr)) + return 1 + } + + // Format the output. + a.Ui.Output(formatACLRole(aclRole)) + return 0 +} diff --git a/command/acl_role_info_test.go b/command/acl_role_info_test.go new file mode 100644 index 000000000..0dfac81e8 --- /dev/null +++ b/command/acl_role_info_test.go @@ -0,0 +1,95 @@ +package command + +import ( + "fmt" + "testing" + + "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/command/agent" + "github.com/hashicorp/nomad/helper/uuid" + "github.com/hashicorp/nomad/nomad/structs" + "github.com/hashicorp/nomad/testutil" + "github.com/mitchellh/cli" + "github.com/stretchr/testify/require" +) + +func TestACLRoleInfoCommand_Run(t *testing.T) { + ci.Parallel(t) + + // Build a test server with ACLs enabled. + srv, _, url := testServer(t, false, func(c *agent.Config) { + c.ACL.Enabled = true + }) + defer srv.Shutdown() + + // Wait for the server to start fully and ensure we have a bootstrap token. + testutil.WaitForLeader(t, srv.Agent.RPC) + rootACLToken := srv.RootToken + require.NotNil(t, rootACLToken) + + ui := cli.NewMockUi() + cmd := &ACLRoleInfoCommand{ + Meta: Meta{ + Ui: ui, + flagAddress: url, + }, + } + + // Perform a lookup without specifying an ID. + require.Equal(t, 1, cmd.Run([]string{"-address=" + url, "-token=" + rootACLToken.SecretID})) + require.Contains(t, ui.ErrorWriter.String(), "This command takes one argument: ") + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() + + // Perform a lookup specifying a random ID. + require.Equal(t, 1, cmd.Run([]string{"-address=" + url, "-token=" + rootACLToken.SecretID, uuid.Generate()})) + require.Contains(t, ui.ErrorWriter.String(), "ACL role not found") + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() + + // Create an ACL policy that can be referenced within the ACL role. + aclPolicy := structs.ACLPolicy{ + Name: "acl-role-policy-cli-test", + Rules: `namespace "default" { + policy = "read" + } + `, + } + err := srv.Agent.Server().State().UpsertACLPolicies( + structs.MsgTypeTestSetup, 10, []*structs.ACLPolicy{&aclPolicy}) + require.NoError(t, err) + + // Create an ACL role referencing the previously created policy. + aclRole := structs.ACLRole{ + ID: uuid.Generate(), + Name: "acl-role-cli-test", + Policies: []*structs.ACLRolePolicyLink{{Name: aclPolicy.Name}}, + } + err = srv.Agent.Server().State().UpsertACLRoles( + structs.MsgTypeTestSetup, 20, []*structs.ACLRole{&aclRole}) + require.NoError(t, err) + + // Look up the ACL role using its ID. + require.Equal(t, 0, cmd.Run([]string{"-address=" + url, "-token=" + rootACLToken.SecretID, aclRole.ID})) + s := ui.OutputWriter.String() + require.Contains(t, s, fmt.Sprintf("ID = %s", aclRole.ID)) + require.Contains(t, s, fmt.Sprintf("Name = %s", aclRole.Name)) + require.Contains(t, s, "Description = ") + require.Contains(t, s, fmt.Sprintf("Policies = %s", aclPolicy.Name)) + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() + + // Look up the ACL role using its Name. + require.Equal(t, 0, cmd.Run([]string{"-address=" + url, "-token=" + rootACLToken.SecretID, "-by-name", aclRole.Name})) + s = ui.OutputWriter.String() + require.Contains(t, s, fmt.Sprintf("ID = %s", aclRole.ID)) + require.Contains(t, s, fmt.Sprintf("Name = %s", aclRole.Name)) + require.Contains(t, s, "Description = ") + require.Contains(t, s, fmt.Sprintf("Policies = %s", aclPolicy.Name)) + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() +} diff --git a/command/acl_role_list.go b/command/acl_role_list.go new file mode 100644 index 000000000..c86d819b5 --- /dev/null +++ b/command/acl_role_list.go @@ -0,0 +1,123 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/hashicorp/nomad/api" + "github.com/mitchellh/cli" + "github.com/posener/complete" +) + +// Ensure ACLRoleListCommand satisfies the cli.Command interface. +var _ cli.Command = &ACLRoleListCommand{} + +// ACLRoleListCommand implements cli.Command. +type ACLRoleListCommand struct { + Meta +} + +// Help satisfies the cli.Command Help function. +func (a *ACLRoleListCommand) Help() string { + helpText := ` +Usage: nomad acl role list [options] + + List is used to list existing ACL roles. + +General Options: + + ` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace) + ` + +ACL List Options: + + -json + Output the ACL roles in a JSON format. + + -t + Format and display the ACL roles using a Go template. +` + + return strings.TrimSpace(helpText) +} + +func (a *ACLRoleListCommand) AutocompleteFlags() complete.Flags { + return mergeAutocompleteFlags(a.Meta.AutocompleteFlags(FlagSetClient), + complete.Flags{ + "-json": complete.PredictNothing, + "-t": complete.PredictAnything, + }) +} + +func (a *ACLRoleListCommand) AutocompleteArgs() complete.Predictor { return complete.PredictNothing } + +// Synopsis satisfies the cli.Command Synopsis function. +func (a *ACLRoleListCommand) Synopsis() string { return "List ACL roles" } + +// Name returns the name of this command. +func (a *ACLRoleListCommand) Name() string { return "acl role list" } + +// Run satisfies the cli.Command Run function. +func (a *ACLRoleListCommand) Run(args []string) int { + var json bool + var tmpl string + + flags := a.Meta.FlagSet(a.Name(), FlagSetClient) + flags.Usage = func() { a.Ui.Output(a.Help()) } + flags.BoolVar(&json, "json", false, "") + flags.StringVar(&tmpl, "t", "", "") + + if err := flags.Parse(args); err != nil { + return 1 + } + + // Check that we got no arguments + if len(flags.Args()) != 0 { + a.Ui.Error("This command takes no arguments") + a.Ui.Error(commandErrorText(a)) + return 1 + } + + // Get the HTTP client + client, err := a.Meta.Client() + if err != nil { + a.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + // Fetch info on the policy + roles, _, err := client.ACLRoles().List(nil) + if err != nil { + a.Ui.Error(fmt.Sprintf("Error listing ACL roles: %s", err)) + return 1 + } + + if json || len(tmpl) > 0 { + out, err := Format(json, tmpl, roles) + if err != nil { + a.Ui.Error(err.Error()) + return 1 + } + + a.Ui.Output(out) + return 0 + } + + a.Ui.Output(formatACLRoles(roles)) + return 0 +} + +func formatACLRoles(roles []*api.ACLRole) string { + if len(roles) == 0 { + return "No ACL roles found" + } + + output := make([]string, 0, len(roles)+1) + output = append(output, "ID|Name|Description|Policies") + for _, role := range roles { + output = append(output, fmt.Sprintf( + "%s|%s|%s|%s", + role.ID, role.Name, role.Description, strings.Join(aclRolePolicyLinkToStringList(role.Policies), ","))) + } + + return formatList(output) +} diff --git a/command/acl_role_list_test.go b/command/acl_role_list_test.go new file mode 100644 index 000000000..9c529f591 --- /dev/null +++ b/command/acl_role_list_test.go @@ -0,0 +1,77 @@ +package command + +import ( + "testing" + + "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/command/agent" + "github.com/hashicorp/nomad/helper/uuid" + "github.com/hashicorp/nomad/nomad/structs" + "github.com/hashicorp/nomad/testutil" + "github.com/mitchellh/cli" + "github.com/stretchr/testify/require" +) + +func TestACLRoleListCommand_Run(t *testing.T) { + ci.Parallel(t) + + // Build a test server with ACLs enabled. + srv, _, url := testServer(t, false, func(c *agent.Config) { + c.ACL.Enabled = true + }) + defer srv.Shutdown() + + // Wait for the server to start fully and ensure we have a bootstrap token. + testutil.WaitForLeader(t, srv.Agent.RPC) + rootACLToken := srv.RootToken + require.NotNil(t, rootACLToken) + + ui := cli.NewMockUi() + cmd := &ACLRoleListCommand{ + Meta: Meta{ + Ui: ui, + flagAddress: url, + }, + } + + // Perform a list straight away without any roles held in state. + require.Equal(t, 0, cmd.Run([]string{"-address=" + url, "-token=" + rootACLToken.SecretID})) + require.Contains(t, ui.OutputWriter.String(), "No ACL roles found") + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() + + // Create an ACL policy that can be referenced within the ACL role. + aclPolicy := structs.ACLPolicy{ + Name: "acl-role-policy-cli-test", + Rules: `namespace "default" { + policy = "read" + } + `, + } + err := srv.Agent.Server().State().UpsertACLPolicies( + structs.MsgTypeTestSetup, 10, []*structs.ACLPolicy{&aclPolicy}) + require.NoError(t, err) + + // Create an ACL role referencing the previously created policy. + aclRole := structs.ACLRole{ + ID: uuid.Generate(), + Name: "acl-role-cli-test", + Policies: []*structs.ACLRolePolicyLink{{Name: aclPolicy.Name}}, + } + err = srv.Agent.Server().State().UpsertACLRoles( + structs.MsgTypeTestSetup, 20, []*structs.ACLRole{&aclRole}) + require.NoError(t, err) + + // Perform a listing to get the created role. + require.Equal(t, 0, cmd.Run([]string{"-address=" + url, "-token=" + rootACLToken.SecretID})) + s := ui.OutputWriter.String() + require.Contains(t, s, "ID") + require.Contains(t, s, "Name") + require.Contains(t, s, "Policies") + require.Contains(t, s, "acl-role-cli-test") + require.Contains(t, s, "acl-role-policy-cli-test") + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() +} diff --git a/command/acl_role_test.go b/command/acl_role_test.go new file mode 100644 index 000000000..7031cc621 --- /dev/null +++ b/command/acl_role_test.go @@ -0,0 +1,61 @@ +package command + +import ( + "testing" + + "github.com/hashicorp/nomad/api" + "github.com/stretchr/testify/require" +) + +func Test_formatACLRole(t *testing.T) { + inputACLRole := api.ACLRole{ + ID: "this-is-usually-a-uuid", + Name: "this-is-my-friendly-name", + Description: "this-is-my-friendly-name", + Policies: []*api.ACLRolePolicyLink{ + {Name: "policy-link-1"}, + {Name: "policy-link-2"}, + {Name: "policy-link-3"}, + {Name: "policy-link-4"}, + }, + CreateIndex: 13, + ModifyIndex: 1313, + } + expectedOutput := "ID = this-is-usually-a-uuid\nName = this-is-my-friendly-name\nDescription = this-is-my-friendly-name\nPolicies = policy-link-1,policy-link-2,policy-link-3,policy-link-4\nCreate Index = 13\nModify Index = 1313" + actualOutput := formatACLRole(&inputACLRole) + require.Equal(t, expectedOutput, actualOutput) +} + +func Test_aclRolePolicyLinkToStringList(t *testing.T) { + inputPolicyLinks := []*api.ACLRolePolicyLink{ + {Name: "z-policy-link-1"}, + {Name: "a-policy-link-2"}, + {Name: "policy-link-3"}, + {Name: "b-policy-link-4"}, + } + expectedOutput := []string{ + "a-policy-link-2", + "b-policy-link-4", + "policy-link-3", + "z-policy-link-1", + } + actualOutput := aclRolePolicyLinkToStringList(inputPolicyLinks) + require.Equal(t, expectedOutput, actualOutput) +} + +func Test_aclRolePolicyNamesToPolicyLinks(t *testing.T) { + inputPolicyNames := []string{ + "policy-link-1", "policy-link-2", "policy-link-3", "policy-link-4", + "policy-link-1", "policy-link-2", "policy-link-3", "policy-link-4", + "policy-link-1", "policy-link-2", "policy-link-3", "policy-link-4", + "policy-link-1", "policy-link-2", "policy-link-3", "policy-link-4", + } + expectedOutput := []*api.ACLRolePolicyLink{ + {Name: "policy-link-1"}, + {Name: "policy-link-2"}, + {Name: "policy-link-3"}, + {Name: "policy-link-4"}, + } + actualOutput := aclRolePolicyNamesToPolicyLinks(inputPolicyNames) + require.ElementsMatch(t, expectedOutput, actualOutput) +} diff --git a/command/acl_role_update.go b/command/acl_role_update.go new file mode 100644 index 000000000..fcb71902f --- /dev/null +++ b/command/acl_role_update.go @@ -0,0 +1,217 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/hashicorp/nomad/api" + "github.com/mitchellh/cli" + "github.com/posener/complete" +) + +// Ensure ACLRoleUpdateCommand satisfies the cli.Command interface. +var _ cli.Command = &ACLRoleUpdateCommand{} + +// ACLRoleUpdateCommand implements cli.Command. +type ACLRoleUpdateCommand struct { + Meta + + name string + description string + policyNames []string + noMerge bool + json bool + tmpl string +} + +// Help satisfies the cli.Command Help function. +func (a *ACLRoleUpdateCommand) Help() string { + helpText := ` +Usage: nomad acl role update [options] + + Update is used to update an existing ACL token. Requires a management token. + +General Options: + + ` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace) + ` + +Update Options: + + -name + Sets the human readable name for the ACL role. The name must be between + 1-128 characters. + + -description + A free form text description of the role that must not exceed 256 + characters. + + -policy-name + Specifies a policy to associate with the role identified by their name. This + flag can be specified multiple times. + + -no-merge + Do not merge the current role information with what is provided to the + command. Instead overwrite all fields with the exception of the role ID + which is immutable. + + -json + Output the ACL role in a JSON format. + + -t + Format and display the ACL role using a Go template. +` + + return strings.TrimSpace(helpText) +} + +func (a *ACLRoleUpdateCommand) AutocompleteFlags() complete.Flags { + return mergeAutocompleteFlags(a.Meta.AutocompleteFlags(FlagSetClient), + complete.Flags{ + "-name": complete.PredictAnything, + "-description": complete.PredictAnything, + "-no-merge": complete.PredictNothing, + "-policy-name": complete.PredictAnything, + "-json": complete.PredictNothing, + "-t": complete.PredictAnything, + }) +} + +func (a *ACLRoleUpdateCommand) AutocompleteArgs() complete.Predictor { return complete.PredictNothing } + +// Synopsis satisfies the cli.Command Synopsis function. +func (a *ACLRoleUpdateCommand) Synopsis() string { return "Update an existing ACL role" } + +// Name returns the name of this command. +func (*ACLRoleUpdateCommand) Name() string { return "acl role update" } + +// Run satisfies the cli.Command Run function. +func (a *ACLRoleUpdateCommand) Run(args []string) int { + + flags := a.Meta.FlagSet(a.Name(), FlagSetClient) + flags.Usage = func() { a.Ui.Output(a.Help()) } + flags.StringVar(&a.name, "name", "", "") + flags.StringVar(&a.description, "description", "", "") + flags.Var((funcVar)(func(s string) error { + a.policyNames = append(a.policyNames, s) + return nil + }), "policy-name", "") + flags.BoolVar(&a.noMerge, "no-merge", false, "") + flags.BoolVar(&a.json, "json", false, "") + flags.StringVar(&a.tmpl, "t", "", "") + if err := flags.Parse(args); err != nil { + return 1 + } + + // Check that we got exactly one argument which is expected to be the ACL + // role ID. + if len(flags.Args()) != 1 { + a.Ui.Error("This command takes one argument: ") + a.Ui.Error(commandErrorText(a)) + return 1 + } + + // Get the HTTP client. + client, err := a.Meta.Client() + if err != nil { + a.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + aclRoleID := flags.Args()[0] + + // Read the current role in both cases, so we can fail better if not found. + currentRole, _, err := client.ACLRoles().Get(aclRoleID, nil) + if err != nil { + a.Ui.Error(fmt.Sprintf("Error when retrieving ACL role: %v", err)) + return 1 + } + + var updatedRole api.ACLRole + + // Depending on whether we are merging or not, we need to take a different + // approach. + switch a.noMerge { + case true: + + // Perform some basic validation on the submitted role information to + // avoid sending API and RPC requests which will fail basic validation. + if a.name == "" { + a.Ui.Error("ACL role name must be specified using the -name flag") + return 1 + } + if len(a.policyNames) < 1 { + a.Ui.Error("At least one policy name must be specified using the -policy-name flag") + return 1 + } + + updatedRole = api.ACLRole{ + ID: aclRoleID, + Name: a.name, + Description: a.description, + Policies: aclRolePolicyNamesToPolicyLinks(a.policyNames), + } + default: + // Check that the operator specified at least one flag to update the ACL + // role with. + if len(a.policyNames) == 0 && a.name == "" && a.description == "" { + a.Ui.Error("Please provide at least one flag to update the ACL role") + a.Ui.Error(commandErrorText(a)) + return 1 + } + + updatedRole = *currentRole + + // If the operator specified a name or description, overwrite the + // existing value as these are simple strings. + if a.name != "" { + updatedRole.Name = a.name + } + if a.description != "" { + updatedRole.Description = a.description + } + + // In order to merge the policy updates, we need to identify if the + // specified policy names already exist within the ACL role linking. + for _, policyName := range a.policyNames { + + // Track whether we found the policy name already in the ACL role + // linking. + var found bool + + for _, existingLinkedPolicy := range currentRole.Policies { + if policyName == existingLinkedPolicy.Name { + found = true + break + } + } + + // If the policy name was not found, append this new link to the + // updated role. + if !found { + updatedRole.Policies = append(updatedRole.Policies, &api.ACLRolePolicyLink{Name: policyName}) + } + } + } + + // Update the ACL role with the new information via the API. + updatedACLRoleRead, _, err := client.ACLRoles().Update(&updatedRole, nil) + if err != nil { + a.Ui.Error(fmt.Sprintf("Error updating ACL role: %s", err)) + return 1 + } + + if a.json || len(a.tmpl) > 0 { + out, err := Format(a.json, a.tmpl, updatedACLRoleRead) + if err != nil { + a.Ui.Error(err.Error()) + return 1 + } + + a.Ui.Output(out) + return 0 + } + + // Format the output + a.Ui.Output(formatACLRole(updatedACLRoleRead)) + return 0 +} diff --git a/command/acl_role_update_test.go b/command/acl_role_update_test.go new file mode 100644 index 000000000..9c02d3696 --- /dev/null +++ b/command/acl_role_update_test.go @@ -0,0 +1,124 @@ +package command + +import ( + "fmt" + "testing" + + "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/command/agent" + "github.com/hashicorp/nomad/helper/uuid" + "github.com/hashicorp/nomad/nomad/structs" + "github.com/hashicorp/nomad/testutil" + "github.com/mitchellh/cli" + "github.com/stretchr/testify/require" +) + +func TestACLRoleUpdateCommand_Run(t *testing.T) { + ci.Parallel(t) + + // Build a test server with ACLs enabled. + srv, _, url := testServer(t, false, func(c *agent.Config) { + c.ACL.Enabled = true + }) + defer srv.Shutdown() + + // Wait for the server to start fully and ensure we have a bootstrap token. + testutil.WaitForLeader(t, srv.Agent.RPC) + rootACLToken := srv.RootToken + require.NotNil(t, rootACLToken) + + ui := cli.NewMockUi() + cmd := &ACLRoleUpdateCommand{ + Meta: Meta{ + Ui: ui, + flagAddress: url, + }, + } + + // Try calling the command without setting an ACL Role ID arg. + require.Equal(t, 1, cmd.Run([]string{"-address=" + url})) + require.Contains(t, ui.ErrorWriter.String(), "This command takes one argument") + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() + + // Try calling the command with an ACL role ID that does not exist. + code := cmd.Run([]string{"-address=" + url, "-token=" + rootACLToken.SecretID, "catch-me-if-you-can"}) + require.Equal(t, 1, code) + require.Contains(t, ui.ErrorWriter.String(), "ACL role not found") + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() + + // Create an ACL policy that can be referenced within the ACL role. + aclPolicy := structs.ACLPolicy{ + Name: "acl-role-cli-test-policy", + Rules: `namespace "default" { + policy = "read" + } + `, + } + err := srv.Agent.Server().State().UpsertACLPolicies( + structs.MsgTypeTestSetup, 10, []*structs.ACLPolicy{&aclPolicy}) + require.NoError(t, err) + + // Create an ACL role that can be used for updating. + aclRole := structs.ACLRole{ + ID: uuid.Generate(), + Name: "acl-role-cli-test", + Description: "my-lovely-role", + Policies: []*structs.ACLRolePolicyLink{{Name: aclPolicy.Name}}, + } + + err = srv.Agent.Server().State().UpsertACLRoles( + structs.MsgTypeTestSetup, 20, []*structs.ACLRole{&aclRole}) + require.NoError(t, err) + + // Try a merge update without setting any parameters to update. + code = cmd.Run([]string{"-address=" + url, "-token=" + rootACLToken.SecretID, aclRole.ID}) + require.Equal(t, 1, code) + require.Contains(t, ui.ErrorWriter.String(), "Please provide at least one flag to update the ACL role") + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() + + // Update the description using the merge method. + code = cmd.Run([]string{ + "-address=" + url, "-token=" + rootACLToken.SecretID, "-description=badger-badger-badger", aclRole.ID}) + require.Equal(t, 0, code) + s := ui.OutputWriter.String() + require.Contains(t, s, fmt.Sprintf("ID = %s", aclRole.ID)) + require.Contains(t, s, "Name = acl-role-cli-test") + require.Contains(t, s, "Description = badger-badger-badger") + require.Contains(t, s, "Policies = acl-role-cli-test-policy") + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() + + // Try updating the role using no-merge without setting the required flags. + code = cmd.Run([]string{"-address=" + url, "-token=" + rootACLToken.SecretID, "-no-merge", aclRole.ID}) + require.Equal(t, 1, code) + require.Contains(t, ui.ErrorWriter.String(), "ACL role name must be specified using the -name flag") + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() + + code = cmd.Run([]string{ + "-address=" + url, "-token=" + rootACLToken.SecretID, "-no-merge", "-name=update-role-name", aclRole.ID}) + require.Equal(t, 1, code) + require.Contains(t, ui.ErrorWriter.String(), "At least one policy name must be specified using the -policy-name flag") + + // Update the role using no-merge with all required flags set. + code = cmd.Run([]string{ + "-address=" + url, "-token=" + rootACLToken.SecretID, "-no-merge", "-name=update-role-name", + "-description=updated-description", "-policy-name=acl-role-cli-test-policy", aclRole.ID}) + require.Equal(t, 0, code) + s = ui.OutputWriter.String() + require.Contains(t, s, fmt.Sprintf("ID = %s", aclRole.ID)) + require.Contains(t, s, "Name = update-role-name") + require.Contains(t, s, "Description = updated-description") + require.Contains(t, s, "Policies = acl-role-cli-test-policy") + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() +} diff --git a/command/commands.go b/command/commands.go index 2418f1033..d8daa7425 100644 --- a/command/commands.go +++ b/command/commands.go @@ -107,6 +107,36 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory { Meta: meta, }, nil }, + "acl role": func() (cli.Command, error) { + return &ACLRoleCommand{ + Meta: meta, + }, nil + }, + "acl role create": func() (cli.Command, error) { + return &ACLRoleCreateCommand{ + Meta: meta, + }, nil + }, + "acl role delete": func() (cli.Command, error) { + return &ACLRoleDeleteCommand{ + Meta: meta, + }, nil + }, + "acl role info": func() (cli.Command, error) { + return &ACLRoleInfoCommand{ + Meta: meta, + }, nil + }, + "acl role list": func() (cli.Command, error) { + return &ACLRoleListCommand{ + Meta: meta, + }, nil + }, + "acl role update": func() (cli.Command, error) { + return &ACLRoleUpdateCommand{ + Meta: meta, + }, nil + }, "acl token": func() (cli.Command, error) { return &ACLTokenCommand{ Meta: meta,