Skip to content

Commit 26571fc

Browse files
Merge pull request #70 from CodeForPhilly/feat/hot-reload-webhook
feat(api): hot-reload webhook for the public data branch
2 parents d860590 + 10abee7 commit 26571fc

11 files changed

Lines changed: 993 additions & 11 deletions

File tree

apps/api/src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import { helpWantedRoutes } from './routes/projects-help-wanted.js';
5353
import { projectMembershipRoutes } from './routes/projects-members.js';
5454
import { previewRoutes } from './routes/preview.js';
5555
import { samlRoutes } from './routes/saml.js';
56+
import { internalRoutes } from './routes/internal.js';
5657

5758
declare module 'fastify' {
5859
interface FastifyInstance {
@@ -174,6 +175,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise<FastifyInsta
174175
await fastify.register(projectMembershipRoutes);
175176
await fastify.register(previewRoutes);
176177
await fastify.register(samlRoutes);
178+
await fastify.register(internalRoutes);
177179

178180
// Serve the OpenAPI JSON at the spec-mandated path /api/_openapi.json
179181
// (swagger-ui also exposes it at /api/_docs/json, but the spec names this path)

apps/api/src/env.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ export const EnvSchema = z.object({
1717
CFP_DATA_REMOTE: z.string().optional(),
1818
/** Branch the push daemon pushes to. Defaults to the repo's current HEAD. */
1919
CFP_DATA_BRANCH: z.string().optional(),
20+
/**
21+
* Shared bearer-token secret for the `POST /api/_internal/reload-data`
22+
* webhook (see specs/behaviors/storage.md#hot-reload). When unset, the
23+
* route is still registered but responds 503 — hot-reload is opt-in per
24+
* environment via the sealed Secret in the GitOps repo.
25+
*/
26+
CFP_DATA_RELOAD_SECRET: z.string().min(32).optional(),
2027
/** Which private-storage backend to use. */
2128
STORAGE_BACKEND: z.enum(['s3', 'filesystem']),
2229
/** Filesystem backend: absolute path to the private-storage directory. */
@@ -73,6 +80,7 @@ export const envJsonSchema = {
7380
CFP_DATA_REPO_PATH: { type: 'string' },
7481
CFP_DATA_REMOTE: { type: 'string' },
7582
CFP_DATA_BRANCH: { type: 'string' },
83+
CFP_DATA_RELOAD_SECRET: { type: 'string', minLength: 32 },
7684
STORAGE_BACKEND: { type: 'string', enum: ['s3', 'filesystem'] },
7785
CFP_PRIVATE_STORAGE_PATH: { type: 'string' },
7886
S3_ENDPOINT: { type: 'string' },

apps/api/src/routes/internal.ts

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
/**
2+
* Internal-only routes.
3+
*
4+
* Currently houses the hot-reload webhook documented in
5+
* `specs/behaviors/storage.md#hot-reload`:
6+
*
7+
* POST /api/_internal/reload-data
8+
* - Hidden from the public OpenAPI doc (`schema.hide: true`)
9+
* - Auth: `Authorization: Bearer <CFP_DATA_RELOAD_SECRET>` —
10+
* constant-time compare, length-checked first to avoid a different
11+
* early-exit timing side channel
12+
* - Body: optional `{ branch?: string, commitHash?: string }`
13+
* - Behavior:
14+
* 1. If `commitHash` is given AND already an ancestor of local
15+
* HEAD, return 200 noChanges without touching the lock or
16+
* the network (handles self-trigger from push-daemon pushes).
17+
* 2. Otherwise call `fastify.reconcileDataRepo({ branch })`
18+
* under the data-repo lock.
19+
* 3. If outcome === 'in-sync', skip the rebuild and return
20+
* 200 noChanges.
21+
* 4. Otherwise rebuild the in-memory state + FTS index in place,
22+
* invalidate the facet cache, and return 200 with
23+
* `rebuilt: true`.
24+
*
25+
* The route is registered unconditionally — when `CFP_DATA_RELOAD_SECRET`
26+
* is unset, requests get a 503 at request time. This keeps the
27+
* deployment surface stable across environments that haven't been
28+
* configured for hot reloads yet.
29+
*/
30+
import { execFile } from 'node:child_process';
31+
import { timingSafeEqual } from 'node:crypto';
32+
import { promisify } from 'node:util';
33+
34+
import type { FastifyInstance } from 'fastify';
35+
36+
import { errorResponse, ok } from '../lib/response.js';
37+
import { reloadInMemoryStateAndFts } from '../store/memory/reload.js';
38+
import type { ReconcileOutcome } from '../store/reconcile.js';
39+
40+
const exec = promisify(execFile);
41+
42+
/** Bearer-token regex — case-insensitive, single whitespace separator. */
43+
const BEARER_RE = /^Bearer\s+(\S+)$/i;
44+
45+
/**
46+
* Constant-time comparison of two strings. Returns false (without
47+
* decoding) when the lengths differ — comparing length is its own early
48+
* exit, but a length mismatch tells the attacker only the secret's
49+
* length, which we accept as a cheaper-than-real-world side channel.
50+
*/
51+
function safeEqualStrings(a: string, b: string): boolean {
52+
if (a.length !== b.length) return false;
53+
const ab = Buffer.from(a, 'utf8');
54+
const bb = Buffer.from(b, 'utf8');
55+
// Buffer.from('utf8') for ASCII tokens has length === string length,
56+
// so the lengths still match; defensive guard anyway.
57+
if (ab.length !== bb.length) return false;
58+
return timingSafeEqual(ab, bb);
59+
}
60+
61+
interface ReloadBody {
62+
readonly branch?: string;
63+
readonly commitHash?: string;
64+
}
65+
66+
const reloadBodySchema = {
67+
type: 'object',
68+
additionalProperties: false,
69+
properties: {
70+
branch: { type: 'string', minLength: 1, maxLength: 200 },
71+
commitHash: {
72+
type: 'string',
73+
// git supports abbreviated SHAs; allow 4-40 hex chars
74+
pattern: '^[0-9a-fA-F]{4,40}$',
75+
},
76+
},
77+
} as const;
78+
79+
export async function internalRoutes(fastify: FastifyInstance): Promise<void> {
80+
fastify.post<{ Body: ReloadBody | undefined }>(
81+
'/api/_internal/reload-data',
82+
{
83+
schema: {
84+
hide: true,
85+
body: reloadBodySchema,
86+
},
87+
},
88+
async (request, reply) => {
89+
const traceId = (request as typeof request & { traceId?: string }).traceId;
90+
const expected = fastify.config.CFP_DATA_RELOAD_SECRET;
91+
92+
// ---- Bearer auth (route refuses to do anything before this passes) ----
93+
const headerValue = request.headers['authorization'];
94+
const headerStr = Array.isArray(headerValue) ? headerValue[0] : headerValue;
95+
const match = typeof headerStr === 'string' ? BEARER_RE.exec(headerStr) : null;
96+
const provided = match?.[1];
97+
98+
if (!provided) {
99+
return reply
100+
.code(401)
101+
.send(errorResponse('unauthorized', 'Authentication required', traceId));
102+
}
103+
104+
// 503 takes precedence over a token-match check ONLY when the
105+
// operator hasn't even configured the secret. We still return 401
106+
// on a missing/empty header BEFORE checking the secret so that
107+
// unauthenticated probes don't get a different status code
108+
// depending on whether the env var is set. Order matters: header
109+
// present → check secret configured → check token equality.
110+
if (!expected) {
111+
return reply.code(503).send(
112+
errorResponse(
113+
'service_unavailable',
114+
'hot-reload not configured',
115+
traceId,
116+
),
117+
);
118+
}
119+
120+
if (!safeEqualStrings(provided, expected)) {
121+
return reply
122+
.code(401)
123+
.send(errorResponse('unauthorized', 'Authentication required', traceId));
124+
}
125+
126+
// ---- Resolve effective branch ----
127+
const body: ReloadBody = request.body ?? {};
128+
const branch = body.branch ?? fastify.config.CFP_DATA_BRANCH;
129+
if (!branch) {
130+
return reply
131+
.code(400)
132+
.send(
133+
errorResponse(
134+
'bad_request',
135+
'branch is required when CFP_DATA_BRANCH is unset',
136+
traceId,
137+
),
138+
);
139+
}
140+
141+
const startedAt = Date.now();
142+
const repoPath = fastify.config.CFP_DATA_REPO_PATH;
143+
const commitHash = body.commitHash;
144+
145+
// ---- Cheap pre-check: is `commitHash` already in local HEAD? ----
146+
// No lock acquired here — `merge-base --is-ancestor` only reads
147+
// git's object store, which is safe alongside an in-flight
148+
// gitsheets transact. Worst case (a transact lands between this
149+
// check and the answer being read) we accept a stale "no" and
150+
// proceed to the full reconcile.
151+
if (commitHash) {
152+
try {
153+
const head = (
154+
await exec('git', ['rev-parse', 'HEAD'], { cwd: repoPath })
155+
).stdout.trim();
156+
await exec(
157+
'git',
158+
['merge-base', '--is-ancestor', commitHash, head],
159+
{ cwd: repoPath },
160+
);
161+
// `git merge-base --is-ancestor` exits 0 = is-ancestor, 1 = not.
162+
// Reaching here means exit 0; short-circuit.
163+
fastify.log.info(
164+
{ branch, commitHash, head },
165+
'hot-reload short-circuit: commit already in local HEAD',
166+
);
167+
return reply.send(
168+
ok({
169+
noChanges: true,
170+
outcome: 'in-sync' as ReconcileOutcome,
171+
head,
172+
durationMs: Date.now() - startedAt,
173+
}),
174+
);
175+
} catch (err) {
176+
// exec throws with `code: 1` when not-ancestor (continue to
177+
// reconcile) and with other codes when the commit is unknown
178+
// or git itself fails. We treat all non-zero as "fall through
179+
// to reconcile" — the reconcile will fetch and try again.
180+
fastify.log.debug(
181+
{
182+
err: err instanceof Error ? err.message : String(err),
183+
branch,
184+
commitHash,
185+
},
186+
'hot-reload pre-check fell through to full reconcile',
187+
);
188+
}
189+
}
190+
191+
// ---- Reconcile under the data-repo lock ----
192+
const result = await fastify.reconcileDataRepo({ branch });
193+
194+
if (result.outcome === 'in-sync') {
195+
fastify.log.info(
196+
{ branch, commit: result.newCommit, outcome: result.outcome },
197+
'hot-reload: nothing to do (in-sync after fetch)',
198+
);
199+
return reply.send(
200+
ok({
201+
noChanges: true,
202+
outcome: result.outcome,
203+
oldCommit: result.oldCommit,
204+
newCommit: result.newCommit,
205+
durationMs: Date.now() - startedAt,
206+
}),
207+
);
208+
}
209+
210+
// ---- Rebuild ----
211+
try {
212+
await reloadInMemoryStateAndFts(fastify);
213+
} catch (err) {
214+
// The in-memory state + FTS index may be partially mutated.
215+
// Log loudly so the operator knows a pod restart is warranted,
216+
// then 500 the request.
217+
fastify.log.error(
218+
{
219+
err: err instanceof Error ? err.message : String(err),
220+
branch,
221+
outcome: result.outcome,
222+
oldCommit: result.oldCommit,
223+
newCommit: result.newCommit,
224+
},
225+
'hot-reload: in-memory rebuild failed AFTER reconcile — pod is in an undefined state, restart required',
226+
);
227+
return reply.code(500).send(
228+
errorResponse(
229+
'internal_error',
230+
'Hot-reload rebuild failed — pod restart required',
231+
traceId,
232+
),
233+
);
234+
}
235+
236+
const durationMs = Date.now() - startedAt;
237+
fastify.log.info(
238+
{
239+
branch,
240+
outcome: result.outcome,
241+
oldCommit: result.oldCommit,
242+
newCommit: result.newCommit,
243+
conflictBranch: result.conflictBranch,
244+
durationMs,
245+
},
246+
'hot-reload: in-memory state + FTS rebuilt',
247+
);
248+
249+
return reply.send(
250+
ok({
251+
noChanges: false,
252+
rebuilt: true,
253+
outcome: result.outcome,
254+
oldCommit: result.oldCommit,
255+
newCommit: result.newCommit,
256+
...(result.conflictBranch ? { conflictBranch: result.conflictBranch } : {}),
257+
durationMs,
258+
}),
259+
);
260+
},
261+
);
262+
}

0 commit comments

Comments
 (0)