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.
| b1be050 | 1 | /** |
| 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 | ||
| 19 | export 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 | ||
| 30 | export interface CheckResult { | |
| 31 | name: string; | |
| 32 | url: string; | |
| 33 | status: number; | |
| 34 | durationMs: number; | |
| 35 | ok: boolean; | |
| 36 | error?: string; | |
| 37 | } | |
| 38 | ||
| 39 | export 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 | ||
| 54 | export 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. */ | |
| 92 | export 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. */ | |
| 102 | export 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. */ | |
| 120 | export 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 | ||
| 127 | export 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 | ||
| 137 | export interface RunSummary { | |
| 138 | results: CheckResult[]; | |
| 139 | passed: number; | |
| 140 | failed: number; | |
| 141 | ok: boolean; | |
| 142 | } | |
| 143 | ||
| 144 | export 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 | ||
| 200 | export 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 | */ | |
| 228 | export 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 | */ | |
| 244 | export 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 | } |