@caelo-cms/provisioning 0.10.53 → 0.10.55

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 (90) hide show
  1. package/dist/agent-db.js +196 -109
  2. package/dist/audit-job-setup.d.ts +69 -0
  3. package/dist/audit-job-setup.d.ts.map +1 -0
  4. package/dist/audit-job-setup.js +317 -0
  5. package/dist/audit-job-setup.js.map +1 -0
  6. package/dist/caddy.d.ts +9 -0
  7. package/dist/caddy.d.ts.map +1 -1
  8. package/dist/caddy.js +38 -0
  9. package/dist/caddy.js.map +1 -1
  10. package/dist/cli.js +32 -8
  11. package/dist/cli.js.map +1 -1
  12. package/dist/gcp-names.d.ts +16 -1
  13. package/dist/gcp-names.d.ts.map +1 -1
  14. package/dist/gcp-names.js +23 -0
  15. package/dist/gcp-names.js.map +1 -1
  16. package/dist/infra-contract.d.ts +1 -1
  17. package/dist/infra-contract.d.ts.map +1 -1
  18. package/dist/infra-contract.js +7 -1
  19. package/dist/infra-contract.js.map +1 -1
  20. package/dist/private-file.d.ts +26 -0
  21. package/dist/private-file.d.ts.map +1 -0
  22. package/dist/private-file.js +49 -0
  23. package/dist/private-file.js.map +1 -0
  24. package/dist/release-contract.d.ts.map +1 -1
  25. package/dist/release-contract.js +3 -0
  26. package/dist/release-contract.js.map +1 -1
  27. package/dist/stack-contract.d.ts +8 -0
  28. package/dist/stack-contract.d.ts.map +1 -1
  29. package/dist/stack-contract.js +13 -1
  30. package/dist/stack-contract.js.map +1 -1
  31. package/dist/tar-entries.d.ts.map +1 -1
  32. package/dist/tar-entries.js +84 -9
  33. package/dist/tar-entries.js.map +1 -1
  34. package/dist/upgrade/app-roll.d.ts.map +1 -1
  35. package/dist/upgrade/app-roll.js +35 -0
  36. package/dist/upgrade/app-roll.js.map +1 -1
  37. package/dist/upgrade/audit-job-roll.d.ts +38 -0
  38. package/dist/upgrade/audit-job-roll.d.ts.map +1 -0
  39. package/dist/upgrade/audit-job-roll.js +43 -0
  40. package/dist/upgrade/audit-job-roll.js.map +1 -0
  41. package/dist/upgrade/converge.d.ts.map +1 -1
  42. package/dist/upgrade/converge.js +33 -2
  43. package/dist/upgrade/converge.js.map +1 -1
  44. package/dist/upgrader-setup.d.ts.map +1 -1
  45. package/dist/upgrader-setup.js +9 -3
  46. package/dist/upgrader-setup.js.map +1 -1
  47. package/dist/wizards/gcp-cost.js +3 -3
  48. package/dist/wizards/gcp-cost.js.map +1 -1
  49. package/dist/wizards/gcp-pulumi.d.ts +6 -0
  50. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  51. package/dist/wizards/gcp-pulumi.js +7 -0
  52. package/dist/wizards/gcp-pulumi.js.map +1 -1
  53. package/dist/wizards/gcp.d.ts.map +1 -1
  54. package/dist/wizards/gcp.js +31 -0
  55. package/dist/wizards/gcp.js.map +1 -1
  56. package/package.json +4 -4
  57. package/src/agent/run.test.ts +1 -0
  58. package/src/audit-job-setup.test.ts +234 -0
  59. package/src/audit-job-setup.ts +360 -0
  60. package/src/caddy-redirects.docker.test.ts +19 -6
  61. package/src/caddy.test.ts +30 -0
  62. package/src/caddy.ts +43 -0
  63. package/src/cli-init-validation.test.ts +44 -0
  64. package/src/cli.ts +36 -7
  65. package/src/gcp-names.ts +28 -1
  66. package/src/infra-contract.test.ts +3 -3
  67. package/src/infra-contract.ts +7 -1
  68. package/src/private-file.test.ts +80 -0
  69. package/src/private-file.ts +50 -0
  70. package/src/release-contract.ts +3 -0
  71. package/src/stack-contract.test.ts +4 -0
  72. package/src/stack-contract.ts +14 -0
  73. package/src/stack-converge.test.ts +1 -1
  74. package/src/tar-entries.test.ts +149 -7
  75. package/src/tar-entries.ts +90 -9
  76. package/src/upgrade/app-roll.ts +39 -0
  77. package/src/upgrade/audit-job-roll.ts +68 -0
  78. package/src/upgrade/converge.ts +42 -1
  79. package/src/upgrade/upgrade.test.ts +76 -2
  80. package/src/upgrader-assess.test.ts +1 -0
  81. package/src/upgrader-job.test.ts +1 -0
  82. package/src/upgrader-setup.test.ts +1 -0
  83. package/src/upgrader-setup.ts +9 -2
  84. package/src/wizards/gcp-cost.ts +3 -3
  85. package/src/wizards/gcp-pulumi.ts +7 -0
  86. package/src/wizards/gcp.ts +37 -0
  87. package/stacks/aws/index.ts +8 -0
  88. package/stacks/azure/index.ts +9 -0
  89. package/stacks/gcp/index.ts +13 -0
  90. package/stacks/gcp-firebase/index.ts +13 -0
