forked from CopilotKit/CopilotKit
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathverify-deploy.ts
More file actions
361 lines (343 loc) · 12.8 KB
/
Copy pathverify-deploy.ts
File metadata and controls
361 lines (343 loc) · 12.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
#!/usr/bin/env npx tsx
/**
* verify-deploy.ts — Parameterized per-env probe driven off
* railway-envs.ts SSOT.
*
* Usage:
* npx tsx showcase/scripts/verify-deploy.ts --env <staging|prod>
* [--services <csv>]
*
* Behavior:
* - Iterates SERVICES from the SSOT; for every entry where
* probe[env] === true, runs the per-driver feature-level verifier
* against domainFor(name, env). HTTP 200 is necessary, not sufficient.
* - Refuses to start if a probe-required service has no domain for
* the requested env (fail loud; no silent skip).
* - Exits 0 only when every probed service is green. Any red → exit 1.
*
* Drivers live in showcase/scripts/verify-deploy.drivers.ts and dispatch on
* ProbeDriver. The driver is feature-level (DOM string + known network call
* for shells; fixture replay for aimock; admin login for pocketbase; etc.).
*/
import { runDriver } from "./verify-deploy.drivers";
import type { ProbeRunner } from "./verify-deploy.drivers";
import { SERVICES, domainFor, probeEnabled, resolveEnv } from "./railway-envs";
import type { EnvName, ProbeDriver } from "./railway-envs";
export interface ParsedArgs {
env: EnvName;
services?: string[];
}
export function parseArgs(argv: string[]): ParsedArgs {
let envRaw: string | undefined;
let services: string[] | undefined;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--env") {
const v = argv[++i];
// Guard a bare trailing `--env` (undefined) here so the
// operator gets a precise, flag-named diagnostic instead of
// the downstream `resolveEnv` "Unknown env" / "--env required"
// surface. Mirrors the same guard on `--services`.
if (v === undefined) {
throw new Error("--env requires a value (staging|prod)");
}
envRaw = v;
} else if (a.startsWith("--env=")) {
const v = a.slice("--env=".length);
// `--env=` (empty post-equals) is the equals-form twin of the
// bare-trailing case above; collapse both to the same precise
// error rather than deferring to `resolveEnv`.
if (v === "") {
throw new Error("--env requires a value (staging|prod)");
}
envRaw = v;
} else if (a === "--services") {
const v = argv[++i];
if (!v) throw new Error("--services requires a CSV value");
services = v
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// Symmetry with the equals-form below: a raw value like `,,`
// or `" "` survives the `!v` guard but produces an empty
// post-filter list. Throw the same precise error here so both
// forms behave identically.
if (services.length === 0) {
throw new Error("--services requires a CSV value");
}
} else if (a.startsWith("--services=")) {
const v = a.slice("--services=".length);
if (!v) throw new Error("--services requires a CSV value");
services = v
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (services.length === 0) {
throw new Error("--services requires a CSV value");
}
} else {
throw new Error(`Unknown argument: ${a}`);
}
}
if (!envRaw) {
throw new Error("--env is required (staging|prod)");
}
const { env } = resolveEnv(envRaw);
return services === undefined ? { env } : { env, services };
}
/**
* Branded host type for `ProbeTarget.host`.
*
* A `Host` is a bare hostname literal (no scheme, no path, no slash) —
* exactly the shape `domainFor()` is documented to return. The brand is
* structural only: at runtime a `Host` IS a string, so it is assignable
* to any `string`-typed parameter with no runtime cost. The point is to
* prevent the inverse direction — a caller cannot hand a `ProbeTarget` a
* scheme-included or path-bearing string without going through
* `asHost()`, which validates fail-loud.
*
* The brand uses a non-exported `unique symbol`, so a stray
* `"foo" as Host` cast from outside this module is a type error — the
* brand symbol is not in scope. `asHost()` is the sole legitimate
* constructor.
*
* Co-located with `ProbeTarget` (the only structural consumer) rather
* than in `railway-envs.ts` to keep the brand at the verify-pipeline
* boundary; `domainFor()` keeps its `string` return type and we validate
* + brand at the point of ingress in `resolveProbeTargets`.
*/
declare const HostBrand: unique symbol;
export type Host = string & { readonly [HostBrand]: true };
/**
* Validate + brand a bare hostname literal as a `Host`. Rejects, with a
* precise diagnostic per case:
* - any scheme separator (`://`)
* - any slash (path component)
* - the empty string
* - leading/trailing whitespace
* - `@` (userinfo), `?` (query), `#` (fragment)
* - any ASCII control character (`\x00-\x1f`, `\x7f` — e.g. `\n`, `\r`)
* - any `:` character (typically a `:port` suffix — bare hostnames
* from `domainFor` never contain a colon; ports are not part of
* the contract)
* - any character outside the DNS-label charset `[A-Za-z0-9.-]`
* (positive shape check — rejects unicode, `_`, `!`, etc.)
*
* The verify-deploy pipeline never wants any of these — drivers always
* build URLs as `https://${host}${path}`, so a host carrying any of
* the above would produce malformed URLs at the driver boundary.
*
* Overlap with `domainFor()` is partial, not full: `domainFor` re-checks
* scheme + empty on the normal SSOT path, but does NOT check path/slash
* or the whitespace/userinfo/query/fragment/control/port/charset cases
* that `asHost` rejects. The override seam in `resolveProbeTargets`
* (test-only path that bypasses `domainFor` entirely) is what makes the
* `asHost` call mandatory — it is the sole ingress that gets to skip
* `domainFor`'s checks.
*/
export function asHost(value: string): Host {
if (value.includes("://")) {
throw new Error(`asHost: host must not include a scheme (got "${value}")`);
}
if (value.includes("/")) {
throw new Error(
`asHost: host must not include a path or slash (got "${value}")`,
);
}
if (value.length === 0) {
throw new Error(`asHost: host must not be empty`);
}
if (value !== value.trim()) {
throw new Error(
`asHost: host must not have leading/trailing whitespace (got "${value}")`,
);
}
if (value.includes("@")) {
throw new Error(
`asHost: host must not include userinfo "@" (got "${value}")`,
);
}
if (value.includes("?")) {
throw new Error(
`asHost: host must not include a query "?" (got "${value}")`,
);
}
if (value.includes("#")) {
throw new Error(
`asHost: host must not include a fragment "#" (got "${value}")`,
);
}
// eslint-disable-next-line no-control-regex
if (/[\x00-\x1f\x7f]/.test(value)) {
throw new Error(
`asHost: host must not include control characters (got ${JSON.stringify(value)})`,
);
}
// Port suffix: verify-pipeline hosts from `domainFor` are bare
// hostnames. A `:port` here would produce `https://host:port/path`
// — well-formed but outside the contract — so reject it loudly so
// callers feed a bare hostname (the SSOT shape).
if (value.includes(":")) {
throw new Error(
`asHost: host must not include a port suffix (got "${value}")`,
);
}
// Positive DNS-label charset check. Anchored so any single invalid
// character (space, `_`, unicode, etc.) is rejected. Combined with
// the negative rules above, this leaves only `[A-Za-z0-9.-]+`.
if (!/^[A-Za-z0-9.-]+$/.test(value)) {
throw new Error(
`asHost: host must match DNS-label charset [A-Za-z0-9.-] (got "${value}")`,
);
}
return value as Host;
}
export interface ProbeTarget {
readonly name: string;
readonly host: Host;
readonly driver: ProbeDriver;
}
export interface ResolveOpts {
env: EnvName;
services?: string[];
/**
* Test seam: shallow-merge a partial entry over the SSOT before resolve.
* `domains` is keyed by env name (matches the open `EnvName`); callers
* supply at least the env under test.
*/
overrides?: Record<string, { domains?: Record<EnvName, string> }>;
}
export function resolveProbeTargets(opts: ResolveOpts): ProbeTarget[] {
const targets: ProbeTarget[] = [];
const filter = opts.services ? new Set(opts.services) : undefined;
// Validate user-supplied service names against the SSOT BEFORE
// filtering — a typo (`docss`) or a name that's not probe-eligible
// for the target env must surface as a clear, distinct error, not a
// silent zero-targets vacuous green.
if (filter) {
for (const name of filter) {
const entry = SERVICES[name];
if (!entry) {
throw new Error(
`unknown service "${name}" (not in SSOT). Run \`bin/showcase services\` to list known names.`,
);
}
if (!probeEnabled(name, opts.env)) {
throw new Error(
`service "${name}" is not probe-eligible for env "${opts.env}" (probe.${opts.env}=false in SSOT)`,
);
}
}
}
for (const [name, entry] of Object.entries(SERVICES)) {
if (filter && !filter.has(name)) continue;
if (!probeEnabled(name, opts.env)) continue;
const overrideDomains = opts.overrides?.[name]?.domains;
const rawHost = overrideDomains
? overrideDomains[opts.env]
: domainFor(name, opts.env);
if (!rawHost) {
throw new Error(
`Service "${name}" is probe-required for env "${opts.env}" but is missing a ${opts.env} domain.`,
);
}
// Brand at the verify-pipeline ingress so every downstream driver
// receives a `Host` (not a raw `string`). On the normal path
// `domainFor` already enforces scheme + empty checks, and `asHost`
// re-validates those (cheap redundancy). The checks that ONLY exist
// here — path/slash, leading/trailing whitespace, `@`/`?`/`#`,
// control chars, port suffix, DNS-label charset — are mandatory
// because the override seam above (`overrideDomains`, test-only)
// bypasses `domainFor` entirely; `asHost` is the sole gate that
// catches those for both paths.
const host = asHost(rawHost);
targets.push({ name, host, driver: entry.probeDriver });
}
return targets.sort((a, b) => a.name.localeCompare(b.name));
}
export interface VerifyOpts {
env: EnvName;
services?: string[];
runner?: ProbeRunner;
}
export interface VerifySummary {
env: EnvName;
passed: Array<{ name: string }>;
failed: Array<{ name: string; error: string }>;
exitCode: number;
}
export async function runVerify(opts: VerifyOpts): Promise<VerifySummary> {
const targets = resolveProbeTargets({
env: opts.env,
services: opts.services,
});
const runner = opts.runner ?? runDriver;
const passed: Array<{ name: string }> = [];
const failed: Array<{ name: string; error: string }> = [];
// Zero-targets is NEVER a success. A verify gate that prints
// "targets=0" and exits 0 is the worst outcome — a vacuous green.
// Fail loud with a clear diagnostic so the operator knows the run
// verified nothing.
if (targets.length === 0) {
const error =
`no probe-required services resolved for env "${opts.env}" — ` +
`check --services and SSOT probe flags`;
process.stdout.write(`verify-deploy --env=${opts.env} targets=0 (FAIL)\n`);
process.stdout.write(` ${error}\n`);
return {
env: opts.env,
passed,
failed: [{ name: "(zero-targets)", error }],
exitCode: 1,
};
}
process.stdout.write(
`verify-deploy --env=${opts.env} targets=${targets.length}\n`,
);
for (const target of targets) {
process.stdout.write(` ${target.name.padEnd(36)} ${target.host} `);
try {
const outcome = await runner(target);
if (outcome.ok) {
passed.push({ name: target.name });
process.stdout.write("OK\n");
} else {
failed.push({ name: target.name, error: outcome.error });
process.stdout.write(`FAIL: ${outcome.error}\n`);
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
failed.push({ name: target.name, error: msg });
process.stdout.write(`FAIL (threw): ${msg}\n`);
}
}
return {
env: opts.env,
passed,
failed,
exitCode: failed.length === 0 ? 0 : 1,
};
}
async function main(): Promise<void> {
const parsed = parseArgs(process.argv.slice(2));
const summary = await runVerify(parsed);
if (summary.failed.length > 0) {
process.stderr.write(
`\n${summary.failed.length} service(s) failed verify in ${summary.env}:\n`,
);
for (const f of summary.failed) {
process.stderr.write(` - ${f.name}: ${f.error}\n`);
}
}
process.exit(summary.exitCode);
}
const isMain = process.argv[1]?.endsWith("verify-deploy.ts");
if (isMain) {
main().catch((e) => {
process.stderr.write(
`verify-deploy crashed: ${e instanceof Error ? e.message : String(e)}\n`,
);
process.exit(2);
});
}
export type { ProbeRunner } from "./verify-deploy.drivers";