diff --git a/.changelog/19821.txt b/.changelog/19821.txt new file mode 100644 index 0000000000..ee88046faa --- /dev/null +++ b/.changelog/19821.txt @@ -0,0 +1,3 @@ +```release-note:feature +cli: Adds new subcommand `peering exported-services` to list services exported to a peer . Refer to the [CLI docs](https://developer.hashicorp.com/consul/commands/peering) for more information. +``` diff --git a/command/peering/exportedservices/exported_services.go b/command/peering/exportedservices/exported_services.go new file mode 100644 index 0000000000..d075257b33 --- /dev/null +++ b/command/peering/exportedservices/exported_services.go @@ -0,0 +1,154 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package exportedservices + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "strings" + + "github.com/mitchellh/cli" + "github.com/ryanuber/columnize" + + "github.com/hashicorp/consul/agent/structs" + "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/command/flags" + "github.com/hashicorp/consul/command/peering" +) + +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 + + name string + format string +} + +func (c *cmd) init() { + c.flags = flag.NewFlagSet("", flag.ContinueOnError) + + c.flags.StringVar(&c.name, "name", "", "(Required) The local name assigned to the peer cluster.") + + c.flags.StringVar( + &c.format, + "format", + peering.PeeringFormatPretty, + fmt.Sprintf("Output format {%s} (default: %s)", strings.Join(peering.GetSupportedFormats(), "|"), peering.PeeringFormatPretty), + ) + + c.http = &flags.HTTPFlags{} + flags.Merge(c.flags, c.http.ClientFlags()) + 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 c.name == "" { + c.UI.Error("Missing the required -name flag") + return 1 + } + + if !peering.FormatIsValid(c.format) { + c.UI.Error(fmt.Sprintf("Invalid format, valid formats are {%s}", strings.Join(peering.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 + } + + peerings := client.Peerings() + + res, _, err := peerings.Read(context.Background(), c.name, &api.QueryOptions{}) + if err != nil { + c.UI.Error(fmt.Sprintf("Error reading peering: %s", err)) + return 1 + } + + if res == nil { + c.UI.Error(fmt.Sprintf("No peering with name %s found.", c.name)) + return 1 + } + + // Convert service to serviceID + services := make([]structs.ServiceID, 0, len(res.StreamStatus.ExportedServices)) + for _, svc := range res.StreamStatus.ExportedServices { + services = append(services, structs.ServiceIDFromString(svc)) + } + + if c.format == peering.PeeringFormatJSON { + output, err := json.Marshal(services) + 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(services)) + + return 0 +} + +func formatExportedServices(services []structs.ServiceID) string { + if len(services) == 0 { + return "" + } + + result := make([]string, 0, len(services)+1) + + if services[0].EnterpriseMeta.ToEnterprisePolicyMeta() != nil { + result = append(result, "Partition\x1fNamespace\x1fService Name") + } + + for _, svc := range services { + if svc.EnterpriseMeta.ToEnterprisePolicyMeta() == nil { + result = append(result, svc.ID) + } else { + result = append(result, fmt.Sprintf("%s\x1f%s\x1f%s", svc.EnterpriseMeta.PartitionOrDefault(), svc.EnterpriseMeta.NamespaceOrDefault(), svc.ID)) + } + } + + 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 to a peer" + help = ` +Usage: consul peering exported-services [options] -name + + Lists services exported to the peer with the provided name. If the peer is not found, + the command exits with a non-zero code. The result is filtered according + to ACL policy configuration. + + Example: + + $ consul peering exported-services -name west-dc +` +) diff --git a/command/peering/exportedservices/exported_services_test.go b/command/peering/exportedservices/exported_services_test.go new file mode 100644 index 0000000000..d63b5b2c44 --- /dev/null +++ b/command/peering/exportedservices/exported_services_test.go @@ -0,0 +1,216 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package exportedservices + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/mitchellh/cli" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/consul/agent" + "github.com/hashicorp/consul/agent/structs" + "github.com/hashicorp/consul/api" + "github.com/hashicorp/consul/sdk/testutil/retry" + "github.com/hashicorp/consul/testrpc" +) + +func TestExportedServicesCommand_noTabs(t *testing.T) { + t.Parallel() + + if strings.ContainsRune(New(cli.NewMockUi()).Help(), '\t') { + t.Fatal("help has tabs") + } +} + +func TestExportedServicesCommand(t *testing.T) { + if testing.Short() { + t.Skip("too slow for testing.Short") + } + + t.Parallel() + + acceptor := agent.NewTestAgent(t, ``) + t.Cleanup(func() { _ = acceptor.Shutdown() }) + + dialer := agent.NewTestAgent(t, `datacenter = "dc2"`) + t.Cleanup(func() { _ = dialer.Shutdown() }) + + testrpc.WaitForTestAgent(t, acceptor.RPC, "dc1") + testrpc.WaitForTestAgent(t, dialer.RPC, "dc2") + + acceptingClient := acceptor.Client() + dialingClient := dialer.Client() + + t.Run("no name flag", func(t *testing.T) { + ui := cli.NewMockUi() + cmd := New(ui) + + args := []string{ + "-http-addr=" + acceptor.HTTPAddr(), + } + + code := cmd.Run(args) + require.Equal(t, 1, code, "err: %s", ui.ErrorWriter.String()) + require.Contains(t, ui.ErrorWriter.String(), "Missing the required -name flag") + }) + + t.Run("invalid format", func(t *testing.T) { + ui := cli.NewMockUi() + cmd := New(ui) + + args := []string{ + "-http-addr=" + acceptor.HTTPAddr(), + "-name=foo", + "-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") + }) + + t.Run("peering does not exist", func(t *testing.T) { + ui := cli.NewMockUi() + cmd := New(ui) + + args := []string{ + "-http-addr=" + acceptor.HTTPAddr(), + "-name=foo", + } + + code := cmd.Run(args) + require.Equal(t, 1, code, "err: %s", ui.ErrorWriter.String()) + require.Contains(t, ui.ErrorWriter.String(), "No peering with name") + }) + + t.Run("peering exist but no exported services", func(t *testing.T) { + // Generate token + generateReq := api.PeeringGenerateTokenRequest{ + PeerName: "foo", + Meta: map[string]string{ + "env": "production", + }, + } + + res, _, err := acceptingClient.Peerings().GenerateToken(context.Background(), generateReq, &api.WriteOptions{}) + require.NoError(t, err, "Could not generate peering token at acceptor for \"foo\"") + + // Establish peering + establishReq := api.PeeringEstablishRequest{ + PeerName: "bar", + PeeringToken: res.PeeringToken, + Meta: map[string]string{ + "env": "production", + }, + } + + _, _, err = dialingClient.Peerings().Establish(context.Background(), establishReq, &api.WriteOptions{}) + require.NoError(t, err, "Could not establish peering for \"bar\"") + + ui := cli.NewMockUi() + cmd := New(ui) + + args := []string{ + "-http-addr=" + acceptor.HTTPAddr(), + "-name=foo", + } + + code := cmd.Run(args) + require.Equal(t, 0, code) + require.Equal(t, ui.ErrorWriter.String(), "") + }) + + t.Run("exported-services with pretty print", func(t *testing.T) { + // Generate token + generateReq := api.PeeringGenerateTokenRequest{ + PeerName: "foo", + Meta: map[string]string{ + "env": "production", + }, + } + + res, _, err := acceptingClient.Peerings().GenerateToken(context.Background(), generateReq, &api.WriteOptions{}) + require.NoError(t, err, "Could not generate peering token at acceptor for \"foo\"") + + // Establish peering + establishReq := api.PeeringEstablishRequest{ + PeerName: "bar", + PeeringToken: res.PeeringToken, + Meta: map[string]string{ + "env": "production", + }, + } + + _, _, err = dialingClient.Peerings().Establish(context.Background(), establishReq, &api.WriteOptions{}) + require.NoError(t, err, "Could not establish peering for \"bar\"") + + _, _, err = acceptingClient.ConfigEntries().Set(&api.ExportedServicesConfigEntry{ + Name: "default", + Services: []api.ExportedService{ + { + Name: "web", + Consumers: []api.ServiceConsumer{ + { + Peer: "foo", + }, + }, + }, + { + Name: "db", + Consumers: []api.ServiceConsumer{ + { + Peer: "foo", + }, + }, + }, + }, + }, nil) + require.NoError(t, err) + + ui := cli.NewMockUi() + cmd := New(ui) + + args := []string{ + "-http-addr=" + acceptor.HTTPAddr(), + "-name=foo", + } + + retry.Run(t, func(r *retry.R) { + code := cmd.Run(args) + require.Equal(r, 0, code) + output := ui.OutputWriter.String() + + // Spot check some fields and values + require.Contains(r, output, "web") + require.Contains(r, output, "db") + }) + }) + + t.Run("exported-services with json", func(t *testing.T) { + + ui := cli.NewMockUi() + cmd := New(ui) + + args := []string{ + "-http-addr=" + acceptor.HTTPAddr(), + "-name=foo", + "-format=json", + } + + code := cmd.Run(args) + require.Equal(t, 0, code) + output := ui.OutputWriter.Bytes() + + var services []structs.ServiceID + require.NoError(t, json.Unmarshal(output, &services)) + + require.Equal(t, "db", services[0].ID) + require.Equal(t, "web", services[1].ID) + }) +} diff --git a/command/peering/peering.go b/command/peering/peering.go index 157dd42c63..9c520b3d6f 100644 --- a/command/peering/peering.go +++ b/command/peering/peering.go @@ -64,6 +64,10 @@ Usage: consul peering [options] [args] $ consul peering read -name west-dc + Lists services exported to a peering connection: + + $ consul peering exported-services -name west-dc + Delete and close a peering connection: $ consul peering delete -name west-dc diff --git a/command/registry.go b/command/registry.go index 8991877429..1a403aa11f 100644 --- a/command/registry.go +++ b/command/registry.go @@ -108,6 +108,7 @@ import ( "github.com/hashicorp/consul/command/peering" peerdelete "github.com/hashicorp/consul/command/peering/delete" peerestablish "github.com/hashicorp/consul/command/peering/establish" + peerexported "github.com/hashicorp/consul/command/peering/exportedservices" peergenerate "github.com/hashicorp/consul/command/peering/generate" peerlist "github.com/hashicorp/consul/command/peering/list" peerread "github.com/hashicorp/consul/command/peering/read" @@ -247,6 +248,7 @@ func RegisteredCommands(ui cli.Ui) map[string]mcli.CommandFactory { entry{"operator usage instances", func(ui cli.Ui) (cli.Command, error) { return instances.New(ui), nil }}, entry{"peering", func(cli.Ui) (cli.Command, error) { return peering.New(), nil }}, entry{"peering delete", func(ui cli.Ui) (cli.Command, error) { return peerdelete.New(ui), nil }}, + entry{"peering exported-services", func(ui cli.Ui) (cli.Command, error) { return peerexported.New(ui), nil }}, entry{"peering generate-token", func(ui cli.Ui) (cli.Command, error) { return peergenerate.New(ui), nil }}, entry{"peering establish", func(ui cli.Ui) (cli.Command, error) { return peerestablish.New(ui), nil }}, entry{"peering list", func(ui cli.Ui) (cli.Command, error) { return peerlist.New(ui), nil }}, diff --git a/website/content/commands/peering/exported-services.mdx b/website/content/commands/peering/exported-services.mdx new file mode 100644 index 0000000000..316b6daf21 --- /dev/null +++ b/website/content/commands/peering/exported-services.mdx @@ -0,0 +1,50 @@ +--- +layout: commands +page_title: 'Commands: Peering Exported Services' +description: | + The `consul peering exported-services` command outputs a list of services exported to a cluster peer. +--- + +# Consul Peering Exported Services + +Command: `consul peering exported-services` + +Corresponding HTTP API Endpoint: [\[GET\] /v1/peering/:name](/consul/api-docs/peering#read-a-peering-connection) + +The `peering exported-services` command displays all of the services that were exported to the cluster peer using an [`exported-services` configuration entry](/consul/docs/connect/config-entries/exported-services). + +The table below shows this command's [required ACLs](/consul/api-docs/api-structure#authentication). + +| ACL Required | +| ------------ | +| `peering:read` | + +## Usage + +Usage: `consul peering exported-services [options] -name ` + +#### Command Options + +- `-name=` - (Required) The name of the peer associated with a connection. + +- `-format={pretty|json}` - Command output format. The default value is `pretty`. + +#### Enterprise Options + +@include 'http_api_partition_options.mdx' + +#### API Options + +@include 'http_api_options_client.mdx' + +## Examples + +The following example outputs the exported services to a peering connection locally referred to as "cluster-02": + +```shell-session hideClipboard +$ consul peering exported-services -name cluster-02 +backend +frontend +web +``` + diff --git a/website/content/commands/peering/index.mdx b/website/content/commands/peering/index.mdx index cd3a2b359c..62af45d22b 100644 --- a/website/content/commands/peering/index.mdx +++ b/website/content/commands/peering/index.mdx @@ -22,11 +22,12 @@ Usage: consul peering [options] Subcommands: - delete Close and delete a peering connection - establish Consume a peering token and establish a connection with the accepting cluster - generate-token Generate a peering token for use by a dialing cluster - list List the local cluster's peering connections - read Read detailed information on a peering connection + delete Close and delete a peering connection + establish Consume a peering token and establish a connection with the accepting cluster + exported-services Lists the services exported to the peer + generate-token Generate a peering token for use by a dialing cluster + list List the local cluster's peering connections + read Read detailed information on a peering connection ``` For more information, examples, and usage about a subcommand, click on the name @@ -36,4 +37,5 @@ of the subcommand in the sidebar or one of the links below: - [establish](/consul/commands/peering/establish) - [generate-token](/consul/commands/peering/generate-token) - [list](/consul/commands/peering/list) -- [read](/consul/commands/peering/read) \ No newline at end of file +- [read](/consul/commands/peering/read) +- [exported-services](/consul/commands/peering/exported-services) \ No newline at end of file diff --git a/website/data/commands-nav-data.json b/website/data/commands-nav-data.json index 2b8ca9e546..f8b8af1587 100644 --- a/website/data/commands-nav-data.json +++ b/website/data/commands-nav-data.json @@ -472,6 +472,10 @@ "title": "establish", "path": "peering/establish" }, + { + "title": "exported-services", + "path": "peering/exported-services" + }, { "title": "generate-token", "path": "peering/generate-token"