From 461fb0fa072ec8a342cdfa6c054f7b417e8d0a77 Mon Sep 17 00:00:00 2001 From: Patryk Rzadzinski Date: Fri, 24 Jul 2026 11:52:13 +0200 Subject: [PATCH] feat(api): support pinning image tags and digests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional `imageTag` and `imageDigest` fields to the server (`spec`), UI (`spec.ui`) and admin tools (`spec.admintools`) specs. Until now every image was deployed as `:`, which couples the deployed tag to the cluster version (which also drives the persistence schema migrations) and leaves the running image on a mutable tag. - `imageTag` overrides the tag used for the image, falling back to `version` when unset. This addresses the long-standing request in #788 / #789 to decouple the admin-tools/ui/server image tag from the Temporal version. - `imageDigest` pins the image to an immutable digest, appended as `:@`, so the running image cannot change behind a mutable tag (image provenance / supply-chain hardening). CRD validation enforces the `sha256:<64 hex>` format. Both fields are optional and backwards-compatible; when unset the image reference is unchanged. Image construction is centralised in a new `meta.ContainerImage` helper used by all four builders (server, ui, admintools deployment and the schema-setup job) to avoid duplication. For the server, `version` still drives the persistence schema version regardless of these overrides — only the deployed image reference changes. Refs #788, #789 Co-Authored-By: Claude Opus 4.8 Signed-off-by: Patryk Rzadzinski --- api/v1beta1/temporalcluster_types.go | 34 ++++ .../bases/temporal.io_temporalclusters.yaml | 40 +++++ docs/api/v1beta1.md | 166 ++++++++++++++++++ docs/features/image-pinning.md | 68 +++++++ .../resource/admintools/deployment_builder.go | 2 +- internal/resource/base/deployment_builder.go | 2 +- internal/resource/meta/image.go | 45 +++++ internal/resource/meta/image_test.go | 65 +++++++ .../persistence/schema_setup_job_builder.go | 3 +- internal/resource/ui/deployment_builder.go | 2 +- mkdocs.yml | 1 + 11 files changed, 424 insertions(+), 4 deletions(-) create mode 100644 docs/features/image-pinning.md create mode 100644 internal/resource/meta/image.go create mode 100644 internal/resource/meta/image_test.go diff --git a/api/v1beta1/temporalcluster_types.go b/api/v1beta1/temporalcluster_types.go index a0677e64..16df18fd 100644 --- a/api/v1beta1/temporalcluster_types.go +++ b/api/v1beta1/temporalcluster_types.go @@ -557,6 +557,17 @@ type TemporalUISpec struct { // Image defines the temporal ui docker image the instance should run. // +optional Image string `json:"image"` + // ImageTag overrides the tag used for the temporal ui image. When set, it + // is used instead of Version as the image tag. + // +optional + ImageTag string `json:"imageTag,omitempty"` + // ImageDigest pins the temporal ui image to an immutable digest + // (e.g. "sha256:abc..."). When set, it is appended to the image reference + // (repository:tag@digest) so the running image cannot change behind a + // mutable tag. + // +kubebuilder:validation:Pattern=`^sha256:[a-fA-F0-9]{64}$` + // +optional + ImageDigest string `json:"imageDigest,omitempty"` // Number of desired replicas for the ui. Default to 1. // +kubebuilder:validation:Minimum=1 // +optional @@ -589,6 +600,17 @@ type TemporalAdminToolsSpec struct { // Version defines the temporal admin tools version the instance should run. // +optional Version string `json:"version"` + // ImageTag overrides the tag used for the temporal admin tools image. When + // set, it is used instead of Version as the image tag. + // +optional + ImageTag string `json:"imageTag,omitempty"` + // ImageDigest pins the temporal admin tools image to an immutable digest + // (e.g. "sha256:abc..."). When set, it is appended to the image reference + // (repository:tag@digest) so the running image cannot change behind a + // mutable tag. + // +kubebuilder:validation:Pattern=`^sha256:[a-fA-F0-9]{64}$` + // +optional + ImageDigest string `json:"imageDigest,omitempty"` // Compute Resources required by the ui. // More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ // +optional @@ -1015,6 +1037,18 @@ type TemporalClusterSpec struct { // This version impacts the underlying persistence schemas versions. // +optional Version *version.Version `json:"version"` + // ImageTag overrides the tag used for the temporal server image. When set, + // it is used instead of Version as the image tag. Version still drives the + // persistence schema version regardless of this override. + // +optional + ImageTag string `json:"imageTag,omitempty"` + // ImageDigest pins the temporal server image to an immutable digest + // (e.g. "sha256:abc..."). When set, it is appended to the image reference + // (repository:tag@digest) so the running image cannot change behind a + // mutable tag. + // +kubebuilder:validation:Pattern=`^sha256:[a-fA-F0-9]{64}$` + // +optional + ImageDigest string `json:"imageDigest,omitempty"` // Log defines temporal cluster's logger configuration. // +optional Log *LogSpec `json:"log,omitempty"` diff --git a/config/crd/bases/temporal.io_temporalclusters.yaml b/config/crd/bases/temporal.io_temporalclusters.yaml index 7eebc60f..32d2f61e 100644 --- a/config/crd/bases/temporal.io_temporalclusters.yaml +++ b/config/crd/bases/temporal.io_temporalclusters.yaml @@ -58,6 +58,19 @@ spec: image: description: Image defines the temporal admin tools docker image the instance should run. type: string + imageDigest: + description: |- + ImageDigest pins the temporal admin tools image to an immutable digest + (e.g. "sha256:abc..."). When set, it is appended to the image reference + (repository:tag@digest) so the running image cannot change behind a + mutable tag. + pattern: ^sha256:[a-fA-F0-9]{64}$ + type: string + imageTag: + description: |- + ImageTag overrides the tag used for the temporal admin tools image. When + set, it is used instead of Version as the image tag. + type: string overrides: description: Overrides adds some overrides to the resources deployed for the ui. properties: @@ -450,6 +463,14 @@ spec: image: description: Image defines the temporal server docker image the cluster should use for each services. type: string + imageDigest: + description: |- + ImageDigest pins the temporal server image to an immutable digest + (e.g. "sha256:abc..."). When set, it is appended to the image reference + (repository:tag@digest) so the running image cannot change behind a + mutable tag. + pattern: ^sha256:[a-fA-F0-9]{64}$ + type: string imagePullSecrets: description: |- An optional list of references to secrets in the same namespace @@ -471,6 +492,12 @@ spec: type: object x-kubernetes-map-type: atomic type: array + imageTag: + description: |- + ImageTag overrides the tag used for the temporal server image. When set, + it is used instead of Version as the image tag. Version still drives the + persistence schema version regardless of this override. + type: string jobInitContainers: description: JobInitContainers adds a list of init containers to the setup's jobs. items: @@ -3876,6 +3903,19 @@ spec: image: description: Image defines the temporal ui docker image the instance should run. type: string + imageDigest: + description: |- + ImageDigest pins the temporal ui image to an immutable digest + (e.g. "sha256:abc..."). When set, it is appended to the image reference + (repository:tag@digest) so the running image cannot change behind a + mutable tag. + pattern: ^sha256:[a-fA-F0-9]{64}$ + type: string + imageTag: + description: |- + ImageTag overrides the tag used for the temporal ui image. When set, it + is used instead of Version as the image tag. + type: string ingress: description: |- Ingress is an optional ingress configuration for the UI. diff --git a/docs/api/v1beta1.md b/docs/api/v1beta1.md index cec32b63..bc88ed62 100644 --- a/docs/api/v1beta1.md +++ b/docs/api/v1beta1.md @@ -89,6 +89,35 @@ This version impacts the underlying persistence schemas versions.

