From 459e70e6090d9efe7fbb572e4f3a2d99204b16fa Mon Sep 17 00:00:00 2001 From: Davide Doninelli Date: Wed, 21 Oct 2026 14:29:52 +0200 Subject: [PATCH] feat(registry): add support for overriding image registry in Helm chart and components Ensure the image registry override is idempotent and update the documentation accordingly. Signed-off-by: Davide Doninelli --- charts/tekton-operator/templates/_helpers.tpl | 43 ++++++- .../tekton-operator/templates/deployment.yaml | 2 + charts/tekton-operator/values.yaml | 5 + docs/AirGapImageConfiguration.md | 13 +++ pkg/reconciler/common/prune.go | 2 +- .../test-replace-image-airgapped.yaml | 69 +++++++++++ pkg/reconciler/common/transformers.go | 30 ++++- pkg/reconciler/common/transformers_test.go | 108 ++++++++++++++++++ 8 files changed, 264 insertions(+), 8 deletions(-) create mode 100644 pkg/reconciler/common/testdata/test-replace-image-airgapped.yaml diff --git a/charts/tekton-operator/templates/_helpers.tpl b/charts/tekton-operator/templates/_helpers.tpl index 76a01005fc..b2a605a3a7 100644 --- a/charts/tekton-operator/templates/_helpers.tpl +++ b/charts/tekton-operator/templates/_helpers.tpl @@ -108,15 +108,17 @@ tekton-operator {{- $image = "ghcr.io/tektoncd/operator/operator-303303c315a48490ba6517859ef65b77" -}} {{- end -}} {{- end -}} -{{- printf "%s:%s" $image $tag -}} +{{- include "tekton-operator.withRegistry" (dict "image" (printf "%s:%s" $image $tag) "registry" .Values.registry) -}} {{- end -}} {{- define "tekton-operator.pruner-image" -}} +{{- $image := "" -}} {{- if contains "sha256:" .Values.pruner.image.tag -}} -{{- printf "%s@%s" .Values.pruner.image.repository .Values.pruner.image.tag -}} +{{- $image = printf "%s@%s" .Values.pruner.image.repository .Values.pruner.image.tag -}} {{- else -}} -{{- printf "%s:%s" .Values.pruner.image.repository .Values.pruner.image.tag -}} +{{- $image = printf "%s:%s" .Values.pruner.image.repository .Values.pruner.image.tag -}} {{- end -}} +{{- $image -}} {{- end -}} {{- define "tekton-operator.webhook-image" -}} @@ -131,7 +133,7 @@ tekton-operator {{- $image = "ghcr.io/tektoncd/operator/webhook-f2bb711aa8f0c0892856a4cbf6d9ddd8" -}} {{- end -}} {{- end -}} -{{- printf "%s:%s" $image $tag -}} +{{- include "tekton-operator.withRegistry" (dict "image" (printf "%s:%s" $image $tag) "registry" .Values.registry) -}} {{- end -}} @@ -156,3 +158,36 @@ tekton-operator {{- end -}} {{- printf "%s:%s" $image $tag -}} {{- end -}} + +{{/* +Replace the image's registry host with .Values.registry (same rule as TEKTON_REGISTRY_OVERRIDE). +Images already under .Values.registry are left unchanged. +*/}} +{{- define "tekton-operator.withRegistry" -}} +{{- $image := .image -}} +{{- $registry := .registry | default "" | trimSuffix "/" -}} +{{- if and $registry (not (hasPrefix (printf "%s/" $registry) $image)) -}} +{{- $parts := splitList "/" $image -}} +{{- $first := first $parts -}} +{{- if and (gt (len $parts) 1) (or (contains "." $first) (contains ":" $first) (eq $first "localhost")) -}} +{{- $image = printf "%s/%s" $registry (join "/" (rest $parts)) -}} +{{- else -}} +{{- $image = printf "%s/%s" $registry $image -}} +{{- end -}} +{{- end -}} +{{- $image -}} +{{- end -}} + +{{/* +TEKTON_REGISTRY_OVERRIDE from .Values.registry, unless set in operator.additionalEnvs. +*/}} +{{- define "tekton-operator.registryOverrideEnv" -}} +{{- $userDefined := false -}} +{{- range .Values.operator.additionalEnvs -}} +{{- if eq .name "TEKTON_REGISTRY_OVERRIDE" -}}{{- $userDefined = true -}}{{- end -}} +{{- end -}} +{{- if and .Values.registry (not $userDefined) }} +- name: TEKTON_REGISTRY_OVERRIDE + value: {{ .Values.registry | trimSuffix "/" | quote }} +{{- end }} +{{- end -}} diff --git a/charts/tekton-operator/templates/deployment.yaml b/charts/tekton-operator/templates/deployment.yaml index 5045360cd5..6b16d319a2 100644 --- a/charts/tekton-operator/templates/deployment.yaml +++ b/charts/tekton-operator/templates/deployment.yaml @@ -73,6 +73,7 @@ spec: - name: CONFIG_OBSERVABILITY_NAME value: {{ include "tekton-operator.fullname" . }}-observability {{- include "tekton-operator.kubernetesMinVersionEnv" . | nindent 12 }} + {{- include "tekton-operator.registryOverrideEnv" . | nindent 12 }} {{- range .Values.operator.additionalEnvs }} - name: {{ .name }} value: {{ .value | quote }} @@ -133,6 +134,7 @@ spec: - name: CONFIG_OBSERVABILITY_NAME value: {{ include "tekton-operator.fullname" . }}-observability {{- include "tekton-operator.kubernetesMinVersionEnv" . | nindent 12 }} + {{- include "tekton-operator.registryOverrideEnv" . | nindent 12 }} {{- range .Values.operator.additionalEnvs }} - name: {{ .name }} value: {{ .value | quote }} diff --git a/charts/tekton-operator/values.yaml b/charts/tekton-operator/values.yaml index 52b4d4bd2c..7066ef70b1 100644 --- a/charts/tekton-operator/values.yaml +++ b/charts/tekton-operator/values.yaml @@ -22,6 +22,11 @@ installCRDs: false ## Controllers to install controllers: "tektonconfig,tektonpipeline,tektontrigger,tektonchain,tektonresult,tektondashboard,manualapprovalgate,tektonpruner,openshiftpipelinesascode" +## Registry mirror (host[:port][/path]) for all images, e.g. for air-gapped clusters. +## Rewrites the operator's own images and sets TEKTON_REGISTRY_OVERRIDE for the +## components it installs (unless set in operator.additionalEnvs). +registry: "" + ## Control the creation of RBAC resources (Serviceaccount, Role, ClusterRole, ClusterRoleBinding) rbac: create: true diff --git a/docs/AirGapImageConfiguration.md b/docs/AirGapImageConfiguration.md index fa4866ce35..a5d29f8e77 100644 --- a/docs/AirGapImageConfiguration.md +++ b/docs/AirGapImageConfiguration.md @@ -42,6 +42,19 @@ Behavior and precedence: - If `TEKTON_REGISTRY_OVERRIDE` is unset, images are taken from per-image env vars (if set) or from the shipped defaults. - If `TEKTON_REGISTRY_OVERRIDE` is set, the operator rewrites the registry host for all resolved images (from per-image env vars and defaults, including images with no matching per-image env var), for every component. The repository path and tag/digest are preserved. - There is currently no per-image opt-out when the global override is set. To exempt specific images, do not set `TEKTON_REGISTRY_OVERRIDE` and rely solely on per-image env vars. +- Images passed to a controller as `*-image*` flags (e.g. `-entrypoint-image`, `-shell-image`, `-el-image`) are rewritten too, as is the `tkn` image of the legacy pruner CronJob (`IMAGE_JOB_PRUNER_TKN`). +- The rewrite is idempotent: an image that already starts with `/` is left unchanged. +- The first path component is treated as the registry host only if it contains a `.` or a `:`, or is `localhost`. Images without a registry host keep their full path: `bitnami/postgresql` becomes `/bitnami/postgresql`. + +### Helm chart + +With the Helm chart, set `registry` instead of the env var. It rewrites the registry of the operator's own images (operator and webhook) and sets `TEKTON_REGISTRY_OVERRIDE` on the operator, which then rewrites the webhook proxy (`IMAGE_PIPELINES_PROXY`) and pruner `tkn` (`IMAGE_JOB_PRUNER_TKN`) images at runtime: + +```yaml +registry: my-internal-registry.io/my-tekton-folder +``` + +A `TEKTON_REGISTRY_OVERRIDE` entry in `operator.additionalEnvs` takes precedence over `registry`. ## Rewrite image one by one diff --git a/pkg/reconciler/common/prune.go b/pkg/reconciler/common/prune.go index 6ca735125c..ee8d7a82b9 100644 --- a/pkg/reconciler/common/prune.go +++ b/pkg/reconciler/common/prune.go @@ -172,7 +172,7 @@ func (pr *Pruner) reconcile(ctx context.Context) error { if tknImageFromEnv == "" { return fmt.Errorf("tkn image '%s' environment variable is not set", prunerContainerImageEnvKey) } - pr.tknImage = tknImageFromEnv + pr.tknImage = overrideImageRegistry(os.Getenv(ImageRegistryOverride), tknImageFromEnv) // reconcile cron jobs err := pr.reconcileCronJobs(ctx) diff --git a/pkg/reconciler/common/testdata/test-replace-image-airgapped.yaml b/pkg/reconciler/common/testdata/test-replace-image-airgapped.yaml new file mode 100644 index 0000000000..a57d4c7505 --- /dev/null +++ b/pkg/reconciler/common/testdata/test-replace-image-airgapped.yaml @@ -0,0 +1,69 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tekton-pipelines-controller +spec: + selector: + matchLabels: + app: tekton-pipelines-controller + template: + metadata: + labels: + app: tekton-pipelines-controller + spec: + containers: + - name: tekton-pipelines-controller + image: ghcr.io/tektoncd/pipeline/controller-10a3e32792f33651396d02b6855a6e36:v1.15.0@sha256:aaaa + args: [ + "-entrypoint-image", "ghcr.io/tektoncd/pipeline/entrypoint-bff0a22da108bc2f16c818c97641a296:v1.15.0@sha256:bbbb", + "-nop-image", "ghcr.io/tektoncd/pipeline/nop-8eac7c133edad5df719dc37b36b62482:v1.15.0@sha256:cccc", + "-sidecarlogresults-image", "ghcr.io/tektoncd/pipeline/sidecarlogresults-7501c6a20d741631510a448b48ab098f:v1.15.0@sha256:dddd", + "-workingdirinit-image", "ghcr.io/tektoncd/pipeline/workingdirinit-0c558922ec6a1b739e550e349f2d5fc1:v1.15.0@sha256:eeee", + "-shell-image", "cgr.dev/chainguard/busybox@sha256:ffff", + "-shell-image-win", "mcr.microsoft.com/powershell:nanoserver@sha256:9999", + "-disable-ha=false", + "-trailing-image" + ] +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: tekton-triggers-controller +spec: + selector: + matchLabels: + app: tekton-triggers-controller + template: + metadata: + labels: + app: tekton-triggers-controller + spec: + containers: + - name: tekton-triggers-controller + image: ghcr.io/tektoncd/triggers/controller-13c3e5ef5e0ad5a6b2a5de1fa6d5a9a4:v0.37.0 + args: [ + "-logtostderr", + "-stderrthreshold", "INFO", + "-el-image", "ghcr.io/tektoncd/triggers/eventlistenersink-7ad1faa98cddbcb0c24990303b220bb8:v0.37.0@sha256:1111", + "-el-port", "8080", + "-el-security-context=true", + "-el-events", "disable" + ] +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: tekton-results-postgres +spec: + selector: + matchLabels: + app: tekton-results-postgres + serviceName: tekton-results-postgres + template: + metadata: + labels: + app: tekton-results-postgres + spec: + containers: + - name: postgres + image: bitnami/postgresql@sha256:2222 diff --git a/pkg/reconciler/common/transformers.go b/pkg/reconciler/common/transformers.go index 8341cc77e5..4d0bfda219 100644 --- a/pkg/reconciler/common/transformers.go +++ b/pkg/reconciler/common/transformers.go @@ -210,15 +210,27 @@ func overrideImageRegistry(registry, imageName string) string { if strings.Contains(imageName, "$(") { return imageName } + // Already under the override registry: leave it alone so that a second + // pass (e.g. an image pre-rewritten by the Helm chart) is a no-op. + registry = strings.TrimSuffix(registry, "/") + if strings.HasPrefix(imageName, registry+"/") { + return imageName + } parts := strings.Split(imageName, "/") - if len(parts) > 1 { + if len(parts) > 1 && isRegistryHost(parts[0]) { // if image has registry part, replace it return registry + "/" + strings.Join(parts[1:], "/") } - // if image does not have registry part, add it + // no registry part (e.g. "bitnami/postgresql"): prepend the registry return registry + "/" + imageName } +// isRegistryHost uses the Docker rule: a registry host contains "." or ":", +// or is "localhost". Otherwise it is a namespace, e.g. "bitnami". +func isRegistryHost(component string) bool { + return strings.ContainsAny(component, ".:") || component == "localhost" +} + // ToLowerCaseKeys converts key value to lower cases. func ToLowerCaseKeys(keyValues map[string]string) map[string]string { newMap := map[string]string{} @@ -330,8 +342,14 @@ func replaceContainerImages(containers []corev1.Container, images map[string]str // matches a given "*-image*" flag (e.g. "-shell-image-win"), it falls back // to rewriting just the registry domain, so TEKTON_REGISTRY_OVERRIDE alone // also applies to arg-based images and not only to container.Image. +// Only flags are matched; a flag's value is never treated as a flag. func replaceContainersArgsImage(container *corev1.Container, images map[string]string, registry string) { - for a, arg := range container.Args { + for a := 0; a < len(container.Args); a++ { + arg := container.Args[a] + if !strings.HasPrefix(arg, "-") { + continue + } + if argVal, hasArg := SplitsByEqual(arg); hasArg { argument := formKey(ArgPrefix, argVal[0]) if url, exist := images[argument]; exist { @@ -342,12 +360,18 @@ func replaceContainersArgsImage(container *corev1.Container, images map[string]s continue } + if a+1 >= len(container.Args) || strings.HasPrefix(container.Args[a+1], "-") { + // flag without a value + continue + } + argument := formKey(ArgPrefix, arg) if url, exist := images[argument]; exist { container.Args[a+1] = url } else if strings.Contains(argument, "_image") { container.Args[a+1] = overrideImageRegistry(registry, container.Args[a+1]) } + a++ // skip the value } } diff --git a/pkg/reconciler/common/transformers_test.go b/pkg/reconciler/common/transformers_test.go index a2f59b1fba..dc27e28187 100644 --- a/pkg/reconciler/common/transformers_test.go +++ b/pkg/reconciler/common/transformers_test.go @@ -1616,3 +1616,111 @@ func TestGetSortedKeys(t *testing.T) { out := getSortedKeys(in) assert.Equal(t, true, reflect.DeepEqual(out, expectedOut)) } + +func TestOverrideImageRegistry(t *testing.T) { + const registry = "nexus.example.com:5000" + tests := []struct { + image string + want string + }{ + {"ghcr.io/tektoncd/pipeline/nop:v1@sha256:abcd", registry + "/tektoncd/pipeline/nop:v1@sha256:abcd"}, + {"cgr.dev/chainguard/busybox@sha256:abcd", registry + "/chainguard/busybox@sha256:abcd"}, + {"localhost/foo/bar:v1", registry + "/foo/bar:v1"}, + {"localhost:5000/foo:v1", registry + "/foo:v1"}, + // no registry host: keep the namespace + {"bitnami/postgresql@sha256:abcd", registry + "/bitnami/postgresql@sha256:abcd"}, + {"busybox", registry + "/busybox"}, + {"$(params.builder-image)", "$(params.builder-image)"}, + {"", ""}, + } + for _, tt := range tests { + if got := overrideImageRegistry(registry, tt.image); got != tt.want { + t.Errorf("overrideImageRegistry(%q) = %q, want %q", tt.image, got, tt.want) + } + } + if got := overrideImageRegistry("", "ghcr.io/foo:v1"); got != "ghcr.io/foo:v1" { + t.Errorf("empty registry must not change the image, got %q", got) + } +} + +// A second pass must be a no-op, including when the registry carries a path. +func TestOverrideImageRegistryIdempotent(t *testing.T) { + for _, registry := range []string{"nexus.example.com:5000", "nexus.example.com/mirror", "nexus.example.com/mirror/"} { + for _, image := range []string{"ghcr.io/tektoncd/pipeline/nop:v1", "bitnami/postgresql", "busybox"} { + once := overrideImageRegistry(registry, image) + if twice := overrideImageRegistry(registry, once); twice != once { + t.Errorf("registry %q: second pass on %q = %q, want %q", registry, once, twice, once) + } + } + } +} + +// TEKTON_REGISTRY_OVERRIDE alone must rewrite container, arg and StatefulSet images. +func TestRegistryOverrideOnlyAirgapped(t *testing.T) { + const registry = "nexus.example.com:5000" + t.Setenv("TEKTON_REGISTRY_OVERRIDE", registry) + images := ImageRegistryDomainOverride(map[string]string{}) + + manifest, err := mf.ManifestFrom(mf.Recursive(path.Join("testdata", "test-replace-image-airgapped.yaml"))) + assertNoError(t, err) + newManifest, err := manifest.Transform(DeploymentImages(images), StatefulSetImages(images)) + assertNoError(t, err) + + wantArgs := map[string][]string{ + "tekton-pipelines-controller": { + "-entrypoint-image", registry + "/tektoncd/pipeline/entrypoint-bff0a22da108bc2f16c818c97641a296:v1.15.0@sha256:bbbb", + "-nop-image", registry + "/tektoncd/pipeline/nop-8eac7c133edad5df719dc37b36b62482:v1.15.0@sha256:cccc", + "-sidecarlogresults-image", registry + "/tektoncd/pipeline/sidecarlogresults-7501c6a20d741631510a448b48ab098f:v1.15.0@sha256:dddd", + "-workingdirinit-image", registry + "/tektoncd/pipeline/workingdirinit-0c558922ec6a1b739e550e349f2d5fc1:v1.15.0@sha256:eeee", + "-shell-image", registry + "/chainguard/busybox@sha256:ffff", + "-shell-image-win", registry + "/powershell:nanoserver@sha256:9999", + "-disable-ha=false", + "-trailing-image", + }, + "tekton-triggers-controller": { + "-logtostderr", + "-stderrthreshold", "INFO", + "-el-image", registry + "/tektoncd/triggers/eventlistenersink-7ad1faa98cddbcb0c24990303b220bb8:v0.37.0@sha256:1111", + "-el-port", "8080", + "-el-security-context=true", + "-el-events", "disable", + }, + } + wantImages := map[string]string{ + "tekton-pipelines-controller": registry + "/tektoncd/pipeline/controller-10a3e32792f33651396d02b6855a6e36:v1.15.0@sha256:aaaa", + "tekton-triggers-controller": registry + "/tektoncd/triggers/controller-13c3e5ef5e0ad5a6b2a5de1fa6d5a9a4:v0.37.0", + } + + for _, u := range newManifest.Resources() { + switch u.GetKind() { + case "Deployment": + d := deploymentFor(t, u) + c := d.Spec.Template.Spec.Containers[0] + if c.Image != wantImages[d.Name] { + t.Errorf("%s image: got %q, want %q", d.Name, c.Image, wantImages[d.Name]) + } + if diff := cmp.Diff(wantArgs[d.Name], c.Args); diff != "" { + t.Errorf("%s args mismatch (-want +got):\n%s", d.Name, diff) + } + case "StatefulSet": + got, _, _ := unstructured.NestedSlice(u.Object, "spec", "template", "spec", "containers") + img := got[0].(map[string]interface{})["image"] + if want := registry + "/bitnami/postgresql@sha256:2222"; img != want { + t.Errorf("postgres image: got %q, want %q", img, want) + } + } + } +} + +func TestDeploymentContainerArgsImageValueNotTreatedAsFlag(t *testing.T) { + t.Setenv("TEKTON_REGISTRY_OVERRIDE", "nexus.example.com") + containers := []corev1.Container{{ + Name: "c", + Args: []string{"-foo-image", "ghcr.io/org/my-image:v1", "-port", "8080"}, + }} + replaceContainerImages(containers, map[string]string{}) + want := []string{"-foo-image", "nexus.example.com/org/my-image:v1", "-port", "8080"} + if diff := cmp.Diff(want, containers[0].Args); diff != "" { + t.Errorf("args mismatch (-want +got):\n%s", diff) + } +}