Skip to content

Commit d27f820

Browse files
tshtarkclaude
andcommitted
feat(api): add stable build ID override via spec.workerOptions.buildID
Adds support for user-controlled build IDs via spec.workerOptions.buildID, enabling rolling updates for non-workflow code changes while preserving new deployment creation for workflow code changes. Key changes: - Add BuildID field to WorkerOptions struct in API types - Update ComputeBuildID to use spec field instead of annotation - Implement drift detection by comparing deployed spec with desired spec - Only check for drift when buildID is explicitly set by user This solves deployment proliferation for PINNED versioning strategy where any pod spec change (image tag, env vars, resources) would generate a new build ID and create unnecessary deployments. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 297dd18 commit d27f820

File tree

6 files changed

+591
-11
lines changed

6 files changed

+591
-11
lines changed

api/v1alpha1/worker_types.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,22 @@ type WorkerOptions struct {
2727
// The Temporal namespace for the worker to connect to.
2828
// +kubebuilder:validation:MinLength=1
2929
TemporalNamespace string `json:"temporalNamespace"`
30+
// BuildID optionally overrides the auto-generated build ID for this worker deployment.
31+
// When set, the controller uses this value instead of computing a build ID from the
32+
// pod template hash. This enables rolling updates for non-workflow code changes
33+
// (bug fixes, config changes) while preserving the same build ID.
34+
//
35+
// WARNING: Using a custom build ID requires careful management. If workflow code changes
36+
// but BuildID stays the same, pinned workflows may execute on workers running incompatible
37+
// code. Only use this when you have a reliable way to compute a hash of your workflow
38+
// definitions (e.g., hashing workflow source files in CI/CD).
39+
//
40+
// When the BuildID is stable but pod template spec changes, the controller triggers
41+
// a rolling update instead of creating a new deployment version.
42+
// +optional
43+
// +kubebuilder:validation:MaxLength=63
44+
// +kubebuilder:validation:Pattern=`^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$`
45+
BuildID string `json:"buildID,omitempty"`
3046
}
3147

3248
// TemporalWorkerDeploymentSpec defines the desired state of TemporalWorkerDeployment

helm/temporal-worker-controller/crds/temporal.io_temporalworkerdeployments.yaml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@ spec:
6464
gate:
6565
properties:
6666
input:
67-
type: object
6867
x-kubernetes-preserve-unknown-fields: true
6968
inputFrom:
7069
properties:
@@ -73,25 +72,27 @@ spec:
7372
key:
7473
type: string
7574
name:
75+
default: ""
7676
type: string
7777
optional:
7878
type: boolean
7979
required:
8080
- key
81-
- name
8281
type: object
82+
x-kubernetes-map-type: atomic
8383
secretKeyRef:
8484
properties:
8585
key:
8686
type: string
8787
name:
88+
default: ""
8889
type: string
8990
optional:
9091
type: boolean
9192
required:
9293
- key
93-
- name
9494
type: object
95+
x-kubernetes-map-type: atomic
9596
type: object
9697
workflowType:
9798
type: string
@@ -3941,6 +3942,10 @@ spec:
39413942
type: object
39423943
workerOptions:
39433944
properties:
3945+
buildID:
3946+
maxLength: 63
3947+
pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$
3948+
type: string
39443949
connectionRef:
39453950
properties:
39463951
name:

internal/k8s/deployments.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"fmt"
1212
"regexp"
1313
"sort"
14+
"strings"
1415

1516
"github.com/distribution/reference"
1617
temporaliov1alpha1 "github.com/temporalio/temporal-worker-controller/api/v1alpha1"
@@ -31,7 +32,7 @@ const (
3132
WorkerDeploymentNameSeparator = "/"
3233
ResourceNameSeparator = "-"
3334
MaxBuildIdLen = 63
34-
ConnectionSpecHashAnnotation = "temporal.io/connection-spec-hash"
35+
ConnectionSpecHashAnnotation = "temporal.io/connection-spec-hash"
3536
)
3637

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