+imageTag
+ +string + + + +(Optional) +

ImageTag overrides the tag used for the temporal server image. When set, +it is used instead of Version as the image tag. Version still drives the +persistence schema version regardless of this override.

+ + + + +imageDigest
+ +string + + + +(Optional) +

ImageDigest pins the temporal server image to an immutable digest +(e.g. “sha256:abc…”). When set, it is appended to the image reference +(repository:tag@digest) so the running image cannot change behind a +mutable tag.

+ + + + log
@@ -4612,6 +4641,34 @@ string +imageTag
+ +string + + + +(Optional) +

ImageTag overrides the tag used for the temporal admin tools image. When +set, it is used instead of Version as the image tag.

+ + + + +imageDigest
+ +string + + + +(Optional) +

ImageDigest pins the temporal admin tools image to an immutable digest +(e.g. “sha256:abc…”). When set, it is appended to the image reference +(repository:tag@digest) so the running image cannot change behind a +mutable tag.

+ + + + resources
@@ -4838,6 +4895,35 @@ This version impacts the underlying persistence schemas versions.

+imageTag
+ +string + + + +(Optional) +

ImageTag overrides the tag used for the temporal server image. When set, +it is used instead of Version as the image tag. Version still drives the +persistence schema version regardless of this override.

+ + + + +imageDigest
+ +string + + + +(Optional) +

