diff --git a/command/commands.go b/command/commands.go index 287cdfaf1934..9334847a3411 100644 --- a/command/commands.go +++ b/command/commands.go @@ -768,6 +768,26 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory { Meta: meta, }, nil }, + "service": func() (cli.Command, error) { + return &ServiceCommand{ + Meta: meta, + }, nil + }, + "service list": func() (cli.Command, error) { + return &ServiceListCommand{ + Meta: meta, + }, nil + }, + "service info": func() (cli.Command, error) { + return &ServiceInfoCommand{ + Meta: meta, + }, nil + }, + "service delete": func() (cli.Command, error) { + return &ServiceDeleteCommand{ + Meta: meta, + }, nil + }, "status": func() (cli.Command, error) { return &StatusCommand{ Meta: meta, diff --git a/command/service.go b/command/service.go new file mode 100644 index 000000000000..fbc213111996 --- /dev/null +++ b/command/service.go @@ -0,0 +1,40 @@ +package command + +import ( + "strings" + + "github.com/mitchellh/cli" +) + +type ServiceCommand struct { + Meta +} + +func (c *ServiceCommand) Help() string { + helpText := ` +Usage: nomad service [options] + + This command groups subcommands for interacting with the services API. + + List services: + + $ nomad service list + + Detail an individual service: + + $ nomad service info + + Delete an individual service registration: + + $ nomad service delete + + Please see the individual subcommand help for detailed usage information. +` + return strings.TrimSpace(helpText) +} + +func (c *ServiceCommand) Name() string { return "service" } + +func (c *ServiceCommand) Synopsis() string { return "Interact with registered services" } + +func (c *ServiceCommand) Run(_ []string) int { return cli.RunResultHelp } diff --git a/command/service_delete.go b/command/service_delete.go new file mode 100644 index 000000000000..8971a05b02c9 --- /dev/null +++ b/command/service_delete.go @@ -0,0 +1,68 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/posener/complete" +) + +type ServiceDeleteCommand struct { + Meta +} + +func (s *ServiceDeleteCommand) Help() string { + helpText := ` +Usage: nomad service delete [options] + + Delete is used to deregister the specified service registration. It should be + used with caution and can only remove a single registration, via the service + name and service ID, at a time. + + When ACLs are enabled, this command requires a token with the 'submit-job' + capability for the service registration namespace. + +General Options: + + ` + generalOptionsUsage(usageOptsDefault) + + return strings.TrimSpace(helpText) +} + +func (s *ServiceDeleteCommand) Name() string { return "service delete" } + +func (s *ServiceDeleteCommand) Synopsis() string { return "Deregister a registered service" } + +func (s *ServiceDeleteCommand) AutocompleteFlags() complete.Flags { + return mergeAutocompleteFlags(s.Meta.AutocompleteFlags(FlagSetClient), + complete.Flags{}) +} + +func (s *ServiceDeleteCommand) Run(args []string) int { + + flags := s.Meta.FlagSet(s.Name(), FlagSetClient) + if err := flags.Parse(args); err != nil { + return 1 + } + args = flags.Args() + + if len(args) != 2 { + s.Ui.Error("This command takes two arguments: and ") + s.Ui.Error(commandErrorText(s)) + return 1 + } + + client, err := s.Meta.Client() + if err != nil { + s.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + if _, err := client.ServiceRegistrations().Delete(args[0], args[1], nil); err != nil { + s.Ui.Error(fmt.Sprintf("Error deleting service registration: %s", err)) + return 1 + } + + s.Ui.Output("Successfully deleted service registration") + return 0 +} diff --git a/command/service_delete_test.go b/command/service_delete_test.go new file mode 100644 index 000000000000..fc7768fe08f2 --- /dev/null +++ b/command/service_delete_test.go @@ -0,0 +1,74 @@ +package command + +import ( + "fmt" + "testing" + + "github.com/hashicorp/nomad/api" + "github.com/hashicorp/nomad/testutil" + "github.com/mitchellh/cli" + "github.com/stretchr/testify/require" +) + +func TestServiceDeleteCommand_Run(t *testing.T) { + t.Parallel() + + srv, client, url := testServer(t, true, nil) + defer srv.Shutdown() + + // Wait until our test node is ready. + testutil.WaitForResult(func() (bool, error) { + nodes, _, err := client.Nodes().List(nil) + if err != nil { + return false, err + } + if len(nodes) == 0 { + return false, fmt.Errorf("missing node") + } + if _, ok := nodes[0].Drivers["mock_driver"]; !ok { + return false, fmt.Errorf("mock_driver not ready") + } + return true, nil + }, func(err error) { + require.NoError(t, err) + }) + + ui := cli.NewMockUi() + cmd := &ServiceDeleteCommand{ + Meta: Meta{ + Ui: ui, + flagAddress: url, + }, + } + + // Run the command without any arguments to ensure we are performing this + // check. + require.Equal(t, 1, cmd.Run([]string{"-address=" + url})) + require.Contains(t, ui.ErrorWriter.String(), + "This command takes two arguments: and ") + ui.ErrorWriter.Reset() + + // Create a test job with a Nomad service. + testJob := testJob("service-discovery-nomad-delete") + testJob.TaskGroups[0].Tasks[0].Services = []*api.Service{ + {Name: "service-discovery-nomad-delete", Provider: "nomad"}} + + // Register that job. + regResp, _, err := client.Jobs().Register(testJob, nil) + require.NoError(t, err) + registerCode := waitForSuccess(ui, client, fullId, t, regResp.EvalID) + require.Equal(t, 0, registerCode) + + // Detail the service as we need the ID. + serviceList, _, err := client.ServiceRegistrations().Get("service-discovery-nomad-delete", nil) + require.NoError(t, err) + require.Len(t, serviceList, 1) + + // Attempt to manually delete the service registration. + code := cmd.Run([]string{"-address=" + url, "service-discovery-nomad-delete", serviceList[0].ID}) + require.Equal(t, 0, code) + require.Contains(t, ui.OutputWriter.String(), "Successfully deleted service registration") + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() +} diff --git a/command/service_info.go b/command/service_info.go new file mode 100644 index 000000000000..2f54e5edab44 --- /dev/null +++ b/command/service_info.go @@ -0,0 +1,186 @@ +package command + +import ( + "fmt" + "sort" + "strings" + + "github.com/hashicorp/nomad/api" + "github.com/mitchellh/cli" + "github.com/posener/complete" +) + +// Ensure ServiceInfoCommand satisfies the cli.Command interface. +var _ cli.Command = &ServiceInfoCommand{} + +// ServiceInfoCommand implements cli.Command. +type ServiceInfoCommand struct { + Meta +} + +// Help satisfies the cli.Command Help function. +func (s *ServiceInfoCommand) Help() string { + helpText := ` +Usage: nomad service info [options] + + Info is used to read the services registered to a single service name. + + When ACLs are enabled, this command requires a token with the 'read-job' + capability for the service namespace. + +General Options: + + ` + generalOptionsUsage(usageOptsDefault) + ` + +Service Info Options: + + -verbose + Display full information. + + -json + Output the service in JSON format. + + -t + Format and display the service using a Go template. +` + return strings.TrimSpace(helpText) +} + +// Synopsis satisfies the cli.Command Synopsis function. +func (s *ServiceInfoCommand) Synopsis() string { + return "Display an individual Nomad service registration" +} + +func (s *ServiceInfoCommand) AutocompleteFlags() complete.Flags { + return mergeAutocompleteFlags(s.Meta.AutocompleteFlags(FlagSetClient), + complete.Flags{ + "-json": complete.PredictNothing, + "-t": complete.PredictAnything, + "-verbose": complete.PredictNothing, + }) +} + +// Name returns the name of this command. +func (s *ServiceInfoCommand) Name() string { return "service info" } + +// Run satisfies the cli.Command Run function. +func (s *ServiceInfoCommand) Run(args []string) int { + var ( + json, verbose bool + tmpl string + ) + + flags := s.Meta.FlagSet(s.Name(), FlagSetClient) + flags.Usage = func() { s.Ui.Output(s.Help()) } + flags.BoolVar(&json, "json", false, "") + flags.BoolVar(&verbose, "verbose", false, "") + flags.StringVar(&tmpl, "t", "", "") + if err := flags.Parse(args); err != nil { + return 1 + } + args = flags.Args() + + if len(args) != 1 { + s.Ui.Error("This command takes one argument: ") + s.Ui.Error(commandErrorText(s)) + return 1 + } + + client, err := s.Meta.Client() + if err != nil { + s.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + serviceInfo, _, err := client.ServiceRegistrations().Get(args[0], nil) + if err != nil { + s.Ui.Error(fmt.Sprintf("Error listing service registrations: %s", err)) + return 1 + } + + if len(serviceInfo) == 0 { + s.Ui.Output("No service registrations found") + return 0 + } + + if json || len(tmpl) > 0 { + out, err := Format(json, tmpl, serviceInfo) + if err != nil { + s.Ui.Error(err.Error()) + return 1 + } + s.Ui.Output(out) + return 0 + } + + // It is possible for multiple jobs to register a service with the same + // name. In order to provide consistency, sort the output by job ID. + sortedJobID := []string{} + jobIDServices := make(map[string][]*api.ServiceRegistration) + + // Populate the objects, ensuring we do not add duplicate job IDs to the + // array which will be sorted. + for _, service := range serviceInfo { + if _, ok := jobIDServices[service.JobID]; ok { + jobIDServices[service.JobID] = append(jobIDServices[service.JobID], service) + } else { + jobIDServices[service.JobID] = []*api.ServiceRegistration{service} + sortedJobID = append(sortedJobID, service.JobID) + } + } + + // Sort the jobIDs. + sort.Strings(sortedJobID) + + if verbose { + s.formatVerboseOutput(sortedJobID, jobIDServices) + } else { + s.formatOutput(sortedJobID, jobIDServices) + } + return 0 +} + +// formatOutput produces the non-verbose output of service registration info +// for a specific service by its name. +func (s *ServiceInfoCommand) formatOutput(jobIDs []string, jobServices map[string][]*api.ServiceRegistration) { + + // Create the output table header. + outputTable := []string{"Job ID|Address|Tags|Node ID|Alloc ID"} + + // Populate the list. + for _, jobID := range jobIDs { + for _, service := range jobServices[jobID] { + outputTable = append(outputTable, fmt.Sprintf( + "%s|%s|[%s]|%s|%s", + service.JobID, + fmt.Sprintf("%s:%v", service.Address, service.Port), + strings.Join(service.Tags, ","), + limit(service.NodeID, shortId), + limit(service.AllocID, shortId), + )) + } + } + s.Ui.Output(formatList(outputTable)) +} + +// formatOutput produces the verbose output of service registration info for a +// specific service by its name. +func (s *ServiceInfoCommand) formatVerboseOutput(jobIDs []string, jobServices map[string][]*api.ServiceRegistration) { + for _, jobID := range jobIDs { + for _, service := range jobServices[jobID] { + out := []string{ + fmt.Sprintf("ID|%s", service.ID), + fmt.Sprintf("Service Name|%s", service.ServiceName), + fmt.Sprintf("Namespace|%s", service.Namespace), + fmt.Sprintf("Job ID|%s", service.JobID), + fmt.Sprintf("Alloc ID|%s", service.AllocID), + fmt.Sprintf("Node ID|%s", service.NodeID), + fmt.Sprintf("Datacenter|%s", service.Datacenter), + fmt.Sprintf("Address|%v", fmt.Sprintf("%s:%v", service.Address, service.Port)), + fmt.Sprintf("Tags|[%s]\n", strings.Join(service.Tags, ",")), + } + s.Ui.Output(formatKV(out)) + s.Ui.Output("") + } + } +} diff --git a/command/service_info_test.go b/command/service_info_test.go new file mode 100644 index 000000000000..70fdb6df015b --- /dev/null +++ b/command/service_info_test.go @@ -0,0 +1,121 @@ +package command + +import ( + "fmt" + "testing" + "time" + + "github.com/hashicorp/nomad/api" + "github.com/hashicorp/nomad/testutil" + "github.com/mitchellh/cli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServiceInfoCommand_Run(t *testing.T) { + t.Parallel() + + srv, client, url := testServer(t, true, nil) + defer srv.Shutdown() + + // Wait until our test node is ready. + testutil.WaitForResult(func() (bool, error) { + nodes, _, err := client.Nodes().List(nil) + if err != nil { + return false, err + } + if len(nodes) == 0 { + return false, fmt.Errorf("missing node") + } + if _, ok := nodes[0].Drivers["mock_driver"]; !ok { + return false, fmt.Errorf("mock_driver not ready") + } + return true, nil + }, func(err error) { + require.NoError(t, err) + }) + + ui := cli.NewMockUi() + cmd := &ServiceInfoCommand{ + Meta: Meta{ + Ui: ui, + flagAddress: url, + }, + } + + // Run the command without any arguments to ensure we are performing this + // check. + require.Equal(t, 1, cmd.Run([]string{"-address=" + url})) + require.Contains(t, ui.ErrorWriter.String(), + "This command takes one argument: ") + ui.ErrorWriter.Reset() + + // Create a test job with a Nomad service. + testJob := testJob("service-discovery-nomad-info") + testJob.TaskGroups[0].Tasks[0].Services = []*api.Service{ + {Name: "service-discovery-nomad-info", Provider: "nomad", Tags: []string{"foo", "bar"}}} + + // Register that job. + regResp, _, err := client.Jobs().Register(testJob, nil) + require.NoError(t, err) + registerCode := waitForSuccess(ui, client, fullId, t, regResp.EvalID) + require.Equal(t, 0, registerCode) + + // Reset the output writer, otherwise we will have additional information here. + ui.OutputWriter.Reset() + + // Job register doesn't assure the service registration has completed. It + // therefore needs this wrapper to account for eventual service + // registration. One this has completed, we can perform lookups without + // similar wraps. + require.Eventually(t, func() bool { + + defer ui.OutputWriter.Reset() + + // Perform a standard lookup. + if code := cmd.Run([]string{"-address=" + url, "service-discovery-nomad-info"}); code != 0 { + return false + } + + // Test each header and data entry. + s := ui.OutputWriter.String() + if !assert.Contains(t, s, "Job ID") { + return false + } + if !assert.Contains(t, s, "Address") { + return false + } + if !assert.Contains(t, s, "Node ID") { + return false + } + if !assert.Contains(t, s, "Alloc ID") { + return false + } + if !assert.Contains(t, s, "service-discovery-nomad-info") { + return false + } + if !assert.Contains(t, s, ":0") { + return false + } + if !assert.Contains(t, s, "[foo,bar]") { + return false + } + return true + }, 5*time.Second, 100*time.Millisecond) + + // Perform a verbose lookup. + code := cmd.Run([]string{"-address=" + url, "-verbose", "service-discovery-nomad-info"}) + require.Equal(t, 0, code) + + // Test KV entries. + s := ui.OutputWriter.String() + require.Contains(t, s, "Service Name = service-discovery-nomad-info") + require.Contains(t, s, "Namespace = default") + require.Contains(t, s, "Job ID = service-discovery-nomad-info") + require.Contains(t, s, "Datacenter = dc1") + require.Contains(t, s, "Address = :0") + require.Contains(t, s, "Tags = [foo,bar]") + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() +} diff --git a/command/service_list.go b/command/service_list.go new file mode 100644 index 000000000000..8e132494f6db --- /dev/null +++ b/command/service_list.go @@ -0,0 +1,180 @@ +package command + +import ( + "fmt" + "sort" + "strings" + + "github.com/hashicorp/nomad/api" + "github.com/mitchellh/cli" + "github.com/posener/complete" +) + +// Ensure ServiceListCommand satisfies the cli.Command interface. +var _ cli.Command = &ServiceListCommand{} + +// ServiceListCommand implements cli.Command. +type ServiceListCommand struct { + Meta +} + +// Help satisfies the cli.Command Help function. +func (s *ServiceListCommand) Help() string { + helpText := ` +Usage: nomad service list [options] + + List is used to list the currently registered services. + + If ACLs are enabled, this command requires a token with the 'read-job' + capabilities for the namespace of all services. Any namespaces that the token + does not have access to will have its services filtered from the results. + +General Options: + + ` + generalOptionsUsage(usageOptsDefault) + ` + +Service List Options: + + -json + Output the services in JSON format. + + -t + Format and display the services using a Go template. +` + return strings.TrimSpace(helpText) +} + +// Synopsis satisfies the cli.Command Synopsis function. +func (s *ServiceListCommand) Synopsis() string { + return "Display all registered Nomad services" +} + +func (s *ServiceListCommand) AutocompleteFlags() complete.Flags { + return mergeAutocompleteFlags(s.Meta.AutocompleteFlags(FlagSetClient), + complete.Flags{ + "-json": complete.PredictNothing, + "-t": complete.PredictAnything, + }) +} + +// Name returns the name of this command. +func (s *ServiceListCommand) Name() string { return "service list" } + +// Run satisfies the cli.Command Run function. +func (s *ServiceListCommand) Run(args []string) int { + + var ( + json bool + tmpl, name string + ) + + flags := s.Meta.FlagSet(s.Name(), FlagSetClient) + flags.Usage = func() { s.Ui.Output(s.Help()) } + flags.BoolVar(&json, "json", false, "") + flags.StringVar(&name, "name", "", "") + flags.StringVar(&tmpl, "t", "", "") + if err := flags.Parse(args); err != nil { + return 1 + } + + if args = flags.Args(); len(args) > 0 { + s.Ui.Error("This command takes no arguments") + s.Ui.Error(commandErrorText(s)) + return 1 + } + + client, err := s.Meta.Client() + if err != nil { + s.Ui.Error(fmt.Sprintf("Error initializing client: %s", err)) + return 1 + } + + list, _, err := client.ServiceRegistrations().List(nil) + if err != nil { + s.Ui.Error(fmt.Sprintf("Error listing service registrations: %s", err)) + return 1 + } + + if len(list) == 0 { + s.Ui.Output("No service registrations found") + return 0 + } + + if json || len(tmpl) > 0 { + out, err := Format(json, tmpl, list) + if err != nil { + s.Ui.Error(err.Error()) + return 1 + } + s.Ui.Output(out) + return 0 + } + + s.formatOutput(list) + return 0 +} + +func (s *ServiceListCommand) formatOutput(regs []*api.ServiceRegistrationListStub) { + + // Create objects to hold sorted a sorted namespace array and a mapping, so + // we can perform service lookups on a namespace basis. + sortedNamespaces := make([]string, len(regs)) + namespacedServices := make(map[string][]*api.ServiceRegistrationStub) + + for i, namespaceServices := range regs { + sortedNamespaces[i] = namespaceServices.Namespace + namespacedServices[namespaceServices.Namespace] = namespaceServices.Services + } + + // Sort the namespaces. + sort.Strings(sortedNamespaces) + + // The table always starts with the service name. + outputTable := []string{"Service Name"} + + // If the request was made using the wildcard namespace, include this in + // the output. + if s.Meta.namespace == api.AllNamespacesNamespace { + outputTable[0] += "|Namespace" + } + + // The tags come last and are always present. + outputTable[0] += "|Tags" + + for _, ns := range sortedNamespaces { + + // Grab the services belonging to this namespace. + services := namespacedServices[ns] + + // Create objects to hold sorted a sorted service name array and a + // mapping, so we can perform service tag lookups on a name basis. + sortedNames := make([]string, len(services)) + serviceTags := make(map[string][]string) + + for i, service := range services { + sortedNames[i] = service.ServiceName + serviceTags[service.ServiceName] = service.Tags + } + + // Sort the service names. + sort.Strings(sortedNames) + + for _, serviceName := range sortedNames { + + // Grab the service tags, and sort these for good measure. + tags := serviceTags[serviceName] + sort.Strings(tags) + + // Build the output array entry. + regOutput := serviceName + + if s.Meta.namespace == api.AllNamespacesNamespace { + regOutput += "|" + ns + } + regOutput += "|" + fmt.Sprintf("[%s]", strings.Join(tags, ",")) + outputTable = append(outputTable, regOutput) + } + } + + s.Ui.Output(formatList(outputTable)) +} diff --git a/command/service_list_test.go b/command/service_list_test.go new file mode 100644 index 000000000000..8295963f96bf --- /dev/null +++ b/command/service_list_test.go @@ -0,0 +1,111 @@ +package command + +import ( + "fmt" + "testing" + "time" + + "github.com/hashicorp/nomad/api" + "github.com/hashicorp/nomad/testutil" + "github.com/mitchellh/cli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServiceListCommand_Run(t *testing.T) { + t.Parallel() + + srv, client, url := testServer(t, true, nil) + defer srv.Shutdown() + + // Wait until our test node is ready. + testutil.WaitForResult(func() (bool, error) { + nodes, _, err := client.Nodes().List(nil) + if err != nil { + return false, err + } + if len(nodes) == 0 { + return false, fmt.Errorf("missing node") + } + if _, ok := nodes[0].Drivers["mock_driver"]; !ok { + return false, fmt.Errorf("mock_driver not ready") + } + return true, nil + }, func(err error) { + require.NoError(t, err) + }) + + ui := cli.NewMockUi() + cmd := &ServiceListCommand{ + Meta: Meta{ + Ui: ui, + flagAddress: url, + }, + } + + // Run the command with some random arguments to ensure we are performing + // this check. + require.Equal(t, 1, cmd.Run([]string{"-address=" + url, "pretty-please"})) + require.Contains(t, ui.ErrorWriter.String(), "This command takes no arguments") + ui.ErrorWriter.Reset() + + // Create a test job with a Nomad service. + testJob := testJob("service-discovery-nomad-list") + testJob.TaskGroups[0].Tasks[0].Services = []*api.Service{ + {Name: "service-discovery-nomad-list", Provider: "nomad", Tags: []string{"foo", "bar"}}} + + // Register that job. + regResp, _, err := client.Jobs().Register(testJob, nil) + require.NoError(t, err) + registerCode := waitForSuccess(ui, client, fullId, t, regResp.EvalID) + require.Equal(t, 0, registerCode) + + // Reset the output writer, otherwise we will have additional information here. + ui.OutputWriter.Reset() + + // Job register doesn't assure the service registration has completed. It + // therefore needs this wrapper to account for eventual service + // registration. One this has completed, we can perform lookups without + // similar wraps. + require.Eventually(t, func() bool { + + defer ui.OutputWriter.Reset() + + // Perform a standard lookup. + if code := cmd.Run([]string{"-address=" + url}); code != 0 { + return false + } + + // Test each header and data entry. + s := ui.OutputWriter.String() + if !assert.Contains(t, s, "Service Name") { + return false + } + if !assert.Contains(t, s, "Tags") { + return false + } + if !assert.Contains(t, s, "service-discovery-nomad-list") { + return false + } + if !assert.Contains(t, s, "[bar,foo]") { + return false + } + return true + }, 5*time.Second, 100*time.Millisecond) + + // Perform a wildcard namespace lookup. + code := cmd.Run([]string{"-address=" + url, "-namespace", "*"}) + require.Equal(t, 0, code) + + // Test each header and data entry. + s := ui.OutputWriter.String() + require.Contains(t, s, "Service Name") + require.Contains(t, s, "Namespace") + require.Contains(t, s, "Tags") + require.Contains(t, s, "service-discovery-nomad-list") + require.Contains(t, s, "default") + require.Contains(t, s, "[bar,foo]") + + ui.OutputWriter.Reset() + ui.ErrorWriter.Reset() +}