Introduce CLI for ApplySet-based pruning (#115979)

* Introduce CLI for ApplySet-based pruning

* Address feedback from justinsb

* Fix parent namespace sourcing and restrict types. Increase test coverage.
This commit is contained in:
Katrina Verey 2023-02-27 12:20:40 -05:00 committed by GitHub
parent 911631a6e0
commit 0dd346673c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 527 additions and 52 deletions

View File

@ -64,6 +64,7 @@ type ApplyFlags struct {
Selector string
Prune bool
PruneResources []prune.Resource
ApplySetRef string
All bool
Overwrite bool
OpenAPIPatch bool
@ -130,6 +131,10 @@ type ApplyOptions struct {
// Function run after all objects have been applied.
// The standard PostProcessorFn is "PrintAndPrunePostProcessor()".
PostProcessorFn func() error
// ApplySet tracks the set of objects that have been applied, for the purposes of pruning.
// See git.k8s.io/enhancements/keps/sig-cli/3659-kubectl-apply-prune
ApplySet *ApplySet
}
var (
@ -223,13 +228,9 @@ func (flags *ApplyFlags) AddFlags(cmd *cobra.Command) {
cmdutil.AddServerSideApplyFlags(cmd)
cmdutil.AddFieldManagerFlagVar(cmd, &flags.FieldManager, FieldManagerClientSideApply)
cmdutil.AddLabelSelectorFlagVar(cmd, &flags.Selector)
cmdutil.AddPruningFlags(cmd, &flags.Prune, &flags.PruneAllowlist, &flags.PruneWhitelist, &flags.All, &flags.ApplySetRef)
cmd.Flags().BoolVar(&flags.Overwrite, "overwrite", flags.Overwrite, "Automatically resolve conflicts between the modified and live configuration by using values from the modified configuration")
cmd.Flags().BoolVar(&flags.Prune, "prune", flags.Prune, "Automatically delete resource objects, that do not appear in the configs and are created by either apply or create --save-config. Should be used with either -l or --all.")
cmd.Flags().BoolVar(&flags.All, "all", flags.All, "Select all resources in the namespace of the specified resource types.")
cmd.Flags().StringArrayVar(&flags.PruneAllowlist, "prune-allowlist", flags.PruneAllowlist, "Overwrite the default allowlist with <group/version/kind> for --prune")
cmd.Flags().StringArrayVar(&flags.PruneWhitelist, "prune-whitelist", flags.PruneWhitelist, "Overwrite the default whitelist with <group/version/kind> for --prune") // TODO: Remove this in kubectl 1.28 or later
cmd.Flags().MarkDeprecated("prune-whitelist", "Use --prune-allowlist instead.")
cmd.Flags().BoolVar(&flags.OpenAPIPatch, "openapi-patch", flags.OpenAPIPatch, "If true, use openapi to calculate diff when the openapi presents and the resource can be found in the openapi spec. Otherwise, fall back to use baked-in types.")
}
@ -297,6 +298,17 @@ func (flags *ApplyFlags) ToOptions(f cmdutil.Factory, cmd *cobra.Command, baseNa
return nil, err
}
var applySet *ApplySet
if flags.ApplySetRef != "" {
var applySetNs string
// ApplySet uses the namespace value from the flag, but not from the kubeconfig or defaults
if enforceNamespace {
applySetNs = namespace
}
if applySet, err = NewApplySet(flags.ApplySetRef, applySetNs, mapper); err != nil {
return nil, err
}
}
if flags.Prune {
pruneAllowlist := slice.ToSet(flags.PruneAllowlist, flags.PruneWhitelist)
flags.PruneResources, err = prune.ParseResources(mapper, pruneAllowlist)
@ -342,6 +354,8 @@ func (flags *ApplyFlags) ToOptions(f cmdutil.Factory, cmd *cobra.Command, baseNa
VisitedUids: sets.NewString(),
VisitedNamespaces: sets.NewString(),
ApplySet: applySet,
}
o.PostProcessorFn = o.PrintAndPrunePostProcessor()
@ -371,19 +385,40 @@ func (o *ApplyOptions) Validate() error {
return fmt.Errorf("cannot set --all and --selector at the same time")
}
if o.Prune && !o.All && o.Selector == "" {
return fmt.Errorf("all resources selected for prune without explicitly passing --all. To prune all resources, pass the --all flag. If you did not mean to prune all resources, specify a label selector")
if o.ApplySet != nil {
if !o.Prune {
return fmt.Errorf("--applyset requires --prune")
}
if err := o.ApplySet.Validate(); err != nil {
return err
}
}
if o.Prune {
// Do not force the recreation of an object(s) if we're pruning; this can cause
// undefined behavior since object UID's change.
if o.DeleteOptions.ForceDeletion {
return fmt.Errorf("--force cannot be used with --prune")
}
// Do not force the recreation of an object(s) if we're pruning; this can cause
// undefined behavior since object UID's change.
if o.Prune && o.DeleteOptions.ForceDeletion {
return fmt.Errorf("--force cannot be used with --prune")
}
// Currently do not support pruning objects which are server-side applied.
if o.Prune && o.ServerSideApply {
return fmt.Errorf("--prune is in alpha and doesn't currently work on objects created by server-side apply")
if o.ApplySet != nil {
if o.All {
return fmt.Errorf("--all is incompatible with --applyset")
} else if o.Selector != "" {
return fmt.Errorf("--selector is incompatible with --applyset")
} else if len(o.PruneResources) > 0 {
return fmt.Errorf("--prune-allowlist is incompatible with --applyset")
} else {
// TODO: remove this once ApplySet implementation is complete
return fmt.Errorf("--applyset is not yet supported")
}
} else {
if !o.All && o.Selector == "" {
return fmt.Errorf("all resources selected for prune without explicitly passing --all. To prune all resources, pass the --all flag. If you did not mean to prune all resources, specify a label selector")
}
if o.ServerSideApply {
return fmt.Errorf("--prune is in alpha and doesn't currently work on objects created by server-side apply")
}
}
}
return nil
@ -955,8 +990,13 @@ func (o *ApplyOptions) PrintAndPrunePostProcessor() func() error {
}
if o.Prune {
p := newPruner(o)
return p.pruneAll(o)
if cmdutil.ApplySet.IsEnabled() && o.ApplySet != nil {
p := newApplySetPruner(o)
return p.pruneAll()
} else {
p := newPruner(o)
return p.pruneAll(o)
}
}
return nil

View File

@ -29,7 +29,10 @@ import (
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/client-go/tools/clientcmd"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
@ -84,26 +87,40 @@ func TestApplyExtraArgsFail(t *testing.T) {
f := cmdtesting.NewTestFactory()
defer f.Cleanup()
c := NewCmdApply("kubectl", f, genericclioptions.NewTestIOStreamsDiscard())
if validateApplyArgs(c, []string{"rc"}) == nil {
t.Fatalf("unexpected non-error")
}
cmd := &cobra.Command{}
flags := NewApplyFlags(genericclioptions.NewTestIOStreamsDiscard())
flags.AddFlags(cmd)
_, err := flags.ToOptions(f, cmd, "kubectl", []string{"rc"})
require.EqualError(t, err, "Unexpected args: [rc]\nSee ' -h' for help and examples")
}
func validateApplyArgs(cmd *cobra.Command, args []string) error {
if len(args) != 0 {
return cmdutil.UsageErrorf(cmd, "Unexpected args: %v", args)
func TestAlphaEnablement(t *testing.T) {
alphas := map[cmdutil.FeatureGate]string{
cmdutil.ApplySet: "applyset",
}
for feature, flag := range alphas {
f := cmdtesting.NewTestFactory()
defer f.Cleanup()
cmd := &cobra.Command{}
flags := NewApplyFlags(genericclioptions.NewTestIOStreamsDiscard())
flags.AddFlags(cmd)
require.Nil(t, cmd.Flags().Lookup(flag), "flag %q should not be registered without the %q feature enabled", flag, feature)
cmdtesting.WithAlphaEnvs([]cmdutil.FeatureGate{feature}, t, func(t *testing.T) {
cmd := &cobra.Command{}
flags := NewApplyFlags(genericclioptions.NewTestIOStreamsDiscard())
flags.AddFlags(cmd)
require.NotNil(t, cmd.Flags().Lookup(flag), "flag %q should be registered with the %q feature enabled", flag, feature)
})
}
return nil
}
func TestApplyFlagValidation(t *testing.T) {
f := cmdtesting.NewTestFactory()
defer f.Cleanup()
tests := []struct {
args [][]string
expectedErr string
args [][]string
enableAlphas []cmdutil.FeatureGate
expectedErr string
}{
{
args: [][]string{
@ -147,6 +164,16 @@ func TestApplyFlagValidation(t *testing.T) {
},
expectedErr: "--force cannot be used with --prune",
},
{
args: [][]string{
{"prune", "true"},
{"force", "true"},
{"applyset", "mySecret"},
{"namespace", "myNs"},
},
enableAlphas: []cmdutil.FeatureGate{cmdutil.ApplySet},
expectedErr: "--force cannot be used with --prune",
},
{
args: [][]string{
{"server-side", "true"},
@ -155,27 +182,91 @@ func TestApplyFlagValidation(t *testing.T) {
},
expectedErr: "--prune is in alpha and doesn't currently work on objects created by server-side apply",
},
{
args: [][]string{
{"prune", "true"},
},
expectedErr: "all resources selected for prune without explicitly passing --all. To prune all resources, pass the --all flag. If you did not mean to prune all resources, specify a label selector",
},
{
args: [][]string{
{"prune", "false"},
{"applyset", "mySecret"},
{"namespace", "myNs"},
},
enableAlphas: []cmdutil.FeatureGate{cmdutil.ApplySet},
expectedErr: "--applyset requires --prune",
},
{
args: [][]string{
{"prune", "true"},
{"applyset", "mySecret"},
{"selector", "foo=bar"},
{"namespace", "myNs"},
},
enableAlphas: []cmdutil.FeatureGate{cmdutil.ApplySet},
expectedErr: "--selector is incompatible with --applyset",
},
{
args: [][]string{
{"prune", "true"},
{"applyset", "mySecret"},
{"namespace", "myNs"},
{"all", "true"},
},
enableAlphas: []cmdutil.FeatureGate{cmdutil.ApplySet},
expectedErr: "--all is incompatible with --applyset",
},
{
args: [][]string{
{"prune", "true"},
{"applyset", "mySecret"},
{"namespace", "myNs"},
{"prune-allowlist", "core/v1/ConfigMap"},
},
enableAlphas: []cmdutil.FeatureGate{cmdutil.ApplySet},
expectedErr: "--prune-allowlist is incompatible with --applyset",
},
{
args: [][]string{
{"prune", "true"},
{"applyset", "foo"},
{"namespace", "myNs"},
},
enableAlphas: []cmdutil.FeatureGate{cmdutil.ApplySet},
expectedErr: "--applyset is not yet supported",
},
}
for _, test := range tests {
cmd := &cobra.Command{}
flags := NewApplyFlags(genericclioptions.NewTestIOStreamsDiscard())
flags.AddFlags(cmd)
cmd.Flags().Set("filename", "unused")
for _, arg := range test.args {
cmd.Flags().Set(arg[0], arg[1])
}
o, err := flags.ToOptions(f, cmd, "kubectl", []string{})
if err != nil {
t.Fatalf("unexpected error creating apply options: %s", err)
}
err = o.Validate()
if err == nil {
t.Fatalf("missing expected error")
}
if test.expectedErr != err.Error() {
t.Errorf("expected error %s, got %s", test.expectedErr, err)
}
for i, test := range tests {
t.Run(fmt.Sprintf("case %d", i), func(t *testing.T) {
f := cmdtesting.NewTestFactory()
defer f.Cleanup()
cmdtesting.WithAlphaEnvs(test.enableAlphas, t, func(t *testing.T) {
cmd := &cobra.Command{}
flags := NewApplyFlags(genericclioptions.NewTestIOStreamsDiscard())
flags.AddFlags(cmd)
cmd.Flags().Set("filename", "unused")
for _, arg := range test.args {
if arg[0] == "namespace" {
f.WithNamespace(arg[1])
} else {
cmd.Flags().Set(arg[0], arg[1])
}
}
o, err := flags.ToOptions(f, cmd, "kubectl", []string{})
if err != nil {
t.Fatalf("unexpected error creating apply options: %s", err)
}
err = o.Validate()
if err == nil {
t.Fatalf("missing expected error for case %d with args %+v", i, test.args)
}
if test.expectedErr != err.Error() {
t.Errorf("expected error %s, got %s", test.expectedErr, err)
}
})
})
}
}
@ -1990,3 +2081,134 @@ func TestDontAllowApplyWithPodGeneratedName(t *testing.T) {
cmd.Flags().Set("dry-run", "client")
cmd.Run(cmd, []string{})
}
func TestApplySetParentValidation(t *testing.T) {
tests := map[string]struct {
applysetFlag string
namespaceFlag string
setup func(*testing.T, *cmdtesting.TestFactory)
expectParentKind string
expectBlankParentNs bool
expectErr string
}{
"parent type must be valid": {
applysetFlag: "doesnotexist/thename",
expectErr: "invalid parent reference \"doesnotexist/thename\": no matches for /, Resource=doesnotexist",
},
"parent name must be present": {
applysetFlag: "secret/",
expectErr: "invalid parent reference \"secret/\": name cannot be blank",
},
"configmap parents are valid": {
applysetFlag: "configmap/thename",
namespaceFlag: "mynamespace",
expectParentKind: "ConfigMap",
},
"secret parents are valid": {
applysetFlag: "secret/thename",
namespaceFlag: "mynamespace",
expectParentKind: "Secret",
},
"plural resource works": {
applysetFlag: "secrets/thename",
namespaceFlag: "mynamespace",
expectParentKind: "Secret",
},
"other namespaced builtin parents types are correctly parsed but invalid": {
applysetFlag: "deployments.apps/thename",
expectParentKind: "Deployment",
expectErr: "[resource \"apps/v1, Resource=deployments\" is not permitted as an ApplySet parent, namespace is required to use namespace-scoped ApplySet]",
},
"namespaced builtin parents with multi-segment groups are correctly parsed but invalid": {
applysetFlag: "priorityclasses.scheduling.k8s.io/thename",
expectParentKind: "PriorityClass",
expectErr: "resource \"scheduling.k8s.io/v1alpha1, Resource=priorityclasses\" is not permitted as an ApplySet parent",
},
"non-namespaced builtin types are correctly parsed but invalid": {
applysetFlag: "namespaces/thename",
expectParentKind: "Namespace",
namespaceFlag: "somenamespace",
expectBlankParentNs: true,
expectErr: "resource \"/v1, Resource=namespaces\" is not permitted as an ApplySet parent",
},
"parent namespace should use the value of the namespace flag": {
applysetFlag: "mysecret",
namespaceFlag: "mynamespace",
expectParentKind: "Secret",
},
"parent namespace should not use the default namespace from ClientConfig": {
applysetFlag: "mysecret",
setup: func(t *testing.T, f *cmdtesting.TestFactory) {
// by default, the value "default" is used for the namespace
// make sure this assumption still holds
ns, overridden, err := f.ToRawKubeConfigLoader().Namespace()
require.NoError(t, err)
require.Falsef(t, overridden, "namespace unexpectedly overridden")
require.Equal(t, "default", ns)
},
expectBlankParentNs: true,
expectParentKind: "Secret",
expectErr: "namespace is required to use namespace-scoped ApplySet",
},
"parent namespace should not use the default namespace from the user's kubeconfig": {
applysetFlag: "mysecret",
setup: func(t *testing.T, f *cmdtesting.TestFactory) {
kubeConfig := clientcmdapi.NewConfig()
kubeConfig.CurrentContext = "default"
kubeConfig.Contexts["default"] = &clientcmdapi.Context{Namespace: "bar"}
clientConfig := clientcmd.NewDefaultClientConfig(*kubeConfig, &clientcmd.ConfigOverrides{
ClusterDefaults: clientcmdapi.Cluster{Server: "http://localhost:8080"}})
f.WithClientConfig(clientConfig)
},
expectBlankParentNs: true,
expectParentKind: "Secret",
expectErr: "namespace is required to use namespace-scoped ApplySet",
},
}
cmdtesting.WithAlphaEnvs([]cmdutil.FeatureGate{cmdutil.ApplySet}, t, func(t *testing.T) {
for name, test := range tests {
t.Run(name, func(t *testing.T) {
cmd := &cobra.Command{}
flags := NewApplyFlags(genericclioptions.NewTestIOStreamsDiscard())
flags.AddFlags(cmd)
cmd.Flags().Set("filename", filenameRC)
cmd.Flags().Set("applyset", test.applysetFlag)
cmd.Flags().Set("prune", "true")
f := cmdtesting.NewTestFactory()
defer f.Cleanup()
var expectedParentNs string
if test.namespaceFlag != "" {
f.WithNamespace(test.namespaceFlag)
if !test.expectBlankParentNs {
expectedParentNs = test.namespaceFlag
}
}
if test.setup != nil {
test.setup(t, f)
}
o, err := flags.ToOptions(f, cmd, "kubectl", []string{})
if test.expectErr == "" {
require.NoError(t, err, "ToOptions error")
} else if err != nil {
require.EqualError(t, err, test.expectErr)
return
}
assert.Equal(t, expectedParentNs, o.ApplySet.ParentRef.Namespace)
assert.Equal(t, test.expectParentKind, o.ApplySet.ParentRef.RESTMapping.GroupVersionKind.Kind)
err = o.Validate()
if test.expectErr != "" {
require.EqualError(t, err, test.expectErr)
} else if err.Error() == "--applyset is not yet supported" {
// TODO: remove this when the feature is complete
} else {
require.NoError(t, err, "Validate error")
}
})
}
})
}

View File

@ -0,0 +1,119 @@
/*
Copyright 2023 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package apply
import (
"fmt"
"strings"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/runtime/schema"
utilerrors "k8s.io/apimachinery/pkg/util/errors"
)
var defaultApplySetParentGVR = schema.GroupVersionResource{Version: "v1", Resource: "secrets"}
// ApplySet tracks the information about an applyset apply/prune
type ApplySet struct {
// ParentRef is the reference to the parent object that is used to track the applyset.
ParentRef *ApplySetParentRef
// resources is the set of all the resources that (might) be part of this applyset.
resources map[schema.GroupVersionResource]struct{}
// namespaces is the set of all namespaces that (might) contain objects that are part of this applyset.
namespaces map[string]struct{}
}
var builtinApplySetParentGVRs = map[schema.GroupVersionResource]bool{
defaultApplySetParentGVR: true,
{Version: "v1", Resource: "configmaps"}: true,
}
// ApplySetParentRef stores object and type meta for the parent object that is used to track the applyset.
type ApplySetParentRef struct {
Name string
Namespace string
RESTMapping *meta.RESTMapping
}
// NewApplySet creates a new ApplySet object from a parent reference in the format [RESOURCE][.GROUP]/NAME
func NewApplySet(parentRefStr string, nsFromFlag string, mapper meta.RESTMapper) (*ApplySet, error) {
parent, err := parentRefFromStr(parentRefStr, mapper)
if err != nil {
return nil, fmt.Errorf("invalid parent reference %q: %w", parentRefStr, err)
}
if parent.IsNamespaced() {
parent.Namespace = nsFromFlag
}
return &ApplySet{
resources: make(map[schema.GroupVersionResource]struct{}),
namespaces: make(map[string]struct{}),
ParentRef: parent,
}, nil
}
// ID is the label value that we are using to identify this applyset.
func (a ApplySet) ID() string {
// TODO: base64(sha256(gknn))
return "placeholder-todo"
}
// Validate imposes restrictions on the parent object that is used to track the applyset.
func (a ApplySet) Validate() error {
var errors []error
// TODO: permit CRDs that have the annotation required by the ApplySet specification
if !builtinApplySetParentGVRs[a.ParentRef.RESTMapping.Resource] {
errors = append(errors, fmt.Errorf("resource %q is not permitted as an ApplySet parent", a.ParentRef.RESTMapping.Resource))
}
if a.ParentRef.IsNamespaced() && a.ParentRef.Namespace == "" {
errors = append(errors, fmt.Errorf("namespace is required to use namespace-scoped ApplySet"))
}
return utilerrors.NewAggregate(errors)
}
func (p *ApplySetParentRef) IsNamespaced() bool {
return p.RESTMapping.Scope.Name() == meta.RESTScopeNameNamespace
}
// parentRefFromStr creates a new ApplySetParentRef from a parent reference in the format [RESOURCE][.GROUP]/NAME
func parentRefFromStr(parentRefStr string, mapper meta.RESTMapper) (*ApplySetParentRef, error) {
var gvr schema.GroupVersionResource
var name string
if groupRes, nameSuffix, hasTypeInfo := strings.Cut(parentRefStr, "/"); hasTypeInfo {
name = nameSuffix
gvr = schema.ParseGroupResource(groupRes).WithVersion("")
} else {
name = parentRefStr
gvr = defaultApplySetParentGVR
}
if name == "" {
return nil, fmt.Errorf("name cannot be blank")
}
gvk, err := mapper.KindFor(gvr)
if err != nil {
return nil, err
}
mapping, err := mapper.RESTMapping(gvk.GroupKind())
if err != nil {
return nil, err
}
return &ApplySetParentRef{Name: name, RESTMapping: mapping}, nil
}

View File

@ -0,0 +1,30 @@
/*
Copyright 2023 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package apply
import "fmt"
type applySetPruner struct {
}
func newApplySetPruner(_ *ApplyOptions) *applySetPruner {
return &applySetPruner{}
}
func (p *applySetPruner) pruneAll() error {
return fmt.Errorf("ApplySet-based pruning is not yet implemented")
}

View File

@ -18,7 +18,6 @@ package explain
import (
"fmt"
"os"
"github.com/spf13/cobra"
@ -82,7 +81,7 @@ func NewExplainOptions(parent string, streams genericclioptions.IOStreams) *Expl
return &ExplainOptions{
IOStreams: streams,
CmdParent: parent,
EnableOpenAPIV3: os.Getenv("KUBECTL_EXPLAIN_OPENAPIV3") == "true",
EnableOpenAPIV3: cmdutil.ExplainOpenapiV3.IsEnabled(),
OutputFormat: "plaintext",
}
}

View File

@ -21,10 +21,12 @@ import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/api/meta"
sptest "k8s.io/apimachinery/pkg/util/strategicpatch/testing"
"k8s.io/cli-runtime/pkg/genericclioptions"
cmdtesting "k8s.io/kubectl/pkg/cmd/testing"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
"k8s.io/kubectl/pkg/util/openapi"
)
@ -150,3 +152,21 @@ func TestExplain(t *testing.T) {
t.Fatalf("expected output should include pod batch/v1beta1")
}
}
func TestAlphaEnablement(t *testing.T) {
alphas := map[cmdutil.FeatureGate]string{
cmdutil.ExplainOpenapiV3: "output",
}
for feature, flag := range alphas {
f := cmdtesting.NewTestFactory()
defer f.Cleanup()
cmd := NewCmdExplain("kubectl", f, genericclioptions.NewTestIOStreamsDiscard())
require.Nil(t, cmd.Flags().Lookup(flag), "flag %q should not be registered without the %q feature enabled", flag, feature)
cmdtesting.WithAlphaEnvs([]cmdutil.FeatureGate{feature}, t, func(t *testing.T) {
cmd := NewCmdExplain("kubectl", f, genericclioptions.NewTestIOStreamsDiscard())
require.NotNil(t, cmd.Flags().Lookup(flag), "flag %q should be registered with the %q feature enabled", flag, feature)
})
}
}

View File

@ -21,8 +21,10 @@ import (
"encoding/json"
"io"
"net/http"
"os"
"testing"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
@ -178,3 +180,18 @@ func InitTestErrorHandler(t *testing.T) {
t.Errorf("Error running command (exit code %d): %s", code, str)
})
}
// WithAlphaEnvs calls func f with the given env-var-based feature gates enabled,
// and then restores the original values of those variables.
func WithAlphaEnvs(features []cmdutil.FeatureGate, t *testing.T, f func(*testing.T)) {
for _, feature := range features {
key := string(feature)
if key != "" {
oldValue := os.Getenv(key)
err := os.Setenv(key, "true")
require.NoError(t, err, "unexpected error setting alpha env")
defer os.Setenv(key, oldValue)
}
}
f(t)
}

View File

@ -422,6 +422,17 @@ func GetPodRunningTimeoutFlag(cmd *cobra.Command) (time.Duration, error) {
return timeout, nil
}
type FeatureGate string
const (
ApplySet FeatureGate = "KUBECTL_APPLYSET"
ExplainOpenapiV3 FeatureGate = "KUBECTL_EXPLAIN_OPENAPIV3"
)
func (f FeatureGate) IsEnabled() bool {
return os.Getenv(string(f)) == "true"
}
func AddValidateFlags(cmd *cobra.Command) {
cmd.Flags().String(
"validate",
@ -499,6 +510,23 @@ func AddLabelSelectorFlagVar(cmd *cobra.Command, p *string) {
cmd.Flags().StringVarP(p, "selector", "l", *p, "Selector (label query) to filter on, supports '=', '==', and '!='.(e.g. -l key1=value1,key2=value2). Matching objects must satisfy all of the specified label constraints.")
}
func AddPruningFlags(cmd *cobra.Command, prune *bool, pruneAllowlist *[]string, pruneWhitelist *[]string, all *bool, applySetRef *string) {
// Flags associated with the original allowlist-based alpha
cmd.Flags().StringArrayVar(pruneAllowlist, "prune-allowlist", *pruneAllowlist, "Overwrite the default allowlist with <group/version/kind> for --prune")
cmd.Flags().StringArrayVar(pruneWhitelist, "prune-whitelist", *pruneWhitelist, "Overwrite the default whitelist with <group/version/kind> for --prune") // TODO: Remove this in kubectl 1.28 or later
_ = cmd.Flags().MarkDeprecated("prune-whitelist", "Use --prune-allowlist instead.")
cmd.Flags().BoolVar(all, "all", *all, "Select all resources in the namespace of the specified resource types.")
// Flags associated with the new ApplySet-based alpha
if ApplySet.IsEnabled() {
cmd.Flags().StringVar(applySetRef, "applyset", *applySetRef, "[alpha] The name of the ApplySet that tracks which resources are being managed, for the purposes of determining what to prune. Live resources that are part of the ApplySet but have been removed from the provided configs will be deleted. Format: [RESOURCE][.GROUP]/NAME. A Secret will be used if no resource or group is specified.")
cmd.Flags().BoolVar(prune, "prune", *prune, "Automatically delete previously applied resource objects that do not appear in the provided configs. For alpha1, use with either -l or --all. For alpha2, use with --applyset.")
} else {
// different docs for the shared --prune flag if only alpha1 is enabled
cmd.Flags().BoolVar(prune, "prune", *prune, "Automatically delete resource objects, that do not appear in the configs and are created by either apply or create --save-config. Should be used with either -l or --all.")
}
}
func AddSubresourceFlags(cmd *cobra.Command, subresource *string, usage string, allowedSubresources ...string) {
cmd.Flags().StringVar(subresource, "subresource", "", fmt.Sprintf("%s Must be one of %v. This flag is alpha and may change in the future.", usage, allowedSubresources))
CheckErr(cmd.RegisterFlagCompletionFunc("subresource", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {