@caelo-cms/provisioning 0.10.61 → 0.10.64
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/caddy/Caddyfile.production +7 -0
- package/caddy/Caddyfile.staging +7 -0
- package/dist/admin-internal-answer.d.ts +12 -0
- package/dist/admin-internal-answer.d.ts.map +1 -0
- package/dist/admin-internal-answer.js +32 -0
- package/dist/admin-internal-answer.js.map +1 -0
- package/dist/admin-internal-google.d.ts +4 -0
- package/dist/admin-internal-google.d.ts.map +1 -0
- package/dist/admin-internal-google.js +161 -0
- package/dist/admin-internal-google.js.map +1 -0
- package/dist/admin-internal-self-hosted.d.ts +30 -0
- package/dist/admin-internal-self-hosted.d.ts.map +1 -0
- package/dist/admin-internal-self-hosted.js +92 -0
- package/dist/admin-internal-self-hosted.js.map +1 -0
- package/dist/admin-internal.d.ts +33 -12
- package/dist/admin-internal.d.ts.map +1 -1
- package/dist/admin-internal.js +27 -159
- package/dist/admin-internal.js.map +1 -1
- package/dist/agent/command.d.ts +7 -0
- package/dist/agent/command.d.ts.map +1 -1
- package/dist/agent/command.js +1 -0
- package/dist/agent/command.js.map +1 -1
- package/dist/agent/lock.d.ts +13 -1
- package/dist/agent/lock.d.ts.map +1 -1
- package/dist/agent/lock.js +62 -5
- package/dist/agent/lock.js.map +1 -1
- package/dist/agent/run.d.ts +5 -1
- package/dist/agent/run.d.ts.map +1 -1
- package/dist/agent/run.js +8 -1
- package/dist/agent/run.js.map +1 -1
- package/dist/agent-db.js +192 -90
- package/dist/caddy.d.ts.map +1 -1
- package/dist/caddy.js +11 -1
- package/dist/caddy.js.map +1 -1
- package/dist/cli-routing.js +1 -1
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +95 -38
- package/dist/cli.js.map +1 -1
- package/dist/compose.d.ts +7 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +5 -0
- package/dist/compose.js.map +1 -1
- package/dist/converge-job-gcp.d.ts.map +1 -1
- package/dist/converge-job-gcp.js +10 -7
- package/dist/converge-job-gcp.js.map +1 -1
- package/dist/converge-job-preflight.d.ts +21 -0
- package/dist/converge-job-preflight.d.ts.map +1 -0
- package/dist/converge-job-preflight.js +111 -0
- package/dist/converge-job-preflight.js.map +1 -0
- package/dist/converge-job.d.ts +8 -2
- package/dist/converge-job.d.ts.map +1 -1
- package/dist/converge-job.js +14 -0
- package/dist/converge-job.js.map +1 -1
- package/dist/gateway-credentials.d.ts +5 -3
- package/dist/gateway-credentials.d.ts.map +1 -1
- package/dist/gateway-credentials.js +5 -8
- package/dist/gateway-credentials.js.map +1 -1
- package/dist/gcloud.d.ts +29 -0
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +66 -0
- package/dist/gcloud.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 +10 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job.d.ts +3 -1
- package/dist/install-job.d.ts.map +1 -1
- package/dist/install-job.js.map +1 -1
- package/dist/install-state.d.ts +10 -0
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +12 -0
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts +12 -0
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +113 -28
- package/dist/lifecycle.js.map +1 -1
- package/dist/mcp-iap.d.ts +2 -0
- package/dist/mcp-iap.d.ts.map +1 -1
- package/dist/mcp-iap.js +50 -17
- package/dist/mcp-iap.js.map +1 -1
- package/dist/owner-reset.d.ts +16 -19
- package/dist/owner-reset.d.ts.map +1 -1
- package/dist/owner-reset.js +92 -19
- package/dist/owner-reset.js.map +1 -1
- package/dist/preview-plan.d.ts +71 -0
- package/dist/preview-plan.d.ts.map +1 -0
- package/dist/preview-plan.js +168 -0
- package/dist/preview-plan.js.map +1 -0
- package/dist/public-role-isolation.d.ts +2 -0
- package/dist/public-role-isolation.d.ts.map +1 -1
- package/dist/public-role-isolation.js +9 -3
- package/dist/public-role-isolation.js.map +1 -1
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +4 -0
- package/dist/release-contract.js.map +1 -1
- package/dist/self-hosted-internal-secret.d.ts +27 -0
- package/dist/self-hosted-internal-secret.d.ts.map +1 -0
- package/dist/self-hosted-internal-secret.js +69 -0
- package/dist/self-hosted-internal-secret.js.map +1 -0
- package/dist/self-hosted-release.d.ts +3 -1
- package/dist/self-hosted-release.d.ts.map +1 -1
- package/dist/self-hosted-release.js +6 -2
- package/dist/self-hosted-release.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +13 -1
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +26 -2
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-converge.d.ts +5 -2
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +73 -8
- package/dist/stack-converge.js.map +1 -1
- package/dist/staging-admin.d.ts +4 -1
- package/dist/staging-admin.d.ts.map +1 -1
- package/dist/staging-admin.js.map +1 -1
- package/dist/state-backend.d.ts +3 -2
- package/dist/state-backend.d.ts.map +1 -1
- package/dist/state-backend.js +3 -2
- package/dist/state-backend.js.map +1 -1
- package/dist/state-command.d.ts +17 -0
- package/dist/state-command.d.ts.map +1 -1
- package/dist/state-command.js +34 -5
- package/dist/state-command.js.map +1 -1
- package/dist/state-migrate.d.ts +14 -3
- package/dist/state-migrate.d.ts.map +1 -1
- package/dist/state-migrate.js +31 -7
- package/dist/state-migrate.js.map +1 -1
- package/dist/state-pulumi.d.ts +0 -2
- package/dist/state-pulumi.d.ts.map +1 -1
- package/dist/state-pulumi.js +19 -20
- package/dist/state-pulumi.js.map +1 -1
- package/dist/static-security-headers.d.ts +86 -0
- package/dist/static-security-headers.d.ts.map +1 -0
- package/dist/static-security-headers.js +126 -0
- package/dist/static-security-headers.js.map +1 -0
- package/dist/static-svg-gcp.d.ts +28 -0
- package/dist/static-svg-gcp.d.ts.map +1 -0
- package/dist/static-svg-gcp.js +106 -0
- package/dist/static-svg-gcp.js.map +1 -0
- package/dist/upgrade/app-roll.d.ts +3 -0
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +12 -5
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/backup.d.ts +2 -0
- package/dist/upgrade/backup.d.ts.map +1 -1
- package/dist/upgrade/backup.js +5 -4
- package/dist/upgrade/backup.js.map +1 -1
- package/dist/upgrade/converge.d.ts +7 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +19 -7
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +38 -3
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +31 -9
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/health.d.ts +31 -5
- package/dist/upgrade/health.d.ts.map +1 -1
- package/dist/upgrade/health.js +114 -25
- package/dist/upgrade/health.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +17 -0
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +23 -12
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrade/reporter.d.ts +2 -0
- package/dist/upgrade/reporter.d.ts.map +1 -1
- package/dist/upgrade/reporter.js +1 -0
- package/dist/upgrade/reporter.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +11 -1
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +5 -2
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-gcp.d.ts +12 -2
- package/dist/upgrader-gcp.d.ts.map +1 -1
- package/dist/upgrader-gcp.js +22 -13
- package/dist/upgrader-gcp.js.map +1 -1
- package/dist/upgrader-iam-preflight.d.ts +16 -3
- package/dist/upgrader-iam-preflight.d.ts.map +1 -1
- package/dist/upgrader-iam-preflight.js +9 -4
- package/dist/upgrader-iam-preflight.js.map +1 -1
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js +10 -8
- package/dist/upgrader-job.js.map +1 -1
- package/dist/upgrader-reporter.d.ts.map +1 -1
- package/dist/upgrader-reporter.js +1 -0
- package/dist/upgrader-reporter.js.map +1 -1
- package/dist/wizards/gcp-pulumi.d.ts +70 -23
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +118 -39
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +67 -11
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/admin-internal-answer.ts +43 -0
- package/src/admin-internal-google.ts +187 -0
- package/src/admin-internal-self-hosted.test.ts +70 -0
- package/src/admin-internal-self-hosted.ts +128 -0
- package/src/admin-internal.ts +59 -174
- package/src/admin-memory.test.ts +1 -0
- package/src/agent/command.ts +3 -0
- package/src/agent/lock-config.test.ts +57 -2
- package/src/agent/lock.ts +68 -5
- package/src/agent/run.test.ts +18 -1
- package/src/agent/run.ts +17 -6
- package/src/caddy-redirects.test.ts +1 -0
- package/src/caddy.ts +12 -0
- package/src/cli-routing.ts +1 -1
- package/src/cli.ts +102 -44
- package/src/compose.ts +12 -0
- package/src/converge-job-gcp.ts +16 -11
- package/src/converge-job-preflight.test.ts +108 -0
- package/src/converge-job-preflight.ts +124 -0
- package/src/converge-job.test.ts +50 -0
- package/src/converge-job.ts +38 -2
- package/src/gateway-credentials.test.ts +4 -3
- package/src/gateway-credentials.ts +6 -10
- package/src/gateway-env-isolation.test.ts +1 -0
- package/src/gcloud.test.ts +93 -1
- package/src/gcloud.ts +82 -0
- package/src/index.test.ts +38 -1
- package/src/infra-contract.test.ts +4 -3
- package/src/infra-contract.ts +10 -1
- package/src/install-job.ts +5 -2
- package/src/install-state.ts +13 -0
- package/src/lifecycle.ts +134 -28
- package/src/mcp-iap.test.ts +66 -5
- package/src/mcp-iap.ts +55 -17
- package/src/owner-reset-providers.test.ts +370 -0
- package/src/owner-reset.test.ts +2 -2
- package/src/owner-reset.ts +108 -19
- package/src/preview-plan.test.ts +233 -0
- package/src/preview-plan.ts +226 -0
- package/src/public-role-isolation.test.ts +13 -2
- package/src/public-role-isolation.ts +14 -4
- package/src/release-contract.ts +4 -0
- package/src/self-hosted-compose.test.ts +25 -0
- package/src/self-hosted-internal-secret.test.ts +61 -0
- package/src/self-hosted-internal-secret.ts +80 -0
- package/src/self-hosted-release.ts +10 -3
- package/src/self-hosted-upgrade.test.ts +35 -1
- package/src/self-hosted-upgrade.ts +40 -3
- package/src/stack-contract.test.ts +1 -0
- package/src/stack-converge.test.ts +89 -5
- package/src/stack-converge.ts +81 -8
- package/src/staging-admin.test.ts +1 -1
- package/src/staging-admin.ts +2 -1
- package/src/state-backend.ts +3 -2
- package/src/state-command.test.ts +31 -0
- package/src/state-command.ts +35 -8
- package/src/state-migrate.test.ts +58 -7
- package/src/state-migrate.ts +48 -10
- package/src/state-pulumi.ts +20 -21
- package/src/static-security-headers.test.ts +124 -0
- package/src/static-security-headers.ts +154 -0
- package/src/static-svg-gcp.test.ts +133 -0
- package/src/static-svg-gcp.ts +140 -0
- package/src/upgrade/app-roll.ts +18 -7
- package/src/upgrade/backup.ts +14 -5
- package/src/upgrade/converge.ts +25 -7
- package/src/upgrade/gcp-lookup.test.ts +80 -1
- package/src/upgrade/gcp-lookup.ts +61 -11
- package/src/upgrade/health.test.ts +132 -8
- package/src/upgrade/health.ts +169 -31
- package/src/upgrade/release-images.test.ts +17 -0
- package/src/upgrade/release-images.ts +26 -14
- package/src/upgrade/reporter.ts +3 -0
- package/src/upgrade/upgrade.test.ts +137 -8
- package/src/upgrade/upgrade.ts +14 -2
- package/src/upgrader-gcp.ts +28 -13
- package/src/upgrader-iam-preflight.test.ts +6 -3
- package/src/upgrader-iam-preflight.ts +26 -5
- package/src/upgrader-job.ts +14 -6
- package/src/upgrader-reporter.ts +1 -0
- package/src/wizards/gcp-pulumi.test.ts +1 -1
- package/src/wizards/gcp-pulumi.ts +147 -38
- package/src/wizards/gcp.ts +89 -9
- package/src/wizards/stack-work-dir.test.ts +175 -0
- package/stacks/aws/index.ts +40 -0
- package/stacks/azure/index.ts +22 -1
- package/stacks/gcp/README.md +4 -0
- package/stacks/gcp/index.ts +6 -2
- package/stacks/gcp/Pulumi.production.yaml +0 -16
|
@@ -34,6 +34,7 @@ import { findSqlInstance } from "./gateway-credentials.js";
|
|
|
34
34
|
import { gcloud as defaultGcloud } from "./gcloud.js";
|
|
35
35
|
import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
|
|
36
36
|
import type { EnsureOutcome } from "./runtime-secrets.js";
|
|
37
|
+
import { type GcloudCredentials, lookupFailure } from "./upgrade/gcp-lookup.js";
|
|
37
38
|
|
|
38
39
|
/** The gcloud call that leaves `public_role` with no database roles. */
|
|
39
40
|
export function revokePublicRoleMembershipsArgs(target: {
|
|
@@ -58,15 +59,24 @@ export function revokePublicRoleMembershipsArgs(target: {
|
|
|
58
59
|
* instead of throwing; `failed` names the next step.
|
|
59
60
|
*/
|
|
60
61
|
export async function revokePublicRoleMemberships(
|
|
61
|
-
install: {
|
|
62
|
+
install: {
|
|
63
|
+
readonly projectId: string;
|
|
64
|
+
readonly env: string;
|
|
65
|
+
readonly credentials: GcloudCredentials;
|
|
66
|
+
},
|
|
62
67
|
deps: { readonly run?: GcloudRunner; readonly sleep?: Sleep } = {},
|
|
63
68
|
): Promise<EnsureOutcome> {
|
|
64
69
|
const run = deps.run ?? defaultGcloud;
|
|
65
70
|
const id = "public_role database role memberships";
|
|
66
|
-
const
|
|
67
|
-
if (
|
|
68
|
-
return {
|
|
71
|
+
const lookup = await findSqlInstance(install, { run });
|
|
72
|
+
if (lookup.status !== "found") {
|
|
73
|
+
return {
|
|
74
|
+
id,
|
|
75
|
+
status: "failed",
|
|
76
|
+
error: lookupFailure(lookup, "Cloud SQL instance", install.credentials),
|
|
77
|
+
};
|
|
69
78
|
}
|
|
79
|
+
const sqlInstance = lookup.name;
|
|
70
80
|
const r = await runWithRetry(
|
|
71
81
|
run,
|
|
72
82
|
deps.sleep ?? realSleep,
|
package/src/release-contract.ts
CHANGED
|
@@ -55,6 +55,10 @@ export const INFRA_CONTRACT_PATHS: readonly string[] = [
|
|
|
55
55
|
"packages/provisioning/src/audit-job-setup.ts",
|
|
56
56
|
// #819 — the converge job, its database login and the admin's grant on it.
|
|
57
57
|
"packages/provisioning/src/converge-job-setup.ts",
|
|
58
|
+
// #891 — the edge security headers the stacks attach, and the converge's
|
|
59
|
+
// SVG backfill + CDN purge on `gcp`.
|
|
60
|
+
"packages/provisioning/src/static-security-headers.ts",
|
|
61
|
+
"packages/provisioning/src/static-svg-gcp.ts",
|
|
58
62
|
];
|
|
59
63
|
|
|
60
64
|
/**
|
|
@@ -21,6 +21,7 @@ const base: Omit<ComposeSpec, "images"> = {
|
|
|
21
21
|
postgresPassword: "pgpw",
|
|
22
22
|
rolePasswords: { admin: "adminpw", public: "publicpw" },
|
|
23
23
|
caeloSecretKek: "0".repeat(64),
|
|
24
|
+
internalSecret: "i".repeat(48),
|
|
24
25
|
anthropicApiKey: "sk-ant-x",
|
|
25
26
|
diskSize: "20Gi",
|
|
26
27
|
ports: { http: 80, https: 443 },
|
|
@@ -34,6 +35,30 @@ function service(yaml: string, name: string): string {
|
|
|
34
35
|
return yaml.slice(start, end === -1 ? undefined : end);
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
describe("self-hosted Compose — internal secret (#770)", () => {
|
|
39
|
+
const yaml = generateDockerCompose({
|
|
40
|
+
...base,
|
|
41
|
+
images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("gives the admin CAELO_INTERNAL_SECRET and the gateway nothing of it", () => {
|
|
45
|
+
expect(service(yaml, "caelo-admin")).toContain(
|
|
46
|
+
` CAELO_INTERNAL_SECRET: "${"i".repeat(48)}"`,
|
|
47
|
+
);
|
|
48
|
+
expect(service(yaml, "caelo-gateway")).not.toContain("CAELO_INTERNAL_SECRET");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("refuses a secret the admin would reject", () => {
|
|
52
|
+
expect(() =>
|
|
53
|
+
generateDockerCompose({
|
|
54
|
+
...base,
|
|
55
|
+
internalSecret: "short",
|
|
56
|
+
images: { mode: "release", admin: ADMIN, gateway: GATEWAY },
|
|
57
|
+
}),
|
|
58
|
+
).toThrow("internalSecret");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
37
62
|
describe("self-hosted Compose — release images (#696)", () => {
|
|
38
63
|
const yaml = generateDockerCompose({
|
|
39
64
|
...base,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #770 — the self-hosted internal secret the Compose file hands the admin:
|
|
5
|
+
* an existing one is kept as it is, a missing one is created once (and
|
|
6
|
+
* reported), a malformed one fails loudly instead of being replaced.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
10
|
+
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
ensureInternalSecret,
|
|
15
|
+
internalSecretPath,
|
|
16
|
+
readInternalSecret,
|
|
17
|
+
writeInternalSecret,
|
|
18
|
+
} from "./self-hosted-internal-secret.js";
|
|
19
|
+
|
|
20
|
+
let dir: string;
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
dir = mkdtempSync(join(tmpdir(), "caelo-internal-secret-"));
|
|
23
|
+
});
|
|
24
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
25
|
+
|
|
26
|
+
describe("ensureInternalSecret", () => {
|
|
27
|
+
it("keeps the secret `init` wrote and reports nothing", () => {
|
|
28
|
+
writeInternalSecret(dir, "a".repeat(96));
|
|
29
|
+
const reported: string[] = [];
|
|
30
|
+
const secret = ensureInternalSecret(dir, {
|
|
31
|
+
randomSecret: () => "b".repeat(96),
|
|
32
|
+
report: (l) => reported.push(l),
|
|
33
|
+
});
|
|
34
|
+
expect(secret).toBe("a".repeat(96));
|
|
35
|
+
expect(reported).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("creates a missing one once, private to the owner, and says so", () => {
|
|
39
|
+
const reported: string[] = [];
|
|
40
|
+
const deps = { randomSecret: () => "c".repeat(96), report: (l: string) => reported.push(l) };
|
|
41
|
+
expect(ensureInternalSecret(dir, deps)).toBe("c".repeat(96));
|
|
42
|
+
expect(ensureInternalSecret(dir, { ...deps, randomSecret: () => "d".repeat(96) })).toBe(
|
|
43
|
+
"c".repeat(96),
|
|
44
|
+
);
|
|
45
|
+
expect(reported).toHaveLength(1);
|
|
46
|
+
expect(reported[0]).toContain(internalSecretPath(dir));
|
|
47
|
+
expect(statSync(internalSecretPath(dir)).mode & 0o077).toBe(0);
|
|
48
|
+
expect(JSON.parse(readFileSync(internalSecretPath(dir), "utf8"))).toEqual({
|
|
49
|
+
secret: "c".repeat(96),
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("refuses a malformed or short file rather than replacing it", () => {
|
|
54
|
+
writeFileSync(internalSecretPath(dir), "{not json");
|
|
55
|
+
expect(() => readInternalSecret(dir)).toThrow("not valid JSON");
|
|
56
|
+
writeFileSync(internalSecretPath(dir), JSON.stringify({ secret: "short" }));
|
|
57
|
+
expect(() =>
|
|
58
|
+
ensureInternalSecret(dir, { randomSecret: () => "e".repeat(96), report: () => {} }),
|
|
59
|
+
).toThrow("at least 32");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #770 — the self-hosted install's `/api/internal/*` secret,
|
|
5
|
+
* `.caelo/internal-secret.json` (`{ "secret": "<hex>" }`).
|
|
6
|
+
*
|
|
7
|
+
* `init` has written it since P15.1, but until #770 nothing handed it to the
|
|
8
|
+
* admin container, so the admin's internal endpoints answered "not set" on
|
|
9
|
+
* every self-hosted install. The Compose file now carries it as the admin's
|
|
10
|
+
* `CAELO_INTERNAL_SECRET`, and the host-side commands that call those
|
|
11
|
+
* endpoints (`owner-reset`) sign their tokens with the same file.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { writePrivateFile } from "./private-file.js";
|
|
17
|
+
|
|
18
|
+
/** The admin refuses shorter secrets (apps/admin internal-jwt.ts). */
|
|
19
|
+
const MIN_LENGTH = 32;
|
|
20
|
+
|
|
21
|
+
/** `<caeloDir>/internal-secret.json`. */
|
|
22
|
+
export function internalSecretPath(caeloDir: string): string {
|
|
23
|
+
return join(caeloDir, "internal-secret.json");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The install's internal secret, or null when the file does not exist.
|
|
28
|
+
* Throws on a malformed file rather than guessing a value: a wrong secret
|
|
29
|
+
* would only surface later as every internal call failing its signature.
|
|
30
|
+
*/
|
|
31
|
+
export function readInternalSecret(caeloDir: string): string | null {
|
|
32
|
+
const path = internalSecretPath(caeloDir);
|
|
33
|
+
if (!existsSync(path)) return null;
|
|
34
|
+
let secret: unknown;
|
|
35
|
+
try {
|
|
36
|
+
secret = (JSON.parse(readFileSync(path, "utf8")) as { secret?: unknown }).secret;
|
|
37
|
+
} catch (e) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`${path} is not valid JSON (${String(e)}); fix it or delete it and run \`cms-provision up\`.`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
if (typeof secret !== "string" || secret.length < MIN_LENGTH) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`${path} has no "secret" of at least ${MIN_LENGTH} characters; fix it or delete it and run \`cms-provision up\`.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return secret;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Write a new internal secret (`init`, or {@link ensureInternalSecret}). */
|
|
51
|
+
export function writeInternalSecret(caeloDir: string, secret: string): void {
|
|
52
|
+
if (secret.length < MIN_LENGTH) throw new Error(`internal secret shorter than ${MIN_LENGTH}`);
|
|
53
|
+
writePrivateFile(internalSecretPath(caeloDir), JSON.stringify({ secret }, null, 2));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The secret the Compose file gives the admin. An install whose directory
|
|
58
|
+
* has none (made by a CLI that did not write the file, or with it deleted)
|
|
59
|
+
* gets one created here, and `report` says so.
|
|
60
|
+
*
|
|
61
|
+
* This is creation of a missing secret, not a fallback (CLAUDE.md §2): no
|
|
62
|
+
* stored state depends on a previous value — before #770 the admin never
|
|
63
|
+
* received the secret, so no token was ever signed with it — and the value
|
|
64
|
+
* is written to disk before the Compose file that uses it, so every later
|
|
65
|
+
* render and every host-side command read the same one. Same pattern as
|
|
66
|
+
* the `caeloSecretKek` back-fill in the CLI's `emitConfig`.
|
|
67
|
+
*/
|
|
68
|
+
export function ensureInternalSecret(
|
|
69
|
+
caeloDir: string,
|
|
70
|
+
deps: { readonly randomSecret: () => string; readonly report: (line: string) => void },
|
|
71
|
+
): string {
|
|
72
|
+
const existing = readInternalSecret(caeloDir);
|
|
73
|
+
if (existing !== null) return existing;
|
|
74
|
+
const secret = deps.randomSecret();
|
|
75
|
+
writeInternalSecret(caeloDir, secret);
|
|
76
|
+
deps.report(
|
|
77
|
+
`Created ${internalSecretPath(caeloDir)} — this install had no internal secret; the admin now gets it as CAELO_INTERNAL_SECRET (needed by \`cms-provision owner-reset\`).`,
|
|
78
|
+
);
|
|
79
|
+
return secret;
|
|
80
|
+
}
|
|
@@ -45,7 +45,8 @@ export interface HostRunner {
|
|
|
45
45
|
/**
|
|
46
46
|
* Run `argv` to completion. With `stdoutPath`, stdout streams into that
|
|
47
47
|
* file (mode 600) instead of being captured — used for database dumps.
|
|
48
|
-
* With `stdinPath`, that file is the process's stdin (restores)
|
|
48
|
+
* With `stdinPath`, that file is the process's stdin (restores); with
|
|
49
|
+
* `stdinText`, that text is (secrets that must not appear in argv). With
|
|
49
50
|
* `inherit`, stdout + stderr go to the terminal (long pulls and starts
|
|
50
51
|
* the operator should watch) and come back empty.
|
|
51
52
|
*/
|
|
@@ -58,6 +59,7 @@ export interface HostRunOptions {
|
|
|
58
59
|
readonly env?: Readonly<Record<string, string>>;
|
|
59
60
|
readonly stdoutPath?: string;
|
|
60
61
|
readonly stdinPath?: string;
|
|
62
|
+
readonly stdinText?: string;
|
|
61
63
|
readonly inherit?: boolean;
|
|
62
64
|
}
|
|
63
65
|
|
|
@@ -67,7 +69,12 @@ export const bunHostRunner: HostRunner = {
|
|
|
67
69
|
try {
|
|
68
70
|
const proc = Bun.spawn([...argv], {
|
|
69
71
|
...(opts?.env ? { env: { ...process.env, ...opts.env } } : {}),
|
|
70
|
-
stdin:
|
|
72
|
+
stdin:
|
|
73
|
+
opts?.stdinText !== undefined
|
|
74
|
+
? Buffer.from(opts.stdinText)
|
|
75
|
+
: opts?.stdinPath
|
|
76
|
+
? Bun.file(opts.stdinPath)
|
|
77
|
+
: "ignore",
|
|
71
78
|
stdout: opts?.stdoutPath ? Bun.file(opts.stdoutPath) : opts?.inherit ? "inherit" : "pipe",
|
|
72
79
|
stderr: opts?.inherit ? "inherit" : "pipe",
|
|
73
80
|
});
|
|
@@ -235,7 +242,7 @@ export async function resolveVerifiedRelease(
|
|
|
235
242
|
error:
|
|
236
243
|
"cosign is required to verify the Caelo release image signatures and was not found on PATH.\n" +
|
|
237
244
|
"Install it (https://docs.sigstore.dev/cosign/system_config/installation/ — e.g. `brew install cosign`,\n" +
|
|
238
|
-
"or the release binary for Linux), then re-run.
|
|
245
|
+
"or the signed release binary for Linux), then re-run.",
|
|
239
246
|
};
|
|
240
247
|
}
|
|
241
248
|
for (const service of RELEASE_SERVICES) {
|
|
@@ -122,7 +122,11 @@ beforeEach(() => {
|
|
|
122
122
|
});
|
|
123
123
|
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
124
124
|
|
|
125
|
-
function deps(
|
|
125
|
+
function deps(
|
|
126
|
+
runner: HostRunner & { calls: string[] },
|
|
127
|
+
roles: ConvergeOutcome = { ok: true },
|
|
128
|
+
caddy: { ok: true } | { ok: false; error: string } = { ok: true },
|
|
129
|
+
) {
|
|
126
130
|
return {
|
|
127
131
|
caeloDir: dir,
|
|
128
132
|
runner,
|
|
@@ -131,6 +135,10 @@ function deps(runner: HostRunner & { calls: string[] }, roles: ConvergeOutcome =
|
|
|
131
135
|
runner.calls.push("converge roles");
|
|
132
136
|
return roles;
|
|
133
137
|
},
|
|
138
|
+
refreshCaddy: async () => {
|
|
139
|
+
runner.calls.push("refresh caddy");
|
|
140
|
+
return caddy;
|
|
141
|
+
},
|
|
134
142
|
log,
|
|
135
143
|
fetchImpl: registry,
|
|
136
144
|
sleep: async () => {},
|
|
@@ -161,6 +169,7 @@ describe("upgradeSelfHosted", () => {
|
|
|
161
169
|
"health admin",
|
|
162
170
|
"up caelo-gateway",
|
|
163
171
|
"health gateway",
|
|
172
|
+
"refresh caddy",
|
|
164
173
|
]);
|
|
165
174
|
// The migrations run the NEW admin image: the Compose file already names it.
|
|
166
175
|
expect(readFileSync(composePath(dir), "utf8")).toBe(render(NEW));
|
|
@@ -200,9 +209,34 @@ describe("upgradeSelfHosted", () => {
|
|
|
200
209
|
"up both",
|
|
201
210
|
"health admin",
|
|
202
211
|
"health gateway",
|
|
212
|
+
"refresh caddy",
|
|
203
213
|
]);
|
|
204
214
|
});
|
|
205
215
|
|
|
216
|
+
// #891 — the SVG CSP + nosniff live in the Caddyfile the release's own
|
|
217
|
+
// generator writes; an upgrade that only rolled the containers left every
|
|
218
|
+
// existing install serving SVGs without them.
|
|
219
|
+
it("re-writes the Caddyfile after the roll, before recording the release", async () => {
|
|
220
|
+
const runner = fakeRunner();
|
|
221
|
+
expect((await upgradeSelfHosted({ version: "0.11.0" }, deps(runner))).ok).toBe(true);
|
|
222
|
+
const s = steps(runner.calls);
|
|
223
|
+
expect(s.indexOf("refresh caddy")).toBeGreaterThan(s.indexOf("health gateway"));
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("fails at the caddy stage (release not recorded, so a re-run retries) when Caddy cannot reload", async () => {
|
|
227
|
+
const runner = fakeRunner();
|
|
228
|
+
const r = await upgradeSelfHosted(
|
|
229
|
+
{ version: "0.11.0" },
|
|
230
|
+
deps(runner, { ok: true }, { ok: false, error: "caddy reload failed: bad config" }),
|
|
231
|
+
);
|
|
232
|
+
expect(r).toMatchObject({ ok: false, stage: "caddy", previousRunning: false });
|
|
233
|
+
if (!r.ok) {
|
|
234
|
+
expect(r.error).toContain("bad config");
|
|
235
|
+
expect(r.error).toContain("regenerate-caddy");
|
|
236
|
+
}
|
|
237
|
+
expect(readRelease(dir)?.version).toBe("0.10.0");
|
|
238
|
+
});
|
|
239
|
+
|
|
206
240
|
it("reports an unhealthy install that is already on the target", async () => {
|
|
207
241
|
recordRelease(
|
|
208
242
|
dir,
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
* was retired in (run-order.ts): past it, nothing running uses it;
|
|
22
22
|
* 6. recreate the admin, health-check it (`/_caelo/health`, which proves
|
|
23
23
|
* the DB pool and roles); then the gateway (`/healthz`);
|
|
24
|
-
* 7.
|
|
24
|
+
* 7. re-write the Caddyfile and reload Caddy — the edge headers live there
|
|
25
|
+
* (#891), and only the new release's generator knows them;
|
|
26
|
+
* 8. record the release in `.caelo/release.json`.
|
|
25
27
|
* Any failure after step 3 puts the previous Compose file back; once a
|
|
26
28
|
* container was recreated the previous images are started again and
|
|
27
29
|
* health-checked (rollback). The database is never restored automatically:
|
|
@@ -90,6 +92,13 @@ export interface SelfHostedUpgradeDeps {
|
|
|
90
92
|
* (`convergeSelfHostedRoles`); drops roles a release retired (#635).
|
|
91
93
|
*/
|
|
92
94
|
readonly convergeRoles: () => Promise<ConvergeOutcome>;
|
|
95
|
+
/**
|
|
96
|
+
* Re-writes the Caddyfile from the install's config + domains table and
|
|
97
|
+
* reloads Caddy (#891): the edge headers (the SVG CSP, nosniff) live in
|
|
98
|
+
* the Caddyfile this release's generator writes, so an upgrade that only
|
|
99
|
+
* rolled the containers would leave the install on the old edge config.
|
|
100
|
+
*/
|
|
101
|
+
readonly refreshCaddy: () => Promise<{ ok: true } | { ok: false; error: string }>;
|
|
93
102
|
readonly fetchImpl?: typeof fetch;
|
|
94
103
|
readonly sleep?: (ms: number) => Promise<void>;
|
|
95
104
|
readonly now?: () => Date;
|
|
@@ -130,7 +139,8 @@ type UpgradeStage =
|
|
|
130
139
|
| "migrate"
|
|
131
140
|
| "roles"
|
|
132
141
|
| "roll-admin"
|
|
133
|
-
| "roll-gateway"
|
|
142
|
+
| "roll-gateway"
|
|
143
|
+
| "caddy";
|
|
134
144
|
|
|
135
145
|
/** Where an install from before #696 keeps its original Compose file during the move. */
|
|
136
146
|
export function legacyComposePath(caeloDir: string): string {
|
|
@@ -287,6 +297,10 @@ export async function upgradeSelfHosted(
|
|
|
287
297
|
false,
|
|
288
298
|
);
|
|
289
299
|
}
|
|
300
|
+
// A run that stopped after the roll (or an install upgraded before the
|
|
301
|
+
// Caddyfile was part of the upgrade) gets this release's edge config.
|
|
302
|
+
const caddy = await refreshCaddy(deps);
|
|
303
|
+
if (!caddy.ok) return caddy.failure;
|
|
290
304
|
log.info(`Already on ${current.version} (${shortDigest(images.admin)}); nothing to do.`);
|
|
291
305
|
return { ok: true, status: "already-current", version: current.version };
|
|
292
306
|
}
|
|
@@ -378,7 +392,13 @@ export async function upgradeSelfHosted(
|
|
|
378
392
|
log.info(`${service} healthy ✓`);
|
|
379
393
|
}
|
|
380
394
|
|
|
381
|
-
// 7.
|
|
395
|
+
// 7. The Caddyfile this release generates (edge headers, #891). The new
|
|
396
|
+
// containers already serve; a failure is reported before the release is
|
|
397
|
+
// recorded, so a re-run (the already-current path above) retries it.
|
|
398
|
+
const caddy = await refreshCaddy(deps);
|
|
399
|
+
if (!caddy.ok) return caddy.failure;
|
|
400
|
+
|
|
401
|
+
// 8. Record.
|
|
382
402
|
log.phase?.("record");
|
|
383
403
|
recordRelease(
|
|
384
404
|
caeloDir,
|
|
@@ -390,6 +410,23 @@ export async function upgradeSelfHosted(
|
|
|
390
410
|
return { ok: true, status: "upgraded", version: target.version };
|
|
391
411
|
}
|
|
392
412
|
|
|
413
|
+
/** Step 7: re-write + reload the Caddyfile; on failure, the result to return. */
|
|
414
|
+
async function refreshCaddy(
|
|
415
|
+
deps: SelfHostedUpgradeDeps,
|
|
416
|
+
): Promise<{ ok: true } | { ok: false; failure: SelfHostedUpgradeResult }> {
|
|
417
|
+
deps.log.step("Re-writing the Caddyfile and reloading Caddy");
|
|
418
|
+
const r = await deps.refreshCaddy();
|
|
419
|
+
if (r.ok) return { ok: true };
|
|
420
|
+
return {
|
|
421
|
+
ok: false,
|
|
422
|
+
failure: fail(
|
|
423
|
+
"caddy",
|
|
424
|
+
`${r.error}\nThe new release is running, but Caddy may still serve the previous Caddyfile (without this release's security headers). Fix the cause, then run \`${selfCommand("regenerate-caddy")}\` or re-run the upgrade.`,
|
|
425
|
+
false,
|
|
426
|
+
),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
393
430
|
function fail(
|
|
394
431
|
stage: UpgradeStage,
|
|
395
432
|
error: string,
|
|
@@ -340,6 +340,7 @@ describe("self-hosted compose", () => {
|
|
|
340
340
|
postgresPassword: "p",
|
|
341
341
|
rolePasswords: { admin: "a", public: "b" },
|
|
342
342
|
caeloSecretKek: "k",
|
|
343
|
+
internalSecret: "i".repeat(48),
|
|
343
344
|
diskSize: "10G",
|
|
344
345
|
ports: { http: 80, https: 443 },
|
|
345
346
|
project: "caelo-example-com",
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
rollService,
|
|
22
22
|
serviceRollArgs,
|
|
23
23
|
} from "./stack-converge.js";
|
|
24
|
+
import { GCP_STATIC_RESPONSE_HEADERS } from "./static-security-headers.js";
|
|
24
25
|
|
|
25
26
|
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
26
27
|
const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
|
|
@@ -44,6 +45,12 @@ function fakeGcloud(answers: Record<string, GcloudResult[]>, fallback: GcloudRes
|
|
|
44
45
|
return { run, calls };
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
/** A static backend bucket already at the stack's CDN policy + headers. */
|
|
49
|
+
const IN_SHAPE_BACKEND = {
|
|
50
|
+
cdnPolicy: STATIC_CDN_POLICY,
|
|
51
|
+
customResponseHeaders: GCP_STATIC_RESPONSE_HEADERS,
|
|
52
|
+
};
|
|
53
|
+
|
|
47
54
|
const policy = (bindings: { role: string; members: string[]; condition?: unknown }[]) =>
|
|
48
55
|
ok(JSON.stringify({ bindings }));
|
|
49
56
|
|
|
@@ -64,6 +71,9 @@ const firebase: InstallTarget = {
|
|
|
64
71
|
};
|
|
65
72
|
const gcp: InstallTarget = { ...firebase, provider: "gcp" };
|
|
66
73
|
|
|
74
|
+
const ingressJson = (ingress: string) =>
|
|
75
|
+
JSON.stringify({ metadata: { annotations: { "run.googleapis.com/ingress": ingress } } });
|
|
76
|
+
|
|
67
77
|
const serviceJson = (env: unknown[]) =>
|
|
68
78
|
JSON.stringify({ spec: { template: { spec: { containers: [{ env }] } } } });
|
|
69
79
|
|
|
@@ -494,6 +504,7 @@ describe("ensureStackInvariants", () => {
|
|
|
494
504
|
policy([{ role: "roles/run.invoker", members: [IAP_AGENT] }]),
|
|
495
505
|
],
|
|
496
506
|
"compute firewall-rules list": [ok("[]")],
|
|
507
|
+
"run services describe caelo-production-gateway-bbb": [ok(ingressJson("all"))],
|
|
497
508
|
});
|
|
498
509
|
|
|
499
510
|
it("is a read-only no-op on an install that is already in shape", async () => {
|
|
@@ -502,10 +513,59 @@ describe("ensureStackInvariants", () => {
|
|
|
502
513
|
expect(report.mustAbort).toBe(false);
|
|
503
514
|
expect(report.outcomes.every((o) => o.status === "present")).toBe(true);
|
|
504
515
|
expect(report.outcomes).toHaveLength(
|
|
505
|
-
stackIamInvariants("gcp-firebase").length + ABSENT_DEFAULT_FIREWALL_RULES.length,
|
|
516
|
+
stackIamInvariants("gcp-firebase").length + ABSENT_DEFAULT_FIREWALL_RULES.length + 1,
|
|
506
517
|
);
|
|
507
518
|
expect(calls.some((c) => c.startsWith("compute firewall-rules delete"))).toBe(false);
|
|
508
519
|
expect(calls.some((c) => c.includes("add-iam-policy-binding"))).toBe(false);
|
|
520
|
+
expect(calls.some((c) => c.startsWith("run services update"))).toBe(false);
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
describe("gcp-firebase gateway ingress (#732)", () => {
|
|
524
|
+
it("opens a load-balancer-only gateway to all traffic, and a re-run is a no-op", async () => {
|
|
525
|
+
const answers = fullFirebase();
|
|
526
|
+
answers["run services describe caelo-production-gateway-bbb"] = [
|
|
527
|
+
ok(ingressJson("internal-and-cloud-load-balancing")),
|
|
528
|
+
];
|
|
529
|
+
const { run, calls } = fakeGcloud(answers);
|
|
530
|
+
const report = await ensureStackInvariants(firebase, { run, sleep: async () => {} });
|
|
531
|
+
expect(report.mustAbort).toBe(false);
|
|
532
|
+
expect(calls).toContain(
|
|
533
|
+
"run services update caelo-production-gateway-bbb --region=europe-west1 --project=acme --ingress=all --quiet",
|
|
534
|
+
);
|
|
535
|
+
expect(report.outcomes.filter((o) => o.status === "applied").map((o) => o.id)).toEqual([
|
|
536
|
+
"ingress internal-and-cloud-load-balancing → all on caelo-production-gateway-bbb",
|
|
537
|
+
]);
|
|
538
|
+
|
|
539
|
+
const again = fakeGcloud(fullFirebase());
|
|
540
|
+
const rerun = await ensureStackInvariants(firebase, {
|
|
541
|
+
run: again.run,
|
|
542
|
+
sleep: async () => {},
|
|
543
|
+
});
|
|
544
|
+
expect(rerun.outcomes.every((o) => o.status === "present")).toBe(true);
|
|
545
|
+
expect(again.calls.some((c) => c.includes("--ingress"))).toBe(false);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("aborts the upgrade when the ingress can't be changed", async () => {
|
|
549
|
+
const { run } = fakeGcloud({
|
|
550
|
+
...fullFirebase(),
|
|
551
|
+
"run services describe caelo-production-gateway-bbb": [
|
|
552
|
+
ok(ingressJson("internal-and-cloud-load-balancing")),
|
|
553
|
+
],
|
|
554
|
+
"run services update": [fail("PERMISSION_DENIED: run.services.update")],
|
|
555
|
+
});
|
|
556
|
+
const report = await ensureStackInvariants(firebase, { run, sleep: async () => {} });
|
|
557
|
+
expect(report.mustAbort).toBe(true);
|
|
558
|
+
expect(report.outcomes.find((o) => o.status === "failed")?.error).toBe(
|
|
559
|
+
"PERMISSION_DENIED: run.services.update",
|
|
560
|
+
);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("never reads or changes the gcp gateway's ingress (its stack declares load-balancer-only)", async () => {
|
|
564
|
+
const { run, calls } = fakeGcloud({ "projects describe": [ok("42")] });
|
|
565
|
+
await ensureStackInvariants(gcp, { run, sleep: async () => {} });
|
|
566
|
+
expect(calls.some((c) => c.startsWith("run services describe"))).toBe(false);
|
|
567
|
+
expect(calls.some((c) => c.includes("--ingress"))).toBe(false);
|
|
568
|
+
});
|
|
509
569
|
});
|
|
510
570
|
|
|
511
571
|
it("upgrade deletes GCP's pre-populated default-network firewall rules without aborting", async () => {
|
|
@@ -697,6 +757,17 @@ describe("ensureStackInvariants", () => {
|
|
|
697
757
|
],
|
|
698
758
|
"compute backend-buckets list": [ok("caelo-production-static-backend-9f8e7d6\n")],
|
|
699
759
|
"compute firewall-rules list": [ok("[]")],
|
|
760
|
+
// #891 — every live SVG already served as a download.
|
|
761
|
+
"storage objects list": [
|
|
762
|
+
ok(
|
|
763
|
+
JSON.stringify([
|
|
764
|
+
{
|
|
765
|
+
storage_url: "gs://acme-caelo-production-static/_assets/a.0123456789abcdef.svg",
|
|
766
|
+
content_disposition: "attachment",
|
|
767
|
+
},
|
|
768
|
+
]),
|
|
769
|
+
),
|
|
770
|
+
],
|
|
700
771
|
});
|
|
701
772
|
|
|
702
773
|
it("raises the pre-#555 1h/24h TTLs to a year", async () => {
|
|
@@ -719,7 +790,7 @@ describe("ensureStackInvariants", () => {
|
|
|
719
790
|
const report = await ensureStackInvariants(gcp, { run, sleep: async () => {} });
|
|
720
791
|
expect(report.mustAbort).toBe(false);
|
|
721
792
|
expect(calls).toContain(
|
|
722
|
-
`compute backend-buckets update caelo-production-static-backend-9f8e7d6 --project=acme --cache-mode=CACHE_ALL_STATIC --default-ttl=3600 --max-ttl=${STATIC_CDN_POLICY.maxTtl} --client-ttl=${STATIC_CDN_POLICY.clientTtl} --quiet`,
|
|
793
|
+
`compute backend-buckets update caelo-production-static-backend-9f8e7d6 --project=acme --cache-mode=CACHE_ALL_STATIC --default-ttl=3600 --max-ttl=${STATIC_CDN_POLICY.maxTtl} --client-ttl=${STATIC_CDN_POLICY.clientTtl} --custom-response-header=X-Content-Type-Options: nosniff --quiet`,
|
|
723
794
|
);
|
|
724
795
|
expect(report.outcomes.find((o) => o.id.startsWith("Cloud CDN"))?.status).toBe("applied");
|
|
725
796
|
// Every IAM invariant was already present.
|
|
@@ -729,7 +800,7 @@ describe("ensureStackInvariants", () => {
|
|
|
729
800
|
it("leaves a backend bucket already at the stack's policy alone", async () => {
|
|
730
801
|
const answers = {
|
|
731
802
|
...fullGcp(),
|
|
732
|
-
"compute backend-buckets describe": [ok(JSON.stringify(
|
|
803
|
+
"compute backend-buckets describe": [ok(JSON.stringify(IN_SHAPE_BACKEND))],
|
|
733
804
|
};
|
|
734
805
|
const { run, calls } = fakeGcloud(answers);
|
|
735
806
|
const report = await ensureStackInvariants(gcp, { run, sleep: async () => {} });
|
|
@@ -737,6 +808,19 @@ describe("ensureStackInvariants", () => {
|
|
|
737
808
|
expect(report.outcomes.every((o) => o.status === "present")).toBe(true);
|
|
738
809
|
});
|
|
739
810
|
|
|
811
|
+
// #891 regression: the backend bucket served every file without nosniff.
|
|
812
|
+
it("adds the nosniff response header to a bucket already at the CDN policy", async () => {
|
|
813
|
+
const answers = {
|
|
814
|
+
...fullGcp(),
|
|
815
|
+
"compute backend-buckets describe": [ok(JSON.stringify({ cdnPolicy: STATIC_CDN_POLICY }))],
|
|
816
|
+
};
|
|
817
|
+
const { run, calls } = fakeGcloud(answers);
|
|
818
|
+
const report = await ensureStackInvariants(gcp, { run, sleep: async () => {} });
|
|
819
|
+
const update = calls.find((c) => c.startsWith("compute backend-buckets update"));
|
|
820
|
+
expect(update).toContain("--custom-response-header=X-Content-Type-Options: nosniff");
|
|
821
|
+
expect(report.outcomes.find((o) => o.id.startsWith("Cloud CDN"))?.status).toBe("applied");
|
|
822
|
+
});
|
|
823
|
+
|
|
740
824
|
it("warns (no abort) when the CDN update fails", async () => {
|
|
741
825
|
const answers = {
|
|
742
826
|
...fullGcp(),
|
|
@@ -754,7 +838,7 @@ describe("ensureStackInvariants", () => {
|
|
|
754
838
|
...fullGcp(),
|
|
755
839
|
"storage buckets get-iam-policy": [policy([])],
|
|
756
840
|
"secrets get-iam-policy": [policy([])],
|
|
757
|
-
"compute backend-buckets describe": [ok(JSON.stringify(
|
|
841
|
+
"compute backend-buckets describe": [ok(JSON.stringify(IN_SHAPE_BACKEND))],
|
|
758
842
|
};
|
|
759
843
|
const { run, calls } = fakeGcloud(answers);
|
|
760
844
|
await ensureStackInvariants(gcp, { run, sleep: async () => {} });
|
|
@@ -774,7 +858,7 @@ describe("ensureStackInvariants", () => {
|
|
|
774
858
|
const answers = {
|
|
775
859
|
...fullGcp(),
|
|
776
860
|
"secrets get-iam-policy": [policy([])],
|
|
777
|
-
"compute backend-buckets describe": [ok(JSON.stringify(
|
|
861
|
+
"compute backend-buckets describe": [ok(JSON.stringify(IN_SHAPE_BACKEND))],
|
|
778
862
|
};
|
|
779
863
|
const { run, calls } = fakeGcloud(answers);
|
|
780
864
|
await ensureStackInvariants(gcp, { run, sleep: async () => {} });
|