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
19 changes: 19 additions & 0 deletions api/v1alpha1/worker_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,25 @@ type WorkerOptions struct {
// The Temporal namespace for the worker to connect to.
// +kubebuilder:validation:MinLength=1
TemporalNamespace string `json:"temporalNamespace"`
// BuildID optionally overrides the auto-generated build ID for this worker deployment.
// When set, the controller uses this value instead of computing a build ID from the
// pod template hash. This enables rolling updates for non-workflow code changes
// (bug fixes, config changes) while preserving the same build ID.
//
// WARNING: Using a custom build ID requires careful management. If workflow code changes
// but BuildID stays the same, pinned workflows may execute on workers running incompatible
// code. Only use this when you have a reliable way to compute a hash of your workflow
// definitions (e.g., hashing workflow source files in CI/CD).
//
// When the BuildID is stable but pod template spec changes, the controller triggers
// a rolling update instead of creating a new deployment version. Currently detected
// changes: replicas, minReadySeconds, container images, container resources (limits/requests),
// and init container images. Other fields (env vars, volumes, commands) are not currently
// monitored for drift.
// +optional
// +kubebuilder:validation:MaxLength=63
// +kubebuilder:validation:Pattern=`^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$`
BuildID string `json:"buildID,omitempty"`
}

// TemporalWorkerDeploymentSpec defines the desired state of TemporalWorkerDeployment
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ spec:
gate:
properties:
input:
type: object
x-kubernetes-preserve-unknown-fields: true
inputFrom:
properties:
Expand All @@ -73,25 +72,27 @@ spec:
key:
type: string
name:
default: ""
type: string
optional:
type: boolean
required:
- key
- name
type: object
x-kubernetes-map-type: atomic
secretKeyRef:
properties:
key:
type: string
name:
default: ""
type: string
optional:
type: boolean
required:
- key
- name
type: object
x-kubernetes-map-type: atomic
type: object
workflowType:
type: string
Expand Down Expand Up @@ -3941,6 +3942,10 @@ spec:
type: object
workerOptions:
properties:
buildID:
maxLength: 63
pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$
type: string
connectionRef:
properties:
name:
Expand Down
19 changes: 16 additions & 3 deletions internal/k8s/deployments.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"fmt"
"regexp"
"sort"
"strings"

"github.com/distribution/reference"
temporaliov1alpha1 "github.com/temporalio/temporal-worker-controller/api/v1alpha1"
Expand All @@ -31,7 +32,7 @@ const (
WorkerDeploymentNameSeparator = "/"
ResourceNameSeparator = "-"
MaxBuildIdLen = 63
ConnectionSpecHashAnnotation = "temporal.io/connection-spec-hash"
ConnectionSpecHashAnnotation = "temporal.io/connection-spec-hash"
)

// DeploymentState represents the Kubernetes state of all deployments for a temporal worker deployment
Expand Down Expand Up @@ -112,6 +113,15 @@ func NewObjectRef(obj client.Object) *corev1.ObjectReference {
}