@@ -35,18 +35,160 @@ describe("listTarEntries", () => {
35
35
  });
36
36
 
37
37
  it("refuses a truncated archive instead of skipping members", () => {
38
- const header = new Uint8Array(512);
39
- header.set(new TextEncoder().encode("a.txt"), 0);
40
- header.set(new TextEncoder().encode("00000001000\0"), 124); // 512 bytes of data, none present
41
- header[156] = "0".charCodeAt(0);
42
- expect(() => listTarEntries(header)).toThrow(/truncated/);
38
+ expect(() => listTarEntries(header("a.txt", "0", { sizeField: 512 }))).toThrow(/truncated/);
39
+ });
40
+
41
+ it("rejects PAX global extended headers ('g')", () => {
42
+ const g = member("PaxHeader", "g", paxLine("path=/etc/passwd"));
43
+ expect(() => listTarEntries(concat(g, member("safe.txt", "0"), END))).toThrow(
44
+ /pax global extended header/,
45
+ );
46
+ });
47
+
48
+ it("refuses a member named by two pax headers — GNU tar keeps the last, bsdtar drops the second", () => {
49
+ // Both tars extract `../../evil` here (the second header carries no
50
+ // path); reading the first header's path would have checked `safe.txt`.
51
+ const archive = concat(
52
+ member("PaxHeader", "x", paxLine("path=srv/caelo/safe.txt")),
53
+ member("PaxHeader", "x", paxLine("mtime=1")),
54
+ member("../../evil", "0"),
55
+ END,
56
+ );
57
+ expect(() => listTarEntries(archive)).toThrow(/several extension headers \(xx\)/);
58
+ });
59
+
60
+ it("refuses a GNU long name next to a pax path, and two long names", () => {
61
+ // bsdtar extracts the `L` name (`../../evil`), GNU tar the pax path.
62
+ const mixed = concat(
63
+ member("././@LongLink", "L", "../../evil\0"),
64
+ member("PaxHeader", "x", paxLine("path=srv/caelo/safe.txt")),
65
+ member("x", "0"),
66
+ END,
67
+ );
68
+ expect(() => listTarEntries(mixed)).toThrow(/several extension headers \(Lx\)/);
69
+ const twice = concat(
70
+ member("././@LongLink", "L", "../../evil\0"),
71
+ member("././@LongLink", "L", "safe.txt\0"),
72
+ member("x", "0"),
73
+ END,
74
+ );
75
+ expect(() => listTarEntries(twice)).toThrow(/several extension headers \(LL\)/);
76
+ });
77
+
78
+ it("still reads a GNU long name together with a long link", () => {
79
+ const archive = concat(
80
+ member("././@LongLink", "K", "target\0"),
81
+ member("././@LongLink", "L", `${"a".repeat(120)}\0`),
82
+ member("short", "2", "", { link: "ignored" }),
83
+ END,
84
+ );
85
+ expect(listTarEntries(archive)).toEqual([
86
+ { name: "a".repeat(120), type: "symlink", linkTarget: "target" },
87
+ ]);
88
+ });
89
+
90
+ it("refuses a header whose checksum does not match — GNU tar would skip it and read its body as headers", () => {
91
+ const archive = concat(
92
+ header("srv/caelo/safe.txt", "0", { sizeField: 512, badChecksum: true }),
93
+ header("../../evil", "0"),
94
+ END,
95
+ );
96
+ expect(() => listTarEntries(archive)).toThrow(/checksum mismatch/);
97
+ });
98
+
99
+ it("refuses a directory or symlink with a body — bsdtar reads the next block as a header", () => {
100
+ for (const [name, type] of [
101
+ ["srv/caelo/link", "2"],
102
+ ["srv/caelo/dir/", "5"],
103
+ ] as const) {
104
+ const archive = concat(
105
+ header(name, type, { sizeField: 512, link: "a" }),
106
+ header("../../evil", "0"),
107
+ END,
108
+ );
109
+ expect(() => listTarEntries(archive)).toThrow(/with a body/);
110
+ }
111
+ });
112
+
113
+ it("walks a member's body by its pax size, so a member hidden behind the header size is seen", () => {
114
+ // tar -x reads `safe.txt` as empty (pax size=0) and then extracts the
115
+ // `../../evil` header that the ustar size field claims is file data.
116
+ const archive = concat(
117
+ member("PaxHeader", "x", paxLine("size=0")),
118
+ header("safe.txt", "0", { sizeField: 512 }),
119
+ member("../../evil", "0"),
120
+ END,
121
+ );
122
+ expect(listTarEntries(archive).map((e) => e.name)).toEqual(["safe.txt", "../../evil"]);
123
+ });
124
+
125
+ it("rejects GNU sparse pax records and an unreadable pax size", () => {
126
+ const sparse = member("PaxHeader", "x", paxLine("GNU.sparse.name=../../evil"));
127
+ expect(() => listTarEntries(concat(sparse, member("safe.txt", "0")))).toThrow(/GNU sparse/);
128
+ const badSize = member("PaxHeader", "x", paxLine("size=-1"));
129
+ expect(() => listTarEntries(concat(badSize, member("safe.txt", "0")))).toThrow(
130
+ /unreadable pax size/,
131
+ );
43
132
  });
