@caelo-cms/provisioning 0.11.12 → 0.11.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist/admin-domain.d.ts +4 -22
  2. package/dist/admin-domain.d.ts.map +1 -1
  3. package/dist/admin-domain.js +4 -58
  4. package/dist/admin-domain.js.map +1 -1
  5. package/dist/agent-db.js +29 -5
  6. package/dist/audit-job-setup.d.ts.map +1 -1
  7. package/dist/audit-job-setup.js +14 -19
  8. package/dist/audit-job-setup.js.map +1 -1
  9. package/dist/caddy.d.ts +2 -1
  10. package/dist/caddy.d.ts.map +1 -1
  11. package/dist/caddy.js +21 -3
  12. package/dist/caddy.js.map +1 -1
  13. package/dist/cli.js +111 -62
  14. package/dist/cli.js.map +1 -1
  15. package/dist/compose.d.ts +22 -2
  16. package/dist/compose.d.ts.map +1 -1
  17. package/dist/compose.js +53 -3
  18. package/dist/compose.js.map +1 -1
  19. package/dist/converge-checkpoint.d.ts +1 -0
  20. package/dist/converge-checkpoint.d.ts.map +1 -1
  21. package/dist/converge-checkpoint.js +1 -0
  22. package/dist/converge-checkpoint.js.map +1 -1
  23. package/dist/deployed-release.d.ts.map +1 -1
  24. package/dist/deployed-release.js +40 -8
  25. package/dist/deployed-release.js.map +1 -1
  26. package/dist/gcp-names.d.ts +33 -14
  27. package/dist/gcp-names.d.ts.map +1 -1
  28. package/dist/gcp-names.js +43 -0
  29. package/dist/gcp-names.js.map +1 -1
  30. package/dist/gcp-url-map.d.ts +19 -4
  31. package/dist/gcp-url-map.d.ts.map +1 -1
  32. package/dist/gcp-url-map.js +33 -5
  33. package/dist/gcp-url-map.js.map +1 -1
  34. package/dist/infra-contract.d.ts +1 -1
  35. package/dist/infra-contract.d.ts.map +1 -1
  36. package/dist/infra-contract.js +22 -1
  37. package/dist/infra-contract.js.map +1 -1
  38. package/dist/lifecycle.d.ts.map +1 -1
  39. package/dist/lifecycle.js +5 -29
  40. package/dist/lifecycle.js.map +1 -1
  41. package/dist/mcp-auth-proxy-setup.d.ts +8 -2
  42. package/dist/mcp-auth-proxy-setup.d.ts.map +1 -1
  43. package/dist/mcp-auth-proxy-setup.js +4 -0
  44. package/dist/mcp-auth-proxy-setup.js.map +1 -1
  45. package/dist/release-manifest.d.ts +1 -1
  46. package/dist/release-manifest.d.ts.map +1 -1
  47. package/dist/release-manifest.js +2 -0
  48. package/dist/release-manifest.js.map +1 -1
  49. package/dist/remote-mcp.d.ts.map +1 -1
  50. package/dist/remote-mcp.js +11 -1
  51. package/dist/remote-mcp.js.map +1 -1
  52. package/dist/runtime-secrets.d.ts +10 -0
  53. package/dist/runtime-secrets.d.ts.map +1 -1
  54. package/dist/runtime-secrets.js +10 -1
  55. package/dist/runtime-secrets.js.map +1 -1
  56. package/dist/scheduler-setup.d.ts +5 -0
  57. package/dist/scheduler-setup.d.ts.map +1 -1
  58. package/dist/scheduler-setup.js +7 -0
  59. package/dist/scheduler-setup.js.map +1 -1
  60. package/dist/stack-contract.d.ts +61 -20
  61. package/dist/stack-contract.d.ts.map +1 -1
  62. package/dist/stack-contract.js +173 -35
  63. package/dist/stack-contract.js.map +1 -1
  64. package/dist/stack-converge.d.ts.map +1 -1
  65. package/dist/stack-converge.js +4 -2
  66. package/dist/stack-converge.js.map +1 -1
  67. package/dist/staging-admin.d.ts +2 -2
  68. package/dist/staging-admin.d.ts.map +1 -1
  69. package/dist/staging-gateway-setup.d.ts +140 -0
  70. package/dist/staging-gateway-setup.d.ts.map +1 -0
  71. package/dist/staging-gateway-setup.js +848 -0
  72. package/dist/staging-gateway-setup.js.map +1 -0
  73. package/dist/upgrade/app-roll.d.ts +4 -4
  74. package/dist/upgrade/app-roll.d.ts.map +1 -1
  75. package/dist/upgrade/app-roll.js +7 -5
  76. package/dist/upgrade/app-roll.js.map +1 -1
  77. package/dist/upgrade/converge.d.ts.map +1 -1
  78. package/dist/upgrade/converge.js +36 -27
  79. package/dist/upgrade/converge.js.map +1 -1
  80. package/dist/upgrade/image-forms.d.ts +3 -3
  81. package/dist/upgrade/image-forms.d.ts.map +1 -1
  82. package/dist/upgrade/image-forms.js +2 -2
  83. package/dist/upgrade/image-forms.js.map +1 -1
  84. package/dist/upgrade/release-images.d.ts +20 -0
  85. package/dist/upgrade/release-images.d.ts.map +1 -1
  86. package/dist/upgrade/release-images.js +28 -0
  87. package/dist/upgrade/release-images.js.map +1 -1
  88. package/dist/upgrade/upgrade.d.ts +3 -1
  89. package/dist/upgrade/upgrade.d.ts.map +1 -1
  90. package/dist/upgrade/upgrade.js +22 -1
  91. package/dist/upgrade/upgrade.js.map +1 -1
  92. package/dist/upgrader-job.d.ts.map +1 -1
  93. package/dist/upgrader-job.js +30 -5
  94. package/dist/upgrader-job.js.map +1 -1
  95. package/dist/upgrader-records.d.ts +2 -2
  96. package/dist/upgrader-records.d.ts.map +1 -1
  97. package/dist/upgrader-records.js +2 -2
  98. package/dist/upgrader-records.js.map +1 -1
  99. package/dist/wizard.d.ts.map +1 -1
  100. package/dist/wizard.js +3 -3
  101. package/dist/wizard.js.map +1 -1
  102. package/dist/wizards/gcp.d.ts.map +1 -1
  103. package/dist/wizards/gcp.js +48 -9
  104. package/dist/wizards/gcp.js.map +1 -1
  105. package/package.json +2 -2
  106. package/src/admin-domain.test.ts +0 -91
  107. package/src/admin-domain.ts +4 -64
  108. package/src/admin-memory.test.ts +2 -0
  109. package/src/audit-job-setup.test.ts +11 -11
  110. package/src/audit-job-setup.ts +14 -19
  111. package/src/caddy-redirects.docker.test.ts +10 -5
  112. package/src/caddy-redirects.test.ts +7 -5
  113. package/src/caddy-validate.test.ts +1 -1
  114. package/src/caddy.test.ts +12 -6
  115. package/src/caddy.ts +23 -4
  116. package/src/cli.ts +138 -77
  117. package/src/compose.ts +67 -2
  118. package/src/converge-checkpoint.ts +1 -0
  119. package/src/database-tls-builders.test.ts +3 -1
  120. package/src/deployed-release.test.ts +21 -0
  121. package/src/deployed-release.ts +42 -7
  122. package/src/gateway-env-isolation.test.ts +2 -0
  123. package/src/gcp-names.ts +53 -0
  124. package/src/gcp-url-map.test.ts +26 -2
  125. package/src/gcp-url-map.ts +36 -5
  126. package/src/index.test.ts +9 -3
  127. package/src/infra-contract.test.ts +5 -3
  128. package/src/infra-contract.ts +22 -1
  129. package/src/lifecycle.ts +6 -40
  130. package/src/mcp-auth-proxy-setup.test.ts +15 -6
  131. package/src/mcp-auth-proxy-setup.ts +14 -2
  132. package/src/release-manifest.ts +3 -1
  133. package/src/remote-mcp-stack-config.test.ts +1 -0
  134. package/src/remote-mcp.test.ts +5 -1
  135. package/src/remote-mcp.ts +10 -1
  136. package/src/runtime-secrets.test.ts +10 -1
  137. package/src/runtime-secrets.ts +21 -0
  138. package/src/scheduler-setup.test.ts +6 -0
  139. package/src/scheduler-setup.ts +7 -0
  140. package/src/self-hosted-backup.test.ts +6 -4
  141. package/src/self-hosted-compose.test.ts +2 -0
  142. package/src/stack-contract-parity.test.ts +7 -3
  143. package/src/stack-contract.test.ts +73 -13
  144. package/src/stack-contract.ts +195 -42
  145. package/src/stack-converge-secrets.test.ts +7 -2
  146. package/src/stack-converge.test.ts +37 -9
  147. package/src/stack-converge.ts +4 -1
  148. package/src/staging-admin.ts +2 -2
  149. package/src/staging-gateway-setup.test.ts +361 -0
  150. package/src/staging-gateway-setup.ts +1083 -0
  151. package/src/upgrade/app-roll.ts +7 -4
  152. package/src/upgrade/converge.ts +40 -26
  153. package/src/upgrade/image-forms.ts +7 -7
  154. package/src/upgrade/release-images.test.ts +28 -0
  155. package/src/upgrade/release-images.ts +44 -0
  156. package/src/upgrade/upgrade.test.ts +63 -11
  157. package/src/upgrade/upgrade.ts +29 -2
  158. package/src/upgrader-job.test.ts +23 -0
  159. package/src/upgrader-job.ts +45 -11
  160. package/src/upgrader-records.ts +7 -7
  161. package/src/wizard.ts +2 -3
  162. package/src/wizards/gcp.ts +73 -9
  163. package/stacks/gcp/index.ts +47 -6
  164. package/stacks/gcp-firebase/index.ts +44 -12
  165. package/stacks/self-hosted/index.ts +9 -2
