From afd83a970537062c9209146032818f697affb65e Mon Sep 17 00:00:00 2001 From: Seth Vargo Date: Fri, 14 Jul 2017 15:45:08 -0400 Subject: [PATCH] Add catalog CLI functions (#3204) --- command/catalog_command.go | 50 +++++ command/catalog_command_test.go | 8 + command/catalog_list_datacenters.go | 68 +++++++ command/catalog_list_datacenters_test.go | 81 ++++++++ command/catalog_list_nodes.go | 190 ++++++++++++++++++ command/catalog_list_nodes_test.go | 175 ++++++++++++++++ command/catalog_list_services.go | 144 +++++++++++++ command/catalog_list_services_test.go | 160 +++++++++++++++ command/commands.go | 55 +++++ command/kv_command.go | 25 --- configutil/flag_map_value.go | 37 ++++ configutil/flag_map_value_test.go | 84 ++++++++ website/source/docs/commands/catalog.html.md | 84 ++++++++ .../commands/catalog/datacenters.html.md.erb | 31 +++ .../docs/commands/catalog/nodes.html.md.erb | 73 +++++++ .../commands/catalog/services.html.md.erb | 63 ++++++ website/source/layouts/docs.erb | 14 ++ 17 files changed, 1317 insertions(+), 25 deletions(-) create mode 100644 command/catalog_command.go create mode 100644 command/catalog_command_test.go create mode 100644 command/catalog_list_datacenters.go create mode 100644 command/catalog_list_datacenters_test.go create mode 100644 command/catalog_list_nodes.go create mode 100644 command/catalog_list_nodes_test.go create mode 100644 command/catalog_list_services.go create mode 100644 command/catalog_list_services_test.go create mode 100644 configutil/flag_map_value.go create mode 100644 configutil/flag_map_value_test.go create mode 100644 website/source/docs/commands/catalog.html.md create mode 100644 website/source/docs/commands/catalog/datacenters.html.md.erb create mode 100644 website/source/docs/commands/catalog/nodes.html.md.erb create mode 100644 website/source/docs/commands/catalog/services.html.md.erb diff --git a/command/catalog_command.go b/command/catalog_command.go new file mode 100644 index 0000000000..30f112460b --- /dev/null +++ b/command/catalog_command.go @@ -0,0 +1,50 @@ +package command + +import ( + "strings" + + "github.com/mitchellh/cli" +) + +var _ cli.Command = (*CatalogCommand)(nil) + +type CatalogCommand struct { + BaseCommand +} + +func (c *CatalogCommand) Run(args []string) int { + return cli.RunResultHelp +} + +func (c *CatalogCommand) Help() string { + helpText := ` +Usage: consul catalog [options] [args] + + This command has subcommands for interacting with Consul's catalog. The + catalog should not be confused with the agent, although the APIs and + responses may be similar. + + Here are some simple examples, and more detailed examples are available + in the subcommands or the documentation. + + List all datacenters: + + $ consul catalog datacenters + + List all nodes: + + $ consul catalog nodes + + List all services: + + $ consul catalog services + + For more examples, ask for subcommand help or view the documentation. + +` + return strings.TrimSpace(helpText) +} + +func (c *CatalogCommand) Synopsis() string { + return "Interact with the catalog" +} diff --git a/command/catalog_command_test.go b/command/catalog_command_test.go new file mode 100644 index 0000000000..2b6b44a712 --- /dev/null +++ b/command/catalog_command_test.go @@ -0,0 +1,8 @@ +package command + +import "testing" + +func TestCatalogCommand_noTabs(t *testing.T) { + t.Parallel() + assertNoTabs(t, new(CatalogCommand)) +} diff --git a/command/catalog_list_datacenters.go b/command/catalog_list_datacenters.go new file mode 100644 index 0000000000..caf01a0d02 --- /dev/null +++ b/command/catalog_list_datacenters.go @@ -0,0 +1,68 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/mitchellh/cli" +) + +var _ cli.Command = (*CatalogListDatacentersCommand)(nil) + +type CatalogListDatacentersCommand struct { + BaseCommand +} + +func (c *CatalogListDatacentersCommand) Help() string { + helpText := ` +Usage: consul catalog datacenters [options] + + Retrieves the list of all known datacenters. This datacenters are sorted in + ascending order based on the estimated median round trip time from the servers + in this datacenter to the servers in the other datacenters. + + To retrieve the list of datacenters: + + $ consul catalog datacenters + + For a full list of options and examples, please see the Consul documentation. + +` + c.BaseCommand.Help() + + return strings.TrimSpace(helpText) +} + +func (c *CatalogListDatacentersCommand) Run(args []string) int { + f := c.BaseCommand.NewFlagSet(c) + + if err := c.BaseCommand.Parse(args); err != nil { + return 1 + } + + if l := len(f.Args()); l > 0 { + c.UI.Error(fmt.Sprintf("Too many arguments (expected 0, got %d)", l)) + return 1 + } + + // Create and test the HTTP client + client, err := c.BaseCommand.HTTPClient() + if err != nil { + c.UI.Error(fmt.Sprintf("Error connecting to Consul agent: %s", err)) + return 1 + } + + dcs, err := client.Catalog().Datacenters() + if err != nil { + c.UI.Error(fmt.Sprintf("Error listing datacenters: %s", err)) + } + + for _, dc := range dcs { + c.UI.Info(dc) + } + + return 0 +} + +func (c *CatalogListDatacentersCommand) Synopsis() string { + return "Lists all known datacenters" +} diff --git a/command/catalog_list_datacenters_test.go b/command/catalog_list_datacenters_test.go new file mode 100644 index 0000000000..da28e76e55 --- /dev/null +++ b/command/catalog_list_datacenters_test.go @@ -0,0 +1,81 @@ +package command + +import ( + "strings" + "testing" + + "github.com/hashicorp/consul/agent" + "github.com/mitchellh/cli" +) + +func testCatalogListDatacentersCommand(t *testing.T) (*cli.MockUi, *CatalogListDatacentersCommand) { + ui := cli.NewMockUi() + return ui, &CatalogListDatacentersCommand{ + BaseCommand: BaseCommand{ + Flags: FlagSetHTTP, + UI: ui, + }, + } +} + +func TestCatalogListDatacentersCommand_noTabs(t *testing.T) { + t.Parallel() + assertNoTabs(t, new(CatalogListDatacentersCommand)) +} + +func TestCatalogListDatacentersCommand_Validation(t *testing.T) { + t.Parallel() + ui, c := testCatalogListDatacentersCommand(t) + + cases := map[string]struct { + args []string + output string + }{ + "args": { + []string{"foo"}, + "Too many arguments", + }, + } + + for name, tc := range cases { + // Ensure our buffer is always clear + if ui.ErrorWriter != nil { + ui.ErrorWriter.Reset() + } + if ui.OutputWriter != nil { + ui.OutputWriter.Reset() + } + + code := c.Run(tc.args) + if code == 0 { + t.Errorf("%s: expected non-zero exit", name) + } + + output := ui.ErrorWriter.String() + if !strings.Contains(output, tc.output) { + t.Errorf("%s: expected %q to contain %q", name, output, tc.output) + } + } +} + +func TestCatalogListDatacentersCommand_Run(t *testing.T) { + t.Parallel() + a := agent.NewTestAgent(t.Name(), nil) + defer a.Shutdown() + + ui, c := testCatalogListDatacentersCommand(t) + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + } + + code := c.Run(args) + if code != 0 { + t.Fatalf("bad: %d. %#v", code, ui.ErrorWriter.String()) + } + + output := ui.OutputWriter.String() + if !strings.Contains(output, "dc") { + t.Errorf("bad: %#v", output) + } +} diff --git a/command/catalog_list_nodes.go b/command/catalog_list_nodes.go new file mode 100644 index 0000000000..0cfb1a745e --- /dev/null +++ b/command/catalog_list_nodes.go @@ -0,0 +1,190 @@ +package command + +import ( + "fmt" + "strings" + + "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/configutil" + "github.com/mitchellh/cli" + "github.com/ryanuber/columnize" +) + +var _ cli.Command = (*CatalogListNodesCommand)(nil) + +// CatalogListNodesCommand is a Command implementation that is used to fetch all the +// nodes in the catalog. +type CatalogListNodesCommand struct { + BaseCommand +} + +func (c *CatalogListNodesCommand) Help() string { + helpText := ` +Usage: consul catalog nodes [options] + + Retrieves the list nodes registered in a given datacenter. By default, the + datacenter of the local agent is queried. + + To retrieve the list of nodes: + + $ consul catalog nodes + + To print detailed information including full node IDs, tagged addresses, and + metadata information: + + $ consul catalog nodes -detailed + + To list nodes which are running a particular service: + + $ consul catalog nodes -service=web + + To filter by node metadata: + + $ consul catalog nodes -node-meta="foo=bar" + + To sort nodes by estimated round-trip time from node-web: + + $ consul catalog nodes -near=node-web + + For a full list of options and examples, please see the Consul documentation. + +` + c.BaseCommand.Help() + + return strings.TrimSpace(helpText) +} + +func (c *CatalogListNodesCommand) Run(args []string) int { + f := c.BaseCommand.NewFlagSet(c) + + detailed := f.Bool("detailed", false, "Output detailed information about "+ + "the nodes including their addresses and metadata.") + + near := f.String("near", "", "Node name to sort the node list in ascending "+ + "order based on estimated round-trip time from that node. "+ + "Passing \"_agent\" will use this agent's node for sorting.") + + nodeMeta := make(map[string]string) + f.Var((*configutil.FlagMapValue)(&nodeMeta), "node-meta", "Metadata to "+ + "filter nodes with the given `key=value` pairs. This flag may be "+ + "specified multiple times to filter on multiple sources of metadata.") + + service := f.String("service", "", "Service `id or name` to filter nodes. "+ + "Only nodes which are providing the given service will be returned.") + + if err := c.BaseCommand.Parse(args); err != nil { + return 1 + } + + if l := len(f.Args()); l > 0 { + c.UI.Error(fmt.Sprintf("Too many arguments (expected 0, got %d)", l)) + return 1 + } + + // Create and test the HTTP client + client, err := c.BaseCommand.HTTPClient() + if err != nil { + c.UI.Error(fmt.Sprintf("Error connecting to Consul agent: %s", err)) + return 1 + } + + var nodes []*api.Node + if *service != "" { + services, _, err := client.Catalog().Service(*service, "", &api.QueryOptions{ + Near: *near, + NodeMeta: nodeMeta, + }) + if err != nil { + c.UI.Error(fmt.Sprintf("Error listing nodes for service: %s", err)) + return 1 + } + + nodes = make([]*api.Node, len(services)) + for i, s := range services { + nodes[i] = &api.Node{ + ID: s.ID, + Node: s.Node, + Address: s.Address, + Datacenter: s.Datacenter, + TaggedAddresses: s.TaggedAddresses, + Meta: s.NodeMeta, + CreateIndex: s.CreateIndex, + ModifyIndex: s.ModifyIndex, + } + } + } else { + nodes, _, err = client.Catalog().Nodes(&api.QueryOptions{ + Near: *near, + NodeMeta: nodeMeta, + }) + if err != nil { + c.UI.Error(fmt.Sprintf("Error listing nodes: %s", err)) + return 1 + } + } + + // Handle the edge case where there are no nodes that match the query. + if len(nodes) == 0 { + c.UI.Error("No nodes match the given query - try expanding your search.") + return 0 + } + + output, err := printNodes(nodes, *detailed) + if err != nil { + c.UI.Error(fmt.Sprintf("Error printing nodes: %s", err)) + return 1 + } + + c.UI.Info(output) + + return 0 +} + +func (c *CatalogListNodesCommand) Synopsis() string { + return "Lists all nodes in the given datacenter" +} + +// printNodes accepts a list of nodes and prints information in a tabular +// format about the nodes. +func printNodes(nodes []*api.Node, detailed bool) (string, error) { + var result []string + if detailed { + result = detailedNodes(nodes) + } else { + result = simpleNodes(nodes) + } + + return columnize.SimpleFormat(result), nil +} + +func detailedNodes(nodes []*api.Node) []string { + result := make([]string, 0, len(nodes)+1) + header := "Node|ID|Address|DC|TaggedAddresses|Meta" + result = append(result, header) + + for _, node := range nodes { + result = append(result, fmt.Sprintf("%s|%s|%s|%s|%s|%s", + node.Node, node.ID, node.Address, node.Datacenter, + mapToKV(node.TaggedAddresses, ", "), mapToKV(node.Meta, ", "))) + } + + return result +} + +func simpleNodes(nodes []*api.Node) []string { + result := make([]string, 0, len(nodes)+1) + header := "Node|ID|Address|DC" + result = append(result, header) + + for _, node := range nodes { + // Shorten the ID in non-detailed mode to just the first octet. + id := node.ID + idx := strings.Index(id, "-") + if idx > 0 { + id = id[0:idx] + } + result = append(result, fmt.Sprintf("%s|%s|%s|%s", + node.Node, id, node.Address, node.Datacenter)) + } + + return result +} diff --git a/command/catalog_list_nodes_test.go b/command/catalog_list_nodes_test.go new file mode 100644 index 0000000000..43e1068c4a --- /dev/null +++ b/command/catalog_list_nodes_test.go @@ -0,0 +1,175 @@ +package command + +import ( + "strings" + "testing" + + "github.com/hashicorp/consul/agent" + "github.com/mitchellh/cli" +) + +func testCatalogListNodesCommand(t *testing.T) (*cli.MockUi, *CatalogListNodesCommand) { + ui := cli.NewMockUi() + return ui, &CatalogListNodesCommand{ + BaseCommand: BaseCommand{ + Flags: FlagSetHTTP, + UI: ui, + }, + } +} + +func TestCatalogListNodesCommand_noTabs(t *testing.T) { + t.Parallel() + assertNoTabs(t, new(CatalogListNodesCommand)) +} + +func TestCatalogListNodesCommand_Validation(t *testing.T) { + t.Parallel() + ui, c := testCatalogListNodesCommand(t) + + cases := map[string]struct { + args []string + output string + }{ + "args": { + []string{"foo"}, + "Too many arguments", + }, + } + + for name, tc := range cases { + // Ensure our buffer is always clear + if ui.ErrorWriter != nil { + ui.ErrorWriter.Reset() + } + if ui.OutputWriter != nil { + ui.OutputWriter.Reset() + } + + code := c.Run(tc.args) + if code == 0 { + t.Errorf("%s: expected non-zero exit", name) + } + + output := ui.ErrorWriter.String() + if !strings.Contains(output, tc.output) { + t.Errorf("%s: expected %q to contain %q", name, output, tc.output) + } + } +} + +func TestCatalogListNodesCommand_Run(t *testing.T) { + t.Parallel() + a := agent.NewTestAgent(t.Name(), nil) + defer a.Shutdown() + + t.Run("simple", func(t *testing.T) { + ui, c := testCatalogListNodesCommand(t) + args := []string{ + "-http-addr=" + a.HTTPAddr(), + } + code := c.Run(args) + if code != 0 { + t.Fatalf("bad exit code %d: %s", code, ui.ErrorWriter.String()) + } + + output := ui.OutputWriter.String() + for _, s := range []string{"Node", "ID", "Address", "DC"} { + if !strings.Contains(output, s) { + t.Errorf("expected %q to contain %q", output, s) + } + } + for _, s := range []string{"TaggedAddresses", "Meta"} { + if strings.Contains(output, s) { + t.Errorf("expected %q to NOT contain %q", output, s) + } + } + }) + + t.Run("detailed", func(t *testing.T) { + ui, c := testCatalogListNodesCommand(t) + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-detailed", + } + code := c.Run(args) + if code != 0 { + t.Fatalf("bad exit code %d: %s", code, ui.ErrorWriter.String()) + } + + output := ui.OutputWriter.String() + for _, s := range []string{"Node", "ID", "Address", "DC", "TaggedAddresses", "Meta"} { + if !strings.Contains(output, s) { + t.Errorf("expected %q to contain %q", output, s) + } + } + }) + + t.Run("node-meta", func(t *testing.T) { + ui, c := testCatalogListNodesCommand(t) + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-node-meta", "foo=bar", + } + code := c.Run(args) + if code != 0 { + t.Fatalf("bad exit code %d: %s", code, ui.ErrorWriter.String()) + } + + output := ui.ErrorWriter.String() + if expected := "No nodes match the given query"; !strings.Contains(output, expected) { + t.Errorf("expected %q to contain %q", output, expected) + } + }) + + t.Run("near", func(t *testing.T) { + ui, c := testCatalogListNodesCommand(t) + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-near", "_agent", + } + code := c.Run(args) + if code != 0 { + t.Fatalf("bad exit code %d: %s", code, ui.ErrorWriter.String()) + } + + output := ui.OutputWriter.String() + if expected := "127.0.0.1"; !strings.Contains(output, expected) { + t.Errorf("expected %q to contain %q", output, expected) + } + }) + + t.Run("service_present", func(t *testing.T) { + ui, c := testCatalogListNodesCommand(t) + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-service", "consul", + } + code := c.Run(args) + if code != 0 { + t.Fatalf("bad exit code %d: %s", code, ui.ErrorWriter.String()) + } + + output := ui.OutputWriter.String() + if expected := "127.0.0.1"; !strings.Contains(output, expected) { + t.Errorf("expected %q to contain %q", output, expected) + } + }) + + t.Run("service_missing", func(t *testing.T) { + ui, c := testCatalogListNodesCommand(t) + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-service", "this-service-will-literally-never-exist", + } + code := c.Run(args) + if code != 0 { + t.Fatalf("bad exit code %d: %s", code, ui.ErrorWriter.String()) + } + + output := ui.ErrorWriter.String() + if expected := "No nodes match the given query"; !strings.Contains(output, expected) { + t.Errorf("expected %q to contain %q", output, expected) + } + }) +} diff --git a/command/catalog_list_services.go b/command/catalog_list_services.go new file mode 100644 index 0000000000..cfd76923f5 --- /dev/null +++ b/command/catalog_list_services.go @@ -0,0 +1,144 @@ +package command + +import ( + "bytes" + "fmt" + "sort" + "strings" + "text/tabwriter" + + "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/configutil" + "github.com/mitchellh/cli" +) + +var _ cli.Command = (*CatalogListServicesCommand)(nil) + +// CatalogListServicesCommand is a Command implementation that is used to fetch all the +// datacenters the agent knows about. +type CatalogListServicesCommand struct { + BaseCommand +} + +func (c *CatalogListServicesCommand) Help() string { + helpText := ` +Usage: consul catalog services [options] + + Retrieves the list services registered in a given datacenter. By default, the + datacenter of the local agent is queried. + + To retrieve the list of services: + + $ consul catalog services + + To include the services' tags in the output: + + $ consul catalog services -tags + + To list services which run on a particular node: + + $ consul catalog services -node=web + + To filter services on node metadata: + + $ consul catalog services -node-meta="foo=bar" + + For a full list of options and examples, please see the Consul documentation. + +` + c.BaseCommand.Help() + + return strings.TrimSpace(helpText) +} + +func (c *CatalogListServicesCommand) Run(args []string) int { + f := c.BaseCommand.NewFlagSet(c) + + node := f.String("node", "", "Node `id or name` for which to list services.") + + nodeMeta := make(map[string]string) + f.Var((*configutil.FlagMapValue)(&nodeMeta), "node-meta", "Metadata to "+ + "filter nodes with the given `key=value` pairs. If specified, only "+ + "services running on nodes matching the given metadata will be returned. "+ + "This flag may be specified multiple times to filter on multiple sources "+ + "of metadata.") + + tags := f.Bool("tags", false, "Display each service's tags as a "+ + "comma-separated list beside each service entry.") + + if err := c.BaseCommand.Parse(args); err != nil { + return 1 + } + + if l := len(f.Args()); l > 0 { + c.UI.Error(fmt.Sprintf("Too many arguments (expected 0, got %d)", l)) + return 1 + } + + // Create and test the HTTP client + client, err := c.BaseCommand.HTTPClient() + if err != nil { + c.UI.Error(fmt.Sprintf("Error connecting to Consul agent: %s", err)) + return 1 + } + + var services map[string][]string + if *node != "" { + catalogNode, _, err := client.Catalog().Node(*node, &api.QueryOptions{ + NodeMeta: nodeMeta, + }) + if err != nil { + c.UI.Error(fmt.Sprintf("Error listing services for node: %s", err)) + return 1 + } + if catalogNode != nil { + services = make(map[string][]string, len(catalogNode.Services)) + for _, s := range catalogNode.Services { + services[s.Service] = append(services[s.Service], s.Tags...) + } + } + } else { + services, _, err = client.Catalog().Services(&api.QueryOptions{ + NodeMeta: nodeMeta, + }) + if err != nil { + c.UI.Error(fmt.Sprintf("Error listing services: %s", err)) + return 1 + } + } + + // Handle the edge case where there are no services that match the query. + if len(services) == 0 { + c.UI.Error("No services match the given query - try expanding your search.") + return 0 + } + + // Order the map for consistent output + order := make([]string, 0, len(services)) + for k, _ := range services { + order = append(order, k) + } + sort.Strings(order) + + if *tags { + var b bytes.Buffer + tw := tabwriter.NewWriter(&b, 0, 2, 6, ' ', 0) + for _, s := range order { + fmt.Fprintf(tw, "%s\t%s\n", s, strings.Join(services[s], ",")) + } + if err := tw.Flush(); err != nil { + c.UI.Error(fmt.Sprintf("Error flushing tabwriter: %s", err)) + return 1 + } + c.UI.Output(strings.TrimSpace(b.String())) + } else { + for _, s := range order { + c.UI.Output(s) + } + } + + return 0 +} + +func (c *CatalogListServicesCommand) Synopsis() string { + return "Lists all registered services in a datacenter" +} diff --git a/command/catalog_list_services_test.go b/command/catalog_list_services_test.go new file mode 100644 index 0000000000..4e6be7c620 --- /dev/null +++ b/command/catalog_list_services_test.go @@ -0,0 +1,160 @@ +package command + +import ( + "strings" + "testing" + + "github.com/hashicorp/consul/agent" + "github.com/hashicorp/consul/api" + "github.com/mitchellh/cli" +) + +func testCatalogListServicesCommand(t *testing.T) (*cli.MockUi, *CatalogListServicesCommand) { + ui := cli.NewMockUi() + return ui, &CatalogListServicesCommand{ + BaseCommand: BaseCommand{ + Flags: FlagSetHTTP, + UI: ui, + }, + } +} + +func TestCatalogListServicesCommand_noTabs(t *testing.T) { + t.Parallel() + assertNoTabs(t, new(CatalogListServicesCommand)) +} + +func TestCatalogListServicesCommand_Validation(t *testing.T) { + t.Parallel() + ui, c := testCatalogListServicesCommand(t) + + cases := map[string]struct { + args []string + output string + }{ + "args": { + []string{"foo"}, + "Too many arguments", + }, + } + + for name, tc := range cases { + // Ensure our buffer is always clear + if ui.ErrorWriter != nil { + ui.ErrorWriter.Reset() + } + if ui.OutputWriter != nil { + ui.OutputWriter.Reset() + } + + code := c.Run(tc.args) + if code == 0 { + t.Errorf("%s: expected non-zero exit", name) + } + + output := ui.ErrorWriter.String() + if !strings.Contains(output, tc.output) { + t.Errorf("%s: expected %q to contain %q", name, output, tc.output) + } + } +} + +func TestCatalogListServicesCommand_Run(t *testing.T) { + t.Parallel() + a := agent.NewTestAgent(t.Name(), nil) + defer a.Shutdown() + + // Add another service with tags for testing + if err := a.Client().Agent().ServiceRegister(&api.AgentServiceRegistration{ + Name: "testing", + Tags: []string{"foo", "bar"}, + Port: 8080, + Address: "127.0.0.1", + }); err != nil { + t.Fatal(err) + } + + t.Run("simple", func(t *testing.T) { + ui, c := testCatalogListServicesCommand(t) + args := []string{ + "-http-addr=" + a.HTTPAddr(), + } + code := c.Run(args) + if code != 0 { + t.Fatalf("bad exit code %d: %s", code, ui.ErrorWriter.String()) + } + + output := ui.OutputWriter.String() + if expected := "consul\ntesting\n"; output != expected { + t.Errorf("expected %q to be %q", output, expected) + } + }) + + t.Run("tags", func(t *testing.T) { + ui, c := testCatalogListServicesCommand(t) + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-tags", + } + code := c.Run(args) + if code != 0 { + t.Fatalf("bad exit code %d: %s", code, ui.ErrorWriter.String()) + } + + output := ui.OutputWriter.String() + if expected := "foo,bar"; !strings.Contains(output, expected) { + t.Errorf("expected %q to contain %q", output, expected) + } + }) + + t.Run("node_missing", func(t *testing.T) { + ui, c := testCatalogListServicesCommand(t) + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-node", "not-a-real-node", + } + code := c.Run(args) + if code != 0 { + t.Fatalf("bad exit code %d: %s", code, ui.ErrorWriter.String()) + } + + output := ui.ErrorWriter.String() + if expected := "No services match the given query"; !strings.Contains(output, expected) { + t.Errorf("expected %q to contain %q", output, expected) + } + }) + + t.Run("node_present", func(t *testing.T) { + ui, c := testCatalogListServicesCommand(t) + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-node", a.Config.NodeName, + } + code := c.Run(args) + if code != 0 { + t.Fatalf("bad exit code %d: %s", code, ui.ErrorWriter.String()) + } + + output := ui.OutputWriter.String() + if expected := "consul\ntesting\n"; !strings.Contains(output, expected) { + t.Errorf("expected %q to contain %q", output, expected) + } + }) + + t.Run("node-meta", func(t *testing.T) { + ui, c := testCatalogListServicesCommand(t) + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-node-meta", "foo=bar", + } + code := c.Run(args) + if code != 0 { + t.Fatalf("bad exit code %d: %s", code, ui.ErrorWriter.String()) + } + + output := ui.ErrorWriter.String() + if expected := "No services match the given query"; !strings.Contains(output, expected) { + t.Errorf("expected %q to contain %q", output, expected) + } + }) +} diff --git a/command/commands.go b/command/commands.go index d8922be180..e1512569bd 100644 --- a/command/commands.go +++ b/command/commands.go @@ -1,8 +1,11 @@ package command import ( + "fmt" "os" "os/signal" + "sort" + "strings" "syscall" "github.com/hashicorp/consul/version" @@ -30,6 +33,42 @@ func init() { }, nil }, + "catalog": func() (cli.Command, error) { + return &CatalogCommand{ + BaseCommand: BaseCommand{ + UI: ui, + Flags: FlagSetNone, + }, + }, nil + }, + + "catalog datacenters": func() (cli.Command, error) { + return &CatalogListDatacentersCommand{ + BaseCommand: BaseCommand{ + Flags: FlagSetHTTP, + UI: ui, + }, + }, nil + }, + + "catalog nodes": func() (cli.Command, error) { + return &CatalogListNodesCommand{ + BaseCommand: BaseCommand{ + Flags: FlagSetHTTP, + UI: ui, + }, + }, nil + }, + + "catalog services": func() (cli.Command, error) { + return &CatalogListServicesCommand{ + BaseCommand: BaseCommand{ + Flags: FlagSetHTTP, + UI: ui, + }, + }, nil + }, + "configtest": func() (cli.Command, error) { return &ConfigTestCommand{ BaseCommand: BaseCommand{ @@ -363,3 +402,19 @@ func makeShutdownCh() <-chan struct{} { return resultCh } + +// mapToKV converts a map[string]string into a human-friendly key=value list, +// sorted by name. +func mapToKV(m map[string]string, joiner string) string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + r := make([]string, len(keys)) + for i, k := range keys { + r[i] = fmt.Sprintf("%s=%s", k, m[k]) + } + return strings.Join(r, joiner) +} diff --git a/command/kv_command.go b/command/kv_command.go index da7183baa4..f456dc0f57 100644 --- a/command/kv_command.go +++ b/command/kv_command.go @@ -49,28 +49,3 @@ Usage: consul kv [options] [args] func (c *KVCommand) Synopsis() string { return "Interact with the key-value store" } - -var apiOptsText = strings.TrimSpace(` -API Options: - - -http-addr= Address of the Consul agent with the port. This can - be an IP address or DNS address, but it must include - the port. This can also be specified via the - CONSUL_HTTP_ADDR environment variable. The default - value is 127.0.0.1:8500. - - -datacenter= Name of the datacenter to query. If unspecified, the - query will default to the datacenter of the Consul - agent at the HTTP address. - - -token= ACL token to use in the request. This can also be - specified via the CONSUL_HTTP_TOKEN environment - variable. If unspecified, the query will default to - the token of the Consul agent at the HTTP address. - - -stale Permit any Consul server (non-leader) to respond to - this request. This allows for lower latency and higher - throughput, but can result in stale data. This option - has no effect on non-read operations. The default - value is false. -`) diff --git a/configutil/flag_map_value.go b/configutil/flag_map_value.go new file mode 100644 index 0000000000..51a88eeba1 --- /dev/null +++ b/configutil/flag_map_value.go @@ -0,0 +1,37 @@ +package configutil + +import ( + "flag" + "fmt" + "strings" +) + +// Ensure implements +var _ flag.Value = (*FlagMapValue)(nil) + +// FlagMapValue is a flag implementation used to provide key=value semantics +// multiple times. +type FlagMapValue map[string]string + +func (h *FlagMapValue) String() string { + return fmt.Sprintf("%v", *h) +} + +func (h *FlagMapValue) Set(value string) error { + idx := strings.Index(value, "=") + if idx == -1 { + return fmt.Errorf("Missing \"=\" value in argument: %s", value) + } + + key, value := value[0:idx], value[idx+1:] + + if *h == nil { + *h = make(map[string]string) + } + + headers := *h + headers[key] = value + *h = headers + + return nil +} diff --git a/configutil/flag_map_value_test.go b/configutil/flag_map_value_test.go new file mode 100644 index 0000000000..cddeed7441 --- /dev/null +++ b/configutil/flag_map_value_test.go @@ -0,0 +1,84 @@ +package configutil + +import ( + "fmt" + "testing" +) + +func TestFlagMapValueSet(t *testing.T) { + t.Parallel() + + t.Run("missing =", func(t *testing.T) { + t.Parallel() + + f := new(FlagMapValue) + if err := f.Set("foo"); err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("sets", func(t *testing.T) { + t.Parallel() + + f := new(FlagMapValue) + if err := f.Set("foo=bar"); err != nil { + t.Fatal(err) + } + + r, ok := (*f)["foo"] + if !ok { + t.Errorf("missing value: %#v", f) + } + if exp := "bar"; r != exp { + t.Errorf("expected %q to be %q", r, exp) + } + }) + + t.Run("sets multiple", func(t *testing.T) { + t.Parallel() + + f := new(FlagMapValue) + + r := map[string]string{ + "foo": "bar", + "zip": "zap", + "cat": "dog", + } + + for k, v := range r { + if err := f.Set(fmt.Sprintf("%s=%s", k, v)); err != nil { + t.Fatal(err) + } + } + + for k, v := range r { + r, ok := (*f)[k] + if !ok { + t.Errorf("missing value %q: %#v", k, f) + } + if exp := v; r != exp { + t.Errorf("expected %q to be %q", r, exp) + } + } + }) + + t.Run("overwrites", func(t *testing.T) { + t.Parallel() + + f := new(FlagMapValue) + if err := f.Set("foo=bar"); err != nil { + t.Fatal(err) + } + if err := f.Set("foo=zip"); err != nil { + t.Fatal(err) + } + + r, ok := (*f)["foo"] + if !ok { + t.Errorf("missing value: %#v", f) + } + if exp := "zip"; r != exp { + t.Errorf("expected %q to be %q", r, exp) + } + }) +} diff --git a/website/source/docs/commands/catalog.html.md b/website/source/docs/commands/catalog.html.md new file mode 100644 index 0000000000..234875bf70 --- /dev/null +++ b/website/source/docs/commands/catalog.html.md @@ -0,0 +1,84 @@ +--- +layout: "docs" +page_title: "Commands: Catalog" +sidebar_current: "docs-commands-catalog" +--- + +# Consul Catalog + +Command: `consul catalog` + +The `catalog` command is used to interact with Consul's catalog via the command +line. It exposes top-level commands for reading and filtering data from the +registry. + +The catalog is also accessible via the [HTTP API](/api/catalog.html). + +## Basic Examples + +List all datacenters: + +```text +$ consul catalog datacenters +dc1 +dc2 +dc3 +``` + +List all nodes: + +```text +$ consul catalog nodes +Node ID Address DC +worker-01 1b662d97 10.4.5.31 dc1 +``` + +List all nodes which provide a particular service: + +```text +$ consul catalog nodes -service=redis +Node ID Address DC +worker-01 1b662d97 10.4.5.31 dc1 +worker-02 d407a592 10.4.4.158 dc1 +``` + +List all services: + +```text +$ consul catalog services +consul +postgresql +redis +``` + +List all services on a node: + +```text +$ consul catalog services -node=worker-01 +consul +postgres +``` + +For more examples, ask for subcommand help or view the subcommand documentation +by clicking on one of the links in the sidebar. + +## Usage + +Usage: `consul catalog ` + +For the exact documentation for your Consul version, run `consul catalog -h` to +view the complete list of subcommands. + +```text +Usage: consul catalog [options] [args] + + # ... + +Subcommands: + datacenters Lists all known datacenters for this agent + nodes Lists all nodes in the given datacenter + services Lists all registered services in a datacenter +``` + +For more information, examples, and usage about a subcommand, click on the name +of the subcommand in the sidebar or one of the links below: diff --git a/website/source/docs/commands/catalog/datacenters.html.md.erb b/website/source/docs/commands/catalog/datacenters.html.md.erb new file mode 100644 index 0000000000..7a9843025d --- /dev/null +++ b/website/source/docs/commands/catalog/datacenters.html.md.erb @@ -0,0 +1,31 @@ +--- +layout: "docs" +page_title: "Commands: Catalog List Datacenters" +sidebar_current: "docs-commands-catalog-datacenters" +--- + +# Consul Catalog List Datacenters + +Command: `consul catalog datacenters` + +The `catalog datacenters` command prints all known datacenters. + +## Examples + +List all datacenters: + +``` +$ consul catalog datacenters +dc1 +dc2 +dc3 +``` + +## Usage + +Usage: `consul catalog datacenters [options]` + +#### API Options + +<%= partial "docs/commands/http_api_options_client" %> +<%= partial "docs/commands/http_api_options_server" %> diff --git a/website/source/docs/commands/catalog/nodes.html.md.erb b/website/source/docs/commands/catalog/nodes.html.md.erb new file mode 100644 index 0000000000..75550c6ec2 --- /dev/null +++ b/website/source/docs/commands/catalog/nodes.html.md.erb @@ -0,0 +1,73 @@ +--- +layout: "docs" +page_title: "Commands: Catalog List Nodes" +sidebar_current: "docs-commands-catalog-nodes" +--- + +# Consul Catalog List Nodes + +Command: `consul catalog nodes` + +The `catalog nodes` command prints all known nodes and metadata about them. +It can also query for nodes that match a particular metadata or provide a +particular service. + +## Examples + +List all nodes: + +```text +$ consul catalog nodes +Node ID Address DC +worker-01 1b662d97 10.4.5.31 dc1 +``` + +Print detailed node information such as tagged addresses and node metadata: + +```text +$ consul catalog nodes -detailed +Node ID Address DC TaggedAddresses Meta +worker-01 1b662d97-8b5c-3cc2-0ac0-96f55ad423b5 10.4.5.31 dc1 lan=10.4.5.31, wan=10.4.5.31 +``` + +List nodes which provide the service name "web": + +```text +$ consul catalog nodes -service=web +Node ID Address DC TaggedAddresses Meta +worker-01 1b662d97-8b5c-3cc2-0ac0-96f55ad423b5 10.4.5.31 dc1 lan=10.4.5.31, wan=10.4.5.31 +``` + +Sort the resulting node list by estimated round trip time to worker-05: + +```text +$ consul catalog nodes -near=web-05 +Node ID Address DC TaggedAddresses Meta +worker-01 1b662d97-8b5c-3cc2-0ac0-96f55ad423b5 10.4.5.31 dc1 lan=10.4.5.31, wan=10.4.5.31 +worker-02 d407a592-e93c-4d8e-8a6d-aba853d1e067 10.4.4.158 dc1 lan=10.4.4.158, wan=10.4.4.158 +``` + +## Usage + +Usage: `consul catalog nodes [options]` + +#### API Options + +<%= partial "docs/commands/http_api_options_client" %> +<%= partial "docs/commands/http_api_options_server" %> + +#### Catalog List Nodes Options + +- `-detailed` - Output detailed information about the nodes including their + addresses and metadata. + +- `-near=`- Node name to sort the node list in ascending order based on + estimated round-trip time from that node. Passing `"_agent"` will use this + agent's node for sorting. + +- `-node-meta=` - Metadata to filter nodes with the given key=value + pairs. This flag may be specified multiple times to filter on multiple sources + of metadata. + +- `-service=` - Service id or name to filter nodes. Only nodes + which are providing the given service will be returned. diff --git a/website/source/docs/commands/catalog/services.html.md.erb b/website/source/docs/commands/catalog/services.html.md.erb new file mode 100644 index 0000000000..535ab9ebcc --- /dev/null +++ b/website/source/docs/commands/catalog/services.html.md.erb @@ -0,0 +1,63 @@ +--- +layout: "docs" +page_title: "Commands: Catalog List Services" +sidebar_current: "docs-commands-catalog-services" +--- + +# Consul Catalog List Services + +Command: `consul catalog services` + +The `catalog services` command prints all known services. It can also query +for services that match particular metadata or list the services that a +particular node provides. + +## Examples + +List all services: + +```text +$ consul catalog services +consul +postgresql +redis +``` + +Show all services with their tags: + +```text +$ consul catalog services -tags +consul +postgresql leader +redis primary,v1 +``` + +List services for the node "worker-01": + +```text +$ consul catalog services -node=worker-01 +consul +redis +``` + + +## Usage + +Usage: `consul catalog services [options]` + +#### API Options + +<%= partial "docs/commands/http_api_options_client" %> +<%= partial "docs/commands/http_api_options_server" %> + +#### Catalog List Nodes Options + +- `-node=` - Node `id or name` for which to list services. + +- `-node-meta=` - Metadata to filter nodes with the given + `key=value` pairs. If specified, only services running on nodes matching the + given metadata will be returned. This flag may be specified multiple times to + filter on multiple sources of metadata. + +- `-tags` - Display each service's tags as a comma-separated list beside each + service entry. diff --git a/website/source/layouts/docs.erb b/website/source/layouts/docs.erb index 40aa5071c8..e10643cedc 100644 --- a/website/source/layouts/docs.erb +++ b/website/source/layouts/docs.erb @@ -56,6 +56,20 @@ > agent + > + catalog + + > event