44
133
  });
45
134
 
46
- describe("escapesRoot", () => {
47
- it("flags absolute and parent-relative member names", () => {
135
+ const END = new Uint8Array(1024);
136
+
137
+ /**
138
+ * One ustar header block with a valid checksum (unless `badChecksum`).
139
+ * `sizeField` is what the header claims, independent of any body after it.
140
+ */
141
+ function header(
142
+ name: string,
143
+ typeflag: string,
144
+ opts: { sizeField?: number; link?: string; badChecksum?: boolean } = {},
145
+ ): Uint8Array {
146
+ const enc = new TextEncoder();
147
+ const h = new Uint8Array(512);
148
+ h.set(enc.encode(name), 0);
149
+ h.set(enc.encode(`${(opts.sizeField ?? 0).toString(8).padStart(11, "0")}\0`), 124);
150
+ h[156] = typeflag.charCodeAt(0);
151
+ if (opts.link) h.set(enc.encode(opts.link), 157);
152
+ h.set(enc.encode("ustar\0"), 257);
153
+ h.fill(0x20, 148, 156);
154
+ const sum = h.reduce((n, b) => n + b, 0) + (opts.badChecksum ? 1 : 0);
155
+ h.set(enc.encode(`${sum.toString(8).padStart(6, "0")}\0 `), 148);
156
+ return h;
157
+ }
158
+
159
+ /** A header plus its padded body. */
160
+ function member(name: string, typeflag: string, bodyText = "", opts: { link?: string } = {}) {
161
+ const body = new TextEncoder().encode(bodyText);
162
+ const out = new Uint8Array(512 + Math.ceil(body.length / 512) * 512);
163
+ out.set(header(name, typeflag, { sizeField: body.length, link: opts.link }), 0);
164
+ out.set(body, 512);
165
+ return out;
166
+ }
167
+
168
+ /** One pax record; its leading length counts its own digits. */
169
+ function paxLine(kv: string): string {
170
+ const base = ` ${kv}\n`;
171
+ const len = String(base.length + 2).length + base.length;
172
+ return `${len}${base}`;
173
+ }
174
+
175
+ function concat(...parts: Uint8Array[]): Uint8Array {
176
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
177
+ let at = 0;
178
+ for (const p of parts) {
179
+ out.set(p, at);
180
+ at += p.length;
181
+ }
182
+ return out;
183
+ }
184
+
185
+ describe("escapesRoot & normalizeMemberName", () => {
186
+ it("flags absolute, parent-relative, and dot-segment member names", () => {
48
187
  expect(escapesRoot("/etc/passwd")).toBe(true);
49
188
  expect(escapesRoot("srv/caelo/../../app")).toBe(true);
189
+ expect(escapesRoot(".//../etc/passwd")).toBe(true);
190
+ expect(escapesRoot("a/./b")).toBe(true);
50
191
  expect(escapesRoot("./srv/caelo/a..b")).toBe(false);
192
+ expect(normalizeMemberName(".//srv//caelo/")).toBe("srv/caelo");
51
193
  });
52
194
  });
@@ -7,12 +7,34 @@
7
7
  * otherwise land outside the target directory).
8
8
  *
9
9
  * Parses ustar headers plus the two extensions GNU tar and bsdtar emit for
10
- * long names: GNU `L`/`K` records and pax `x`/`g` records (`path=`,
11
- * `linkpath=`). Anything it cannot read is an error, never a skipped member.
10
+ * long names: GNU `L`/`K` records and pax `x` records (`path=`, `linkpath=`,
11
+ * `size=`). Anything it cannot read is an error, never a skipped member.
12
+ *
13
+ * The parser must walk the archive exactly as `tar -x` will: a pax `size=`
14
+ * overrides the header's size field for the next member, so ignoring it
15
+ * would let an archive hide a member from this check inside what the parser
16
+ * takes for a body. Records that rename a member in ways it does not model
17
+ * (pax global `g` headers, GNU sparse `GNU.sparse.*` keys) are refused.
18
+ *
19
+ * Wherever GNU tar and bsdtar read the same bytes differently, the archive is
20
+ * refused rather than read one way: no Caelo backup is ever written like that,
21
+ * and whichever reading this module picked, the other tar would extract
22
+ * something it never checked.
23
+ * - A header whose checksum does not match: GNU tar skips it and reads the
24
+ * next block as a header, so a member hidden in the "body" gets extracted.
25
+ * - A directory or symlink with a non-zero size: bsdtar reads no body for
26
+ * them, so the block after the header is the next member's header.
27
+ * - Two name records for one member (two pax `x` headers, two `L`/`K`, or
28
+ * `L`/`K` next to `x`): GNU tar keeps only the last `x` header, bsdtar
29
+ * flags the second as malformed and drops it, and the two tars disagree
30
+ * on whether a GNU long name or a pax path wins.
12
31
  */
13
32
 
14
33
  const BLOCK = 512;
15
34
 
35
+ /** The extension headers one member may carry: one pax record, or a GNU long name and/or long link. */
36
+ const SINGLE_NAMING = new Set(["x", "L", "K", "LK", "KL"]);
37
+
16
38
  export type TarEntryType = "file" | "directory" | "symlink" | "other";
17
39
 
18
40
  export interface TarEntry {
@@ -30,15 +52,34 @@ export function listTarEntries(tar: Uint8Array): TarEntry[] {
30
52
  let longLink: string | null = null;
31
53
  let paxPath: string | null = null;
32
54
  let paxLink: string | null = null;
55
+ let paxSize: number | null = null;
56
+ // The extension headers already seen for the member being read.
57
+ let extensions = "";
33
58
  while (offset + BLOCK <= tar.length) {
34
59
  const header = tar.subarray(offset, offset + BLOCK);
35
60
  if (header.every((b) => b === 0)) break; // end-of-archive marker
36
- const size = octal(header.subarray(124, 136));
61
+ verifyChecksum(header);
37
62
  const typeflag = String.fromCharCode(header[156] ?? 0);
63
+ // A pax `size=` describes the member that follows the pax header, never
64
+ // another extension header (GNU tar applies it the same way).
65
+ const isExtension =
66
+ typeflag === "x" || typeflag === "g" || typeflag === "L" || typeflag === "K";
67
+ const size = !isExtension && paxSize !== null ? paxSize : octal(header.subarray(124, 136));
38
68
  const body = tar.subarray(offset + BLOCK, offset + BLOCK + size);
39
69
  if (body.length < size) throw new Error("truncated tar archive");
40
70
  offset += BLOCK + Math.ceil(size / BLOCK) * BLOCK;
41
71
 
72
+ if (typeflag === "g") {
73
+ throw new Error("tar archive contains unsupported pax global extended header");
74
+ }
75
+ if (isExtension) {
76
+ extensions += typeflag;
77
+ if (!SINGLE_NAMING.has(extensions)) {
78
+ throw new Error(
79
+ `tar archive names one member through several extension headers (${extensions}) — tar implementations disagree on which one wins`,
80
+ );
81
+ }
82
+ }
42
83
  if (typeflag === "L") {
43
84
  longName = cString(body);
44
85
  continue;
@@ -47,11 +88,18 @@ export function listTarEntries(tar: Uint8Array): TarEntry[] {
47
88
  longLink = cString(body);
48
89
  continue;
49
90
  }
50
- if (typeflag === "x" || typeflag === "g") {
91
+ if (typeflag === "x") {
51
92
  const pax = paxRecords(body);
52
- if (typeflag === "x") {
53
- paxPath = pax.path ?? null;
54
- paxLink = pax.linkpath ?? null;
93
+ if (Object.keys(pax).some((key) => key.startsWith("GNU.sparse."))) {
94
+ throw new Error("tar archive contains an unsupported GNU sparse member");
95
+ }
96
+ paxPath = pax.path ?? null;
97
+ paxLink = pax.linkpath ?? null;
98
+ if (pax.size !== undefined) {
99
+ if (!/^[0-9]+$/.test(pax.size)) {
100
+ throw new Error(`unreadable pax size ${JSON.stringify(pax.size)}`);
101
+ }
102
+ paxSize = Number.parseInt(pax.size, 10);
55
103
  }
56
104
  continue;
57
105
  }
@@ -60,6 +108,8 @@ export function listTarEntries(tar: Uint8Array): TarEntry[] {
60
108
  const name = paxPath ?? longName ?? (prefix ? `${prefix}/${shortName}` : shortName);
61
109
  const link = paxLink ?? longLink ?? cString(header.subarray(157, 257));
62
110
  longName = longLink = paxPath = paxLink = null;
111
+ paxSize = null;
112
+ extensions = "";
63
113
  const type: TarEntryType =
64
114
  typeflag === "0" || typeflag === "\0" || typeflag === "7"
65
115
  ? "file"
@@ -68,6 +118,9 @@ export function listTarEntries(tar: Uint8Array): TarEntry[] {
68
118
  : typeflag === "2"
69
119
  ? "symlink"
70
120
  : "other";
121
+ if (type !== "file" && size !== 0) {
122
+ throw new Error(`tar member ${JSON.stringify(name)} is a ${type} with a body`);
123
+ }
71
124
  entries.push(type === "symlink" ? { name, type, linkTarget: link } : { name, type });
72
125
  }
73
126
  return entries;
@@ -75,12 +128,40 @@ export function listTarEntries(tar: Uint8Array): TarEntry[] {
75
128
 
76
129
  /** `./a/b/` → `a/b`; the archive root (`./`) → "". */
77
130
  export function normalizeMemberName(name: string): string {
78
- return name.replace(/^(\.\/)+/, "").replace(/\/+$/, "");
131
+ return name
132
+ .replace(/\/+/g, "/")
133
+ .replace(/^(\.\/)+/, "")
134
+ .replace(/\/+$/, "");
79
135
  }
80
136
 
81
137
  /** Whether a member name could write outside the extraction directory. */
82
138
  export function escapesRoot(name: string): boolean {
83
- return name.startsWith("/") || name.split("/").some((part) => part === "..");
139
+ const normalized = name.replace(/\/+/g, "/").replace(/^(\.\/)+/, "");
140
+ return (
141
+ name.startsWith("/") || normalized.split("/").some((part) => part === ".." || part === ".")
142
+ );
143
+ }
144
+
145
+ /**
146
+ * Refuse a header whose checksum does not match, as `tar -x` would — except
147
+ * GNU tar then goes on reading at the next block, which this check must not.
148
+ * The sum counts the checksum field as spaces; old tars summed signed bytes,
149
+ * which GNU tar and bsdtar both still accept.
150
+ */
151
+ function verifyChecksum(header: Uint8Array): void {
152
+ const recorded = cString(header.subarray(148, 156)).trim();
153
+ if (!/^[0-7]+$/.test(recorded)) throw new Error("unreadable tar header checksum");
154
+ let unsigned = 0;
155
+ let signed = 0;
156
+ for (let i = 0; i < BLOCK; i++) {
157
+ const b = i >= 148 && i < 156 ? 0x20 : (header[i] ?? 0);
158
+ unsigned += b;
159
+ signed += b > 127 ? b - 256 : b;
160
+ }
161
+ const expected = Number.parseInt(recorded, 8);
162
+ if (expected !== unsigned && expected !== signed) {
163
+ throw new Error("tar header checksum mismatch — the archive is damaged or was altered");
164
+ }
84
165
  }
85
166
 
86
167
  function octal(field: Uint8Array): number {
@@ -42,6 +42,7 @@ import {
42
42
  rollService,
43
43
  serviceRollArgs,
44
44
  } from "../stack-converge.js";
45
+ import { describeAuditJobImage, setAuditJobImage } from "./audit-job-roll.js";
45
46
  import { restoreCommand, type SqlBackup } from "./backup.js";
46
47
  import {
47
48
  describeServiceJson,
@@ -366,7 +367,17 @@ export async function executeAppRoll(
366
367
  }
367
368
 
368
369
  const rolled: RollPlan[] = [];
370
+ /** The audit job's image before this roll moved it (#875); null = not moved. */
371
+ let auditJobPriorImage: string | null = null;
369
372
  const rollBack = async (alsoCurrent?: RollPlan): Promise<void> => {
373
+ if (auditJobPriorImage) {
374
+ const back = await setAuditJobImage(run, install, auditJobPriorImage);
375
+ if (back.ok) r.info(` audit job image restored to ${auditJobPriorImage.slice(-19)}`);
376
+ else
377
+ r.error(
378
+ ` audit job rollback FAILED (${back.error}) — set its image to ${auditJobPriorImage} by hand`,
379
+ );
380
+ }
370
381
  const services = alsoCurrent ? [...rolled, alsoCurrent] : rolled;
371
382
  if (services.length === 0) return;
372
383
  r.warn(`Rolling back ${services.length} service(s) to the revisions that served before...`);
@@ -447,6 +458,34 @@ export async function executeAppRoll(
447
458
  rolled.push(p);
448
459
  }
449
460
 
461
+ // #875 — the audit job runs the admin's image; it follows the admin. A
462
+ // failure here does not undo the release: audits then fail loudly with
463
+ // the version mismatch and the fix, until the next upgrade moves it.
464
+ const sAudit = r.step("Moving the quality-audit job onto the admin's image...");
465
+ const described = await describeAuditJobImage(run, install);
466
+ const auditPrior = described.ok ? described.image : null;
467
+ if (!described.ok) {
468
+ sAudit.warned(
469
+ `Could not read the quality-audit job (${described.error}); Stage audits fail until the next upgrade moves it.`,
470
+ );
471
+ } else if (!auditPrior) {
472
+ sAudit.warned(
473
+ "The install has no quality-audit job yet; Stage audits fail until `cms-provision upgrade` sets it up.",
474
+ );
475
+ } else if (auditPrior === admin.imageRef) {
476
+ sAudit.done("Quality-audit job already on the admin's image");
477
+ } else {
478
+ const moved = await setAuditJobImage(run, install, admin.imageRef);
479
+ if (moved.ok) {
480
+ auditJobPriorImage = auditPrior;
481
+ sAudit.done(`Quality-audit job on ${admin.digest.slice(0, 19)}...`);
482
+ } else {
483
+ sAudit.warned(
484
+ `Quality-audit job not moved (${moved.error}); Stage audits fail until the next upgrade moves it.`,
485
+ );
486
+ }
487
+ }
488
+
450
489
  if (deps.health) {
451
490
  const s = r.step("Deep health check (database, migrations, version)...");
452
491
  // A probe that throws is a failed check: roll back, never leave the
@@ -0,0 +1,68 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #875 — the quality-audit job runs the admin image (admin-core
5
+ * quality/audit-task.ts) and refuses a batch from an admin on another
6
+ * version. So every app-roll moves the job onto the admin's new image right
7
+ * after the services rolled, and a rollback moves it back.
8
+ *
9
+ * `run jobs update --image` changes nothing else of the job (identity, env,
10
+ * parallelism), so this stays inside the app-roll's rights: the upgrader's
11
+ * `roles/run.developer` plus actAs on the job's service account
12
+ * (upgrader-setup.ts).
13
+ */
14
+
15
+ import type { GcloudRunner } from "../gcloud-retry.js";
16
+ import { auditJobName } from "../gcp-names.js";
17
+
18
+ /**
19
+ * The job's image now; `image: null` only when the install has no audit job
20
+ * yet. Any other gcloud failure (permissions, API, network) is an error, not
21
+ * "no job", so the app-roll reports it instead of a misleading hint.
22
+ */
23
+ export async function describeAuditJobImage(
24
+ run: GcloudRunner,
25
+ install: { readonly projectId: string; readonly region: string; readonly env: string },
26
+ ): Promise<
27
+ | { readonly ok: true; readonly image: string | null }
28
+ | { readonly ok: false; readonly error: string }
29
+ > {
30
+ const r = await run([
31
+ "run",
32
+ "jobs",
33
+ "describe",
34
+ auditJobName(install.env),
35
+ `--region=${install.region}`,
36
+ `--project=${install.projectId}`,
37
+ "--format=value(spec.template.spec.template.spec.containers[0].image)",
38
+ ]);
39
+ if (!r.ok) {
40
+ return /NOT_FOUND|not found|could not be found/i.test(r.stderr)
41
+ ? { ok: true, image: null }
42
+ : { ok: false, error: r.stderr.trim() };
43
+ }
44
+ const image = r.stdout.trim();
45
+ if (image.length === 0) {
46
+ return { ok: false, error: "the job's description names no container image" };
47
+ }
48
+ return { ok: true, image };
49
+ }
50
+
51
+ /** Point the audit job at `imageRef`. */
52
+ export async function setAuditJobImage(
53
+ run: GcloudRunner,
54
+ install: { readonly projectId: string; readonly region: string; readonly env: string },
55
+ imageRef: string,
56
+ ): Promise<{ readonly ok: boolean; readonly error?: string }> {
57
+ const r = await run([
58
+ "run",
59
+ "jobs",
60
+ "update",
61
+ auditJobName(install.env),
62
+ `--image=${imageRef}`,
63
+ `--region=${install.region}`,
64
+ `--project=${install.projectId}`,
65
+ "--quiet",
66
+ ]);
67
+ return r.ok ? { ok: true } : { ok: false, error: r.stderr.trim() };
68
+ }
@@ -7,7 +7,8 @@
7
7
  * CLI-generated runtime secrets, the stack's IAM/CDN/firewall invariants,
8
8
  * MCP access through IAP, public_role's Cloud SQL role memberships (#633),
9
9
  * the gateway's and public_role's database logins, the operator-access sync
10
- * job, and dropping bindings nothing reads any more.
10
+ * job, the quality-audit job (#875), and dropping bindings nothing reads any
11
+ * more.
11
12
  *
12
13
  * It needs the rights of whoever provisioned the install, so only
13
14
  * `cms-provision upgrade` (the operator's own identity) runs it — never the
@@ -24,6 +25,7 @@
24
25
  * {@link AppRollHooks} the app-roll calls in between, and a part after it.
25
26
  */
26
27
 
28
+ import { ensureAuditJob, resolveAdminImage } from "../audit-job-setup.js";
27
29
  import { findSqlInstance, planPublicRoleSwitch } from "../gateway-credentials.js";
28
30
  import type { GcloudRunner, Sleep } from "../gcloud-retry.js";
29
31
  import { upgraderServiceAccountEmail } from "../gcp-names.js";
@@ -40,6 +42,8 @@ import {
40
42
  } from "../runtime-secrets.js";
41
43
  import { selfCommand } from "../self-command.js";
42
44
  import {
45
+ AUDIT_JOB_ENV_VAR,
46
+ AUDIT_RESULTS_BUCKET_ENV_VAR,
43
47
  MCP_ENV_VAR,
44
48
  OPERATOR_ACCESS_JOB_ENV_VAR,
45
49
  UPGRADER_JOB_ENV_VAR,
@@ -268,6 +272,42 @@ export function startConverge(
268
272
  incomplete = true;
269
273
  }
270
274
 
275
+ async function auditJob(): Promise<void> {
276
+ // #875 — the job the quality audit fans its Lighthouse runs out to.
277
+ // Deployed with the admin's CURRENT image (the admin still runs it until
278
+ // the app-roll moves both together, and the job refuses a batch from
279
+ // another version). A failure only costs Stage audits, so it warns and
280
+ // the admin keeps its current env.
281
+ const s = r.step("Ensuring the quality-audit job (parallel Lighthouse runs)...");
282
+ const image = await resolveAdminImage(
283
+ { projectId: install.projectId, region: install.region, env: install.env },
284
+ { run },
285
+ );
286
+ const aj = image.ok
287
+ ? await ensureAuditJob(
288
+ {
289
+ provider: install.provider,
290
+ projectId: install.projectId,
291
+ region: install.region,
292
+ env: install.env,
293
+ adminImageRef: image.imageRef,
294
+ },
295
+ runDeps,
296
+ )
297
+ : { ok: false as const, done: [], error: image.error };
298
+ for (const step of aj.done) r.info(` ${step}`);
299
+ if (aj.ok) {
300
+ s.done("Quality-audit job ready (Stage audits run in parallel)");
301
+ return;
302
+ }
303
+ s.warned(`Quality-audit job not set up: ${aj.error}`);
304
+ r.warn(
305
+ "The upgrade continues; Stage audits fail with the missing job until this succeeds. Re-run upgrade once the cause is fixed.",
306
+ );
307
+ incomplete = true;
308
+ leaveUntouched.push(AUDIT_JOB_ENV_VAR, AUDIT_RESULTS_BUCKET_ENV_VAR);
309
+ }
310
+
271
311
  /** The admin's IAP resource: the service on gcp-firebase, the LB backend on gcp. */
272
312
  async function adminIapResource(): Promise<IapResource | null> {
273
313
  if (install.provider === "gcp-firebase") {
@@ -406,6 +446,7 @@ export function startConverge(
406
446
  if (!(await publicRoleIsolation())) return false;
407
447
  await mcpAccess();
408
448
  await urlMapRedirectsAccess();
449
+ await auditJob();
409
450
  return true;
410
451
  },
411
452
 
@@ -60,7 +60,9 @@ const serviceJson = (image: string, env: unknown[]) =>
60
60
  },
61
61
  });
62
62
 
63
- const ADMIN_JSON = serviceJson("registry/admin@sha256:old", [
63
+ /** The admin image the install runs before the upgrade (digest-pinned, as deployed). */
64
+ const OLD_ADMIN_IMAGE = `registry/admin@sha256:${"0".repeat(64)}`;
65
+ const ADMIN_JSON = serviceJson(OLD_ADMIN_IMAGE, [
64
66
  { name: "CAELO_FIREBASE_SITE", value: "caelo-production-site-abc123" },
65
67
  {
66
68
  name: "ADMIN_DATABASE_URL",
@@ -98,6 +100,10 @@ function fakeInstall(events: string[], overrides: [RegExp, Answer][] = []) {
98
100
  [new RegExp(`describe ${ADMIN} .*--format=json`), ok(ADMIN_JSON)],
99
101
  [new RegExp(`describe ${GATEWAY} .*--format=json`), ok(GATEWAY_JSON)],
100
102
  [/describe (\S+) .*status\.url/, (l) => ok(`https://${l.split(" ")[3]}-xyz.a.run.app\n`)],
103
+ [
104
+ /^run jobs describe caelo-production-audit .*containers\[0\]\.image/,
105
+ ok(`${OLD_ADMIN_IMAGE}\n`),
106
+ ],
101
107
  [/^sql instances list/, ok(`${SQL}\n`)],
102
108
  [/^sql backups list/, ok(`${BACKUP_ID}\n`)],
103
109
  [
@@ -223,7 +229,7 @@ const CONVERGE_WRITE =
223
229
  /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/;
224
230
  /** Calls that move a service or run migrations — app-roll-only. */
225
231
  const APP_ROLL_WRITE =
226
- /^run services update |^run services update-traffic|jobs execute caelo-migrate/;
232
+ /^run services update |^run services update-traffic|jobs execute caelo-migrate|^run jobs update caelo-production-audit /;
227
233
  /** Any gcloud call that changes something. */
228
234
  const ANY_WRITE =
229
235
  /\b(create|update|update-traffic|delete|add-iam-policy-binding|remove-iam-policy-binding|set-iam-policy|execute|enable|add|restore)\b/;
@@ -285,6 +291,69 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
285
291
  );
286
292
  });
287
293
 
294
+ it("sets up the quality-audit job on the CURRENT admin image before the roll, then moves it with the admin (#875)", async () => {
295
+ const events: string[] = [];
296
+ const { deps, lines } = upgradeDeps(events);
297
+ expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
298
+ const deploy = indexOf(events, /^gcloud run jobs deploy caelo-production-audit /);
299
+ const adminRoll = indexOf(events, new RegExp(`^gcloud run services update ${ADMIN} `));
300
+ const move = indexOf(events, /^gcloud run jobs update caelo-production-audit /);
301
+ const deep = indexOf(events, /^health deep/);
302
+ expect(events[deploy]).toContain(`--image=${OLD_ADMIN_IMAGE}`);
303
+ expect(deploy < adminRoll && adminRoll < move && move < deep).toBe(true);
304
+ expect(events[move]).toContain(`--image=${IMAGES.admin.imageRef}`);
305
+ expect(lines).toContain("done: Quality-audit job ready (Stage audits run in parallel)");
306
+ });
307
+
308
+ it("a failed audit-job setup leaves the converge incomplete and the admin's audit env untouched", async () => {
309
+ const events: string[] = [];
310
+ const { deps, recorded, lines } = upgradeDeps(events, {
311
+ overrides: [[/^run jobs deploy caelo-production-audit/, fail("PERMISSION_DENIED")]],
312
+ });
313
+ expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
314
+ expect(recorded.contract).toEqual([]);
315
+ expect(lines.some((l) => l.includes("Quality-audit job not set up"))).toBe(true);
316
+ expect(
317
+ events.some((e) => /services update caelo-production-admin.*CAELO_AUDIT_JOB=/.test(e)),
318
+ ).toBe(false);
319
+ });
320
+
321
+ it("an unreadable audit job is reported as an error, not as a missing job, and is not moved", async () => {
322
+ const events: string[] = [];
323
+ const { deps, lines } = upgradeDeps(events, {
324
+ overrides: [
325
+ [
326
+ /^run jobs describe caelo-production-audit .*containers\[0\]\.image/,
327
+ fail("PERMISSION_DENIED"),
328
+ ],
329
+ ],
330
+ });
331
+ expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
332
+ expect(
333
+ lines.some((l) => l.includes("Could not read the quality-audit job (PERMISSION_DENIED)")),
334
+ ).toBe(true);
335
+ expect(lines.some((l) => l.includes("has no quality-audit job yet"))).toBe(false);
336
+ expect(events.some((e) => /^gcloud run jobs update caelo-production-audit /.test(e))).toBe(
337
+ false,
338
+ );
339
+ });
340
+
341
+ it("a missing audit job is reported as missing", async () => {
342
+ const events: string[] = [];
343
+ const { deps, lines } = upgradeDeps(events, {
344
+ overrides: [
345
+ [
346
+ /^run jobs describe caelo-production-audit .*containers\[0\]\.image/,
347
+ fail(
348
+ "ERROR: (gcloud.run.jobs.describe) Cannot find job [caelo-production-audit]. NOT_FOUND",
349
+ ),
350
+ ],
351
+ ],
352
+ });
353
+ expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
354
+ expect(lines.some((l) => l.includes("has no quality-audit job yet"))).toBe(true);
355
+ });
356
+
288
357
  it("without an ops image the upgrader step fails, the roll goes on, the converge is incomplete", async () => {
289
358
  const events: string[] = [];
290
359
  const { deps, recorded, lines } = upgradeDeps(events);
@@ -378,6 +447,11 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
378
447
  `${ADMIN} --region europe-west1 --project acme --to-revisions ${ADMIN}-00001-old=100`,
379
448
  ),
380
449
  ]);
450
+ // The audit job follows the admin back to its prior image (#875).
451
+ const jobBack = events
452
+ .slice(deep)
453
+ .filter((e) => e.startsWith("gcloud run jobs update caelo-production-audit "));
454
+ expect(jobBack).toEqual([expect.stringContaining(`--image=${OLD_ADMIN_IMAGE}`)]);
381
455
  expect(recorded.digests).toEqual([]);
382
456
  expect(recorded.contract).toEqual([]);
383
457
  const restore = `gcloud sql backups restore ${BACKUP_ID} --restore-instance=${SQL} --project=acme`;
@@ -30,6 +30,7 @@ const POLICY: StoredUpdatePolicy = {
30
30
  autoSecurity: true,
31
31
  autoPatch: true,
32
32
  autoMinor: true,
33
+ autoConverge: false,
33
34
  channel: "stable",
34
35
  windowStart: null,
35
36
  windowEnd: null,
@@ -49,6 +49,7 @@ const POLICY: StoredUpdatePolicy = {
49
49
  autoSecurity: false,
50
50
  autoPatch: false,
51
51
  autoMinor: false,
52
+ autoConverge: false,
52
53
  channel: "stable",
53
54
  windowStart: null,
54
55
  windowEnd: null,
@@ -83,6 +83,7 @@ describe("ensureUpgraderJob", () => {
83
83
  "iam service-accounts add-iam-policy-binding caelo-production-gateway-sa@acme.iam.gserviceaccount.com → roles/iam.serviceAccountUser",
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
+ "iam service-accounts add-iam-policy-binding caelo-production-audit@acme.iam.gserviceaccount.com → roles/iam.serviceAccountUser",
86
87
  "storage buckets add-iam-policy-binding gs://acme-caelo-ops → roles/storage.objectUser",
87
88
  "storage buckets add-iam-policy-binding gs://acme-caelo-ops → projects/acme/roles/caeloUpgraderOpsList",
88
89
  ].sort(),