Skip to content

Commit

Permalink
feat: allow using glob pattern as an argument
Browse files Browse the repository at this point in the history
  • Loading branch information
kangasta committed Dec 23, 2024
1 parent 5fecc89 commit 8b76015
Show file tree
Hide file tree
Showing 21 changed files with 146 additions and 47 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Allow using unix style glob pattern as an argument. For example, if there are two servers available with titles `server-1` and `server-2`, these servers can be stopped with `upctl server stop server-*` command.

# [3.13.0] - 2024-12-13

### Added
Expand Down
49 changes: 38 additions & 11 deletions internal/commands/runcommand.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ func commandRunE(command Command, service internal.AllServices, config *config.C
switch typedCommand := command.(type) {
case NoArgumentCommand:
cmdLogger.Debug("executing without arguments", "arguments", args)
// need to pass in fake arguments here, to actually trigger execution
results, err := execute(typedCommand, executor, []string{""}, 1,
results, err := execute(typedCommand, executor, nil, resolveNone, 1,
func(exec Executor, _ string) (output.Output, error) {
return typedCommand.ExecuteWithoutArguments(exec)
})
Expand All @@ -41,7 +40,7 @@ func commandRunE(command Command, service internal.AllServices, config *config.C
if len(args) != 1 || args[0] == "" {
return fmt.Errorf("exactly one positional argument is required")
}
results, err := execute(typedCommand, executor, args, 1, typedCommand.ExecuteSingleArgument)
results, err := execute(typedCommand, executor, args, resolveOnly, 1, typedCommand.ExecuteSingleArgument)
if err != nil {
return err
}
Expand All @@ -52,7 +51,7 @@ func commandRunE(command Command, service internal.AllServices, config *config.C
if len(args) < 1 {
return fmt.Errorf("at least one positional argument is required")
}
results, err := execute(typedCommand, executor, args, typedCommand.MaximumExecutions(), typedCommand.Execute)
results, err := execute(typedCommand, executor, args, resolveAll, typedCommand.MaximumExecutions(), typedCommand.Execute)
if err != nil {
return err
}
Expand All @@ -71,27 +70,50 @@ type resolvedArgument struct {
Original string
}

func resolveArguments(nc Command, exec Executor, args []string) (out []resolvedArgument, err error) {
type resolveMode string

const (
resolveAll resolveMode = "all"
resolveNone resolveMode = "none"
resolveOnly resolveMode = "only"
)

func resolveArguments(nc Command, exec Executor, args []string, mode resolveMode) (out []resolvedArgument, err error) {
if mode == resolveNone {
return nil, nil
}

if resolve, ok := nc.(resolver.ResolutionProvider); ok {
argumentResolver, err := resolve.Get(exec.Context(), exec.All())
if err != nil {
return nil, fmt.Errorf("cannot get resolver: %w", err)
}
for _, arg := range args {
resolved := argumentResolver(arg)
value, err := resolved.GetOnly()
out = append(out, resolvedArgument{Resolved: value, Error: err, Original: arg})
if mode == resolveOnly {
value, err := resolved.GetOnly()
out = append(out, resolvedArgument{Resolved: value, Error: err, Original: arg})
}
if mode == resolveAll {
values, err := resolved.GetAll()
if err != nil {
out = append(out, resolvedArgument{Resolved: "", Error: err, Original: arg})
}
for _, value := range values {
out = append(out, resolvedArgument{Resolved: value, Error: err, Original: arg})
}
}
}
} else {
for _, arg := range args {
out = append(out, resolvedArgument{Resolved: arg, Original: arg})
}
}
return
return out, nil
}

func execute(command Command, executor Executor, args []string, parallelRuns int, executeCommand func(exec Executor, arg string) (output.Output, error)) ([]output.Output, error) {
resolvedArgs, err := resolveArguments(command, executor, args)
func execute(command Command, executor Executor, args []string, mode resolveMode, parallelRuns int, executeCommand func(exec Executor, arg string) (output.Output, error)) ([]output.Output, error) {
resolvedArgs, err := resolveArguments(command, executor, args, mode)
if err != nil {
// If authentication failed, return helpful message instead of the raw error.
if clierrors.CheckAuthenticationFailed(err) {
Expand All @@ -112,6 +134,11 @@ func execute(command Command, executor Executor, args []string, parallelRuns int
// make a copy of the original args to pass into the workers
argQueue := resolvedArgs

// The worker logic below expects at least one argument to be present. Add an empty argument if none are present to execute commands that do not expect arguments.
if argQueue == nil {
argQueue = []resolvedArgument{{}}
}

outputs := make([]output.Output, 0, len(args))
executor.Debug("starting work", "workers", workerCount)
for {
Expand Down Expand Up @@ -157,7 +184,7 @@ func execute(command Command, executor Executor, args []string, parallelRuns int
outputs = append(outputs, res.Result)
}

if len(outputs) >= len(args) {
if len(outputs) >= len(resolvedArgs) {
executor.Debug("execute done")
// We're done, update ui for the last time and render the results
executor.StopProgressLog()
Expand Down
4 changes: 2 additions & 2 deletions internal/commands/runcommand_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ func TestExecute_Resolution(t *testing.T) {
cfg := config.New()
cfg.Viper().Set(config.KeyOutput, config.ValueOutputJSON)
executor := NewExecutor(cfg, mService, flume.New("test"))
outputs, err := execute(cmd, executor, []string{"a", "b", "failtoresolve", "c"}, 10, func(_ Executor, arg string) (output.Output, error) {
outputs, err := execute(cmd, executor, []string{"a", "b", "failtoresolve", "c"}, resolveOnly, 10, func(_ Executor, arg string) (output.Output, error) {
return output.OnlyMarshaled{Value: arg}, nil
})
assert.Len(t, outputs, 4)
Expand Down Expand Up @@ -281,7 +281,7 @@ func TestExecute_Error(t *testing.T) {
cfg := config.New()
cfg.Viper().Set(config.KeyOutput, config.ValueOutputJSON)
executor := NewExecutor(cfg, mService, flume.New("test"))
outputs, err := execute(cmd, executor, []string{"a", "b", "failToExecute", "c"}, 10, cmd.Execute)
outputs, err := execute(cmd, executor, []string{"a", "b", "failToExecute", "c"}, resolveOnly, 10, cmd.Execute)
assert.Len(t, outputs, 4)
assert.NoError(t, err)

Expand Down
2 changes: 1 addition & 1 deletion internal/resolver/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func (s CachingAccount) Get(ctx context.Context, svc internal.AllServices) (Reso
return func(arg string) Resolved {
rv := Resolved{Arg: arg}
for _, account := range accounts {
rv.AddMatch(account.Username, MatchArgWithWhitespace(arg, account.Username))
rv.AddMatch(account.Username, MatchTitle(arg, account.Username))
}
return rv
}, nil
Expand Down
2 changes: 1 addition & 1 deletion internal/resolver/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func (s CachingDatabase) Get(ctx context.Context, svc internal.AllServices) (Res
return func(arg string) Resolved {
rv := Resolved{Arg: arg}
for _, db := range databases {
rv.AddMatch(db.UUID, MatchArgWithWhitespace(arg, db.Title))
rv.AddMatch(db.UUID, MatchTitle(arg, db.Title))
rv.AddMatch(db.UUID, MatchUUID(arg, db.UUID))
}
return rv
Expand Down
13 changes: 11 additions & 2 deletions internal/resolver/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package resolver

import "fmt"

// AmbiguousResolutionError is a resolver error when multiple matching entries have been found
// AmbiguousResolutionError is a resolver error when multiple matching entries have been found.
type AmbiguousResolutionError string

var _ error = AmbiguousResolutionError("")
Expand All @@ -11,7 +11,16 @@ func (s AmbiguousResolutionError) Error() string {
return fmt.Sprintf("'%v' is ambiguous, found multiple matches", string(s))
}

// NotFoundError is a resolver error when no matching entries have been found
// NonGlobMultipleMatchesError is a resolver error when multiple matching entries have been found with non-glob argument.
type NonGlobMultipleMatchesError string

var _ error = NonGlobMultipleMatchesError("")

func (s NonGlobMultipleMatchesError) Error() string {
return fmt.Sprintf("'%v' is not a glob pattern, but matches multiple values. To target multiple resources with single argument, use a glob pattern, e.g. server-*", string(s))
}

// NotFoundError is a resolver error when no matching entries have been found.
type NotFoundError string

var _ error = NotFoundError("")
Expand Down
2 changes: 1 addition & 1 deletion internal/resolver/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func (s CachingGateway) Get(ctx context.Context, svc internal.AllServices) (Reso
return func(arg string) Resolved {
rv := Resolved{Arg: arg}
for _, gtw := range gateways {
rv.AddMatch(gtw.UUID, MatchArgWithWhitespace(arg, gtw.Name))
rv.AddMatch(gtw.UUID, MatchTitle(arg, gtw.Name))
rv.AddMatch(gtw.UUID, MatchUUID(arg, gtw.UUID))
}
return rv
Expand Down
3 changes: 1 addition & 2 deletions internal/resolver/ipaddress.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@ func (s CachingIPAddress) Get(ctx context.Context, svc internal.AllServices) (Re
return func(arg string) Resolved {
rv := Resolved{Arg: arg}
for _, ipAddress := range ipaddresses.IPAddresses {
rv.AddMatch(ipAddress.Address, MatchArgWithWhitespace(arg, ipAddress.PTRRecord))
rv.AddMatch(ipAddress.Address, MatchArgWithWhitespace(arg, ipAddress.Address))
rv.AddMatch(ipAddress.Address, MatchTitle(arg, ipAddress.PTRRecord, ipAddress.Address))
}
return rv
}, nil
Expand Down
2 changes: 1 addition & 1 deletion internal/resolver/kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func (s CachingKubernetes) Get(ctx context.Context, svc service.AllServices) (Re
return func(arg string) Resolved {
rv := Resolved{Arg: arg}
for _, cluster := range clusters {
rv.AddMatch(cluster.UUID, MatchArgWithWhitespace(arg, cluster.Name))
rv.AddMatch(cluster.UUID, MatchTitle(arg, cluster.Name))
rv.AddMatch(cluster.UUID, MatchUUID(arg, cluster.UUID))
}
return rv
Expand Down
2 changes: 1 addition & 1 deletion internal/resolver/loadbalancer.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func (s CachingLoadBalancer) Get(ctx context.Context, svc internal.AllServices)
return func(arg string) Resolved {
rv := Resolved{Arg: arg}
for _, lb := range loadbalancers {
rv.AddMatch(lb.UUID, MatchArgWithWhitespace(arg, lb.Name))
rv.AddMatch(lb.UUID, MatchTitle(arg, lb.Name))
rv.AddMatch(lb.UUID, MatchUUID(arg, lb.UUID))
}
return rv
Expand Down
19 changes: 19 additions & 0 deletions internal/resolver/matchers.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package resolver

import (
"path/filepath"
"strings"

"github.com/UpCloudLtd/upcloud-cli/v3/internal/completion"
Expand All @@ -17,6 +18,24 @@ func MatchArgWithWhitespace(arg, value string) MatchType {
return MatchTypeNone
}

// MatchStringWithWhitespace checks if arg matches given value as an unix style glob pattern.
func MatchArgWithGlobPattern(arg, value string) MatchType {
if matched, _ := filepath.Match(arg, value); matched {
return MatchTypeGlobPattern
}
return MatchTypeNone
}

// MatchTitle checks if arg matches any of the given values by using MatchArgWithWhitespace and MatchArgWithGlobPattern matchers.
func MatchTitle(arg string, values ...string) MatchType {
match := MatchTypeNone
for _, value := range values {
match = max(match, MatchArgWithWhitespace(arg, value))
match = max(match, MatchArgWithGlobPattern(arg, value))
}
return match
}

func MatchUUID(arg, value string) MatchType {
if value == arg {
return MatchTypeExact
Expand Down
22 changes: 18 additions & 4 deletions internal/resolver/matchers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,42 @@ import (
func TestMatchers(t *testing.T) {
cases := []struct {
name string
execFn func(string, string) MatchType
execFn func(string, ...string) MatchType
arg string
value string
expected MatchType
}{
{
name: "Exact match",
execFn: MatchArgWithWhitespace,
execFn: MatchTitle,
arg: "McDuck",
value: "McDuck",
expected: MatchTypeExact,
},
{
name: "Case-insensitive match",
execFn: MatchArgWithWhitespace,
execFn: MatchTitle,
arg: "mcduck",
value: "McDuck",
expected: MatchTypeCaseInsensitive,
},
{
name: "Glob match",
execFn: MatchTitle,
arg: "McDuck-*",
value: "McDuck-1",
expected: MatchTypeGlobPattern,
},
{
name: "No case-insensitive glob match",
execFn: MatchTitle,
arg: "mcduck-*",
value: "McDuck-1",
expected: MatchTypeNone,
},
{
name: "No match",
execFn: MatchArgWithWhitespace,
execFn: MatchTitle,
arg: "scrooge",
value: "McDuck",
expected: MatchTypeNone,
Expand Down
2 changes: 1 addition & 1 deletion internal/resolver/network.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func networkMatcher(cached []upcloud.Network) func(arg string) Resolved {
return func(arg string) Resolved {
rv := Resolved{Arg: arg}
for _, network := range cached {
rv.AddMatch(network.UUID, MatchArgWithWhitespace(arg, network.Name))
rv.AddMatch(network.UUID, MatchTitle(arg, network.Name))
rv.AddMatch(network.UUID, MatchUUID(arg, network.UUID))
}
return rv
Expand Down
2 changes: 1 addition & 1 deletion internal/resolver/networkpeering.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func (s CachingNetworkPeering) Get(ctx context.Context, svc internal.AllServices
return func(arg string) Resolved {
rv := Resolved{Arg: arg}
for _, peering := range gateways {
rv.AddMatch(peering.UUID, MatchArgWithWhitespace(arg, peering.Name))
rv.AddMatch(peering.UUID, MatchTitle(arg, peering.Name))
rv.AddMatch(peering.UUID, MatchUUID(arg, peering.UUID))
}
return rv
Expand Down
2 changes: 1 addition & 1 deletion internal/resolver/objectstorage.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func (s CachingObjectStorage) Get(ctx context.Context, svc internal.AllServices)
return func(arg string) Resolved {
rv := Resolved{Arg: arg}
for _, objsto := range objectstorages {
rv.AddMatch(objsto.UUID, MatchArgWithWhitespace(arg, objsto.Name))
rv.AddMatch(objsto.UUID, MatchTitle(arg, objsto.Name))
rv.AddMatch(objsto.UUID, MatchUUID(arg, objsto.UUID))
}
return rv
Expand Down
38 changes: 25 additions & 13 deletions internal/resolver/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ type ResolutionProvider interface {
type MatchType int

const (
MatchTypeExact MatchType = 3
MatchTypeCaseInsensitive MatchType = 2
MatchTypeWildCard MatchType = 2
MatchTypeExact MatchType = 4
MatchTypeCaseInsensitive MatchType = 3
MatchTypeGlobPattern MatchType = 2
MatchTypePrefix MatchType = 1
MatchTypeNone MatchType = 0
)
Expand All @@ -42,13 +42,12 @@ func (r *Resolved) AddMatch(uuid string, matchType MatchType) {
r.matches[uuid] = max(current, matchType)
}

// GetAll returns all matches with match-type that equals the highest available match-type for the resolved value. I.e., if there is an exact match, only exact matches are returned even if there would be case-insensitive matches.
func (r *Resolved) GetAll() ([]string, error) {
func (r *Resolved) getAll() ([]string, MatchType) {
var all []string
for _, matchType := range []MatchType{
MatchTypeExact,
MatchTypeCaseInsensitive,
MatchTypeWildCard,
MatchTypeGlobPattern,
MatchTypePrefix,
} {
for uuid, match := range r.matches {
Expand All @@ -58,22 +57,35 @@ func (r *Resolved) GetAll() ([]string, error) {
}

if len(all) > 0 {
return all, nil
return all, matchType
}
}

var err error
return all, MatchTypeNone
}

// GetAll returns matches with match-type that equals the highest available match-type for the resolved value. I.e., if there is an exact match, only exact matches are returned even if there would be case-insensitive matches.
//
// If match-type is not a glob pattern match, an error is returned if there are multiple matches.
func (r *Resolved) GetAll() ([]string, error) {
all, matchType := r.getAll()

if len(all) == 0 {
err = NotFoundError(r.Arg)
return nil, NotFoundError(r.Arg)
}

// For backwards compatibility, allow multiple matches only for glob patterns.
if len(all) > 1 && matchType != MatchTypeGlobPattern {
return nil, NonGlobMultipleMatchesError(r.Arg)
}
return all, err
return all, nil
}

// GetOnly returns the only match if there is only one match. If there are no or multiple matches, an empty value and an error is returned.
func (r *Resolved) GetOnly() (string, error) {
all, err := r.GetAll()
if err != nil {
return "", err
all, _ := r.getAll()
if len(all) == 0 {
return "", NotFoundError(r.Arg)
}

if len(all) > 1 {
Expand Down
Loading

0 comments on commit 8b76015

Please sign in to comment.