func ComputeBuildID(w *temporaliov1alpha1.TemporalWorkerDeployment) string {
// Check for user-provided build ID in spec.workerOptions.buildID
if override := w.Spec.WorkerOptions.BuildID; override != "" {
cleaned := cleanBuildID(override)
if cleaned != "" {
return TruncateString(cleaned, MaxBuildIdLen)
}
// Fall through to default hash-based generation if buildID is invalid after cleaning
}

if containers := w.Spec.Template.Spec.Containers; len(containers) > 0 {
if img := containers[0].Image; img != "" {
shortHashSuffix := ResourceNameSeparator + utils.ComputeHash(&w.Spec.Template, nil, true)
Expand Down Expand Up @@ -177,9 +187,12 @@ func CleanStringForDNS(s string) string {
//
// Temporal build IDs only need to be ASCII.
func cleanBuildID(s string) string {
// Keep only letters, numbers, dashes, and dots.
// Keep only letters, numbers, dashes, underscores, and dots.
re := regexp.MustCompile(`[^a-zA-Z0-9-._]+`)
return re.ReplaceAllString(s, ResourceNameSeparator)
s = re.ReplaceAllString(s, ResourceNameSeparator)
// Trim leading/trailing separators to comply with K8s label requirements
// (must begin and end with alphanumeric character)
return strings.Trim(s, "-._")
}

// NewDeploymentWithOwnerRef creates a new deployment resource, including owner references
Expand Down
75 changes: 75 additions & 0 deletions internal/k8s/deployments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,81 @@ func TestGenerateBuildID(t *testing.T) {
expectedHashLen: 4,
expectEquality: false,
},
{
name: "spec buildID override",
generateInputs: func() (*temporaliov1alpha1.TemporalWorkerDeployment, *temporaliov1alpha1.TemporalWorkerDeployment) {
twd := testhelpers.MakeTWDWithImage("", "", "some-image")
twd.Spec.WorkerOptions.BuildID = "manual-override-v1"
return twd, nil
},
expectedPrefix: "manual-override-v1",
expectedHashLen: 2, // "v1" is length 2.
// The override returns cleanBuildID(buildIDValue).
// If buildID is "manual-override-v1", cleanBuildID returns "manual-override-v1".
// split by "-" gives ["manual", "override", "v1"]. last element is "v1", len is 2.
expectEquality: false,
},
{
name: "spec buildID override stability",
generateInputs: func() (*temporaliov1alpha1.TemporalWorkerDeployment, *temporaliov1alpha1.TemporalWorkerDeployment) {
// Two TWDs with DIFFERENT images but SAME buildID
twd1 := testhelpers.MakeTWDWithImage("", "", "image-v1")
twd1.Spec.WorkerOptions.BuildID = "stable-id"

twd2 := testhelpers.MakeTWDWithImage("", "", "image-v2")
twd2.Spec.WorkerOptions.BuildID = "stable-id"
return twd1, twd2
},
expectedPrefix: "stable-id",
expectedHashLen: 2, // "id" has len 2
expectEquality: true,
},
{
name: "spec buildID override with long value is truncated",
generateInputs: func() (*temporaliov1alpha1.TemporalWorkerDeployment, *temporaliov1alpha1.TemporalWorkerDeployment) {
// 72 char buildID - should be truncated to 63
longBuildID := "this-is-a-very-long-build-id-value-that-exceeds-63-characters-limit"
twd := testhelpers.MakeTWDWithImage("", "", "some-image")
twd.Spec.WorkerOptions.BuildID = longBuildID
return twd, nil
},
expectedPrefix: "this-is-a-very-long-build-id-value-that-exceeds-63-characters-l",
expectedHashLen: 1, // "l" has len 1
expectEquality: false,
},
{
name: "spec buildID override with empty value falls back to hash",
generateInputs: func() (*temporaliov1alpha1.TemporalWorkerDeployment, *temporaliov1alpha1.TemporalWorkerDeployment) {
twd := testhelpers.MakeTWDWithImage("", "", "fallback-image")
twd.Spec.WorkerOptions.BuildID = "" // empty buildID
return twd, nil
},
expectedPrefix: "fallback-image", // Falls back to image-based build ID
expectedHashLen: 4,
expectEquality: false,
},
{
name: "spec buildID override with only invalid chars falls back to hash",
generateInputs: func() (*temporaliov1alpha1.TemporalWorkerDeployment, *temporaliov1alpha1.TemporalWorkerDeployment) {
twd := testhelpers.MakeTWDWithImage("", "", "fallback-image2")
twd.Spec.WorkerOptions.BuildID = "###$$$%%%" // all invalid chars
return twd, nil
},
expectedPrefix: "fallback-image2", // Falls back to image-based build ID
expectedHashLen: 4,
expectEquality: false,
},
{
name: "spec buildID override trims leading and trailing separators",
generateInputs: func() (*temporaliov1alpha1.TemporalWorkerDeployment, *temporaliov1alpha1.TemporalWorkerDeployment) {
twd := testhelpers.MakeTWDWithImage("", "", "some-image")
twd.Spec.WorkerOptions.BuildID = "---my-build-id---" // leading/trailing dashes
return twd, nil
},
expectedPrefix: "my-build-id", // dashes trimmed
expectedHashLen: 2, // "id" has len 2
expectEquality: false,
},
}

for _, tt := range tests {
Expand Down
Loading