diff --git a/CHANGELOG.md b/CHANGELOG.md index 67bd673b..296938ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +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. +- 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 + +- 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/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..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) @@ -237,17 +247,36 @@ func (r *ClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct if err != nil { return ctrl.Result{}, microerror.Mask(err) } + + writeToken := token if !tokenValid { - newToken, err := r.Teleport.GenerateToken(ctx, registerName, roles) + writeToken, 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 { + } + + // 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: + 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) - } else { - log.Info("ConfigMap has valid teleport join token", "configMapName", configMap.GetName(), "roles", roles) + 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, + "nestedValuesOnly", key.UsesNestedKubeAgentValues(tkaVersion)) } } diff --git a/internal/controller/cluster_controller_test.go b/internal/controller/cluster_controller_test.go index 7ac58cbe..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,9 +158,46 @@ 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}, }, + { + 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.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}, + }, { name: "case 6: Return an error in case reconnection to Teleport fails after the credentials are rotated", namespace: test.NamespaceName, @@ -195,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 { @@ -323,6 +364,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, @@ -330,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 0174fd6a..b75c54e0 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,20 @@ const ( RoleKube = "kube" RoleApp = "app" RoleNode = "node" + + // TeleportKubeAgentValuesKey is the top-level key under which + // teleport-kube-agent v0.11.0+ reads its values. + TeleportKubeAgentValuesKey = "teleport-kube-agent" + + // teleportKubeAgentNestedSinceVersion is the lowest chart version that + // reads its values nested under TeleportKubeAgentValuesKey. + teleportKubeAgentNestedSinceVersion = "0.11.0" + + // 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" ) func ParseRoles(s string) ([]string, error) { @@ -97,18 +112,91 @@ 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 { - dataTpl := `roles: "%s" +// UsesNestedKubeAgentValues reports whether the teleport-kube-agent chart +// version expects its values nested under the `teleport-kube-agent` key. +// 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.Compare(threshold) >= 0 +} + +// 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 "" + } + v, err := semver.NewVersion(strings.TrimPrefix(teleportVersion, "v")) + if err != nil { + return "" + } + bundled := semver.MustParse(teleportKubeAgentBundledTeleportVersion) + if v.Compare(bundled) < 0 { + return "" + } + return teleportVersion +} + +// 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" -` - +`, RolesToString(roles), authToken, proxyAddr, kubeClusterName) if teleportVersion != "" { - dataTpl = fmt.Sprintf("%steleportVersionOverride: %q", dataTpl, teleportVersion) + body += fmt.Sprintf("teleportVersionOverride: %q\n", teleportVersion) } + return body +} - return fmt.Sprintf(dataTpl, RolesToString(roles), authToken, proxyAddr, kubeClusterName) +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 body } 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..a84a81ff --- /dev/null +++ b/internal/pkg/key/key_test.go @@ -0,0 +1,117 @@ +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", false}, + {"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_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-only 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 TestResolveNestedTeleportVersionOverride(t *testing.T) { + cases := []struct { + name string + teleportVersion string + want string + }{ + {"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 := 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 44271395..f0582a0f 100644 --- a/internal/pkg/teleport/configmap.go +++ b/internal/pkg/teleport/configmap.go @@ -50,29 +50,81 @@ 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 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 "", 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) + _, 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) { + 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 { +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{} @@ -137,27 +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 { - 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["authToken"] = token - valuesYaml["roles"] = key.RolesToString(roles) - - updatedValuesYaml, err := yaml.Marshal(valuesYaml) +// 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 fmt.Errorf("failed to marshal updated content into YAML: %w", err) + return err } - // Update the ConfigMap's data with the modified value - configMap.Data["values"] = string(updatedValuesYaml) + if configMap.Data == nil { + configMap.Data = map[string]string{} + } + 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)) } @@ -165,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{ @@ -205,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) +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 63124f39..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) @@ -249,3 +249,222 @@ func loadConfigMap(ctx context.Context, ctrlClient client.Client, expected *core } return actual, err } + +func Test_CreateConfigMap_NestedOnlyFor0_11_0(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.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"}, test.AppVersionNested); 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_CreateConfigMap_NestedSkipsDowngradeOverride(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 - 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"}, test.AppVersionNested); 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_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") + + 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.TeleportVersionForNested, + }, token.NewGenerator()) + + if err := teleport.UpdateConfigMap(ctx, log, ctrlClient, existing, test.NewTokenName, []string{"kube", "app"}, test.AppVersionNested); 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_NestedDropsDowngradeOverride(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, + 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"}, test.AppVersionNested); 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_DualBlockForUnknownTKA(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.TeleportVersion, // 1.0.0 - matches NewDualBlockConfigMap fixture + }, 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.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_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, + 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"}) + dual := test.NewDualBlockConfigMap(test.ClusterName, test.AppName, test.NamespaceName, test.TokenName, []string{"kube"}) + + tele := New(test.NamespaceName, &config.Config{AppName: test.AppName}, token.NewGenerator()) + + cases := []struct { + name string + cm *corev1.ConfigMap + tkaVersion string + wantOk bool + }{ + {"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 := tele.IsConfigMapLayoutUpToDate(c.cm, c.tkaVersion) + 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..83cad220 100644 --- a/internal/pkg/test/resources.go +++ b/internal/pkg/test/resources.go @@ -36,14 +36,20 @@ const ( TokenTypeNode = "node" TokenTypeApp = "app" - AppCatalog = "app-catalog" - AppVersion = "appVersion" - ManagementClusterName = "management-cluster" - ProxyAddr = "127.0.0.1" - IdentityFileValue = "identity-file-value" - TeleportVersion = "1.0.0" - - ConfigMapValuesFormat = "authToken: %s\nproxyAddr: %s\nroles: %s\nkubeClusterName: %s\nteleportVersionOverride: %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() @@ -86,14 +92,81 @@ 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, 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) +} + +// 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) + 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(ConfigMapValuesFormat, tokenName, ProxyAddr, strings.Join(roles, ","), registerName, TeleportVersion), + "values": values, }, } }