Enable plugin resolution as subcommand for selected builtin commands (#116293)

* Enable plugin resolution as subcommand for selected builtin commands

This PR adds external plugin resolution as subcommand for selected builtin
commands if subcommand does not exist as builtin.

In it's alpha stage, this will only be enabled for create command and
this feature is hidden behind `KUBECTL_ENABLE_CMD_SHADOW` environment variable.

* Rename parameter to exactMatch to better reflect
This commit is contained in:
Arda Güçlü 2023-03-09 16:16:01 +03:00 committed by GitHub
parent 87a40ae670
commit a901bb630b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 155 additions and 5 deletions

View File

@ -83,6 +83,10 @@ import (
const kubectlCmdHeaders = "KUBECTL_COMMAND_HEADERS" const kubectlCmdHeaders = "KUBECTL_COMMAND_HEADERS"
var (
allowedCmdsSubcommandPlugin = map[string]struct{}{"create": {}}
)
type KubectlOptions struct { type KubectlOptions struct {
PluginHandler PluginHandler PluginHandler PluginHandler
Arguments []string Arguments []string
@ -116,7 +120,7 @@ func NewDefaultKubectlCommandWithArgs(o KubectlOptions) *cobra.Command {
// only look for suitable extension executables if // only look for suitable extension executables if
// the specified command does not already exist // the specified command does not already exist
if _, _, err := cmd.Find(cmdPathPieces); err != nil { if foundCmd, foundArgs, err := cmd.Find(cmdPathPieces); err != nil {
// Also check the commands that will be added by Cobra. // Also check the commands that will be added by Cobra.
// These commands are only added once rootCmd.Execute() is called, so we // These commands are only added once rootCmd.Execute() is called, so we
// need to check them explicitly here. // need to check them explicitly here.
@ -132,11 +136,39 @@ func NewDefaultKubectlCommandWithArgs(o KubectlOptions) *cobra.Command {
case "help", cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd: case "help", cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd:
// Don't search for a plugin // Don't search for a plugin
default: default:
if err := HandlePluginCommand(o.PluginHandler, cmdPathPieces); err != nil { if err := HandlePluginCommand(o.PluginHandler, cmdPathPieces, false); err != nil {
fmt.Fprintf(o.IOStreams.ErrOut, "Error: %v\n", err) fmt.Fprintf(o.IOStreams.ErrOut, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
} }
} else if err == nil {
if cmdutil.CmdPluginAsSubcommand.IsEnabled() {
// Command exists(e.g. kubectl create), but it is not certain that
// subcommand also exists (e.g. kubectl create networkpolicy)
if _, ok := allowedCmdsSubcommandPlugin[foundCmd.Name()]; ok {
var subcommand string
for _, arg := range foundArgs { // first "non-flag" argument as subcommand
if !strings.HasPrefix(arg, "-") {
subcommand = arg
break
}
}
builtinSubcmdExist := false
for _, subcmd := range foundCmd.Commands() {
if subcmd.Name() == subcommand {
builtinSubcmdExist = true
break
}
}
if !builtinSubcmdExist {
if err := HandlePluginCommand(o.PluginHandler, cmdPathPieces, true); err != nil {
fmt.Fprintf(o.IOStreams.ErrOut, "Error: %v\n", err)
os.Exit(1)
}
}
}
}
} }
} }
@ -224,7 +256,7 @@ func (h *DefaultPluginHandler) Execute(executablePath string, cmdArgs, environme
// HandlePluginCommand receives a pluginHandler and command-line arguments and attempts to find // HandlePluginCommand receives a pluginHandler and command-line arguments and attempts to find
// a plugin executable on the PATH that satisfies the given arguments. // a plugin executable on the PATH that satisfies the given arguments.
func HandlePluginCommand(pluginHandler PluginHandler, cmdArgs []string) error { func HandlePluginCommand(pluginHandler PluginHandler, cmdArgs []string, exactMatch bool) error {
var remainingArgs []string // all "non-flag" arguments var remainingArgs []string // all "non-flag" arguments
for _, arg := range cmdArgs { for _, arg := range cmdArgs {
if strings.HasPrefix(arg, "-") { if strings.HasPrefix(arg, "-") {
@ -244,6 +276,12 @@ func HandlePluginCommand(pluginHandler PluginHandler, cmdArgs []string) error {
for len(remainingArgs) > 0 { for len(remainingArgs) > 0 {
path, found := pluginHandler.Lookup(strings.Join(remainingArgs, "-")) path, found := pluginHandler.Lookup(strings.Join(remainingArgs, "-"))
if !found { if !found {
if exactMatch {
// if exactMatch is true, we shouldn't continue searching with shorter names.
// this is especially for not searching kubectl-create plugin
// when kubectl-create-foo plugin is not found.
break
}
remainingArgs = remainingArgs[:len(remainingArgs)-1] remainingArgs = remainingArgs[:len(remainingArgs)-1]
continue continue
} }

View File

@ -25,7 +25,10 @@ import (
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts" "github.com/google/go-cmp/cmp/cmpopts"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/cli-runtime/pkg/genericclioptions"
cmdtesting "k8s.io/kubectl/pkg/cmd/testing"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
) )
func TestNormalizationFuncGlobalExistence(t *testing.T) { func TestNormalizationFuncGlobalExistence(t *testing.T) {
@ -54,6 +57,104 @@ func TestNormalizationFuncGlobalExistence(t *testing.T) {
} }
} }
func TestKubectlSubcommandShadowPlugin(t *testing.T) {
tests := []struct {
name string
args []string
expectPlugin string
expectPluginArgs []string
expectError string
}{
{
name: "test that a plugin executable is found based on command args when builtin subcommand does not exist",
args: []string{"kubectl", "create", "foo", "--bar", "--bar2", "--namespace", "test-namespace"},
expectPlugin: "plugin/testdata/kubectl-create-foo",
expectPluginArgs: []string{"--bar", "--bar2", "--namespace", "test-namespace"},
},
{
name: "test that a plugin executable is not found based on command args when also builtin subcommand does not exist",
args: []string{"kubectl", "create", "foo2", "--bar", "--bar2", "--namespace", "test-namespace"},
expectError: "unable to find a plugin executable \"kubectl-create-foo2\"",
},
{
name: "test that normal commands are able to be executed, when builtin subcommand exists",
args: []string{"kubectl", "create", "role", "--bar", "--bar2", "--namespace", "test-namespace"},
expectPlugin: "",
expectPluginArgs: []string{},
},
// rest of the tests are copied from TestKubectlCommandHandlesPlugins function,
// just to retest them also when feature is enabled.
{
name: "test that normal commands are able to be executed, when no plugin overshadows them",
args: []string{"kubectl", "get", "foo"},
expectPlugin: "",
expectPluginArgs: []string{},
},
{
name: "test that a plugin executable is found based on command args",
args: []string{"kubectl", "foo", "--bar"},
expectPlugin: "plugin/testdata/kubectl-foo",
expectPluginArgs: []string{"--bar"},
},
{
name: "test that a plugin does not execute over an existing command by the same name",
args: []string{"kubectl", "version"},
},
{
name: "test that a plugin does not execute over Cobra's help command",
args: []string{"kubectl", "help"},
},
{
name: "test that a plugin does not execute over Cobra's __complete command",
args: []string{"kubectl", cobra.ShellCompRequestCmd},
},
{
name: "test that a plugin does not execute over Cobra's __completeNoDesc command",
args: []string{"kubectl", cobra.ShellCompNoDescRequestCmd},
},
{
name: "test that a flag does not break Cobra's help command",
args: []string{"kubectl", "--kubeconfig=/path/to/kubeconfig", "help"},
},
{
name: "test that a flag does not break Cobra's __complete command",
args: []string{"kubectl", "--kubeconfig=/path/to/kubeconfig", cobra.ShellCompRequestCmd},
},
{
name: "test that a flag does not break Cobra's __completeNoDesc command",
args: []string{"kubectl", "--kubeconfig=/path/to/kubeconfig", cobra.ShellCompNoDescRequestCmd},
},
}
for _, test := range tests {
cmdtesting.WithAlphaEnvs([]cmdutil.FeatureGate{cmdutil.CmdPluginAsSubcommand}, t, func(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
pluginsHandler := &testPluginHandler{
pluginsDirectory: "plugin/testdata",
}
ioStreams, _, _, _ := genericclioptions.NewTestIOStreams()
root := NewDefaultKubectlCommandWithArgs(KubectlOptions{PluginHandler: pluginsHandler, Arguments: test.args, IOStreams: ioStreams})
if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if pluginsHandler.err != nil && pluginsHandler.err.Error() != test.expectError {
t.Fatalf("unexpected error: expected %q to occur, but got %q", test.expectError, pluginsHandler.err)
}
if pluginsHandler.executedPlugin != test.expectPlugin {
t.Fatalf("unexpected plugin execution: expected %q, got %q", test.expectPlugin, pluginsHandler.executedPlugin)
}
if !cmp.Equal(pluginsHandler.withArgs, test.expectPluginArgs, cmpopts.EquateEmpty()) {
t.Fatalf("unexpected plugin execution args: expected %q, got %q", test.expectPluginArgs, pluginsHandler.withArgs)
}
})
})
}
}
func TestKubectlCommandHandlesPlugins(t *testing.T) { func TestKubectlCommandHandlesPlugins(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@ -68,6 +169,12 @@ func TestKubectlCommandHandlesPlugins(t *testing.T) {
expectPlugin: "", expectPlugin: "",
expectPluginArgs: []string{}, expectPluginArgs: []string{},
}, },
{
name: "test that normal commands are able to be executed, when no plugin overshadows them and shadowing feature is not enabled",
args: []string{"kubectl", "create", "foo"},
expectPlugin: "",
expectPluginArgs: []string{},
},
{ {
name: "test that a plugin executable is found based on command args", name: "test that a plugin executable is found based on command args",
args: []string{"kubectl", "foo", "--bar"}, args: []string{"kubectl", "foo", "--bar"},

View File

@ -182,6 +182,7 @@ func TestPluginPathsAreValid(t *testing.T) {
func TestListPlugins(t *testing.T) { func TestListPlugins(t *testing.T) {
pluginPath, _ := filepath.Abs("./testdata") pluginPath, _ := filepath.Abs("./testdata")
expectPlugins := []string{ expectPlugins := []string{
filepath.Join(pluginPath, "kubectl-create-foo"),
filepath.Join(pluginPath, "kubectl-foo"), filepath.Join(pluginPath, "kubectl-foo"),
filepath.Join(pluginPath, "kubectl-version"), filepath.Join(pluginPath, "kubectl-version"),
} }

View File

@ -0,0 +1,3 @@
#!/bin/bash
echo "I am plugin create foo"

View File

@ -425,8 +425,9 @@ func GetPodRunningTimeoutFlag(cmd *cobra.Command) (time.Duration, error) {
type FeatureGate string type FeatureGate string
const ( const (
ApplySet FeatureGate = "KUBECTL_APPLYSET" ApplySet FeatureGate = "KUBECTL_APPLYSET"
ExplainOpenapiV3 FeatureGate = "KUBECTL_EXPLAIN_OPENAPIV3" ExplainOpenapiV3 FeatureGate = "KUBECTL_EXPLAIN_OPENAPIV3"
CmdPluginAsSubcommand FeatureGate = "KUBECTL_ENABLE_CMD_SHADOW"
) )
func (f FeatureGate) IsEnabled() bool { func (f FeatureGate) IsEnabled() bool {