@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.
- package/dist/agent-db.js +196 -109
- package/dist/audit-job-setup.d.ts +69 -0
- package/dist/audit-job-setup.d.ts.map +1 -0
- package/dist/audit-job-setup.js +317 -0
- package/dist/audit-job-setup.js.map +1 -0
- package/dist/caddy.d.ts +9 -0
- package/dist/caddy.d.ts.map +1 -1
- package/dist/caddy.js +38 -0
- package/dist/caddy.js.map +1 -1
- package/dist/cli.js +32 -8
- package/dist/cli.js.map +1 -1
- package/dist/gcp-names.d.ts +16 -1
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +23 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.d.ts.map +1 -1
- package/dist/infra-contract.js +7 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/private-file.d.ts +26 -0
- package/dist/private-file.d.ts.map +1 -0
- package/dist/private-file.js +49 -0
- package/dist/private-file.js.map +1 -0
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +3 -0
- package/dist/release-contract.js.map +1 -1
- package/dist/stack-contract.d.ts +8 -0
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +13 -1
- package/dist/stack-contract.js.map +1 -1
- package/dist/tar-entries.d.ts.map +1 -1
- package/dist/tar-entries.js +84 -9
- package/dist/tar-entries.js.map +1 -1
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +35 -0
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/audit-job-roll.d.ts +38 -0
- package/dist/upgrade/audit-job-roll.d.ts.map +1 -0
- package/dist/upgrade/audit-job-roll.js +43 -0
- package/dist/upgrade/audit-job-roll.js.map +1 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +33 -2
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrader-setup.d.ts.map +1 -1
- package/dist/upgrader-setup.js +9 -3
- package/dist/upgrader-setup.js.map +1 -1
- package/dist/wizards/gcp-cost.js +3 -3
- package/dist/wizards/gcp-cost.js.map +1 -1
- package/dist/wizards/gcp-pulumi.d.ts +6 -0
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +7 -0
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +31 -0
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +4 -4
- package/src/agent/run.test.ts +1 -0
- package/src/audit-job-setup.test.ts +234 -0
- package/src/audit-job-setup.ts +360 -0
- package/src/caddy-redirects.docker.test.ts +19 -6
- package/src/caddy.test.ts +30 -0
- package/src/caddy.ts +43 -0
- package/src/cli-init-validation.test.ts +44 -0
- package/src/cli.ts +36 -7
- package/src/gcp-names.ts +28 -1
- package/src/infra-contract.test.ts +3 -3
- package/src/infra-contract.ts +7 -1
- package/src/private-file.test.ts +80 -0
- package/src/private-file.ts +50 -0
- package/src/release-contract.ts +3 -0
- package/src/stack-contract.test.ts +4 -0
- package/src/stack-contract.ts +14 -0
- package/src/stack-converge.test.ts +1 -1
- package/src/tar-entries.test.ts +149 -7
- package/src/tar-entries.ts +90 -9
- package/src/upgrade/app-roll.ts +39 -0
- package/src/upgrade/audit-job-roll.ts +68 -0
- package/src/upgrade/converge.ts +42 -1
- package/src/upgrade/upgrade.test.ts +76 -2
- package/src/upgrader-assess.test.ts +1 -0
- package/src/upgrader-job.test.ts +1 -0
- package/src/upgrader-setup.test.ts +1 -0
- package/src/upgrader-setup.ts +9 -2
- package/src/wizards/gcp-cost.ts +3 -3
- package/src/wizards/gcp-pulumi.ts +7 -0
- package/src/wizards/gcp.ts +37 -0
- package/stacks/aws/index.ts +8 -0
- package/stacks/azure/index.ts +9 -0
- package/stacks/gcp/index.ts +13 -0
- package/stacks/gcp-firebase/index.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@caelo-cms/provisioning",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.55",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
6
|
"description": "Pulumi-driven provisioning for Caelo CMS — `bunx @caelo-cms/provisioning --provider <self-hosted|gcp|aws|azure>` brings up a TLS-served install with Postgres + storage + secrets manager + the admin + the API gateway.",
|
|
@@ -40,16 +40,16 @@
|
|
|
40
40
|
"prepublishOnly": "tsc -b --force && bun build ../platform-updater/src/agent-db.ts --target=bun --outfile=dist/agent-db.js && tsc --project stacks/gcp/tsconfig.json && tsc --project stacks/gcp-firebase/tsconfig.json"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@caelo-cms/shared": "^0.10.
|
|
43
|
+
"@caelo-cms/shared": "^0.10.55",
|
|
44
44
|
"@clack/prompts": "1.8.1",
|
|
45
45
|
"@pulumi/command": "^1",
|
|
46
46
|
"@pulumi/gcp": "^10",
|
|
47
47
|
"@pulumi/pulumi": "^3",
|
|
48
|
-
"@pulumi/random": "^4.21.
|
|
48
|
+
"@pulumi/random": "^4.21.4",
|
|
49
49
|
"kleur": "4.1.5",
|
|
50
50
|
"ts-node": "^10",
|
|
51
51
|
"typescript": "^5",
|
|
52
|
-
"zod": "4.6.
|
|
52
|
+
"zod": "4.6.7"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@pulumi/aws": "^7",
|
package/src/agent/run.test.ts
CHANGED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The quality-audit job's setup (audit-job-setup.ts) against a fake gcloud.
|
|
5
|
+
* The load-bearing assertions are the trust boundary (#875): the job's
|
|
6
|
+
* service account may create results, read the staged build (gcp only) and
|
|
7
|
+
* write logs — no secret, database, IAM or run rights, no VPC — and the
|
|
8
|
+
* admin may run the job with overrides, read its executions and read the
|
|
9
|
+
* results, nothing else.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from "bun:test";
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import {
|
|
16
|
+
AUDIT_FORBIDDEN_ROLES,
|
|
17
|
+
AUDIT_PARALLELISM_DEFAULT,
|
|
18
|
+
AUDIT_TASK_SCRIPT,
|
|
19
|
+
type AuditJobTarget,
|
|
20
|
+
ensureAuditJob,
|
|
21
|
+
} from "./audit-job-setup.js";
|
|
22
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
23
|
+
|
|
24
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
25
|
+
const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
|
|
26
|
+
|
|
27
|
+
const AUDIT = "serviceAccount:caelo-production-audit@acme.iam.gserviceaccount.com";
|
|
28
|
+
const ADMIN_SA = "serviceAccount:caelo-production-run-sa@acme.iam.gserviceaccount.com";
|
|
29
|
+
const ADMIN_IMAGE = `europe-west1-docker.pkg.dev/caelo-website/caelo-cms-images/admin@sha256:${"a".repeat(64)}`;
|
|
30
|
+
const RESULTS = "acme-caelo-production-audit-results";
|
|
31
|
+
|
|
32
|
+
const TARGET: AuditJobTarget = {
|
|
33
|
+
provider: "gcp",
|
|
34
|
+
projectId: "acme",
|
|
35
|
+
region: "europe-west1",
|
|
36
|
+
env: "production",
|
|
37
|
+
adminImageRef: ADMIN_IMAGE,
|
|
38
|
+
parallelism: 15,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function fakeGcloud(overrides: [RegExp, GcloudResult][] = []) {
|
|
42
|
+
const calls: string[][] = [];
|
|
43
|
+
const rules: [RegExp, GcloudResult][] = [
|
|
44
|
+
...overrides,
|
|
45
|
+
[/^iam service-accounts describe/, fail("NOT_FOUND")],
|
|
46
|
+
[/^storage buckets list/, ok("gs://acme-caelo-production-static/\n")],
|
|
47
|
+
];
|
|
48
|
+
const run = async (args: string[]) => {
|
|
49
|
+
calls.push(args);
|
|
50
|
+
const line = args.join(" ");
|
|
51
|
+
return rules.find(([re]) => re.test(line))?.[1] ?? ok("");
|
|
52
|
+
};
|
|
53
|
+
return { run, calls };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const flag = (args: string[], name: string) =>
|
|
57
|
+
args.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
|
|
58
|
+
|
|
59
|
+
/** Every IAM grant the setup made, as {where, member, role}. */
|
|
60
|
+
function grants(calls: string[][]) {
|
|
61
|
+
return calls
|
|
62
|
+
.filter((c) => c.includes("add-iam-policy-binding"))
|
|
63
|
+
.map((c) => ({
|
|
64
|
+
where: c.slice(0, c.indexOf("add-iam-policy-binding") + 2).join(" "),
|
|
65
|
+
member: flag(c, "member"),
|
|
66
|
+
role: flag(c, "role") as string,
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const deployOf = (calls: string[][]) =>
|
|
71
|
+
calls.find((c) => c[0] === "run" && c[1] === "jobs" && c[2] === "deploy") as string[];
|
|
72
|
+
|
|
73
|
+
describe("ensureAuditJob", () => {
|
|
74
|
+
it("grants the job only results-create, build-read (gcp) and logs", async () => {
|
|
75
|
+
const { run, calls } = fakeGcloud();
|
|
76
|
+
const r = await ensureAuditJob(TARGET, { run, sleep: async () => {} });
|
|
77
|
+
expect(r.ok).toBe(true);
|
|
78
|
+
const job = grants(calls).filter((g) => g.member === AUDIT);
|
|
79
|
+
expect(job.map((g) => [g.where, g.role])).toEqual([
|
|
80
|
+
[`storage buckets add-iam-policy-binding gs://${RESULTS}`, "roles/storage.objectCreator"],
|
|
81
|
+
[
|
|
82
|
+
"storage buckets add-iam-policy-binding gs://acme-caelo-production-staging",
|
|
83
|
+
"roles/storage.objectViewer",
|
|
84
|
+
],
|
|
85
|
+
[
|
|
86
|
+
"storage buckets add-iam-policy-binding gs://acme-caelo-production-static",
|
|
87
|
+
"roles/storage.objectViewer",
|
|
88
|
+
],
|
|
89
|
+
["projects add-iam-policy-binding acme", "roles/logging.logWriter"],
|
|
90
|
+
]);
|
|
91
|
+
for (const g of job) {
|
|
92
|
+
for (const forbidden of AUDIT_FORBIDDEN_ROLES)
|
|
93
|
+
expect(g.role.startsWith(forbidden)).toBe(false);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("gives the admin run-with-overrides and view on the job and read on the results, nothing else", async () => {
|
|
98
|
+
const { run, calls } = fakeGcloud();
|
|
99
|
+
await ensureAuditJob(TARGET, { run, sleep: async () => {} });
|
|
100
|
+
expect(
|
|
101
|
+
grants(calls)
|
|
102
|
+
.filter((g) => g.member === ADMIN_SA)
|
|
103
|
+
.map((g) => [g.where, g.role]),
|
|
104
|
+
).toEqual([
|
|
105
|
+
[`storage buckets add-iam-policy-binding gs://${RESULTS}`, "roles/storage.objectViewer"],
|
|
106
|
+
[
|
|
107
|
+
"run jobs add-iam-policy-binding caelo-production-audit",
|
|
108
|
+
"roles/run.jobsExecutorWithOverrides",
|
|
109
|
+
],
|
|
110
|
+
["run jobs add-iam-policy-binding caelo-production-audit", "roles/run.viewer"],
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("points the job at a task script the admin image ships", () => {
|
|
115
|
+
// The image copies the repo's packages/ to /app/packages (apps/admin/Dockerfile);
|
|
116
|
+
// a moved script would only fail when the first audit starts.
|
|
117
|
+
const repoRoot = join(import.meta.dir, "..", "..", "..");
|
|
118
|
+
expect(AUDIT_TASK_SCRIPT.startsWith("/app/packages/")).toBe(true);
|
|
119
|
+
expect(existsSync(join(repoRoot, AUDIT_TASK_SCRIPT.slice("/app/".length)))).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("deploys the admin image as its own SA with no secrets and no VPC", async () => {
|
|
123
|
+
const { run, calls } = fakeGcloud();
|
|
124
|
+
await ensureAuditJob(TARGET, { run, sleep: async () => {} });
|
|
125
|
+
const deploy = deployOf(calls);
|
|
126
|
+
expect(flag(deploy, "image")).toBe(ADMIN_IMAGE);
|
|
127
|
+
expect(flag(deploy, "service-account")).toBe(
|
|
128
|
+
"caelo-production-audit@acme.iam.gserviceaccount.com",
|
|
129
|
+
);
|
|
130
|
+
expect(flag(deploy, "args")).toBe(`--bun,${AUDIT_TASK_SCRIPT}`);
|
|
131
|
+
expect(flag(deploy, "max-retries")).toBe("0");
|
|
132
|
+
expect(flag(deploy, "parallelism")).toBe("15");
|
|
133
|
+
expect(flag(deploy, "set-env-vars")).toBe(
|
|
134
|
+
`CAELO_PROVIDER=gcp,CAELO_AUDIT_RESULTS_BUCKET=${RESULTS},CAELO_STAGING_BUCKET=acme-caelo-production-staging,CAELO_STATIC_BUCKET=acme-caelo-production-static`,
|
|
135
|
+
);
|
|
136
|
+
const line = deploy.join(" ");
|
|
137
|
+
for (const banned of ["--network", "--subnet", "--vpc", "--set-secrets", "--update-secrets"]) {
|
|
138
|
+
expect(line).not.toContain(banned);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("creates the private results bucket with a one-day lifecycle when it is missing", async () => {
|
|
143
|
+
const { run, calls } = fakeGcloud();
|
|
144
|
+
await ensureAuditJob(TARGET, { run, sleep: async () => {} });
|
|
145
|
+
const create = calls.find(
|
|
146
|
+
(c) => c.slice(0, 3).join(" ") === "storage buckets create",
|
|
147
|
+
) as string[];
|
|
148
|
+
expect(create).toContain(`gs://${RESULTS}`);
|
|
149
|
+
expect(create).toContain("--public-access-prevention");
|
|
150
|
+
expect(create).toContain("--uniform-bucket-level-access");
|
|
151
|
+
expect(calls.some((c) => c.join(" ").includes("--lifecycle-file="))).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("keeps an existing bucket and an operator's parallelism on converge", async () => {
|
|
155
|
+
const { run, calls } = fakeGcloud([
|
|
156
|
+
[/^storage buckets list/, ok(`gs://${RESULTS}/\n`)],
|
|
157
|
+
[/^run jobs describe caelo-production-audit /, ok(`${ADMIN_IMAGE}\n`)],
|
|
158
|
+
]);
|
|
159
|
+
const { parallelism: _, ...converge } = TARGET;
|
|
160
|
+
expect((await ensureAuditJob(converge, { run, sleep: async () => {} })).ok).toBe(true);
|
|
161
|
+
expect(calls.some((c) => c.slice(0, 3).join(" ") === "storage buckets create")).toBe(false);
|
|
162
|
+
expect(flag(deployOf(calls), "parallelism")).toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("gives a job the converge creates the default parallelism, as a fresh install's", async () => {
|
|
166
|
+
const { run, calls } = fakeGcloud([
|
|
167
|
+
[/^run jobs describe/, fail("ERROR: Cannot find job [caelo-production-audit]. NOT_FOUND")],
|
|
168
|
+
]);
|
|
169
|
+
const { parallelism: _, ...converge } = TARGET;
|
|
170
|
+
const r = await ensureAuditJob(converge, { run, sleep: async () => {} });
|
|
171
|
+
expect(r.ok).toBe(true);
|
|
172
|
+
expect(flag(deployOf(calls), "parallelism")).toBe(String(AUDIT_PARALLELISM_DEFAULT));
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("refuses to redeploy a job it cannot read on converge", async () => {
|
|
176
|
+
const { run, calls } = fakeGcloud([[/^run jobs describe/, fail("PERMISSION_DENIED")]]);
|
|
177
|
+
const { parallelism: _, ...converge } = TARGET;
|
|
178
|
+
const r = await ensureAuditJob(converge, { run, sleep: async () => {} });
|
|
179
|
+
expect(r).toMatchObject({ ok: false, error: expect.stringContaining("PERMISSION_DENIED") });
|
|
180
|
+
expect(deployOf(calls)).toBeUndefined();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("on gcp-firebase reads no bucket: the task audits the public preview channel", async () => {
|
|
184
|
+
const { run, calls } = fakeGcloud();
|
|
185
|
+
await ensureAuditJob({ ...TARGET, provider: "gcp-firebase" }, { run, sleep: async () => {} });
|
|
186
|
+
const job = grants(calls).filter((g) => g.member === AUDIT);
|
|
187
|
+
expect(job.map((g) => g.role)).toEqual([
|
|
188
|
+
"roles/storage.objectCreator",
|
|
189
|
+
"roles/logging.logWriter",
|
|
190
|
+
]);
|
|
191
|
+
expect(flag(deployOf(calls), "set-env-vars")).toBe(
|
|
192
|
+
`CAELO_PROVIDER=gcp-firebase,CAELO_AUDIT_RESULTS_BUCKET=${RESULTS}`,
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("refuses an image that is not pinned by digest", async () => {
|
|
197
|
+
const { run, calls } = fakeGcloud();
|
|
198
|
+
const r = await ensureAuditJob({ ...TARGET, adminImageRef: "x/admin:latest" }, { run });
|
|
199
|
+
expect(r.ok).toBe(false);
|
|
200
|
+
expect(calls).toEqual([]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("refuses a parallelism outside 1-15", async () => {
|
|
204
|
+
const { run } = fakeGcloud();
|
|
205
|
+
const r = await ensureAuditJob({ ...TARGET, parallelism: 16 }, { run });
|
|
206
|
+
expect(r).toMatchObject({ ok: false, error: expect.stringContaining("1 to 15") });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("stops at the first failed grant and names it", async () => {
|
|
210
|
+
const { run, calls } = fakeGcloud([
|
|
211
|
+
[
|
|
212
|
+
/^storage buckets add-iam-policy-binding gs:\/\/acme-caelo-production-audit-results --member=serviceAccount:caelo-production-audit/,
|
|
213
|
+
fail("PERMISSION_DENIED"),
|
|
214
|
+
],
|
|
215
|
+
]);
|
|
216
|
+
const r = await ensureAuditJob(TARGET, { run, sleep: async () => {} });
|
|
217
|
+
expect(r).toMatchObject({
|
|
218
|
+
ok: false,
|
|
219
|
+
error: expect.stringContaining("roles/storage.objectCreator"),
|
|
220
|
+
});
|
|
221
|
+
expect(calls.some((c) => c[2] === "deploy")).toBe(false);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("says when the bucket name is taken by another project", async () => {
|
|
225
|
+
const { run } = fakeGcloud([
|
|
226
|
+
[/^storage buckets create/, fail("HTTPError 409: already exists")],
|
|
227
|
+
]);
|
|
228
|
+
const r = await ensureAuditJob(TARGET, { run, sleep: async () => {} });
|
|
229
|
+
expect(r).toMatchObject({
|
|
230
|
+
ok: false,
|
|
231
|
+
error: expect.stringContaining("does not belong to project acme"),
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #875 — set up (and converge) the parallel quality-audit job on a Google
|
|
5
|
+
* Cloud install (`gcp`, `gcp-firebase`). Run by the wizard after `pulumi up`
|
|
6
|
+
* and by `cms-provision upgrade`'s converge, both as the operator's own
|
|
7
|
+
* gcloud identity. App-roll moves the job's image with the admin's
|
|
8
|
+
* (upgrade/app-roll.ts), because the job runs the admin image.
|
|
9
|
+
*
|
|
10
|
+
* Why a separate job at all (CLAUDE.md §11.B Tier 5 exception): Lighthouse
|
|
11
|
+
* is CPU-bound and simulates the CPU slowdown from observed task durations,
|
|
12
|
+
* so a run that shares a CPU with another run or with the editor's chat
|
|
13
|
+
* measures that interference too. One task per run measures the page alone,
|
|
14
|
+
* and all runs finish in about the time of one.
|
|
15
|
+
*
|
|
16
|
+
* Why the CLI owns it and not the stacks: `upgrade` never runs Pulumi, so
|
|
17
|
+
* existing installs get the job only from here, and a bucket or job both
|
|
18
|
+
* Pulumi and `upgrade` create would collide on the next wizard re-run (the
|
|
19
|
+
* same reasoning as operator-access.ts and upgrader-setup.ts).
|
|
20
|
+
*
|
|
21
|
+
* Trust boundary. The job's service account, `caelo-<env>-audit`, holds:
|
|
22
|
+
*
|
|
23
|
+
* - `roles/storage.objectCreator` on the audit results bucket — create
|
|
24
|
+
* only: it can neither read results back nor overwrite one;
|
|
25
|
+
* - on `gcp` only, `roles/storage.objectViewer` on the staging and static
|
|
26
|
+
* buckets: it serves the staged build to its own browser, as the admin's
|
|
27
|
+
* audit does (admin-core quality/staged-origin.ts). gcp-firebase audits
|
|
28
|
+
* the public preview channel and needs no bucket;
|
|
29
|
+
* - `roles/logging.logWriter`.
|
|
30
|
+
*
|
|
31
|
+
* Nothing else: no Secret Manager, no Cloud SQL, and no VPC egress, so the
|
|
32
|
+
* database's private IP is not even routable from a task. Plugin data on a
|
|
33
|
+
* page comes the way any visitor gets it: the task's browser calls the
|
|
34
|
+
* site's public `/api/*` (the gateway, behind Cloud Armor) over the
|
|
35
|
+
* internet. The admin's run
|
|
36
|
+
* SA may run the job WITH overrides (`roles/run.jobsExecutorWithOverrides`:
|
|
37
|
+
* it passes each audit's batch of orders), read its executions
|
|
38
|
+
* (`roles/run.viewer`) and read the results bucket. Unlike the
|
|
39
|
+
* operator-access and upgrader jobs, overrides are acceptable here: a
|
|
40
|
+
* compromised admin can make the job load other URLs, but the job holds
|
|
41
|
+
* nothing worth taking. {@link AUDIT_FORBIDDEN_ROLES} names that line; the
|
|
42
|
+
* tests assert no grant crosses it.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
46
|
+
import { tmpdir } from "node:os";
|
|
47
|
+
import { join } from "node:path";
|
|
48
|
+
import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
|
|
49
|
+
import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
|
|
50
|
+
import {
|
|
51
|
+
auditJobName,
|
|
52
|
+
auditServiceAccountEmail,
|
|
53
|
+
auditServiceAccountId,
|
|
54
|
+
gcpBucketName,
|
|
55
|
+
runServiceAccountEmail,
|
|
56
|
+
} from "./gcp-names.js";
|
|
57
|
+
import { readAdminConfig } from "./migration-runner.js";
|
|
58
|
+
import type { GcpProvider } from "./stack-contract.js";
|
|
59
|
+
import { describeAuditJobImage } from "./upgrade/audit-job-roll.js";
|
|
60
|
+
|
|
61
|
+
/** Where the admin image ships the task's entry point. */
|
|
62
|
+
export const AUDIT_TASK_SCRIPT = "/app/packages/admin-core/src/quality/audit-task.ts";
|
|
63
|
+
|
|
64
|
+
/** How many audit tasks run at once by default: 5 pages x 3 runs. */
|
|
65
|
+
export const AUDIT_PARALLELISM_DEFAULT = 15;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Per task: container start, browser start, CPU benchmark and one
|
|
69
|
+
* Lighthouse run. Matches admin-core `parallelAuditTimeoutMs`, which the
|
|
70
|
+
* admin also passes per execution.
|
|
71
|
+
*/
|
|
72
|
+
const TASK_TIMEOUT = "5m";
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Roles the audit job's identity must never hold anywhere. Prefix matches
|
|
76
|
+
* (`roles/secretmanager.` = every Secret Manager role).
|
|
77
|
+
*/
|
|
78
|
+
export const AUDIT_FORBIDDEN_ROLES = [
|
|
79
|
+
"roles/owner",
|
|
80
|
+
"roles/editor",
|
|
81
|
+
"roles/run.",
|
|
82
|
+
"roles/iam.",
|
|
83
|
+
"roles/secretmanager.",
|
|
84
|
+
"roles/cloudsql.",
|
|
85
|
+
"roles/compute.",
|
|
86
|
+
"roles/resourcemanager.",
|
|
87
|
+
"roles/storage.admin",
|
|
88
|
+
"roles/storage.objectAdmin",
|
|
89
|
+
"roles/storage.objectUser",
|
|
90
|
+
] as const;
|
|
91
|
+
|
|
92
|
+
/** Delete results after a day: the admin reads them within minutes. */
|
|
93
|
+
export const AUDIT_RESULTS_LIFECYCLE = {
|
|
94
|
+
rule: [{ action: { type: "Delete" }, condition: { age: 1 } }],
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export interface AuditJobTarget {
|
|
98
|
+
readonly provider: GcpProvider;
|
|
99
|
+
readonly projectId: string;
|
|
100
|
+
readonly region: string;
|
|
101
|
+
/** Pulumi stack name (`production`). */
|
|
102
|
+
readonly env: string;
|
|
103
|
+
/** The admin image the job runs, digest-pinned (`…/admin@sha256:…`). */
|
|
104
|
+
readonly adminImageRef: string;
|
|
105
|
+
/**
|
|
106
|
+
* Tasks at once (`auditParallelism`, 1–15). Absent on converge: an
|
|
107
|
+
* existing job keeps the value it has, so an operator's choice survives
|
|
108
|
+
* `upgrade`; a job the converge creates gets
|
|
109
|
+
* {@link AUDIT_PARALLELISM_DEFAULT}, as a fresh install's does.
|
|
110
|
+
*/
|
|
111
|
+
readonly parallelism?: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type AuditJobResult =
|
|
115
|
+
| { readonly ok: true; readonly done: string[] }
|
|
116
|
+
| { readonly ok: false; readonly done: string[]; readonly error: string };
|
|
117
|
+
|
|
118
|
+
/** Ensure the audit job and everything it needs. Never throws for a gcloud failure. */
|
|
119
|
+
export async function ensureAuditJob(
|
|
120
|
+
target: AuditJobTarget,
|
|
121
|
+
deps: { run?: GcloudRunner; sleep?: Sleep } = {},
|
|
122
|
+
): Promise<AuditJobResult> {
|
|
123
|
+
const run = deps.run ?? defaultGcloud;
|
|
124
|
+
const sleep = deps.sleep ?? realSleep;
|
|
125
|
+
const retry = (args: string[]) => runWithRetry(run, sleep, args);
|
|
126
|
+
const { projectId, region, env, provider } = target;
|
|
127
|
+
const project = `--project=${projectId}`;
|
|
128
|
+
const sa = auditServiceAccountEmail(projectId, env);
|
|
129
|
+
const member = `serviceAccount:${sa}`;
|
|
130
|
+
const runSaMember = `serviceAccount:${runServiceAccountEmail(projectId, env)}`;
|
|
131
|
+
const job = auditJobName(env);
|
|
132
|
+
const results = gcpBucketName(projectId, env, "audit-results");
|
|
133
|
+
const resultsUrl = `gs://${results}`;
|
|
134
|
+
const done: string[] = [];
|
|
135
|
+
const fail = (step: string, r: GcloudResult): AuditJobResult => ({
|
|
136
|
+
ok: false,
|
|
137
|
+
done,
|
|
138
|
+
error: `${step}: ${r.stderr.trim()}`,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (!/@sha256:[0-9a-f]{64}$/.test(target.adminImageRef)) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
done,
|
|
145
|
+
error: `the audit job's image must be pinned by digest, got ${target.adminImageRef}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (
|
|
149
|
+
target.parallelism !== undefined &&
|
|
150
|
+
(!Number.isInteger(target.parallelism) || target.parallelism < 1 || target.parallelism > 15)
|
|
151
|
+
) {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
done,
|
|
155
|
+
error: `auditParallelism must be a whole number from 1 to 15, got ${target.parallelism}`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 1. The job's own service account.
|
|
160
|
+
const describe = await run([
|
|
161
|
+
"iam",
|
|
162
|
+
"service-accounts",
|
|
163
|
+
"describe",
|
|
164
|
+
sa,
|
|
165
|
+
project,
|
|
166
|
+
"--format=value(email)",
|
|
167
|
+
]);
|
|
168
|
+
if (!describe.ok) {
|
|
169
|
+
const create = await run([
|
|
170
|
+
"iam",
|
|
171
|
+
"service-accounts",
|
|
172
|
+
"create",
|
|
173
|
+
auditServiceAccountId(env),
|
|
174
|
+
"--display-name=Caelo quality audit",
|
|
175
|
+
"--description=Runs the quality audit's Lighthouse runs in parallel; reads the staged build, writes audit results, nothing else.",
|
|
176
|
+
project,
|
|
177
|
+
]);
|
|
178
|
+
if (!create.ok && !/already exists/i.test(create.stderr)) return fail(`create ${sa}`, create);
|
|
179
|
+
}
|
|
180
|
+
done.push(`service account ${sa}`);
|
|
181
|
+
|
|
182
|
+
// 2. The results bucket: private, results deleted after a day.
|
|
183
|
+
const owned = await run(["storage", "buckets", "list", project, "--format=value(name)"]);
|
|
184
|
+
if (!owned.ok) return fail(`list the buckets of project ${projectId}`, owned);
|
|
185
|
+
const names = owned.stdout.split("\n").map((l) =>
|
|
186
|
+
l
|
|
187
|
+
.trim()
|
|
188
|
+
.replace(/^gs:\/\//, "")
|
|
189
|
+
.replace(/\/$/, ""),
|
|
190
|
+
);
|
|
191
|
+
if (!names.includes(results)) {
|
|
192
|
+
const created = await run([
|
|
193
|
+
"storage",
|
|
194
|
+
"buckets",
|
|
195
|
+
"create",
|
|
196
|
+
resultsUrl,
|
|
197
|
+
project,
|
|
198
|
+
`--location=${region}`,
|
|
199
|
+
"--default-storage-class=STANDARD",
|
|
200
|
+
"--uniform-bucket-level-access",
|
|
201
|
+
"--public-access-prevention",
|
|
202
|
+
"--soft-delete-duration=0",
|
|
203
|
+
]);
|
|
204
|
+
if (!created.ok) {
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
done,
|
|
208
|
+
error: /already exists|409/i.test(created.stderr)
|
|
209
|
+
? `${resultsUrl} exists but does not belong to project ${projectId}; delete or rename that bucket, this install needs the name`
|
|
210
|
+
: `create ${resultsUrl}: ${created.stderr.trim()}`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// A private directory (mkdtemp, mode 700): no other local user can swap
|
|
215
|
+
// the file between write and read.
|
|
216
|
+
const dir = mkdtempSync(join(tmpdir(), "caelo-audit-"));
|
|
217
|
+
try {
|
|
218
|
+
const file = join(dir, "lifecycle.json");
|
|
219
|
+
writeFileSync(file, JSON.stringify(AUDIT_RESULTS_LIFECYCLE), { mode: 0o600 });
|
|
220
|
+
const lifecycle = await run([
|
|
221
|
+
"storage",
|
|
222
|
+
"buckets",
|
|
223
|
+
"update",
|
|
224
|
+
resultsUrl,
|
|
225
|
+
project,
|
|
226
|
+
`--lifecycle-file=${file}`,
|
|
227
|
+
]);
|
|
228
|
+
if (!lifecycle.ok) return fail(`set the lifecycle of ${resultsUrl}`, lifecycle);
|
|
229
|
+
} finally {
|
|
230
|
+
rmSync(dir, { recursive: true, force: true });
|
|
231
|
+
}
|
|
232
|
+
done.push(`bucket ${resultsUrl} (results deleted after 1 day)`);
|
|
233
|
+
|
|
234
|
+
// 3. Bucket grants: the job creates results and (gcp) reads the build;
|
|
235
|
+
// the admin reads results.
|
|
236
|
+
const bucketGrants: { bucket: string; member: string; role: string; why: string }[] = [
|
|
237
|
+
{ bucket: results, member, role: "roles/storage.objectCreator", why: "tasks write results" },
|
|
238
|
+
...(provider === "gcp"
|
|
239
|
+
? (["staging", "static"] as const).map((b) => ({
|
|
240
|
+
bucket: gcpBucketName(projectId, env, b),
|
|
241
|
+
member,
|
|
242
|
+
role: "roles/storage.objectViewer",
|
|
243
|
+
why: "tasks serve the staged build to their browser",
|
|
244
|
+
}))
|
|
245
|
+
: []),
|
|
246
|
+
{
|
|
247
|
+
bucket: results,
|
|
248
|
+
member: runSaMember,
|
|
249
|
+
role: "roles/storage.objectViewer",
|
|
250
|
+
why: "the admin reads the results",
|
|
251
|
+
},
|
|
252
|
+
];
|
|
253
|
+
for (const g of bucketGrants) {
|
|
254
|
+
const r = await retry([
|
|
255
|
+
"storage",
|
|
256
|
+
"buckets",
|
|
257
|
+
"add-iam-policy-binding",
|
|
258
|
+
`gs://${g.bucket}`,
|
|
259
|
+
`--member=${g.member}`,
|
|
260
|
+
`--role=${g.role}`,
|
|
261
|
+
project,
|
|
262
|
+
"--quiet",
|
|
263
|
+
"--format=none",
|
|
264
|
+
]);
|
|
265
|
+
if (!r.ok) return fail(`grant ${g.role} on gs://${g.bucket} (${g.why})`, r);
|
|
266
|
+
done.push(`${g.member === member ? "audit job" : "admin"}: ${g.role} on gs://${g.bucket}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 4. Logs.
|
|
270
|
+
const logs = await retry([
|
|
271
|
+
"projects",
|
|
272
|
+
"add-iam-policy-binding",
|
|
273
|
+
projectId,
|
|
274
|
+
`--member=${member}`,
|
|
275
|
+
"--role=roles/logging.logWriter",
|
|
276
|
+
"--condition=None",
|
|
277
|
+
"--quiet",
|
|
278
|
+
"--format=none",
|
|
279
|
+
]);
|
|
280
|
+
if (!logs.ok) return fail("grant the audit job roles/logging.logWriter", logs);
|
|
281
|
+
done.push("audit job: roles/logging.logWriter on the project");
|
|
282
|
+
|
|
283
|
+
// 5. The job: the admin image, its own SA, no secrets, no VPC. A job
|
|
284
|
+
// created without a parallelism would run every task at once with no
|
|
285
|
+
// cap; an existing job keeps its own.
|
|
286
|
+
let parallelism = target.parallelism;
|
|
287
|
+
if (parallelism === undefined) {
|
|
288
|
+
const existing = await describeAuditJobImage(run, { projectId, region, env });
|
|
289
|
+
if (!existing.ok) return { ok: false, done, error: `read job ${job}: ${existing.error}` };
|
|
290
|
+
if (existing.image === null) parallelism = AUDIT_PARALLELISM_DEFAULT;
|
|
291
|
+
}
|
|
292
|
+
const envVars = [
|
|
293
|
+
`CAELO_PROVIDER=${provider}`,
|
|
294
|
+
`CAELO_AUDIT_RESULTS_BUCKET=${results}`,
|
|
295
|
+
...(provider === "gcp"
|
|
296
|
+
? [
|
|
297
|
+
`CAELO_STAGING_BUCKET=${gcpBucketName(projectId, env, "staging")}`,
|
|
298
|
+
`CAELO_STATIC_BUCKET=${gcpBucketName(projectId, env, "static")}`,
|
|
299
|
+
]
|
|
300
|
+
: []),
|
|
301
|
+
];
|
|
302
|
+
const deploy = await run([
|
|
303
|
+
"run",
|
|
304
|
+
"jobs",
|
|
305
|
+
"deploy",
|
|
306
|
+
job,
|
|
307
|
+
`--image=${target.adminImageRef}`,
|
|
308
|
+
`--region=${region}`,
|
|
309
|
+
project,
|
|
310
|
+
`--service-account=${sa}`,
|
|
311
|
+
"--command=bun",
|
|
312
|
+
`--args=--bun,${AUDIT_TASK_SCRIPT}`,
|
|
313
|
+
`--set-env-vars=${envVars.join(",")}`,
|
|
314
|
+
"--max-retries=0",
|
|
315
|
+
`--task-timeout=${TASK_TIMEOUT}`,
|
|
316
|
+
// Lighthouse wants two cores: the browser and the trace processing.
|
|
317
|
+
"--cpu=2",
|
|
318
|
+
"--memory=2Gi",
|
|
319
|
+
...(parallelism !== undefined ? [`--parallelism=${parallelism}`] : []),
|
|
320
|
+
"--quiet",
|
|
321
|
+
]);
|
|
322
|
+
if (!deploy.ok) return fail(`deploy job ${job}`, deploy);
|
|
323
|
+
done.push(`job ${job} (${target.adminImageRef.split("@")[1]?.slice(0, 19)}...)`);
|
|
324
|
+
|
|
325
|
+
// 6. The admin may run it with its batch and read its executions.
|
|
326
|
+
for (const role of ["roles/run.jobsExecutorWithOverrides", "roles/run.viewer"]) {
|
|
327
|
+
const r = await retry([
|
|
328
|
+
"run",
|
|
329
|
+
"jobs",
|
|
330
|
+
"add-iam-policy-binding",
|
|
331
|
+
job,
|
|
332
|
+
`--region=${region}`,
|
|
333
|
+
project,
|
|
334
|
+
`--member=${runSaMember}`,
|
|
335
|
+
`--role=${role}`,
|
|
336
|
+
"--quiet",
|
|
337
|
+
"--format=none",
|
|
338
|
+
]);
|
|
339
|
+
if (!r.ok) return fail(`grant the admin ${role} on ${job}`, r);
|
|
340
|
+
done.push(`admin: ${role} on ${job}`);
|
|
341
|
+
}
|
|
342
|
+
return { ok: true, done };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* {@link ensureAuditJob}'s image: the deployed admin's, so the job runs the
|
|
347
|
+
* code the admin runs.
|
|
348
|
+
*/
|
|
349
|
+
export async function resolveAdminImage(
|
|
350
|
+
opts: { readonly projectId: string; readonly region: string; readonly env: string },
|
|
351
|
+
deps: { run?: GcloudRunner } = {},
|
|
352
|
+
): Promise<{ ok: true; imageRef: string } | { ok: false; error: string }> {
|
|
353
|
+
const cfg = await readAdminConfig(opts, deps.run ?? defaultGcloud);
|
|
354
|
+
if (!cfg)
|
|
355
|
+
return {
|
|
356
|
+
ok: false,
|
|
357
|
+
error: "could not read the admin service's image (is the admin deployed?)",
|
|
358
|
+
};
|
|
359
|
+
return { ok: true, imageRef: cfg.imageRef };
|
|
360
|
+
}
|
|
@@ -47,6 +47,8 @@ const LIVE = "example.test";
|
|
|
47
47
|
const STAGING = "staging.example.test";
|
|
48
48
|
const CADDY_DIR = resolve(import.meta.dir, "../caddy");
|
|
49
49
|
const SERVED = ["index.html", "about/index.html", "blog/index.html"];
|
|
50
|
+
/** Consecutive matching answers that count as "the reload settled". */
|
|
51
|
+
const SETTLED_PROBES = 5;
|
|
50
52
|
|
|
51
53
|
function docker(args: string[]): { ok: boolean; out: string } {
|
|
52
54
|
const r = Bun.spawnSync(["docker", ...args], { stdout: "pipe", stderr: "pipe" });
|
|
@@ -100,11 +102,25 @@ describe.skipIf(!dockerUp)(`self-hosted redirects through ${CADDY_IMAGE}`, () =>
|
|
|
100
102
|
const location = head.match(/^location: ([^\r\n]*)/im)?.[1]?.trim() ?? null;
|
|
101
103
|
return { status, location };
|
|
102
104
|
};
|
|
105
|
+
/**
|
|
106
|
+
* Waits until `path` answers with `location` on {@link SETTLED_PROBES}
|
|
107
|
+
* consecutive connections. One matching answer is not enough: Caddy's
|
|
108
|
+
* graceful reload keeps the old config's listener open next to the new
|
|
109
|
+
* one (SO_REUSEPORT) while it drains, so the connection after the first
|
|
110
|
+
* new answer can still reach the old config (CI runs 38867358899 and
|
|
111
|
+
* 39242522450). Only a streak of new answers shows the reload settled.
|
|
112
|
+
*/
|
|
103
113
|
const eventually = async (host: string, path: string, location: string | null) => {
|
|
104
114
|
const deadline = Date.now() + 30_000;
|
|
115
|
+
let streak = 0;
|
|
105
116
|
for (;;) {
|
|
106
117
|
const got = probe(host, path).location;
|
|
107
|
-
|
|
118
|
+
streak = got === location ? streak + 1 : 0;
|
|
119
|
+
if (streak >= SETTLED_PROBES) return;
|
|
120
|
+
if (got === location) {
|
|
121
|
+
await Bun.sleep(200);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
108
124
|
if (Date.now() > deadline) {
|
|
109
125
|
throw new Error(
|
|
110
126
|
`${host}${path} → ${got}, expected ${location}: ${docker(["logs", name]).out}`,
|
|
@@ -241,11 +257,8 @@ describe.skipIf(!dockerUp)(`self-hosted redirects through ${CADDY_IMAGE}`, () =>
|
|
|
241
257
|
it("the reload loop picks up a deploy's new snippet", async () => {
|
|
242
258
|
write("production", snippet([["/brand-new", "/blog", 301]]));
|
|
243
259
|
await eventually(LIVE, "/brand-new", "/blog/");
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
// after the first new answer can still reach the old config: the old
|
|
247
|
-
// redirect may answer once more (CI run 38867358899). What must hold is
|
|
248
|
-
// that the new snippet REPLACES the old one once the reload settled.
|
|
260
|
+
// What must hold is that the new snippet REPLACES the old one once the
|
|
261
|
+
// reload settled (see `eventually` for why one answer is not enough).
|
|
249
262
|
await eventually(LIVE, "/old", null);
|
|
250
263
|
expect(probe(LIVE, "/old").status).toBe(404);
|
|
251
264
|
expect(probe(LIVE, "/brand-new").location).toBe("/blog/");
|