Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
41 changes: 35 additions & 6 deletions internal/controller/cluster_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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))
}
}

Expand Down
63 changes: 58 additions & 5 deletions internal/controller/cluster_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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},
},
{
Expand All @@ -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},
},
{
Expand All @@ -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},
},
{
Expand All @@ -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},
},
{
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -323,13 +364,25 @@ 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,
LastRead: lastRead,
}
}

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 {
Expand Down
100 changes: 94 additions & 6 deletions internal/pkg/key/key.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"strings"
"time"

"github.com/Masterminds/semver/v3"
"github.com/gravitational/teleport/api/types"
)

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