create-svc 0.1.85 → 0.1.87
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 +1 -1
- package/src/scaffold.test.ts +3 -1
- package/src/scaffold.ts +2 -0
- package/src/service-runtime/authctl-command.ts +12 -0
- package/src/service-runtime/authctl.test.ts +16 -0
- package/src/service-runtime/authctl.ts +21 -3
- package/templates/shared/README.md +21 -4
- package/templates/shared/docker-compose.yml +16 -0
- package/templates/shared/scripts/dev.ts +36 -0
- package/templates/shared/scripts/e2e.ts +275 -0
- package/templates/shared/scripts/local-docker.ts +1 -1
- package/templates/variants/bun-connectrpc/Makefile +10 -1
- package/templates/variants/bun-connectrpc/package.json +3 -0
- package/templates/variants/bun-hono/Makefile +10 -1
- package/templates/variants/bun-hono/package.json +3 -0
- package/templates/variants/go-chi/Makefile +10 -1
- package/templates/variants/go-chi/package.json +3 -0
- package/templates/variants/go-connectrpc/Makefile +10 -1
- package/templates/variants/go-connectrpc/package.json +3 -0
package/package.json
CHANGED
package/src/scaffold.test.ts
CHANGED
|
@@ -113,6 +113,8 @@ test("scaffolds all runtime/framework variants with shared cloudrun config", asy
|
|
|
113
113
|
|
|
114
114
|
const dockerCompose = await Bun.file(join(generatedRoot, "docker-compose.yml")).text();
|
|
115
115
|
expect(dockerCompose).toContain('image: postgres:16-alpine');
|
|
116
|
+
expect(dockerCompose).toContain("image: temporalio/auto-setup:");
|
|
117
|
+
expect(dockerCompose).toContain("127.0.0.1:7233:7233");
|
|
116
118
|
expect(dockerCompose).toContain(`127.0.0.1:${localPort}:5432`);
|
|
117
119
|
|
|
118
120
|
const envExample = await Bun.file(join(generatedRoot, ".env.example")).text();
|
|
@@ -337,7 +339,7 @@ test("scaffolds a backend package cleanly into a nested monorepo-style directory
|
|
|
337
339
|
expect(readme).toContain("`microservice` profile");
|
|
338
340
|
expect(readme).toContain("api.dns-api.anmho.com");
|
|
339
341
|
expect(readme).toContain("open Docker Desktop if needed");
|
|
340
|
-
expect(readme).toContain("local Postgres
|
|
342
|
+
expect(readme).toContain("local Postgres and Temporal services in `docker-compose.yml`");
|
|
341
343
|
expect(readme).toContain("gcloud auth login");
|
|
342
344
|
expect(readme).toContain("known-good CLIs");
|
|
343
345
|
expect(readme).toContain("service create");
|
package/src/scaffold.ts
CHANGED
|
@@ -244,6 +244,8 @@ function buildReplacements(config: ScaffoldConfig) {
|
|
|
244
244
|
COMMAND_GEN: config.runtime === "bun" ? "bun run gen" : "make gen",
|
|
245
245
|
COMMAND_LINT: config.runtime === "bun" ? "bun run lint" : "make lint",
|
|
246
246
|
COMMAND_TEST: config.runtime === "bun" ? "bun run test" : "make test",
|
|
247
|
+
COMMAND_TEST_E2E_LOCAL: config.runtime === "bun" ? "bun run test:e2e:local" : "make test-e2e-local",
|
|
248
|
+
COMMAND_TEST_E2E_PROD: config.runtime === "bun" ? "bun run test:e2e:prod" : "make test-e2e-prod",
|
|
247
249
|
COMMAND_DEV_DOWN: "service dev down",
|
|
248
250
|
COMMAND_BOOTSTRAP: "service create",
|
|
249
251
|
COMMAND_DEPLOY: "service deploy",
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type AuthctlCommand = {
|
|
2
|
+
path: string;
|
|
3
|
+
runWithBun: boolean;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export function authctlSpawnArgs(command: AuthctlCommand, args: string[]) {
|
|
7
|
+
return command.runWithBun ? [bunExecutable(), command.path, ...args] : [command.path, ...args];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function bunExecutable() {
|
|
11
|
+
return Bun.which("bun") ?? process.execPath;
|
|
12
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { authctlSpawnArgs } from "./authctl-command";
|
|
3
|
+
|
|
4
|
+
test("authctlSpawnArgs runs repo-local authctl through bun", () => {
|
|
5
|
+
const args = authctlSpawnArgs({ path: "./node_modules/.bin/authctl", runWithBun: true }, ["doctor", "--json"]);
|
|
6
|
+
|
|
7
|
+
expect(args[0]).toEndWith("bun");
|
|
8
|
+
expect(args.slice(1)).toEqual(["./node_modules/.bin/authctl", "doctor", "--json"]);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("authctlSpawnArgs runs global authctl directly", () => {
|
|
12
|
+
expect(authctlSpawnArgs({ path: "/usr/local/bin/authctl", runWithBun: false }, ["version"])).toEqual([
|
|
13
|
+
"/usr/local/bin/authctl",
|
|
14
|
+
"version",
|
|
15
|
+
]);
|
|
16
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
+
import { authctlSpawnArgs, type AuthctlCommand } from "./authctl-command";
|
|
2
3
|
import { serviceConfig } from "./runtime";
|
|
3
4
|
|
|
4
5
|
type CommandResult = {
|
|
@@ -9,6 +10,7 @@ type CommandResult = {
|
|
|
9
10
|
};
|
|
10
11
|
|
|
11
12
|
const decoder = new TextDecoder();
|
|
13
|
+
const localAuthctlPath = "./node_modules/.bin/authctl";
|
|
12
14
|
|
|
13
15
|
export type AuthDoctorResult = {
|
|
14
16
|
hasAuthctl: boolean;
|
|
@@ -418,12 +420,12 @@ function resolveResourceServerCommand(): ResourceServerCommand | undefined {
|
|
|
418
420
|
}
|
|
419
421
|
|
|
420
422
|
function authctl(args: string[], options: { allowFailure?: boolean; quiet?: boolean } = {}): CommandResult {
|
|
421
|
-
const command =
|
|
423
|
+
const command = authctlCommand();
|
|
422
424
|
if (!command) {
|
|
423
425
|
throw new Error("authctl is not installed; run bun install in this generated service or link @anmho/authctl");
|
|
424
426
|
}
|
|
425
427
|
|
|
426
|
-
const result = Bun.spawnSync(
|
|
428
|
+
const result = Bun.spawnSync(authctlSpawnArgs(command, args), {
|
|
427
429
|
cwd: process.cwd(),
|
|
428
430
|
env: authctlEnvironment(),
|
|
429
431
|
stdin: "inherit",
|
|
@@ -464,8 +466,24 @@ function formatAuthctlFailure(args: string[], output: CommandResult) {
|
|
|
464
466
|
return `authctl ${args.join(" ")} failed with exit code ${output.exitCode}\n${detail}`;
|
|
465
467
|
}
|
|
466
468
|
|
|
469
|
+
function authctlCommand(): AuthctlCommand | undefined {
|
|
470
|
+
if (existsSync(localAuthctlPath)) {
|
|
471
|
+
return {
|
|
472
|
+
path: localAuthctlPath,
|
|
473
|
+
runWithBun: true,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
const global = Bun.which("authctl");
|
|
477
|
+
return global
|
|
478
|
+
? {
|
|
479
|
+
path: global,
|
|
480
|
+
runWithBun: false,
|
|
481
|
+
}
|
|
482
|
+
: undefined;
|
|
483
|
+
}
|
|
484
|
+
|
|
467
485
|
function authctlPath() {
|
|
468
|
-
return
|
|
486
|
+
return authctlCommand()?.path;
|
|
469
487
|
}
|
|
470
488
|
|
|
471
489
|
function authctlEnvironment() {
|
|
@@ -29,6 +29,8 @@ console to create and deploy.
|
|
|
29
29
|
{{COMMAND_GEN}}
|
|
30
30
|
{{COMMAND_LINT}}
|
|
31
31
|
{{COMMAND_TEST}}
|
|
32
|
+
{{COMMAND_TEST_E2E_LOCAL}}
|
|
33
|
+
{{COMMAND_TEST_E2E_PROD}}
|
|
32
34
|
{{COMMAND_BOOTSTRAP}}
|
|
33
35
|
{{COMMAND_DEPLOY}}
|
|
34
36
|
{{COMMAND_PROTECT_MAIN}}
|
|
@@ -43,7 +45,7 @@ console to create and deploy.
|
|
|
43
45
|
|
|
44
46
|
## Local development
|
|
45
47
|
|
|
46
|
-
The scaffold writes a ready-to-use `.env.local` and includes
|
|
48
|
+
The scaffold writes a ready-to-use `.env.local` and includes local Postgres and Temporal services in `docker-compose.yml`.
|
|
47
49
|
|
|
48
50
|
First local run:
|
|
49
51
|
|
|
@@ -55,11 +57,26 @@ First local run:
|
|
|
55
57
|
Local runtime uses:
|
|
56
58
|
|
|
57
59
|
- `DATABASE_URL` from `.env.local`, pointed at Docker Compose Postgres
|
|
58
|
-
- `{{COMMAND_MIGRATE}}` and `{{COMMAND_DEV}}`, which open Docker Desktop if needed, wait for Docker readiness, and start Docker Compose Postgres
|
|
59
|
-
- `TEMPORAL_ENABLED=true` by default with `localhost:7233` and `default`;
|
|
60
|
+
- `{{COMMAND_MIGRATE}}` and `{{COMMAND_DEV}}`, which open Docker Desktop if needed, wait for Docker readiness, and start Docker Compose Postgres and Temporal
|
|
61
|
+
- `TEMPORAL_ENABLED=true` by default with `localhost:7233` and `default`; `{{COMMAND_DEV}}` waits for the local Temporal container before starting the API and worker
|
|
60
62
|
|
|
61
63
|
No cloud credentials are required for local HTTP development after Docker and Postgres are running.
|
|
62
64
|
|
|
65
|
+
Run the local end-to-end test against the already-running local service:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
{{COMMAND_TEST_E2E_LOCAL}}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The production end-to-end test exercises health and public webhook idempotency,
|
|
72
|
+
then requires Cloud Logging rows and Cloud Monitoring
|
|
73
|
+
`run.googleapis.com/container/instance_count` rows for the current Cloud Run API
|
|
74
|
+
and worker revisions:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
{{COMMAND_TEST_E2E_PROD}}
|
|
78
|
+
```
|
|
79
|
+
|
|
63
80
|
## Temporal
|
|
64
81
|
|
|
65
82
|
Temporal is enabled by default for generated services.
|
|
@@ -78,7 +95,7 @@ creates a trigger, the API service starts `waitlistFollowUpWorkflow` /
|
|
|
78
95
|
`WaitlistFollowUpWorkflow` asynchronously on the service task queue. The API
|
|
79
96
|
request only waits for the trigger record; workflow completion happens through
|
|
80
97
|
Temporal and is polled by the worker service.
|
|
81
|
-
Local `{{COMMAND_DEV}}` starts the API process and the worker process together.
|
|
98
|
+
Local `{{COMMAND_DEV}}` starts the API process and the worker process together after Docker Compose starts the local Temporal server.
|
|
82
99
|
|
|
83
100
|
Production and preview deploys render `TEMPORAL_ENABLED=true` into the Cloud Run
|
|
84
101
|
manifest unless you override it. For Temporal Cloud, replace the local defaults
|
|
@@ -15,5 +15,21 @@ services:
|
|
|
15
15
|
timeout: 5s
|
|
16
16
|
retries: 10
|
|
17
17
|
|
|
18
|
+
temporal:
|
|
19
|
+
image: temporalio/auto-setup:1.28.1
|
|
20
|
+
depends_on:
|
|
21
|
+
postgres:
|
|
22
|
+
condition: service_healthy
|
|
23
|
+
environment:
|
|
24
|
+
DB: postgres12
|
|
25
|
+
DB_PORT: 5432
|
|
26
|
+
POSTGRES_USER: {{LOCAL_DATABASE_USER}}
|
|
27
|
+
POSTGRES_PWD: {{LOCAL_DATABASE_PASSWORD}}
|
|
28
|
+
POSTGRES_SEEDS: postgres
|
|
29
|
+
DBNAME: temporal
|
|
30
|
+
VISIBILITY_DBNAME: temporal_visibility
|
|
31
|
+
ports:
|
|
32
|
+
- "127.0.0.1:7233:7233"
|
|
33
|
+
|
|
18
34
|
volumes:
|
|
19
35
|
postgres-data:
|
|
@@ -16,6 +16,9 @@ const env = {
|
|
|
16
16
|
if (env.DATABASE_URL && !env.CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE) {
|
|
17
17
|
env.CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE = env.DATABASE_URL;
|
|
18
18
|
}
|
|
19
|
+
if (temporalEnabled(env)) {
|
|
20
|
+
await waitForTemporal(env.TEMPORAL_ADDRESS || "localhost:7233");
|
|
21
|
+
}
|
|
19
22
|
|
|
20
23
|
const api = Bun.spawn(apiCommand, {
|
|
21
24
|
stdin: "inherit",
|
|
@@ -57,3 +60,36 @@ function parseCommands(argv: string[]) {
|
|
|
57
60
|
const workerCommand = argv.slice(separator + 1);
|
|
58
61
|
return { apiCommand, workerCommand: workerCommand.length > 0 ? workerCommand : undefined };
|
|
59
62
|
}
|
|
63
|
+
|
|
64
|
+
function temporalEnabled(env: Record<string, string | undefined>) {
|
|
65
|
+
return (env.TEMPORAL_ENABLED ?? "true").trim().toLowerCase() !== "false";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function waitForTemporal(address: string) {
|
|
69
|
+
const { host, port } = parseTemporalAddress(address);
|
|
70
|
+
const deadline = Date.now() + 120_000;
|
|
71
|
+
|
|
72
|
+
while (Date.now() < deadline) {
|
|
73
|
+
const exitCode = await Bun.spawn(["nc", "-z", host, String(port)], {
|
|
74
|
+
stdin: "ignore",
|
|
75
|
+
stdout: "ignore",
|
|
76
|
+
stderr: "ignore",
|
|
77
|
+
}).exited;
|
|
78
|
+
if (exitCode === 0) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
await Bun.sleep(2_000);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
throw new Error(`Temporal did not become ready at ${host}:${port} within 120 seconds`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseTemporalAddress(address: string) {
|
|
88
|
+
const trimmed = address.trim();
|
|
89
|
+
const withoutScheme = trimmed.includes("://") ? new URL(trimmed).host : trimmed;
|
|
90
|
+
const [host = "localhost", port = "7233"] = withoutScheme.split(":");
|
|
91
|
+
return {
|
|
92
|
+
host: host || "localhost",
|
|
93
|
+
port: Number(port || "7233"),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
type Target = "local" | "prod";
|
|
2
|
+
|
|
3
|
+
export {};
|
|
4
|
+
|
|
5
|
+
type CommandOptions = {
|
|
6
|
+
allowFailure?: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type CloudRunService = {
|
|
10
|
+
status?: {
|
|
11
|
+
latestReadyRevisionName?: string;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type MonitoringSeries = {
|
|
16
|
+
resource?: {
|
|
17
|
+
labels?: Record<string, string>;
|
|
18
|
+
};
|
|
19
|
+
points?: Array<{
|
|
20
|
+
value?: {
|
|
21
|
+
int64Value?: string;
|
|
22
|
+
doubleValue?: number;
|
|
23
|
+
};
|
|
24
|
+
interval?: {
|
|
25
|
+
endTime?: string;
|
|
26
|
+
};
|
|
27
|
+
}>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const args = parseArgs(Bun.argv.slice(2));
|
|
31
|
+
const serviceName = "{{SERVICE_NAME}}";
|
|
32
|
+
const projectId = "{{PROJECT_ID}}";
|
|
33
|
+
const region = "{{REGION}}";
|
|
34
|
+
const apiHostname = "{{API_HOSTNAME}}";
|
|
35
|
+
const baseUrl = args.url ?? (args.target === "prod" ? `https://${apiHostname}` : `http://127.0.0.1:${Bun.env.PORT || "3000"}`);
|
|
36
|
+
const proofId = `e2e-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
37
|
+
|
|
38
|
+
section(`${serviceName} ${args.target} e2e`);
|
|
39
|
+
detail("base_url", baseUrl);
|
|
40
|
+
detail("event_id", proofId);
|
|
41
|
+
|
|
42
|
+
await requestJSON(`${baseUrl}/healthz`, { expectStatus: 200 });
|
|
43
|
+
|
|
44
|
+
const webhookPayload = {
|
|
45
|
+
id: proofId,
|
|
46
|
+
source: "generated-e2e",
|
|
47
|
+
service: serviceName,
|
|
48
|
+
target: args.target,
|
|
49
|
+
};
|
|
50
|
+
const firstWebhook = await requestJSON(`${baseUrl}/webhooks/generated-e2e`, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: {
|
|
53
|
+
"content-type": "application/json",
|
|
54
|
+
"x-webhook-event-id": proofId,
|
|
55
|
+
},
|
|
56
|
+
body: JSON.stringify(webhookPayload),
|
|
57
|
+
expectStatus: 202,
|
|
58
|
+
});
|
|
59
|
+
detail("webhook_first", JSON.stringify(firstWebhook));
|
|
60
|
+
|
|
61
|
+
const secondWebhook = await requestJSON(`${baseUrl}/webhooks/generated-e2e`, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: {
|
|
64
|
+
"content-type": "application/json",
|
|
65
|
+
"x-webhook-event-id": proofId,
|
|
66
|
+
},
|
|
67
|
+
body: JSON.stringify(webhookPayload),
|
|
68
|
+
expectStatus: 200,
|
|
69
|
+
});
|
|
70
|
+
detail("webhook_duplicate", JSON.stringify(secondWebhook));
|
|
71
|
+
if (!isDuplicateWebhook(secondWebhook)) {
|
|
72
|
+
throw new Error("second webhook delivery did not report duplicate=true");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (args.target === "prod") {
|
|
76
|
+
await Bun.sleep(5_000);
|
|
77
|
+
const state = await printCloudRunState();
|
|
78
|
+
await printCloudLogs(serviceName, state.apiRevision);
|
|
79
|
+
await printCloudLogs(`${serviceName}-worker`, state.workerRevision);
|
|
80
|
+
await printCloudMetrics(state);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
section("e2e complete");
|
|
84
|
+
|
|
85
|
+
async function requestJSON(
|
|
86
|
+
url: string,
|
|
87
|
+
options: RequestInit & { expectStatus: number } = { expectStatus: 200 }
|
|
88
|
+
) {
|
|
89
|
+
const response = await fetch(url, {
|
|
90
|
+
...options,
|
|
91
|
+
signal: AbortSignal.timeout(15_000),
|
|
92
|
+
});
|
|
93
|
+
const text = await response.text();
|
|
94
|
+
if (response.status !== options.expectStatus) {
|
|
95
|
+
throw new Error(`${url} returned ${response.status}, expected ${options.expectStatus}: ${text}`);
|
|
96
|
+
}
|
|
97
|
+
if (!text) {
|
|
98
|
+
return {};
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(text);
|
|
102
|
+
} catch {
|
|
103
|
+
return { text };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isDuplicateWebhook(value: unknown) {
|
|
108
|
+
return Boolean(value && typeof value === "object" && "duplicate" in value && value.duplicate === true);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function printCloudRunState() {
|
|
112
|
+
const [api, worker] = await Promise.all([
|
|
113
|
+
describeCloudRunService(serviceName),
|
|
114
|
+
describeCloudRunService(`${serviceName}-worker`),
|
|
115
|
+
]);
|
|
116
|
+
const apiRevision = api.status?.latestReadyRevisionName ?? "";
|
|
117
|
+
const workerRevision = worker.status?.latestReadyRevisionName ?? "";
|
|
118
|
+
if (!apiRevision) {
|
|
119
|
+
throw new Error(`Cloud Run service ${serviceName} did not report latestReadyRevisionName`);
|
|
120
|
+
}
|
|
121
|
+
if (!workerRevision) {
|
|
122
|
+
throw new Error(`Cloud Run service ${serviceName}-worker did not report latestReadyRevisionName`);
|
|
123
|
+
}
|
|
124
|
+
detail("api_revision", apiRevision);
|
|
125
|
+
detail("worker_revision", workerRevision);
|
|
126
|
+
return { apiRevision, workerRevision };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function describeCloudRunService(name: string): Promise<CloudRunService> {
|
|
130
|
+
const output = await command([
|
|
131
|
+
"gcloud",
|
|
132
|
+
"run",
|
|
133
|
+
"services",
|
|
134
|
+
"describe",
|
|
135
|
+
name,
|
|
136
|
+
"--project",
|
|
137
|
+
projectId,
|
|
138
|
+
"--region",
|
|
139
|
+
region,
|
|
140
|
+
"--format=json",
|
|
141
|
+
]);
|
|
142
|
+
return JSON.parse(output || "{}") as CloudRunService;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function printCloudLogs(name: string, revision: string) {
|
|
146
|
+
const filter = [
|
|
147
|
+
'resource.type="cloud_run_revision"',
|
|
148
|
+
`resource.labels.service_name="${name}"`,
|
|
149
|
+
`resource.labels.revision_name="${revision}"`,
|
|
150
|
+
].join(" AND ");
|
|
151
|
+
const output = await command([
|
|
152
|
+
"gcloud",
|
|
153
|
+
"logging",
|
|
154
|
+
"read",
|
|
155
|
+
filter,
|
|
156
|
+
"--project",
|
|
157
|
+
projectId,
|
|
158
|
+
"--limit=3",
|
|
159
|
+
"--format=json",
|
|
160
|
+
]);
|
|
161
|
+
const rows = JSON.parse(output || "[]") as unknown[];
|
|
162
|
+
section(`cloud logs ${name}`);
|
|
163
|
+
if (rows.length === 0) {
|
|
164
|
+
throw new Error(`Cloud Logging did not return rows for ${name} revision ${revision}`);
|
|
165
|
+
}
|
|
166
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function printCloudMetrics(expected: { apiRevision: string; workerRevision: string }) {
|
|
170
|
+
const expectedRevisions = new Set([expected.apiRevision, expected.workerRevision]);
|
|
171
|
+
for (let attempt = 1; attempt <= 6; attempt += 1) {
|
|
172
|
+
const rows = await readCloudMetrics();
|
|
173
|
+
const seenRevisions = new Set(rows.map((row) => row.revision).filter(Boolean));
|
|
174
|
+
section(`cloud metrics container/instance_count attempt ${attempt}`);
|
|
175
|
+
for (const row of rows) {
|
|
176
|
+
console.log(`${row.service}\t${row.revision}\t${row.value}\t${row.endTime}`);
|
|
177
|
+
}
|
|
178
|
+
const missing = [...expectedRevisions].filter((revision) => !seenRevisions.has(revision));
|
|
179
|
+
if (missing.length === 0) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (attempt === 6) {
|
|
183
|
+
throw new Error(`Cloud Monitoring did not return current revision metrics: ${missing.join(", ")}`);
|
|
184
|
+
}
|
|
185
|
+
detail("metrics_waiting_for_revisions", missing.join(", "));
|
|
186
|
+
await Bun.sleep(20_000);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function readCloudMetrics() {
|
|
191
|
+
const end = new Date().toISOString();
|
|
192
|
+
const start = new Date(Date.now() - 20 * 60_000).toISOString();
|
|
193
|
+
const accessToken = await command(["gcloud", "auth", "print-access-token"]);
|
|
194
|
+
const filter = `metric.type="run.googleapis.com/container/instance_count" AND resource.labels.service_name=starts_with("${serviceName}")`;
|
|
195
|
+
const url = new URL(`https://monitoring.googleapis.com/v3/projects/${projectId}/timeSeries`);
|
|
196
|
+
url.searchParams.set("filter", filter);
|
|
197
|
+
url.searchParams.set("interval.startTime", start);
|
|
198
|
+
url.searchParams.set("interval.endTime", end);
|
|
199
|
+
url.searchParams.set("aggregation.alignmentPeriod", "60s");
|
|
200
|
+
url.searchParams.set("aggregation.perSeriesAligner", "ALIGN_SUM");
|
|
201
|
+
url.searchParams.set("view", "FULL");
|
|
202
|
+
const response = await fetch(url, {
|
|
203
|
+
headers: { authorization: `Bearer ${accessToken}` },
|
|
204
|
+
signal: AbortSignal.timeout(15_000),
|
|
205
|
+
});
|
|
206
|
+
if (!response.ok) {
|
|
207
|
+
throw new Error(`Cloud Monitoring query failed: ${response.status} ${await response.text()}`);
|
|
208
|
+
}
|
|
209
|
+
const data = (await response.json()) as { timeSeries?: MonitoringSeries[] };
|
|
210
|
+
return ((data.timeSeries ?? []) as MonitoringSeries[]).map((series) => {
|
|
211
|
+
const labels = series.resource?.labels ?? {};
|
|
212
|
+
const point = series.points?.[0];
|
|
213
|
+
const value = point?.value?.int64Value ?? point?.value?.doubleValue ?? "";
|
|
214
|
+
return {
|
|
215
|
+
service: labels.service_name ?? "",
|
|
216
|
+
revision: labels.revision_name ?? "",
|
|
217
|
+
value,
|
|
218
|
+
endTime: point?.interval?.endTime ?? "",
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function command(commandArgs: string[], options: CommandOptions = {}) {
|
|
224
|
+
const process = Bun.spawn(commandArgs, {
|
|
225
|
+
stdin: "ignore",
|
|
226
|
+
stdout: "pipe",
|
|
227
|
+
stderr: "pipe",
|
|
228
|
+
env: Bun.env,
|
|
229
|
+
});
|
|
230
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
231
|
+
new Response(process.stdout).text(),
|
|
232
|
+
new Response(process.stderr).text(),
|
|
233
|
+
process.exited,
|
|
234
|
+
]);
|
|
235
|
+
if (exitCode !== 0 && !options.allowFailure) {
|
|
236
|
+
throw new Error(`${commandArgs.join(" ")} failed with exit code ${exitCode}\n${stderr.trim()}`);
|
|
237
|
+
}
|
|
238
|
+
return stdout.trim();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function parseArgs(argv: string[]) {
|
|
242
|
+
let target: Target = "local";
|
|
243
|
+
let url = "";
|
|
244
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
245
|
+
const arg = argv[index];
|
|
246
|
+
if (arg === "--local") {
|
|
247
|
+
target = "local";
|
|
248
|
+
} else if (arg === "--prod") {
|
|
249
|
+
target = "prod";
|
|
250
|
+
} else if (arg === "--url") {
|
|
251
|
+
const value = argv[index + 1];
|
|
252
|
+
if (!value || value.startsWith("-")) {
|
|
253
|
+
throw new Error("Missing value for --url");
|
|
254
|
+
}
|
|
255
|
+
url = value;
|
|
256
|
+
index += 1;
|
|
257
|
+
} else if (arg.startsWith("--url=")) {
|
|
258
|
+
url = arg.slice("--url=".length);
|
|
259
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
260
|
+
console.log("Usage: bun run ./scripts/e2e.ts [--local|--prod] [--url <origin>]");
|
|
261
|
+
process.exit(0);
|
|
262
|
+
} else {
|
|
263
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return { target, url };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function section(title: string) {
|
|
270
|
+
console.log(`\n== ${title} ==`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function detail(key: string, value: string) {
|
|
274
|
+
console.log(`${key}: ${value}`);
|
|
275
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export async function ensureLocalPostgres() {
|
|
2
2
|
await ensureDockerRunning();
|
|
3
|
-
await run(["docker", "compose", "up", "-d"], { label: "start local
|
|
3
|
+
await run(["docker", "compose", "up", "-d"], { label: "start local services" });
|
|
4
4
|
}
|
|
5
5
|
|
|
6
6
|
async function ensureDockerRunning() {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.PHONY: dev migrate gen lint test create deploy protect-main dashboards observability-bootstrap auth destroy
|
|
1
|
+
.PHONY: dev migrate gen lint test test-e2e test-e2e-local test-e2e-prod create deploy protect-main dashboards observability-bootstrap auth destroy
|
|
2
2
|
|
|
3
3
|
SERVICE := service
|
|
4
4
|
|
|
@@ -17,6 +17,15 @@ lint:
|
|
|
17
17
|
test:
|
|
18
18
|
bun test
|
|
19
19
|
|
|
20
|
+
test-e2e:
|
|
21
|
+
bun run ./scripts/e2e.ts $(ARGS)
|
|
22
|
+
|
|
23
|
+
test-e2e-local:
|
|
24
|
+
bun run ./scripts/e2e.ts --local $(ARGS)
|
|
25
|
+
|
|
26
|
+
test-e2e-prod:
|
|
27
|
+
bun run ./scripts/e2e.ts --prod $(ARGS)
|
|
28
|
+
|
|
20
29
|
create:
|
|
21
30
|
$(SERVICE) create
|
|
22
31
|
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
"gen": "bun run ./scripts/codegen.ts",
|
|
10
10
|
"lint": "tsc --noEmit",
|
|
11
11
|
"test": "bun test",
|
|
12
|
+
"test:e2e": "bun run ./scripts/e2e.ts",
|
|
13
|
+
"test:e2e:local": "bun run ./scripts/e2e.ts --local",
|
|
14
|
+
"test:e2e:prod": "bun run ./scripts/e2e.ts --prod",
|
|
12
15
|
"create": "service create",
|
|
13
16
|
"deploy": "service deploy",
|
|
14
17
|
"protect-main": "service protect-main",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.PHONY: dev migrate gen lint test create deploy protect-main dashboards observability-bootstrap auth destroy
|
|
1
|
+
.PHONY: dev migrate gen lint test test-e2e test-e2e-local test-e2e-prod create deploy protect-main dashboards observability-bootstrap auth destroy
|
|
2
2
|
|
|
3
3
|
SERVICE := service
|
|
4
4
|
|
|
@@ -17,6 +17,15 @@ lint:
|
|
|
17
17
|
test:
|
|
18
18
|
bun test
|
|
19
19
|
|
|
20
|
+
test-e2e:
|
|
21
|
+
bun run ./scripts/e2e.ts $(ARGS)
|
|
22
|
+
|
|
23
|
+
test-e2e-local:
|
|
24
|
+
bun run ./scripts/e2e.ts --local $(ARGS)
|
|
25
|
+
|
|
26
|
+
test-e2e-prod:
|
|
27
|
+
bun run ./scripts/e2e.ts --prod $(ARGS)
|
|
28
|
+
|
|
20
29
|
create:
|
|
21
30
|
$(SERVICE) create
|
|
22
31
|
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
"gen": "bun run ./scripts/codegen.ts",
|
|
10
10
|
"lint": "tsc --noEmit",
|
|
11
11
|
"test": "bun test",
|
|
12
|
+
"test:e2e": "bun run ./scripts/e2e.ts",
|
|
13
|
+
"test:e2e:local": "bun run ./scripts/e2e.ts --local",
|
|
14
|
+
"test:e2e:prod": "bun run ./scripts/e2e.ts --prod",
|
|
12
15
|
"create": "service create",
|
|
13
16
|
"deploy": "service deploy",
|
|
14
17
|
"protect-main": "service protect-main",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.PHONY: dev migrate migrate-lint gen lint test create deploy protect-main dashboards observability-bootstrap auth destroy
|
|
1
|
+
.PHONY: dev migrate migrate-lint gen lint test test-e2e test-e2e-local test-e2e-prod create deploy protect-main dashboards observability-bootstrap auth destroy
|
|
2
2
|
|
|
3
3
|
SERVICE := service
|
|
4
4
|
WITH_ENV := set -a; [ ! -f .env.local ] || . ./.env.local; set +a;
|
|
@@ -27,6 +27,15 @@ lint:
|
|
|
27
27
|
test:
|
|
28
28
|
bun test ./test
|
|
29
29
|
|
|
30
|
+
test-e2e:
|
|
31
|
+
bun run ./scripts/e2e.ts $(ARGS)
|
|
32
|
+
|
|
33
|
+
test-e2e-local:
|
|
34
|
+
bun run ./scripts/e2e.ts --local $(ARGS)
|
|
35
|
+
|
|
36
|
+
test-e2e-prod:
|
|
37
|
+
bun run ./scripts/e2e.ts --prod $(ARGS)
|
|
38
|
+
|
|
30
39
|
create:
|
|
31
40
|
$(SERVICE) create
|
|
32
41
|
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
"gen": "make gen",
|
|
10
10
|
"lint": "make lint",
|
|
11
11
|
"test": "make test",
|
|
12
|
+
"test:e2e": "bun run ./scripts/e2e.ts",
|
|
13
|
+
"test:e2e:local": "bun run ./scripts/e2e.ts --local",
|
|
14
|
+
"test:e2e:prod": "bun run ./scripts/e2e.ts --prod",
|
|
12
15
|
"create": "service create",
|
|
13
16
|
"deploy": "service deploy",
|
|
14
17
|
"protect-main": "service protect-main",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.PHONY: dev migrate migrate-lint gen lint test create deploy protect-main dashboards observability-bootstrap auth destroy
|
|
1
|
+
.PHONY: dev migrate migrate-lint gen lint test test-e2e test-e2e-local test-e2e-prod create deploy protect-main dashboards observability-bootstrap auth destroy
|
|
2
2
|
|
|
3
3
|
SERVICE := service
|
|
4
4
|
WITH_ENV := set -a; [ ! -f .env.local ] || . ./.env.local; set +a;
|
|
@@ -28,6 +28,15 @@ lint:
|
|
|
28
28
|
test:
|
|
29
29
|
bun test ./test
|
|
30
30
|
|
|
31
|
+
test-e2e:
|
|
32
|
+
bun run ./scripts/e2e.ts $(ARGS)
|
|
33
|
+
|
|
34
|
+
test-e2e-local:
|
|
35
|
+
bun run ./scripts/e2e.ts --local $(ARGS)
|
|
36
|
+
|
|
37
|
+
test-e2e-prod:
|
|
38
|
+
bun run ./scripts/e2e.ts --prod $(ARGS)
|
|
39
|
+
|
|
31
40
|
create:
|
|
32
41
|
$(SERVICE) create
|
|
33
42
|
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
"gen": "make gen",
|
|
10
10
|
"lint": "make lint",
|
|
11
11
|
"test": "make test",
|
|
12
|
+
"test:e2e": "bun run ./scripts/e2e.ts",
|
|
13
|
+
"test:e2e:local": "bun run ./scripts/e2e.ts --local",
|
|
14
|
+
"test:e2e:prod": "bun run ./scripts/e2e.ts --prod",
|
|
12
15
|
"create": "service create",
|
|
13
16
|
"deploy": "service deploy",
|
|
14
17
|
"protect-main": "service protect-main",
|