Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions docs/crd-conventions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Custom resource conventions

This document describes the proposed conventions for the `cloud.temporal.io` custom resources (CR) and how they map to the Temporal Cloud Ops API.

## Resource shape

### Spec structure

Put the fields of a Cloud create request's `spec` directly under the CR's `spec`. For example, `CreateNamespaceRequest.spec.apiKeyAuth` becomes `Namespace.spec.apiKeyAuth`, rather than `Namespace.spec.spec.apiKeyAuth`. This makes manifests easier to read and write. In the current Cloud create requests reviewed (as of October 2026), root request fields do not collide with fields inside the request's `spec`.

Place root request fields that describe desired state, such as the Cloud Namespace's `projectId` and `tags`, in the CR's `spec`.

Document upstream field mappings in Go field comments. These should appear in the generated CRD schema and `kubectl explain`.

### Name mapping

`metadata.name` identifies the Kubernetes CR and cannot be changed. It is used as a Cloud resource name only when the resource specific mapping below says so.

| Cloud naming field | CR mapping | Examples |
| --- | --- | --- |
| Immutable, user-chosen `spec.name` | Map CR `metadata.name` to Cloud `spec.name`; omit CR `spec.name`. | Namespace |
| Mutable `spec.name` | Map to CR `spec.name`. | Service Account, Nexus Endpoint, Custom Role. |
| Mutable `spec.displayName` | Map to CR `spec.name`. | Project, API Key, User Group. |
Comment on lines +22 to +23

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recommend actually that we standardize on spec.displayName as the name of a mutable, optional string name for those resources that support it. spec.name and metadata.name are too easily confused, IMHO. spec.displayName (again, only for those resources that support a mutable, optional name) makes it more obvious that this is different from an immutable string name identifier.

| No Cloud name field | CR `metadata.name` is a local name and is not sent as a Cloud name. | Connectivity Rule, if it gets a CRD. |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Kinds that don't have a unique string name and only have a generated ID that is returned in the HTTP response from the Temporal Cloud API (e.g. ConnectivityRule), we will want to ensure that there is a status.id field that is filled in upon successful creation of the resource.

A warning, though: the problem with this type of API behaviour is that it wreaks havoc on GitOps-driven declarative configuration systems and existing resource adoption. Since the identifier field that is used to look up a specific resource (in this example, that would be the ConnectivityRuleId field) does not exist in the CRD's Spec field, the GitOps operator will simply call kubectl apply on the Kubernetes manifest representing the desired state of the ConnectivityRule.

For the initial reconcile loop, Temporal Resource Operator will attempt to determine if the resource already exists. However, TRO has no way of looking up the ConnectivityRule because it doesn't have the status.id field set (yet). So, it will attempt to create a new ConnectivityRule with the contents of the spec fields.

If there is already a ConnectivityRule that exists with that same configuration settings, a 409 Conflict will be returned by the Temporal Cloud API and the CR will be marked as Stalled/Failed. The user will inspect the CR's status.conditions and see the reason for the stall/failure. So while the GitOps operator won't take any actions (because the spec of the ConnectivityRule in the Kubernetes manifest in the Git repo doesn't change), the user will have no way to remedy the situation because a Kubernetes manifest in a Git repository doesn't contain status fields. And thus the resource will become essentially stuck.


Changing CR `spec.name` updates the Cloud resource without renaming the CR. Both mutable Cloud naming fields map to CR `spec.name` for a consistent CR interface.

### Field naming

For naming consistency, drop an upstream `Spec` suffix when the field already sits under the CR's `spec` and the suffix adds no meaning. For example, Cloud `NamespaceSpec.capacitySpec` becomes CR `spec.capacity`. Write `ID` rather than `Id` in CR field names. e.g., use `projectID` instead of `projectId`, and `connectivityRuleIDs` instead of `connectivityRuleIds`.

### Deprecated fields

Omit deprecated Cloud fields from new CRDs when a supported replacement exists. For Namespace, use `replicas.region` instead of `regions`, and `searchAttributes` instead of `customSearchAttributes`.

### Admission validation

Validate fields through the CRD schema so the API server rejects invalid manifests when they are applied. Use OpenAPI schema rules for types, required fields, ranges, patterns, and enum values. Use CEL rules for checks that span fields or compare against the previous value. For example, a CEL rule on `spec.capacity` can require exactly one of `onDemand` and `provisioned` when `capacity` is set, and a CEL rule on an API Key can prevent `spec.ownerID` from changing after creation.

### Status structure

A CR's `status` has three parts: Cloud reference fields that link the CR to its Cloud resource, progress fields that report reconciliation, and the observed state, which is the rest of the Cloud API's get response excluding `spec`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Obviously this will need expanding when we get to that stage :) Good to focus on spec for the time being, though.


