create-svc 0.1.62 → 0.1.64
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/README.md +9 -6
- package/package.json +2 -1
- package/src/cli.test.ts +1 -0
- package/src/cli.ts +17 -6
- package/src/naming.test.ts +6 -0
- package/src/naming.ts +1 -1
- package/src/scaffold.test.ts +19 -3
- package/src/scaffold.ts +10 -0
- package/src/service-runtime/authctl.ts +32 -0
- package/src/service-runtime/cloudrun/bootstrap.ts +3 -4
- package/src/service-runtime/cloudrun/cleanup.ts +6 -1
- package/src/service-runtime/cloudrun/cli.ts +52 -12
- package/src/service-runtime/cloudrun/config.ts +3 -0
- package/src/service-runtime/cloudrun/deploy-args.ts +17 -0
- package/src/service-runtime/cloudrun/deploy.ts +25 -0
- package/src/service-runtime/cloudrun/lib.test.ts +12 -1
- package/src/service-runtime/cloudrun/lib.ts +55 -15
- package/src/service-runtime/cloudrun/temporal-config.test.ts +66 -0
- package/src/service-runtime/cloudrun/temporal-config.ts +84 -0
- package/src/service-runtime/workers/cli.ts +88 -0
- package/src/service.test.ts +15 -2
- package/src/service.ts +31 -1
- package/templates/shared/.env.example +1 -1
- package/templates/shared/README.md +41 -9
- package/templates/shared/scripts/dev.ts +37 -5
- package/templates/shared/service.jsonc +8 -2
- package/templates/shared/service.yaml +4 -1
- package/templates/targets/workers/.github/workflows/deploy.yml +3 -0
- package/templates/targets/workers/.github/workflows/preview.yml +3 -0
- package/templates/targets/workers/README.md +28 -0
- package/templates/targets/workers/package.json +6 -1
- package/templates/targets/workers/src/index.ts +36 -25
- package/templates/targets/workers/src/trigger.ts +81 -0
- package/templates/targets/workers/test/app.test.ts +46 -1
- package/templates/targets/workers/trigger/waitlist-follow-up.ts +24 -0
- package/templates/targets/workers/trigger.config.ts +24 -0
- package/templates/targets/workers/tsconfig.json +1 -1
- package/templates/targets/workers/wrangler.toml +2 -0
- package/templates/variants/bun-connectrpc/package.json +1 -1
- package/templates/variants/bun-connectrpc/src/index.ts +2 -6
- package/templates/variants/bun-connectrpc/src/temporal/client.ts +28 -0
- package/templates/variants/bun-connectrpc/src/temporal.ts +56 -0
- package/templates/variants/bun-connectrpc/src/waitlist/service.ts +16 -1
- package/templates/variants/bun-connectrpc/src/worker.ts +21 -0
- package/templates/variants/bun-connectrpc/test/app.test.ts +22 -0
- package/templates/variants/bun-hono/package.json +1 -1
- package/templates/variants/bun-hono/src/index.ts +2 -6
- package/templates/variants/bun-hono/src/temporal/client.ts +28 -0
- package/templates/variants/bun-hono/src/temporal.ts +56 -0
- package/templates/variants/bun-hono/src/waitlist/service.ts +10 -1
- package/templates/variants/bun-hono/src/worker.ts +21 -0
- package/templates/variants/go-chi/Dockerfile +2 -0
- package/templates/variants/go-chi/Makefile +1 -1
- package/templates/variants/go-chi/cmd/server/main.go +5 -4
- package/templates/variants/go-chi/cmd/worker/main.go +56 -0
- package/templates/variants/go-chi/internal/app/service.go +35 -3
- package/templates/variants/go-chi/internal/config/config.go +34 -3
- package/templates/variants/go-chi/internal/config/config_test.go +63 -0
- package/templates/variants/go-chi/internal/temporal/client.go +55 -0
- package/templates/variants/go-connectrpc/Dockerfile +2 -0
- package/templates/variants/go-connectrpc/Makefile +1 -1
- package/templates/variants/go-connectrpc/cmd/server/main.go +5 -4
- package/templates/variants/go-connectrpc/cmd/worker/main.go +56 -0
- package/templates/variants/go-connectrpc/internal/app/service.go +35 -3
- package/templates/variants/go-connectrpc/internal/config/config.go +34 -3
- package/templates/variants/go-connectrpc/internal/config/config_test.go +63 -0
- package/templates/variants/go-connectrpc/internal/temporal/client.go +55 -0
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { config } from "./config";
|
|
4
4
|
import { serviceRoot } from "../runtime";
|
|
5
5
|
import { localDockerBuildArgs, parseDeployArgs, type DeployArgs } from "./deploy-args";
|
|
6
|
+
import { resolveTemporalRuntimeConfigValues } from "./temporal-config";
|
|
6
7
|
|
|
7
8
|
type CommandOptions = {
|
|
8
9
|
allowFailure?: boolean;
|
|
@@ -36,6 +37,8 @@ type CommandResult = {
|
|
|
36
37
|
exitCode: number;
|
|
37
38
|
};
|
|
38
39
|
|
|
40
|
+
type CloudRunProcess = "api" | "worker";
|
|
41
|
+
|
|
39
42
|
const decoder = new TextDecoder();
|
|
40
43
|
const encoder = new TextEncoder();
|
|
41
44
|
const CLOUDFLARE_DNS_TTL_AUTO = 1;
|
|
@@ -506,12 +509,16 @@ export function resolveDeploymentTarget(environment: DeployArgs["environment"],
|
|
|
506
509
|
};
|
|
507
510
|
}
|
|
508
511
|
|
|
509
|
-
export async function renderManifest(image: string, target: DeploymentTarget) {
|
|
512
|
+
export async function renderManifest(image: string, target: DeploymentTarget, process: CloudRunProcess = "api") {
|
|
510
513
|
const template = await Bun.file(join(serviceRoot, "service.yaml")).text();
|
|
511
514
|
const temporal = resolveTemporalRuntimeConfig();
|
|
515
|
+
const serviceName = process === "worker" ? `${target.serviceName}-worker` : target.serviceName;
|
|
512
516
|
const values = {
|
|
513
|
-
SERVICE_NAME:
|
|
517
|
+
SERVICE_NAME: serviceName,
|
|
514
518
|
SERVICE_ID: config.serviceName,
|
|
519
|
+
SERVICE_ROLE: process,
|
|
520
|
+
SERVICE_INGRESS: process === "worker" ? "internal" : "all",
|
|
521
|
+
CONTAINER_COMMAND: renderContainerCommand(process),
|
|
515
522
|
RUNTIME_SERVICE_ACCOUNT: config.runtimeServiceAccount,
|
|
516
523
|
IMAGE_URL: image,
|
|
517
524
|
DATABASE_URL_SECRET: target.databaseSecretName,
|
|
@@ -545,24 +552,40 @@ export async function renderManifest(image: string, target: DeploymentTarget) {
|
|
|
545
552
|
}
|
|
546
553
|
|
|
547
554
|
export function resolveTemporalRuntimeConfig() {
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
const namespace = process.env.TEMPORAL_NAMESPACE?.trim() || config.temporal.namespace;
|
|
551
|
-
const taskQueue = process.env.TEMPORAL_TASK_QUEUE?.trim() || config.temporal.taskQueue;
|
|
552
|
-
const apiKeySecretName = process.env.TEMPORAL_API_KEY_SECRET?.trim() || (process.env.TEMPORAL_API_KEY?.trim() ? config.temporal.apiKeySecretName : "");
|
|
553
|
-
const enabled = enabledOverride
|
|
554
|
-
? ["1", "true", "yes", "on"].includes(enabledOverride.toLowerCase())
|
|
555
|
-
: Boolean(process.env.TEMPORAL_ADDRESS?.trim() || process.env.TEMPORAL_API_KEY?.trim() || process.env.TEMPORAL_API_KEY_SECRET?.trim());
|
|
555
|
+
return resolveTemporalRuntimeConfigValues(config.temporal, process.env, readTemporalProviderFields);
|
|
556
|
+
}
|
|
556
557
|
|
|
558
|
+
function readTemporalProviderFields(mount: string, path: string) {
|
|
557
559
|
return {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
taskQueue,
|
|
562
|
-
apiKeySecretName,
|
|
560
|
+
address: readVaultField(mount, path, ["TEMPORAL_ADDRESS", "address"]),
|
|
561
|
+
namespace: readVaultField(mount, path, ["TEMPORAL_NAMESPACE", "namespace"]),
|
|
562
|
+
apiKey: readVaultField(mount, path, ["TEMPORAL_API_KEY", "api_key"]),
|
|
563
563
|
};
|
|
564
564
|
}
|
|
565
565
|
|
|
566
|
+
function readVaultField(mount: string, path: string, fields: string[]) {
|
|
567
|
+
const vault = Bun.which("vault");
|
|
568
|
+
if (!vault || !path) {
|
|
569
|
+
return "";
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
for (const field of fields) {
|
|
573
|
+
const result = Bun.spawnSync([vault, "kv", "get", `-mount=${mount}`, `-field=${field}`, path], {
|
|
574
|
+
cwd: process.cwd(),
|
|
575
|
+
env: process.env,
|
|
576
|
+
stdout: "pipe",
|
|
577
|
+
stderr: "pipe",
|
|
578
|
+
});
|
|
579
|
+
if (result.success && result.stdout) {
|
|
580
|
+
const value = decoder.decode(result.stdout).trim();
|
|
581
|
+
if (value) {
|
|
582
|
+
return value;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return "";
|
|
587
|
+
}
|
|
588
|
+
|
|
566
589
|
export async function writeRenderedManifest(image: string, target: DeploymentTarget) {
|
|
567
590
|
const rendered = await renderManifest(image, target);
|
|
568
591
|
const path = new URL("../../.cloudrun.rendered.yaml", import.meta.url);
|
|
@@ -570,6 +593,23 @@ export async function writeRenderedManifest(image: string, target: DeploymentTar
|
|
|
570
593
|
return path;
|
|
571
594
|
}
|
|
572
595
|
|
|
596
|
+
export async function writeRenderedWorkerManifest(image: string, target: DeploymentTarget) {
|
|
597
|
+
const rendered = await renderManifest(image, target, "worker");
|
|
598
|
+
const path = new URL("../../.cloudrun.worker.rendered.yaml", import.meta.url);
|
|
599
|
+
await Bun.write(path, rendered);
|
|
600
|
+
return path;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function renderContainerCommand(process: CloudRunProcess) {
|
|
604
|
+
if (process === "api") {
|
|
605
|
+
return "";
|
|
606
|
+
}
|
|
607
|
+
if (config.runtime === "bun") {
|
|
608
|
+
return [" command:", " - bun", " args:", " - run", " - ./src/worker.ts"].join("\n");
|
|
609
|
+
}
|
|
610
|
+
return [" command:", " - /app/worker"].join("\n");
|
|
611
|
+
}
|
|
612
|
+
|
|
573
613
|
export function serviceUrl(serviceName: string) {
|
|
574
614
|
return gcloud(
|
|
575
615
|
["run", "services", "describe", serviceName, "--project", config.project.id, "--region", config.region, "--format=value(status.url)"]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { resolveTemporalRuntimeConfigValues } from "./temporal-config";
|
|
3
|
+
|
|
4
|
+
const baseConfig = {
|
|
5
|
+
enabled: true,
|
|
6
|
+
address: "localhost:7233",
|
|
7
|
+
namespace: "default",
|
|
8
|
+
taskQueue: "orders",
|
|
9
|
+
apiKeySecretName: "orders-temporal-api-key",
|
|
10
|
+
vaultMount: "secret",
|
|
11
|
+
vaultPath: "prod/providers/temporal",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
test("resolveTemporalRuntimeConfigValues reads production Temporal config from Vault fields", () => {
|
|
15
|
+
const resolved = resolveTemporalRuntimeConfigValues(baseConfig, {}, () => ({
|
|
16
|
+
address: "temporal.example.tmprl.cloud:7233",
|
|
17
|
+
namespace: "anmho.prod",
|
|
18
|
+
apiKey: "secret-key",
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
expect(resolved).toEqual({
|
|
22
|
+
enabled: true,
|
|
23
|
+
address: "temporal.example.tmprl.cloud:7233",
|
|
24
|
+
namespace: "anmho.prod",
|
|
25
|
+
taskQueue: "orders",
|
|
26
|
+
apiKeySecretName: "orders-temporal-api-key",
|
|
27
|
+
apiKey: "secret-key",
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("resolveTemporalRuntimeConfigValues prefers explicit environment overrides", () => {
|
|
32
|
+
const resolved = resolveTemporalRuntimeConfigValues(
|
|
33
|
+
baseConfig,
|
|
34
|
+
{
|
|
35
|
+
TEMPORAL_ADDRESS: "env.temporal:7233",
|
|
36
|
+
TEMPORAL_NAMESPACE: "env.namespace",
|
|
37
|
+
TEMPORAL_TASK_QUEUE: "env-task-queue",
|
|
38
|
+
TEMPORAL_API_KEY: "env-key",
|
|
39
|
+
TEMPORAL_API_KEY_SECRET: "env-secret-name",
|
|
40
|
+
},
|
|
41
|
+
() => ({
|
|
42
|
+
address: "vault.temporal:7233",
|
|
43
|
+
namespace: "vault.namespace",
|
|
44
|
+
apiKey: "vault-key",
|
|
45
|
+
})
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
expect(resolved.address).toBe("env.temporal:7233");
|
|
49
|
+
expect(resolved.namespace).toBe("env.namespace");
|
|
50
|
+
expect(resolved.taskQueue).toBe("env-task-queue");
|
|
51
|
+
expect(resolved.apiKey).toBe("env-key");
|
|
52
|
+
expect(resolved.apiKeySecretName).toBe("env-secret-name");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("resolveTemporalRuntimeConfigValues fails clearly when enabled Temporal resolves to localhost", () => {
|
|
56
|
+
expect(() => resolveTemporalRuntimeConfigValues(baseConfig, {}, () => ({}))).toThrow(
|
|
57
|
+
"Temporal is enabled for this Cloud Run service, but the resolved Temporal address is local"
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("resolveTemporalRuntimeConfigValues allows explicit Temporal disable", () => {
|
|
62
|
+
const resolved = resolveTemporalRuntimeConfigValues(baseConfig, { TEMPORAL_ENABLED: "false" }, () => ({}));
|
|
63
|
+
|
|
64
|
+
expect(resolved.enabled).toBeFalse();
|
|
65
|
+
expect(resolved.apiKeySecretName).toBe("");
|
|
66
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
type TemporalConfigInput = {
|
|
2
|
+
enabled: boolean;
|
|
3
|
+
address: string;
|
|
4
|
+
namespace: string;
|
|
5
|
+
taskQueue: string;
|
|
6
|
+
apiKeySecretName: string;
|
|
7
|
+
vaultMount: string;
|
|
8
|
+
vaultPath: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type TemporalProviderFields = {
|
|
12
|
+
address?: string;
|
|
13
|
+
namespace?: string;
|
|
14
|
+
apiKey?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type TemporalRuntimeConfig = {
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
address: string;
|
|
20
|
+
namespace: string;
|
|
21
|
+
taskQueue: string;
|
|
22
|
+
apiKeySecretName: string;
|
|
23
|
+
apiKey: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function resolveTemporalRuntimeConfigValues(
|
|
27
|
+
config: TemporalConfigInput,
|
|
28
|
+
env: Record<string, string | undefined>,
|
|
29
|
+
readProviderFields: (mount: string, path: string) => TemporalProviderFields
|
|
30
|
+
): TemporalRuntimeConfig {
|
|
31
|
+
const enabledOverride = env.TEMPORAL_ENABLED?.trim();
|
|
32
|
+
const enabled = enabledOverride ? isTruthy(enabledOverride) : config.enabled;
|
|
33
|
+
const taskQueue = env.TEMPORAL_TASK_QUEUE?.trim() || config.taskQueue;
|
|
34
|
+
|
|
35
|
+
if (!enabled) {
|
|
36
|
+
return {
|
|
37
|
+
enabled: false,
|
|
38
|
+
address: env.TEMPORAL_ADDRESS?.trim() || config.address,
|
|
39
|
+
namespace: env.TEMPORAL_NAMESPACE?.trim() || config.namespace,
|
|
40
|
+
taskQueue,
|
|
41
|
+
apiKeySecretName: "",
|
|
42
|
+
apiKey: "",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const provider = readProviderFields(config.vaultMount, config.vaultPath);
|
|
47
|
+
const address = env.TEMPORAL_ADDRESS?.trim() || provider.address || config.address;
|
|
48
|
+
const namespace = env.TEMPORAL_NAMESPACE?.trim() || provider.namespace || config.namespace;
|
|
49
|
+
const apiKey = env.TEMPORAL_API_KEY?.trim() || provider.apiKey || "";
|
|
50
|
+
const apiKeySecretName = env.TEMPORAL_API_KEY_SECRET?.trim() || (apiKey ? config.apiKeySecretName : "");
|
|
51
|
+
|
|
52
|
+
if (isLocalTemporalAddress(address)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
[
|
|
55
|
+
"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.",
|
|
58
|
+
"Set TEMPORAL_ENABLED=false only for services that should deploy without Temporal.",
|
|
59
|
+
].join(" ")
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!namespace) {
|
|
64
|
+
throw new Error(`Temporal is enabled but TEMPORAL_NAMESPACE is missing; set it in env or Vault at ${config.vaultMount}/${config.vaultPath}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
enabled,
|
|
69
|
+
address,
|
|
70
|
+
namespace,
|
|
71
|
+
taskQueue,
|
|
72
|
+
apiKeySecretName,
|
|
73
|
+
apiKey,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isTruthy(value: string) {
|
|
78
|
+
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isLocalTemporalAddress(address: string) {
|
|
82
|
+
const value = address.trim().toLowerCase();
|
|
83
|
+
return value === "" || value.startsWith("localhost:") || value.startsWith("127.0.0.1:") || value.startsWith("[::1]:");
|
|
84
|
+
}
|
|
@@ -15,6 +15,12 @@ const config = {
|
|
|
15
15
|
hostname: serviceConfig.dns.hostname,
|
|
16
16
|
neonDatabaseName: serviceConfig.neon.database_name,
|
|
17
17
|
neonRoleName: serviceConfig.neon.role_name,
|
|
18
|
+
triggerDev: {
|
|
19
|
+
projectRefEnv: serviceConfig.workers?.trigger_dev?.project_ref_env || "TRIGGER_PROJECT_REF",
|
|
20
|
+
accessTokenEnv: serviceConfig.workers?.trigger_dev?.access_token_env || "TRIGGER_ACCESS_TOKEN",
|
|
21
|
+
secretKeyEnv: serviceConfig.workers?.trigger_dev?.secret_key_env || "TRIGGER_SECRET_KEY",
|
|
22
|
+
waitlistTaskId: serviceConfig.workers?.trigger_dev?.waitlist_task_id || `${serviceConfig.service_id}-waitlist-follow-up`,
|
|
23
|
+
},
|
|
18
24
|
git: {
|
|
19
25
|
enabled: Boolean(serviceConfig.git?.enabled),
|
|
20
26
|
owner: serviceConfig.git?.owner || "anmho",
|
|
@@ -35,18 +41,28 @@ export async function main(argv = Bun.argv.slice(2)) {
|
|
|
35
41
|
|
|
36
42
|
if (command === "create") {
|
|
37
43
|
return runMain("Create", async () => {
|
|
44
|
+
ensureTriggerDevConfig();
|
|
38
45
|
ensureAuthResourceServer();
|
|
39
46
|
ensureAuthClient();
|
|
40
47
|
const databaseUrl = await resolveDatabaseUrl({ preferRemote: true });
|
|
41
48
|
await applySchemaWithRetries(databaseUrl);
|
|
42
49
|
await ensureHyperdrive(databaseUrl);
|
|
50
|
+
deployTriggerDevTasks();
|
|
51
|
+
publishTriggerDevSecret();
|
|
43
52
|
run("wrangler", ["deploy"]);
|
|
44
53
|
return `Created https://${config.hostname}`;
|
|
45
54
|
});
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
if (command === "deploy") {
|
|
58
|
+
if (hasHelpFlag(rest)) {
|
|
59
|
+
console.log(formatHelp());
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
49
62
|
return runMain("Deploy", () => {
|
|
63
|
+
ensureTriggerDevConfig();
|
|
64
|
+
deployTriggerDevTasks();
|
|
65
|
+
publishTriggerDevSecret();
|
|
50
66
|
run("wrangler", ["deploy", ...rest]);
|
|
51
67
|
return `Deployed https://${config.hostname}`;
|
|
52
68
|
});
|
|
@@ -148,6 +164,10 @@ export async function main(argv = Bun.argv.slice(2)) {
|
|
|
148
164
|
throw new Error(`Unknown command: ${command}\n\n${formatHelp()}`);
|
|
149
165
|
}
|
|
150
166
|
|
|
167
|
+
function hasHelpFlag(args: string[]) {
|
|
168
|
+
return args.includes("--help") || args.includes("-h") || args.includes("help");
|
|
169
|
+
}
|
|
170
|
+
|
|
151
171
|
function formatHelp() {
|
|
152
172
|
return [
|
|
153
173
|
"Usage:",
|
|
@@ -507,6 +527,11 @@ async function runDoctor() {
|
|
|
507
527
|
return "dashboard directory found";
|
|
508
528
|
});
|
|
509
529
|
await record(results, "authctl", "warn", () => runAuthDoctor().detail);
|
|
530
|
+
await record(results, "Trigger.dev config", "fail", () => {
|
|
531
|
+
ensureTriggerDevConfig();
|
|
532
|
+
return `${config.triggerDev.waitlistTaskId} configured`;
|
|
533
|
+
});
|
|
534
|
+
await record(results, "Trigger.dev CLI", "warn", () => checkCommand("trigger"));
|
|
510
535
|
await record(results, "deployed health", "warn", async () => {
|
|
511
536
|
const response = await fetch(`https://${config.hostname}/healthz`, { signal: AbortSignal.timeout(5_000) });
|
|
512
537
|
if (!response.ok) {
|
|
@@ -523,6 +548,69 @@ async function runDoctor() {
|
|
|
523
548
|
return output;
|
|
524
549
|
}
|
|
525
550
|
|
|
551
|
+
function ensureTriggerDevConfig() {
|
|
552
|
+
const missing = [];
|
|
553
|
+
if (!process.env[config.triggerDev.projectRefEnv]?.trim()) {
|
|
554
|
+
missing.push(config.triggerDev.projectRefEnv);
|
|
555
|
+
}
|
|
556
|
+
if (!process.env[config.triggerDev.accessTokenEnv]?.trim()) {
|
|
557
|
+
missing.push(config.triggerDev.accessTokenEnv);
|
|
558
|
+
}
|
|
559
|
+
if (!process.env[config.triggerDev.secretKeyEnv]?.trim()) {
|
|
560
|
+
missing.push(config.triggerDev.secretKeyEnv);
|
|
561
|
+
}
|
|
562
|
+
if (missing.length > 0) {
|
|
563
|
+
throw new Error(`${formatList(missing)} required for Workers Trigger.dev task deployment and dispatch`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function formatList(values: string[]) {
|
|
568
|
+
if (values.length <= 1) {
|
|
569
|
+
return values[0] ?? "";
|
|
570
|
+
}
|
|
571
|
+
if (values.length === 2) {
|
|
572
|
+
return values.join(" and ");
|
|
573
|
+
}
|
|
574
|
+
return `${values.slice(0, -1).join(", ")}, and ${values.at(-1)}`;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function deployTriggerDevTasks() {
|
|
578
|
+
run("trigger", ["deploy", "--project-ref", process.env[config.triggerDev.projectRefEnv]?.trim() || ""]);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function publishTriggerDevSecret() {
|
|
582
|
+
const secret = process.env[config.triggerDev.secretKeyEnv]?.trim();
|
|
583
|
+
if (!secret) {
|
|
584
|
+
throw new Error(`${config.triggerDev.secretKeyEnv} required to publish the Workers Trigger.dev secret`);
|
|
585
|
+
}
|
|
586
|
+
const wrangler = resolveCommandPath("wrangler");
|
|
587
|
+
if (!wrangler) {
|
|
588
|
+
throw new Error("missing required command: wrangler");
|
|
589
|
+
}
|
|
590
|
+
runShell(`printf %s "$${config.triggerDev.secretKeyEnv}" | ${shellQuote(wrangler)} secret put TRIGGER_SECRET_KEY --name ${shellQuote(config.serviceName)}`);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function runShell(script: string) {
|
|
594
|
+
const shell = Bun.which("sh");
|
|
595
|
+
if (!shell) {
|
|
596
|
+
throw new Error("missing required command: sh");
|
|
597
|
+
}
|
|
598
|
+
const result = Bun.spawnSync([shell, "-c", script], {
|
|
599
|
+
cwd: process.cwd(),
|
|
600
|
+
env: process.env,
|
|
601
|
+
stdin: "inherit",
|
|
602
|
+
stdout: "inherit",
|
|
603
|
+
stderr: "inherit",
|
|
604
|
+
});
|
|
605
|
+
if (!result.success) {
|
|
606
|
+
throw new Error(`shell command failed with exit code ${result.exitCode}: ${script}`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function shellQuote(value: string) {
|
|
611
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
612
|
+
}
|
|
613
|
+
|
|
526
614
|
async function record(
|
|
527
615
|
results: Array<{ name: string; status: DoctorStatus; detail: string }>,
|
|
528
616
|
name: string,
|
package/src/service.test.ts
CHANGED
|
@@ -2,7 +2,13 @@ import { expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
findGeneratedServiceRoot,
|
|
7
|
+
formatOutsideServiceCommandError,
|
|
8
|
+
generatedDependenciesInstalled,
|
|
9
|
+
generatedServiceCommandHelp,
|
|
10
|
+
normalizeScaffoldArgs,
|
|
11
|
+
} from "./service";
|
|
6
12
|
|
|
7
13
|
test("normalizeScaffoldArgs treats explicit scaffold commands as generator commands", () => {
|
|
8
14
|
expect(normalizeScaffoldArgs(["create", "launch-api", "--yes"])).toEqual(["launch-api", "--yes"]);
|
|
@@ -23,7 +29,7 @@ test("formatOutsideServiceCommandError rejects repo-local commands outside gener
|
|
|
23
29
|
test("formatOutsideServiceCommandError does not treat positional names as scaffold commands", () => {
|
|
24
30
|
const message = formatOutsideServiceCommandError("launch-api");
|
|
25
31
|
expect(message).toContain("Unknown command: launch-api");
|
|
26
|
-
expect(message).toContain("service
|
|
32
|
+
expect(message).toContain("service new <service_id>");
|
|
27
33
|
});
|
|
28
34
|
|
|
29
35
|
test("findGeneratedServiceRoot detects generated service context from nested directories", async () => {
|
|
@@ -47,3 +53,10 @@ test("generatedDependenciesInstalled requires node_modules when package.json exi
|
|
|
47
53
|
await mkdir(join(root, "node_modules"));
|
|
48
54
|
expect(generatedDependenciesInstalled(root)).toBeTrue();
|
|
49
55
|
});
|
|
56
|
+
|
|
57
|
+
test("generatedServiceCommandHelp intercepts deploy help before side effects", () => {
|
|
58
|
+
expect(generatedServiceCommandHelp(["deploy", "--help"])).toContain("service deploy");
|
|
59
|
+
expect(generatedServiceCommandHelp(["deploy", "-h"])).toContain("--environment");
|
|
60
|
+
expect(generatedServiceCommandHelp(["deploy"])).toBeUndefined();
|
|
61
|
+
expect(generatedServiceCommandHelp(["destroy", "--help"])).toBeUndefined();
|
|
62
|
+
});
|
package/src/service.ts
CHANGED
|
@@ -58,7 +58,7 @@ export function formatOutsideServiceCommandError(command: string) {
|
|
|
58
58
|
"",
|
|
59
59
|
"No service.jsonc was found in this directory or its parents.",
|
|
60
60
|
"To create a new service, run:",
|
|
61
|
-
" service
|
|
61
|
+
" service new <service_id>",
|
|
62
62
|
].join("\n");
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -85,6 +85,12 @@ function isGeneratedServiceRoot(path: string) {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
async function delegateToGeneratedService(serviceRoot: string, argv: string[]) {
|
|
88
|
+
const commandHelp = generatedServiceCommandHelp(argv);
|
|
89
|
+
if (commandHelp) {
|
|
90
|
+
console.log(commandHelp);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
88
94
|
ensureGeneratedDependencies(serviceRoot);
|
|
89
95
|
process.chdir(serviceRoot);
|
|
90
96
|
process.env.CREATE_SVC_SERVICE_ROOT = serviceRoot;
|
|
@@ -123,3 +129,27 @@ function ensureGeneratedDependencies(serviceRoot: string) {
|
|
|
123
129
|
process.exit(result.exitCode || 1);
|
|
124
130
|
}
|
|
125
131
|
}
|
|
132
|
+
|
|
133
|
+
export function generatedServiceCommandHelp(argv: string[]) {
|
|
134
|
+
const [command, ...rest] = argv;
|
|
135
|
+
if (command !== "deploy" || !hasHelpFlag(rest)) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return [
|
|
140
|
+
"Usage:",
|
|
141
|
+
" service deploy [--ci] [--environment main|preview|personal] [--name <name>]",
|
|
142
|
+
"",
|
|
143
|
+
"Options:",
|
|
144
|
+
" --ci Run without interactive prompts",
|
|
145
|
+
" --environment <environment> Deploy main, preview, or personal",
|
|
146
|
+
" --name <name> Name preview or personal environment",
|
|
147
|
+
" --build <local|cloudbuild> Select image build strategy",
|
|
148
|
+
" --cloud-build Use Cloud Build",
|
|
149
|
+
" --destroy Destroy a non-main deployment environment",
|
|
150
|
+
].join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function hasHelpFlag(args: string[]) {
|
|
154
|
+
return args.includes("--help") || args.includes("-h") || args.includes("help");
|
|
155
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
DATABASE_URL=postgres://{{LOCAL_DATABASE_USER}}:{{LOCAL_DATABASE_PASSWORD}}@127.0.0.1:{{LOCAL_DATABASE_PORT}}/{{LOCAL_DATABASE_NAME}}?sslmode=disable
|
|
5
5
|
|
|
6
|
-
TEMPORAL_ENABLED=
|
|
6
|
+
TEMPORAL_ENABLED=true
|
|
7
7
|
TEMPORAL_ADDRESS=localhost:7233
|
|
8
8
|
TEMPORAL_NAMESPACE=default
|
|
9
9
|
TEMPORAL_TASK_QUEUE={{SERVICE_ID}}
|
|
@@ -55,10 +55,49 @@ Local runtime uses:
|
|
|
55
55
|
|
|
56
56
|
- `DATABASE_URL` from `.env.local`, pointed at Docker Compose Postgres
|
|
57
57
|
- `{{COMMAND_MIGRATE}}` and `{{COMMAND_DEV}}`, which open Docker Desktop if needed, wait for Docker readiness, and start Docker Compose Postgres
|
|
58
|
-
- `TEMPORAL_ENABLED=
|
|
58
|
+
- `TEMPORAL_ENABLED=true` by default with `localhost:7233` and `default`; set `TEMPORAL_ENABLED=false` when you are not running a local Temporal server
|
|
59
59
|
|
|
60
60
|
No cloud credentials are required for local HTTP development after Docker and Postgres are running.
|
|
61
61
|
|
|
62
|
+
## Temporal
|
|
63
|
+
|
|
64
|
+
Temporal is enabled by default for generated services.
|
|
65
|
+
|
|
66
|
+
Local development uses these generated defaults:
|
|
67
|
+
|
|
68
|
+
- `TEMPORAL_ENABLED=true`
|
|
69
|
+
- `TEMPORAL_ADDRESS=localhost:7233`
|
|
70
|
+
- `TEMPORAL_NAMESPACE=default`
|
|
71
|
+
- `TEMPORAL_TASK_QUEUE={{TEMPORAL_TASK_QUEUE}}`
|
|
72
|
+
|
|
73
|
+
The generated app deploys a Cloud Run API service and, when Temporal is enabled,
|
|
74
|
+
a separate Cloud Run worker service named `{{SERVICE_ID}}-worker`.
|
|
75
|
+
When `POST /v1/triggers/waitlist` or the ConnectRPC `RecordTrigger` method
|
|
76
|
+
creates a trigger, the API service starts `waitlistFollowUpWorkflow` /
|
|
77
|
+
`WaitlistFollowUpWorkflow` asynchronously on the service task queue. The API
|
|
78
|
+
request only waits for the trigger record; workflow completion happens through
|
|
79
|
+
Temporal and is polled by the worker service.
|
|
80
|
+
Local `{{COMMAND_DEV}}` starts the API process and the worker process together.
|
|
81
|
+
|
|
82
|
+
Production and preview deploys render `TEMPORAL_ENABLED=true` into the Cloud Run
|
|
83
|
+
manifest unless you override it. For Temporal Cloud, replace the local defaults
|
|
84
|
+
with real connection settings before the worker can run:
|
|
85
|
+
|
|
86
|
+
- `TEMPORAL_ADDRESS`
|
|
87
|
+
- `TEMPORAL_NAMESPACE`
|
|
88
|
+
- any TLS certificate/key or API-key settings used by your worker code
|
|
89
|
+
|
|
90
|
+
If a service does not need Temporal, opt out with:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
TEMPORAL_ENABLED=false {{COMMAND_DEV}}
|
|
94
|
+
TEMPORAL_ENABLED=false {{COMMAND_DEPLOY}}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
When Cloud Run starts with Temporal enabled but without address or namespace
|
|
98
|
+
values, the generated runtime fails during startup with an explicit
|
|
99
|
+
configuration error instead of silently running a broken worker.
|
|
100
|
+
|
|
62
101
|
## Remote provisioning
|
|
63
102
|
|
|
64
103
|
The generated service config lives in [service.jsonc](service.jsonc).
|
|
@@ -158,14 +197,7 @@ Optional remote-only Vault overrides for Neon admin key lookup:
|
|
|
158
197
|
|
|
159
198
|
The generator only stores application-facing connection material in Secret Manager. Neon admin credentials stay local to create and deploy.
|
|
160
199
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
Cloud Run variants include an in-process Temporal worker in the service process.
|
|
164
|
-
Production Temporal is enabled when you set `TEMPORAL_ENABLED=true`, or when
|
|
165
|
-
`TEMPORAL_ADDRESS`, `TEMPORAL_API_KEY`, or `TEMPORAL_API_KEY_SECRET` is present
|
|
166
|
-
during deploy rendering.
|
|
167
|
-
|
|
168
|
-
For Temporal Cloud, provide:
|
|
200
|
+
For production Temporal Cloud, provide:
|
|
169
201
|
|
|
170
202
|
```bash
|
|
171
203
|
TEMPORAL_ENABLED=true
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { readLocalEnv } from "./local-env";
|
|
2
2
|
import { ensureLocalPostgres } from "./local-docker";
|
|
3
3
|
|
|
4
|
-
const
|
|
4
|
+
const { apiCommand, workerCommand } = parseCommands(Bun.argv.slice(2));
|
|
5
5
|
|
|
6
|
-
if (
|
|
7
|
-
throw new Error("Usage: bun run ./scripts/dev.ts <command...>");
|
|
6
|
+
if (apiCommand.length === 0) {
|
|
7
|
+
throw new Error("Usage: bun run ./scripts/dev.ts <api-command...> [--worker <worker-command...>]");
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
await ensureLocalPostgres();
|
|
@@ -17,11 +17,43 @@ if (env.DATABASE_URL && !env.CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPER
|
|
|
17
17
|
env.CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE = env.DATABASE_URL;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
const
|
|
20
|
+
const api = Bun.spawn(apiCommand, {
|
|
21
21
|
stdin: "inherit",
|
|
22
22
|
stdout: "inherit",
|
|
23
23
|
stderr: "inherit",
|
|
24
24
|
env,
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
const worker = workerCommand
|
|
28
|
+
? Bun.spawn(workerCommand, {
|
|
29
|
+
stdin: "ignore",
|
|
30
|
+
stdout: "inherit",
|
|
31
|
+
stderr: "inherit",
|
|
32
|
+
env: {
|
|
33
|
+
...env,
|
|
34
|
+
PORT: env.TEMPORAL_WORKER_PORT || "0",
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
: undefined;
|
|
38
|
+
|
|
39
|
+
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
|
40
|
+
process.on(signal, () => {
|
|
41
|
+
api.kill(signal);
|
|
42
|
+
worker?.kill(signal);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const exitCode = await Promise.race([api.exited, worker?.exited ?? new Promise<never>(() => {})]);
|
|
47
|
+
api.kill();
|
|
48
|
+
worker?.kill();
|
|
49
|
+
process.exit(exitCode);
|
|
50
|
+
|
|
51
|
+
function parseCommands(argv: string[]) {
|
|
52
|
+
const separator = argv.indexOf("--worker");
|
|
53
|
+
if (separator === -1) {
|
|
54
|
+
return { apiCommand: argv, workerCommand: undefined };
|
|
55
|
+
}
|
|
56
|
+
const apiCommand = argv.slice(0, separator);
|
|
57
|
+
const workerCommand = argv.slice(separator + 1);
|
|
58
|
+
return { apiCommand, workerCommand: workerCommand.length > 0 ? workerCommand : undefined };
|
|
59
|
+
}
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
|
|
55
55
|
"temporal": {
|
|
56
|
-
"enabled":
|
|
56
|
+
"enabled": true,
|
|
57
57
|
"address": "localhost:7233",
|
|
58
58
|
"namespace": "default",
|
|
59
59
|
"task_queue": "{{SERVICE_ID}}",
|
|
@@ -117,6 +117,12 @@
|
|
|
117
117
|
"workers": {
|
|
118
118
|
"script_name": "{{SERVICE_ID}}",
|
|
119
119
|
"hyperdrive_binding": "HYPERDRIVE",
|
|
120
|
-
"
|
|
120
|
+
"trigger_dev": {
|
|
121
|
+
"project_ref_env": "TRIGGER_PROJECT_REF",
|
|
122
|
+
"access_token_env": "TRIGGER_ACCESS_TOKEN",
|
|
123
|
+
"secret_key_env": "TRIGGER_SECRET_KEY",
|
|
124
|
+
"api_url": "https://api.trigger.dev",
|
|
125
|
+
"waitlist_task_id": "{{SERVICE_ID}}-waitlist-follow-up"
|
|
126
|
+
}
|
|
121
127
|
}
|
|
122
128
|
}
|
|
@@ -5,18 +5,21 @@ metadata:
|
|
|
5
5
|
labels:
|
|
6
6
|
managed_by: create-service
|
|
7
7
|
service_id: ${SERVICE_ID}
|
|
8
|
+
service_role: ${SERVICE_ROLE}
|
|
8
9
|
annotations:
|
|
9
|
-
run.googleapis.com/ingress:
|
|
10
|
+
run.googleapis.com/ingress: ${SERVICE_INGRESS}
|
|
10
11
|
spec:
|
|
11
12
|
template:
|
|
12
13
|
metadata:
|
|
13
14
|
labels:
|
|
14
15
|
managed_by: create-service
|
|
15
16
|
service_id: ${SERVICE_ID}
|
|
17
|
+
service_role: ${SERVICE_ROLE}
|
|
16
18
|
spec:
|
|
17
19
|
serviceAccountName: ${RUNTIME_SERVICE_ACCOUNT}
|
|
18
20
|
containers:
|
|
19
21
|
- image: ${IMAGE_URL}
|
|
22
|
+
${CONTAINER_COMMAND}
|
|
20
23
|
ports:
|
|
21
24
|
- containerPort: 8080
|
|
22
25
|
env:
|
|
@@ -16,5 +16,8 @@ jobs:
|
|
|
16
16
|
- run: bun run deploy
|
|
17
17
|
env:
|
|
18
18
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
19
|
+
TRIGGER_PROJECT_REF: ${{ secrets.TRIGGER_PROJECT_REF }}
|
|
20
|
+
TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_ACCESS_TOKEN }}
|
|
21
|
+
TRIGGER_SECRET_KEY: ${{ secrets.TRIGGER_SECRET_KEY }}
|
|
19
22
|
- if: ${{ vars.GCX_ENABLED == 'true' }}
|
|
20
23
|
run: bun run dashboards
|