diff --git a/pkg/reconciler/kubernetes/tektoninstallerset/client/check.go b/pkg/reconciler/kubernetes/tektoninstallerset/client/check.go index 39e7ed4616..9a43fc79d3 100644 --- a/pkg/reconciler/kubernetes/tektoninstallerset/client/check.go +++ b/pkg/reconciler/kubernetes/tektoninstallerset/client/check.go @@ -135,7 +135,7 @@ func verifyMeta(resourceKind, isType string, logger *zap.SugaredLogger, set v1al // Spec Hash Check logger.Debugf("%v/%v: spec hash check", resourceKind, isType) - expectedHash, err := hash.Compute(comp.GetSpec()) + expectedHash, err := hash.Compute(specHashInput(comp)) if err != nil { return err } @@ -149,3 +149,17 @@ func verifyMeta(resourceKind, isType string, logger *zap.SugaredLogger, set v1al return nil } + +// specHashInput is the canonical input for computing an InstallerSet's spec hash. +// Including PlatformDataHashKey means that operator-injected platform config +// (e.g. OpenShift APIServer TLS profile) causes InstallerSets to be refreshed +// when that config changes, without requiring the component's spec to change. +func specHashInput(comp v1alpha1.TektonComponent) interface{} { + return struct { + Spec interface{} + PlatformData string + }{ + Spec: comp.GetSpec(), + PlatformData: comp.GetAnnotations()[v1alpha1.PlatformDataHashKey], + } +} diff --git a/pkg/reconciler/kubernetes/tektoninstallerset/client/check_test.go b/pkg/reconciler/kubernetes/tektoninstallerset/client/check_test.go index 322245e50c..abd258f3cb 100644 --- a/pkg/reconciler/kubernetes/tektoninstallerset/client/check_test.go +++ b/pkg/reconciler/kubernetes/tektoninstallerset/client/check_test.go @@ -49,7 +49,7 @@ func buildTriggerComponent(disabled bool) *v1alpha1.TektonTrigger { } func computeHash(comp *v1alpha1.TektonTrigger) string { - h, err := hash.Compute(comp.GetSpec()) + h, err := hash.Compute(specHashInput(comp)) if err != nil { panic("failed to compute hash: " + err.Error()) } diff --git a/pkg/reconciler/kubernetes/tektoninstallerset/client/create.go b/pkg/reconciler/kubernetes/tektoninstallerset/client/create.go index 09229d27f0..2c5473f019 100644 --- a/pkg/reconciler/kubernetes/tektoninstallerset/client/create.go +++ b/pkg/reconciler/kubernetes/tektoninstallerset/client/create.go @@ -131,7 +131,7 @@ func (i *InstallerSetClient) waitForStatus(ctx context.Context, set *v1alpha1.Te } func (i *InstallerSetClient) makeInstallerSet(ctx context.Context, comp v1alpha1.TektonComponent, manifest *mf.Manifest, isName, isType string, customLabels map[string]string) (*v1alpha1.TektonInstallerSet, error) { - specHash, err := hash.Compute(comp.GetSpec()) + specHash, err := hash.Compute(specHashInput(comp)) if err != nil { return nil, err } diff --git a/pkg/reconciler/kubernetes/tektoninstallerset/client/update.go b/pkg/reconciler/kubernetes/tektoninstallerset/client/update.go index f3a7dae5fd..36db5428d8 100644 --- a/pkg/reconciler/kubernetes/tektoninstallerset/client/update.go +++ b/pkg/reconciler/kubernetes/tektoninstallerset/client/update.go @@ -88,7 +88,7 @@ func (i *InstallerSetClient) updateSet(ctx context.Context, comp v1alpha1.Tekton return err } - specHash, err := hash.Compute(comp.GetSpec()) + specHash, err := hash.Compute(specHashInput(comp)) if err != nil { return err } diff --git a/pkg/reconciler/kubernetes/tektoninstallerset/client/update_test.go b/pkg/reconciler/kubernetes/tektoninstallerset/client/update_test.go index 3626229438..9de48e9395 100644 --- a/pkg/reconciler/kubernetes/tektoninstallerset/client/update_test.go +++ b/pkg/reconciler/kubernetes/tektoninstallerset/client/update_test.go @@ -41,7 +41,7 @@ func TestInstallerSetClient_Update(t *testing.T) { }, } - expectedHash, err := hash.Compute(comp.GetSpec()) + expectedHash, err := hash.Compute(specHashInput(comp)) assert.NilError(t, err) tests := []struct { diff --git a/pkg/reconciler/openshift/tektonpipeline/extension.go b/pkg/reconciler/openshift/tektonpipeline/extension.go index 0e6d3322d3..ff7568842c 100644 --- a/pkg/reconciler/openshift/tektonpipeline/extension.go +++ b/pkg/reconciler/openshift/tektonpipeline/extension.go @@ -25,6 +25,7 @@ import ( mf "github.com/manifestival/manifestival" "github.com/tektoncd/operator/pkg/apis/operator/v1alpha1" operatorclient "github.com/tektoncd/operator/pkg/client/injection/client" + tektonConfiginformer "github.com/tektoncd/operator/pkg/client/injection/informers/operator/v1alpha1/tektonconfig" "github.com/tektoncd/operator/pkg/reconciler/common" "github.com/tektoncd/operator/pkg/reconciler/kubernetes/tektoninstallerset/client" occommon "github.com/tektoncd/operator/pkg/reconciler/openshift/common" @@ -40,6 +41,8 @@ const ( tektonPipelinesControllerName = "tekton-pipelines-controller" tektonRemoteResolversControllerName = "tekton-pipelines-remote-resolvers" + tektonPipelinesWebhookDeployment = "tekton-pipelines-webhook" + webhookContainerName = "webhook" ) func OpenShiftExtension(ctx context.Context) common.Extension { @@ -49,12 +52,13 @@ func OpenShiftExtension(ctx context.Context) common.Extension { logger.Fatal("Failed to find version from env") } - ext := openshiftExtension{ + ext := &openshiftExtension{ // component version is used for metrics, passing a dummy // value through extension not going to affect execution installerSetClient: client.NewInstallerSetClient(operatorclient.Get(ctx).OperatorV1alpha1().TektonInstallerSets(), version, "pipelines-ext", v1alpha1.KindTektonPipeline, nil), - kubeClientSet: kubeclient.Get(ctx), + kubeClientSet: kubeclient.Get(ctx), + tektonConfigLister: tektonConfiginformer.Get(ctx).Lister(), } return ext } @@ -62,9 +66,11 @@ func OpenShiftExtension(ctx context.Context) common.Extension { type openshiftExtension struct { installerSetClient *client.InstallerSetClient kubeClientSet kubernetes.Interface + tektonConfigLister occommon.TektonConfigLister + resolvedTLSConfig *occommon.TLSEnvVars } -func (oe openshiftExtension) Transformers(comp v1alpha1.TektonComponent) []mf.Transformer { +func (oe *openshiftExtension) Transformers(comp v1alpha1.TektonComponent) []mf.Transformer { trns := []mf.Transformer{ occommon.ApplyCABundlesToDeployment, occommon.RemoveRunAsUser(), @@ -73,9 +79,28 @@ func (oe openshiftExtension) Transformers(comp v1alpha1.TektonComponent) []mf.Tr occommon.ApplyCABundlesForStatefulSet(tektonPipelinesControllerName), occommon.ApplyCABundlesForStatefulSet(tektonRemoteResolversControllerName), } + + // Inject APIServer TLS profile env vars into the webhook so that it applies + // the cluster-wide TLS version and cipher suite policy (PQC readiness). + if oe.resolvedTLSConfig != nil { + trns = append(trns, occommon.InjectTLSEnvVars(oe.resolvedTLSConfig, "Deployment", tektonPipelinesWebhookDeployment, []string{webhookContainerName})) + } + return trns } -func (oe openshiftExtension) PreReconcile(ctx context.Context, comp v1alpha1.TektonComponent) error { + +func (oe *openshiftExtension) PreReconcile(ctx context.Context, comp v1alpha1.TektonComponent) error { + logger := logging.FromContext(ctx) + + resolvedTLS, err := occommon.ResolveCentralTLSToEnvVars(ctx, oe.tektonConfigLister) + if err != nil { + return err + } + oe.resolvedTLSConfig = resolvedTLS + if oe.resolvedTLSConfig != nil { + logger.Infof("Injecting central TLS config into webhook: MinVersion=%s", oe.resolvedTLSConfig.MinVersion) + } + manifest, err := preManifest() if err != nil { return err @@ -102,7 +127,7 @@ func (oe openshiftExtension) PreReconcile(ctx context.Context, comp v1alpha1.Tek return common.ReconcileTargetNamespace(ctx, labels, nil, comp, oe.kubeClientSet) } -func (oe openshiftExtension) PostReconcile(ctx context.Context, comp v1alpha1.TektonComponent) error { +func (oe *openshiftExtension) PostReconcile(ctx context.Context, comp v1alpha1.TektonComponent) error { pipeline := comp.(*v1alpha1.TektonPipeline) // Install monitoring if metrics is enabled @@ -124,7 +149,7 @@ func (oe openshiftExtension) PostReconcile(ctx context.Context, comp v1alpha1.Te return nil } -func (oe openshiftExtension) Finalize(ctx context.Context, comp v1alpha1.TektonComponent) error { +func (oe *openshiftExtension) Finalize(ctx context.Context, comp v1alpha1.TektonComponent) error { if err := oe.installerSetClient.CleanupPostSet(ctx); err != nil { return err } @@ -134,7 +159,7 @@ func (oe openshiftExtension) Finalize(ctx context.Context, comp v1alpha1.TektonC return nil } -func (oe openshiftExtension) GetPlatformData() string { +func (oe *openshiftExtension) GetPlatformData() string { return "" } diff --git a/pkg/reconciler/openshift/tektonpipeline/extension_test.go b/pkg/reconciler/openshift/tektonpipeline/extension_test.go new file mode 100644 index 0000000000..4f2e84e5c6 --- /dev/null +++ b/pkg/reconciler/openshift/tektonpipeline/extension_test.go @@ -0,0 +1,199 @@ +/* +Copyright 2026 The Tekton Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tektonpipeline + +import ( + "testing" + + mf "github.com/manifestival/manifestival" + "github.com/tektoncd/operator/pkg/apis/operator/v1alpha1" + occommon "github.com/tektoncd/operator/pkg/reconciler/openshift/common" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" +) + +// makeWebhookDeployment returns an unstructured webhook Deployment for transformer tests. +func makeWebhookDeployment(t *testing.T) unstructured.Unstructured { + t.Helper() + + d := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: tektonPipelinesWebhookDeployment, + Namespace: "tekton-pipelines", + }, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: webhookContainerName}, + }, + }, + }, + }, + } + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(d) + if err != nil { + t.Fatalf("failed to convert deployment to unstructured: %v", err) + } + u := unstructured.Unstructured{Object: obj} + u.SetKind("Deployment") + u.SetAPIVersion("apps/v1") + return u +} + +func TestTransformers_NoTLSConfig(t *testing.T) { + ext := &openshiftExtension{ + resolvedTLSConfig: nil, + } + + transformers := ext.Transformers(&v1alpha1.TektonPipeline{}) + + // Without TLS config, the base transformers are returned but TLS injection is not present. + // Verify by applying transformers to a webhook deployment — env vars must remain empty. + u := makeWebhookDeployment(t) + manifest, err := mf.ManifestFrom(mf.Slice([]unstructured.Unstructured{u})) + if err != nil { + t.Fatalf("failed to build manifest: %v", err) + } + + transformed, err := manifest.Transform(transformers...) + if err != nil { + t.Fatalf("transform failed: %v", err) + } + + d := &appsv1.Deployment{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(transformed.Resources()[0].Object, d); err != nil { + t.Fatalf("failed to convert back: %v", err) + } + for _, c := range d.Spec.Template.Spec.Containers { + if c.Name != webhookContainerName { + continue + } + for _, e := range c.Env { + if e.Name == occommon.TLSMinVersionEnvVar || e.Name == occommon.TLSCipherSuitesEnvVar { + t.Errorf("unexpected TLS env var %s set when resolvedTLSConfig is nil", e.Name) + } + } + } +} + +func TestTransformers_WithTLSConfig_InjectsEnvVarsIntoWebhook(t *testing.T) { + tlsConfig := &occommon.TLSEnvVars{ + MinVersion: "1.2", + CipherSuites: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_AES_128_GCM_SHA256", + } + ext := &openshiftExtension{ + resolvedTLSConfig: tlsConfig, + } + + transformers := ext.Transformers(&v1alpha1.TektonPipeline{}) + + u := makeWebhookDeployment(t) + manifest, err := mf.ManifestFrom(mf.Slice([]unstructured.Unstructured{u})) + if err != nil { + t.Fatalf("failed to build manifest: %v", err) + } + + transformed, err := manifest.Transform(transformers...) + if err != nil { + t.Fatalf("transform failed: %v", err) + } + + d := &appsv1.Deployment{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(transformed.Resources()[0].Object, d); err != nil { + t.Fatalf("failed to convert back: %v", err) + } + + envMap := map[string]string{} + for _, c := range d.Spec.Template.Spec.Containers { + if c.Name != webhookContainerName { + continue + } + for _, e := range c.Env { + envMap[e.Name] = e.Value + } + } + + if got := envMap[occommon.TLSMinVersionEnvVar]; got != tlsConfig.MinVersion { + t.Errorf("%s = %q, want %q", occommon.TLSMinVersionEnvVar, got, tlsConfig.MinVersion) + } + if got := envMap[occommon.TLSCipherSuitesEnvVar]; got != tlsConfig.CipherSuites { + t.Errorf("%s = %q, want %q", occommon.TLSCipherSuitesEnvVar, got, tlsConfig.CipherSuites) + } +} + +func TestTransformers_WithTLSConfig_DoesNotInjectIntoOtherDeployments(t *testing.T) { + tlsConfig := &occommon.TLSEnvVars{ + MinVersion: "1.3", + CipherSuites: "TLS_AES_128_GCM_SHA256", + } + ext := &openshiftExtension{ + resolvedTLSConfig: tlsConfig, + } + + transformers := ext.Transformers(&v1alpha1.TektonPipeline{}) + + // Use a different deployment name — TLS env vars must NOT be injected. + d := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: tektonPipelinesControllerName, + Namespace: "tekton-pipelines", + }, + Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "controller"}, + }, + }, + }, + }, + } + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(d) + if err != nil { + t.Fatalf("failed to convert: %v", err) + } + u := unstructured.Unstructured{Object: obj} + u.SetKind("Deployment") + u.SetAPIVersion("apps/v1") + + manifest, err := mf.ManifestFrom(mf.Slice([]unstructured.Unstructured{u})) + if err != nil { + t.Fatalf("failed to build manifest: %v", err) + } + + transformed, err := manifest.Transform(transformers...) + if err != nil { + t.Fatalf("transform failed: %v", err) + } + + result := &appsv1.Deployment{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(transformed.Resources()[0].Object, result); err != nil { + t.Fatalf("failed to convert back: %v", err) + } + + for _, c := range result.Spec.Template.Spec.Containers { + for _, e := range c.Env { + if e.Name == occommon.TLSMinVersionEnvVar || e.Name == occommon.TLSCipherSuitesEnvVar { + t.Errorf("unexpected TLS env var %s injected into non-webhook deployment", e.Name) + } + } + } +} diff --git a/pkg/reconciler/shared/tektonconfig/pipeline/pipeline.go b/pkg/reconciler/shared/tektonconfig/pipeline/pipeline.go index 880e12e5ca..61d8489df1 100644 --- a/pkg/reconciler/shared/tektonconfig/pipeline/pipeline.go +++ b/pkg/reconciler/shared/tektonconfig/pipeline/pipeline.go @@ -133,6 +133,16 @@ func UpdatePipeline(ctx context.Context, old *v1alpha1.TektonPipeline, new *v1al updated = true } + oldPlatformData := old.ObjectMeta.Annotations[v1alpha1.PlatformDataHashKey] + newPlatformData := new.ObjectMeta.Annotations[v1alpha1.PlatformDataHashKey] + if oldPlatformData != newPlatformData { + if old.ObjectMeta.Annotations == nil { + old.ObjectMeta.Annotations = map[string]string{} + } + old.ObjectMeta.Annotations[v1alpha1.PlatformDataHashKey] = newPlatformData + updated = true + } + if updated { _, err := clients.Update(ctx, old, metav1.UpdateOptions{}) if err != nil { diff --git a/pkg/reconciler/shared/tektonconfig/tektonconfig.go b/pkg/reconciler/shared/tektonconfig/tektonconfig.go index 4189669c99..a70165e3ac 100644 --- a/pkg/reconciler/shared/tektonconfig/tektonconfig.go +++ b/pkg/reconciler/shared/tektonconfig/tektonconfig.go @@ -178,6 +178,12 @@ func (r *Reconciler) ReconcileKind(ctx context.Context, tc *v1alpha1.TektonConfi // Ensure Pipeline CR tektonpipeline := pipeline.GetTektonPipelineCR(tc, r.operatorVersion) + if platformData := r.extension.GetPlatformData(); platformData != "" { + if tektonpipeline.Annotations == nil { + tektonpipeline.Annotations = map[string]string{} + } + tektonpipeline.Annotations[v1alpha1.PlatformDataHashKey] = platformData + } logger.Debug("Ensuring TektonPipeline CR exists") if _, err := pipeline.EnsureTektonPipelineExists(ctx, r.operatorClientSet.OperatorV1alpha1().TektonPipelines(), tektonpipeline); err != nil { errMsg := fmt.Sprintf("TektonPipeline: %s", err.Error())