diff --git a/pkg/config/config.go b/pkg/config/config.go index 5850807..6db3437 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -48,8 +48,10 @@ func LoadConfig(configPath string) (*Config, error) { return nil, fmt.Errorf("config file path is required") } - // Set up viper - v := viper.New() + // Use a custom key delimiter to avoid conflicts with dots in map keys. + // Kubernetes labels (e.g. "kubernetes.io/nodeReady") contain dots that + // viper's default "." delimiter would misinterpret as nested keys. + v := viper.NewWithOptions(viper.KeyDelimiter("::")) v.SetConfigType("json") v.AutomaticEnv() v.SetEnvPrefix(envPrefix) @@ -69,7 +71,7 @@ func LoadConfig(configPath string) (*Config, error) { // Track if managedIdentity was explicitly set in config // This is necessary because viper unmarshals empty JSON objects {} as nil pointers // Using viper.IsSet() correctly detects if the key was present in the config file - config.isMIExplicitlySet = v.IsSet("azure.managedIdentity") + config.isMIExplicitlySet = v.IsSet("azure::managedIdentity") // Set defaults for any missing values config.SetDefaults() diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 4c858fe..eb780f5 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "sort" "strings" "testing" ) @@ -1069,6 +1070,145 @@ func TestAuthenticationMethodValidation(t *testing.T) { } } +func TestLoadConfigWithDottedLabels(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + configJSON string + expectedLabels map[string]string + }{ + { + name: "labels with dotted keys are preserved", + configJSON: `{ + "azure": { + "subscriptionId": "12345678-1234-1234-1234-123456789012", + "tenantId": "12345678-1234-1234-1234-123456789012", + "cloud": "AzurePublicCloud", + "bootstrapToken": { "token": "abcdef.0123456789abcdef" }, + "targetCluster": { + "resourceId": "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster", + "location": "eastus" + } + }, + "node": { + "labels": { + "kubernetes.io/nodeReady": "true", + "node.kubernetes.io/instance-type": "Standard_D2s_v3" + }, + "kubelet": { + "serverURL": "https://test-cluster.hcp.eastus.azmk8s.io:443", + "caCertData": "LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0t" + } + } + }`, + expectedLabels: map[string]string{ + "kubernetes.io/nodeready": "true", + "node.kubernetes.io/instance-type": "Standard_D2s_v3", + "kubernetes.azure.com/managed": "false", // default label + }, + }, + { + name: "labels without dots still work", + configJSON: `{ + "azure": { + "subscriptionId": "12345678-1234-1234-1234-123456789012", + "tenantId": "12345678-1234-1234-1234-123456789012", + "cloud": "AzurePublicCloud", + "bootstrapToken": { "token": "abcdef.0123456789abcdef" }, + "targetCluster": { + "resourceId": "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster", + "location": "eastus" + } + }, + "node": { + "labels": { + "env": "production", + "team": "platform" + }, + "kubelet": { + "serverURL": "https://test-cluster.hcp.eastus.azmk8s.io:443", + "caCertData": "LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0t" + } + } + }`, + expectedLabels: map[string]string{ + "env": "production", + "team": "platform", + "kubernetes.azure.com/managed": "false", + }, + }, + { + name: "mixed dotted and simple labels", + configJSON: `{ + "azure": { + "subscriptionId": "12345678-1234-1234-1234-123456789012", + "tenantId": "12345678-1234-1234-1234-123456789012", + "cloud": "AzurePublicCloud", + "bootstrapToken": { "token": "abcdef.0123456789abcdef" }, + "targetCluster": { + "resourceId": "/subscriptions/12345678-1234-1234-1234-123456789012/resourceGroups/test-rg/providers/Microsoft.ContainerService/managedClusters/test-cluster", + "location": "eastus" + } + }, + "node": { + "labels": { + "env": "staging", + "topology.kubernetes.io/zone": "eastus-1", + "disktype": "ssd" + }, + "kubelet": { + "serverURL": "https://test-cluster.hcp.eastus.azmk8s.io:443", + "caCertData": "LS0tLS1CRUdJTi1DRVJUSUZJQ0FURS0tLS0t" + } + } + }`, + expectedLabels: map[string]string{ + "env": "staging", + "topology.kubernetes.io/zone": "eastus-1", + "disktype": "ssd", + "kubernetes.azure.com/managed": "false", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + configFile := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(configFile, []byte(tt.configJSON), 0o600); err != nil { + t.Fatalf("Failed to write test config file: %v", err) + } + + config, err := LoadConfig(configFile) + if err != nil { + t.Fatalf("LoadConfig() unexpected error: %v", err) + } + + // Verify all expected labels are present with correct values + for key, expectedVal := range tt.expectedLabels { + gotVal, ok := config.Node.Labels[key] + if !ok { + t.Errorf("expected label %q not found in config.Node.Labels (got keys: %v)", key, labelKeys(config.Node.Labels)) + } else if gotVal != expectedVal { + t.Errorf("label %q = %q, want %q", key, gotVal, expectedVal) + } + } + }) + } +} + +// labelKeys returns the keys of a map for diagnostic output. +func labelKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + func TestIsBootstrapTokenConfigured(t *testing.T) { tests := []struct { name string