Repository: kubernetes/kubernetes (language: go)

Issue:
kubelet config file, resolverConfig option not used
<!-- Please use this template while reporting a bug and provide as much info as possible. Not doing so may result in your bug not being addressed in a timely manner. Thanks!

If the matter is security related, please disclose it privately via https://kubernetes.io/security/
-->


**What happened**:

When using kubelet config file, `resolverConfig` option is not used by kubelet.

**What you expected to happen**:

kubelet config option `resolverConfig` is used to set resolver configuration file by kubelet.

**How to reproduce it (as minimally and precisely as possible)**:

kubelet config file:
```
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
[...]
resolverConfig: /var/lib/kubelet-config/resolv.conf
```

During kubelet start, default `/etc/resolv.conf` is used:
```
Jun 26 13:02:53 dev-k8s-worker-r3 kubelet[30137]: I0626 13:02:53.550870   30137 flags.go:33] FLAG: --resolv-conf="/etc/resolv.conf"
```

I confirmed it examining `resolv.conf` inside container, content is taken from node `/etc/resolv.conf` instead of `/var/lib/kubelet-config/resolv.conf`.

On the other hand when using deprecated kubelet parameter `--resolv-conf=/var/lib/kubelet-config/resolv.conf` everything works fine, kubelet is using proper path:

```
Jun 26 13:06:49 dev-k8s-worker-r3 kubelet[6501]: I0626 13:06:49.235306    6501 flags.go:33] FLAG: --resolv-conf="/var/lib/kubelet-config/resolv.conf"
```

**Environment**:
- Kubernetes version (use `kubectl version`):
```
Client Version: version.Info{Major:"1", Minor:"14", GitVersion:"v1.14.3", GitCommit:"5e53fd6bc17c0dec8434817e69b04a25d8ae0ff0", GitTreeState:"clean", BuildDate:"2019-06-06T01:44:30Z", GoVersion:"go1.12.5", Compiler:"gc", Platform:"linux/amd64"}
Server Version: version.Info{Major:"1", Minor:"14", GitVersion:"v1.14.3", GitCommit:"5e53fd6bc17c0dec8434817e69b04a25d8ae0ff0", GitTreeState:"clean", BuildDate:"2019-06-06T01:36:19Z", GoVersion:"go1.12.5", Compiler:"gc", Platform:"linux/amd64"}
```
- Cloud provider or hardware configuration: bare-metal
- OS (e.g: `cat /etc/os-release`): Ubuntu 18.04.1
- Kernel (e.g. `uname -a`):
```
Linux dev-k8s-deployer-r1 4.15.0-42-generic #45-Ubuntu SMP Thu Nov 15 19:32:57 UTC 2018 x86_64 x86_64 x86_64 GNU/Linux
```



Discussion / hints:
/sig node
/assign

@zouyee: Closing this issue.

<details>

