Skip to content
Open
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
43 changes: 39 additions & 4 deletions charts/tekton-operator/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -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" -}}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for pruner image we shoulndt call withRegistry, otherwise there will be double override (the second is done pruner.go with TEKTON_REGISTRY_OVERRIDE

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i missed it... thx!

{{- $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" -}}
Expand All @@ -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 -}}


Expand All @@ -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 -}}
2 changes: 2 additions & 0 deletions charts/tekton-operator/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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 }}
Expand Down
5 changes: 5 additions & 0 deletions charts/tekton-operator/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions docs/AirGapImageConfiguration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<override>/` 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 `<override>/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

Expand Down
2 changes: 1 addition & 1 deletion pkg/reconciler/common/prune.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
69 changes: 69 additions & 0 deletions pkg/reconciler/common/testdata/test-replace-image-airgapped.yaml
Original file line number Diff line number Diff line change
@@ -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
30 changes: 27 additions & 3 deletions pkg/reconciler/common/transformers.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,15 +210,27 @@ func overrideImageRegistry(registry, imageName string) string {
if strings.Contains(imageName, "$(") {
return imageName

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may want to make this fonction idemptotent
if the image already starts with the override prefix, leave it alone. A second pass becomes a no-op.

@dadez73 dadez73 Oct 20, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the logic for idempotent function. It helps in cases registry already set i.e. registries that include a path. With TEKTON_REGISTRY_OVERRIDE=myreg.io/mirror, an image that had already been rewritten (myreg.io/mirror/tektoncd/x) was treated as having the host myreg.io, so it became myreg.io/mirror/mirror/tektoncd/x.

}
// 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{}
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
}

Expand Down
108 changes: 108 additions & 0 deletions pkg/reconciler/common/transformers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}