From c2ab387bd8ea02b200478fdd39cf805c535b240d Mon Sep 17 00:00:00 2001 From: Franco Date: Wed, 13 May 2026 10:21:35 -0300 Subject: [PATCH 1/4] Support new TKA values schema, add better teleportVersionOverride updates --- CHANGELOG.md | 3 + go.mod | 2 +- internal/controller/cluster_controller.go | 24 +++- .../controller/cluster_controller_test.go | 24 ++++ internal/pkg/key/key.go | 40 +++++- internal/pkg/key/key_test.go | 41 ++++++ internal/pkg/teleport/configmap.go | 85 ++++++++--- internal/pkg/teleport/configmap_test.go | 136 ++++++++++++++++++ internal/pkg/test/matchers.go | 6 +- internal/pkg/test/resources.go | 19 ++- 10 files changed, 353 insertions(+), 27 deletions(-) create mode 100644 internal/pkg/key/key_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 67bd673b..ab496cf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `io.giantswarm.application.audience` and `io.giantswarm.application.managed` chart annotations for Backstage visibility. +- Emit `teleport-kube-agent` config map values nested under the `teleport-kube-agent` key when the configured app version is newer than v0.10.8, and migrate existing flat config maps on reconcile. ### Changed + +- Reconcile loop now refreshes `teleportVersionOverride` in the teleport-kube-agent config map whenever the operator's `teleportVersion` changes, even when the join token is still valid. ## [0.12.4] - 2026-01-30 diff --git a/go.mod b/go.mod index 2f973f71..20f8a0eb 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/giantswarm/teleport-operator go 1.25.9 require ( + github.com/Masterminds/semver/v3 v3.4.0 github.com/giantswarm/apiextensions-application v0.6.2 github.com/giantswarm/microerror v0.4.1 github.com/go-logr/logr v1.4.3 @@ -21,7 +22,6 @@ require ( ) require ( - github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/beevik/etree v1.6.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect diff --git a/internal/controller/cluster_controller.go b/internal/controller/cluster_controller.go index f19df8eb..44227673 100644 --- a/internal/controller/cluster_controller.go +++ b/internal/controller/cluster_controller.go @@ -237,7 +237,19 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct if err != nil { return ctrl.Result{}, microerror.Mask(err) } - if !tokenValid { + + currentTeleportVersion, err := r.Teleport.GetTeleportVersionFromConfigMap(configMap) + if err != nil { + return ctrl.Result{}, microerror.Mask(err) + } + layoutUpToDate, err := r.Teleport.IsConfigMapLayoutUpToDate(configMap) + if err != nil { + return ctrl.Result{}, microerror.Mask(err) + } + versionDrifted := currentTeleportVersion != r.Teleport.Config.TeleportVersion + + switch { + case !tokenValid: newToken, err := r.Teleport.GenerateToken(ctx, registerName, roles) if err != nil { return ctrl.Result{}, microerror.Mask(err) @@ -246,7 +258,15 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct return ctrl.Result{}, microerror.Mask(err) } log.Info("Updated config map with new teleport join token", "configMapName", configMap.GetName(), "roles", roles) - } else { + case versionDrifted, !layoutUpToDate: + if err := r.Teleport.UpdateConfigMap(ctx, log, r.Client, configMap, token, roles); err != nil { + return ctrl.Result{}, microerror.Mask(err) + } + log.Info("Updated config map to align teleport version and values layout", + "configMapName", configMap.GetName(), + "teleportVersion", r.Teleport.Config.TeleportVersion, + "nestedValues", key.UsesNestedKubeAgentValues(r.Teleport.Config.AppVersion)) + default: log.Info("ConfigMap has valid teleport join token", "configMapName", configMap.GetName(), "roles", roles) } } diff --git a/internal/controller/cluster_controller_test.go b/internal/controller/cluster_controller_test.go index 7ac58cbe..ee104b85 100644 --- a/internal/controller/cluster_controller_test.go +++ b/internal/controller/cluster_controller_test.go @@ -160,6 +160,24 @@ func Test_ClusterController(t *testing.T) { expectedConfigMap: test.NewConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.NewTokenName, []string{key.RoleKube, key.RoleApp}), expectedRoles: []string{key.RoleKube, key.RoleApp}, }, + { + name: "case 6.5: Rewrite configmap when teleportVersionOverride drifts even if token is valid", + namespace: test.NamespaceName, + token: test.TokenName, + config: newConfigWithTeleportVersion(test.TeleportVersionNew), + identity: newIdentity(test.LastReadValue), + tokens: []teleportTypes.ProvisionToken{ + test.NewToken(test.TokenName, test.ClusterName, []string{key.RoleKube}), + test.NewToken(test.TokenName, test.ClusterName, []string{key.RoleNode}), + }, + cluster: test.NewCluster(test.ClusterName, test.NamespaceName, []string{key.TeleportOperatorFinalizer}, time.Time{}), + secret: test.NewSecret(test.ClusterName, test.NamespaceName, test.TokenName), + configMap: test.NewConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{key.RoleKube}), + expectedCluster: test.NewCluster(test.ClusterName, test.NamespaceName, []string{key.TeleportOperatorFinalizer}, time.Time{}), + expectedSecret: test.NewSecret(test.ClusterName, test.NamespaceName, test.TokenName), + expectedConfigMap: test.NewConfigMapWithTeleportVersion(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, test.TeleportVersionNew, []string{key.RoleKube}), + expectedRoles: []string{key.RoleKube}, + }, { name: "case 6: Return an error in case reconnection to Teleport fails after the credentials are rotated", namespace: test.NamespaceName, @@ -323,6 +341,12 @@ func newConfig() *config.Config { } } +func newConfigWithTeleportVersion(teleportVersion string) *config.Config { + cfg := newConfig() + cfg.TeleportVersion = teleportVersion + return cfg +} + func newIdentity(lastRead time.Time) *config.IdentityConfig { return &config.IdentityConfig{ IdentityFile: test.IdentityFileValue, diff --git a/internal/pkg/key/key.go b/internal/pkg/key/key.go index 0174fd6a..9430f67e 100644 --- a/internal/pkg/key/key.go +++ b/internal/pkg/key/key.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "github.com/Masterminds/semver/v3" "github.com/gravitational/teleport/api/types" ) @@ -33,6 +34,15 @@ const ( RoleKube = "kube" RoleApp = "app" RoleNode = "node" + + // TeleportKubeAgentValuesKey is the top-level key under which + // teleport-kube-agent chart versions newer than v0.10.8 expect their + // values to be nested. + TeleportKubeAgentValuesKey = "teleport-kube-agent" + + // teleportKubeAgentNestedSinceVersion is the threshold above which the + // chart switched to nested values. + teleportKubeAgentNestedSinceVersion = "0.10.8" ) func ParseRoles(s string) ([]string, error) { @@ -97,7 +107,20 @@ func GetAppName(clusterName string, appName string) string { return fmt.Sprintf("%s-%s", clusterName, appName) } -func GetConfigmapDataFromTemplate(authToken string, proxyAddr string, kubeClusterName string, teleportVersion string, roles []string) string { +// UsesNestedKubeAgentValues reports whether the teleport-kube-agent chart +// version expects its values nested under the `teleport-kube-agent` key. +// Versions strictly greater than v0.10.8 use the nested layout. An +// unparseable version is treated as the legacy (flat) layout. +func UsesNestedKubeAgentValues(appVersion string) bool { + v, err := semver.NewVersion(strings.TrimPrefix(appVersion, "v")) + if err != nil { + return false + } + threshold := semver.MustParse(teleportKubeAgentNestedSinceVersion) + return v.GreaterThan(threshold) +} + +func GetConfigmapDataFromTemplate(authToken string, proxyAddr string, kubeClusterName string, teleportVersion string, roles []string, appVersion string) string { dataTpl := `roles: "%s" authToken: "%s" proxyAddr: "%s" @@ -108,7 +131,20 @@ kubeClusterName: "%s" dataTpl = fmt.Sprintf("%steleportVersionOverride: %q", dataTpl, teleportVersion) } - return fmt.Sprintf(dataTpl, RolesToString(roles), authToken, proxyAddr, kubeClusterName) + body := fmt.Sprintf(dataTpl, RolesToString(roles), authToken, proxyAddr, kubeClusterName) + if !UsesNestedKubeAgentValues(appVersion) { + return body + } + + var nested strings.Builder + nested.WriteString(TeleportKubeAgentValuesKey) + nested.WriteString(":\n") + for line := range strings.SplitSeq(strings.TrimRight(body, "\n"), "\n") { + nested.WriteString(" ") + nested.WriteString(line) + nested.WriteString("\n") + } + return nested.String() } func GetTbotConfigmapDataFromTemplate(kubeClusterName string, clusterName string) string { diff --git a/internal/pkg/key/key_test.go b/internal/pkg/key/key_test.go new file mode 100644 index 00000000..3f1d336f --- /dev/null +++ b/internal/pkg/key/key_test.go @@ -0,0 +1,41 @@ +package key + +import "testing" + +func TestUsesNestedKubeAgentValues(t *testing.T) { + cases := []struct { + version string + nested bool + }{ + {"", false}, + {"not-a-version", false}, + {"0.3.0", false}, + {"0.10.8", false}, + {"v0.10.8", false}, + {"0.10.9", true}, + {"0.11.0", true}, + {"v0.11.0", true}, + {"1.0.0", true}, + } + for _, c := range cases { + t.Run(c.version, func(t *testing.T) { + if got := UsesNestedKubeAgentValues(c.version); got != c.nested { + t.Fatalf("UsesNestedKubeAgentValues(%q) = %v, want %v", c.version, got, c.nested) + } + }) + } +} + +func TestGetConfigmapDataFromTemplate_NestedWhenNewer(t *testing.T) { + data := GetConfigmapDataFromTemplate("tok", "proxy:443", "kube", "16.0.0", []string{"kube", "app"}, "0.11.0") + if want := "teleport-kube-agent:\n"; data[:len(want)] != want { + t.Fatalf("expected nested layout, got:\n%s", data) + } +} + +func TestGetConfigmapDataFromTemplate_FlatWhenOlder(t *testing.T) { + data := GetConfigmapDataFromTemplate("tok", "proxy:443", "kube", "16.0.0", []string{"kube", "app"}, "0.10.8") + if want := "roles:"; data[:len(want)] != want { + t.Fatalf("expected flat layout, got:\n%s", data) + } +} diff --git a/internal/pkg/teleport/configmap.go b/internal/pkg/teleport/configmap.go index 44271395..bdaf4db5 100644 --- a/internal/pkg/teleport/configmap.go +++ b/internal/pkg/teleport/configmap.go @@ -50,22 +50,64 @@ func (t *Teleport) GetTbotConfigMap(ctx context.Context, ctrlClient client.Clien } func (t *Teleport) GetTokenFromConfigMap(ctx context.Context, configMap *corev1.ConfigMap) (string, error) { + valuesYaml, err := parseConfigMapValues(configMap) + if err != nil { + return "", err + } + + token, ok := valuesYaml["authToken"].(string) + if !ok { + return "", microerror.Mask(fmt.Errorf("malformed ConfigMap: key `authToken` not found")) + } + + return token, nil +} + +// GetTeleportVersionFromConfigMap returns the teleportVersionOverride +// currently stored in the ConfigMap, or an empty string if not set. +func (t *Teleport) GetTeleportVersionFromConfigMap(configMap *corev1.ConfigMap) (string, error) { + valuesYaml, err := parseConfigMapValues(configMap) + if err != nil { + return "", err + } + + version, _ := valuesYaml["teleportVersionOverride"].(string) + return version, nil +} + +// IsConfigMapLayoutUpToDate reports whether the ConfigMap's top-level shape +// matches what the configured AppVersion expects (nested under +// `teleport-kube-agent` vs. flat at the root). +func (t *Teleport) IsConfigMapLayoutUpToDate(configMap *corev1.ConfigMap) (bool, error) { valuesBytes, ok := configMap.Data["values"] if !ok { - return "", microerror.Mask(fmt.Errorf("malformed ConfigMap: key `values` not found")) + return false, microerror.Mask(fmt.Errorf("malformed ConfigMap: key `values` not found")) } - var valuesYaml map[string]interface{} - if err := yaml.Unmarshal([]byte(valuesBytes), &valuesYaml); err != nil { - return "", microerror.Mask(fmt.Errorf("failed to parse YAML: %w", err)) + var root map[string]interface{} + if err := yaml.Unmarshal([]byte(valuesBytes), &root); err != nil { + return false, microerror.Mask(fmt.Errorf("failed to parse YAML: %w", err)) } - token, ok := valuesYaml["authToken"].(string) + _, isNested := root[key.TeleportKubeAgentValuesKey].(map[string]interface{}) + return isNested == key.UsesNestedKubeAgentValues(t.Config.AppVersion), nil +} + +func parseConfigMapValues(configMap *corev1.ConfigMap) (map[string]interface{}, error) { + valuesBytes, ok := configMap.Data["values"] if !ok { - return "", microerror.Mask(fmt.Errorf("malformed ConfigMap: key `authToken` not found")) + return nil, microerror.Mask(fmt.Errorf("malformed ConfigMap: key `values` not found")) } - return token, nil + var root map[string]interface{} + if err := yaml.Unmarshal([]byte(valuesBytes), &root); err != nil { + return nil, microerror.Mask(fmt.Errorf("failed to parse YAML: %w", err)) + } + + if nested, ok := root[key.TeleportKubeAgentValuesKey].(map[string]interface{}); ok { + return nested, nil + } + return root, nil } func (t *Teleport) CreateConfigMap(ctx context.Context, log logr.Logger, ctrlClient client.Client, clusterName string, clusterNamespace string, registerName string, token string, roles []string) error { @@ -138,20 +180,29 @@ func (t *Teleport) EnsureTbotConfigMap(ctx context.Context, log logr.Logger, ctr } func (t *Teleport) UpdateConfigMap(ctx context.Context, log logr.Logger, ctrlClient client.Client, configMap *corev1.ConfigMap, token string, roles []string) error { - valuesBytes, ok := configMap.Data["values"] - if !ok { - return microerror.Mask(fmt.Errorf("malformed ConfigMap: key `values` not found")) - } - - var valuesYaml map[string]interface{} - if err := yaml.Unmarshal([]byte(valuesBytes), &valuesYaml); err != nil { - return microerror.Mask(fmt.Errorf("failed to parse YAML: %w", err)) + valuesYaml, err := parseConfigMapValues(configMap) + if err != nil { + return err } valuesYaml["authToken"] = token valuesYaml["roles"] = key.RolesToString(roles) + if currentVersion, _ := valuesYaml["teleportVersionOverride"].(string); currentVersion != t.Config.TeleportVersion { + if t.Config.TeleportVersion != "" { + valuesYaml["teleportVersionOverride"] = t.Config.TeleportVersion + } else { + delete(valuesYaml, "teleportVersionOverride") + } + } + + var out interface{} = valuesYaml + if key.UsesNestedKubeAgentValues(t.Config.AppVersion) { + out = map[string]interface{}{ + key.TeleportKubeAgentValuesKey: valuesYaml, + } + } - updatedValuesYaml, err := yaml.Marshal(valuesYaml) + updatedValuesYaml, err := yaml.Marshal(out) if err != nil { return fmt.Errorf("failed to marshal updated content into YAML: %w", err) } @@ -213,7 +264,7 @@ func (t *Teleport) getConfigMapData(registerName string, token string, roles []s teleportVersionOverride = t.Config.TeleportVersion ) - return key.GetConfigmapDataFromTemplate(authToken, proxyAddr, kubeClusterName, teleportVersionOverride, roles) + return key.GetConfigmapDataFromTemplate(authToken, proxyAddr, kubeClusterName, teleportVersionOverride, roles, t.Config.AppVersion) } func (t *Teleport) getTbotConfigMapData(registerName string, clusterName string) string { diff --git a/internal/pkg/teleport/configmap_test.go b/internal/pkg/teleport/configmap_test.go index 63124f39..f2126762 100644 --- a/internal/pkg/teleport/configmap_test.go +++ b/internal/pkg/teleport/configmap_test.go @@ -249,3 +249,139 @@ func loadConfigMap(ctx context.Context, ctrlClient client.Client, expected *core } return actual, err } + +func Test_CreateConfigMap_NestedLayout(t *testing.T) { + ctx := context.TODO() + log := ctrl.Log.WithName("test") + + ctrlClient, err := test.NewFakeK8sClient(nil) + if err != nil { + t.Fatalf("unexpected error %v", err) + } + + teleport := New(test.NamespaceName, &config.Config{ + AppName: test.AppName, + AppVersion: test.AppVersionNested, + ProxyAddr: test.ProxyAddr, + TeleportVersion: test.TeleportVersion, + }, token.NewGenerator()) + + registerName := key.GetRegisterName(test.ManagementClusterName, test.ClusterName) + if err := teleport.CreateConfigMap(ctx, log, ctrlClient, test.ClusterName, test.NamespaceName, registerName, test.TokenName, []string{"kube", "app"}); err != nil { + t.Fatalf("unexpected error %v", err) + } + + expected := test.NewNestedConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{"kube", "app"}) + actual, err := loadConfigMap(ctx, ctrlClient, expected) + if err != nil { + t.Fatalf("unexpected error %v", err) + } + test.CheckConfigMap(t, expected, actual) +} + +func Test_UpdateConfigMap_MigratesFlatToNested(t *testing.T) { + ctx := context.TODO() + log := ctrl.Log.WithName("test") + + existing := test.NewConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{"kube", "app"}) + ctrlClient, err := test.NewFakeK8sClient([]runtime.Object{existing}) + if err != nil { + t.Fatalf("unexpected error %v", err) + } + + teleport := New(test.NamespaceName, &config.Config{ + AppName: test.AppName, + AppVersion: test.AppVersionNested, + ProxyAddr: test.ProxyAddr, + TeleportVersion: test.TeleportVersion, + }, token.NewGenerator()) + + if err := teleport.UpdateConfigMap(ctx, log, ctrlClient, existing, test.NewTokenName, []string{"kube", "app"}); err != nil { + t.Fatalf("unexpected error %v", err) + } + + expected := test.NewNestedConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.NewTokenName, []string{"kube", "app"}) + actual, err := loadConfigMap(ctx, ctrlClient, expected) + if err != nil { + t.Fatalf("unexpected error %v", err) + } + test.CheckConfigMap(t, expected, actual) +} + +func Test_UpdateConfigMap_RefreshesTeleportVersionOverride(t *testing.T) { + ctx := context.TODO() + log := ctrl.Log.WithName("test") + + existing := test.NewConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{"kube", "app"}) + ctrlClient, err := test.NewFakeK8sClient([]runtime.Object{existing}) + if err != nil { + t.Fatalf("unexpected error %v", err) + } + + teleport := New(test.NamespaceName, &config.Config{ + AppName: test.AppName, + ProxyAddr: test.ProxyAddr, + TeleportVersion: test.TeleportVersionNew, + }, token.NewGenerator()) + + if err := teleport.UpdateConfigMap(ctx, log, ctrlClient, existing, test.TokenName, []string{"kube", "app"}); err != nil { + t.Fatalf("unexpected error %v", err) + } + + expected := test.NewConfigMapWithTeleportVersion(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, test.TeleportVersionNew, []string{"kube", "app"}) + actual, err := loadConfigMap(ctx, ctrlClient, expected) + if err != nil { + t.Fatalf("unexpected error %v", err) + } + test.CheckConfigMap(t, expected, actual) +} + +func Test_GetTokenFromConfigMap_NestedLayout(t *testing.T) { + ctx := context.TODO() + + nested := test.NewNestedConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{"kube", "app"}) + teleport := New(test.NamespaceName, &config.Config{ + AppName: test.AppName, + AppVersion: test.AppVersionNested, + ProxyAddr: test.ProxyAddr, + }, token.NewGenerator()) + + got, err := teleport.GetTokenFromConfigMap(ctx, nested) + if err != nil { + t.Fatalf("unexpected error %v", err) + } + if got != test.TokenName { + t.Fatalf("unexpected token: expected %s, actual %s", test.TokenName, got) + } +} + +func Test_IsConfigMapLayoutUpToDate(t *testing.T) { + flat := test.NewConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{"kube"}) + nested := test.NewNestedConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{"kube"}) + + newApp := New(test.NamespaceName, &config.Config{AppName: test.AppName, AppVersion: test.AppVersionNested}, token.NewGenerator()) + oldApp := New(test.NamespaceName, &config.Config{AppName: test.AppName, AppVersion: "0.3.0"}, token.NewGenerator()) + + cases := []struct { + name string + tele *Teleport + cm *corev1.ConfigMap + wantOk bool + }{ + {"flat-old-ok", oldApp, flat, true}, + {"flat-new-mismatch", newApp, flat, false}, + {"nested-new-ok", newApp, nested, true}, + {"nested-old-mismatch", oldApp, nested, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + ok, err := c.tele.IsConfigMapLayoutUpToDate(c.cm) + if err != nil { + t.Fatalf("unexpected error %v", err) + } + if ok != c.wantOk { + t.Fatalf("got %v, want %v", ok, c.wantOk) + } + }) + } +} diff --git a/internal/pkg/test/matchers.go b/internal/pkg/test/matchers.go index d06b3149..16385f31 100644 --- a/internal/pkg/test/matchers.go +++ b/internal/pkg/test/matchers.go @@ -69,20 +69,20 @@ func CheckConfigMap(t *testing.T, expected, actual *corev1.ConfigMap) { t.Fatalf("config maps do not match:\nexpected %v\nactual %v", expected, actual) } - var expectedValues map[string]string + var expectedValues map[string]interface{} err := yaml.Unmarshal([]byte(expected.Data["values"]), &expectedValues) if err != nil { t.Fatalf("unexpected error %v", err) } - var actualValues map[string]string + var actualValues map[string]interface{} err = yaml.Unmarshal([]byte(actual.Data["values"]), &actualValues) if err != nil { t.Fatalf("unexpected error %v", err) } if !reflect.DeepEqual(expectedValues, actualValues) { - t.Fatalf("config maps do not match:\nexpected %v\nactual %v", expected, actual) + t.Fatalf("config maps do not match:\nexpected %v\nactual %v", expected.Data["values"], actual.Data["values"]) } } diff --git a/internal/pkg/test/resources.go b/internal/pkg/test/resources.go index ae5090f5..eedac217 100644 --- a/internal/pkg/test/resources.go +++ b/internal/pkg/test/resources.go @@ -38,12 +38,15 @@ const ( AppCatalog = "app-catalog" AppVersion = "appVersion" + AppVersionNested = "0.11.0" ManagementClusterName = "management-cluster" ProxyAddr = "127.0.0.1" IdentityFileValue = "identity-file-value" TeleportVersion = "1.0.0" + TeleportVersionNew = "2.0.0" - ConfigMapValuesFormat = "authToken: %s\nproxyAddr: %s\nroles: %s\nkubeClusterName: %s\nteleportVersionOverride: %s" + ConfigMapValuesFormat = "authToken: %s\nproxyAddr: %s\nroles: %s\nkubeClusterName: %s\nteleportVersionOverride: %s" + ConfigMapValuesNestedFormat = "teleport-kube-agent:\n authToken: %s\n proxyAddr: %s\n roles: %s\n kubeClusterName: %s\n teleportVersionOverride: %s" ) var LastReadValue = time.Now() @@ -86,6 +89,18 @@ func NewIdentitySecret(namespaceName, identityFile string) *corev1.Secret { } func NewConfigMap(clusterName, appName, namespaceName, tokenName string, roles []string) *corev1.ConfigMap { + return newConfigMap(clusterName, appName, namespaceName, tokenName, roles, TeleportVersion, ConfigMapValuesFormat) +} + +func NewConfigMapWithTeleportVersion(clusterName, appName, namespaceName, tokenName, teleportVersion string, roles []string) *corev1.ConfigMap { + return newConfigMap(clusterName, appName, namespaceName, tokenName, roles, teleportVersion, ConfigMapValuesFormat) +} + +func NewNestedConfigMap(clusterName, appName, namespaceName, tokenName string, roles []string) *corev1.ConfigMap { + return newConfigMap(clusterName, appName, namespaceName, tokenName, roles, TeleportVersion, ConfigMapValuesNestedFormat) +} + +func newConfigMap(clusterName, appName, namespaceName, tokenName string, roles []string, teleportVersion, valuesFormat string) *corev1.ConfigMap { registerName := key.GetRegisterName(ManagementClusterName, clusterName) return &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ @@ -93,7 +108,7 @@ func NewConfigMap(clusterName, appName, namespaceName, tokenName string, roles [ Namespace: namespaceName, }, Data: map[string]string{ - "values": fmt.Sprintf(ConfigMapValuesFormat, tokenName, ProxyAddr, strings.Join(roles, ","), registerName, TeleportVersion), + "values": fmt.Sprintf(valuesFormat, tokenName, ProxyAddr, strings.Join(roles, ","), registerName, teleportVersion), }, } } From c750776b150e667fff549f80096088b7ba78ccd2 Mon Sep 17 00:00:00 2001 From: Franco Date: Mon, 18 May 2026 20:32:24 -0300 Subject: [PATCH 2/4] Handle teleportVersionOverride when TKA is lower than 0.11.0 --- CHANGELOG.md | 3 +- internal/controller/cluster_controller.go | 3 +- internal/pkg/key/key.go | 52 +++++++++++++++---- internal/pkg/key/key_test.go | 28 +++++++++- internal/pkg/teleport/configmap.go | 10 ++-- internal/pkg/teleport/configmap_test.go | 62 ++++++++++++++++++++++- internal/pkg/test/resources.go | 41 ++++++++++----- 7 files changed, 166 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab496cf2..1acad7e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `io.giantswarm.application.audience` and `io.giantswarm.application.managed` chart annotations for Backstage visibility. -- Emit `teleport-kube-agent` config map values nested under the `teleport-kube-agent` key when the configured app version is newer than v0.10.8, and migrate existing flat config maps on reconcile. +- Emit `teleport-kube-agent` config map values nested under the `teleport-kube-agent` key when the configured app version is v0.11.0 or newer, and migrate existing flat config maps on reconcile. ### Changed - Reconcile loop now refreshes `teleportVersionOverride` in the teleport-kube-agent config map whenever the operator's `teleportVersion` changes, even when the join token is still valid. +- For nested-layout charts (>= v0.11.0), skip writing `teleportVersionOverride` when the configured Teleport version is below the chart-bundled default (v18.7.6) or unparseable, to avoid silently downgrading. ## [0.12.4] - 2026-01-30 diff --git a/internal/controller/cluster_controller.go b/internal/controller/cluster_controller.go index 44227673..9598febf 100644 --- a/internal/controller/cluster_controller.go +++ b/internal/controller/cluster_controller.go @@ -246,7 +246,8 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct if err != nil { return ctrl.Result{}, microerror.Mask(err) } - versionDrifted := currentTeleportVersion != r.Teleport.Config.TeleportVersion + desiredTeleportVersion := key.ResolveTeleportVersionOverride(r.Teleport.Config.AppVersion, r.Teleport.Config.TeleportVersion) + versionDrifted := currentTeleportVersion != desiredTeleportVersion switch { case !tokenValid: diff --git a/internal/pkg/key/key.go b/internal/pkg/key/key.go index 9430f67e..e03794d8 100644 --- a/internal/pkg/key/key.go +++ b/internal/pkg/key/key.go @@ -36,13 +36,19 @@ const ( RoleNode = "node" // TeleportKubeAgentValuesKey is the top-level key under which - // teleport-kube-agent chart versions newer than v0.10.8 expect their + // teleport-kube-agent chart versions v0.11.0 and newer expect their // values to be nested. TeleportKubeAgentValuesKey = "teleport-kube-agent" - // teleportKubeAgentNestedSinceVersion is the threshold above which the - // chart switched to nested values. - teleportKubeAgentNestedSinceVersion = "0.10.8" + // teleportKubeAgentNestedSinceVersion is the lowest chart version that + // expects the nested values layout. + teleportKubeAgentNestedSinceVersion = "0.11.0" + + // teleportKubeAgentBundledTeleportVersion is the Teleport version + // bundled by teleport-kube-agent v0.11.0. With the nested-layout chart + // we skip teleportVersionOverride when the configured override would + // be a downgrade against this bundled version. + teleportKubeAgentBundledTeleportVersion = "18.7.6" ) func ParseRoles(s string) ([]string, error) { @@ -109,15 +115,43 @@ func GetAppName(clusterName string, appName string) string { // UsesNestedKubeAgentValues reports whether the teleport-kube-agent chart // version expects its values nested under the `teleport-kube-agent` key. -// Versions strictly greater than v0.10.8 use the nested layout. An -// unparseable version is treated as the legacy (flat) layout. +// Versions at or above v0.11.0 use the nested layout. An unparseable +// version is treated as the legacy (flat) layout. func UsesNestedKubeAgentValues(appVersion string) bool { v, err := semver.NewVersion(strings.TrimPrefix(appVersion, "v")) if err != nil { return false } threshold := semver.MustParse(teleportKubeAgentNestedSinceVersion) - return v.GreaterThan(threshold) + return v.Compare(threshold) >= 0 +} + +// ResolveTeleportVersionOverride returns the value that should be written +// as `teleportVersionOverride` for a given chart appVersion and operator +// teleportVersion, or an empty string when the override should be omitted. +// +// For the nested-layout chart (>= v0.11.0) the chart already bundles +// Teleport v18.7.6, so an override below that would be a downgrade and is +// skipped. An unparseable teleportVersion is also skipped in that case. +// Older flat-layout charts keep the legacy behaviour: any non-empty value +// is written through. +func ResolveTeleportVersionOverride(appVersion, teleportVersion string) string { + if teleportVersion == "" { + return "" + } + if !UsesNestedKubeAgentValues(appVersion) { + return teleportVersion + } + + v, err := semver.NewVersion(strings.TrimPrefix(teleportVersion, "v")) + if err != nil { + return "" + } + bundled := semver.MustParse(teleportKubeAgentBundledTeleportVersion) + if v.Compare(bundled) < 0 { + return "" + } + return teleportVersion } func GetConfigmapDataFromTemplate(authToken string, proxyAddr string, kubeClusterName string, teleportVersion string, roles []string, appVersion string) string { @@ -127,8 +161,8 @@ proxyAddr: "%s" kubeClusterName: "%s" ` - if teleportVersion != "" { - dataTpl = fmt.Sprintf("%steleportVersionOverride: %q", dataTpl, teleportVersion) + if override := ResolveTeleportVersionOverride(appVersion, teleportVersion); override != "" { + dataTpl = fmt.Sprintf("%steleportVersionOverride: %q", dataTpl, override) } body := fmt.Sprintf(dataTpl, RolesToString(roles), authToken, proxyAddr, kubeClusterName) diff --git a/internal/pkg/key/key_test.go b/internal/pkg/key/key_test.go index 3f1d336f..adad0999 100644 --- a/internal/pkg/key/key_test.go +++ b/internal/pkg/key/key_test.go @@ -12,7 +12,7 @@ func TestUsesNestedKubeAgentValues(t *testing.T) { {"0.3.0", false}, {"0.10.8", false}, {"v0.10.8", false}, - {"0.10.9", true}, + {"0.10.9", false}, {"0.11.0", true}, {"v0.11.0", true}, {"1.0.0", true}, @@ -39,3 +39,29 @@ func TestGetConfigmapDataFromTemplate_FlatWhenOlder(t *testing.T) { t.Fatalf("expected flat layout, got:\n%s", data) } } + +func TestResolveTeleportVersionOverride(t *testing.T) { + cases := []struct { + name string + appVersion string + teleportVersion string + want string + }{ + {"empty teleport version", "0.11.0", "", ""}, + {"flat layout passes value through", "0.10.8", "1.0.0", "1.0.0"}, + {"flat layout passes unparseable through", "0.10.8", "master-abc", "master-abc"}, + {"nested at bundled is kept", "0.11.0", "18.7.6", "18.7.6"}, + {"nested above bundled is kept", "0.11.0", "18.8.0", "18.8.0"}, + {"nested below bundled is dropped", "0.11.0", "18.7.5", ""}, + {"nested far below bundled is dropped", "0.11.0", "1.0.0", ""}, + {"nested with v-prefix kept", "0.11.0", "v18.7.6", "v18.7.6"}, + {"nested unparseable is dropped", "0.11.0", "master-abc", ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := ResolveTeleportVersionOverride(c.appVersion, c.teleportVersion); got != c.want { + t.Fatalf("ResolveTeleportVersionOverride(%q, %q) = %q, want %q", c.appVersion, c.teleportVersion, got, c.want) + } + }) + } +} diff --git a/internal/pkg/teleport/configmap.go b/internal/pkg/teleport/configmap.go index bdaf4db5..41070cf7 100644 --- a/internal/pkg/teleport/configmap.go +++ b/internal/pkg/teleport/configmap.go @@ -187,12 +187,10 @@ func (t *Teleport) UpdateConfigMap(ctx context.Context, log logr.Logger, ctrlCli valuesYaml["authToken"] = token valuesYaml["roles"] = key.RolesToString(roles) - if currentVersion, _ := valuesYaml["teleportVersionOverride"].(string); currentVersion != t.Config.TeleportVersion { - if t.Config.TeleportVersion != "" { - valuesYaml["teleportVersionOverride"] = t.Config.TeleportVersion - } else { - delete(valuesYaml, "teleportVersionOverride") - } + if override := key.ResolveTeleportVersionOverride(t.Config.AppVersion, t.Config.TeleportVersion); override != "" { + valuesYaml["teleportVersionOverride"] = override + } else { + delete(valuesYaml, "teleportVersionOverride") } var out interface{} = valuesYaml diff --git a/internal/pkg/teleport/configmap_test.go b/internal/pkg/teleport/configmap_test.go index f2126762..57ca777b 100644 --- a/internal/pkg/teleport/configmap_test.go +++ b/internal/pkg/teleport/configmap_test.go @@ -263,7 +263,7 @@ func Test_CreateConfigMap_NestedLayout(t *testing.T) { AppName: test.AppName, AppVersion: test.AppVersionNested, ProxyAddr: test.ProxyAddr, - TeleportVersion: test.TeleportVersion, + TeleportVersion: test.TeleportVersionForNested, }, token.NewGenerator()) registerName := key.GetRegisterName(test.ManagementClusterName, test.ClusterName) @@ -279,6 +279,35 @@ func Test_CreateConfigMap_NestedLayout(t *testing.T) { test.CheckConfigMap(t, expected, actual) } +func Test_CreateConfigMap_NestedLayout_SkipsDowngradeOverride(t *testing.T) { + ctx := context.TODO() + log := ctrl.Log.WithName("test") + + ctrlClient, err := test.NewFakeK8sClient(nil) + if err != nil { + t.Fatalf("unexpected error %v", err) + } + + teleport := New(test.NamespaceName, &config.Config{ + AppName: test.AppName, + AppVersion: test.AppVersionNested, + ProxyAddr: test.ProxyAddr, + TeleportVersion: test.TeleportVersion, // 1.0.0 - below bundled 18.7.6 + }, token.NewGenerator()) + + registerName := key.GetRegisterName(test.ManagementClusterName, test.ClusterName) + if err := teleport.CreateConfigMap(ctx, log, ctrlClient, test.ClusterName, test.NamespaceName, registerName, test.TokenName, []string{"kube", "app"}); err != nil { + t.Fatalf("unexpected error %v", err) + } + + expected := test.NewNestedConfigMapWithoutVersionOverride(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{"kube", "app"}) + actual, err := loadConfigMap(ctx, ctrlClient, expected) + if err != nil { + t.Fatalf("unexpected error %v", err) + } + test.CheckConfigMap(t, expected, actual) +} + func Test_UpdateConfigMap_MigratesFlatToNested(t *testing.T) { ctx := context.TODO() log := ctrl.Log.WithName("test") @@ -293,7 +322,7 @@ func Test_UpdateConfigMap_MigratesFlatToNested(t *testing.T) { AppName: test.AppName, AppVersion: test.AppVersionNested, ProxyAddr: test.ProxyAddr, - TeleportVersion: test.TeleportVersion, + TeleportVersion: test.TeleportVersionForNested, }, token.NewGenerator()) if err := teleport.UpdateConfigMap(ctx, log, ctrlClient, existing, test.NewTokenName, []string{"kube", "app"}); err != nil { @@ -308,6 +337,35 @@ func Test_UpdateConfigMap_MigratesFlatToNested(t *testing.T) { test.CheckConfigMap(t, expected, actual) } +func Test_UpdateConfigMap_NestedLayout_StripsDowngradeOverride(t *testing.T) { + ctx := context.TODO() + log := ctrl.Log.WithName("test") + + existing := test.NewNestedConfigMapWithTeleportVersion(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, test.TeleportVersionForNested, []string{"kube", "app"}) + ctrlClient, err := test.NewFakeK8sClient([]runtime.Object{existing}) + if err != nil { + t.Fatalf("unexpected error %v", err) + } + + teleport := New(test.NamespaceName, &config.Config{ + AppName: test.AppName, + AppVersion: test.AppVersionNested, + ProxyAddr: test.ProxyAddr, + TeleportVersion: test.TeleportVersion, // downgrade vs bundled 18.7.6 + }, token.NewGenerator()) + + if err := teleport.UpdateConfigMap(ctx, log, ctrlClient, existing, test.TokenName, []string{"kube", "app"}); err != nil { + t.Fatalf("unexpected error %v", err) + } + + expected := test.NewNestedConfigMapWithoutVersionOverride(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{"kube", "app"}) + actual, err := loadConfigMap(ctx, ctrlClient, expected) + if err != nil { + t.Fatalf("unexpected error %v", err) + } + test.CheckConfigMap(t, expected, actual) +} + func Test_UpdateConfigMap_RefreshesTeleportVersionOverride(t *testing.T) { ctx := context.TODO() log := ctrl.Log.WithName("test") diff --git a/internal/pkg/test/resources.go b/internal/pkg/test/resources.go index eedac217..c1b6cfd0 100644 --- a/internal/pkg/test/resources.go +++ b/internal/pkg/test/resources.go @@ -36,17 +36,20 @@ const ( TokenTypeNode = "node" TokenTypeApp = "app" - AppCatalog = "app-catalog" - AppVersion = "appVersion" - AppVersionNested = "0.11.0" - ManagementClusterName = "management-cluster" - ProxyAddr = "127.0.0.1" - IdentityFileValue = "identity-file-value" - TeleportVersion = "1.0.0" - TeleportVersionNew = "2.0.0" - - ConfigMapValuesFormat = "authToken: %s\nproxyAddr: %s\nroles: %s\nkubeClusterName: %s\nteleportVersionOverride: %s" - ConfigMapValuesNestedFormat = "teleport-kube-agent:\n authToken: %s\n proxyAddr: %s\n roles: %s\n kubeClusterName: %s\n teleportVersionOverride: %s" + AppCatalog = "app-catalog" + AppVersion = "appVersion" + AppVersionNested = "0.11.0" + ManagementClusterName = "management-cluster" + ProxyAddr = "127.0.0.1" + IdentityFileValue = "identity-file-value" + TeleportVersion = "1.0.0" + TeleportVersionNew = "2.0.0" + TeleportVersionForNested = "18.7.6" + TeleportVersionForNestedNew = "18.8.0" + + ConfigMapValuesFormat = "authToken: %s\nproxyAddr: %s\nroles: %s\nkubeClusterName: %s\nteleportVersionOverride: %s" + ConfigMapValuesNestedFormat = "teleport-kube-agent:\n authToken: %s\n proxyAddr: %s\n roles: %s\n kubeClusterName: %s\n teleportVersionOverride: %s" + ConfigMapValuesNestedFormatNoVerOvr = "teleport-kube-agent:\n authToken: %s\n proxyAddr: %s\n roles: %s\n kubeClusterName: %s\n" ) var LastReadValue = time.Now() @@ -97,18 +100,30 @@ func NewConfigMapWithTeleportVersion(clusterName, appName, namespaceName, tokenN } func NewNestedConfigMap(clusterName, appName, namespaceName, tokenName string, roles []string) *corev1.ConfigMap { - return newConfigMap(clusterName, appName, namespaceName, tokenName, roles, TeleportVersion, ConfigMapValuesNestedFormat) + return newConfigMap(clusterName, appName, namespaceName, tokenName, roles, TeleportVersionForNested, ConfigMapValuesNestedFormat) +} + +func NewNestedConfigMapWithTeleportVersion(clusterName, appName, namespaceName, tokenName, teleportVersion string, roles []string) *corev1.ConfigMap { + return newConfigMap(clusterName, appName, namespaceName, tokenName, roles, teleportVersion, ConfigMapValuesNestedFormat) +} + +func NewNestedConfigMapWithoutVersionOverride(clusterName, appName, namespaceName, tokenName string, roles []string) *corev1.ConfigMap { + return newConfigMap(clusterName, appName, namespaceName, tokenName, roles, "", ConfigMapValuesNestedFormatNoVerOvr) } func newConfigMap(clusterName, appName, namespaceName, tokenName string, roles []string, teleportVersion, valuesFormat string) *corev1.ConfigMap { registerName := key.GetRegisterName(ManagementClusterName, clusterName) + values := fmt.Sprintf(valuesFormat, tokenName, ProxyAddr, strings.Join(roles, ","), registerName, teleportVersion) + if valuesFormat == ConfigMapValuesNestedFormatNoVerOvr { + values = fmt.Sprintf(valuesFormat, tokenName, ProxyAddr, strings.Join(roles, ","), registerName) + } return &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ Name: key.GetConfigmapName(clusterName, appName), Namespace: namespaceName, }, Data: map[string]string{ - "values": fmt.Sprintf(valuesFormat, tokenName, ProxyAddr, strings.Join(roles, ","), registerName, teleportVersion), + "values": values, }, } } From 9aac5237c3026fd28e2f2878fcb857f22141a23a Mon Sep 17 00:00:00 2001 From: Franco Date: Mon, 18 May 2026 20:39:26 -0300 Subject: [PATCH 3/4] Fix CI ordering issues --- internal/pkg/teleport/configmap_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/pkg/teleport/configmap_test.go b/internal/pkg/teleport/configmap_test.go index 57ca777b..0b79619f 100644 --- a/internal/pkg/teleport/configmap_test.go +++ b/internal/pkg/teleport/configmap_test.go @@ -421,10 +421,10 @@ func Test_IsConfigMapLayoutUpToDate(t *testing.T) { oldApp := New(test.NamespaceName, &config.Config{AppName: test.AppName, AppVersion: "0.3.0"}, token.NewGenerator()) cases := []struct { - name string - tele *Teleport - cm *corev1.ConfigMap - wantOk bool + name string + tele *Teleport + cm *corev1.ConfigMap + wantOk bool }{ {"flat-old-ok", oldApp, flat, true}, {"flat-new-mismatch", newApp, flat, false}, From e2b9242a7b54fdfd52024a4b0087ebb1b3b2fe3c Mon Sep 17 00:00:00 2001 From: Franco Date: Tue, 19 May 2026 14:19:26 -0300 Subject: [PATCH 4/4] Rework config wrapper --- CHANGELOG.md | 7 +- internal/controller/cluster_controller.go | 50 +++++---- .../controller/cluster_controller_test.go | 41 +++++-- internal/pkg/key/key.go | 98 ++++++++++------- internal/pkg/key/key_test.go | 90 ++++++++++++---- internal/pkg/teleport/app_manager.go | 74 +++++++++++++ internal/pkg/teleport/app_manager_test.go | 101 ++++++++++++++++++ internal/pkg/teleport/configmap.go | 88 ++++++++------- internal/pkg/teleport/configmap_test.go | 87 +++++++++------ internal/pkg/test/resources.go | 43 ++++++++ 10 files changed, 520 insertions(+), 159 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1acad7e9..296938ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `io.giantswarm.application.audience` and `io.giantswarm.application.managed` chart annotations for Backstage visibility. -- Emit `teleport-kube-agent` config map values nested under the `teleport-kube-agent` key when the configured app version is v0.11.0 or newer, and migrate existing flat config maps on reconcile. +- Decide the teleport-kube-agent values layout per cluster, based on the actually-deployed chart version (HelmRelease `status.history[0].chartVersion` preferred, App CR `spec.version` fallback). For chart versions below v0.11.0 (or when no resource is found) the operator emits both the legacy flat layout AND a nested `teleport-kube-agent:` block, so a cluster upgrade across the v0.11.0 boundary stays live during the upgrade window. For v0.11.0+ only the nested block is emitted. ### Changed -- Reconcile loop now refreshes `teleportVersionOverride` in the teleport-kube-agent config map whenever the operator's `teleportVersion` changes, even when the join token is still valid. -- For nested-layout charts (>= v0.11.0), skip writing `teleportVersionOverride` when the configured Teleport version is below the chart-bundled default (v18.7.6) or unparseable, to avoid silently downgrading. +- The operator's own `teleport.appVersion` configuration no longer influences the values layout decision — the per-cluster chart version does. +- Drift detection on the values config map is now a single byte-compare against the rendered desired document, collapsing token rotation, teleport-version drift, and layout migration into one path. +- The nested `teleport-kube-agent:` block always applies the bundled-default floor: `teleportVersionOverride` is dropped when the configured Teleport version is below v18.7.6 (the chart's bundled default) or unparseable, to avoid silently downgrading. The flat block keeps the legacy passthrough behaviour. ## [0.12.4] - 2026-01-30 diff --git a/internal/controller/cluster_controller.go b/internal/controller/cluster_controller.go index 9598febf..7a290d04 100644 --- a/internal/controller/cluster_controller.go +++ b/internal/controller/cluster_controller.go @@ -69,7 +69,7 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct return ctrl.Result{}, microerror.Mask(err) } - log.Info("Reconciling cluster", "cluster", cluster) + log.Info("Reconciling cluster") appsEnabled, err := r.Teleport.AreTeleportAppsEnabled(ctx, cluster.Name, cluster.Namespace) if err != nil { @@ -212,6 +212,16 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct } } + // Look up the deployed teleport-kube-agent chart version for this cluster. + // The layout of the values ConfigMap we write depends on it: nested-only + // for v0.11.0+, dual (flat + nested) for older or unknown versions. + tkaResourceName := key.GetAppName(cluster.Name, r.Teleport.Config.AppName) + tkaVersion, err := teleport.GetTeleportKubeAgentVersion(ctx, r.Client, tkaResourceName, cluster.Namespace) + if err != nil { + return ctrl.Result{}, microerror.Mask(err) + } + log = log.WithValues("tkaVersion", tkaVersion) + // Check if the configmap exists in the cluster, if not, generate teleport token and create the config map // if it is, check teleport token validity, and update the configmap if teleport token has expired configMap, err := r.Teleport.GetConfigMap(ctx, log, r.Client, cluster.Name, cluster.Namespace) @@ -224,7 +234,7 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct if err != nil { return ctrl.Result{}, microerror.Mask(err) } - if err := r.Teleport.CreateConfigMap(ctx, log, r.Client, cluster.Name, cluster.Namespace, registerName, token, roles); err != nil { + if err := r.Teleport.CreateConfigMap(ctx, log, r.Client, cluster.Name, cluster.Namespace, registerName, token, roles, tkaVersion); err != nil { return ctrl.Result{}, microerror.Mask(err) } log.Info("Created new config map with teleport join token", "configMapName", key.GetConfigmapName(cluster.Name, r.Teleport.Config.AppName), "roles", roles) @@ -238,37 +248,35 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct return ctrl.Result{}, microerror.Mask(err) } - currentTeleportVersion, err := r.Teleport.GetTeleportVersionFromConfigMap(configMap) - if err != nil { - return ctrl.Result{}, microerror.Mask(err) - } - layoutUpToDate, err := r.Teleport.IsConfigMapLayoutUpToDate(configMap) - if err != nil { - return ctrl.Result{}, microerror.Mask(err) + writeToken := token + if !tokenValid { + writeToken, err = r.Teleport.GenerateToken(ctx, registerName, roles) + if err != nil { + return ctrl.Result{}, microerror.Mask(err) + } } - desiredTeleportVersion := key.ResolveTeleportVersionOverride(r.Teleport.Config.AppVersion, r.Teleport.Config.TeleportVersion) - versionDrifted := currentTeleportVersion != desiredTeleportVersion + + // Single drift check: compare the stored values document to what the + // template would produce now. This catches token rotation, teleport + // version drift, and layout changes (dual ↔ nested-only) in one shot. + desiredValues := r.Teleport.RenderConfigMapValues(registerName, writeToken, roles, tkaVersion) switch { + case configMap.Data["values"] == desiredValues: + log.Info("ConfigMap has valid teleport join token", "configMapName", configMap.GetName(), "roles", roles) case !tokenValid: - newToken, err := r.Teleport.GenerateToken(ctx, registerName, roles) - if err != nil { - return ctrl.Result{}, microerror.Mask(err) - } - if err := r.Teleport.UpdateConfigMap(ctx, log, r.Client, configMap, newToken, roles); err != nil { + if err := r.Teleport.UpdateConfigMap(ctx, log, r.Client, configMap, writeToken, roles, tkaVersion); err != nil { return ctrl.Result{}, microerror.Mask(err) } log.Info("Updated config map with new teleport join token", "configMapName", configMap.GetName(), "roles", roles) - case versionDrifted, !layoutUpToDate: - if err := r.Teleport.UpdateConfigMap(ctx, log, r.Client, configMap, token, roles); err != nil { + default: + if err := r.Teleport.UpdateConfigMap(ctx, log, r.Client, configMap, writeToken, roles, tkaVersion); err != nil { return ctrl.Result{}, microerror.Mask(err) } log.Info("Updated config map to align teleport version and values layout", "configMapName", configMap.GetName(), "teleportVersion", r.Teleport.Config.TeleportVersion, - "nestedValues", key.UsesNestedKubeAgentValues(r.Teleport.Config.AppVersion)) - default: - log.Info("ConfigMap has valid teleport join token", "configMapName", configMap.GetName(), "roles", roles) + "nestedValuesOnly", key.UsesNestedKubeAgentValues(tkaVersion)) } } diff --git a/internal/controller/cluster_controller_test.go b/internal/controller/cluster_controller_test.go index ee104b85..49578918 100644 --- a/internal/controller/cluster_controller_test.go +++ b/internal/controller/cluster_controller_test.go @@ -38,6 +38,7 @@ func Test_ClusterController(t *testing.T) { secret *corev1.Secret configMap *corev1.ConfigMap userValuesConfigMap *corev1.ConfigMap + tkaApp *appv1alpha1.App newTeleportClient func(ctx context.Context, proxyAddr, identityFile string) (teleport.Client, error) expectedCluster *capi.Cluster expectedSecret *corev1.Secret @@ -63,7 +64,7 @@ func Test_ClusterController(t *testing.T) { }, expectedCluster: test.NewCluster(test.ClusterName, test.NamespaceName, []string{key.TeleportOperatorFinalizer}, time.Time{}), expectedSecret: test.NewSecret(test.ClusterName, test.NamespaceName, test.TokenName), - expectedConfigMap: test.NewConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{key.RoleKube, key.RoleApp}), + expectedConfigMap: test.NewDualBlockConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{key.RoleKube, key.RoleApp}), expectedRoles: []string{key.RoleKube, key.RoleApp}, }, { @@ -84,7 +85,7 @@ func Test_ClusterController(t *testing.T) { }, expectedCluster: test.NewCluster(test.ClusterName, test.NamespaceName, []string{key.TeleportOperatorFinalizer}, time.Time{}), expectedSecret: test.NewSecret(test.ClusterName, test.NamespaceName, test.TokenName), - expectedConfigMap: test.NewConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{key.RoleKube}), + expectedConfigMap: test.NewDualBlockConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{key.RoleKube}), expectedRoles: []string{key.RoleKube}, }, { @@ -96,7 +97,7 @@ func Test_ClusterController(t *testing.T) { cluster: test.NewCluster(test.ClusterName, test.NamespaceName, []string{key.TeleportOperatorFinalizer}, time.Time{}), expectedCluster: test.NewCluster(test.ClusterName, test.NamespaceName, []string{key.TeleportOperatorFinalizer}, time.Time{}), expectedSecret: test.NewSecret(test.ClusterName, test.NamespaceName, test.TokenName), - expectedConfigMap: test.NewConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{key.RoleKube}), + expectedConfigMap: test.NewDualBlockConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{key.RoleKube}), expectedRoles: []string{key.RoleKube}, }, { @@ -119,7 +120,7 @@ func Test_ClusterController(t *testing.T) { }, expectedCluster: test.NewCluster(test.ClusterName, test.NamespaceName, []string{key.TeleportOperatorFinalizer}, time.Time{}), expectedSecret: test.NewSecret(test.ClusterName, test.NamespaceName, test.NewTokenName), - expectedConfigMap: test.NewConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.NewTokenName, []string{key.RoleKube, key.RoleApp}), + expectedConfigMap: test.NewDualBlockConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.NewTokenName, []string{key.RoleKube, key.RoleApp}), expectedRoles: []string{key.RoleKube, key.RoleApp}, }, { @@ -157,7 +158,7 @@ func Test_ClusterController(t *testing.T) { }, expectedCluster: test.NewCluster(test.ClusterName, test.NamespaceName, []string{key.TeleportOperatorFinalizer}, time.Time{}), expectedSecret: test.NewSecret(test.ClusterName, test.NamespaceName, test.NewTokenName), - expectedConfigMap: test.NewConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.NewTokenName, []string{key.RoleKube, key.RoleApp}), + expectedConfigMap: test.NewDualBlockConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.NewTokenName, []string{key.RoleKube, key.RoleApp}), expectedRoles: []string{key.RoleKube, key.RoleApp}, }, { @@ -175,7 +176,26 @@ func Test_ClusterController(t *testing.T) { configMap: test.NewConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{key.RoleKube}), expectedCluster: test.NewCluster(test.ClusterName, test.NamespaceName, []string{key.TeleportOperatorFinalizer}, time.Time{}), expectedSecret: test.NewSecret(test.ClusterName, test.NamespaceName, test.TokenName), - expectedConfigMap: test.NewConfigMapWithTeleportVersion(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, test.TeleportVersionNew, []string{key.RoleKube}), + expectedConfigMap: test.NewDualBlockConfigMapWithTeleportVersion(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, test.TeleportVersionNew, []string{key.RoleKube}), + expectedRoles: []string{key.RoleKube}, + }, + { + name: "case 7: Migrate flat configmap to nested when TKA App is v0.11.0", + namespace: test.NamespaceName, + token: test.TokenName, + config: newConfig(), + identity: newIdentity(test.LastReadValue), + tokens: []teleportTypes.ProvisionToken{ + test.NewToken(test.TokenName, test.ClusterName, []string{key.RoleKube}), + test.NewToken(test.TokenName, test.ClusterName, []string{key.RoleNode}), + }, + cluster: test.NewCluster(test.ClusterName, test.NamespaceName, []string{key.TeleportOperatorFinalizer}, time.Time{}), + secret: test.NewSecret(test.ClusterName, test.NamespaceName, test.TokenName), + configMap: test.NewConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{key.RoleKube}), + tkaApp: tkaAppWithVersion(test.ClusterName, test.AppName, test.NamespaceName, test.AppVersionNested), + expectedCluster: test.NewCluster(test.ClusterName, test.NamespaceName, []string{key.TeleportOperatorFinalizer}, time.Time{}), + expectedSecret: test.NewSecret(test.ClusterName, test.NamespaceName, test.TokenName), + expectedConfigMap: test.NewNestedConfigMapWithoutVersionOverride(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{key.RoleKube}), expectedRoles: []string{key.RoleKube}, }, { @@ -213,6 +233,9 @@ func Test_ClusterController(t *testing.T) { if tc.userValuesConfigMap != nil { runtimeObjects = append(runtimeObjects, tc.userValuesConfigMap) } + if tc.tkaApp != nil { + runtimeObjects = append(runtimeObjects, tc.tkaApp) + } newTeleportClient := teleport.NewClient if tc.newTeleportClient != nil { @@ -354,6 +377,12 @@ func newIdentity(lastRead time.Time) *config.IdentityConfig { } } +func tkaAppWithVersion(clusterName, appName, namespace, version string) *appv1alpha1.App { + app := test.NewApp(key.GetAppName(clusterName, appName), namespace) + app.Spec.Version = version + return app +} + func findSecretInList(secretList *corev1.SecretList, name string) *corev1.Secret { for _, secret := range secretList.Items { if secret.Name == name { diff --git a/internal/pkg/key/key.go b/internal/pkg/key/key.go index e03794d8..b75c54e0 100644 --- a/internal/pkg/key/key.go +++ b/internal/pkg/key/key.go @@ -36,18 +36,17 @@ const ( RoleNode = "node" // TeleportKubeAgentValuesKey is the top-level key under which - // teleport-kube-agent chart versions v0.11.0 and newer expect their - // values to be nested. + // teleport-kube-agent v0.11.0+ reads its values. TeleportKubeAgentValuesKey = "teleport-kube-agent" // teleportKubeAgentNestedSinceVersion is the lowest chart version that - // expects the nested values layout. + // reads its values nested under TeleportKubeAgentValuesKey. teleportKubeAgentNestedSinceVersion = "0.11.0" - // teleportKubeAgentBundledTeleportVersion is the Teleport version - // bundled by teleport-kube-agent v0.11.0. With the nested-layout chart - // we skip teleportVersionOverride when the configured override would - // be a downgrade against this bundled version. + // teleportKubeAgentBundledTeleportVersion is the Teleport version that + // teleport-kube-agent v0.11.0 bundles by default. The nested block + // skips teleportVersionOverride when the operator-configured override + // would be a downgrade against this bundled version. teleportKubeAgentBundledTeleportVersion = "18.7.6" ) @@ -126,23 +125,18 @@ func UsesNestedKubeAgentValues(appVersion string) bool { return v.Compare(threshold) >= 0 } -// ResolveTeleportVersionOverride returns the value that should be written -// as `teleportVersionOverride` for a given chart appVersion and operator -// teleportVersion, or an empty string when the override should be omitted. -// -// For the nested-layout chart (>= v0.11.0) the chart already bundles -// Teleport v18.7.6, so an override below that would be a downgrade and is -// skipped. An unparseable teleportVersion is also skipped in that case. -// Older flat-layout charts keep the legacy behaviour: any non-empty value -// is written through. -func ResolveTeleportVersionOverride(appVersion, teleportVersion string) string { +// ResolveNestedTeleportVersionOverride returns the value to write as +// `teleportVersionOverride` inside the nested `teleport-kube-agent:` block, +// or "" when the override should be omitted. The nested block always +// targets chart v0.11.0+ consumers (whether emitted alone or alongside a +// flat block for upgrade safety), so the floor applies unconditionally: +// any teleportVersion below teleportKubeAgentBundledTeleportVersion — or +// one that doesn't parse as semver — is dropped to avoid a downgrade +// against the chart's bundled Teleport. +func ResolveNestedTeleportVersionOverride(teleportVersion string) string { if teleportVersion == "" { return "" } - if !UsesNestedKubeAgentValues(appVersion) { - return teleportVersion - } - v, err := semver.NewVersion(strings.TrimPrefix(teleportVersion, "v")) if err != nil { return "" @@ -154,31 +148,55 @@ func ResolveTeleportVersionOverride(appVersion, teleportVersion string) string { return teleportVersion } -func GetConfigmapDataFromTemplate(authToken string, proxyAddr string, kubeClusterName string, teleportVersion string, roles []string, appVersion string) string { - dataTpl := `roles: "%s" +// GetConfigmapDataFromTemplate renders the teleport-kube-agent values document +// for a cluster. The layout depends on the cluster's deployed chart version: +// +// - tkaVersion >= v0.11.0: a single nested block under TeleportKubeAgentValuesKey, +// because that's the only place the chart looks. +// - tkaVersion < v0.11.0 or unknown ("" / unparseable): the legacy flat +// layout at the root, PLUS the nested block. The flat block keeps the +// chart working today; the nested block is there so an in-place upgrade +// past v0.11.0 doesn't lose values in the window before the operator's +// next reconcile. +// +// teleportVersionOverride is passed through in the flat block when non-empty, +// but the nested block applies a floor (see ResolveTeleportVersionOverride): +// the override is dropped if it would be a downgrade against the v0.11.0 +// chart's bundled Teleport version. +func GetConfigmapDataFromTemplate(authToken, proxyAddr, kubeClusterName, teleportVersion string, roles []string, tkaVersion string) string { + flat := renderFlatValuesBlock(authToken, proxyAddr, kubeClusterName, teleportVersion, roles) + nestedOverride := ResolveNestedTeleportVersionOverride(teleportVersion) + nested := renderNestedValuesBlock(authToken, proxyAddr, kubeClusterName, nestedOverride, roles) + + if UsesNestedKubeAgentValues(tkaVersion) { + return nested + } + return flat + nested +} + +func renderFlatValuesBlock(authToken, proxyAddr, kubeClusterName, teleportVersion string, roles []string) string { + body := fmt.Sprintf(`roles: "%s" authToken: "%s" proxyAddr: "%s" kubeClusterName: "%s" -` - - if override := ResolveTeleportVersionOverride(appVersion, teleportVersion); override != "" { - dataTpl = fmt.Sprintf("%steleportVersionOverride: %q", dataTpl, override) - } - - body := fmt.Sprintf(dataTpl, RolesToString(roles), authToken, proxyAddr, kubeClusterName) - if !UsesNestedKubeAgentValues(appVersion) { - return body +`, RolesToString(roles), authToken, proxyAddr, kubeClusterName) + if teleportVersion != "" { + body += fmt.Sprintf("teleportVersionOverride: %q\n", teleportVersion) } + return body +} - var nested strings.Builder - nested.WriteString(TeleportKubeAgentValuesKey) - nested.WriteString(":\n") - for line := range strings.SplitSeq(strings.TrimRight(body, "\n"), "\n") { - nested.WriteString(" ") - nested.WriteString(line) - nested.WriteString("\n") +func renderNestedValuesBlock(authToken, proxyAddr, kubeClusterName, teleportVersion string, roles []string) string { + body := fmt.Sprintf(`%s: + roles: "%s" + authToken: "%s" + proxyAddr: "%s" + kubeClusterName: "%s" +`, TeleportKubeAgentValuesKey, RolesToString(roles), authToken, proxyAddr, kubeClusterName) + if teleportVersion != "" { + body += fmt.Sprintf(" teleportVersionOverride: %q\n", teleportVersion) } - return nested.String() + return body } func GetTbotConfigmapDataFromTemplate(kubeClusterName string, clusterName string) string { diff --git a/internal/pkg/key/key_test.go b/internal/pkg/key/key_test.go index adad0999..a84a81ff 100644 --- a/internal/pkg/key/key_test.go +++ b/internal/pkg/key/key_test.go @@ -26,42 +26,92 @@ func TestUsesNestedKubeAgentValues(t *testing.T) { } } -func TestGetConfigmapDataFromTemplate_NestedWhenNewer(t *testing.T) { - data := GetConfigmapDataFromTemplate("tok", "proxy:443", "kube", "16.0.0", []string{"kube", "app"}, "0.11.0") +func TestGetConfigmapDataFromTemplate_NestedOnlyAtOrAbove0_11_0(t *testing.T) { + data := GetConfigmapDataFromTemplate("tok", "proxy:443", "kube", "18.7.6", []string{"kube", "app"}, "0.11.0") if want := "teleport-kube-agent:\n"; data[:len(want)] != want { - t.Fatalf("expected nested layout, got:\n%s", data) + t.Fatalf("expected nested-only layout, got:\n%s", data) } } -func TestGetConfigmapDataFromTemplate_FlatWhenOlder(t *testing.T) { - data := GetConfigmapDataFromTemplate("tok", "proxy:443", "kube", "16.0.0", []string{"kube", "app"}, "0.10.8") - if want := "roles:"; data[:len(want)] != want { - t.Fatalf("expected flat layout, got:\n%s", data) +func TestGetConfigmapDataFromTemplate_DualBlockBelow0_11_0(t *testing.T) { + cases := []string{"", "0.10.8", "not-a-version"} + for _, tkaVersion := range cases { + t.Run(tkaVersion, func(t *testing.T) { + data := GetConfigmapDataFromTemplate("tok", "proxy:443", "kube", "18.7.6", []string{"kube", "app"}, tkaVersion) + if !startsWith(data, "roles:") { + t.Fatalf("expected flat root keys first, got:\n%s", data) + } + if !containsLine(data, "teleport-kube-agent:") { + t.Fatalf("expected nested block, got:\n%s", data) + } + }) + } +} + +func TestGetConfigmapDataFromTemplate_NestedFloorDropsDowngrade(t *testing.T) { + data := GetConfigmapDataFromTemplate("tok", "proxy:443", "kube", "17.5.4", []string{"kube"}, "0.11.0") + if containsLine(data, ` teleportVersionOverride: "17.5.4"`) { + t.Fatalf("expected nested block to omit downgrade override, got:\n%s", data) + } +} + +func TestGetConfigmapDataFromTemplate_DualBlockFlatPassesOverride(t *testing.T) { + data := GetConfigmapDataFromTemplate("tok", "proxy:443", "kube", "1.0.0", []string{"kube"}, "") + if !containsLine(data, `teleportVersionOverride: "1.0.0"`) { + t.Fatalf("expected flat block to keep passthrough override, got:\n%s", data) + } + if containsLine(data, ` teleportVersionOverride: "1.0.0"`) { + t.Fatalf("expected nested block to drop below-floor override, got:\n%s", data) } } -func TestResolveTeleportVersionOverride(t *testing.T) { +func TestResolveNestedTeleportVersionOverride(t *testing.T) { cases := []struct { name string - appVersion string teleportVersion string want string }{ - {"empty teleport version", "0.11.0", "", ""}, - {"flat layout passes value through", "0.10.8", "1.0.0", "1.0.0"}, - {"flat layout passes unparseable through", "0.10.8", "master-abc", "master-abc"}, - {"nested at bundled is kept", "0.11.0", "18.7.6", "18.7.6"}, - {"nested above bundled is kept", "0.11.0", "18.8.0", "18.8.0"}, - {"nested below bundled is dropped", "0.11.0", "18.7.5", ""}, - {"nested far below bundled is dropped", "0.11.0", "1.0.0", ""}, - {"nested with v-prefix kept", "0.11.0", "v18.7.6", "v18.7.6"}, - {"nested unparseable is dropped", "0.11.0", "master-abc", ""}, + {"empty", "", ""}, + {"at bundled is kept", "18.7.6", "18.7.6"}, + {"above bundled is kept", "18.8.0", "18.8.0"}, + {"below bundled is dropped", "18.7.5", ""}, + {"far below bundled is dropped", "1.0.0", ""}, + {"with v-prefix is kept", "v18.7.6", "v18.7.6"}, + {"unparseable is dropped", "master-abc", ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - if got := ResolveTeleportVersionOverride(c.appVersion, c.teleportVersion); got != c.want { - t.Fatalf("ResolveTeleportVersionOverride(%q, %q) = %q, want %q", c.appVersion, c.teleportVersion, got, c.want) + if got := ResolveNestedTeleportVersionOverride(c.teleportVersion); got != c.want { + t.Fatalf("ResolveNestedTeleportVersionOverride(%q) = %q, want %q", c.teleportVersion, got, c.want) } }) } } + +func startsWith(s, prefix string) bool { + return len(s) >= len(prefix) && s[:len(prefix)] == prefix +} + +func containsLine(s, line string) bool { + for _, l := range splitLines(s) { + if l == line { + return true + } + } + return false +} + +func splitLines(s string) []string { + var out []string + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '\n' { + out = append(out, s[start:i]) + start = i + 1 + } + } + if start < len(s) { + out = append(out, s[start:]) + } + return out +} diff --git a/internal/pkg/teleport/app_manager.go b/internal/pkg/teleport/app_manager.go index 0892ec58..fb87bca5 100644 --- a/internal/pkg/teleport/app_manager.go +++ b/internal/pkg/teleport/app_manager.go @@ -25,6 +25,80 @@ func newHelmReleaseUnstructured() *unstructured.Unstructured { return hr } +// GetTeleportKubeAgentVersion returns the chart version of the deployed +// teleport-kube-agent for a cluster, or "" if no matching HelmRelease or +// App CR exists. HelmRelease takes precedence (same order as +// NewTeleportAppConfigManager). For HelmReleases we prefer +// status.history[0].chartVersion (Flux's actually-installed version, +// independent of how the chart reference was authored), falling back to +// spec.chart.spec.version for HelmReleases that haven't reconciled yet. +// Callers treat the empty string as "unknown / pre-0.11.0". +func GetTeleportKubeAgentVersion( + ctx context.Context, + ctrlClient client.Client, + resourceName, namespace string, +) (string, error) { + hr := newHelmReleaseUnstructured() + err := ctrlClient.Get(ctx, client.ObjectKey{Name: resourceName, Namespace: namespace}, hr) + if err == nil { + if v := helmReleaseInstalledChartVersion(hr); v != "" { + return v, nil + } + if v := helmReleaseSpecChartVersion(hr); v != "" { + return v, nil + } + return "", nil + } + if !apierrors.IsNotFound(err) { + return "", microerror.Mask(err) + } + + app := &v1alpha1.App{} + err = ctrlClient.Get(ctx, client.ObjectKey{Name: resourceName, Namespace: namespace}, app) + if err == nil { + return app.Spec.Version, nil + } + if !apierrors.IsNotFound(err) { + return "", microerror.Mask(err) + } + + return "", nil +} + +func helmReleaseInstalledChartVersion(hr *unstructured.Unstructured) string { + status, ok := hr.Object["status"].(map[string]interface{}) + if !ok { + return "" + } + history, ok := status["history"].([]interface{}) + if !ok || len(history) == 0 { + return "" + } + entry, ok := history[0].(map[string]interface{}) + if !ok { + return "" + } + v, _ := entry["chartVersion"].(string) + return v +} + +func helmReleaseSpecChartVersion(hr *unstructured.Unstructured) string { + spec, ok := hr.Object["spec"].(map[string]interface{}) + if !ok { + return "" + } + chart, ok := spec["chart"].(map[string]interface{}) + if !ok { + return "" + } + chartSpec, ok := chart["spec"].(map[string]interface{}) + if !ok { + return "" + } + v, _ := chartSpec["version"].(string) + return v +} + // TeleportAppConfigManager abstracts injecting a ConfigMap reference into either a // Giant Swarm App CR (via spec.extraConfigs) or a Flux HelmRelease (via // spec.valuesFrom). diff --git a/internal/pkg/teleport/app_manager_test.go b/internal/pkg/teleport/app_manager_test.go index e957d71f..77a06bed 100644 --- a/internal/pkg/teleport/app_manager_test.go +++ b/internal/pkg/teleport/app_manager_test.go @@ -5,6 +5,7 @@ import ( "testing" appv1alpha1 "github.com/giantswarm/apiextensions-application/api/v1alpha1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -354,3 +355,103 @@ func Test_AppCR_DeleteConfig_NoOp_WhenEntryAbsent(t *testing.T) { t.Errorf("expected ExtraConfigs unchanged (nil/empty), got %d entries", len(updated.Spec.ExtraConfigs)) } } + +func Test_GetTeleportKubeAgentVersion_NoResource(t *testing.T) { + fakeClient, err := test.NewFakeK8sClientFromObjects() + if err != nil { + t.Fatalf("failed to create fake client: %v", err) + } + + v, err := GetTeleportKubeAgentVersion(context.Background(), fakeClient, testResourceName, testNamespace) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v != "" { + t.Errorf("expected empty version, got %q", v) + } +} + +func Test_GetTeleportKubeAgentVersion_AppCR(t *testing.T) { + app := test.NewApp(testResourceName, testNamespace) + app.Spec.Version = "0.11.0" + + fakeClient, err := test.NewFakeK8sClientFromObjects(app) + if err != nil { + t.Fatalf("failed to create fake client: %v", err) + } + + v, err := GetTeleportKubeAgentVersion(context.Background(), fakeClient, testResourceName, testNamespace) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v != "0.11.0" { + t.Errorf("expected 0.11.0, got %q", v) + } +} + +func Test_GetTeleportKubeAgentVersion_HelmRelease_FromStatusHistory(t *testing.T) { + hr := test.NewHelmRelease(testResourceName, testNamespace) + if err := unstructured.SetNestedSlice(hr.Object, []interface{}{ + map[string]interface{}{"chartVersion": "0.11.0+abc"}, + }, "status", "history"); err != nil { + t.Fatalf("set history: %v", err) + } + + fakeClient, err := test.NewFakeK8sClientFromObjects(hr) + if err != nil { + t.Fatalf("failed to create fake client: %v", err) + } + + v, err := GetTeleportKubeAgentVersion(context.Background(), fakeClient, testResourceName, testNamespace) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v != "0.11.0+abc" { + t.Errorf("expected 0.11.0+abc, got %q", v) + } +} + +func Test_GetTeleportKubeAgentVersion_HelmRelease_FromSpecChart(t *testing.T) { + hr := test.NewHelmRelease(testResourceName, testNamespace) + if err := unstructured.SetNestedField(hr.Object, "0.11.0", "spec", "chart", "spec", "version"); err != nil { + t.Fatalf("set spec.chart.spec.version: %v", err) + } + + fakeClient, err := test.NewFakeK8sClientFromObjects(hr) + if err != nil { + t.Fatalf("failed to create fake client: %v", err) + } + + v, err := GetTeleportKubeAgentVersion(context.Background(), fakeClient, testResourceName, testNamespace) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v != "0.11.0" { + t.Errorf("expected 0.11.0, got %q", v) + } +} + +func Test_GetTeleportKubeAgentVersion_HelmReleaseTakesPrecedence(t *testing.T) { + hr := test.NewHelmRelease(testResourceName, testNamespace) + if err := unstructured.SetNestedSlice(hr.Object, []interface{}{ + map[string]interface{}{"chartVersion": "0.11.0-hr"}, + }, "status", "history"); err != nil { + t.Fatalf("set history: %v", err) + } + + app := test.NewApp(testResourceName, testNamespace) + app.Spec.Version = "0.10.0-app" + + fakeClient, err := test.NewFakeK8sClientFromObjects(hr, app) + if err != nil { + t.Fatalf("failed to create fake client: %v", err) + } + + v, err := GetTeleportKubeAgentVersion(context.Background(), fakeClient, testResourceName, testNamespace) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v != "0.11.0-hr" { + t.Errorf("expected HelmRelease to win, got %q", v) + } +} diff --git a/internal/pkg/teleport/configmap.go b/internal/pkg/teleport/configmap.go index 41070cf7..f0582a0f 100644 --- a/internal/pkg/teleport/configmap.go +++ b/internal/pkg/teleport/configmap.go @@ -76,9 +76,14 @@ func (t *Teleport) GetTeleportVersionFromConfigMap(configMap *corev1.ConfigMap) } // IsConfigMapLayoutUpToDate reports whether the ConfigMap's top-level shape -// matches what the configured AppVersion expects (nested under -// `teleport-kube-agent` vs. flat at the root). -func (t *Teleport) IsConfigMapLayoutUpToDate(configMap *corev1.ConfigMap) (bool, error) { +// matches what the cluster's deployed teleport-kube-agent chart version +// expects: +// +// - tkaVersion >= v0.11.0: nested-only — the `teleport-kube-agent:` block +// must exist and the flat block must be absent (root has no authToken). +// - tkaVersion < v0.11.0 or unknown: dual — both the nested block AND +// the flat root keys must be present. +func (t *Teleport) IsConfigMapLayoutUpToDate(configMap *corev1.ConfigMap, tkaVersion string) (bool, error) { valuesBytes, ok := configMap.Data["values"] if !ok { return false, microerror.Mask(fmt.Errorf("malformed ConfigMap: key `values` not found")) @@ -89,8 +94,13 @@ func (t *Teleport) IsConfigMapLayoutUpToDate(configMap *corev1.ConfigMap) (bool, return false, microerror.Mask(fmt.Errorf("failed to parse YAML: %w", err)) } - _, isNested := root[key.TeleportKubeAgentValuesKey].(map[string]interface{}) - return isNested == key.UsesNestedKubeAgentValues(t.Config.AppVersion), nil + _, hasNested := root[key.TeleportKubeAgentValuesKey].(map[string]interface{}) + _, hasFlat := root["authToken"].(string) + + if key.UsesNestedKubeAgentValues(tkaVersion) { + return hasNested && !hasFlat, nil + } + return hasNested && hasFlat, nil } func parseConfigMapValues(configMap *corev1.ConfigMap) (map[string]interface{}, error) { @@ -110,11 +120,11 @@ func parseConfigMapValues(configMap *corev1.ConfigMap) (map[string]interface{}, return root, nil } -func (t *Teleport) CreateConfigMap(ctx context.Context, log logr.Logger, ctrlClient client.Client, clusterName string, clusterNamespace string, registerName string, token string, roles []string) error { +func (t *Teleport) CreateConfigMap(ctx context.Context, log logr.Logger, ctrlClient client.Client, clusterName string, clusterNamespace string, registerName string, token string, roles []string, tkaVersion string) error { configMapName := key.GetConfigmapName(clusterName, t.Config.AppName) configMapData := map[string]string{ - "values": t.getConfigMapData(registerName, token, roles), + "values": t.getConfigMapData(registerName, token, roles, tkaVersion), } cm := corev1.ConfigMap{} @@ -179,34 +189,21 @@ func (t *Teleport) EnsureTbotConfigMap(ctx context.Context, log logr.Logger, ctr return nil } -func (t *Teleport) UpdateConfigMap(ctx context.Context, log logr.Logger, ctrlClient client.Client, configMap *corev1.ConfigMap, token string, roles []string) error { - valuesYaml, err := parseConfigMapValues(configMap) +// UpdateConfigMap rewrites the ConfigMap's `values` from the template so the +// produced YAML matches what GetConfigmapDataFromTemplate would emit for the +// given tkaVersion. This means the controller can do a single string compare +// to detect drift, and a tkaVersion crossing 0.11.0 actually drops the flat +// block from the stored ConfigMap. +func (t *Teleport) UpdateConfigMap(ctx context.Context, log logr.Logger, ctrlClient client.Client, configMap *corev1.ConfigMap, token string, roles []string, tkaVersion string) error { + registerName, err := registerNameFromConfigMap(configMap) if err != nil { return err } - valuesYaml["authToken"] = token - valuesYaml["roles"] = key.RolesToString(roles) - if override := key.ResolveTeleportVersionOverride(t.Config.AppVersion, t.Config.TeleportVersion); override != "" { - valuesYaml["teleportVersionOverride"] = override - } else { - delete(valuesYaml, "teleportVersionOverride") - } - - var out interface{} = valuesYaml - if key.UsesNestedKubeAgentValues(t.Config.AppVersion) { - out = map[string]interface{}{ - key.TeleportKubeAgentValuesKey: valuesYaml, - } + if configMap.Data == nil { + configMap.Data = map[string]string{} } - - updatedValuesYaml, err := yaml.Marshal(out) - if err != nil { - return fmt.Errorf("failed to marshal updated content into YAML: %w", err) - } - - // Update the ConfigMap's data with the modified value - configMap.Data["values"] = string(updatedValuesYaml) + configMap.Data["values"] = t.getConfigMapData(registerName, token, roles, tkaVersion) if err := ctrlClient.Update(ctx, configMap); err != nil { return microerror.Mask(fmt.Errorf("failed to update ConfigMap: %w", err)) } @@ -214,6 +211,20 @@ func (t *Teleport) UpdateConfigMap(ctx context.Context, log logr.Logger, ctrlCli return nil } +// registerNameFromConfigMap extracts the kubeClusterName from the stored +// values — it's the register name and doesn't change between reconciles. +func registerNameFromConfigMap(configMap *corev1.ConfigMap) (string, error) { + values, err := parseConfigMapValues(configMap) + if err != nil { + return "", err + } + name, ok := values["kubeClusterName"].(string) + if !ok || name == "" { + return "", microerror.Mask(fmt.Errorf("malformed ConfigMap: key `kubeClusterName` not found")) + } + return name, nil +} + func (t *Teleport) DeleteConfigMap(ctx context.Context, log logr.Logger, ctrlClient client.Client, clusterName string, clusterNamespace string) error { configMapName := key.GetConfigmapName(clusterName, t.Config.AppName) cm := corev1.ConfigMap{ @@ -254,15 +265,16 @@ func (t *Teleport) DeleteTbotConfigMap(ctx context.Context, log logr.Logger, ctr return nil } -func (t *Teleport) getConfigMapData(registerName string, token string, roles []string) string { - var ( - authToken = token - proxyAddr = t.Config.ProxyAddr - kubeClusterName = registerName - teleportVersionOverride = t.Config.TeleportVersion - ) +// RenderConfigMapValues returns the deterministic YAML the operator wants +// the cluster's teleport-kube-agent values ConfigMap to contain. The +// controller uses it both for the initial write and for byte-compare +// drift detection on subsequent reconciles. +func (t *Teleport) RenderConfigMapValues(registerName, token string, roles []string, tkaVersion string) string { + return t.getConfigMapData(registerName, token, roles, tkaVersion) +} - return key.GetConfigmapDataFromTemplate(authToken, proxyAddr, kubeClusterName, teleportVersionOverride, roles, t.Config.AppVersion) +func (t *Teleport) getConfigMapData(registerName, token string, roles []string, tkaVersion string) string { + return key.GetConfigmapDataFromTemplate(token, t.Config.ProxyAddr, registerName, t.Config.TeleportVersion, roles, tkaVersion) } func (t *Teleport) getTbotConfigMapData(registerName string, clusterName string) string { diff --git a/internal/pkg/teleport/configmap_test.go b/internal/pkg/teleport/configmap_test.go index 0b79619f..978ccb9f 100644 --- a/internal/pkg/teleport/configmap_test.go +++ b/internal/pkg/teleport/configmap_test.go @@ -201,7 +201,7 @@ func Test_ConfigMapCRUD(t *testing.T) { } if tc.configMapToCreate != nil { - err = teleport.CreateConfigMap(ctx, log, ctrlClient, tc.clusterName, tc.namespace, tc.registerName, tc.token, []string{"kube", "app"}) + err = teleport.CreateConfigMap(ctx, log, ctrlClient, tc.clusterName, tc.namespace, tc.registerName, tc.token, []string{"kube", "app"}, "") test.CheckError(t, tc.expectError, err) if err != nil { actualConfigMap, err = loadConfigMap(ctx, ctrlClient, tc.configMapToCreate) @@ -213,7 +213,7 @@ func Test_ConfigMapCRUD(t *testing.T) { } if tc.configMapToUpdate != nil { - err = teleport.UpdateConfigMap(ctx, log, ctrlClient, tc.configMap, tc.token, []string{"kube", "app"}) + err = teleport.UpdateConfigMap(ctx, log, ctrlClient, tc.configMap, tc.token, []string{"kube", "app"}, "") test.CheckError(t, tc.expectError, err) if err != nil { actualConfigMap, err = loadConfigMap(ctx, ctrlClient, tc.configMapToUpdate) @@ -250,7 +250,7 @@ func loadConfigMap(ctx context.Context, ctrlClient client.Client, expected *core return actual, err } -func Test_CreateConfigMap_NestedLayout(t *testing.T) { +func Test_CreateConfigMap_NestedOnlyFor0_11_0(t *testing.T) { ctx := context.TODO() log := ctrl.Log.WithName("test") @@ -261,13 +261,12 @@ func Test_CreateConfigMap_NestedLayout(t *testing.T) { teleport := New(test.NamespaceName, &config.Config{ AppName: test.AppName, - AppVersion: test.AppVersionNested, ProxyAddr: test.ProxyAddr, TeleportVersion: test.TeleportVersionForNested, }, token.NewGenerator()) registerName := key.GetRegisterName(test.ManagementClusterName, test.ClusterName) - if err := teleport.CreateConfigMap(ctx, log, ctrlClient, test.ClusterName, test.NamespaceName, registerName, test.TokenName, []string{"kube", "app"}); err != nil { + if err := teleport.CreateConfigMap(ctx, log, ctrlClient, test.ClusterName, test.NamespaceName, registerName, test.TokenName, []string{"kube", "app"}, test.AppVersionNested); err != nil { t.Fatalf("unexpected error %v", err) } @@ -279,7 +278,7 @@ func Test_CreateConfigMap_NestedLayout(t *testing.T) { test.CheckConfigMap(t, expected, actual) } -func Test_CreateConfigMap_NestedLayout_SkipsDowngradeOverride(t *testing.T) { +func Test_CreateConfigMap_NestedSkipsDowngradeOverride(t *testing.T) { ctx := context.TODO() log := ctrl.Log.WithName("test") @@ -290,13 +289,12 @@ func Test_CreateConfigMap_NestedLayout_SkipsDowngradeOverride(t *testing.T) { teleport := New(test.NamespaceName, &config.Config{ AppName: test.AppName, - AppVersion: test.AppVersionNested, ProxyAddr: test.ProxyAddr, TeleportVersion: test.TeleportVersion, // 1.0.0 - below bundled 18.7.6 }, token.NewGenerator()) registerName := key.GetRegisterName(test.ManagementClusterName, test.ClusterName) - if err := teleport.CreateConfigMap(ctx, log, ctrlClient, test.ClusterName, test.NamespaceName, registerName, test.TokenName, []string{"kube", "app"}); err != nil { + if err := teleport.CreateConfigMap(ctx, log, ctrlClient, test.ClusterName, test.NamespaceName, registerName, test.TokenName, []string{"kube", "app"}, test.AppVersionNested); err != nil { t.Fatalf("unexpected error %v", err) } @@ -308,6 +306,34 @@ func Test_CreateConfigMap_NestedLayout_SkipsDowngradeOverride(t *testing.T) { test.CheckConfigMap(t, expected, actual) } +func Test_CreateConfigMap_DualBlockWhenTKAUnknown(t *testing.T) { + ctx := context.TODO() + log := ctrl.Log.WithName("test") + + ctrlClient, err := test.NewFakeK8sClient(nil) + if err != nil { + t.Fatalf("unexpected error %v", err) + } + + teleport := New(test.NamespaceName, &config.Config{ + AppName: test.AppName, + ProxyAddr: test.ProxyAddr, + TeleportVersion: test.TeleportVersion, // 1.0.0 - flat passes through, nested drops (below floor) + }, token.NewGenerator()) + + registerName := key.GetRegisterName(test.ManagementClusterName, test.ClusterName) + if err := teleport.CreateConfigMap(ctx, log, ctrlClient, test.ClusterName, test.NamespaceName, registerName, test.TokenName, []string{"kube", "app"}, ""); err != nil { + t.Fatalf("unexpected error %v", err) + } + + expected := test.NewDualBlockConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{"kube", "app"}) + actual, err := loadConfigMap(ctx, ctrlClient, expected) + if err != nil { + t.Fatalf("unexpected error %v", err) + } + test.CheckConfigMap(t, expected, actual) +} + func Test_UpdateConfigMap_MigratesFlatToNested(t *testing.T) { ctx := context.TODO() log := ctrl.Log.WithName("test") @@ -320,12 +346,11 @@ func Test_UpdateConfigMap_MigratesFlatToNested(t *testing.T) { teleport := New(test.NamespaceName, &config.Config{ AppName: test.AppName, - AppVersion: test.AppVersionNested, ProxyAddr: test.ProxyAddr, TeleportVersion: test.TeleportVersionForNested, }, token.NewGenerator()) - if err := teleport.UpdateConfigMap(ctx, log, ctrlClient, existing, test.NewTokenName, []string{"kube", "app"}); err != nil { + if err := teleport.UpdateConfigMap(ctx, log, ctrlClient, existing, test.NewTokenName, []string{"kube", "app"}, test.AppVersionNested); err != nil { t.Fatalf("unexpected error %v", err) } @@ -337,7 +362,7 @@ func Test_UpdateConfigMap_MigratesFlatToNested(t *testing.T) { test.CheckConfigMap(t, expected, actual) } -func Test_UpdateConfigMap_NestedLayout_StripsDowngradeOverride(t *testing.T) { +func Test_UpdateConfigMap_NestedDropsDowngradeOverride(t *testing.T) { ctx := context.TODO() log := ctrl.Log.WithName("test") @@ -349,12 +374,11 @@ func Test_UpdateConfigMap_NestedLayout_StripsDowngradeOverride(t *testing.T) { teleport := New(test.NamespaceName, &config.Config{ AppName: test.AppName, - AppVersion: test.AppVersionNested, ProxyAddr: test.ProxyAddr, TeleportVersion: test.TeleportVersion, // downgrade vs bundled 18.7.6 }, token.NewGenerator()) - if err := teleport.UpdateConfigMap(ctx, log, ctrlClient, existing, test.TokenName, []string{"kube", "app"}); err != nil { + if err := teleport.UpdateConfigMap(ctx, log, ctrlClient, existing, test.TokenName, []string{"kube", "app"}, test.AppVersionNested); err != nil { t.Fatalf("unexpected error %v", err) } @@ -366,7 +390,7 @@ func Test_UpdateConfigMap_NestedLayout_StripsDowngradeOverride(t *testing.T) { test.CheckConfigMap(t, expected, actual) } -func Test_UpdateConfigMap_RefreshesTeleportVersionOverride(t *testing.T) { +func Test_UpdateConfigMap_DualBlockForUnknownTKA(t *testing.T) { ctx := context.TODO() log := ctrl.Log.WithName("test") @@ -379,14 +403,14 @@ func Test_UpdateConfigMap_RefreshesTeleportVersionOverride(t *testing.T) { teleport := New(test.NamespaceName, &config.Config{ AppName: test.AppName, ProxyAddr: test.ProxyAddr, - TeleportVersion: test.TeleportVersionNew, + TeleportVersion: test.TeleportVersion, // 1.0.0 - matches NewDualBlockConfigMap fixture }, token.NewGenerator()) - if err := teleport.UpdateConfigMap(ctx, log, ctrlClient, existing, test.TokenName, []string{"kube", "app"}); err != nil { + if err := teleport.UpdateConfigMap(ctx, log, ctrlClient, existing, test.TokenName, []string{"kube", "app"}, ""); err != nil { t.Fatalf("unexpected error %v", err) } - expected := test.NewConfigMapWithTeleportVersion(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, test.TeleportVersionNew, []string{"kube", "app"}) + expected := test.NewDualBlockConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{"kube", "app"}) actual, err := loadConfigMap(ctx, ctrlClient, expected) if err != nil { t.Fatalf("unexpected error %v", err) @@ -399,9 +423,8 @@ func Test_GetTokenFromConfigMap_NestedLayout(t *testing.T) { nested := test.NewNestedConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{"kube", "app"}) teleport := New(test.NamespaceName, &config.Config{ - AppName: test.AppName, - AppVersion: test.AppVersionNested, - ProxyAddr: test.ProxyAddr, + AppName: test.AppName, + ProxyAddr: test.ProxyAddr, }, token.NewGenerator()) got, err := teleport.GetTokenFromConfigMap(ctx, nested) @@ -416,24 +439,26 @@ func Test_GetTokenFromConfigMap_NestedLayout(t *testing.T) { func Test_IsConfigMapLayoutUpToDate(t *testing.T) { flat := test.NewConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{"kube"}) nested := test.NewNestedConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{"kube"}) + dual := test.NewDualBlockConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{"kube"}) - newApp := New(test.NamespaceName, &config.Config{AppName: test.AppName, AppVersion: test.AppVersionNested}, token.NewGenerator()) - oldApp := New(test.NamespaceName, &config.Config{AppName: test.AppName, AppVersion: "0.3.0"}, token.NewGenerator()) + tele := New(test.NamespaceName, &config.Config{AppName: test.AppName}, token.NewGenerator()) cases := []struct { - name string - tele *Teleport - cm *corev1.ConfigMap - wantOk bool + name string + cm *corev1.ConfigMap + tkaVersion string + wantOk bool }{ - {"flat-old-ok", oldApp, flat, true}, - {"flat-new-mismatch", newApp, flat, false}, - {"nested-new-ok", newApp, nested, true}, - {"nested-old-mismatch", oldApp, nested, false}, + {"flat-unknown-needs-nested", flat, "", false}, + {"flat-new-needs-nested-only", flat, test.AppVersionNested, false}, + {"nested-new-ok", nested, test.AppVersionNested, true}, + {"nested-unknown-needs-flat", nested, "", false}, + {"dual-unknown-ok", dual, "", true}, + {"dual-new-still-has-flat", dual, test.AppVersionNested, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - ok, err := c.tele.IsConfigMapLayoutUpToDate(c.cm) + ok, err := tele.IsConfigMapLayoutUpToDate(c.cm, c.tkaVersion) if err != nil { t.Fatalf("unexpected error %v", err) } diff --git a/internal/pkg/test/resources.go b/internal/pkg/test/resources.go index c1b6cfd0..83cad220 100644 --- a/internal/pkg/test/resources.go +++ b/internal/pkg/test/resources.go @@ -111,6 +111,49 @@ func NewNestedConfigMapWithoutVersionOverride(clusterName, appName, namespaceNam return newConfigMap(clusterName, appName, namespaceName, tokenName, roles, "", ConfigMapValuesNestedFormatNoVerOvr) } +// NewDualBlockConfigMap returns a ConfigMap with both the flat root layout +// (legacy passthrough teleportVersionOverride) AND the nested +// teleport-kube-agent: block, matching what the operator emits for clusters +// whose TKA chart version is unknown or < v0.11.0. The flat block keeps +// TeleportVersion as a passthrough; the nested block has no override +// because TeleportVersion (1.0.0) is below the bundled-default floor. +func NewDualBlockConfigMap(clusterName, appName, namespaceName, tokenName string, roles []string) *corev1.ConfigMap { + return NewDualBlockConfigMapWithTeleportVersion(clusterName, appName, namespaceName, tokenName, TeleportVersion, roles) +} + +// NewDualBlockConfigMapWithTeleportVersion is NewDualBlockConfigMap with the +// flat block's teleportVersionOverride set to the given value. The nested +// block omits the override unless teleportVersion is at or above +// teleport-kube-agent's bundled-default floor (handled in tests by using +// TeleportVersionForNested when needed). +func NewDualBlockConfigMapWithTeleportVersion(clusterName, appName, namespaceName, tokenName, teleportVersion string, roles []string) *corev1.ConfigMap { + registerName := key.GetRegisterName(ManagementClusterName, clusterName) + flat := fmt.Sprintf(`roles: "%s" +authToken: "%s" +proxyAddr: "%s" +kubeClusterName: "%s" +`, strings.Join(roles, ","), tokenName, ProxyAddr, registerName) + if teleportVersion != "" { + flat += fmt.Sprintf("teleportVersionOverride: %q\n", teleportVersion) + } + nested := fmt.Sprintf(`%s: + roles: "%s" + authToken: "%s" + proxyAddr: "%s" + kubeClusterName: "%s" +`, key.TeleportKubeAgentValuesKey, strings.Join(roles, ","), tokenName, ProxyAddr, registerName) + if override := key.ResolveNestedTeleportVersionOverride(teleportVersion); override != "" { + nested += fmt.Sprintf(" teleportVersionOverride: %q\n", override) + } + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: key.GetConfigmapName(clusterName, appName), + Namespace: namespaceName, + }, + Data: map[string]string{"values": flat + nested}, + } +} + func newConfigMap(clusterName, appName, namespaceName, tokenName string, roles []string, teleportVersion, valuesFormat string) *corev1.ConfigMap { registerName := key.GetRegisterName(ManagementClusterName, clusterName) values := fmt.Sprintf(valuesFormat, tokenName, ProxyAddr, strings.Join(roles, ","), registerName, teleportVersion)