ImageDigest pins the temporal server image to an immutable digest +(e.g. “sha256:abc…”). When set, it is appended to the image reference +(repository:tag@digest) so the running image cannot change behind a +mutable tag.

+ + + + log
@@ -5297,6 +5383,32 @@ TemporalNamespaceArchivalSpec If not set, the default cluster configuration is used.

+ + +customSearchAttributes
+ +map[string]string + + + +(Optional) +

CustomSearchAttributes is an optional mapping of custom search attribute names to types. +Supported types: Text, Keyword, Int, Double, Bool, DateTime, KeywordList.

+ + + + +allowSearchAttributeDeletion
+ +bool + + + +(Optional) +

AllowSearchAttributeDeletion makes the controller remove custom search attributes +from the Temporal server if they are not present in the spec.

+ + @@ -5521,6 +5633,32 @@ TemporalNamespaceArchivalSpec If not set, the default cluster configuration is used.

+ + +customSearchAttributes
+ +map[string]string + + + +(Optional) +

CustomSearchAttributes is an optional mapping of custom search attribute names to types. +Supported types: Text, Keyword, Int, Double, Bool, DateTime, KeywordList.

+ + + + +allowSearchAttributeDeletion
+ +bool + + + +(Optional) +

AllowSearchAttributeDeletion makes the controller remove custom search attributes +from the Temporal server if they are not present in the spec.

+ + @@ -6082,6 +6220,34 @@ string +imageTag
+ +string + + + +(Optional) +

ImageTag overrides the tag used for the temporal ui image. When set, it +is used instead of Version as the image tag.

+ + + + +imageDigest
+ +string + + + +(Optional) +

ImageDigest pins the temporal ui image to an immutable digest +(e.g. “sha256:abc…”). When set, it is appended to the image reference +(repository:tag@digest) so the running image cannot change behind a +mutable tag.