In response to [this](https://github.com/kubernetes/kubernetes/issues/79421#issuecomment-507043271):

>/close


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository.
</details>
/reopen
@zouyee: Reopened this issue.

<details>

In response to [this](https://github.com/kubernetes/kubernetes/issues/79421#issuecomment-508028754):

>/reopen


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository.
</details>
Issues go stale after 90d of inactivity.
Mark the issue as fresh with `/remove-lifecycle stale`.
Stale issues rot after an additional 30d of inactivity and eventually close.

If this issue is safe to close now please do so with `/close`.

Send feedback to sig-testing, kubernetes/test-infra and/or [fejta](https://github.com/fejta).
/lifecycle stale
Stale issues rot after 30d of inactivity.
Mark the issue as fresh with `/remove-lifecycle rotten`.
Rotten issues close after an additional 30d of inactivity.

If this issue is safe to close now please do so with `/close`.

Send feedback to sig-testing, kubernetes/test-infra and/or [fejta](https://github.com/fejta).
/lifecycle rotten
Rotten issues close after 30d of inactivity.
Reopen the issue with `/reopen`.
Mark the issue as fresh with `/remove-lifecycle rotten`.

Send feedback to sig-testing, kubernetes/test-infra and/or [fejta](https://github.com/fejta).
/close
@fejta-bot: Closing this issue.

<details>

In response to [this](https://github.com/kubernetes/kubernetes/issues/79421#issuecomment-586565595):

>Rotten issues close after 30d of inactivity.
>Reopen the issue with `/reopen`.
>Mark the issue as fresh with `/remove-lifecycle rotten`.
>
>Send feedback to sig-testing, kubernetes/test-infra and/or [fejta](https://github.com/fejta).
>/close


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository.
</details>
This is still present. The issue is caused by a mismatch in the json field name between the internal and public structs:
* https://github.com/kubernetes/kubernetes/blob/d902351c991a68fa76de9935a485afeb1f780c11/pkg/kubelet/apis/config/types.go#L281
https://github.com/kubernetes/kubernetes/blob/d902351c991a68fa76de9935a485afeb1f780c11/staging/src/k8s.io/kubelet/config/v1beta1/types.go#L448

The kubelet unmarshals the config file as a `kubeletconfiginternal.KubeletConfiguration`:
* https://github.com/kubernetes/kubernetes/blob/d902351c991a68fa76de9935a485afeb1f780c11/cmd/kubelet/app/server.go#L312

This ends up ignoring the `resolvConf` field, due to lack of `json:"resolvConf,omitempty"` tag on the ResolverConfig internal struct.

The docs also confirm that this field should be in the config file as `resolvConf`:
https://github.com/kubernetes/website/blob/debcd63ee4389432ab78aec9927371919e3d7ee4/content/en/docs/reference/config-api/kubelet-config.v1beta1.md?plain=1#L907-L915

If you put it in the config file as `resolverConfig` it will work, but this is definitely a bug.


cc @mattjmcnaughton @mtaufen since I see you were tagged on a misguided previous attempt to fix this.
/reopen
/sig node
@aojea: Reopened this issue.

<details>

In response to [this](https://github.com/kubernetes/kubernetes/issues/79421#issuecomment-2199327522):

>/reopen
>/sig node


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>
I understand the motivation here, as the json field doesn't match the CLI name. But since it can be set with `resolvConf` in the kubelet configuration, I think this is okay. Maybe we need a documentation update to make clearer this distinction. What would folks here like us to do about this?

/triage needs-information
@haircommander you have it backwards, it needs to be `resolverConfig` NOT `resolvConf` in the kubelet configuration file in order to be loaded.

I would be against a docs fix, it needs to be fixed in the actual struct tags. The current behavior is broken if you try to generate a KubeletConfig from code since the field is inconsistently tagged across the public and private types, as demonstrated above. You have to do dumb things like this:

```golang
import (
  kubeletconfig "k8s.io/kubelet/config/v1beta1"
)

func writeKubeletConfig(path string, config *kubeletconfig.KubeletConfiguration) error {
	b, err := yaml.Marshal(config)
	if err != nil {
		return err
	}

	// replace resolvConf with resolverConfig
	// ref: https://github.com/kubernetes/kubernetes/issues/79421
	b = bytes.ReplaceAll(b, []byte("resolvConf: "), []byte("resolverConfig: "))
	return os.WriteFile(filepath.Join(path, "kubelet.conf"), b, 0600)
}
```


I see, yeah that's not great. I don't think the fix will be easy, we may need to create a new kubelet config type, or duplicate the field for a period..

/triage accepted
/priority backlog
/remove-triage needs-information
> or duplicate the field for a period

That seems like probably the best way to do that. A little ugly but shouldn't be too invasive.
We can make a v1beta2 with a different field name or JSON name, but you shouldn't really change it within v1beta1.  Configs are APIs.

Whichever direction we choose, something changes.
@thockin I think the problem is here https://github.com/kubernetes/kubernetes/pull/121193/files#diff-5167bfceb87d4b085e13f6f6b01b55cecb505ab9e2f3289dd1fcc437a98d761b

It is not converting the read file to the internal type, hence it ends keeping the external type keys not doing the conversion, and the patch is not doing the expected.

It seems the motivation is to avoid the problem with the default values https://github.com/kubernetes/kubernetes/pull/121193#discussion_r1377881069 , my understanding is that the dropin files are supposed to use only the configured values and leave the empty ones as non-existent.

cc: @liggitt 



I'm confused about the issue... I don't think the comment at https://github.com/kubernetes/kubernetes/issues/79421#issuecomment-2248608624 is correct

In general, JSON tags only exist on the versioned types (as intended), and do not need to be identical to the struct field names. Is kubelet config merging doing something unusual that requires internal / versioned types to have json tags or perfectly match the struct fields? If so, that's a bug in that feature and should be fixed, not a bug with this field.
[mergeKubeletConfigurations](https://github.com/kubernetes/kubernetes/blob/master/cmd/kubelet/app/server.go#L317) is buggy... it is JSON-marshaling the internal config (which is not right), then merging with versioned JSON from drop-in files, then decoding that back into the internal config.

All serialized merging has to happen in the same versioned config, to/from versioned config API types, not internal ones.
@brandond, are you only seeing this when using `--config-dir`?
The discussion starting at https://github.com/kubernetes/kubernetes/issues/79421#issuecomment-2198462046 is the same as https://github.com/kubernetes/kubernetes/issues/126630 and is due to the bug in [mergeKubeletConfigurations](https://github.com/kubernetes/kubernetes/blob/master/cmd/kubelet/app/server.go#L317), which only is triggered when the `--config-dir` option is given to kubelet.

Fix patch:
diff --git a/cmd/kubelet/app/server.go b/cmd/kubelet/app/server.go
index 90ea87b1ddadc..5e2300ddbb851 100644
--- a/cmd/kubelet/app/server.go
+++ b/cmd/kubelet/app/server.go
@@ -42,6 +42,7 @@ import (
 	"google.golang.org/grpc/codes"
 	"google.golang.org/grpc/status"
 	jsonpatch "gopkg.in/evanphx/json-patch.v4"
+
 	"k8s.io/klog/v2"
 	"k8s.io/mount-utils"
 
@@ -51,9 +52,11 @@ import (
 	semconv "go.opentelemetry.io/otel/semconv/v1.12.0"
 	oteltrace "go.opentelemetry.io/otel/trace"
 	noopoteltrace "go.opentelemetry.io/otel/trace/noop"
+
 	v1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/api/resource"
 	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/types"
 	utilnet "k8s.io/apimachinery/pkg/util/net"
 	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
@@ -94,6 +97,7 @@ import (
 	"k8s.io/kubernetes/pkg/kubelet"
 	kubeletconfiginternal "k8s.io/kubernetes/pkg/kubelet/apis/config"
 	kubeletscheme "k8s.io/kubernetes/pkg/kubelet/apis/config/scheme"
+	kubeletconfigv1beta1conversion "k8s.io/kubernetes/pkg/kubelet/apis/config/v1beta1"
 	kubeletconfigvalidation "k8s.io/kubernetes/pkg/kubelet/apis/config/validation"
 	"k8s.io/kubernetes/pkg/kubelet/cadvisor"
 	kubeletcertificate "k8s.io/kubernetes/pkg/kubelet/certificate"
@@ -316,7 +320,15 @@ is checked every 20 seconds (also configurable with a flag).`,
 // potentially overriding the previous values.
 func mergeKubeletConfigurations(kubeletConfig *kubeletconfiginternal.KubeletConfiguration, kubeletDropInConfigDir string) error {
 	const dropinFileExtension = ".conf"
-	baseKubeletConfigJSON, err := json.Marshal(kubeletConfig)
+
+	// TODO: move the "internal --> versioned --> merge --> default --> internal" operation inside the loop,
+	// and use the version of each drop-in file as the versioned target once config files can be in versions other than just v1beta1
+	versionedConfig := &kubeletconfigv1beta1.KubeletConfiguration{}
+	if err := kubeletconfigv1beta1conversion.Convert_config_KubeletConfiguration_To_v1beta1_KubeletConfiguration(kubeletConfig, versionedConfig, nil); err != nil {
+		return err
+	}
+
+	baseKubeletConfigJSON, err := json.Marshal(versionedConfig)
 	if err != nil {
 		return fmt.Errorf("failed to marshal base config: %w", err)
 	}
@@ -326,10 +338,17 @@ func mergeKubeletConfigurations(kubeletConfig *kubeletconfiginternal.KubeletConf
 			return err
 		}
 		if !info.IsDir() && filepath.Ext(info.Name()) == dropinFileExtension {
-			dropinConfigJSON, err := loadDropinConfigFileIntoJSON(path)
+			dropinConfigJSON, gvk, err := loadDropinConfigFileIntoJSON(path)
 			if err != nil {
 				return fmt.Errorf("failed to load kubelet dropin file, path: %s, error: %w", path, err)
 			}
+			if gvk == nil || gvk.Empty() {
+				return fmt.Errorf("failed to load kubelet dropin file, path: %s, no apiVersion/kind", path)
+			}
+			// TODO: expand this once more than a single kubelet config version exists
+			if *gvk != kubeletconfigv1beta1.SchemeGroupVersion.WithKind("KubeletConfiguration") {
+				return fmt.Errorf("failed to load kubelet dropin file, path: %s, unknown apiVersion/kind: %v", path, gvk.String())
+			}
 			mergedConfigJSON, err := jsonpatch.MergePatch(baseKubeletConfigJSON, dropinConfigJSON)
 			if err != nil {
 				return fmt.Errorf("failed to merge drop-in and current config: %w", err)
@@ -341,9 +360,18 @@ func mergeKubeletConfigurations(kubeletConfig *kubeletconfiginternal.KubeletConf
 		return fmt.Errorf("failed to walk through kubelet dropin directory %q: %w", kubeletDropInConfigDir, err)
 	}
 
-	if err := json.Unmarshal(baseKubeletConfigJSON, kubeletConfig); err != nil {
+	// reset versioned config and decode
+	versionedConfig = &kubeletconfigv1beta1.KubeletConfiguration{}
+	if err := json.Unmarshal(baseKubeletConfigJSON, versionedConfig); err != nil {
 		return fmt.Errorf("failed to unmarshal merged JSON into kubelet configuration: %w", err)
 	}
+	// apply defaulting after decoding
+	kubeletconfigv1beta1conversion.SetDefaults_KubeletConfiguration(versionedConfig)
+	// convert back to internal config
+	if err := kubeletconfigv1beta1conversion.Convert_v1beta1_KubeletConfiguration_To_config_KubeletConfiguration(versionedConfig, kubeletConfig, nil); err != nil {
+		return fmt.Errorf("failed to convert merged config to internal kubelet configuration: %w", err)
+	}
+
 	return nil
 }
 
@@ -423,16 +451,16 @@ func loadConfigFile(name string) (*kubeletconfiginternal.KubeletConfiguration, e
 	return kc, err
 }
 
-func loadDropinConfigFileIntoJSON(name string) ([]byte, error) {
+func loadDropinConfigFileIntoJSON(name string) ([]byte, *schema.GroupVersionKind, error) {
 	const errFmt = "failed to load drop-in kubelet config file %s, error %v"
 	// compute absolute path based on current working dir
 	kubeletConfigFile, err := filepath.Abs(name)
 	if err != nil {
-		return nil, fmt.Errorf(errFmt, name, err)
+		return nil, nil, fmt.Errorf(errFmt, name, err)
 	}
 	loader, err := configfiles.NewFsLoader(&utilfs.DefaultFs{}, kubeletConfigFile)
 	if err != nil {
-		return nil, fmt.Errorf(errFmt, name, err)
+		return nil, nil, fmt.Errorf(errFmt, name, err)
 	}
 	return loader.LoadIntoJSON()
 }
diff --git a/pkg/kubelet/kubeletconfig/configfiles/configfiles.go b/pkg/kubelet/kubeletconfig/configfiles/configfiles.go
index 4e8fd8ea0210f..38d446b23cdf4 100644
--- a/pkg/kubelet/kubeletconfig/configfiles/configfiles.go
+++ b/pkg/kubelet/kubeletconfig/configfiles/configfiles.go
@@ -20,6 +20,7 @@ import (
 	"fmt"
 	"path/filepath"
 
+	"k8s.io/apimachinery/pkg/runtime/schema"
 	"k8s.io/apimachinery/pkg/runtime/serializer"
 	kubeletconfig "k8s.io/kubernetes/pkg/kubelet/apis/config"
 	kubeletscheme "k8s.io/kubernetes/pkg/kubelet/apis/config/scheme"
@@ -33,7 +34,7 @@ type Loader interface {
 	Load() (*kubeletconfig.KubeletConfiguration, error)
 	// LoadIntoJSON loads and returns the KubeletConfiguration from the storage layer, or an error if a configuration could not be
 	// loaded. It returns the configuration as a JSON byte slice
-	LoadIntoJSON() ([]byte, error)
+	LoadIntoJSON() ([]byte, *schema.GroupVersionKind, error)
 }
 
 // fsLoader loads configuration from `configDir`
@@ -81,15 +82,15 @@ func (loader *fsLoader) Load() (*kubeletconfig.KubeletConfiguration, error) {
 	return kc, nil
 }
 
-func (loader *fsLoader) LoadIntoJSON() ([]byte, error) {
+func (loader *fsLoader) LoadIntoJSON() ([]byte, *schema.GroupVersionKind, error) {
 	data, err := loader.fs.ReadFile(loader.kubeletFile)
 	if err != nil {
-		return nil, fmt.Errorf("failed to read drop-in kubelet config file %q, error: %v", loader.kubeletFile, err)
+		return nil, nil, fmt.Errorf("failed to read drop-in kubelet config file %q, error: %w", loader.kubeletFile, err)
 	}
 
 	// no configuration is an error, some parameters are required
 	if len(data) == 0 {
-		return nil, fmt.Errorf("kubelet config file %q was empty", loader.kubeletFile)
+		return nil, nil, fmt.Errorf("kubelet config file %q was empty", loader.kubeletFile)
 	}
 
 	return utilcodec.DecodeKubeletConfigurationIntoJSON(loader.kubeletCodecs, data)
diff --git a/pkg/kubelet/kubeletconfig/util/codec/codec.go b/pkg/kubelet/kubeletconfig/util/codec/codec.go
index ba4b41cfd85f0..7bced679cd664 100644
--- a/pkg/kubelet/kubeletconfig/util/codec/codec.go
+++ b/pkg/kubelet/kubeletconfig/util/codec/codec.go
@@ -109,14 +109,15 @@ func DecodeKubeletConfiguration(kubeletCodecs *serializer.CodecFactory, data []b
 }
 
 // DecodeKubeletConfigurationIntoJSON decodes a serialized KubeletConfiguration to the internal type.
-func DecodeKubeletConfigurationIntoJSON(kubeletCodecs *serializer.CodecFactory, data []byte) ([]byte, error) {
+func DecodeKubeletConfigurationIntoJSON(kubeletCodecs *serializer.CodecFactory, data []byte) ([]byte, *schema.GroupVersionKind, error) {
 	// The UniversalDecoder runs defaulting and returns the internal type by default.
-	obj, _, err := kubeletCodecs.UniversalDecoder().Decode(data, nil, &unstructured.Unstructured{})
+	obj, gvk, err := kubeletCodecs.UniversalDecoder().Decode(data, nil, &unstructured.Unstructured{})
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
 	objT := obj.(*unstructured.Unstructured)
 
-	return json.Marshal(objT.Object)
+	jsonData, err := json.Marshal(objT.Object)
+	return jsonData, gvk, err
 }


Test patch:
diff --git a/cmd/kubelet/app/server_test.go b/cmd/kubelet/app/server_test.go
index 3513c5fc7ed4f..c3a0d24f161e9 100644
--- a/cmd/kubelet/app/server_test.go
+++ b/cmd/kubelet/app/server_test.go
@@ -25,6 +25,7 @@ import (
 
 	"github.com/stretchr/testify/require"
 	"gopkg.in/yaml.v2"
+
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/kubernetes/cmd/kubelet/app/options"
 	kubeletconfiginternal "k8s.io/kubernetes/pkg/kubelet/apis/config"
@@ -80,6 +81,7 @@ func TestMergeKubeletConfigurations(t *testing.T) {
 		overwrittenConfigFields map[string]interface{}
 		cliArgs                 []string
 		name                    string
+		expectMergeError        string
 	}{
 		{
 			kubeletConfig: &kubeletconfiginternal.KubeletConfiguration{
@@ -222,6 +224,55 @@ readOnlyPort: 10255
 			},
 			name: "cli args override kubelet.conf",
 		},
+		{
+			kubeletConfig: &kubeletconfiginternal.KubeletConfiguration{
+				ResolverConfig: "original",
+				Port:           123,
+				ReadOnlyPort:   234,
+			},
+			dropin1: `
+apiVersion: kubelet.config.k8s.io/v1beta1
+kind: KubeletConfiguration
+resolvConf: dropin1
+`,
+			dropin2: `
+apiVersion: kubelet.config.k8s.io/v1beta1
+kind: KubeletConfiguration
+port: 0
+`,
+			overwrittenConfigFields: map[string]interface{}{
+				"ResolverConfig": "dropin1",    // overridden by dropin1
+				"Port":           int32(10250), // reset to 0 by dropin2, then re-defaulted
+				"ReadOnlyPort":   int32(234),   // preserved from original config
+			},
+			name: "json conversion is correct",
+		},
+		{
+			kubeletConfig: &kubeletconfiginternal.KubeletConfiguration{},
+			dropin1: `
+apiVersion: kubelet.config.k8s.io/v1beta2
+kind: KubeletConfiguration
+`,
+			name:             "invalid drop-in apiVersion",
+			expectMergeError: `unknown apiVersion/kind: kubelet.config.k8s.io/v1beta2, Kind=KubeletConfiguration`,
+		},
+		{
+			kubeletConfig: &kubeletconfiginternal.KubeletConfiguration{},
+			dropin1: `
+apiVersion: kubelet.config.k8s.io/v1beta1
+kind: KubeletConfiguration2
+`,
+			name:             "invalid drop-in kind",
+			expectMergeError: `unknown apiVersion/kind: kubelet.config.k8s.io/v1beta1, Kind=KubeletConfiguration2`,
+		},
+		{
+			kubeletConfig: &kubeletconfiginternal.KubeletConfiguration{},
+			dropin1: `
+port: 123
+`,
+			name:             "empty drop-in apiVersion/kind",
+			expectMergeError: `'Kind' is missing`,
+		},
 	}
 
 	for _, test := range testCases {
@@ -248,15 +299,24 @@ readOnlyPort: 10255
 				err := os.Mkdir(kubeletConfDir, 0755)
 				require.NoError(t, err, "Failed to create kubelet.conf.d directory")
 
-				err = os.WriteFile(filepath.Join(kubeletConfDir, "10-kubelet.conf"), []byte(test.dropin1), 0644)
-				require.NoError(t, err, "failed to create config from a yaml file")
+				if len(test.dropin1) > 0 {
+					err = os.WriteFile(filepath.Join(kubeletConfDir, "10-kubelet.conf"), []byte(test.dropin1), 0644)
+					require.NoError(t, err, "failed to create config from a yaml file")
+				}
 
-				err = os.WriteFile(filepath.Join(kubeletConfDir, "20-kubelet.conf"), []byte(test.dropin2), 0644)
-				require.NoError(t, err, "failed to create config from a yaml file")
+				if len(test.dropin2) > 0 {
+					err = os.WriteFile(filepath.Join(kubeletConfDir, "20-kubelet.conf"), []byte(test.dropin2), 0644)
+					require.NoError(t, err, "failed to create config from a yaml file")
+				}
 
 				// Merge the kubelet configurations
 				err = mergeKubeletConfigurations(kubeletConfig, kubeletConfDir)
-				require.NoError(t, err, "failed to merge kubelet drop-in configs")
+				if test.expectMergeError == "" {
+					require.NoError(t, err, "failed to merge kubelet drop-in configs")
+				} else {
+					require.Error(t, err)
+					require.Contains(t, err.Error(), test.expectMergeError)
+				}
 			}
 
 			// Use kubelet config flag precedence