diff --git a/.changelog/20331.txt b/.changelog/20331.txt
new file mode 100644
index 0000000000..245e0eda69
--- /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 df7abcaef3..a735e75a1d 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 0000000000..99710024b1
--- /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 0000000000..5686460519
--- /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 0000000000..aa6521f62f
--- /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 0000000000..da1d3dd177
--- /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 3d49a9b422..9f86aa6e9b 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 f8b8af1587..dc0a24e4d1 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"
}
]
},