Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

post-deploy-smoke.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

post-deploy-smoke.tsBlame247 lines · 1 contributor
b1be050CC LABS App1/**
2 * Post-deploy smoke suite — pure helpers + DI'd runner.
3 *
4 * Block S1+S3: after every `systemctl restart` we curl a list of critical
5 * endpoints and verify each returns the right status and shape. If ANY
6 * check fails, the workflow auto-rolls back to the previous good SHA.
7 *
8 * This file is the PURE side of that suite: the CHECKS array, the
9 * assertion helpers (assertStatus / assertKey / assertContains), the
10 * runner (which takes a `fetch` impl via DI so tests don't hit the
11 * network), and the migration-verification helper.
12 *
13 * The CLI driver lives in `scripts/post-deploy-smoke.ts`.
14 */
15/* eslint-disable @typescript-eslint/no-explicit-any */
16
17// ─── Types ──────────────────────────────────────────────────────────
18
19export interface Check {
20 name: string;
21 url: string;
22 /** One status, or an array of acceptable status codes. */
23 expectStatus: number | number[];
24 /** Required JSON key in the response body (when set, response must be JSON). */
25 expectKey?: string;
26 /** Required substring in the response body (text or JSON). */
27 expectContains?: string;
28}
29
30export interface CheckResult {
31 name: string;
32 url: string;
33 status: number;
34 durationMs: number;
35 ok: boolean;
36 error?: string;
37}
38
39export type FetchLike = (
40 url: string,
41 init?: { method?: string; headers?: Record<string, string> }
42) => Promise<{
43 status: number;
44 text: () => Promise<string>;
45}>;
46
47// ─── The 15-endpoint smoke list ─────────────────────────────────────
48//
49// Every endpoint a customer touches in their first 60 seconds must be
50// covered here. If a request crashes (e.g. selecting columns that
51// don't exist because migrations didn't run), the test fails and the
52// deploy rolls back automatically.
53
54export const CHECKS: readonly Check[] = [
55 { name: "healthz", url: "/healthz", expectStatus: 200, expectKey: "ok" },
56 { name: "readyz", url: "/readyz", expectStatus: 200 },
57 { name: "version", url: "/api/version", expectStatus: 200, expectKey: "sha" },
58 {
59 name: "login renders",
60 url: "/login",
61 expectStatus: 200,
62 expectContains: "Sign in",
63 },
64 {
65 name: "register renders",
66 url: "/register",
67 expectStatus: 200,
68 expectContains: "Create account",
69 },
70 { name: "landing renders", url: "/", expectStatus: 200 },
71 { name: "explore renders", url: "/explore", expectStatus: 200 },
72 { name: "demo renders", url: "/demo", expectStatus: [200, 302] },
73 { name: "pricing renders", url: "/pricing", expectStatus: 200 },
74 { name: "status renders", url: "/status", expectStatus: 200 },
75 // /api/v2 is the v2 surface; /api/v2/healthz isn't required to exist
76 // yet, so 404 is also acceptable.
77 { name: "api v2 health", url: "/api/v2/healthz", expectStatus: [200, 404] },
78 {
79 name: "mcp discovery",
80 url: "/mcp",
81 expectStatus: 200,
82 expectKey: "serverInfo",
83 },
84 { name: "manifest", url: "/manifest.webmanifest", expectStatus: 200 },
85 { name: "sw", url: "/sw.js", expectStatus: 200 },
86 { name: "dxt download", url: "/gluecron.dxt", expectStatus: 200 },
87];
88
89// ─── Pure assertion helpers ─────────────────────────────────────────
90
91/** Returns null on pass, an error string on fail. */
92export function assertStatus(
93 got: number,
94 expected: number | number[]
95): string | null {
96 const allowed = Array.isArray(expected) ? expected : [expected];
97 if (allowed.includes(got)) return null;
98 return `expected status ${allowed.join("/")}, got ${got}`;
99}
100
101/** Returns null on pass, an error string on fail. */
102export function assertKey(body: string, key: string): string | null {
103 // expectKey implies JSON. Parse defensively — non-JSON is a failure.
104 let parsed: unknown;
105 try {
106 parsed = JSON.parse(body);
107 } catch {
108 return `expected JSON with key "${key}", got non-JSON body`;
109 }
110 if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
111 return `expected JSON object with key "${key}", got ${typeof parsed}`;
112 }
113 if (!Object.prototype.hasOwnProperty.call(parsed, key)) {
114 return `expected JSON key "${key}", not present`;
115 }
116 return null;
117}
118
119/** Returns null on pass, an error string on fail. */
120export function assertContains(body: string, needle: string): string | null {
121 if (body.includes(needle)) return null;
122 return `expected body to contain ${JSON.stringify(needle)}`;
123}
124
125// ─── The runner ─────────────────────────────────────────────────────
126
127export interface RunOptions {
128 baseUrl: string;
129 fetchImpl: FetchLike;
130 checks?: readonly Check[];
131 /** Optional clock for deterministic durations in tests. */
132 now?: () => number;
133 /** Optional sink for human-readable progress lines. */
134 log?: (line: string) => void;
135}
136
137export interface RunSummary {
138 results: CheckResult[];
139 passed: number;
140 failed: number;
141 ok: boolean;
142}
143
144export async function runChecks(opts: RunOptions): Promise<RunSummary> {
145 const checks = opts.checks ?? CHECKS;
146 const now = opts.now ?? (() => Date.now());
147 const log = opts.log ?? (() => undefined);
148 const results: CheckResult[] = [];
149
150 for (const check of checks) {
151 const t0 = now();
152 let status = 0;
153 let body = "";
154 let fetchErr: string | undefined;
155 try {
156 const res = await opts.fetchImpl(opts.baseUrl + check.url);
157 status = res.status;
158 try {
159 body = await res.text();
160 } catch (err) {
161 body = "";
162 fetchErr = `body read failed: ${(err as Error).message}`;
163 }
164 } catch (err) {
165 fetchErr = `fetch failed: ${(err as Error).message}`;
166 }
167
168 const durationMs = now() - t0;
169 let error: string | null = fetchErr ?? null;
170 if (!error) error = assertStatus(status, check.expectStatus);
171 if (!error && check.expectKey) error = assertKey(body, check.expectKey);
172 if (!error && check.expectContains)
173 error = assertContains(body, check.expectContains);
174
175 const result: CheckResult = {
176 name: check.name,
177 url: check.url,
178 status,
179 durationMs,
180 ok: error === null,
181 ...(error !== null ? { error } : {}),
182 };
183 results.push(result);
184 log(
185 `[smoke] ${result.ok ? "PASS" : "FAIL"} ${check.name.padEnd(20)} ${String(status).padStart(3)} ${durationMs}ms${error ? " — " + error : ""}`
186 );
187 }
188
189 const failed = results.filter((r) => !r.ok).length;
190 return {
191 results,
192 passed: results.length - failed,
193 failed,
194 ok: failed === 0,
195 };
196}
197
198// ─── ASCII table for the workflow summary ───────────────────────────
199
200export function formatTable(results: readonly CheckResult[]): string {
201 const header = ["name", "status", "duration_ms", "result"];
202 const rows = results.map((r) => [
203 r.name,
204 String(r.status),
205 String(r.durationMs),
206 r.ok ? "PASS" : `FAIL: ${r.error ?? "?"}`,
207 ]);
208 const all = [header, ...rows];
209 const widths = header.map((_, col) =>
210 Math.max(...all.map((row) => row[col].length))
211 );
212 const fmt = (row: string[]) =>
213 row.map((cell, i) => cell.padEnd(widths[i])).join(" | ");
214 const sep = widths.map((w) => "-".repeat(w)).join("-+-");
215 return [fmt(header), sep, ...rows.map(fmt)].join("\n");
216}
217
218// ─── Migration verification helper ──────────────────────────────────
219
220/**
221 * Returns the list of migration file names that are present on disk
222 * but NOT present in the DB-applied list. Empty list means "all good".
223 *
224 * Both inputs are bare file names (e.g. "0053_deploy_steps.sql") so
225 * the caller is free to source them however they like (ls drizzle/*.sql
226 * for files, SELECT name FROM _migrations for applied).
227 */
228export function missingMigrations(
229 fileNames: readonly string[],
230 appliedNames: readonly string[]
231): string[] {
232 const applied = new Set(appliedNames);
233 return fileNames
234 .filter((name) => name.endsWith(".sql"))
235 .filter((name) => !applied.has(name))
236 .slice()
237 .sort();
238}
239
240/**
241 * Returns the latest migration file (lexicographic max .sql), or null
242 * if the input is empty.
243 */
244export function latestMigration(fileNames: readonly string[]): string | null {
245 const sql = fileNames.filter((n) => n.endsWith(".sql")).slice().sort();
246 return sql.length === 0 ? null : sql[sql.length - 1];
247}