@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
package/src/caddy.test.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import { describe, expect, it } from "bun:test";
4
4
  import {
5
5
  type CaddyfileSpec,
6
+ caddyTokenProblem,
6
7
  generateCaddyfile,
7
8
  selfHostedAdminHost,
8
9
  selfHostedSeedDomains,
@@ -105,4 +106,33 @@ describe("Caddyfile TLS", () => {
105
106
  const out = generateCaddyfile({ ...spec, tls: "internal" });
106
107
  expect(out.startsWith("{\n email owner@example.com\n local_certs\n}\n")).toBe(true);
107
108
  });
109
+
110
+ it("rejects unsafe characters in ownerEmail and domain hostnames", () => {
111
+ expect(() => generateCaddyfile({ ...spec, ownerEmail: "owner@example.com\n debug" })).toThrow(
112
+ /invalid ownerEmail/,
113
+ );
114
+ expect(() =>
115
+ generateCaddyfile({
116
+ ...spec,
117
+ domains: [
118
+ { hostname: "evil.example.com {\n respond 200\n}", kind: "public", env: "production" },
119
+ ],
120
+ }),
121
+ ).toThrow(/invalid hostname/);
122
+ });
123
+
124
+ it("refuses a comma in a site address — it would add a second address to the block", () => {
125
+ // `admin.example.com,:80` would serve the admin on every host on port 80.
126
+ expect(() =>
127
+ generateCaddyfile({
128
+ ...spec,
129
+ domains: [{ hostname: "admin.example.com,:80", kind: "admin", env: "production" }],
130
+ }),
131
+ ).toThrow(/invalid hostname/);
132
+ // A comma is harmless inside a token like the email.
133
+ expect(caddyTokenProblem("a,b@example.com", "token")).toBeNull();
134
+ expect(caddyTokenProblem("a,b.example.com", "address")).toMatch(/','/);
135
+ expect(caddyTokenProblem("", "token")).toBe("it is empty");
136
+ expect(caddyTokenProblem("192.168.1.10", "address")).toBeNull();
137
+ });
108
138
  });
package/src/caddy.ts CHANGED
@@ -103,9 +103,52 @@ export function selfHostedSeedDomains(domain: string, stagingDomain: string): Ca
103
103
  ];
104
104
  }
105
105
 
