Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Exported services CLI and docs #20331

Merged
merged 8 commits into from
Feb 6, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .changelog/20331.txt
Original file line number Diff line number Diff line change
@@ -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.
```
154 changes: 154 additions & 0 deletions command/exportedservices/exported_services.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// 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/ryanuber/columnize"
)

const (
PrettyFormat string = "pretty"
JSONFormat string = "json"
)

func getSupportedFormats() []string {
return []string{PrettyFormat, JSONFormat}
}

func formatIsValid(f string) bool {
return f == PrettyFormat || f == JSONFormat
tauhid621 marked this conversation as resolved.
Show resolved Hide resolved
}

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
}

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.http = &flags.HTTPFlags{}
flags.Merge(c.flags, c.http.ClientFlags())
flags.Merge(c.flags, c.http.ServerFlags())
flags.Merge(c.flags, c.http.PartitionFlag())
absolutelightning marked this conversation as resolved.
Show resolved Hide resolved
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
}

if len(exportedServices) == 0 {
c.UI.Info("No exported services found")
return 0
}

if c.format == JSONFormat {
output, err := json.MarshalIndent(exportedServices, "", " ")
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(exportedServices))

return 0
}

func formatExportedServices(services []api.ResolvedExportedService) string {
result := make([]string, 0, len(services)+1)
absolutelightning marked this conversation as resolved.
Show resolved Hide resolved

if services[0].Partition != "" {
result = append(result, "Service\x1fPartition\x1fNamespace\x1fConsumer")
} else {
result = append(result, "Service\x1fConsumer")
}

for _, expService := range services {
service := ""
if expService.Partition != "" {
service = fmt.Sprintf("%s\x1f%s\x1f%s", expService.Service, expService.Partition, expService.Namespace)
} else {
service = expService.Service
}

for _, peer := range expService.Consumers.Peers {
result = append(result, fmt.Sprintf("%s\x1fPeer: %s", service, peer))
}
for _, partition := range expService.Consumers.Partitions {
result = append(result, fmt.Sprintf("%s\x1fPartition: %s", service, partition))
}

}

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 exported-services [options]

Lists all the exported services and their consumers. Wildcards and sameness groups(Enterprise) are expanded.

Example:

$ consul exported-services
`
)
178 changes: 178 additions & 0 deletions command/exportedservices/exported_services_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// 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)
tauhid621 marked this conversation as resolved.
Show resolved Hide resolved
require.Equal(t, []string{"east"}, resp[1].Consumers.Peers)
}
2 changes: 2 additions & 0 deletions command/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import (
"github.com/hashicorp/consul/command/debug"
"github.com/hashicorp/consul/command/event"
"github.com/hashicorp/consul/command/exec"
exportedservices "github.com/hashicorp/consul/command/exportedservices"
"github.com/hashicorp/consul/command/forceleave"
"github.com/hashicorp/consul/command/info"
"github.com/hashicorp/consul/command/intention"
Expand Down Expand Up @@ -212,6 +213,7 @@ func RegisteredCommands(ui cli.Ui) map[string]mcli.CommandFactory {
entry{"debug", func(ui cli.Ui) (cli.Command, error) { return debug.New(ui), nil }},
entry{"event", func(ui cli.Ui) (cli.Command, error) { return event.New(ui), nil }},
entry{"exec", func(ui cli.Ui) (cli.Command, error) { return exec.New(ui, MakeShutdownCh()), nil }},
entry{"exported-services", func(ui cli.Ui) (cli.Command, error) { return exportedservices.New(ui), nil }},
entry{"force-leave", func(ui cli.Ui) (cli.Command, error) { return forceleave.New(ui), nil }},
entry{"info", func(ui cli.Ui) (cli.Command, error) { return info.New(ui), nil }},
entry{"intention", func(ui cli.Ui) (cli.Command, error) { return intention.New(), nil }},
Expand Down
Loading
Loading