Skip to content

kubeadm: add the "config validate" subcommand #118013

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions cmd/kubeadm/app/cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to print something if the validation is okay, nothing output to stdout now if everything is good.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps, we should. i'd like to get more opinions on this. the exit code 0 / 1 may be sufficient.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like there are no comments on this. my preference is to not print on stdout on "OK", similarly to some go tools.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If making it like the dry-run, we can print a result for each configuration in the file

[root@daocloud ~]# ./kubeadm-dev  config  validate --config kubeadmcfg.yaml
InitConfiguration validated
ClusterConfiguration validated
Bar unknown

This would be much clear if there are multi items in the configuration files.

Or, we can just print OK/Validated/Done when there is no error/warning. This is also good.

Copy link
Member Author

@neolit123 neolit123 May 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually, there is no clean way to detect if a warning was printed. it just goes to stderr from klog. non warnings go there too.

printing per validated config is possible but it will complicate the plumbing of functions a bit. we don't want to print the same messages during normal init/join...but the backend functions are the same.

i will check what options work well.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated to just print "ok" if validation has no errors.

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{
Expand Down
178 changes: 159 additions & 19 deletions cmd/kubeadm/app/cmd/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
})
}
}

Expand Down
81 changes: 78 additions & 3 deletions cmd/kubeadm/app/util/config/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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{}

Expand All @@ -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
}
Expand All @@ -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
}
Expand All @@ -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 {
Expand Down
10 changes: 7 additions & 3 deletions cmd/kubeadm/app/util/config/initconfiguration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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) {
Expand Down
Loading