Files
nomad/command/service_info.go
Tim Gross b09c1146a9 CLI: fix prefix matching across multiple commands (#23502)
Several commands that inspect objects where the names are user-controlled share
a bug where the user cannot inspect the object if it has a name that is an exact
prefix of the name of another object (in the same namespace, where
applicable). For example, the object "test" can't be inspected if there's an
object with the name "testing".

Copy existing logic we have for jobs, node pools, etc. to the impacted commands:

* `plugin status`
* `quota inspect`
* `quota status`
* `scaling policy info`
* `service info`
* `volume deregister`
* `volume detach`
* `volume status`

If we get multiple objects for the prefix query, we check if any of them are an
exact match and use that object instead of returning an error. Where possible
because the prefix query signatures are the same, use a generic function that
can be shared across multiple commands.

Fixes: https://github.com/hashicorp/nomad/issues/13920
Fixes: https://github.com/hashicorp/nomad/issues/17132
Fixes: https://github.com/hashicorp/nomad/issues/23236
Ref: https://hashicorp.atlassian.net/browse/NET-10054
Ref: https://hashicorp.atlassian.net/browse/NET-10055
2024-07-10 09:04:10 -04:00

347 lines
9.4 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package command
import (
"fmt"
"net"
"os"
"sort"
"strconv"
"strings"
"github.com/hashicorp/nomad/api"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)
// Ensure ServiceInfoCommand satisfies the cli.Command interface.
var _ cli.Command = &ServiceInfoCommand{}
// ServiceInfoCommand implements cli.Command.
type ServiceInfoCommand struct {
Meta
}
// Help satisfies the cli.Command Help function.
func (s *ServiceInfoCommand) Help() string {
helpText := `
Usage: nomad service info [options] <service_name>
Info is used to read the services registered to a single service name.
When ACLs are enabled, this command requires a token with the 'read-job'
capability for the service namespace.
General Options:
` + generalOptionsUsage(usageOptsDefault) + `
Service Info Options:
-verbose
Display full information.
-per-page
How many results to show per page.
-page-token
Where to start pagination.
-filter
Specifies an expression used to filter query results.
-json
Output the service in JSON format.
-t
Format and display the service using a Go template.
`
return strings.TrimSpace(helpText)
}
// Synopsis satisfies the cli.Command Synopsis function.
func (s *ServiceInfoCommand) Synopsis() string {
return "Display an individual Nomad service registration"
}
func (s *ServiceInfoCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(s.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-json": complete.PredictNothing,
"-filter": complete.PredictAnything,
"-per-page": complete.PredictAnything,
"-page-token": complete.PredictAnything,
"-t": complete.PredictAnything,
"-verbose": complete.PredictNothing,
})
}
// Name returns the name of this command.
func (s *ServiceInfoCommand) Name() string { return "service info" }
// Run satisfies the cli.Command Run function.
func (s *ServiceInfoCommand) Run(args []string) int {
var (
json, verbose bool
perPage int
tmpl, filter, pageToken string
)
flags := s.Meta.FlagSet(s.Name(), FlagSetClient)
flags.Usage = func() { s.Ui.Output(s.Help()) }
flags.BoolVar(&json, "json", false, "")
flags.BoolVar(&verbose, "verbose", false, "")
flags.StringVar(&tmpl, "t", "", "")
flags.StringVar(&filter, "filter", "", "")
flags.IntVar(&perPage, "per-page", 0, "")
flags.StringVar(&pageToken, "page-token", "", "")
if err := flags.Parse(args); err != nil {
return 1
}
args = flags.Args()
if len(args) != 1 {
s.Ui.Error("This command takes one argument: <service_name>")
s.Ui.Error(commandErrorText(s))
return 1
}
client, err := s.Meta.Client()
if err != nil {
s.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
return 1
}
ns := s.Meta.namespace
serviceID := args[0]
// Set up the options to capture any filter passed.
opts := api.QueryOptions{
Filter: filter,
Prefix: serviceID,
Namespace: ns,
}
ns, serviceID, possible, err := getServiceByPrefix(client.Services(), &opts)
if err != nil {
s.Ui.Error(fmt.Sprintf("Error listing service registrations: %s", err))
return 1
}
if len(possible) > 0 {
s.Ui.Error(fmt.Sprintf("Prefix matched multiple services\n\n%s",
formatServiceListOutput(s.Meta.namespace, possible)))
return 1
}
// Set up the options to capture any filter passed.
opts = api.QueryOptions{
Filter: filter,
PerPage: int32(perPage),
NextToken: pageToken,
Namespace: ns,
}
serviceInfo, qm, err := client.Services().Get(serviceID, &opts)
if err != nil {
s.Ui.Error(fmt.Sprintf("Error listing service registrations: %s", err))
return 1
}
if len(serviceInfo) == 0 {
s.Ui.Output("No service registrations found")
return 0
}
if json || len(tmpl) > 0 {
out, err := Format(json, tmpl, serviceInfo)
if err != nil {
s.Ui.Error(err.Error())
return 1
}
s.Ui.Output(out)
return 0
}
// It is possible for multiple jobs to register a service with the same
// name. In order to provide consistency, sort the output by job ID.
sortedJobID := []string{}
jobIDServices := make(map[string][]*api.ServiceRegistration)
// Populate the objects, ensuring we do not add duplicate job IDs to the
// array which will be sorted.
for _, service := range serviceInfo {
if _, ok := jobIDServices[service.JobID]; ok {
jobIDServices[service.JobID] = append(jobIDServices[service.JobID], service)
} else {
jobIDServices[service.JobID] = []*api.ServiceRegistration{service}
sortedJobID = append(sortedJobID, service.JobID)
}
}
// Sort the jobIDs.
sort.Strings(sortedJobID)
if verbose {
s.formatVerboseOutput(sortedJobID, jobIDServices)
} else {
s.formatOutput(sortedJobID, jobIDServices)
}
if qm.NextToken != "" {
s.Ui.Output(fmt.Sprintf("\nResults have been paginated. To get the next page run: \n\n%s ",
argsWithNewPageToken(os.Args, qm.NextToken)))
}
return 0
}
// formatOutput produces the non-verbose output of service registration info
// for a specific service by its name.
func (s *ServiceInfoCommand) formatOutput(jobIDs []string, jobServices map[string][]*api.ServiceRegistration) {
// Create the output table header.
outputTable := []string{"Job ID|Address|Tags|Node ID|Alloc ID"}
// Populate the list.
for _, jobID := range jobIDs {
for _, service := range jobServices[jobID] {
outputTable = append(outputTable, fmt.Sprintf(
"%s|%s|[%s]|%s|%s",
service.JobID,
formatAddress(service.Address, service.Port),
strings.Join(service.Tags, ","),
limit(service.NodeID, shortId),
limit(service.AllocID, shortId),
))
}
}
s.Ui.Output(formatList(outputTable))
}
func formatAddress(address string, port int) string {
if port == 0 {
return address
}
return net.JoinHostPort(address, strconv.Itoa(port))
}
// formatOutput produces the verbose output of service registration info for a
// specific service by its name.
func (s *ServiceInfoCommand) formatVerboseOutput(jobIDs []string, jobServices map[string][]*api.ServiceRegistration) {
for _, jobID := range jobIDs {
for _, service := range jobServices[jobID] {
out := []string{
fmt.Sprintf("ID|%s", service.ID),
fmt.Sprintf("Service Name|%s", service.ServiceName),
fmt.Sprintf("Namespace|%s", service.Namespace),
fmt.Sprintf("Job ID|%s", service.JobID),
fmt.Sprintf("Alloc ID|%s", service.AllocID),
fmt.Sprintf("Node ID|%s", service.NodeID),
fmt.Sprintf("Datacenter|%s", service.Datacenter),
fmt.Sprintf("Address|%v", fmt.Sprintf("%s:%v", service.Address, service.Port)),
fmt.Sprintf("Tags|[%s]\n", strings.Join(service.Tags, ",")),
}
s.Ui.Output(formatKV(out))
s.Ui.Output("")
}
}
}
// argsWithNewPageToken takes the arguments which called the CLI and modifies
// them to include the correct next token. The function ensures the argument
// ordering is maintained which is vital when using pagination on info related
// calls which have an identifier as their final argument.
func argsWithNewPageToken(osArgs []string, nextToken string) string {
// Copy the arguments into a new array which will be modified and make a
// note of the original length as we may need to modify the length if this
// is the first pagination call without a next token.
newArgs := osArgs
numArgs := len(newArgs)
for i := 0; i < numArgs; i++ {
// If the caller already included a pagination token, replace this
// occurrence with the new next token and exit as we don't need to
// modify any other arguments.
if strings.HasPrefix(newArgs[i], "-page-token") {
if strings.Contains(newArgs[i], "=") {
newArgs[i] = "-page-token=" + nextToken
} else {
newArgs[i+1] = nextToken
}
break
}
// If we have reached the final argument (service name) and are still
// looping we have not added the next token argument. Add this while
// ensuring the service name if the final argument on the command.
if i == numArgs-1 {
serviceName := newArgs[i]
newArgs[i] = "-page-token=" + nextToken
newArgs = append(newArgs, serviceName)
}
}
return strings.Join(newArgs, " ")
}
func getServiceByPrefix(client *api.Services, opts *api.QueryOptions) (ns, id string, possible []*api.ServiceRegistrationListStub, err error) {
possible, _, err = client.List(opts)
if err != nil {
return
}
switch len(possible) {
case 0:
err = fmt.Errorf("No service registrations with prefix %q found", opts.Prefix)
return
case 1: // single namespace
ns = possible[0].Namespace
services := possible[0].Services
switch len(services) {
case 0:
// should never happen because we should never get an empty stub
err = fmt.Errorf("No service registrations with prefix %q found", opts.Prefix)
return
case 1:
id = services[0].ServiceName
possible = nil
return
default:
for _, service := range services {
if service.ServiceName == opts.Prefix { // exact match
id = service.ServiceName
possible = nil
return
}
}
return
}
default: // multiple namespaces, so we passed '*' namespace arg
exactMatchesCount := 0
for _, stub := range possible {
for _, service := range stub.Services {
if service.ServiceName == opts.Prefix {
id = service.ServiceName
exactMatchesCount++
continue
}
}
}
switch exactMatchesCount {
case 0:
// should never happen because we should never get an empty stub
err = fmt.Errorf("No service registrations with prefix %q found", opts.Prefix)
return
case 1:
possible = nil
return
default:
return
}
}
}