Repository navigation
Expand file tree
/
Copy pathdeploy-canary.mjs
More file actions
820 lines (765 loc) · 34.5 KB
/
Copy pathdeploy-canary.mjs
File metadata and controls
820 lines (765 loc) · 34.5 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
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
#!/usr/bin/env node
//
// Deploy HEAD to Google App Engine as a NON-TRAFFIC canary, then authorize the
// canary's versioned host in Firebase so the operator can exercise the REAL
// product end-to-end -- including Google sign-in -- before switching 100% of
// production traffic.
//
// Why this script exists:
// - `gcloud app deploy --no-promote` creates a version reachable only at a
// versioned `*.appspot.com` URL (e.g. https://<version>-dot-<project>...).
// That host is NOT in Firebase's Authorized domains list, so Firebase OAuth
// (signInWithRedirect via auth.simlin.com) rejects the calling origin and the
// operator cannot actually log in to test the canary.
// - Login is gated TWICE: the Identity Toolkit `authorizedDomains` list (the
// OAuth origin check) AND the browser API key's HTTP-referrer allowlist,
// which blocks identitytoolkit requests from unlisted hosts with
// auth/requests-from-referer-...-are-blocked before OAuth even starts
// (issue #967). The fix is to add the canary's host to BOTH for the
// duration of the test, then remove it from both.
//
// This deliberately uses the OPERATOR's own credentials (`gcloud auth
// print-access-token`), NOT the CI deploy service account: mutating Firebase
// auth config requires roles/firebaseauth.admin, which is an operator-level
// grant we intentionally keep off the CI deploy SA.
//
// It does NOT promote traffic. Promoting is a separate, explicit command the
// script prints for you to run after the smoke test passes.
//
// Usage:
// node scripts/deploy-canary.mjs [--project <id>] # deploy canary + authorize
// node scripts/deploy-canary.mjs --cleanup <version> [--project <id>]
//
// The build/deploy itself is delegated to scripts/deploy-web-staged.sh (the
// self-contained staged deploy); this script adds only the --no-promote canary
// orchestration + Firebase authorized-domain management around it.
//
// ---------------------------------------------------------------------------
// The file is split into a PURE CORE (exported, unit-tested in
// scripts/tests/deploy-canary.test.mjs) and an IMPERATIVE SHELL (all the
// gcloud/fetch side effects). main() runs only when the file is executed
// directly, so importing it for tests has no side effects.
// ---------------------------------------------------------------------------
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { fileURLToPath, pathToFileURL } from 'node:url';
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..');
const IDENTITY_TOOLKIT_BASE = 'https://identitytoolkit.googleapis.com/admin/v2';
const API_KEYS_BASE = 'https://apikeys.googleapis.com/v2';
// Where the shipped SPA hardcodes its Firebase web API key, which is public by
// design and referrer-restricted (issue #381).
const FIREBASE_CONFIG_SOURCE = path.join(REPO_ROOT, 'src/app/App.tsx');
// ===========================================================================
// PURE CORE -- no I/O, deterministic, unit-tested.
// ===========================================================================
/**
* Reduce a URL (or already-bare host) to its bare hostname: scheme, path,
* query, and any port are stripped. Firebase `authorizedDomains` entries are
* bare hostnames (the OAuth check compares window.location.hostname), so the
* port is intentionally dropped. Accepts input without a scheme so a host can
* be passed straight through. Throws on empty/non-string input.
*/
export function hostFromUrl(input) {
if (typeof input !== 'string' || input.trim() === '') {
throw new Error('hostFromUrl: expected a non-empty URL or host string');
}
const trimmed = input.trim();
// Give the URL parser a scheme to chew on when one is absent, so a bare host
// like "foo.appspot.com" parses instead of throwing.
const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed);
const url = new URL(hasScheme ? trimmed : `https://${trimmed}`);
return url.hostname;
}
/**
* Return a new authorized-domains list that includes `host`, deduped and with
* existing order preserved. A no-op (returns an equivalent deduped copy) when
* `host` is already present. Never mutates the input.
*
* This is the read side of the read-modify-write that guarantees the PATCH
* never wipes the existing list: the caller passes the full current list (from
* a successful GET); this appends to it rather than replacing it.
*/
export function addAuthorizedDomain(domains, host) {
const base = Array.isArray(domains) ? domains : [];
const seen = new Set();
const out = [];
for (const d of [...base, host]) {
if (!seen.has(d)) {
seen.add(d);
out.push(d);
}
}
return out;
}
/**
* Return a new authorized-domains list with every occurrence of `host`
* removed. A no-op (returns a copy) when `host` is absent. Never mutates the
* input. Like addAuthorizedDomain, this operates on the FULL current list so
* the resulting PATCH replaces the field without losing other entries.
*/
export function removeAuthorizedDomain(domains, host) {
const base = Array.isArray(domains) ? domains : [];
return base.filter((d) => d !== host);
}
/**
* Extract the Firebase web API key string from app source (src/app/App.tsx
* hardcodes it in the firebase `config` literal). The canary flow needs the
* KEY STRING to find the corresponding GCP API key via keys:lookupKey -- that
* key's HTTP-referrer restrictions are a second login gate on the canary host,
* independent of Firebase authorizedDomains. Returns undefined when absent.
*/
export function extractFirebaseApiKey(source) {
if (typeof source !== 'string') return undefined;
const m = source.match(/apiKey:\s*['"]([^'"]+)['"]/);
return m ? m[1] : undefined;
}
/**
* The API-key referrer entry for a host. Referrer restrictions are URL
* patterns, not bare hostnames like authorizedDomains, so the whole-site form
* is `<host>/*`. Throws on empty input (a blank pattern would be meaningless
* and a `/*` entry would allow everything).
*/
export function referrerForHost(host) {
if (typeof host !== 'string' || host.trim() === '') {
throw new Error('referrerForHost: expected a non-empty host');
}
return `${host.trim()}/*`;
}
/**
* New `restrictions` object for an API key with `referrer` added to
* browserKeyRestrictions.allowedReferrers, preserving apiTargets and any other
* sibling fields. Returns null when no PATCH is needed OR safe:
* - the referrer is already present (idempotent re-run), or
* - the key has no non-empty referrer allowlist. An unrestricted key does not
* block the canary, and PATCHing a list onto it would RESTRICT a key that
* was deliberately open -- never do that as a side effect.
* Never mutates the input.
*/
export function addAllowedReferrer(restrictions, referrer) {
const current = restrictions?.browserKeyRestrictions?.allowedReferrers;
if (!Array.isArray(current) || current.length === 0) return null;
if (current.includes(referrer)) return null;
return {
...restrictions,
browserKeyRestrictions: {
...restrictions.browserKeyRestrictions,
allowedReferrers: addAuthorizedDomain(current, referrer),
},
};
}
/**
* New `restrictions` object with `referrer` removed from
* browserKeyRestrictions.allowedReferrers, preserving everything else. Returns
* null when no PATCH is needed OR safe:
* - the referrer is absent (idempotent re-run), or
* - removing it would EMPTY the list, which the API treats as "no referrer
* restriction at all" -- cleanup must never silently un-restrict the key.
* Never mutates the input.
*/
export function removeAllowedReferrer(restrictions, referrer) {
const current = restrictions?.browserKeyRestrictions?.allowedReferrers;
if (!Array.isArray(current) || !current.includes(referrer)) return null;
const next = removeAuthorizedDomain(current, referrer);
if (next.length === 0) return null;
return {
...restrictions,
browserKeyRestrictions: {
...restrictions.browserKeyRestrictions,
allowedReferrers: next,
},
};
}
/** First non-blank, trimmed line of text (or '' for blank/non-string input). */
export function firstNonEmptyLine(text) {
if (typeof text !== 'string') return '';
for (const line of text.split('\n')) {
const t = line.trim();
if (t !== '') return t;
}
return '';
}
/**
* Extract the first http(s) URL from arbitrary command output. `gcloud app
* browse --no-launch-browser` prints a human sentence followed by the URL, so
* we cannot just trim the whole output. Returns undefined when none is found.
*/
export function extractUrl(text) {
if (typeof text !== 'string') return undefined;
const m = text.match(/https?:\/\/\S+/);
return m ? m[0] : undefined;
}
/**
* Fraction of `default`-service traffic currently allocated to `versionId`,
* read from the `split.allocations` map (versionId -> fraction) returned by
* `gcloud app services describe`. Returns 0 when the version is absent or the
* map is missing/malformed, and treats a non-finite-number allocation as 0
* (never truthy) so the caller's serving check can't be fooled by a string.
*
* Cleanup uses this to REFUSE to delete a version that is serving traffic: once
* the operator promotes the canary, it IS production, and deleting it is a full
* outage. De-authorizing the host is always safe; only the delete is dangerous.
*/
export function versionTrafficShare(allocations, versionId) {
if (!allocations || typeof allocations !== 'object') return 0;
const share = allocations[versionId];
return typeof share === 'number' && Number.isFinite(share) ? share : 0;
}
/**
* Parse argv (without node/script) into { mode, project, version }.
* mode: 'deploy' (default) | 'cleanup' | 'help'
* Supports `--flag value` and `--flag=value`. Throws on unknown flags and on
* --cleanup without a version id (failing loud beats a confusing later error).
*/
export function parseArgs(argv) {
const args = { mode: 'deploy', project: undefined, version: undefined };
const list = [...argv];
while (list.length > 0) {
const token = list.shift();
const eq = token.startsWith('--') ? token.indexOf('=') : -1;
const flag = eq >= 0 ? token.slice(0, eq) : token;
const inlineVal = eq >= 0 ? token.slice(eq + 1) : undefined;
// Do not consume a following flag as this flag's value: `--cleanup
// --project p` must leave version unset (and report the missing version),
// not swallow `--project`.
const takeVal = () => {
if (inlineVal !== undefined) return inlineVal;
return list.length > 0 && !list[0].startsWith('--') ? list.shift() : undefined;
};
switch (flag) {
case '--cleanup':
args.mode = 'cleanup';
args.version = takeVal();
break;
case '--project': {
// Fail loud on a missing/blank value: this tool deploys and mutates
// PROD Firebase config, so `--project $EMPTY` (or `--project --cleanup`)
// must error rather than silently fall back to the gcloud default and
// operate on the wrong project. Omitting --project entirely is still
// fine -- that intentionally uses the env var / gcloud default.
const value = takeVal();
if (value === undefined || value.trim() === '') {
throw new Error('--project requires a value, e.g. --project my-gcp-project');
}
args.project = value;
break;
}
case '--help':
case '-h':
args.mode = 'help';
break;
default:
throw new Error(`unknown argument: ${token}`);
}
}
if (args.mode === 'cleanup' && (!args.version || args.version.startsWith('--'))) {
throw new Error('--cleanup requires a version id, e.g. --cleanup 20260627t123456');
}
return args;
}
// ===========================================================================
// IMPERATIVE SHELL -- gcloud + fetch side effects. Kept deliberately small and
// obvious; the testable logic lives in the pure core above.
// ===========================================================================
function die(msg) {
console.error(`\nERROR: ${msg}`);
process.exit(1);
}
/**
* Run a command, inheriting stdio by default so the operator watches progress.
* With { capture: true } stdout is returned as a string (stderr still streams).
* Fails fast with a clear message on a missing binary or non-zero exit.
*/
function run(cmd, cmdArgs, { capture = false } = {}) {
const res = spawnSync(cmd, cmdArgs, {
cwd: REPO_ROOT,
encoding: 'utf8',
stdio: capture ? ['inherit', 'pipe', 'inherit'] : 'inherit',
});
if (res.error) {
if (res.error.code === 'ENOENT') {
const hint = cmd === 'gcloud' ? ' Install the Google Cloud SDK and authenticate: gcloud auth login.' : '';
die(`'${cmd}' was not found on PATH.${hint}`);
}
die(`failed to run ${cmd}: ${res.error.message}`);
}
if (res.status !== 0) {
die(`\`${cmd} ${cmdArgs.join(' ')}\` exited with status ${res.status}`);
}
return capture ? res.stdout : '';
}
/** Resolve the GCP project: explicit override wins, else the gcloud default. */
function resolveProject(override) {
if (override) return override;
const project = firstNonEmptyLine(run('gcloud', ['config', 'get-value', 'project'], { capture: true }));
if (!project || project === '(unset)') {
die('no gcloud project configured. Pass --project <id> or run: gcloud config set project <id>');
}
return project;
}
/** The operator's own OAuth access token (NOT the CI deploy SA's). */
function accessToken() {
const token = firstNonEmptyLine(run('gcloud', ['auth', 'print-access-token'], { capture: true }));
if (!token) {
die('could not obtain an access token. Run: gcloud auth login');
}
return token;
}
/** Build + deploy the staged server with --no-promote (traffic stays put). */
function deployStagedNoPromote(project) {
console.log('\n==> Building and deploying the staged server with --no-promote (no traffic switch)\n');
// Reuse the proven staged build/deploy orchestration; do NOT pass --version,
// so gcloud auto-generates the version id we then discover below. Pass the
// resolved --project through (deploy-web-staged.sh forwards "$@" to
// `gcloud app deploy`): otherwise an operator who selected a non-default
// project via --project/SIMLIN_CANARY_PROJECT would deploy HEAD to the
// AMBIENT gcloud project while the version lookup and Firebase mutation below
// target the selected one -- a split-brain that could deploy to the wrong
// project. resolveProject() always yields a concrete id, so this is also
// correct (and harmlessly explicit) in the default case.
run('bash', [path.join(REPO_ROOT, 'scripts/deploy-web-staged.sh'), '--no-promote', `--project=${project}`]);
}
/**
* The id of the most-recently-created `default` version. We query by createTime
* immediately after our deploy rather than scraping the deploy output (gcloud's
* human output format is not a stable contract). Tradeoff: if another deploy of
* the same service races this one, the newest version could be theirs -- this
* is a single-operator manual canary tool, so that race is acceptable; the
* printed URL lets the operator sanity-check the id before promoting.
*/
function latestVersionId(project) {
const out = run(
'gcloud',
[
'app',
'versions',
'list',
'--service=default',
'--sort-by=~version.createTime',
'--limit=1',
'--format=value(id)',
`--project=${project}`,
],
{ capture: true },
);
const id = firstNonEmptyLine(out);
if (!id) {
die('could not determine the deployed version id from `gcloud app versions list`');
}
return id;
}
/**
* The canary's URL + bare host, via `gcloud app browse` (region-aware -- we do
* NOT hand-construct the appspot host, since the region segment varies).
*/
function versionUrlAndHost(project, id) {
const out = run(
'gcloud',
['app', 'browse', '--no-launch-browser', '--service=default', `--version=${id}`, `--project=${project}`],
{ capture: true },
);
const url = extractUrl(out);
if (!url) {
die(`could not extract the canary URL from \`gcloud app browse\` output:\n${out}`);
}
return { url, host: hostFromUrl(url) };
}
/**
* Delete an idle canary version (cleanup). We delete rather than `stop` because
* `gcloud app versions stop` only works for manual/basic scaling, and prod uses
* automatic_scaling (app.yaml) -- a stop would error out, leaving the version
* (and its slot toward the GAE version cap) behind. delete works for any scaling
* and reclaims the slot. The caller guarantees this version serves no traffic
* (gcloud also refuses to delete a version that is receiving traffic). gcloud
* prompts for confirmation; stdio is inherited so the operator confirms.
*/
function deleteVersion(project, id) {
run('gcloud', ['app', 'versions', 'delete', id, '--service=default', `--project=${project}`]);
}
/**
* The `default` service's current traffic split as a { versionId: fraction }
* map. Read from `gcloud app services describe` so cleanup can avoid deleting a
* version that is serving traffic. Returns {} if the service has no split yet.
*/
function serviceTrafficAllocations(project) {
const out = run('gcloud', ['app', 'services', 'describe', 'default', '--format=json', `--project=${project}`], {
capture: true,
});
let parsed;
try {
parsed = JSON.parse(out);
} catch {
die(`could not parse \`gcloud app services describe default\` JSON output:\n${out}`);
}
return parsed?.split?.allocations ?? {};
}
/**
* Auth headers for Google REST API calls (Identity Toolkit, API Keys). Tokens
* from `gcloud auth print-access-token` are user credentials minted via the
* gcloud CLI's own OAuth client, so without an explicit quota project Google
* attributes API quota to THAT client's project (where these APIs are not
* enabled) and the call 403s with SERVICE_DISABLED. `x-goog-user-project` pins
* quota to the target project instead; it requires serviceusage.services.use
* there, which the operator (owner/editor) has.
*/
function googleAuthHeaders(project, token) {
return { authorization: `Bearer ${token}`, 'x-goog-user-project': project };
}
/**
* GET the project's Identity Toolkit config. This MUST succeed before any
* PATCH: the read-modify-write below depends on a real current list, never an
* assumed-empty one. Returns the parsed config object.
*/
async function getIdentityConfig(project, token) {
const res = await fetch(`${IDENTITY_TOOLKIT_BASE}/projects/${encodeURIComponent(project)}/config`, {
headers: googleAuthHeaders(project, token),
});
if (!res.ok) {
const body = await res.text();
const hint =
res.status === 403
? ' (does your account have roles/firebaseauth.admin? this needs the operator creds, not the CI deploy SA)'
: '';
die(`Identity Toolkit GET config failed: ${res.status} ${res.statusText}${hint}\n${body}`);
}
// Parse defensively: a 200 whose body isn't JSON is a real (if rare) failure
// we want to surface clearly, not a confusing low-level throw.
const text = await res.text();
try {
return JSON.parse(text);
} catch {
die(`Identity Toolkit GET config returned ${res.status} with a non-JSON body:\n${text}`);
}
}
/**
* PATCH authorizedDomains with `?updateMask=authorizedDomains`. The mask scopes
* the write to exactly that field, but the API REPLACES the whole repeated
* field with the body's list -- so `domains` MUST be the full desired list
* (current entries + the change), which the pure add/remove helpers produce
* from a freshly GET-ed current list. Sending only the new host here would wipe
* localhost / firebaseapp.com / app.simlin.com.
*/
async function patchAuthorizedDomains(project, token, domains) {
const res = await fetch(
`${IDENTITY_TOOLKIT_BASE}/projects/${encodeURIComponent(project)}/config?updateMask=authorizedDomains`,
{
method: 'PATCH',
headers: { ...googleAuthHeaders(project, token), 'content-type': 'application/json' },
body: JSON.stringify({ authorizedDomains: domains }),
},
);
if (!res.ok) {
const body = await res.text();
die(`Identity Toolkit PATCH config failed: ${res.status} ${res.statusText}\n${body}`);
}
// The response body is discarded, so do NOT parse it: a successful PATCH whose
// body isn't JSON must not be reported as a failure AFTER the change applied.
}
/**
* Shared fetch wrapper for Google REST APIs: attaches the operator token and
* the x-goog-user-project quota header, and turns any non-2xx into a die()
* with the response body (Google error bodies name the missing role/API).
*/
async function googleApi(url, project, token, { method = 'GET', body } = {}) {
const res = await fetch(url, {
method,
headers: {
...googleAuthHeaders(project, token),
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
if (!res.ok) {
die(`${method} ${url} failed: ${res.status} ${res.statusText}\n${text}`);
}
try {
return JSON.parse(text);
} catch {
die(`${method} ${url} returned ${res.status} with a non-JSON body:\n${text}`);
}
}
/**
* Resolve the browser API key the shipped SPA authenticates with: read the key
* STRING out of src/app/App.tsx, then keys:lookupKey to its GCP resource name.
* This targets exactly the key whose referrer restrictions gate canary login
* (Firebase's auto-created browser key), never a lookalike picked by display
* name.
*/
async function lookupBrowserKey(project, token) {
let source;
try {
source = readFileSync(FIREBASE_CONFIG_SOURCE, 'utf8');
} catch (err) {
die(`could not read ${FIREBASE_CONFIG_SOURCE}: ${err.message}`);
}
const keyString = extractFirebaseApiKey(source);
if (!keyString) {
die(`could not find the firebase apiKey in ${FIREBASE_CONFIG_SOURCE}`);
}
const lookup = await googleApi(
`${API_KEYS_BASE}/keys:lookupKey?keyString=${encodeURIComponent(keyString)}`,
project,
token,
);
if (!lookup.name) {
die(`keys:lookupKey returned no key name for the apiKey in ${FIREBASE_CONFIG_SOURCE}`);
}
return lookup.name; // projects/<number>/locations/global/keys/<uid>
}
/** GET the API key resource (including .restrictions). */
async function getApiKey(project, token, keyName) {
return googleApi(`${API_KEYS_BASE}/${keyName}`, project, token);
}
/**
* PATCH the key's restrictions and wait for the returned long-running
* operation to finish, so the operator isn't told "authorized" while the
* change is still unapplied. The updateMask scopes the write to
* `restrictions`, and like the authorizedDomains PATCH it REPLACES the whole
* field -- callers must pass the full desired restrictions (the pure
* add/removeAllowedReferrer helpers build it from a fresh GET, preserving
* apiTargets).
*/
async function patchApiKeyRestrictions(project, token, keyName, restrictions) {
const op = await googleApi(`${API_KEYS_BASE}/${keyName}?updateMask=restrictions`, project, token, {
method: 'PATCH',
body: { restrictions },
});
let current = op;
for (let i = 0; i < 30 && current.name && !current.done; i++) {
await sleep(1000);
current = await googleApi(`${API_KEYS_BASE}/${current.name}`, project, token);
}
if (current.error) {
die(`API key restrictions update failed: ${JSON.stringify(current.error)}`);
}
if (current.name && !current.done) {
console.log(' (the API key update operation is still finalizing; it should apply within a minute)');
}
}
/**
* Authorize `host` on the browser key's referrer allowlist (the second canary
* login gate, after authorizedDomains -- see issue #967). Fresh GET then PATCH
* of the merged list; a no-op (already present, or the key is not
* referrer-restricted) skips the PATCH.
*/
async function authorizeReferrer(project, token, host) {
console.log('\n==> Authorizing the canary host on the Firebase browser API key (referrer allowlist)');
const keyName = await lookupBrowserKey(project, token);
const key = await getApiKey(project, token, keyName);
const before = key.restrictions?.browserKeyRestrictions?.allowedReferrers ?? [];
const next = addAllowedReferrer(key.restrictions, referrerForHost(host));
printDomains('before', before);
if (next === null) {
console.log(
before.length === 0
? ' key has no referrer restrictions -- nothing to authorize.'
: ` ${referrerForHost(host)} is already allowed -- no change.`,
);
return;
}
await patchApiKeyRestrictions(project, token, keyName, next);
printDomains('after', next.browserKeyRestrictions.allowedReferrers);
}
/**
* Remove `host` from the browser key's referrer allowlist (cleanup inverse of
* authorizeReferrer). removeAllowedReferrer also refuses to empty the list, so
* cleanup can never leave the key unrestricted.
*/
async function deauthorizeReferrer(project, token, host) {
console.log('\n==> Removing the canary host from the Firebase browser API key (referrer allowlist)');
const keyName = await lookupBrowserKey(project, token);
const key = await getApiKey(project, token, keyName);
const before = key.restrictions?.browserKeyRestrictions?.allowedReferrers ?? [];
const next = removeAllowedReferrer(key.restrictions, referrerForHost(host));
printDomains('before', before);
if (next === null) {
console.log(` ${referrerForHost(host)} is not in the allowlist -- no change.`);
return;
}
await patchApiKeyRestrictions(project, token, keyName, next);
printDomains('after', next.browserKeyRestrictions.allowedReferrers);
}
function printDomains(label, domains) {
console.log(` ${label}:`);
for (const d of domains) {
console.log(` - ${d}`);
}
if (domains.length === 0) {
console.log(' (none)');
}
}
function printHelp() {
console.log(
[
'Deploy a NON-TRAFFIC canary to App Engine and authorize its host in Firebase.',
'',
'Usage:',
' node scripts/deploy-canary.mjs [--project <id>]',
' Build + deploy HEAD with --no-promote, then add the canary host to',
' Firebase authorizedDomains AND the browser API key referrer allowlist',
' so you can log in and smoke-test it.',
'',
' node scripts/deploy-canary.mjs --cleanup <version> [--project <id>]',
' Remove that version host from both lists, then delete the version',
' UNLESS it is serving traffic (a promoted canary is production; the delete',
' is refused so cleanup can never cause an outage).',
'',
'Project defaults to `gcloud config get-value project`; override with',
'--project or the SIMLIN_CANARY_PROJECT env var. Mutating Firebase auth',
'config requires roles/firebaseauth.admin on YOUR account (this uses your',
'own gcloud credentials, not the CI deploy service account). Traffic is',
'never promoted; the deploy + promote are separate steps.',
].join('\n'),
);
}
async function runDeployMode(project, token) {
// Preflight: validate Firebase + API Keys access BEFORE the (long) deploy,
// so a missing grant or disabled API fails in seconds rather than after an
// upload.
console.log('==> Preflight: reading current Firebase authorized domains');
const preflight = await getIdentityConfig(project, token);
printDomains('current authorizedDomains', preflight.authorizedDomains ?? []);
console.log('\n==> Preflight: reading the Firebase browser API key restrictions');
const preflightKey = await getApiKey(project, token, await lookupBrowserKey(project, token));
printDomains('current allowedReferrers', preflightKey.restrictions?.browserKeyRestrictions?.allowedReferrers ?? []);
deployStagedNoPromote(project);
const versionId = latestVersionId(project);
const { url, host } = versionUrlAndHost(project, versionId);
console.log(`\n==> Canary version: ${versionId}`);
console.log(` Canary URL: ${url}`);
console.log(` Canary host: ${host}`);
// Read-modify-write against a FRESH GET (the config could have changed during
// the deploy), then PATCH the full merged list.
console.log('\n==> Authorizing the canary host in Firebase');
const current = await getIdentityConfig(project, token);
const before = current.authorizedDomains ?? [];
const after = addAuthorizedDomain(before, host);
printDomains('before', before);
if (after.length === before.length) {
console.log(` ${host} is already authorized -- no change.`);
} else {
await patchAuthorizedDomains(project, token, after);
printDomains('after', after);
}
// Second login gate: the browser API key's HTTP-referrer allowlist blocks
// identitytoolkit requests from the canary host before OAuth even starts
// (auth/requests-from-referer-...-are-blocked). Authorize it too.
await authorizeReferrer(project, token, host);
printPostDeploy(project, versionId, url);
}
function printPostDeploy(project, versionId, url) {
const projectFlag = `--project=${project}`;
console.log(
[
'',
'============================================================',
'Canary is deployed and authorized -- traffic NOT switched.',
'============================================================',
'',
`Canary URL: ${url}`,
'',
'Smoke test the canary (against the URL above):',
` - curl -sI ${url}/ -> 200 HTML, links a hashed /static/js/index.<hash>.js`,
` - curl -sI ${url}/static/js/sd-component.js -> 200 (the embed component)`,
` - curl -sI ${url}/static/wasm/<hash>.module.wasm -> 200, content-type: application/wasm`,
' - In a browser: log in with EMAIL/PASSWORD, land on Home with no console errors.',
' (The domain/referrer authorization enables identitytoolkit calls from this',
' host. Google/Apple redirect login still CANNOT complete here: the SDK reads',
' the redirect result through an auth.simlin.com iframe, and browsers partition',
' cross-SITE iframe storage -- appspot.com vs simlin.com. It works on',
' app.simlin.com, which is same-site with the auth domain; verify OAuth there',
' right after promoting.)',
' - Open an example model and confirm it simulates; edit + save + reload persists.',
'',
'When the smoke test PASSES, switch 100% of traffic to the canary:',
` gcloud app services set-traffic default --splits=${versionId}=1 ${projectFlag}`,
'',
'Cleanup -- two cases:',
` (a) ABANDONING the canary (did NOT promote): de-authorizes the host AND deletes`,
' the version, which is safe because it serves no traffic:',
` node scripts/deploy-canary.mjs --cleanup ${versionId} ${projectFlag}`,
' (b) AFTER PROMOTING: this version is now production. The same cleanup command',
' is safe to run -- it will de-authorize the host but REFUSE to delete a',
' serving version. To reclaim resources, delete the PREVIOUS (now-idle)',
' version instead, not this one:',
` gcloud app versions list --service=default ${projectFlag}`,
` gcloud app versions delete <previous-version> --service=default ${projectFlag}`,
'',
'NOTE: keep the canary host authorized only as long as you are testing it;',
'stale appspot hosts in authorizedDomains or the API-key referrer allowlist',
'needlessly widen the OAuth surface. --cleanup removes both.',
].join('\n'),
);
}
async function runCleanupMode(project, token, versionId) {
// Derive the host the same way the deploy path did (browse is region-aware).
// Do this BEFORE deleting the version so browse can still resolve it.
const { host } = versionUrlAndHost(project, versionId);
console.log(`==> Cleaning up canary version ${versionId} (host ${host})`);
console.log('\n==> Removing the canary host from Firebase authorized domains');
const current = await getIdentityConfig(project, token);
const before = current.authorizedDomains ?? [];
const after = removeAuthorizedDomain(before, host);
printDomains('before', before);
if (after.length === before.length) {
console.log(` ${host} was not authorized -- no change.`);
} else {
await patchAuthorizedDomains(project, token, after);
printDomains('after', after);
}
await deauthorizeReferrer(project, token, host);
// Guard the delete: if the operator promoted this canary, it is now serving
// production traffic and deleting it is a full-site outage. De-authorizing the
// host above is always safe; only the delete is dangerous, so we refuse it
// here rather than blindly running it.
const share = versionTrafficShare(serviceTrafficAllocations(project), versionId);
if (share > 0) {
const pct = (share * 100).toFixed(share < 0.01 ? 2 : 0);
console.log(
[
'',
`REFUSING TO DELETE version ${versionId}: it is serving ${pct}% of default-service traffic.`,
'De-authorized the host only. If you promoted the canary, it IS production now --',
'deleting it would take the site down. To reclaim resources, delete the PREVIOUS',
'(now-idle) version instead:',
` gcloud app versions list --service=default --project=${project}`,
` gcloud app versions delete <previous-version> --service=default --project=${project}`,
].join('\n'),
);
return;
}
// delete (not stop): prod uses automatic_scaling, where `gcloud app versions
// stop` is rejected; delete works and frees the slot toward the version cap.
console.log('\n==> Deleting the canary version (it is serving no traffic)');
deleteVersion(project, versionId);
console.log(`\nDone. Version ${versionId} is deleted and its host is de-authorized.`);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.mode === 'help') {
printHelp();
return;
}
const project = resolveProject(args.project ?? process.env.SIMLIN_CANARY_PROJECT);
console.log('============================================================');
console.log(`Project: ${project}`);
console.log('WARNING: this touches PRODUCTION Firebase auth config and');
console.log('deploys to production App Engine. It does NOT promote traffic.');
console.log('============================================================\n');
const token = accessToken();
if (args.mode === 'cleanup') {
await runCleanupMode(project, token, args.version);
} else {
await runDeployMode(project, token);
}
}
// Run only when executed directly, so importing the pure helpers in tests has
// no side effects (mirrors the guard-free build-deploy-staging.mjs by gating
// the entrypoint instead).
const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (invokedDirectly) {
main().catch((err) => die(err.message));
}