create-svc 0.1.68 → 0.1.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-svc",
3
- "version": "0.1.68",
3
+ "version": "0.1.70",
4
4
  "description": "Local microservice bootstrap CLI for Cloud Run and Workers services with Neon-backed data.",
5
5
  "module": "index.ts",
6
6
  "type": "module",
@@ -79,18 +79,31 @@ export async function prepareGcpProject() {
79
79
 
80
80
  function publishTemporalSecrets() {
81
81
  const temporal = resolveTemporalRuntimeConfig();
82
- if (!temporal.apiKey || !temporal.apiKeySecretName) {
83
- return "No Temporal API key configured";
82
+ const secrets = [
83
+ { name: temporal.apiKeySecretName, value: temporal.apiKey },
84
+ { name: temporal.tlsCaCertSecretName, value: temporal.tlsCaCert },
85
+ { name: temporal.tlsCertSecretName, value: temporal.tlsCert },
86
+ { name: temporal.tlsKeySecretName, value: temporal.tlsKey },
87
+ ].filter((secret) => secret.name && secret.value);
88
+
89
+ if (secrets.length === 0) {
90
+ return "No Temporal credentials configured";
84
91
  }
85
92
 
86
- addSecretVersion(temporal.apiKeySecretName, temporal.apiKey);
87
- ensureSecretAccessor(temporal.apiKeySecretName, `serviceAccount:${config.runtimeServiceAccount}`);
88
- return temporal.apiKeySecretName;
93
+ for (const secret of secrets) {
94
+ addSecretVersion(secret.name, secret.value);
95
+ ensureSecretAccessor(secret.name, `serviceAccount:${config.runtimeServiceAccount}`);
96
+ }
97
+ return secrets.map((secret) => secret.name).join(", ");
89
98
  }
90
99
 
91
100
  function shouldPublishTemporalSecrets() {
92
101
  const temporal = resolveTemporalRuntimeConfig();
93
- return Boolean(temporal.enabled && temporal.apiKey && temporal.apiKeySecretName);
102
+ return Boolean(
103
+ temporal.enabled &&
104
+ ((temporal.apiKey && temporal.apiKeySecretName) ||
105
+ (temporal.tlsCaCert && temporal.tlsCaCertSecretName && temporal.tlsCert && temporal.tlsCertSecretName && temporal.tlsKey && temporal.tlsKeySecretName))
106
+ );
94
107
  }
95
108
 
96
109
  if (import.meta.main) {
@@ -41,6 +41,9 @@ function matchesSecretResource(name: string) {
41
41
  return (
42
42
  name === `${config.serviceName}-database-url` ||
43
43
  name === config.temporal.apiKeySecretName ||
44
+ name === config.temporal.tlsCaCertSecretName ||
45
+ name === config.temporal.tlsCertSecretName ||
46
+ name === config.temporal.tlsKeySecretName ||
44
47
  name.startsWith(`${config.serviceName}-pr-`) ||
45
48
  name.startsWith(`${config.serviceName}-dev-`)
46
49
  );
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { mkdir, readdir } from "node:fs/promises";
3
+ import { mkdir, readdir, stat } from "node:fs/promises";
4
4
  import { ensureAuthClient, ensureAuthResourceServer, runAuthCommand, runAuthDoctor } from "../authctl";
5
5
  import { stopLocalDev } from "../local-dev";
6
6
  import { bootstrap, prepareGcpProject } from "./bootstrap";
@@ -8,6 +8,7 @@ import { cleanup } from "./cleanup";
8
8
  import { deploy } from "./deploy";
9
9
  import { observabilityBootstrap } from "./observability";
10
10
  import { config } from "./config";
11
+ import { formatSdkModeDetail, type SdkState } from "./sdk-state";
11
12
  import {
12
13
  accessSecretVersion,
13
14
  assertProductionDomainAvailable,
@@ -259,7 +260,7 @@ async function runDoctor() {
259
260
  return "gcx available";
260
261
  });
261
262
  await record(results, "dashboard artifacts", "warn", async () => {
262
- if (!(await Bun.file("./grafana").exists()) && !(await Bun.file("./dashboards").exists())) {
263
+ if (!(await directoryExists("./grafana")) && !(await directoryExists("./dashboards"))) {
263
264
  throw new Error("no grafana/ or dashboards/ directory found");
264
265
  }
265
266
  return "dashboard directory found";
@@ -295,11 +296,8 @@ async function runDoctor() {
295
296
  });
296
297
  await record(results, "SDK mode", "warn", async () => {
297
298
  const text = await Bun.file(".service/sdk.json").text();
298
- const state = JSON.parse(text) as { mode?: string; module?: string };
299
- if (state.mode !== "local" && state.mode !== "remote") {
300
- throw new Error("SDK mode must be local or remote");
301
- }
302
- return `${state.mode}: ${state.module || bufModule()}`;
299
+ const state = JSON.parse(text) as SdkState;
300
+ return formatSdkModeDetail(state, bufModule());
303
301
  });
304
302
  }
305
303
 
@@ -350,8 +348,9 @@ async function runSdk(args: string[]) {
350
348
  if (subcommand === "publish") {
351
349
  requireCommand("buf");
352
350
  run("buf", ["push"]);
353
- await writeSdkMode("remote");
354
- return "Schema pushed to Buf Schema Registry and recorded for consumers";
351
+ const published = resolvePublishedSdk();
352
+ await writeSdkMode("remote", published);
353
+ return `Schema pushed to Buf Schema Registry and recorded for consumers: ${published.commit}`;
355
354
  }
356
355
 
357
356
  if (subcommand === "build") {
@@ -371,8 +370,10 @@ async function runSdk(args: string[]) {
371
370
  }
372
371
 
373
372
  if (subcommand === "use-remote") {
374
- await writeSdkMode("remote");
375
- return `Remote Buf SDK recorded for consumers: ${bufModule()}`;
373
+ requireCommand("buf");
374
+ const published = resolvePublishedSdk();
375
+ await writeSdkMode("remote", published);
376
+ return `Remote Buf SDK recorded for consumers: ${bufModule()}@${published.commit}`;
376
377
  }
377
378
 
378
379
  throw new Error("Usage: service sdk <build|publish|use-local|use-remote>");
@@ -385,7 +386,40 @@ async function assertLocalSdkArtifacts() {
385
386
  }
386
387
  }
387
388
 
388
- async function writeSdkMode(mode: "local" | "remote") {
389
+ type PublishedSdk = {
390
+ commit: string;
391
+ digest?: string;
392
+ createTime?: string;
393
+ };
394
+
395
+ function resolvePublishedSdk(): PublishedSdk {
396
+ const module = bufModule();
397
+ const result = run("buf", ["registry", "module", "commit", "list", module, "--format", "json", "--page-size", "1"]);
398
+ const parsed = JSON.parse(result.stdout) as {
399
+ commits?: Array<Record<string, unknown>>;
400
+ commit?: Record<string, unknown>;
401
+ };
402
+ const commit = parsed.commits?.[0] ?? parsed.commit;
403
+ if (!commit) {
404
+ throw new Error(`Could not resolve the published Buf commit for ${module}`);
405
+ }
406
+ const name = stringField(commit, "name") ?? stringField(commit, "commit") ?? stringField(commit, "id");
407
+ if (!name) {
408
+ throw new Error(`Buf commit response for ${module} did not include a commit identifier`);
409
+ }
410
+ return {
411
+ commit: name.includes(":") ? name.slice(name.lastIndexOf(":") + 1) : name,
412
+ digest: stringField(commit, "digest"),
413
+ createTime: stringField(commit, "create_time") ?? stringField(commit, "createTime"),
414
+ };
415
+ }
416
+
417
+ function stringField(source: Record<string, unknown>, key: string) {
418
+ const value = source[key];
419
+ return typeof value === "string" && value.length > 0 ? value : undefined;
420
+ }
421
+
422
+ async function writeSdkMode(mode: "local" | "remote", published?: PublishedSdk) {
389
423
  await mkdir(".service", { recursive: true });
390
424
  const localPath = await resolveLocalSdkPath();
391
425
  await Bun.write(
@@ -395,6 +429,15 @@ async function writeSdkMode(mode: "local" | "remote") {
395
429
  mode,
396
430
  module: bufModule(),
397
431
  localPath,
432
+ ...(published
433
+ ? {
434
+ remote: {
435
+ commit: published.commit,
436
+ digest: published.digest,
437
+ createTime: published.createTime,
438
+ },
439
+ }
440
+ : {}),
398
441
  updatedAt: new Date().toISOString(),
399
442
  },
400
443
  null,
@@ -441,6 +484,14 @@ async function findFiles(root: string, suffix = ""): Promise<string[]> {
441
484
  return files;
442
485
  }
443
486
 
487
+ async function directoryExists(path: string) {
488
+ try {
489
+ return (await stat(path)).isDirectory();
490
+ } catch {
491
+ return false;
492
+ }
493
+ }
494
+
444
495
  if (import.meta.main) {
445
496
  await main();
446
497
  }
@@ -40,6 +40,9 @@ export const config = {
40
40
  namespace: serviceConfig.temporal.namespace,
41
41
  taskQueue: serviceConfig.temporal.task_queue,
42
42
  apiKeySecretName: serviceConfig.temporal.api_key_secret_name,
43
+ tlsCaCertSecretName: serviceConfig.temporal.tls_ca_cert_secret_name,
44
+ tlsCertSecretName: serviceConfig.temporal.tls_cert_secret_name,
45
+ tlsKeySecretName: serviceConfig.temporal.tls_key_secret_name,
43
46
  vaultMount: vault.mount || "secret",
44
47
  vaultPath: vault.temporal_path || "prod/providers/temporal",
45
48
  },
@@ -537,6 +537,7 @@ export async function renderManifest(image: string, target: DeploymentTarget, pr
537
537
  " key: latest",
538
538
  ].join("\n")
539
539
  : "",
540
+ TEMPORAL_MTLS_ENV: renderTemporalMtlsEnv(temporal),
540
541
  AUTH_ISSUER: config.auth.issuer,
541
542
  AUTH_AUDIENCE: config.auth.audience,
542
543
  AUTH_JWKS_URL: config.auth.jwksUrl,
@@ -560,9 +561,34 @@ function readTemporalProviderFields(mount: string, path: string) {
560
561
  address: readVaultField(mount, path, ["TEMPORAL_ADDRESS", "address"]),
561
562
  namespace: readVaultField(mount, path, ["TEMPORAL_NAMESPACE", "namespace"]),
562
563
  apiKey: readVaultField(mount, path, ["TEMPORAL_API_KEY", "api_key"]),
564
+ tlsCaCert: readVaultField(mount, path, ["TEMPORAL_TLS_CA_CERT", "tls_ca_cert", "ca_cert"]),
565
+ tlsCert: readVaultField(mount, path, ["TEMPORAL_TLS_CERT", "tls_cert", "client_cert"]),
566
+ tlsKey: readVaultField(mount, path, ["TEMPORAL_TLS_KEY", "tls_key", "client_key"]),
563
567
  };
564
568
  }
565
569
 
570
+ function renderTemporalMtlsEnv(temporal: ReturnType<typeof resolveTemporalRuntimeConfig>) {
571
+ const entries = [
572
+ ["TEMPORAL_TLS_CA_CERT", temporal.tlsCaCertSecretName],
573
+ ["TEMPORAL_TLS_CERT", temporal.tlsCertSecretName],
574
+ ["TEMPORAL_TLS_KEY", temporal.tlsKeySecretName],
575
+ ].filter((entry): entry is [string, string] => Boolean(entry[1]));
576
+
577
+ if (entries.length === 0) {
578
+ return "";
579
+ }
580
+
581
+ return entries
582
+ .flatMap(([envName, secretName]) => [
583
+ ` - name: ${envName}`,
584
+ " valueFrom:",
585
+ " secretKeyRef:",
586
+ ` name: ${secretName}`,
587
+ " key: latest",
588
+ ])
589
+ .join("\n");
590
+ }
591
+
566
592
  function readVaultField(mount: string, path: string, fields: string[]) {
567
593
  const vault = Bun.which("vault");
568
594
  if (!vault || !path) {
@@ -0,0 +1,21 @@
1
+ export type SdkState = {
2
+ mode?: string;
3
+ module?: string;
4
+ remote?: {
5
+ commit?: string;
6
+ digest?: string;
7
+ };
8
+ };
9
+
10
+ export function formatSdkModeDetail(state: SdkState, fallbackModule: string) {
11
+ if (state.mode !== "local" && state.mode !== "remote") {
12
+ throw new Error("SDK mode must be local or remote");
13
+ }
14
+ const module = state.module || fallbackModule;
15
+ if (state.mode === "remote") {
16
+ const version = state.remote?.commit ? `@${state.remote.commit}` : "without recorded commit";
17
+ const digest = state.remote?.digest ? ` (${state.remote.digest})` : "";
18
+ return `${state.mode}: ${module}${version}${digest}`;
19
+ }
20
+ return `${state.mode}: ${module}`;
21
+ }
@@ -3,6 +3,7 @@ import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { scaffoldProject, type ScaffoldConfig } from "../../scaffold";
6
+ import { formatSdkModeDetail } from "./sdk-state";
6
7
 
7
8
  function baseConfig(directory: string): ScaffoldConfig {
8
9
  return {
@@ -42,7 +43,15 @@ test("service sdk publish pushes the named Buf module and selects remote SDK mod
42
43
  await mkdir(fakeBin);
43
44
  await writeFile(
44
45
  join(fakeBin, "buf"),
45
- ["#!/bin/sh", `echo "$@" > "${bufLog}"`, "exit 0", ""].join("\n")
46
+ [
47
+ "#!/bin/sh",
48
+ `echo "$@" >> "${bufLog}"`,
49
+ 'if [ "$1 $2 $3 $4" = "registry module commit list" ]; then',
50
+ ' printf \'{"commits":[{"name":"buf.build/anmho/sdk-proof:commit-123","digest":"b5:abc123","create_time":"2026-05-25T12:00:00Z"}]}\'',
51
+ "fi",
52
+ "exit 0",
53
+ "",
54
+ ].join("\n")
46
55
  );
47
56
  await chmod(join(fakeBin, "buf"), 0o755);
48
57
 
@@ -55,17 +64,79 @@ test("service sdk publish pushes the named Buf module and selects remote SDK mod
55
64
 
56
65
  expect(result.success, [result.stdout.toString(), result.stderr.toString()].join("\n")).toBeTrue();
57
66
  expect(result.stdout.toString()).toContain("recorded for consumers");
58
- expect((await readFile(bufLog, "utf8")).trim()).toBe("push");
67
+ expect((await readFile(bufLog, "utf8")).trim()).toBe(
68
+ ["push", "registry module commit list buf.build/anmho/sdk-proof --format json --page-size 1"].join("\n")
69
+ );
59
70
  const sdkState = JSON.parse(await Bun.file(join(generatedRoot, ".service", "sdk.json")).text());
60
71
  expect(sdkState).toMatchObject({
61
72
  mode: "remote",
62
73
  module: "buf.build/anmho/sdk-proof",
63
74
  localPath: "./gen/waitlist/v1",
75
+ remote: {
76
+ commit: "commit-123",
77
+ digest: "b5:abc123",
78
+ createTime: "2026-05-25T12:00:00Z",
79
+ },
64
80
  });
65
81
  const bufConfig = await Bun.file(join(generatedRoot, "buf.yaml")).text();
66
82
  expect(bufConfig).toContain("name: buf.build/anmho/sdk-proof");
67
83
  });
68
84
 
85
+ test("formatSdkModeDetail reports the recorded remote SDK commit", () => {
86
+ expect(
87
+ formatSdkModeDetail(
88
+ {
89
+ mode: "remote",
90
+ module: "buf.build/anmho/sdk-proof",
91
+ remote: {
92
+ commit: "commit-123",
93
+ digest: "b5:abc123",
94
+ },
95
+ },
96
+ "buf.build/anmho/fallback"
97
+ )
98
+ ).toBe("remote: buf.build/anmho/sdk-proof@commit-123 (b5:abc123)");
99
+ });
100
+
101
+ test("service sdk use-remote records the current Buf commit", async () => {
102
+ const root = await mkdtemp(join(tmpdir(), "create-svc-sdk-"));
103
+ const generatedRoot = join(root, "sdk-proof");
104
+ const fakeBin = join(root, "bin");
105
+
106
+ await scaffoldProject(baseConfig(generatedRoot));
107
+ await mkdir(join(generatedRoot, "node_modules"));
108
+ await mkdir(fakeBin);
109
+ await writeFile(
110
+ join(fakeBin, "buf"),
111
+ [
112
+ "#!/bin/sh",
113
+ 'if [ "$1 $2 $3 $4" = "registry module commit list" ]; then',
114
+ ' printf \'{"commits":[{"name":"buf.build/anmho/sdk-proof:commit-456","digest":"b5:def456"}]}\'',
115
+ "fi",
116
+ "exit 0",
117
+ "",
118
+ ].join("\n")
119
+ );
120
+ await chmod(join(fakeBin, "buf"), 0o755);
121
+
122
+ const result = Bun.spawnSync(["bun", join(import.meta.dir, "..", "..", "..", "index.ts"), "sdk", "use-remote"], {
123
+ cwd: generatedRoot,
124
+ env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH ?? ""}` },
125
+ stdout: "pipe",
126
+ stderr: "pipe",
127
+ });
128
+
129
+ expect(result.success, [result.stdout.toString(), result.stderr.toString()].join("\n")).toBeTrue();
130
+ const sdkState = JSON.parse(await Bun.file(join(generatedRoot, ".service", "sdk.json")).text());
131
+ expect(sdkState).toMatchObject({
132
+ mode: "remote",
133
+ remote: {
134
+ commit: "commit-456",
135
+ digest: "b5:def456",
136
+ },
137
+ });
138
+ });
139
+
69
140
  test("service sdk publish leaves local SDK mode when Buf push fails", async () => {
70
141
  const root = await mkdtemp(join(tmpdir(), "create-svc-sdk-"));
71
142
  const generatedRoot = join(root, "sdk-proof");
@@ -7,6 +7,9 @@ const baseConfig = {
7
7
  namespace: "default",
8
8
  taskQueue: "orders",
9
9
  apiKeySecretName: "orders-temporal-api-key",
10
+ tlsCaCertSecretName: "orders-temporal-ca-cert",
11
+ tlsCertSecretName: "orders-temporal-client-cert",
12
+ tlsKeySecretName: "orders-temporal-client-key",
10
13
  vaultMount: "secret",
11
14
  vaultPath: "prod/providers/temporal",
12
15
  };
@@ -25,6 +28,37 @@ test("resolveTemporalRuntimeConfigValues reads production Temporal config from V
25
28
  taskQueue: "orders",
26
29
  apiKeySecretName: "orders-temporal-api-key",
27
30
  apiKey: "secret-key",
31
+ tlsCaCertSecretName: "",
32
+ tlsCertSecretName: "",
33
+ tlsKeySecretName: "",
34
+ tlsCaCert: "",
35
+ tlsCert: "",
36
+ tlsKey: "",
37
+ });
38
+ });
39
+
40
+ test("resolveTemporalRuntimeConfigValues reads self-hosted mTLS config from Vault fields", () => {
41
+ const resolved = resolveTemporalRuntimeConfigValues(baseConfig, {}, () => ({
42
+ address: "temporal-grpc.anmho.com:7233",
43
+ namespace: "default",
44
+ tlsCaCert: "ca-pem",
45
+ tlsCert: "cert-pem",
46
+ tlsKey: "key-pem",
47
+ }));
48
+
49
+ expect(resolved).toMatchObject({
50
+ enabled: true,
51
+ address: "temporal-grpc.anmho.com:7233",
52
+ namespace: "default",
53
+ taskQueue: "orders",
54
+ apiKey: "",
55
+ apiKeySecretName: "",
56
+ tlsCaCertSecretName: "orders-temporal-ca-cert",
57
+ tlsCertSecretName: "orders-temporal-client-cert",
58
+ tlsKeySecretName: "orders-temporal-client-key",
59
+ tlsCaCert: "ca-pem",
60
+ tlsCert: "cert-pem",
61
+ tlsKey: "key-pem",
28
62
  });
29
63
  });
30
64
 
@@ -52,6 +86,15 @@ test("resolveTemporalRuntimeConfigValues prefers explicit environment overrides"
52
86
  expect(resolved.apiKeySecretName).toBe("env-secret-name");
53
87
  });
54
88
 
89
+ test("resolveTemporalRuntimeConfigValues rejects partial mTLS config", () => {
90
+ expect(() =>
91
+ resolveTemporalRuntimeConfigValues({ ...baseConfig, address: "temporal-grpc.anmho.com:7233" }, {}, () => ({
92
+ namespace: "default",
93
+ tlsCaCert: "ca-pem",
94
+ }))
95
+ ).toThrow("Temporal mTLS is partially configured");
96
+ });
97
+
55
98
  test("resolveTemporalRuntimeConfigValues fails clearly when enabled Temporal resolves to localhost", () => {
56
99
  expect(() => resolveTemporalRuntimeConfigValues(baseConfig, {}, () => ({}))).toThrow(
57
100
  "Temporal is enabled for this Cloud Run service, but the resolved Temporal address is local"
@@ -63,4 +106,5 @@ test("resolveTemporalRuntimeConfigValues allows explicit Temporal disable", () =
63
106
 
64
107
  expect(resolved.enabled).toBeFalse();
65
108
  expect(resolved.apiKeySecretName).toBe("");
109
+ expect(resolved.tlsCaCertSecretName).toBe("");
66
110
  });
@@ -4,6 +4,9 @@ type TemporalConfigInput = {
4
4
  namespace: string;
5
5
  taskQueue: string;
6
6
  apiKeySecretName: string;
7
+ tlsCaCertSecretName?: string;
8
+ tlsCertSecretName?: string;
9
+ tlsKeySecretName?: string;
7
10
  vaultMount: string;
8
11
  vaultPath: string;
9
12
  };
@@ -12,6 +15,9 @@ type TemporalProviderFields = {
12
15
  address?: string;
13
16
  namespace?: string;
14
17
  apiKey?: string;
18
+ tlsCaCert?: string;
19
+ tlsCert?: string;
20
+ tlsKey?: string;
15
21
  };
16
22
 
17
23
  export type TemporalRuntimeConfig = {
@@ -21,6 +27,12 @@ export type TemporalRuntimeConfig = {
21
27
  taskQueue: string;
22
28
  apiKeySecretName: string;
23
29
  apiKey: string;
30
+ tlsCaCertSecretName: string;
31
+ tlsCertSecretName: string;
32
+ tlsKeySecretName: string;
33
+ tlsCaCert: string;
34
+ tlsCert: string;
35
+ tlsKey: string;
24
36
  };
25
37
 
26
38
  export function resolveTemporalRuntimeConfigValues(
@@ -40,6 +52,12 @@ export function resolveTemporalRuntimeConfigValues(
40
52
  taskQueue,
41
53
  apiKeySecretName: "",
42
54
  apiKey: "",
55
+ tlsCaCertSecretName: "",
56
+ tlsCertSecretName: "",
57
+ tlsKeySecretName: "",
58
+ tlsCaCert: "",
59
+ tlsCert: "",
60
+ tlsKey: "",
43
61
  };
44
62
  }
45
63
 
@@ -48,13 +66,22 @@ export function resolveTemporalRuntimeConfigValues(
48
66
  const namespace = env.TEMPORAL_NAMESPACE?.trim() || provider.namespace || config.namespace;
49
67
  const apiKey = env.TEMPORAL_API_KEY?.trim() || provider.apiKey || "";
50
68
  const apiKeySecretName = env.TEMPORAL_API_KEY_SECRET?.trim() || (apiKey ? config.apiKeySecretName : "");
69
+ const tlsCaCert = env.TEMPORAL_TLS_CA_CERT?.trim() || provider.tlsCaCert || "";
70
+ const tlsCert = env.TEMPORAL_TLS_CERT?.trim() || provider.tlsCert || "";
71
+ const tlsKey = env.TEMPORAL_TLS_KEY?.trim() || provider.tlsKey || "";
72
+ const tlsCaCertSecretName =
73
+ env.TEMPORAL_TLS_CA_CERT_SECRET?.trim() || (tlsCaCert ? config.tlsCaCertSecretName || `${config.taskQueue}-temporal-ca-cert` : "");
74
+ const tlsCertSecretName =
75
+ env.TEMPORAL_TLS_CERT_SECRET?.trim() || (tlsCert ? config.tlsCertSecretName || `${config.taskQueue}-temporal-client-cert` : "");
76
+ const tlsKeySecretName =
77
+ env.TEMPORAL_TLS_KEY_SECRET?.trim() || (tlsKey ? config.tlsKeySecretName || `${config.taskQueue}-temporal-client-key` : "");
51
78
 
52
79
  if (isLocalTemporalAddress(address)) {
53
80
  throw new Error(
54
81
  [
55
82
  "Temporal is enabled for this Cloud Run service, but the resolved Temporal address is local.",
56
- `Set TEMPORAL_ADDRESS, TEMPORAL_NAMESPACE, and TEMPORAL_API_KEY, or populate Vault at ${config.vaultMount}/${config.vaultPath}`,
57
- "with TEMPORAL_ADDRESS, TEMPORAL_NAMESPACE, and TEMPORAL_API_KEY before running service create or service deploy.",
83
+ `Set TEMPORAL_ADDRESS, TEMPORAL_NAMESPACE, and TEMPORAL_API_KEY or TEMPORAL_TLS_* credentials, or populate Vault at ${config.vaultMount}/${config.vaultPath}`,
84
+ "with TEMPORAL_ADDRESS, TEMPORAL_NAMESPACE, and either TEMPORAL_API_KEY or TEMPORAL_TLS_CA_CERT/TEMPORAL_TLS_CERT/TEMPORAL_TLS_KEY before running service create or service deploy.",
58
85
  "Set TEMPORAL_ENABLED=false only for services that should deploy without Temporal.",
59
86
  ].join(" ")
60
87
  );
@@ -63,6 +90,16 @@ export function resolveTemporalRuntimeConfigValues(
63
90
  if (!namespace) {
64
91
  throw new Error(`Temporal is enabled but TEMPORAL_NAMESPACE is missing; set it in env or Vault at ${config.vaultMount}/${config.vaultPath}`);
65
92
  }
93
+ if (!apiKey && (Boolean(tlsCaCert) || Boolean(tlsCert) || Boolean(tlsKey)) && (!tlsCaCert || !tlsCert || !tlsKey)) {
94
+ throw new Error(
95
+ `Temporal mTLS is partially configured; set TEMPORAL_TLS_CA_CERT, TEMPORAL_TLS_CERT, and TEMPORAL_TLS_KEY together in env or Vault at ${config.vaultMount}/${config.vaultPath}`
96
+ );
97
+ }
98
+ if (!apiKey && !tlsCaCert) {
99
+ throw new Error(
100
+ `Temporal is enabled but no credentials were found; set TEMPORAL_API_KEY or TEMPORAL_TLS_CA_CERT/TEMPORAL_TLS_CERT/TEMPORAL_TLS_KEY in env or Vault at ${config.vaultMount}/${config.vaultPath}`
101
+ );
102
+ }
66
103
 
67
104
  return {
68
105
  enabled,
@@ -71,6 +108,12 @@ export function resolveTemporalRuntimeConfigValues(
71
108
  taskQueue,
72
109
  apiKeySecretName,
73
110
  apiKey,
111
+ tlsCaCertSecretName,
112
+ tlsCertSecretName,
113
+ tlsKeySecretName,
114
+ tlsCaCert,
115
+ tlsCert,
116
+ tlsKey,
74
117
  };
75
118
  }
76
119
 
@@ -57,7 +57,10 @@
57
57
  "address": "localhost:7233",
58
58
  "namespace": "default",
59
59
  "task_queue": "{{SERVICE_ID}}",
60
- "api_key_secret_name": "{{SERVICE_ID}}-temporal-api-key"
60
+ "api_key_secret_name": "{{SERVICE_ID}}-temporal-api-key",
61
+ "tls_ca_cert_secret_name": "{{SERVICE_ID}}-temporal-ca-cert",
62
+ "tls_cert_secret_name": "{{SERVICE_ID}}-temporal-client-cert",
63
+ "tls_key_secret_name": "{{SERVICE_ID}}-temporal-client-key"
61
64
  },
62
65
 
63
66
  "providers": {
@@ -36,6 +36,7 @@ ${CONTAINER_COMMAND}
36
36
  - name: TEMPORAL_TASK_QUEUE
37
37
  value: "${TEMPORAL_TASK_QUEUE}"
38
38
  ${TEMPORAL_API_KEY_ENV}
39
+ ${TEMPORAL_MTLS_ENV}
39
40
  - name: DATABASE_URL
40
41
  valueFrom:
41
42
  secretKeyRef:
@@ -36,6 +36,9 @@ func main() {
36
36
  Namespace: cfg.TemporalNamespace,
37
37
  TaskQueue: cfg.TemporalTaskQueue,
38
38
  APIKey: cfg.TemporalAPIKey,
39
+ TLSCACert: cfg.TemporalTLSCACert,
40
+ TLSCert: cfg.TemporalTLSCert,
41
+ TLSKey: cfg.TemporalTLSKey,
39
42
  }
40
43
  dispatcher, err := temporalapp.NewTriggerDispatcher(temporalConfig)
41
44
  if err != nil {
@@ -23,6 +23,9 @@ func main() {
23
23
  Namespace: cfg.TemporalNamespace,
24
24
  TaskQueue: cfg.TemporalTaskQueue,
25
25
  APIKey: cfg.TemporalAPIKey,
26
+ TLSCACert: cfg.TemporalTLSCACert,
27
+ TLSCert: cfg.TemporalTLSCert,
28
+ TLSKey: cfg.TemporalTLSKey,
26
29
  })
27
30
  if err != nil {
28
31
  log.Fatal(err)
@@ -14,6 +14,9 @@ type Config struct {
14
14
  TemporalNamespace string
15
15
  TemporalTaskQueue string
16
16
  TemporalAPIKey string
17
+ TemporalTLSCACert string
18
+ TemporalTLSCert string
19
+ TemporalTLSKey string
17
20
  AuthEnabled bool
18
21
  AuthIssuer string
19
22
  AuthAudience string
@@ -29,6 +32,9 @@ func Load() (Config, error) {
29
32
  TemporalNamespace: envOrRuntime("TEMPORAL_NAMESPACE", "default"),
30
33
  TemporalTaskQueue: envOr("TEMPORAL_TASK_QUEUE", "{{SERVICE_NAME}}"),
31
34
  TemporalAPIKey: strings.TrimSpace(os.Getenv("TEMPORAL_API_KEY")),
35
+ TemporalTLSCACert: strings.TrimSpace(os.Getenv("TEMPORAL_TLS_CA_CERT")),
36
+ TemporalTLSCert: strings.TrimSpace(os.Getenv("TEMPORAL_TLS_CERT")),
37
+ TemporalTLSKey: strings.TrimSpace(os.Getenv("TEMPORAL_TLS_KEY")),
32
38
  AuthEnabled: envBool("AUTH_ENABLED"),
33
39
  AuthIssuer: envOr("AUTH_ISSUER", "{{AUTH_ISSUER}}"),
34
40
  AuthAudience: envOr("AUTH_AUDIENCE", "{{AUTH_AUDIENCE}}"),
@@ -14,12 +14,9 @@ type TriggerDispatcher struct {
14
14
  }
15
15
 
16
16
  func NewTriggerDispatcher(cfg WorkerConfig) (*TriggerDispatcher, error) {
17
- options := client.Options{
18
- HostPort: cfg.Address,
19
- Namespace: cfg.Namespace,
20
- }
21
- if cfg.APIKey != "" {
22
- options.Credentials = client.NewAPIKeyStaticCredentials(cfg.APIKey)
17
+ options, err := temporalClientOptions(cfg)
18
+ if err != nil {
19
+ return nil, err
23
20
  }
24
21
 
25
22
  temporalClient, err := client.Dial(options)
@@ -1,6 +1,10 @@
1
1
  package temporalapp
2
2
 
3
3
  import (
4
+ "crypto/tls"
5
+ "crypto/x509"
6
+ "fmt"
7
+ "net"
4
8
  "go.temporal.io/sdk/client"
5
9
  "go.temporal.io/sdk/worker"
6
10
  )
@@ -10,15 +14,15 @@ type WorkerConfig struct {
10
14
  Namespace string
11
15
  TaskQueue string
12
16
  APIKey string
17
+ TLSCACert string
18
+ TLSCert string
19
+ TLSKey string
13
20
  }
14
21
 
15
22
  func StartWorker(cfg WorkerConfig) (func(), error) {
16
- options := client.Options{
17
- HostPort: cfg.Address,
18
- Namespace: cfg.Namespace,
19
- }
20
- if cfg.APIKey != "" {
21
- options.Credentials = client.NewAPIKeyStaticCredentials(cfg.APIKey)
23
+ options, err := temporalClientOptions(cfg)
24
+ if err != nil {
25
+ return nil, err
22
26
  }
23
27
 
24
28
  temporalClient, err := client.Dial(options)
@@ -40,3 +44,45 @@ func StartWorker(cfg WorkerConfig) (func(), error) {
40
44
  temporalClient.Close()
41
45
  }, nil
42
46
  }
47
+
48
+ func temporalClientOptions(cfg WorkerConfig) (client.Options, error) {
49
+ options := client.Options{
50
+ HostPort: cfg.Address,
51
+ Namespace: cfg.Namespace,
52
+ }
53
+ if cfg.APIKey != "" {
54
+ options.Credentials = client.NewAPIKeyStaticCredentials(cfg.APIKey)
55
+ }
56
+ if cfg.TLSCACert != "" || cfg.TLSCert != "" || cfg.TLSKey != "" {
57
+ tlsConfig, err := temporalTLSConfig(cfg)
58
+ if err != nil {
59
+ return client.Options{}, err
60
+ }
61
+ options.ConnectionOptions.TLS = tlsConfig
62
+ }
63
+ return options, nil
64
+ }
65
+
66
+ func temporalTLSConfig(cfg WorkerConfig) (*tls.Config, error) {
67
+ if cfg.TLSCACert == "" || cfg.TLSCert == "" || cfg.TLSKey == "" {
68
+ return nil, fmt.Errorf("TEMPORAL_TLS_CA_CERT, TEMPORAL_TLS_CERT, and TEMPORAL_TLS_KEY must be set together")
69
+ }
70
+ certificate, err := tls.X509KeyPair([]byte(cfg.TLSCert), []byte(cfg.TLSKey))
71
+ if err != nil {
72
+ return nil, fmt.Errorf("parse Temporal client certificate: %w", err)
73
+ }
74
+ roots := x509.NewCertPool()
75
+ if !roots.AppendCertsFromPEM([]byte(cfg.TLSCACert)) {
76
+ return nil, fmt.Errorf("parse Temporal CA certificate")
77
+ }
78
+ serverName, _, err := net.SplitHostPort(cfg.Address)
79
+ if err != nil {
80
+ serverName = cfg.Address
81
+ }
82
+ return &tls.Config{
83
+ Certificates: []tls.Certificate{certificate},
84
+ RootCAs: roots,
85
+ ServerName: serverName,
86
+ MinVersion: tls.VersionTLS12,
87
+ }, nil
88
+ }
@@ -40,6 +40,9 @@ func main() {
40
40
  Namespace: cfg.TemporalNamespace,
41
41
  TaskQueue: cfg.TemporalTaskQueue,
42
42
  APIKey: cfg.TemporalAPIKey,
43
+ TLSCACert: cfg.TemporalTLSCACert,
44
+ TLSCert: cfg.TemporalTLSCert,
45
+ TLSKey: cfg.TemporalTLSKey,
43
46
  }
44
47
  dispatcher, err := temporalapp.NewTriggerDispatcher(temporalConfig)
45
48
  if err != nil {
@@ -23,6 +23,9 @@ func main() {
23
23
  Namespace: cfg.TemporalNamespace,
24
24
  TaskQueue: cfg.TemporalTaskQueue,
25
25
  APIKey: cfg.TemporalAPIKey,
26
+ TLSCACert: cfg.TemporalTLSCACert,
27
+ TLSCert: cfg.TemporalTLSCert,
28
+ TLSKey: cfg.TemporalTLSKey,
26
29
  })
27
30
  if err != nil {
28
31
  log.Fatal(err)
@@ -14,6 +14,9 @@ type Config struct {
14
14
  TemporalNamespace string
15
15
  TemporalTaskQueue string
16
16
  TemporalAPIKey string
17
+ TemporalTLSCACert string
18
+ TemporalTLSCert string
19
+ TemporalTLSKey string
17
20
  AuthEnabled bool
18
21
  AuthIssuer string
19
22
  AuthAudience string
@@ -29,6 +32,9 @@ func Load() (Config, error) {
29
32
  TemporalNamespace: envOrRuntime("TEMPORAL_NAMESPACE", "default"),
30
33
  TemporalTaskQueue: envOr("TEMPORAL_TASK_QUEUE", "{{SERVICE_NAME}}"),
31
34
  TemporalAPIKey: strings.TrimSpace(os.Getenv("TEMPORAL_API_KEY")),
35
+ TemporalTLSCACert: strings.TrimSpace(os.Getenv("TEMPORAL_TLS_CA_CERT")),
36
+ TemporalTLSCert: strings.TrimSpace(os.Getenv("TEMPORAL_TLS_CERT")),
37
+ TemporalTLSKey: strings.TrimSpace(os.Getenv("TEMPORAL_TLS_KEY")),
32
38
  AuthEnabled: envBool("AUTH_ENABLED"),
33
39
  AuthIssuer: envOr("AUTH_ISSUER", "{{AUTH_ISSUER}}"),
34
40
  AuthAudience: envOr("AUTH_AUDIENCE", "{{AUTH_AUDIENCE}}"),
@@ -14,12 +14,9 @@ type TriggerDispatcher struct {
14
14
  }
15
15
 
16
16
  func NewTriggerDispatcher(cfg WorkerConfig) (*TriggerDispatcher, error) {
17
- options := client.Options{
18
- HostPort: cfg.Address,
19
- Namespace: cfg.Namespace,
20
- }
21
- if cfg.APIKey != "" {
22
- options.Credentials = client.NewAPIKeyStaticCredentials(cfg.APIKey)
17
+ options, err := temporalClientOptions(cfg)
18
+ if err != nil {
19
+ return nil, err
23
20
  }
24
21
 
25
22
  temporalClient, err := client.Dial(options)
@@ -1,6 +1,10 @@
1
1
  package temporalapp
2
2
 
3
3
  import (
4
+ "crypto/tls"
5
+ "crypto/x509"
6
+ "fmt"
7
+ "net"
4
8
  "go.temporal.io/sdk/client"
5
9
  "go.temporal.io/sdk/worker"
6
10
  )
@@ -10,15 +14,15 @@ type WorkerConfig struct {
10
14
  Namespace string
11
15
  TaskQueue string
12
16
  APIKey string
17
+ TLSCACert string
18
+ TLSCert string
19
+ TLSKey string
13
20
  }
14
21
 
15
22
  func StartWorker(cfg WorkerConfig) (func(), error) {
16
- options := client.Options{
17
- HostPort: cfg.Address,
18
- Namespace: cfg.Namespace,
19
- }
20
- if cfg.APIKey != "" {
21
- options.Credentials = client.NewAPIKeyStaticCredentials(cfg.APIKey)
23
+ options, err := temporalClientOptions(cfg)
24
+ if err != nil {
25
+ return nil, err
22
26
  }
23
27
 
24
28
  temporalClient, err := client.Dial(options)
@@ -40,3 +44,45 @@ func StartWorker(cfg WorkerConfig) (func(), error) {
40
44
  temporalClient.Close()
41
45
  }, nil
42
46
  }
47
+
48
+ func temporalClientOptions(cfg WorkerConfig) (client.Options, error) {
49
+ options := client.Options{
50
+ HostPort: cfg.Address,
51
+ Namespace: cfg.Namespace,
52
+ }
53
+ if cfg.APIKey != "" {
54
+ options.Credentials = client.NewAPIKeyStaticCredentials(cfg.APIKey)
55
+ }
56
+ if cfg.TLSCACert != "" || cfg.TLSCert != "" || cfg.TLSKey != "" {
57
+ tlsConfig, err := temporalTLSConfig(cfg)
58
+ if err != nil {
59
+ return client.Options{}, err
60
+ }
61
+ options.ConnectionOptions.TLS = tlsConfig
62
+ }
63
+ return options, nil
64
+ }
65
+
66
+ func temporalTLSConfig(cfg WorkerConfig) (*tls.Config, error) {
67
+ if cfg.TLSCACert == "" || cfg.TLSCert == "" || cfg.TLSKey == "" {
68
+ return nil, fmt.Errorf("TEMPORAL_TLS_CA_CERT, TEMPORAL_TLS_CERT, and TEMPORAL_TLS_KEY must be set together")
69
+ }
70
+ certificate, err := tls.X509KeyPair([]byte(cfg.TLSCert), []byte(cfg.TLSKey))
71
+ if err != nil {
72
+ return nil, fmt.Errorf("parse Temporal client certificate: %w", err)
73
+ }
74
+ roots := x509.NewCertPool()
75
+ if !roots.AppendCertsFromPEM([]byte(cfg.TLSCACert)) {
76
+ return nil, fmt.Errorf("parse Temporal CA certificate")
77
+ }
78
+ serverName, _, err := net.SplitHostPort(cfg.Address)
79
+ if err != nil {
80
+ serverName = cfg.Address
81
+ }
82
+ return &tls.Config{
83
+ Certificates: []tls.Certificate{certificate},
84
+ RootCAs: roots,
85
+ ServerName: serverName,
86
+ MinVersion: tls.VersionTLS12,
87
+ }, nil
88
+ }