@@ -0,0 +1,848 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * #1330 — the staging gateway on Google Cloud: the live gateway's image with
4
+ * `CAELO_GATEWAY_ENVIRONMENT=staging`, which serves the staged build from
5
+ * the private staging bucket behind the staging gate, on every host
6
+ * (`*.run.app` included), and staging's own data (`cms_public_staging`).
7
+ * One shape on both providers (E11 of #763); only the edge in front of it
8
+ * differs:
9
+ *
10
+ * - `gcp-firebase` (E13 a): a second Firebase Hosting site with one `**`
11
+ * rewrite to the staging gateway, the staging host as that site's
12
+ * custom domain. The Cloud Run domain mapping that sent the staging
13
+ * host to the old gateway service (#650) is deleted: nothing may lead
14
+ * to the staged files but the gate.
15
+ * - `gcp`: the staging host gets its own host rule on the site's load
16
+ * balancer, routed in full to the staging gateway's serverless NEG and
17
+ * backend (the gateway's Cloud Armor policy as an extra layer, E3), and
18
+ * a managed certificate of its own, like the admin's.
19
+ *
20
+ * CLI-owned, like the audit and capture jobs: the stacks declare the
21
+ * staging gateway's service account and its secret binding, everything
22
+ * else is created here, by the wizard after the first `pulumi up` and by
23
+ * every converge (`cms-provision upgrade`, the converge job), idempotently.
24
+ * Split in two because the service must exist before the upgrade plans its
25
+ * rolls ({@link ensureStagingGatewayService}, from ensureReleaseServices),
26
+ * while the edge only routes traffic to it ({@link ensureStagingGatewayEdge},
27
+ * later in the converge).
28
+ *
29
+ * The staging gateway's account reads the staging bucket and its own login
30
+ * password, nothing else: no media, no static bucket, no gate key (the admin
31
+ * publishes that to the staging database), no live database.
32
+ */
33
+ import { createHash } from "node:crypto";
34
+ import { DB_PUBLIC_POOL_MAX_ENV, DEFAULT_STAGING_GATEWAY_MAX_INSTANCES, GATEWAY_POOL_MAX, isIpLiteral, } from "@caelo-cms/shared";
35
+ import { gcloud as defaultGcloud } from "./gcloud.js";
36
+ import { realSleep, runWithRetry } from "./gcloud-retry.js";
37
+ import { firebaseStagingSiteId, gcpBucketName, gcpNamePrefix, gcpSecretId, runServiceAccountEmail, stagingGatewayEdgeNames, stagingGatewayServiceAccountEmail, } from "./gcp-names.js";
38
+ import { ensureStagingGatewayServiceAccount, } from "./runtime-secrets.js";
39
+ import { cloudRunServicePrefix, databaseUrls, fixedCloudRunServiceName, STAGING_GATEWAY_TIMEOUT_SECONDS, serviceSecrets, stagingGatewayEnvContract, } from "./stack-contract.js";
40
+ import { listFlag, liveContainerEnv } from "./stack-converge.js";
41
+ import { lookupFailure, resolveGcpResourceName } from "./upgrade/gcp-lookup.js";
42
+ const NOT_FOUND = /NOT_FOUND|not found|cannot find|does not exist|could not be found|404/i;
43
+ const ALREADY_EXISTS = /ALREADY_EXISTS|already exists|409/i;
44
+ /** The `gcp` staging host's managed certificate: the edge's prefix plus six hex of the host's SHA-256. */
45
+ export function stagingCertName(env, stagingHost) {
46
+ const hash = createHash("sha256").update(stagingHost.toLowerCase()).digest("hex").slice(0, 6);
47
+ return `${stagingGatewayEdgeNames(env).cert}-${hash}`;
48
+ }
49
+ /** The staging gateway's Cloud Run service name (fixed on both providers). */
50
+ export function stagingGatewayServiceName(env) {
51
+ return fixedCloudRunServiceName("gcp", env, "staging-gateway");
52
+ }
53
+ /**
54
+ * The `gcloud run deploy` argv of a first staging gateway: the release's
55
+ * gateway image, its env contract, direct VPC egress (it reaches the staging
56
+ * database on Cloud SQL's private IP), request-based CPU, invokable without
57
+ * a Google identity (the gate is the staging gateway's own, on every host),
58
+ * load-balancer-only ingress on `gcp`, all on `gcp-firebase` (Hosting
59
+ * rewrites arrive from Google's edge). Its scale-out is the one the
60
+ * connection budget reserves for it ({@link DEFAULT_STAGING_GATEWAY_MAX_INSTANCES}:
61
+ * staging is used by the team, not by visitors).
62
+ */
63
+ export function stagingGatewayDeployArgs(inputs) {
64
+ const env = stagingGatewayEnvContract({
65
+ provider: inputs.provider,
66
+ projectId: inputs.projectId,
67
+ env: inputs.env,
68
+ domain: inputs.domain,
69
+ region: inputs.region,
70
+ stagingHost: inputs.stagingHost,
71
+ databaseUrls: databaseUrls(inputs.databaseHost),
72
+ });
73
+ const plain = [
74
+ ...env.flatMap((e) => ("value" in e ? [`${e.name}=${e.value}`] : [])),
75
+ // The connection budget's share of one instance (pool-budget.ts): the
76
+ // stacks set the same on the live gateway.
77
+ `${DB_PUBLIC_POOL_MAX_ENV}=${GATEWAY_POOL_MAX}`,
78
+ ];
79
+ const secrets = env.flatMap((e) => "valueSource" in e
80
+ ? [`${e.name}=${e.valueSource.secretKeyRef.secret}:${e.valueSource.secretKeyRef.version}`]
81
+ : []);
82
+ return [
83
+ "run",
84
+ "deploy",
85
+ stagingGatewayServiceName(inputs.env),
86
+ `--image=${inputs.gatewayImageRef}`,
87
+ `--region=${inputs.region}`,
88
+ `--project=${inputs.projectId}`,
89
+ `--service-account=${stagingGatewayServiceAccountEmail(inputs.projectId, inputs.env)}`,
90
+ inputs.provider === "gcp" ? "--ingress=internal-and-cloud-load-balancing" : "--ingress=all",
91
+ "--allow-unauthenticated",
92
+ "--min-instances=0",
93
+ `--max-instances=${DEFAULT_STAGING_GATEWAY_MAX_INSTANCES}`,
94
+ "--cpu=1",
95
+ "--memory=512Mi",
96
+ "--cpu-throttling",
97
+ `--timeout=${STAGING_GATEWAY_TIMEOUT_SECONDS}`,
98
+ `--network=${inputs.vpc.network}`,
99
+ `--subnet=${inputs.vpc.subnetwork}`,
100
+ "--vpc-egress=private-ranges-only",
101
+ listFlag("--set-env-vars", plain),
102
+ listFlag("--set-secrets", secrets),
103
+ "--quiet",
104
+ ];
105
+ }
106
+ /**
107
+ * The live gateway's direct VPC egress, to give the staging gateway the same
108
+ * route to Cloud SQL's private IP (the stacks auto-name the network).
109
+ *
110
+ * @returns null when the service JSON has none
111
+ */
112
+ export function liveVpcInterface(serviceJson) {
113
+ const svc = JSON.parse(serviceJson);
114
+ const raw = svc.spec?.template?.metadata?.annotations?.["run.googleapis.com/network-interfaces"];
115
+ if (!raw)
116
+ return null;
117
+ try {
118
+ const parsed = JSON.parse(raw);
119
+ const first = parsed[0];
120
+ return first?.network && first.subnetwork
121
+ ? { network: first.network, subnetwork: first.subnetwork }
122
+ : null;
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ }
128
+ /**
129
+ * Everything the staging gateway service needs, created when missing, in an
130
+ * order that never deploys something that cannot start: its account, its
131
+ * secret access, the staging bucket (`gcp-firebase`: new, private, the admin
132
+ * may write it) and its read access, then the service itself. Deployed
133
+ * once; the app-roll moves it onto each release's gateway image after that.
134
+ */
135
+ export async function ensureStagingGatewayService(inputs, deps = {}) {
136
+ const run = deps.run ?? defaultGcloud;
137
+ const sleep = deps.sleep ?? realSleep;
138
+ const outcomes = [];
139
+ const account = await ensureStagingGatewayServiceAccount(inputs, { run });
140
+ outcomes.push(account);
141
+ if (account.status === "failed")
142
+ return outcomes;
143
+ const member = `serviceAccount:${stagingGatewayServiceAccountEmail(inputs.projectId, inputs.env)}`;
144
+ for (const secret of serviceSecrets("staging-gateway")) {
145
+ const id = `secretAccessor → staging gateway on ${secret}`;
146
+ // The account may have been created a moment ago; IAM answers "does not
147
+ // exist" until it has propagated, so this is retried, not failed.
148
+ const r = await runWithRetry(run, sleep, [
149
+ "secrets",
150
+ "add-iam-policy-binding",
151
+ gcpSecretId(inputs.env, secret),
152
+ `--project=${inputs.projectId}`,
153
+ `--member=${member}`,
154
+ "--role=roles/secretmanager.secretAccessor",
155
+ "--condition=None",
156
+ "--quiet",
157
+ ]);
158
+ outcomes.push(r.ok ? { id, status: "present" } : { id, status: "failed", error: r.stderr.trim() });
159
+ }
160
+ if (outcomes.some((o) => o.status === "failed"))
161
+ return outcomes;
162
+ outcomes.push(...(await ensureStagingBucket(inputs, member, run, sleep)));
163
+ if (outcomes.some((o) => o.status === "failed"))
164
+ return outcomes;
165
+ outcomes.push(await ensureService(inputs, run));
166
+ return outcomes;
167
+ }
168
+ /**
169
+ * The staging bucket and who may use it. On `gcp` the stack has always
170
+ * created it (Stage uploads there); on `gcp-firebase` it is new with #1330:
171
+ * uniform access, public access prevention enforced, in the install's
172
+ * region. The admin writes it (Stage's two build slots), the staging
173
+ * gateway only reads it.
174
+ */
175
+ async function ensureStagingBucket(t, stagingMember, run, sleep) {
176
+ const bucket = gcpBucketName(t.projectId, t.env, "staging");
177
+ const url = `gs://${bucket}`;
178
+ const outcomes = [];
179
+ if (t.provider === "gcp-firebase") {
180
+ const id = `staging bucket ${url}`;
181
+ const described = await run([
182
+ "storage",
183
+ "buckets",
184
+ "describe",
185
+ url,
186
+ `--project=${t.projectId}`,
187
+ "--format=value(name)",
188
+ ]);
189
+ if (described.ok)
190
+ outcomes.push({ id, status: "present" });
191
+ else if (!NOT_FOUND.test(described.stderr)) {
192
+ return [{ id, status: "failed", error: described.stderr.trim() }];
193
+ }
194
+ else {
195
+ const created = await run([
196
+ "storage",
197
+ "buckets",
198
+ "create",
199
+ url,
200
+ `--project=${t.projectId}`,
201
+ `--location=${t.region}`,
202
+ "--uniform-bucket-level-access",
203
+ "--public-access-prevention",
204
+ "--quiet",
205
+ ]);
206
+ if (!created.ok && !ALREADY_EXISTS.test(created.stderr)) {
207
+ return [{ id, status: "failed", error: created.stderr.trim() }];
208
+ }
209
+ outcomes.push({ id, status: "applied", detail: "private, uniform access" });
210
+ }
211
+ outcomes.push(await bucketBinding(run, sleep, url, `serviceAccount:${runServiceAccountEmail(t.projectId, t.env)}`, "roles/storage.objectAdmin", "admin writes the staged build", t.projectId));
212
+ }
213
+ outcomes.push(await bucketBinding(run, sleep, url, stagingMember, "roles/storage.objectViewer", "staging gateway reads the staged build", t.projectId));
214
+ return outcomes;
215
+ }
216
+ async function bucketBinding(run, sleep, url, member, role, what, projectId) {
217
+ const id = `${role} on ${url} (${what})`;
218
+ const r = await runWithRetry(run, sleep, [
219
+ "storage",
220
+ "buckets",
221
+ "add-iam-policy-binding",
222
+ url,
223
+ `--project=${projectId}`,
224
+ `--member=${member}`,
225
+ `--role=${role}`,
226
+ "--quiet",
227
+ ]);
228
+ return r.ok ? { id, status: "present" } : { id, status: "failed", error: r.stderr.trim() };
229
+ }
230
+ async function ensureService(inputs, run) {
231
+ const name = stagingGatewayServiceName(inputs.env);
232
+ const id = `Cloud Run service ${name} (the staging gateway)`;
233
+ const existing = await run([
234
+ "run",
235
+ "services",
236
+ "describe",
237
+ name,
238
+ `--region=${inputs.region}`,
239
+ `--project=${inputs.projectId}`,
240
+ "--format=value(metadata.name)",
241
+ ]);
242
+ if (!existing.ok && !NOT_FOUND.test(existing.stderr)) {
243
+ return { id, status: "failed", error: existing.stderr.trim() };
244
+ }
245
+ if (!existing.ok) {
246
+ const deployed = await run(stagingGatewayDeployArgs(inputs));
247
+ if (!deployed.ok)
248
+ return { id, status: "failed", error: deployed.stderr.trim() };
249
+ }
250
+ // Invokable without a Google identity: the staging gate is the gateway's
251
+ // own and holds on every host (E8). Idempotent, ensured on every run.
252
+ const invoker = await run([
253
+ "run",
254
+ "services",
255
+ "add-iam-policy-binding",
256
+ name,
257
+ `--region=${inputs.region}`,
258
+ `--project=${inputs.projectId}`,
259
+ "--member=allUsers",
260
+ "--role=roles/run.invoker",
261
+ "--quiet",
262
+ ]);
263
+ if (!invoker.ok) {
264
+ return { id, status: "failed", error: `allUsers run.invoker: ${invoker.stderr.trim()}` };
265
+ }
266
+ return existing.ok ? { id, status: "present" } : { id, status: "applied" };
267
+ }
268
+ /**
269
+ * #1330 — the staging gateway (staging-gateway-setup.ts), with the live
270
+ * gateway's route to Cloud SQL: its direct VPC egress and the database's DNS
271
+ * name from its `PUBLIC_DATABASE_URL` (verify-full, #1338).
272
+ */
273
+ export async function ensureStagingGatewayFromLiveGateway(target, deps = {}) {
274
+ const run = deps.run ?? defaultGcloud;
275
+ const sleep = deps.sleep ?? realSleep;
276
+ const id = "the staging gateway's route to the database";
277
+ const prefix = cloudRunServicePrefix(target.provider, target.env, "gateway");
278
+ const live = await resolveGcpResourceName(run, "run-service", prefix, target.projectId, target.region);
279
+ if (live.status !== "found") {
280
+ return [
281
+ {
282
+ id,
283
+ status: "failed",
284
+ error: lookupFailure(live, `${prefix}* Cloud Run service`, "operator"),
285
+ },
286
+ ];
287
+ }
288
+ const described = await run([
289
+ "run",
290
+ "services",
291
+ "describe",
292
+ live.name,
293
+ `--region=${target.region}`,
294
+ `--project=${target.projectId}`,
295
+ "--format=json",
296
+ ]);
297
+ if (!described.ok) {
298
+ return [{ id, status: "failed", error: `describe ${live.name}: ${described.stderr.trim()}` }];
299
+ }
300
+ const vpc = liveVpcInterface(described.stdout);
301
+ const url = liveContainerEnv(described.stdout).get("PUBLIC_DATABASE_URL");
302
+ let host = null;
303
+ try {
304
+ host = url?.kind === "value" && url.value ? new URL(url.value).hostname : null;
305
+ }
306
+ catch {
307
+ host = null;
308
+ }
309
+ if (!vpc || !host) {
310
+ return [
311
+ {
312
+ id,
313
+ status: "failed",
314
+ error: `${live.name} has ${vpc ? "no PUBLIC_DATABASE_URL" : "no direct VPC egress"}, so the staging gateway's route to the database is unknown. Re-run the installer to restore the gateway, then upgrade again.`,
315
+ },
316
+ ];
317
+ }
318
+ if (isIpLiteral(host)) {
319
+ return [
320
+ {
321
+ id,
322
+ status: "failed",
323
+ error: `${live.name} still dials the Cloud SQL instance's IP; the staging gateway connects with verify-full TLS, which needs the instance's DNS name (#1338). Install the release that moves the services onto it first (contract 15), then this one.`,
324
+ },
325
+ ];
326
+ }
327
+ return ensureStagingGatewayService({
328
+ provider: target.provider,
329
+ projectId: target.projectId,
330
+ region: target.region,
331
+ env: target.env,
332
+ domain: target.domain,
333
+ stagingHost: target.stagingHost,
334
+ gatewayImageRef: target.gatewayImageRef,
335
+ databaseHost: host,
336
+ vpc,
337
+ }, { run, sleep });
338
+ }
339
+ /**
340
+ * Route the staging host to the staging gateway — and nowhere else. See the
341
+ * module comment for the per-provider primitive.
342
+ */
343
+ export async function ensureStagingGatewayEdge(t, deps = {}) {
344
+ return t.provider === "gcp-firebase"
345
+ ? ensureFirebaseStagingSite(t, deps)
346
+ : ensureGcpEdge(t, deps);
347
+ }
348
+ // --- gcp-firebase ----------------------------------------------------------
349
+ const FIREBASE_HOSTING_API = "https://firebasehosting.googleapis.com/v1beta1";
350
+ /** The config of the staging site's only version: every path to the staging gateway. */
351
+ export function firebaseStagingSiteConfig(t) {
352
+ return {
353
+ rewrites: [
354
+ { glob: "**", run: { serviceId: stagingGatewayServiceName(t.env), region: t.region } },
355
+ ],
356
+ };
357
+ }
358
+ /** Whether a served version's config already is {@link firebaseStagingSiteConfig}. */
359
+ export function isFirebaseStagingConfig(config, t) {
360
+ const want = firebaseStagingSiteConfig(t).rewrites;
361
+ const rewrites = config?.rewrites;
362
+ const redirects = config?.redirects ?? [];
363
+ const headers = config?.headers ?? [];
364
+ return (Array.isArray(rewrites) &&
365
+ rewrites.length === 1 &&
366
+ JSON.stringify(rewrites[0]) === JSON.stringify(want[0]) &&
367
+ redirects.length === 0 &&
368
+ headers.length === 0);
369
+ }
370
+ /** The DNS records a Firebase custom domain asks for (`requiredDnsUpdates.desired`). */
371
+ export function firebaseDesiredRecords(customDomain) {
372
+ const desired = customDomain?.requiredDnsUpdates?.desired;
373
+ return (desired ?? []).flatMap((set) => (set.records ?? []).flatMap((r) => r.domainName && r.type && r.rdata
374
+ ? [{ name: r.domainName.replace(/\.$/, ""), type: r.type, value: r.rdata }]
375
+ : []));
376
+ }
377
+ async function ensureFirebaseStagingSite(t, deps) {
378
+ const run = deps.run ?? defaultGcloud;
379
+ const http = deps.http ?? fetch;
380
+ const done = [];
381
+ const fail = (error) => ({ ok: false, error, done });
382
+ const liveSite = await readAdminEnv(run, t, "CAELO_FIREBASE_SITE");
383
+ if (!liveSite.ok)
384
+ return fail(liveSite.error);
385
+ const site = firebaseStagingSiteId(t.env, liveSite.value);
386
+ const bearer = await accessToken(run);
387
+ if (!bearer.ok)
388
+ return fail(bearer.error);
389
+ const headers = {
390
+ Authorization: `Bearer ${bearer.token}`,
391
+ "Content-Type": "application/json",
392
+ // A user's token needs a quota project for the Firebase APIs.
393
+ "X-Goog-User-Project": t.projectId,
394
+ };
395
+ const call = async (path, method = "GET", body) => {
396
+ const res = await http(`${FIREBASE_HOSTING_API}/${path}`, {
397
+ method,
398
+ headers,
399
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
400
+ });
401
+ const text = await res.text();
402
+ let json = null;
403
+ try {
404
+ json = text ? JSON.parse(text) : null;
405
+ }
406
+ catch {
407
+ json = null;
408
+ }
409
+ return { ok: res.ok, status: res.status, json, text };
410
+ };
411
+ const project = encodeURIComponent(t.projectId);
412
+ // 1. The site.
413
+ const existing = await call(`projects/${project}/sites/${site}`);
414
+ if (existing.status === 404) {
415
+ const created = await call(`projects/${project}/sites?siteId=${site}`, "POST", {});
416
+ if (!created.ok && created.status !== 409) {
417
+ return fail(`create Hosting site ${site}: HTTP ${created.status} ${created.text.slice(0, 300)}`);
418
+ }
419
+ done.push(`created the staging Hosting site ${site}`);
420
+ }
421
+ else if (!existing.ok) {
422
+ return fail(`read Hosting site ${site}: HTTP ${existing.status} ${existing.text.slice(0, 300)}`);
423
+ }
424
+ // 2. Its one version: `**` → the staging gateway, released on its live channel.
425
+ const released = await call(`sites/${site}/channels/live/releases?pageSize=1`);
426
+ if (!released.ok && released.status !== 404) {
427
+ return fail(`read ${site}'s release: HTTP ${released.status} ${released.text.slice(0, 300)}`);
428
+ }
429
+ const servedConfig = released.json
430
+ ?.releases?.[0]?.version?.config;
431
+ if (!isFirebaseStagingConfig(servedConfig, t)) {
432
+ const version = await call(`sites/${site}/versions`, "POST", {
433
+ config: firebaseStagingSiteConfig(t),
434
+ });
435
+ const name = version.json?.name;
436
+ if (!version.ok || !name) {
437
+ return fail(`create a version of ${site}: HTTP ${version.status} ${version.text.slice(0, 300)}`);
438
+ }
439
+ const versionId = name.split("/").pop() ?? "";
440
+ const finalized = await call(`sites/${site}/versions/${versionId}?updateMask=status`, "PATCH", {
441
+ status: "FINALIZED",
442
+ });
443
+ if (!finalized.ok) {
444
+ return fail(`finalize ${name}: HTTP ${finalized.status} ${finalized.text.slice(0, 300)}`);
445
+ }
446
+ const release = await call(`sites/${site}/releases?versionName=${encodeURIComponent(name)}`, "POST", { message: "Caelo: the staging host is served by the staging gateway (#1330)" });
447
+ if (!release.ok) {
448
+ return fail(`release ${name}: HTTP ${release.status} ${release.text.slice(0, 300)}`);
449
+ }
450
+ done.push(`released ${site}: every path → ${stagingGatewayServiceName(t.env)}`);
451
+ }
452
+ // 3. The staging host as its custom domain.
453
+ const domainPath = `projects/${project}/sites/${site}/customDomains/${encodeURIComponent(t.stagingHost)}`;
454
+ let domain = await call(domainPath);
455
+ if (domain.status === 404) {
456
+ const created = await call(`projects/${project}/sites/${site}/customDomains?customDomainId=${encodeURIComponent(t.stagingHost)}`, "POST", {});
457
+ if (!created.ok && created.status !== 409) {
458
+ return fail(`add ${t.stagingHost} to ${site}: HTTP ${created.status} ${created.text.slice(0, 300)}`);
459
+ }
460
+ done.push(`added ${t.stagingHost} as the custom domain of ${site}`);
461
+ domain = await call(domainPath);
462
+ }
463
+ if (!domain.ok) {
464
+ return fail(`read ${t.stagingHost} on ${site}: HTTP ${domain.status} ${domain.text.slice(0, 300)}`);
465
+ }
466
+ // 4. The old route: the Cloud Run domain mapping (#650) to the service
467
+ // that is now the MCP-Auth-Proxy. Nothing but the gate may lead to the
468
+ // staged files, and the proxy no longer forwards (#1331).
469
+ const mapping = await deleteStagingDomainMapping(t, run);
470
+ if (!mapping.ok)
471
+ return fail(mapping.error);
472
+ if (mapping.deleted)
473
+ done.push(`deleted the Cloud Run domain mapping of ${t.stagingHost}`);
474
+ return { ok: true, done, dnsRecords: firebaseDesiredRecords(domain.json) };
475
+ }
476
+ /**
477
+ * Delete the staging host's Cloud Run domain mapping (#650), if there is
478
+ * one. Needs the gcloud `beta` component; without it the mapping is reported
479
+ * as unknown rather than guessed away.
480
+ */
481
+ async function deleteStagingDomainMapping(t, run) {
482
+ const scope = [`--domain=${t.stagingHost}`, `--region=${t.region}`, `--project=${t.projectId}`];
483
+ const described = await run([
484
+ "beta",
485
+ "run",
486
+ "domain-mappings",
487
+ "describe",
488
+ ...scope,
489
+ "--format=value(metadata.name)",
490
+ ]);
491
+ if (!described.ok) {
492
+ if (/\bNOT_FOUND\b|not found|could not be found/i.test(described.stderr)) {
493
+ return { ok: true, deleted: false };
494
+ }
495
+ return {
496
+ ok: false,
497
+ error: `read the Cloud Run domain mapping of ${t.stagingHost}: ${described.stderr.trim()}`,
498
+ };
499
+ }
500
+ const deleted = await run(["beta", "run", "domain-mappings", "delete", ...scope, "--quiet"]);
501
+ return deleted.ok
502
+ ? { ok: true, deleted: true }
503
+ : {
504
+ ok: false,
505
+ error: `delete the Cloud Run domain mapping of ${t.stagingHost}: ${deleted.stderr.trim()}`,
506
+ };
507
+ }
508
+ async function readAdminEnv(run, t, name) {
509
+ const prefix = cloudRunServicePrefix(t.provider, t.env, "admin");
510
+ const admin = await resolveGcpResourceName(run, "run-service", prefix, t.projectId, t.region);
511
+ if (admin.status !== "found") {
512
+ return { ok: false, error: lookupFailure(admin, `${prefix}* Cloud Run service`, "operator") };
513
+ }
514
+ const described = await run([
515
+ "run",
516
+ "services",
517
+ "describe",
518
+ admin.name,
519
+ `--region=${t.region}`,
520
+ `--project=${t.projectId}`,
521
+ "--format=json",
522
+ ]);
523
+ if (!described.ok) {
524
+ return { ok: false, error: `describe ${admin.name}: ${described.stderr.trim()}` };
525
+ }
526
+ let value;
527
+ try {
528
+ const v = liveContainerEnv(described.stdout).get(name);
529
+ value = v?.kind === "value" ? v.value : undefined;
530
+ }
531
+ catch {
532
+ return { ok: false, error: `describe ${admin.name}: output is not JSON` };
533
+ }
534
+ return value
535
+ ? { ok: true, value }
536
+ : { ok: false, error: `the admin service has no ${name}; run \`cms-provision upgrade\`` };
537
+ }
538
+ /**
539
+ * The URL map with the staging host routed in full to the staging gateway's
540
+ * backend: its own host rule and path matcher ({@link stagingGatewayEdgeNames}),
541
+ * every other host rule and matcher kept as it is. Null when it already is.
542
+ *
543
+ * @throws when the map has no host rule for the site (not this install's map)
544
+ */
545
+ export function planStagingHostRule(map, t, backend) {
546
+ const { pathMatcher } = stagingGatewayEdgeNames(t.env);
547
+ const host = t.stagingHost.toLowerCase();
548
+ const rules = map.hostRules ?? [];
549
+ const matchers = map.pathMatchers ?? [];
550
+ const otherRules = rules
551
+ .map((r) => ({ ...r, hosts: r.hosts.filter((h) => h.toLowerCase() !== host) }))
552
+ .filter((r) => r.pathMatcher !== pathMatcher && r.hosts.length > 0);
553
+ const current = rules.find((r) => r.pathMatcher === pathMatcher);
554
+ const currentMatcher = matchers.find((m) => m.name === pathMatcher);
555
+ const sameRule = current !== undefined &&
556
+ current.hosts.length === 1 &&
557
+ current.hosts[0]?.toLowerCase() === host &&
558
+ otherRules.length === rules.length - 1;
559
+ if (sameRule && currentMatcher?.defaultService === backend)
560
+ return null;
561
+ return {
562
+ ...map,
563
+ hostRules: [...otherRules, { hosts: [host], pathMatcher }],
564
+ pathMatchers: [
565
+ ...matchers.filter((m) => m.name !== pathMatcher),
566
+ { name: pathMatcher, defaultService: backend },
567
+ ],
568
+ };
569
+ }
570
+ const COMPUTE_API = "https://compute.googleapis.com/compute/v1";
571
+ const OPERATION_POLLS = 60;
572
+ async function ensureGcpEdge(t, deps) {
573
+ const run = deps.run ?? defaultGcloud;
574
+ const http = deps.http ?? fetch;
575
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
576
+ const done = [];
577
+ const fail = (error) => ({ ok: false, error, done });
578
+ const project = `--project=${t.projectId}`;
579
+ const names = stagingGatewayEdgeNames(t.env);
580
+ const service = stagingGatewayServiceName(t.env);
581
+ // 1. Serverless NEG → the staging gateway.
582
+ const neg = await run([
583
+ "compute",
584
+ "network-endpoint-groups",
585
+ "describe",
586
+ names.neg,
587
+ `--region=${t.region}`,
588
+ project,
589
+ "--format=value(name)",
590
+ ]);
591
+ if (!neg.ok) {
592
+ if (!NOT_FOUND.test(neg.stderr))
593
+ return fail(`describe ${names.neg}: ${neg.stderr.trim()}`);
594
+ const created = await run([
595
+ "compute",
596
+ "network-endpoint-groups",
597
+ "create",
598
+ names.neg,
599
+ `--region=${t.region}`,
600
+ project,
601
+ "--network-endpoint-type=serverless",
602
+ `--cloud-run-service=${service}`,
603
+ "--quiet",
604
+ ]);
605
+ if (!created.ok)
606
+ return fail(`create ${names.neg}: ${created.stderr.trim()}`);
607
+ done.push(`created serverless NEG ${names.neg} → ${service}`);
608
+ }
609
+ // 2. Its backend service, behind the gateway's Cloud Armor policy (an
610
+ // extra layer: the staging gate protects without it, E3).
611
+ const policies = await run([
612
+ "compute",
613
+ "security-policies",
614
+ "list",
615
+ project,
616
+ `--filter=name~^${gcpNamePrefix(t.env)}-waf`,
617
+ "--format=value(name)",
618
+ ]);
619
+ if (!policies.ok)
620
+ return fail(`list security policies: ${policies.stderr.trim()}`);
621
+ const policyNames = policies.stdout
622
+ .split("\n")
623
+ .map((s) => s.trim())
624
+ .filter(Boolean);
625
+ if (policyNames.length !== 1) {
626
+ return fail(`expected exactly one ${gcpNamePrefix(t.env)}-waf* Cloud Armor policy, found ${policyNames.length}`);
627
+ }
628
+ const policy = policyNames[0];
629
+ const backend = await run([
630
+ "compute",
631
+ "backend-services",
632
+ "describe",
633
+ names.backend,
634
+ "--global",
635
+ project,
636
+ "--format=json",
637
+ ]);
638
+ let backendJson = {};
639
+ if (!backend.ok) {
640
+ if (!NOT_FOUND.test(backend.stderr)) {
641
+ return fail(`describe ${names.backend}: ${backend.stderr.trim()}`);
642
+ }
643
+ const created = await run([
644
+ "compute",
645
+ "backend-services",
646
+ "create",
647
+ names.backend,
648
+ "--global",
649
+ project,
650
+ "--load-balancing-scheme=EXTERNAL_MANAGED",
651
+ "--protocol=HTTPS",
652
+ `--security-policy=${policy}`,
653
+ "--format=json",
654
+ "--quiet",
655
+ ]);
656
+ if (!created.ok)
657
+ return fail(`create ${names.backend}: ${created.stderr.trim()}`);
658
+ done.push(`created backend service ${names.backend} (Cloud Armor ${policy})`);
659
+ const parsed = safeJson(created.stdout);
660
+ backendJson = ((Array.isArray(parsed) ? parsed[0] : parsed) ?? {});
661
+ }
662
+ else {
663
+ backendJson = (safeJson(backend.stdout) ?? {});
664
+ }
665
+ if (!(backendJson.backends ?? []).some((b) => b.group?.endsWith(`/networkEndpointGroups/${names.neg}`))) {
666
+ const added = await run([
667
+ "compute",
668
+ "backend-services",
669
+ "add-backend",
670
+ names.backend,
671
+ "--global",
672
+ project,
673
+ `--network-endpoint-group=${names.neg}`,
674
+ `--network-endpoint-group-region=${t.region}`,
675
+ "--quiet",
676
+ ]);
677
+ if (!added.ok)
678
+ return fail(`add ${names.neg} to ${names.backend}: ${added.stderr.trim()}`);
679
+ done.push(`added ${names.neg} to ${names.backend}`);
680
+ }
681
+ const backendLink = backendJson.selfLink ??
682
+ `${COMPUTE_API}/projects/${t.projectId}/global/backendServices/${names.backend}`;
683
+ // 3. The staging host's own managed certificate on the HTTPS proxy. Named
684
+ // after the host: a managed certificate's domains are fixed, so a new
685
+ // staging host gets a new certificate and the old one leaves the proxy.
686
+ const certName = stagingCertName(t.env, t.stagingHost);
687
+ const cert = await run([
688
+ "compute",
689
+ "ssl-certificates",
690
+ "describe",
691
+ certName,
692
+ "--global",
693
+ project,
694
+ "--format=value(name)",
695
+ ]);
696
+ if (!cert.ok) {
697
+ if (!NOT_FOUND.test(cert.stderr))
698
+ return fail(`describe ${certName}: ${cert.stderr.trim()}`);
699
+ const created = await run([
700
+ "compute",
701
+ "ssl-certificates",
702
+ "create",
703
+ certName,
704
+ "--global",
705
+ project,
706
+ `--domains=${t.stagingHost}`,
707
+ "--quiet",
708
+ ]);
709
+ if (!created.ok)
710
+ return fail(`create ${certName}: ${created.stderr.trim()}`);
711
+ done.push(`created managed certificate ${certName} for ${t.stagingHost}`);
712
+ }
713
+ const proxies = await run([
714
+ "compute",
715
+ "target-https-proxies",
716
+ "list",
717
+ project,
718
+ `--filter=name~^${gcpNamePrefix(t.env)}`,
719
+ "--format=json",
720
+ ]);
721
+ if (!proxies.ok)
722
+ return fail(`list HTTPS proxies: ${proxies.stderr.trim()}`);
723
+ const proxyList = (safeJson(proxies.stdout) ?? []);
724
+ if (proxyList.length !== 1 || !proxyList[0]?.name) {
725
+ return fail(`expected exactly one ${gcpNamePrefix(t.env)}* HTTPS proxy, found ${proxyList.length}`);
726
+ }
727
+ const proxy = proxyList[0];
728
+ const certs = (proxy.sslCertificates ?? []).map((c) => c.slice(c.lastIndexOf("/") + 1));
729
+ const wanted = [...certs.filter((c) => !c.startsWith(`${names.cert}-`)), certName];
730
+ if (wanted.length !== certs.length || !certs.includes(certName)) {
731
+ const set = await run([
732
+ "compute",
733
+ "target-https-proxies",
734
+ "update",
735
+ proxy.name,
736
+ project,
737
+ `--ssl-certificates=${wanted.join(",")}`,
738
+ "--global-ssl-certificates",
739
+ "--quiet",
740
+ ]);
741
+ if (!set.ok)
742
+ return fail(`attach ${certName} to ${proxy.name}: ${set.stderr.trim()}`);
743
+ done.push(`attached ${certName} to ${proxy.name}`);
744
+ }
745
+ // 4. The URL map: the staging host to the staging gateway, every path.
746
+ const maps = await run(["compute", "url-maps", "list", "--global", project, "--format=json"]);
747
+ if (!maps.ok)
748
+ return fail(`list URL maps: ${maps.stderr.trim()}`);
749
+ const all = (safeJson(maps.stdout) ?? []);
750
+ const site = all.filter((m) => (m.hostRules ?? []).some((h) => h.hosts.includes(t.domain)));
751
+ if (site.length !== 1) {
752
+ return fail(`expected exactly one URL map routing ${t.domain}, found ${site.length}`);
753
+ }
754
+ const next = planStagingHostRule(site[0], t, backendLink);
755
+ if (next) {
756
+ const bearer = await accessToken(run);
757
+ if (!bearer.ok)
758
+ return fail(bearer.error);
759
+ if (!URL_MAP_NAME.test(next.name))
760
+ return fail(`"${next.name}" is not a URL map name`);
761
+ const headers = {
762
+ Authorization: `Bearer ${bearer.token}`,
763
+ "Content-Type": "application/json",
764
+ };
765
+ const base = `${COMPUTE_API}/projects/${encodeURIComponent(t.projectId)}/global`;
766
+ // `urlMaps.update` carries the fingerprint: a concurrent write (the
767
+ // admin publishing redirects) fails it instead of being lost.
768
+ const put = await http(`${base}/urlMaps/${encodeURIComponent(next.name)}`, {
769
+ method: "PUT",
770
+ headers,
771
+ body: JSON.stringify(next),
772
+ });
773
+ const text = await put.text();
774
+ if (!put.ok)
775
+ return fail(`update URL map ${next.name}: HTTP ${put.status} ${text.slice(0, 500)}`);
776
+ let op = (safeJson(text) ?? {});
777
+ for (let i = 0; op.status !== "DONE" && i < OPERATION_POLLS; i++) {
778
+ await sleep(2_000);
779
+ if (!OPERATION_NAME.test(op.name ?? "")) {
780
+ return fail(`the URL-map update answered no operation name (${op.name ?? "none"})`);
781
+ }
782
+ const poll = await http(`${base}/operations/${encodeURIComponent(op.name ?? "")}`, {
783
+ method: "GET",
784
+ headers,
785
+ });
786
+ if (!poll.ok)
787
+ return fail(`poll ${op.name}: HTTP ${poll.status}`);
788
+ op = (safeJson(await poll.text()) ?? {});
789
+ }
790
+ if (op.status !== "DONE")
791
+ return fail(`URL-map update ${op.name} did not finish`);
792
+ const errors = op.error?.errors ?? [];
793
+ if (errors.length > 0)
794
+ return fail(`URL-map update failed: ${errors.map((e) => e.message).join("; ")}`);
795
+ done.push(`URL map ${next.name}: ${t.stagingHost} → ${names.backend}`);
796
+ }
797
+ // The staging host answers on the load balancer's address, like the site.
798
+ const address = await run([
799
+ "compute",
800
+ "addresses",
801
+ "list",
802
+ "--global",
803
+ project,
804
+ `--filter=name~^${gcpNamePrefix(t.env)}`,
805
+ "--format=value(address)",
806
+ ]);
807
+ const ips = address.ok
808
+ ? address.stdout
809
+ .split("\n")
810
+ .map((s) => s.trim())
811
+ .filter(Boolean)
812
+ : [];
813
+ return {
814
+ ok: true,
815
+ done,
816
+ dnsRecords: ips.length === 1 ? [{ name: t.stagingHost, type: "A", value: ips[0] }] : [],
817
+ };
818
+ }
819
+ /** An OAuth access token as gcloud prints one: base64url-ish characters only. */
820
+ const ACCESS_TOKEN = /^[A-Za-z0-9._~+/=-]{1,4096}$/;
821
+ /** A Compute resource name (RFC 1035 label). */
822
+ const URL_MAP_NAME = /^[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?$/;
823
+ /** A Compute operation name. */
824
+ const OPERATION_NAME = /^[A-Za-z0-9_-]{1,128}$/;
825
+ /**
826
+ * The caller's gcloud access token, checked to be one before it goes into
827
+ * a request header: gcloud's output is never forwarded as it came.
828
+ */
829
+ async function accessToken(run) {
830
+ const r = await run(["auth", "print-access-token"]);
831
+ const token = r.ok ? r.stdout.trim() : "";
832
+ if (!ACCESS_TOKEN.test(token)) {
833
+ return {
834
+ ok: false,
835
+ error: `gcloud auth print-access-token gave no access token${r.ok ? "" : `: ${r.stderr.trim()}`}`,
836
+ };
837
+ }
838
+ return { ok: true, token };
839
+ }
840
+ function safeJson(text) {
841
+ try {
842
+ return JSON.parse(text);
843
+ }
844
+ catch {
845
+ return null;
846
+ }
847
+ }
848
+ //# sourceMappingURL=staging-gateway-setup.js.map