diff --git a/cmd/kubeadm/app/cmd/config.go b/cmd/kubeadm/app/cmd/config.go index 3881321dc61..018421fa74f 100644 --- a/cmd/kubeadm/app/cmd/config.go +++ b/cmd/kubeadm/app/cmd/config.go @@ -76,6 +76,7 @@ func newCmdConfig(out io.Writer) *cobra.Command { kubeConfigFile = cmdutil.GetKubeConfigPath(kubeConfigFile) cmd.AddCommand(newCmdConfigPrint(out)) cmd.AddCommand(newCmdConfigMigrate(out)) + cmd.AddCommand(newCmdConfigValidate(out)) cmd.AddCommand(newCmdConfigImages(out)) return cmd } @@ -272,6 +273,46 @@ func newCmdConfigMigrate(out io.Writer) *cobra.Command { return cmd } +// newCmdConfigValidate returns cobra.Command for the "kubeadm config validate" command +func newCmdConfigValidate(out io.Writer) *cobra.Command { + var cfgPath string + + cmd := &cobra.Command{ + Use: "validate", + Short: "Read a file containing the kubeadm configuration API and report any validation problems", + Long: fmt.Sprintf(dedent.Dedent(` + This command lets you validate a kubeadm configuration API file and report any warnings and errors. + If there are no errors the exit status will be zero, otherwise it will be non-zero. + Any unmarshaling problems such as unknown API fields will trigger errors. Unknown API versions and + fields with invalid values will also trigger errors. Any other errors or warnings may be reported + depending on contents of the input file. + + In this version of kubeadm, the following API versions are supported: + - %s + `), kubeadmapiv1.SchemeGroupVersion), + RunE: func(cmd *cobra.Command, args []string) error { + if len(cfgPath) == 0 { + return errors.Errorf("the --%s flag is mandatory", options.CfgPath) + } + + cfgBytes, err := os.ReadFile(cfgPath) + if err != nil { + return err + } + + if err := configutil.ValidateConfig(cfgBytes); err != nil { + return err + } + fmt.Fprintln(out, "ok") + + return nil + }, + Args: cobra.NoArgs, + } + options.AddConfigFlag(cmd.Flags(), &cfgPath) + return cmd +} + // newCmdConfigImages returns the "kubeadm config images" command func newCmdConfigImages(out io.Writer) *cobra.Command { cmd := &cobra.Command{ diff --git a/cmd/kubeadm/app/cmd/config_test.go b/cmd/kubeadm/app/cmd/config_test.go index 2df6da16daf..920dfb42d0e 100644 --- a/cmd/kubeadm/app/cmd/config_test.go +++ b/cmd/kubeadm/app/cmd/config_test.go @@ -52,6 +52,44 @@ var ( // kubeadm lookup dl.k8s.io to resolve what the latest stable release is dummyKubernetesVersion = constants.MinimumControlPlaneVersion dummyKubernetesVersionStr = dummyKubernetesVersion.String() + + // predefined configuration contents for migration and validation + cfgInvalidSubdomain = []byte(dedent.Dedent(fmt.Sprintf(` +apiVersion: %s +kind: InitConfiguration +nodeRegistration: + criSocket: %s + name: foo bar # not a valid subdomain +`, kubeadmapiv1.SchemeGroupVersion.String(), constants.UnknownCRISocket))) + + cfgUnknownAPI = []byte(dedent.Dedent(fmt.Sprintf(` +apiVersion: foo/bar # not a valid GroupVersion +kind: zzz # not a valid Kind +nodeRegistration: + criSocket: %s +`, constants.UnknownCRISocket))) + + cfgLegacyAPI = []byte(dedent.Dedent(fmt.Sprintf(` +apiVersion: kubeadm.k8s.io/v1beta1 # legacy API +kind: InitConfiguration +nodeRegistration: + criSocket: %s +`, constants.UnknownCRISocket))) + + cfgUnknownField = []byte(dedent.Dedent(fmt.Sprintf(` +apiVersion: %s +kind: InitConfiguration +foo: bar +nodeRegistration: + criSocket: %s +`, kubeadmapiv1.SchemeGroupVersion.String(), constants.UnknownCRISocket))) + + cfgValid = []byte(dedent.Dedent(fmt.Sprintf(` +apiVersion: %s +kind: InitConfiguration +nodeRegistration: + criSocket: %s +`, kubeadmapiv1.SchemeGroupVersion.String(), constants.UnknownCRISocket))) ) func TestNewCmdConfigImagesList(t *testing.T) { @@ -391,30 +429,132 @@ func TestImagesPull(t *testing.T) { } func TestMigrate(t *testing.T) { - cfg := []byte(dedent.Dedent(fmt.Sprintf(` - # This is intentionally testing an old API version. Sometimes this may be the latest version (if no old configs are supported). - apiVersion: %s - kind: InitConfiguration - nodeRegistration: - criSocket: %s - `, kubeadmapiv1.SchemeGroupVersion.String(), constants.UnknownCRISocket))) - configFile, cleanup := tempConfig(t, cfg) + cfgFileInvalidSubdomain, cleanup := tempConfig(t, cfgInvalidSubdomain) + defer cleanup() + cfgFileUnknownAPI, cleanup := tempConfig(t, cfgUnknownAPI) + defer cleanup() + cfgFileLegacyAPI, cleanup := tempConfig(t, cfgLegacyAPI) + defer cleanup() + cfgFileUnknownField, cleanup := tempConfig(t, cfgUnknownField) + defer cleanup() + cfgFileValid, cleanup := tempConfig(t, cfgValid) defer cleanup() - var output bytes.Buffer - command := newCmdConfigMigrate(&output) - if err := command.Flags().Set("old-config", configFile); err != nil { - t.Fatalf("failed to set old-config flag") + testcases := []struct { + name string + cfg string + expectedError bool + }{ + { + name: "invalid subdomain", + cfg: cfgFileInvalidSubdomain, + expectedError: true, + }, + { + name: "unknown API GVK", + cfg: cfgFileUnknownAPI, + expectedError: true, + }, + { + name: "legacy API GVK", + cfg: cfgFileLegacyAPI, + expectedError: true, + }, + { + name: "unknown field", + cfg: cfgFileUnknownField, + expectedError: true, + }, + { + name: "valid", + cfg: cfgFileValid, + expectedError: false, + }, } - newConfigPath := filepath.Join(filepath.Dir(configFile), "new-migrated-config") - if err := command.Flags().Set("new-config", newConfigPath); err != nil { - t.Fatalf("failed to set new-config flag") + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + var output bytes.Buffer + command := newCmdConfigMigrate(&output) + if err := command.Flags().Set("old-config", tc.cfg); err != nil { + t.Fatalf("failed to set old-config flag") + } + newConfigPath := filepath.Join(filepath.Dir(tc.cfg), "new-migrated-config") + if err := command.Flags().Set("new-config", newConfigPath); err != nil { + t.Fatalf("failed to set new-config flag") + } + err := command.RunE(nil, nil) + if (err != nil) != tc.expectedError { + t.Fatalf("Expected error from validate command: %v, got: %v, error: %v", + tc.expectedError, err != nil, err) + } + if err != nil { + return + } + if _, err := configutil.LoadInitConfigurationFromFile(newConfigPath); err != nil { + t.Fatalf("Could not read output back into internal type: %v", err) + } + }) } - if err := command.RunE(nil, nil); err != nil { - t.Fatalf("Error from running the migrate command: %v", err) + +} + +func TestValidate(t *testing.T) { + cfgFileInvalidSubdomain, cleanup := tempConfig(t, cfgInvalidSubdomain) + defer cleanup() + cfgFileUnknownAPI, cleanup := tempConfig(t, cfgUnknownAPI) + defer cleanup() + cfgFileLegacyAPI, cleanup := tempConfig(t, cfgLegacyAPI) + defer cleanup() + cfgFileUnknownField, cleanup := tempConfig(t, cfgUnknownField) + defer cleanup() + cfgFileValid, cleanup := tempConfig(t, cfgValid) + defer cleanup() + + testcases := []struct { + name string + cfg string + expectedError bool + }{ + { + name: "invalid subdomain", + cfg: cfgFileInvalidSubdomain, + expectedError: true, + }, + { + name: "unknown API GVK", + cfg: cfgFileUnknownAPI, + expectedError: true, + }, + { + name: "legacy API GVK", + cfg: cfgFileLegacyAPI, + expectedError: true, + }, + { + name: "unknown field", + cfg: cfgFileUnknownField, + expectedError: true, + }, + { + name: "valid", + cfg: cfgFileValid, + expectedError: false, + }, } - if _, err := configutil.LoadInitConfigurationFromFile(newConfigPath); err != nil { - t.Fatalf("Could not read output back into internal type: %v", err) + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + var output bytes.Buffer + command := newCmdConfigValidate(&output) + if err := command.Flags().Set("config", tc.cfg); err != nil { + t.Fatalf("Failed to set config flag") + } + if err := command.RunE(nil, nil); (err != nil) != tc.expectedError { + t.Fatalf("Expected error from validate command: %v, got: %v, error: %v", + tc.expectedError, err != nil, err) + } + }) } } diff --git a/cmd/kubeadm/app/util/config/common.go b/cmd/kubeadm/app/util/config/common.go index a1902469035..11e54ca6379 100644 --- a/cmd/kubeadm/app/util/config/common.go +++ b/cmd/kubeadm/app/util/config/common.go @@ -37,6 +37,7 @@ import ( kubeadmapi "k8s.io/kubernetes/cmd/kubeadm/app/apis/kubeadm" kubeadmscheme "k8s.io/kubernetes/cmd/kubeadm/app/apis/kubeadm/scheme" kubeadmapiv1 "k8s.io/kubernetes/cmd/kubeadm/app/apis/kubeadm/v1beta3" + "k8s.io/kubernetes/cmd/kubeadm/app/componentconfigs" "k8s.io/kubernetes/cmd/kubeadm/app/constants" kubeadmutil "k8s.io/kubernetes/cmd/kubeadm/app/util" ) @@ -190,8 +191,44 @@ func ChooseAPIServerBindAddress(bindAddress net.IP) (net.IP, error) { return ip, nil } +// validateKnownGVKs takes a list of GVKs and verifies if they are known in kubeadm or component config schemes +func validateKnownGVKs(gvks []schema.GroupVersionKind) error { + var unknown []schema.GroupVersionKind + + schemes := []*runtime.Scheme{ + kubeadmscheme.Scheme, + componentconfigs.Scheme, + } + + for _, gvk := range gvks { + var scheme *runtime.Scheme + + // Skip legacy known GVs so that they don't return errors. + // This makes the function return errors only for GVs that where never known. + if err := validateSupportedVersion(gvk.GroupVersion(), true); err != nil { + continue + } + + for _, s := range schemes { + if _, err := s.New(gvk); err == nil { + scheme = s + break + } + } + if scheme == nil { + unknown = append(unknown, gvk) + } + } + + if len(unknown) > 0 { + return errors.Errorf("unknown configuration APIs: %#v", unknown) + } + + return nil +} + // MigrateOldConfig migrates an old configuration from a byte slice into a new one (returned again as a byte slice). -// Only kubeadm kinds are migrated. Others are silently ignored. +// Only kubeadm kinds are migrated. func MigrateOldConfig(oldConfig []byte) ([]byte, error) { newConfig := [][]byte{} @@ -205,9 +242,13 @@ func MigrateOldConfig(oldConfig []byte) ([]byte, error) { gvks = append(gvks, gvk) } + if err := validateKnownGVKs(gvks); err != nil { + return []byte{}, err + } + // Migrate InitConfiguration and ClusterConfiguration if there are any in the config if kubeadmutil.GroupVersionKindsHasInitConfiguration(gvks...) || kubeadmutil.GroupVersionKindsHasClusterConfiguration(gvks...) { - o, err := documentMapToInitConfiguration(gvkmap, true) + o, err := documentMapToInitConfiguration(gvkmap, true, true) if err != nil { return []byte{}, err } @@ -220,7 +261,7 @@ func MigrateOldConfig(oldConfig []byte) ([]byte, error) { // Migrate JoinConfiguration if there is any if kubeadmutil.GroupVersionKindsHasJoinConfiguration(gvks...) { - o, err := documentMapToJoinConfiguration(gvkmap, true) + o, err := documentMapToJoinConfiguration(gvkmap, true, true) if err != nil { return []byte{}, err } @@ -234,6 +275,40 @@ func MigrateOldConfig(oldConfig []byte) ([]byte, error) { return bytes.Join(newConfig, []byte(constants.YAMLDocumentSeparator)), nil } +// ValidateConfig takes a byte slice containing a kubeadm configuration and performs conversion +// to internal types and validation. +func ValidateConfig(oldConfig []byte) error { + gvkmap, err := kubeadmutil.SplitYAMLDocuments(oldConfig) + if err != nil { + return err + } + + gvks := []schema.GroupVersionKind{} + for gvk := range gvkmap { + gvks = append(gvks, gvk) + } + + if err := validateKnownGVKs(gvks); err != nil { + return err + } + + // Validate InitConfiguration and ClusterConfiguration if there are any in the config + if kubeadmutil.GroupVersionKindsHasInitConfiguration(gvks...) || kubeadmutil.GroupVersionKindsHasClusterConfiguration(gvks...) { + if _, err := documentMapToInitConfiguration(gvkmap, true, true); err != nil { + return err + } + } + + // Validate JoinConfiguration if there is any + if kubeadmutil.GroupVersionKindsHasJoinConfiguration(gvks...) { + if _, err := documentMapToJoinConfiguration(gvkmap, true, true); err != nil { + return err + } + } + + return nil +} + // isKubeadmPrereleaseVersion returns true if the kubeadm version is a pre-release version and // the minimum control plane version is N+2 MINOR version of the given k8sVersion. func isKubeadmPrereleaseVersion(versionInfo *apimachineryversion.Info, k8sVersion, mcpVersion *version.Version) bool { diff --git a/cmd/kubeadm/app/util/config/initconfiguration.go b/cmd/kubeadm/app/util/config/initconfiguration.go index db4c29d7672..ab1312ab7f3 100644 --- a/cmd/kubeadm/app/util/config/initconfiguration.go +++ b/cmd/kubeadm/app/util/config/initconfiguration.go @@ -287,11 +287,11 @@ func BytesToInitConfiguration(b []byte) (*kubeadmapi.InitConfiguration, error) { return nil, err } - return documentMapToInitConfiguration(gvkmap, false) + return documentMapToInitConfiguration(gvkmap, false, false) } // documentMapToInitConfiguration converts a map of GVKs and YAML documents to defaulted and validated configuration object. -func documentMapToInitConfiguration(gvkmap kubeadmapi.DocumentMap, allowDeprecated bool) (*kubeadmapi.InitConfiguration, error) { +func documentMapToInitConfiguration(gvkmap kubeadmapi.DocumentMap, allowDeprecated, strictErrors bool) (*kubeadmapi.InitConfiguration, error) { var initcfg *kubeadmapi.InitConfiguration var clustercfg *kubeadmapi.ClusterConfiguration @@ -303,7 +303,11 @@ func documentMapToInitConfiguration(gvkmap kubeadmapi.DocumentMap, allowDeprecat // verify the validity of the YAML if err := strict.VerifyUnmarshalStrict([]*runtime.Scheme{kubeadmscheme.Scheme, componentconfigs.Scheme}, gvk, fileContent); err != nil { - klog.Warning(err.Error()) + if !strictErrors { + klog.Warning(err.Error()) + } else { + return nil, err + } } if kubeadmutil.GroupVersionKindsHasInitConfiguration(gvk) { diff --git a/cmd/kubeadm/app/util/config/joinconfiguration.go b/cmd/kubeadm/app/util/config/joinconfiguration.go index 7cd9eb419df..0b6e420254b 100644 --- a/cmd/kubeadm/app/util/config/joinconfiguration.go +++ b/cmd/kubeadm/app/util/config/joinconfiguration.go @@ -85,12 +85,12 @@ func LoadJoinConfigurationFromFile(cfgPath string) (*kubeadmapi.JoinConfiguratio return nil, err } - return documentMapToJoinConfiguration(gvkmap, false) + return documentMapToJoinConfiguration(gvkmap, false, false) } // documentMapToJoinConfiguration takes a map between GVKs and YAML documents (as returned by SplitYAMLDocuments), // finds a JoinConfiguration, decodes it, dynamically defaults it and then validates it prior to return. -func documentMapToJoinConfiguration(gvkmap kubeadmapi.DocumentMap, allowDeprecated bool) (*kubeadmapi.JoinConfiguration, error) { +func documentMapToJoinConfiguration(gvkmap kubeadmapi.DocumentMap, allowDeprecated, strictErrors bool) (*kubeadmapi.JoinConfiguration, error) { joinBytes := []byte{} for gvk, bytes := range gvkmap { // not interested in anything other than JoinConfiguration @@ -105,7 +105,11 @@ func documentMapToJoinConfiguration(gvkmap kubeadmapi.DocumentMap, allowDeprecat // verify the validity of the YAML if err := strict.VerifyUnmarshalStrict([]*runtime.Scheme{kubeadmscheme.Scheme}, gvk, bytes); err != nil { - klog.Warning(err.Error()) + if !strictErrors { + klog.Warning(err.Error()) + } else { + return nil, err + } } joinBytes = bytes