Files
nomad/command/plugin_status_csi.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

265 lines
6.9 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package command
import (
"fmt"
"sort"
"strings"
"github.com/hashicorp/nomad/api"
)
func (c *PluginStatusCommand) csiBanner() {
if !(c.json || len(c.template) > 0) {
c.Ui.Output(c.Colorize().Color("[bold]Container Storage Interface[reset]"))
}
}
func (c *PluginStatusCommand) csiStatus(client *api.Client, id string) int {
if id == "" {
c.csiBanner()
plugs, _, err := client.CSIPlugins().List(nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error querying CSI plugins: %s", err))
return 1
}
if len(plugs) == 0 {
// No output if we have no plugins
c.Ui.Error("No CSI plugins")
} else {
str, err := c.csiFormatPlugins(plugs)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error formatting: %s", err))
return 1
}
c.Ui.Output(str)
}
return 0
}
// get a CSI plugin that matches the given prefix or a list of all matches if an
// exact match is not found.
pluginStub, possible, err := getByPrefix[api.CSIPluginListStub]("plugins", client.CSIPlugins().List,
func(plugin *api.CSIPluginListStub, prefix string) bool { return plugin.ID == prefix },
&api.QueryOptions{
Prefix: id,
Namespace: c.namespace,
})
if err != nil {
c.Ui.Error(fmt.Sprintf("Error listing plugins: %s", err))
return 1
}
if len(possible) > 0 {
out, err := c.csiFormatPlugins(possible)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error formatting: %s", err))
return 1
}
c.Ui.Error(fmt.Sprintf("Prefix matched multiple plugins\n\n%s", out))
return 1
}
id = pluginStub.ID
// Lookup matched a single plugin
plug, _, err := client.CSIPlugins().Info(id, nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error querying plugin: %s", err))
return 1
}
str, err := c.csiFormatPlugin(plug)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error formatting plugin: %s", err))
return 1
}
c.Ui.Output(str)
return 0
}
func (c *PluginStatusCommand) csiFormatPlugins(plugs []*api.CSIPluginListStub) (string, error) {
// Sort the output by quota name
sort.Slice(plugs, func(i, j int) bool { return plugs[i].ID < plugs[j].ID })
if c.json || len(c.template) > 0 {
out, err := Format(c.json, c.template, plugs)
if err != nil {
return "", fmt.Errorf("format error: %v", err)
}
return out, nil
}
rows := make([]string, len(plugs)+1)
rows[0] = "ID|Provider|Controllers Healthy/Expected|Nodes Healthy/Expected"
for i, p := range plugs {
rows[i+1] = fmt.Sprintf("%s|%s|%d/%d|%d/%d",
limit(p.ID, c.length),
p.Provider,
p.ControllersHealthy,
p.ControllersExpected,
p.NodesHealthy,
p.NodesExpected,
)
}
return formatList(rows), nil
}
func (c *PluginStatusCommand) csiFormatPlugin(plug *api.CSIPlugin) (string, error) {
if c.json || len(c.template) > 0 {
out, err := Format(c.json, c.template, plug)
if err != nil {
return "", fmt.Errorf("format error: %v", err)
}
return out, nil
}
output := []string{
fmt.Sprintf("ID|%s", plug.ID),
fmt.Sprintf("Provider|%s", plug.Provider),
fmt.Sprintf("Version|%s", plug.Version),
fmt.Sprintf("Controllers Healthy|%d", plug.ControllersHealthy),
fmt.Sprintf("Controllers Expected|%d", plug.ControllersExpected),
fmt.Sprintf("Nodes Healthy|%d", plug.NodesHealthy),
fmt.Sprintf("Nodes Expected|%d", plug.NodesExpected),
}
// Exit early
if c.short {
return formatKV(output), nil
}
full := []string{formatKV(output)}
if c.verbose {
controllerCaps := c.formatControllerCaps(plug.Controllers)
if controllerCaps != "" {
full = append(full, c.Colorize().Color("\n[bold]Controller Capabilities[reset]"))
full = append(full, controllerCaps)
}
nodeCaps := c.formatNodeCaps(plug.Nodes)
if nodeCaps != "" {
full = append(full, c.Colorize().Color("\n[bold]Node Capabilities[reset]"))
full = append(full, nodeCaps)
}
topos := c.formatTopology(plug.Nodes)
if topos != "" {
full = append(full, c.Colorize().Color("\n[bold]Accessible Topologies[reset]"))
full = append(full, topos)
}
}
// Format the allocs
banner := c.Colorize().Color("\n[bold]Allocations[reset]")
allocs := formatAllocListStubs(plug.Allocations, c.verbose, c.length)
full = append(full, banner)
full = append(full, allocs)
return strings.Join(full, "\n"), nil
}
func (c *PluginStatusCommand) formatControllerCaps(controllers map[string]*api.CSIInfo) string {
caps := []string{}
for _, controller := range controllers {
switch info := controller.ControllerInfo; {
case info.SupportsCreateDelete:
caps = append(caps, "CREATE_DELETE_VOLUME")
fallthrough
case info.SupportsAttachDetach:
caps = append(caps, "CONTROLLER_ATTACH_DETACH")
fallthrough
case info.SupportsListVolumes:
caps = append(caps, "LIST_VOLUMES")
fallthrough
case info.SupportsGetCapacity:
caps = append(caps, "GET_CAPACITY")
fallthrough
case info.SupportsCreateDeleteSnapshot:
caps = append(caps, "CREATE_DELETE_SNAPSHOT")
fallthrough
case info.SupportsListSnapshots:
caps = append(caps, "LIST_SNAPSHOTS")
fallthrough
case info.SupportsClone:
caps = append(caps, "CLONE_VOLUME")
fallthrough
case info.SupportsReadOnlyAttach:
caps = append(caps, "ATTACH_READONLY")
fallthrough
case info.SupportsExpand:
caps = append(caps, "EXPAND_VOLUME")
fallthrough
case info.SupportsListVolumesAttachedNodes:
caps = append(caps, "LIST_VOLUMES_PUBLISHED_NODES")
fallthrough
case info.SupportsCondition:
caps = append(caps, "VOLUME_CONDITION")
fallthrough
case info.SupportsGet:
caps = append(caps, "GET_VOLUME")
fallthrough
default:
}
break
}
if len(caps) == 0 {
return ""
}
sort.StringSlice(caps).Sort()
return " " + strings.Join(caps, "\n ")
}
func (c *PluginStatusCommand) formatNodeCaps(nodes map[string]*api.CSIInfo) string {
caps := []string{}
for _, node := range nodes {
if node.RequiresTopologies {
caps = append(caps, "VOLUME_ACCESSIBILITY_CONSTRAINTS")
}
switch info := node.NodeInfo; {
case info.RequiresNodeStageVolume:
caps = append(caps, "STAGE_UNSTAGE_VOLUME")
fallthrough
case info.SupportsStats:
caps = append(caps, "GET_VOLUME_STATS")
fallthrough
case info.SupportsExpand:
caps = append(caps, "EXPAND_VOLUME")
fallthrough
case info.SupportsCondition:
caps = append(caps, "VOLUME_CONDITION")
fallthrough
default:
}
break
}
if len(caps) == 0 {
return ""
}
sort.StringSlice(caps).Sort()
return " " + strings.Join(caps, "\n ")
}
func (c *PluginStatusCommand) formatTopology(nodes map[string]*api.CSIInfo) string {
rows := []string{"Node ID|Accessible Topology"}
for nodeID, node := range nodes {
if node.NodeInfo.AccessibleTopology != nil {
segments := node.NodeInfo.AccessibleTopology.Segments
segmentPairs := make([]string, 0, len(segments))
for k, v := range segments {
segmentPairs = append(segmentPairs, fmt.Sprintf("%s=%s", k, v))
}
rows = append(rows, fmt.Sprintf("%s|%s", nodeID[:8], strings.Join(segmentPairs, ",")))
}
}
if len(rows) == 1 {
return ""
}
return formatList(rows)
}