106
+ /**
107
+ * Characters that would let a value end its Caddyfile token and start new
108
+ * directives or blocks: whitespace (incl. newlines), both quote styles Caddy
109
+ * tokenizes (`"` and backtick), braces (blocks and `{placeholders}`), the
110
+ * comment marker, escapes and semicolons. A site address additionally may
111
+ * not hold a comma, which would add further addresses to the same block.
112
+ */
113
+ const UNSAFE_CADDY_TOKEN_RE = /[\s"`{}#\\;]/;
114
+ const UNSAFE_CADDY_ADDRESS_RE = /[\s"`{}#\\;,]/;
115
+
116
+ /**
117
+ * Why `value` cannot go into the Caddyfile as one token, or null when it can.
118
+ * `init` checks the operator's domains and email with this before it writes
119
+ * anything, so a bad value never leaves a half-made install behind.
120
+ *
121
+ * @param kind `address` for a site address (hostname), `token` for any other value
122
+ */
123
+ export function caddyTokenProblem(value: string, kind: "address" | "token"): string | null {
124
+ if (value.length === 0) return "it is empty";
125
+ const unsafe = kind === "address" ? UNSAFE_CADDY_ADDRESS_RE : UNSAFE_CADDY_TOKEN_RE;
126
+ if (unsafe.test(value)) {
127
+ return `it contains whitespace, a quote, a brace, '#', '\\', ';'${kind === "address" ? " or ','" : ""}`;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ function assertSafeCaddyToken(label: string, value: string, kind: "address" | "token"): void {
133
+ const problem = caddyTokenProblem(value, kind);
134
+ if (problem) {
135
+ throw new Error(
136
+ `Caddyfile: invalid ${label} ${JSON.stringify(value)} — ${problem}. Fix the value in .caelo/config.json (or the domain you added) and run the command again.`,
137
+ );
138
+ }
139
+ }
140
+
141
+ /** Every caller-supplied value is checked before any of it is emitted (#888). */
106
142
  export function generateCaddyfile(spec: CaddyfileSpec): string {
143
+ assertSafeCaddyToken("ownerEmail", spec.ownerEmail, "token");
144
+ assertSafeCaddyToken("publicSiteRoot", spec.publicSiteRoot, "token");
145
+ assertSafeCaddyToken("stagingSiteRoot", spec.stagingSiteRoot, "token");
146
+ assertSafeCaddyToken("adminUpstream", spec.adminUpstream, "token");
147
+ assertSafeCaddyToken("gatewayUpstream", spec.gatewayUpstream, "token");
148
+
107
149
  const hostnames = new Set<string>();
108
150
  for (const d of spec.domains) {
151
+ assertSafeCaddyToken("hostname", d.hostname, "address");
109
152
  if (hostnames.has(d.hostname)) {
110
153
  // Caddy refuses a config with two site blocks for one address, so a
111
154
  // duplicate would take every vhost down, not just this one.
@@ -0,0 +1,44 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
3
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
6
+
7
+ const CLI = resolve(import.meta.dir, "cli.ts");
8
+
9
+ let dir: string;
10
+ beforeEach(() => {
11
+ dir = mkdtempSync(join(tmpdir(), "caelo-init-validation-"));
12
+ });
13
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
14
+
15
+ function init(...args: string[]) {
16
+ return Bun.spawnSync([process.execPath, CLI, "init", ...args], {
17
+ env: { ...process.env, CAELO_SELF_HOSTED_DIR: join(dir, ".caelo") },
18
+ stdout: "pipe",
19
+ stderr: "pipe",
20
+ });
21
+ }
22
+
23
+ describe("cms-provision init refuses a value the Caddyfile cannot hold before writing anything", () => {
24
+ // Checked only when the Caddyfile was generated, a bad value left
25
+ // config.json behind, and the re-run stopped at "config already exists".
26
+ for (const [flag, value] of [
27
+ ["--domain", "example.com,:80"],
28
+ ["--domain", "example.com {\n respond 200\n}"],
29
+ ["--owner-email", "owner@example.com\n debug"],
30
+ ["--staging-domain", "staging.example.com #"],
31
+ ] as const) {
32
+ it(`${flag} ${JSON.stringify(value)}`, () => {
33
+ const base: Record<string, string> = {
34
+ "--domain": "example.com",
35
+ "--owner-email": "owner@example.com",
36
+ };
37
+ base[flag] = value;
38
+ const r = init(...Object.entries(base).flat());
39
+ expect(r.exitCode).toBe(2);
40
+ expect(r.stderr.toString()).toContain(flag);
41
+ expect(existsSync(join(dir, ".caelo"))).toBe(false);
42
+ });
43
+ }
44
+ });
package/src/cli.ts CHANGED
@@ -51,6 +51,7 @@ import { generateBootstrapToken } from "./bootstrap-token.js";
51
51
  import {
52
52
  type CaddyDomainSpec,
53
53
  type CaddyTlsMode,
54
+ caddyTokenProblem,
54
55
  generateCaddyfile,
55
56
  selfHostedAdminHost,
56
57
  selfHostedSeedDomains,
@@ -71,6 +72,7 @@ import {
71
72
  SITE_OUTPUT_ROOT,
72
73
  selfHostedSiteUrl,
73
74
  } from "./compose.js";
75
+ import { tightenPrivateTree, writePrivateFile } from "./private-file.js";
74
76
  import { selfCommand } from "./self-command.js";
75
77
  import {
76
78
  bunHostRunner,
@@ -192,8 +194,8 @@ function loadProvider(): Provider {
192
194
  }
193
195
 
194
196
  function saveProvider(provider: Provider): void {
195
- if (!existsSync(CAELO_DIR)) mkdirSync(CAELO_DIR, { recursive: true });
196
- writeFileSync(PROVIDER_PATH, JSON.stringify({ provider }, null, 2));
197
+ if (!existsSync(CAELO_DIR)) mkdirSync(CAELO_DIR, { recursive: true, mode: 0o700 });
198
+ writePrivateFile(PROVIDER_PATH, JSON.stringify({ provider }, null, 2));
197
199
  }
198
200
 
199
201
  /**
@@ -224,6 +226,20 @@ function randomSecret(bytes = 32): string {
224
226
 
225
227
  function loadConfig(): CaeloConfig | null {
226
228
  if (!existsSync(CONFIG_PATH)) return null;
229
+ // Every command that works on an install reads its config first, so this
230
+ // is where an install made by an older CLI loses its world-readable
231
+ // secrets (#888) — not only once each file happens to be rewritten.
232
+ tightenPrivateTree(
233
+ CAELO_DIR,
234
+ [BOOTSTRAP_DIR, "backups"],
235
+ [
236
+ "config.json",
237
+ "provider.json",
238
+ "internal-secret.json",
239
+ "docker-compose.yml",
240
+ `${BOOTSTRAP_DIR}/pending-token.json`,
241
+ ],
242
+ );
227
243
  const cfg = JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as CaeloConfig;
228
244
  // #707 — settings added later, back-filled (and stored) with what every
229
245
  // install ran before they existed, so `upgrade` / `agent` render the same
@@ -238,8 +254,8 @@ function loadConfig(): CaeloConfig | null {
238
254
  }
239
255
 
240
256
  function saveConfig(c: CaeloConfig): void {
241
- if (!existsSync(CAELO_DIR)) mkdirSync(CAELO_DIR, { recursive: true });
242
- writeFileSync(CONFIG_PATH, JSON.stringify(c, null, 2));
257
+ if (!existsSync(CAELO_DIR)) mkdirSync(CAELO_DIR, { recursive: true, mode: 0o700 });
258
+ writePrivateFile(CONFIG_PATH, JSON.stringify(c, null, 2));
243
259
  }
244
260
 
245
261
  function arg(name: string): string | undefined {
@@ -293,7 +309,7 @@ function emitConfig(cfg: CaeloConfig, extraDomains: CaddyDomainSpec[] = []): voi
293
309
  * operator-added domains only `up` / `regenerate-caddy` read back).
294
310
  */
295
311
  function emitComposeFiles(cfg: CaeloConfig): void {
296
- writeFileSync(COMPOSE_PATH, renderCompose(cfg, installImages(cfg)), { mode: 0o600 });
312
+ writePrivateFile(COMPOSE_PATH, renderCompose(cfg, installImages(cfg)));
297
313
  // No secrets (it reads the passwords from the container's environment),
298
314
  // and the postgres user inside the container must be able to read it.
299
315
  writeFileSync(
@@ -527,6 +543,19 @@ async function init(): Promise<void> {
527
543
  console.error(`--staging-domain: ${stagingProblem}`);
528
544
  process.exit(2);
529
545
  }
546
+ // The Caddyfile check would only fire in emitConfig, after config.json is
547
+ // written — and a re-run then stops at "config already exists".
548
+ for (const [flag, value, kind] of [
549
+ ["--domain", domain, "address"],
550
+ ["--staging-domain", stagingArg, "address"],
551
+ ["--owner-email", ownerEmail, "token"],
552
+ ] as const) {
553
+ const problem = value === undefined ? null : caddyTokenProblem(value, kind);
554
+ if (problem) {
555
+ console.error(`${flag} ${JSON.stringify(value)}: ${problem}.`);
556
+ process.exit(2);
557
+ }
558
+ }
530
559
  // #696 — the prebuilt release images, resolved to digests and
531
560
  // cosign-verified BEFORE anything is written, so a failed check leaves no
532
561
  // half-made install. `--dev` (contributors only) runs this checkout instead.
@@ -589,7 +618,7 @@ async function init(): Promise<void> {
589
618
  // staging file.
590
619
  const tok = generateBootstrapToken();
591
620
  mkdirSync(resolve(CAELO_DIR, BOOTSTRAP_DIR), { recursive: true, mode: 0o700 });
592
- writeFileSync(PENDING_TOKEN_PATH, JSON.stringify(tok, null, 2), { mode: 0o600 });
621
+ writePrivateFile(PENDING_TOKEN_PATH, JSON.stringify(tok, null, 2));
593
622
 
594
623
  // P15.1 — mint the shared HMAC secret for /api/internal/* endpoints.
595
624
  // Same secret is read by:
@@ -601,7 +630,7 @@ async function init(): Promise<void> {
601
630
  // internal-secret`.
602
631
  const internalSecret = randomSecret(48);
603
632
  const internalSecretPath = resolve(CAELO_DIR, "internal-secret.json");
604
- writeFileSync(internalSecretPath, JSON.stringify({ secret: internalSecret }, null, 2));
633
+ writePrivateFile(internalSecretPath, JSON.stringify({ secret: internalSecret }, null, 2));
605
634
  // #696 Phase 4c — the update agent (Linux + systemd): the fresh volume's
606
635
  // bootstrap.sh creates its database login on Postgres's first start.
607
636
  const { reportAgentSetup, setUpAgent } = await import("./agent/command.js");
package/src/gcp-names.ts CHANGED
@@ -69,7 +69,7 @@ export function staticPublisherServiceAccountId(env: string): string {
69
69
  export function gcpBucketName(
70
70
  projectId: string,
71
71
  env: string,
72
- bucket: "media" | "static" | "staging",
72
+ bucket: "media" | "static" | "staging" | "audit-results",
73
73
  ): string {
74
74
  return `${projectId}-${gcpNamePrefix(env)}-${bucket}`;
75
75
  }
@@ -120,6 +120,33 @@ export function upgraderJobResource(projectId: string, region: string, env: stri
120
120
  return `projects/${projectId}/locations/${region}/jobs/${upgraderJobName(env)}`;
121
121
  }
122
122
 
123
+ /**
124
+ * #875 — Cloud Run job that runs the quality audit's Lighthouse runs in
125
+ * parallel, one task per run (audit-job-setup.ts).
126
+ */
127
+ export function auditJobName(env: string): string {
128
+ return `${gcpNamePrefix(env)}-audit`;
129
+ }
130
+
131
+ /** Full resource name of the audit job, as the admin's env carries it. */
132
+ export function auditJobResource(projectId: string, region: string, env: string): string {
133
+ return `projects/${projectId}/locations/${region}/jobs/${auditJobName(env)}`;
134
+ }
135
+
136
+ /**
137
+ * Account id of the audit job's own service account: reads the staged
138
+ * build, creates audit results, writes logs — nothing else
139
+ * (audit-job-setup.ts).
140
+ */
141
+ export function auditServiceAccountId(env: string): string {
142
+ return `${gcpNamePrefix(env)}-audit`;
143
+ }
144
+
145
+ /** Email of the audit job's service account. */
146
+ export function auditServiceAccountEmail(projectId: string, env: string): string {
147
+ return `${auditServiceAccountId(env)}@${projectId}.iam.gserviceaccount.com`;
148
+ }
149
+
123
150
  /**
124
151
  * Account id of the upgrader job's own service account: may roll the two
125
152
  * Cloud Run services, run the migration job and back up Cloud SQL, never
@@ -20,8 +20,8 @@ describe("infrastructure contract steps", () => {
20
20
  );
21
21
  });
22
22
 
23
- it("is at 7: public_role revoke (3), URL-map redirects role (4), no gateway cms_admin login (5), ops-bucket list (6), gateway proxy hops (7)", () => {
24
- expect(INFRA_CONTRACT_VERSION).toBe(7);
23
+ it("is at 8: public_role revoke (3), URL-map redirects role (4), no gateway cms_admin login (5), ops-bucket list (6), gateway proxy hops (7), quality-audit job (8)", () => {
24
+ expect(INFRA_CONTRACT_VERSION).toBe(8);
25
25
  expect(INFRA_CONTRACT_STEPS[3]).toContain("#633");
26
26
  expect(INFRA_CONTRACT_STEPS[4]).toContain("#587");
27
27
  expect(INFRA_CONTRACT_STEPS[5]).toContain("#635");
@@ -37,6 +37,6 @@ describe("infrastructure contract steps", () => {
37
37
  });
38
38
 
39
39
  it("names a version a newer release added by number", () => {
40
- expect(infraStepsText(7, 8)).toBe("infrastructure step 8 (see that release's notes)");
40
+ expect(infraStepsText(8, 9)).toBe("infrastructure step 9 (see that release's notes)");
41
41
  });
42
42
  });
@@ -22,7 +22,7 @@
22
22
  * whose app-roll references infrastructure an install does not have yet fails
23
23
  * at roll time, after migrations ran.
24
24
  */
25
- export const INFRA_CONTRACT_VERSION = 7;
25
+ export const INFRA_CONTRACT_VERSION = 8;
26
26
 
27
27
  // History (why each bump happened — an install below a number needs the
28
28
  // converge that introduced it):
@@ -51,6 +51,11 @@ export const INFRA_CONTRACT_VERSION = 7;
51
51
  // runs from (the installed one), which does not set it — so the new
52
52
  // gateway would not start. The operator-identity upgrade runs the new
53
53
  // release's contract and sets it.
54
+ // 8 — #875: the quality-audit job, its service account, the audit results
55
+ // bucket and their grants, plus CAELO_AUDIT_JOB /
56
+ // CAELO_AUDIT_RESULTS_BUCKET on the admin. The new admin fans every
57
+ // Stage audit out to that job, and the app-roll moves the job's image,
58
+ // so it must exist before an in-cloud upgrader rolls onto it.
54
59
 
55
60
  /**
56
61
  * What each contract version adds, in operator terms — the upgrader's
@@ -65,6 +70,7 @@ export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
65
70
  5: "the gateway's cms_admin login removed from its env and IAM (#635)",
66
71
  6: "the upgrader's permission to list its ops bucket (#799)",
67
72
  7: "the gateway's trusted proxy hop count, CAELO_TRUSTED_PROXY_HOPS (#845)",
73
+ 8: "the quality-audit job, its service account, results bucket and grants (#875)",
68
74
  };
69
75
 
70
76
  /**
@@ -0,0 +1,80 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
3
+ import {
4
+ chmodSync,
5
+ closeSync,
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ openSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ readSync,
12
+ rmSync,
13
+ statSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { tightenPrivateTree, writePrivateFile } from "./private-file.js";
19
+
20
+ let dir: string;
21
+ beforeEach(() => {
22
+ dir = mkdtempSync(join(tmpdir(), "caelo-private-file-"));
23
+ });
24
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
25
+
26
+ const mode = (path: string) => statSync(path).mode & 0o777;
27
+
28
+ describe("writePrivateFile", () => {
29
+ it("creates the file owner-only and replaces a world-readable file with an owner-only one", () => {
30
+ const fresh = join(dir, "fresh.json");
31
+ writePrivateFile(fresh, "{}");
32
+ expect(mode(fresh)).toBe(0o600);
33
+
34
+ const legacy = join(dir, "legacy.json");
35
+ writeFileSync(legacy, "{}", { mode: 0o644 });
36
+ chmodSync(legacy, 0o644);
37
+ writePrivateFile(legacy, '{"secret":"x"}');
38
+ expect(mode(legacy)).toBe(0o600);
39
+ expect(readFileSync(legacy, "utf8")).toBe('{"secret":"x"}');
40
+ expect(readdirSync(dir).sort()).toEqual(["fresh.json", "legacy.json"]);
41
+ });
42
+
43
+ it("never writes the new secret into the old world-readable inode", () => {
44
+ // A local user who opened the 0644 file before the rewrite keeps that
45
+ // descriptor; a write-then-chmod would let it read the new secret.
46
+ const legacy = join(dir, "internal-secret.json");
47
+ writeFileSync(legacy, "old");
48
+ chmodSync(legacy, 0o644);
49
+ const fd = openSync(legacy, "r");
50
+ try {
51
+ writePrivateFile(legacy, "new-secret");
52
+ const buf = Buffer.alloc(64);
53
+ const n = readSync(fd, buf, 0, buf.length, 0);
54
+ expect(buf.subarray(0, n).toString()).toBe("old");
55
+ } finally {
56
+ closeSync(fd);
57
+ }
58
+ });
59
+ });
60
+
61
+ describe("tightenPrivateTree", () => {
62
+ it("takes group/other access off an older install's directory and secrets, skipping missing ones", () => {
63
+ const install = join(dir, ".caelo");
64
+ mkdirSync(join(install, "bootstrap"), { recursive: true });
65
+ chmodSync(install, 0o755);
66
+ chmodSync(join(install, "bootstrap"), 0o755);
67
+ writeFileSync(join(install, "config.json"), "{}");
68
+ chmodSync(join(install, "config.json"), 0o644);
69
+ writeFileSync(join(install, "Caddyfile"), "");
70
+ chmodSync(join(install, "Caddyfile"), 0o644);
71
+
72
+ tightenPrivateTree(install, ["bootstrap", "backups"], ["config.json", "internal-secret.json"]);
73
+
74
+ expect(mode(install)).toBe(0o700);
75
+ expect(mode(join(install, "bootstrap"))).toBe(0o700);
76
+ expect(mode(join(install, "config.json"))).toBe(0o600);
77
+ // Not a secret: the Caddy container reads it through its bind mount.
78
+ expect(mode(join(install, "Caddyfile"))).toBe(0o644);
79
+ });
80
+ });
@@ -0,0 +1,50 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ import { randomUUID } from "node:crypto";
3
+ import { chmodSync, existsSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+
5
+ /**
6
+ * Write a file only its owner may read (0600), replacing any file at `path`.
7
+ *
8
+ * The data goes into a fresh temp file created 0600 (`wx`, so never an
9
+ * existing file or a planted symlink) and is renamed over `path`. Writing in
10
+ * place would not do: `writeFileSync`'s `mode` applies only when the call
11
+ * creates the file, so a secret file an older CLI wrote world-readable would
12
+ * hold the new secret world-readable until a chmod after the write — and a
13
+ * reader that opened it before any chmod keeps its descriptor. The rename
14
+ * gives the new contents a new inode that was never readable by anyone else.
15
+ */
16
+ export function writePrivateFile(path: string, data: string): void {
17
+ const tmp = `${path}.${randomUUID()}.tmp`;
18
+ writeFileSync(tmp, data, { mode: 0o600, flag: "wx" });
19
+ try {
20
+ renameSync(tmp, path);
21
+ } catch (e) {
22
+ rmSync(tmp, { force: true });
23
+ throw e;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Take group/other access off an install's secrets directory and the secret
29
+ * files in it, for installs an older CLI created with the default umask
30
+ * (`.caelo/` 0755, `config.json` / `internal-secret.json` 0644). Those files
31
+ * are written once and rarely rewritten, so tightening only on the next write
32
+ * would leave them readable to every local user. Missing paths are skipped:
33
+ * which secrets exist depends on the provider and how far `init` got.
34
+ *
35
+ * @param dir the install's directory, tightened to 0700
36
+ * @param privateDirs subdirectories (relative to `dir`) tightened to 0700
37
+ * @param privateFiles files (relative to `dir`) tightened to 0600
38
+ */
39
+ export function tightenPrivateTree(
40
+ dir: string,
41
+ privateDirs: readonly string[],
42
+ privateFiles: readonly string[],
43
+ ): void {
44
+ const tighten = (path: string, mode: number) => {
45
+ if (existsSync(path) && (statSync(path).mode & 0o077) !== 0) chmodSync(path, mode);
46
+ };
47
+ tighten(dir, 0o700);
48
+ for (const sub of privateDirs) tighten(`${dir}/${sub}`, 0o700);
49
+ for (const file of privateFiles) tighten(`${dir}/${file}`, 0o600);
50
+ }
@@ -49,6 +49,9 @@ export const INFRA_CONTRACT_PATHS: readonly string[] = [
49
49
  // The in-cloud upgrader job's service account, custom roles and bucket
50
50
  // grants (#799 added one without the checks noticing).
51
51
  "packages/provisioning/src/upgrader-setup.ts",
52
+ // #875 — the quality-audit job, its service account, results bucket and
53
+ // grants.
54
+ "packages/provisioning/src/audit-job-setup.ts",
52
55
  ];
53
56
 
54
57
  /**
@@ -74,6 +74,8 @@ describe("adminEnvContract", () => {
74
74
  CAELO_OPERATOR_ACCESS_JOB:
75
75
  "projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync",
76
76
  CAELO_UPGRADER_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-upgrader",
77
+ CAELO_AUDIT_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-audit",
78
+ CAELO_AUDIT_RESULTS_BUCKET: "acme-caelo-production-audit-results",
77
79
  CAELO_STATIC_BUCKET: "acme-caelo-production-static",
78
80
  CAELO_STAGING_BUCKET: "acme-caelo-production-staging",
79
81
  ...ADMIN_SECRETS,
@@ -104,6 +106,8 @@ describe("adminEnvContract", () => {
104
106
  CAELO_OPERATOR_ACCESS_JOB:
105
107
  "projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync",
106
108
  CAELO_UPGRADER_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-upgrader",
109
+ CAELO_AUDIT_JOB: "projects/acme/locations/europe-west1/jobs/caelo-production-audit",
110
+ CAELO_AUDIT_RESULTS_BUCKET: "acme-caelo-production-audit-results",
107
111
  CAELO_FIREBASE_SITE: "caelo-production-site-abc123",
108
112
  CAELO_GATEWAY_SERVICE: "caelo-production-gateway-1a2b3c4",
109
113
  CAELO_GATEWAY_REGION: "europe-west1",
@@ -36,6 +36,7 @@
36
36
 
37
37
  import type { SupportedProvider } from "./adapter.js";
38
38
  import {
39
+ auditJobResource,
39
40
  gatewayServiceAccountEmail,
40
41
  gcpBucketName,
41
42
  gcpNamePrefix,
@@ -169,6 +170,15 @@ export const OPERATOR_ACCESS_JOB_ENV_VAR = "CAELO_OPERATOR_ACCESS_JOB";
169
170
  */
170
171
  export const UPGRADER_JOB_ENV_VAR = "CAELO_UPGRADER_JOB";
171
172
 
173
+ /**
174
+ * #875 — the admin env vars naming the parallel quality-audit job and the
175
+ * bucket its tasks write results into (admin-core quality/
176
+ * gcp-audit-launcher.ts). `upgrade` leaves them untouched when it could not
177
+ * set the job up.
178
+ */
179
+ export const AUDIT_JOB_ENV_VAR = "CAELO_AUDIT_JOB";
180
+ export const AUDIT_RESULTS_BUCKET_ENV_VAR = "CAELO_AUDIT_RESULTS_BUCKET";
181
+
172
182
  /** Where the admin image ships the static-generator CLI. */
173
183
  const GENERATOR_CLI = "/app/apps/static-generator/src/cli.ts";
174
184
 
@@ -442,6 +452,10 @@ export function adminEnvContract<V>(inputs: AdminEnvInputs<V>): CloudRunEnvVar<V
442
452
  // #696 — the job "Install update" starts (upgrader-setup.ts); the admin
443
453
  // may only run it, never override it.
444
454
  { name: UPGRADER_JOB_ENV_VAR, value: upgraderJobResource(projectId, inputs.region, env) },
455
+ // #875 — the job the quality audit fans its Lighthouse runs out to, and
456
+ // where the tasks write their results (audit-job-setup.ts).
457
+ { name: AUDIT_JOB_ENV_VAR, value: auditJobResource(projectId, inputs.region, env) },
458
+ { name: AUDIT_RESULTS_BUCKET_ENV_VAR, value: gcpBucketName(projectId, env, "audit-results") },
445
459
  ];
446
460
  if (inputs.provider === "gcp") {
447
461
  // v0.2.78 — the GCS StaticPublisher: Stage uploads to staging,
@@ -312,7 +312,7 @@ describe("planContractEnv", () => {
312
312
  // #650 — the same roll converges the protected staging domain's host
313
313
  // and gate key onto an install that predates it.
314
314
  expect(plan.services.admin.flags).toEqual([
315
- "--update-env-vars=CAELO_SITE_URL=https://acme.com,MEDIA_ROOT_DIR=/app/apps/admin/data/media,CAELO_MCP_IAP_SERVICE_ACCOUNT=caelo-mcp@acme.iam.gserviceaccount.com,CAELO_OPERATOR_ACCESS_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync,CAELO_UPGRADER_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-upgrader,CAELO_STAGING_HOST=staging.acme.com",
315
+ "--update-env-vars=CAELO_SITE_URL=https://acme.com,MEDIA_ROOT_DIR=/app/apps/admin/data/media,CAELO_MCP_IAP_SERVICE_ACCOUNT=caelo-mcp@acme.iam.gserviceaccount.com,CAELO_OPERATOR_ACCESS_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-operator-access-sync,CAELO_UPGRADER_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-upgrader,CAELO_AUDIT_JOB=projects/acme/locations/europe-west1/jobs/caelo-production-audit,CAELO_AUDIT_RESULTS_BUCKET=acme-caelo-production-audit-results,CAELO_STAGING_HOST=staging.acme.com",
316
316
  "--update-secrets=CAELO_STAGING_GATE_SECRET=caelo-production-staging-gate-secret:latest",
317
317
  ]);
318
318
  expect(plan.services.gateway.flags).toEqual([