create-svc 0.1.86 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-svc",
3
- "version": "0.1.86",
3
+ "version": "0.1.87",
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",
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",
@@ -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}}
@@ -60,6 +62,21 @@ Local runtime uses:
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.
@@ -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,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",