diff --git a/cmd/kubeadm/app/apis/kubeadm/validation/validation.go b/cmd/kubeadm/app/apis/kubeadm/validation/validation.go index 0bc24c57398..b91c0849fe5 100644 --- a/cmd/kubeadm/app/apis/kubeadm/validation/validation.go +++ b/cmd/kubeadm/app/apis/kubeadm/validation/validation.go @@ -284,7 +284,7 @@ func ValidateMixedArguments(flag *pflag.FlagSet) error { mixedInvalidFlags := []string{} flag.Visit(func(f *pflag.Flag) { - if f.Name == "config" || strings.HasPrefix(f.Name, "skip-") { + if f.Name == "config" || strings.HasPrefix(f.Name, "skip-") || f.Name == "dry-run" { // "--skip-*" flags can be set with --config return } diff --git a/cmd/kubeadm/app/cmd/init.go b/cmd/kubeadm/app/cmd/init.go index 39f1c8b7adb..dadffef4392 100644 --- a/cmd/kubeadm/app/cmd/init.go +++ b/cmd/kubeadm/app/cmd/init.go @@ -20,6 +20,7 @@ import ( "fmt" "io" "io/ioutil" + "os" "text/template" "time" @@ -27,6 +28,7 @@ import ( "github.com/spf13/cobra" "k8s.io/apimachinery/pkg/runtime" + clientset "k8s.io/client-go/kubernetes" kubeadmapi "k8s.io/kubernetes/cmd/kubeadm/app/apis/kubeadm" kubeadmapiext "k8s.io/kubernetes/cmd/kubeadm/app/apis/kubeadm/v1alpha1" "k8s.io/kubernetes/cmd/kubeadm/app/apis/kubeadm/validation" @@ -85,6 +87,7 @@ func NewCmdInit(out io.Writer) *cobra.Command { var cfgPath string var skipPreFlight bool var skipTokenPrint bool + var dryRun bool cmd := &cobra.Command{ Use: "init", Short: "Run this in order to set up the Kubernetes master", @@ -93,7 +96,7 @@ func NewCmdInit(out io.Writer) *cobra.Command { internalcfg := &kubeadmapi.MasterConfiguration{} api.Scheme.Convert(cfg, internalcfg, nil) - i, err := NewInit(cfgPath, internalcfg, skipPreFlight, skipTokenPrint) + i, err := NewInit(cfgPath, internalcfg, skipPreFlight, skipTokenPrint, dryRun) kubeadmutil.CheckErr(err) kubeadmutil.CheckErr(i.Validate(cmd)) @@ -155,6 +158,11 @@ func NewCmdInit(out io.Writer) *cobra.Command { &skipTokenPrint, "skip-token-print", skipTokenPrint, "Skip printing of the default bootstrap token generated by 'kubeadm init'", ) + // Note: All flags that are not bound to the cfg object should be whitelisted in cmd/kubeadm/app/apis/kubeadm/validation/validation.go + cmd.PersistentFlags().BoolVar( + &dryRun, "dry-run", dryRun, + "Don't apply any changes; just output what would be done", + ) cmd.PersistentFlags().StringVar( &cfg.Token, "token", cfg.Token, @@ -167,7 +175,7 @@ func NewCmdInit(out io.Writer) *cobra.Command { return cmd } -func NewInit(cfgPath string, cfg *kubeadmapi.MasterConfiguration, skipPreFlight, skipTokenPrint bool) (*Init, error) { +func NewInit(cfgPath string, cfg *kubeadmapi.MasterConfiguration, skipPreFlight, skipTokenPrint, dryRun bool) (*Init, error) { fmt.Println("[kubeadm] WARNING: kubeadm is in beta, please do not use it for production clusters.") @@ -209,12 +217,13 @@ func NewInit(cfgPath string, cfg *kubeadmapi.MasterConfiguration, skipPreFlight, fmt.Println("[preflight] Skipping pre-flight checks") } - return &Init{cfg: cfg, skipTokenPrint: skipTokenPrint}, nil + return &Init{cfg: cfg, skipTokenPrint: skipTokenPrint, dryRun: dryRun}, nil } type Init struct { cfg *kubeadmapi.MasterConfiguration skipTokenPrint bool + dryRun bool } // Validate validates configuration passed to "kubeadm init" @@ -255,16 +264,11 @@ func (i *Init) Run(out io.Writer) error { } } - client, err := kubeconfigutil.ClientSetFromFile(kubeadmconstants.GetAdminKubeConfigPath()) + client, err := createClientsetAndOptionallyWaitForReady(i.cfg, i.dryRun) if err != nil { return err } - fmt.Printf("[init] Waiting for the kubelet to boot up the control plane as Static Pods from directory %q\n", kubeadmconstants.GetStaticPodDirectory()) - if err := apiclient.WaitForAPI(client, 30*time.Minute); err != nil { - return err - } - // PHASE 4: Mark the master with the right label/taint if err := markmasterphase.MarkMaster(client, i.cfg.NodeName); err != nil { return err @@ -348,3 +352,25 @@ func (i *Init) Run(out io.Writer) error { return initDoneTempl.Execute(out, ctx) } + +func createClientsetAndOptionallyWaitForReady(cfg *kubeadmapi.MasterConfiguration, dryRun bool) (clientset.Interface, error) { + if dryRun { + // If we're dry-running; we should create a faked client that answers some GETs in order to be able to do the full init flow and just logs the rest of requests + dryRunGetter := apiclient.NewInitDryRunGetter(cfg.NodeName, cfg.Networking.ServiceSubnet) + return apiclient.NewDryRunClient(dryRunGetter, os.Stdout), nil + } + + // If we're acting for real,we should create a connection to the API server and wait for it to come up + client, err := kubeconfigutil.ClientSetFromFile(kubeadmconstants.GetAdminKubeConfigPath()) + if err != nil { + return nil, err + } + + fmt.Printf("[init] Waiting for the kubelet to boot up the control plane as Static Pods from directory %q\n", kubeadmconstants.GetStaticPodDirectory()) + // TODO: Adjust this timeout or start polling the kubelet API + // TODO: Make this timeout more realistic when we do create some more complex logic about the interaction with the kubelet + if err := apiclient.WaitForAPI(client, 30*time.Minute); err != nil { + return nil, err + } + return client, nil +} diff --git a/cmd/kubeadm/app/cmd/phases/markmaster.go b/cmd/kubeadm/app/cmd/phases/markmaster.go index 3cdd00c8d60..5c4e91e2b0c 100644 --- a/cmd/kubeadm/app/cmd/phases/markmaster.go +++ b/cmd/kubeadm/app/cmd/phases/markmaster.go @@ -17,8 +17,6 @@ limitations under the License. package phases import ( - "fmt" - "github.com/spf13/cobra" markmasterphase "k8s.io/kubernetes/cmd/kubeadm/app/phases/markmaster" @@ -41,8 +39,6 @@ func NewCmdMarkMaster() *cobra.Command { kubeadmutil.CheckErr(err) nodeName := args[0] - fmt.Printf("[markmaster] Will mark node %s as master by adding a label and a taint\n", nodeName) - return markmasterphase.MarkMaster(client, nodeName) }, } diff --git a/cmd/kubeadm/app/cmd/token.go b/cmd/kubeadm/app/cmd/token.go index 730356605bb..78a581bf810 100644 --- a/cmd/kubeadm/app/cmd/token.go +++ b/cmd/kubeadm/app/cmd/token.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "io" + "os" "sort" "strings" "text/tabwriter" @@ -36,6 +37,7 @@ import ( kubeadmconstants "k8s.io/kubernetes/cmd/kubeadm/app/constants" tokenphase "k8s.io/kubernetes/cmd/kubeadm/app/phases/bootstraptoken/node" kubeadmutil "k8s.io/kubernetes/cmd/kubeadm/app/util" + "k8s.io/kubernetes/cmd/kubeadm/app/util/apiclient" kubeconfigutil "k8s.io/kubernetes/cmd/kubeadm/app/util/kubeconfig" tokenutil "k8s.io/kubernetes/cmd/kubeadm/app/util/token" "k8s.io/kubernetes/pkg/api" @@ -46,6 +48,7 @@ import ( func NewCmdToken(out io.Writer, errW io.Writer) *cobra.Command { var kubeConfigFile string + var dryRun bool tokenCmd := &cobra.Command{ Use: "token", Short: "Manage bootstrap tokens.", @@ -86,6 +89,8 @@ func NewCmdToken(out io.Writer, errW io.Writer) *cobra.Command { tokenCmd.PersistentFlags().StringVar(&kubeConfigFile, "kubeconfig", "/etc/kubernetes/admin.conf", "The KubeConfig file to use for talking to the cluster") + tokenCmd.PersistentFlags().BoolVar(&dryRun, + "dry-run", dryRun, "Whether to enable dry-run mode or not") var usages []string var tokenDuration time.Duration @@ -106,7 +111,7 @@ func NewCmdToken(out io.Writer, errW io.Writer) *cobra.Command { if len(args) != 0 { token = args[0] } - client, err := kubeconfigutil.ClientSetFromFile(kubeConfigFile) + client, err := getClientset(kubeConfigFile, dryRun) kubeadmutil.CheckErr(err) // TODO: remove this warning in 1.9 @@ -136,7 +141,7 @@ func NewCmdToken(out io.Writer, errW io.Writer) *cobra.Command { This command will list all Bootstrap Tokens for you. `), Run: func(tokenCmd *cobra.Command, args []string) { - client, err := kubeconfigutil.ClientSetFromFile(kubeConfigFile) + client, err := getClientset(kubeConfigFile, dryRun) kubeadmutil.CheckErr(err) err = RunListTokens(out, errW, client) @@ -158,7 +163,7 @@ func NewCmdToken(out io.Writer, errW io.Writer) *cobra.Command { if len(args) < 1 { kubeadmutil.CheckErr(fmt.Errorf("missing subcommand; 'token delete' is missing token of form [%q]", tokenutil.TokenIDRegexpString)) } - client, err := kubeconfigutil.ClientSetFromFile(kubeConfigFile) + client, err := getClientset(kubeConfigFile, dryRun) kubeadmutil.CheckErr(err) err = RunDeleteToken(out, client, args[0]) @@ -338,3 +343,15 @@ func getSecretString(secret *v1.Secret, key string) string { } return "" } + +func getClientset(file string, dryRun bool) (clientset.Interface, error) { + if dryRun { + dryRunGetter, err := apiclient.NewClientBackedDryRunGetterFromKubeconfig(file) + if err != nil { + return nil, err + } + return apiclient.NewDryRunClient(dryRunGetter, os.Stdout), nil + } + client, err := kubeconfigutil.ClientSetFromFile(file) + return client, err +} diff --git a/cmd/kubeadm/app/phases/markmaster/markmaster.go b/cmd/kubeadm/app/phases/markmaster/markmaster.go index 79e817ffc0b..9917f08a92b 100644 --- a/cmd/kubeadm/app/phases/markmaster/markmaster.go +++ b/cmd/kubeadm/app/phases/markmaster/markmaster.go @@ -34,6 +34,8 @@ import ( // MarkMaster taints the master and sets the master label func MarkMaster(client clientset.Interface, masterName string) error { + fmt.Printf("[markmaster] Will mark node %s as master by adding a label and a taint\n", masterName) + // Loop on every falsy return. Return with an error if raised. Exit successfully if true is returned. return wait.Poll(kubeadmconstants.APICallRetryInterval, kubeadmconstants.MarkMasterTimeout, func() (bool, error) { // First get the node object diff --git a/cmd/kubeadm/app/util/apiclient/BUILD b/cmd/kubeadm/app/util/apiclient/BUILD index 02286772b76..d4cfb392fdb 100644 --- a/cmd/kubeadm/app/util/apiclient/BUILD +++ b/cmd/kubeadm/app/util/apiclient/BUILD @@ -3,23 +3,37 @@ package(default_visibility = ["//visibility:public"]) load( "@io_bazel_rules_go//go:def.bzl", "go_library", + "go_test", ) go_library( name = "go_default_library", srcs = [ + "clientbacked_dryrun.go", + "dryrunclient.go", "idempotency.go", + "init_dryrun.go", "wait.go", ], deps = [ "//cmd/kubeadm/app/constants:go_default_library", + "//pkg/registry/core/service/ipallocator:go_default_library", + "//vendor/github.com/ghodss/yaml:go_default_library", "//vendor/k8s.io/api/core/v1:go_default_library", "//vendor/k8s.io/api/extensions/v1beta1:go_default_library", "//vendor/k8s.io/api/rbac/v1beta1:go_default_library", "//vendor/k8s.io/apimachinery/pkg/api/errors:go_default_library", "//vendor/k8s.io/apimachinery/pkg/apis/meta/v1:go_default_library", + "//vendor/k8s.io/apimachinery/pkg/runtime:go_default_library", + "//vendor/k8s.io/apimachinery/pkg/util/intstr:go_default_library", "//vendor/k8s.io/apimachinery/pkg/util/wait:go_default_library", + "//vendor/k8s.io/client-go/dynamic:go_default_library", "//vendor/k8s.io/client-go/kubernetes:go_default_library", + "//vendor/k8s.io/client-go/kubernetes/fake:go_default_library", + "//vendor/k8s.io/client-go/kubernetes/scheme:go_default_library", + "//vendor/k8s.io/client-go/rest:go_default_library", + "//vendor/k8s.io/client-go/testing:go_default_library", + "//vendor/k8s.io/client-go/tools/clientcmd:go_default_library", ], ) @@ -35,3 +49,19 @@ filegroup( srcs = [":package-srcs"], tags = ["automanaged"], ) + +go_test( + name = "go_default_test", + srcs = [ + "dryrunclient_test.go", + "init_dryrun_test.go", + ], + library = ":go_default_library", + deps = [ + "//vendor/k8s.io/api/core/v1:go_default_library", + "//vendor/k8s.io/api/rbac/v1beta1:go_default_library", + "//vendor/k8s.io/apimachinery/pkg/apis/meta/v1:go_default_library", + "//vendor/k8s.io/apimachinery/pkg/runtime/schema:go_default_library", + "//vendor/k8s.io/client-go/testing:go_default_library", + ], +) diff --git a/cmd/kubeadm/app/util/apiclient/clientbacked_dryrun.go b/cmd/kubeadm/app/util/apiclient/clientbacked_dryrun.go new file mode 100644 index 00000000000..6d9266276e9 --- /dev/null +++ b/cmd/kubeadm/app/util/apiclient/clientbacked_dryrun.go @@ -0,0 +1,139 @@ +/* +Copyright 2017 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 apiclient + +import ( + "encoding/json" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + kuberuntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/dynamic" + clientsetscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + core "k8s.io/client-go/testing" + "k8s.io/client-go/tools/clientcmd" +) + +// ClientBackedDryRunGetter implements the DryRunGetter interface for use in NewDryRunClient() and proxies all GET and LIST requests to the backing API server reachable via rest.Config +type ClientBackedDryRunGetter struct { + baseConfig *rest.Config + dynClientPool dynamic.ClientPool +} + +// InitDryRunGetter should implement the DryRunGetter interface +var _ DryRunGetter = &ClientBackedDryRunGetter{} + +// NewClientBackedDryRunGetter creates a new ClientBackedDryRunGetter instance based on the rest.Config object +func NewClientBackedDryRunGetter(config *rest.Config) *ClientBackedDryRunGetter { + return &ClientBackedDryRunGetter{ + baseConfig: config, + dynClientPool: dynamic.NewDynamicClientPool(config), + } +} + +// NewClientBackedDryRunGetterFromKubeconfig creates a new ClientBackedDryRunGetter instance from the given KubeConfig file +func NewClientBackedDryRunGetterFromKubeconfig(file string) (*ClientBackedDryRunGetter, error) { + config, err := clientcmd.LoadFromFile(file) + if err != nil { + return nil, fmt.Errorf("failed to load kubeconfig: %v", err) + } + clientConfig, err := clientcmd.NewDefaultClientConfig(*config, &clientcmd.ConfigOverrides{}).ClientConfig() + if err != nil { + return nil, fmt.Errorf("failed to create API client configuration from kubeconfig: %v", err) + } + return NewClientBackedDryRunGetter(clientConfig), nil +} + +// HandleGetAction handles GET actions to the dryrun clientset this interface supports +func (clg *ClientBackedDryRunGetter) HandleGetAction(action core.GetAction) (bool, runtime.Object, error) { + rc, err := clg.actionToResourceClient(action) + if err != nil { + return true, nil, err + } + + unversionedObj, err := rc.Get(action.GetName(), metav1.GetOptions{}) + // If the unversioned object does not have .apiVersion; the inner object is probably nil + if len(unversionedObj.GetAPIVersion()) == 0 { + return true, nil, apierrors.NewNotFound(action.GetResource().GroupResource(), action.GetName()) + } + newObj, err := decodeUnversionedIntoAPIObject(action, unversionedObj) + if err != nil { + fmt.Printf("error after decode: %v %v\n", unversionedObj, err) + return true, nil, err + } + return true, newObj, err +} + +// HandleListAction handles LIST actions to the dryrun clientset this interface supports +func (clg *ClientBackedDryRunGetter) HandleListAction(action core.ListAction) (bool, runtime.Object, error) { + rc, err := clg.actionToResourceClient(action) + if err != nil { + return true, nil, err + } + + listOpts := metav1.ListOptions{ + LabelSelector: action.GetListRestrictions().Labels.String(), + FieldSelector: action.GetListRestrictions().Fields.String(), + } + + unversionedList, err := rc.List(listOpts) + // If the runtime.Object here is nil, we should return successfully with no result + if unversionedList == nil { + return true, unversionedList, nil + } + newObj, err := decodeUnversionedIntoAPIObject(action, unversionedList) + if err != nil { + fmt.Printf("error after decode: %v %v\n", unversionedList, err) + return true, nil, err + } + return true, newObj, err +} + +// actionToResourceClient returns the ResourceInterface for the given action +// First; the function gets the right API group interface from the resource type. The API group struct behind the interface +// returned may be cached in the dynamic client pool. Then, an APIResource object is constructed so that it can be passed to +// dynamic.Interface's Resource() function, which will give us the final ResourceInterface to query +func (clg *ClientBackedDryRunGetter) actionToResourceClient(action core.Action) (dynamic.ResourceInterface, error) { + dynIface, err := clg.dynClientPool.ClientForGroupVersionResource(action.GetResource()) + if err != nil { + return nil, err + } + + apiResource := &metav1.APIResource{ + Name: action.GetResource().Resource, + Namespaced: action.GetNamespace() != "", + } + + return dynIface.Resource(apiResource, action.GetNamespace()), nil +} + +// decodeUnversionedIntoAPIObject converts the *unversioned.Unversioned object returned from the dynamic client +// to bytes; and then decodes it back _to an external api version (k8s.io/api vs k8s.io/kubernetes/pkg/api*)_ using the normal API machinery +func decodeUnversionedIntoAPIObject(action core.Action, unversionedObj runtime.Object) (runtime.Object, error) { + objBytes, err := json.Marshal(unversionedObj) + if err != nil { + return nil, err + } + newObj, err := kuberuntime.Decode(clientsetscheme.Codecs.UniversalDecoder(action.GetResource().GroupVersion()), objBytes) + if err != nil { + return nil, err + } + return newObj, nil +} diff --git a/cmd/kubeadm/app/util/apiclient/dryrunclient.go b/cmd/kubeadm/app/util/apiclient/dryrunclient.go new file mode 100644 index 00000000000..c306a9c4740 --- /dev/null +++ b/cmd/kubeadm/app/util/apiclient/dryrunclient.go @@ -0,0 +1,237 @@ +/* +Copyright 2017 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 apiclient + +import ( + "bufio" + "bytes" + "fmt" + "io" + "strings" + + "github.com/ghodss/yaml" + "k8s.io/apimachinery/pkg/runtime" + clientset "k8s.io/client-go/kubernetes" + fakeclientset "k8s.io/client-go/kubernetes/fake" + core "k8s.io/client-go/testing" +) + +// DryRunGetter is an interface that must be supplied to the NewDryRunClient function in order to contstruct a fully functional fake dryrun clientset +type DryRunGetter interface { + HandleGetAction(core.GetAction) (bool, runtime.Object, error) + HandleListAction(core.ListAction) (bool, runtime.Object, error) +} + +// MarshalFunc takes care of converting any object to a byte array for displaying the object to the user +type MarshalFunc func(runtime.Object) ([]byte, error) + +// DefaultMarshalFunc is the default MarshalFunc used; uses YAML to print objects to the user +func DefaultMarshalFunc(obj runtime.Object) ([]byte, error) { + b, err := yaml.Marshal(obj) + return b, err +} + +// DryRunClientOptions specifies options to pass to NewDryRunClientWithOpts in order to get a dryrun clientset +type DryRunClientOptions struct { + Writer io.Writer + Getter DryRunGetter + PrependReactors []core.Reactor + AppendReactors []core.Reactor + MarshalFunc MarshalFunc + PrintGETAndLIST bool +} + +// actionWithName is the generic interface for an action that has a name associated with it +// This just makes it easier to catch all actions that has a name; instead of hard-coding all request that has it associated +type actionWithName interface { + core.Action + GetName() string +} + +// actionWithObject is the generic interface for an action that has an object associated with it +// This just makes it easier to catch all actions that has an object; instead of hard-coding all request that has it associated +type actionWithObject interface { + core.Action + GetObject() runtime.Object +} + +// NewDryRunClient is a wrapper for NewDryRunClientWithOpts using some default values +func NewDryRunClient(drg DryRunGetter, w io.Writer) clientset.Interface { + return NewDryRunClientWithOpts(DryRunClientOptions{ + Writer: w, + Getter: drg, + PrependReactors: []core.Reactor{}, + AppendReactors: []core.Reactor{}, + MarshalFunc: DefaultMarshalFunc, + PrintGETAndLIST: false, + }) +} + +// NewDryRunClientWithOpts returns a clientset.Interface that can be used normally for talking to the Kubernetes API. +// This client doesn't apply changes to the backend. The client gets GET/LIST values from the DryRunGetter implementation. +// This client logs all I/O to the writer w in YAML format +func NewDryRunClientWithOpts(opts DryRunClientOptions) clientset.Interface { + // Build a chain of reactors to act like a normal clientset; but log everything's that happening and don't change any state + client := fakeclientset.NewSimpleClientset() + + // Build the chain of reactors. Order matters; first item here will be invoked first on match, then the second one will be evaluted, etc. + defaultReactorChain := []core.Reactor{ + // Log everything that happens. Default the object if it's about to be created/updated so that the logged object is representative. + &core.SimpleReactor{ + Verb: "*", + Resource: "*", + Reaction: func(action core.Action) (bool, runtime.Object, error) { + logDryRunAction(action, opts.Writer, opts.MarshalFunc) + + return false, nil, nil + }, + }, + // Let the DryRunGetter implementation take care of all GET requests. + // The DryRunGetter implementation may call a real API Server behind the scenes or just fake everything + &core.SimpleReactor{ + Verb: "get", + Resource: "*", + Reaction: func(action core.Action) (bool, runtime.Object, error) { + getAction, ok := action.(core.GetAction) + if !ok { + // something's wrong, we can't handle this event + return true, nil, fmt.Errorf("can't cast get reactor event action object to GetAction interface") + } + handled, obj, err := opts.Getter.HandleGetAction(getAction) + + if opts.PrintGETAndLIST { + // Print the marshalled object format with one tab indentation + objBytes, err := opts.MarshalFunc(obj) + if err == nil { + fmt.Println("[dryrun] Returning faked GET response:") + printBytesWithLinePrefix(opts.Writer, objBytes, "\t") + } + } + + return handled, obj, err + }, + }, + // Let the DryRunGetter implementation take care of all GET requests. + // The DryRunGetter implementation may call a real API Server behind the scenes or just fake everything + &core.SimpleReactor{ + Verb: "list", + Resource: "*", + Reaction: func(action core.Action) (bool, runtime.Object, error) { + listAction, ok := action.(core.ListAction) + if !ok { + // something's wrong, we can't handle this event + return true, nil, fmt.Errorf("can't cast list reactor event action object to ListAction interface") + } + handled, objs, err := opts.Getter.HandleListAction(listAction) + + if opts.PrintGETAndLIST { + // Print the marshalled object format with one tab indentation + objBytes, err := opts.MarshalFunc(objs) + if err == nil { + fmt.Println("[dryrun] Returning faked LIST response:") + printBytesWithLinePrefix(opts.Writer, objBytes, "\t") + } + } + + return handled, objs, err + }, + }, + // For the verbs that modify anything on the server; just return the object if present and exit successfully + &core.SimpleReactor{ + Verb: "create", + Resource: "*", + Reaction: successfulModificationReactorFunc, + }, + &core.SimpleReactor{ + Verb: "update", + Resource: "*", + Reaction: successfulModificationReactorFunc, + }, + &core.SimpleReactor{ + Verb: "delete", + Resource: "*", + Reaction: successfulModificationReactorFunc, + }, + &core.SimpleReactor{ + Verb: "delete-collection", + Resource: "*", + Reaction: successfulModificationReactorFunc, + }, + &core.SimpleReactor{ + Verb: "patch", + Resource: "*", + Reaction: successfulModificationReactorFunc, + }, + } + + // The chain of reactors will look like this: + // opts.PrependReactors | defaultReactorChain | opts.AppendReactors | client.Fake.ReactionChain (default reactors for the fake clientset) + fullReactorChain := append(opts.PrependReactors, defaultReactorChain...) + fullReactorChain = append(fullReactorChain, opts.AppendReactors...) + + // Prepend the reaction chain with our reactors. Important, these MUST be prepended; not appended due to how the fake clientset works by default + client.Fake.ReactionChain = append(fullReactorChain, client.Fake.ReactionChain...) + return client +} + +// successfulModificationReactorFunc is a no-op that just returns the POSTed/PUTed value if present; but does nothing to edit any backing data store. +func successfulModificationReactorFunc(action core.Action) (bool, runtime.Object, error) { + objAction, ok := action.(actionWithObject) + if ok { + return true, objAction.GetObject(), nil + } + return true, nil, nil +} + +// logDryRunAction logs the action that was recorded by the "catch-all" (*,*) reactor and tells the user what would have happened in an user-friendly way +func logDryRunAction(action core.Action, w io.Writer, marshalFunc MarshalFunc) { + + group := action.GetResource().Group + if len(group) == 0 { + group = "core" + } + fmt.Fprintf(w, "[dryrun] Would perform action %s on resource %q in API group \"%s/%s\"\n", strings.ToUpper(action.GetVerb()), action.GetResource().Resource, group, action.GetResource().Version) + + namedAction, ok := action.(actionWithName) + if ok { + fmt.Fprintf(w, "[dryrun] Resource name: %q\n", namedAction.GetName()) + } + + objAction, ok := action.(actionWithObject) + if ok && objAction.GetObject() != nil { + // Print the marshalled object with a tab indentation + objBytes, err := marshalFunc(objAction.GetObject()) + if err == nil { + fmt.Println("[dryrun] Attached object:") + printBytesWithLinePrefix(w, objBytes, "\t") + } + } + + patchAction, ok := action.(core.PatchAction) + if ok { + // Replace all occurences of \" with a simple " when printing + fmt.Fprintf(w, "[dryrun] Attached patch:\n\t%s\n", strings.Replace(string(patchAction.GetPatch()), `\"`, `"`, -1)) + } +} + +// printBytesWithLinePrefix prints objBytes to writer w with linePrefix in the beginning of every line +func printBytesWithLinePrefix(w io.Writer, objBytes []byte, linePrefix string) { + scanner := bufio.NewScanner(bytes.NewReader(objBytes)) + for scanner.Scan() { + fmt.Fprintf(w, "%s%s\n", linePrefix, scanner.Text()) + } +} diff --git a/cmd/kubeadm/app/util/apiclient/dryrunclient_test.go b/cmd/kubeadm/app/util/apiclient/dryrunclient_test.go new file mode 100644 index 00000000000..246bc7a78d6 --- /dev/null +++ b/cmd/kubeadm/app/util/apiclient/dryrunclient_test.go @@ -0,0 +1,100 @@ +/* +Copyright 2017 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 apiclient + +import ( + "bytes" + "testing" + + "k8s.io/api/core/v1" + rbac "k8s.io/api/rbac/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + core "k8s.io/client-go/testing" +) + +func TestLogDryRunAction(t *testing.T) { + var tests = []struct { + action core.Action + expectedBytes []byte + buf *bytes.Buffer + }{ + { + action: core.NewGetAction(schema.GroupVersionResource{Version: "v1", Resource: "services"}, "default", "kubernetes"), + expectedBytes: []byte(`[dryrun] Would perform action GET on resource "services" in API group "core/v1" +[dryrun] Resource name: "kubernetes" +`), + }, + { + action: core.NewRootGetAction(schema.GroupVersionResource{Group: rbac.GroupName, Version: rbac.SchemeGroupVersion.Version, Resource: "clusterrolebindings"}, "system:node"), + expectedBytes: []byte(`[dryrun] Would perform action GET on resource "clusterrolebindings" in API group "rbac.authorization.k8s.io/v1beta1" +[dryrun] Resource name: "system:node" +`), + }, + { + action: core.NewListAction(schema.GroupVersionResource{Version: "v1", Resource: "services"}, schema.GroupVersionKind{Version: "v1", Kind: "Service"}, "default", metav1.ListOptions{}), + expectedBytes: []byte(`[dryrun] Would perform action LIST on resource "services" in API group "core/v1" +`), + }, + { + action: core.NewCreateAction(schema.GroupVersionResource{Version: "v1", Resource: "services"}, "default", &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo", + }, + Spec: v1.ServiceSpec{ + ClusterIP: "1.1.1.1", + }, + }), + expectedBytes: []byte(`[dryrun] Would perform action CREATE on resource "services" in API group "core/v1" + metadata: + creationTimestamp: null + name: foo + spec: + clusterIP: 1.1.1.1 + status: + loadBalancer: {} +`), + }, + { + action: core.NewPatchAction(schema.GroupVersionResource{Version: "v1", Resource: "nodes"}, "default", "my-node", []byte(`{"spec":{"taints":[{"key": "foo", "value": "bar"}]}}`)), + expectedBytes: []byte(`[dryrun] Would perform action PATCH on resource "nodes" in API group "core/v1" +[dryrun] Resource name: "my-node" +[dryrun] Attached patch: + {"spec":{"taints":[{"key": "foo", "value": "bar"}]}} +`), + }, + { + action: core.NewDeleteAction(schema.GroupVersionResource{Version: "v1", Resource: "pods"}, "default", "my-pod"), + expectedBytes: []byte(`[dryrun] Would perform action DELETE on resource "pods" in API group "core/v1" +[dryrun] Resource name: "my-pod" +`), + }, + } + for _, rt := range tests { + rt.buf = bytes.NewBufferString("") + logDryRunAction(rt.action, rt.buf, DefaultMarshalFunc) + actualBytes := rt.buf.Bytes() + + if !bytes.Equal(actualBytes, rt.expectedBytes) { + t.Errorf( + "failed LogDryRunAction:\n\texpected bytes: %q\n\t actual: %q", + rt.expectedBytes, + actualBytes, + ) + } + } +} diff --git a/cmd/kubeadm/app/util/apiclient/init_dryrun.go b/cmd/kubeadm/app/util/apiclient/init_dryrun.go new file mode 100644 index 00000000000..ab31a986654 --- /dev/null +++ b/cmd/kubeadm/app/util/apiclient/init_dryrun.go @@ -0,0 +1,162 @@ +/* +Copyright 2017 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 apiclient + +import ( + "fmt" + "net" + "strings" + + "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/intstr" + core "k8s.io/client-go/testing" + "k8s.io/kubernetes/cmd/kubeadm/app/constants" + "k8s.io/kubernetes/pkg/registry/core/service/ipallocator" +) + +// InitDryRunGetter implements the DryRunGetter interface and can be used to GET/LIST values in the dryrun fake clientset +// Need to handle these routes in a special manner: +// - GET /default/services/kubernetes -- must return a valid Service +// - GET /clusterrolebindings/system:nodes -- can safely return a NotFound error +// - GET /kube-system/secrets/bootstrap-token-* -- can safely return a NotFound error +// - GET /nodes/ -- must return a valid Node +// - ...all other, unknown GETs/LISTs will be logged +type InitDryRunGetter struct { + masterName string + serviceSubnet string +} + +// InitDryRunGetter should implement the DryRunGetter interface +var _ DryRunGetter = &InitDryRunGetter{} + +// NewInitDryRunGetter creates a new instance of the InitDryRunGetter struct +func NewInitDryRunGetter(masterName string, serviceSubnet string) *InitDryRunGetter { + return &InitDryRunGetter{ + masterName: masterName, + serviceSubnet: serviceSubnet, + } +} + +// HandleGetAction handles GET actions to the dryrun clientset this interface supports +func (idr *InitDryRunGetter) HandleGetAction(action core.GetAction) (bool, runtime.Object, error) { + funcs := []func(core.GetAction) (bool, runtime.Object, error){ + idr.handleKubernetesService, + idr.handleGetNode, + idr.handleSystemNodesClusterRoleBinding, + idr.handleGetBootstrapToken, + } + for _, f := range funcs { + handled, obj, err := f(action) + if handled { + return handled, obj, err + } + } + + return false, nil, nil +} + +// HandleListAction handles GET actions to the dryrun clientset this interface supports. +// Currently there are no known LIST calls during kubeadm init this code has to take care of. +func (idr *InitDryRunGetter) HandleListAction(action core.ListAction) (bool, runtime.Object, error) { + return false, nil, nil +} + +// handleKubernetesService returns a faked Kubernetes service in order to be able to continue running kubeadm init. +// The kube-dns addon code GETs the kubernetes service in order to extract the service subnet +func (idr *InitDryRunGetter) handleKubernetesService(action core.GetAction) (bool, runtime.Object, error) { + if action.GetName() != "kubernetes" || action.GetNamespace() != metav1.NamespaceDefault || action.GetResource().Resource != "services" { + // We can't handle this event + return false, nil, nil + } + + _, svcSubnet, err := net.ParseCIDR(idr.serviceSubnet) + if err != nil { + return true, nil, fmt.Errorf("error parsing CIDR %q: %v", idr.serviceSubnet, err) + } + + internalAPIServerVirtualIP, err := ipallocator.GetIndexedIP(svcSubnet, 1) + if err != nil { + return true, nil, fmt.Errorf("unable to get first IP address from the given CIDR (%s): %v", svcSubnet.String(), err) + } + + // The only used field of this Service object is the ClusterIP, which kube-dns uses to calculate its own IP + return true, &v1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kubernetes", + Namespace: metav1.NamespaceDefault, + Labels: map[string]string{ + "component": "apiserver", + "provider": "kubernetes", + }, + }, + Spec: v1.ServiceSpec{ + ClusterIP: internalAPIServerVirtualIP.String(), + Ports: []v1.ServicePort{ + { + Name: "https", + Port: 443, + TargetPort: intstr.FromInt(6443), + }, + }, + }, + }, nil +} + +// handleGetNode returns a fake node object for the purpose of moving kubeadm init forwards. +func (idr *InitDryRunGetter) handleGetNode(action core.GetAction) (bool, runtime.Object, error) { + if action.GetName() != idr.masterName || action.GetResource().Resource != "nodes" { + // We can't handle this event + return false, nil, nil + } + + return true, &v1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: idr.masterName, + Labels: map[string]string{ + "kubernetes.io/hostname": idr.masterName, + }, + }, + Spec: v1.NodeSpec{ + ExternalID: idr.masterName, + }, + }, nil +} + +// handleSystemNodesClusterRoleBinding handles the GET call to the system:nodes clusterrolebinding +func (idr *InitDryRunGetter) handleSystemNodesClusterRoleBinding(action core.GetAction) (bool, runtime.Object, error) { + if action.GetName() != constants.NodesClusterRoleBinding || action.GetResource().Resource != "clusterrolebindings" { + // We can't handle this event + return false, nil, nil + } + // We can safely return a NotFound error here as the code will just proceed normally and don't care about modifying this clusterrolebinding + // This can only happen on an upgrade; and in that case the ClientBackedDryRunGetter impl will be used + return true, nil, apierrors.NewNotFound(action.GetResource().GroupResource(), "clusterrolebinding not found") +} + +// handleGetBootstrapToken handles the case where kubeadm init creates the default token; and the token code GETs the +// bootstrap token secret first in order to check if it already exists +func (idr *InitDryRunGetter) handleGetBootstrapToken(action core.GetAction) (bool, runtime.Object, error) { + if !strings.HasPrefix(action.GetName(), "bootstrap-token-") || action.GetNamespace() != metav1.NamespaceSystem || action.GetResource().Resource != "secrets" { + // We can't handle this event + return false, nil, nil + } + // We can safely return a NotFound error here as the code will just proceed normally and create the Bootstrap Token + return true, nil, apierrors.NewNotFound(action.GetResource().GroupResource(), "secret not found") +} diff --git a/cmd/kubeadm/app/util/apiclient/init_dryrun_test.go b/cmd/kubeadm/app/util/apiclient/init_dryrun_test.go new file mode 100644 index 00000000000..9e681b52187 --- /dev/null +++ b/cmd/kubeadm/app/util/apiclient/init_dryrun_test.go @@ -0,0 +1,126 @@ +/* +Copyright 2017 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 apiclient + +import ( + "bytes" + "encoding/json" + "testing" + + rbac "k8s.io/api/rbac/v1beta1" + "k8s.io/apimachinery/pkg/runtime/schema" + core "k8s.io/client-go/testing" +) + +func TestHandleGetAction(t *testing.T) { + masterName := "master-foo" + serviceSubnet := "10.96.0.1/12" + idr := NewInitDryRunGetter(masterName, serviceSubnet) + + var tests = []struct { + action core.GetActionImpl + expectedHandled bool + expectedObjectJSON []byte + expectedErr bool + }{ + { + action: core.NewGetAction(schema.GroupVersionResource{Version: "v1", Resource: "services"}, "default", "kubernetes"), + expectedHandled: true, + expectedObjectJSON: []byte(`{"metadata":{"name":"kubernetes","namespace":"default","creationTimestamp":null,"labels":{"component":"apiserver","provider":"kubernetes"}},"spec":{"ports":[{"name":"https","port":443,"targetPort":6443}],"clusterIP":"10.96.0.1"},"status":{"loadBalancer":{}}}`), + expectedErr: false, + }, + { + action: core.NewRootGetAction(schema.GroupVersionResource{Version: "v1", Resource: "nodes"}, masterName), + expectedHandled: true, + expectedObjectJSON: []byte(`{"metadata":{"name":"master-foo","creationTimestamp":null,"labels":{"kubernetes.io/hostname":"master-foo"}},"spec":{"externalID":"master-foo"},"status":{"daemonEndpoints":{"kubeletEndpoint":{"Port":0}},"nodeInfo":{"machineID":"","systemUUID":"","bootID":"","kernelVersion":"","osImage":"","containerRuntimeVersion":"","kubeletVersion":"","kubeProxyVersion":"","operatingSystem":"","architecture":""}}}`), + expectedErr: false, + }, + { + action: core.NewRootGetAction(schema.GroupVersionResource{Group: rbac.GroupName, Version: rbac.SchemeGroupVersion.Version, Resource: "clusterrolebindings"}, "system:node"), + expectedHandled: true, + expectedObjectJSON: []byte(``), + expectedErr: true, // we expect a NotFound error here + }, + { + action: core.NewGetAction(schema.GroupVersionResource{Version: "v1", Resource: "secrets"}, "kube-system", "bootstrap-token-abcdef"), + expectedHandled: true, + expectedObjectJSON: []byte(``), + expectedErr: true, // we expect a NotFound error here + }, + { // an ask for a kubernetes service in the _kube-system_ ns should not be answered + action: core.NewGetAction(schema.GroupVersionResource{Version: "v1", Resource: "services"}, "kube-system", "kubernetes"), + expectedHandled: false, + expectedObjectJSON: []byte(``), + expectedErr: false, + }, + { // an ask for an other service than kubernetes should not be answered + action: core.NewGetAction(schema.GroupVersionResource{Version: "v1", Resource: "services"}, "default", "my-other-service"), + expectedHandled: false, + expectedObjectJSON: []byte(``), + expectedErr: false, + }, + { // an ask for an other node than the master should not be answered + action: core.NewRootGetAction(schema.GroupVersionResource{Version: "v1", Resource: "nodes"}, "other-node"), + expectedHandled: false, + expectedObjectJSON: []byte(``), + expectedErr: false, + }, + { // an ask for a secret in any other ns than kube-system should not be answered + action: core.NewGetAction(schema.GroupVersionResource{Version: "v1", Resource: "secrets"}, "default", "bootstrap-token-abcdef"), + expectedHandled: false, + expectedObjectJSON: []byte(``), + expectedErr: false, + }, + } + for _, rt := range tests { + handled, obj, actualErr := idr.HandleGetAction(rt.action) + objBytes := []byte(``) + if obj != nil { + var err error + objBytes, err = json.Marshal(obj) + if err != nil { + t.Fatalf("couldn't marshal returned object") + } + } + + if handled != rt.expectedHandled { + t.Errorf( + "failed HandleGetAction:\n\texpected handled: %t\n\t actual: %t %v", + rt.expectedHandled, + handled, + rt.action, + ) + } + + if !bytes.Equal(objBytes, rt.expectedObjectJSON) { + t.Errorf( + "failed HandleGetAction:\n\texpected object: %q\n\t actual: %q", + rt.expectedObjectJSON, + objBytes, + ) + } + + if (actualErr != nil) != rt.expectedErr { + t.Errorf( + "failed HandleGetAction:\n\texpected error: %t\n\t actual: %t %v", + rt.expectedErr, + (actualErr != nil), + rt.action, + ) + } + } +} diff --git a/staging/src/k8s.io/client-go/testing/actions.go b/staging/src/k8s.io/client-go/testing/actions.go index 12a2ecf95b0..8633a81dec5 100644 --- a/staging/src/k8s.io/client-go/testing/actions.go +++ b/staging/src/k8s.io/client-go/testing/actions.go @@ -319,6 +319,17 @@ type DeleteAction interface { GetName() string } +type DeleteCollectionAction interface { + Action + GetListRestrictions() ListRestrictions +} + +type PatchAction interface { + Action + GetName() string + GetPatch() []byte +} + type WatchAction interface { Action GetWatchRestrictions() WatchRestrictions