diff --git a/.github/workflows/chant.yml b/.github/workflows/chant.yml index a197d7e7..13da47a0 100644 --- a/.github/workflows/chant.yml +++ b/.github/workflows/chant.yml @@ -40,6 +40,7 @@ jobs: npm run --prefix lexicons/helm prepack npm run --prefix lexicons/docker prepack npm run --prefix lexicons/slurm prepack + npm run --prefix lexicons/temporal prepack - name: Typecheck run: npx tsc --noEmit -p packages/core/tsconfig.json @@ -89,6 +90,7 @@ jobs: npm run --prefix lexicons/helm prepack npm run --prefix lexicons/docker prepack npm run --prefix lexicons/slurm prepack + npm run --prefix lexicons/temporal prepack - name: Run tests run: npx vitest run @@ -144,6 +146,9 @@ jobs: - name: Generate and validate Slurm lexicon run: npm run --prefix lexicons/slurm prepack + - name: Generate and validate Temporal lexicon + run: npm run --prefix lexicons/temporal prepack + smoke-npm: if: false # disabled — Docker smoke tests are for local/release validation, not every push runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e80605ac..e6854d77 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -35,6 +35,7 @@ jobs: - run: npm run --prefix lexicons/helm prepack - run: npm run --prefix lexicons/docker prepack - run: npm run --prefix lexicons/slurm prepack + - run: npm run --prefix lexicons/temporal prepack - run: npx vitest run publish: @@ -129,3 +130,10 @@ jobs: V=$(node -e "process.stdout.write(require('./package.json').version)") P=$(npm view @intentius/chant-lexicon-slurm version 2>/dev/null || echo "none") [ "$V" = "$P" ] && echo "Already at $V, skipping" || npm publish --access public + + - name: Publish @intentius/chant-lexicon-temporal + working-directory: lexicons/temporal + run: | + V=$(node -e "process.stdout.write(require('./package.json').version)") + P=$(npm view @intentius/chant-lexicon-temporal version 2>/dev/null || echo "none") + [ "$V" = "$P" ] && echo "Already at $V, skipping" || npm publish --access public diff --git a/examples/temporal-crdb-deploy/chant.config.ts b/examples/temporal-crdb-deploy/chant.config.ts new file mode 100644 index 00000000..a7c2522d --- /dev/null +++ b/examples/temporal-crdb-deploy/chant.config.ts @@ -0,0 +1,35 @@ +/** + * chant configuration — Temporal worker profiles. + * + * The `temporal.profiles` object is the single source of truth for how this project's + * Temporal worker connects to Temporal Cloud. Importing this config in worker.ts means + * connection configuration is version-controlled and TypeScript-checked — a missing + * `taskQueue` or wrong namespace is a compile error, not a runtime failure after 5 minutes. + * + * Usage in worker.ts: + * import config from "../chant.config.ts"; + * const profile = config.temporal.profiles[config.temporal.defaultProfile ?? "cloud"]; + */ + +import type { TemporalChantConfig } from "@intentius/chant-lexicon-temporal"; + +export default { + temporal: { + profiles: { + cloud: { + address: process.env.TEMPORAL_ADDRESS ?? "crdb-deploy.a2dd6.tmprl.cloud:7233", + namespace: process.env.TEMPORAL_NAMESPACE ?? "crdb-deploy.a2dd6", + taskQueue: "crdb-deploy", + tls: true, + apiKey: { env: "TEMPORAL_API_KEY" }, + }, + local: { + address: "localhost:7233", + namespace: "default", + taskQueue: "crdb-deploy", + autoStart: true, + }, + }, + defaultProfile: "cloud", + } satisfies TemporalChantConfig, +}; diff --git a/examples/temporal-crdb-deploy/package.json b/examples/temporal-crdb-deploy/package.json index 1c3e759a..f8c975b2 100644 --- a/examples/temporal-crdb-deploy/package.json +++ b/examples/temporal-crdb-deploy/package.json @@ -4,15 +4,17 @@ "type": "module", "private": true, "scripts": { - "build": "npm run build:shared && npm run build:east && npm run build:central && npm run build:west", + "build": "npm run build:temporal && npm run build:shared && npm run build:east && npm run build:central && npm run build:west", "build:shared": "chant build src/shared --lexicon gcp -o dist/shared-infra.yaml", "build:east": "chant build src/east --lexicon gcp -o dist/east-infra.yaml && chant build src/east --lexicon k8s -o dist/east-k8s.yaml", "build:central": "chant build src/central --lexicon gcp -o dist/central-infra.yaml && chant build src/central --lexicon k8s -o dist/central-k8s.yaml", "build:west": "chant build src/west --lexicon gcp -o dist/west-infra.yaml && chant build src/west --lexicon k8s -o dist/west-k8s.yaml", + "build:temporal": "chant build src --lexicon temporal -o dist/temporal-setup.sh", "lint": "chant lint src/shared && chant lint src/east && chant lint src/central && chant lint src/west", "bootstrap": "bash scripts/bootstrap.sh", "deploy": "bash scripts/deploy.sh", "teardown": "bash scripts/teardown.sh", + "temporal:setup": "bash dist/temporal-setup.sh", "temporal:worker": "tsx temporal/worker.ts", "temporal:deploy": "tsx temporal/client.ts start", "temporal:signal": "tsx temporal/client.ts signal", @@ -23,6 +25,7 @@ "@intentius/chant": "*", "@intentius/chant-lexicon-gcp": "*", "@intentius/chant-lexicon-k8s": "*", + "@intentius/chant-lexicon-temporal": "*", "@temporalio/activity": "^1.10.4", "@temporalio/client": "^1.10.4", "@temporalio/worker": "^1.10.4", diff --git a/examples/temporal-crdb-deploy/src/central/infra.ts b/examples/temporal-crdb-deploy/src/central/infra.ts new file mode 100644 index 00000000..98432821 --- /dev/null +++ b/examples/temporal-crdb-deploy/src/central/infra.ts @@ -0,0 +1,37 @@ +/** + * Central region (us-central1) GCP infrastructure. + * + * Replaces: infra/cluster.ts + infra/dns.ts + */ + +import { GkeCrdbRegion } from "@intentius/chant-lexicon-gcp"; +import { GCP_PROJECT_ID, CRDB_DOMAIN, BACKUP_BUCKET } from "../shared/config"; + +export const central = GkeCrdbRegion({ + region: "us-central1", + clusterName: "gke-crdb-central", + network: "crdb-multi-region", + subnetwork: "crdb-multi-region-central-nodes", + domain: `central.${CRDB_DOMAIN}`, + project: GCP_PROJECT_ID, + crdbNamespace: "crdb-central", + masterCidr: "172.17.0.0/28", + nodeConfig: { + machineType: "n2-standard-2", + diskSizeGb: 100, + nodeCount: 1, + maxNodeCount: 3, + }, + backupBucket: BACKUP_BUCKET, +}); + +export const cluster = central.cluster; +export const nodePool = central.nodePool; +export const defaultPool = central.defaultPool; +export const dnsZone = central.dnsZone; +export const dnsGsa = central.dnsGsa; +export const dnsWiBinding = central.dnsWiBinding; +export const dnsAdminBinding = central.dnsAdminBinding; +export const crdbGsa = central.crdbGsa; +export const crdbWiBinding = central.crdbWiBinding; +export const crdbBackupBinding = (central as Record).crdbBackupBinding; diff --git a/examples/temporal-crdb-deploy/src/central/infra/cluster.ts b/examples/temporal-crdb-deploy/src/central/infra/cluster.ts deleted file mode 100644 index a8715576..00000000 --- a/examples/temporal-crdb-deploy/src/central/infra/cluster.ts +++ /dev/null @@ -1,64 +0,0 @@ -// GCP infrastructure: GKE cluster (us-central1) + Workload Identity IAM bindings. -// Sized for CockroachDB: 3x e2-standard-2 (2 vCPU / 8 GiB) worker nodes. - -import { - GkeCluster, - GCPServiceAccount, - IAMPolicyMember, -} from "@intentius/chant-lexicon-gcp"; -import { config } from "../config"; - -// ── GKE Cluster ──────────────────────────────────────────────────── - -export const { cluster, nodePool, defaultPool } = GkeCluster({ - name: config.clusterName, - location: config.region, - machineType: "e2-standard-2", - network: "crdb-multi-region", - subnetwork: "crdb-multi-region-central-nodes", - minNodeCount: 1, - maxNodeCount: 1, - diskSizeGb: 100, - releaseChannel: "REGULAR", - workloadIdentity: true, - privateNodes: true, - masterCidr: "172.16.1.0/28", -}); - -// ── Workload Identity — ExternalDNS Service Account ──────────────── - -export const externalDnsGsa = new GCPServiceAccount({ - metadata: { - name: "gke-crdb-central-dns", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - displayName: "GKE CockroachDB central external-dns workload identity", -}); - -export const externalDnsWiBinding = new IAMPolicyMember({ - metadata: { - name: "gke-crdb-central-dns-wi", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - member: `serviceAccount:${config.projectId}.svc.id.goog[kube-system/external-dns-sa]`, - role: "roles/iam.workloadIdentityUser", - resourceRef: { - apiVersion: "iam.cnrm.cloud.google.com/v1beta1", - kind: "IAMServiceAccount", - name: "gke-crdb-central-dns", - }, -}); - -export const externalDnsProjectBinding = new IAMPolicyMember({ - metadata: { - name: "gke-crdb-central-dns-project", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - member: `serviceAccount:gke-crdb-central-dns@${config.projectId}.iam.gserviceaccount.com`, - role: "roles/dns.admin", - resourceRef: { - apiVersion: "resourcemanager.cnrm.cloud.google.com/v1beta1", - kind: "Project", - external: `projects/${config.projectId}`, - }, -}); diff --git a/examples/temporal-crdb-deploy/src/central/infra/dns.ts b/examples/temporal-crdb-deploy/src/central/infra/dns.ts deleted file mode 100644 index 5df6887a..00000000 --- a/examples/temporal-crdb-deploy/src/central/infra/dns.ts +++ /dev/null @@ -1,13 +0,0 @@ -// DNS: Cloud DNS public zone for the central CockroachDB UI subdomain. - -import { DNSManagedZone } from "@intentius/chant-lexicon-gcp"; -import { config } from "../config"; - -export const dnsZone = new DNSManagedZone({ - metadata: { - name: "crdb-central-zone", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - dnsName: `${config.domain}.`, - description: "CockroachDB central UI — managed by chant", -}); diff --git a/examples/temporal-crdb-deploy/src/central/k8s.ts b/examples/temporal-crdb-deploy/src/central/k8s.ts new file mode 100644 index 00000000..be800ef7 --- /dev/null +++ b/examples/temporal-crdb-deploy/src/central/k8s.ts @@ -0,0 +1,52 @@ +/** + * Central region (us-central1) Kubernetes resources. + * + * Replaces: k8s/namespace.ts + k8s/storage.ts + k8s/cockroachdb.ts + + * k8s/external-secrets.ts + k8s/ingress.ts + k8s/tls.ts + + * k8s/backend-config.ts + k8s/monitoring.ts (8 files → 1 call) + */ + +import { CockroachDbRegionStack } from "@intentius/chant-lexicon-k8s"; +import { GCP_PROJECT_ID, CRDB_DOMAIN, INTERNAL_DOMAIN, CRDB_CLUSTER, ALL_CIDRS } from "../shared/config"; + +export const central = CockroachDbRegionStack({ + region: "central", + namespace: "crdb-central", + domain: `central.${CRDB_DOMAIN}`, + internalDomain: `central.${INTERNAL_DOMAIN}`, + publicRootDomain: CRDB_DOMAIN, + + projectId: GCP_PROJECT_ID, + clusterName: "gke-crdb-central", + clusterRegion: "us-central1", + crdbGsaEmail: `gke-crdb-central-crdb@${GCP_PROJECT_ID}.iam.gserviceaccount.com`, + externalDnsGsaEmail: `gke-crdb-central-dns@${GCP_PROJECT_ID}.iam.gserviceaccount.com`, + + cockroachdb: { + ...CRDB_CLUSTER, + locality: "cloud=gcp,region=us-central1", + skipInit: true, + mountClientCerts: true, + advertiseHostDomain: `central.${INTERNAL_DOMAIN}`, + extraCertNodeAddresses: [ + `cockroachdb-0.central.${INTERNAL_DOMAIN}`, + `cockroachdb-1.central.${INTERNAL_DOMAIN}`, + `cockroachdb-2.central.${INTERNAL_DOMAIN}`, + ], + }, + + tls: { + gcpSecretNames: { + ca: "crdb-ca-crt", + nodeCrt: "crdb-node-crt", + nodeKey: "crdb-node-key", + clientRootCrt: "crdb-client-root-crt", + clientRootKey: "crdb-client-root-key", + }, + }, + + quota: { cpu: "8", memory: "20Gi", maxPods: 25 }, + allowCidrs: ALL_CIDRS, + cloudArmor: { policyName: "crdb-ui-waf" }, + monitoring: true, +}); diff --git a/examples/temporal-crdb-deploy/src/central/k8s/backend-config.ts b/examples/temporal-crdb-deploy/src/central/k8s/backend-config.ts deleted file mode 100644 index 84312b87..00000000 --- a/examples/temporal-crdb-deploy/src/central/k8s/backend-config.ts +++ /dev/null @@ -1,17 +0,0 @@ -// BackendConfig: Cloud Armor WAF policy attachment + health check for CockroachDB UI. - -import { createResource } from "@intentius/chant/runtime"; - -const BackendConfig = createResource("K8s::GKE::BackendConfig", "k8s", {}); - -export const crdbBackendConfig = new BackendConfig({ - metadata: { - name: "crdb-ui-backend", - namespace: "crdb-central", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - spec: { - securityPolicy: { name: "crdb-ui-waf" }, - healthCheck: { type: "HTTPS", requestPath: "/health", port: 8080 }, - }, -}); diff --git a/examples/temporal-crdb-deploy/src/central/k8s/cockroachdb.ts b/examples/temporal-crdb-deploy/src/central/k8s/cockroachdb.ts deleted file mode 100644 index 7773c61d..00000000 --- a/examples/temporal-crdb-deploy/src/central/k8s/cockroachdb.ts +++ /dev/null @@ -1,71 +0,0 @@ -// K8s workloads: CockroachDB cluster (central slice — 3 of 9 nodes). -// Headless service annotated for ExternalDNS to register pod IPs in Cloud DNS private zone. - -import { CockroachDbCluster } from "@intentius/chant-lexicon-k8s"; -import { config } from "../config"; -import { INTERNAL_DOMAIN } from "../../shared/config"; - -const NAMESPACE = "crdb-central"; - -const crdb = CockroachDbCluster({ - name: config.name, - namespace: NAMESPACE, - replicas: config.replicas, - image: config.image, - storageSize: config.storageSize, - storageClassName: "pd-ssd", - cpuLimit: config.cpuLimit, - memoryLimit: config.memoryLimit, - locality: config.locality, - joinAddresses: config.joinAddresses, - secure: true, - skipInit: true, - skipCertGen: true, - mountClientCerts: true, - advertiseHostDomain: `central.${INTERNAL_DOMAIN}`, - extraCertNodeAddresses: [ - `cockroachdb-0.central.${INTERNAL_DOMAIN}`, - `cockroachdb-1.central.${INTERNAL_DOMAIN}`, - `cockroachdb-2.central.${INTERNAL_DOMAIN}`, - ], - labels: { - "app.kubernetes.io/part-of": "cockroachdb-multi-region", - "app.kubernetes.io/instance": "central", - }, - defaults: { - serviceAccount: { - metadata: { - annotations: { - "iam.gke.io/gcp-service-account": config.crdbGsaEmail, - }, - }, - }, - publicService: { - metadata: { - annotations: { - "cloud.google.com/backend-config": '{"default":"crdb-ui-backend"}', - "cloud.google.com/app-protocols": '{"http":"HTTPS"}', - }, - }, - }, - headlessService: { - metadata: { - annotations: { - "external-dns.alpha.kubernetes.io/hostname": `central.${INTERNAL_DOMAIN}`, - }, - }, - }, - }, -}); - -export const cockroachdbServiceAccount = crdb.serviceAccount; -export const cockroachdbRole = crdb.role; -export const cockroachdbRoleBinding = crdb.roleBinding; -export const cockroachdbClusterRole = crdb.clusterRole; -export const cockroachdbClusterRoleBinding = crdb.clusterRoleBinding; -export const cockroachdbPublicService = crdb.publicService; -export const cockroachdbHeadlessService = crdb.headlessService; -export const cockroachdbPdb = crdb.pdb; -export const cockroachdbStatefulSet = crdb.statefulSet; -// No initJob for central — skipInit: true, east runs cockroach init for the full cluster -export const cockroachdbCertGenJob = crdb.certGenJob; diff --git a/examples/temporal-crdb-deploy/src/central/k8s/external-secrets.ts b/examples/temporal-crdb-deploy/src/central/k8s/external-secrets.ts deleted file mode 100644 index d3fdd34d..00000000 --- a/examples/temporal-crdb-deploy/src/central/k8s/external-secrets.ts +++ /dev/null @@ -1,53 +0,0 @@ -// External Secrets: ClusterSecretStore + ExternalSecrets for CockroachDB TLS certs. - -import { createResource } from "@intentius/chant/runtime"; -import { config } from "../config"; - -const ClusterSecretStore = createResource("K8s::ExternalSecrets::ClusterSecretStore", "k8s", {}); -const ExternalSecret = createResource("K8s::ExternalSecrets::ExternalSecret", "k8s", {}); - -export const gcpSecretStore = new ClusterSecretStore({ - metadata: { name: "gcp-secret-manager" }, - spec: { - provider: { - gcpsm: { - projectID: config.projectId, - auth: { - workloadIdentity: { - clusterLocation: config.region, - clusterName: config.clusterName, - serviceAccountRef: { name: "external-secrets-sa", namespace: "kube-system" }, - }, - }, - }, - }, - }, -}); - -export const nodeCertsSecret = new ExternalSecret({ - metadata: { name: "cockroachdb-node-certs-eso", namespace: "crdb-central" }, - spec: { - refreshInterval: "1h", - secretStoreRef: { name: "gcp-secret-manager", kind: "ClusterSecretStore" }, - target: { name: "cockroachdb-node-certs", creationPolicy: "Owner" }, - data: [ - { secretKey: "ca.crt", remoteRef: { key: "crdb-ca-crt" } }, - { secretKey: "node.crt", remoteRef: { key: "crdb-node-crt" } }, - { secretKey: "node.key", remoteRef: { key: "crdb-node-key" } }, - ], - }, -}); - -export const clientCertsSecret = new ExternalSecret({ - metadata: { name: "cockroachdb-client-certs-eso", namespace: "crdb-central" }, - spec: { - refreshInterval: "1h", - secretStoreRef: { name: "gcp-secret-manager", kind: "ClusterSecretStore" }, - target: { name: "cockroachdb-client-certs", creationPolicy: "Owner" }, - data: [ - { secretKey: "ca.crt", remoteRef: { key: "crdb-ca-crt" } }, - { secretKey: "client.root.crt", remoteRef: { key: "crdb-client-root-crt" } }, - { secretKey: "client.root.key", remoteRef: { key: "crdb-client-root-key" } }, - ], - }, -}); diff --git a/examples/temporal-crdb-deploy/src/central/k8s/ingress.ts b/examples/temporal-crdb-deploy/src/central/k8s/ingress.ts deleted file mode 100644 index f709676f..00000000 --- a/examples/temporal-crdb-deploy/src/central/k8s/ingress.ts +++ /dev/null @@ -1,48 +0,0 @@ -// K8s workloads: GCE Ingress for CockroachDB UI + ExternalDNS agent. -// ExternalDNS manages both public zone (UI ingress) and private zone (pod discovery). - -import { - GceIngress, - GkeExternalDnsAgent, -} from "@intentius/chant-lexicon-k8s"; -import { config } from "../config"; -import { CRDB_DOMAIN, INTERNAL_DOMAIN } from "../../shared/config"; -import { crdbBackendConfig } from "./backend-config"; -import { crdbManagedCert, crdbFrontendConfig } from "./tls"; - -const NAMESPACE = "crdb-central"; - -// ── GCE Ingress ──────────────────────────────────────────────────── - -const ing = GceIngress({ - name: "cockroachdb-ui", - hosts: [ - { - hostname: config.domain, - paths: [{ path: "/", serviceName: "cockroachdb-public", servicePort: 8080 }], - }, - ], - namespace: NAMESPACE, - managedCertificate: "crdb-ui-cert", - frontendConfig: "crdb-ui-frontend", -}); - -export const gceIngress = ing.ingress; -export { crdbBackendConfig }; -export { crdbManagedCert, crdbFrontendConfig }; - -// ── ExternalDNS ──────────────────────────────────────────────────── -// Watches headless Services to register pod IPs in crdb.internal private zone. - -const dns = GkeExternalDnsAgent({ - gcpServiceAccountEmail: config.externalDnsGsaEmail, - gcpProjectId: config.projectId, - domainFilters: [CRDB_DOMAIN, INTERNAL_DOMAIN], - txtOwnerId: config.clusterName, - source: ["service", "ingress"], -}); - -export const dnsDeployment = dns.deployment; -export const dnsServiceAccount = dns.serviceAccount; -export const dnsClusterRole = dns.clusterRole; -export const dnsClusterRoleBinding = dns.clusterRoleBinding; diff --git a/examples/temporal-crdb-deploy/src/central/k8s/monitoring.ts b/examples/temporal-crdb-deploy/src/central/k8s/monitoring.ts deleted file mode 100644 index d87a9a6b..00000000 --- a/examples/temporal-crdb-deploy/src/central/k8s/monitoring.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Prometheus monitoring for CockroachDB metrics (central region). - -import { Deployment, Service, ConfigMap } from "@intentius/chant-lexicon-k8s"; - -const NAMESPACE = "crdb-central"; - -export const prometheusConfig = new ConfigMap({ - metadata: { name: "prometheus-config", namespace: NAMESPACE }, - data: { - "prometheus.yml": ` -global: - scrape_interval: 15s -scrape_configs: - - job_name: "cockroachdb" - kubernetes_sd_configs: - - role: pod - namespaces: - names: ["${NAMESPACE}"] - relabel_configs: - - source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name] - regex: "cockroachdb" - action: keep - - source_labels: [__meta_kubernetes_pod_ip] - target_label: __address__ - replacement: "\${1}:8080" - - source_labels: [__meta_kubernetes_pod_name] - target_label: instance - metrics_path: "/_status/vars" -`, - }, -}); - -export const prometheusDeployment = new Deployment({ - metadata: { - name: "prometheus", - namespace: NAMESPACE, - labels: { "app.kubernetes.io/name": "prometheus", "app.kubernetes.io/managed-by": "chant" }, - }, - spec: { - replicas: 1, - selector: { matchLabels: { "app.kubernetes.io/name": "prometheus" } }, - template: { - metadata: { labels: { "app.kubernetes.io/name": "prometheus" } }, - spec: { - containers: [{ - name: "prometheus", - image: "prom/prometheus:v2.51.0", - args: ["--config.file=/etc/prometheus/prometheus.yml", "--storage.tsdb.retention.time=15d"], - ports: [{ name: "http", containerPort: 9090 }], - resources: { requests: { cpu: "500m", memory: "1Gi" }, limits: { cpu: "1", memory: "2Gi" } }, - volumeMounts: [{ name: "config", mountPath: "/etc/prometheus" }], - }], - volumes: [{ name: "config", configMap: { name: "prometheus-config" } }], - }, - }, - }, -}); - -export const prometheusService = new Service({ - metadata: { name: "prometheus", namespace: NAMESPACE }, - spec: { - selector: { "app.kubernetes.io/name": "prometheus" }, - ports: [{ name: "http", port: 9090, targetPort: "http" }], - }, -}); diff --git a/examples/temporal-crdb-deploy/src/central/k8s/namespace.ts b/examples/temporal-crdb-deploy/src/central/k8s/namespace.ts deleted file mode 100644 index 0e701801..00000000 --- a/examples/temporal-crdb-deploy/src/central/k8s/namespace.ts +++ /dev/null @@ -1,54 +0,0 @@ -// K8s workloads: Namespace with resource quotas and network policy. - -import { - NetworkPolicy, - NamespaceEnv, -} from "@intentius/chant-lexicon-k8s"; -import { ALL_CIDRS } from "../../shared/config"; - -const ns = NamespaceEnv({ - name: "crdb-central", - cpuQuota: "8", - memoryQuota: "20Gi", - maxPods: 25, - defaultCpuRequest: "250m", - defaultMemoryRequest: "512Mi", - defaultCpuLimit: "1", - defaultMemoryLimit: "4Gi", - defaultDenyIngress: true, - labels: { - "pod-security.kubernetes.io/enforce": "baseline", - "pod-security.kubernetes.io/enforce-version": "latest", - "pod-security.kubernetes.io/warn": "restricted", - "pod-security.kubernetes.io/audit": "restricted", - }, -}); - -export const namespace = ns.namespace; -export const resourceQuota = ns.resourceQuota; -export const limitRange = ns.limitRange; -export const networkPolicy = ns.networkPolicy; - -// Allow CockroachDB inter-node (26257) and HTTP UI (8080) traffic from all region CIDRs. -export const crdbIngressPolicy = new NetworkPolicy({ - metadata: { - name: "allow-cockroachdb", - namespace: "crdb-central", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - spec: { - podSelector: { - matchLabels: { "app.kubernetes.io/name": "cockroachdb" }, - }, - policyTypes: ["Ingress"], - ingress: [ - { - from: ALL_CIDRS.map((cidr) => ({ ipBlock: { cidr } })), - ports: [ - { protocol: "TCP", port: 26257 }, - { protocol: "TCP", port: 8080 }, - ], - }, - ], - }, -}); diff --git a/examples/temporal-crdb-deploy/src/central/k8s/storage.ts b/examples/temporal-crdb-deploy/src/central/k8s/storage.ts deleted file mode 100644 index b1d655fb..00000000 --- a/examples/temporal-crdb-deploy/src/central/k8s/storage.ts +++ /dev/null @@ -1,10 +0,0 @@ -// K8s workloads: GCE Persistent Disk StorageClass for SSD volumes. - -import { GcePdStorageClass } from "@intentius/chant-lexicon-k8s"; - -const pd = GcePdStorageClass({ - name: "pd-ssd", - type: "pd-ssd", -}); - -export const storageClass = pd.storageClass; diff --git a/examples/temporal-crdb-deploy/src/central/k8s/tls.ts b/examples/temporal-crdb-deploy/src/central/k8s/tls.ts deleted file mode 100644 index 76012746..00000000 --- a/examples/temporal-crdb-deploy/src/central/k8s/tls.ts +++ /dev/null @@ -1,36 +0,0 @@ -// GKE ManagedCertificate + FrontendConfig for CockroachDB UI HTTPS termination. -// ManagedCertificate provisions a Google-managed TLS cert via ACME HTTP-01. -// FrontendConfig enforces HTTP→HTTPS redirect at the load balancer. - -import { createResource } from "@intentius/chant/runtime"; -import { config } from "../config"; - -const ManagedCertificate = createResource("K8s::NetworkingGKE::ManagedCertificate", "k8s", {}); -const FrontendConfig = createResource("K8s::NetworkingGKEBeta::FrontendConfig", "k8s", {}); - -const NAMESPACE = "crdb-central"; - -export const crdbManagedCert = new ManagedCertificate({ - metadata: { - name: "crdb-ui-cert", - namespace: NAMESPACE, - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - spec: { - domains: [config.domain], - }, -}); - -export const crdbFrontendConfig = new FrontendConfig({ - metadata: { - name: "crdb-ui-frontend", - namespace: NAMESPACE, - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - spec: { - redirectToHttps: { - enabled: true, - responseCodeName: "MOVED_PERMANENTLY_DEFAULT", - }, - }, -}); diff --git a/examples/temporal-crdb-deploy/src/east/infra.ts b/examples/temporal-crdb-deploy/src/east/infra.ts new file mode 100644 index 00000000..c035a087 --- /dev/null +++ b/examples/temporal-crdb-deploy/src/east/infra.ts @@ -0,0 +1,42 @@ +/** + * East region (us-east4) GCP infrastructure. + * + * GkeCrdbRegion creates: GKE cluster + node pools, public DNS zone, + * ExternalDNS GSA + WI bindings, CRDB GSA + WI binding, and (via backupBucket) + * the GCS access binding for backups. + * + * Replaces: infra/cluster.ts + infra/dns.ts + */ + +import { GkeCrdbRegion } from "@intentius/chant-lexicon-gcp"; +import { GCP_PROJECT_ID, CRDB_DOMAIN, BACKUP_BUCKET } from "../shared/config"; + +export const east = GkeCrdbRegion({ + region: "us-east4", + clusterName: "gke-crdb-east", + network: "crdb-multi-region", + subnetwork: "crdb-multi-region-east-nodes", + domain: `east.${CRDB_DOMAIN}`, + project: GCP_PROJECT_ID, + crdbNamespace: "crdb-east", + masterCidr: "172.16.0.0/28", + nodeConfig: { + machineType: "n2-standard-2", + diskSizeGb: 100, + nodeCount: 1, + maxNodeCount: 3, + }, + backupBucket: BACKUP_BUCKET, +}); + +// Re-export individual resources for chant discovery +export const cluster = east.cluster; +export const nodePool = east.nodePool; +export const defaultPool = east.defaultPool; +export const dnsZone = east.dnsZone; +export const dnsGsa = east.dnsGsa; +export const dnsWiBinding = east.dnsWiBinding; +export const dnsAdminBinding = east.dnsAdminBinding; +export const crdbGsa = east.crdbGsa; +export const crdbWiBinding = east.crdbWiBinding; +export const crdbBackupBinding = (east as Record).crdbBackupBinding; diff --git a/examples/temporal-crdb-deploy/src/east/infra/cluster.ts b/examples/temporal-crdb-deploy/src/east/infra/cluster.ts deleted file mode 100644 index 90afcd22..00000000 --- a/examples/temporal-crdb-deploy/src/east/infra/cluster.ts +++ /dev/null @@ -1,65 +0,0 @@ -// GCP infrastructure: GKE cluster (us-east4) + Workload Identity IAM bindings. -// Uses n2-standard-2 (2 vCPU / 8 GiB) — e2-standard-2 is out of capacity -// in all us-east4 zones at time of writing. - -import { - GkeCluster, - GCPServiceAccount, - IAMPolicyMember, -} from "@intentius/chant-lexicon-gcp"; -import { config } from "../config"; - -// ── GKE Cluster ──────────────────────────────────────────────────── - -export const { cluster, nodePool, defaultPool } = GkeCluster({ - name: config.clusterName, - location: config.region, - machineType: "n2-standard-2", - network: "crdb-multi-region", - subnetwork: "crdb-multi-region-east-nodes", - minNodeCount: 1, - maxNodeCount: 3, - diskSizeGb: 100, - releaseChannel: "REGULAR", - workloadIdentity: true, - privateNodes: true, - masterCidr: "172.16.0.0/28", -}); - -// ── Workload Identity — ExternalDNS Service Account ──────────────── - -export const externalDnsGsa = new GCPServiceAccount({ - metadata: { - name: "gke-crdb-east-dns", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - displayName: "GKE CockroachDB east external-dns workload identity", -}); - -export const externalDnsWiBinding = new IAMPolicyMember({ - metadata: { - name: "gke-crdb-east-dns-wi", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - member: `serviceAccount:${config.projectId}.svc.id.goog[kube-system/external-dns-sa]`, - role: "roles/iam.workloadIdentityUser", - resourceRef: { - apiVersion: "iam.cnrm.cloud.google.com/v1beta1", - kind: "IAMServiceAccount", - name: "gke-crdb-east-dns", - }, -}); - -export const externalDnsProjectBinding = new IAMPolicyMember({ - metadata: { - name: "gke-crdb-east-dns-project", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - member: `serviceAccount:gke-crdb-east-dns@${config.projectId}.iam.gserviceaccount.com`, - role: "roles/dns.admin", - resourceRef: { - apiVersion: "resourcemanager.cnrm.cloud.google.com/v1beta1", - kind: "Project", - external: `projects/${config.projectId}`, - }, -}); diff --git a/examples/temporal-crdb-deploy/src/east/infra/dns.ts b/examples/temporal-crdb-deploy/src/east/infra/dns.ts deleted file mode 100644 index bf9bf873..00000000 --- a/examples/temporal-crdb-deploy/src/east/infra/dns.ts +++ /dev/null @@ -1,13 +0,0 @@ -// DNS: Cloud DNS public zone for the east CockroachDB UI subdomain. - -import { DNSManagedZone } from "@intentius/chant-lexicon-gcp"; -import { config } from "../config"; - -export const dnsZone = new DNSManagedZone({ - metadata: { - name: "crdb-east-zone", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - dnsName: `${config.domain}.`, - description: "CockroachDB east UI — managed by chant", -}); diff --git a/examples/temporal-crdb-deploy/src/east/k8s.ts b/examples/temporal-crdb-deploy/src/east/k8s.ts new file mode 100644 index 00000000..e4a112d5 --- /dev/null +++ b/examples/temporal-crdb-deploy/src/east/k8s.ts @@ -0,0 +1,56 @@ +/** + * East region (us-east4) Kubernetes resources. + * + * CockroachDbRegionStack creates: namespace, storage, CockroachDB StatefulSet, + * External Secrets (TLS), GCE Ingress, ExternalDNS, BackendConfig (Cloud Armor), + * ManagedCertificate, FrontendConfig, and Prometheus monitoring. + * + * Replaces: k8s/namespace.ts + k8s/storage.ts + k8s/cockroachdb.ts + + * k8s/external-secrets.ts + k8s/ingress.ts + k8s/tls.ts + + * k8s/backend-config.ts + k8s/monitoring.ts (8 files → 1 call) + */ + +import { CockroachDbRegionStack } from "@intentius/chant-lexicon-k8s"; +import { GCP_PROJECT_ID, CRDB_DOMAIN, INTERNAL_DOMAIN, CRDB_CLUSTER, ALL_CIDRS } from "../shared/config"; + +export const east = CockroachDbRegionStack({ + region: "east", + namespace: "crdb-east", + domain: `east.${CRDB_DOMAIN}`, + internalDomain: `east.${INTERNAL_DOMAIN}`, + publicRootDomain: CRDB_DOMAIN, + + projectId: GCP_PROJECT_ID, + clusterName: "gke-crdb-east", + clusterRegion: "us-east4", + crdbGsaEmail: `gke-crdb-east-crdb@${GCP_PROJECT_ID}.iam.gserviceaccount.com`, + externalDnsGsaEmail: `gke-crdb-east-dns@${GCP_PROJECT_ID}.iam.gserviceaccount.com`, + + cockroachdb: { + ...CRDB_CLUSTER, + locality: "cloud=gcp,region=us-east4", + skipInit: false, + mountClientCerts: true, + advertiseHostDomain: `east.${INTERNAL_DOMAIN}`, + extraCertNodeAddresses: [ + `cockroachdb-0.east.${INTERNAL_DOMAIN}`, + `cockroachdb-1.east.${INTERNAL_DOMAIN}`, + `cockroachdb-2.east.${INTERNAL_DOMAIN}`, + ], + }, + + tls: { + gcpSecretNames: { + ca: "crdb-ca-crt", + nodeCrt: "crdb-node-crt", + nodeKey: "crdb-node-key", + clientRootCrt: "crdb-client-root-crt", + clientRootKey: "crdb-client-root-key", + }, + }, + + quota: { cpu: "8", memory: "20Gi", maxPods: 25 }, + allowCidrs: ALL_CIDRS, + cloudArmor: { policyName: "crdb-ui-waf" }, + monitoring: true, +}); diff --git a/examples/temporal-crdb-deploy/src/east/k8s/backend-config.ts b/examples/temporal-crdb-deploy/src/east/k8s/backend-config.ts deleted file mode 100644 index d99c8f3d..00000000 --- a/examples/temporal-crdb-deploy/src/east/k8s/backend-config.ts +++ /dev/null @@ -1,17 +0,0 @@ -// BackendConfig: Cloud Armor WAF policy attachment + health check for CockroachDB UI. - -import { createResource } from "@intentius/chant/runtime"; - -const BackendConfig = createResource("K8s::GKE::BackendConfig", "k8s", {}); - -export const crdbBackendConfig = new BackendConfig({ - metadata: { - name: "crdb-ui-backend", - namespace: "crdb-east", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - spec: { - securityPolicy: { name: "crdb-ui-waf" }, - healthCheck: { type: "HTTPS", requestPath: "/health", port: 8080 }, - }, -}); diff --git a/examples/temporal-crdb-deploy/src/east/k8s/cockroachdb.ts b/examples/temporal-crdb-deploy/src/east/k8s/cockroachdb.ts deleted file mode 100644 index 1bd19332..00000000 --- a/examples/temporal-crdb-deploy/src/east/k8s/cockroachdb.ts +++ /dev/null @@ -1,71 +0,0 @@ -// K8s workloads: CockroachDB cluster (east slice — 3 of 9 nodes). -// Headless service annotated for ExternalDNS to register pod IPs in Cloud DNS private zone. - -import { CockroachDbCluster } from "@intentius/chant-lexicon-k8s"; -import { config } from "../config"; -import { INTERNAL_DOMAIN } from "../../shared/config"; - -const NAMESPACE = "crdb-east"; - -const crdb = CockroachDbCluster({ - name: config.name, - namespace: NAMESPACE, - replicas: config.replicas, - image: config.image, - storageSize: config.storageSize, - storageClassName: "pd-ssd", - cpuLimit: config.cpuLimit, - memoryLimit: config.memoryLimit, - locality: config.locality, - joinAddresses: config.joinAddresses, - secure: true, - skipCertGen: true, - mountClientCerts: true, - advertiseHostDomain: `east.${INTERNAL_DOMAIN}`, - extraCertNodeAddresses: [ - `cockroachdb-0.east.${INTERNAL_DOMAIN}`, - `cockroachdb-1.east.${INTERNAL_DOMAIN}`, - `cockroachdb-2.east.${INTERNAL_DOMAIN}`, - ], - labels: { - "app.kubernetes.io/part-of": "cockroachdb-multi-region", - "app.kubernetes.io/instance": "east", - }, - defaults: { - serviceAccount: { - metadata: { - annotations: { - "iam.gke.io/gcp-service-account": config.crdbGsaEmail, - }, - }, - }, - publicService: { - metadata: { - annotations: { - "cloud.google.com/backend-config": '{"default":"crdb-ui-backend"}', - // Tell the GCE LB to use HTTPS when forwarding to CockroachDB (TLS-only on port 8080) - "cloud.google.com/app-protocols": '{"http":"HTTPS"}', - }, - }, - }, - headlessService: { - metadata: { - annotations: { - "external-dns.alpha.kubernetes.io/hostname": `east.${INTERNAL_DOMAIN}`, - }, - }, - }, - }, -}); - -export const cockroachdbServiceAccount = crdb.serviceAccount; -export const cockroachdbRole = crdb.role; -export const cockroachdbRoleBinding = crdb.roleBinding; -export const cockroachdbClusterRole = crdb.clusterRole; -export const cockroachdbClusterRoleBinding = crdb.clusterRoleBinding; -export const cockroachdbPublicService = crdb.publicService; -export const cockroachdbHeadlessService = crdb.headlessService; -export const cockroachdbPdb = crdb.pdb; -export const cockroachdbStatefulSet = crdb.statefulSet; -export const cockroachdbInitJob = crdb.initJob; -// No certGenJob for east — skipCertGen: true, generate-certs.sh handles shared certs diff --git a/examples/temporal-crdb-deploy/src/east/k8s/external-secrets.ts b/examples/temporal-crdb-deploy/src/east/k8s/external-secrets.ts deleted file mode 100644 index 105cd0df..00000000 --- a/examples/temporal-crdb-deploy/src/east/k8s/external-secrets.ts +++ /dev/null @@ -1,54 +0,0 @@ -// External Secrets: ClusterSecretStore + ExternalSecrets for CockroachDB TLS certs. -// Syncs certificates from GCP Secret Manager into K8s Secrets via Workload Identity. - -import { createResource } from "@intentius/chant/runtime"; -import { config } from "../config"; - -const ClusterSecretStore = createResource("K8s::ExternalSecrets::ClusterSecretStore", "k8s", {}); -const ExternalSecret = createResource("K8s::ExternalSecrets::ExternalSecret", "k8s", {}); - -export const gcpSecretStore = new ClusterSecretStore({ - metadata: { name: "gcp-secret-manager" }, - spec: { - provider: { - gcpsm: { - projectID: config.projectId, - auth: { - workloadIdentity: { - clusterLocation: config.region, - clusterName: config.clusterName, - serviceAccountRef: { name: "external-secrets-sa", namespace: "kube-system" }, - }, - }, - }, - }, - }, -}); - -export const nodeCertsSecret = new ExternalSecret({ - metadata: { name: "cockroachdb-node-certs-eso", namespace: "crdb-east" }, - spec: { - refreshInterval: "1h", - secretStoreRef: { name: "gcp-secret-manager", kind: "ClusterSecretStore" }, - target: { name: "cockroachdb-node-certs", creationPolicy: "Owner" }, - data: [ - { secretKey: "ca.crt", remoteRef: { key: "crdb-ca-crt" } }, - { secretKey: "node.crt", remoteRef: { key: "crdb-node-crt" } }, - { secretKey: "node.key", remoteRef: { key: "crdb-node-key" } }, - ], - }, -}); - -export const clientCertsSecret = new ExternalSecret({ - metadata: { name: "cockroachdb-client-certs-eso", namespace: "crdb-east" }, - spec: { - refreshInterval: "1h", - secretStoreRef: { name: "gcp-secret-manager", kind: "ClusterSecretStore" }, - target: { name: "cockroachdb-client-certs", creationPolicy: "Owner" }, - data: [ - { secretKey: "ca.crt", remoteRef: { key: "crdb-ca-crt" } }, - { secretKey: "client.root.crt", remoteRef: { key: "crdb-client-root-crt" } }, - { secretKey: "client.root.key", remoteRef: { key: "crdb-client-root-key" } }, - ], - }, -}); diff --git a/examples/temporal-crdb-deploy/src/east/k8s/ingress.ts b/examples/temporal-crdb-deploy/src/east/k8s/ingress.ts deleted file mode 100644 index dd58ec0f..00000000 --- a/examples/temporal-crdb-deploy/src/east/k8s/ingress.ts +++ /dev/null @@ -1,48 +0,0 @@ -// K8s workloads: GCE Ingress for CockroachDB UI + ExternalDNS agent. -// ExternalDNS manages both public zone (UI ingress) and private zone (pod discovery). - -import { - GceIngress, - GkeExternalDnsAgent, -} from "@intentius/chant-lexicon-k8s"; -import { config } from "../config"; -import { CRDB_DOMAIN, INTERNAL_DOMAIN } from "../../shared/config"; -import { crdbBackendConfig } from "./backend-config"; -import { crdbManagedCert, crdbFrontendConfig } from "./tls"; - -const NAMESPACE = "crdb-east"; - -// ── GCE Ingress ──────────────────────────────────────────────────── - -const ing = GceIngress({ - name: "cockroachdb-ui", - hosts: [ - { - hostname: config.domain, - paths: [{ path: "/", serviceName: "cockroachdb-public", servicePort: 8080 }], - }, - ], - namespace: NAMESPACE, - managedCertificate: "crdb-ui-cert", - frontendConfig: "crdb-ui-frontend", -}); - -export const gceIngress = ing.ingress; -export { crdbBackendConfig }; -export { crdbManagedCert, crdbFrontendConfig }; - -// ── ExternalDNS ──────────────────────────────────────────────────── -// Watches headless Services to register pod IPs in crdb.internal private zone. - -const dns = GkeExternalDnsAgent({ - gcpServiceAccountEmail: config.externalDnsGsaEmail, - gcpProjectId: config.projectId, - domainFilters: [CRDB_DOMAIN, INTERNAL_DOMAIN], - txtOwnerId: config.clusterName, - source: ["service", "ingress"], -}); - -export const dnsDeployment = dns.deployment; -export const dnsServiceAccount = dns.serviceAccount; -export const dnsClusterRole = dns.clusterRole; -export const dnsClusterRoleBinding = dns.clusterRoleBinding; diff --git a/examples/temporal-crdb-deploy/src/east/k8s/monitoring.ts b/examples/temporal-crdb-deploy/src/east/k8s/monitoring.ts deleted file mode 100644 index 7b896fee..00000000 --- a/examples/temporal-crdb-deploy/src/east/k8s/monitoring.ts +++ /dev/null @@ -1,66 +0,0 @@ -// Prometheus monitoring for CockroachDB metrics (east region). -// Lightweight: no Grafana — CockroachDB has built-in UI dashboards. - -import { Deployment, Service, ConfigMap } from "@intentius/chant-lexicon-k8s"; - -const NAMESPACE = "crdb-east"; - -export const prometheusConfig = new ConfigMap({ - metadata: { name: "prometheus-config", namespace: NAMESPACE }, - data: { - "prometheus.yml": ` -global: - scrape_interval: 15s -scrape_configs: - - job_name: "cockroachdb" - kubernetes_sd_configs: - - role: pod - namespaces: - names: ["${NAMESPACE}"] - relabel_configs: - - source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name] - regex: "cockroachdb" - action: keep - - source_labels: [__meta_kubernetes_pod_ip] - target_label: __address__ - replacement: "\${1}:8080" - - source_labels: [__meta_kubernetes_pod_name] - target_label: instance - metrics_path: "/_status/vars" -`, - }, -}); - -export const prometheusDeployment = new Deployment({ - metadata: { - name: "prometheus", - namespace: NAMESPACE, - labels: { "app.kubernetes.io/name": "prometheus", "app.kubernetes.io/managed-by": "chant" }, - }, - spec: { - replicas: 1, - selector: { matchLabels: { "app.kubernetes.io/name": "prometheus" } }, - template: { - metadata: { labels: { "app.kubernetes.io/name": "prometheus" } }, - spec: { - containers: [{ - name: "prometheus", - image: "prom/prometheus:v2.51.0", - args: ["--config.file=/etc/prometheus/prometheus.yml", "--storage.tsdb.retention.time=15d"], - ports: [{ name: "http", containerPort: 9090 }], - resources: { requests: { cpu: "500m", memory: "1Gi" }, limits: { cpu: "1", memory: "2Gi" } }, - volumeMounts: [{ name: "config", mountPath: "/etc/prometheus" }], - }], - volumes: [{ name: "config", configMap: { name: "prometheus-config" } }], - }, - }, - }, -}); - -export const prometheusService = new Service({ - metadata: { name: "prometheus", namespace: NAMESPACE }, - spec: { - selector: { "app.kubernetes.io/name": "prometheus" }, - ports: [{ name: "http", port: 9090, targetPort: "http" }], - }, -}); diff --git a/examples/temporal-crdb-deploy/src/east/k8s/namespace.ts b/examples/temporal-crdb-deploy/src/east/k8s/namespace.ts deleted file mode 100644 index 3987ce8d..00000000 --- a/examples/temporal-crdb-deploy/src/east/k8s/namespace.ts +++ /dev/null @@ -1,54 +0,0 @@ -// K8s workloads: Namespace with resource quotas and network policy. - -import { - NetworkPolicy, - NamespaceEnv, -} from "@intentius/chant-lexicon-k8s"; -import { ALL_CIDRS } from "../../shared/config"; - -const ns = NamespaceEnv({ - name: "crdb-east", - cpuQuota: "8", - memoryQuota: "20Gi", - maxPods: 25, - defaultCpuRequest: "250m", - defaultMemoryRequest: "512Mi", - defaultCpuLimit: "1", - defaultMemoryLimit: "4Gi", - defaultDenyIngress: true, - labels: { - "pod-security.kubernetes.io/enforce": "baseline", - "pod-security.kubernetes.io/enforce-version": "latest", - "pod-security.kubernetes.io/warn": "restricted", - "pod-security.kubernetes.io/audit": "restricted", - }, -}); - -export const namespace = ns.namespace; -export const resourceQuota = ns.resourceQuota; -export const limitRange = ns.limitRange; -export const networkPolicy = ns.networkPolicy; - -// Allow CockroachDB inter-node (26257) and HTTP UI (8080) traffic from all region CIDRs. -export const crdbIngressPolicy = new NetworkPolicy({ - metadata: { - name: "allow-cockroachdb", - namespace: "crdb-east", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - spec: { - podSelector: { - matchLabels: { "app.kubernetes.io/name": "cockroachdb" }, - }, - policyTypes: ["Ingress"], - ingress: [ - { - from: ALL_CIDRS.map((cidr) => ({ ipBlock: { cidr } })), - ports: [ - { protocol: "TCP", port: 26257 }, - { protocol: "TCP", port: 8080 }, - ], - }, - ], - }, -}); diff --git a/examples/temporal-crdb-deploy/src/east/k8s/storage.ts b/examples/temporal-crdb-deploy/src/east/k8s/storage.ts deleted file mode 100644 index b1d655fb..00000000 --- a/examples/temporal-crdb-deploy/src/east/k8s/storage.ts +++ /dev/null @@ -1,10 +0,0 @@ -// K8s workloads: GCE Persistent Disk StorageClass for SSD volumes. - -import { GcePdStorageClass } from "@intentius/chant-lexicon-k8s"; - -const pd = GcePdStorageClass({ - name: "pd-ssd", - type: "pd-ssd", -}); - -export const storageClass = pd.storageClass; diff --git a/examples/temporal-crdb-deploy/src/east/k8s/tls.ts b/examples/temporal-crdb-deploy/src/east/k8s/tls.ts deleted file mode 100644 index 9822be46..00000000 --- a/examples/temporal-crdb-deploy/src/east/k8s/tls.ts +++ /dev/null @@ -1,36 +0,0 @@ -// GKE ManagedCertificate + FrontendConfig for CockroachDB UI HTTPS termination. -// ManagedCertificate provisions a Google-managed TLS cert via ACME HTTP-01. -// FrontendConfig enforces HTTP→HTTPS redirect at the load balancer. - -import { createResource } from "@intentius/chant/runtime"; -import { config } from "../config"; - -const ManagedCertificate = createResource("K8s::NetworkingGKE::ManagedCertificate", "k8s", {}); -const FrontendConfig = createResource("K8s::NetworkingGKEBeta::FrontendConfig", "k8s", {}); - -const NAMESPACE = "crdb-east"; - -export const crdbManagedCert = new ManagedCertificate({ - metadata: { - name: "crdb-ui-cert", - namespace: NAMESPACE, - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - spec: { - domains: [config.domain], - }, -}); - -export const crdbFrontendConfig = new FrontendConfig({ - metadata: { - name: "crdb-ui-frontend", - namespace: NAMESPACE, - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - spec: { - redirectToHttps: { - enabled: true, - responseCodeName: "MOVED_PERMANENTLY_DEFAULT", - }, - }, -}); diff --git a/examples/temporal-crdb-deploy/src/shared/iam-crdb.ts b/examples/temporal-crdb-deploy/src/shared/iam-crdb.ts deleted file mode 100644 index 4496e1af..00000000 --- a/examples/temporal-crdb-deploy/src/shared/iam-crdb.ts +++ /dev/null @@ -1,42 +0,0 @@ -// CockroachDB Workload Identity: per-region GCP SAs with GCS backup bucket access. - -import { GCPServiceAccount, IAMPolicyMember } from "@intentius/chant-lexicon-gcp"; -import { GCP_PROJECT_ID } from "./config"; - -const REGIONS = ["east", "central", "west"] as const; - -export const crdbServiceAccounts = REGIONS.map(region => new GCPServiceAccount({ - metadata: { - name: `gke-crdb-${region}-crdb`, - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - displayName: `CockroachDB ${region} workload identity`, -})); - -export const crdbWiBindings = REGIONS.map(region => new IAMPolicyMember({ - metadata: { - name: `gke-crdb-${region}-crdb-wi`, - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - member: `serviceAccount:${GCP_PROJECT_ID}.svc.id.goog[crdb-${region}/cockroachdb]`, - role: "roles/iam.workloadIdentityUser", - resourceRef: { - apiVersion: "iam.cnrm.cloud.google.com/v1beta1", - kind: "IAMServiceAccount", - name: `gke-crdb-${region}-crdb`, - }, -})); - -export const crdbBucketBindings = REGIONS.map(region => new IAMPolicyMember({ - metadata: { - name: `gke-crdb-${region}-crdb-backup`, - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - member: `serviceAccount:gke-crdb-${region}-crdb@${GCP_PROJECT_ID}.iam.gserviceaccount.com`, - role: "roles/storage.objectAdmin", - resourceRef: { - apiVersion: "storage.cnrm.cloud.google.com/v1beta1", - kind: "StorageBucket", - name: `${GCP_PROJECT_ID}-crdb-backups`, - }, -})); diff --git a/examples/temporal-crdb-deploy/src/shared/iam-eso.ts b/examples/temporal-crdb-deploy/src/shared/iam-eso.ts deleted file mode 100644 index 089ef311..00000000 --- a/examples/temporal-crdb-deploy/src/shared/iam-eso.ts +++ /dev/null @@ -1,42 +0,0 @@ -// External Secrets Operator Workload Identity: GCP SA with Secret Manager access. - -import { GCPServiceAccount, IAMPolicyMember } from "@intentius/chant-lexicon-gcp"; -import { GCP_PROJECT_ID } from "./config"; - -const REGIONS = ["east", "central", "west"] as const; - -export const esoServiceAccount = new GCPServiceAccount({ - metadata: { - name: "crdb-eso", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - displayName: "CockroachDB External Secrets Operator", -}); - -export const esoWiBindings = REGIONS.map(region => new IAMPolicyMember({ - metadata: { - name: `crdb-eso-wi-${region}`, - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - member: `serviceAccount:${GCP_PROJECT_ID}.svc.id.goog[kube-system/external-secrets-sa]`, - role: "roles/iam.workloadIdentityUser", - resourceRef: { - apiVersion: "iam.cnrm.cloud.google.com/v1beta1", - kind: "IAMServiceAccount", - name: "crdb-eso", - }, -})); - -export const esoSecretAccessBinding = new IAMPolicyMember({ - metadata: { - name: "crdb-eso-secret-access", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - member: `serviceAccount:crdb-eso@${GCP_PROJECT_ID}.iam.gserviceaccount.com`, - role: "roles/secretmanager.secretAccessor", - resourceRef: { - apiVersion: "resourcemanager.cnrm.cloud.google.com/v1beta1", - kind: "Project", - external: `projects/${GCP_PROJECT_ID}`, - }, -}); diff --git a/examples/temporal-crdb-deploy/src/shared/iam.ts b/examples/temporal-crdb-deploy/src/shared/iam.ts new file mode 100644 index 00000000..5f19bc17 --- /dev/null +++ b/examples/temporal-crdb-deploy/src/shared/iam.ts @@ -0,0 +1,59 @@ +/** + * Shared IAM — GCP service accounts and bindings that span all 3 regions. + * + * Per-region resources (DNS GSA, CRDB GSA, WI bindings) live in each region's infra.ts + * via GkeCrdbRegion. This file handles the two cross-region concerns: + * + * • External Secrets Operator (ESO) GSA — one SA, three WI bindings (one per cluster) + * • CRDB backup bucket bindings — granting each region's CRDB GSA access to the bucket + * is done via GkeCrdbRegion when backupBucket is provided + */ + +import { GCPServiceAccount, IAMPolicyMember } from "@intentius/chant-lexicon-gcp"; +import { GCP_PROJECT_ID, BACKUP_BUCKET } from "./config"; + +// ── External Secrets Operator ───────────────────────────────────────────────── +// One GSA per project — all three clusters share it via separate WI bindings. + +export const esoServiceAccount = new GCPServiceAccount({ + metadata: { + name: "crdb-eso", + labels: { "app.kubernetes.io/managed-by": "chant" }, + }, + displayName: "CockroachDB External Secrets Operator", +}); + +// Each cluster gets its own WI binding (kube-system/external-secrets-sa → crdb-eso GSA). +const ESO_REGIONS = ["east", "central", "west"] as const; + +export const esoWiBindings = ESO_REGIONS.map(region => new IAMPolicyMember({ + metadata: { + name: `crdb-eso-wi-${region}`, + labels: { "app.kubernetes.io/managed-by": "chant" }, + }, + member: `serviceAccount:${GCP_PROJECT_ID}.svc.id.goog[kube-system/external-secrets-sa]`, + role: "roles/iam.workloadIdentityUser", + resourceRef: { + apiVersion: "iam.cnrm.cloud.google.com/v1beta1", + kind: "IAMServiceAccount", + name: "crdb-eso", + }, +})); + +// Secret Manager read access so ESO can sync certs into K8s Secrets. +export const esoSecretAccessBinding = new IAMPolicyMember({ + metadata: { + name: "crdb-eso-secret-access", + labels: { "app.kubernetes.io/managed-by": "chant" }, + }, + member: `serviceAccount:crdb-eso@${GCP_PROJECT_ID}.iam.gserviceaccount.com`, + role: "roles/secretmanager.secretAccessor", + resourceRef: { + apiVersion: "resourcemanager.cnrm.cloud.google.com/v1beta1", + kind: "Project", + external: `projects/${GCP_PROJECT_ID}`, + }, +}); + +// Suppress: array-based exports are required here (one binding per region) +export { BACKUP_BUCKET }; diff --git a/examples/temporal-crdb-deploy/src/shared/infra/networking.ts b/examples/temporal-crdb-deploy/src/shared/infra/networking.ts index b44b13fd..3814bfe1 100644 --- a/examples/temporal-crdb-deploy/src/shared/infra/networking.ts +++ b/examples/temporal-crdb-deploy/src/shared/infra/networking.ts @@ -2,9 +2,7 @@ // GCP VPC is global — subnets in different regions route natively. No VPN needed. import { - VpcNetwork, - Router, - RouterNAT, + MultiRegionVpc, GCP, defaultAnnotations, Firewall, @@ -15,29 +13,23 @@ export const annotations = defaultAnnotations({ "cnrm.cloud.google.com/project-id": GCP.ProjectId, }); -// ── VPC + Subnets ────────────────────────────────────────────────── -// One global VPC with 6 subnets (nodes + pods per region). -// VpcNetwork composite handles NAT for one region; we add NAT for the other two manually. +// ── VPC + Subnets + Routers + NATs ───────────────────────────────── +// MultiRegionVpc creates the network, 2 subnets per region, 1 Router per region, +// and 1 RouterNAT per region — all in one call. -export const network = VpcNetwork({ +export const network = MultiRegionVpc({ name: "crdb-multi-region", - subnets: [ - { name: "east-nodes", ipCidrRange: REGIONS.east.nodeCidr, region: "us-east4" }, - { name: "east-pods", ipCidrRange: REGIONS.east.podCidr, region: "us-east4" }, - { name: "central-nodes", ipCidrRange: REGIONS.central.nodeCidr, region: "us-central1" }, - { name: "central-pods", ipCidrRange: REGIONS.central.podCidr, region: "us-central1" }, - { name: "west-nodes", ipCidrRange: REGIONS.west.nodeCidr, region: "us-west1" }, - { name: "west-pods", ipCidrRange: REGIONS.west.podCidr, region: "us-west1" }, + regions: [ + { region: "us-east4", regionAlias: "east", nodeSubnetCidr: REGIONS.east.nodeCidr, podSubnetCidr: REGIONS.east.podCidr }, + { region: "us-central1", regionAlias: "central", nodeSubnetCidr: REGIONS.central.nodeCidr, podSubnetCidr: REGIONS.central.podCidr }, + { region: "us-west1", regionAlias: "west", nodeSubnetCidr: REGIONS.west.nodeCidr, podSubnetCidr: REGIONS.west.podCidr }, ], - enableNat: true, - natRegion: "us-east4", - allowInternalTraffic: true, }); // ── Extra firewall rule for GKE-allocated pod CIDRs ──────────────── // GKE allocates secondary IP ranges for pods that differ from the subnet CIDRs -// declared above. The VpcNetwork composite's allow-internal rule only covers -// the configured subnet CIDRs. We add this rule for the actual GKE pod ranges. +// declared above. MultiRegionVpc's allow-internal rule only covers the configured +// subnet CIDRs. This rule adds the actual GKE pod ranges. // Find ranges with: gcloud compute networks subnets describe --region= export const firewallGkePods = new Firewall({ metadata: { @@ -56,47 +48,3 @@ export const firewallGkePods = new Firewall({ GKE_POD_CIDRS.west, ], }); - -// ── Cloud NAT for us-central1 ────────────────────────────────────── - -export const centralRouter = new Router({ - metadata: { - name: "crdb-multi-region-central", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - region: "us-central1", - networkRef: { name: "crdb-multi-region" }, -}); - -export const centralNat = new RouterNAT({ - metadata: { - name: "crdb-multi-region-central", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - region: "us-central1", - routerRef: { name: "crdb-multi-region-central" }, - natIpAllocateOption: "AUTO_ONLY", - sourceSubnetworkIpRangesToNat: "ALL_SUBNETWORKS_ALL_PRIMARY_IP_RANGES", -}); - -// ── Cloud NAT for us-west1 ───────────────────────────────────────── - -export const westRouter = new Router({ - metadata: { - name: "crdb-multi-region-west", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - region: "us-west1", - networkRef: { name: "crdb-multi-region" }, -}); - -export const westNat = new RouterNAT({ - metadata: { - name: "crdb-multi-region-west", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - region: "us-west1", - routerRef: { name: "crdb-multi-region-west" }, - natIpAllocateOption: "AUTO_ONLY", - sourceSubnetworkIpRangesToNat: "ALL_SUBNETWORKS_ALL_PRIMARY_IP_RANGES", -}); diff --git a/examples/temporal-crdb-deploy/src/temporal.ts b/examples/temporal-crdb-deploy/src/temporal.ts new file mode 100644 index 00000000..10dab19b --- /dev/null +++ b/examples/temporal-crdb-deploy/src/temporal.ts @@ -0,0 +1,30 @@ +/** + * Temporal infrastructure — namespace + search attributes. + * + * Build: npm run build:temporal → dist/temporal-setup.sh + * Apply: npm run temporal:setup → registers namespace + search attrs in Temporal Cloud + * + * Why this is in chant source (not a README step): + * • Namespace configuration is version-controlled and PR-reviewable + * • Repeatable setup across environments (no manual Temporal Cloud UI steps) + * • TypeScript enforces the shape — a misspelled retention period is a lint error + */ + +import { TemporalCloudStack } from "@intentius/chant-lexicon-temporal"; + +export const { ns, searchAttributes } = TemporalCloudStack({ + namespace: "crdb-deploy", + retention: "30d", + description: "CockroachDB multi-region deployment orchestration namespace", + searchAttributes: [ + // GCP context — filterable from the Temporal Cloud UI workflow list + { name: "GcpProject", type: "Keyword" }, + { name: "CrdbDomain", type: "Keyword" }, + // Deployment progress — updated by the workflow at each phase transition + // Filter: "show me all workflows currently in WAIT_DNS_DELEGATION" + { name: "DeployPhase", type: "Keyword" }, + // Active region — updated during parallel regional deploys + // Filter: "show me all workflows currently deploying the east region" + { name: "DeployRegion", type: "Keyword" }, + ], +}); diff --git a/examples/temporal-crdb-deploy/src/west/infra.ts b/examples/temporal-crdb-deploy/src/west/infra.ts new file mode 100644 index 00000000..753449c8 --- /dev/null +++ b/examples/temporal-crdb-deploy/src/west/infra.ts @@ -0,0 +1,37 @@ +/** + * West region (us-west1) GCP infrastructure. + * + * Replaces: infra/cluster.ts + infra/dns.ts + */ + +import { GkeCrdbRegion } from "@intentius/chant-lexicon-gcp"; +import { GCP_PROJECT_ID, CRDB_DOMAIN, BACKUP_BUCKET } from "../shared/config"; + +export const west = GkeCrdbRegion({ + region: "us-west1", + clusterName: "gke-crdb-west", + network: "crdb-multi-region", + subnetwork: "crdb-multi-region-west-nodes", + domain: `west.${CRDB_DOMAIN}`, + project: GCP_PROJECT_ID, + crdbNamespace: "crdb-west", + masterCidr: "172.18.0.0/28", + nodeConfig: { + machineType: "n2-standard-2", + diskSizeGb: 100, + nodeCount: 1, + maxNodeCount: 3, + }, + backupBucket: BACKUP_BUCKET, +}); + +export const cluster = west.cluster; +export const nodePool = west.nodePool; +export const defaultPool = west.defaultPool; +export const dnsZone = west.dnsZone; +export const dnsGsa = west.dnsGsa; +export const dnsWiBinding = west.dnsWiBinding; +export const dnsAdminBinding = west.dnsAdminBinding; +export const crdbGsa = west.crdbGsa; +export const crdbWiBinding = west.crdbWiBinding; +export const crdbBackupBinding = (west as Record).crdbBackupBinding; diff --git a/examples/temporal-crdb-deploy/src/west/infra/cluster.ts b/examples/temporal-crdb-deploy/src/west/infra/cluster.ts deleted file mode 100644 index 3778118e..00000000 --- a/examples/temporal-crdb-deploy/src/west/infra/cluster.ts +++ /dev/null @@ -1,64 +0,0 @@ -// GCP infrastructure: GKE cluster (us-west1) + Workload Identity IAM bindings. -// Sized for CockroachDB: 3x e2-standard-2 (2 vCPU / 8 GiB) worker nodes. - -import { - GkeCluster, - GCPServiceAccount, - IAMPolicyMember, -} from "@intentius/chant-lexicon-gcp"; -import { config } from "../config"; - -// ── GKE Cluster ──────────────────────────────────────────────────── - -export const { cluster, nodePool, defaultPool } = GkeCluster({ - name: config.clusterName, - location: config.region, - machineType: "e2-standard-2", - network: "crdb-multi-region", - subnetwork: "crdb-multi-region-west-nodes", - minNodeCount: 1, - maxNodeCount: 1, - diskSizeGb: 100, - releaseChannel: "REGULAR", - workloadIdentity: true, - privateNodes: true, - masterCidr: "172.16.2.0/28", -}); - -// ── Workload Identity — ExternalDNS Service Account ──────────────── - -export const externalDnsGsa = new GCPServiceAccount({ - metadata: { - name: "gke-crdb-west-dns", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - displayName: "GKE CockroachDB west external-dns workload identity", -}); - -export const externalDnsWiBinding = new IAMPolicyMember({ - metadata: { - name: "gke-crdb-west-dns-wi", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - member: `serviceAccount:${config.projectId}.svc.id.goog[kube-system/external-dns-sa]`, - role: "roles/iam.workloadIdentityUser", - resourceRef: { - apiVersion: "iam.cnrm.cloud.google.com/v1beta1", - kind: "IAMServiceAccount", - name: "gke-crdb-west-dns", - }, -}); - -export const externalDnsProjectBinding = new IAMPolicyMember({ - metadata: { - name: "gke-crdb-west-dns-project", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - member: `serviceAccount:gke-crdb-west-dns@${config.projectId}.iam.gserviceaccount.com`, - role: "roles/dns.admin", - resourceRef: { - apiVersion: "resourcemanager.cnrm.cloud.google.com/v1beta1", - kind: "Project", - external: `projects/${config.projectId}`, - }, -}); diff --git a/examples/temporal-crdb-deploy/src/west/infra/dns.ts b/examples/temporal-crdb-deploy/src/west/infra/dns.ts deleted file mode 100644 index df1db02a..00000000 --- a/examples/temporal-crdb-deploy/src/west/infra/dns.ts +++ /dev/null @@ -1,13 +0,0 @@ -// DNS: Cloud DNS public zone for the west CockroachDB UI subdomain. - -import { DNSManagedZone } from "@intentius/chant-lexicon-gcp"; -import { config } from "../config"; - -export const dnsZone = new DNSManagedZone({ - metadata: { - name: "crdb-west-zone", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - dnsName: `${config.domain}.`, - description: "CockroachDB west UI — managed by chant", -}); diff --git a/examples/temporal-crdb-deploy/src/west/k8s.ts b/examples/temporal-crdb-deploy/src/west/k8s.ts new file mode 100644 index 00000000..ebf7a9b5 --- /dev/null +++ b/examples/temporal-crdb-deploy/src/west/k8s.ts @@ -0,0 +1,52 @@ +/** + * West region (us-west1) Kubernetes resources. + * + * Replaces: k8s/namespace.ts + k8s/storage.ts + k8s/cockroachdb.ts + + * k8s/external-secrets.ts + k8s/ingress.ts + k8s/tls.ts + + * k8s/backend-config.ts + k8s/monitoring.ts (8 files → 1 call) + */ + +import { CockroachDbRegionStack } from "@intentius/chant-lexicon-k8s"; +import { GCP_PROJECT_ID, CRDB_DOMAIN, INTERNAL_DOMAIN, CRDB_CLUSTER, ALL_CIDRS } from "../shared/config"; + +export const west = CockroachDbRegionStack({ + region: "west", + namespace: "crdb-west", + domain: `west.${CRDB_DOMAIN}`, + internalDomain: `west.${INTERNAL_DOMAIN}`, + publicRootDomain: CRDB_DOMAIN, + + projectId: GCP_PROJECT_ID, + clusterName: "gke-crdb-west", + clusterRegion: "us-west1", + crdbGsaEmail: `gke-crdb-west-crdb@${GCP_PROJECT_ID}.iam.gserviceaccount.com`, + externalDnsGsaEmail: `gke-crdb-west-dns@${GCP_PROJECT_ID}.iam.gserviceaccount.com`, + + cockroachdb: { + ...CRDB_CLUSTER, + locality: "cloud=gcp,region=us-west1", + skipInit: true, + mountClientCerts: true, + advertiseHostDomain: `west.${INTERNAL_DOMAIN}`, + extraCertNodeAddresses: [ + `cockroachdb-0.west.${INTERNAL_DOMAIN}`, + `cockroachdb-1.west.${INTERNAL_DOMAIN}`, + `cockroachdb-2.west.${INTERNAL_DOMAIN}`, + ], + }, + + tls: { + gcpSecretNames: { + ca: "crdb-ca-crt", + nodeCrt: "crdb-node-crt", + nodeKey: "crdb-node-key", + clientRootCrt: "crdb-client-root-crt", + clientRootKey: "crdb-client-root-key", + }, + }, + + quota: { cpu: "8", memory: "20Gi", maxPods: 25 }, + allowCidrs: ALL_CIDRS, + cloudArmor: { policyName: "crdb-ui-waf" }, + monitoring: true, +}); diff --git a/examples/temporal-crdb-deploy/src/west/k8s/backend-config.ts b/examples/temporal-crdb-deploy/src/west/k8s/backend-config.ts deleted file mode 100644 index 1b22e54a..00000000 --- a/examples/temporal-crdb-deploy/src/west/k8s/backend-config.ts +++ /dev/null @@ -1,17 +0,0 @@ -// BackendConfig: Cloud Armor WAF policy attachment + health check for CockroachDB UI. - -import { createResource } from "@intentius/chant/runtime"; - -const BackendConfig = createResource("K8s::GKE::BackendConfig", "k8s", {}); - -export const crdbBackendConfig = new BackendConfig({ - metadata: { - name: "crdb-ui-backend", - namespace: "crdb-west", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - spec: { - securityPolicy: { name: "crdb-ui-waf" }, - healthCheck: { type: "HTTPS", requestPath: "/health", port: 8080 }, - }, -}); diff --git a/examples/temporal-crdb-deploy/src/west/k8s/cockroachdb.ts b/examples/temporal-crdb-deploy/src/west/k8s/cockroachdb.ts deleted file mode 100644 index 149df7e9..00000000 --- a/examples/temporal-crdb-deploy/src/west/k8s/cockroachdb.ts +++ /dev/null @@ -1,71 +0,0 @@ -// K8s workloads: CockroachDB cluster (west slice — 3 of 9 nodes). -// Headless service annotated for ExternalDNS to register pod IPs in Cloud DNS private zone. - -import { CockroachDbCluster } from "@intentius/chant-lexicon-k8s"; -import { config } from "../config"; -import { INTERNAL_DOMAIN } from "../../shared/config"; - -const NAMESPACE = "crdb-west"; - -const crdb = CockroachDbCluster({ - name: config.name, - namespace: NAMESPACE, - replicas: config.replicas, - image: config.image, - storageSize: config.storageSize, - storageClassName: "pd-ssd", - cpuLimit: config.cpuLimit, - memoryLimit: config.memoryLimit, - locality: config.locality, - joinAddresses: config.joinAddresses, - secure: true, - skipInit: true, - skipCertGen: true, - mountClientCerts: true, - advertiseHostDomain: `west.${INTERNAL_DOMAIN}`, - extraCertNodeAddresses: [ - `cockroachdb-0.west.${INTERNAL_DOMAIN}`, - `cockroachdb-1.west.${INTERNAL_DOMAIN}`, - `cockroachdb-2.west.${INTERNAL_DOMAIN}`, - ], - labels: { - "app.kubernetes.io/part-of": "cockroachdb-multi-region", - "app.kubernetes.io/instance": "west", - }, - defaults: { - serviceAccount: { - metadata: { - annotations: { - "iam.gke.io/gcp-service-account": config.crdbGsaEmail, - }, - }, - }, - publicService: { - metadata: { - annotations: { - "cloud.google.com/backend-config": '{"default":"crdb-ui-backend"}', - "cloud.google.com/app-protocols": '{"http":"HTTPS"}', - }, - }, - }, - headlessService: { - metadata: { - annotations: { - "external-dns.alpha.kubernetes.io/hostname": `west.${INTERNAL_DOMAIN}`, - }, - }, - }, - }, -}); - -export const cockroachdbServiceAccount = crdb.serviceAccount; -export const cockroachdbRole = crdb.role; -export const cockroachdbRoleBinding = crdb.roleBinding; -export const cockroachdbClusterRole = crdb.clusterRole; -export const cockroachdbClusterRoleBinding = crdb.clusterRoleBinding; -export const cockroachdbPublicService = crdb.publicService; -export const cockroachdbHeadlessService = crdb.headlessService; -export const cockroachdbPdb = crdb.pdb; -export const cockroachdbStatefulSet = crdb.statefulSet; -// No initJob for west — skipInit: true, east runs cockroach init for the full cluster -export const cockroachdbCertGenJob = crdb.certGenJob; diff --git a/examples/temporal-crdb-deploy/src/west/k8s/external-secrets.ts b/examples/temporal-crdb-deploy/src/west/k8s/external-secrets.ts deleted file mode 100644 index 9d76ce08..00000000 --- a/examples/temporal-crdb-deploy/src/west/k8s/external-secrets.ts +++ /dev/null @@ -1,53 +0,0 @@ -// External Secrets: ClusterSecretStore + ExternalSecrets for CockroachDB TLS certs. - -import { createResource } from "@intentius/chant/runtime"; -import { config } from "../config"; - -const ClusterSecretStore = createResource("K8s::ExternalSecrets::ClusterSecretStore", "k8s", {}); -const ExternalSecret = createResource("K8s::ExternalSecrets::ExternalSecret", "k8s", {}); - -export const gcpSecretStore = new ClusterSecretStore({ - metadata: { name: "gcp-secret-manager" }, - spec: { - provider: { - gcpsm: { - projectID: config.projectId, - auth: { - workloadIdentity: { - clusterLocation: config.region, - clusterName: config.clusterName, - serviceAccountRef: { name: "external-secrets-sa", namespace: "kube-system" }, - }, - }, - }, - }, - }, -}); - -export const nodeCertsSecret = new ExternalSecret({ - metadata: { name: "cockroachdb-node-certs-eso", namespace: "crdb-west" }, - spec: { - refreshInterval: "1h", - secretStoreRef: { name: "gcp-secret-manager", kind: "ClusterSecretStore" }, - target: { name: "cockroachdb-node-certs", creationPolicy: "Owner" }, - data: [ - { secretKey: "ca.crt", remoteRef: { key: "crdb-ca-crt" } }, - { secretKey: "node.crt", remoteRef: { key: "crdb-node-crt" } }, - { secretKey: "node.key", remoteRef: { key: "crdb-node-key" } }, - ], - }, -}); - -export const clientCertsSecret = new ExternalSecret({ - metadata: { name: "cockroachdb-client-certs-eso", namespace: "crdb-west" }, - spec: { - refreshInterval: "1h", - secretStoreRef: { name: "gcp-secret-manager", kind: "ClusterSecretStore" }, - target: { name: "cockroachdb-client-certs", creationPolicy: "Owner" }, - data: [ - { secretKey: "ca.crt", remoteRef: { key: "crdb-ca-crt" } }, - { secretKey: "client.root.crt", remoteRef: { key: "crdb-client-root-crt" } }, - { secretKey: "client.root.key", remoteRef: { key: "crdb-client-root-key" } }, - ], - }, -}); diff --git a/examples/temporal-crdb-deploy/src/west/k8s/ingress.ts b/examples/temporal-crdb-deploy/src/west/k8s/ingress.ts deleted file mode 100644 index b31fabf3..00000000 --- a/examples/temporal-crdb-deploy/src/west/k8s/ingress.ts +++ /dev/null @@ -1,48 +0,0 @@ -// K8s workloads: GCE Ingress for CockroachDB UI + ExternalDNS agent. -// ExternalDNS manages both public zone (UI ingress) and private zone (pod discovery). - -import { - GceIngress, - GkeExternalDnsAgent, -} from "@intentius/chant-lexicon-k8s"; -import { config } from "../config"; -import { CRDB_DOMAIN, INTERNAL_DOMAIN } from "../../shared/config"; -import { crdbBackendConfig } from "./backend-config"; -import { crdbManagedCert, crdbFrontendConfig } from "./tls"; - -const NAMESPACE = "crdb-west"; - -// ── GCE Ingress ──────────────────────────────────────────────────── - -const ing = GceIngress({ - name: "cockroachdb-ui", - hosts: [ - { - hostname: config.domain, - paths: [{ path: "/", serviceName: "cockroachdb-public", servicePort: 8080 }], - }, - ], - namespace: NAMESPACE, - managedCertificate: "crdb-ui-cert", - frontendConfig: "crdb-ui-frontend", -}); - -export const gceIngress = ing.ingress; -export { crdbBackendConfig }; -export { crdbManagedCert, crdbFrontendConfig }; - -// ── ExternalDNS ──────────────────────────────────────────────────── -// Watches headless Services to register pod IPs in crdb.internal private zone. - -const dns = GkeExternalDnsAgent({ - gcpServiceAccountEmail: config.externalDnsGsaEmail, - gcpProjectId: config.projectId, - domainFilters: [CRDB_DOMAIN, INTERNAL_DOMAIN], - txtOwnerId: config.clusterName, - source: ["service", "ingress"], -}); - -export const dnsDeployment = dns.deployment; -export const dnsServiceAccount = dns.serviceAccount; -export const dnsClusterRole = dns.clusterRole; -export const dnsClusterRoleBinding = dns.clusterRoleBinding; diff --git a/examples/temporal-crdb-deploy/src/west/k8s/monitoring.ts b/examples/temporal-crdb-deploy/src/west/k8s/monitoring.ts deleted file mode 100644 index 34c5868e..00000000 --- a/examples/temporal-crdb-deploy/src/west/k8s/monitoring.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Prometheus monitoring for CockroachDB metrics (west region). - -import { Deployment, Service, ConfigMap } from "@intentius/chant-lexicon-k8s"; - -const NAMESPACE = "crdb-west"; - -export const prometheusConfig = new ConfigMap({ - metadata: { name: "prometheus-config", namespace: NAMESPACE }, - data: { - "prometheus.yml": ` -global: - scrape_interval: 15s -scrape_configs: - - job_name: "cockroachdb" - kubernetes_sd_configs: - - role: pod - namespaces: - names: ["${NAMESPACE}"] - relabel_configs: - - source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name] - regex: "cockroachdb" - action: keep - - source_labels: [__meta_kubernetes_pod_ip] - target_label: __address__ - replacement: "\${1}:8080" - - source_labels: [__meta_kubernetes_pod_name] - target_label: instance - metrics_path: "/_status/vars" -`, - }, -}); - -export const prometheusDeployment = new Deployment({ - metadata: { - name: "prometheus", - namespace: NAMESPACE, - labels: { "app.kubernetes.io/name": "prometheus", "app.kubernetes.io/managed-by": "chant" }, - }, - spec: { - replicas: 1, - selector: { matchLabels: { "app.kubernetes.io/name": "prometheus" } }, - template: { - metadata: { labels: { "app.kubernetes.io/name": "prometheus" } }, - spec: { - containers: [{ - name: "prometheus", - image: "prom/prometheus:v2.51.0", - args: ["--config.file=/etc/prometheus/prometheus.yml", "--storage.tsdb.retention.time=15d"], - ports: [{ name: "http", containerPort: 9090 }], - resources: { requests: { cpu: "500m", memory: "1Gi" }, limits: { cpu: "1", memory: "2Gi" } }, - volumeMounts: [{ name: "config", mountPath: "/etc/prometheus" }], - }], - volumes: [{ name: "config", configMap: { name: "prometheus-config" } }], - }, - }, - }, -}); - -export const prometheusService = new Service({ - metadata: { name: "prometheus", namespace: NAMESPACE }, - spec: { - selector: { "app.kubernetes.io/name": "prometheus" }, - ports: [{ name: "http", port: 9090, targetPort: "http" }], - }, -}); diff --git a/examples/temporal-crdb-deploy/src/west/k8s/namespace.ts b/examples/temporal-crdb-deploy/src/west/k8s/namespace.ts deleted file mode 100644 index 298df392..00000000 --- a/examples/temporal-crdb-deploy/src/west/k8s/namespace.ts +++ /dev/null @@ -1,54 +0,0 @@ -// K8s workloads: Namespace with resource quotas and network policy. - -import { - NetworkPolicy, - NamespaceEnv, -} from "@intentius/chant-lexicon-k8s"; -import { ALL_CIDRS } from "../../shared/config"; - -const ns = NamespaceEnv({ - name: "crdb-west", - cpuQuota: "8", - memoryQuota: "20Gi", - maxPods: 25, - defaultCpuRequest: "250m", - defaultMemoryRequest: "512Mi", - defaultCpuLimit: "1", - defaultMemoryLimit: "4Gi", - defaultDenyIngress: true, - labels: { - "pod-security.kubernetes.io/enforce": "baseline", - "pod-security.kubernetes.io/enforce-version": "latest", - "pod-security.kubernetes.io/warn": "restricted", - "pod-security.kubernetes.io/audit": "restricted", - }, -}); - -export const namespace = ns.namespace; -export const resourceQuota = ns.resourceQuota; -export const limitRange = ns.limitRange; -export const networkPolicy = ns.networkPolicy; - -// Allow CockroachDB inter-node (26257) and HTTP UI (8080) traffic from all region CIDRs. -export const crdbIngressPolicy = new NetworkPolicy({ - metadata: { - name: "allow-cockroachdb", - namespace: "crdb-west", - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - spec: { - podSelector: { - matchLabels: { "app.kubernetes.io/name": "cockroachdb" }, - }, - policyTypes: ["Ingress"], - ingress: [ - { - from: ALL_CIDRS.map((cidr) => ({ ipBlock: { cidr } })), - ports: [ - { protocol: "TCP", port: 26257 }, - { protocol: "TCP", port: 8080 }, - ], - }, - ], - }, -}); diff --git a/examples/temporal-crdb-deploy/src/west/k8s/storage.ts b/examples/temporal-crdb-deploy/src/west/k8s/storage.ts deleted file mode 100644 index b1d655fb..00000000 --- a/examples/temporal-crdb-deploy/src/west/k8s/storage.ts +++ /dev/null @@ -1,10 +0,0 @@ -// K8s workloads: GCE Persistent Disk StorageClass for SSD volumes. - -import { GcePdStorageClass } from "@intentius/chant-lexicon-k8s"; - -const pd = GcePdStorageClass({ - name: "pd-ssd", - type: "pd-ssd", -}); - -export const storageClass = pd.storageClass; diff --git a/examples/temporal-crdb-deploy/src/west/k8s/tls.ts b/examples/temporal-crdb-deploy/src/west/k8s/tls.ts deleted file mode 100644 index d7b6156a..00000000 --- a/examples/temporal-crdb-deploy/src/west/k8s/tls.ts +++ /dev/null @@ -1,36 +0,0 @@ -// GKE ManagedCertificate + FrontendConfig for CockroachDB UI HTTPS termination. -// ManagedCertificate provisions a Google-managed TLS cert via ACME HTTP-01. -// FrontendConfig enforces HTTP→HTTPS redirect at the load balancer. - -import { createResource } from "@intentius/chant/runtime"; -import { config } from "../config"; - -const ManagedCertificate = createResource("K8s::NetworkingGKE::ManagedCertificate", "k8s", {}); -const FrontendConfig = createResource("K8s::NetworkingGKEBeta::FrontendConfig", "k8s", {}); - -const NAMESPACE = "crdb-west"; - -export const crdbManagedCert = new ManagedCertificate({ - metadata: { - name: "crdb-ui-cert", - namespace: NAMESPACE, - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - spec: { - domains: [config.domain], - }, -}); - -export const crdbFrontendConfig = new FrontendConfig({ - metadata: { - name: "crdb-ui-frontend", - namespace: NAMESPACE, - labels: { "app.kubernetes.io/managed-by": "chant" }, - }, - spec: { - redirectToHttps: { - enabled: true, - responseCodeName: "MOVED_PERMANENTLY_DEFAULT", - }, - }, -}); diff --git a/examples/temporal-crdb-deploy/temporal/types.ts b/examples/temporal-crdb-deploy/temporal/types.ts index b3443b12..ea0248f3 100644 --- a/examples/temporal-crdb-deploy/temporal/types.ts +++ b/examples/temporal-crdb-deploy/temporal/types.ts @@ -29,3 +29,23 @@ export const REGION_CONFIG: Record { - const address = process.env.TEMPORAL_ADDRESS; - const namespace = process.env.TEMPORAL_NAMESPACE; - const apiKey = process.env.TEMPORAL_API_KEY; + const profileName = process.env.TEMPORAL_PROFILE ?? chantConfig.temporal.defaultProfile ?? 'cloud'; + const profile = chantConfig.temporal.profiles[profileName]; + + if (!profile) { + console.error(`Unknown Temporal profile "${profileName}". Available: ${Object.keys(chantConfig.temporal.profiles).join(', ')}`); + process.exit(1); + } + + // Resolve API key — either a literal string or an env var reference. + const apiKey = typeof profile.apiKey === 'object' && profile.apiKey !== null + ? process.env[(profile.apiKey as { env: string }).env] + : profile.apiKey as string | undefined; - if (!address || !namespace || !apiKey) { + if (profile.tls && !apiKey) { console.error( - 'Missing required env vars: TEMPORAL_ADDRESS, TEMPORAL_NAMESPACE, TEMPORAL_API_KEY\n' + - 'Set them in .env and source it: set -a && source .env && set +a', + `Profile "${profileName}" requires an API key.\n` + + `Set TEMPORAL_API_KEY in .env: set -a && source .env && set +a`, ); process.exit(1); } - console.log(`Connecting to Temporal Cloud: ${address} (namespace: ${namespace})`); + console.log(`Connecting to Temporal (profile: ${profileName}): ${profile.address} (namespace: ${profile.namespace})`); const connection = await NativeConnection.connect({ - address, - tls: {}, - metadata: { - Authorization: `Bearer ${apiKey}`, - }, + address: profile.address, + ...(profile.tls && { + tls: typeof profile.tls === 'object' ? profile.tls : {}, + metadata: apiKey ? { Authorization: `Bearer ${apiKey}` } : {}, + }), }); const worker = await Worker.create({ connection, - namespace, - taskQueue: 'crdb-deploy', - // `@temporalio/worker` ships its own TypeScript loader — .ts workflow files - // are bundled into Temporal's deterministic V8 sandbox automatically. + namespace: profile.namespace, + taskQueue: profile.taskQueue, workflowsPath: fileURLToPath(new URL('./workflows/deploy.ts', import.meta.url)), activities: { ...infraActivities, @@ -55,7 +63,7 @@ async function run(): Promise { }, }); - console.log('Worker ready — polling task queue: crdb-deploy'); + console.log(`Worker ready — polling task queue: ${profile.taskQueue}`); await worker.run(); } diff --git a/examples/temporal-self-hosted/README.md b/examples/temporal-self-hosted/README.md new file mode 100644 index 00000000..e5e365ea --- /dev/null +++ b/examples/temporal-self-hosted/README.md @@ -0,0 +1,90 @@ +# temporal-self-hosted + +Demonstrates using chant to declare a **self-hosted Temporal** setup as code — namespace configuration, search attributes, and schedules version-controlled alongside your application. + +This is the right example if you run your own Temporal server (Docker or Kubernetes) rather than using Temporal Cloud. For the Temporal Cloud path, see [`temporal-crdb-deploy`](../temporal-crdb-deploy/). + +--- + +## What chant generates + +| Source | Output | Purpose | +|--------|--------|---------| +| `TemporalDevStack` in `src/stack.ts` | `dist/docker-compose.yml` | Starts Temporal server + UI locally | +| `TemporalDevStack` in `src/stack.ts` | `dist/temporal-helm-values.yaml` | Scaffold for production Helm install | +| `TemporalNamespace` (via `TemporalDevStack`) | `dist/temporal-setup.sh` | Creates the `my-app` namespace | +| `SearchAttribute` in `src/search-attrs.ts` | `dist/temporal-setup.sh` | Registers `JobType` and `Priority` attrs | +| `TemporalSchedule` in `src/schedules.ts` | `dist/schedules/daily-sync.ts` | Runnable TypeScript that creates the schedule | + +One source tree, reproducible setup across every environment. + +--- + +## Quickstart + +```bash +npm run build # generate dist/ artifacts +npm run start # docker compose up — Temporal on :7233, UI on :8080 +npm run setup # create namespace + search attributes +``` + +Then register the schedule (once your worker is running): + +```bash +npx tsx dist/schedules/daily-sync.ts +``` + +To stop the server: + +```bash +npm run stop +``` + +--- + +## Connecting your worker + +`chant.config.ts` declares the local worker profile. Import it in your `worker.ts` instead of reading raw env vars: + +```ts +import { Worker, NativeConnection } from "@temporalio/worker"; +import config from "./chant.config.ts"; + +const profile = config.temporal.profiles[config.temporal.defaultProfile ?? "local"]; + +const connection = await NativeConnection.connect({ address: profile.address }); +const worker = await Worker.create({ + connection, + namespace: profile.namespace, + taskQueue: profile.taskQueue, + workflowsPath: new URL("./workflows/index.ts", import.meta.url).pathname, + activities, +}); + +await worker.run(); +``` + +If `autoStart: true` is set (it is in this example), `chant run` will start `temporal server start-dev` automatically before the worker. + +--- + +## Dev → production + +The same `src/` tree generates both: + +- **Dev:** `dist/docker-compose.yml` — single-container dev server via `temporal server start-dev` +- **Prod:** `dist/temporal-helm-values.yaml` — scaffold for `helm install temporal temporal/temporal -f dist/temporal-helm-values.yaml` + +Namespace and search attribute setup (`dist/temporal-setup.sh`) works against both — it uses `$TEMPORAL_ADDRESS` (defaults to `localhost:7233`). + +--- + +## Project structure + +``` +src/ + stack.ts — TemporalDevStack composite (server + namespace) + search-attrs.ts — SearchAttribute declarations scoped to "my-app" + schedules.ts — TemporalSchedule for the daily-sync job +chant.config.ts — local worker profile +``` diff --git a/examples/temporal-self-hosted/chant.config.ts b/examples/temporal-self-hosted/chant.config.ts new file mode 100644 index 00000000..ae2f9d90 --- /dev/null +++ b/examples/temporal-self-hosted/chant.config.ts @@ -0,0 +1,24 @@ +/** + * chant configuration — Temporal worker profiles. + * + * Import this in your worker.ts to get a typed, version-controlled connection profile: + * + * import config from "./chant.config.ts"; + * const profile = config.temporal.profiles[config.temporal.defaultProfile ?? "local"]; + */ + +import type { TemporalChantConfig } from "@intentius/chant-lexicon-temporal"; + +export default { + temporal: { + profiles: { + local: { + address: "localhost:7233", + namespace: "my-app", + taskQueue: "my-app", + autoStart: true, + }, + }, + defaultProfile: "local", + } satisfies TemporalChantConfig, +}; diff --git a/examples/temporal-self-hosted/package.json b/examples/temporal-self-hosted/package.json new file mode 100644 index 00000000..8011a90d --- /dev/null +++ b/examples/temporal-self-hosted/package.json @@ -0,0 +1,17 @@ +{ + "name": "temporal-self-hosted-example", + "version": "0.0.1", + "type": "module", + "private": true, + "scripts": { + "build": "chant build src --lexicon temporal -o dist/docker-compose.yml", + "start": "docker compose -f dist/docker-compose.yml up -d", + "setup": "bash dist/temporal-setup.sh", + "stop": "docker compose -f dist/docker-compose.yml down", + "lint": "chant lint src" + }, + "dependencies": { + "@intentius/chant": "*", + "@intentius/chant-lexicon-temporal": "*" + } +} diff --git a/examples/temporal-self-hosted/src/schedules.ts b/examples/temporal-self-hosted/src/schedules.ts new file mode 100644 index 00000000..82bf843e --- /dev/null +++ b/examples/temporal-self-hosted/src/schedules.ts @@ -0,0 +1,18 @@ +import { TemporalSchedule } from "@intentius/chant-lexicon-temporal"; + +export const dailySync = new TemporalSchedule({ + scheduleId: "daily-sync", + namespace: "my-app", + spec: { + cronExpressions: ["0 8 * * *"], + }, + action: { + workflowType: "syncWorkflow", + taskQueue: "my-app", + workflowExecutionTimeout: "30m", + }, + policies: { + overlap: "Skip", + pauseOnFailure: true, + }, +}); diff --git a/examples/temporal-self-hosted/src/search-attrs.ts b/examples/temporal-self-hosted/src/search-attrs.ts new file mode 100644 index 00000000..2489dee2 --- /dev/null +++ b/examples/temporal-self-hosted/src/search-attrs.ts @@ -0,0 +1,13 @@ +import { SearchAttribute } from "@intentius/chant-lexicon-temporal"; + +export const jobTypeAttr = new SearchAttribute({ + name: "JobType", + type: "Keyword", + namespace: "my-app", +}); + +export const priorityAttr = new SearchAttribute({ + name: "Priority", + type: "Int", + namespace: "my-app", +}); diff --git a/examples/temporal-self-hosted/src/stack.ts b/examples/temporal-self-hosted/src/stack.ts new file mode 100644 index 00000000..98ad5077 --- /dev/null +++ b/examples/temporal-self-hosted/src/stack.ts @@ -0,0 +1,7 @@ +import { TemporalDevStack } from "@intentius/chant-lexicon-temporal"; + +export const { server, ns } = TemporalDevStack({ + namespace: "my-app", + retention: "14d", + description: "Application namespace — managed by chant", +}); diff --git a/lexicons/gcp/docs/package.json b/lexicons/gcp/docs/package.json index 72fbddd5..b9badb32 100644 --- a/lexicons/gcp/docs/package.json +++ b/lexicons/gcp/docs/package.json @@ -10,10 +10,7 @@ }, "dependencies": { "@astrojs/starlight": "^0.37.6", - "astro": "5.6.1", + "astro": "^5.6.1", "sharp": "^0.34.2" - }, - "overrides": { - "@astrojs/sitemap": "3.7.0" } } diff --git a/lexicons/gcp/package.json b/lexicons/gcp/package.json index 35b0c3f0..57c2941d 100644 --- a/lexicons/gcp/package.json +++ b/lexicons/gcp/package.json @@ -1,6 +1,6 @@ { "name": "@intentius/chant-lexicon-gcp", - "version": "0.1.4", + "version": "0.1.5", "description": "Google Cloud lexicon for chant — declarative IaC in TypeScript", "license": "Apache-2.0", "homepage": "https://intentius.io/chant", diff --git a/lexicons/gcp/src/composites/gke-crdb-region.ts b/lexicons/gcp/src/composites/gke-crdb-region.ts new file mode 100644 index 00000000..3c5161f4 --- /dev/null +++ b/lexicons/gcp/src/composites/gke-crdb-region.ts @@ -0,0 +1,232 @@ +/** + * GkeCrdbRegion composite — GKE cluster + public DNS zone + per-region IAM for a CockroachDB region. + * + * Replaces the 2-file per-region GCP infra pattern (infra/cluster.ts + infra/dns.ts) with + * a single composite call. Handles the GKE cluster, the public DNS zone, and all per-region + * Workload Identity IAM bindings for both ExternalDNS and CockroachDB pods. + */ + +import { Composite } from "@intentius/chant"; +import { GkeCluster } from "./gke-cluster"; +import { DNSManagedZone, GCPServiceAccount, IAMPolicyMember } from "../generated"; + +export interface GkeCrdbRegionNodeConfig { + /** Machine type (default: "n2-standard-2"). */ + machineType?: string; + /** Disk size per node in GB (default: 100). */ + diskSizeGb?: number; + /** Initial/min node count (default: 1). */ + nodeCount?: number; + /** Maximum node count for autoscaling (default: 3). */ + maxNodeCount?: number; +} + +export interface GkeCrdbRegionConfig { + /** + * GCP region (e.g. "us-east4"). + * Used as both the cluster location and for NAT router naming. + */ + region: string; + /** GKE cluster name (e.g. "gke-crdb-east"). Used as prefix for child resources. */ + clusterName: string; + /** VPC network resource name. */ + network: string; + /** Node subnet resource name for the cluster. */ + subnetwork: string; + /** Public DNS domain for this region (e.g. "east.crdb.example.com"). */ + domain: string; + /** GCP project ID for Workload Identity pool and service account names. */ + project: string; + /** + * K8s namespace where CockroachDB pods run (e.g. "crdb-east"). + * Used in the Workload Identity binding subject for CRDB pods. + */ + crdbNamespace: string; + /** + * K8s ServiceAccount name for CockroachDB pods (default: "cockroachdb"). + * Used in the Workload Identity binding subject. + */ + crdbK8sSa?: string; + /** + * CIDR block for the GKE master's private endpoint (e.g. "172.16.0.0/28"). + * Must be /28, unique per cluster, not overlapping with node/pod CIDRs. + */ + masterCidr?: string; + /** Node pool configuration. */ + nodeConfig?: GkeCrdbRegionNodeConfig; + /** GCP release channel for cluster upgrades (default: "REGULAR"). */ + releaseChannel?: "RAPID" | "REGULAR" | "STABLE"; + /** Optional backup bucket name to grant CRDB GSA storage.objectAdmin access. */ + backupBucket?: string; + /** Additional labels for all resources. */ + labels?: Record; + /** Namespace for Config Connector resources. */ + namespace?: string; +} + +/** + * Create a GkeCrdbRegion composite — returns all GCP resources for one CockroachDB region: + * GKE cluster, public DNS zone, per-region GSAs, and Workload Identity IAM bindings. + * + * @example + * ```ts + * import { GkeCrdbRegion } from "@intentius/chant-lexicon-gcp"; + * + * export const east = GkeCrdbRegion({ + * region: "us-east4", + * clusterName: "gke-crdb-east", + * network: "crdb-multi-region", + * subnetwork: "crdb-multi-region-east-nodes", + * domain: "east.crdb.example.com", + * project: GCP_PROJECT_ID, + * crdbNamespace: "crdb-east", + * masterCidr: "172.16.0.0/28", + * }); + * ``` + */ +export const GkeCrdbRegion = Composite((props) => { + const { + region, + clusterName, + network, + subnetwork, + domain, + project, + crdbNamespace, + crdbK8sSa = "cockroachdb", + masterCidr = "172.16.0.0/28", + nodeConfig = {}, + releaseChannel = "REGULAR", + backupBucket, + labels: extraLabels = {}, + namespace, + } = props; + + const { + machineType = "n2-standard-2", + diskSizeGb = 100, + nodeCount = 1, + maxNodeCount = 3, + } = nodeConfig; + + const commonLabels: Record = { + "app.kubernetes.io/name": clusterName, + "app.kubernetes.io/managed-by": "chant", + ...extraLabels, + }; + + const meta = (component: string, resourceName: string) => ({ + name: resourceName, + ...(namespace && { namespace }), + labels: { ...commonLabels, "app.kubernetes.io/component": component }, + }); + + // ── GKE Cluster ────────────────────────────────────────────────────────────── + + const { cluster, nodePool, defaultPool } = GkeCluster({ + name: clusterName, + location: region, + machineType, + diskSizeGb, + initialNodeCount: nodeCount, + minNodeCount: nodeCount, + maxNodeCount, + network, + subnetwork, + workloadIdentity: true, + privateNodes: true, + masterCidr, + releaseChannel, + labels: extraLabels, + ...(namespace && { namespace }), + }); + + // ── Public DNS Zone ─────────────────────────────────────────────────────────── + + const dnsZoneName = `${clusterName}-zone`; + + const dnsZone = new DNSManagedZone({ + metadata: meta("dns", dnsZoneName), + dnsName: `${domain}.`, + description: `CockroachDB ${region} UI — managed by chant`, + } as Record); + + // ── ExternalDNS Service Account ─────────────────────────────────────────────── + + const dnsGsaName = `${clusterName}-dns`; + + const dnsGsa = new GCPServiceAccount({ + metadata: meta("iam", dnsGsaName), + displayName: `CockroachDB ${region} ExternalDNS workload identity`, + } as Record); + + const dnsWiBinding = new IAMPolicyMember({ + metadata: meta("iam", `${dnsGsaName}-wi`), + member: `serviceAccount:${project}.svc.id.goog[kube-system/external-dns-sa]`, + role: "roles/iam.workloadIdentityUser", + resourceRef: { + apiVersion: "iam.cnrm.cloud.google.com/v1beta1", + kind: "IAMServiceAccount", + name: dnsGsaName, + }, + } as Record); + + const dnsAdminBinding = new IAMPolicyMember({ + metadata: meta("iam", `${dnsGsaName}-admin`), + member: `serviceAccount:${dnsGsaName}@${project}.iam.gserviceaccount.com`, + role: "roles/dns.admin", + resourceRef: { + apiVersion: "resourcemanager.cnrm.cloud.google.com/v1beta1", + kind: "Project", + external: `projects/${project}`, + }, + } as Record); + + // ── CockroachDB Service Account ─────────────────────────────────────────────── + + const crdbGsaName = `${clusterName}-crdb`; + + const crdbGsa = new GCPServiceAccount({ + metadata: meta("iam", crdbGsaName), + displayName: `CockroachDB ${region} workload identity`, + } as Record); + + const crdbWiBinding = new IAMPolicyMember({ + metadata: meta("iam", `${crdbGsaName}-wi`), + member: `serviceAccount:${project}.svc.id.goog[${crdbNamespace}/${crdbK8sSa}]`, + role: "roles/iam.workloadIdentityUser", + resourceRef: { + apiVersion: "iam.cnrm.cloud.google.com/v1beta1", + kind: "IAMServiceAccount", + name: crdbGsaName, + }, + } as Record); + + const result: Record = { + cluster, + nodePool, + defaultPool, + dnsZone, + dnsGsa, + dnsWiBinding, + dnsAdminBinding, + crdbGsa, + crdbWiBinding, + }; + + // Optional: GCS backup access for CRDB GSA + if (backupBucket) { + result.crdbBackupBinding = new IAMPolicyMember({ + metadata: meta("iam", `${crdbGsaName}-backup`), + member: `serviceAccount:${crdbGsaName}@${project}.iam.gserviceaccount.com`, + role: "roles/storage.objectAdmin", + resourceRef: { + apiVersion: "storage.cnrm.cloud.google.com/v1beta1", + kind: "StorageBucket", + name: backupBucket, + }, + } as Record); + } + + return result; +}, "GkeCrdbRegion"); diff --git a/lexicons/gcp/src/composites/index.ts b/lexicons/gcp/src/composites/index.ts index 86f53f2b..28cd83f2 100644 --- a/lexicons/gcp/src/composites/index.ts +++ b/lexicons/gcp/src/composites/index.ts @@ -20,3 +20,7 @@ export { SecureProject } from "./secure-project"; export type { SecureProjectProps } from "./secure-project"; export { MemorystoreRedis } from "./memorystore-redis"; export type { MemorystoreRedisProps } from "./memorystore-redis"; +export { MultiRegionVpc } from "./multi-region-vpc"; +export type { MultiRegionVpcConfig, MultiRegionVpcRegion } from "./multi-region-vpc"; +export { GkeCrdbRegion } from "./gke-crdb-region"; +export type { GkeCrdbRegionConfig, GkeCrdbRegionNodeConfig } from "./gke-crdb-region"; diff --git a/lexicons/gcp/src/composites/multi-region-vpc.ts b/lexicons/gcp/src/composites/multi-region-vpc.ts new file mode 100644 index 00000000..2fab3fef --- /dev/null +++ b/lexicons/gcp/src/composites/multi-region-vpc.ts @@ -0,0 +1,157 @@ +/** + * MultiRegionVpc composite — VPC + 2 subnets per region + Router + RouterNAT per region + Firewall. + * + * Collapses the boilerplate of a multi-region GKE networking stack into a single call. + * Each region gets node and pod subnets, a Cloud Router, and a Cloud NAT gateway. + * A single allow-internal firewall rule covers all region CIDRs. + */ + +import { Composite } from "@intentius/chant"; +import { VPCNetwork, Subnetwork, Router, RouterNAT, Firewall } from "../generated"; + +export interface MultiRegionVpcRegion { + /** GCP region name (e.g. "us-east4"). Used as the Router/NAT region. */ + region: string; + /** + * Short alias used in resource names (e.g. "east"). + * Defaults to the full region string. Use this to keep names concise. + */ + regionAlias?: string; + /** IP CIDR for GKE node subnet (e.g. "10.1.0.0/20"). */ + nodeSubnetCidr: string; + /** IP CIDR for GKE pod subnet (e.g. "10.1.16.0/20"). */ + podSubnetCidr: string; +} + +export interface MultiRegionVpcConfig { + /** VPC network name. Used as prefix for all sub-resources. */ + name: string; + /** One entry per GCP region. */ + regions: MultiRegionVpcRegion[]; + /** Enable VPC flow logs on all subnets (default: false). */ + enableFlowLogs?: boolean; + /** Additional labels for all resources. */ + labels?: Record; + /** Namespace for Config Connector resources. */ + namespace?: string; +} + +/** + * Create a MultiRegionVpc composite — one VPC with subnets, Cloud NAT, and an + * allow-internal firewall for every region in the array. + * + * The composite eliminates the per-region Router/NAT boilerplate that arises when + * `VpcNetwork` only handles a single NAT region and the rest must be wired manually. + * + * @example + * ```ts + * import { MultiRegionVpc } from "@intentius/chant-lexicon-gcp"; + * + * export const network = MultiRegionVpc({ + * name: "crdb-multi-region", + * regions: [ + * { region: "us-east4", regionAlias: "east", nodeSubnetCidr: "10.1.0.0/20", podSubnetCidr: "10.1.16.0/20" }, + * { region: "us-central1", regionAlias: "central", nodeSubnetCidr: "10.2.0.0/20", podSubnetCidr: "10.2.16.0/20" }, + * { region: "us-west1", regionAlias: "west", nodeSubnetCidr: "10.3.0.0/20", podSubnetCidr: "10.3.16.0/20" }, + * ], + * }); + * ``` + */ +export const MultiRegionVpc = Composite((props) => { + const { + name, + regions, + enableFlowLogs = false, + labels: extraLabels = {}, + namespace, + } = props; + + const commonLabels: Record = { + "app.kubernetes.io/name": name, + "app.kubernetes.io/managed-by": "chant", + ...extraLabels, + }; + + const meta = (component: string, resourceName?: string) => ({ + metadata: { + name: resourceName ?? name, + ...(namespace && { namespace }), + labels: { ...commonLabels, "app.kubernetes.io/component": component }, + }, + }); + + const network = new VPCNetwork({ + ...meta("network"), + autoCreateSubnetworks: false, + routingMode: "REGIONAL", + } as Record); + + const subnetEntries: Record = {}; + const routerEntries: Record = {}; + const natEntries: Record = {}; + const allCidrs: string[] = []; + + for (const r of regions) { + const alias = r.regionAlias ?? r.region; + + allCidrs.push(r.nodeSubnetCidr, r.podSubnetCidr); + + const flowConfig = enableFlowLogs + ? { logConfig: { enable: true, aggregationInterval: "INTERVAL_5_SEC", flowSampling: 0.5 } } + : {}; + + subnetEntries[`subnet_${alias}_nodes`] = new Subnetwork({ + ...meta("subnet", `${name}-${alias}-nodes`), + networkRef: { name }, + ipCidrRange: r.nodeSubnetCidr, + region: r.region, + privateIpGoogleAccess: true, + ...flowConfig, + } as Record); + + subnetEntries[`subnet_${alias}_pods`] = new Subnetwork({ + ...meta("subnet", `${name}-${alias}-pods`), + networkRef: { name }, + ipCidrRange: r.podSubnetCidr, + region: r.region, + privateIpGoogleAccess: true, + ...flowConfig, + } as Record); + + const routerName = `${name}-${alias}`; + + routerEntries[`router_${alias}`] = new Router({ + ...meta("router", routerName), + networkRef: { name }, + region: r.region, + } as Record); + + natEntries[`nat_${alias}`] = new RouterNAT({ + ...meta("nat", routerName), + routerRef: { name: routerName }, + region: r.region, + natIpAllocateOption: "AUTO_ONLY", + sourceSubnetworkIpRangesToNat: "ALL_SUBNETWORKS_ALL_PRIMARY_IP_RANGES", + } as Record); + } + + // Allow all TCP/UDP/ICMP between every subnet CIDR in the VPC. + const firewallInternal = new Firewall({ + ...meta("firewall", `${name}-allow-internal`), + networkRef: { name }, + allow: [ + { protocol: "tcp", ports: ["0-65535"] }, + { protocol: "udp", ports: ["0-65535"] }, + { protocol: "icmp" }, + ], + sourceRanges: allCidrs, + } as Record); + + return { + network, + ...subnetEntries, + ...routerEntries, + ...natEntries, + firewallInternal, + }; +}, "MultiRegionVpc"); diff --git a/lexicons/k8s/package.json b/lexicons/k8s/package.json index 19cc529f..d8ed8e69 100644 --- a/lexicons/k8s/package.json +++ b/lexicons/k8s/package.json @@ -1,6 +1,6 @@ { "name": "@intentius/chant-lexicon-k8s", - "version": "0.1.4", + "version": "0.1.5", "description": "Kubernetes lexicon for chant — declarative IaC in TypeScript", "license": "Apache-2.0", "homepage": "https://intentius.io/chant", diff --git a/lexicons/k8s/src/composites/cockroachdb-region-stack.ts b/lexicons/k8s/src/composites/cockroachdb-region-stack.ts new file mode 100644 index 00000000..bd5d1710 --- /dev/null +++ b/lexicons/k8s/src/composites/cockroachdb-region-stack.ts @@ -0,0 +1,553 @@ +/** + * CockroachDbRegionStack composite — all K8s resources for one CockroachDB region. + * + * Collapses the 8-file per-region K8s pattern into a single composite call: + * namespace, storage, CockroachDB StatefulSet, External Secrets, GCE Ingress, + * ExternalDNS, Cloud Armor BackendConfig, TLS cert, and optional Prometheus monitoring. + * + * @gke Requires: External Secrets Operator (ESO), ExternalDNS (via GkeExternalDnsAgent). + */ + +import { Composite, mergeDefaults } from "@intentius/chant"; +import { createResource } from "@intentius/chant/runtime"; +import { + NetworkPolicy, + Deployment, + Service, + ConfigMap, +} from "../generated"; +import { NamespaceEnv } from "./namespace-env"; +import { GcePdStorageClass } from "./gce-pd-storage-class"; +import { CockroachDbCluster } from "./cockroachdb-cluster"; +import { GceIngress } from "./gce-ingress"; +import { GkeExternalDnsAgent } from "./gke-external-dns-agent"; + +// ── External CRDs (not in main generated index) ────────────────────────────────── + +const ClusterSecretStore = createResource("K8s::ExternalSecrets::ClusterSecretStore", "k8s", {}); +const ExternalSecret = createResource("K8s::ExternalSecrets::ExternalSecret", "k8s", {}); +const BackendConfig = createResource("K8s::GKE::BackendConfig", "k8s", {}); +const ManagedCertificate = createResource("K8s::NetworkingGKE::ManagedCertificate", "k8s", {}); +const FrontendConfig = createResource("K8s::NetworkingGKEBeta::FrontendConfig", "k8s", {}); + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface CockroachDbRegionCockroachConfig { + /** Cluster name (default: "cockroachdb"). */ + name?: string; + /** StatefulSet replicas (default: 3). */ + replicas?: number; + /** CockroachDB image (default: "cockroachdb/cockroach:v24.3.0"). */ + image?: string; + /** PVC storage size per node (default: "100Gi"). */ + storageSize?: string; + /** CPU limit per pod (default: "2"). */ + cpuLimit?: string; + /** Memory limit per pod (default: "8Gi"). */ + memoryLimit?: string; + /** Locality flag (e.g. "cloud=gcp,region=us-east4"). */ + locality?: string; + /** Join addresses for multi-region cluster. */ + joinAddresses?: string[]; + /** + * Skip the cockroach init Job (set true for secondary regions). + * Only the first region should run cockroach init. + */ + skipInit?: boolean; + /** + * Mount the client certs secret into pods (needed for multi-region init via kubectl exec). + */ + mountClientCerts?: boolean; + /** + * Extra DNS names to include in node cert SANs (e.g. cross-region ExternalDNS names). + */ + extraCertNodeAddresses?: string[]; + /** + * Domain for cross-cluster advertise address (e.g. "east.crdb.internal"). + * When set, pods advertise "${HOSTNAME}.${advertiseHostDomain}" for cross-cluster gossip. + */ + advertiseHostDomain?: string; +} + +export interface CockroachDbRegionTlsConfig { + /** + * GCP Secret Manager secret names to sync into K8s Secrets via External Secrets Operator. + */ + gcpSecretNames: { + /** CA cert secret name in GCP Secret Manager. */ + ca: string; + /** Node cert secret name. */ + nodeCrt: string; + /** Node cert key secret name. */ + nodeKey: string; + /** Client root cert secret name. */ + clientRootCrt: string; + /** Client root key secret name. */ + clientRootKey: string; + }; +} + +export interface CockroachDbRegionStackConfig { + /** + * Short region identifier used in resource names and labels (e.g. "east"). + * Used to distinguish resources across regions. + */ + region: string; + /** K8s namespace for all namespaced resources (e.g. "crdb-east"). */ + namespace: string; + /** Public UI domain for this region (e.g. "east.crdb.example.com"). */ + domain: string; + /** Cross-cluster ExternalDNS domain (e.g. "east.crdb.internal"). */ + internalDomain: string; + /** Root public domain for ExternalDNS domain filter (e.g. "crdb.example.com"). */ + publicRootDomain: string; + + /** GCP project ID — used in ClusterSecretStore Workload Identity config. */ + projectId: string; + /** GKE cluster name — used in ClusterSecretStore Workload Identity config. */ + clusterName: string; + /** GKE cluster region (e.g. "us-east4") — used in ClusterSecretStore config. */ + clusterRegion: string; + /** GCP service account email for CRDB Workload Identity annotation on the K8s SA. */ + crdbGsaEmail: string; + /** GCP service account email for ExternalDNS Workload Identity. */ + externalDnsGsaEmail: string; + + /** CockroachDB cluster configuration. */ + cockroachdb: CockroachDbRegionCockroachConfig; + /** TLS certificate sync config (External Secrets Operator). */ + tls: CockroachDbRegionTlsConfig; + + /** Namespace resource quotas. */ + quota?: { + /** Total CPU limit quota (e.g. "8"). */ + cpu?: string; + /** Total memory limit quota (e.g. "20Gi"). */ + memory?: string; + /** Maximum pods. */ + maxPods?: number; + }; + + /** + * CIDRs that may send traffic to CockroachDB ports (26257 + 8080). + * Used in the allow-cockroachdb NetworkPolicy. + */ + allowCidrs?: string[]; + + /** + * Cloud Armor WAF configuration. + * When set, a BackendConfig is created attaching the policy to the public service. + */ + cloudArmor?: { + /** Cloud Armor security policy name. */ + policyName: string; + }; + + /** + * Emit Prometheus ConfigMap + Deployment + Service for CockroachDB metrics. + * Uses a lightweight Prometheus scraping `/_status/vars` (default: false). + */ + monitoring?: boolean; + + /** Additional labels for all resources. */ + labels?: Record; +} + +/** + * Create a CockroachDbRegionStack composite — returns all K8s resources for one + * CockroachDB region in a single call. + * + * Replaces: namespace.ts, storage.ts, cockroachdb.ts, external-secrets.ts, + * ingress.ts, tls.ts, backend-config.ts, monitoring.ts (8 files → 1 call). + * + * @gke + * @example + * ```ts + * import { CockroachDbRegionStack } from "@intentius/chant-lexicon-k8s"; + * + * export const east = CockroachDbRegionStack({ + * region: "east", + * namespace: "crdb-east", + * domain: `east.${CRDB_DOMAIN}`, + * internalDomain: `east.${INTERNAL_DOMAIN}`, + * publicRootDomain: CRDB_DOMAIN, + * projectId: GCP_PROJECT_ID, + * clusterName: "gke-crdb-east", + * clusterRegion: "us-east4", + * crdbGsaEmail: `gke-crdb-east-crdb@${GCP_PROJECT_ID}.iam.gserviceaccount.com`, + * externalDnsGsaEmail: `gke-crdb-east-dns@${GCP_PROJECT_ID}.iam.gserviceaccount.com`, + * cockroachdb: { + * locality: "cloud=gcp,region=us-east4", + * joinAddresses: [...], + * advertiseHostDomain: `east.${INTERNAL_DOMAIN}`, + * skipInit: false, + * mountClientCerts: true, + * }, + * tls: { + * gcpSecretNames: { + * ca: "crdb-ca-crt", nodeCrt: "crdb-node-crt", nodeKey: "crdb-node-key", + * clientRootCrt: "crdb-client-root-crt", clientRootKey: "crdb-client-root-key", + * }, + * }, + * allowCidrs: ALL_CIDRS, + * cloudArmor: { policyName: "crdb-ui-waf" }, + * monitoring: true, + * }); + * ``` + */ +export const CockroachDbRegionStack = Composite((props) => { + const { + region, + namespace: ns, + domain, + internalDomain, + publicRootDomain, + projectId, + clusterName, + clusterRegion, + crdbGsaEmail, + externalDnsGsaEmail, + cockroachdb: crdbProps, + tls, + quota, + allowCidrs = [], + cloudArmor, + monitoring = false, + labels: extraLabels = {}, + } = props; + + const crdbName = crdbProps.name ?? "cockroachdb"; + + const commonLabels: Record = { + "app.kubernetes.io/managed-by": "chant", + "app.kubernetes.io/part-of": `cockroachdb-multi-region`, + "app.kubernetes.io/instance": region, + ...extraLabels, + }; + + // ── Namespace + Quotas ───────────────────────────────────────────────────────── + + const nsResult = NamespaceEnv({ + name: ns, + ...(quota?.cpu && { cpuQuota: quota.cpu }), + ...(quota?.memory && { memoryQuota: quota.memory }), + ...(quota?.maxPods !== undefined && { maxPods: quota.maxPods }), + defaultCpuRequest: "250m", + defaultMemoryRequest: "512Mi", + defaultCpuLimit: "1", + defaultMemoryLimit: "4Gi", + defaultDenyIngress: true, + labels: { + "pod-security.kubernetes.io/enforce": "baseline", + "pod-security.kubernetes.io/enforce-version": "latest", + "pod-security.kubernetes.io/warn": "restricted", + "pod-security.kubernetes.io/audit": "restricted", + ...commonLabels, + }, + }); + + // Allow CockroachDB inter-node (26257) + HTTP UI (8080) from all region CIDRs. + const crdbNetworkPolicy = new NetworkPolicy(mergeDefaults({ + metadata: { + name: "allow-cockroachdb", + namespace: ns, + labels: commonLabels, + }, + spec: { + podSelector: { matchLabels: { "app.kubernetes.io/name": crdbName } }, + policyTypes: ["Ingress"], + ingress: allowCidrs.length > 0 + ? [{ + from: allowCidrs.map((cidr) => ({ ipBlock: { cidr } })), + ports: [ + { protocol: "TCP", port: 26257 }, + { protocol: "TCP", port: 8080 }, + ], + }] + : [], + }, + }, {})); + + // ── Storage ──────────────────────────────────────────────────────────────────── + + const { storageClass } = GcePdStorageClass({ + name: "pd-ssd", + type: "pd-ssd", + labels: commonLabels, + }); + + // ── CockroachDB Cluster ──────────────────────────────────────────────────────── + + const crdb = CockroachDbCluster({ + name: crdbName, + namespace: ns, + replicas: crdbProps.replicas ?? 3, + image: crdbProps.image, + storageSize: crdbProps.storageSize, + storageClassName: "pd-ssd", + cpuLimit: crdbProps.cpuLimit, + memoryLimit: crdbProps.memoryLimit, + locality: crdbProps.locality, + joinAddresses: crdbProps.joinAddresses, + secure: true, + skipCertGen: true, + skipInit: crdbProps.skipInit, + mountClientCerts: crdbProps.mountClientCerts, + advertiseHostDomain: crdbProps.advertiseHostDomain, + extraCertNodeAddresses: crdbProps.extraCertNodeAddresses, + labels: commonLabels, + defaults: { + serviceAccount: { + metadata: { + annotations: { + "iam.gke.io/gcp-service-account": crdbGsaEmail, + }, + }, + }, + publicService: { + metadata: { + annotations: { + "cloud.google.com/backend-config": `{"default":"${crdbName}-ui-backend"}`, + "cloud.google.com/app-protocols": `{"http":"HTTPS"}`, + }, + }, + }, + headlessService: { + metadata: { + annotations: { + "external-dns.alpha.kubernetes.io/hostname": internalDomain, + }, + }, + }, + }, + }); + + // ── External Secrets (TLS certs from GCP Secret Manager) ────────────────────── + + const secretStoreName = "gcp-secret-manager"; + + const gcpSecretStore = new ClusterSecretStore({ + metadata: { name: secretStoreName }, + spec: { + provider: { + gcpsm: { + projectID: projectId, + auth: { + workloadIdentity: { + clusterLocation: clusterRegion, + clusterName, + serviceAccountRef: { name: "external-secrets-sa", namespace: "kube-system" }, + }, + }, + }, + }, + }, + }); + + const nodeCertsSecret = new ExternalSecret({ + metadata: { name: `${crdbName}-node-certs-eso`, namespace: ns }, + spec: { + refreshInterval: "1h", + secretStoreRef: { name: secretStoreName, kind: "ClusterSecretStore" }, + target: { name: `${crdbName}-node-certs`, creationPolicy: "Owner" }, + data: [ + { secretKey: "ca.crt", remoteRef: { key: tls.gcpSecretNames.ca } }, + { secretKey: "node.crt", remoteRef: { key: tls.gcpSecretNames.nodeCrt } }, + { secretKey: "node.key", remoteRef: { key: tls.gcpSecretNames.nodeKey } }, + ], + }, + }); + + const clientCertsSecret = new ExternalSecret({ + metadata: { name: `${crdbName}-client-certs-eso`, namespace: ns }, + spec: { + refreshInterval: "1h", + secretStoreRef: { name: secretStoreName, kind: "ClusterSecretStore" }, + target: { name: `${crdbName}-client-certs`, creationPolicy: "Owner" }, + data: [ + { secretKey: "ca.crt", remoteRef: { key: tls.gcpSecretNames.ca } }, + { secretKey: "client.root.crt", remoteRef: { key: tls.gcpSecretNames.clientRootCrt } }, + { secretKey: "client.root.key", remoteRef: { key: tls.gcpSecretNames.clientRootKey } }, + ], + }, + }); + + // ── TLS: Managed Certificate + FrontendConfig ───────────────────────────────── + + const certName = `${crdbName}-ui-cert`; + const frontendName = `${crdbName}-ui-frontend`; + const backendName = `${crdbName}-ui-backend`; + + const crdbManagedCert = new ManagedCertificate({ + metadata: { + name: certName, + namespace: ns, + labels: commonLabels, + }, + spec: { domains: [domain] }, + }); + + const crdbFrontendConfig = new FrontendConfig({ + metadata: { + name: frontendName, + namespace: ns, + labels: commonLabels, + }, + spec: { + redirectToHttps: { + enabled: true, + responseCodeName: "MOVED_PERMANENTLY_DEFAULT", + }, + }, + }); + + // ── GCE Ingress ──────────────────────────────────────────────────────────────── + + const { ingress: gceIngress } = GceIngress({ + name: `${crdbName}-ui`, + hosts: [{ + hostname: domain, + paths: [{ path: "/", serviceName: `${crdbName}-public`, servicePort: 8080 }], + }], + namespace: ns, + managedCertificate: certName, + frontendConfig: frontendName, + labels: commonLabels, + }); + + // ── ExternalDNS ──────────────────────────────────────────────────────────────── + + const dnsAgent = GkeExternalDnsAgent({ + gcpServiceAccountEmail: externalDnsGsaEmail, + gcpProjectId: projectId, + domainFilters: [publicRootDomain, internalDomain.split(".").slice(1).join(".")], + txtOwnerId: clusterName, + source: ["service", "ingress"], + labels: commonLabels, + }); + + // ── Result object ───────────────────────────────────────────────────────────── + + const result: Record = { + // Namespace + ...nsResult, + crdbNetworkPolicy, + + // Storage + storageClass, + + // CockroachDB + cockroachdbServiceAccount: crdb.serviceAccount, + cockroachdbRole: crdb.role, + cockroachdbRoleBinding: crdb.roleBinding, + cockroachdbClusterRole: crdb.clusterRole, + cockroachdbClusterRoleBinding: crdb.clusterRoleBinding, + cockroachdbPublicService: crdb.publicService, + cockroachdbHeadlessService: crdb.headlessService, + cockroachdbPdb: crdb.pdb, + cockroachdbStatefulSet: crdb.statefulSet, + ...(crdb.initJob && { cockroachdbInitJob: crdb.initJob }), + + // External Secrets + gcpSecretStore, + nodeCertsSecret, + clientCertsSecret, + + // TLS + crdbManagedCert, + crdbFrontendConfig, + + // Ingress + ExternalDNS + gceIngress, + dnsDeployment: dnsAgent.deployment, + dnsServiceAccount: dnsAgent.serviceAccount, + dnsClusterRole: dnsAgent.clusterRole, + dnsClusterRoleBinding: dnsAgent.clusterRoleBinding, + }; + + // Optional: Cloud Armor BackendConfig + if (cloudArmor) { + result.crdbBackendConfig = new BackendConfig({ + metadata: { + name: backendName, + namespace: ns, + labels: commonLabels, + }, + spec: { + securityPolicy: { name: cloudArmor.policyName }, + healthCheck: { type: "HTTPS", requestPath: "/health", port: 8080 }, + }, + }); + } + + // Optional: Prometheus monitoring + if (monitoring) { + result.prometheusConfig = new ConfigMap({ + metadata: { name: "prometheus-config", namespace: ns }, + data: { + "prometheus.yml": [ + `global:`, + ` scrape_interval: 15s`, + `scrape_configs:`, + ` - job_name: "cockroachdb"`, + ` kubernetes_sd_configs:`, + ` - role: pod`, + ` namespaces:`, + ` names: ["${ns}"]`, + ` relabel_configs:`, + ` - source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_name]`, + ` regex: "cockroachdb"`, + ` action: keep`, + ` - source_labels: [__meta_kubernetes_pod_ip]`, + ` target_label: __address__`, + ` replacement: "$1:8080"`, + ` - source_labels: [__meta_kubernetes_pod_name]`, + ` target_label: instance`, + ` metrics_path: "/_status/vars"`, + ].join("\n"), + }, + } as Record); + + result.prometheusDeployment = new Deployment({ + metadata: { + name: "prometheus", + namespace: ns, + labels: { "app.kubernetes.io/name": "prometheus", ...commonLabels }, + }, + spec: { + replicas: 1, + selector: { matchLabels: { "app.kubernetes.io/name": "prometheus" } }, + template: { + metadata: { labels: { "app.kubernetes.io/name": "prometheus" } }, + spec: { + containers: [{ + name: "prometheus", + image: "prom/prometheus:v2.51.0", + args: [ + "--config.file=/etc/prometheus/prometheus.yml", + "--storage.tsdb.retention.time=15d", + ], + ports: [{ name: "http", containerPort: 9090 }], + resources: { + requests: { cpu: "500m", memory: "1Gi" }, + limits: { cpu: "1", memory: "2Gi" }, + }, + volumeMounts: [{ name: "config", mountPath: "/etc/prometheus" }], + }], + volumes: [{ name: "config", configMap: { name: "prometheus-config" } }], + }, + }, + }, + } as Record); + + result.prometheusService = new Service({ + metadata: { name: "prometheus", namespace: ns }, + spec: { + selector: { "app.kubernetes.io/name": "prometheus" }, + ports: [{ name: "http", port: 9090, targetPort: "http" }], + }, + } as Record); + } + + return result; +}, "CockroachDbRegionStack"); diff --git a/lexicons/k8s/src/composites/index.ts b/lexicons/k8s/src/composites/index.ts index 9626b5ed..57174fcb 100644 --- a/lexicons/k8s/src/composites/index.ts +++ b/lexicons/k8s/src/composites/index.ts @@ -73,6 +73,8 @@ export { AksExternalDnsAgent } from "./aks-external-dns-agent"; export type { AksExternalDnsAgentProps, AksExternalDnsAgentResult } from "./aks-external-dns-agent"; export { CockroachDbCluster } from "./cockroachdb-cluster"; export type { CockroachDbClusterProps, CockroachDbClusterResult } from "./cockroachdb-cluster"; +export { CockroachDbRegionStack } from "./cockroachdb-region-stack"; +export type { CockroachDbRegionStackConfig, CockroachDbRegionCockroachConfig, CockroachDbRegionTlsConfig } from "./cockroachdb-region-stack"; export { RayCluster } from "./ray-cluster"; export type { RayClusterProps, RayClusterResult, RayClusterSpec, ResourceSpec, HeadGroupSpec, WorkerGroupSpec } from "./ray-cluster"; export { RayJob } from "./ray-job"; diff --git a/lexicons/temporal/docs/astro.config.mjs b/lexicons/temporal/docs/astro.config.mjs new file mode 100644 index 00000000..aa12991b --- /dev/null +++ b/lexicons/temporal/docs/astro.config.mjs @@ -0,0 +1,23 @@ +// @ts-check +import { defineConfig } from 'astro/config'; +import starlight from '@astrojs/starlight'; + +export default defineConfig({ + base: '/chant/lexicons/temporal/', + integrations: [ + starlight({ + title: 'Temporal', + sidebar: [ + { label: '← chant docs', link: '../../' }, + { label: 'Overview', slug: 'index' }, + { label: 'Getting Started', slug: 'getting-started' }, + { label: 'Temporal Concepts', slug: 'temporal-concepts' }, + { label: 'Resources', slug: 'resources' }, + { label: 'Serialization', slug: 'serialization' }, + { label: 'Worker Profiles', slug: 'worker-profiles' }, + { label: 'AI Skills', slug: 'skills' }, + { label: 'Lint Rules', slug: 'lint-rules' }, + ], + }), + ], +}); diff --git a/lexicons/temporal/docs/package.json b/lexicons/temporal/docs/package.json new file mode 100644 index 00000000..93e473cb --- /dev/null +++ b/lexicons/temporal/docs/package.json @@ -0,0 +1,19 @@ +{ + "name": "@intentius/chant-lexicon-temporal-docs", + "type": "module", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "dependencies": { + "@astrojs/starlight": "^0.37.6", + "astro": "5.6.1", + "sharp": "^0.34.2" + }, + "overrides": { + "@astrojs/sitemap": "3.7.0" + } +} diff --git a/lexicons/temporal/docs/src/content/docs/getting-started.mdx b/lexicons/temporal/docs/src/content/docs/getting-started.mdx new file mode 100644 index 00000000..a27636a1 --- /dev/null +++ b/lexicons/temporal/docs/src/content/docs/getting-started.mdx @@ -0,0 +1,50 @@ +--- +title: Getting Started +description: Scaffold your first Temporal project with chant. +--- + +## Prerequisites + +- chant installed: `npm install -g @intentius/chant` +- Docker or Temporal CLI for local dev +- Node.js 20+ + +## Scaffold a new project + +```bash +chant init --lexicon temporal --template local +``` + +This creates `src/temporal.ts` with a dev server and default namespace. + +## Build + +```bash +chant build src/ --output dist/ +``` + +Outputs: +- `dist/docker-compose.yml` — Temporal dev server +- `dist/temporal-setup.sh` — namespace provisioning + +## Start the server and provision + +```bash +docker compose -f dist/docker-compose.yml up -d +TEMPORAL_ADDRESS=localhost:7233 bash dist/temporal-setup.sh +``` + +Open the Web UI: http://localhost:8080 + +## Templates + +| Template | Use case | +|---|---| +| `local` (default) | Local dev with `temporal server start-dev` | +| `cloud` | Temporal Cloud — namespaces, search attrs, schedules | +| `full` | Full stack — all 4 resource types, production server mode | + +```bash +chant init --lexicon temporal --template cloud +chant init --lexicon temporal --template full +``` diff --git a/lexicons/temporal/docs/src/content/docs/index.mdx b/lexicons/temporal/docs/src/content/docs/index.mdx new file mode 100644 index 00000000..97a41039 --- /dev/null +++ b/lexicons/temporal/docs/src/content/docs/index.mdx @@ -0,0 +1,58 @@ +--- +title: Temporal Lexicon +description: Temporal.io server deployment, namespace provisioning, search attributes, and SDK schedule creation for chant projects. +--- + +The `@intentius/chant-lexicon-temporal` package bridges Temporal.io's infrastructure concerns +into chant's build pipeline. Declare server configurations, namespaces, search attributes, and +schedules in TypeScript — `chant build` compiles them to deployment artifacts. + +## Stats + +| Category | Count | +|---|---| +| Resources | 4 | +| Intrinsics | 0 | +| Pseudo-parameters | 0 | +| Lint rules | 0 | +| Skills | 2 | + +## Resources + +| Resource | Entity type | Output | +|---|---|---| +| `TemporalServer` | `Temporal::Server` | `docker-compose.yml` + `temporal-helm-values.yaml` | +| `TemporalNamespace` | `Temporal::Namespace` | `temporal-setup.sh` (namespace create) | +| `SearchAttribute` | `Temporal::SearchAttribute` | `temporal-setup.sh` (search-attribute create) | +| `TemporalSchedule` | `Temporal::Schedule` | `schedules/.ts` (SDK schedule creation) | + +## Install + +```bash +npm install @intentius/chant-lexicon-temporal +``` + +Add to `chant.config.ts`: + +```ts +export default { + lexicons: ["temporal"], +}; +``` + +## Quick example + +```ts +import { TemporalServer, TemporalNamespace } from "@intentius/chant-lexicon-temporal"; + +export const server = new TemporalServer({ mode: "dev" }); +export const ns = new TemporalNamespace({ name: "default", retention: "7d" }); +``` + +Build: + +```bash +chant build src/ --output dist/ +docker compose up -d # starts Temporal dev server +bash dist/temporal-setup.sh # creates namespace +``` diff --git a/lexicons/temporal/docs/src/content/docs/lint-rules.mdx b/lexicons/temporal/docs/src/content/docs/lint-rules.mdx new file mode 100644 index 00000000..f04a283d --- /dev/null +++ b/lexicons/temporal/docs/src/content/docs/lint-rules.mdx @@ -0,0 +1,13 @@ +--- +title: Lint Rules +description: Temporal lexicon lint rules. +--- + +No lint rules are defined in this version of the Temporal lexicon. + +Future versions may add rules such as: +- `TMP001` — `TemporalServer` should not use `mode: "full"` without explicit `postgresVersion` +- `TMP002` — `TemporalSchedule` with `overlap: "AllowAll"` should have a comment explaining the intent +- `TMP003` — `TemporalNamespace` retention shorter than 3 days may cause data loss + +To write custom rules for your project, see the [lint-rules authoring guide](/chant/docs/lexicon-authoring/lint-rules/). diff --git a/lexicons/temporal/docs/src/content/docs/resources.mdx b/lexicons/temporal/docs/src/content/docs/resources.mdx new file mode 100644 index 00000000..dbc1d6d0 --- /dev/null +++ b/lexicons/temporal/docs/src/content/docs/resources.mdx @@ -0,0 +1,113 @@ +--- +title: Resources +description: Full reference for all 4 Temporal lexicon resource types. +--- + +## TemporalServer + +Serializes to `docker-compose.yml` (primary output) and `temporal-helm-values.yaml`. + +```ts +import { TemporalServer } from "@intentius/chant-lexicon-temporal"; + +export const server = new TemporalServer({ + version: "1.26.2", + mode: "dev", + port: 7233, + uiPort: 8080, +}); +``` + +| Prop | Type | Default | Description | +|---|---|---|---| +| `version` | `string` | `"1.26.2"` | Temporal server image tag | +| `mode` | `"dev" \| "full"` | `"dev"` | Deployment mode | +| `port` | `number` | `7233` | gRPC port | +| `uiPort` | `number` | `8080` | Web UI port | +| `postgresVersion` | `string` | `"16-alpine"` | PostgreSQL tag (full mode) | +| `helmChartVersion` | `string` | — | Helm chart version comment | + +--- + +## TemporalNamespace + +Serializes to `temporal-setup.sh` as a `temporal operator namespace create` command. + +```ts +import { TemporalNamespace } from "@intentius/chant-lexicon-temporal"; + +export const ns = new TemporalNamespace({ + name: "prod-deploy", + retention: "30d", + description: "Production deployment workflows", +}); +``` + +| Prop | Type | Default | Description | +|---|---|---|---| +| `name` | `string` | — | Namespace name (required) | +| `retention` | `string` | `"7d"` | History retention (e.g. `"30d"`) | +| `description` | `string` | — | Human-readable description | +| `isGlobalNamespace` | `boolean` | — | Multi-cluster global namespace | + +--- + +## SearchAttribute + +Serializes to `temporal-setup.sh` as `temporal operator search-attribute create` commands. + +```ts +import { SearchAttribute } from "@intentius/chant-lexicon-temporal"; + +export const gcpProject = new SearchAttribute({ + name: "GcpProject", + type: "Text", + namespace: "prod-deploy", +}); +``` + +| Prop | Type | Default | Description | +|---|---|---|---| +| `name` | `string` | — | Attribute name (required, PascalCase) | +| `type` | `AttributeType` | — | Value type (required) | +| `namespace` | `string` | — | Target namespace (omit for global) | + +**Attribute types:** `Text`, `Keyword`, `Int`, `Double`, `Bool`, `Datetime`, `KeywordList` + +--- + +## TemporalSchedule + +Serializes to `schedules/.ts` — a standalone TypeScript runner using the Temporal SDK. + +```ts +import { TemporalSchedule } from "@intentius/chant-lexicon-temporal"; + +export const weekly = new TemporalSchedule({ + scheduleId: "weekly-backup", + spec: { cronExpressions: ["0 3 * * SUN"] }, + action: { + workflowType: "backupWorkflow", + taskQueue: "backup-queue", + args: [{ bucket: "my-backup-bucket" }], + }, + policies: { + overlap: "Skip", + pauseOnFailure: true, + }, + namespace: "prod-deploy", +}); +``` + +| Prop | Type | Description | +|---|---|---| +| `scheduleId` | `string` | Unique schedule ID (required) | +| `spec.cronExpressions` | `string[]` | Cron expressions | +| `spec.intervals` | `Array<{every, offset?}>` | Fixed intervals (e.g. `"1d"`) | +| `action.workflowType` | `string` | Workflow function name (required) | +| `action.taskQueue` | `string` | Worker task queue (required) | +| `action.args` | `unknown[]` | Workflow input arguments | +| `policies.overlap` | `OverlapPolicy` | Concurrent run policy | +| `policies.pauseOnFailure` | `boolean` | Pause schedule after failure | +| `state.paused` | `boolean` | Start paused | +| `namespace` | `string` | Target namespace | diff --git a/lexicons/temporal/docs/src/content/docs/serialization.mdx b/lexicons/temporal/docs/src/content/docs/serialization.mdx new file mode 100644 index 00000000..47950d61 --- /dev/null +++ b/lexicons/temporal/docs/src/content/docs/serialization.mdx @@ -0,0 +1,135 @@ +--- +title: Serialization +description: What each Temporal resource emits and how the outputs are structured. +--- + +The Temporal serializer uses a `SerializerResult` (primary file + additional files) when multiple output types are present. If only `TemporalServer` entities exist, it returns a plain string. + +## Output map + +| Entity | File key | Format | +|---|---|---| +| `TemporalServer` | primary (`docker-compose.yml`) | YAML | +| `TemporalServer` | `temporal-helm-values.yaml` | YAML | +| `TemporalNamespace` | `temporal-setup.sh` | Bash | +| `SearchAttribute` | `temporal-setup.sh` | Bash (appended) | +| `TemporalSchedule` | `schedules/.ts` | TypeScript | + +## docker-compose.yml (dev mode) + +```yaml +# Generated by chant — do not edit directly. +services: + temporal: + image: temporalio/admin-tools:1.26.2 + command: temporal server start-dev --namespace default --ui-port 8080 + ports: + - "7233:7233" + - "8080:8080" +``` + +## docker-compose.yml (full mode) + +```yaml +services: + temporal: + image: temporalio/auto-setup:1.26.2 + ports: + - "7233:7233" + environment: + - DB=postgresql + - DB_PORT=5432 + - POSTGRES_USER=temporal + - POSTGRES_PWD=temporal + - POSTGRES_SEEDS=postgresql + depends_on: + - postgresql + temporal-ui: + image: temporalio/ui:1.26.2 + environment: + - TEMPORAL_ADDRESS=temporal:7233 + ports: + - "8080:8080" + postgresql: + image: postgres:16-alpine + environment: + - POSTGRES_USER=temporal + - POSTGRES_PASSWORD=temporal + volumes: + - temporal-db:/var/lib/postgresql/data +volumes: + temporal-db: {} +``` + +## temporal-helm-values.yaml + +```yaml +# Generated by chant — do not edit directly. +# Apply: helm install temporal temporal/temporal -f temporal-helm-values.yaml +server: + replicaCount: 1 + image: + repository: temporalio/server + tag: "1.26.2" +frontend: + replicaCount: 1 + service: + port: 7233 +web: + enabled: true +``` + +## temporal-setup.sh + +```bash +#!/usr/bin/env bash +set -euo pipefail +TEMPORAL_ADDRESS="${TEMPORAL_ADDRESS:-localhost:7233}" + +temporal operator namespace create \ + --address "${TEMPORAL_ADDRESS}" \ + --namespace "prod-deploy" \ + --retention "30d" \ + --description "Production deployment workflows" + +temporal operator search-attribute create \ + --address "${TEMPORAL_ADDRESS}" \ + --namespace "prod-deploy" \ + --name "GcpProject" \ + --type Text +``` + +## schedules/daily-backup.ts + +```ts +// Generated by chant — do not edit directly. +import { Client, Connection } from "@temporalio/client"; + +async function createSchedule(): Promise { + const connection = await Connection.connect({ + address: process.env.TEMPORAL_ADDRESS ?? "localhost:7233", + }); + const client = new Client({ + connection, + namespace: process.env.TEMPORAL_NAMESPACE ?? "default", + }); + await client.schedule.create({ + scheduleId: "daily-backup", + spec: { cronExpressions: ["0 3 * * *"] }, + action: { + type: "startWorkflow", + workflowType: "backupWorkflow", + taskQueue: "backup-queue", + args: [], + }, + policies: { overlap: "Skip" }, + }); + console.log("Schedule created: daily-backup"); + await connection.close(); +} + +createSchedule().catch((err: unknown) => { + console.error(err); + process.exit(1); +}); +``` diff --git a/lexicons/temporal/docs/src/content/docs/skills.mdx b/lexicons/temporal/docs/src/content/docs/skills.mdx new file mode 100644 index 00000000..705111e4 --- /dev/null +++ b/lexicons/temporal/docs/src/content/docs/skills.mdx @@ -0,0 +1,29 @@ +--- +title: AI Skills +description: The Temporal lexicon ships two AI skills for operational workflows. +--- + +The Temporal lexicon registers two skills in the chant MCP server. + +## chant-temporal + +**Trigger conditions:** `*.ts` files with temporal imports, "temporal" or "workflow" context. + +Covers: +- Building Temporal projects with `chant build` +- Starting the dev server via generated `docker-compose.yml` +- Running namespace provisioning (`bash dist/temporal-setup.sh`) +- Deploying with Helm (`temporal-helm-values.yaml`) +- Registering schedules (`npx tsx dist/schedules/.ts`) + +## chant-temporal-ops + +**Trigger conditions:** "temporal workflow", "stuck activity", "chant run" context. + +Covers: +- Signaling gate steps to unblock paused workflows +- Diagnosing stuck activities (heartbeat timeout vs. schedule-to-start) +- Resetting workflows to a previous checkpoint via `temporal workflow reset` +- Inspecting run history with `temporal workflow show` +- Pausing/resuming/canceling schedules +- Common failure patterns (no pollers, infinite retries, duplicate workflow IDs) diff --git a/lexicons/temporal/docs/src/content/docs/temporal-concepts.mdx b/lexicons/temporal/docs/src/content/docs/temporal-concepts.mdx new file mode 100644 index 00000000..11374dc2 --- /dev/null +++ b/lexicons/temporal/docs/src/content/docs/temporal-concepts.mdx @@ -0,0 +1,76 @@ +--- +title: Temporal Concepts +description: Core Temporal.io concepts relevant to the chant lexicon. +--- + +## Server modes + +The `TemporalServer` resource supports two deployment modes: + +### dev mode (default) + +Runs a single container using `temporal server start-dev`. Backed by an in-memory SQLite database — state is lost on restart. Ideal for local development. + +```ts +export const server = new TemporalServer({ mode: "dev" }); +``` + +### full mode + +Runs `temporalio/auto-setup` with a PostgreSQL database and the Temporal Web UI as separate services. State persists across restarts. + +```ts +export const server = new TemporalServer({ mode: "full" }); +``` + +## Namespaces + +Namespaces are isolated units of workflow execution. Each namespace has its own retention policy (how long workflow history is kept after completion). + +```ts +export const ns = new TemporalNamespace({ + name: "prod-deploy", + retention: "30d", +}); +``` + +The serializer emits `temporal operator namespace create` commands to `temporal-setup.sh`. Run this script once after the server is ready. + +## Search Attributes + +Search attributes let you filter workflows in the Temporal Web UI and via `temporal workflow list --query`. They must be registered before workflows can use them. + +```ts +export const gcpProject = new SearchAttribute({ + name: "GcpProject", + type: "Text", + namespace: "prod-deploy", +}); +``` + +Types: `Text`, `Keyword`, `Int`, `Double`, `Bool`, `Datetime`, `KeywordList` + +## Schedules + +A `TemporalSchedule` declares a recurring workflow trigger. The serializer emits a self-contained TypeScript runner (`schedules/.ts`) that uses the Temporal SDK to create the schedule. + +```ts +export const daily = new TemporalSchedule({ + scheduleId: "daily-report", + spec: { cronExpressions: ["0 9 * * MON-FRI"] }, + action: { + workflowType: "generateReport", + taskQueue: "reports", + }, + policies: { overlap: "Skip" }, +}); +``` + +Overlap policies: +- `Skip` — skip the new run if one is already running (most common) +- `BufferOne` — buffer one run while another is active +- `AllowAll` — run concurrently (use with care) + +## Worker profiles + +Worker profiles (defined in `chant.config.ts`) tell `chant run` how to connect to Temporal. See the [Worker Profiles](/worker-profiles) page. diff --git a/lexicons/temporal/docs/src/content/docs/worker-profiles.mdx b/lexicons/temporal/docs/src/content/docs/worker-profiles.mdx new file mode 100644 index 00000000..a86adefb --- /dev/null +++ b/lexicons/temporal/docs/src/content/docs/worker-profiles.mdx @@ -0,0 +1,74 @@ +--- +title: Worker Profiles +description: Configure chant.config.ts with Temporal connection profiles for chant run. +--- + +Worker profiles define how `chant run` (issue #8) connects to a Temporal server. They are stored in `chant.config.ts` under the `temporal.profiles` key. + +## Profile shape + +```ts +import type { TemporalChantConfig } from "@intentius/chant-lexicon-temporal"; + +export default { + lexicons: ["temporal"], + temporal: { + profiles: { + local: { + address: "localhost:7233", + namespace: "default", + taskQueue: "my-deploy", + autoStart: true, + }, + cloud: { + address: "myns.a2dd6.tmprl.cloud:7233", + namespace: "myns.a2dd6", + taskQueue: "my-deploy", + tls: true, + apiKey: { env: "TEMPORAL_API_KEY" }, + }, + }, + defaultProfile: "local", + } satisfies TemporalChantConfig, +}; +``` + +## Profile fields + +| Field | Type | Description | +|---|---|---| +| `address` | `string` | gRPC address, e.g. `"localhost:7233"` | +| `namespace` | `string` | Temporal namespace | +| `taskQueue` | `string` | Task queue the worker polls | +| `tls` | `boolean \| { serverNameOverride? }` | TLS config (use `true` for Temporal Cloud) | +| `apiKey` | `string \| { env: string }` | API key (Temporal Cloud) | +| `autoStart` | `boolean` | Auto-start `temporal server start-dev` (local only) | + +## API key patterns + +```ts +// Inline (not recommended for production) +apiKey: "my-secret-key" + +// From environment variable (recommended) +apiKey: { env: "TEMPORAL_API_KEY" } +``` + +## Selecting a profile + +```bash +# Use defaultProfile +chant run alb-deploy + +# Override profile +chant run alb-deploy --profile cloud +``` + +## Environment variable fallback + +If no profile is specified, `chant run` also respects: +- `TEMPORAL_ADDRESS` +- `TEMPORAL_NAMESPACE` +- `TEMPORAL_API_KEY` + +These match the convention used in the `temporal-crdb-deploy` example. diff --git a/lexicons/temporal/package.json b/lexicons/temporal/package.json new file mode 100644 index 00000000..6d46f5eb --- /dev/null +++ b/lexicons/temporal/package.json @@ -0,0 +1,32 @@ +{ + "name": "@intentius/chant-lexicon-temporal", + "version": "0.1.5", + "description": "Temporal lexicon for chant — server deployment, namespaces, search attributes, and schedules", + "license": "Apache-2.0", + "type": "module", + "files": [ + "src/", + "dist/" + ], + "exports": { + ".": "./src/index.ts", + "./*": "./src/*.ts", + "./manifest": "./dist/manifest.json", + "./meta": "./dist/meta.json", + "./types": "./dist/types/index.d.ts" + }, + "scripts": { + "generate": "tsx src/codegen/generate-cli.ts", + "bundle": "tsx src/codegen/package-cli.ts", + "validate": "tsx src/validate-cli.ts", + "docs": "tsx src/codegen/docs-cli.ts", + "prepack": "npm run generate && npm run bundle && npm run validate" + }, + "peerDependencies": { + "@intentius/chant": "^0.1.0" + }, + "devDependencies": { + "@intentius/chant": "*", + "typescript": "^5.9.3" + } +} diff --git a/lexicons/temporal/src/codegen/docs-cli.ts b/lexicons/temporal/src/codegen/docs-cli.ts new file mode 100644 index 00000000..41c9ab1c --- /dev/null +++ b/lexicons/temporal/src/codegen/docs-cli.ts @@ -0,0 +1,7 @@ +#!/usr/bin/env tsx +/** + * CLI entry point for `npm run docs` in lexicon-temporal. + */ +import { generateDocs } from "./docs"; + +await generateDocs({ verbose: true }); diff --git a/lexicons/temporal/src/codegen/docs.ts b/lexicons/temporal/src/codegen/docs.ts new file mode 100644 index 00000000..b0bb13a6 --- /dev/null +++ b/lexicons/temporal/src/codegen/docs.ts @@ -0,0 +1,23 @@ +import { docsPipeline, writeDocsSite } from "@intentius/chant/codegen/docs"; + +/** + * Generate documentation site for the Temporal lexicon. + */ +export async function generateDocs(options?: { verbose?: boolean }): Promise { + const config = { + name: "temporal", + displayName: "Temporal", + description: "Temporal lexicon documentation", + distDir: "./dist", + outDir: "./docs", + serviceFromType: (type: string) => type.split("::")[1] ?? type, + resourceTypeUrl: (type: string) => `#${type}`, + }; + + const result = docsPipeline(config); + writeDocsSite(config, result); + + if (options?.verbose) { + console.error("Documentation generated"); + } +} diff --git a/lexicons/temporal/src/codegen/generate-cli.ts b/lexicons/temporal/src/codegen/generate-cli.ts new file mode 100644 index 00000000..3c2d714f --- /dev/null +++ b/lexicons/temporal/src/codegen/generate-cli.ts @@ -0,0 +1,17 @@ +#!/usr/bin/env tsx +/** + * Thin entry point for `npm run generate` in lexicon-temporal. + */ +import { generate, writeGeneratedFiles } from "./generate"; +import { dirname } from "path"; +import { fileURLToPath } from "url"; + +const result = await generate({ verbose: true }); +// src/codegen/generate-cli.ts → dirname x3 → package root +const pkgDir = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); +writeGeneratedFiles(result, pkgDir); + +console.error(`temporal: ${result.resources} resources (hand-written)`); +if (result.warnings.length > 0) { + console.error(`${result.warnings.length} warnings`); +} diff --git a/lexicons/temporal/src/codegen/generate.ts b/lexicons/temporal/src/codegen/generate.ts new file mode 100644 index 00000000..5f02f95b --- /dev/null +++ b/lexicons/temporal/src/codegen/generate.ts @@ -0,0 +1,82 @@ +/** + * Temporal lexicon generation step. + * + * All 4 resources (TemporalServer, TemporalNamespace, SearchAttribute, TemporalSchedule) + * are hand-written. There is no remote spec to fetch or parse. This module builds + * the required lexiconJSON catalog and typesDTS stubs so that packagePipeline + * can hash and write dist/ artifacts correctly. + */ + +import { mkdirSync, writeFileSync } from "fs"; +import { join } from "path"; +import type { GenerateResult } from "@intentius/chant/codegen/generate"; + +export type { GenerateResult }; + +// ── Lexicon catalog (meta.json) ────────────────────────────────────── +// Describes the 4 hand-written resources so tooling (LSP, MCP, docs) can +// enumerate them without parsing the TypeScript source at runtime. + +const LEXICON_JSON = JSON.stringify( + { + TemporalServer: { + resourceType: "Temporal::Server", + kind: "resource", + lexicon: "temporal", + description: "Temporal server deployment — emits docker-compose.yml and Helm values", + }, + TemporalNamespace: { + resourceType: "Temporal::Namespace", + kind: "resource", + lexicon: "temporal", + description: "Temporal namespace — emits namespace create command in temporal-setup.sh", + }, + SearchAttribute: { + resourceType: "Temporal::SearchAttribute", + kind: "resource", + lexicon: "temporal", + description: "Temporal search attribute — emits search-attribute create command in temporal-setup.sh", + }, + TemporalSchedule: { + resourceType: "Temporal::Schedule", + kind: "resource", + lexicon: "temporal", + description: "Temporal schedule — emits SDK schedule creation TypeScript to schedules/.ts", + }, + }, + null, + 2, +); + +// ── Type declarations stub (types/index.d.ts) ──────────────────────── +// All types are declared in src/resources.ts and re-exported from src/index.ts. +// The dist/types/index.d.ts file is a stub that satisfies the BundleSpec shape. + +const TYPES_DTS = `// Types for @intentius/chant-lexicon-temporal are declared in src/resources.ts. +// They are available via the package's main export: import { ... } from "@intentius/chant-lexicon-temporal"; +export {}; +`; + +// ── Generate ───────────────────────────────────────────────────────── + +export async function generate(opts?: { verbose?: boolean; force?: boolean }): Promise { + if (opts?.verbose) { + console.error("temporal: all resources are hand-written — building catalog from static definitions"); + } + return { + lexiconJSON: LEXICON_JSON, + typesDTS: TYPES_DTS, + indexTS: "", + resources: 4, + properties: 0, + enums: 0, + warnings: [], + }; +} + +export function writeGeneratedFiles(_result: GenerateResult, pkgDir: string): void { + // Write the catalog to src/generated/ so createCatalogResource can locate it at runtime. + const generatedDir = join(pkgDir, "src", "generated"); + mkdirSync(generatedDir, { recursive: true }); + writeFileSync(join(generatedDir, "lexicon-temporal.json"), LEXICON_JSON, "utf-8"); +} diff --git a/lexicons/temporal/src/codegen/package-cli.ts b/lexicons/temporal/src/codegen/package-cli.ts new file mode 100644 index 00000000..861246d7 --- /dev/null +++ b/lexicons/temporal/src/codegen/package-cli.ts @@ -0,0 +1,14 @@ +#!/usr/bin/env tsx +/** + * CLI entry point for `npm run bundle` in lexicon-temporal. + */ +import { packageLexicon } from "./package"; +import { writeBundleSpec } from "@intentius/chant/codegen/package"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const pkgDir = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); +const { spec, stats } = await packageLexicon({ verbose: true }); +writeBundleSpec(spec, join(pkgDir, "dist")); + +console.error(`Packaged ${stats.resources} resources, ${stats.ruleCount} rules, ${stats.skillCount} skills`); diff --git a/lexicons/temporal/src/codegen/package.ts b/lexicons/temporal/src/codegen/package.ts new file mode 100644 index 00000000..30b4e278 --- /dev/null +++ b/lexicons/temporal/src/codegen/package.ts @@ -0,0 +1,55 @@ +/** + * Temporal lexicon packaging — delegates to core packagePipeline. + */ + +import { readFileSync } from "fs"; +import { temporalPlugin } from "../plugin"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +import { + packagePipeline, + collectSkills, + type PackageOptions, + type PackageResult, +} from "@intentius/chant/codegen/package"; +import { generate } from "./generate"; + +export type { PackageOptions, PackageResult }; + +// package.ts is at src/codegen/package.ts — 2 dirname calls reach src/ +// then join(pkgDir, "..") is the package root where package.json lives +const pkgDir = dirname(dirname(fileURLToPath(import.meta.url))); + +/** + * Package the Temporal lexicon into a distributable BundleSpec. + */ +export async function packageLexicon(opts: PackageOptions = {}): Promise { + const pkgJson = JSON.parse(readFileSync(join(pkgDir, "..", "package.json"), "utf-8")); + + return packagePipeline( + { + generate: (genOpts) => generate({ verbose: genOpts.verbose, force: genOpts.force }), + + buildManifest: (_genResult) => { + return { + name: "temporal", + version: pkgJson.version ?? "0.0.1", + chantVersion: ">=0.1.0", + namespace: "Temporal", + intrinsics: [], + pseudoParameters: {}, + }; + }, + + srcDir: pkgDir, + + collectSkills: () => { + const skillDefs = temporalPlugin.skills?.() ?? []; + return collectSkills(skillDefs); + }, + + version: pkgJson.version ?? "0.0.1", + }, + opts, + ); +} diff --git a/lexicons/temporal/src/composites/cloud-stack.ts b/lexicons/temporal/src/composites/cloud-stack.ts new file mode 100644 index 00000000..5188ecb2 --- /dev/null +++ b/lexicons/temporal/src/composites/cloud-stack.ts @@ -0,0 +1,74 @@ +/** + * TemporalCloudStack composite — a Temporal Cloud namespace + search attributes. + * + * Bundles a TemporalNamespace with an optional set of SearchAttribute entities. + * No server resource is included — this composite assumes you are connecting + * to Temporal Cloud (or a remotely managed server). + * + * @example + * ```typescript + * export const { ns, searchAttributes } = TemporalCloudStack({ + * namespace: "prod", + * retention: "30d", + * searchAttributes: [ + * { name: "Project", type: "Keyword" }, + * { name: "Priority", type: "Int" }, + * ], + * }); + * ``` + */ + +import { TemporalNamespace, SearchAttribute } from "../resources"; +import type { SearchAttributeProps } from "../resources"; + +export interface TemporalCloudStackConfig { + /** Namespace name (required). */ + namespace: string; + /** + * Workflow history retention. + * @default "30d" + */ + retention?: string; + /** Human-readable description for the namespace. */ + description?: string; + /** + * Search attribute definitions to register in this namespace. + * Each entry creates a SearchAttribute entity scoped to this namespace. + */ + searchAttributes?: Array>; +} + +export interface TemporalCloudStackResources { + ns: InstanceType; + searchAttributes: InstanceType[]; +} + +/** + * Create a Temporal Cloud namespace stack. + * + * Returns a TemporalNamespace and an array of SearchAttribute entities. + * Export the namespace and spread the search attributes from your chant project: + * + * ```typescript + * export const { ns, searchAttributes } = TemporalCloudStack({ namespace: "prod" }); + * export const [projectAttr, priorityAttr] = searchAttributes; + * ``` + */ +export function TemporalCloudStack(config: TemporalCloudStackConfig): TemporalCloudStackResources { + const ns = new TemporalNamespace({ + name: config.namespace, + retention: config.retention ?? "30d", + ...(config.description ? { description: config.description } : {}), + } as Record); + + const searchAttributes = (config.searchAttributes ?? []).map( + ({ name, type }) => + new SearchAttribute({ + name, + type, + namespace: config.namespace, + } as Record), + ); + + return { ns, searchAttributes }; +} diff --git a/lexicons/temporal/src/composites/composites.test.ts b/lexicons/temporal/src/composites/composites.test.ts new file mode 100644 index 00000000..4149024c --- /dev/null +++ b/lexicons/temporal/src/composites/composites.test.ts @@ -0,0 +1,131 @@ +/** + * Composite unit tests — TemporalDevStack, TemporalCloudStack. + */ + +import { describe, test, expect } from "vitest"; +import { TemporalDevStack } from "./dev-stack"; +import { TemporalCloudStack } from "./cloud-stack"; +import { DECLARABLE_MARKER } from "@intentius/chant/declarable"; + +function getProps(entity: unknown): Record { + return (entity as { props: Record }).props; +} + +function getEntityType(entity: unknown): string { + return (entity as Record).entityType as string; +} + +// ── TemporalDevStack ───────────────────────────────────────────────── + +describe("TemporalDevStack: basic", () => { + test("returns server and ns", () => { + const result = TemporalDevStack(); + expect(result.server).toBeDefined(); + expect(result.ns).toBeDefined(); + }); + + test("server has entityType Temporal::Server", () => { + const { server } = TemporalDevStack(); + expect(getEntityType(server)).toBe("Temporal::Server"); + }); + + test("ns has entityType Temporal::Namespace", () => { + const { ns } = TemporalDevStack(); + expect(getEntityType(ns)).toBe("Temporal::Namespace"); + }); + + test("both entities have DECLARABLE_MARKER", () => { + const { server, ns } = TemporalDevStack(); + expect((server as Record)[DECLARABLE_MARKER]).toBe(true); + expect((ns as Record)[DECLARABLE_MARKER]).toBe(true); + }); + + test("defaults: namespace=default, retention=7d, mode=dev", () => { + const { server, ns } = TemporalDevStack(); + expect(getProps(ns).name).toBe("default"); + expect(getProps(ns).retention).toBe("7d"); + expect(getProps(server).mode).toBe("dev"); + }); + + test("config overrides namespace and retention", () => { + const { ns } = TemporalDevStack({ namespace: "my-app", retention: "14d" }); + expect(getProps(ns).name).toBe("my-app"); + expect(getProps(ns).retention).toBe("14d"); + }); + + test("config passes version and ports to server", () => { + const { server } = TemporalDevStack({ version: "1.25.0", port: 7234, uiPort: 8081 }); + expect(getProps(server).version).toBe("1.25.0"); + expect(getProps(server).port).toBe(7234); + expect(getProps(server).uiPort).toBe(8081); + }); + + test("description is forwarded to namespace when provided", () => { + const { ns } = TemporalDevStack({ description: "local dev namespace" }); + expect(getProps(ns).description).toBe("local dev namespace"); + }); + + test("description is absent when not provided", () => { + const { ns } = TemporalDevStack(); + expect(getProps(ns).description).toBeUndefined(); + }); +}); + +// ── TemporalCloudStack ─────────────────────────────────────────────── + +describe("TemporalCloudStack: basic", () => { + test("returns ns and searchAttributes array", () => { + const result = TemporalCloudStack({ namespace: "prod" }); + expect(result.ns).toBeDefined(); + expect(result.searchAttributes).toBeDefined(); + }); + + test("ns has entityType Temporal::Namespace", () => { + const { ns } = TemporalCloudStack({ namespace: "prod" }); + expect(getEntityType(ns)).toBe("Temporal::Namespace"); + }); + + test("defaults retention to 30d", () => { + const { ns } = TemporalCloudStack({ namespace: "prod" }); + expect(getProps(ns).name).toBe("prod"); + expect(getProps(ns).retention).toBe("30d"); + }); + + test("custom retention is forwarded", () => { + const { ns } = TemporalCloudStack({ namespace: "staging", retention: "14d" }); + expect(getProps(ns).retention).toBe("14d"); + }); + + test("returns empty searchAttributes when none specified", () => { + const { searchAttributes } = TemporalCloudStack({ namespace: "prod" }); + expect(searchAttributes).toHaveLength(0); + }); + + test("creates SearchAttribute entities for each entry", () => { + const { searchAttributes } = TemporalCloudStack({ + namespace: "prod", + searchAttributes: [ + { name: "Project", type: "Keyword" }, + { name: "Priority", type: "Int" }, + ], + }); + expect(searchAttributes).toHaveLength(2); + expect(getEntityType(searchAttributes[0])).toBe("Temporal::SearchAttribute"); + expect(getEntityType(searchAttributes[1])).toBe("Temporal::SearchAttribute"); + }); + + test("search attributes are scoped to the namespace", () => { + const { searchAttributes } = TemporalCloudStack({ + namespace: "prod", + searchAttributes: [{ name: "Project", type: "Keyword" }], + }); + expect(getProps(searchAttributes[0]).namespace).toBe("prod"); + expect(getProps(searchAttributes[0]).name).toBe("Project"); + expect(getProps(searchAttributes[0]).type).toBe("Keyword"); + }); + + test("description is forwarded to namespace", () => { + const { ns } = TemporalCloudStack({ namespace: "prod", description: "Production namespace" }); + expect(getProps(ns).description).toBe("Production namespace"); + }); +}); diff --git a/lexicons/temporal/src/composites/dev-stack.ts b/lexicons/temporal/src/composites/dev-stack.ts new file mode 100644 index 00000000..8821c686 --- /dev/null +++ b/lexicons/temporal/src/composites/dev-stack.ts @@ -0,0 +1,81 @@ +/** + * TemporalDevStack composite — a local dev server + default namespace. + * + * Wires together a TemporalServer (dev mode) and a TemporalNamespace so + * that `chant build` emits a docker-compose.yml and temporal-setup.sh + * ready for local development. + * + * @example + * ```typescript + * export const { server, ns } = TemporalDevStack({ + * namespace: "my-app", + * retention: "7d", + * }); + * ``` + */ + +import { TemporalServer, TemporalNamespace } from "../resources"; + +export interface TemporalDevStackConfig { + /** + * Temporal server version. + * @default "1.26.2" + */ + version?: string; + /** + * gRPC port for the Temporal frontend. + * @default 7233 + */ + port?: number; + /** + * Port for the Temporal Web UI. + * @default 8080 + */ + uiPort?: number; + /** + * Namespace to create on first run. + * @default "default" + */ + namespace?: string; + /** + * Workflow history retention for the namespace. + * @default "7d" + */ + retention?: string; + /** + * Human-readable description for the namespace. + */ + description?: string; +} + +export interface TemporalDevStackResources { + server: InstanceType; + ns: InstanceType; +} + +/** + * Create a local Temporal dev stack. + * + * Returns a TemporalServer (dev mode) and a TemporalNamespace wired to the + * same default namespace. Export both from your chant project: + * + * ```typescript + * export const { server, ns } = TemporalDevStack({ namespace: "my-app" }); + * ``` + */ +export function TemporalDevStack(config: TemporalDevStackConfig = {}): TemporalDevStackResources { + const server = new TemporalServer({ + mode: "dev", + version: config.version, + port: config.port, + uiPort: config.uiPort, + } as Record); + + const ns = new TemporalNamespace({ + name: config.namespace ?? "default", + retention: config.retention ?? "7d", + ...(config.description ? { description: config.description } : {}), + } as Record); + + return { server, ns }; +} diff --git a/lexicons/temporal/src/config.ts b/lexicons/temporal/src/config.ts new file mode 100644 index 00000000..26eb7c13 --- /dev/null +++ b/lexicons/temporal/src/config.ts @@ -0,0 +1,150 @@ +/** + * Temporal worker profile — connection configuration for `chant run`. + * + * Add this to `chant.config.ts` to define how chant connects to Temporal: + * + * ```ts + * import type { TemporalChantConfig } from "@intentius/chant-lexicon-temporal"; + * + * export default { + * lexicons: ["temporal"], + * temporal: { + * profiles: { + * local: { + * address: "localhost:7233", + * namespace: "default", + * taskQueue: "my-deploy", + * autoStart: true, + * }, + * cloud: { + * address: "myns.a2dd6.tmprl.cloud:7233", + * namespace: "myns.a2dd6", + * taskQueue: "my-deploy", + * tls: true, + * apiKey: { env: "TEMPORAL_API_KEY" }, + * }, + * }, + * defaultProfile: "local", + * } satisfies TemporalChantConfig, + * }; + * ``` + * + * ChantConfig uses `.passthrough()` in its Zod schema so the `temporal` key + * is accepted at runtime without core changes. Issue #8 (`chant run`) will + * read these profiles when starting workers. + */ + +/** + * Activity timeout and retry configuration for infrastructure activity groups. + * + * Pre-built profiles match the four activity groups typically seen in infra workflows: + * fast/idempotent operations, long-running infra (GKE, kubectl apply --wait), + * K8s wait loops, and human-gate activities. + * + * @example + * ```ts + * import { TEMPORAL_ACTIVITY_PROFILES } from "@intentius/chant-lexicon-temporal"; + * import { proxyActivities } from "@temporalio/workflow"; + * + * const { applyInfra } = proxyActivities( + * TEMPORAL_ACTIVITY_PROFILES.longInfra + * ); + * ``` + */ +export interface TemporalActivityProfile { + /** Maximum time allowed for a single activity execution attempt. */ + startToCloseTimeout: string; + /** + * Time after the last heartbeat before Temporal marks the activity as timed out. + * Required for activities that call `activity.heartbeat()` to signal liveness. + */ + heartbeatTimeout?: string; + /** Retry policy for failed activity attempts. */ + retry?: { + /** Initial wait before the first retry (e.g. "5s"). */ + initialInterval?: string; + /** Multiplier applied to the interval on each retry (e.g. 2). */ + backoffCoefficient?: number; + /** Maximum number of attempts including the first (0 = unlimited). */ + maximumAttempts?: number; + /** Cap on retry intervals (e.g. "5m"). */ + maximumInterval?: string; + }; +} + +/** + * Named activity profiles for common infrastructure workflow patterns. + * + * Import and spread into `proxyActivities()` so retry/timeout configuration + * lives in the lexicon rather than inline in workflow code. + */ +export const TEMPORAL_ACTIVITY_PROFILES = { + /** + * Fast, idempotent operations: `chant build`, `kubectl apply` without `--wait`, + * fetching nameservers, reading cluster status. + */ + fastIdempotent: { + startToCloseTimeout: "5m", + retry: { maximumAttempts: 3, initialInterval: "5s", backoffCoefficient: 2 }, + }, + + /** + * Long-running infra: GKE cluster creation via Config Connector (~10-20 min), + * `kubectl apply --wait` for large resource sets, Helm installs. + * Must heartbeat; 60 s silence → Temporal marks the activity dead. + */ + longInfra: { + startToCloseTimeout: "20m", + heartbeatTimeout: "60s", + retry: { maximumAttempts: 3, initialInterval: "30s", backoffCoefficient: 2 }, + }, + + /** + * K8s wait loops: polling for StatefulSet rollout, ExternalDNS A-records, + * DNS propagation. Medium timeout with heartbeating. + */ + k8sWait: { + startToCloseTimeout: "15m", + heartbeatTimeout: "60s", + retry: { maximumAttempts: 3, initialInterval: "10s", backoffCoefficient: 2 }, + }, + + /** + * Human-gate activities: waiting for an operator action (DNS delegation, approval). + * Very long timeout, single attempt — no retry on human-gate timeouts. + */ + humanGate: { + startToCloseTimeout: "48h", + heartbeatTimeout: "90s", + retry: { maximumAttempts: 1 }, + }, +} as const satisfies Record; + +export interface TemporalWorkerProfile { + /** Temporal server gRPC address. e.g. "localhost:7233" or "myns.a2dd6.tmprl.cloud:7233" */ + address: string; + /** Temporal namespace to connect to */ + namespace: string; + /** Task queue the worker polls */ + taskQueue: string; + /** TLS configuration. Pass `true` or `{}` for Temporal Cloud default TLS */ + tls?: boolean | { serverNameOverride?: string }; + /** + * API key for Temporal Cloud authentication. + * String value: used as-is (Bearer token). + * Object form: reads from process.env at runtime. + */ + apiKey?: string | { env: string }; + /** + * Automatically start `temporal server start-dev` before the worker. + * Only applicable for local development profiles. + */ + autoStart?: boolean; +} + +export interface TemporalChantConfig { + /** Named connection profiles */ + profiles: Record; + /** Profile used when --profile flag is omitted from `chant run` */ + defaultProfile?: string; +} diff --git a/lexicons/temporal/src/coverage.test.ts b/lexicons/temporal/src/coverage.test.ts new file mode 100644 index 00000000..e761604f --- /dev/null +++ b/lexicons/temporal/src/coverage.test.ts @@ -0,0 +1,9 @@ +import { test, expect } from "vitest"; +import { analyze } from "./coverage"; + +test("coverage is 100% — all 4 resources are hand-written", () => { + const result = analyze(); + expect(result.total).toBe(4); + expect(result.covered).toBe(4); + expect(result.missing).toHaveLength(0); +}); diff --git a/lexicons/temporal/src/coverage.ts b/lexicons/temporal/src/coverage.ts new file mode 100644 index 00000000..e37adceb --- /dev/null +++ b/lexicons/temporal/src/coverage.ts @@ -0,0 +1,37 @@ +/** + * Coverage analysis for the Temporal lexicon. + * + * All 4 resources are hand-written (no remote spec), so coverage is always + * 100% by definition. This module satisfies the lexicon completeness checklist + * and provides a consistent interface for the plugin's coverage() lifecycle method. + */ + +export interface CoverageResult { + total: number; + covered: number; + missing: string[]; +} + +const RESOURCES = [ + "Temporal::Server", + "Temporal::Namespace", + "Temporal::SearchAttribute", + "Temporal::Schedule", +] as const; + +export function analyze(): CoverageResult { + return { + total: RESOURCES.length, + covered: RESOURCES.length, + missing: [], + }; +} + +export function printCoverageResult(result: CoverageResult, verbose?: boolean): void { + if (verbose) { + console.error( + `Coverage: ${result.covered}/${result.total} resources (${Math.round((result.covered / result.total) * 100)}%).`, + ); + console.error("All resources are hand-written — no remote spec to track against."); + } +} diff --git a/lexicons/temporal/src/example.test.ts b/lexicons/temporal/src/example.test.ts new file mode 100644 index 00000000..88b6e25e --- /dev/null +++ b/lexicons/temporal/src/example.test.ts @@ -0,0 +1,59 @@ +/** + * Integration test for the temporal-self-hosted example project. + * + * Builds the example from source using the temporal serializer and + * verifies all expected output files are produced. + */ + +import { describe, test, expect } from "vitest"; +import { resolve } from "path"; +import { fileURLToPath } from "url"; +import { build } from "@intentius/chant/build"; +import { lintCommand } from "@intentius/chant/cli/commands/lint"; +import { temporalSerializer } from "./serializer"; + +const examplesRoot = resolve(fileURLToPath(import.meta.url), "../../../../examples"); +const srcDir = resolve(examplesRoot, "temporal-self-hosted", "src"); + +describe("temporal-self-hosted example", () => { + test("passes lint", async () => { + const result = await lintCommand({ path: srcDir, format: "stylish", fix: true }); + if (!result.success || result.errorCount > 0) console.log(result.output); + expect(result.success).toBe(true); + expect(result.errorCount).toBe(0); + }); + + test("build produces docker-compose.yml primary output", async () => { + const result = await build(srcDir, [temporalSerializer]); + expect(result.errors).toHaveLength(0); + const output = result.outputs.get("temporal")!; + const primary = typeof output === "string" ? output : (output as { primary: string }).primary; + expect(primary).toContain("temporalio/admin-tools"); + expect(primary).toContain("temporal server start-dev"); + expect(primary).toContain("7233"); + }); + + test("build produces temporal-setup.sh with namespace and search attributes", async () => { + const result = await build(srcDir, [temporalSerializer]); + const output = result.outputs.get("temporal")!; + const files = + typeof output === "string" ? {} : (output as { files: Record }).files ?? {}; + expect(files["temporal-setup.sh"]).toBeDefined(); + expect(files["temporal-setup.sh"]).toContain("temporal operator namespace create"); + expect(files["temporal-setup.sh"]).toContain('--namespace "my-app"'); + expect(files["temporal-setup.sh"]).toContain("temporal operator search-attribute create"); + expect(files["temporal-setup.sh"]).toContain('"JobType"'); + expect(files["temporal-setup.sh"]).toContain('"Priority"'); + }); + + test("build produces schedules/daily-sync.ts", async () => { + const result = await build(srcDir, [temporalSerializer]); + const output = result.outputs.get("temporal")!; + const files = + typeof output === "string" ? {} : (output as { files: Record }).files ?? {}; + expect(files["schedules/daily-sync.ts"]).toBeDefined(); + expect(files["schedules/daily-sync.ts"]).toContain("daily-sync"); + expect(files["schedules/daily-sync.ts"]).toContain("syncWorkflow"); + expect(files["schedules/daily-sync.ts"]).toContain("client.schedule.create"); + }); +}); diff --git a/lexicons/temporal/src/index.ts b/lexicons/temporal/src/index.ts new file mode 100644 index 00000000..b431d424 --- /dev/null +++ b/lexicons/temporal/src/index.ts @@ -0,0 +1,29 @@ +// Plugin +export { temporalPlugin } from "./plugin"; + +// Serializer +export { temporalSerializer } from "./serializer"; + +// Resources (hand-written) +export { + TemporalServer, + TemporalNamespace, + SearchAttribute, + TemporalSchedule, +} from "./resources"; +export type { + TemporalServerProps, + TemporalNamespaceProps, + SearchAttributeProps, + TemporalScheduleProps, +} from "./resources"; + +// Worker profile config shape + activity profiles +export type { TemporalWorkerProfile, TemporalChantConfig, TemporalActivityProfile } from "./config"; +export { TEMPORAL_ACTIVITY_PROFILES } from "./config"; + +// Composites +export { TemporalDevStack } from "./composites/dev-stack"; +export type { TemporalDevStackConfig, TemporalDevStackResources } from "./composites/dev-stack"; +export { TemporalCloudStack } from "./composites/cloud-stack"; +export type { TemporalCloudStackConfig, TemporalCloudStackResources } from "./composites/cloud-stack"; diff --git a/lexicons/temporal/src/lint/post-synth/post-synth.test.ts b/lexicons/temporal/src/lint/post-synth/post-synth.test.ts new file mode 100644 index 00000000..a76cff8d --- /dev/null +++ b/lexicons/temporal/src/lint/post-synth/post-synth.test.ts @@ -0,0 +1,152 @@ +/** + * Post-synth check tests — TMP010, TMP011. + */ + +import { describe, test, expect } from "vitest"; +import type { PostSynthContext } from "@intentius/chant/lint/post-synth"; +import { DECLARABLE_MARKER } from "@intentius/chant/declarable"; +import { tmp010 } from "./tmp010-cron-syntax"; +import { tmp011 } from "./tmp011-namespace-reference"; + +// ── Helpers ───────────────────────────────────────────────────────── + +function makeCtxFromOutput(output: string | { primary: string; files: Record }): PostSynthContext { + return { + outputs: new Map([["temporal", output]]), + entities: new Map(), + buildResult: { + outputs: new Map([["temporal", output]]), + entities: new Map(), + warnings: [], + errors: [], + sourceFileCount: 1, + }, + }; +} + +function makeEntity(entityType: string, props: Record) { + return { + [DECLARABLE_MARKER]: true, + entityType, + lexicon: "temporal", + kind: "resource", + props, + attributes: {}, + }; +} + +function makeCtxFromEntities(entities: Map): PostSynthContext { + return { + outputs: new Map([["temporal", ""]]), + entities: entities as Map, + buildResult: { + outputs: new Map([["temporal", ""]]), + entities: entities as Map, + warnings: [], + errors: [], + sourceFileCount: 1, + }, + }; +} + +// ── TMP010: cron-syntax ────────────────────────────────────────────── + +describe("TMP010: cron-syntax", () => { + test("warns for invalid cron with only 4 fields", () => { + const content = `cronExpressions: ["0 3 * *"]`; + const ctx = makeCtxFromOutput({ + primary: "# docker-compose", + files: { "schedules/daily.ts": content }, + }); + const diags = tmp010.check(ctx); + expect(diags.length).toBeGreaterThanOrEqual(1); + expect(diags[0].checkId).toBe("TMP010"); + expect(diags[0].severity).toBe("warning"); + }); + + test("passes for valid 5-field cron", () => { + const content = `cronExpressions: ["0 3 * * *"]`; + const ctx = makeCtxFromOutput({ + primary: "# docker-compose", + files: { "schedules/daily.ts": content }, + }); + expect(tmp010.check(ctx)).toHaveLength(0); + }); + + test("passes for valid 6-field cron (with seconds)", () => { + const content = `cronExpressions: ["0 0 3 * * *"]`; + const ctx = makeCtxFromOutput({ + primary: "# docker-compose", + files: { "schedules/daily.ts": content }, + }); + expect(tmp010.check(ctx)).toHaveLength(0); + }); + + test("skips non-temporal lexicons", () => { + const ctx: PostSynthContext = { + outputs: new Map([["aws", `cronExpressions: ["invalid"]`]]), + entities: new Map(), + buildResult: { + outputs: new Map([["aws", ""]]), + entities: new Map(), + warnings: [], + errors: [], + sourceFileCount: 1, + }, + }; + expect(tmp010.check(ctx)).toHaveLength(0); + }); + + test("skips non-schedule files", () => { + const content = `cronExpressions: ["bad"]`; + const ctx = makeCtxFromOutput({ + primary: content, + files: { "temporal-setup.sh": content }, + }); + expect(tmp010.check(ctx)).toHaveLength(0); + }); + + test("passes when output is plain string (no schedule files)", () => { + const ctx = makeCtxFromOutput("# docker-compose.yml\nservices:\n temporal:\n"); + expect(tmp010.check(ctx)).toHaveLength(0); + }); +}); + +// ── TMP011: namespace-reference ────────────────────────────────────── + +describe("TMP011: namespace-reference", () => { + test("errors when SearchAttribute references undeclared namespace", () => { + const ctx = makeCtxFromEntities(new Map([ + ["attr", makeEntity("Temporal::SearchAttribute", { name: "Project", type: "Keyword", namespace: "prod" })], + ])); + const diags = tmp011.check(ctx); + expect(diags).toHaveLength(1); + expect(diags[0].checkId).toBe("TMP011"); + expect(diags[0].severity).toBe("error"); + expect(diags[0].message).toContain("prod"); + }); + + test("passes when SearchAttribute namespace is declared", () => { + const ctx = makeCtxFromEntities(new Map([ + ["ns", makeEntity("Temporal::Namespace", { name: "prod", retention: "30d" })], + ["attr", makeEntity("Temporal::SearchAttribute", { name: "Project", type: "Keyword", namespace: "prod" })], + ])); + expect(tmp011.check(ctx)).toHaveLength(0); + }); + + test("passes when SearchAttribute has no namespace (global)", () => { + const ctx = makeCtxFromEntities(new Map([ + ["attr", makeEntity("Temporal::SearchAttribute", { name: "Project", type: "Keyword" })], + ])); + expect(tmp011.check(ctx)).toHaveLength(0); + }); + + test("flags each attribute with a missing namespace independently", () => { + const ctx = makeCtxFromEntities(new Map([ + ["attr1", makeEntity("Temporal::SearchAttribute", { name: "A", type: "Keyword", namespace: "missing1" })], + ["attr2", makeEntity("Temporal::SearchAttribute", { name: "B", type: "Keyword", namespace: "missing2" })], + ])); + const diags = tmp011.check(ctx); + expect(diags).toHaveLength(2); + }); +}); diff --git a/lexicons/temporal/src/lint/post-synth/tmp010-cron-syntax.ts b/lexicons/temporal/src/lint/post-synth/tmp010-cron-syntax.ts new file mode 100644 index 00000000..6918423e --- /dev/null +++ b/lexicons/temporal/src/lint/post-synth/tmp010-cron-syntax.ts @@ -0,0 +1,64 @@ +/** + * TMP010: TemporalSchedule cron expression syntax + * + * Validates that cron expressions in TemporalSchedule.spec.cronExpressions + * are valid 5- or 6-field cron syntax. Malformed crons are silently ignored + * by Temporal's scheduler, leading to schedules that never fire. + * + * This is a pre-submission guard — final validation is Temporal's own parser. + */ + +import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth"; + +/** Very permissive cron field pattern — catches obvious syntax errors. */ +const CRON_FIELD = /^[0-9*,/\-?LW#]+$/; + +function isValidCronExpression(expr: string): boolean { + const fields = expr.trim().split(/\s+/); + if (fields.length < 5 || fields.length > 6) return false; + return fields.every((f) => CRON_FIELD.test(f)); +} + +export const tmp010: PostSynthCheck = { + id: "TMP010", + description: "TemporalSchedule cron expressions must be valid 5- or 6-field cron syntax", + + check(ctx: PostSynthContext): PostSynthDiagnostic[] { + const diagnostics: PostSynthDiagnostic[] = []; + + for (const [lexicon, output] of ctx.outputs) { + if (lexicon !== "temporal") continue; + + // Schedules emit individual TypeScript files — check the schedules/ files. + const files = + typeof output === "string" + ? new Map() + : (output as { primary: string; files?: Record }).files + ? new Map(Object.entries((output as { files: Record }).files)) + : new Map(); + + for (const [filename, content] of files) { + if (!filename.startsWith("schedules/")) continue; + + // Extract cron expressions from the generated TypeScript: + // cronExpressions: ["0 3 * * *"] + const cronMatches = [...content.matchAll(/cronExpressions:\s*\[([^\]]+)\]/g)]; + for (const match of cronMatches) { + const exprs = [...match[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]); + for (const expr of exprs) { + if (!isValidCronExpression(expr)) { + diagnostics.push({ + checkId: "TMP010", + severity: "warning", + message: `${filename}: cron expression "${expr}" does not look like valid 5- or 6-field cron syntax`, + lexicon: "temporal", + }); + } + } + } + } + } + + return diagnostics; + }, +}; diff --git a/lexicons/temporal/src/lint/post-synth/tmp011-namespace-reference.ts b/lexicons/temporal/src/lint/post-synth/tmp011-namespace-reference.ts new file mode 100644 index 00000000..533d88e4 --- /dev/null +++ b/lexicons/temporal/src/lint/post-synth/tmp011-namespace-reference.ts @@ -0,0 +1,49 @@ +/** + * TMP011: SearchAttribute references an undeclared namespace + * + * If SearchAttribute.namespace is set to a value X, there must be a + * TemporalNamespace entity with name === X in the project. A missing + * namespace means the search attribute create command will fail at runtime. + */ + +import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth"; + +export const tmp011: PostSynthCheck = { + id: "TMP011", + description: "SearchAttribute.namespace must reference a declared TemporalNamespace entity", + + check(ctx: PostSynthContext): PostSynthDiagnostic[] { + const diagnostics: PostSynthDiagnostic[] = []; + + // Collect declared namespace names + const declaredNamespaces = new Set(); + for (const [, entity] of ctx.entities) { + const et = (entity as Record).entityType as string; + if (et !== "Temporal::Namespace") continue; + const props = (entity as { props?: Record }).props ?? {}; + const name = props.name as string | undefined; + if (name) declaredNamespaces.add(name); + } + + // Check each SearchAttribute that specifies a namespace + for (const [entityKey, entity] of ctx.entities) { + const et = (entity as Record).entityType as string; + if (et !== "Temporal::SearchAttribute") continue; + + const props = (entity as { props?: Record }).props ?? {}; + const ns = props.namespace as string | undefined; + if (!ns) continue; // no namespace specified — applies globally, OK + + if (!declaredNamespaces.has(ns)) { + diagnostics.push({ + checkId: "TMP011", + severity: "error", + message: `SearchAttribute "${entityKey}" references namespace "${ns}" which is not declared — add a TemporalNamespace with name "${ns}"`, + lexicon: "temporal", + }); + } + } + + return diagnostics; + }, +}; diff --git a/lexicons/temporal/src/lint/rules/index.ts b/lexicons/temporal/src/lint/rules/index.ts new file mode 100644 index 00000000..5bbb8ff8 --- /dev/null +++ b/lexicons/temporal/src/lint/rules/index.ts @@ -0,0 +1,2 @@ +export { tmp001 } from "./tmp001"; +export { tmp002 } from "./tmp002"; diff --git a/lexicons/temporal/src/lint/rules/lint-rules.test.ts b/lexicons/temporal/src/lint/rules/lint-rules.test.ts new file mode 100644 index 00000000..87f61314 --- /dev/null +++ b/lexicons/temporal/src/lint/rules/lint-rules.test.ts @@ -0,0 +1,150 @@ +/** + * Lint rule tests — TMP001, TMP002. + */ + +import { describe, test, expect } from "vitest"; +import type { LintContext } from "@intentius/chant/lint/rule"; +import { DECLARABLE_MARKER } from "@intentius/chant/declarable"; +import { tmp001 } from "./tmp001"; +import { tmp002 } from "./tmp002"; + +// ── Helpers ───────────────────────────────────────────────────────── + +function makeEntity(entityType: string, props: Record) { + return { + [DECLARABLE_MARKER]: true, + entityType, + lexicon: "temporal", + kind: "resource", + props, + attributes: {}, + }; +} + +function makeCtx(entities: Map): LintContext { + return { + entities: entities as Map, + project: { name: "test" } as never, + }; +} + +// ── TMP001: retention-too-short ────────────────────────────────────── + +describe("TMP001: retention-too-short", () => { + test("flags namespace with 1d retention", () => { + const ctx = makeCtx(new Map([ + ["ns", makeEntity("Temporal::Namespace", { name: "default", retention: "1d" })], + ])); + const diags = tmp001.check(ctx); + expect(diags).toHaveLength(1); + expect(diags[0].ruleId).toBe("TMP001"); + expect(diags[0].severity).toBe("error"); + expect(diags[0].message).toContain("1d"); + }); + + test("flags namespace with 48h retention", () => { + const ctx = makeCtx(new Map([ + ["ns", makeEntity("Temporal::Namespace", { name: "default", retention: "48h" })], + ])); + const diags = tmp001.check(ctx); + expect(diags).toHaveLength(1); + expect(diags[0].ruleId).toBe("TMP001"); + }); + + test("passes with 3d retention (exactly at threshold)", () => { + const ctx = makeCtx(new Map([ + ["ns", makeEntity("Temporal::Namespace", { name: "default", retention: "3d" })], + ])); + expect(tmp001.check(ctx)).toHaveLength(0); + }); + + test("passes with 7d retention", () => { + const ctx = makeCtx(new Map([ + ["ns", makeEntity("Temporal::Namespace", { name: "default", retention: "7d" })], + ])); + expect(tmp001.check(ctx)).toHaveLength(0); + }); + + test("passes when retention is unset (defaults to 7d)", () => { + const ctx = makeCtx(new Map([ + ["ns", makeEntity("Temporal::Namespace", { name: "default" })], + ])); + expect(tmp001.check(ctx)).toHaveLength(0); + }); + + test("skips non-namespace entities", () => { + const ctx = makeCtx(new Map([ + ["s", makeEntity("Temporal::Server", { mode: "dev" })], + ])); + expect(tmp001.check(ctx)).toHaveLength(0); + }); + + test("skips unrecognised retention format", () => { + const ctx = makeCtx(new Map([ + ["ns", makeEntity("Temporal::Namespace", { name: "default", retention: "1week" })], + ])); + expect(tmp001.check(ctx)).toHaveLength(0); + }); +}); + +// ── TMP002: allowall-without-note ──────────────────────────────────── + +describe("TMP002: allowall-without-note", () => { + test("warns for AllowAll overlap without state.note", () => { + const ctx = makeCtx(new Map([ + ["sched", makeEntity("Temporal::Schedule", { + scheduleId: "heavy-job", + spec: { cronExpressions: ["0 * * * *"] }, + action: { workflowType: "heavyWorkflow", taskQueue: "heavy" }, + policies: { overlap: "AllowAll" }, + })], + ])); + const diags = tmp002.check(ctx); + expect(diags).toHaveLength(1); + expect(diags[0].ruleId).toBe("TMP002"); + expect(diags[0].severity).toBe("warning"); + }); + + test("passes when AllowAll has a note", () => { + const ctx = makeCtx(new Map([ + ["sched", makeEntity("Temporal::Schedule", { + scheduleId: "heavy-job", + spec: { cronExpressions: ["0 * * * *"] }, + action: { workflowType: "heavyWorkflow", taskQueue: "heavy" }, + policies: { overlap: "AllowAll" }, + state: { note: "Workflow is idempotent — concurrent runs are safe" }, + })], + ])); + expect(tmp002.check(ctx)).toHaveLength(0); + }); + + test("passes for Skip overlap (no note needed)", () => { + const ctx = makeCtx(new Map([ + ["sched", makeEntity("Temporal::Schedule", { + scheduleId: "daily", + spec: { cronExpressions: ["0 3 * * *"] }, + action: { workflowType: "dailyWorkflow", taskQueue: "daily" }, + policies: { overlap: "Skip" }, + })], + ])); + expect(tmp002.check(ctx)).toHaveLength(0); + }); + + test("passes when no policies set", () => { + const ctx = makeCtx(new Map([ + ["sched", makeEntity("Temporal::Schedule", { + scheduleId: "daily", + spec: { cronExpressions: ["0 3 * * *"] }, + action: { workflowType: "dailyWorkflow", taskQueue: "daily" }, + })], + ])); + expect(tmp002.check(ctx)).toHaveLength(0); + }); + + test("skips non-schedule entities", () => { + const ctx = makeCtx(new Map([ + ["ns", makeEntity("Temporal::Namespace", { name: "default" })], + ])); + expect(tmp002.check(ctx)).toHaveLength(0); + }); +}); diff --git a/lexicons/temporal/src/lint/rules/tmp001.ts b/lexicons/temporal/src/lint/rules/tmp001.ts new file mode 100644 index 00000000..9b0f8b4c --- /dev/null +++ b/lexicons/temporal/src/lint/rules/tmp001.ts @@ -0,0 +1,53 @@ +/** + * TMP001: TemporalNamespace retention too short + * + * Workflow history older than the retention period is permanently deleted. + * Retentions shorter than 3 days leave very little time for debugging + * failures or running ad-hoc queries against closed workflow executions. + */ + +import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule"; + +/** Parse a retention string like "1d", "12h", "3d" → total hours. Returns NaN on unrecognised format. */ +function retentionHours(retention: string): number { + const days = /^(\d+)d$/i.exec(retention); + if (days) return Number(days[1]) * 24; + const hours = /^(\d+)h$/i.exec(retention); + if (hours) return Number(hours[1]); + return NaN; +} + +export const tmp001: LintRule = { + id: "TMP001", + severity: "error", + category: "correctness", + description: "TemporalNamespace retention should be at least 3 days to preserve workflow history for debugging", + + check(context: LintContext): LintDiagnostic[] { + const diagnostics: LintDiagnostic[] = []; + + for (const [name, entity] of context.entities) { + const et = (entity as Record).entityType as string; + if (et !== "Temporal::Namespace") continue; + + const props = (entity as { props?: Record }).props ?? {}; + const retention = props.retention as string | undefined; + if (!retention) continue; // default "7d" — not an error + + const hours = retentionHours(retention); + if (isNaN(hours)) continue; // unrecognised format — skip + + if (hours < 72) { + diagnostics.push({ + ruleId: "TMP001", + severity: "error", + message: `Namespace "${name}" has retention "${retention}" — minimum recommended is 3d (72h) to preserve workflow history`, + entity: name, + fix: 'Set retention to at least "3d" — e.g. retention: "7d"', + }); + } + } + + return diagnostics; + }, +}; diff --git a/lexicons/temporal/src/lint/rules/tmp002.ts b/lexicons/temporal/src/lint/rules/tmp002.ts new file mode 100644 index 00000000..215052cd --- /dev/null +++ b/lexicons/temporal/src/lint/rules/tmp002.ts @@ -0,0 +1,45 @@ +/** + * TMP002: TemporalSchedule AllowAll overlap without explanatory note + * + * AllowAll allows any number of concurrent schedule runs. This is safe for + * idempotent, read-only workflows, but can cause resource exhaustion or + * duplicate side-effects if not explicitly intended. Requiring a note forces + * the author to document the intent. + */ + +import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule"; + +export const tmp002: LintRule = { + id: "TMP002", + severity: "warning", + category: "best-practices", + description: "TemporalSchedule with overlap AllowAll should include state.note explaining the intent", + + check(context: LintContext): LintDiagnostic[] { + const diagnostics: LintDiagnostic[] = []; + + for (const [name, entity] of context.entities) { + const et = (entity as Record).entityType as string; + if (et !== "Temporal::Schedule") continue; + + const props = (entity as { props?: Record }).props ?? {}; + const policies = props.policies as Record | undefined; + if (policies?.overlap !== "AllowAll") continue; + + const state = props.state as Record | undefined; + const note = state?.note as string | undefined; + + if (!note || note.trim() === "") { + diagnostics.push({ + ruleId: "TMP002", + severity: "warning", + message: `Schedule "${name}" uses overlap "AllowAll" — add state.note explaining why concurrent runs are safe`, + entity: name, + fix: 'Add state: { note: "Workflow is idempotent — concurrent runs are safe" }', + }); + } + } + + return diagnostics; + }, +}; diff --git a/lexicons/temporal/src/plugin.test.ts b/lexicons/temporal/src/plugin.test.ts new file mode 100644 index 00000000..9630e750 --- /dev/null +++ b/lexicons/temporal/src/plugin.test.ts @@ -0,0 +1,97 @@ +/** + * Temporal plugin tests. + */ + +import { describe, expect, it } from "vitest"; +import { temporalPlugin } from "./plugin"; +import { isLexiconPlugin } from "@intentius/chant/lexicon"; + +describe("temporal plugin", () => { + it("is a valid LexiconPlugin", () => { + expect(isLexiconPlugin(temporalPlugin)).toBe(true); + }); + + it("has the correct name", () => { + expect(temporalPlugin.name).toBe("temporal"); + }); + + it("has a serializer with name 'temporal'", () => { + expect(temporalPlugin.serializer).toBeDefined(); + expect(temporalPlugin.serializer.name).toBe("temporal"); + }); + + it("lintRules() returns 2 rules (TMP001, TMP002)", () => { + const rules = temporalPlugin.lintRules?.(); + expect(Array.isArray(rules)).toBe(true); + expect(rules?.length).toBe(2); + const ids = rules?.map((r) => r.id).sort(); + expect(ids).toEqual(["TMP001", "TMP002"]); + }); + + it("mcpTools() returns 1 diff tool", () => { + const tools = temporalPlugin.mcpTools?.(); + expect(Array.isArray(tools)).toBe(true); + expect(tools?.length).toBe(1); + expect(tools?.[0].name).toBe("diff"); + }); + + it("mcpResources() returns at least 2 resources including resource-catalog", () => { + const resources = temporalPlugin.mcpResources?.(); + expect(Array.isArray(resources)).toBe(true); + expect((resources?.length ?? 0)).toBeGreaterThanOrEqual(2); + const uris = resources?.map((r) => r.uri); + expect(uris).toContain("resource-catalog"); + }); + + it("skills() returns 2 skill entries", () => { + const skills = temporalPlugin.skills?.(); + expect(Array.isArray(skills)).toBe(true); + expect(skills?.length).toBe(2); + }); + + it("skills include chant-temporal and chant-temporal-ops", () => { + const skills = temporalPlugin.skills?.() ?? []; + const names = skills.map((s) => s.name); + expect(names).toContain("chant-temporal"); + expect(names).toContain("chant-temporal-ops"); + }); + + describe("initTemplates", () => { + it("default template returns src with temporal.ts", () => { + const result = temporalPlugin.initTemplates?.(); + expect(result).toBeDefined(); + expect(result?.src).toBeDefined(); + expect(result?.src?.["temporal.ts"]).toBeDefined(); + }); + + it("default template includes TemporalServer and TemporalNamespace imports", () => { + const result = temporalPlugin.initTemplates?.(); + const src = result?.src?.["temporal.ts"] as string; + expect(src).toContain("TemporalServer"); + expect(src).toContain("TemporalNamespace"); + }); + + it("'cloud' template includes TemporalNamespace and SearchAttribute (no server)", () => { + const result = temporalPlugin.initTemplates?.("cloud"); + const src = result?.src?.["temporal.ts"] as string; + expect(src).toContain("TemporalNamespace"); + expect(src).toContain("SearchAttribute"); + expect(src).not.toContain("TemporalServer"); + }); + + it("'full' template includes all 4 resource types", () => { + const result = temporalPlugin.initTemplates?.("full"); + const src = result?.src?.["temporal.ts"] as string; + expect(src).toContain("TemporalServer"); + expect(src).toContain("TemporalNamespace"); + expect(src).toContain("SearchAttribute"); + expect(src).toContain("TemporalSchedule"); + }); + + it("'full' template uses mode: \"full\"", () => { + const result = temporalPlugin.initTemplates?.("full"); + const src = result?.src?.["temporal.ts"] as string; + expect(src).toContain('"full"'); + }); + }); +}); diff --git a/lexicons/temporal/src/plugin.ts b/lexicons/temporal/src/plugin.ts new file mode 100644 index 00000000..9f7d1c13 --- /dev/null +++ b/lexicons/temporal/src/plugin.ts @@ -0,0 +1,286 @@ +/** + * Temporal lexicon plugin — implements the LexiconPlugin lifecycle. + * + * All resources are hand-written; there is no remote spec to generate from. + * The generate step is a no-op. The package step builds dist/manifest.json + * and copies skill markdown files. + */ + +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +import type { LexiconPlugin } from "@intentius/chant/lexicon"; +import { discoverLintRules, discoverPostSynthChecks } from "@intentius/chant/lint/discover"; +import { createSkillsLoader, createDiffTool, createCatalogResource } from "@intentius/chant/lexicon-plugin-helpers"; +import { temporalSerializer } from "./serializer"; + +const srcDir = dirname(fileURLToPath(import.meta.url)); +const rulesDir = join(srcDir, "lint/rules"); +const postSynthDir = join(srcDir, "lint/post-synth"); + +export const temporalPlugin: LexiconPlugin = { + name: "temporal", + serializer: temporalSerializer, + + // ── Required lifecycle methods ────────────────────────────────────── + + async generate(options?: { verbose?: boolean }): Promise { + const { generate, writeGeneratedFiles } = await import("./codegen/generate"); + const { dirname: pathDirname } = await import("path"); + const { fileURLToPath: toPath } = await import("url"); + const pkgDir = pathDirname(pathDirname(toPath(import.meta.url))); + const result = await generate(options); + writeGeneratedFiles(result, pkgDir); + }, + + async validate(options?: { verbose?: boolean }): Promise { + const { validate } = await import("./validate"); + const result = await validate(options); + if (result.failed > 0) { + throw new Error(`Temporal lexicon validation failed:\n${result.errors.join("\n")}`); + } + }, + + async coverage(options?: { verbose?: boolean }): Promise { + const { analyze, printCoverageResult } = await import("./coverage"); + const result = analyze(); + printCoverageResult(result, options?.verbose ?? true); + }, + + async package(options?: { verbose?: boolean; force?: boolean }): Promise { + const { packageLexicon } = await import("./codegen/package"); + const { writeBundleSpec } = await import("@intentius/chant/codegen/package"); + const { join: pathJoin, dirname: pathDirname } = await import("path"); + const { fileURLToPath: toPath } = await import("url"); + + const { spec, stats } = await packageLexicon(options); + const pkgDir = pathDirname(pathDirname(toPath(import.meta.url))); + writeBundleSpec(spec, pathJoin(pkgDir, "dist")); + + console.error(`Packaged ${stats.resources} resources, ${stats.ruleCount} rules, ${stats.skillCount} skills`); + }, + + // ── Optional extensions ───────────────────────────────────────────── + + lintRules() { + return discoverLintRules(rulesDir, import.meta.url); + }, + + postSynthChecks() { + return discoverPostSynthChecks(postSynthDir, import.meta.url); + }, + + skills() { + return createSkillsLoader(import.meta.url, [ + { + file: "chant-temporal.md", + name: "chant-temporal", + description: "Temporal server setup, namespace provisioning, and schedule registration", + triggers: [ + { type: "file-pattern", value: "**/*.ts" }, + { type: "context", value: "temporal" }, + { type: "context", value: "workflow" }, + ], + parameters: [], + examples: [], + }, + { + file: "chant-temporal-ops.md", + name: "chant-temporal-ops", + description: "Signal workflows, diagnose stuck activities, reset checkpoints", + triggers: [ + { type: "context", value: "temporal workflow" }, + { type: "context", value: "stuck activity" }, + { type: "context", value: "chant run" }, + ], + parameters: [], + examples: [], + }, + ])(); + }, + + mcpTools() { + return [ + createDiffTool( + temporalSerializer, + "Compare current Temporal build output (docker-compose.yml, temporal-setup.sh, schedules/) against previous version", + ), + ]; + }, + + mcpResources() { + return [ + createCatalogResource( + import.meta.url, + "Temporal Resource Types", + "All supported Temporal resource types: TemporalServer, TemporalNamespace, SearchAttribute, TemporalSchedule", + "lexicon-temporal.json", + ), + { + uri: "examples/dev-server", + name: "Local Dev Server Example", + description: "Minimal Temporal dev server with namespace for local development", + mimeType: "text/typescript", + async handler(): Promise { + return [ + 'import { TemporalServer, TemporalNamespace } from "@intentius/chant-lexicon-temporal";', + "", + "// Local dev server — runs via `temporal server start-dev`", + 'export const server = new TemporalServer({ mode: "dev" });', + "", + "export const ns = new TemporalNamespace({", + ' name: "default",', + ' retention: "7d",', + "});", + ].join("\n"); + }, + }, + { + uri: "examples/cloud-setup", + name: "Temporal Cloud Setup Example", + description: "Namespace and search attributes for Temporal Cloud (no local server)", + mimeType: "text/typescript", + async handler(): Promise { + return [ + 'import { TemporalNamespace, SearchAttribute, TemporalSchedule } from "@intentius/chant-lexicon-temporal";', + "", + "export const ns = new TemporalNamespace({", + ' name: "prod",', + ' retention: "30d",', + ' description: "Production workflow namespace",', + "});", + "", + "export const projectAttr = new SearchAttribute({", + ' name: "Project",', + ' type: "Keyword",', + ' namespace: "prod",', + "});", + "", + "export const dailyReport = new TemporalSchedule({", + ' scheduleId: "daily-report",', + " spec: {", + ' cronExpressions: ["0 8 * * *"],', + " },", + " action: {", + ' workflowType: "reportWorkflow",', + ' taskQueue: "reports",', + " },", + " policies: {", + ' overlap: "Skip",', + " },", + "});", + ].join("\n"); + }, + }, + ]; + }, + + // ── initTemplates ──────────────────────────────────────────────────── + // 3 templates: "local" (default), "cloud", "full" + + initTemplates(template?: string) { + if (template === "cloud") { + return { + src: { + "temporal.ts": [ + 'import { TemporalNamespace, SearchAttribute, TemporalSchedule } from "@intentius/chant-lexicon-temporal";', + "", + "// Namespace — provision once after connecting to Temporal Cloud", + "export const ns = new TemporalNamespace({", + ' name: "my-namespace",', + ' retention: "30d",', + ' description: "Production deployment namespace",', + "});", + "", + "// Search attributes — register once per namespace", + "export const projectAttr = new SearchAttribute({", + ' name: "Project",', + ' type: "Keyword",', + ' namespace: "my-namespace",', + "});", + "", + "// Schedule — daily maintenance run", + "export const maintenanceSchedule = new TemporalSchedule({", + ' scheduleId: "daily-maintenance",', + " spec: {", + ' cronExpressions: ["0 2 * * *"],', + " },", + " action: {", + ' workflowType: "maintenanceWorkflow",', + ' taskQueue: "maintenance-queue",', + " },", + " policies: {", + ' overlap: "Skip",', + " },", + "});", + ].join("\n"), + }, + }; + } + + if (template === "full") { + return { + src: { + "temporal.ts": [ + 'import {', + ' TemporalServer,', + ' TemporalNamespace,', + ' SearchAttribute,', + ' TemporalSchedule,', + '} from "@intentius/chant-lexicon-temporal";', + "", + "// Full server stack (auto-setup + postgres + UI)", + "export const server = new TemporalServer({", + ' version: "1.26.2",', + ' mode: "full",', + " port: 7233,", + " uiPort: 8080,", + "});", + "", + "export const ns = new TemporalNamespace({", + ' name: "default",', + ' retention: "7d",', + "});", + "", + "export const projectAttr = new SearchAttribute({", + ' name: "Project",', + ' type: "Keyword",', + "});", + "", + "export const weeklyBackup = new TemporalSchedule({", + ' scheduleId: "weekly-backup",', + " spec: {", + ' cronExpressions: ["0 3 * * SUN"],', + " },", + " action: {", + ' workflowType: "backupWorkflow",', + ' taskQueue: "backup-queue",', + " },", + "});", + ].join("\n"), + }, + }; + } + + // default = "local" + return { + src: { + "temporal.ts": [ + 'import { TemporalServer, TemporalNamespace } from "@intentius/chant-lexicon-temporal";', + "", + "// Local dev server — runs via `temporal server start-dev`", + "export const server = new TemporalServer({ mode: \"dev\" });", + "", + "export const ns = new TemporalNamespace({", + ' name: "default",', + ' retention: "7d",', + "});", + ].join("\n"), + }, + }; + }, + + async docs(options?) { + const { generateDocs } = await import("./codegen/docs"); + return generateDocs(options); + }, +}; diff --git a/lexicons/temporal/src/resources.ts b/lexicons/temporal/src/resources.ts new file mode 100644 index 00000000..f17fd346 --- /dev/null +++ b/lexicons/temporal/src/resources.ts @@ -0,0 +1,121 @@ +/** + * Temporal lexicon resources — hand-written Declarable constructors. + * + * These model the Temporal.io infrastructure concerns that chant serializes: + * server deployment configs, namespace provisioning scripts, search attribute + * registration, and SDK schedule creation code. + * + * All 4 resources are hand-written (no upstream spec to generate from). + * + * Reference: https://docs.temporal.io + */ + +import { createResource } from "@intentius/chant/runtime"; + +// ── TemporalServer ──────────────────────────────────────────────────── +// Serializes to docker-compose.yml (primary) and temporal-helm-values.yaml. +// mode: "dev" emits a single-container dev server via `temporal server start-dev`. +// mode: "full" emits auto-setup + postgresql + UI services. + +export const TemporalServer = createResource("Temporal::Server", "temporal", {}); + +export interface TemporalServerProps { + /** Temporal server version tag. Default: "1.26.2" */ + version?: string; + /** Deployment mode. Default: "dev" */ + mode?: "dev" | "full"; + /** gRPC port. Default: 7233 */ + port?: number; + /** Web UI port. Default: 8080 */ + uiPort?: number; + /** PostgreSQL image tag for "full" mode. Default: "16-alpine" */ + postgresVersion?: string; + /** Helm chart version hint — written as a comment in helm-values.yaml */ + helmChartVersion?: string; +} + +// ── TemporalNamespace ───────────────────────────────────────────────── +// Serializes to temporal-setup.sh as `temporal operator namespace create` commands. + +export const TemporalNamespace = createResource("Temporal::Namespace", "temporal", {}); + +export interface TemporalNamespaceProps { + /** Namespace name */ + name: string; + /** Workflow execution retention period. Default: "7d" */ + retention?: string; + /** Human-readable description */ + description?: string; + /** Whether this is a global (multi-cluster) namespace */ + isGlobalNamespace?: boolean; +} + +// ── SearchAttribute ─────────────────────────────────────────────────── +// Serializes to temporal-setup.sh as `temporal operator search-attribute create` commands. + +export const SearchAttribute = createResource("Temporal::SearchAttribute", "temporal", {}); + +export interface SearchAttributeProps { + /** Attribute name (PascalCase recommended by Temporal) */ + name: string; + /** Attribute value type */ + type: "Text" | "Keyword" | "Int" | "Double" | "Bool" | "Datetime" | "KeywordList"; + /** Namespace to register in. If omitted, --namespace flag is not emitted */ + namespace?: string; +} + +// ── TemporalSchedule ────────────────────────────────────────────────── +// Serializes to schedules/.ts — runnable TypeScript that creates +// the schedule via the Temporal SDK client. + +export const TemporalSchedule = createResource("Temporal::Schedule", "temporal", {}); + +export interface TemporalScheduleProps { + /** Unique schedule identifier */ + scheduleId: string; + /** Schedule timing specification */ + spec: { + /** Cron expressions (e.g. "0 9 * * MON-FRI") */ + cronExpressions?: string[]; + /** Fixed intervals (e.g. { every: "1d" }) */ + intervals?: Array<{ every: string; offset?: string }>; + }; + /** What workflow to start */ + action: { + workflowType: string; + taskQueue: string; + args?: unknown[]; + workflowExecutionTimeout?: string; + workflowRunTimeout?: string; + memo?: Record; + searchAttributes?: Record; + /** + * Retry policy for the triggered workflow execution. + * When set, the generated schedule script includes `workflowStartOptions.retry`. + */ + workflowRetryPolicy?: { + /** Initial retry interval (e.g. "10s"). Default: Temporal server default (~1s). */ + initialInterval?: string; + /** Backoff multiplier for each subsequent retry (e.g. 2). */ + backoffCoefficient?: number; + /** 0 = unlimited retries; defaults to Temporal server default (unlimited). */ + maximumAttempts?: number; + /** Cap on retry intervals (e.g. "5m"). */ + maximumInterval?: string; + /** Error types that should NOT trigger a retry. */ + nonRetryableErrorTypes?: string[]; + }; + }; + policies?: { + overlap?: "Skip" | "BufferOne" | "BufferAll" | "CancelOther" | "TerminateOther" | "AllowAll"; + catchupWindow?: string; + pauseOnFailure?: boolean; + }; + /** Initial paused state */ + state?: { + paused?: boolean; + note?: string; + }; + /** Namespace to create schedule in. Default: process.env.TEMPORAL_NAMESPACE ?? "default" */ + namespace?: string; +} diff --git a/lexicons/temporal/src/serializer.test.ts b/lexicons/temporal/src/serializer.test.ts new file mode 100644 index 00000000..4677e268 --- /dev/null +++ b/lexicons/temporal/src/serializer.test.ts @@ -0,0 +1,292 @@ +/** + * Temporal serializer tests. + */ + +import { describe, expect, it } from "vitest"; +import { temporalSerializer } from "./serializer"; +import { DECLARABLE_MARKER } from "@intentius/chant/declarable"; + +// ── Test helpers ─────────────────────────────────────────────────── + +function makeEntity( + entityType: string, + props: Record, +): Record { + return { + [DECLARABLE_MARKER]: true, + entityType, + lexicon: "temporal", + kind: "resource", + props, + attributes: {}, + }; +} + +function makeServer(props: Record = {}): [string, Record] { + return ["server", makeEntity("Temporal::Server", props)]; +} + +function makeNamespace(name: string, props: Record = {}): [string, Record] { + return [name, makeEntity("Temporal::Namespace", { name, ...props })]; +} + +function makeSearchAttr(name: string, props: Record = {}): [string, Record] { + return [name, makeEntity("Temporal::SearchAttribute", { name, type: "Text", ...props })]; +} + +function makeSchedule(name: string, props: Record = {}): [string, Record] { + return [name, makeEntity("Temporal::Schedule", { + scheduleId: name, + spec: { intervals: [{ every: "1d" }] }, + action: { workflowType: "testWorkflow", taskQueue: "test-queue" }, + ...props, + })]; +} + +// ── Tests ───────────────────────────────────────────────────────── + +describe("temporal serializer", () => { + it("has the correct name", () => { + expect(temporalSerializer.name).toBe("temporal"); + }); + + it("has rulePrefix TMP", () => { + expect(temporalSerializer.rulePrefix).toBe("TMP"); + }); + + it("serializes empty map to a string with header comment", () => { + const result = temporalSerializer.serialize(new Map()); + expect(typeof result).toBe("string"); + expect(result as string).toContain("Generated by chant"); + }); + + // ── TemporalServer ───────────────────────────────────────────── + + it("serializes TemporalServer dev mode to docker-compose with start-dev command", () => { + const entities = new Map([makeServer({ mode: "dev" })]); + const result = temporalSerializer.serialize(entities) as string; + expect(typeof result).toBe("string"); + expect(result).toContain("temporal server start-dev"); + expect(result).toContain("temporalio/admin-tools"); + expect(result).toContain("7233"); + expect(result).toContain("8080"); + }); + + it("uses default mode 'dev' when mode is not specified", () => { + const entities = new Map([makeServer()]); + const result = temporalSerializer.serialize(entities) as string; + expect(result).toContain("temporal server start-dev"); + }); + + it("serializes TemporalServer full mode with auto-setup + postgresql + UI", () => { + const entities = new Map([makeServer({ mode: "full" })]); + const result = temporalSerializer.serialize(entities) as string; + expect(typeof result).toBe("string"); + expect(result).toContain("temporalio/auto-setup"); + expect(result).toContain("postgresql"); + expect(result).toContain("temporal-ui"); + expect(result).toContain("POSTGRES_USER=temporal"); + expect(result).not.toContain("temporal server start-dev"); + }); + + it("embeds custom version in docker-compose image tag", () => { + const entities = new Map([makeServer({ version: "1.25.0", mode: "dev" })]); + const result = temporalSerializer.serialize(entities) as string; + expect(result).toContain("1.25.0"); + }); + + it("returns plain string (not SerializerResult) when only TemporalServer present", () => { + const entities = new Map([makeServer()]); + const result = temporalSerializer.serialize(entities); + expect(typeof result).toBe("string"); + }); + + // ── TemporalServer + Helm values ────────────────────────────── + + it("includes helm-values.yaml in files when server + namespace present", () => { + const entities = new Map([ + makeServer({ version: "1.26.2" }), + makeNamespace("default"), + ]); + const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; + expect(typeof result).toBe("object"); + expect(result.files["temporal-helm-values.yaml"]).toBeDefined(); + expect(result.files["temporal-helm-values.yaml"]).toContain("temporalio/server"); + expect(result.files["temporal-helm-values.yaml"]).toContain("1.26.2"); + }); + + it("includes helm chart comment when helmChartVersion is set", () => { + const entities = new Map([ + makeServer({ helmChartVersion: "0.42.0" }), + makeNamespace("default"), + ]); + const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; + expect(result.files["temporal-helm-values.yaml"]).toContain("0.42.0"); + }); + + // ── TemporalNamespace ───────────────────────────────────────── + + it("serializes TemporalNamespace to temporal-setup.sh with namespace create command", () => { + const entities = new Map([makeNamespace("prod", { retention: "30d", description: "Production" })]); + const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; + expect(typeof result).toBe("object"); + const sh = result.files["temporal-setup.sh"]; + expect(sh).toBeDefined(); + expect(sh).toContain("temporal operator namespace create"); + expect(sh).toContain("--namespace \"prod\""); + expect(sh).toContain("--retention \"30d\""); + expect(sh).toContain("--description \"Production\""); + }); + + it("emits --global-namespace flag when isGlobalNamespace is true", () => { + const entities = new Map([makeNamespace("global-ns", { isGlobalNamespace: true })]); + const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; + expect(result.files["temporal-setup.sh"]).toContain("--global-namespace"); + }); + + it("uses default retention of 7d when not specified", () => { + const entities = new Map([makeNamespace("ns")]); + const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; + expect(result.files["temporal-setup.sh"]).toContain('"7d"'); + }); + + // ── SearchAttribute ──────────────────────────────────────────── + + it("serializes SearchAttribute to setup.sh with search-attribute create command", () => { + const entities = new Map([makeSearchAttr("GcpProject", { type: "Text" })]); + const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; + const sh = result.files["temporal-setup.sh"]; + expect(sh).toContain("temporal operator search-attribute create"); + expect(sh).toContain('--name "GcpProject"'); + expect(sh).toContain("--type Text"); + }); + + it("includes --namespace flag when namespace is specified on SearchAttribute", () => { + const entities = new Map([makeSearchAttr("Env", { type: "Keyword", namespace: "prod" })]); + const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; + expect(result.files["temporal-setup.sh"]).toContain('--namespace "prod"'); + }); + + it("omits --namespace flag when namespace is not specified", () => { + const entities = new Map([makeSearchAttr("Env", { type: "Keyword" })]); + const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; + expect(result.files["temporal-setup.sh"]).not.toContain("--namespace"); + }); + + it("emits all search attributes in setup.sh", () => { + const entities = new Map([ + makeSearchAttr("GcpProject", { type: "Text" }), + makeSearchAttr("CrdbDomain", { type: "Keyword" }), + makeSearchAttr("DeployPhase", { type: "Int" }), + ]); + const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; + const sh = result.files["temporal-setup.sh"]; + expect(sh).toContain('"GcpProject"'); + expect(sh).toContain('"CrdbDomain"'); + expect(sh).toContain('"DeployPhase"'); + }); + + it("combines namespaces and search attributes in a single temporal-setup.sh", () => { + const entities = new Map([ + makeNamespace("deploy-ns"), + makeSearchAttr("GcpProject", { type: "Text", namespace: "deploy-ns" }), + ]); + const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; + const sh = result.files["temporal-setup.sh"]; + // Both namespace create and search-attribute create appear in one file + expect(sh).toContain("temporal operator namespace create"); + expect(sh).toContain("temporal operator search-attribute create"); + // Namespaces before search attributes + expect(sh.indexOf("namespace create")).toBeLessThan(sh.indexOf("search-attribute create")); + }); + + // ── TemporalSchedule ─────────────────────────────────────────── + + it("serializes TemporalSchedule to schedules/.ts with SDK schedule.create call", () => { + const entities = new Map([makeSchedule("daily-backup")]); + const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; + const ts = result.files["schedules/daily-backup.ts"]; + expect(ts).toBeDefined(); + expect(ts).toContain("client.schedule.create"); + expect(ts).toContain('"daily-backup"'); + }); + + it("embeds workflowType and taskQueue in schedule code", () => { + const entities = new Map([makeSchedule("my-sched", { + action: { workflowType: "myWorkflow", taskQueue: "my-queue" }, + })]); + const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; + const ts = result.files["schedules/my-sched.ts"]; + expect(ts).toContain('"myWorkflow"'); + expect(ts).toContain('"my-queue"'); + }); + + it("generates separate schedule files for each TemporalSchedule entity", () => { + const entities = new Map([ + makeSchedule("schedule-a"), + makeSchedule("schedule-b"), + ]); + const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; + expect(result.files["schedules/schedule-a.ts"]).toBeDefined(); + expect(result.files["schedules/schedule-b.ts"]).toBeDefined(); + }); + + it("uses scheduleId from props as the file key", () => { + const entities = new Map([ + ["myEntity", makeEntity("Temporal::Schedule", { + scheduleId: "custom-id", + spec: { intervals: [{ every: "1h" }] }, + action: { workflowType: "w", taskQueue: "q" }, + })], + ]); + const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; + expect(result.files["schedules/custom-id.ts"]).toBeDefined(); + }); + + it("includes overlap policy in schedule code when specified", () => { + const entities = new Map([makeSchedule("with-policy", { + policies: { overlap: "BufferOne" }, + })]); + const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; + expect(result.files["schedules/with-policy.ts"]).toContain("BufferOne"); + }); + + // ── Mixed entities ───────────────────────────────────────────── + + it("returns SerializerResult with all keys for mixed entities", () => { + const entities = new Map([ + makeServer(), + makeNamespace("default"), + makeSearchAttr("Project", { type: "Keyword" }), + makeSchedule("weekly"), + ]); + const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; + expect(typeof result).toBe("object"); + expect(result.primary).toContain("temporal server start-dev"); + expect(result.files["temporal-helm-values.yaml"]).toBeDefined(); + expect(result.files["temporal-setup.sh"]).toBeDefined(); + expect(result.files["schedules/weekly.ts"]).toBeDefined(); + }); + + it("primary contains docker-compose content even in mixed mode", () => { + const entities = new Map([ + makeServer({ mode: "full" }), + makeNamespace("default"), + ]); + const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; + expect(result.primary).toContain("temporalio/auto-setup"); + }); + + it("setup.sh uses TEMPORAL_ADDRESS env var", () => { + const entities = new Map([makeNamespace("ns")]); + const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; + expect(result.files["temporal-setup.sh"]).toContain("TEMPORAL_ADDRESS"); + expect(result.files["temporal-setup.sh"]).toContain("localhost:7233"); + }); + + it("setup.sh has set -euo pipefail", () => { + const entities = new Map([makeNamespace("ns")]); + const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; + expect(result.files["temporal-setup.sh"]).toContain("set -euo pipefail"); + }); +}); diff --git a/lexicons/temporal/src/serializer.ts b/lexicons/temporal/src/serializer.ts new file mode 100644 index 00000000..24f6ce76 --- /dev/null +++ b/lexicons/temporal/src/serializer.ts @@ -0,0 +1,310 @@ +/** + * Temporal serializer. + * + * Routes entities by entityType: + * Temporal::Server → docker-compose.yml (primary) + temporal-helm-values.yaml (file) + * Temporal::Namespace → temporal-setup.sh (file) + * Temporal::SearchAttribute → temporal-setup.sh (file, appended after namespaces) + * Temporal::Schedule → schedules/.ts (file per schedule) + * + * Returns a plain string when only TemporalServer entities are present. + * Returns SerializerResult for any other combination. + */ + +import type { Declarable } from "@intentius/chant/declarable"; +import type { Serializer, SerializerResult } from "@intentius/chant/serializer"; +import type { TemporalServerProps, TemporalNamespaceProps, SearchAttributeProps, TemporalScheduleProps } from "./resources"; + +// ── Helpers ───────────────────────────────────────────────────────── + +function getProps(entity: Declarable): Record { + if ("props" in entity && typeof entity.props === "object" && entity.props !== null) { + return entity.props as Record; + } + return {}; +} + +function entityType(entity: Declarable): string { + return (entity as Record).entityType as string; +} + +// ── Docker Compose ─────────────────────────────────────────────────── + +const HEADER = "# Generated by chant — do not edit directly.\n# Source of truth: the chant TypeScript project.\n"; + +function serializeDockerCompose(servers: Map): string { + const lines: string[] = [HEADER]; + + if (servers.size === 0) { + lines.push("# No TemporalServer resources defined."); + return lines.join("\n") + "\n"; + } + + // Use the first server entity (a project should have exactly one) + const [, serverEntity] = [...servers.entries()][0]; + const props = getProps(serverEntity) as TemporalServerProps; + const version = props.version ?? "1.26.2"; + const mode = props.mode ?? "dev"; + const port = props.port ?? 7233; + const uiPort = props.uiPort ?? 8080; + const postgresVersion = props.postgresVersion ?? "16-alpine"; + + lines.push("services:"); + + if (mode === "dev") { + lines.push(` temporal:`); + lines.push(` image: temporalio/admin-tools:${version}`); + lines.push(` command: temporal server start-dev --namespace default --ui-port ${uiPort}`); + lines.push(` ports:`); + lines.push(` - "${port}:${port}"`); + lines.push(` - "${uiPort}:${uiPort}"`); + } else { + // full mode + lines.push(` temporal:`); + lines.push(` image: temporalio/auto-setup:${version}`); + lines.push(` ports:`); + lines.push(` - "${port}:7233"`); + lines.push(` environment:`); + lines.push(` - DB=postgresql`); + lines.push(` - DB_PORT=5432`); + lines.push(` - POSTGRES_USER=temporal`); + lines.push(` - POSTGRES_PWD=temporal`); + lines.push(` - POSTGRES_SEEDS=postgresql`); + lines.push(` depends_on:`); + lines.push(` - postgresql`); + lines.push(` temporal-ui:`); + lines.push(` image: temporalio/ui:${version}`); + lines.push(` environment:`); + lines.push(` - TEMPORAL_ADDRESS=temporal:7233`); + lines.push(` - TEMPORAL_CORS_ORIGINS=http://localhost:3000`); + lines.push(` ports:`); + lines.push(` - "${uiPort}:8080"`); + lines.push(` postgresql:`); + lines.push(` image: postgres:${postgresVersion}`); + lines.push(` environment:`); + lines.push(` - POSTGRES_USER=temporal`); + lines.push(` - POSTGRES_PASSWORD=temporal`); + lines.push(` volumes:`); + lines.push(` - temporal-db:/var/lib/postgresql/data`); + lines.push(`volumes:`); + lines.push(` temporal-db: {}`); + } + + return lines.join("\n") + "\n"; +} + +// ── Helm Values ────────────────────────────────────────────────────── + +function serializeHelmValues(servers: Map): string { + if (servers.size === 0) return ""; + + const [, serverEntity] = [...servers.entries()][0]; + const props = getProps(serverEntity) as TemporalServerProps; + const version = props.version ?? "1.26.2"; + const port = props.port ?? 7233; + const chartVersion = props.helmChartVersion; + + const lines: string[] = [ + "# Generated by chant — do not edit directly.", + "# Apply: helm install temporal temporal/temporal -f temporal-helm-values.yaml", + "# Helm charts: https://github.com/temporalio/helm-charts", + ]; + + if (chartVersion) { + lines.push(`# Chart version: ${chartVersion}`); + } + + lines.push( + "server:", + " replicaCount: 1", + " image:", + " repository: temporalio/server", + ` tag: "${version}"`, + "frontend:", + " replicaCount: 1", + " service:", + ` port: ${port}`, + "web:", + " enabled: true", + ); + + return lines.join("\n") + "\n"; +} + +// ── Setup Script ───────────────────────────────────────────────────── + +function serializeSetupScript( + namespaces: Map, + searchAttrs: Map, +): string { + const lines: string[] = [ + "#!/usr/bin/env bash", + "# Generated by chant — do not edit directly.", + "# Run once after the Temporal server is ready.", + "set -euo pipefail", + "", + 'TEMPORAL_ADDRESS="${TEMPORAL_ADDRESS:-localhost:7233}"', + "", + ]; + + if (namespaces.size > 0) { + lines.push("# ── Namespaces ────────────────────────────────────────────────────────"); + lines.push(""); + for (const [, entity] of namespaces) { + const props = getProps(entity) as TemporalNamespaceProps; + const parts: string[] = [ + `temporal operator namespace create \\`, + ` --address "\${TEMPORAL_ADDRESS}" \\`, + ` --namespace "${props.name}" \\`, + ` --retention "${props.retention ?? "7d"}"`, + ]; + if (props.description) { + // replace last line — remove trailing backslash, add it back with description + parts[parts.length - 1] += " \\"; + parts.push(` --description "${props.description}"`); + } + if (props.isGlobalNamespace) { + parts[parts.length - 1] += " \\"; + parts.push(` --global-namespace`); + } + lines.push(...parts); + lines.push(""); + } + } + + if (searchAttrs.size > 0) { + lines.push("# ── Search Attributes ─────────────────────────────────────────────────"); + lines.push(""); + for (const [, entity] of searchAttrs) { + const props = getProps(entity) as SearchAttributeProps; + const parts: string[] = [ + `temporal operator search-attribute create \\`, + ` --address "\${TEMPORAL_ADDRESS}" \\`, + ]; + if (props.namespace) { + parts.push(` --namespace "${props.namespace}" \\`); + } + parts.push( + ` --name "${props.name}" \\`, + ` --type ${props.type}`, + ); + lines.push(...parts); + lines.push(""); + } + } + + return lines.join("\n") + "\n"; +} + +// ── Schedule Code ──────────────────────────────────────────────────── + +function serializeSchedule(scheduleId: string, props: TemporalScheduleProps): string { + const lines: string[] = [ + `// Generated by chant — do not edit directly.`, + `// Run: npx tsx schedules/${scheduleId}.ts`, + `import { Client, Connection } from "@temporalio/client";`, + ``, + `async function createSchedule(): Promise {`, + ` const connection = await Connection.connect({`, + ` address: process.env.TEMPORAL_ADDRESS ?? "localhost:7233",`, + ` });`, + ` const client = new Client({`, + ` connection,`, + ` namespace: process.env.TEMPORAL_NAMESPACE ?? "${props.namespace ?? "default"}",`, + ` });`, + ``, + ` await client.schedule.create({`, + ` scheduleId: ${JSON.stringify(props.scheduleId)},`, + ` spec: ${JSON.stringify(props.spec, null, 6).replace(/^/gm, " ").trimStart()},`, + ` action: {`, + ` type: "startWorkflow",`, + ` workflowType: ${JSON.stringify(props.action.workflowType)},`, + ` taskQueue: ${JSON.stringify(props.action.taskQueue)},`, + ` args: ${JSON.stringify(props.action.args ?? [])},`, + ` },`, + ]; + + if (props.policies) { + lines.push(` policies: ${JSON.stringify(props.policies, null, 6).replace(/^/gm, " ").trimStart()},`); + } + + if (props.state) { + lines.push(` state: ${JSON.stringify(props.state, null, 6).replace(/^/gm, " ").trimStart()},`); + } + + lines.push( + ` });`, + ``, + ` console.log("Schedule created: ${scheduleId}");`, + ` await connection.close();`, + `}`, + ``, + `createSchedule().catch((err: unknown) => {`, + ` console.error(err);`, + ` process.exit(1);`, + `});`, + ``, + ); + + return lines.join("\n"); +} + +// ── Serializer ─────────────────────────────────────────────────────── + +export const temporalSerializer: Serializer = { + name: "temporal", + rulePrefix: "TMP", + + serialize( + entities: Map, + _outputs?: unknown, + ): string | SerializerResult { + const servers = new Map(); + const namespaces = new Map(); + const searchAttrs = new Map(); + const schedules = new Map(); + + for (const [name, entity] of entities) { + const et = entityType(entity); + if (et === "Temporal::Server") servers.set(name, entity); + else if (et === "Temporal::Namespace") namespaces.set(name, entity); + else if (et === "Temporal::SearchAttribute") searchAttrs.set(name, entity); + else if (et === "Temporal::Schedule") schedules.set(name, entity); + } + + const primary = serializeDockerCompose(servers); + + const hasExtraFiles = + servers.size > 0 || // always emit helm-values when a server exists + namespaces.size > 0 || + searchAttrs.size > 0 || + schedules.size > 0; + + // Only-server case: no extra files needed beyond docker-compose → return string + if ( + namespaces.size === 0 && + searchAttrs.size === 0 && + schedules.size === 0 + ) { + return primary; + } + + const files: Record = {}; + + if (servers.size > 0) { + files["temporal-helm-values.yaml"] = serializeHelmValues(servers); + } + + if (namespaces.size > 0 || searchAttrs.size > 0) { + files["temporal-setup.sh"] = serializeSetupScript(namespaces, searchAttrs); + } + + for (const [name, entity] of schedules) { + const props = getProps(entity) as TemporalScheduleProps; + const scheduleId = props.scheduleId ?? name; + files[`schedules/${scheduleId}.ts`] = serializeSchedule(scheduleId, props); + } + + return { primary, files }; + }, +}; diff --git a/lexicons/temporal/src/skills/chant-temporal-ops.md b/lexicons/temporal/src/skills/chant-temporal-ops.md new file mode 100644 index 00000000..35043da8 --- /dev/null +++ b/lexicons/temporal/src/skills/chant-temporal-ops.md @@ -0,0 +1,184 @@ +--- +skill: chant-temporal-ops +description: Signal workflows, diagnose stuck activities, reset checkpoints, and cancel runs via the Temporal CLI and chant run +user-invocable: true +--- + +# Temporal Operations Playbook + +## Signal a gate (unblock a paused workflow) + +Workflows that use `setHandler` on a signal pause at gate activities waiting for a named signal. The `chant run signal` command (available in issue #8) forwards signals to the running workflow. + +```bash +# Via chant CLI (requires chant run — issue #8) +chant run signal dnsConfigured + +# Directly via temporal CLI +temporal workflow signal \ + --workflow-id \ + --name dnsConfigured \ + --namespace +``` + +List pending signals by querying the workflow: + +```bash +temporal workflow query \ + --workflow-id \ + --type currentPhase \ + --namespace +``` + +## Check run status + +```bash +# Summary view +temporal workflow describe --workflow-id --namespace + +# Full event history +temporal workflow show --workflow-id --namespace + +# Filter by search attribute (requires registered custom attributes) +temporal workflow list \ + --namespace \ + --query 'GcpProject = "my-project"' +``` + +## Diagnose a stuck activity + +Activities can be stuck for three distinct reasons: + +| Symptom | Cause | Fix | +|---|---|---| +| Activity never started | `scheduleToStartTimeout` exceeded — no available workers | Start the worker: `chant run ` | +| Activity started but no heartbeats | `heartbeatTimeout` exceeded — worker crashed mid-activity | Bounce the worker; Temporal auto-retries | +| Activity running but slow | Normal — long-running activities with heartbeats | Wait, or check heartbeat details | + +### View activity timeout details + +```bash +temporal workflow show --workflow-id --namespace | grep -A5 "ActivityTaskScheduled" +``` + +### Check worker connectivity + +```bash +# See which task queues have pollers +temporal task-queue describe --task-queue --namespace +``` + +A task queue with `pollerCount: 0` means no workers are running. + +## Reset a workflow to a previous checkpoint + +Use `workflow reset` to replay a workflow from a specific event, skipping failed activities: + +```bash +# Reset to just before the most recent failure +temporal workflow reset \ + --workflow-id \ + --namespace \ + --event-id \ + --reason "Retrying after infra fix" +``` + +Find the event ID to reset to: + +```bash +# List events — find the last successful ActivityTaskCompleted before the failure +temporal workflow show --workflow-id --namespace | grep -n "ActivityTask" +``` + +Reset to the beginning of a named phase (requires workflow to record phase transitions as signals or markers): + +```bash +temporal workflow reset \ + --workflow-id \ + --namespace \ + --reapply-type None \ + --type LastWorkflowTask +``` + +## Cancel a stuck or unwanted run + +```bash +# Graceful cancel — workflow receives CancellationError and can clean up +temporal workflow cancel \ + --workflow-id \ + --namespace + +# Forceful terminate — immediate stop, no cleanup +temporal workflow terminate \ + --workflow-id \ + --namespace \ + --reason "Terminated by operator" +``` + +## Pause and resume a schedule + +```bash +# Pause +temporal schedule pause \ + --schedule-id \ + --namespace \ + --note "Paused for maintenance" + +# Resume +temporal schedule unpause \ + --schedule-id \ + --namespace + +# Trigger immediately (ignores spec) +temporal schedule trigger \ + --schedule-id \ + --namespace +``` + +## Inspect workflow history for debugging + +```bash +# Show all events in JSON (machine-readable) +temporal workflow show \ + --workflow-id \ + --namespace \ + --output json + +# Filter to activity failures only +temporal workflow show \ + --workflow-id \ + --namespace \ + --output json | jq '.[] | select(.eventType == "ActivityTaskFailed")' +``` + +## Common failure patterns + +### "no workers polling" after `chant run` + +The worker started but cannot connect. Check: +1. `TEMPORAL_ADDRESS` matches the server address +2. `TEMPORAL_NAMESPACE` matches the namespace the workflow was started in +3. TLS and API key config match (`tls: true` + `apiKey` for Temporal Cloud) + +### Activity retrying indefinitely + +Default retry policy has `maximumAttempts: 0` (unlimited). If an activity is retrying unexpectedly: + +```bash +# Check the current attempt count and last failure +temporal workflow show --workflow-id --namespace | grep "attempt\|failure" +``` + +Add `maximumAttempts` to the activity's retry policy in the workflow code, or cancel the run. + +### "workflow execution already started" on re-run + +`chant run` uses deterministic workflow IDs (e.g. `crdb-deploy-{project}`). If a previous run is still open: + +```bash +# Check if it's still running +temporal workflow describe --workflow-id --namespace | grep "status" + +# If stuck, terminate it first +temporal workflow terminate --workflow-id --namespace --reason "Restarting" +``` diff --git a/lexicons/temporal/src/skills/chant-temporal.md b/lexicons/temporal/src/skills/chant-temporal.md new file mode 100644 index 00000000..c0f7afaa --- /dev/null +++ b/lexicons/temporal/src/skills/chant-temporal.md @@ -0,0 +1,201 @@ +--- +skill: chant-temporal +description: Build and manage Temporal server deployment, namespace provisioning, and schedule registration from a chant project +user-invocable: true +--- + +# Temporal Operational Playbook + +## How chant and Temporal relate + +chant is a **synthesis compiler** — it compiles TypeScript resource declarations into deployment artifacts. `chant build` does not start Temporal or register anything; synthesis is pure and deterministic. Your job as an agent is to bridge synthesis and deployment: + +- Use **chant** for: build, lint, diff (local config comparison) +- Use **docker compose / kubectl / temporal CLI** for: starting the server, applying configs, and all runtime operations + +The source of truth for Temporal configuration is the TypeScript in `src/`. The generated artifacts in `dist/` are intermediate outputs. + +## Resources and their outputs + +| Resource | Emits | +|---|---| +| `TemporalServer` | `docker-compose.yml` (primary) + `temporal-helm-values.yaml` | +| `TemporalNamespace` | `temporal-setup.sh` (namespace create commands) | +| `SearchAttribute` | `temporal-setup.sh` (search-attribute create commands) | +| `TemporalSchedule` | `schedules/.ts` (SDK schedule creation script) | + +## Build and validate + +### Build the project + +```bash +chant build src/ --output dist/ +``` + +Options: +- `--watch` — rebuild on source changes +- `--format json` — not applicable (Temporal outputs are YAML/shell/TypeScript) + +### Lint the source + +```bash +chant lint src/ +``` + +## Start the Temporal server + +### Local dev (generated docker-compose.yml) + +```bash +# Start the dev server +docker compose up -d + +# Verify it's running +temporal operator cluster health +``` + +The dev server runs as a single container (`temporal server start-dev`). The Web UI is available at `http://localhost:8080`. + +### Temporal Cloud + +No server to start. Configure your worker profile in `chant.config.ts`: + +```ts +import type { TemporalChantConfig } from "@intentius/chant-lexicon-temporal"; + +export default { + lexicons: ["temporal"], + temporal: { + profiles: { + cloud: { + address: "myns.a2dd6.tmprl.cloud:7233", + namespace: "myns.a2dd6", + taskQueue: "my-deploy", + tls: true, + apiKey: { env: "TEMPORAL_API_KEY" }, + }, + }, + defaultProfile: "cloud", + } satisfies TemporalChantConfig, +}; +``` + +## Provision namespaces and search attributes + +After the server is ready, run the generated setup script: + +```bash +bash dist/temporal-setup.sh +``` + +This creates namespaces and registers search attributes using the `temporal` CLI. The `TEMPORAL_ADDRESS` env var overrides the default `localhost:7233`: + +```bash +TEMPORAL_ADDRESS=myns.a2dd6.tmprl.cloud:7233 bash dist/temporal-setup.sh +``` + +Verify: + +```bash +temporal operator namespace describe --namespace default +temporal operator search-attribute list --namespace default +``` + +## Deploy with Helm + +```bash +helm repo add temporal https://go.temporal.io/server/helm-charts +helm repo update +helm install temporal temporal/temporal -f dist/temporal-helm-values.yaml +``` + +Wait for all pods: + +```bash +kubectl get pods -l app.kubernetes.io/name=temporal -w +``` + +## Register schedules + +Each `TemporalSchedule` resource generates a standalone TypeScript runner: + +```bash +# Set connection env vars +export TEMPORAL_ADDRESS=localhost:7233 +export TEMPORAL_NAMESPACE=default + +# Run the generated schedule creation script +npx tsx dist/schedules/daily-backup.ts +``` + +Verify the schedule was created: + +```bash +temporal schedule list --namespace default +temporal schedule describe --schedule-id daily-backup --namespace default +``` + +## Key resource types + +| Resource | Purpose | +|---|---| +| `TemporalServer` | Server deployment config (dev vs full mode) | +| `TemporalNamespace` | Namespace with retention policy | +| `SearchAttribute` | Custom workflow search field | +| `TemporalSchedule` | Recurring workflow trigger | + +## Common patterns + +### Minimal local dev stack + +```ts +import { TemporalServer, TemporalNamespace } from "@intentius/chant-lexicon-temporal"; + +export const server = new TemporalServer({ mode: "dev" }); +export const ns = new TemporalNamespace({ name: "default", retention: "7d" }); +``` + +### Production namespace with search attributes + +```ts +import { TemporalNamespace, SearchAttribute } from "@intentius/chant-lexicon-temporal"; + +export const ns = new TemporalNamespace({ + name: "prod-deploy", + retention: "30d", + description: "Production deployment workflows", +}); + +export const gcpProject = new SearchAttribute({ + name: "GcpProject", + type: "Text", + namespace: "prod-deploy", +}); + +export const environment = new SearchAttribute({ + name: "Environment", + type: "Keyword", + namespace: "prod-deploy", +}); +``` + +### Recurring backup schedule + +```ts +import { TemporalSchedule } from "@intentius/chant-lexicon-temporal"; + +export const backupSchedule = new TemporalSchedule({ + scheduleId: "daily-backup", + spec: { + cronExpressions: ["0 3 * * *"], + }, + action: { + workflowType: "backupWorkflow", + taskQueue: "backup-queue", + }, + policies: { + overlap: "Skip", + pauseOnFailure: true, + }, +}); +``` diff --git a/lexicons/temporal/src/validate-cli.ts b/lexicons/temporal/src/validate-cli.ts new file mode 100644 index 00000000..6f1a105c --- /dev/null +++ b/lexicons/temporal/src/validate-cli.ts @@ -0,0 +1,7 @@ +#!/usr/bin/env tsx +import { validate } from "./validate"; + +const result = await validate({ verbose: true }); +if (result.failed > 0) { + process.exit(1); +} diff --git a/lexicons/temporal/src/validate.test.ts b/lexicons/temporal/src/validate.test.ts new file mode 100644 index 00000000..ccd9f809 --- /dev/null +++ b/lexicons/temporal/src/validate.test.ts @@ -0,0 +1,9 @@ +import { test, expect } from "vitest"; +import { validate } from "./validate"; + +test("validate passes — all 4 resources have correct entityType strings", async () => { + const result = await validate({ verbose: false }); + expect(result.valid ?? result.failed === 0).toBe(true); + expect(result.failed).toBe(0); + expect(result.errors).toHaveLength(0); +}); diff --git a/lexicons/temporal/src/validate.ts b/lexicons/temporal/src/validate.ts new file mode 100644 index 00000000..50c0f990 --- /dev/null +++ b/lexicons/temporal/src/validate.ts @@ -0,0 +1,92 @@ +/** + * Validate the Temporal lexicon dist/ artifacts. + * + * Since all resources are hand-written, validation checks that + * the packaging step produced correct dist/ artifacts: manifest.json, + * meta.json (with the 4 expected resource types), types/index.d.ts, + * and integrity.json. + */ + +import { existsSync, readFileSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +export interface ValidateResult { + passed: number; + failed: number; + errors: string[]; +} + +const EXPECTED_RESOURCE_TYPES = [ + "TemporalServer", + "TemporalNamespace", + "SearchAttribute", + "TemporalSchedule", +] as const; + +export async function validate(opts?: { verbose?: boolean; basePath?: string }): Promise { + const pkgDir = opts?.basePath ?? dirname(dirname(fileURLToPath(import.meta.url))); + const distDir = join(pkgDir, "dist"); + const errors: string[] = []; + + // manifest.json + const manifestPath = join(distDir, "manifest.json"); + if (!existsSync(manifestPath)) { + errors.push("dist/manifest.json not found — run npm run bundle"); + } else { + try { + const m = JSON.parse(readFileSync(manifestPath, "utf-8")) as Record; + if (m["name"] !== "temporal") errors.push(`manifest.json: expected name "temporal", got ${JSON.stringify(m["name"])}`); + if (m["namespace"] !== "Temporal") errors.push(`manifest.json: expected namespace "Temporal", got ${JSON.stringify(m["namespace"])}`); + } catch (err) { + errors.push(`manifest.json: parse error — ${err instanceof Error ? err.message : String(err)}`); + } + } + + // meta.json (lexicon catalog) + const metaPath = join(distDir, "meta.json"); + if (!existsSync(metaPath)) { + errors.push("dist/meta.json not found — run npm run bundle"); + } else { + try { + const catalog = JSON.parse(readFileSync(metaPath, "utf-8")) as Record; + for (const name of EXPECTED_RESOURCE_TYPES) { + if (!(name in catalog)) { + errors.push(`meta.json: missing resource "${name}"`); + } + } + } catch (err) { + errors.push(`meta.json: parse error — ${err instanceof Error ? err.message : String(err)}`); + } + } + + // types/index.d.ts + const dtsPath = join(distDir, "types", "index.d.ts"); + if (!existsSync(dtsPath)) { + errors.push("dist/types/index.d.ts not found — run npm run bundle"); + } + + // integrity.json + const integrityPath = join(distDir, "integrity.json"); + if (!existsSync(integrityPath)) { + errors.push("dist/integrity.json not found — run npm run bundle"); + } + + const result: ValidateResult = { + passed: 4 - errors.length < 0 ? 0 : 4 - errors.length, + failed: errors.length, + errors, + }; + + if (opts?.verbose) { + if (errors.length === 0) { + console.error(`Validation passed: all dist/ artifacts present and valid`); + } else { + for (const err of errors) { + console.error(` ✗ ${err}`); + } + } + } + + return result; +} diff --git a/lexicons/temporal/tsconfig.json b/lexicons/temporal/tsconfig.json new file mode 100644 index 00000000..5cb4fda3 --- /dev/null +++ b/lexicons/temporal/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": [ + "src/**/*" + ] +} diff --git a/package-lock.json b/package-lock.json index d1ee4484..67b104a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,8 @@ "@intentius/chant-lexicon-gitlab": "*", "@intentius/chant-lexicon-helm": "*", "@intentius/chant-lexicon-k8s": "*", - "@intentius/chant-lexicon-slurm": "*" + "@intentius/chant-lexicon-slurm": "*", + "@intentius/chant-lexicon-temporal": "*" }, "devDependencies": { "@types/node": "^20.0.0", @@ -161,7 +162,19 @@ }, "lexicons/slurm": { "name": "@intentius/chant-lexicon-slurm", - "version": "0.1.4", + "version": "0.1.5", + "devDependencies": { + "@intentius/chant": "*", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@intentius/chant": "^0.1.0" + } + }, + "lexicons/temporal": { + "name": "@intentius/chant-lexicon-temporal", + "version": "0.1.5", + "license": "Apache-2.0", "devDependencies": { "@intentius/chant": "*", "typescript": "^5.9.3" @@ -885,6 +898,10 @@ "resolved": "lexicons/slurm", "link": true }, + "node_modules/@intentius/chant-lexicon-temporal": { + "resolved": "lexicons/temporal", + "link": true + }, "node_modules/@intentius/chant-test-utils": { "resolved": "packages/test-utils", "link": true @@ -3319,7 +3336,7 @@ }, "packages/core": { "name": "@intentius/chant", - "version": "0.1.4", + "version": "0.1.5", "license": "Apache-2.0", "dependencies": { "fflate": "^0.8.2", diff --git a/package.json b/package.json index ecfa9e10..636e405f 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "@intentius/chant-lexicon-gitlab": "*", "@intentius/chant-lexicon-helm": "*", "@intentius/chant-lexicon-k8s": "*", - "@intentius/chant-lexicon-slurm": "*" + "@intentius/chant-lexicon-slurm": "*", + "@intentius/chant-lexicon-temporal": "*" }, "devDependencies": { "@types/node": "^20.0.0",