114115
func ComputeBuildID(w *temporaliov1alpha1.TemporalWorkerDeployment) string {
116+
// Check for user-provided build ID in spec.workerOptions.buildID
117+
if override := w.Spec.WorkerOptions.BuildID; override != "" {
118+
cleaned := cleanBuildID(override)
119+
if cleaned != "" {
120+
return TruncateString(cleaned, MaxBuildIdLen)
121+
}
122+
// Fall through to default hash-based generation if buildID is invalid after cleaning
123+
}
124+
115125
if containers := w.Spec.Template.Spec.Containers; len(containers) > 0 {
116126
if img := containers[0].Image; img != "" {
117127
shortHashSuffix := ResourceNameSeparator + utils.ComputeHash(&w.Spec.Template, nil, true)
@@ -177,9 +187,12 @@ func CleanStringForDNS(s string) string {
177187
//
178188
// Temporal build IDs only need to be ASCII.
179189
func cleanBuildID(s string) string {
180-
// Keep only letters, numbers, dashes, and dots.
190+
// Keep only letters, numbers, dashes, underscores, and dots.
181191
re := regexp.MustCompile(`[^a-zA-Z0-9-._]+`)
182-
return re.ReplaceAllString(s, ResourceNameSeparator)
192+
s = re.ReplaceAllString(s, ResourceNameSeparator)
193+
// Trim leading/trailing separators to comply with K8s label requirements
194+
// (must begin and end with alphanumeric character)
195+
return strings.Trim(s, "-._")
183196
}
184197

185198
// NewDeploymentWithOwnerRef creates a new deployment resource, including owner references

internal/k8s/deployments_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,81 @@ func TestGenerateBuildID(t *testing.T) {
350350
expectedHashLen: 4,
351351
expectEquality: false,
352352
},
353+
{
354+
name: "spec buildID override",
355+
generateInputs: func() (*temporaliov1alpha1.TemporalWorkerDeployment, *temporaliov1alpha1.TemporalWorkerDeployment) {
356+
twd := testhelpers.MakeTWDWithImage("", "", "some-image")
357+
twd.Spec.WorkerOptions.BuildID = "manual-override-v1"
358+
return twd, nil
359+
},
360+
expectedPrefix: "manual-override-v1",
361+
expectedHashLen: 2, // "v1" is length 2.
362+
// The override returns cleanBuildID(buildIDValue).
363+
// If buildID is "manual-override-v1", cleanBuildID returns "manual-override-v1".
364+
// split by "-" gives ["manual", "override", "v1"]. last element is "v1", len is 2.
365+
expectEquality: false,
366+
},
367+
{
368+
name: "spec buildID override stability",
369+
generateInputs: func() (*temporaliov1alpha1.TemporalWorkerDeployment, *temporaliov1alpha1.TemporalWorkerDeployment) {
370+
// Two TWDs with DIFFERENT images but SAME buildID
371+
twd1 := testhelpers.MakeTWDWithImage("", "", "image-v1")
372+
twd1.Spec.WorkerOptions.BuildID = "stable-id"
373+
374+
twd2 := testhelpers.MakeTWDWithImage("", "", "image-v2")
375+
twd2.Spec.WorkerOptions.BuildID = "stable-id"
376+
return twd1, twd2
377+
},
378+
expectedPrefix: "stable-id",
379+
expectedHashLen: 2, // "id" has len 2
380+
expectEquality: true,
381+
},
382+
{
383+
name: "spec buildID override with long value is truncated",
384+
generateInputs: func() (*temporaliov1alpha1.TemporalWorkerDeployment, *temporaliov1alpha1.TemporalWorkerDeployment) {
385+
// 72 char buildID - should be truncated to 63
386+
longBuildID := "this-is-a-very-long-build-id-value-that-exceeds-63-characters-limit"
387+
twd := testhelpers.MakeTWDWithImage("", "", "some-image")
388+
twd.Spec.WorkerOptions.BuildID = longBuildID
389+
return twd, nil
390+
},
391+
expectedPrefix: "this-is-a-very-long-build-id-value-that-exceeds-63-characters-l",
392+
expectedHashLen: 1, // "l" has len 1
393+
expectEquality: false,
394+
},
395+
{
396+
name: "spec buildID override with empty value falls back to hash",
397+
generateInputs: func() (*temporaliov1alpha1.TemporalWorkerDeployment, *temporaliov1alpha1.TemporalWorkerDeployment) {
398+
twd := testhelpers.MakeTWDWithImage("", "", "fallback-image")
399+
twd.Spec.WorkerOptions.BuildID = "" // empty buildID
400+
return twd, nil
401+
},
402+
expectedPrefix: "fallback-image", // Falls back to image-based build ID
403+
expectedHashLen: 4,
404+
expectEquality: false,
405+
},
406+
{
407+
name: "spec buildID override with only invalid chars falls back to hash",
408+
generateInputs: func() (*temporaliov1alpha1.TemporalWorkerDeployment, *temporaliov1alpha1.TemporalWorkerDeployment) {
409+
twd := testhelpers.MakeTWDWithImage("", "", "fallback-image2")
410+
twd.Spec.WorkerOptions.BuildID = "###$$$%%%" // all invalid chars
411+
return twd, nil
412+
},
413+
expectedPrefix: "fallback-image2", // Falls back to image-based build ID
414+
expectedHashLen: 4,
415+
expectEquality: false,
416+
},
417+
{
418+
name: "spec buildID override trims leading and trailing separators",
419+
generateInputs: func() (*temporaliov1alpha1.TemporalWorkerDeployment, *temporaliov1alpha1.TemporalWorkerDeployment) {
420+
twd := testhelpers.MakeTWDWithImage("", "", "some-image")
421+
twd.Spec.WorkerOptions.BuildID = "---my-build-id---" // leading/trailing dashes
422+
return twd, nil
423+
},
424+
expectedPrefix: "my-build-id", // dashes trimmed
425+
expectedHashLen: 2, // "id" has len 2
426+
expectEquality: false,
427+
},
353428
}
354429

355430
for _, tt := range tests {

0 commit comments

Comments
 (0)