@caelo-cms/provisioning 0.11.2 → 0.11.4

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 (73) hide show
  1. package/dist/agent-db.js +46 -31
  2. package/dist/audit-job-setup.js +2 -2
  3. package/dist/audit-job-setup.js.map +1 -1
  4. package/dist/capture-job-setup.d.ts +46 -0
  5. package/dist/capture-job-setup.d.ts.map +1 -0
  6. package/dist/capture-job-setup.js +288 -0
  7. package/dist/capture-job-setup.js.map +1 -0
  8. package/dist/gcp-names.d.ts +17 -1
  9. package/dist/gcp-names.d.ts.map +1 -1
  10. package/dist/gcp-names.js +24 -0
  11. package/dist/gcp-names.js.map +1 -1
  12. package/dist/infra-contract.d.ts +1 -1
  13. package/dist/infra-contract.d.ts.map +1 -1
  14. package/dist/infra-contract.js +11 -1
  15. package/dist/infra-contract.js.map +1 -1
  16. package/dist/release-contract.d.ts.map +1 -1
  17. package/dist/release-contract.js +2 -0
  18. package/dist/release-contract.js.map +1 -1
  19. package/dist/stack-contract.d.ts +9 -0
  20. package/dist/stack-contract.d.ts.map +1 -1
  21. package/dist/stack-contract.js +16 -1
  22. package/dist/stack-contract.js.map +1 -1
  23. package/dist/upgrade/admin-image-jobs.d.ts +47 -0
  24. package/dist/upgrade/admin-image-jobs.d.ts.map +1 -0
  25. package/dist/upgrade/{audit-job-roll.js → admin-image-jobs.js} +19 -8
  26. package/dist/upgrade/admin-image-jobs.js.map +1 -0
  27. package/dist/upgrade/app-roll.d.ts.map +1 -1
  28. package/dist/upgrade/app-roll.js +31 -28
  29. package/dist/upgrade/app-roll.js.map +1 -1
  30. package/dist/upgrade/converge.d.ts.map +1 -1
  31. package/dist/upgrade/converge.js +31 -1
  32. package/dist/upgrade/converge.js.map +1 -1
  33. package/dist/upgrader-iam-preflight.d.ts.map +1 -1
  34. package/dist/upgrader-iam-preflight.js +8 -2
  35. package/dist/upgrader-iam-preflight.js.map +1 -1
  36. package/dist/upgrader-setup.d.ts.map +1 -1
  37. package/dist/upgrader-setup.js +9 -3
  38. package/dist/upgrader-setup.js.map +1 -1
  39. package/dist/wizards/gcp-cost.d.ts.map +1 -1
  40. package/dist/wizards/gcp-cost.js +5 -0
  41. package/dist/wizards/gcp-cost.js.map +1 -1
  42. package/dist/wizards/gcp.d.ts.map +1 -1
  43. package/dist/wizards/gcp.js +30 -0
  44. package/dist/wizards/gcp.js.map +1 -1
  45. package/package.json +2 -2
  46. package/src/audit-job-setup.ts +2 -2
  47. package/src/capture-job-setup.test.ts +213 -0
  48. package/src/capture-job-setup.ts +322 -0
  49. package/src/gcp-names.ts +29 -1
  50. package/src/infra-contract.test.ts +4 -3
  51. package/src/infra-contract.ts +11 -1
  52. package/src/release-contract.ts +2 -0
  53. package/src/stack-contract.test.ts +6 -0
  54. package/src/stack-contract.ts +17 -0
  55. package/src/stack-converge.test.ts +1 -1
  56. package/src/upgrade/{audit-job-roll.ts → admin-image-jobs.ts} +46 -16
  57. package/src/upgrade/app-roll.ts +34 -31
  58. package/src/upgrade/converge.ts +40 -0
  59. package/src/upgrade/upgrade.test.ts +40 -2
  60. package/src/upgrader-iam-preflight.test.ts +21 -0
  61. package/src/upgrader-iam-preflight.ts +8 -1
  62. package/src/upgrader-job.test.ts +1 -0
  63. package/src/upgrader-setup.test.ts +1 -0
  64. package/src/upgrader-setup.ts +9 -2
  65. package/src/wizards/gcp-cost.ts +6 -0
  66. package/src/wizards/gcp.ts +36 -0
  67. package/stacks/aws/index.ts +9 -0
  68. package/stacks/azure/index.ts +9 -0
  69. package/stacks/gcp/index.ts +13 -0
  70. package/stacks/gcp-firebase/index.ts +13 -0
  71. package/dist/upgrade/audit-job-roll.d.ts +0 -38
  72. package/dist/upgrade/audit-job-roll.d.ts.map +0 -1
  73. package/dist/upgrade/audit-job-roll.js.map +0 -1
