feat: add TemporalWorker auto-provisioning from Queue labels

- Validate temporal namespace and Kubernetes names
- Configurable via env vars: KMSVC_TEMPORAL_NAMESPACE, KMSVC_TEMPORAL_WORKER_IMAGE
- Set ownerReference for cascade deletion and lifecycle management
- Use CreateOrUpdate for spec propagation (idempotent)
- Clean up TemporalWorker on Queue deletion
This commit is contained in:
Story Crater Bot
2026-07-11 07:30:13 -07:00
parent 6577efc100
commit 317396e785
+114
View File
@@ -7,6 +7,7 @@ package operator
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"strconv" "strconv"
"time" "time"
@@ -110,6 +111,10 @@ func (r *QueueReconciler) Reconcile(ctx context.Context, namespace, name string)
return r.setFailed(ctx, &queue, "PublishRedis", err) return r.setFailed(ctx, &queue, "PublishRedis", err)
} }
if err := r.reconcileTemporalWorker(ctx, &queue); err != nil {
return r.setFailed(ctx, &queue, "TemporalWorker", err)
}
queue.Status.Phase = kmsvcv1.QueuePhaseReady queue.Status.Phase = kmsvcv1.QueuePhaseReady
if err := r.Client.Status().Update(ctx, &queue); err != nil { if err := r.Client.Status().Update(ctx, &queue); err != nil {
return fmt.Errorf("update status %s: %w", name, err) return fmt.Errorf("update status %s: %w", name, err)
@@ -221,6 +226,56 @@ func (r *QueueReconciler) publishRedisState(ctx context.Context, queue *kmsvcv1.
return kmsvcredis.PutShardMap(ctx, r.Redis, queue.Name, shards) return kmsvcredis.PutShardMap(ctx, r.Redis, queue.Name, shards)
} }
// reconcileTemporalWorker creates or updates a TemporalWorker CRD if the Queue
// has the temporal.io/namespace label. One TemporalWorker per Temporal namespace
// handles all task queues in that namespace (Kafka broker model).
func (r *QueueReconciler) reconcileTemporalWorker(ctx context.Context, queue *kmsvcv1.Queue) error {
if queue.Labels == nil {
return nil
}
namespace := queue.Labels["temporal.io/namespace"]
if namespace == "" {
return nil
}
if !isValidTemporalNamespace(namespace) {
return fmt.Errorf("invalid temporal namespace label %q: must be lowercase alphanumeric and hyphens", namespace)
}
workerName := "worker-" + namespace
if err := validateKubernetesName(workerName); err != nil {
return fmt.Errorf("invalid kubernetes name %q: %w", workerName, err)
}
replicas := int32(1)
workerNamespace := getEnvOrDefault("KMSVC_TEMPORAL_NAMESPACE", "temporal")
workerImage := getEnvOrDefault("KMSVC_TEMPORAL_WORKER_IMAGE", "story-crater-backend:latest")
worker := &kmsvcv1.TemporalWorker{
ObjectMeta: metav1.ObjectMeta{
Name: workerName,
Namespace: workerNamespace,
},
Spec: kmsvcv1.TemporalWorkerSpec{
Namespace: namespace,
Image: workerImage,
Replicas: &replicas,
},
}
if err := controllerutil.SetControllerReference(queue, worker, r.Client.Scheme()); err != nil {
return fmt.Errorf("set controller reference: %w", err)
}
if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, worker, func() error {
return nil
}); err != nil {
return fmt.Errorf("create or update TemporalWorker %s: %w", workerName, err)
}
return nil
}
func (r *QueueReconciler) reconcileDelete(ctx context.Context, queue *kmsvcv1.Queue) error { func (r *QueueReconciler) reconcileDelete(ctx context.Context, queue *kmsvcv1.Queue) error {
if !controllerutil.ContainsFinalizer(queue, finalizerName) { if !controllerutil.ContainsFinalizer(queue, finalizerName) {
return nil return nil
@@ -239,6 +294,24 @@ func (r *QueueReconciler) reconcileDelete(ctx context.Context, queue *kmsvcv1.Qu
if err := kmsvcredis.DeleteShardMap(ctx, r.Redis, queue.Name); err != nil { if err := kmsvcredis.DeleteShardMap(ctx, r.Redis, queue.Name); err != nil {
return err return err
} }
if queue.Labels != nil {
namespace := queue.Labels["temporal.io/namespace"]
if namespace != "" {
workerName := "worker-" + namespace
workerNamespace := getEnvOrDefault("KMSVC_TEMPORAL_NAMESPACE", "temporal")
worker := &kmsvcv1.TemporalWorker{
ObjectMeta: metav1.ObjectMeta{
Name: workerName,
Namespace: workerNamespace,
},
}
if err := r.Client.Delete(ctx, worker); err != nil && !apierrors.IsNotFound(err) {
return fmt.Errorf("delete TemporalWorker %s: %w", workerName, err)
}
}
}
controllerutil.RemoveFinalizer(queue, finalizerName) controllerutil.RemoveFinalizer(queue, finalizerName)
if err := r.Client.Update(ctx, queue); err != nil { if err := r.Client.Update(ctx, queue); err != nil {
return fmt.Errorf("remove finalizer %s: %w", queue.Name, err) return fmt.Errorf("remove finalizer %s: %w", queue.Name, err)
@@ -255,3 +328,44 @@ func nextShardID(shards []kmsvcv1.ShardStatus) string {
} }
return strconv.Itoa(max + 1) return strconv.Itoa(max + 1)
} }
func isValidTemporalNamespace(namespace string) bool {
if len(namespace) == 0 || len(namespace) > 255 {
return false
}
for _, ch := range namespace {
if !((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-') {
return false
}
}
return true
}
func validateKubernetesName(name string) error {
if len(name) == 0 || len(name) > 253 {
return fmt.Errorf("name length must be 1-253 characters")
}
for i, ch := range name {
isLower := ch >= 'a' && ch <= 'z'
isDigit := ch >= '0' && ch <= '9'
isHyphen := ch == '-'
isValid := isLower || isDigit || isHyphen
if !isValid {
return fmt.Errorf("name contains invalid character %q at position %d", ch, i)
}
if i == 0 && (isHyphen || isDigit) {
return fmt.Errorf("name must start with lowercase letter")
}
if i == len(name)-1 && isHyphen {
return fmt.Errorf("name must end with lowercase letter or digit")
}
}
return nil
}
func getEnvOrDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}