@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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
24
|
-
expect(INFRA_CONTRACT_VERSION).toBe(
|
|
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(
|
|
40
|
+
expect(infraStepsText(8, 9)).toBe("infrastructure step 9 (see that release's notes)");
|
|
41
41
|
});
|
|
42
42
|
});
|
package/src/infra-contract.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|
package/src/release-contract.ts
CHANGED
|
@@ -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",
|
package/src/stack-contract.ts
CHANGED
|
@@ -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([
|