### Reconciliation

For the fields a CR manages, its `spec` is the source of truth. When the controller finds that one of those fields was changed outside the operator, for example in the Cloud UI, it restores the CR's value and records the drift in `conditions`. Errors that only the Cloud API can detect, such as an unknown region, are also reported in `conditions`.

## Example Namespace CR

This manifest illustrates the proposed CR shape. The `status` field names `cloudID` and `cloudResourceVersion` are placeholders for the Cloud reference fields, whose names are not yet decided. All values are illustrative.

```yaml
apiVersion: cloud.temporal.io/v1alpha1
kind: Namespace
metadata:
name: payments-prod
namespace: temporal-cloud
labels:
team: payments
spec:
projectID: project-123
tags:
environment: production
replicas:
- region: aws-us-west-2
retentionDays: 30
apiKeyAuth:
enabled: true
capacity:
onDemand: {}
status:
# Cloud reference
cloudID: payments-prod.a1b2c
cloudResourceVersion: "4"
# Progress
observedGeneration: 1
conditions:
- type: Reconciling
status: "False"
reason: ReconcileSucceeded
observedGeneration: 1
lastTransitionTime: "2026-10-08T10:05:00Z"
# Observed state
state: Active
activeRegion: aws-us-west-2
endpoints:
grpcAddress: payments-prod.a1b2c.tmprl.cloud:7233
replicas:
- id: replica-1
region: aws-us-west-2
isPrimary: true
state: Active
limits:
actionsPerSecondLimit: 400
createdTime: "2026-10-08T10:00:00Z"
lastModifiedTime: "2026-10-08T10:05:00Z"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would recommend going with a slightly different structure:

status:
  # Cloud reference
  cloud:
    id: payments-prod.a1b2c
    resourceVersion: "4"
  # Progress
  observedGeneration: 1
  conditions:
    - type: Reconciling
      status: "False"
      reason: ReconcileSucceeded
      observedGeneration: 1
      lastTransitionTime: "2026-10-08T10:05:00Z"
  # Observed state
  observed:
    state: Active
    activeRegion: aws-us-west-2
    endpoints:
      grpcAddress: payments-prod.a1b2c.tmprl.cloud:7233
    replicas:
      - id: replica-1
        region: aws-us-west-2
        isPrimary: true
        state: Active
    limits:
      actionsPerSecondLimit: 400
    createdTime: "2026-10-08T10:00:00Z"
    lastModifiedTime: "2026-10-08T10:05:00Z"

That way, we can have a common core.temporal.io/v1 StatusCloud struct containing the common id and resourceVersion fields in all HTTP responses from Temporal Cloud API and then add StatusCloud as a field to the existing core.temporal.io/v1 StatusBase struct.

Also, we can have a nice, clean status.observed field that contains essentially the rest of the response fields from the Temporal Cloud API call.

```

## References

- [Temporal Cloud Ops API reference](https://saas-api.tmprl.cloud/docs/httpapi.html)
- [Cloud API protobuf source](https://github.com/temporalio/cloud-api/tree/main/temporal/api/cloud)
Loading