@@ -55,7 +55,7 @@ import {
55
55
  rollService,
56
56
  serviceRollArgs,
57
57
  } from "../stack-converge.js";
58
- import { describeAuditJobImage, setAuditJobImage } from "./audit-job-roll.js";
58
+ import { adminImageJobs, describeJobImage, setJobImage } from "./admin-image-jobs.js";
59
59
  import { restoreCommand, type SqlBackup } from "./backup.js";
60
60
  import {
61
61
  describeServiceJson,
@@ -494,15 +494,15 @@ export async function executeAppRoll(
494
494
  }
495
495
 
496
496
  const rolled: RollPlan[] = [];
497
- /** The audit job's image before this roll moved it (#875); null = not moved. */
498
- let auditJobPriorImage: string | null = null;
497
+ /** Jobs this roll moved onto the admin's image (#875, #1217), with their prior image. */
498
+ const movedJobs: { readonly name: string; readonly label: string; readonly prior: string }[] = [];
499
499
  const rollBack = async (alsoCurrent?: RollPlan): Promise<void> => {
500
- if (auditJobPriorImage) {
501
- const back = await setAuditJobImage(run, install, auditJobPriorImage);
502
- if (back.ok) r.info(` audit job image restored to ${auditJobPriorImage.slice(-19)}`);
500
+ for (const j of movedJobs) {
501
+ const back = await setJobImage(run, install, j.name, j.prior);
502
+ if (back.ok) r.info(` ${j.label} image restored to ${j.prior.slice(-19)}`);
503
503
  else
504
504
  r.error(
505
- ` audit job rollback FAILED (${back.error}) — set its image to ${auditJobPriorImage} by hand`,
505
+ ` ${j.label} rollback FAILED (${back.error}) — set its image to ${j.prior} by hand`,
506
506
  );
507
507
  }
508
508
  const services = alsoCurrent ? [...rolled, alsoCurrent] : rolled;
@@ -589,31 +589,34 @@ export async function executeAppRoll(
589
589
  rolled.push(p);
590
590
  }
591
591
 
592
- // #875 — the audit job runs the admin's image; it follows the admin. A
593
- // failure here does not undo the release: audits then fail loudly with
594
- // the version mismatch and the fix, until the next upgrade moves it.
595
- const sAudit = r.step("Moving the quality-audit job onto the admin's image...");
596
- const described = await describeAuditJobImage(run, install);
597
- const auditPrior = described.ok ? described.image : null;
598
- if (!described.ok) {
599
- sAudit.warned(
600
- `Could not read the quality-audit job (${described.error}); Stage audits fail until the next upgrade moves it.`,
601
- );
602
- } else if (!auditPrior) {
603
- sAudit.warned(
604
- "The install has no quality-audit job yet; Stage audits fail until `cms-provision upgrade` sets it up.",
605
- );
606
- } else if (auditPrior === admin.imageRef) {
607
- sAudit.done("Quality-audit job already on the admin's image");
608
- } else {
609
- const moved = await setAuditJobImage(run, install, admin.imageRef);
610
- if (moved.ok) {
611
- auditJobPriorImage = auditPrior;
612
- sAudit.done(`Quality-audit job on ${admin.digest.slice(0, 19)}...`);
613
- } else {
614
- sAudit.warned(
615
- `Quality-audit job not moved (${moved.error}); Stage audits fail until the next upgrade moves it.`,
592
+ // #875 / #1217 — the audit and capture jobs run the admin's image; they
593
+ // follow the admin. A failure here does not undo the release: the job then
594
+ // refuses with the version mismatch and the fix, until the next upgrade
595
+ // moves it.
596
+ for (const job of adminImageJobs(install.env)) {
597
+ const sJob = r.step(`Moving the ${job.label} onto the admin's image...`);
598
+ const described = await describeJobImage(run, install, job.name);
599
+ const prior = described.ok ? described.image : null;
600
+ if (!described.ok) {
601
+ sJob.warned(
602
+ `Could not read the ${job.label} (${described.error}); ${job.lagging} until the next upgrade moves it.`,
616
603
  );
604
+ } else if (!prior) {
605
+ sJob.warned(
606
+ `The install has no ${job.label} yet; ${job.lagging} until \`cms-provision upgrade\` sets it up.`,
607
+ );
608
+ } else if (prior === admin.imageRef) {
609
+ sJob.done(`The ${job.label} is already on the admin's image`);
610
+ } else {
611
+ const moved = await setJobImage(run, install, job.name, admin.imageRef);
612
+ if (moved.ok) {
613
+ movedJobs.push({ name: job.name, label: job.label, prior });
614
+ sJob.done(`The ${job.label} is on ${admin.digest.slice(0, 19)}...`);
615
+ } else {
616
+ sJob.warned(
617
+ `The ${job.label} was not moved (${moved.error}); ${job.lagging} until the next upgrade moves it.`,
618
+ );
619
+ }
617
620
  }
618
621
  }
619
622
 
@@ -29,6 +29,7 @@
29
29
  */
30
30
 
31
31
  import { ensureAuditJob, resolveAdminImage } from "../audit-job-setup.js";
32
+ import { ensureCaptureJob } from "../capture-job-setup.js";
32
33
  import { ensureConvergeJob } from "../converge-job-setup.js";
33
34
  import { findSqlInstance, planPublicRoleSwitch } from "../gateway-credentials.js";
34
35
  import type { GcloudRunner, Sleep } from "../gcloud-retry.js";
@@ -49,6 +50,8 @@ import { selfCommand } from "../self-command.js";
49
50
  import {
50
51
  AUDIT_JOB_ENV_VAR,
51
52
  AUDIT_RESULTS_BUCKET_ENV_VAR,
53
+ CAPTURE_BUCKET_ENV_VAR,
54
+ CAPTURE_JOB_ENV_VAR,
52
55
  type CloudRunEnvVar,
53
56
  CONVERGE_JOB_ENV_VAR,
54
57
  MCP_ENV_VAR,
@@ -331,6 +334,42 @@ export function startConverge(
331
334
  leaveUntouched.push(AUDIT_JOB_ENV_VAR, AUDIT_RESULTS_BUCKET_ENV_VAR);
332
335
  }
333
336
 
337
+ async function captureJob(): Promise<void> {
338
+ // #1217 — the job every server-side browser render runs in, so no
339
+ // Chromium runs in the admin. Deployed with the admin's CURRENT image
340
+ // (the app-roll then moves both together; the job refuses an order from
341
+ // another version). A failure only costs server-side screenshots, so it
342
+ // warns and the admin keeps its current env.
343
+ const s = r.step("Ensuring the capture job (server-side screenshots outside the admin)...");
344
+ const image = await resolveAdminImage(
345
+ { projectId: install.projectId, region: install.region, env: install.env },
346
+ { run },
347
+ );
348
+ const cj = image.ok
349
+ ? await ensureCaptureJob(
350
+ {
351
+ provider: install.provider,
352
+ projectId: install.projectId,
353
+ region: install.region,
354
+ env: install.env,
355
+ adminImageRef: image.imageRef,
356
+ },
357
+ runDeps,
358
+ )
359
+ : { ok: false as const, done: [], error: image.error };
360
+ for (const step of cj.done) r.info(` ${step}`);
361
+ if (cj.ok) {
362
+ s.done("Capture job ready (screenshots run outside the admin)");
363
+ return;
364
+ }
365
+ s.warned(`Capture job not set up: ${cj.error}`);
366
+ r.warn(
367
+ "The upgrade continues; server-side screenshots fail with the missing job until this succeeds. Re-run upgrade once the cause is fixed.",
368
+ );
369
+ incomplete = true;
370
+ leaveUntouched.push(CAPTURE_JOB_ENV_VAR, CAPTURE_BUCKET_ENV_VAR);
371
+ }
372
+
334
373
  async function remoteMcpEdge(): Promise<void> {
335
374
  // #806 — on `gcp` the load balancer routes the remote-MCP discovery
336
375
  // paths to the gateway and Cloud Armor lets agent traffic through on
@@ -592,6 +631,7 @@ export function startConverge(
592
631
  await mcpAccess();
593
632
  await urlMapRedirectsAccess();
594
633
  await auditJob();
634
+ await captureJob();
595
635
  await remoteMcpEdge();
596
636
  return true;
597
637
  },
@@ -112,7 +112,7 @@ function fakeInstall(events: string[], overrides: [RegExp, Answer][] = []) {
112
112
  [new RegExp(`describe ${GATEWAY} .*--format=json`), ok(GATEWAY_JSON)],
113
113
  [/describe (\S+) .*status\.url/, (l) => ok(`https://${l.split(" ")[3]}-xyz.a.run.app\n`)],
114
114
  [
115
- /^run jobs describe caelo-production-audit .*containers\[0\]\.image/,
115
+ /^run jobs describe caelo-production-(audit|capture) .*containers\[0\]\.image/,
116
116
  ok(`${OLD_ADMIN_IMAGE}\n`),
117
117
  ],
118
118
  [/^sql instances list/, ok(`${SQL}\n`)],
@@ -242,7 +242,7 @@ const CONVERGE_WRITE =
242
242
  /add-iam-policy-binding|remove-iam-policy-binding|set-iam-policy|service-accounts create|secrets create|secrets versions add|services enable|roles create|roles update|roles undelete|firewall-rules delete|users assign-roles/;
243
243
  /** Calls that move a service or run migrations — app-roll-only. */
244
244
  const APP_ROLL_WRITE =
245
- /^run services update |^run services update-traffic|jobs execute caelo-migrate|^run jobs update caelo-production-audit /;
245
+ /^run services update |^run services update-traffic|jobs execute caelo-migrate|^run jobs update caelo-production-(audit|capture) /;
246
246
  /** Any gcloud call that changes something. */
247
247
  const ANY_WRITE =
248
248
  /\b(create|update|update-traffic|delete|add-iam-policy-binding|remove-iam-policy-binding|set-iam-policy|execute|enable|add|restore)\b/;
@@ -331,6 +331,39 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
331
331
  ).toBe(false);
332
332
  });
333
333
 
334
+ it("sets up the capture job on the CURRENT admin image before the roll, then moves it with the admin (#1217)", async () => {
335
+ const events: string[] = [];
336
+ const { deps, lines } = upgradeDeps(events);
337
+ expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
338
+ const deploy = indexOf(events, /^gcloud run jobs deploy caelo-production-capture /);
339
+ const adminRoll = indexOf(events, new RegExp(`^gcloud run services update ${ADMIN} `));
340
+ const move = indexOf(events, /^gcloud run jobs update caelo-production-capture /);
341
+ const deep = indexOf(events, /^health deep/);
342
+ expect(events[deploy]).toContain(`--image=${OLD_ADMIN_IMAGE}`);
343
+ expect(deploy < adminRoll && adminRoll < move && move < deep).toBe(true);
344
+ expect(events[move]).toContain(`--image=${IMAGES.admin.imageRef}`);
345
+ expect(events[adminRoll]).toContain(
346
+ "CAELO_CAPTURE_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-capture",
347
+ );
348
+ expect(lines).toContain("done: Capture job ready (screenshots run outside the admin)");
349
+ });
350
+
351
+ it("a failed capture-job setup leaves the converge incomplete and the admin's capture env untouched", async () => {
352
+ const events: string[] = [];
353
+ const { deps, recorded, lines } = upgradeDeps(events, {
354
+ overrides: [[/^run jobs deploy caelo-production-capture/, fail("PERMISSION_DENIED")]],
355
+ });
356
+ expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
357
+ expect(recorded.contract).toEqual([]);
358
+ expect(lines.some((l) => l.includes("Capture job not set up"))).toBe(true);
359
+ expect(
360
+ events.some((e) => /services update caelo-production-admin.*CAELO_CAPTURE_JOB=/.test(e)),
361
+ ).toBe(false);
362
+ expect(
363
+ events.some((e) => /services update caelo-production-admin.*CAELO_CAPTURE_BUCKET=/.test(e)),
364
+ ).toBe(false);
365
+ });
366
+
334
367
  it("an unreadable audit job is reported as an error, not as a missing job, and is not moved", async () => {
335
368
  const events: string[] = [];
336
369
  const { deps, lines } = upgradeDeps(events, {
@@ -465,6 +498,11 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
465
498
  .slice(deep)
466
499
  .filter((e) => e.startsWith("gcloud run jobs update caelo-production-audit "));
467
500
  expect(jobBack).toEqual([expect.stringContaining(`--image=${OLD_ADMIN_IMAGE}`)]);
501
+ // ... and so does the capture job (#1217).
502
+ const captureBack = events
503
+ .slice(deep)
504
+ .filter((e) => e.startsWith("gcloud run jobs update caelo-production-capture "));
505
+ expect(captureBack).toEqual([expect.stringContaining(`--image=${OLD_ADMIN_IMAGE}`)]);
468
506
  expect(recorded.digests).toEqual([]);
469
507
  expect(recorded.contract).toEqual([]);
470
508
  const restore = `gcloud sql backups restore ${BACKUP_ID} --restore-instance=${SQL} --project=acme`;
@@ -85,6 +85,10 @@ function setupGrants(): Map<string, Set<string>> {
85
85
  `projects/${p}/serviceAccounts/caelo-production-audit@${p}.iam.gserviceaccount.com`,
86
86
  of("roles/iam.serviceAccountUser"),
87
87
  ],
88
+ [
89
+ `projects/${p}/serviceAccounts/caelo-production-capture@${p}.iam.gserviceaccount.com`,
90
+ of("roles/iam.serviceAccountUser"),
91
+ ],
88
92
  // The project-level caeloUpgraderRun reaches the job too.
89
93
  [`projects/${p}/locations/europe-west1/jobs/caelo-production-upgrader`, project],
90
94
  [`gs://${p}-caelo-ops`, bucketList],
@@ -133,6 +137,23 @@ describe("upgraderAccessChecks", () => {
133
137
  );
134
138
  });
135
139
 
140
+ it("#1217: the capture job's account is checked too, since every app-roll moves that job", async () => {
141
+ const grants = setupGrants();
142
+ grants.delete(
143
+ "projects/acme/serviceAccounts/caelo-production-capture@acme.iam.gserviceaccount.com",
144
+ );
145
+ const r = await runIamPreflight(upgraderAccessChecks(TARGET), tester(grants));
146
+ expect(r.kind).toBe("missing");
147
+ if (r.kind !== "missing") return;
148
+ expect(r.missing.map((m) => [resourceName(m.check.resource), m.missing])).toEqual([
149
+ [
150
+ "projects/acme/serviceAccounts/caelo-production-capture@acme.iam.gserviceaccount.com",
151
+ ["iam.serviceAccounts.actAs"],
152
+ ],
153
+ ]);
154
+ expect(preflightMissingReason(r.missing, UPGRADER_PREFLIGHT_VOICE)).toContain("capture job");
155
+ });
156
+
136
157
  it("never needs a permission only a forbidden role would give (no compute, no IAM admin)", () => {
137
158
  const all = upgraderAccessChecks(TARGET).flatMap((c) => c.permissions);
138
159
  for (const p of all) {
@@ -52,6 +52,7 @@
52
52
  import { z } from "zod";
53
53
  import {
54
54
  auditServiceAccountEmail,
55
+ captureServiceAccountEmail,
55
56
  gatewayServiceAccountEmail,
56
57
  mcpIapServiceAccountEmail,
57
58
  runServiceAccountEmail,
@@ -187,7 +188,7 @@ export function upgraderAccessChecks(target: {
187
188
  },
188
189
  {
189
190
  // #875 — every app-roll moves the audit job onto the new admin image
190
- // (upgrade/audit-job-roll.ts), and upgrader-setup.ts grants actAs on
191
+ // (upgrade/admin-image-jobs.ts), and upgrader-setup.ts grants actAs on
191
192
  // the job's account for exactly that.
192
193
  need: "move the quality-audit job onto the new admin image",
193
194
  resource: project,
@@ -198,6 +199,12 @@ export function upgraderAccessChecks(target: {
198
199
  resource: sa(auditServiceAccountEmail(projectId, env)),
199
200
  permissions: ["iam.serviceAccounts.actAs"],
200
201
  },
202
+ {
203
+ // #1217 — the capture job follows the admin's image the same way.
204
+ need: "run the capture job as its service account after moving it",
205
+ resource: sa(captureServiceAccountEmail(projectId, env)),
206
+ permissions: ["iam.serviceAccounts.actAs"],
207
+ },
201
208
  {
202
209
  need: "keep the upgrader job running as its own service account after it updates itself",
203
210
  resource: sa(upgraderServiceAccountEmail(projectId, env)),
@@ -427,6 +427,7 @@ describe("runUpgraderJob", () => {
427
427
  "iam projects/acme/locations/europe-west1/jobs/caelo-production-upgrader",
428
428
  "iam projects/acme/serviceAccounts/caelo-mcp@acme.iam.gserviceaccount.com",
429
429
  "iam projects/acme/serviceAccounts/caelo-production-audit@acme.iam.gserviceaccount.com",
430
+ "iam projects/acme/serviceAccounts/caelo-production-capture@acme.iam.gserviceaccount.com",
430
431
  "iam projects/acme/serviceAccounts/caelo-production-gateway-sa@acme.iam.gserviceaccount.com",
431
432
  "iam projects/acme/serviceAccounts/caelo-production-run-sa@acme.iam.gserviceaccount.com",
432
433
  "iam projects/acme/serviceAccounts/caelo-production-upgrader@acme.iam.gserviceaccount.com",
@@ -84,6 +84,7 @@ describe("ensureUpgraderJob", () => {
84
84
  "iam service-accounts add-iam-policy-binding caelo-production-upgrader@acme.iam.gserviceaccount.com → roles/iam.serviceAccountUser",
85
85
  "iam service-accounts add-iam-policy-binding caelo-mcp@acme.iam.gserviceaccount.com → roles/iam.serviceAccountTokenCreator",
86
86
  "iam service-accounts add-iam-policy-binding caelo-production-audit@acme.iam.gserviceaccount.com → roles/iam.serviceAccountUser",
87
+ "iam service-accounts add-iam-policy-binding caelo-production-capture@acme.iam.gserviceaccount.com → roles/iam.serviceAccountUser",
87
88
  "storage buckets add-iam-policy-binding gs://acme-caelo-ops → roles/storage.objectUser",
88
89
  "storage buckets add-iam-policy-binding gs://acme-caelo-ops → projects/acme/roles/caeloUpgraderOpsList",
89
90
  ].sort(),
@@ -32,8 +32,9 @@
32
32
  * setup is removed.
33
33
  * - `roles/iam.serviceAccountUser` on exactly the admin's and the
34
34
  * gateway's runtime service accounts (a revision runs as them), on
35
- * itself (its own job) and on the quality-audit job's account (the
36
- * app-roll moves that job's image, #875) — not on the project.
35
+ * itself (its own job) and on the quality-audit and capture jobs'
36
+ * accounts (the app-roll moves those jobs' images, #875, #1217) — not
37
+ * on the project.
37
38
  * - the custom role `caeloUpgraderSqlBackup`: create/read Cloud SQL
38
39
  * backups and read the instance — no restore, no instance changes.
39
40
  * - `roles/cloudsql.instanceUser` + a Cloud SQL IAM database user that
@@ -76,6 +77,7 @@ import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
76
77
  import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
77
78
  import {
78
79
  auditServiceAccountEmail,
80
+ captureServiceAccountEmail,
79
81
  gatewayServiceAccountEmail,
80
82
  mcpIapServiceAccountEmail,
81
83
  runServiceAccountEmail,
@@ -371,6 +373,11 @@ export async function ensureUpgraderJob(
371
373
  role: "roles/iam.serviceAccountUser",
372
374
  why: "the app-roll moves the quality-audit job, which runs as it, onto the new image",
373
375
  },
376
+ {
377
+ account: captureServiceAccountEmail(projectId, env),
378
+ role: "roles/iam.serviceAccountUser",
379
+ why: "the app-roll moves the capture job, which runs as it, onto the new image",
380
+ },
374
381
  {
375
382
  account: mcpIapServiceAccountEmail(projectId),
376
383
  role: "roles/iam.serviceAccountTokenCreator",
@@ -109,6 +109,12 @@ export function estimateGcpCost(inputs: CostEstimateInputs): {
109
109
  notes:
110
110
  "one container per Lighthouse run, all in parallel (up to 5 pages x 3 runs, ~90 s at 2 vCPU each): ~$0.05 per audited Stage, partly in the free tier; results bucket ~$0",
111
111
  },
112
+ {
113
+ name: "Screenshots (Cloud Run capture job)",
114
+ monthlyUsd: 1,
115
+ notes:
116
+ "one short container per server-side screenshot or page render (~20-40 s at 1 vCPU / 2 GiB): ~$0.001 each, a 50-page site import ~$0.05; mostly in the free tier; capture bucket ~$0",
117
+ },
112
118
  {
113
119
  name: "Cloud Run gateway",
114
120
  monthlyUsd: inputs.gatewayMinInstances === 0 ? 1 : 1 + inputs.gatewayMinInstances * 15,
@@ -35,6 +35,7 @@ import { bold, cyan, dim, green, red, yellow } from "kleur/colors";
35
35
  import { checkAdcImpersonation } from "../adc-impersonation.js";
36
36
  import { type AdoptionCandidate, AdoptionError, formatAdopted } from "../adopt-existing.js";
37
37
  import { ensureAuditJob, resolveAdminImage } from "../audit-job-setup.js";
38
+ import { ensureCaptureJob } from "../capture-job-setup.js";
38
39
  import { CERT_WAIT_TIMEOUT_MS, lbCertProbe, waitForCert } from "../cert-wait.js";
39
40
  import { redactCredentials } from "../checkpoint-secrets.js";
40
41
  import { ensureConvergeJob } from "../converge-job-setup.js";
@@ -507,6 +508,10 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
507
508
  // Before the upgrader, which gets actAs on the job's service account.
508
509
  await stepAuditJob(projectId, region, provider);
509
510
 
511
+ // === 11.35. The capture job: server-side screenshots outside the admin (#1217) ===
512
+ // Before the upgrader as well, which gets actAs on its service account.
513
+ await stepCaptureJob(projectId, region, provider);
514
+
510
515
  // === 11.4. The upgrader job: Settings → Updates → Install update (#696) ===
511
516
  // Before operator access, which keeps the upgrader's caelo-mcp grant.
512
517
  const releaseVersion = await releaseVersionForDigest(gcloud, "admin", imageDigests.admin);
@@ -1149,6 +1154,37 @@ async function stepAuditJob(
1149
1154
  s.stop(green(`Quality-audit job ready (${r.done.length} steps)`));
1150
1155
  }
1151
1156
 
1157
+ /**
1158
+ * Set up the capture job (capture-job-setup.ts) on the deployed admin's
1159
+ * image. A failure stops the wizard: without the job every server-side
1160
+ * screenshot fails. Idempotent, so it runs on every wizard pass.
1161
+ */
1162
+ async function stepCaptureJob(
1163
+ projectId: string,
1164
+ region: string,
1165
+ provider: "gcp" | "gcp-firebase",
1166
+ ): Promise<void> {
1167
+ const s = spinner();
1168
+ s.start("Setting up the capture job (server-side screenshots outside the admin)...");
1169
+ const image = await resolveAdminImage({ projectId, region, env: GCP_STACK_ENV });
1170
+ const r = image.ok
1171
+ ? await ensureCaptureJob({
1172
+ provider,
1173
+ projectId,
1174
+ region,
1175
+ env: GCP_STACK_ENV,
1176
+ adminImageRef: image.imageRef,
1177
+ })
1178
+ : { ok: false as const, done: [], error: image.error };
1179
+ if (!r.ok) {
1180
+ s.stop(red(`Failed: ${r.error}`));
1181
+ log.error("Re-run the wizard once the cause is fixed (this step is idempotent).");
1182
+ cancel("Aborted.");
1183
+ process.exit(1);
1184
+ }
1185
+ s.stop(green(`Capture job ready (${r.done.length} steps)`));
1186
+ }
1187
+
1152
1188
  /**
1153
1189
  * Whether an upgrader set up by an earlier run exists, so a failed setup
1154
1190
  * this time still keeps its caelo-mcp signing grant (the operator-access
@@ -398,6 +398,15 @@ new aws.cloudwatch.LogDelivery(
398
398
  // (quality/lighthouse-runner-select.ts) instead of measuring on its own
399
399
  // shared CPU. Nothing is staged on AWS before #749 either.
400
400
  //
401
+ // FIXME(issue #749): no capture task definition yet. On GCP every
402
+ // server-side browser render (screenshots, external page renders, the
403
+ // site-import crawl) runs in its own short-lived container (#1217,
404
+ // src/capture-job-setup.ts) so the admin never starts Chromium; the AWS
405
+ // equivalent is a Fargate task per render behind admin-core
406
+ // capture/capture-client.ts `CaptureLauncher`. Until it exists the admin
407
+ // refuses every server-side render with that gap
408
+ // (capture/capture-backend.ts) instead of running a browser in its own task.
409
+ //
401
410
  // FIXME(issue #696): no in-cloud upgrader or converge job (#819) yet. On
402
411
  // GCP Settings → Updates hands app-roll releases to the upgrader job and
403
412
  // cloud-setup releases to the converge job; on AWS the admin refuses a
@@ -361,6 +361,15 @@ const logWorkspace = new azure.operationalinsights.Workspace(`${namePrefix}-logs
361
361
  // instead of measuring on its own shared CPU. Nothing is staged on Azure
362
362
  // before #750 either.
363
363
  //
364
+ // FIXME(issue #750): no capture Container Apps job yet. On GCP every
365
+ // server-side browser render (screenshots, external page renders, the
366
+ // site-import crawl) runs in its own short-lived container (#1217,
367
+ // src/capture-job-setup.ts) so the admin never starts Chromium; the Azure
368
+ // equivalent is a Container Apps job execution per render behind admin-core
369
+ // capture/capture-client.ts `CaptureLauncher`. Until it exists the admin
370
+ // refuses every server-side render with that gap
371
+ // (capture/capture-backend.ts) instead of running a browser in its own app.
372
+ //
364
373
  // FIXME(issue #696): no in-cloud upgrader or converge job (#819) yet. On
365
374
  // GCP Settings → Updates hands app-roll releases to the upgrader job and
366
375
  // cloud-setup releases to the converge job; on Azure the admin refuses a
@@ -1057,6 +1057,19 @@ new gcp.iap.WebBackendServiceIamMember(
1057
1057
  // bucket or job both this program and `upgrade` created would collide on
1058
1058
  // the next wizard run. The app-roll moves the job's image with the admin's.
1059
1059
 
1060
+ // =========================================================================
1061
+ // Capture job — deliberately NOT here
1062
+ // =========================================================================
1063
+ //
1064
+ // #1217 — every server-side browser render (screenshots, rendered HTML of
1065
+ // external pages, the site-import crawl) runs in a short-lived Cloud Run job,
1066
+ // one execution per render, so no Chromium runs in the admin. Its service
1067
+ // account may only create results under `out/` of the capture bucket; it
1068
+ // reads its input and a preview's media through URLs the admin signs per
1069
+ // capture. The CLI owns the job, its account, the bucket and the grants
1070
+ // (packages/provisioning/src/capture-job-setup.ts), for the same reason as
1071
+ // the audit job above.
1072
+
1060
1073
  // =========================================================================
1061
1074
  // Operator access — deliberately NOT here
1062
1075
  // =========================================================================
@@ -964,6 +964,19 @@ new gcp.iap.WebCloudRunServiceIamMember(
964
964
  // bucket or job both this program and `upgrade` created would collide on
965
965
  // the next wizard run. The app-roll moves the job's image with the admin's.
966
966
 
967
+ // =========================================================================
968
+ // Capture job — deliberately NOT here
969
+ // =========================================================================
970
+ //
971
+ // #1217 — every server-side browser render (screenshots, rendered HTML of
972
+ // external pages, the site-import crawl) runs in a short-lived Cloud Run job,
973
+ // one execution per render, so no Chromium runs in the admin. Its service
974
+ // account may only create results under `out/` of the capture bucket; it
975
+ // reads its input and a preview's media through URLs the admin signs per
976
+ // capture. The CLI owns the job, its account, the bucket and the grants
977
+ // (packages/provisioning/src/capture-job-setup.ts), for the same reason as
978
+ // the audit job above.
979
+
967
980
  // =========================================================================
968
981
  // Operator access — deliberately NOT here
969
982
  // =========================================================================
@@ -1,38 +0,0 @@
1
- /**
2
- * #875 — the quality-audit job runs the admin image (admin-core
3
- * quality/audit-task.ts) and refuses a batch from an admin on another
4
- * version. So every app-roll moves the job onto the admin's new image right
5
- * after the services rolled, and a rollback moves it back.
6
- *
7
- * `run jobs update --image` changes nothing else of the job (identity, env,
8
- * parallelism), so this stays inside the app-roll's rights: the upgrader's
9
- * `roles/run.developer` plus actAs on the job's service account
10
- * (upgrader-setup.ts).
11
- */
12
- import type { GcloudRunner } from "../gcloud-retry.js";
13
- /**
14
- * The job's image now; `image: null` only when the install has no audit job
15
- * yet. Any other gcloud failure (permissions, API, network) is an error, not
16
- * "no job", so the app-roll reports it instead of a misleading hint.
17
- */
18
- export declare function describeAuditJobImage(run: GcloudRunner, install: {
19
- readonly projectId: string;
20
- readonly region: string;
21
- readonly env: string;
22
- }): Promise<{
23
- readonly ok: true;
24
- readonly image: string | null;
25
- } | {
26
- readonly ok: false;
27
- readonly error: string;
28
- }>;
29
- /** Point the audit job at `imageRef`. */
30
- export declare function setAuditJobImage(run: GcloudRunner, install: {
31
- readonly projectId: string;
32
- readonly region: string;
33
- readonly env: string;
34
- }, imageRef: string): Promise<{
35
- readonly ok: boolean;
36
- readonly error?: string;
37
- }>;
38
- //# sourceMappingURL=audit-job-roll.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"audit-job-roll.d.ts","sourceRoot":"","sources":["../../src/upgrade/audit-job-roll.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAGvD;;;;GAIG;AACH,wBAAsB,qBAAqB,CACzC,GAAG,EAAE,YAAY,EACjB,OAAO,EAAE;IAAE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACrF,OAAO,CACN;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GACpD;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CACjD,CAsBA;AAED,yCAAyC;AACzC,wBAAsB,gBAAgB,CACpC,GAAG,EAAE,YAAY,EACjB,OAAO,EAAE;IAAE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CAAE,EACtF,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC;IAAE,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAY5D"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"audit-job-roll.js","sourceRoot":"","sources":["../../src/upgrade/audit-job-roll.ts"],"names":[],"mappings":"AAAA,mCAAmC;AAenC,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAE/C;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB,CACzC,GAAiB,EACjB,OAAsF;IAKtF,MAAM,CAAC,GAAG,MAAM,GAAG,CAAC;QAClB,KAAK;QACL,MAAM;QACN,UAAU;QACV,YAAY,CAAC,OAAO,CAAC,GAAG,CAAC;QACzB,YAAY,OAAO,CAAC,MAAM,EAAE;QAC5B,aAAa,OAAO,CAAC,SAAS,EAAE;QAChC,sEAAsE;KACvE,CAAC,CAAC;IACH,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;QACV,wEAAwE;QACxE,0CAA0C;QAC1C,OAAO,qDAAqD,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC;YACzE,CAAC,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE;YAC3B,CAAC,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC;IAC5C,CAAC;IACD,MAAM,KAAK,GAAG,CAAC,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;IAC9B,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACvB,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,gDAAgD,EAAE,CAAC;IAChF,CAAC;IACD,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;AAC7B,CAAC;AAED,yCAAyC;AACzC,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACpC,GAAiB,EACjB,OAAsF,EACtF,QAAgB;IAEhB,MAAM,CAAC,GAAG,MAAM,GAAG,CAAC;QAClB,KAAK;QACL,MAAM;QACN,QAAQ;QACR,YAAY,CAAC,OAAO,CAAC,GAAG,CAAC;QACzB,WAAW,QAAQ,EAAE;QACrB,YAAY,OAAO,CAAC,MAAM,EAAE;QAC5B,aAAa,OAAO,CAAC,SAAS,EAAE;QAChC,SAAS;KACV,CAAC,CAAC;IACH,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC;AACrE,CAAC"}