+ + + + replicas
int32 diff --git a/docs/features/image-pinning.md b/docs/features/image-pinning.md new file mode 100644 index 00000000..04de9974 --- /dev/null +++ b/docs/features/image-pinning.md @@ -0,0 +1,68 @@ +# Pinning image tags and digests + +By default the operator derives every container image tag from the configured +`version`: the server, web UI and admin tools images are all deployed as +`:`. This is convenient, but it means: + +- the deployed tag is coupled to `version`, even though `version` also drives + the persistence schema migrations, and +- a mutable tag can be repointed upstream, so the running image can change + without any change to the `TemporalCluster` resource. + +Each component (`spec`, `spec.ui`, `spec.admintools`) therefore exposes two +optional fields: + +- **`imageTag`** — overrides the tag used for the image. When set, it is used + instead of `version`. For the server, `version` still drives the persistence + schema version regardless of this override — only the deployed image tag + changes. This is useful when an image is published with a tag that differs + from the Temporal version (for example admin-tools images). +- **`imageDigest`** — pins the image to an immutable digest (`sha256:...`). When + set, it is appended to the reference as `:@`. The tag is + kept for readability while the digest guarantees the running image cannot + change behind a mutable tag. This is recommended for security-sensitive or + compliance-driven deployments (e.g. supply-chain / image-provenance + requirements). + +Both fields are optional and can be combined. The resulting image reference is: + +| `imageTag` | `imageDigest` | Resulting reference | +| ---------- | ------------- | ---------------------------- | +| unset | unset | `image:version` | +| set | unset | `image:imageTag` | +| unset | set | `image:version@digest` | +| set | set | `image:imageTag@digest` | + +## Example: pin the server and UI to a digest + +```yaml +apiVersion: temporal.io/v1beta1 +kind: TemporalCluster +metadata: + name: prod +spec: + version: 1.28.2 + image: temporalio/server + imageDigest: sha256:459aa877ef82c9a7f0ce688f2451ec8e576142c4efefecb2988d2f5f8b12699e + ui: + enabled: true + version: 2.44.1 + image: temporalio/ui + imageDigest: sha256:0000000000000000000000000000000000000000000000000000000000000000 +``` + +## Example: override the admin-tools tag + +```yaml +apiVersion: temporal.io/v1beta1 +kind: TemporalCluster +metadata: + name: prod +spec: + version: 1.28.2 + admintools: + enabled: true + image: temporalio/admin-tools + # the admin-tools image for this version is published under a different tag + imageTag: 1.28.2-tctl-1.18.1-cli-0.13.2 +``` diff --git a/internal/resource/admintools/deployment_builder.go b/internal/resource/admintools/deployment_builder.go index 7bec4487..378e32a4 100644 --- a/internal/resource/admintools/deployment_builder.go +++ b/internal/resource/admintools/deployment_builder.go @@ -134,7 +134,7 @@ func (b *DeploymentBuilder) Update(object client.Object) error { Containers: []corev1.Container{ { Name: "admintools", - Image: fmt.Sprintf("%s:%s", b.instance.Spec.AdminTools.Image, b.instance.Spec.AdminTools.Version), + Image: meta.ContainerImage(b.instance.Spec.AdminTools.Image, b.instance.Spec.AdminTools.Version, b.instance.Spec.AdminTools.ImageTag, b.instance.Spec.AdminTools.ImageDigest), ImagePullPolicy: corev1.PullIfNotPresent, TerminationMessagePath: corev1.TerminationMessagePathDefault, TerminationMessagePolicy: corev1.TerminationMessageReadFile, diff --git a/internal/resource/base/deployment_builder.go b/internal/resource/base/deployment_builder.go index 03a7bafe..a7fc7736 100644 --- a/internal/resource/base/deployment_builder.go +++ b/internal/resource/base/deployment_builder.go @@ -349,7 +349,7 @@ func (b *DeploymentBuilder) Update(object client.Object) error { Containers: []corev1.Container{ { Name: "service", // name "service" is here to simplify overrides - Image: fmt.Sprintf("%s:%s", b.instance.Spec.Image, b.instance.Spec.Version), + Image: meta.ContainerImage(b.instance.Spec.Image, fmt.Sprintf("%s", b.instance.Spec.Version), b.instance.Spec.ImageTag, b.instance.Spec.ImageDigest), ImagePullPolicy: corev1.PullIfNotPresent, Resources: b.service.Resources, TerminationMessagePath: corev1.TerminationMessagePathDefault, diff --git a/internal/resource/meta/image.go b/internal/resource/meta/image.go new file mode 100644 index 00000000..7029f910 --- /dev/null +++ b/internal/resource/meta/image.go @@ -0,0 +1,45 @@ +// Licensed to Alexandre VILAIN under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Alexandre VILAIN licenses this file to you 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 meta + +import "fmt" + +// ContainerImage builds a container image reference from a repository and a +// default tag, optionally overriding the tag and/or pinning the image to an +// immutable digest. The result follows the OCI "repository:tag[@digest]" form: +// +// - repository:defaultTag when neither override is set +// - repository:tagOverride when tagOverride is set (defaultTag ignored) +// - repository:tag@digest when digest is set (the tag is kept for +// readability while the digest guarantees immutability) +// +// The digest is expected to already carry its algorithm prefix (e.g. +// "sha256:abc..."); CRD validation enforces the format on the API fields. +func ContainerImage(repository, defaultTag, tagOverride, digest string) string { + tag := defaultTag + if tagOverride != "" { + tag = tagOverride + } + + image := fmt.Sprintf("%s:%s", repository, tag) + if digest != "" { + image = fmt.Sprintf("%s@%s", image, digest) + } + + return image +} diff --git a/internal/resource/meta/image_test.go b/internal/resource/meta/image_test.go new file mode 100644 index 00000000..7a6a9d0a --- /dev/null +++ b/internal/resource/meta/image_test.go @@ -0,0 +1,65 @@ +// Licensed to Alexandre VILAIN under one or more contributor +// license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright +// ownership. Alexandre VILAIN licenses this file to you 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 meta_test + +import ( + "testing" + + "github.com/alexandrevilain/temporal-operator/internal/resource/meta" + "github.com/stretchr/testify/assert" +) + +func TestContainerImage(t *testing.T) { + const ( + repository = "temporalio/server" + digest = "sha256:459aa877ef82c9a7f0ce688f2451ec8e576142c4efefecb2988d2f5f8b12699e" + ) + + for name, tc := range map[string]struct { + defaultTag string + tagOverride string + digest string + expected string + }{ + "default tag only": { + defaultTag: "1.28.2", + expected: "temporalio/server:1.28.2", + }, + "tag override replaces default": { + defaultTag: "1.28.2", + tagOverride: "1.28.2-custom", + expected: "temporalio/server:1.28.2-custom", + }, + "digest pins default tag": { + defaultTag: "1.28.2", + digest: digest, + expected: "temporalio/server:1.28.2@" + digest, + }, + "tag override and digest combined": { + defaultTag: "1.28.2", + tagOverride: "1.28.2-custom", + digest: digest, + expected: "temporalio/server:1.28.2-custom@" + digest, + }, + } { + t.Run(name, func(t *testing.T) { + got := meta.ContainerImage(repository, tc.defaultTag, tc.tagOverride, tc.digest) + assert.Equal(t, tc.expected, got) + }) + } +} diff --git a/internal/resource/persistence/schema_setup_job_builder.go b/internal/resource/persistence/schema_setup_job_builder.go index 1d504c41..ae6d9639 100644 --- a/internal/resource/persistence/schema_setup_job_builder.go +++ b/internal/resource/persistence/schema_setup_job_builder.go @@ -22,6 +22,7 @@ import ( "github.com/alexandrevilain/temporal-operator/api/v1beta1" "github.com/alexandrevilain/temporal-operator/internal/metadata" + "github.com/alexandrevilain/temporal-operator/internal/resource/meta" "github.com/alexandrevilain/temporal-operator/internal/resource/mtls/istio" "github.com/alexandrevilain/temporal-operator/internal/resource/mtls/linkerd" "github.com/alexandrevilain/temporal-operator/pkg/version" @@ -124,7 +125,7 @@ func (b *SchemaJobBuilder) Build() client.Object { Containers: []corev1.Container{ { Name: "schema-script-runner", - Image: fmt.Sprintf("%s:%s", b.instance.Spec.AdminTools.Image, version.DefaultAdminToolTag(b.instance.Spec.Version)), + Image: meta.ContainerImage(b.instance.Spec.AdminTools.Image, version.DefaultAdminToolTag(b.instance.Spec.Version), b.instance.Spec.AdminTools.ImageTag, b.instance.Spec.AdminTools.ImageDigest), ImagePullPolicy: corev1.PullIfNotPresent, Resources: b.instance.Spec.JobResources, TerminationMessagePath: corev1.TerminationMessagePathDefault, diff --git a/internal/resource/ui/deployment_builder.go b/internal/resource/ui/deployment_builder.go index a5dbec42..3f144e7e 100644 --- a/internal/resource/ui/deployment_builder.go +++ b/internal/resource/ui/deployment_builder.go @@ -127,7 +127,7 @@ func (b *DeploymentBuilder) Update(object client.Object) error { Containers: []corev1.Container{ { Name: "ui", - Image: fmt.Sprintf("%s:%s", b.instance.Spec.UI.Image, b.instance.Spec.UI.Version), + Image: meta.ContainerImage(b.instance.Spec.UI.Image, b.instance.Spec.UI.Version, b.instance.Spec.UI.ImageTag, b.instance.Spec.UI.ImageDigest), ImagePullPolicy: corev1.PullIfNotPresent, Resources: b.instance.Spec.UI.Resources, TerminationMessagePath: corev1.TerminationMessagePathDefault, diff --git a/mkdocs.yml b/mkdocs.yml index d28a3f9a..fc64cb11 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -26,6 +26,7 @@ nav: - Archival: features/archival.md - Temporal UI: features/temporal-ui.md - Admin Tools: features/admin-tools.md + - Image tag & digest: features/image-pinning.md - mTLS: - Using Cert-Manager: features/mtls/cert-manager.md - Using Istio: features/mtls/istio.md