diff --git a/.changelog/20331.txt b/.changelog/20331.txt new file mode 100644 index 000000000000..245e0eda693a --- /dev/null +++ b/.changelog/20331.txt @@ -0,0 +1,3 @@ +```release-note:feature +cli: Adds new command `exported-services` to list all services exported and their consumers. Refer to the [CLI docs](https://developer.hashicorp.com/consul/commands/exported-services) for more information. +``` diff --git a/command/registry.go b/command/registry.go index df7abcaef3d8..a735e75a1d89 100644 --- a/command/registry.go +++ b/command/registry.go @@ -122,6 +122,7 @@ import ( "github.com/hashicorp/consul/command/services" svcsderegister "github.com/hashicorp/consul/command/services/deregister" svcsexport "github.com/hashicorp/consul/command/services/export" + exportedservices "github.com/hashicorp/consul/command/services/exportedservices" svcsregister "github.com/hashicorp/consul/command/services/register" "github.com/hashicorp/consul/command/snapshot" snapinspect "github.com/hashicorp/consul/command/snapshot/inspect" @@ -264,6 +265,7 @@ func RegisteredCommands(ui cli.Ui) map[string]mcli.CommandFactory { entry{"services register", func(ui cli.Ui) (cli.Command, error) { return svcsregister.New(ui), nil }}, entry{"services deregister", func(ui cli.Ui) (cli.Command, error) { return svcsderegister.New(ui), nil }}, entry{"services export", func(ui cli.Ui) (cli.Command, error) { return svcsexport.New(ui), nil }}, + entry{"services exported-services", func(ui cli.Ui) (cli.Command, error) { return exportedservices.New(ui), nil }}, entry{"snapshot", func(cli.Ui) (cli.Command, error) { return snapshot.New(), nil }}, entry{"snapshot inspect", func(ui cli.Ui) (cli.Command, error) { return snapinspect.New(ui), nil }}, entry{"snapshot restore", func(ui cli.Ui) (cli.Command, error) { return snaprestore.New(ui), nil }}, diff --git a/command/services/exportedservices/exported_services.go b/command/services/exportedservices/exported_services.go new file mode 100644 index 000000000000..99710024b10b --- /dev/null +++ b/command/services/exportedservices/exported_services.go @@ -0,0 +1,176 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package exportedservices + +import ( + "encoding/json" + "flag" + "fmt" + "strings" + + "github.com/mitchellh/cli" + + "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/command/flags" + "github.com/hashicorp/go-bexpr" + "github.com/ryanuber/columnize" +) + +const ( + PrettyFormat string = "pretty" + JSONFormat string = "json" +) + +func getSupportedFormats() []string { + return []string{PrettyFormat, JSONFormat} +} + +func formatIsValid(f string) bool { + for _, format := range getSupportedFormats() { + if f == format { + return true + } + } + return false + +} + +func New(ui cli.Ui) *cmd { + c := &cmd{UI: ui} + c.init() + return c +} + +type cmd struct { + UI cli.Ui + flags *flag.FlagSet + http *flags.HTTPFlags + help string + + format string + filter string +} + +func (c *cmd) init() { + c.flags = flag.NewFlagSet("", flag.ContinueOnError) + + c.flags.StringVar( + &c.format, + "format", + PrettyFormat, + fmt.Sprintf("Output format {%s} (default: %s)", strings.Join(getSupportedFormats(), "|"), PrettyFormat), + ) + + c.flags.StringVar(&c.filter, "filter", "", "go-bexpr filter string to filter the response") + + c.http = &flags.HTTPFlags{} + flags.Merge(c.flags, c.http.ClientFlags()) + flags.Merge(c.flags, c.http.ServerFlags()) + flags.Merge(c.flags, c.http.PartitionFlag()) + c.help = flags.Usage(help, c.flags) +} + +func (c *cmd) Run(args []string) int { + if err := c.flags.Parse(args); err != nil { + return 1 + } + + if !formatIsValid(c.format) { + c.UI.Error(fmt.Sprintf("Invalid format, valid formats are {%s}", strings.Join(getSupportedFormats(), "|"))) + return 1 + } + + client, err := c.http.APIClient() + if err != nil { + c.UI.Error(fmt.Sprintf("Error connect to Consul agent: %s", err)) + return 1 + } + + exportedServices, _, err := client.ExportedServices(nil) + if err != nil { + c.UI.Error(fmt.Sprintf("Error reading exported services: %v", err)) + return 1 + } + + var filterType []api.ResolvedExportedService + filter, err := bexpr.CreateFilter(c.filter, nil, filterType) + if err != nil { + c.UI.Error(fmt.Sprintf("Error while creating filter: %s", err)) + return 1 + } + + raw, err := filter.Execute(exportedServices) + if err != nil { + c.UI.Error(fmt.Sprintf("Error while filtering response: %s", err)) + return 1 + } + + filteredServices := raw.([]api.ResolvedExportedService) + + if len(filteredServices) == 0 { + c.UI.Info("No exported services found") + return 0 + } + + if c.format == JSONFormat { + output, err := json.MarshalIndent(filteredServices, "", " ") + if err != nil { + c.UI.Error(fmt.Sprintf("Error marshalling JSON: %s", err)) + return 1 + } + c.UI.Output(string(output)) + return 0 + } + + c.UI.Output(formatExportedServices(filteredServices)) + + return 0 +} + +func formatExportedServices(services []api.ResolvedExportedService) string { + result := make([]string, 0, len(services)+1) + + if services[0].Partition != "" { + result = append(result, "Service\x1fPartition\x1fNamespace\x1fConsumer Peers\x1fConsumer Partitions") + } else { + result = append(result, "Service\x1fConsumer Peers") + } + + for _, expService := range services { + row := "" + peers := strings.Join(expService.Consumers.Peers, ", ") + partitions := strings.Join(expService.Consumers.Partitions, ", ") + if expService.Partition != "" { + row = fmt.Sprintf("%s\x1f%s\x1f%s\x1f%s\x1f%s", expService.Service, expService.Partition, expService.Namespace, peers, partitions) + } else { + row = fmt.Sprintf("%s\x1f%s", expService.Service, peers) + } + + result = append(result, row) + + } + + return columnize.Format(result, &columnize.Config{Delim: string([]byte{0x1f})}) +} + +func (c *cmd) Synopsis() string { + return synopsis +} + +func (c *cmd) Help() string { + return flags.Usage(c.help, nil) +} + +const ( + synopsis = "Lists exported services" + help = ` +Usage: consul services exported-services [options] + + Lists all the exported services and their consumers. Wildcards and sameness groups(Enterprise) are expanded. + + Example: + + $ consul services exported-services +` +) diff --git a/command/services/exportedservices/exported_services_test.go b/command/services/exportedservices/exported_services_test.go new file mode 100644 index 000000000000..568646051937 --- /dev/null +++ b/command/services/exportedservices/exported_services_test.go @@ -0,0 +1,323 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package exportedservices + +import ( + "encoding/json" + "testing" + + "github.com/hashicorp/consul/agent" + "github.com/hashicorp/consul/api" + "github.com/mitchellh/cli" + + "github.com/stretchr/testify/require" +) + +func TestExportedServices_noTabs(t *testing.T) { + t.Parallel() + + require.NotContains(t, New(cli.NewMockUi()).Help(), "\t") +} + +func TestExportedServices_Error(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + a := agent.NewTestAgent(t, ``) + defer a.Shutdown() + + t.Run("No exported services", func(t *testing.T) { + ui := cli.NewMockUi() + cmd := New(ui) + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + } + + code := cmd.Run(args) + require.Equal(t, 0, code) + + output := ui.OutputWriter.String() + require.Equal(t, "No exported services found\n", output) + }) + + t.Run("invalid format", func(t *testing.T) { + ui := cli.NewMockUi() + cmd := New(ui) + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-format=toml", + } + + code := cmd.Run(args) + require.Equal(t, 1, code, "exited successfully when it should have failed") + output := ui.ErrorWriter.String() + require.Contains(t, output, "Invalid format") + }) +} + +func TestExportedServices_Pretty(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + a := agent.NewTestAgent(t, ``) + defer a.Shutdown() + client := a.Client() + + ui := cli.NewMockUi() + c := New(ui) + + set, _, err := client.ConfigEntries().Set(&api.ExportedServicesConfigEntry{ + Name: "default", + Services: []api.ExportedService{ + { + Name: "db", + Consumers: []api.ServiceConsumer{ + { + Peer: "east", + }, + { + Peer: "west", + }, + }, + }, + { + Name: "web", + Consumers: []api.ServiceConsumer{ + { + Peer: "east", + }, + }, + }, + }, + }, nil) + require.NoError(t, err) + require.True(t, set) + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + } + + code := c.Run(args) + require.Equal(t, 0, code) + + output := ui.OutputWriter.String() + + // Spot check some fields and values + require.Contains(t, output, "db") + require.Contains(t, output, "web") +} + +func TestExportedServices_JSON(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + a := agent.NewTestAgent(t, ``) + defer a.Shutdown() + client := a.Client() + + ui := cli.NewMockUi() + c := New(ui) + + set, _, err := client.ConfigEntries().Set(&api.ExportedServicesConfigEntry{ + Name: "default", + Services: []api.ExportedService{ + { + Name: "db", + Consumers: []api.ServiceConsumer{ + { + Peer: "east", + }, + { + Peer: "west", + }, + }, + }, + { + Name: "web", + Consumers: []api.ServiceConsumer{ + { + Peer: "east", + }, + }, + }, + }, + }, nil) + require.NoError(t, err) + require.True(t, set) + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-format=json", + } + + code := c.Run(args) + require.Equal(t, 0, code) + + var resp []api.ResolvedExportedService + + err = json.Unmarshal(ui.OutputWriter.Bytes(), &resp) + require.NoError(t, err) + + require.Equal(t, 2, len(resp)) + require.Equal(t, "db", resp[0].Service) + require.Equal(t, "web", resp[1].Service) + require.Equal(t, []string{"east", "west"}, resp[0].Consumers.Peers) + require.Equal(t, []string{"east"}, resp[1].Consumers.Peers) +} + +func TestExportedServices_filter(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + a := agent.NewTestAgent(t, ``) + defer a.Shutdown() + client := a.Client() + + set, _, err := client.ConfigEntries().Set(&api.ExportedServicesConfigEntry{ + Name: "default", + Services: []api.ExportedService{ + { + Name: "db", + Consumers: []api.ServiceConsumer{ + { + Peer: "east", + }, + { + Peer: "west", + }, + }, + }, + { + Name: "web", + Consumers: []api.ServiceConsumer{ + { + Peer: "east", + }, + }, + }, + { + Name: "backend", + Consumers: []api.ServiceConsumer{ + { + Peer: "west", + }, + }, + }, + { + Name: "frontend", + Consumers: []api.ServiceConsumer{ + { + Peer: "peer1", + }, + { + Peer: "peer2", + }, + }, + }, + }, + }, nil) + require.NoError(t, err) + require.True(t, set) + + t.Run("consumerPeer=east", func(t *testing.T) { + ui := cli.NewMockUi() + cmd := New(ui) + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-format=json", + "-filter=" + `east in Consumers.Peers`, + } + + code := cmd.Run(args) + require.Equal(t, 0, code) + + var resp []api.ResolvedExportedService + err = json.Unmarshal(ui.OutputWriter.Bytes(), &resp) + require.NoError(t, err) + + require.Equal(t, 2, len(resp)) + require.Equal(t, "db", resp[0].Service) + require.Equal(t, "web", resp[1].Service) + require.Equal(t, []string{"east", "west"}, resp[0].Consumers.Peers) + require.Equal(t, []string{"east"}, resp[1].Consumers.Peers) + + }) + + t.Run("consumerPeer=west", func(t *testing.T) { + ui := cli.NewMockUi() + cmd := New(ui) + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-format=json", + "-filter=" + `west in Consumers.Peers`, + } + + code := cmd.Run(args) + require.Equal(t, 0, code) + + var resp []api.ResolvedExportedService + err = json.Unmarshal(ui.OutputWriter.Bytes(), &resp) + require.NoError(t, err) + + require.Equal(t, 2, len(resp)) + require.Equal(t, "backend", resp[0].Service) + require.Equal(t, "db", resp[1].Service) + require.Equal(t, []string{"west"}, resp[0].Consumers.Peers) + require.Equal(t, []string{"east", "west"}, resp[1].Consumers.Peers) + }) + + t.Run("consumerPeer=peer1", func(t *testing.T) { + ui := cli.NewMockUi() + cmd := New(ui) + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-format=json", + "-filter=" + `peer1 in Consumers.Peers`, + } + + code := cmd.Run(args) + require.Equal(t, 0, code) + + var resp []api.ResolvedExportedService + err = json.Unmarshal(ui.OutputWriter.Bytes(), &resp) + require.NoError(t, err) + + require.Equal(t, 1, len(resp)) + require.Equal(t, "frontend", resp[0].Service) + require.Equal(t, []string{"peer1", "peer2"}, resp[0].Consumers.Peers) + }) + + t.Run("No exported services", func(t *testing.T) { + ui := cli.NewMockUi() + cmd := New(ui) + + args := []string{ + "-http-addr=" + a.HTTPAddr(), + "-filter=" + `unknown in Consumers.Peers`, + } + + code := cmd.Run(args) + require.Equal(t, 0, code) + + output := ui.OutputWriter.String() + require.Equal(t, "No exported services found\n", output) + }) +} diff --git a/website/content/api-docs/exported-services.mdx b/website/content/api-docs/exported-services.mdx new file mode 100644 index 000000000000..aa6521f62fb6 --- /dev/null +++ b/website/content/api-docs/exported-services.mdx @@ -0,0 +1,143 @@ +--- +layout: api +page_title: Exported Services - HTTP API +description: The /exported-services endpoint lists exported services and their consumers. +--- + +# Exported Services HTTP Endpoint + + + The exported services HTTP API endpoint requires Consul v1.17.3 or newer. + + +The `/exported-services` endpoint returns a list of exported services, as well as the admin partitions and cluster peers that consume the services. + +This list consists of the services that were exported using an [`exported-services` configuration entry](/consul/docs/connect/config-entries/exported-services). Sameness groups and wildcards in the configuration entry are expanded in the response. + +## List Exported Services + +This endpoint returns a list of exported services. + +| Method | Path | Produces | +| ------------------ | -------------------- | ------------------ | +| `GET` | `/exported-services` | `application/json` | + + +The table below shows this endpoint's support for +[blocking queries](/consul/api-docs/features/blocking), +[consistency modes](/consul/api-docs/features/consistency), +[agent caching](/consul/api-docs/features/caching), and +[required ACLs](/consul/api-docs/api-structure#authentication). + +| Blocking Queries | Consistency Modes | Agent Caching | ACL Required | +| ---------------- | ----------------- | --------------- | ------------------------------ | +| `YES` | `none` | `none` | `mesh:read` or `operator:read` | + + +### Query Parameters + +- `partition` `(string: "")` - Specifies the admin partition the services are exported from. When not specified, assumes the default value `default`. + + +### Sample Request + +```shell-session +$ curl --header "X-Consul-Token: 0137db51-5895-4c25-b6cd-d9ed992f4a52" \ + http://127.0.0.1:8500/v1/exported-services +``` + +### Sample Response + + + + + +```json +[ + { + "Service": "frontend", + "Consumers": { + "Peers": [ + "east", + "west", + ] + } + }, + { + "Service": "db", + "Consumers": { + "Peers": [ + "east", + ] + } + }, + { + "Service": "web", + "Consumers": { + "Peers": [ + "east", + "west" + ] + } + } +] +``` + + + + + +```json +[ + { + "Service": "frontend", + "Partition": "default", + "Namespace": "default", + "Consumers": { + "Peers": [ + "east", + "west" + ], + "Partitions": [ + "part1" + ] + } + }, + { + "Service": "frontend", + "Partition": "default", + "Namespace": "ns", + "Consumers": { + "Peers": [ + "east", + ] + } + }, + { + "Service": "web", + "Partition": "default", + "Namespace": "default", + "Consumers": { + "Peers": [ + "west" + ], + "Partitions": [ + "part1" + ] + } + }, + { + "Service": "db", + "Partition": "default", + "Namespace": "default", + "Consumers": { + "Partitions": [ + "part1" + ] + } + } +] +``` + + + \ No newline at end of file diff --git a/website/content/commands/services/exported-services.mdx b/website/content/commands/services/exported-services.mdx new file mode 100644 index 000000000000..da1d3dd17703 --- /dev/null +++ b/website/content/commands/services/exported-services.mdx @@ -0,0 +1,57 @@ +--- +layout: commands +page_title: 'Commands: Exported Services' +description: >- + The `consul services exported-services` command lists exported services and their consumers. +--- + +# Consul Exported Services + +Command: `consul services exported-services` + +Corresponding HTTP API Endpoint: [\[GET\] /v1/exported-services](/consul/api-docs/exported-services) + +The `exported-services` command displays the services that were exported using an [`exported-services` configuration entry](/consul/docs/connect/config-entries/exported-services). Sameness groups and wildcards in the configuration entry are expanded in the response. + + +The table below shows this command's [required ACLs](/consul/api-docs/api-structure#authentication). + +| ACL Required | +| ------------------------------ | +| `mesh:read` or `operator:read` | + +## Usage + +Usage: `consul services exported-services [options]` + +#### Command Options + +- `-format={pretty|json}` - Command output format. The default value is `pretty`. + +- `-filter` - Specifies an expression to use for filtering the results. `Consumers.Peers` and `Consumers.Partitions' selectors are supported. + +#### Enterprise Options + +@include 'http_api_partition_options.mdx' + +#### API Options + +@include 'http_api_options_client.mdx' + +## Examples + +To list all exported services and consumers: + + $ consul services exported-services + Service Consumer Peers + backend east, west + db west + frontend east, east-eu + web east + +The following lists exported services with a filter expression: + + $ consul services exported-services -filter='"west" in Consumers.Peers' + Service Consumer Peers + backend east, west + db west diff --git a/website/data/api-docs-nav-data.json b/website/data/api-docs-nav-data.json index 3d49a9b42283..9f86aa6e9b07 100644 --- a/website/data/api-docs-nav-data.json +++ b/website/data/api-docs-nav-data.json @@ -131,6 +131,10 @@ "title": "Events", "path": "event" }, + { + "title": "Exported Services", + "path": "exported-services" + }, { "title": "HCP Consul Central Link", "path": "hcp-link" diff --git a/website/data/commands-nav-data.json b/website/data/commands-nav-data.json index f8b8af1587a3..dc0a24e4d1af 100644 --- a/website/data/commands-nav-data.json +++ b/website/data/commands-nav-data.json @@ -516,6 +516,10 @@ { "title": "export", "path": "services/export" + }, + { + "title": "exported-services", + "path": "services/exported-services" } ] },