From d068a5b55c611e492f8e00011d3d3027998540b9 Mon Sep 17 00:00:00 2001 From: Brian Morton Date: Thu, 25 Jun 2026 06:45:43 +0000 Subject: [PATCH 01/12] docs: add TemporalWorkflowRun CRD design spec Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Brian Morton --- .../2026-06-25-temporalworkflowrun-design.md | 363 ++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-25-temporalworkflowrun-design.md diff --git a/docs/superpowers/specs/2026-06-25-temporalworkflowrun-design.md b/docs/superpowers/specs/2026-06-25-temporalworkflowrun-design.md new file mode 100644 index 00000000..03610a92 --- /dev/null +++ b/docs/superpowers/specs/2026-06-25-temporalworkflowrun-design.md @@ -0,0 +1,363 @@ +# TemporalWorkflowRun CRD — Design + +**Status:** Approved (brainstorming) +**Date:** 2026-06-25 + +## Summary + +Add a new namespaced CRD, `TemporalWorkflowRun`, that lets users trigger +**one-off Temporal workflow executions** declaratively from Kubernetes — the +workflow analog of a Kubernetes `Job`. Creating the CR starts exactly one +workflow; the operator tracks the execution in `status`; an optional TTL cleans +up the CR after the workflow reaches a terminal state. + +The feature is **closed by default**: a `TemporalCluster` must explicitly opt in +via a `workflowRunPolicy` before the operator will start workflows against it, +and that policy can constrain which Temporal namespaces and task queues are +permitted. Kubernetes RBAC governs who may author runs; same-namespace +`clusterRef` resolution bounds the blast radius. + +## Goals + +- A `Job`-like, one-shot, immutable CRD for starting a single workflow. +- Keep `status` continuously updated with workflow execution details. +- Optional `ttlSecondsAfterFinished` that deletes the **CR** after the workflow + finishes (success or failure). +- Configurable behavior for the in-flight workflow when the CR is deleted. +- A cluster-owner-controlled opt-in policy so this cannot open a hole by + default. +- Reuse the existing mTLS target-resolution path unchanged. +- A Chainsaw e2e suite runnable locally on `nsc` and dispatchable/nightly in CI. + +## Non-Goals + +- Token-based (JWT/Entra) **outbound** authorization for operator-initiated + gRPC calls. This affects the existing `TemporalSchedule`/`TemporalNamespace` + controllers equally and is a separate, cross-cutting change. This PR reuses + the existing mTLS path only. +- Capturing the workflow **success result** payload into status (lives in + Temporal history; potentially large/sensitive). Only failure message/type is + captured. +- Re-running / declarative "ensure running" semantics. The CR is one-shot and + immutable; to run again, create a new CR. + +## Design Decisions (from brainstorming) + +1. **One-shot & immutable** (`Job`-like). The workflow definition is immutable + after create; only cleanup knobs (`ttlSecondsAfterFinished`, + `cancellationPolicy`) may change. +2. **TTL deletes the CR**, not the Temporal execution. Unset TTL = keep forever. + The countdown starts at the observed terminal time (`status.completionTime`). +3. **On-delete behavior** is a `cancellationPolicy`: `Abandon` (default, leave + running), `Cancel` (graceful), `Terminate` (hard), enacted via a finalizer. +4. **Status captures failure** (message + type) on non-success terminal states, + but **not** the success result payload. +5. **mTLS only** for now; outbound JWT authz is out of scope (see Non-Goals). +6. **Closed-by-default cluster policy.** `TemporalCluster.spec.workflowRunPolicy` + gates everything; `enabled` defaults `false`. `TemporalDevServer` carries the + same policy but defaults `enabled: true` (throwaway dev environments). + +## CRD: `TemporalWorkflowRun` + +Naming follows the `Temporal*` convention shared by every existing CRD. +shortName: `twr`. Scope: Namespaced. Storage version: v1alpha1. + +### Spec + +```go +type TemporalWorkflowRunSpec struct { + // ClusterRef references the TemporalCluster or TemporalDevServer that runs + // the workflow. Resolved in the same Kubernetes namespace as this CR. + ClusterRef ClusterReference `json:"clusterRef"` + + // Namespace is the Temporal namespace to start the workflow in. + Namespace string `json:"namespace"` + + // Workflow describes the one-off workflow to start. Immutable after create. + Workflow StartWorkflowAction `json:"workflow"` + + // TTLSecondsAfterFinished, when set, deletes this CR that many seconds + // after the workflow reaches a terminal state. Unset = keep forever. + // Mutable. + // +optional + TTLSecondsAfterFinished *int32 `json:"ttlSecondsAfterFinished,omitempty"` + + // CancellationPolicy controls what happens to a still-running workflow when + // this CR is deleted. Mutable. + // +kubebuilder:validation:Enum=Abandon;Cancel;Terminate + // +kubebuilder:default=Abandon + // +optional + CancellationPolicy string `json:"cancellationPolicy,omitempty"` +} +``` + +`Workflow` **reuses the existing `StartWorkflowAction`** type (from +`temporalschedule_types.go`): `workflowType`, `taskQueue`, `workflowID`, `args`, +`workflowExecutionTimeout`, `workflowRunTimeout`, `workflowTaskTimeout`, +`workflowIDReusePolicy`, `retryPolicy`, `memo`, `searchAttributes`. +`workflow.workflowID` defaults to `metadata.name` when empty. + +### Status + +```go +type TemporalWorkflowRunStatus struct { + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // Phase is a friendly lifecycle summary. + // +optional + Phase string `json:"phase,omitempty"` // Pending|Running|Completed|Failed|Terminated|Canceled|TimedOut|ContinuedAsNew + // +optional + WorkflowID string `json:"workflowID,omitempty"` + // +optional + RunID string `json:"runID,omitempty"` + // +optional + WorkflowType string `json:"workflowType,omitempty"` + // +optional + TaskQueue string `json:"taskQueue,omitempty"` + // +optional + StartTime *metav1.Time `json:"startTime,omitempty"` + // +optional + CloseTime *metav1.Time `json:"closeTime,omitempty"` + // CompletionTime is when the operator first observed a terminal state; it + // drives the TTL countdown. + // +optional + CompletionTime *metav1.Time `json:"completionTime,omitempty"` + // +optional + HistoryLength int64 `json:"historyLength,omitempty"` + // Failure carries the failure message/type for non-success terminal states. + // +optional + Failure *WorkflowRunFailure `json:"failure,omitempty"` + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +type WorkflowRunFailure struct { + // +optional + Message string `json:"message,omitempty"` + // +optional + Type string `json:"type,omitempty"` +} +``` + +Conditions: `Ready` (reconcile health / policy / start success) and `Finished` +(true once the workflow reaches a terminal state). + +Print columns: Cluster (`.spec.clusterRef.name`), Namespace +(`.spec.namespace`), Phase (`.status.phase`), Ready +(`.status.conditions[?(@.type=="Ready")].status`), Age. + +### Phase mapping + +Temporal `WorkflowExecutionStatus` → `phase`: + +| Temporal status | phase | terminal | +|-------------------------------|-----------------|----------| +| `UNSPECIFIED` | `Pending` | no | +| `RUNNING` | `Running` | no | +| `COMPLETED` | `Completed` | yes | +| `FAILED` | `Failed` | yes | +| `CANCELED` | `Canceled` | yes | +| `TERMINATED` | `Terminated` | yes | +| `CONTINUED_AS_NEW` | `ContinuedAsNew`| yes | +| `TIMED_OUT` | `TimedOut` | yes | + +`failure` is populated for `Failed`/`Terminated`/`TimedOut`. + +## Cluster-side opt-in policy + +Add to both `TemporalClusterSpec` and `TemporalDevServerSpec`: + +```go +type WorkflowRunPolicy struct { + // Enabled permits operator-initiated workflow runs against this target. + // TemporalCluster default: false. TemporalDevServer default: true. + // +optional + Enabled bool `json:"enabled,omitempty"` + // AllowedNamespaces optionally restricts which Temporal namespaces runs may + // target. Empty = any. + // +optional + AllowedNamespaces []string `json:"allowedNamespaces,omitempty"` + // AllowedTaskQueues optionally restricts which task queues runs may use. + // Empty = any. + // +optional + AllowedTaskQueues []string `json:"allowedTaskQueues,omitempty"` +} +``` + +Field is `spec.workflowRunPolicy *WorkflowRunPolicy` on both targets. + +**Default semantics** are encoded in the resolver/controller, not via kubebuilder +defaults, so that absence is meaningful per-kind: + +- `TemporalCluster`: a nil/absent policy means **disabled** (closed by default). +- `TemporalDevServer`: a nil/absent policy means **enabled with no allowlists**. + +`resolveTarget` surfaces the effective policy on `ResolvedTarget`: + +```go +type ResolvedTarget struct { + Address string + TLSConfig *tls.Config + Ready bool + WorkflowRunPolicy WorkflowRunPolicy // effective, defaults already applied +} +``` + +The controller enforces the policy before starting: if disabled, the namespace +is not in a non-empty `AllowedNamespaces`, or the task queue is not in a +non-empty `AllowedTaskQueues`, it sets `Ready=False` with reason +`WorkflowRunNotPermitted` and a clear message, and **never starts the workflow**. + +## Temporal client — `internal/temporal/workflowrun.go` + +New interface, built with the raw `workflowservice` gRPC client, consistent with +the existing schedule/namespace clients. mTLS is handled entirely by the +injected `*tls.Config`. + +```go +type WorkflowRunClient interface { + // Start starts the workflow and returns its runID. Idempotent: uses + // RequestId derived from the CR UID plus the configured workflow-id reuse + // policy so a retried Start does not double-execute. + Start(ctx context.Context, params StartWorkflowParams) (runID string, err error) + // Describe returns the observed execution state. For non-success terminal + // states it reads the close event from history to extract the failure + // message/type. + Describe(ctx context.Context, namespace, workflowID, runID string) (*WorkflowExecutionInfo, error) + Cancel(ctx context.Context, namespace, workflowID, runID string) error + Terminate(ctx context.Context, namespace, workflowID, runID, reason string) error + Close() error +} + +type WorkflowRunClientFactory func(ctx context.Context, address string, tlsConfig *tls.Config) (WorkflowRunClient, error) + +type WorkflowExecutionInfo struct { + Status enumspb.WorkflowExecutionStatus + RunID string + StartTime *time.Time + CloseTime *time.Time + HistoryLength int64 + Failure *WorkflowFailure // message + type, only when applicable +} +``` + +`Start` reuses the existing `StartWorkflowParams` plus the json/plain payload +encoding helpers already in `schedule.go` (`encodeJSONPayloads`, +`encodeJSONFields`) — those should be reused (and, if convenient, hoisted to a +shared location) rather than duplicated. + +## Controller — `internal/controller/temporalworkflowrun_controller.go` + +Finalizer: `temporal.bmor10.com/workflowrun`. + +Reconcile flow: + +1. Get the CR. Resolve the target via `resolveTarget` (mTLS handled here). + - Not found / unreachable while deleting → drop finalizer and forget + (existing `removeFinalizerAndForget` pattern). + - `ErrTargetNotFound` → `Ready=False` (`ClusterNotFound`), requeue. +2. Build the `WorkflowRunClient` via the injectable factory. +3. **Deletion** (`DeletionTimestamp != nil`): if the workflow is still running, + apply `cancellationPolicy` (`Cancel`/`Terminate`; `Abandon` is a no-op), then + remove the finalizer. +4. Ensure the finalizer is present. +5. Target not Ready → `Ready=False` (`ClusterNotReady`), short requeue. +6. **Enforce `WorkflowRunPolicy`.** Violation → `Ready=False` + (`WorkflowRunNotPermitted`), no start, status update, return. +7. **Start if needed** (`status.runID == ""`): call `Start`; record + `workflowID`, `runID`, `workflowType`, `taskQueue`, `startTime`, phase + `Running`, `Ready=True`. +8. **Poll** if non-terminal: `Describe`; update status; requeue ~10s. +9. **Terminal**: set `phase`, `failure` (if any), `closeTime`, `historyLength`, + `completionTime` (once), `Finished=True`. If `ttlSecondsAfterFinished` is set, + delete the CR when `completionTime + TTL <= now`; otherwise requeue for the + remaining duration. + +RBAC markers: full verbs on `temporalworkflowruns` (+`/status`, `/finalizers`), +and `get;list;watch` on `temporalclusters`/`temporaldevservers` (already present +for satellite controllers). + +Idempotency: deterministic workflow ID (`spec.workflow.workflowID` or CR name) + +`RequestId` from the CR UID means a duplicate `Start` is de-duplicated by +Temporal. On `AlreadyExists`, the controller resolves the existing runID via +`Describe`. + +## Webhook — `internal/webhook/v1alpha1/temporalworkflowrun_webhook.go` + +Validating webhook (`failurePolicy=fail`, `verbs=create;update`): + +- **Create:** require `clusterRef.name`, `namespace`, `workflow.workflowType`, + `workflow.taskQueue`; validate `args`/`memo`/`searchAttributes` are valid JSON + (reuse `validateJSONList`/`validateJSONMap`); validate + `workflow.workflowIDReusePolicy` enum. +- **Update:** reject changes to `clusterRef`, `namespace`, and the entire + `workflow` block (immutable, one-shot). Allow `ttlSecondsAfterFinished` and + `cancellationPolicy` to change. Re-run create-style validation on the + (unchanged) workflow for safety. + +## Wiring & generated artifacts + +- `cmd/main.go`: register `TemporalWorkflowRunReconciler` and the webhook setup. +- `PROJECT`: add the new resource/webhook entries. +- `make generate manifests`: deepcopy + CRD manifest (including the new + `workflowRunPolicy` fields on cluster/devserver CRDs). +- `make helm-chart`: regenerate `dist/chart` (new CRD + RBAC). Do not hand-edit + `dist/chart`. +- `make api-docs docs-crd-reference`: regenerate and commit + `docs/api/v1alpha1.md` and `docs/content/reference/_index.md` (docs CI drift + check). +- `config/samples`: a `TemporalWorkflowRun` example (+ a cluster snippet showing + `workflowRunPolicy`). + +## Testing + +**Unit / envtest:** + +- Controller (fake client + fake `WorkflowRunClient`): start → status populated; + poll to each terminal state; failure capture; TTL deletion timing; each + `cancellationPolicy` on delete; policy-denied (disabled, namespace not + allowed, task queue not allowed) starts nothing. +- Webhook: required-field validation, JSON validation, reuse-policy enum, and + immutability of `clusterRef`/`namespace`/`workflow` on update. +- Client mapping: payload encoding, status→phase mapping, failure extraction + from the close event. + +**E2e — `test/e2e/workflowrun/` (Chainsaw):** + +Runs against a `TemporalDevServer` (embedded SQLite — no external DB). Terminal +state is driven by terminating the workflow with the `admin-tools` image, so no +custom worker image is required. + +Steps: + +1. `dev-server` — apply a `TemporalDevServer` whose `workflowRunPolicy` sets an + `allowedTaskQueues` allowlist; assert Ready. +2. `register-namespace` — apply a `TemporalNamespace` (`orders`); assert + registered. +3. `happy-path` — apply a `TemporalWorkflowRun` using an allowed task queue and + a short `ttlSecondsAfterFinished`. Assert `phase: Running`, + `status.workflowID`/`runID` populated, `Ready=True`. +4. `terminal-and-ttl` — `kubectl run … admin-tools temporal workflow terminate`; + assert operator observes `phase: Terminated` and `Finished=True`; then assert + the CR is auto-deleted once the TTL elapses. +5. `policy-deny` — apply a `TemporalWorkflowRun` whose `taskQueue` is not in the + allowlist; assert `Ready=False`, reason `WorkflowRunNotPermitted`, and no + `workflowID` in status. + +**CI wiring (`.github/workflows/e2e.yml`):** add +`workflowrun='{"suite":"workflowrun"}'` (devserver-style, no `persistence`); +include it in the nightly `schedule` combo list, the `workflow_dispatch` `case` +switch, and the `all` option (matching how `mtls`/`upgrade`/etc. are +dispatchable but excluded from the default PR run). Mirror the devserver image +pre-pull handling (dev-server + admin-tools images). + +**Local:** `make chainsaw-test-nsc SUITE=workflowrun`. + +## Delivery + +Conventional Commits with DCO sign-off, on a feature branch, opened as a PR. +Run `make generate manifests build test lint helm-chart api-docs +docs-crd-reference` before pushing. The commitlint check is non-blocking but the +DCO and generated-artifact drift checks are enforced. From 3af5fc20ab3e3d0c54914fca7ddc31df5426da92 Mon Sep 17 00:00:00 2001 From: Brian Morton Date: Thu, 25 Jun 2026 07:10:25 +0000 Subject: [PATCH 02/12] docs: add TemporalWorkflowRun implementation plan Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Brian Morton --- .../2026-06-25-temporalworkflowrun-crd.md | 1706 +++++++++++++++++ 1 file changed, 1706 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-25-temporalworkflowrun-crd.md diff --git a/docs/superpowers/plans/2026-06-25-temporalworkflowrun-crd.md b/docs/superpowers/plans/2026-06-25-temporalworkflowrun-crd.md new file mode 100644 index 00000000..01b8739a --- /dev/null +++ b/docs/superpowers/plans/2026-06-25-temporalworkflowrun-crd.md @@ -0,0 +1,1706 @@ +# TemporalWorkflowRun CRD Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `TemporalWorkflowRun` CRD that triggers one-off Temporal workflow executions declaratively (a `Job` analog), tracks the execution in status, supports TTL cleanup, and is gated by a closed-by-default cluster opt-in policy. + +**Architecture:** A new namespaced CRD plus a satellite controller that reuses the existing `resolveTarget`/`clusterTLSConfig` (mTLS) path. The controller starts the workflow via a new raw-gRPC `WorkflowRunClient`, polls `DescribeWorkflowExecution` to keep status current, enforces a `WorkflowRunPolicy` declared on the target `TemporalCluster`/`TemporalDevServer`, and deletes the CR after `ttlSecondsAfterFinished`. A finalizer applies a `cancellationPolicy` to in-flight workflows on delete. + +**Tech Stack:** Go, kubebuilder/controller-runtime v0.23 (typed webhooks), `go.temporal.io/api` raw gRPC clients, Ginkgo/Gomega + envtest, Chainsaw e2e. + +## Global Constraints + +- Module `github.com/bmorton/temporal-operator`; API group domain `temporal.bmor10.com`; copyright owner "Brian Morton". Every new `.go` file starts with the Apache 2.0 license header copied verbatim from a neighbouring file (e.g. `api/v1alpha1/temporalschedule_types.go` lines 1-15). +- Go 1.26.4 (per `go.mod`). +- Conventional Commits; **every** commit signed off: `git commit -s`. +- Never hand-edit `dist/chart` — regenerate with `make helm-chart`. +- After API changes run `make generate manifests`; after API doc-affecting changes run `make api-docs docs-crd-reference` and commit `docs/api/v1alpha1.md` + `docs/content/reference/_index.md`. +- controller-runtime v0.23 typed webhooks: implement `admission.Validator[*T]`, register via `ctrl.NewWebhookManagedBy(mgr, &T{}).WithValidator(...)`. +- Satellite controllers resolve `clusterRef` only within the CR's own Kubernetes namespace (security boundary — do not change this). +- Add the `Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>` trailer to commit messages. + +--- + +## File Structure + +**Created:** +- `api/v1alpha1/temporalworkflowrun_types.go` — CRD spec/status/`WorkflowRunFailure`, plus the shared `WorkflowRunPolicy` type and kubebuilder markers. +- `internal/temporal/workflowrun.go` — `WorkflowRunClient` interface, factory, raw-gRPC impl, status→phase mapping, failure extraction. +- `internal/temporal/workflowrun_test.go` — pure mapping/encoding tests. +- `internal/controller/temporalworkflowrun_controller.go` — reconciler. +- `internal/controller/temporalworkflowrun_controller_test.go` — envtest/Ginkgo tests + fake client. +- `internal/webhook/v1alpha1/temporalworkflowrun_webhook.go` — validating webhook. +- `internal/webhook/v1alpha1/temporalworkflowrun_webhook_test.go` — webhook tests. +- `config/samples/temporal_v1alpha1_temporalworkflowrun.yaml` — sample CR. +- `test/e2e/workflowrun/{chainsaw-test.yaml,01-devserver.yaml,01-assert.yaml,02-namespace.yaml,02-assert.yaml,03-workflowrun.yaml,03-assert.yaml,05-deny.yaml,05-assert.yaml}` — e2e suite. + +**Modified:** +- `api/v1alpha1/temporalcluster_types.go` — add `WorkflowRunPolicy *WorkflowRunPolicy` to `TemporalClusterSpec`. +- `api/v1alpha1/temporaldevserver_types.go` — add `WorkflowRunPolicy *WorkflowRunPolicy` to `TemporalDevServerSpec`. +- `api/v1alpha1/conditions.go` — add reason constants. +- `internal/controller/target.go` — surface effective `WorkflowRunPolicy` on `ResolvedTarget`. +- `internal/controller/target_test.go` — policy-default tests. +- `cmd/main.go` — register reconciler + webhook. +- `PROJECT` — add resource/webhook entries. +- `.github/workflows/e2e.yml` — add the `workflowrun` combo. +- Generated: `config/crd/bases/*`, `config/rbac/*`, `dist/chart/*`, `docs/api/v1alpha1.md`, `docs/content/reference/_index.md`, `api/v1alpha1/zz_generated.deepcopy.go`. + +--- + +### Task 1: API types — `TemporalWorkflowRun` + `WorkflowRunPolicy` + +**Files:** +- Create: `api/v1alpha1/temporalworkflowrun_types.go` +- Modify: `api/v1alpha1/temporalcluster_types.go` (add field to `TemporalClusterSpec`), `api/v1alpha1/temporaldevserver_types.go` (add field to `TemporalDevServerSpec`) +- Modify: `api/v1alpha1/conditions.go` (add reason constants) + +**Interfaces:** +- Produces: types `TemporalWorkflowRun`, `TemporalWorkflowRunSpec`, `TemporalWorkflowRunStatus`, `WorkflowRunFailure`, `WorkflowRunPolicy`; constants `ReasonWorkflowRunNotPermitted`, `ReasonWorkflowFinished`, `ReasonWorkflowRunning`, `ReasonClusterNotFound`, `ReasonClusterNotReady`. `TemporalWorkflowRunSpec.Workflow` is of the **existing** type `StartWorkflowAction`. + +- [ ] **Step 1: Create the types file.** Copy the license header (lines 1-15) from `api/v1alpha1/temporalschedule_types.go`, then add: + +```go +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// WorkflowRunPolicy is a per-target opt-in gate controlling operator-initiated +// workflow runs. It is declared on a TemporalCluster or TemporalDevServer by the +// cluster owner. Defaults differ per kind and are applied by the controller's +// target resolver, not by kubebuilder defaults, so an absent policy is +// meaningful: disabled for TemporalCluster, enabled (no allowlist) for +// TemporalDevServer. +type WorkflowRunPolicy struct { + // Enabled permits operator-initiated workflow runs against this target. + // +optional + Enabled bool `json:"enabled,omitempty"` + // AllowedNamespaces optionally restricts which Temporal namespaces runs may + // target. Empty means any namespace is allowed. + // +optional + AllowedNamespaces []string `json:"allowedNamespaces,omitempty"` + // AllowedTaskQueues optionally restricts which task queues runs may use. + // Empty means any task queue is allowed. + // +optional + AllowedTaskQueues []string `json:"allowedTaskQueues,omitempty"` +} + +// TemporalWorkflowRunSpec defines the desired state of a one-off workflow run. +type TemporalWorkflowRunSpec struct { + // ClusterRef references the TemporalCluster or TemporalDevServer that runs + // the workflow. Resolved in the same Kubernetes namespace as this CR. + ClusterRef ClusterReference `json:"clusterRef"` + + // Namespace is the Temporal namespace to start the workflow in. + Namespace string `json:"namespace"` + + // Workflow describes the one-off workflow to start. Immutable after create. + Workflow StartWorkflowAction `json:"workflow"` + + // TTLSecondsAfterFinished, when set, deletes this CR that many seconds after + // the workflow reaches a terminal state. Unset keeps the CR indefinitely. + // +optional + TTLSecondsAfterFinished *int32 `json:"ttlSecondsAfterFinished,omitempty"` + + // CancellationPolicy controls what happens to a still-running workflow when + // this CR is deleted. + // +kubebuilder:validation:Enum=Abandon;Cancel;Terminate + // +kubebuilder:default=Abandon + // +optional + CancellationPolicy string `json:"cancellationPolicy,omitempty"` +} + +// WorkflowRunFailure carries failure detail for non-success terminal states. +type WorkflowRunFailure struct { + // +optional + Message string `json:"message,omitempty"` + // +optional + Type string `json:"type,omitempty"` +} + +// TemporalWorkflowRunStatus is the observed state of a workflow run. +type TemporalWorkflowRunStatus struct { + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // Phase is a friendly lifecycle summary: Pending, Running, Completed, + // Failed, Terminated, Canceled, TimedOut, or ContinuedAsNew. + // +optional + Phase string `json:"phase,omitempty"` + // +optional + WorkflowID string `json:"workflowID,omitempty"` + // +optional + RunID string `json:"runID,omitempty"` + // +optional + WorkflowType string `json:"workflowType,omitempty"` + // +optional + TaskQueue string `json:"taskQueue,omitempty"` + // +optional + StartTime *metav1.Time `json:"startTime,omitempty"` + // +optional + CloseTime *metav1.Time `json:"closeTime,omitempty"` + // CompletionTime is when the operator first observed a terminal state; it + // drives the TTL countdown. + // +optional + CompletionTime *metav1.Time `json:"completionTime,omitempty"` + // +optional + HistoryLength int64 `json:"historyLength,omitempty"` + // Failure carries the failure message/type for non-success terminal states. + // +optional + Failure *WorkflowRunFailure `json:"failure,omitempty"` + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Namespaced,shortName=twr +// +kubebuilder:subresource:status +// +kubebuilder:storageversion +// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.clusterRef.name` +// +kubebuilder:printcolumn:name="Namespace",type=string,JSONPath=`.spec.namespace` +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// TemporalWorkflowRun is the Schema for the temporalworkflowruns API. +type TemporalWorkflowRun struct { + metav1.TypeMeta `json:",inline"` + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` + // +required + Spec TemporalWorkflowRunSpec `json:"spec"` + // +optional + Status TemporalWorkflowRunStatus `json:"status,omitzero"` +} + +// +kubebuilder:object:root=true + +// TemporalWorkflowRunList contains a list of TemporalWorkflowRun. +type TemporalWorkflowRunList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []TemporalWorkflowRun `json:"items"` +} + +func init() { + registerType(&TemporalWorkflowRun{}, &TemporalWorkflowRunList{}) +} +``` + +- [ ] **Step 2: Add the policy field to both targets.** In `api/v1alpha1/temporalcluster_types.go`, inside `TemporalClusterSpec` (after the `PreventDeletion` field), add: + +```go + // WorkflowRunPolicy gates operator-initiated TemporalWorkflowRun executions + // against this cluster. Absent means disabled (closed by default). + // +optional + WorkflowRunPolicy *WorkflowRunPolicy `json:"workflowRunPolicy,omitempty"` +``` + +In `api/v1alpha1/temporaldevserver_types.go`, inside `TemporalDevServerSpec` (after `Affinity`), add: + +```go + // WorkflowRunPolicy gates operator-initiated TemporalWorkflowRun executions + // against this dev server. Absent means enabled with no allowlist. + // +optional + WorkflowRunPolicy *WorkflowRunPolicy `json:"workflowRunPolicy,omitempty"` +``` + +- [ ] **Step 3: Add reason constants.** In `api/v1alpha1/conditions.go`, append inside the reasons `const` block: + +```go + // ReasonWorkflowRunNotPermitted indicates the target's WorkflowRunPolicy denied the run. + ReasonWorkflowRunNotPermitted = "WorkflowRunNotPermitted" + // ReasonWorkflowRunning indicates the workflow is currently running. + ReasonWorkflowRunning = "WorkflowRunning" + // ReasonWorkflowFinished indicates the workflow reached a terminal state. + ReasonWorkflowFinished = "WorkflowFinished" + // ReasonClusterNotFound indicates the referenced Temporal target was not found. + ReasonClusterNotFound = "ClusterNotFound" + // ReasonClusterNotReady indicates the referenced Temporal target is not ready. + ReasonClusterNotReady = "ClusterNotReady" +``` + +- [ ] **Step 4: Regenerate deepcopy + manifests.** + +Run: `make generate manifests` +Expected: succeeds; `git status` shows new `config/crd/bases/temporal.bmor10.com_temporalworkflowruns.yaml`, updates to the cluster/devserver CRDs, and `zz_generated.deepcopy.go` changes. + +- [ ] **Step 5: Build to confirm it compiles.** + +Run: `go build ./api/...` +Expected: exit 0. + +- [ ] **Step 6: Commit.** + +```bash +git add api/ config/crd config/rbac +git commit -s -m "feat(api): add TemporalWorkflowRun CRD and WorkflowRunPolicy types" +``` + +--- + +### Task 2: Surface the effective `WorkflowRunPolicy` from `resolveTarget` + +**Files:** +- Modify: `internal/controller/target.go` +- Test: `internal/controller/target_test.go` + +**Interfaces:** +- Consumes: `temporalv1alpha1.WorkflowRunPolicy` (Task 1), existing `resolveTarget`/`ResolvedTarget`. +- Produces: `ResolvedTarget.WorkflowRunPolicy temporalv1alpha1.WorkflowRunPolicy` (effective, defaults applied); helper `effectiveWorkflowRunPolicy(kind string, p *temporalv1alpha1.WorkflowRunPolicy) temporalv1alpha1.WorkflowRunPolicy`. + +- [ ] **Step 1: Write the failing test.** Append to `internal/controller/target_test.go` (create the file with the standard header + `package controller` and ginkgo imports if it does not yet exist; otherwise add a `Describe`): + +```go +var _ = Describe("effectiveWorkflowRunPolicy", func() { + It("defaults TemporalCluster to disabled when nil", func() { + p := effectiveWorkflowRunPolicy(temporalv1alpha1.ClusterKindTemporalCluster, nil) + Expect(p.Enabled).To(BeFalse()) + }) + It("defaults TemporalDevServer to enabled when nil", func() { + p := effectiveWorkflowRunPolicy(temporalv1alpha1.ClusterKindTemporalDevServer, nil) + Expect(p.Enabled).To(BeTrue()) + Expect(p.AllowedNamespaces).To(BeEmpty()) + }) + It("passes an explicit policy through unchanged", func() { + in := &temporalv1alpha1.WorkflowRunPolicy{Enabled: true, AllowedTaskQueues: []string{"q"}} + p := effectiveWorkflowRunPolicy(temporalv1alpha1.ClusterKindTemporalCluster, in) + Expect(p.Enabled).To(BeTrue()) + Expect(p.AllowedTaskQueues).To(Equal([]string{"q"})) + }) +}) +``` + +- [ ] **Step 2: Run it to verify it fails.** + +Run: `go test ./internal/controller/ -run TestControllers -v` (the Ginkgo suite entrypoint) +Expected: compile error `undefined: effectiveWorkflowRunPolicy`. + +- [ ] **Step 3: Implement.** In `internal/controller/target.go`, add the field to `ResolvedTarget`: + +```go + // WorkflowRunPolicy is the effective (defaults-applied) policy for + // operator-initiated workflow runs against this target. + WorkflowRunPolicy temporalv1alpha1.WorkflowRunPolicy +``` + +Add the helper and set the field in both branches of `resolveTarget`: + +```go +// effectiveWorkflowRunPolicy applies per-kind defaults to a possibly-nil policy. +// A nil policy means disabled for TemporalCluster (closed by default) and +// enabled with no allowlist for TemporalDevServer (throwaway dev environments). +func effectiveWorkflowRunPolicy(kind string, p *temporalv1alpha1.WorkflowRunPolicy) temporalv1alpha1.WorkflowRunPolicy { + if p != nil { + return *p + } + switch kind { + case temporalv1alpha1.ClusterKindTemporalDevServer: + return temporalv1alpha1.WorkflowRunPolicy{Enabled: true} + default: + return temporalv1alpha1.WorkflowRunPolicy{Enabled: false} + } +} +``` + +In the `ClusterKindTemporalCluster` branch set `WorkflowRunPolicy: effectiveWorkflowRunPolicy(temporalv1alpha1.ClusterKindTemporalCluster, cluster.Spec.WorkflowRunPolicy)` on the returned `ResolvedTarget`; in the `ClusterKindTemporalDevServer` branch set `WorkflowRunPolicy: effectiveWorkflowRunPolicy(temporalv1alpha1.ClusterKindTemporalDevServer, dev.Spec.WorkflowRunPolicy)`. + +- [ ] **Step 4: Run tests to verify they pass.** + +Run: `go test ./internal/controller/ -run TestControllers` +Expected: PASS. + +- [ ] **Step 5: Commit.** + +```bash +git add internal/controller/target.go internal/controller/target_test.go +git commit -s -m "feat(controller): surface effective WorkflowRunPolicy from resolveTarget" +``` + +--- + +### Task 3: `WorkflowRunClient` (raw-gRPC Temporal client) + +**Files:** +- Create: `internal/temporal/workflowrun.go` +- Test: `internal/temporal/workflowrun_test.go` + +**Interfaces:** +- Consumes (same `temporal` package, from `schedule.go`): `StartWorkflowParams`, `RetryParams`, `encodeJSONPayloads`, `encodeJSONFields`, `optDuration`, `reusePolicies`. +- Produces: `WorkflowRunClient` interface; `WorkflowRunClientFactory`; `NewWorkflowRunClient`; structs `WorkflowExecutionInfo`, `WorkflowFailure`; pure helpers `PhaseFromStatus(enumspb.WorkflowExecutionStatus) string`, `IsTerminalStatus(enumspb.WorkflowExecutionStatus) bool`, `workflowFailureFromEvent(*historypb.HistoryEvent) *WorkflowFailure`. Phase strings: `Pending|Running|Completed|Failed|Terminated|Canceled|TimedOut|ContinuedAsNew`. + +- [ ] **Step 1: Write the failing test.** Create `internal/temporal/workflowrun_test.go` (license header + `package temporal`): + +```go +package temporal + +import ( + "testing" + + enumspb "go.temporal.io/api/enums/v1" + failurepb "go.temporal.io/api/failure/v1" + historypb "go.temporal.io/api/history/v1" +) + +func TestPhaseFromStatus(t *testing.T) { + cases := map[enumspb.WorkflowExecutionStatus]struct { + phase string + terminal bool + }{ + enumspb.WORKFLOW_EXECUTION_STATUS_UNSPECIFIED: {"Pending", false}, + enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING: {"Running", false}, + enumspb.WORKFLOW_EXECUTION_STATUS_COMPLETED: {"Completed", true}, + enumspb.WORKFLOW_EXECUTION_STATUS_FAILED: {"Failed", true}, + enumspb.WORKFLOW_EXECUTION_STATUS_CANCELED: {"Canceled", true}, + enumspb.WORKFLOW_EXECUTION_STATUS_TERMINATED: {"Terminated", true}, + enumspb.WORKFLOW_EXECUTION_STATUS_CONTINUED_AS_NEW: {"ContinuedAsNew", true}, + enumspb.WORKFLOW_EXECUTION_STATUS_TIMED_OUT: {"TimedOut", true}, + } + for st, want := range cases { + if got := PhaseFromStatus(st); got != want.phase { + t.Errorf("PhaseFromStatus(%v) = %q, want %q", st, got, want.phase) + } + if got := IsTerminalStatus(st); got != want.terminal { + t.Errorf("IsTerminalStatus(%v) = %v, want %v", st, got, want.terminal) + } + } +} + +func TestWorkflowFailureFromFailedEvent(t *testing.T) { + ev := &historypb.HistoryEvent{ + Attributes: &historypb.HistoryEvent_WorkflowExecutionFailedEventAttributes{ + WorkflowExecutionFailedEventAttributes: &historypb.WorkflowExecutionFailedEventAttributes{ + Failure: &failurepb.Failure{ + Message: "boom", + FailureInfo: &failurepb.Failure_ApplicationFailureInfo{ + ApplicationFailureInfo: &failurepb.ApplicationFailureInfo{Type: "MyError"}, + }, + }, + }, + }, + } + f := workflowFailureFromEvent(ev) + if f == nil || f.Message != "boom" || f.Type != "MyError" { + t.Fatalf("unexpected failure: %+v", f) + } +} + +func TestWorkflowFailureFromTerminatedEvent(t *testing.T) { + ev := &historypb.HistoryEvent{ + Attributes: &historypb.HistoryEvent_WorkflowExecutionTerminatedEventAttributes{ + WorkflowExecutionTerminatedEventAttributes: &historypb.WorkflowExecutionTerminatedEventAttributes{Reason: "stopped"}, + }, + } + f := workflowFailureFromEvent(ev) + if f == nil || f.Message != "stopped" { + t.Fatalf("unexpected failure: %+v", f) + } +} +``` + +- [ ] **Step 2: Run it to verify it fails.** + +Run: `go test ./internal/temporal/ -run 'TestPhaseFromStatus|TestWorkflowFailure'` +Expected: compile error (undefined `PhaseFromStatus`, etc.). + +- [ ] **Step 3: Implement the client.** Create `internal/temporal/workflowrun.go` (license header + `package temporal`): + +```go +package temporal + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + + commonpb "go.temporal.io/api/common/v1" + enumspb "go.temporal.io/api/enums/v1" + historypb "go.temporal.io/api/history/v1" + taskqueuepb "go.temporal.io/api/taskqueue/v1" + workflowservice "go.temporal.io/api/workflowservice/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" +) + +const workflowRunIdentity = "temporal-operator" + +// ErrWorkflowNotFound is returned by Describe when the execution does not exist. +var ErrWorkflowNotFound = errors.New("workflow execution not found") + +// WorkflowFailure is the failure detail for a non-success terminal workflow. +type WorkflowFailure struct { + Message string + Type string +} + +// WorkflowExecutionInfo is the observed state of a workflow execution. +type WorkflowExecutionInfo struct { + Status enumspb.WorkflowExecutionStatus + RunID string + WorkflowType string + TaskQueue string + StartTime *time.Time + CloseTime *time.Time + HistoryLength int64 + Failure *WorkflowFailure +} + +// WorkflowRunClient starts and observes one-off workflow executions. +type WorkflowRunClient interface { + // Start starts the workflow and returns its runID. requestID makes Start + // idempotent: a retried call with the same requestID is de-duplicated by + // Temporal. On AlreadyExists it resolves and returns the open run's runID. + Start(ctx context.Context, namespace, requestID string, params StartWorkflowParams) (string, error) + // Describe returns the execution state. For non-success terminal states it + // reads the close event to populate Failure. runID may be empty to address + // the latest run. + Describe(ctx context.Context, namespace, workflowID, runID string) (*WorkflowExecutionInfo, error) + Cancel(ctx context.Context, namespace, workflowID, runID, reason string) error + Terminate(ctx context.Context, namespace, workflowID, runID, reason string) error + Close() error +} + +// WorkflowRunClientFactory builds a WorkflowRunClient. nil tlsConfig = insecure. +type WorkflowRunClientFactory func(ctx context.Context, address string, tlsConfig *tls.Config) (WorkflowRunClient, error) + +type grpcWorkflowRunClient struct { + conn *grpc.ClientConn + workflow workflowservice.WorkflowServiceClient +} + +// NewWorkflowRunClient dials the frontend and returns a WorkflowRunClient. +func NewWorkflowRunClient(_ context.Context, address string, tlsConfig *tls.Config) (WorkflowRunClient, error) { + creds := insecure.NewCredentials() + if tlsConfig != nil { + creds = credentials.NewTLS(tlsConfig) + } + conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(creds)) + if err != nil { + return nil, err + } + return &grpcWorkflowRunClient{conn: conn, workflow: workflowservice.NewWorkflowServiceClient(conn)}, nil +} + +func (c *grpcWorkflowRunClient) Start(ctx context.Context, namespace, requestID string, p StartWorkflowParams) (string, error) { + reuse, ok := reusePolicies[p.IDReusePolicy] + if !ok { + return "", fmt.Errorf("unknown workflow id reuse policy %q", p.IDReusePolicy) + } + req := &workflowservice.StartWorkflowExecutionRequest{ + Namespace: namespace, + WorkflowId: p.WorkflowID, + WorkflowType: &commonpb.WorkflowType{Name: p.WorkflowType}, + TaskQueue: &taskqueuepb.TaskQueue{Name: p.TaskQueue, Kind: enumspb.TASK_QUEUE_KIND_NORMAL}, + Input: encodeJSONPayloads(p.Args), + WorkflowExecutionTimeout: optDuration(p.ExecutionTimeout), + WorkflowRunTimeout: optDuration(p.RunTimeout), + WorkflowTaskTimeout: optDuration(p.TaskTimeout), + Identity: workflowRunIdentity, + RequestId: requestID, + WorkflowIdReusePolicy: reuse, + } + if m := encodeJSONFields(p.Memo); m != nil { + req.Memo = &commonpb.Memo{Fields: m} + } + if sa := encodeJSONFields(p.SearchAttributes); sa != nil { + req.SearchAttributes = &commonpb.SearchAttributes{IndexedFields: sa} + } + if r := p.Retry; r != nil { + req.RetryPolicy = &commonpb.RetryPolicy{ + InitialInterval: optDuration(r.InitialInterval), + BackoffCoefficient: r.BackoffCoefficient, + MaximumInterval: optDuration(r.MaximumInterval), + MaximumAttempts: r.MaximumAttempts, + NonRetryableErrorTypes: r.NonRetryableErrorTypes, + } + } + resp, err := c.workflow.StartWorkflowExecution(ctx, req) + if err != nil { + if status.Code(err) == codes.AlreadyExists { + info, derr := c.Describe(ctx, namespace, p.WorkflowID, "") + if derr != nil { + return "", derr + } + return info.RunID, nil + } + return "", err + } + return resp.GetRunId(), nil +} + +func (c *grpcWorkflowRunClient) Describe(ctx context.Context, namespace, workflowID, runID string) (*WorkflowExecutionInfo, error) { + resp, err := c.workflow.DescribeWorkflowExecution(ctx, &workflowservice.DescribeWorkflowExecutionRequest{ + Namespace: namespace, + Execution: &commonpb.WorkflowExecution{WorkflowId: workflowID, RunId: runID}, + }) + if err != nil { + if status.Code(err) == codes.NotFound { + return nil, ErrWorkflowNotFound + } + return nil, err + } + wi := resp.GetWorkflowExecutionInfo() + info := &WorkflowExecutionInfo{ + Status: wi.GetStatus(), + RunID: wi.GetExecution().GetRunId(), + WorkflowType: wi.GetType().GetName(), + TaskQueue: wi.GetTaskQueue(), + HistoryLength: wi.GetHistoryLength(), + } + if wi.GetStartTime() != nil { + t := wi.GetStartTime().AsTime() + info.StartTime = &t + } + if wi.GetCloseTime() != nil { + t := wi.GetCloseTime().AsTime() + info.CloseTime = &t + } + if IsTerminalStatus(info.Status) && info.Status != enumspb.WORKFLOW_EXECUTION_STATUS_COMPLETED && + info.Status != enumspb.WORKFLOW_EXECUTION_STATUS_CANCELED { + info.Failure = c.closeFailure(ctx, namespace, workflowID, info.RunID) + } + return info, nil +} + +// closeFailure reads the single close event and extracts a failure, best-effort. +func (c *grpcWorkflowRunClient) closeFailure(ctx context.Context, namespace, workflowID, runID string) *WorkflowFailure { + resp, err := c.workflow.GetWorkflowExecutionHistory(ctx, &workflowservice.GetWorkflowExecutionHistoryRequest{ + Namespace: namespace, + Execution: &commonpb.WorkflowExecution{WorkflowId: workflowID, RunId: runID}, + HistoryEventFilterType: enumspb.HISTORY_EVENT_FILTER_TYPE_CLOSE_EVENT, + }) + if err != nil { + return nil + } + for _, ev := range resp.GetHistory().GetEvents() { + if f := workflowFailureFromEvent(ev); f != nil { + return f + } + } + return nil +} + +func (c *grpcWorkflowRunClient) Cancel(ctx context.Context, namespace, workflowID, runID, reason string) error { + _, err := c.workflow.RequestCancelWorkflowExecution(ctx, &workflowservice.RequestCancelWorkflowExecutionRequest{ + Namespace: namespace, + WorkflowExecution: &commonpb.WorkflowExecution{WorkflowId: workflowID, RunId: runID}, + Identity: workflowRunIdentity, + Reason: reason, + }) + if err != nil && status.Code(err) == codes.NotFound { + return ErrWorkflowNotFound + } + return err +} + +func (c *grpcWorkflowRunClient) Terminate(ctx context.Context, namespace, workflowID, runID, reason string) error { + _, err := c.workflow.TerminateWorkflowExecution(ctx, &workflowservice.TerminateWorkflowExecutionRequest{ + Namespace: namespace, + WorkflowExecution: &commonpb.WorkflowExecution{WorkflowId: workflowID, RunId: runID}, + Identity: workflowRunIdentity, + Reason: reason, + }) + if err != nil && status.Code(err) == codes.NotFound { + return ErrWorkflowNotFound + } + return err +} + +func (c *grpcWorkflowRunClient) Close() error { return c.conn.Close() } + +// PhaseFromStatus maps a Temporal execution status to a friendly phase string. +func PhaseFromStatus(s enumspb.WorkflowExecutionStatus) string { + switch s { + case enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING: + return "Running" + case enumspb.WORKFLOW_EXECUTION_STATUS_COMPLETED: + return "Completed" + case enumspb.WORKFLOW_EXECUTION_STATUS_FAILED: + return "Failed" + case enumspb.WORKFLOW_EXECUTION_STATUS_CANCELED: + return "Canceled" + case enumspb.WORKFLOW_EXECUTION_STATUS_TERMINATED: + return "Terminated" + case enumspb.WORKFLOW_EXECUTION_STATUS_CONTINUED_AS_NEW: + return "ContinuedAsNew" + case enumspb.WORKFLOW_EXECUTION_STATUS_TIMED_OUT: + return "TimedOut" + default: + return "Pending" + } +} + +// IsTerminalStatus reports whether the status is a closed (terminal) state. +func IsTerminalStatus(s enumspb.WorkflowExecutionStatus) bool { + switch s { + case enumspb.WORKFLOW_EXECUTION_STATUS_UNSPECIFIED, enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING: + return false + default: + return true + } +} + +// workflowFailureFromEvent extracts failure detail from a close history event. +func workflowFailureFromEvent(ev *historypb.HistoryEvent) *WorkflowFailure { + if a := ev.GetWorkflowExecutionFailedEventAttributes(); a != nil { + f := a.GetFailure() + return &WorkflowFailure{Message: f.GetMessage(), Type: f.GetApplicationFailureInfo().GetType()} + } + if a := ev.GetWorkflowExecutionTerminatedEventAttributes(); a != nil { + return &WorkflowFailure{Message: a.GetReason(), Type: "Terminated"} + } + if a := ev.GetWorkflowExecutionTimedOutEventAttributes(); a != nil { + return &WorkflowFailure{Message: "workflow execution timed out", Type: "TimedOut"} + } + return nil +} +``` + +Add `"time"` to the import block (used by `WorkflowExecutionInfo`). + +- [ ] **Step 4: Run tests to verify they pass.** + +Run: `go test ./internal/temporal/ -run 'TestPhaseFromStatus|TestWorkflowFailure'` +Expected: PASS. + +- [ ] **Step 5: Build the package.** + +Run: `go build ./internal/temporal/` +Expected: exit 0. (If a proto getter name differs — e.g. `GetType()` on the failed event — fix per the compiler and the `go.temporal.io/api` version in `go.mod`.) + +- [ ] **Step 6: Commit.** + +```bash +git add internal/temporal/workflowrun.go internal/temporal/workflowrun_test.go +git commit -s -m "feat(temporal): add WorkflowRunClient for one-off workflow executions" +``` + +--- + +### Task 4: `TemporalWorkflowRun` controller + +**Files:** +- Create: `internal/controller/temporalworkflowrun_controller.go` +- Test: `internal/controller/temporalworkflowrun_controller_test.go` + +**Interfaces:** +- Consumes: `resolveTarget`, `ResolvedTarget.WorkflowRunPolicy` (Task 2), `temporal.WorkflowRunClient`/`WorkflowRunClientFactory`/`NewWorkflowRunClient`/`StartWorkflowParams`/`PhaseFromStatus`/`IsTerminalStatus`/`ErrTargetNotFound`, and the package-level helpers `durPtr`, `rawList`, `rawMap` (from `temporalschedule_controller.go`). +- Produces: `TemporalWorkflowRunReconciler{client.Client, Scheme, ClientFactory temporal.WorkflowRunClientFactory}` with `Reconcile` and `SetupWithManager`. + +- [ ] **Step 1: Implement the controller.** Create `internal/controller/temporalworkflowrun_controller.go` (license header + `package controller`): + +```go +package controller + +import ( + "context" + "fmt" + "strconv" + "time" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + temporalv1alpha1 "github.com/bmorton/temporal-operator/api/v1alpha1" + "github.com/bmorton/temporal-operator/internal/temporal" +) + +const workflowRunFinalizer = "temporal.bmor10.com/workflowrun" + +// workflowRunPollInterval is how often a running workflow's status is refreshed. +const workflowRunPollInterval = 10 * time.Second + +// TemporalWorkflowRunReconciler reconciles TemporalWorkflowRun objects. +type TemporalWorkflowRunReconciler struct { + client.Client + Scheme *runtime.Scheme + + // ClientFactory builds the Temporal workflow-run client; injectable for tests. + ClientFactory temporal.WorkflowRunClientFactory +} + +// +kubebuilder:rbac:groups=temporal.bmor10.com,resources=temporalworkflowruns,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=temporal.bmor10.com,resources=temporalworkflowruns/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=temporal.bmor10.com,resources=temporalworkflowruns/finalizers,verbs=update + +// Reconcile starts a one-off workflow, tracks its status, and cleans up via TTL. +func (r *TemporalWorkflowRunReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var run temporalv1alpha1.TemporalWorkflowRun + if err := r.Get(ctx, req.NamespacedName, &run); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + target, err := resolveTarget(ctx, r.Client, run.Namespace, run.Spec.ClusterRef) + if err != nil { + if !run.DeletionTimestamp.IsZero() { + return ctrl.Result{}, r.removeFinalizer(ctx, &run) + } + if err == ErrTargetNotFound { + r.setReady(&run, metav1.ConditionFalse, temporalv1alpha1.ReasonClusterNotFound, "referenced Temporal target not found") + return ctrl.Result{RequeueAfter: 15 * time.Second}, r.statusUpdate(ctx, &run) + } + return ctrl.Result{}, err + } + + wc, err := r.clientFactory()(ctx, target.Address, target.TLSConfig) + if err != nil { + if !run.DeletionTimestamp.IsZero() { + return ctrl.Result{}, r.removeFinalizer(ctx, &run) + } + return ctrl.Result{}, fmt.Errorf("building temporal client: %w", err) + } + defer func() { _ = wc.Close() }() + + if !run.DeletionTimestamp.IsZero() { + return ctrl.Result{}, r.reconcileDelete(ctx, &run, wc) + } + + if !controllerutil.ContainsFinalizer(&run, workflowRunFinalizer) { + controllerutil.AddFinalizer(&run, workflowRunFinalizer) + if err := r.Update(ctx, &run); err != nil { + return ctrl.Result{}, err + } + } + + if !target.Ready { + r.setReady(&run, metav1.ConditionFalse, temporalv1alpha1.ReasonClusterNotReady, "waiting for the Temporal target to become ready") + return ctrl.Result{RequeueAfter: 15 * time.Second}, r.statusUpdate(ctx, &run) + } + + return r.reconcileRun(ctx, &run, wc, target.WorkflowRunPolicy) +} + +func (r *TemporalWorkflowRunReconciler) reconcileRun(ctx context.Context, run *temporalv1alpha1.TemporalWorkflowRun, wc temporal.WorkflowRunClient, policy temporalv1alpha1.WorkflowRunPolicy) (ctrl.Result, error) { + log := logf.FromContext(ctx) + wfID := resolveWorkflowID(run) + taskQueue := run.Spec.Workflow.TaskQueue + + // Start the workflow once. + if run.Status.RunID == "" { + if err := checkWorkflowRunPolicy(policy, run.Spec.Namespace, taskQueue); err != nil { + r.setReady(run, metav1.ConditionFalse, temporalv1alpha1.ReasonWorkflowRunNotPermitted, err.Error()) + return ctrl.Result{}, r.statusUpdate(ctx, run) + } + params, err := workflowRunParams(run) + if err != nil { + r.setReady(run, metav1.ConditionFalse, "InvalidSpec", err.Error()) + return ctrl.Result{}, r.statusUpdate(ctx, run) + } + runID, err := wc.Start(ctx, run.Spec.Namespace, string(run.UID), params) + if err != nil { + return ctrl.Result{}, fmt.Errorf("starting workflow: %w", err) + } + log.Info("started workflow", "workflowID", wfID, "runID", runID) + run.Status.WorkflowID = wfID + run.Status.RunID = runID + run.Status.WorkflowType = run.Spec.Workflow.WorkflowType + run.Status.TaskQueue = taskQueue + run.Status.Phase = "Running" + r.setReady(run, metav1.ConditionTrue, temporalv1alpha1.ReasonWorkflowRunning, "workflow started") + } + + // Refresh observed state. + info, err := wc.Describe(ctx, run.Spec.Namespace, wfID, run.Status.RunID) + if err != nil { + return ctrl.Result{}, fmt.Errorf("describing workflow: %w", err) + } + run.Status.Phase = temporal.PhaseFromStatus(info.Status) + run.Status.HistoryLength = info.HistoryLength + if info.StartTime != nil { + t := metav1.NewTime(*info.StartTime) + run.Status.StartTime = &t + } + if info.CloseTime != nil { + t := metav1.NewTime(*info.CloseTime) + run.Status.CloseTime = &t + } + + if !temporal.IsTerminalStatus(info.Status) { + r.setReady(run, metav1.ConditionTrue, temporalv1alpha1.ReasonWorkflowRunning, "workflow is running") + return ctrl.Result{RequeueAfter: workflowRunPollInterval}, r.statusUpdate(ctx, run) + } + + // Terminal: record completion, failure, and Finished condition once. + if run.Status.CompletionTime == nil { + now := metav1.Now() + run.Status.CompletionTime = &now + } + if info.Failure != nil { + run.Status.Failure = &temporalv1alpha1.WorkflowRunFailure{Message: info.Failure.Message, Type: info.Failure.Type} + } + meta.SetStatusCondition(&run.Status.Conditions, metav1.Condition{ + Type: "Finished", Status: metav1.ConditionTrue, + Reason: temporalv1alpha1.ReasonWorkflowFinished, Message: "workflow reached a terminal state: " + run.Status.Phase, + ObservedGeneration: run.Generation, + }) + r.setReady(run, metav1.ConditionTrue, temporalv1alpha1.ReasonWorkflowFinished, "workflow finished: "+run.Status.Phase) + if err := r.statusUpdate(ctx, run); err != nil { + return ctrl.Result{}, err + } + + // TTL cleanup. + if run.Spec.TTLSecondsAfterFinished != nil { + deadline := run.Status.CompletionTime.Add(time.Duration(*run.Spec.TTLSecondsAfterFinished) * time.Second) + if remaining := time.Until(deadline); remaining > 0 { + return ctrl.Result{RequeueAfter: remaining}, nil + } + log.Info("deleting workflow run after TTL", "name", run.Name) + return ctrl.Result{}, client.IgnoreNotFound(r.Delete(ctx, run)) + } + return ctrl.Result{}, nil +} + +func (r *TemporalWorkflowRunReconciler) reconcileDelete(ctx context.Context, run *temporalv1alpha1.TemporalWorkflowRun, wc temporal.WorkflowRunClient) error { + log := logf.FromContext(ctx) + if !controllerutil.ContainsFinalizer(run, workflowRunFinalizer) { + return nil + } + // Apply the cancellation policy only if the workflow is still running. + if run.Status.RunID != "" && run.Status.CompletionTime == nil { + wfID := resolveWorkflowID(run) + switch run.Spec.CancellationPolicy { + case "Cancel": + if err := wc.Cancel(ctx, run.Spec.Namespace, wfID, run.Status.RunID, "TemporalWorkflowRun deleted"); err != nil && err != temporal.ErrWorkflowNotFound { + return fmt.Errorf("cancelling workflow: %w", err) + } + log.Info("requested workflow cancellation on delete", "workflowID", wfID) + case "Terminate": + if err := wc.Terminate(ctx, run.Spec.Namespace, wfID, run.Status.RunID, "TemporalWorkflowRun deleted"); err != nil && err != temporal.ErrWorkflowNotFound { + return fmt.Errorf("terminating workflow: %w", err) + } + log.Info("terminated workflow on delete", "workflowID", wfID) + } + } + return r.removeFinalizer(ctx, run) +} + +func (r *TemporalWorkflowRunReconciler) removeFinalizer(ctx context.Context, run *temporalv1alpha1.TemporalWorkflowRun) error { + if controllerutil.ContainsFinalizer(run, workflowRunFinalizer) { + controllerutil.RemoveFinalizer(run, workflowRunFinalizer) + if err := r.Update(ctx, run); err != nil { + return err + } + } + return nil +} + +func (r *TemporalWorkflowRunReconciler) clientFactory() temporal.WorkflowRunClientFactory { + if r.ClientFactory != nil { + return r.ClientFactory + } + return temporal.NewWorkflowRunClient +} + +func (r *TemporalWorkflowRunReconciler) setReady(run *temporalv1alpha1.TemporalWorkflowRun, status metav1.ConditionStatus, reason, message string) { + run.Status.ObservedGeneration = run.Generation + meta.SetStatusCondition(&run.Status.Conditions, metav1.Condition{ + Type: temporalv1alpha1.ConditionReady, Status: status, + Reason: reason, Message: message, ObservedGeneration: run.Generation, + }) +} + +func (r *TemporalWorkflowRunReconciler) statusUpdate(ctx context.Context, run *temporalv1alpha1.TemporalWorkflowRun) error { + return client.IgnoreNotFound(r.Status().Update(ctx, run)) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *TemporalWorkflowRunReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&temporalv1alpha1.TemporalWorkflowRun{}). + Named("temporalworkflowrun"). + Complete(r) +} + +func resolveWorkflowID(run *temporalv1alpha1.TemporalWorkflowRun) string { + if run.Spec.Workflow.WorkflowID != "" { + return run.Spec.Workflow.WorkflowID + } + return run.Name +} + +// checkWorkflowRunPolicy enforces the target's effective WorkflowRunPolicy. +func checkWorkflowRunPolicy(p temporalv1alpha1.WorkflowRunPolicy, namespace, taskQueue string) error { + if !p.Enabled { + return fmt.Errorf("workflow runs are not enabled on the referenced Temporal target") + } + if len(p.AllowedNamespaces) > 0 && !contains(p.AllowedNamespaces, namespace) { + return fmt.Errorf("Temporal namespace %q is not in the target's allowedNamespaces", namespace) + } + if len(p.AllowedTaskQueues) > 0 && !contains(p.AllowedTaskQueues, taskQueue) { + return fmt.Errorf("task queue %q is not in the target's allowedTaskQueues", taskQueue) + } + return nil +} + +func contains(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + +// workflowRunParams maps a TemporalWorkflowRun's workflow spec to StartWorkflowParams. +func workflowRunParams(run *temporalv1alpha1.TemporalWorkflowRun) (temporal.StartWorkflowParams, error) { + a := run.Spec.Workflow + params := temporal.StartWorkflowParams{ + WorkflowType: a.WorkflowType, + TaskQueue: a.TaskQueue, + WorkflowID: resolveWorkflowID(run), + Args: rawList(a.Args), + ExecutionTimeout: durPtr(a.WorkflowExecutionTimeout), + RunTimeout: durPtr(a.WorkflowRunTimeout), + TaskTimeout: durPtr(a.WorkflowTaskTimeout), + IDReusePolicy: a.WorkflowIDReusePolicy, + Memo: rawMap(a.Memo), + SearchAttributes: rawMap(a.SearchAttributes), + } + if a.RetryPolicy != nil { + backoff := 2.0 + if a.RetryPolicy.BackoffCoefficient != "" { + f, err := strconv.ParseFloat(a.RetryPolicy.BackoffCoefficient, 64) + if err != nil { + return temporal.StartWorkflowParams{}, fmt.Errorf("invalid retryPolicy.backoffCoefficient: %w", err) + } + backoff = f + } + params.Retry = &temporal.RetryParams{ + InitialInterval: durPtr(a.RetryPolicy.InitialInterval), + BackoffCoefficient: backoff, + MaximumInterval: durPtr(a.RetryPolicy.MaximumInterval), + MaximumAttempts: a.RetryPolicy.MaximumAttempts, + NonRetryableErrorTypes: a.RetryPolicy.NonRetryableErrorTypes, + } + } + return params, nil +} +``` + +Note: `resolveTarget` returns the sentinel `ErrTargetNotFound`; the existing schedule controller compares with `errors.Is`. Using `err == ErrTargetNotFound` is acceptable here since it is returned directly, but prefer `errors.Is(err, ErrTargetNotFound)` and add `"errors"` to imports to match house style. Same for `temporal.ErrWorkflowNotFound`. + +- [ ] **Step 2: Write the controller tests.** Create `internal/controller/temporalworkflowrun_controller_test.go` (license header + `package controller`), with a fake client and tests: + +```go +package controller + +import ( + "context" + "crypto/tls" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + enumspb "go.temporal.io/api/enums/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + temporalv1alpha1 "github.com/bmorton/temporal-operator/api/v1alpha1" + "github.com/bmorton/temporal-operator/internal/temporal" +) + +// fakeWorkflowRunClient is an in-memory WorkflowRunClient. status drives Describe. +type fakeWorkflowRunClient struct { + started []string + canceled, terminated []string + status enumspb.WorkflowExecutionStatus + failure *temporal.WorkflowFailure +} + +func (f *fakeWorkflowRunClient) Start(_ context.Context, _, _ string, p temporal.StartWorkflowParams) (string, error) { + f.started = append(f.started, p.WorkflowID) + if f.status == enumspb.WORKFLOW_EXECUTION_STATUS_UNSPECIFIED { + f.status = enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING + } + return "run-" + p.WorkflowID, nil +} + +func (f *fakeWorkflowRunClient) Describe(_ context.Context, _, _, _ string) (*temporal.WorkflowExecutionInfo, error) { + return &temporal.WorkflowExecutionInfo{Status: f.status, RunID: "run", Failure: f.failure}, nil +} + +func (f *fakeWorkflowRunClient) Cancel(_ context.Context, _, wfID, _, _ string) error { + f.canceled = append(f.canceled, wfID) + return nil +} + +func (f *fakeWorkflowRunClient) Terminate(_ context.Context, _, wfID, _, _ string) error { + f.terminated = append(f.terminated, wfID) + return nil +} + +func (f *fakeWorkflowRunClient) Close() error { return nil } + +var _ = Describe("TemporalWorkflowRun reconciler", func() { + const testNamespace = "default" + ctx := context.Background() + var counter int + var fake *fakeWorkflowRunClient + + var factory temporal.WorkflowRunClientFactory = func(_ context.Context, _ string, _ *tls.Config) (temporal.WorkflowRunClient, error) { + return fake, nil + } + reconciler := func() *TemporalWorkflowRunReconciler { + return &TemporalWorkflowRunReconciler{Client: k8sClient, Scheme: k8sClient.Scheme(), ClientFactory: factory} + } + + newReadyCluster := func(name string, policy *temporalv1alpha1.WorkflowRunPolicy) *temporalv1alpha1.TemporalCluster { + c := &temporalv1alpha1.TemporalCluster{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: testNamespace}, + Spec: validClusterSpec("1.31.1"), + } + c.Spec.WorkflowRunPolicy = policy + Expect(k8sClient.Create(ctx, c)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, c) }) + meta.SetStatusCondition(&c.Status.Conditions, metav1.Condition{ + Type: temporalv1alpha1.ConditionReady, Status: metav1.ConditionTrue, Reason: "Ready", Message: "ready", + }) + Expect(k8sClient.Status().Update(ctx, c)).To(Succeed()) + return c + } + + newRun := func(name, cluster string) *temporalv1alpha1.TemporalWorkflowRun { + return &temporalv1alpha1.TemporalWorkflowRun{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: testNamespace}, + Spec: temporalv1alpha1.TemporalWorkflowRunSpec{ + ClusterRef: temporalv1alpha1.ClusterReference{Name: cluster}, + Namespace: "orders", + Workflow: temporalv1alpha1.StartWorkflowAction{WorkflowType: "Greet", TaskQueue: "tq"}, + }, + } + } + + BeforeEach(func() { + counter++ + fake = &fakeWorkflowRunClient{} + }) + + It("starts the workflow and records Running status", func() { + c := newReadyCluster(fmt.Sprintf("cluster-%d", counter), &temporalv1alpha1.WorkflowRunPolicy{Enabled: true}) + run := newRun(fmt.Sprintf("run-%d", counter), c.Name) + Expect(k8sClient.Create(ctx, run)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, run) }) + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: run.Name, Namespace: testNamespace}} + _, err := reconciler().Reconcile(ctx, req) + Expect(err).NotTo(HaveOccurred()) + _, err = reconciler().Reconcile(ctx, req) + Expect(err).NotTo(HaveOccurred()) + + Expect(fake.started).To(ContainElement(run.Name)) + var got temporalv1alpha1.TemporalWorkflowRun + Expect(k8sClient.Get(ctx, req.NamespacedName, &got)).To(Succeed()) + Expect(got.Status.Phase).To(Equal("Running")) + Expect(got.Status.RunID).NotTo(BeEmpty()) + Expect(meta.IsStatusConditionTrue(got.Status.Conditions, temporalv1alpha1.ConditionReady)).To(BeTrue()) + }) + + It("denies the run when policy is disabled and starts nothing", func() { + c := newReadyCluster(fmt.Sprintf("cluster-%d", counter), &temporalv1alpha1.WorkflowRunPolicy{Enabled: false}) + run := newRun(fmt.Sprintf("run-%d", counter), c.Name) + Expect(k8sClient.Create(ctx, run)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, run) }) + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: run.Name, Namespace: testNamespace}} + _, err := reconciler().Reconcile(ctx, req) + Expect(err).NotTo(HaveOccurred()) + + Expect(fake.started).To(BeEmpty()) + var got temporalv1alpha1.TemporalWorkflowRun + Expect(k8sClient.Get(ctx, req.NamespacedName, &got)).To(Succeed()) + Expect(got.Status.RunID).To(BeEmpty()) + cond := meta.FindStatusCondition(got.Status.Conditions, temporalv1alpha1.ConditionReady) + Expect(cond).NotTo(BeNil()) + Expect(cond.Reason).To(Equal(temporalv1alpha1.ReasonWorkflowRunNotPermitted)) + }) + + It("captures failure and sets Finished when the workflow fails", func() { + c := newReadyCluster(fmt.Sprintf("cluster-%d", counter), &temporalv1alpha1.WorkflowRunPolicy{Enabled: true}) + fake.status = enumspb.WORKFLOW_EXECUTION_STATUS_FAILED + fake.failure = &temporal.WorkflowFailure{Message: "boom", Type: "MyError"} + run := newRun(fmt.Sprintf("run-%d", counter), c.Name) + Expect(k8sClient.Create(ctx, run)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, run) }) + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: run.Name, Namespace: testNamespace}} + _, err := reconciler().Reconcile(ctx, req) + Expect(err).NotTo(HaveOccurred()) + + var got temporalv1alpha1.TemporalWorkflowRun + Expect(k8sClient.Get(ctx, req.NamespacedName, &got)).To(Succeed()) + Expect(got.Status.Phase).To(Equal("Failed")) + Expect(got.Status.Failure).NotTo(BeNil()) + Expect(got.Status.Failure.Message).To(Equal("boom")) + Expect(meta.IsStatusConditionTrue(got.Status.Conditions, "Finished")).To(BeTrue()) + }) + + It("terminates a running workflow on delete with cancellationPolicy=Terminate", func() { + c := newReadyCluster(fmt.Sprintf("cluster-%d", counter), &temporalv1alpha1.WorkflowRunPolicy{Enabled: true}) + run := newRun(fmt.Sprintf("run-%d", counter), c.Name) + run.Spec.CancellationPolicy = "Terminate" + Expect(k8sClient.Create(ctx, run)).To(Succeed()) + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: run.Name, Namespace: testNamespace}} + _, err := reconciler().Reconcile(ctx, req) // adds finalizer + starts + Expect(err).NotTo(HaveOccurred()) + + Expect(k8sClient.Delete(ctx, run)).To(Succeed()) + _, err = reconciler().Reconcile(ctx, req) // handles deletion + Expect(err).NotTo(HaveOccurred()) + Expect(fake.terminated).NotTo(BeEmpty()) + }) +}) +``` + +- [ ] **Step 3: Run the suite.** + +Run: `make test` (or, faster: `KUBEBUILDER_ASSETS="$(bin/setup-envtest use 1.33.0 --bin-dir bin -p path)" go test ./internal/controller/ -run TestControllers`) +Expected: PASS. Fix compile/logic issues the loop surfaces (e.g. the terminal-status branch sets `CompletionTime`, so the Terminate test deletes while `CompletionTime==nil` only if the workflow was still Running — keep `fake.status` defaulting to RUNNING in that test). + +- [ ] **Step 4: Commit.** + +```bash +git add internal/controller/temporalworkflowrun_controller.go internal/controller/temporalworkflowrun_controller_test.go +git commit -s -m "feat(controller): reconcile TemporalWorkflowRun (start, status, TTL, policy)" +``` + +--- + +### Task 5: Validating webhook + +**Files:** +- Create: `internal/webhook/v1alpha1/temporalworkflowrun_webhook.go` +- Test: `internal/webhook/v1alpha1/temporalworkflowrun_webhook_test.go` + +**Interfaces:** +- Consumes: `temporalv1alpha1.TemporalWorkflowRun`, the existing `validateJSONList`/`validateJSONMap`/`validReusePolicies` helpers in package `v1alpha1` (webhook package), `apiequality.Semantic.DeepEqual`. +- Produces: `SetupTemporalWorkflowRunWebhookWithManager(mgr) error`; `TemporalWorkflowRunCustomValidator`. + +- [ ] **Step 1: Write the webhook.** Create `internal/webhook/v1alpha1/temporalworkflowrun_webhook.go` (license header + `package v1alpha1`): + +```go +package v1alpha1 + +import ( + "context" + "fmt" + + apiequality "k8s.io/apimachinery/pkg/api/equality" + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + temporalv1alpha1 "github.com/bmorton/temporal-operator/api/v1alpha1" +) + +var temporalworkflowrunlog = logf.Log.WithName("temporalworkflowrun-resource") + +// SetupTemporalWorkflowRunWebhookWithManager registers the webhook. +func SetupTemporalWorkflowRunWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr, &temporalv1alpha1.TemporalWorkflowRun{}). + WithValidator(&TemporalWorkflowRunCustomValidator{}). + Complete() +} + +// +kubebuilder:webhook:path=/validate-temporal-bmor10-com-v1alpha1-temporalworkflowrun,mutating=false,failurePolicy=fail,sideEffects=None,groups=temporal.bmor10.com,resources=temporalworkflowruns,verbs=create;update,versions=v1alpha1,name=vtemporalworkflowrun-v1alpha1.kb.io,admissionReviewVersions=v1 + +// TemporalWorkflowRunCustomValidator validates TemporalWorkflowRun resources. +type TemporalWorkflowRunCustomValidator struct{} + +var _ admission.Validator[*temporalv1alpha1.TemporalWorkflowRun] = &TemporalWorkflowRunCustomValidator{} + +func (v *TemporalWorkflowRunCustomValidator) ValidateCreate(_ context.Context, run *temporalv1alpha1.TemporalWorkflowRun) (admission.Warnings, error) { + temporalworkflowrunlog.Info("Validation for TemporalWorkflowRun upon creation", "name", run.GetName()) + return nil, validateWorkflowRun(run) +} + +func (v *TemporalWorkflowRunCustomValidator) ValidateUpdate(_ context.Context, oldRun, newRun *temporalv1alpha1.TemporalWorkflowRun) (admission.Warnings, error) { + temporalworkflowrunlog.Info("Validation for TemporalWorkflowRun upon update", "name", newRun.GetName()) + if newRun.Spec.ClusterRef != oldRun.Spec.ClusterRef { + return nil, fmt.Errorf("spec.clusterRef is immutable") + } + if newRun.Spec.Namespace != oldRun.Spec.Namespace { + return nil, fmt.Errorf("spec.namespace is immutable (was %q)", oldRun.Spec.Namespace) + } + if !apiequality.Semantic.DeepEqual(oldRun.Spec.Workflow, newRun.Spec.Workflow) { + return nil, fmt.Errorf("spec.workflow is immutable; create a new TemporalWorkflowRun to run again") + } + return nil, validateWorkflowRun(newRun) +} + +func (v *TemporalWorkflowRunCustomValidator) ValidateDelete(_ context.Context, _ *temporalv1alpha1.TemporalWorkflowRun) (admission.Warnings, error) { + return nil, nil +} + +func validateWorkflowRun(run *temporalv1alpha1.TemporalWorkflowRun) error { + if run.Spec.ClusterRef.Name == "" { + return fmt.Errorf("spec.clusterRef.name must not be empty") + } + if run.Spec.Namespace == "" { + return fmt.Errorf("spec.namespace must not be empty") + } + w := run.Spec.Workflow + if w.WorkflowType == "" { + return fmt.Errorf("spec.workflow.workflowType must not be empty") + } + if w.TaskQueue == "" { + return fmt.Errorf("spec.workflow.taskQueue must not be empty") + } + if _, ok := validReusePolicies[w.WorkflowIDReusePolicy]; !ok { + return fmt.Errorf("spec.workflow.workflowIDReusePolicy %q is not valid", w.WorkflowIDReusePolicy) + } + if err := validateJSONList("spec.workflow.args", w.Args); err != nil { + return err + } + if err := validateJSONMap("spec.workflow.memo", w.Memo); err != nil { + return err + } + return validateJSONMap("spec.workflow.searchAttributes", w.SearchAttributes) +} +``` + +- [ ] **Step 2: Write the test.** Create `internal/webhook/v1alpha1/temporalworkflowrun_webhook_test.go` (license header + `package v1alpha1`). Mirror `temporalschedule_webhook_test.go` structure: + +```go +package v1alpha1 + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + temporalv1alpha1 "github.com/bmorton/temporal-operator/api/v1alpha1" +) + +func validRun() *temporalv1alpha1.TemporalWorkflowRun { + return &temporalv1alpha1.TemporalWorkflowRun{ + ObjectMeta: metav1.ObjectMeta{Name: "r"}, + Spec: temporalv1alpha1.TemporalWorkflowRunSpec{ + ClusterRef: temporalv1alpha1.ClusterReference{Name: "c"}, + Namespace: "orders", + Workflow: temporalv1alpha1.StartWorkflowAction{WorkflowType: "Greet", TaskQueue: "tq"}, + }, + } +} + +func TestValidateWorkflowRunCreate(t *testing.T) { + v := &TemporalWorkflowRunCustomValidator{} + if _, err := v.ValidateCreate(context.Background(), validRun()); err != nil { + t.Fatalf("expected valid, got %v", err) + } + bad := validRun() + bad.Spec.Workflow.TaskQueue = "" + if _, err := v.ValidateCreate(context.Background(), bad); err == nil { + t.Fatal("expected error for empty taskQueue") + } +} + +func TestValidateWorkflowRunImmutability(t *testing.T) { + v := &TemporalWorkflowRunCustomValidator{} + old := validRun() + newRun := validRun() + newRun.Spec.Workflow.WorkflowType = "Other" + if _, err := v.ValidateUpdate(context.Background(), old, newRun); err == nil { + t.Fatal("expected error mutating spec.workflow") + } + + // Mutable fields are allowed. + ttl := int32(60) + mutable := validRun() + mutable.Spec.TTLSecondsAfterFinished = &ttl + mutable.Spec.CancellationPolicy = "Terminate" + if _, err := v.ValidateUpdate(context.Background(), old, mutable); err != nil { + t.Fatalf("expected mutable update to pass, got %v", err) + } +} + +func TestValidateWorkflowRunInvalidJSON(t *testing.T) { + v := &TemporalWorkflowRunCustomValidator{} + bad := validRun() + bad.Spec.Workflow.Args = []runtime.RawExtension{{Raw: []byte("{not json")}} + if _, err := v.ValidateCreate(context.Background(), bad); err == nil { + t.Fatal("expected error for invalid JSON args") + } +} +``` + +- [ ] **Step 3: Run tests.** + +Run: `go test ./internal/webhook/v1alpha1/ -run 'TestValidateWorkflowRun'` +Expected: PASS. (If `validReusePolicies`/`validateJSONList`/`validateJSONMap` are unexported in another file of the same package, they are accessible — same package `v1alpha1`.) + +- [ ] **Step 4: Commit.** + +```bash +git add internal/webhook/v1alpha1/temporalworkflowrun_webhook.go internal/webhook/v1alpha1/temporalworkflowrun_webhook_test.go +git commit -s -m "feat(webhook): validate TemporalWorkflowRun (required fields, immutability)" +``` + +--- + +### Task 6: Wire into the manager, PROJECT, sample, and regenerate artifacts + +**Files:** +- Modify: `cmd/main.go`, `PROJECT` +- Create: `config/samples/temporal_v1alpha1_temporalworkflowrun.yaml` +- Regenerated: `config/crd/*`, `config/rbac/*`, `dist/chart/*`, `docs/api/v1alpha1.md`, `docs/content/reference/_index.md` + +**Interfaces:** +- Consumes: `controller.TemporalWorkflowRunReconciler` (Task 4), `webhookv1alpha1.SetupTemporalWorkflowRunWebhookWithManager` (Task 5). + +- [ ] **Step 1: Register the controller in `cmd/main.go`.** After the `TemporalClusterConnectionReconciler` setup block, add: + +```go + if err := (&controller.TemporalWorkflowRunReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "TemporalWorkflowRun") + os.Exit(1) + } +``` + +- [ ] **Step 2: Register the webhook in `cmd/main.go`.** After the last `if webhooksEnabled { ... SetupTemporalClusterConnectionWebhookWithManager ... }` block, add: + +```go + if webhooksEnabled { + if err := webhookv1alpha1.SetupTemporalWorkflowRunWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "TemporalWorkflowRun") + os.Exit(1) + } + } +``` + +- [ ] **Step 3: Add the PROJECT entry.** In `PROJECT`, add a resource block alongside the other satellite CRDs (before the trailing `version: "3"`), matching the existing indentation: + +```yaml +- api: + crdVersion: v1 + namespaced: true + domain: bmor10.com + group: temporal + kind: TemporalWorkflowRun + path: github.com/bmorton/temporal-operator/api/v1alpha1 + version: v1alpha1 + webhooks: + validation: true + webhookVersion: v1 +``` + +- [ ] **Step 4: Create the sample CR.** Create `config/samples/temporal_v1alpha1_temporalworkflowrun.yaml`: + +```yaml +apiVersion: temporal.bmor10.com/v1alpha1 +kind: TemporalWorkflowRun +metadata: + name: greet-once +spec: + clusterRef: + name: temporal-sample + namespace: default + ttlSecondsAfterFinished: 300 + cancellationPolicy: Abandon + workflow: + workflowType: GreetingWorkflow + taskQueue: greeting-tq + args: + - '"world"' +--- +# The referenced TemporalCluster must opt in to operator-initiated runs: +# apiVersion: temporal.bmor10.com/v1alpha1 +# kind: TemporalCluster +# metadata: +# name: temporal-sample +# spec: +# workflowRunPolicy: +# enabled: true +# allowedNamespaces: ["default"] +# allowedTaskQueues: ["greeting-tq"] +``` + +- [ ] **Step 5: Regenerate manifests, chart, and docs.** + +Run: `make manifests helm-chart api-docs docs-crd-reference` +Expected: succeeds. `git status` shows the new CRD under `config/crd/bases/`, RBAC additions, `dist/chart` updates (new CRD template + RBAC), and updated `docs/api/v1alpha1.md` + `docs/content/reference/_index.md`. + +- [ ] **Step 6: Build the whole project.** + +Run: `make build` +Expected: exit 0. + +- [ ] **Step 7: Commit.** + +```bash +git add cmd/main.go PROJECT config docs dist +git commit -s -m "feat: wire TemporalWorkflowRun controller, webhook, and generated manifests" +``` + +--- + +### Task 7: Chainsaw e2e suite + CI wiring + +**Files:** +- Create: `test/e2e/workflowrun/chainsaw-test.yaml`, `01-devserver.yaml`, `01-assert.yaml`, `02-namespace.yaml`, `02-assert.yaml`, `03-workflowrun.yaml`, `03-assert.yaml`, `04-assert-terminated.yaml`, `05-deny.yaml`, `05-assert.yaml` +- Modify: `.github/workflows/e2e.yml` + +**Interfaces:** None (declarative YAML + CI). Drives terminal state by terminating the workflow with `admin-tools`, so no custom worker image is needed. + +- [ ] **Step 1: Dev server fixture.** Create `test/e2e/workflowrun/01-devserver.yaml`: + +```yaml +apiVersion: temporal.bmor10.com/v1alpha1 +kind: TemporalDevServer +metadata: + name: wfr +spec: + version: "1.31.1" + namespaces: ["orders"] + workflowRunPolicy: + enabled: true + allowedTaskQueues: ["greeting-tq"] +``` + +And `test/e2e/workflowrun/01-assert.yaml`: + +```yaml +apiVersion: temporal.bmor10.com/v1alpha1 +kind: TemporalDevServer +metadata: + name: wfr +status: + (conditions[?type == 'Ready'].status | [0]): "True" +``` + +- [ ] **Step 2: Namespace fixture.** Create `test/e2e/workflowrun/02-namespace.yaml`: + +```yaml +apiVersion: temporal.bmor10.com/v1alpha1 +kind: TemporalNamespace +metadata: + name: orders +spec: + clusterRef: + name: wfr + kind: TemporalDevServer + retentionPeriod: "24h" +``` + +And `test/e2e/workflowrun/02-assert.yaml`: + +```yaml +apiVersion: temporal.bmor10.com/v1alpha1 +kind: TemporalNamespace +metadata: + name: orders +status: + registered: true + (conditions[?type == 'Ready'].status | [0]): "True" +``` + +- [ ] **Step 3: WorkflowRun fixtures.** Create `test/e2e/workflowrun/03-workflowrun.yaml`: + +```yaml +apiVersion: temporal.bmor10.com/v1alpha1 +kind: TemporalWorkflowRun +metadata: + name: greet-once +spec: + clusterRef: + name: wfr + kind: TemporalDevServer + namespace: orders + ttlSecondsAfterFinished: 30 + workflow: + workflowType: GreetingWorkflow + taskQueue: greeting-tq +``` + +`test/e2e/workflowrun/03-assert.yaml` (started + Running, no worker so it stays Running): + +```yaml +apiVersion: temporal.bmor10.com/v1alpha1 +kind: TemporalWorkflowRun +metadata: + name: greet-once +status: + phase: Running + (workflowID != null && workflowID != ''): true + (runID != null && runID != ''): true + (conditions[?type == 'Ready'].status | [0]): "True" +``` + +`test/e2e/workflowrun/04-assert-terminated.yaml` (after external terminate): + +```yaml +apiVersion: temporal.bmor10.com/v1alpha1 +kind: TemporalWorkflowRun +metadata: + name: greet-once +status: + phase: Terminated + (conditions[?type == 'Finished'].status | [0]): "True" +``` + +- [ ] **Step 4: Deny fixtures.** Create `test/e2e/workflowrun/05-deny.yaml` (task queue not in allowlist): + +```yaml +apiVersion: temporal.bmor10.com/v1alpha1 +kind: TemporalWorkflowRun +metadata: + name: denied-run +spec: + clusterRef: + name: wfr + kind: TemporalDevServer + namespace: orders + workflow: + workflowType: GreetingWorkflow + taskQueue: not-allowed-tq +``` + +`test/e2e/workflowrun/05-assert.yaml`: + +```yaml +apiVersion: temporal.bmor10.com/v1alpha1 +kind: TemporalWorkflowRun +metadata: + name: denied-run +status: + (conditions[?type == 'Ready'].status | [0]): "False" + (conditions[?type == 'Ready'].reason | [0]): WorkflowRunNotPermitted +``` + +- [ ] **Step 5: Chainsaw test orchestration.** Create `test/e2e/workflowrun/chainsaw-test.yaml`: + +```yaml +# Chainsaw test: stand up a disposable TemporalDevServer, start a one-off +# workflow via TemporalWorkflowRun, drive it to a terminal state by terminating +# it with admin-tools, verify TTL deletes the CR, and verify the +# WorkflowRunPolicy denies a run using a disallowed task queue. No external DB +# and no custom worker image are required. +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: workflowrun +spec: + timeouts: + apply: 1m + assert: 5m + exec: 2m + steps: + - name: dev-server + try: + - apply: + file: 01-devserver.yaml + - assert: + file: 01-assert.yaml + - name: register-namespace + try: + - apply: + file: 02-namespace.yaml + - assert: + file: 02-assert.yaml + - name: start-workflow + try: + - apply: + file: 03-workflowrun.yaml + - assert: + file: 03-assert.yaml + - name: terminate-and-ttl + try: + - script: + content: | + kubectl run -n $NAMESPACE wf-terminate --rm -i --restart=Never \ + --image=temporalio/admin-tools:1.31.1 -- \ + temporal workflow terminate \ + --address dev-wfr:7233 --namespace orders \ + --workflow-id greet-once --reason e2e + - assert: + file: 04-assert-terminated.yaml + - error: + # After TTL (30s) the operator deletes the CR. + file: 04-assert-terminated.yaml + - name: policy-deny + try: + - apply: + file: 05-deny.yaml + - assert: + file: 05-assert.yaml +``` + +Note: confirm the dev server in-cluster frontend Service DNS name. `DevServerFrontendEndpoint` (in `internal/resources/devserver.go`) and the existing `devserver` suite use `dev-:7233`, hence `dev-wfr:7233`. If the helper differs, match it. + +- [ ] **Step 6: Wire into CI.** In `.github/workflows/e2e.yml`, in the matrix-compute step, add the combo definition next to `devserver`: + +```bash + workflowrun='{"suite":"workflowrun"}' +``` + +Add `$workflowrun` to the `schedule` combos list and the `all` list, and add a `workflow_dispatch` case: + +```bash + workflowrun) echo "combos=[$workflowrun]" >> "$GITHUB_OUTPUT" ;; +``` + +Finally, in the "Pre-pull and load Temporal images" step, ensure the `workflowrun` suite pulls the dev-server + admin-tools images the same way the `devserver` suite does (extend the `grep -q "^devserver"` branch condition to also match `^workflowrun`, or add an equivalent branch). Inspect that step (`.github/workflows/e2e.yml` around the devserver handling) and mirror it. + +- [ ] **Step 7: Validate the suite YAML locally (lint only — full run needs a cluster).** + +Run: `./bin/chainsaw lint test --test-dir test/e2e/workflowrun || true` (install chainsaw with `make chainsaw` first if absent) +Expected: no schema errors. Full execution happens via `make chainsaw-test-nsc SUITE=workflowrun` in Task 8. + +- [ ] **Step 8: Commit.** + +```bash +git add test/e2e/workflowrun .github/workflows/e2e.yml +git commit -s -m "test(e2e): add workflowrun Chainsaw suite and CI wiring" +``` + +--- + +### Task 8: Full verification & PR + +**Files:** none (verification + delivery). + +- [ ] **Step 1: Run the full local checks.** + +Run: `make generate manifests build test lint` +Expected: all succeed; `git status` is clean (no stale generated artifacts). If `make manifests`/`helm-chart`/`api-docs` produce diffs, commit them (`fix: regenerate manifests`), because CI enforces drift checks. + +- [ ] **Step 2: Run the e2e suite on an ephemeral nsc cluster (the real validation signal).** + +Run: `make chainsaw-test-nsc SUITE=workflowrun` +Expected: the suite passes — dev server Ready, workflow started (phase Running, workflowID/runID populated), terminate → Terminated + Finished, CR auto-deleted after TTL, and the policy-deny run reports `Ready=False`/`WorkflowRunNotPermitted`. If anything fails, fix and re-run before opening the PR. + +- [ ] **Step 3: Push the branch and open the PR.** + +```bash +git push -u origin feat/temporalworkflowrun-crd +gh pr create --fill --title "feat: add TemporalWorkflowRun CRD for one-off workflow runs" \ + --body "Implements a Job-like TemporalWorkflowRun CRD: starts one-off workflows, tracks execution status, TTL cleanup, finalizer-based cancellation policy, and a closed-by-default WorkflowRunPolicy opt-in on TemporalCluster/TemporalDevServer. mTLS reuses the existing target-resolution path. Includes unit/envtest coverage and a Chainsaw e2e suite (dispatchable + nightly). See docs/superpowers/specs/2026-06-25-temporalworkflowrun-design.md." +``` + +Expected: PR created. Verify CI (build, test, lint, DCO, generated-chart/docs drift) is green. + +--- + +## Self-Review Notes + +- **Spec coverage:** one-shot/immutable (Task 1 spec + Task 5 webhook); TTL deletes CR (Task 4 reconcileRun); cancellationPolicy via finalizer (Task 4 reconcileDelete); status detail + failure-only (Task 1 status, Task 3 failure extraction, Task 4 mapping); cluster opt-in policy closed-by-default (Task 1 fields, Task 2 effective defaults, Task 4 enforcement); mTLS reuse (Task 4 via resolveTarget — unchanged); e2e nsc + CI (Task 7); generated-artifact + DCO discipline (Tasks 6, 8). All spec sections map to a task. +- **Type consistency:** `WorkflowRunPolicy`, `StartWorkflowParams`, `WorkflowExecutionInfo`, `WorkflowFailure`, `PhaseFromStatus`/`IsTerminalStatus`, `effectiveWorkflowRunPolicy`, `workflowRunParams`, `checkWorkflowRunPolicy`, `resolveWorkflowID` are referenced consistently across tasks. `spec.workflow` is the existing `StartWorkflowAction` type throughout. +- **Known follow-the-compiler points (flagged inline):** exact `go.temporal.io/api` proto getter names (failure type, timed-out attrs), dev-server frontend DNS in e2e, and the e2e.yml image pre-pull branch — each task tells the implementer to verify against the live code/version. From 1e3c999028c636b7481312969f07680dded0c428 Mon Sep 17 00:00:00 2001 From: Brian Morton Date: Thu, 25 Jun 2026 07:22:20 +0000 Subject: [PATCH 03/12] feat(api): add TemporalWorkflowRun CRD and WorkflowRunPolicy types Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Brian Morton --- api/v1alpha1/conditions.go | 10 + api/v1alpha1/temporalcluster_types.go | 5 + api/v1alpha1/temporaldevserver_types.go | 5 + api/v1alpha1/temporalworkflowrun_types.go | 143 +++++++++ api/v1alpha1/zz_generated.deepcopy.go | 170 +++++++++++ .../temporal.bmor10.com_temporalclusters.yaml | 24 ++ ...emporal.bmor10.com_temporaldevservers.yaml | 24 ++ ...poral.bmor10.com_temporalworkflowruns.yaml | 279 ++++++++++++++++++ 8 files changed, 660 insertions(+) create mode 100644 api/v1alpha1/temporalworkflowrun_types.go create mode 100644 config/crd/bases/temporal.bmor10.com_temporalworkflowruns.yaml diff --git a/api/v1alpha1/conditions.go b/api/v1alpha1/conditions.go index 7dfc5a8e..4c7b4697 100644 --- a/api/v1alpha1/conditions.go +++ b/api/v1alpha1/conditions.go @@ -74,4 +74,14 @@ const ( ReasonReplicationDrift = "ReplicationConfigDrift" // ReasonActiveClusterInvalid indicates the active cluster selection is invalid. ReasonActiveClusterInvalid = "ActiveClusterInvalid" + // ReasonWorkflowRunNotPermitted indicates the target's WorkflowRunPolicy denied the run. + ReasonWorkflowRunNotPermitted = "WorkflowRunNotPermitted" + // ReasonWorkflowRunning indicates the workflow is currently running. + ReasonWorkflowRunning = "WorkflowRunning" + // ReasonWorkflowFinished indicates the workflow reached a terminal state. + ReasonWorkflowFinished = "WorkflowFinished" + // ReasonClusterNotFound indicates the referenced Temporal target was not found. + ReasonClusterNotFound = "ClusterNotFound" + // ReasonClusterNotReady indicates the referenced Temporal target is not ready. + ReasonClusterNotReady = "ClusterNotReady" ) diff --git a/api/v1alpha1/temporalcluster_types.go b/api/v1alpha1/temporalcluster_types.go index 690a0695..5b31422b 100644 --- a/api/v1alpha1/temporalcluster_types.go +++ b/api/v1alpha1/temporalcluster_types.go @@ -83,6 +83,11 @@ type TemporalClusterSpec struct { // validating webhook as a safety measure. // +optional PreventDeletion bool `json:"preventDeletion,omitempty"` + + // WorkflowRunPolicy gates operator-initiated TemporalWorkflowRun executions + // against this cluster. Absent means disabled (closed by default). + // +optional + WorkflowRunPolicy *WorkflowRunPolicy `json:"workflowRunPolicy,omitempty"` } // ServicesSpec configures each Temporal service plus shared overrides. diff --git a/api/v1alpha1/temporaldevserver_types.go b/api/v1alpha1/temporaldevserver_types.go index d4d62084..e671ffa2 100644 --- a/api/v1alpha1/temporaldevserver_types.go +++ b/api/v1alpha1/temporaldevserver_types.go @@ -76,6 +76,11 @@ type TemporalDevServerSpec struct { // Affinity applied to the dev server pod. // +optional Affinity *corev1.Affinity `json:"affinity,omitempty"` + + // WorkflowRunPolicy gates operator-initiated TemporalWorkflowRun executions + // against this dev server. Absent means enabled with no allowlist. + // +optional + WorkflowRunPolicy *WorkflowRunPolicy `json:"workflowRunPolicy,omitempty"` } // DevServerUISpec controls the bundled Web UI. diff --git a/api/v1alpha1/temporalworkflowrun_types.go b/api/v1alpha1/temporalworkflowrun_types.go new file mode 100644 index 00000000..a7db8cef --- /dev/null +++ b/api/v1alpha1/temporalworkflowrun_types.go @@ -0,0 +1,143 @@ +/* +Copyright 2026 Brian Morton. + +Licensed 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 v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// WorkflowRunPolicy is a per-target opt-in gate controlling operator-initiated +// workflow runs. It is declared on a TemporalCluster or TemporalDevServer by the +// cluster owner. Defaults differ per kind and are applied by the controller's +// target resolver, not by kubebuilder defaults, so an absent policy is +// meaningful: disabled for TemporalCluster, enabled (no allowlist) for +// TemporalDevServer. +type WorkflowRunPolicy struct { + // Enabled permits operator-initiated workflow runs against this target. + // +optional + Enabled bool `json:"enabled,omitempty"` + // AllowedNamespaces optionally restricts which Temporal namespaces runs may + // target. Empty means any namespace is allowed. + // +optional + AllowedNamespaces []string `json:"allowedNamespaces,omitempty"` + // AllowedTaskQueues optionally restricts which task queues runs may use. + // Empty means any task queue is allowed. + // +optional + AllowedTaskQueues []string `json:"allowedTaskQueues,omitempty"` +} + +// TemporalWorkflowRunSpec defines the desired state of a one-off workflow run. +type TemporalWorkflowRunSpec struct { + // ClusterRef references the TemporalCluster or TemporalDevServer that runs + // the workflow. Resolved in the same Kubernetes namespace as this CR. + ClusterRef ClusterReference `json:"clusterRef"` + + // Namespace is the Temporal namespace to start the workflow in. + Namespace string `json:"namespace"` + + // Workflow describes the one-off workflow to start. Immutable after create. + Workflow StartWorkflowAction `json:"workflow"` + + // TTLSecondsAfterFinished, when set, deletes this CR that many seconds after + // the workflow reaches a terminal state. Unset keeps the CR indefinitely. + // +optional + TTLSecondsAfterFinished *int32 `json:"ttlSecondsAfterFinished,omitempty"` + + // CancellationPolicy controls what happens to a still-running workflow when + // this CR is deleted. + // +kubebuilder:validation:Enum=Abandon;Cancel;Terminate + // +kubebuilder:default=Abandon + // +optional + CancellationPolicy string `json:"cancellationPolicy,omitempty"` +} + +// WorkflowRunFailure carries failure detail for non-success terminal states. +type WorkflowRunFailure struct { + // +optional + Message string `json:"message,omitempty"` + // +optional + Type string `json:"type,omitempty"` +} + +// TemporalWorkflowRunStatus is the observed state of a workflow run. +type TemporalWorkflowRunStatus struct { + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // Phase is a friendly lifecycle summary: Pending, Running, Completed, + // Failed, Terminated, Canceled, TimedOut, or ContinuedAsNew. + // +optional + Phase string `json:"phase,omitempty"` + // +optional + WorkflowID string `json:"workflowID,omitempty"` + // +optional + RunID string `json:"runID,omitempty"` + // +optional + WorkflowType string `json:"workflowType,omitempty"` + // +optional + TaskQueue string `json:"taskQueue,omitempty"` + // +optional + StartTime *metav1.Time `json:"startTime,omitempty"` + // +optional + CloseTime *metav1.Time `json:"closeTime,omitempty"` + // CompletionTime is when the operator first observed a terminal state; it + // drives the TTL countdown. + // +optional + CompletionTime *metav1.Time `json:"completionTime,omitempty"` + // +optional + HistoryLength int64 `json:"historyLength,omitempty"` + // Failure carries the failure message/type for non-success terminal states. + // +optional + Failure *WorkflowRunFailure `json:"failure,omitempty"` + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Namespaced,shortName=twr +// +kubebuilder:subresource:status +// +kubebuilder:storageversion +// +kubebuilder:printcolumn:name="Cluster",type=string,JSONPath=`.spec.clusterRef.name` +// +kubebuilder:printcolumn:name="Namespace",type=string,JSONPath=`.spec.namespace` +// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase` +// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` +// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` + +// TemporalWorkflowRun is the Schema for the temporalworkflowruns API. +type TemporalWorkflowRun struct { + metav1.TypeMeta `json:",inline"` + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` + // +required + Spec TemporalWorkflowRunSpec `json:"spec"` + // +optional + Status TemporalWorkflowRunStatus `json:"status,omitzero"` +} + +// +kubebuilder:object:root=true + +// TemporalWorkflowRunList contains a list of TemporalWorkflowRun. +type TemporalWorkflowRunList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []TemporalWorkflowRun `json:"items"` +} + +func init() { + registerType(&TemporalWorkflowRun{}, &TemporalWorkflowRunList{}) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 9688934a..3234eacd 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1642,6 +1642,11 @@ func (in *TemporalClusterSpec) DeepCopyInto(out *TemporalClusterSpec) { *out = new(ClusterMetadataSpec) (*in).DeepCopyInto(*out) } + if in.WorkflowRunPolicy != nil { + in, out := &in.WorkflowRunPolicy, &out.WorkflowRunPolicy + *out = new(WorkflowRunPolicy) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TemporalClusterSpec. @@ -1797,6 +1802,11 @@ func (in *TemporalDevServerSpec) DeepCopyInto(out *TemporalDevServerSpec) { *out = new(corev1.Affinity) (*in).DeepCopyInto(*out) } + if in.WorkflowRunPolicy != nil { + in, out := &in.WorkflowRunPolicy, &out.WorkflowRunPolicy + *out = new(WorkflowRunPolicy) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TemporalDevServerSpec. @@ -2169,6 +2179,126 @@ func (in *TemporalSearchAttributeStatus) DeepCopy() *TemporalSearchAttributeStat return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TemporalWorkflowRun) DeepCopyInto(out *TemporalWorkflowRun) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TemporalWorkflowRun. +func (in *TemporalWorkflowRun) DeepCopy() *TemporalWorkflowRun { + if in == nil { + return nil + } + out := new(TemporalWorkflowRun) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TemporalWorkflowRun) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TemporalWorkflowRunList) DeepCopyInto(out *TemporalWorkflowRunList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]TemporalWorkflowRun, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TemporalWorkflowRunList. +func (in *TemporalWorkflowRunList) DeepCopy() *TemporalWorkflowRunList { + if in == nil { + return nil + } + out := new(TemporalWorkflowRunList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *TemporalWorkflowRunList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TemporalWorkflowRunSpec) DeepCopyInto(out *TemporalWorkflowRunSpec) { + *out = *in + out.ClusterRef = in.ClusterRef + in.Workflow.DeepCopyInto(&out.Workflow) + if in.TTLSecondsAfterFinished != nil { + in, out := &in.TTLSecondsAfterFinished, &out.TTLSecondsAfterFinished + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TemporalWorkflowRunSpec. +func (in *TemporalWorkflowRunSpec) DeepCopy() *TemporalWorkflowRunSpec { + if in == nil { + return nil + } + out := new(TemporalWorkflowRunSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TemporalWorkflowRunStatus) DeepCopyInto(out *TemporalWorkflowRunStatus) { + *out = *in + if in.StartTime != nil { + in, out := &in.StartTime, &out.StartTime + *out = (*in).DeepCopy() + } + if in.CloseTime != nil { + in, out := &in.CloseTime, &out.CloseTime + *out = (*in).DeepCopy() + } + if in.CompletionTime != nil { + in, out := &in.CompletionTime, &out.CompletionTime + *out = (*in).DeepCopy() + } + if in.Failure != nil { + in, out := &in.Failure, &out.Failure + *out = new(WorkflowRunFailure) + **out = **in + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TemporalWorkflowRunStatus. +func (in *TemporalWorkflowRunStatus) DeepCopy() *TemporalWorkflowRunStatus { + if in == nil { + return nil + } + out := new(TemporalWorkflowRunStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UIAuthSpec) DeepCopyInto(out *UIAuthSpec) { *out = *in @@ -2294,3 +2424,43 @@ func (in *UpgradeStatus) DeepCopy() *UpgradeStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkflowRunFailure) DeepCopyInto(out *WorkflowRunFailure) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkflowRunFailure. +func (in *WorkflowRunFailure) DeepCopy() *WorkflowRunFailure { + if in == nil { + return nil + } + out := new(WorkflowRunFailure) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkflowRunPolicy) DeepCopyInto(out *WorkflowRunPolicy) { + *out = *in + if in.AllowedNamespaces != nil { + in, out := &in.AllowedNamespaces, &out.AllowedNamespaces + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.AllowedTaskQueues != nil { + in, out := &in.AllowedTaskQueues, &out.AllowedTaskQueues + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkflowRunPolicy. +func (in *WorkflowRunPolicy) DeepCopy() *WorkflowRunPolicy { + if in == nil { + return nil + } + out := new(WorkflowRunPolicy) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/bases/temporal.bmor10.com_temporalclusters.yaml b/config/crd/bases/temporal.bmor10.com_temporalclusters.yaml index b4b74e0d..083c3a65 100644 --- a/config/crd/bases/temporal.bmor10.com_temporalclusters.yaml +++ b/config/crd/bases/temporal.bmor10.com_temporalclusters.yaml @@ -6195,6 +6195,30 @@ spec: description: Version is the Temporal server version, e.g. "1.31.1". pattern: ^\d+\.\d+\.\d+$ type: string + workflowRunPolicy: + description: |- + WorkflowRunPolicy gates operator-initiated TemporalWorkflowRun executions + against this cluster. Absent means disabled (closed by default). + properties: + allowedNamespaces: + description: |- + AllowedNamespaces optionally restricts which Temporal namespaces runs may + target. Empty means any namespace is allowed. + items: + type: string + type: array + allowedTaskQueues: + description: |- + AllowedTaskQueues optionally restricts which task queues runs may use. + Empty means any task queue is allowed. + items: + type: string + type: array + enabled: + description: Enabled permits operator-initiated workflow runs + against this target. + type: boolean + type: object required: - numHistoryShards - persistence diff --git a/config/crd/bases/temporal.bmor10.com_temporaldevservers.yaml b/config/crd/bases/temporal.bmor10.com_temporaldevservers.yaml index fa772999..bcada8b8 100644 --- a/config/crd/bases/temporal.bmor10.com_temporaldevservers.yaml +++ b/config/crd/bases/temporal.bmor10.com_temporaldevservers.yaml @@ -1161,6 +1161,30 @@ spec: version matrix. When empty, the latest supported server version is used. Use Image to pin a specific CLI image directly. type: string + workflowRunPolicy: + description: |- + WorkflowRunPolicy gates operator-initiated TemporalWorkflowRun executions + against this dev server. Absent means enabled with no allowlist. + properties: + allowedNamespaces: + description: |- + AllowedNamespaces optionally restricts which Temporal namespaces runs may + target. Empty means any namespace is allowed. + items: + type: string + type: array + allowedTaskQueues: + description: |- + AllowedTaskQueues optionally restricts which task queues runs may use. + Empty means any task queue is allowed. + items: + type: string + type: array + enabled: + description: Enabled permits operator-initiated workflow runs + against this target. + type: boolean + type: object type: object status: description: status defines the observed state of TemporalDevServer diff --git a/config/crd/bases/temporal.bmor10.com_temporalworkflowruns.yaml b/config/crd/bases/temporal.bmor10.com_temporalworkflowruns.yaml new file mode 100644 index 00000000..2e1b1a0a --- /dev/null +++ b/config/crd/bases/temporal.bmor10.com_temporalworkflowruns.yaml @@ -0,0 +1,279 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: temporalworkflowruns.temporal.bmor10.com +spec: + group: temporal.bmor10.com + names: + kind: TemporalWorkflowRun + listKind: TemporalWorkflowRunList + plural: temporalworkflowruns + shortNames: + - twr + singular: temporalworkflowrun + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.clusterRef.name + name: Cluster + type: string + - jsonPath: .spec.namespace + name: Namespace + type: string + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: TemporalWorkflowRun is the Schema for the temporalworkflowruns + API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: TemporalWorkflowRunSpec defines the desired state of a one-off + workflow run. + properties: + cancellationPolicy: + default: Abandon + description: |- + CancellationPolicy controls what happens to a still-running workflow when + this CR is deleted. + enum: + - Abandon + - Cancel + - Terminate + type: string + clusterRef: + description: |- + ClusterRef references the TemporalCluster or TemporalDevServer that runs + the workflow. Resolved in the same Kubernetes namespace as this CR. + properties: + kind: + default: TemporalCluster + description: Kind selects the referenced object type. + enum: + - TemporalCluster + - TemporalDevServer + type: string + name: + description: Name is the name of the referenced object. + type: string + required: + - name + type: object + namespace: + description: Namespace is the Temporal namespace to start the workflow + in. + type: string + ttlSecondsAfterFinished: + description: |- + TTLSecondsAfterFinished, when set, deletes this CR that many seconds after + the workflow reaches a terminal state. Unset keeps the CR indefinitely. + format: int32 + type: integer + workflow: + description: Workflow describes the one-off workflow to start. Immutable + after create. + properties: + args: + description: Args are JSON-serializable workflow inputs (one json/plain + payload each). + items: + type: object + x-kubernetes-preserve-unknown-fields: true + type: array + memo: + additionalProperties: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + retryPolicy: + description: RetryPolicySpec is the retry policy for the started + workflow. + properties: + backoffCoefficient: + description: BackoffCoefficient is a decimal string (e.g. + "2.0") parsed to float64. + type: string + initialInterval: + type: string + maximumAttempts: + format: int32 + type: integer + maximumInterval: + type: string + nonRetryableErrorTypes: + items: + type: string + type: array + type: object + searchAttributes: + additionalProperties: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + taskQueue: + type: string + workflowExecutionTimeout: + type: string + workflowID: + type: string + workflowIDReusePolicy: + enum: + - AllowDuplicate + - AllowDuplicateFailedOnly + - RejectDuplicate + - TerminateIfRunning + type: string + workflowRunTimeout: + type: string + workflowTaskTimeout: + type: string + workflowType: + type: string + required: + - taskQueue + - workflowType + type: object + required: + - clusterRef + - namespace + - workflow + type: object + status: + description: TemporalWorkflowRunStatus is the observed state of a workflow + run. + properties: + closeTime: + format: date-time + type: string + completionTime: + description: |- + CompletionTime is when the operator first observed a terminal state; it + drives the TTL countdown. + format: date-time + type: string + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + failure: + description: Failure carries the failure message/type for non-success + terminal states. + properties: + message: + type: string + type: + type: string + type: object + historyLength: + format: int64 + type: integer + observedGeneration: + format: int64 + type: integer + phase: + description: |- + Phase is a friendly lifecycle summary: Pending, Running, Completed, + Failed, Terminated, Canceled, TimedOut, or ContinuedAsNew. + type: string + runID: + type: string + startTime: + format: date-time + type: string + taskQueue: + type: string + workflowID: + type: string + workflowType: + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} From 878fef274d99e95a175fa27edab0623249678617 Mon Sep 17 00:00:00 2001 From: Brian Morton Date: Thu, 25 Jun 2026 07:27:36 +0000 Subject: [PATCH 04/12] feat(controller): surface effective WorkflowRunPolicy from resolveTarget Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Brian Morton --- internal/controller/target.go | 32 ++++++++++++++++++++++++------ internal/controller/target_test.go | 18 +++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/internal/controller/target.go b/internal/controller/target.go index 8341ae93..17506041 100644 --- a/internal/controller/target.go +++ b/internal/controller/target.go @@ -44,6 +44,9 @@ type ResolvedTarget struct { TLSConfig *tls.Config // Ready reports whether the referenced target's Ready condition is true. Ready bool + // WorkflowRunPolicy is the effective (defaults-applied) policy for + // operator-initiated workflow runs against this target. + WorkflowRunPolicy temporalv1alpha1.WorkflowRunPolicy } // resolveTarget resolves a ClusterReference to a connectable Temporal frontend. @@ -65,9 +68,10 @@ func resolveTarget(ctx context.Context, c client.Client, namespace string, ref t return nil, fmt.Errorf("building temporal client tls: %w", err) } return &ResolvedTarget{ - Address: frontendAddress(&cluster), - TLSConfig: tlsConfig, - Ready: meta.IsStatusConditionTrue(cluster.Status.Conditions, temporalv1alpha1.ConditionReady), + Address: frontendAddress(&cluster), + TLSConfig: tlsConfig, + Ready: meta.IsStatusConditionTrue(cluster.Status.Conditions, temporalv1alpha1.ConditionReady), + WorkflowRunPolicy: effectiveWorkflowRunPolicy(temporalv1alpha1.ClusterKindTemporalCluster, cluster.Spec.WorkflowRunPolicy), }, nil case temporalv1alpha1.ClusterKindTemporalDevServer: @@ -79,12 +83,28 @@ func resolveTarget(ctx context.Context, c client.Client, namespace string, ref t return nil, err } return &ResolvedTarget{ - Address: resources.DevServerFrontendEndpoint(&dev), - TLSConfig: nil, - Ready: meta.IsStatusConditionTrue(dev.Status.Conditions, temporalv1alpha1.ConditionReady), + Address: resources.DevServerFrontendEndpoint(&dev), + TLSConfig: nil, + Ready: meta.IsStatusConditionTrue(dev.Status.Conditions, temporalv1alpha1.ConditionReady), + WorkflowRunPolicy: effectiveWorkflowRunPolicy(temporalv1alpha1.ClusterKindTemporalDevServer, dev.Spec.WorkflowRunPolicy), }, nil default: return nil, fmt.Errorf("unknown cluster reference kind %q", ref.Kind) } } + +// effectiveWorkflowRunPolicy applies per-kind defaults to a possibly-nil policy. +// A nil policy means disabled for TemporalCluster (closed by default) and +// enabled with no allowlist for TemporalDevServer (throwaway dev environments). +func effectiveWorkflowRunPolicy(kind string, p *temporalv1alpha1.WorkflowRunPolicy) temporalv1alpha1.WorkflowRunPolicy { + if p != nil { + return *p + } + switch kind { + case temporalv1alpha1.ClusterKindTemporalDevServer: + return temporalv1alpha1.WorkflowRunPolicy{Enabled: true} + default: + return temporalv1alpha1.WorkflowRunPolicy{Enabled: false} + } +} diff --git a/internal/controller/target_test.go b/internal/controller/target_test.go index 5ca221b6..f22f8429 100644 --- a/internal/controller/target_test.go +++ b/internal/controller/target_test.go @@ -48,3 +48,21 @@ var _ = Describe("resolveTarget", func() { Expect(target.Ready).To(BeFalse()) }) }) + +var _ = Describe("effectiveWorkflowRunPolicy", func() { + It("defaults TemporalCluster to disabled when nil", func() { + p := effectiveWorkflowRunPolicy(temporalv1alpha1.ClusterKindTemporalCluster, nil) + Expect(p.Enabled).To(BeFalse()) + }) + It("defaults TemporalDevServer to enabled when nil", func() { + p := effectiveWorkflowRunPolicy(temporalv1alpha1.ClusterKindTemporalDevServer, nil) + Expect(p.Enabled).To(BeTrue()) + Expect(p.AllowedNamespaces).To(BeEmpty()) + }) + It("passes an explicit policy through unchanged", func() { + in := &temporalv1alpha1.WorkflowRunPolicy{Enabled: true, AllowedTaskQueues: []string{"q"}} + p := effectiveWorkflowRunPolicy(temporalv1alpha1.ClusterKindTemporalCluster, in) + Expect(p.Enabled).To(BeTrue()) + Expect(p.AllowedTaskQueues).To(Equal([]string{"q"})) + }) +}) From c2329f22e7ca195bcbbebdf8a3129aebdb3cae6c Mon Sep 17 00:00:00 2001 From: Brian Morton Date: Thu, 25 Jun 2026 07:32:00 +0000 Subject: [PATCH 05/12] feat(temporal): add WorkflowRunClient for one-off workflow executions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Brian Morton --- internal/temporal/workflowrun.go | 269 ++++++++++++++++++++++++++ internal/temporal/workflowrun_test.go | 80 ++++++++ 2 files changed, 349 insertions(+) create mode 100644 internal/temporal/workflowrun.go create mode 100644 internal/temporal/workflowrun_test.go diff --git a/internal/temporal/workflowrun.go b/internal/temporal/workflowrun.go new file mode 100644 index 00000000..11627114 --- /dev/null +++ b/internal/temporal/workflowrun.go @@ -0,0 +1,269 @@ +/* +Copyright 2026 Brian Morton. + +Licensed 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 temporal + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "time" + + commonpb "go.temporal.io/api/common/v1" + enumspb "go.temporal.io/api/enums/v1" + historypb "go.temporal.io/api/history/v1" + taskqueuepb "go.temporal.io/api/taskqueue/v1" + workflowservice "go.temporal.io/api/workflowservice/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" +) + +const workflowRunIdentity = "temporal-operator" + +// ErrWorkflowNotFound is returned by Describe when the execution does not exist. +var ErrWorkflowNotFound = errors.New("workflow execution not found") + +// WorkflowFailure is the failure detail for a non-success terminal workflow. +type WorkflowFailure struct { + Message string + Type string +} + +// WorkflowExecutionInfo is the observed state of a workflow execution. +type WorkflowExecutionInfo struct { + Status enumspb.WorkflowExecutionStatus + RunID string + WorkflowType string + TaskQueue string + StartTime *time.Time + CloseTime *time.Time + HistoryLength int64 + Failure *WorkflowFailure +} + +// WorkflowRunClient starts and observes one-off workflow executions. +type WorkflowRunClient interface { + // Start starts the workflow and returns its runID. requestID makes Start + // idempotent: a retried call with the same requestID is de-duplicated by + // Temporal. On AlreadyExists it resolves and returns the open run's runID. + Start(ctx context.Context, namespace, requestID string, params StartWorkflowParams) (string, error) + // Describe returns the execution state. For non-success terminal states it + // reads the close event to populate Failure. runID may be empty to address + // the latest run. + Describe(ctx context.Context, namespace, workflowID, runID string) (*WorkflowExecutionInfo, error) + Cancel(ctx context.Context, namespace, workflowID, runID, reason string) error + Terminate(ctx context.Context, namespace, workflowID, runID, reason string) error + Close() error +} + +// WorkflowRunClientFactory builds a WorkflowRunClient. nil tlsConfig = insecure. +type WorkflowRunClientFactory func(ctx context.Context, address string, tlsConfig *tls.Config) (WorkflowRunClient, error) + +type grpcWorkflowRunClient struct { + conn *grpc.ClientConn + workflow workflowservice.WorkflowServiceClient +} + +// NewWorkflowRunClient dials the frontend and returns a WorkflowRunClient. +func NewWorkflowRunClient(_ context.Context, address string, tlsConfig *tls.Config) (WorkflowRunClient, error) { + creds := insecure.NewCredentials() + if tlsConfig != nil { + creds = credentials.NewTLS(tlsConfig) + } + conn, err := grpc.NewClient(address, grpc.WithTransportCredentials(creds)) + if err != nil { + return nil, err + } + return &grpcWorkflowRunClient{conn: conn, workflow: workflowservice.NewWorkflowServiceClient(conn)}, nil +} + +func (c *grpcWorkflowRunClient) Start(ctx context.Context, namespace, requestID string, p StartWorkflowParams) (string, error) { + reuse, ok := reusePolicies[p.IDReusePolicy] + if !ok { + return "", fmt.Errorf("unknown workflow id reuse policy %q", p.IDReusePolicy) + } + req := &workflowservice.StartWorkflowExecutionRequest{ + Namespace: namespace, + WorkflowId: p.WorkflowID, + WorkflowType: &commonpb.WorkflowType{Name: p.WorkflowType}, + TaskQueue: &taskqueuepb.TaskQueue{Name: p.TaskQueue, Kind: enumspb.TASK_QUEUE_KIND_NORMAL}, + Input: encodeJSONPayloads(p.Args), + WorkflowExecutionTimeout: optDuration(p.ExecutionTimeout), + WorkflowRunTimeout: optDuration(p.RunTimeout), + WorkflowTaskTimeout: optDuration(p.TaskTimeout), + Identity: workflowRunIdentity, + RequestId: requestID, + WorkflowIdReusePolicy: reuse, + } + if m := encodeJSONFields(p.Memo); m != nil { + req.Memo = &commonpb.Memo{Fields: m} + } + if sa := encodeJSONFields(p.SearchAttributes); sa != nil { + req.SearchAttributes = &commonpb.SearchAttributes{IndexedFields: sa} + } + if r := p.Retry; r != nil { + req.RetryPolicy = &commonpb.RetryPolicy{ + InitialInterval: optDuration(r.InitialInterval), + BackoffCoefficient: r.BackoffCoefficient, + MaximumInterval: optDuration(r.MaximumInterval), + MaximumAttempts: r.MaximumAttempts, + NonRetryableErrorTypes: r.NonRetryableErrorTypes, + } + } + resp, err := c.workflow.StartWorkflowExecution(ctx, req) + if err != nil { + if status.Code(err) == codes.AlreadyExists { + info, derr := c.Describe(ctx, namespace, p.WorkflowID, "") + if derr != nil { + return "", derr + } + return info.RunID, nil + } + return "", err + } + return resp.GetRunId(), nil +} + +func (c *grpcWorkflowRunClient) Describe(ctx context.Context, namespace, workflowID, runID string) (*WorkflowExecutionInfo, error) { + resp, err := c.workflow.DescribeWorkflowExecution(ctx, &workflowservice.DescribeWorkflowExecutionRequest{ + Namespace: namespace, + Execution: &commonpb.WorkflowExecution{WorkflowId: workflowID, RunId: runID}, + }) + if err != nil { + if status.Code(err) == codes.NotFound { + return nil, ErrWorkflowNotFound + } + return nil, err + } + wi := resp.GetWorkflowExecutionInfo() + info := &WorkflowExecutionInfo{ + Status: wi.GetStatus(), + RunID: wi.GetExecution().GetRunId(), + WorkflowType: wi.GetType().GetName(), + TaskQueue: wi.GetTaskQueue(), + HistoryLength: wi.GetHistoryLength(), + } + if wi.GetStartTime() != nil { + t := wi.GetStartTime().AsTime() + info.StartTime = &t + } + if wi.GetCloseTime() != nil { + t := wi.GetCloseTime().AsTime() + info.CloseTime = &t + } + if IsTerminalStatus(info.Status) && info.Status != enumspb.WORKFLOW_EXECUTION_STATUS_COMPLETED && + info.Status != enumspb.WORKFLOW_EXECUTION_STATUS_CANCELED { + info.Failure = c.closeFailure(ctx, namespace, workflowID, info.RunID) + } + return info, nil +} + +// closeFailure reads the single close event and extracts a failure, best-effort. +func (c *grpcWorkflowRunClient) closeFailure(ctx context.Context, namespace, workflowID, runID string) *WorkflowFailure { + resp, err := c.workflow.GetWorkflowExecutionHistory(ctx, &workflowservice.GetWorkflowExecutionHistoryRequest{ + Namespace: namespace, + Execution: &commonpb.WorkflowExecution{WorkflowId: workflowID, RunId: runID}, + HistoryEventFilterType: enumspb.HISTORY_EVENT_FILTER_TYPE_CLOSE_EVENT, + }) + if err != nil { + return nil + } + for _, ev := range resp.GetHistory().GetEvents() { + if f := workflowFailureFromEvent(ev); f != nil { + return f + } + } + return nil +} + +func (c *grpcWorkflowRunClient) Cancel(ctx context.Context, namespace, workflowID, runID, reason string) error { + _, err := c.workflow.RequestCancelWorkflowExecution(ctx, &workflowservice.RequestCancelWorkflowExecutionRequest{ + Namespace: namespace, + WorkflowExecution: &commonpb.WorkflowExecution{WorkflowId: workflowID, RunId: runID}, + Identity: workflowRunIdentity, + Reason: reason, + }) + if err != nil && status.Code(err) == codes.NotFound { + return ErrWorkflowNotFound + } + return err +} + +func (c *grpcWorkflowRunClient) Terminate(ctx context.Context, namespace, workflowID, runID, reason string) error { + _, err := c.workflow.TerminateWorkflowExecution(ctx, &workflowservice.TerminateWorkflowExecutionRequest{ + Namespace: namespace, + WorkflowExecution: &commonpb.WorkflowExecution{WorkflowId: workflowID, RunId: runID}, + Identity: workflowRunIdentity, + Reason: reason, + }) + if err != nil && status.Code(err) == codes.NotFound { + return ErrWorkflowNotFound + } + return err +} + +func (c *grpcWorkflowRunClient) Close() error { return c.conn.Close() } + +// PhaseFromStatus maps a Temporal execution status to a friendly phase string. +func PhaseFromStatus(s enumspb.WorkflowExecutionStatus) string { + switch s { + case enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING: + return "Running" + case enumspb.WORKFLOW_EXECUTION_STATUS_COMPLETED: + return "Completed" + case enumspb.WORKFLOW_EXECUTION_STATUS_FAILED: + return "Failed" + case enumspb.WORKFLOW_EXECUTION_STATUS_CANCELED: + return "Canceled" + case enumspb.WORKFLOW_EXECUTION_STATUS_TERMINATED: + return "Terminated" + case enumspb.WORKFLOW_EXECUTION_STATUS_CONTINUED_AS_NEW: + return "ContinuedAsNew" + case enumspb.WORKFLOW_EXECUTION_STATUS_TIMED_OUT: + return "TimedOut" + default: + return "Pending" + } +} + +// IsTerminalStatus reports whether the status is a closed (terminal) state. +func IsTerminalStatus(s enumspb.WorkflowExecutionStatus) bool { + switch s { + case enumspb.WORKFLOW_EXECUTION_STATUS_UNSPECIFIED, enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING: + return false + default: + return true + } +} + +// workflowFailureFromEvent extracts failure detail from a close history event. +func workflowFailureFromEvent(ev *historypb.HistoryEvent) *WorkflowFailure { + if a := ev.GetWorkflowExecutionFailedEventAttributes(); a != nil { + f := a.GetFailure() + return &WorkflowFailure{Message: f.GetMessage(), Type: f.GetApplicationFailureInfo().GetType()} + } + if a := ev.GetWorkflowExecutionTerminatedEventAttributes(); a != nil { + return &WorkflowFailure{Message: a.GetReason(), Type: "Terminated"} + } + if a := ev.GetWorkflowExecutionTimedOutEventAttributes(); a != nil { + return &WorkflowFailure{Message: "workflow execution timed out", Type: "TimedOut"} + } + return nil +} diff --git a/internal/temporal/workflowrun_test.go b/internal/temporal/workflowrun_test.go new file mode 100644 index 00000000..a85c4be5 --- /dev/null +++ b/internal/temporal/workflowrun_test.go @@ -0,0 +1,80 @@ +/* +Copyright 2026 Brian Morton. + +Licensed 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 temporal + +import ( + "testing" + + enumspb "go.temporal.io/api/enums/v1" + failurepb "go.temporal.io/api/failure/v1" + historypb "go.temporal.io/api/history/v1" +) + +func TestPhaseFromStatus(t *testing.T) { + cases := map[enumspb.WorkflowExecutionStatus]struct { + phase string + terminal bool + }{ + enumspb.WORKFLOW_EXECUTION_STATUS_UNSPECIFIED: {"Pending", false}, + enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING: {"Running", false}, + enumspb.WORKFLOW_EXECUTION_STATUS_COMPLETED: {"Completed", true}, + enumspb.WORKFLOW_EXECUTION_STATUS_FAILED: {"Failed", true}, + enumspb.WORKFLOW_EXECUTION_STATUS_CANCELED: {"Canceled", true}, + enumspb.WORKFLOW_EXECUTION_STATUS_TERMINATED: {"Terminated", true}, + enumspb.WORKFLOW_EXECUTION_STATUS_CONTINUED_AS_NEW: {"ContinuedAsNew", true}, + enumspb.WORKFLOW_EXECUTION_STATUS_TIMED_OUT: {"TimedOut", true}, + } + for st, want := range cases { + if got := PhaseFromStatus(st); got != want.phase { + t.Errorf("PhaseFromStatus(%v) = %q, want %q", st, got, want.phase) + } + if got := IsTerminalStatus(st); got != want.terminal { + t.Errorf("IsTerminalStatus(%v) = %v, want %v", st, got, want.terminal) + } + } +} + +func TestWorkflowFailureFromFailedEvent(t *testing.T) { + ev := &historypb.HistoryEvent{ + Attributes: &historypb.HistoryEvent_WorkflowExecutionFailedEventAttributes{ + WorkflowExecutionFailedEventAttributes: &historypb.WorkflowExecutionFailedEventAttributes{ + Failure: &failurepb.Failure{ + Message: "boom", + FailureInfo: &failurepb.Failure_ApplicationFailureInfo{ + ApplicationFailureInfo: &failurepb.ApplicationFailureInfo{Type: "MyError"}, + }, + }, + }, + }, + } + f := workflowFailureFromEvent(ev) + if f == nil || f.Message != "boom" || f.Type != "MyError" { + t.Fatalf("unexpected failure: %+v", f) + } +} + +func TestWorkflowFailureFromTerminatedEvent(t *testing.T) { + ev := &historypb.HistoryEvent{ + Attributes: &historypb.HistoryEvent_WorkflowExecutionTerminatedEventAttributes{ + WorkflowExecutionTerminatedEventAttributes: &historypb.WorkflowExecutionTerminatedEventAttributes{Reason: "stopped"}, + }, + } + f := workflowFailureFromEvent(ev) + if f == nil || f.Message != "stopped" { + t.Fatalf("unexpected failure: %+v", f) + } +} From a2ce5bfad40c8442a7aec7abeab3793634d664c7 Mon Sep 17 00:00:00 2001 From: Brian Morton Date: Thu, 25 Jun 2026 07:38:30 +0000 Subject: [PATCH 06/12] feat(controller): reconcile TemporalWorkflowRun (start, status, TTL, policy) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Brian Morton --- .../temporalworkflowrun_controller.go | 307 ++++++++++++++++++ .../temporalworkflowrun_controller_test.go | 186 +++++++++++ 2 files changed, 493 insertions(+) create mode 100644 internal/controller/temporalworkflowrun_controller.go create mode 100644 internal/controller/temporalworkflowrun_controller_test.go diff --git a/internal/controller/temporalworkflowrun_controller.go b/internal/controller/temporalworkflowrun_controller.go new file mode 100644 index 00000000..0fb13aa8 --- /dev/null +++ b/internal/controller/temporalworkflowrun_controller.go @@ -0,0 +1,307 @@ +/* +Copyright 2026 Brian Morton. + +Licensed 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 controller + +import ( + "context" + "errors" + "fmt" + "strconv" + "time" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + temporalv1alpha1 "github.com/bmorton/temporal-operator/api/v1alpha1" + "github.com/bmorton/temporal-operator/internal/temporal" +) + +const workflowRunFinalizer = "temporal.bmor10.com/workflowrun" + +// workflowRunPollInterval is how often a running workflow's status is refreshed. +const workflowRunPollInterval = 10 * time.Second + +// TemporalWorkflowRunReconciler reconciles TemporalWorkflowRun objects. +type TemporalWorkflowRunReconciler struct { + client.Client + Scheme *runtime.Scheme + + // ClientFactory builds the Temporal workflow-run client; injectable for tests. + ClientFactory temporal.WorkflowRunClientFactory +} + +// +kubebuilder:rbac:groups=temporal.bmor10.com,resources=temporalworkflowruns,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=temporal.bmor10.com,resources=temporalworkflowruns/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=temporal.bmor10.com,resources=temporalworkflowruns/finalizers,verbs=update + +// Reconcile starts a one-off workflow, tracks its status, and cleans up via TTL. +func (r *TemporalWorkflowRunReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var run temporalv1alpha1.TemporalWorkflowRun + if err := r.Get(ctx, req.NamespacedName, &run); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + target, err := resolveTarget(ctx, r.Client, run.Namespace, run.Spec.ClusterRef) + if err != nil { + if !run.DeletionTimestamp.IsZero() { + return ctrl.Result{}, r.removeFinalizer(ctx, &run) + } + if errors.Is(err, ErrTargetNotFound) { + r.setReady(&run, metav1.ConditionFalse, temporalv1alpha1.ReasonClusterNotFound, "referenced Temporal target not found") + return ctrl.Result{RequeueAfter: 15 * time.Second}, r.statusUpdate(ctx, &run) + } + return ctrl.Result{}, err + } + + wc, err := r.clientFactory()(ctx, target.Address, target.TLSConfig) + if err != nil { + if !run.DeletionTimestamp.IsZero() { + return ctrl.Result{}, r.removeFinalizer(ctx, &run) + } + return ctrl.Result{}, fmt.Errorf("building temporal client: %w", err) + } + defer func() { _ = wc.Close() }() + + if !run.DeletionTimestamp.IsZero() { + return ctrl.Result{}, r.reconcileDelete(ctx, &run, wc) + } + + if !controllerutil.ContainsFinalizer(&run, workflowRunFinalizer) { + controllerutil.AddFinalizer(&run, workflowRunFinalizer) + if err := r.Update(ctx, &run); err != nil { + return ctrl.Result{}, err + } + } + + if !target.Ready { + r.setReady(&run, metav1.ConditionFalse, temporalv1alpha1.ReasonClusterNotReady, "waiting for the Temporal target to become ready") + return ctrl.Result{RequeueAfter: 15 * time.Second}, r.statusUpdate(ctx, &run) + } + + return r.reconcileRun(ctx, &run, wc, target.WorkflowRunPolicy) +} + +func (r *TemporalWorkflowRunReconciler) reconcileRun(ctx context.Context, run *temporalv1alpha1.TemporalWorkflowRun, wc temporal.WorkflowRunClient, policy temporalv1alpha1.WorkflowRunPolicy) (ctrl.Result, error) { + log := logf.FromContext(ctx) + wfID := resolveWorkflowID(run) + taskQueue := run.Spec.Workflow.TaskQueue + + // Start the workflow once. + if run.Status.RunID == "" { + if err := checkWorkflowRunPolicy(policy, run.Spec.Namespace, taskQueue); err != nil { + r.setReady(run, metav1.ConditionFalse, temporalv1alpha1.ReasonWorkflowRunNotPermitted, err.Error()) + return ctrl.Result{}, r.statusUpdate(ctx, run) + } + params, err := workflowRunParams(run) + if err != nil { + r.setReady(run, metav1.ConditionFalse, "InvalidSpec", err.Error()) + return ctrl.Result{}, r.statusUpdate(ctx, run) + } + runID, err := wc.Start(ctx, run.Spec.Namespace, string(run.UID), params) + if err != nil { + return ctrl.Result{}, fmt.Errorf("starting workflow: %w", err) + } + log.Info("started workflow", "workflowID", wfID, "runID", runID) + run.Status.WorkflowID = wfID + run.Status.RunID = runID + run.Status.WorkflowType = run.Spec.Workflow.WorkflowType + run.Status.TaskQueue = taskQueue + run.Status.Phase = "Running" + r.setReady(run, metav1.ConditionTrue, temporalv1alpha1.ReasonWorkflowRunning, "workflow started") + } + + // Refresh observed state. + info, err := wc.Describe(ctx, run.Spec.Namespace, wfID, run.Status.RunID) + if err != nil { + return ctrl.Result{}, fmt.Errorf("describing workflow: %w", err) + } + run.Status.Phase = temporal.PhaseFromStatus(info.Status) + run.Status.HistoryLength = info.HistoryLength + if info.StartTime != nil { + t := metav1.NewTime(*info.StartTime) + run.Status.StartTime = &t + } + if info.CloseTime != nil { + t := metav1.NewTime(*info.CloseTime) + run.Status.CloseTime = &t + } + + if !temporal.IsTerminalStatus(info.Status) { + r.setReady(run, metav1.ConditionTrue, temporalv1alpha1.ReasonWorkflowRunning, "workflow is running") + return ctrl.Result{RequeueAfter: workflowRunPollInterval}, r.statusUpdate(ctx, run) + } + + // Terminal: record completion, failure, and Finished condition once. + if run.Status.CompletionTime == nil { + now := metav1.Now() + run.Status.CompletionTime = &now + } + if info.Failure != nil { + run.Status.Failure = &temporalv1alpha1.WorkflowRunFailure{Message: info.Failure.Message, Type: info.Failure.Type} + } + meta.SetStatusCondition(&run.Status.Conditions, metav1.Condition{ + Type: "Finished", Status: metav1.ConditionTrue, + Reason: temporalv1alpha1.ReasonWorkflowFinished, Message: "workflow reached a terminal state: " + run.Status.Phase, + ObservedGeneration: run.Generation, + }) + r.setReady(run, metav1.ConditionTrue, temporalv1alpha1.ReasonWorkflowFinished, "workflow finished: "+run.Status.Phase) + if err := r.statusUpdate(ctx, run); err != nil { + return ctrl.Result{}, err + } + + // TTL cleanup. + if run.Spec.TTLSecondsAfterFinished != nil { + deadline := run.Status.CompletionTime.Add(time.Duration(*run.Spec.TTLSecondsAfterFinished) * time.Second) + if remaining := time.Until(deadline); remaining > 0 { + return ctrl.Result{RequeueAfter: remaining}, nil + } + log.Info("deleting workflow run after TTL", "name", run.Name) + return ctrl.Result{}, client.IgnoreNotFound(r.Delete(ctx, run)) + } + return ctrl.Result{}, nil +} + +func (r *TemporalWorkflowRunReconciler) reconcileDelete(ctx context.Context, run *temporalv1alpha1.TemporalWorkflowRun, wc temporal.WorkflowRunClient) error { + log := logf.FromContext(ctx) + if !controllerutil.ContainsFinalizer(run, workflowRunFinalizer) { + return nil + } + // Apply the cancellation policy only if the workflow is still running. + if run.Status.RunID != "" && run.Status.CompletionTime == nil { + wfID := resolveWorkflowID(run) + switch run.Spec.CancellationPolicy { + case "Cancel": + if err := wc.Cancel(ctx, run.Spec.Namespace, wfID, run.Status.RunID, "TemporalWorkflowRun deleted"); err != nil && !errors.Is(err, temporal.ErrWorkflowNotFound) { + return fmt.Errorf("cancelling workflow: %w", err) + } + log.Info("requested workflow cancellation on delete", "workflowID", wfID) + case "Terminate": + if err := wc.Terminate(ctx, run.Spec.Namespace, wfID, run.Status.RunID, "TemporalWorkflowRun deleted"); err != nil && !errors.Is(err, temporal.ErrWorkflowNotFound) { + return fmt.Errorf("terminating workflow: %w", err) + } + log.Info("terminated workflow on delete", "workflowID", wfID) + } + } + return r.removeFinalizer(ctx, run) +} + +func (r *TemporalWorkflowRunReconciler) removeFinalizer(ctx context.Context, run *temporalv1alpha1.TemporalWorkflowRun) error { + if controllerutil.ContainsFinalizer(run, workflowRunFinalizer) { + controllerutil.RemoveFinalizer(run, workflowRunFinalizer) + if err := r.Update(ctx, run); err != nil { + return err + } + } + return nil +} + +func (r *TemporalWorkflowRunReconciler) clientFactory() temporal.WorkflowRunClientFactory { + if r.ClientFactory != nil { + return r.ClientFactory + } + return temporal.NewWorkflowRunClient +} + +func (r *TemporalWorkflowRunReconciler) setReady(run *temporalv1alpha1.TemporalWorkflowRun, status metav1.ConditionStatus, reason, message string) { + run.Status.ObservedGeneration = run.Generation + meta.SetStatusCondition(&run.Status.Conditions, metav1.Condition{ + Type: temporalv1alpha1.ConditionReady, Status: status, + Reason: reason, Message: message, ObservedGeneration: run.Generation, + }) +} + +func (r *TemporalWorkflowRunReconciler) statusUpdate(ctx context.Context, run *temporalv1alpha1.TemporalWorkflowRun) error { + return client.IgnoreNotFound(r.Status().Update(ctx, run)) +} + +// SetupWithManager sets up the controller with the Manager. +func (r *TemporalWorkflowRunReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&temporalv1alpha1.TemporalWorkflowRun{}). + Named("temporalworkflowrun"). + Complete(r) +} + +func resolveWorkflowID(run *temporalv1alpha1.TemporalWorkflowRun) string { + if run.Spec.Workflow.WorkflowID != "" { + return run.Spec.Workflow.WorkflowID + } + return run.Name +} + +// checkWorkflowRunPolicy enforces the target's effective WorkflowRunPolicy. +func checkWorkflowRunPolicy(p temporalv1alpha1.WorkflowRunPolicy, namespace, taskQueue string) error { + if !p.Enabled { + return fmt.Errorf("workflow runs are not enabled on the referenced Temporal target") + } + if len(p.AllowedNamespaces) > 0 && !contains(p.AllowedNamespaces, namespace) { + return fmt.Errorf("Temporal namespace %q is not in the target's allowedNamespaces", namespace) + } + if len(p.AllowedTaskQueues) > 0 && !contains(p.AllowedTaskQueues, taskQueue) { + return fmt.Errorf("task queue %q is not in the target's allowedTaskQueues", taskQueue) + } + return nil +} + +func contains(haystack []string, needle string) bool { + for _, s := range haystack { + if s == needle { + return true + } + } + return false +} + +// workflowRunParams maps a TemporalWorkflowRun's workflow spec to StartWorkflowParams. +func workflowRunParams(run *temporalv1alpha1.TemporalWorkflowRun) (temporal.StartWorkflowParams, error) { + a := run.Spec.Workflow + params := temporal.StartWorkflowParams{ + WorkflowType: a.WorkflowType, + TaskQueue: a.TaskQueue, + WorkflowID: resolveWorkflowID(run), + Args: rawList(a.Args), + ExecutionTimeout: durPtr(a.WorkflowExecutionTimeout), + RunTimeout: durPtr(a.WorkflowRunTimeout), + TaskTimeout: durPtr(a.WorkflowTaskTimeout), + IDReusePolicy: a.WorkflowIDReusePolicy, + Memo: rawMap(a.Memo), + SearchAttributes: rawMap(a.SearchAttributes), + } + if a.RetryPolicy != nil { + backoff := 2.0 + if a.RetryPolicy.BackoffCoefficient != "" { + f, err := strconv.ParseFloat(a.RetryPolicy.BackoffCoefficient, 64) + if err != nil { + return temporal.StartWorkflowParams{}, fmt.Errorf("invalid retryPolicy.backoffCoefficient: %w", err) + } + backoff = f + } + params.Retry = &temporal.RetryParams{ + InitialInterval: durPtr(a.RetryPolicy.InitialInterval), + BackoffCoefficient: backoff, + MaximumInterval: durPtr(a.RetryPolicy.MaximumInterval), + MaximumAttempts: a.RetryPolicy.MaximumAttempts, + NonRetryableErrorTypes: a.RetryPolicy.NonRetryableErrorTypes, + } + } + return params, nil +} diff --git a/internal/controller/temporalworkflowrun_controller_test.go b/internal/controller/temporalworkflowrun_controller_test.go new file mode 100644 index 00000000..adb0bae0 --- /dev/null +++ b/internal/controller/temporalworkflowrun_controller_test.go @@ -0,0 +1,186 @@ +/* +Copyright 2026 Brian Morton. + +Licensed 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 controller + +import ( + "context" + "crypto/tls" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + enumspb "go.temporal.io/api/enums/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + temporalv1alpha1 "github.com/bmorton/temporal-operator/api/v1alpha1" + "github.com/bmorton/temporal-operator/internal/temporal" +) + +// fakeWorkflowRunClient is an in-memory WorkflowRunClient. status drives Describe. +type fakeWorkflowRunClient struct { + started []string + canceled, terminated []string + status enumspb.WorkflowExecutionStatus + failure *temporal.WorkflowFailure +} + +func (f *fakeWorkflowRunClient) Start(_ context.Context, _, _ string, p temporal.StartWorkflowParams) (string, error) { + f.started = append(f.started, p.WorkflowID) + if f.status == enumspb.WORKFLOW_EXECUTION_STATUS_UNSPECIFIED { + f.status = enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING + } + return "run-" + p.WorkflowID, nil +} + +func (f *fakeWorkflowRunClient) Describe(_ context.Context, _, _, _ string) (*temporal.WorkflowExecutionInfo, error) { + return &temporal.WorkflowExecutionInfo{Status: f.status, RunID: "run", Failure: f.failure}, nil +} + +func (f *fakeWorkflowRunClient) Cancel(_ context.Context, _, wfID, _, _ string) error { + f.canceled = append(f.canceled, wfID) + return nil +} + +func (f *fakeWorkflowRunClient) Terminate(_ context.Context, _, wfID, _, _ string) error { + f.terminated = append(f.terminated, wfID) + return nil +} + +func (f *fakeWorkflowRunClient) Close() error { return nil } + +var _ = Describe("TemporalWorkflowRun reconciler", func() { + const testNamespace = "default" + ctx := context.Background() + var counter int + var fake *fakeWorkflowRunClient + + var factory temporal.WorkflowRunClientFactory = func(_ context.Context, _ string, _ *tls.Config) (temporal.WorkflowRunClient, error) { + return fake, nil + } + reconciler := func() *TemporalWorkflowRunReconciler { + return &TemporalWorkflowRunReconciler{Client: k8sClient, Scheme: k8sClient.Scheme(), ClientFactory: factory} + } + + newReadyCluster := func(name string, policy *temporalv1alpha1.WorkflowRunPolicy) *temporalv1alpha1.TemporalCluster { + c := &temporalv1alpha1.TemporalCluster{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: testNamespace}, + Spec: validClusterSpec("1.31.1"), + } + c.Spec.WorkflowRunPolicy = policy + Expect(k8sClient.Create(ctx, c)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, c) }) + meta.SetStatusCondition(&c.Status.Conditions, metav1.Condition{ + Type: temporalv1alpha1.ConditionReady, Status: metav1.ConditionTrue, Reason: "Ready", Message: "ready", + }) + Expect(k8sClient.Status().Update(ctx, c)).To(Succeed()) + return c + } + + newRun := func(name, cluster string) *temporalv1alpha1.TemporalWorkflowRun { + return &temporalv1alpha1.TemporalWorkflowRun{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: testNamespace}, + Spec: temporalv1alpha1.TemporalWorkflowRunSpec{ + ClusterRef: temporalv1alpha1.ClusterReference{Name: cluster}, + Namespace: "orders", + Workflow: temporalv1alpha1.StartWorkflowAction{WorkflowType: "Greet", TaskQueue: "tq"}, + }, + } + } + + BeforeEach(func() { + counter++ + fake = &fakeWorkflowRunClient{} + }) + + It("starts the workflow and records Running status", func() { + c := newReadyCluster(fmt.Sprintf("cluster-%d", counter), &temporalv1alpha1.WorkflowRunPolicy{Enabled: true}) + run := newRun(fmt.Sprintf("run-%d", counter), c.Name) + Expect(k8sClient.Create(ctx, run)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, run) }) + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: run.Name, Namespace: testNamespace}} + _, err := reconciler().Reconcile(ctx, req) + Expect(err).NotTo(HaveOccurred()) + _, err = reconciler().Reconcile(ctx, req) + Expect(err).NotTo(HaveOccurred()) + + Expect(fake.started).To(ContainElement(run.Name)) + var got temporalv1alpha1.TemporalWorkflowRun + Expect(k8sClient.Get(ctx, req.NamespacedName, &got)).To(Succeed()) + Expect(got.Status.Phase).To(Equal("Running")) + Expect(got.Status.RunID).NotTo(BeEmpty()) + Expect(meta.IsStatusConditionTrue(got.Status.Conditions, temporalv1alpha1.ConditionReady)).To(BeTrue()) + }) + + It("denies the run when policy is disabled and starts nothing", func() { + c := newReadyCluster(fmt.Sprintf("cluster-%d", counter), &temporalv1alpha1.WorkflowRunPolicy{Enabled: false}) + run := newRun(fmt.Sprintf("run-%d", counter), c.Name) + Expect(k8sClient.Create(ctx, run)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, run) }) + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: run.Name, Namespace: testNamespace}} + _, err := reconciler().Reconcile(ctx, req) + Expect(err).NotTo(HaveOccurred()) + + Expect(fake.started).To(BeEmpty()) + var got temporalv1alpha1.TemporalWorkflowRun + Expect(k8sClient.Get(ctx, req.NamespacedName, &got)).To(Succeed()) + Expect(got.Status.RunID).To(BeEmpty()) + cond := meta.FindStatusCondition(got.Status.Conditions, temporalv1alpha1.ConditionReady) + Expect(cond).NotTo(BeNil()) + Expect(cond.Reason).To(Equal(temporalv1alpha1.ReasonWorkflowRunNotPermitted)) + }) + + It("captures failure and sets Finished when the workflow fails", func() { + c := newReadyCluster(fmt.Sprintf("cluster-%d", counter), &temporalv1alpha1.WorkflowRunPolicy{Enabled: true}) + fake.status = enumspb.WORKFLOW_EXECUTION_STATUS_FAILED + fake.failure = &temporal.WorkflowFailure{Message: "boom", Type: "MyError"} + run := newRun(fmt.Sprintf("run-%d", counter), c.Name) + Expect(k8sClient.Create(ctx, run)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, run) }) + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: run.Name, Namespace: testNamespace}} + _, err := reconciler().Reconcile(ctx, req) + Expect(err).NotTo(HaveOccurred()) + + var got temporalv1alpha1.TemporalWorkflowRun + Expect(k8sClient.Get(ctx, req.NamespacedName, &got)).To(Succeed()) + Expect(got.Status.Phase).To(Equal("Failed")) + Expect(got.Status.Failure).NotTo(BeNil()) + Expect(got.Status.Failure.Message).To(Equal("boom")) + Expect(meta.IsStatusConditionTrue(got.Status.Conditions, "Finished")).To(BeTrue()) + }) + + It("terminates a running workflow on delete with cancellationPolicy=Terminate", func() { + c := newReadyCluster(fmt.Sprintf("cluster-%d", counter), &temporalv1alpha1.WorkflowRunPolicy{Enabled: true}) + run := newRun(fmt.Sprintf("run-%d", counter), c.Name) + run.Spec.CancellationPolicy = "Terminate" + Expect(k8sClient.Create(ctx, run)).To(Succeed()) + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: run.Name, Namespace: testNamespace}} + _, err := reconciler().Reconcile(ctx, req) // adds finalizer + starts + Expect(err).NotTo(HaveOccurred()) + + Expect(k8sClient.Delete(ctx, run)).To(Succeed()) + _, err = reconciler().Reconcile(ctx, req) // handles deletion + Expect(err).NotTo(HaveOccurred()) + Expect(fake.terminated).NotTo(BeEmpty()) + }) +}) From 105d32266bc5af1b3082e0aee9b034aefca5ebc6 Mon Sep 17 00:00:00 2001 From: Brian Morton Date: Thu, 25 Jun 2026 07:44:13 +0000 Subject: [PATCH 07/12] feat(webhook): validate TemporalWorkflowRun (required fields, immutability) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Brian Morton --- .../v1alpha1/temporalworkflowrun_webhook.go | 94 +++++++++++++++++++ .../temporalworkflowrun_webhook_test.go | 78 +++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 internal/webhook/v1alpha1/temporalworkflowrun_webhook.go create mode 100644 internal/webhook/v1alpha1/temporalworkflowrun_webhook_test.go diff --git a/internal/webhook/v1alpha1/temporalworkflowrun_webhook.go b/internal/webhook/v1alpha1/temporalworkflowrun_webhook.go new file mode 100644 index 00000000..8c195a77 --- /dev/null +++ b/internal/webhook/v1alpha1/temporalworkflowrun_webhook.go @@ -0,0 +1,94 @@ +/* +Copyright 2026 Brian Morton. + +Licensed 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 v1alpha1 + +import ( + "context" + "fmt" + + apiequality "k8s.io/apimachinery/pkg/api/equality" + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + temporalv1alpha1 "github.com/bmorton/temporal-operator/api/v1alpha1" +) + +var temporalworkflowrunlog = logf.Log.WithName("temporalworkflowrun-resource") + +// SetupTemporalWorkflowRunWebhookWithManager registers the webhook. +func SetupTemporalWorkflowRunWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr, &temporalv1alpha1.TemporalWorkflowRun{}). + WithValidator(&TemporalWorkflowRunCustomValidator{}). + Complete() +} + +// +kubebuilder:webhook:path=/validate-temporal-bmor10-com-v1alpha1-temporalworkflowrun,mutating=false,failurePolicy=fail,sideEffects=None,groups=temporal.bmor10.com,resources=temporalworkflowruns,verbs=create;update,versions=v1alpha1,name=vtemporalworkflowrun-v1alpha1.kb.io,admissionReviewVersions=v1 + +// TemporalWorkflowRunCustomValidator validates TemporalWorkflowRun resources. +type TemporalWorkflowRunCustomValidator struct{} + +var _ admission.Validator[*temporalv1alpha1.TemporalWorkflowRun] = &TemporalWorkflowRunCustomValidator{} + +func (v *TemporalWorkflowRunCustomValidator) ValidateCreate(_ context.Context, run *temporalv1alpha1.TemporalWorkflowRun) (admission.Warnings, error) { + temporalworkflowrunlog.Info("Validation for TemporalWorkflowRun upon creation", "name", run.GetName()) + return nil, validateWorkflowRun(run) +} + +func (v *TemporalWorkflowRunCustomValidator) ValidateUpdate(_ context.Context, oldRun, newRun *temporalv1alpha1.TemporalWorkflowRun) (admission.Warnings, error) { + temporalworkflowrunlog.Info("Validation for TemporalWorkflowRun upon update", "name", newRun.GetName()) + if newRun.Spec.ClusterRef != oldRun.Spec.ClusterRef { + return nil, fmt.Errorf("spec.clusterRef is immutable") + } + if newRun.Spec.Namespace != oldRun.Spec.Namespace { + return nil, fmt.Errorf("spec.namespace is immutable (was %q)", oldRun.Spec.Namespace) + } + if !apiequality.Semantic.DeepEqual(oldRun.Spec.Workflow, newRun.Spec.Workflow) { + return nil, fmt.Errorf("spec.workflow is immutable; create a new TemporalWorkflowRun to run again") + } + return nil, validateWorkflowRun(newRun) +} + +func (v *TemporalWorkflowRunCustomValidator) ValidateDelete(_ context.Context, _ *temporalv1alpha1.TemporalWorkflowRun) (admission.Warnings, error) { + return nil, nil +} + +func validateWorkflowRun(run *temporalv1alpha1.TemporalWorkflowRun) error { + if run.Spec.ClusterRef.Name == "" { + return fmt.Errorf("spec.clusterRef.name must not be empty") + } + if run.Spec.Namespace == "" { + return fmt.Errorf("spec.namespace must not be empty") + } + w := run.Spec.Workflow + if w.WorkflowType == "" { + return fmt.Errorf("spec.workflow.workflowType must not be empty") + } + if w.TaskQueue == "" { + return fmt.Errorf("spec.workflow.taskQueue must not be empty") + } + if _, ok := validReusePolicies[w.WorkflowIDReusePolicy]; !ok { + return fmt.Errorf("spec.workflow.workflowIDReusePolicy %q is not valid", w.WorkflowIDReusePolicy) + } + if err := validateJSONList("spec.workflow.args", w.Args); err != nil { + return err + } + if err := validateJSONMap("spec.workflow.memo", w.Memo); err != nil { + return err + } + return validateJSONMap("spec.workflow.searchAttributes", w.SearchAttributes) +} diff --git a/internal/webhook/v1alpha1/temporalworkflowrun_webhook_test.go b/internal/webhook/v1alpha1/temporalworkflowrun_webhook_test.go new file mode 100644 index 00000000..608a2ac8 --- /dev/null +++ b/internal/webhook/v1alpha1/temporalworkflowrun_webhook_test.go @@ -0,0 +1,78 @@ +/* +Copyright 2026 Brian Morton. + +Licensed 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 v1alpha1 + +import ( + "context" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + temporalv1alpha1 "github.com/bmorton/temporal-operator/api/v1alpha1" +) + +func validRun() *temporalv1alpha1.TemporalWorkflowRun { + return &temporalv1alpha1.TemporalWorkflowRun{ + ObjectMeta: metav1.ObjectMeta{Name: "r"}, + Spec: temporalv1alpha1.TemporalWorkflowRunSpec{ + ClusterRef: temporalv1alpha1.ClusterReference{Name: "c"}, + Namespace: "orders", + Workflow: temporalv1alpha1.StartWorkflowAction{WorkflowType: "Greet", TaskQueue: "tq"}, + }, + } +} + +func TestValidateWorkflowRunCreate(t *testing.T) { + v := &TemporalWorkflowRunCustomValidator{} + if _, err := v.ValidateCreate(context.Background(), validRun()); err != nil { + t.Fatalf("expected valid, got %v", err) + } + bad := validRun() + bad.Spec.Workflow.TaskQueue = "" + if _, err := v.ValidateCreate(context.Background(), bad); err == nil { + t.Fatal("expected error for empty taskQueue") + } +} + +func TestValidateWorkflowRunImmutability(t *testing.T) { + v := &TemporalWorkflowRunCustomValidator{} + old := validRun() + newRun := validRun() + newRun.Spec.Workflow.WorkflowType = "Other" + if _, err := v.ValidateUpdate(context.Background(), old, newRun); err == nil { + t.Fatal("expected error mutating spec.workflow") + } + + // Mutable fields are allowed. + ttl := int32(60) + mutable := validRun() + mutable.Spec.TTLSecondsAfterFinished = &ttl + mutable.Spec.CancellationPolicy = "Terminate" + if _, err := v.ValidateUpdate(context.Background(), old, mutable); err != nil { + t.Fatalf("expected mutable update to pass, got %v", err) + } +} + +func TestValidateWorkflowRunInvalidJSON(t *testing.T) { + v := &TemporalWorkflowRunCustomValidator{} + bad := validRun() + bad.Spec.Workflow.Args = []runtime.RawExtension{{Raw: []byte("{not json")}} + if _, err := v.ValidateCreate(context.Background(), bad); err == nil { + t.Fatal("expected error for invalid JSON args") + } +} From a8ba8ce978a566f4e2d9706535b71d6f4c507cd2 Mon Sep 17 00:00:00 2001 From: Brian Morton Date: Thu, 25 Jun 2026 07:52:20 +0000 Subject: [PATCH 08/12] feat: wire TemporalWorkflowRun controller, webhook, and generated manifests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Brian Morton --- PROJECT | 11 + cmd/main.go | 13 + config/crd/kustomization.yaml | 1 + config/manager/kustomization.yaml | 2 +- config/rbac/role.yaml | 3 + ...temporal_v1alpha1_temporalworkflowrun.yaml | 26 + config/webhook/manifests.yaml | 20 + .../temporalclusters.temporal.bmor10.com.yaml | 23 + ...emporaldevservers.temporal.bmor10.com.yaml | 23 + ...poralworkflowruns.temporal.bmor10.com.yaml | 270 ++ dist/chart/templates/rbac/manager-role.yaml | 3 + .../validating-webhook-configuration.yaml | 20 + dist/install.yaml | 2355 ++++++++++++++++- docs/api/v1alpha1.md | 86 + docs/content/reference/_index.md | 86 + 15 files changed, 2804 insertions(+), 138 deletions(-) create mode 100644 config/samples/temporal_v1alpha1_temporalworkflowrun.yaml create mode 100644 dist/chart/templates/crd/temporalworkflowruns.temporal.bmor10.com.yaml diff --git a/PROJECT b/PROJECT index 699522b6..a23c636e 100644 --- a/PROJECT +++ b/PROJECT @@ -56,4 +56,15 @@ resources: kind: TemporalClusterClient path: github.com/bmorton/temporal-operator/api/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + domain: bmor10.com + group: temporal + kind: TemporalWorkflowRun + path: github.com/bmorton/temporal-operator/api/v1alpha1 + version: v1alpha1 + webhooks: + validation: true + webhookVersion: v1 version: "3" diff --git a/cmd/main.go b/cmd/main.go index fdd3975c..69992494 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -249,6 +249,13 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "TemporalClusterConnection") os.Exit(1) } + if err := (&controller.TemporalWorkflowRunReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "TemporalWorkflowRun") + os.Exit(1) + } webhooksEnabled := os.Getenv("ENABLE_WEBHOOKS") != "false" if webhooksEnabled { if err := webhookv1alpha1.SetupTemporalClusterWebhookWithManager(mgr); err != nil { @@ -280,6 +287,12 @@ func main() { os.Exit(1) } } + if webhooksEnabled { + if err := webhookv1alpha1.SetupTemporalWorkflowRunWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "TemporalWorkflowRun") + os.Exit(1) + } + } // +kubebuilder:scaffold:builder uiOpts := ui.Options{ diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index c72889f5..30799492 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -9,6 +9,7 @@ resources: - bases/temporal.bmor10.com_temporalschedules.yaml - bases/temporal.bmor10.com_temporaldevservers.yaml - bases/temporal.bmor10.com_temporalclusterconnections.yaml +- bases/temporal.bmor10.com_temporalworkflowruns.yaml # +kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/manager/kustomization.yaml b/config/manager/kustomization.yaml index ab46c4e4..ad13e96b 100644 --- a/config/manager/kustomization.yaml +++ b/config/manager/kustomization.yaml @@ -4,5 +4,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization images: - name: controller - newName: ghcr.io/bmorton/temporal-operator + newName: controller newTag: latest diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 065895a5..74c7ebe9 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -126,6 +126,7 @@ rules: - temporalnamespaces - temporalschedules - temporalsearchattributes + - temporalworkflowruns verbs: - create - delete @@ -144,6 +145,7 @@ rules: - temporalnamespaces/finalizers - temporalschedules/finalizers - temporalsearchattributes/finalizers + - temporalworkflowruns/finalizers verbs: - update - apiGroups: @@ -156,6 +158,7 @@ rules: - temporalnamespaces/status - temporalschedules/status - temporalsearchattributes/status + - temporalworkflowruns/status verbs: - get - patch diff --git a/config/samples/temporal_v1alpha1_temporalworkflowrun.yaml b/config/samples/temporal_v1alpha1_temporalworkflowrun.yaml new file mode 100644 index 00000000..411179fa --- /dev/null +++ b/config/samples/temporal_v1alpha1_temporalworkflowrun.yaml @@ -0,0 +1,26 @@ +apiVersion: temporal.bmor10.com/v1alpha1 +kind: TemporalWorkflowRun +metadata: + name: greet-once +spec: + clusterRef: + name: temporal-sample + namespace: default + ttlSecondsAfterFinished: 300 + cancellationPolicy: Abandon + workflow: + workflowType: GreetingWorkflow + taskQueue: greeting-tq + args: + - '"world"' +--- +# The referenced TemporalCluster must opt in to operator-initiated runs: +# apiVersion: temporal.bmor10.com/v1alpha1 +# kind: TemporalCluster +# metadata: +# name: temporal-sample +# spec: +# workflowRunPolicy: +# enabled: true +# allowedNamespaces: ["default"] +# allowedTaskQueues: ["greeting-tq"] diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index 9b1703cb..087aebf6 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -131,3 +131,23 @@ webhooks: resources: - temporalsearchattributes sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-temporal-bmor10-com-v1alpha1-temporalworkflowrun + failurePolicy: Fail + name: vtemporalworkflowrun-v1alpha1.kb.io + rules: + - apiGroups: + - temporal.bmor10.com + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - temporalworkflowruns + sideEffects: None diff --git a/dist/chart/templates/crd/temporalclusters.temporal.bmor10.com.yaml b/dist/chart/templates/crd/temporalclusters.temporal.bmor10.com.yaml index af1ca4d5..7c0c8b56 100644 --- a/dist/chart/templates/crd/temporalclusters.temporal.bmor10.com.yaml +++ b/dist/chart/templates/crd/temporalclusters.temporal.bmor10.com.yaml @@ -5902,6 +5902,29 @@ spec: description: Version is the Temporal server version, e.g. "1.31.1". pattern: ^\d+\.\d+\.\d+$ type: string + workflowRunPolicy: + description: |- + WorkflowRunPolicy gates operator-initiated TemporalWorkflowRun executions + against this cluster. Absent means disabled (closed by default). + properties: + allowedNamespaces: + description: |- + AllowedNamespaces optionally restricts which Temporal namespaces runs may + target. Empty means any namespace is allowed. + items: + type: string + type: array + allowedTaskQueues: + description: |- + AllowedTaskQueues optionally restricts which task queues runs may use. + Empty means any task queue is allowed. + items: + type: string + type: array + enabled: + description: Enabled permits operator-initiated workflow runs against this target. + type: boolean + type: object required: - numHistoryShards - persistence diff --git a/dist/chart/templates/crd/temporaldevservers.temporal.bmor10.com.yaml b/dist/chart/templates/crd/temporaldevservers.temporal.bmor10.com.yaml index 46dfcd54..a0233479 100644 --- a/dist/chart/templates/crd/temporaldevservers.temporal.bmor10.com.yaml +++ b/dist/chart/templates/crd/temporaldevservers.temporal.bmor10.com.yaml @@ -1117,6 +1117,29 @@ spec: version matrix. When empty, the latest supported server version is used. Use Image to pin a specific CLI image directly. type: string + workflowRunPolicy: + description: |- + WorkflowRunPolicy gates operator-initiated TemporalWorkflowRun executions + against this dev server. Absent means enabled with no allowlist. + properties: + allowedNamespaces: + description: |- + AllowedNamespaces optionally restricts which Temporal namespaces runs may + target. Empty means any namespace is allowed. + items: + type: string + type: array + allowedTaskQueues: + description: |- + AllowedTaskQueues optionally restricts which task queues runs may use. + Empty means any task queue is allowed. + items: + type: string + type: array + enabled: + description: Enabled permits operator-initiated workflow runs against this target. + type: boolean + type: object type: object status: description: status defines the observed state of TemporalDevServer diff --git a/dist/chart/templates/crd/temporalworkflowruns.temporal.bmor10.com.yaml b/dist/chart/templates/crd/temporalworkflowruns.temporal.bmor10.com.yaml new file mode 100644 index 00000000..09e56d22 --- /dev/null +++ b/dist/chart/templates/crd/temporalworkflowruns.temporal.bmor10.com.yaml @@ -0,0 +1,270 @@ +{{- if .Values.crd.enable }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: temporalworkflowruns.temporal.bmor10.com +spec: + group: temporal.bmor10.com + names: + kind: TemporalWorkflowRun + listKind: TemporalWorkflowRunList + plural: temporalworkflowruns + shortNames: + - twr + singular: temporalworkflowrun + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.clusterRef.name + name: Cluster + type: string + - jsonPath: .spec.namespace + name: Namespace + type: string + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: TemporalWorkflowRun is the Schema for the temporalworkflowruns API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: TemporalWorkflowRunSpec defines the desired state of a one-off workflow run. + properties: + cancellationPolicy: + default: Abandon + description: |- + CancellationPolicy controls what happens to a still-running workflow when + this CR is deleted. + enum: + - Abandon + - Cancel + - Terminate + type: string + clusterRef: + description: |- + ClusterRef references the TemporalCluster or TemporalDevServer that runs + the workflow. Resolved in the same Kubernetes namespace as this CR. + properties: + kind: + default: TemporalCluster + description: Kind selects the referenced object type. + enum: + - TemporalCluster + - TemporalDevServer + type: string + name: + description: Name is the name of the referenced object. + type: string + required: + - name + type: object + namespace: + description: Namespace is the Temporal namespace to start the workflow in. + type: string + ttlSecondsAfterFinished: + description: |- + TTLSecondsAfterFinished, when set, deletes this CR that many seconds after + the workflow reaches a terminal state. Unset keeps the CR indefinitely. + format: int32 + type: integer + workflow: + description: Workflow describes the one-off workflow to start. Immutable after create. + properties: + args: + description: Args are JSON-serializable workflow inputs (one json/plain payload each). + items: + type: object + x-kubernetes-preserve-unknown-fields: true + type: array + memo: + additionalProperties: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + retryPolicy: + description: RetryPolicySpec is the retry policy for the started workflow. + properties: + backoffCoefficient: + description: BackoffCoefficient is a decimal string (e.g. "2.0") parsed to float64. + type: string + initialInterval: + type: string + maximumAttempts: + format: int32 + type: integer + maximumInterval: + type: string + nonRetryableErrorTypes: + items: + type: string + type: array + type: object + searchAttributes: + additionalProperties: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + taskQueue: + type: string + workflowExecutionTimeout: + type: string + workflowID: + type: string + workflowIDReusePolicy: + enum: + - AllowDuplicate + - AllowDuplicateFailedOnly + - RejectDuplicate + - TerminateIfRunning + type: string + workflowRunTimeout: + type: string + workflowTaskTimeout: + type: string + workflowType: + type: string + required: + - taskQueue + - workflowType + type: object + required: + - clusterRef + - namespace + - workflow + type: object + status: + description: TemporalWorkflowRunStatus is the observed state of a workflow run. + properties: + closeTime: + format: date-time + type: string + completionTime: + description: |- + CompletionTime is when the operator first observed a terminal state; it + drives the TTL countdown. + format: date-time + type: string + conditions: + items: + description: Condition contains details for one aspect of the current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + failure: + description: Failure carries the failure message/type for non-success terminal states. + properties: + message: + type: string + type: + type: string + type: object + historyLength: + format: int64 + type: integer + observedGeneration: + format: int64 + type: integer + phase: + description: |- + Phase is a friendly lifecycle summary: Pending, Running, Completed, + Failed, Terminated, Canceled, TimedOut, or ContinuedAsNew. + type: string + runID: + type: string + startTime: + format: date-time + type: string + taskQueue: + type: string + workflowID: + type: string + workflowType: + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/dist/chart/templates/rbac/manager-role.yaml b/dist/chart/templates/rbac/manager-role.yaml index a058eff2..64a0c96e 100644 --- a/dist/chart/templates/rbac/manager-role.yaml +++ b/dist/chart/templates/rbac/manager-role.yaml @@ -125,6 +125,7 @@ rules: - temporalnamespaces - temporalschedules - temporalsearchattributes + - temporalworkflowruns verbs: - create - delete @@ -143,6 +144,7 @@ rules: - temporalnamespaces/finalizers - temporalschedules/finalizers - temporalsearchattributes/finalizers + - temporalworkflowruns/finalizers verbs: - update - apiGroups: @@ -155,6 +157,7 @@ rules: - temporalnamespaces/status - temporalschedules/status - temporalsearchattributes/status + - temporalworkflowruns/status verbs: - get - patch diff --git a/dist/chart/templates/webhook/validating-webhook-configuration.yaml b/dist/chart/templates/webhook/validating-webhook-configuration.yaml index a4555ab8..31441e62 100644 --- a/dist/chart/templates/webhook/validating-webhook-configuration.yaml +++ b/dist/chart/templates/webhook/validating-webhook-configuration.yaml @@ -108,3 +108,23 @@ webhooks: resources: - temporalsearchattributes sideEffects: None + - admissionReviewVersions: + - v1 + clientConfig: + service: + name: temporal-operator-webhook-service + namespace: {{ .Release.Namespace }} + path: /validate-temporal-bmor10-com-v1alpha1-temporalworkflowrun + failurePolicy: Fail + name: vtemporalworkflowrun-v1alpha1.kb.io + rules: + - apiGroups: + - temporal.bmor10.com + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - temporalworkflowruns + sideEffects: None diff --git a/dist/install.yaml b/dist/install.yaml index 3f051669..dd193529 100644 --- a/dist/install.yaml +++ b/dist/install.yaml @@ -64,20 +64,23 @@ spec: description: spec defines the desired state of TemporalClusterClient properties: clusterRef: - description: ClusterRef references the TemporalCluster to generate - client credentials for. + description: |- + ClusterRef references the cluster to generate client credentials for. + Client credentials are only available for mTLS-enabled TemporalClusters. properties: + kind: + default: TemporalCluster + description: Kind selects the referenced object type. + enum: + - TemporalCluster + - TemporalDevServer + type: string name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + description: Name is the name of the referenced object. type: string + required: + - name type: object - x-kubernetes-map-type: atomic secretName: description: |- SecretName is the name of the Secret to write generated client credentials into. @@ -176,6 +179,226 @@ spec: --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: temporalclusterconnections.temporal.bmor10.com +spec: + group: temporal.bmor10.com + names: + kind: TemporalClusterConnection + listKind: TemporalClusterConnectionList + plural: temporalclusterconnections + shortNames: + - tcconn + singular: temporalclusterconnection + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: TemporalClusterConnection is the Schema for the temporalclusterconnections + API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + TemporalClusterConnectionSpec defines a multi-cluster replication group and + drives automatic remote-cluster connection registration between its peers. + properties: + peers: + description: |- + Peers participating in replication. At least two are required. Each peer's + Name must equal that cluster's clusterMetadata.currentClusterName. + items: + description: |- + ClusterConnectionPeer identifies one cluster in a replication group. Exactly + one of ClusterRef or FrontendAddress must be set. + properties: + clusterRef: + description: |- + ClusterRef points at a local TemporalCluster CR. The operator resolves its + frontend address and reuses its CA automatically. + properties: + kind: + default: TemporalCluster + description: Kind selects the referenced object type. + enum: + - TemporalCluster + - TemporalDevServer + type: string + name: + description: Name is the name of the referenced object. + type: string + required: + - name + type: object + enableConnection: + default: true + description: EnableConnection toggles replication traffic without + removing the peer. + type: boolean + frontendAddress: + description: FrontendAddress is an external peer's gRPC frontend + address (host:port). + type: string + name: + description: Name is the replication-group cluster name (== + clusterMetadata.currentClusterName). + type: string + tlsSecretRef: + description: |- + TLSSecretRef supplies mTLS material for an external peer. Ignored for + ClusterRef peers (the cluster CA is reused). + properties: + caKey: + description: CAKey is the Secret key holding the CA bundle. + Defaults to "ca.crt". + type: string + certKey: + description: CertKey is the Secret key holding the client + certificate. Defaults to "tls.crt". + type: string + keyKey: + description: KeyKey is the Secret key holding the client + private key. Defaults to "tls.key". + type: string + name: + description: Name is the Secret name. + type: string + required: + - name + type: object + required: + - name + type: object + minItems: 2 + type: array + required: + - peers + type: object + status: + description: TemporalClusterConnectionStatus defines the observed state. + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + format: int64 + type: integer + peers: + description: Peers reports per-peer connection state. + items: + description: PeerConnectionStatus reports the observed state of + one peer. + properties: + connected: + description: |- + Connected is true when this peer appears as an enabled remote cluster on + the other reachable peers. + type: boolean + message: + type: string + name: + type: string + reachable: + description: Reachable is true when the operator could connect + to this peer's frontend. + type: boolean + required: + - name + type: object + type: array + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.19.0 @@ -243,21 +466,77 @@ spec: description: Authorization configures the authorizer and claim mapper. properties: authorizer: + description: |- + Authorizer selects the Temporal authorizer plugin. If unset, it defaults + to "default" (per-namespace RBAC) when JWT validation is configured. + Set it to "" to select the no-op (allow-all) authorizer for + authenticate-only mode. type: string claimMapper: + description: |- + ClaimMapper is the Temporal claim mapper. Defaults to "default" when JWT + validation is configured. type: string config: - description: Config is a passthrough for authorization provider - configuration. + description: |- + Config is a passthrough merged into the authorization block for any knob + not modeled above. type: object x-kubernetes-preserve-unknown-fields: true + entra: + description: |- + Entra derives the Entra JWKS keySourceURI from a tenant ID and applies + sensible JWT defaults. + properties: + tenantID: + description: |- + TenantID is the Entra (Azure AD) tenant. Derives the JWKS keySourceURI + https://login.microsoftonline.com/{tenantID}/discovery/v2.0/keys. + minLength: 1 + type: string + required: + - tenantID + type: object + jwtKeyProvider: + description: JWTKeyProvider configures JWKS-based token signature + validation. + properties: + keySourceURIs: + description: KeySourceURIs are JWKS endpoints used to validate + token signatures. + items: + type: string + type: array + refreshInterval: + description: RefreshInterval controls how often keys are refreshed, + e.g. "1m". + type: string + type: object + permissionsClaimName: + description: |- + PermissionsClaimName maps to global.authorization.permissionsClaimName. + Defaults to "roles" when Entra is set, otherwise "permissions". + type: string type: object clusterMetadata: - description: ClusterMetadata is a passthrough for multi-cluster setup. + description: ClusterMetadata configures multi-cluster replication. properties: - raw: - type: object - x-kubernetes-preserve-unknown-fields: true + currentClusterName: + minLength: 1 + type: string + enableGlobalNamespace: + type: boolean + failoverVersionIncrement: + format: int32 + minimum: 1 + type: integer + initialFailoverVersion: + format: int32 + minimum: 1 + type: integer + masterClusterName: + minLength: 1 + type: string type: object dynamicConfig: description: DynamicConfig is a passthrough for Temporal's dynamic @@ -419,6 +698,42 @@ spec: description: Persistence configures the default and visibility datastores. Required. properties: + azureWorkloadIdentity: + description: |- + AzureWorkloadIdentity, when set, makes this cluster authenticate to its + SQL datastore(s) passwordlessly using Azure Workload Identity. The operator + generates a ServiceAccount, token sidecar/initContainers, and the + passwordCommand wiring in the cluster's namespace; the operator itself + holds no database credential. SQL stores only. + properties: + clientId: + description: |- + ClientID is the Azure managed-identity / app-registration client ID used + for the ServiceAccount's azure.workload.identity/client-id annotation. + type: string + image: + description: |- + Image overrides the azure-cli image used by the token sidecar / + initContainers (default "mcr.microsoft.com/azure-cli:2.87.0"). + type: string + refreshInterval: + description: |- + RefreshInterval is how often the server-pod sidecar refreshes the token + (default 30m). + type: string + scope: + description: |- + Scope is the Entra token scope requested for the database. Defaults to + "https://ossrdbms-aad.database.windows.net/.default". + type: string + serviceAccountName: + description: |- + ServiceAccountName overrides the generated ServiceAccount name + (default "-azure"). + type: string + required: + - clientId + type: object defaultStore: description: |- DefaultStore holds workflow execution state. Exactly one of sql or @@ -732,6 +1047,34 @@ spec: x-kubernetes-validations: - message: exactly one of sql or cassandra must be set for defaultStore rule: has(self.sql) != has(self.cassandra) + schemaJob: + description: SchemaJob customizes the schema setup/update Jobs + the operator runs. + properties: + podTemplate: + description: |- + PodTemplate overrides metadata and the pod spec of the schema Job pods. + Use it to attach a ServiceAccount, pod labels (e.g. Azure Workload + Identity), and a token initContainer so the Job can authenticate with a + passwordCommand instead of a static password. + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + spec: + description: |- + Spec is a partial PodSpec (strategic-merge patch) merged onto the + generated pod template. It is stored as an opaque object to keep the + CRD schema small. + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + type: object visibilityStore: description: |- VisibilityStore holds visibility records. One of sql, cassandra, or @@ -6151,10 +6494,56 @@ spec: description: UI configures temporal-ui as part of this cluster. properties: auth: - description: Auth is a passthrough for temporal-ui authentication - config. + description: Auth configures temporal-ui authentication (OIDC, + e.g. Microsoft Entra). + properties: + callbackURL: + type: string + clientID: + type: string + clientSecretRef: + description: ClientSecretRef references a Secret key holding + the OIDC client secret. + properties: + key: + default: password + type: string + name: + type: string + required: + - name + type: object + enabled: + default: false + type: boolean + entra: + description: Entra derives ProviderURL from a Microsoft Entra + tenant ID. + properties: + tenantID: + minLength: 1 + type: string + required: + - tenantID + type: object + extraEnv: + description: |- + ExtraEnv is a passthrough of additional temporal-ui auth env vars + (map of string to string). + type: object + x-kubernetes-preserve-unknown-fields: true + providerURL: + description: ProviderURL is the OIDC issuer URL (set directly + or via Entra). + type: string + scopes: + description: Scopes default to ["openid", "profile", "email"]. + items: + type: string + type: array + required: + - enabled type: object - x-kubernetes-preserve-unknown-fields: true codecServer: description: UICodecServerSpec configures the temporal-ui codec server. @@ -6204,19 +6593,1401 @@ spec: description: Version is the Temporal server version, e.g. "1.31.1". pattern: ^\d+\.\d+\.\d+$ type: string - required: - - numHistoryShards - - persistence - - version - type: object + workflowRunPolicy: + description: |- + WorkflowRunPolicy gates operator-initiated TemporalWorkflowRun executions + against this cluster. Absent means disabled (closed by default). + properties: + allowedNamespaces: + description: |- + AllowedNamespaces optionally restricts which Temporal namespaces runs may + target. Empty means any namespace is allowed. + items: + type: string + type: array + allowedTaskQueues: + description: |- + AllowedTaskQueues optionally restricts which task queues runs may use. + Empty means any task queue is allowed. + items: + type: string + type: array + enabled: + description: Enabled permits operator-initiated workflow runs + against this target. + type: boolean + type: object + required: + - numHistoryShards + - persistence + - version + type: object x-kubernetes-validations: - message: numHistoryShards is immutable rule: self.numHistoryShards == oldSelf.numHistoryShards status: - description: status defines the observed state of TemporalCluster + description: status defines the observed state of TemporalCluster + properties: + conditions: + description: Conditions represent the current state of the TemporalCluster + resource. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + endpoints: + description: Endpoints reports the resolved service endpoints. + properties: + frontend: + type: string + metrics: + type: string + ui: + type: string + type: object + numHistoryShards: + description: NumHistoryShards as observed from the database, not the + spec. + format: int32 + type: integer + observedGeneration: + description: ObservedGeneration is the most recent generation observed + by the controller. + format: int64 + type: integer + persistence: + description: Persistence reports datastore reachability and schema + state. + properties: + history: + description: History records schema upgrades applied by the operator. + items: + description: SchemaUpgradeRecord records a single schema migration. + properties: + fromVersion: + type: string + store: + type: string + time: + format: date-time + type: string + toVersion: + type: string + required: + - fromVersion + - store + - time + - toVersion + type: object + type: array + reachable: + description: Reachable indicates whether the datastores were reachable + at last reconcile. + type: boolean + schemaVersions: + additionalProperties: + type: string + description: SchemaVersions maps a store name to its observed + schema version. + type: object + type: object + phase: + description: Phase is a coarse, human-friendly summary of the cluster + lifecycle. + type: string + services: + additionalProperties: + description: ServiceStatus reports the readiness of a single service. + properties: + desired: + format: int32 + type: integer + ready: + format: int32 + type: integer + version: + type: string + type: object + description: Services reports per-service readiness keyed by service + name. + type: object + upgrade: + description: Upgrade reports the state of an in-progress version upgrade, + if any. + properties: + fromVersion: + type: string + phase: + type: string + rollbackable: + description: |- + Rollbackable is true until schema migration begins, after which a + rollback is no longer safe. + type: boolean + startedAt: + format: date-time + type: string + toVersion: + type: string + type: object + version: + description: Version is the currently-running Temporal server version. + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: temporaldevservers.temporal.bmor10.com +spec: + group: temporal.bmor10.com + names: + kind: TemporalDevServer + listKind: TemporalDevServerList + plural: temporaldevservers + shortNames: + - tds + singular: temporaldevserver + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.version + name: Version + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.endpoints.ui + name: UI + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: TemporalDevServer is the Schema for the temporaldevservers API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec defines the desired state of TemporalDevServer + properties: + affinity: + description: Affinity applied to the dev server pod. + properties: + nodeAffinity: + description: Describes node affinity scheduling rules for the + pod. + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node matches the corresponding matchExpressions; the + node(s) with the highest sum are the most preferred. + items: + description: |- + An empty preferred scheduling term matches all objects with implicit weight 0 + (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). + properties: + preference: + description: A node selector term, associated with the + corresponding weight. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + weight: + description: Weight associated with matching the corresponding + nodeSelectorTerm, in the range 1-100. + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to an update), the system + may or may not try to eventually evict the pod from its node. + properties: + nodeSelectorTerms: + description: Required. A list of node selector terms. + The terms are ORed. + items: + description: |- + A null or empty node selector term matches no objects. The requirements of + them are ANDed. + The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the selector + applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + description: Describes pod affinity scheduling rules (e.g. co-locate + this pod in the same node, zone, etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, associated + with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podAntiAffinity: + description: Describes pod anti-affinity scheduling rules (e.g. + avoid putting this pod in the same node, zone, etc. as some + other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the anti-affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling anti-affinity expressions, etc.), + compute a sum by iterating through the elements of this field and subtracting + "weight" from the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, associated + with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the anti-affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the anti-affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + image: + description: |- + Image overrides the full image reference. Default + temporalio/temporal:. + type: string + imagePullSecrets: + description: ImagePullSecrets references secrets for pulling the image. + items: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + type: array + namespaces: + description: |- + Namespaces are extra Temporal namespaces created at startup, in addition + to the always-present "default" namespace. These are created once at boot + with no drift management; use TemporalNamespace CRs for managed namespaces. + items: + type: string + type: array + nodeSelector: + additionalProperties: + type: string + description: NodeSelector constrains the dev server pod to matching + nodes. + type: object + resources: + description: Resources sets the dev server container resource requirements. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This field depends on the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + service: + description: Service configures how the frontend/UI Service is exposed. + properties: + annotations: + additionalProperties: + type: string + type: object + type: + default: ClusterIP + description: Service Type string describes ingress methods for + a service + enum: + - ClusterIP + - NodePort + - LoadBalancer + type: string + type: object + storage: + description: Storage selects ephemeral (default) or PVC-backed SQLite + storage. + properties: + size: + anyOf: + - type: integer + - type: string + description: Size is the PVC size when Type=Persistent. Default + "1Gi". + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + storageClassName: + description: StorageClassName is the PVC storage class when Type=Persistent. + type: string + type: + default: Ephemeral + description: Type selects ephemeral (emptyDir, wiped on restart) + or Persistent (PVC). + enum: + - Ephemeral + - Persistent + type: string + type: object + tolerations: + description: Tolerations applied to the dev server pod. + items: + description: |- + The pod this Toleration is attached to tolerates any taint that matches + the triple using the matching operator . + properties: + effect: + description: |- + Effect indicates the taint effect to match. Empty means match all taint effects. + When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: |- + Key is the taint key that the toleration applies to. Empty means match all taint keys. + If the key is empty, operator must be Exists; this combination means to match all values and all keys. + type: string + operator: + description: |- + Operator represents a key's relationship to the value. + Valid operators are Exists, Equal, Lt, and Gt. Defaults to Equal. + Exists is equivalent to wildcard for value, so that a pod can + tolerate all taints of a particular category. + Lt and Gt perform numeric comparisons (requires feature gate TaintTolerationComparisonOperators). + type: string + tolerationSeconds: + description: |- + TolerationSeconds represents the period of time the toleration (which must be + of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, + it is not set, which means tolerate the taint forever (do not evict). Zero and + negative values will be treated as 0 (evict immediately) by the system. + format: int64 + type: integer + value: + description: |- + Value is the taint value the toleration matches to. + If the operator is Exists, the value should be empty, otherwise just a regular string. + type: string + type: object + type: array + ui: + description: UI controls the bundled Temporal Web UI (port 8233). + properties: + enabled: + default: true + description: Enabled toggles the bundled Web UI. Default true. + type: boolean + required: + - enabled + type: object + version: + description: |- + Version is the Temporal server version to run, e.g. "1.31.1". The operator + maps it to the matching temporalio/temporal CLI image via the supported + version matrix. When empty, the latest supported server version is used. + Use Image to pin a specific CLI image directly. + type: string + workflowRunPolicy: + description: |- + WorkflowRunPolicy gates operator-initiated TemporalWorkflowRun executions + against this dev server. Absent means enabled with no allowlist. + properties: + allowedNamespaces: + description: |- + AllowedNamespaces optionally restricts which Temporal namespaces runs may + target. Empty means any namespace is allowed. + items: + type: string + type: array + allowedTaskQueues: + description: |- + AllowedTaskQueues optionally restricts which task queues runs may use. + Empty means any task queue is allowed. + items: + type: string + type: array + enabled: + description: Enabled permits operator-initiated workflow runs + against this target. + type: boolean + type: object + type: object + status: + description: status defines the observed state of TemporalDevServer properties: conditions: - description: Conditions represent the current state of the TemporalCluster + description: Conditions represent the current state of the TemporalDevServer resource. items: description: Condition contains details for one aspect of the current @@ -6280,99 +8051,22 @@ spec: description: Endpoints reports the resolved service endpoints. properties: frontend: - type: string - metrics: + description: Frontend is the gRPC frontend endpoint (host:7233). type: string ui: + description: UI is the Web UI endpoint (host:8233). type: string type: object - numHistoryShards: - description: NumHistoryShards as observed from the database, not the - spec. - format: int32 - type: integer observedGeneration: description: ObservedGeneration is the most recent generation observed by the controller. format: int64 type: integer - persistence: - description: Persistence reports datastore reachability and schema - state. - properties: - history: - description: History records schema upgrades applied by the operator. - items: - description: SchemaUpgradeRecord records a single schema migration. - properties: - fromVersion: - type: string - store: - type: string - time: - format: date-time - type: string - toVersion: - type: string - required: - - fromVersion - - store - - time - - toVersion - type: object - type: array - reachable: - description: Reachable indicates whether the datastores were reachable - at last reconcile. - type: boolean - schemaVersions: - additionalProperties: - type: string - description: SchemaVersions maps a store name to its observed - schema version. - type: object - type: object phase: - description: Phase is a coarse, human-friendly summary of the cluster - lifecycle. + description: Phase is a coarse, human-friendly lifecycle summary. type: string - services: - additionalProperties: - description: ServiceStatus reports the readiness of a single service. - properties: - desired: - format: int32 - type: integer - ready: - format: int32 - type: integer - version: - type: string - type: object - description: Services reports per-service readiness keyed by service - name. - type: object - upgrade: - description: Upgrade reports the state of an in-progress version upgrade, - if any. - properties: - fromVersion: - type: string - phase: - type: string - rollbackable: - description: |- - Rollbackable is true until schema migration begins, after which a - rollback is no longer safe. - type: boolean - startedAt: - format: date-time - type: string - toVersion: - type: string - type: object version: - description: Version is the currently-running Temporal server version. + description: Version is the currently-running image tag. type: string type: object required: @@ -6438,26 +8132,41 @@ spec: spec: description: spec defines the desired state of TemporalNamespace properties: + activeCluster: + description: |- + ActiveCluster is the authoritative cluster for this namespace. Changing it + triggers an operator-executed failover. Only meaningful when IsGlobal. + type: string allowDeletion: description: |- AllowDeletion permits the operator to delete the Temporal namespace when the CR is deleted. When false, the namespace is left in place. type: boolean clusterRef: - description: ClusterRef references the TemporalCluster that owns this - namespace. + description: |- + ClusterRef references the TemporalCluster or TemporalDevServer that owns + this namespace. properties: + kind: + default: TemporalCluster + description: Kind selects the referenced object type. + enum: + - TemporalCluster + - TemporalDevServer + type: string name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + description: Name is the name of the referenced object. type: string + required: + - name type: object - x-kubernetes-map-type: atomic + clusters: + description: |- + Clusters lists the cluster names this namespace is replicated to. Only + meaningful when IsGlobal is true. + items: + type: string + type: array description: description: Description is a human-friendly description of the namespace. type: string @@ -6470,6 +8179,10 @@ spec: - reconcile - ignore type: string + isGlobal: + description: IsGlobal marks the namespace as global for multi-cluster + replication. + type: boolean ownerEmail: description: OwnerEmail is the owner contact for the namespace. type: string @@ -6557,6 +8270,24 @@ spec: description: Registered indicates whether the namespace exists in the Temporal cluster. type: boolean + replication: + description: Replication reports the observed replication state of + a global namespace. + properties: + activeCluster: + type: string + clusters: + items: + type: string + type: array + failoverInProgress: + type: boolean + isGlobal: + type: boolean + lastFailoverTime: + format: date-time + type: string + type: object type: object required: - spec @@ -6700,20 +8431,22 @@ spec: the CR is deleted. When false, the schedule is left in place. type: boolean clusterRef: - description: ClusterRef references the TemporalCluster that hosts - this schedule. + description: ClusterRef references the TemporalCluster or TemporalDevServer + that hosts this schedule. properties: + kind: + default: TemporalCluster + description: Kind selects the referenced object type. + enum: + - TemporalCluster + - TemporalDevServer + type: string name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + description: Name is the name of the referenced object. type: string + required: + - name type: object - x-kubernetes-map-type: atomic namespace: description: Namespace is the Temporal namespace the schedule lives in. @@ -7248,20 +8981,22 @@ spec: namespace when the CR is deleted. type: boolean clusterRef: - description: ClusterRef references the TemporalCluster this search - attribute belongs to. + description: ClusterRef references the TemporalCluster or TemporalDevServer + this search attribute belongs to. properties: + kind: + default: TemporalCluster + description: Kind selects the referenced object type. + enum: + - TemporalCluster + - TemporalDevServer + type: string name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + description: Name is the name of the referenced object. type: string + required: + - name type: object - x-kubernetes-map-type: atomic name: description: Name is the search attribute name. type: string @@ -7368,6 +9103,285 @@ spec: subresources: status: {} --- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: temporalworkflowruns.temporal.bmor10.com +spec: + group: temporal.bmor10.com + names: + kind: TemporalWorkflowRun + listKind: TemporalWorkflowRunList + plural: temporalworkflowruns + shortNames: + - twr + singular: temporalworkflowrun + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.clusterRef.name + name: Cluster + type: string + - jsonPath: .spec.namespace + name: Namespace + type: string + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: TemporalWorkflowRun is the Schema for the temporalworkflowruns + API. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: TemporalWorkflowRunSpec defines the desired state of a one-off + workflow run. + properties: + cancellationPolicy: + default: Abandon + description: |- + CancellationPolicy controls what happens to a still-running workflow when + this CR is deleted. + enum: + - Abandon + - Cancel + - Terminate + type: string + clusterRef: + description: |- + ClusterRef references the TemporalCluster or TemporalDevServer that runs + the workflow. Resolved in the same Kubernetes namespace as this CR. + properties: + kind: + default: TemporalCluster + description: Kind selects the referenced object type. + enum: + - TemporalCluster + - TemporalDevServer + type: string + name: + description: Name is the name of the referenced object. + type: string + required: + - name + type: object + namespace: + description: Namespace is the Temporal namespace to start the workflow + in. + type: string + ttlSecondsAfterFinished: + description: |- + TTLSecondsAfterFinished, when set, deletes this CR that many seconds after + the workflow reaches a terminal state. Unset keeps the CR indefinitely. + format: int32 + type: integer + workflow: + description: Workflow describes the one-off workflow to start. Immutable + after create. + properties: + args: + description: Args are JSON-serializable workflow inputs (one json/plain + payload each). + items: + type: object + x-kubernetes-preserve-unknown-fields: true + type: array + memo: + additionalProperties: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + retryPolicy: + description: RetryPolicySpec is the retry policy for the started + workflow. + properties: + backoffCoefficient: + description: BackoffCoefficient is a decimal string (e.g. + "2.0") parsed to float64. + type: string + initialInterval: + type: string + maximumAttempts: + format: int32 + type: integer + maximumInterval: + type: string + nonRetryableErrorTypes: + items: + type: string + type: array + type: object + searchAttributes: + additionalProperties: + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + taskQueue: + type: string + workflowExecutionTimeout: + type: string + workflowID: + type: string + workflowIDReusePolicy: + enum: + - AllowDuplicate + - AllowDuplicateFailedOnly + - RejectDuplicate + - TerminateIfRunning + type: string + workflowRunTimeout: + type: string + workflowTaskTimeout: + type: string + workflowType: + type: string + required: + - taskQueue + - workflowType + type: object + required: + - clusterRef + - namespace + - workflow + type: object + status: + description: TemporalWorkflowRunStatus is the observed state of a workflow + run. + properties: + closeTime: + format: date-time + type: string + completionTime: + description: |- + CompletionTime is when the operator first observed a terminal state; it + drives the TTL countdown. + format: date-time + type: string + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + failure: + description: Failure carries the failure message/type for non-success + terminal states. + properties: + message: + type: string + type: + type: string + type: object + historyLength: + format: int64 + type: integer + observedGeneration: + format: int64 + type: integer + phase: + description: |- + Phase is a friendly lifecycle summary: Pending, Running, Completed, + Failed, Terminated, Canceled, TimedOut, or ContinuedAsNew. + type: string + runID: + type: string + startTime: + format: date-time + type: string + taskQueue: + type: string + workflowID: + type: string + workflowType: + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +--- apiVersion: v1 kind: ServiceAccount metadata: @@ -7427,6 +9441,7 @@ rules: - "" resources: - configmaps + - persistentvolumeclaims - secrets - services verbs: @@ -7444,6 +9459,23 @@ rules: verbs: - create - patch +- apiGroups: + - "" + resources: + - pods + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - serviceaccounts + verbs: + - create + - get + - list + - watch - apiGroups: - apps resources: @@ -7521,10 +9553,13 @@ rules: - temporal.bmor10.com resources: - temporalclusterclients + - temporalclusterconnections - temporalclusters + - temporaldevservers - temporalnamespaces - temporalschedules - temporalsearchattributes + - temporalworkflowruns verbs: - create - delete @@ -7537,20 +9572,26 @@ rules: - temporal.bmor10.com resources: - temporalclusterclients/finalizers + - temporalclusterconnections/finalizers - temporalclusters/finalizers + - temporaldevservers/finalizers - temporalnamespaces/finalizers - temporalschedules/finalizers - temporalsearchattributes/finalizers + - temporalworkflowruns/finalizers verbs: - update - apiGroups: - temporal.bmor10.com resources: - temporalclusterclients/status + - temporalclusterconnections/status - temporalclusters/status + - temporaldevservers/status - temporalnamespaces/status - temporalschedules/status - temporalsearchattributes/status + - temporalworkflowruns/status verbs: - get - patch @@ -8128,6 +10169,26 @@ webhooks: resources: - temporalclusters sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: temporal-operator-webhook-service + namespace: temporal-operator-system + path: /validate-temporal-bmor10-com-v1alpha1-temporalclusterconnection + failurePolicy: Fail + name: vtemporalclusterconnection-v1alpha1.kb.io + rules: + - apiGroups: + - temporal.bmor10.com + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - temporalclusterconnections + sideEffects: None - admissionReviewVersions: - v1 clientConfig: @@ -8188,3 +10249,23 @@ webhooks: resources: - temporalsearchattributes sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: temporal-operator-webhook-service + namespace: temporal-operator-system + path: /validate-temporal-bmor10-com-v1alpha1-temporalworkflowrun + failurePolicy: Fail + name: vtemporalworkflowrun-v1alpha1.kb.io + rules: + - apiGroups: + - temporal.bmor10.com + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - temporalworkflowruns + sideEffects: None diff --git a/docs/api/v1alpha1.md b/docs/api/v1alpha1.md index 3262552a..a62b164b 100644 --- a/docs/api/v1alpha1.md +++ b/docs/api/v1alpha1.md @@ -27,6 +27,7 @@ Package v1alpha1 contains API Schema definitions for the temporal v1alpha1 API g - [TemporalNamespace](#temporalnamespace) - [TemporalSchedule](#temporalschedule) - [TemporalSearchAttribute](#temporalsearchattribute) +- [TemporalWorkflowRun](#temporalworkflowrun) @@ -206,6 +207,7 @@ _Appears in:_ - [TemporalNamespaceSpec](#temporalnamespacespec) - [TemporalScheduleSpec](#temporalschedulespec) - [TemporalSearchAttributeSpec](#temporalsearchattributespec) +- [TemporalWorkflowRunSpec](#temporalworkflowrunspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -976,6 +978,7 @@ StartWorkflowAction starts a workflow when the schedule fires. _Appears in:_ - [ScheduleActionSpec](#scheduleactionspec) +- [TemporalWorkflowRunSpec](#temporalworkflowrunspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -1134,6 +1137,7 @@ _Appears in:_ | `authorization` _[AuthorizationSpec](#authorizationspec)_ | Authorization configures the authorizer and claim mapper. | | Optional: \{\}
| | `clusterMetadata` _[ClusterMetadataSpec](#clustermetadataspec)_ | ClusterMetadata configures multi-cluster replication. | | Optional: \{\}
| | `preventDeletion` _boolean_ | PreventDeletion, when true, blocks deletion of the cluster via the
validating webhook as a safety measure. | | Optional: \{\}
| +| `workflowRunPolicy` _[WorkflowRunPolicy](#workflowrunpolicy)_ | WorkflowRunPolicy gates operator-initiated TemporalWorkflowRun executions
against this cluster. Absent means disabled (closed by default). | | Optional: \{\}
| @@ -1183,6 +1187,7 @@ _Appears in:_ | `nodeSelector` _object (keys:string, values:string)_ | NodeSelector constrains the dev server pod to matching nodes. | | Optional: \{\}
| | `tolerations` _[Toleration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.34/#toleration-v1-core) array_ | Tolerations applied to the dev server pod. | | Optional: \{\}
| | `affinity` _[Affinity](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.34/#affinity-v1-core)_ | Affinity applied to the dev server pod. | | Optional: \{\}
| +| `workflowRunPolicy` _[WorkflowRunPolicy](#workflowrunpolicy)_ | WorkflowRunPolicy gates operator-initiated TemporalWorkflowRun executions
against this dev server. Absent means enabled with no allowlist. | | Optional: \{\}
| @@ -1314,6 +1319,46 @@ _Appears in:_ +#### TemporalWorkflowRun + + + +TemporalWorkflowRun is the Schema for the temporalworkflowruns API. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `temporal.bmor10.com/v1alpha1` | | | +| `kind` _string_ | `TemporalWorkflowRun` | | | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.34/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | Optional: \{\}
| +| `spec` _[TemporalWorkflowRunSpec](#temporalworkflowrunspec)_ | | | Required: \{\}
| + + +#### TemporalWorkflowRunSpec + + + +TemporalWorkflowRunSpec defines the desired state of a one-off workflow run. + + + +_Appears in:_ +- [TemporalWorkflowRun](#temporalworkflowrun) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `clusterRef` _[ClusterReference](#clusterreference)_ | ClusterRef references the TemporalCluster or TemporalDevServer that runs
the workflow. Resolved in the same Kubernetes namespace as this CR. | | | +| `namespace` _string_ | Namespace is the Temporal namespace to start the workflow in. | | | +| `workflow` _[StartWorkflowAction](#startworkflowaction)_ | Workflow describes the one-off workflow to start. Immutable after create. | | | +| `ttlSecondsAfterFinished` _integer_ | TTLSecondsAfterFinished, when set, deletes this CR that many seconds after
the workflow reaches a terminal state. Unset keeps the CR indefinitely. | | Optional: \{\}
| +| `cancellationPolicy` _string_ | CancellationPolicy controls what happens to a still-running workflow when
this CR is deleted. | Abandon | Enum: [Abandon Cancel Terminate]
Optional: \{\}
| + + + + #### UIAuthSpec @@ -1416,3 +1461,44 @@ _Appears in:_ | `startedAt` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.34/#time-v1-meta)_ | | | Optional: \{\}
| +#### WorkflowRunFailure + + + +WorkflowRunFailure carries failure detail for non-success terminal states. + + + +_Appears in:_ +- [TemporalWorkflowRunStatus](#temporalworkflowrunstatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `message` _string_ | | | Optional: \{\}
| +| `type` _string_ | | | Optional: \{\}
| + + +#### WorkflowRunPolicy + + + +WorkflowRunPolicy is a per-target opt-in gate controlling operator-initiated +workflow runs. It is declared on a TemporalCluster or TemporalDevServer by the +cluster owner. Defaults differ per kind and are applied by the controller's +target resolver, not by kubebuilder defaults, so an absent policy is +meaningful: disabled for TemporalCluster, enabled (no allowlist) for +TemporalDevServer. + + + +_Appears in:_ +- [TemporalClusterSpec](#temporalclusterspec) +- [TemporalDevServerSpec](#temporaldevserverspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `enabled` _boolean_ | Enabled permits operator-initiated workflow runs against this target. | | Optional: \{\}
| +| `allowedNamespaces` _string array_ | AllowedNamespaces optionally restricts which Temporal namespaces runs may
target. Empty means any namespace is allowed. | | Optional: \{\}
| +| `allowedTaskQueues` _string array_ | AllowedTaskQueues optionally restricts which task queues runs may use.
Empty means any task queue is allowed. | | Optional: \{\}
| + + diff --git a/docs/content/reference/_index.md b/docs/content/reference/_index.md index 4ab699c3..8d8bbb2e 100644 --- a/docs/content/reference/_index.md +++ b/docs/content/reference/_index.md @@ -32,6 +32,7 @@ Package v1alpha1 contains API Schema definitions for the temporal v1alpha1 API g - [TemporalNamespace](#temporalnamespace) - [TemporalSchedule](#temporalschedule) - [TemporalSearchAttribute](#temporalsearchattribute) +- [TemporalWorkflowRun](#temporalworkflowrun) @@ -211,6 +212,7 @@ _Appears in:_ - [TemporalNamespaceSpec](#temporalnamespacespec) - [TemporalScheduleSpec](#temporalschedulespec) - [TemporalSearchAttributeSpec](#temporalsearchattributespec) +- [TemporalWorkflowRunSpec](#temporalworkflowrunspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -981,6 +983,7 @@ StartWorkflowAction starts a workflow when the schedule fires. _Appears in:_ - [ScheduleActionSpec](#scheduleactionspec) +- [TemporalWorkflowRunSpec](#temporalworkflowrunspec) | Field | Description | Default | Validation | | --- | --- | --- | --- | @@ -1139,6 +1142,7 @@ _Appears in:_ | `authorization` _[AuthorizationSpec](#authorizationspec)_ | Authorization configures the authorizer and claim mapper. | | Optional: \{\}
| | `clusterMetadata` _[ClusterMetadataSpec](#clustermetadataspec)_ | ClusterMetadata configures multi-cluster replication. | | Optional: \{\}
| | `preventDeletion` _boolean_ | PreventDeletion, when true, blocks deletion of the cluster via the
validating webhook as a safety measure. | | Optional: \{\}
| +| `workflowRunPolicy` _[WorkflowRunPolicy](#workflowrunpolicy)_ | WorkflowRunPolicy gates operator-initiated TemporalWorkflowRun executions
against this cluster. Absent means disabled (closed by default). | | Optional: \{\}
| @@ -1188,6 +1192,7 @@ _Appears in:_ | `nodeSelector` _object (keys:string, values:string)_ | NodeSelector constrains the dev server pod to matching nodes. | | Optional: \{\}
| | `tolerations` _[Toleration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.34/#toleration-v1-core) array_ | Tolerations applied to the dev server pod. | | Optional: \{\}
| | `affinity` _[Affinity](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.34/#affinity-v1-core)_ | Affinity applied to the dev server pod. | | Optional: \{\}
| +| `workflowRunPolicy` _[WorkflowRunPolicy](#workflowrunpolicy)_ | WorkflowRunPolicy gates operator-initiated TemporalWorkflowRun executions
against this dev server. Absent means enabled with no allowlist. | | Optional: \{\}
| @@ -1319,6 +1324,46 @@ _Appears in:_ +#### TemporalWorkflowRun + + + +TemporalWorkflowRun is the Schema for the temporalworkflowruns API. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `temporal.bmor10.com/v1alpha1` | | | +| `kind` _string_ | `TemporalWorkflowRun` | | | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.34/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | Optional: \{\}
| +| `spec` _[TemporalWorkflowRunSpec](#temporalworkflowrunspec)_ | | | Required: \{\}
| + + +#### TemporalWorkflowRunSpec + + + +TemporalWorkflowRunSpec defines the desired state of a one-off workflow run. + + + +_Appears in:_ +- [TemporalWorkflowRun](#temporalworkflowrun) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `clusterRef` _[ClusterReference](#clusterreference)_ | ClusterRef references the TemporalCluster or TemporalDevServer that runs
the workflow. Resolved in the same Kubernetes namespace as this CR. | | | +| `namespace` _string_ | Namespace is the Temporal namespace to start the workflow in. | | | +| `workflow` _[StartWorkflowAction](#startworkflowaction)_ | Workflow describes the one-off workflow to start. Immutable after create. | | | +| `ttlSecondsAfterFinished` _integer_ | TTLSecondsAfterFinished, when set, deletes this CR that many seconds after
the workflow reaches a terminal state. Unset keeps the CR indefinitely. | | Optional: \{\}
| +| `cancellationPolicy` _string_ | CancellationPolicy controls what happens to a still-running workflow when
this CR is deleted. | Abandon | Enum: [Abandon Cancel Terminate]
Optional: \{\}
| + + + + #### UIAuthSpec @@ -1421,3 +1466,44 @@ _Appears in:_ | `startedAt` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.34/#time-v1-meta)_ | | | Optional: \{\}
| +#### WorkflowRunFailure + + + +WorkflowRunFailure carries failure detail for non-success terminal states. + + + +_Appears in:_ +- [TemporalWorkflowRunStatus](#temporalworkflowrunstatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `message` _string_ | | | Optional: \{\}
| +| `type` _string_ | | | Optional: \{\}
| + + +#### WorkflowRunPolicy + + + +WorkflowRunPolicy is a per-target opt-in gate controlling operator-initiated +workflow runs. It is declared on a TemporalCluster or TemporalDevServer by the +cluster owner. Defaults differ per kind and are applied by the controller's +target resolver, not by kubebuilder defaults, so an absent policy is +meaningful: disabled for TemporalCluster, enabled (no allowlist) for +TemporalDevServer. + + + +_Appears in:_ +- [TemporalClusterSpec](#temporalclusterspec) +- [TemporalDevServerSpec](#temporaldevserverspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `enabled` _boolean_ | Enabled permits operator-initiated workflow runs against this target. | | Optional: \{\}
| +| `allowedNamespaces` _string array_ | AllowedNamespaces optionally restricts which Temporal namespaces runs may
target. Empty means any namespace is allowed. | | Optional: \{\}
| +| `allowedTaskQueues` _string array_ | AllowedTaskQueues optionally restricts which task queues runs may use.
Empty means any task queue is allowed. | | Optional: \{\}
| + + From 11897d45507ade1311247735fbf5bac6eeb8b6d5 Mon Sep 17 00:00:00 2001 From: Brian Morton Date: Thu, 25 Jun 2026 07:57:47 +0000 Subject: [PATCH 09/12] test(e2e): add workflowrun Chainsaw suite and CI wiring Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Brian Morton --- .github/workflows/e2e.yml | 10 ++-- test/e2e/workflowrun/01-assert.yaml | 6 +++ test/e2e/workflowrun/01-devserver.yaml | 10 ++++ test/e2e/workflowrun/02-assert.yaml | 7 +++ test/e2e/workflowrun/02-namespace.yaml | 9 ++++ test/e2e/workflowrun/03-assert.yaml | 9 ++++ test/e2e/workflowrun/03-workflowrun.yaml | 13 +++++ .../e2e/workflowrun/04-assert-terminated.yaml | 7 +++ test/e2e/workflowrun/05-assert.yaml | 7 +++ test/e2e/workflowrun/05-deny.yaml | 12 +++++ test/e2e/workflowrun/chainsaw-test.yaml | 53 +++++++++++++++++++ 11 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 test/e2e/workflowrun/01-assert.yaml create mode 100644 test/e2e/workflowrun/01-devserver.yaml create mode 100644 test/e2e/workflowrun/02-assert.yaml create mode 100644 test/e2e/workflowrun/02-namespace.yaml create mode 100644 test/e2e/workflowrun/03-assert.yaml create mode 100644 test/e2e/workflowrun/03-workflowrun.yaml create mode 100644 test/e2e/workflowrun/04-assert-terminated.yaml create mode 100644 test/e2e/workflowrun/05-assert.yaml create mode 100644 test/e2e/workflowrun/05-deny.yaml create mode 100644 test/e2e/workflowrun/chainsaw-test.yaml diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 712cc725..44c82bd0 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -10,7 +10,7 @@ on: suite: description: Which suite(s) to run type: choice - options: [default, devserver, mtls, upgrade, opensearch, cassandra, multicluster, all] + options: [default, devserver, workflowrun, mtls, upgrade, opensearch, cassandra, multicluster, all] default: default permissions: @@ -32,22 +32,24 @@ jobs: run: | postgres='{"temporal":"1.31.1","persistence":"postgres","suite":"postgres/lifecycle"}' devserver='{"suite":"devserver"}' + workflowrun='{"suite":"workflowrun"}' mtls='{"temporal":"1.31.1","persistence":"mtls","suite":"mtls"}' upgrade='{"temporal":"1.30.0","persistence":"upgrade","suite":"upgrade"}' opensearch='{"temporal":"1.31.1","persistence":"opensearch","suite":"opensearch"}' cassandra='{"temporal":"1.31.1","persistence":"cassandra","suite":"cassandra"}' multicluster='{"temporal":"1.31.1","persistence":"multicluster","suite":"multicluster"}' if [ "$EVENT" = "schedule" ]; then - echo "combos=[$postgres,$devserver,$mtls,$upgrade,$opensearch,$cassandra,$multicluster]" >> "$GITHUB_OUTPUT" + echo "combos=[$postgres,$devserver,$workflowrun,$mtls,$upgrade,$opensearch,$cassandra,$multicluster]" >> "$GITHUB_OUTPUT" elif [ "$EVENT" = "workflow_dispatch" ]; then case "$SUITE" in devserver) echo "combos=[$devserver]" >> "$GITHUB_OUTPUT" ;; + workflowrun) echo "combos=[$workflowrun]" >> "$GITHUB_OUTPUT" ;; mtls) echo "combos=[$mtls]" >> "$GITHUB_OUTPUT" ;; upgrade) echo "combos=[$upgrade]" >> "$GITHUB_OUTPUT" ;; opensearch) echo "combos=[$opensearch]" >> "$GITHUB_OUTPUT" ;; cassandra) echo "combos=[$cassandra]" >> "$GITHUB_OUTPUT" ;; multicluster) echo "combos=[$multicluster]" >> "$GITHUB_OUTPUT" ;; - all) echo "combos=[$postgres,$devserver,$mtls,$upgrade,$opensearch,$cassandra,$multicluster]" >> "$GITHUB_OUTPUT" ;; + all) echo "combos=[$postgres,$devserver,$workflowrun,$mtls,$upgrade,$opensearch,$cassandra,$multicluster]" >> "$GITHUB_OUTPUT" ;; *) echo "combos=[$postgres,$devserver]" >> "$GITHUB_OUTPUT" ;; esac else @@ -119,7 +121,7 @@ jobs: | grep -oE '1\.[0-9]+\.[0-9]+' | sort -u || true) images="" for v in $versions; do - if echo "${{ matrix.combo.suite }}" | grep -q "^devserver"; then + if echo "${{ matrix.combo.suite }}" | grep -qE "^(devserver|workflowrun)"; then # The dev server's spec.version is a Temporal *server* version, but # it runs the temporalio/temporal *CLI* image, which is versioned # independently (its tags are CLI releases, e.g. 1.7.x, not server diff --git a/test/e2e/workflowrun/01-assert.yaml b/test/e2e/workflowrun/01-assert.yaml new file mode 100644 index 00000000..d9d78e25 --- /dev/null +++ b/test/e2e/workflowrun/01-assert.yaml @@ -0,0 +1,6 @@ +apiVersion: temporal.bmor10.com/v1alpha1 +kind: TemporalDevServer +metadata: + name: wfr +status: + (conditions[?type == 'Ready'].status | [0]): "True" diff --git a/test/e2e/workflowrun/01-devserver.yaml b/test/e2e/workflowrun/01-devserver.yaml new file mode 100644 index 00000000..0dec7fef --- /dev/null +++ b/test/e2e/workflowrun/01-devserver.yaml @@ -0,0 +1,10 @@ +apiVersion: temporal.bmor10.com/v1alpha1 +kind: TemporalDevServer +metadata: + name: wfr +spec: + version: "1.31.1" + namespaces: ["orders"] + workflowRunPolicy: + enabled: true + allowedTaskQueues: ["greeting-tq"] diff --git a/test/e2e/workflowrun/02-assert.yaml b/test/e2e/workflowrun/02-assert.yaml new file mode 100644 index 00000000..20222a4f --- /dev/null +++ b/test/e2e/workflowrun/02-assert.yaml @@ -0,0 +1,7 @@ +apiVersion: temporal.bmor10.com/v1alpha1 +kind: TemporalNamespace +metadata: + name: orders +status: + registered: true + (conditions[?type == 'Ready'].status | [0]): "True" diff --git a/test/e2e/workflowrun/02-namespace.yaml b/test/e2e/workflowrun/02-namespace.yaml new file mode 100644 index 00000000..462f9d00 --- /dev/null +++ b/test/e2e/workflowrun/02-namespace.yaml @@ -0,0 +1,9 @@ +apiVersion: temporal.bmor10.com/v1alpha1 +kind: TemporalNamespace +metadata: + name: orders +spec: + clusterRef: + name: wfr + kind: TemporalDevServer + retentionPeriod: "24h" diff --git a/test/e2e/workflowrun/03-assert.yaml b/test/e2e/workflowrun/03-assert.yaml new file mode 100644 index 00000000..32272c97 --- /dev/null +++ b/test/e2e/workflowrun/03-assert.yaml @@ -0,0 +1,9 @@ +apiVersion: temporal.bmor10.com/v1alpha1 +kind: TemporalWorkflowRun +metadata: + name: greet-once +status: + phase: Running + (workflowID != null && workflowID != ''): true + (runID != null && runID != ''): true + (conditions[?type == 'Ready'].status | [0]): "True" diff --git a/test/e2e/workflowrun/03-workflowrun.yaml b/test/e2e/workflowrun/03-workflowrun.yaml new file mode 100644 index 00000000..5586e44d --- /dev/null +++ b/test/e2e/workflowrun/03-workflowrun.yaml @@ -0,0 +1,13 @@ +apiVersion: temporal.bmor10.com/v1alpha1 +kind: TemporalWorkflowRun +metadata: + name: greet-once +spec: + clusterRef: + name: wfr + kind: TemporalDevServer + namespace: orders + ttlSecondsAfterFinished: 30 + workflow: + workflowType: GreetingWorkflow + taskQueue: greeting-tq diff --git a/test/e2e/workflowrun/04-assert-terminated.yaml b/test/e2e/workflowrun/04-assert-terminated.yaml new file mode 100644 index 00000000..e206b58d --- /dev/null +++ b/test/e2e/workflowrun/04-assert-terminated.yaml @@ -0,0 +1,7 @@ +apiVersion: temporal.bmor10.com/v1alpha1 +kind: TemporalWorkflowRun +metadata: + name: greet-once +status: + phase: Terminated + (conditions[?type == 'Finished'].status | [0]): "True" diff --git a/test/e2e/workflowrun/05-assert.yaml b/test/e2e/workflowrun/05-assert.yaml new file mode 100644 index 00000000..72cc0e8c --- /dev/null +++ b/test/e2e/workflowrun/05-assert.yaml @@ -0,0 +1,7 @@ +apiVersion: temporal.bmor10.com/v1alpha1 +kind: TemporalWorkflowRun +metadata: + name: denied-run +status: + (conditions[?type == 'Ready'].status | [0]): "False" + (conditions[?type == 'Ready'].reason | [0]): WorkflowRunNotPermitted diff --git a/test/e2e/workflowrun/05-deny.yaml b/test/e2e/workflowrun/05-deny.yaml new file mode 100644 index 00000000..54be3735 --- /dev/null +++ b/test/e2e/workflowrun/05-deny.yaml @@ -0,0 +1,12 @@ +apiVersion: temporal.bmor10.com/v1alpha1 +kind: TemporalWorkflowRun +metadata: + name: denied-run +spec: + clusterRef: + name: wfr + kind: TemporalDevServer + namespace: orders + workflow: + workflowType: GreetingWorkflow + taskQueue: not-allowed-tq diff --git a/test/e2e/workflowrun/chainsaw-test.yaml b/test/e2e/workflowrun/chainsaw-test.yaml new file mode 100644 index 00000000..cc56f381 --- /dev/null +++ b/test/e2e/workflowrun/chainsaw-test.yaml @@ -0,0 +1,53 @@ +# Chainsaw test: stand up a disposable TemporalDevServer, start a one-off +# workflow via TemporalWorkflowRun, drive it to a terminal state by terminating +# it with admin-tools, verify TTL deletes the CR, and verify the +# WorkflowRunPolicy denies a run using a disallowed task queue. No external DB +# and no custom worker image are required. +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: workflowrun +spec: + timeouts: + apply: 1m + assert: 5m + exec: 2m + steps: + - name: dev-server + try: + - apply: + file: 01-devserver.yaml + - assert: + file: 01-assert.yaml + - name: register-namespace + try: + - apply: + file: 02-namespace.yaml + - assert: + file: 02-assert.yaml + - name: start-workflow + try: + - apply: + file: 03-workflowrun.yaml + - assert: + file: 03-assert.yaml + - name: terminate-and-ttl + try: + - script: + content: | + kubectl run -n $NAMESPACE wf-terminate --rm -i --restart=Never \ + --image=temporalio/admin-tools:1.31.1 -- \ + temporal workflow terminate \ + --address dev-wfr:7233 --namespace orders \ + --workflow-id greet-once --reason e2e + - assert: + file: 04-assert-terminated.yaml + - error: + # After TTL (30s) the operator deletes the CR. + file: 04-assert-terminated.yaml + - name: policy-deny + try: + - apply: + file: 05-deny.yaml + - assert: + file: 05-assert.yaml From 3c1d9bf99e7087c471268396eb71a0d97bb37cfe Mon Sep 17 00:00:00 2001 From: Brian Morton Date: Thu, 25 Jun 2026 08:03:40 +0000 Subject: [PATCH 10/12] fix(controller): lowercase policy error string for staticcheck ST1005 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Brian Morton --- internal/controller/temporalworkflowrun_controller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/controller/temporalworkflowrun_controller.go b/internal/controller/temporalworkflowrun_controller.go index 0fb13aa8..90503b9b 100644 --- a/internal/controller/temporalworkflowrun_controller.go +++ b/internal/controller/temporalworkflowrun_controller.go @@ -254,7 +254,7 @@ func checkWorkflowRunPolicy(p temporalv1alpha1.WorkflowRunPolicy, namespace, tas return fmt.Errorf("workflow runs are not enabled on the referenced Temporal target") } if len(p.AllowedNamespaces) > 0 && !contains(p.AllowedNamespaces, namespace) { - return fmt.Errorf("Temporal namespace %q is not in the target's allowedNamespaces", namespace) + return fmt.Errorf("namespace %q is not in the target's allowedNamespaces", namespace) } if len(p.AllowedTaskQueues) > 0 && !contains(p.AllowedTaskQueues, taskQueue) { return fmt.Errorf("task queue %q is not in the target's allowedTaskQueues", taskQueue) From b2fbd76cc94c2770e0ce3a32740e19d657df48e2 Mon Sep 17 00:00:00 2001 From: Brian Morton Date: Thu, 25 Jun 2026 08:16:34 +0000 Subject: [PATCH 11/12] fix: address final review for TemporalWorkflowRun (e2e address, deny requeue, cancel tolerance, tests) - Fix 1: Corrected dev-server Service address in e2e test from dev-wfr:7233 to wfr-devserver:7233 - Fix 2: Added workflowRunPolicyRetry const (30s) and requeue to policy-denied path - Fix 3: Map gRPC codes.FailedPrecondition to ErrWorkflowNotFound in Cancel/Terminate - Fix 4: Added 5 unit tests: TTL deletion, namespace-deny, taskqueue-deny, cancel-on-delete, abandon-no-op Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Brian Morton --- .../temporalworkflowrun_controller.go | 5 +- .../temporalworkflowrun_controller_test.go | 103 ++++++++++++++++++ internal/temporal/workflowrun.go | 6 +- test/e2e/workflowrun/chainsaw-test.yaml | 2 +- 4 files changed, 112 insertions(+), 4 deletions(-) diff --git a/internal/controller/temporalworkflowrun_controller.go b/internal/controller/temporalworkflowrun_controller.go index 90503b9b..8131af89 100644 --- a/internal/controller/temporalworkflowrun_controller.go +++ b/internal/controller/temporalworkflowrun_controller.go @@ -40,6 +40,9 @@ const workflowRunFinalizer = "temporal.bmor10.com/workflowrun" // workflowRunPollInterval is how often a running workflow's status is refreshed. const workflowRunPollInterval = 10 * time.Second +// workflowRunPolicyRetry is how often to re-check policy when a run is denied. +const workflowRunPolicyRetry = 30 * time.Second + // TemporalWorkflowRunReconciler reconciles TemporalWorkflowRun objects. type TemporalWorkflowRunReconciler struct { client.Client @@ -109,7 +112,7 @@ func (r *TemporalWorkflowRunReconciler) reconcileRun(ctx context.Context, run *t if run.Status.RunID == "" { if err := checkWorkflowRunPolicy(policy, run.Spec.Namespace, taskQueue); err != nil { r.setReady(run, metav1.ConditionFalse, temporalv1alpha1.ReasonWorkflowRunNotPermitted, err.Error()) - return ctrl.Result{}, r.statusUpdate(ctx, run) + return ctrl.Result{RequeueAfter: workflowRunPolicyRetry}, r.statusUpdate(ctx, run) } params, err := workflowRunParams(run) if err != nil { diff --git a/internal/controller/temporalworkflowrun_controller_test.go b/internal/controller/temporalworkflowrun_controller_test.go index adb0bae0..6493b0d0 100644 --- a/internal/controller/temporalworkflowrun_controller_test.go +++ b/internal/controller/temporalworkflowrun_controller_test.go @@ -24,6 +24,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" enumspb "go.temporal.io/api/enums/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -183,4 +184,106 @@ var _ = Describe("TemporalWorkflowRun reconciler", func() { Expect(err).NotTo(HaveOccurred()) Expect(fake.terminated).NotTo(BeEmpty()) }) + + It("deletes terminal run after TTL", func() { + c := newReadyCluster(fmt.Sprintf("cluster-%d", counter), &temporalv1alpha1.WorkflowRunPolicy{Enabled: true}) + fake.status = enumspb.WORKFLOW_EXECUTION_STATUS_COMPLETED + run := newRun(fmt.Sprintf("run-%d", counter), c.Name) + ttl := int32(0) + run.Spec.TTLSecondsAfterFinished = &ttl + Expect(k8sClient.Create(ctx, run)).To(Succeed()) + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: run.Name, Namespace: testNamespace}} + _, err := reconciler().Reconcile(ctx, req) // start + finalize + Expect(err).NotTo(HaveOccurred()) + _, err = reconciler().Reconcile(ctx, req) // TTL cleanup + Expect(err).NotTo(HaveOccurred()) + + Eventually(func() bool { + var got temporalv1alpha1.TemporalWorkflowRun + err := k8sClient.Get(ctx, req.NamespacedName, &got) + return err != nil && apierrors.IsNotFound(err) + }).Should(BeTrue()) + }) + + It("denies run when namespace not in policy allowlist", func() { + c := newReadyCluster(fmt.Sprintf("cluster-%d", counter), &temporalv1alpha1.WorkflowRunPolicy{ + Enabled: true, + AllowedNamespaces: []string{"other"}, + }) + run := newRun(fmt.Sprintf("run-%d", counter), c.Name) + run.Spec.Namespace = "orders" + Expect(k8sClient.Create(ctx, run)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, run) }) + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: run.Name, Namespace: testNamespace}} + _, err := reconciler().Reconcile(ctx, req) + Expect(err).NotTo(HaveOccurred()) + + Expect(fake.started).To(BeEmpty()) + var got temporalv1alpha1.TemporalWorkflowRun + Expect(k8sClient.Get(ctx, req.NamespacedName, &got)).To(Succeed()) + Expect(got.Status.RunID).To(BeEmpty()) + cond := meta.FindStatusCondition(got.Status.Conditions, temporalv1alpha1.ConditionReady) + Expect(cond).NotTo(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + Expect(cond.Reason).To(Equal(temporalv1alpha1.ReasonWorkflowRunNotPermitted)) + }) + + It("denies run when task queue not in policy allowlist", func() { + c := newReadyCluster(fmt.Sprintf("cluster-%d", counter), &temporalv1alpha1.WorkflowRunPolicy{ + Enabled: true, + AllowedTaskQueues: []string{"allowed-tq"}, + }) + run := newRun(fmt.Sprintf("run-%d", counter), c.Name) + run.Spec.Workflow.TaskQueue = "tq" + Expect(k8sClient.Create(ctx, run)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, run) }) + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: run.Name, Namespace: testNamespace}} + _, err := reconciler().Reconcile(ctx, req) + Expect(err).NotTo(HaveOccurred()) + + Expect(fake.started).To(BeEmpty()) + var got temporalv1alpha1.TemporalWorkflowRun + Expect(k8sClient.Get(ctx, req.NamespacedName, &got)).To(Succeed()) + Expect(got.Status.RunID).To(BeEmpty()) + cond := meta.FindStatusCondition(got.Status.Conditions, temporalv1alpha1.ConditionReady) + Expect(cond).NotTo(BeNil()) + Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + Expect(cond.Reason).To(Equal(temporalv1alpha1.ReasonWorkflowRunNotPermitted)) + }) + + It("cancels a running workflow on delete with cancellationPolicy=Cancel", func() { + c := newReadyCluster(fmt.Sprintf("cluster-%d", counter), &temporalv1alpha1.WorkflowRunPolicy{Enabled: true}) + run := newRun(fmt.Sprintf("run-%d", counter), c.Name) + run.Spec.CancellationPolicy = "Cancel" + Expect(k8sClient.Create(ctx, run)).To(Succeed()) + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: run.Name, Namespace: testNamespace}} + _, err := reconciler().Reconcile(ctx, req) // adds finalizer + starts + Expect(err).NotTo(HaveOccurred()) + + Expect(k8sClient.Delete(ctx, run)).To(Succeed()) + _, err = reconciler().Reconcile(ctx, req) // handles deletion + Expect(err).NotTo(HaveOccurred()) + Expect(fake.canceled).NotTo(BeEmpty()) + }) + + It("does not cancel or terminate with cancellationPolicy=Abandon", func() { + c := newReadyCluster(fmt.Sprintf("cluster-%d", counter), &temporalv1alpha1.WorkflowRunPolicy{Enabled: true}) + run := newRun(fmt.Sprintf("run-%d", counter), c.Name) + run.Spec.CancellationPolicy = "Abandon" + Expect(k8sClient.Create(ctx, run)).To(Succeed()) + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: run.Name, Namespace: testNamespace}} + _, err := reconciler().Reconcile(ctx, req) // adds finalizer + starts + Expect(err).NotTo(HaveOccurred()) + + Expect(k8sClient.Delete(ctx, run)).To(Succeed()) + _, err = reconciler().Reconcile(ctx, req) // handles deletion + Expect(err).NotTo(HaveOccurred()) + Expect(fake.canceled).To(BeEmpty()) + Expect(fake.terminated).To(BeEmpty()) + }) }) diff --git a/internal/temporal/workflowrun.go b/internal/temporal/workflowrun.go index 11627114..a27c0fdb 100644 --- a/internal/temporal/workflowrun.go +++ b/internal/temporal/workflowrun.go @@ -200,7 +200,8 @@ func (c *grpcWorkflowRunClient) Cancel(ctx context.Context, namespace, workflowI Identity: workflowRunIdentity, Reason: reason, }) - if err != nil && status.Code(err) == codes.NotFound { + code := status.Code(err) + if err != nil && (code == codes.NotFound || code == codes.FailedPrecondition) { return ErrWorkflowNotFound } return err @@ -213,7 +214,8 @@ func (c *grpcWorkflowRunClient) Terminate(ctx context.Context, namespace, workfl Identity: workflowRunIdentity, Reason: reason, }) - if err != nil && status.Code(err) == codes.NotFound { + code := status.Code(err) + if err != nil && (code == codes.NotFound || code == codes.FailedPrecondition) { return ErrWorkflowNotFound } return err diff --git a/test/e2e/workflowrun/chainsaw-test.yaml b/test/e2e/workflowrun/chainsaw-test.yaml index cc56f381..bf5a566a 100644 --- a/test/e2e/workflowrun/chainsaw-test.yaml +++ b/test/e2e/workflowrun/chainsaw-test.yaml @@ -38,7 +38,7 @@ spec: kubectl run -n $NAMESPACE wf-terminate --rm -i --restart=Never \ --image=temporalio/admin-tools:1.31.1 -- \ temporal workflow terminate \ - --address dev-wfr:7233 --namespace orders \ + --address wfr-devserver:7233 --namespace orders \ --workflow-id greet-once --reason e2e - assert: file: 04-assert-terminated.yaml From f9f56e6913aef0d36e20dc761d923e59d15ce33b Mon Sep 17 00:00:00 2001 From: Brian Morton Date: Thu, 23 Jul 2026 06:26:20 +0000 Subject: [PATCH 12/12] fix(controller): harden TemporalWorkflowRun terminal and deletion paths Two correctness fixes surfaced in review: - Skip re-describing an already-terminal run. Temporal retention can purge a finished execution; the reconciler would then loop forever on ErrWorkflowNotFound. Terminal runs now go straight to TTL bookkeeping. - Only drop the finalizer on delete when the target is genuinely gone (ErrTargetNotFound). A transient resolveTarget/client-build error no longer abandons the finalizer, so cancellationPolicy Cancel/Terminate is honored once the target is reachable again. Adds regression tests for both paths and introduces CancellationPolicy* constants to replace repeated string literals. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 873f1b5d-e31e-47f2-9adf-61295bd74e79 Signed-off-by: Brian Morton --- api/v1alpha1/temporalworkflowrun_types.go | 11 +++ .../temporalworkflowrun_controller.go | 50 ++++++++---- .../temporalworkflowrun_controller_test.go | 77 ++++++++++++++++++- .../temporalworkflowrun_webhook_test.go | 2 +- 4 files changed, 121 insertions(+), 19 deletions(-) diff --git a/api/v1alpha1/temporalworkflowrun_types.go b/api/v1alpha1/temporalworkflowrun_types.go index a7db8cef..5a869d69 100644 --- a/api/v1alpha1/temporalworkflowrun_types.go +++ b/api/v1alpha1/temporalworkflowrun_types.go @@ -65,6 +65,17 @@ type TemporalWorkflowRunSpec struct { CancellationPolicy string `json:"cancellationPolicy,omitempty"` } +// Cancellation policies applied to a still-running workflow when its +// TemporalWorkflowRun is deleted. +const ( + // CancellationPolicyAbandon leaves the workflow running. + CancellationPolicyAbandon = "Abandon" + // CancellationPolicyCancel requests graceful cancellation of the workflow. + CancellationPolicyCancel = "Cancel" + // CancellationPolicyTerminate forcefully terminates the workflow. + CancellationPolicyTerminate = "Terminate" +) + // WorkflowRunFailure carries failure detail for non-success terminal states. type WorkflowRunFailure struct { // +optional diff --git a/internal/controller/temporalworkflowrun_controller.go b/internal/controller/temporalworkflowrun_controller.go index 8131af89..f36e7e85 100644 --- a/internal/controller/temporalworkflowrun_controller.go +++ b/internal/controller/temporalworkflowrun_controller.go @@ -66,7 +66,15 @@ func (r *TemporalWorkflowRunReconciler) Reconcile(ctx context.Context, req ctrl. target, err := resolveTarget(ctx, r.Client, run.Namespace, run.Spec.ClusterRef) if err != nil { if !run.DeletionTimestamp.IsZero() { - return ctrl.Result{}, r.removeFinalizer(ctx, &run) + // Only abandon cancellation when the target is genuinely gone; there + // is nothing left to Cancel/Terminate. For any other (potentially + // transient) error, requeue so the cancellation policy is still + // honored once the target becomes resolvable again, rather than + // silently dropping the finalizer with a workflow still in flight. + if errors.Is(err, ErrTargetNotFound) { + return ctrl.Result{}, r.removeFinalizer(ctx, &run) + } + return ctrl.Result{}, err } if errors.Is(err, ErrTargetNotFound) { r.setReady(&run, metav1.ConditionFalse, temporalv1alpha1.ReasonClusterNotFound, "referenced Temporal target not found") @@ -77,9 +85,8 @@ func (r *TemporalWorkflowRunReconciler) Reconcile(ctx context.Context, req ctrl. wc, err := r.clientFactory()(ctx, target.Address, target.TLSConfig) if err != nil { - if !run.DeletionTimestamp.IsZero() { - return ctrl.Result{}, r.removeFinalizer(ctx, &run) - } + // A client build failure during deletion is likely transient; requeue so + // the cancellation policy is applied rather than silently skipped. return ctrl.Result{}, fmt.Errorf("building temporal client: %w", err) } defer func() { _ = wc.Close() }() @@ -108,6 +115,14 @@ func (r *TemporalWorkflowRunReconciler) reconcileRun(ctx context.Context, run *t wfID := resolveWorkflowID(run) taskQueue := run.Spec.Workflow.TaskQueue + // Already terminal: the recorded phase/completion/failure are persisted, so + // avoid re-describing the execution. Temporal retention may have purged it + // (Describe would return ErrWorkflowNotFound), which must not turn a finished + // run into a perpetual reconcile-error loop. Only TTL bookkeeping remains. + if run.Status.CompletionTime != nil { + return r.ttlCleanup(ctx, run) + } + // Start the workflow once. if run.Status.RunID == "" { if err := checkWorkflowRunPolicy(policy, run.Spec.Namespace, taskQueue); err != nil { @@ -171,16 +186,21 @@ func (r *TemporalWorkflowRunReconciler) reconcileRun(ctx context.Context, run *t return ctrl.Result{}, err } - // TTL cleanup. - if run.Spec.TTLSecondsAfterFinished != nil { - deadline := run.Status.CompletionTime.Add(time.Duration(*run.Spec.TTLSecondsAfterFinished) * time.Second) - if remaining := time.Until(deadline); remaining > 0 { - return ctrl.Result{RequeueAfter: remaining}, nil - } - log.Info("deleting workflow run after TTL", "name", run.Name) - return ctrl.Result{}, client.IgnoreNotFound(r.Delete(ctx, run)) + return r.ttlCleanup(ctx, run) +} + +// ttlCleanup deletes the CR once its post-finished TTL elapses, requeuing until +// the deadline. When no TTL is set the run is kept indefinitely. +func (r *TemporalWorkflowRunReconciler) ttlCleanup(ctx context.Context, run *temporalv1alpha1.TemporalWorkflowRun) (ctrl.Result, error) { + if run.Spec.TTLSecondsAfterFinished == nil { + return ctrl.Result{}, nil + } + deadline := run.Status.CompletionTime.Add(time.Duration(*run.Spec.TTLSecondsAfterFinished) * time.Second) + if remaining := time.Until(deadline); remaining > 0 { + return ctrl.Result{RequeueAfter: remaining}, nil } - return ctrl.Result{}, nil + logf.FromContext(ctx).Info("deleting workflow run after TTL", "name", run.Name) + return ctrl.Result{}, client.IgnoreNotFound(r.Delete(ctx, run)) } func (r *TemporalWorkflowRunReconciler) reconcileDelete(ctx context.Context, run *temporalv1alpha1.TemporalWorkflowRun, wc temporal.WorkflowRunClient) error { @@ -192,12 +212,12 @@ func (r *TemporalWorkflowRunReconciler) reconcileDelete(ctx context.Context, run if run.Status.RunID != "" && run.Status.CompletionTime == nil { wfID := resolveWorkflowID(run) switch run.Spec.CancellationPolicy { - case "Cancel": + case temporalv1alpha1.CancellationPolicyCancel: if err := wc.Cancel(ctx, run.Spec.Namespace, wfID, run.Status.RunID, "TemporalWorkflowRun deleted"); err != nil && !errors.Is(err, temporal.ErrWorkflowNotFound) { return fmt.Errorf("cancelling workflow: %w", err) } log.Info("requested workflow cancellation on delete", "workflowID", wfID) - case "Terminate": + case temporalv1alpha1.CancellationPolicyTerminate: if err := wc.Terminate(ctx, run.Spec.Namespace, wfID, run.Status.RunID, "TemporalWorkflowRun deleted"); err != nil && !errors.Is(err, temporal.ErrWorkflowNotFound) { return fmt.Errorf("terminating workflow: %w", err) } diff --git a/internal/controller/temporalworkflowrun_controller_test.go b/internal/controller/temporalworkflowrun_controller_test.go index 6493b0d0..69d491b0 100644 --- a/internal/controller/temporalworkflowrun_controller_test.go +++ b/internal/controller/temporalworkflowrun_controller_test.go @@ -19,6 +19,7 @@ package controller import ( "context" "crypto/tls" + "errors" "fmt" . "github.com/onsi/ginkgo/v2" @@ -28,6 +29,7 @@ import ( "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/reconcile" temporalv1alpha1 "github.com/bmorton/temporal-operator/api/v1alpha1" @@ -40,6 +42,8 @@ type fakeWorkflowRunClient struct { canceled, terminated []string status enumspb.WorkflowExecutionStatus failure *temporal.WorkflowFailure + describeErr error + describeCalls int } func (f *fakeWorkflowRunClient) Start(_ context.Context, _, _ string, p temporal.StartWorkflowParams) (string, error) { @@ -51,6 +55,10 @@ func (f *fakeWorkflowRunClient) Start(_ context.Context, _, _ string, p temporal } func (f *fakeWorkflowRunClient) Describe(_ context.Context, _, _, _ string) (*temporal.WorkflowExecutionInfo, error) { + f.describeCalls++ + if f.describeErr != nil { + return nil, f.describeErr + } return &temporal.WorkflowExecutionInfo{Status: f.status, RunID: "run", Failure: f.failure}, nil } @@ -71,8 +79,12 @@ var _ = Describe("TemporalWorkflowRun reconciler", func() { ctx := context.Background() var counter int var fake *fakeWorkflowRunClient + var factoryErr error var factory temporal.WorkflowRunClientFactory = func(_ context.Context, _ string, _ *tls.Config) (temporal.WorkflowRunClient, error) { + if factoryErr != nil { + return nil, factoryErr + } return fake, nil } reconciler := func() *TemporalWorkflowRunReconciler { @@ -108,6 +120,7 @@ var _ = Describe("TemporalWorkflowRun reconciler", func() { BeforeEach(func() { counter++ fake = &fakeWorkflowRunClient{} + factoryErr = nil }) It("starts the workflow and records Running status", func() { @@ -172,7 +185,7 @@ var _ = Describe("TemporalWorkflowRun reconciler", func() { It("terminates a running workflow on delete with cancellationPolicy=Terminate", func() { c := newReadyCluster(fmt.Sprintf("cluster-%d", counter), &temporalv1alpha1.WorkflowRunPolicy{Enabled: true}) run := newRun(fmt.Sprintf("run-%d", counter), c.Name) - run.Spec.CancellationPolicy = "Terminate" + run.Spec.CancellationPolicy = temporalv1alpha1.CancellationPolicyTerminate Expect(k8sClient.Create(ctx, run)).To(Succeed()) req := reconcile.Request{NamespacedName: types.NamespacedName{Name: run.Name, Namespace: testNamespace}} @@ -257,7 +270,7 @@ var _ = Describe("TemporalWorkflowRun reconciler", func() { It("cancels a running workflow on delete with cancellationPolicy=Cancel", func() { c := newReadyCluster(fmt.Sprintf("cluster-%d", counter), &temporalv1alpha1.WorkflowRunPolicy{Enabled: true}) run := newRun(fmt.Sprintf("run-%d", counter), c.Name) - run.Spec.CancellationPolicy = "Cancel" + run.Spec.CancellationPolicy = temporalv1alpha1.CancellationPolicyCancel Expect(k8sClient.Create(ctx, run)).To(Succeed()) req := reconcile.Request{NamespacedName: types.NamespacedName{Name: run.Name, Namespace: testNamespace}} @@ -273,7 +286,7 @@ var _ = Describe("TemporalWorkflowRun reconciler", func() { It("does not cancel or terminate with cancellationPolicy=Abandon", func() { c := newReadyCluster(fmt.Sprintf("cluster-%d", counter), &temporalv1alpha1.WorkflowRunPolicy{Enabled: true}) run := newRun(fmt.Sprintf("run-%d", counter), c.Name) - run.Spec.CancellationPolicy = "Abandon" + run.Spec.CancellationPolicy = temporalv1alpha1.CancellationPolicyAbandon Expect(k8sClient.Create(ctx, run)).To(Succeed()) req := reconcile.Request{NamespacedName: types.NamespacedName{Name: run.Name, Namespace: testNamespace}} @@ -286,4 +299,62 @@ var _ = Describe("TemporalWorkflowRun reconciler", func() { Expect(fake.canceled).To(BeEmpty()) Expect(fake.terminated).To(BeEmpty()) }) + + It("does not re-describe a finished run whose execution has aged out of Temporal", func() { + c := newReadyCluster(fmt.Sprintf("cluster-%d", counter), &temporalv1alpha1.WorkflowRunPolicy{Enabled: true}) + fake.status = enumspb.WORKFLOW_EXECUTION_STATUS_COMPLETED + run := newRun(fmt.Sprintf("run-%d", counter), c.Name) + Expect(k8sClient.Create(ctx, run)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, run) }) + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: run.Name, Namespace: testNamespace}} + _, err := reconciler().Reconcile(ctx, req) // start + reach terminal, record CompletionTime + Expect(err).NotTo(HaveOccurred()) + + var got temporalv1alpha1.TemporalWorkflowRun + Expect(k8sClient.Get(ctx, req.NamespacedName, &got)).To(Succeed()) + Expect(got.Status.CompletionTime).NotTo(BeNil()) + + // Simulate the execution being purged from Temporal by retention. + fake.describeErr = temporal.ErrWorkflowNotFound + callsBefore := fake.describeCalls + _, err = reconciler().Reconcile(ctx, req) + Expect(err).NotTo(HaveOccurred(), "a finished run must not error when its execution is gone") + Expect(fake.describeCalls).To(Equal(callsBefore), "a finished run must not re-describe the execution") + }) + + It("keeps the finalizer and requeues when the client is unavailable during deletion", func() { + c := newReadyCluster(fmt.Sprintf("cluster-%d", counter), &temporalv1alpha1.WorkflowRunPolicy{Enabled: true}) + run := newRun(fmt.Sprintf("run-%d", counter), c.Name) + run.Spec.CancellationPolicy = temporalv1alpha1.CancellationPolicyTerminate + Expect(k8sClient.Create(ctx, run)).To(Succeed()) + + req := reconcile.Request{NamespacedName: types.NamespacedName{Name: run.Name, Namespace: testNamespace}} + _, err := reconciler().Reconcile(ctx, req) // adds finalizer + starts + Expect(err).NotTo(HaveOccurred()) + + Expect(k8sClient.Delete(ctx, run)).To(Succeed()) + + // Transient client failure during deletion must not drop the finalizer. + factoryErr = errors.New("dial timeout") + _, err = reconciler().Reconcile(ctx, req) + Expect(err).To(HaveOccurred()) + + var got temporalv1alpha1.TemporalWorkflowRun + Expect(k8sClient.Get(ctx, req.NamespacedName, &got)).To(Succeed()) + Expect(controllerutil.ContainsFinalizer(&got, workflowRunFinalizer)).To(BeTrue()) + Expect(fake.terminated).To(BeEmpty()) + + // Once the client recovers, the cancellation policy is honored and the + // finalizer is released. + factoryErr = nil + _, err = reconciler().Reconcile(ctx, req) + Expect(err).NotTo(HaveOccurred()) + Expect(fake.terminated).NotTo(BeEmpty()) + Eventually(func() bool { + var g temporalv1alpha1.TemporalWorkflowRun + err := k8sClient.Get(ctx, req.NamespacedName, &g) + return err != nil && apierrors.IsNotFound(err) + }).Should(BeTrue()) + }) }) diff --git a/internal/webhook/v1alpha1/temporalworkflowrun_webhook_test.go b/internal/webhook/v1alpha1/temporalworkflowrun_webhook_test.go index 608a2ac8..1740a519 100644 --- a/internal/webhook/v1alpha1/temporalworkflowrun_webhook_test.go +++ b/internal/webhook/v1alpha1/temporalworkflowrun_webhook_test.go @@ -62,7 +62,7 @@ func TestValidateWorkflowRunImmutability(t *testing.T) { ttl := int32(60) mutable := validRun() mutable.Spec.TTLSecondsAfterFinished = &ttl - mutable.Spec.CancellationPolicy = "Terminate" + mutable.Spec.CancellationPolicy = temporalv1alpha1.CancellationPolicyTerminate if _, err := v.ValidateUpdate(context.Background(), old, mutable); err != nil { t.Fatalf("expected mutable update to pass, got %v", err) }