@caelo-cms/provisioning 0.10.60 → 0.10.62
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/operator-access.d.ts +1 -2
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +1 -2
- package/dist/operator-access.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/operator-access.ts +1 -2
- 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
package/src/state-pulumi.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* The real {@link StatePulumiOps} for `state migrate` (#696): the Pulumi CLI
|
|
5
5
|
* (state operations) and the Automation API (previews), always in the
|
|
6
|
-
* install's stack dir and with exactly the backend's
|
|
6
|
+
* install's own stack work dir (#736) and with exactly the backend's
|
|
7
|
+
* environment. The
|
|
7
8
|
* operator never runs `pulumi` by hand (CLAUDE.md §11.C).
|
|
8
9
|
*
|
|
9
10
|
* Secrets: outputs are read with `--show-secrets` only to hash them; the
|
|
@@ -18,12 +19,14 @@ import { join } from "node:path";
|
|
|
18
19
|
import * as automation from "@pulumi/pulumi/automation";
|
|
19
20
|
import { redactCredentials } from "./checkpoint-secrets.js";
|
|
20
21
|
import { GCP_STACK_ENV } from "./gcp-names.js";
|
|
22
|
+
import { collectPreviewSteps } from "./preview-plan.js";
|
|
21
23
|
import type { ResolvedBackend } from "./state-backend.js";
|
|
22
24
|
import type { StatePulumiOps } from "./state-migrate.js";
|
|
23
25
|
import {
|
|
24
26
|
alignStackSettingsFile,
|
|
25
27
|
applyStackConfig,
|
|
26
|
-
|
|
28
|
+
assertStackProject,
|
|
29
|
+
prepareStackWorkDir,
|
|
27
30
|
pulumiGcpEnv,
|
|
28
31
|
type StackConfigInputs,
|
|
29
32
|
type StateResource,
|
|
@@ -49,13 +52,6 @@ export function canonicalJson(value: unknown): string {
|
|
|
49
52
|
return JSON.stringify(value);
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
/** Steps of a preview's change summary other than "same". */
|
|
53
|
-
export function changeCount(summary: Readonly<Record<string, number | undefined>>): number {
|
|
54
|
-
return Object.entries(summary)
|
|
55
|
-
.filter(([op]) => op !== "same")
|
|
56
|
-
.reduce((n, [, c]) => n + (c ?? 0), 0);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
55
|
interface Deployment {
|
|
60
56
|
pending_operations?: unknown[];
|
|
61
57
|
secrets_providers?: {
|
|
@@ -76,7 +72,7 @@ export interface RealPulumiOpsInputs {
|
|
|
76
72
|
|
|
77
73
|
/** {@link StatePulumiOps} backed by the `pulumi` CLI + Automation API. */
|
|
78
74
|
export function realStatePulumiOps(inputs: RealPulumiOpsInputs): StatePulumiOps {
|
|
79
|
-
const workDir =
|
|
75
|
+
const workDir = prepareStackWorkDir(inputs.installRoot, inputs.provider);
|
|
80
76
|
|
|
81
77
|
const cli = async (
|
|
82
78
|
args: string[],
|
|
@@ -99,7 +95,7 @@ export function realStatePulumiOps(inputs: RealPulumiOpsInputs): StatePulumiOps
|
|
|
99
95
|
return { stdout, stderr };
|
|
100
96
|
};
|
|
101
97
|
|
|
102
|
-
// The read-only steps (preflight, verification) align the
|
|
98
|
+
// The read-only steps (preflight, verification) align the install's
|
|
103
99
|
// stack settings file and may set config to run a preview; the file is
|
|
104
100
|
// put back exactly as it was afterwards, so a refused migration or a dry
|
|
105
101
|
// run leaves it untouched.
|
|
@@ -127,7 +123,7 @@ export function realStatePulumiOps(inputs: RealPulumiOpsInputs): StatePulumiOps
|
|
|
127
123
|
};
|
|
128
124
|
|
|
129
125
|
const exportDeployment = async (b: ResolvedBackend): Promise<Deployment> => {
|
|
130
|
-
await alignStackSettingsFile(
|
|
126
|
+
await alignStackSettingsFile(workDir, b);
|
|
131
127
|
const { stdout } = await cli(["stack", "export", "--stack", GCP_STACK_ENV], stateOnlyEnv(b));
|
|
132
128
|
return ((JSON.parse(stdout) as { deployment?: Deployment }).deployment ?? {}) as Deployment;
|
|
133
129
|
};
|
|
@@ -179,7 +175,7 @@ export function realStatePulumiOps(inputs: RealPulumiOpsInputs): StatePulumiOps
|
|
|
179
175
|
},
|
|
180
176
|
|
|
181
177
|
async previewChanges(b) {
|
|
182
|
-
await alignStackSettingsFile(
|
|
178
|
+
await alignStackSettingsFile(workDir, b);
|
|
183
179
|
const stack = await automation.LocalWorkspace.selectStack(
|
|
184
180
|
{ stackName: GCP_STACK_ENV, workDir },
|
|
185
181
|
{
|
|
@@ -191,21 +187,24 @@ export function realStatePulumiOps(inputs: RealPulumiOpsInputs): StatePulumiOps
|
|
|
191
187
|
);
|
|
192
188
|
// The stack's own config wins: an operator may have tuned a knob
|
|
193
189
|
// (`pulumi config set`), and resetting it would show as drift. Only a
|
|
194
|
-
//
|
|
195
|
-
// gets what the wizard sets
|
|
196
|
-
// config (installs of one provider share the package's stack dir).
|
|
190
|
+
// settings file without the install's config (an install from before
|
|
191
|
+
// its own work dir, #736) gets what the wizard sets.
|
|
197
192
|
const config: automation.ConfigMap = await stack.getAllConfig().catch(() => ({}));
|
|
198
|
-
if (config[`caelo-${inputs.provider}:project`]
|
|
193
|
+
if (config[`caelo-${inputs.provider}:project`] === undefined) {
|
|
199
194
|
const resources = ((await stack.exportStack()).deployment?.resources ??
|
|
200
195
|
[]) as StateResource[];
|
|
201
196
|
await applyStackConfig(stack, inputs.stackConfig, resources);
|
|
202
197
|
}
|
|
203
|
-
|
|
204
|
-
|
|
198
|
+
await assertStackProject(stack, inputs.provider, inputs.stackConfig.projectId);
|
|
199
|
+
// #737 — the steps themselves, not just the summary's count: a
|
|
200
|
+
// refusal lists what would change, replaces and deletes first.
|
|
201
|
+
const collector = collectPreviewSteps();
|
|
202
|
+
await stack.preview({ refresh: true, onEvent: collector.onEvent });
|
|
203
|
+
return collector.steps();
|
|
205
204
|
},
|
|
206
205
|
|
|
207
206
|
async outputsDigest(b) {
|
|
208
|
-
await alignStackSettingsFile(
|
|
207
|
+
await alignStackSettingsFile(workDir, b);
|
|
209
208
|
const { stdout } = await cli(
|
|
210
209
|
["stack", "output", "--stack", GCP_STACK_ENV, "--json", "--show-secrets"],
|
|
211
210
|
stateOnlyEnv(b),
|
|
@@ -216,7 +215,7 @@ export function realStatePulumiOps(inputs: RealPulumiOpsInputs): StatePulumiOps
|
|
|
216
215
|
},
|
|
217
216
|
|
|
218
217
|
async stackMigrate(from, to) {
|
|
219
|
-
await alignStackSettingsFile(
|
|
218
|
+
await alignStackSettingsFile(workDir, from);
|
|
220
219
|
await cli(
|
|
221
220
|
[
|
|
222
221
|
"stack",
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #891 regression: the public static site served user-uploaded SVGs from
|
|
5
|
+
* the CDN with no CSP on every provider, so a script that got past
|
|
6
|
+
* `sanitizeSvg` ran on the site's origin (the one that also serves
|
|
7
|
+
* `/api/*`) when the SVG's URL was opened. These tests pin each provider's
|
|
8
|
+
* edge config and that every stack wires it (source-level for the stacks:
|
|
9
|
+
* the Pulumi programs cannot run in a unit test).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from "bun:test";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
import { NOSNIFF, SVG_DOCUMENT_CSP } from "@caelo-cms/shared";
|
|
16
|
+
import {
|
|
17
|
+
CLOUDFRONT_SVG_BEHAVIORS,
|
|
18
|
+
cloudFrontHeadersPolicy,
|
|
19
|
+
FRONT_DOOR_SECURITY_RULE_SET,
|
|
20
|
+
frontDoorSecurityRules,
|
|
21
|
+
GCP_STATIC_RESPONSE_HEADERS,
|
|
22
|
+
} from "./static-security-headers.js";
|
|
23
|
+
|
|
24
|
+
const stack = (provider: string): string =>
|
|
25
|
+
readFileSync(resolve(import.meta.dir, "..", "stacks", provider, "index.ts"), "utf8");
|
|
26
|
+
|
|
27
|
+
describe("AWS CloudFront response headers policies", () => {
|
|
28
|
+
it("site: nosniff only (a CSP with sandbox would break every page)", () => {
|
|
29
|
+
const p = cloudFrontHeadersPolicy("site");
|
|
30
|
+
expect(p.securityHeadersConfig.contentTypeOptions).toEqual({ override: true });
|
|
31
|
+
expect(p.securityHeadersConfig.contentSecurityPolicy).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("svg: nosniff + the sandboxing CSP, overriding any origin header", () => {
|
|
35
|
+
const p = cloudFrontHeadersPolicy("svg");
|
|
36
|
+
expect(p.securityHeadersConfig.contentTypeOptions).toEqual({ override: true });
|
|
37
|
+
expect(p.securityHeadersConfig.contentSecurityPolicy).toEqual({
|
|
38
|
+
contentSecurityPolicy: SVG_DOCUMENT_CSP,
|
|
39
|
+
override: true,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("SVG behaviors cover both S3 origins, the media one first (first match wins)", () => {
|
|
44
|
+
expect(CLOUDFRONT_SVG_BEHAVIORS).toEqual([
|
|
45
|
+
{ pathPattern: "/media/*.svg", targetOriginId: "media" },
|
|
46
|
+
{ pathPattern: "*.svg", targetOriginId: "static" },
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("the stack attaches the svg policy to the SVG behaviors ahead of /media/*, the site policy everywhere else", () => {
|
|
51
|
+
const src = stack("aws");
|
|
52
|
+
expect(src).toContain('...cloudFrontHeadersPolicy("site")');
|
|
53
|
+
expect(src).toContain('...cloudFrontHeadersPolicy("svg")');
|
|
54
|
+
expect(src).toContain("...CLOUDFRONT_SVG_BEHAVIORS.map(");
|
|
55
|
+
expect(src.match(/responseHeadersPolicyId: siteHeadersPolicy\.id/g)).toHaveLength(2);
|
|
56
|
+
expect(src).toContain("responseHeadersPolicyId: svgHeadersPolicy.id");
|
|
57
|
+
expect(src.indexOf("...CLOUDFRONT_SVG_BEHAVIORS.map(")).toBeLessThan(
|
|
58
|
+
src.indexOf('pathPattern: "/media/*"'),
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("Azure Front Door security rule set", () => {
|
|
64
|
+
const rules = frontDoorSecurityRules();
|
|
65
|
+
|
|
66
|
+
it("nosniff on every response (no condition)", () => {
|
|
67
|
+
const nosniff = rules.find((r) => r.ruleName === "nosniff");
|
|
68
|
+
expect(nosniff && "conditions" in nosniff).toBe(false);
|
|
69
|
+
expect(nosniff?.actions).toEqual([
|
|
70
|
+
{
|
|
71
|
+
name: "ModifyResponseHeader",
|
|
72
|
+
parameters: {
|
|
73
|
+
typeName: "DeliveryRuleHeaderActionParameters",
|
|
74
|
+
headerAction: "Overwrite",
|
|
75
|
+
headerName: "X-Content-Type-Options",
|
|
76
|
+
value: NOSNIFF,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("the CSP when the file extension is svg, compared lower-cased", () => {
|
|
83
|
+
const svg = rules.find((r) => r.ruleName === "svgCsp");
|
|
84
|
+
expect(svg && "conditions" in svg ? svg.conditions : []).toEqual([
|
|
85
|
+
{
|
|
86
|
+
name: "UrlFileExtension",
|
|
87
|
+
parameters: {
|
|
88
|
+
typeName: "DeliveryRuleUrlFileExtensionMatchConditionParameters",
|
|
89
|
+
operator: "Equal",
|
|
90
|
+
negateCondition: false,
|
|
91
|
+
matchValues: ["svg"],
|
|
92
|
+
transforms: ["Lowercase"],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
]);
|
|
96
|
+
expect(svg?.actions[0]?.parameters).toMatchObject({
|
|
97
|
+
headerName: "Content-Security-Policy",
|
|
98
|
+
value: SVG_DOCUMENT_CSP,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("rules keep processing so both apply, with distinct orders", () => {
|
|
103
|
+
expect(rules.every((r) => r.matchProcessingBehavior === "Continue")).toBe(true);
|
|
104
|
+
expect(new Set(rules.map((r) => r.order)).size).toBe(rules.length);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("the stack creates the set and attaches it to the static route next to the redirects", () => {
|
|
108
|
+
const src = stack("azure");
|
|
109
|
+
expect(src).toContain("ruleSetName: FRONT_DOOR_SECURITY_RULE_SET");
|
|
110
|
+
expect(src).toContain("for (const rule of frontDoorSecurityRules())");
|
|
111
|
+
expect(src).toContain("ruleSets: [{ id: redirectRuleSet.id }, { id: securityRuleSet.id }]");
|
|
112
|
+
expect(FRONT_DOOR_SECURITY_RULE_SET).not.toBe("caeloRedirects");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("GCP static backend bucket", () => {
|
|
117
|
+
it("adds nosniff to every response", () => {
|
|
118
|
+
expect(GCP_STATIC_RESPONSE_HEADERS).toEqual([`X-Content-Type-Options: ${NOSNIFF}`]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("the stack sets the headers on the static backend bucket", () => {
|
|
122
|
+
expect(stack("gcp")).toContain("customResponseHeaders: [...GCP_STATIC_RESPONSE_HEADERS]");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #891 — the public site's security response headers at each provider's
|
|
5
|
+
* Tier-1 edge (CLAUDE.md §11.B): every response `nosniff`, every SVG the
|
|
6
|
+
* sandboxing CSP (`SVG_DOCUMENT_CSP`, @caelo-cms/shared). An uploaded SVG
|
|
7
|
+
* opened directly is a document that runs script in the site's origin —
|
|
8
|
+
* the origin that also serves `/api/*` through the same load balancer — so
|
|
9
|
+
* the header must come from the edge that serves the file, not from page
|
|
10
|
+
* HTML. `sanitizeSvg` at upload stays the first line; this is the second.
|
|
11
|
+
*
|
|
12
|
+
* The provider configs below are plain data the stacks pass to Pulumi
|
|
13
|
+
* (imported from `dist/`, so this file stays free of provider SDKs), and
|
|
14
|
+
* the unit tests pin them:
|
|
15
|
+
*
|
|
16
|
+
* - AWS: CloudFront response headers policies — one with nosniff on every
|
|
17
|
+
* behavior, one adding the CSP on `*.svg` behaviors of both S3 origins.
|
|
18
|
+
* - Azure: a stack-owned Front Door rule set next to the deploy-owned
|
|
19
|
+
* redirects set — nosniff on every response, the CSP when the URL file
|
|
20
|
+
* extension is `svg`.
|
|
21
|
+
* - Self-hosted: the Caddyfile generator (caddy.ts).
|
|
22
|
+
* - Firebase Hosting: version-config headers the publisher writes
|
|
23
|
+
* (admin-core static-publisher-firebase.ts).
|
|
24
|
+
* - GCP (`gcp` stack): the static backend bucket's custom response header
|
|
25
|
+
* carries nosniff; the CSP cannot be attached per file on that load
|
|
26
|
+
* balancer (see `gcsObjectMetadata` in admin-core for why), so SVG
|
|
27
|
+
* objects are stored `Content-Disposition: attachment` instead and the
|
|
28
|
+
* converge backfills them (static-svg-gcp.ts).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { NOSNIFF, SVG_DOCUMENT_CSP } from "@caelo-cms/shared";
|
|
32
|
+
|
|
33
|
+
// ===========================================================================
|
|
34
|
+
// GCP — Cloud CDN backend bucket
|
|
35
|
+
// ===========================================================================
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `customResponseHeaders` of the `gcp` stack's static backend bucket, in
|
|
39
|
+
* the `Name: value` form the Compute API and `gcloud compute backend-buckets
|
|
40
|
+
* update --custom-response-header` take. The converge (stack-converge.ts)
|
|
41
|
+
* brings existing installs to exactly this list.
|
|
42
|
+
*/
|
|
43
|
+
export const GCP_STATIC_RESPONSE_HEADERS: readonly string[] = [
|
|
44
|
+
`X-Content-Type-Options: ${NOSNIFF}`,
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// ===========================================================================
|
|
48
|
+
// AWS — CloudFront
|
|
49
|
+
// ===========================================================================
|
|
50
|
+
|
|
51
|
+
/** The two response headers policies the AWS stack attaches. */
|
|
52
|
+
export type CloudFrontHeadersPolicyKind = "site" | "svg";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `aws.cloudfront.ResponseHeadersPolicy` arguments (minus `name`). `site`
|
|
56
|
+
* goes on every cache behavior; `svg` on the `*.svg` behaviors
|
|
57
|
+
* ({@link CLOUDFRONT_SVG_BEHAVIORS}). `override: true` so an origin header
|
|
58
|
+
* can never weaken them.
|
|
59
|
+
*/
|
|
60
|
+
export function cloudFrontHeadersPolicy(kind: CloudFrontHeadersPolicyKind): {
|
|
61
|
+
readonly comment: string;
|
|
62
|
+
readonly securityHeadersConfig: {
|
|
63
|
+
readonly contentTypeOptions: { readonly override: true };
|
|
64
|
+
readonly contentSecurityPolicy?: {
|
|
65
|
+
readonly contentSecurityPolicy: string;
|
|
66
|
+
readonly override: true;
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
} {
|
|
70
|
+
if (kind === "site") {
|
|
71
|
+
return {
|
|
72
|
+
comment: "Caelo static site: nosniff on every response (#891)",
|
|
73
|
+
securityHeadersConfig: { contentTypeOptions: { override: true } },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
comment: "Caelo static site: sandboxing CSP on SVG documents (#891)",
|
|
78
|
+
securityHeadersConfig: {
|
|
79
|
+
contentTypeOptions: { override: true },
|
|
80
|
+
contentSecurityPolicy: { contentSecurityPolicy: SVG_DOCUMENT_CSP, override: true },
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The SVG cache behaviors, in CloudFront precedence order (first match
|
|
87
|
+
* wins): `/media/*.svg` must come before the generic `*.svg` (which would
|
|
88
|
+
* route media SVGs to the static origin) and both before `/media/*`.
|
|
89
|
+
* CloudFront path patterns are case-sensitive; the static generator writes
|
|
90
|
+
* every media extension in lower case (media-asset-path.ts).
|
|
91
|
+
*/
|
|
92
|
+
export const CLOUDFRONT_SVG_BEHAVIORS: ReadonlyArray<{
|
|
93
|
+
readonly pathPattern: string;
|
|
94
|
+
readonly targetOriginId: "media" | "static";
|
|
95
|
+
}> = [
|
|
96
|
+
{ pathPattern: "/media/*.svg", targetOriginId: "media" },
|
|
97
|
+
{ pathPattern: "*.svg", targetOriginId: "static" },
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
// ===========================================================================
|
|
101
|
+
// Azure — Front Door rules engine
|
|
102
|
+
// ===========================================================================
|
|
103
|
+
|
|
104
|
+
/** Name of the stack-owned Front Door rule set (the redirects set is the deploy's). */
|
|
105
|
+
export const FRONT_DOOR_SECURITY_RULE_SET = "caeloSecurityHeaders";
|
|
106
|
+
|
|
107
|
+
/** One `ModifyResponseHeader` action that overwrites `headerName`. */
|
|
108
|
+
function overwriteResponseHeader(headerName: string, value: string) {
|
|
109
|
+
return {
|
|
110
|
+
name: "ModifyResponseHeader" as const,
|
|
111
|
+
parameters: {
|
|
112
|
+
typeName: "DeliveryRuleHeaderActionParameters" as const,
|
|
113
|
+
headerAction: "Overwrite",
|
|
114
|
+
headerName,
|
|
115
|
+
value,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The rules of {@link FRONT_DOOR_SECURITY_RULE_SET}, as `azure.cdn.Rule`
|
|
122
|
+
* arguments (minus the resource group / profile / rule set the stack
|
|
123
|
+
* fills in). A rule without conditions runs on every request; the SVG rule
|
|
124
|
+
* matches the URL's file extension lower-cased, so `.SVG` is covered.
|
|
125
|
+
* `Continue` lets both rules (and the redirects set) apply.
|
|
126
|
+
*/
|
|
127
|
+
export function frontDoorSecurityRules() {
|
|
128
|
+
return [
|
|
129
|
+
{
|
|
130
|
+
ruleName: "nosniff",
|
|
131
|
+
order: 1,
|
|
132
|
+
matchProcessingBehavior: "Continue",
|
|
133
|
+
actions: [overwriteResponseHeader("X-Content-Type-Options", NOSNIFF)],
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
ruleName: "svgCsp",
|
|
137
|
+
order: 2,
|
|
138
|
+
matchProcessingBehavior: "Continue",
|
|
139
|
+
conditions: [
|
|
140
|
+
{
|
|
141
|
+
name: "UrlFileExtension" as const,
|
|
142
|
+
parameters: {
|
|
143
|
+
typeName: "DeliveryRuleUrlFileExtensionMatchConditionParameters" as const,
|
|
144
|
+
operator: "Equal",
|
|
145
|
+
negateCondition: false,
|
|
146
|
+
matchValues: ["svg"],
|
|
147
|
+
transforms: ["Lowercase"],
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
],
|
|
151
|
+
actions: [overwriteResponseHeader("Content-Security-Policy", SVG_DOCUMENT_CSP)],
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #891 — the converge's backfill of a `gcp` install's live SVGs: patch the
|
|
5
|
+
* ones not yet served as downloads, then purge Cloud CDN once.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from "bun:test";
|
|
9
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
10
|
+
import type { InstallTarget } from "./stack-converge.js";
|
|
11
|
+
import { ensureSvgDownloads, svgObjectsMissingDisposition } from "./static-svg-gcp.js";
|
|
12
|
+
|
|
13
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
14
|
+
const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
|
|
15
|
+
|
|
16
|
+
const gcp: InstallTarget = {
|
|
17
|
+
provider: "gcp",
|
|
18
|
+
projectId: "acme",
|
|
19
|
+
region: "europe-west1",
|
|
20
|
+
env: "production",
|
|
21
|
+
services: { admin: "caelo-production-admin-1", gateway: "caelo-production-gateway-2" },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const URL_A = "gs://acme-caelo-production-static/_assets/a.0123456789abcdef.svg";
|
|
25
|
+
const URL_B = "gs://acme-caelo-production-static/_assets/brand/b.0123456789abcdef.svg";
|
|
26
|
+
|
|
27
|
+
/** Answers by the longest matching command prefix; records every call. */
|
|
28
|
+
function fakeGcloud(answers: Record<string, GcloudResult>) {
|
|
29
|
+
const calls: string[] = [];
|
|
30
|
+
const run = async (args: string[]) => {
|
|
31
|
+
const line = args.join(" ");
|
|
32
|
+
calls.push(line);
|
|
33
|
+
const key = Object.keys(answers)
|
|
34
|
+
.filter((k) => line.startsWith(k))
|
|
35
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
36
|
+
return key ? (answers[key] as GcloudResult) : ok();
|
|
37
|
+
};
|
|
38
|
+
return { run, calls };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const listing = (...objects: object[]) => ok(JSON.stringify(objects));
|
|
42
|
+
|
|
43
|
+
describe("svgObjectsMissingDisposition", () => {
|
|
44
|
+
it("returns the objects not served as attachment", () => {
|
|
45
|
+
expect(
|
|
46
|
+
svgObjectsMissingDisposition(
|
|
47
|
+
JSON.stringify([
|
|
48
|
+
{ storage_url: URL_A, content_disposition: "attachment" },
|
|
49
|
+
{ storage_url: URL_B },
|
|
50
|
+
{ storage_url: "gs://x/c.svg", content_disposition: "inline" },
|
|
51
|
+
]),
|
|
52
|
+
),
|
|
53
|
+
).toEqual([URL_B, "gs://x/c.svg"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("treats empty output as no objects and refuses anything that is not a listing", () => {
|
|
57
|
+
expect(svgObjectsMissingDisposition("")).toEqual([]);
|
|
58
|
+
expect(() => svgObjectsMissingDisposition("{}")).toThrow("not a JSON list");
|
|
59
|
+
expect(() => svgObjectsMissingDisposition('[{"name":"a.svg"}]')).toThrow("storage_url");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("ensureSvgDownloads", () => {
|
|
64
|
+
it("patches the SVGs a pre-#891 install serves inline, then purges the CDN once", async () => {
|
|
65
|
+
const { run, calls } = fakeGcloud({
|
|
66
|
+
"storage objects list": listing(
|
|
67
|
+
{ storage_url: URL_A },
|
|
68
|
+
{ storage_url: URL_B, content_disposition: "attachment" },
|
|
69
|
+
),
|
|
70
|
+
"compute url-maps list": ok("caelo-production-url-map-1a2b3c\n"),
|
|
71
|
+
});
|
|
72
|
+
const outcome = await ensureSvgDownloads(gcp, run);
|
|
73
|
+
expect(outcome.status).toBe("applied");
|
|
74
|
+
expect(outcome.onFailure).toBe("warn");
|
|
75
|
+
expect(calls).toEqual([
|
|
76
|
+
"storage objects list gs://acme-caelo-production-static/**.svg --project=acme --format=json",
|
|
77
|
+
`storage objects update ${URL_A} --content-disposition=attachment --project=acme`,
|
|
78
|
+
"compute url-maps list --global --project=acme --filter=name~^caelo-production-url-map --format=value(name)",
|
|
79
|
+
"compute url-maps invalidate-cdn-cache caelo-production-url-map-1a2b3c --path=/* --global --async --project=acme --quiet",
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("does nothing (no purge) when every SVG is already a download", async () => {
|
|
84
|
+
const { run, calls } = fakeGcloud({
|
|
85
|
+
"storage objects list": listing({ storage_url: URL_A, content_disposition: "attachment" }),
|
|
86
|
+
});
|
|
87
|
+
expect((await ensureSvgDownloads(gcp, run)).status).toBe("present");
|
|
88
|
+
expect(calls).toHaveLength(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("is present for an install without any SVG", async () => {
|
|
92
|
+
const { run } = fakeGcloud({
|
|
93
|
+
"storage objects list": fail(
|
|
94
|
+
"ERROR: (gcloud.storage.objects.list) One or more URLs matched no objects.",
|
|
95
|
+
),
|
|
96
|
+
});
|
|
97
|
+
expect((await ensureSvgDownloads(gcp, run)).status).toBe("present");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("patches in batches so no argv grows with the site", async () => {
|
|
101
|
+
const many = Array.from({ length: 250 }, (_, i) => ({
|
|
102
|
+
storage_url: `gs://acme-caelo-production-static/_assets/i${i}.svg`,
|
|
103
|
+
}));
|
|
104
|
+
const { run, calls } = fakeGcloud({
|
|
105
|
+
"storage objects list": ok(JSON.stringify(many)),
|
|
106
|
+
"compute url-maps list": ok("caelo-production-url-map-1\n"),
|
|
107
|
+
});
|
|
108
|
+
expect((await ensureSvgDownloads(gcp, run)).status).toBe("applied");
|
|
109
|
+
const updates = calls.filter((c) => c.startsWith("storage objects update"));
|
|
110
|
+
expect(updates).toHaveLength(3);
|
|
111
|
+
expect(updates[0]?.split(" gs://")).toHaveLength(101);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("warns with the manual next step when the patch or the purge fails", async () => {
|
|
115
|
+
const patchFails = fakeGcloud({
|
|
116
|
+
"storage objects list": listing({ storage_url: URL_A }),
|
|
117
|
+
"storage objects update": fail("PERMISSION_DENIED"),
|
|
118
|
+
});
|
|
119
|
+
const patched = await ensureSvgDownloads(gcp, patchFails.run);
|
|
120
|
+
expect(patched).toMatchObject({ status: "failed", onFailure: "warn" });
|
|
121
|
+
expect(patched.error).toContain("PERMISSION_DENIED");
|
|
122
|
+
expect(patchFails.calls.some((c) => c.includes("invalidate-cdn-cache"))).toBe(false);
|
|
123
|
+
|
|
124
|
+
const purgeFails = fakeGcloud({
|
|
125
|
+
"storage objects list": listing({ storage_url: URL_A }),
|
|
126
|
+
"compute url-maps list": ok("caelo-production-url-map-1\n"),
|
|
127
|
+
"compute url-maps invalidate-cdn-cache": fail("rate limited"),
|
|
128
|
+
});
|
|
129
|
+
const purged = await ensureSvgDownloads(gcp, purgeFails.run);
|
|
130
|
+
expect(purged.status).toBe("failed");
|
|
131
|
+
expect(purged.error).toContain("invalidate-cdn-cache");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #891 — backfill of the `gcp` stack's live SVG objects, run by the converge
|
|
5
|
+
* (`ensureStackInvariants`, stack-converge.ts) on every upgrade.
|
|
6
|
+
*
|
|
7
|
+
* On GCS the publisher stores every SVG with `Content-Disposition:
|
|
8
|
+
* attachment` (admin-core `gcsObjectMetadata` says why that, not a CSP), but
|
|
9
|
+
* the hash-skip never rewrites an unchanged object, and Cloud CDN keeps the
|
|
10
|
+
* responses it cached for up to a year (`STATIC_CDN_POLICY.maxTtl`). So the
|
|
11
|
+
* converge patches every live SVG that lacks the header and, when it patched
|
|
12
|
+
* any, invalidates the CDN cache once so the edge stops serving the old
|
|
13
|
+
* responses. A second run finds nothing to patch and purges nothing.
|
|
14
|
+
*
|
|
15
|
+
* Failures warn instead of aborting the upgrade: the publisher's
|
|
16
|
+
* CACHE_POLICY_VERSION bump rewrites every object on the next Publish live
|
|
17
|
+
* anyway, and the report names what is left to do.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { GcloudRunner } from "./gcloud-retry.js";
|
|
21
|
+
import { gcpBucketName, gcpNamePrefix } from "./gcp-names.js";
|
|
22
|
+
import type { InstallTarget, InvariantOutcome } from "./stack-converge.js";
|
|
23
|
+
|
|
24
|
+
/** `gcloud storage objects update` takes many URLs; keep each argv bounded. */
|
|
25
|
+
const UPDATE_BATCH = 100;
|
|
26
|
+
|
|
27
|
+
/** gcloud's answer when a wildcard matches nothing (an install with no SVGs). */
|
|
28
|
+
const NO_MATCH = /matched no objects|No URLs matched|One or more URLs matched no/i;
|
|
29
|
+
|
|
30
|
+
/** One object of `gcloud storage objects list --format=json` (standardised keys). */
|
|
31
|
+
interface ListedObject {
|
|
32
|
+
readonly storage_url?: unknown;
|
|
33
|
+
readonly content_disposition?: unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The `gs://…` URLs in `listJson` (gcloud's standardised object listing)
|
|
38
|
+
* that are not yet served as downloads.
|
|
39
|
+
*
|
|
40
|
+
* @throws when the output is not the JSON list gcloud prints.
|
|
41
|
+
*/
|
|
42
|
+
export function svgObjectsMissingDisposition(listJson: string): string[] {
|
|
43
|
+
const parsed = JSON.parse(listJson.trim() === "" ? "[]" : listJson) as unknown;
|
|
44
|
+
if (!Array.isArray(parsed)) throw new Error("the object listing is not a JSON list");
|
|
45
|
+
const out: string[] = [];
|
|
46
|
+
for (const o of parsed as ListedObject[]) {
|
|
47
|
+
if (typeof o.storage_url !== "string") {
|
|
48
|
+
throw new Error("an object in the listing has no storage_url");
|
|
49
|
+
}
|
|
50
|
+
if (o.content_disposition !== "attachment") out.push(o.storage_url);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Patch the live SVGs of a `gcp` install and purge the CDN when any changed. */
|
|
56
|
+
export async function ensureSvgDownloads(
|
|
57
|
+
install: InstallTarget,
|
|
58
|
+
run: GcloudRunner,
|
|
59
|
+
): Promise<InvariantOutcome> {
|
|
60
|
+
const bucket = gcpBucketName(install.projectId, install.env, "static");
|
|
61
|
+
const base = {
|
|
62
|
+
id: `SVGs in gs://${bucket} served as downloads`,
|
|
63
|
+
onFailure: "warn" as const,
|
|
64
|
+
why: "an uploaded SVG opened on the live site downloads instead of running script on the site's origin (#891)",
|
|
65
|
+
};
|
|
66
|
+
const project = `--project=${install.projectId}`;
|
|
67
|
+
const failed = (error: string): InvariantOutcome => ({
|
|
68
|
+
...base,
|
|
69
|
+
status: "failed",
|
|
70
|
+
error: `${error}\n The next Publish live rewrites every SVG with the header; purge the CDN afterwards (gcloud compute url-maps invalidate-cdn-cache <url-map> --path="/*" --global).`,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const list = await run([
|
|
74
|
+
"storage",
|
|
75
|
+
"objects",
|
|
76
|
+
"list",
|
|
77
|
+
`gs://${bucket}/**.svg`,
|
|
78
|
+
project,
|
|
79
|
+
"--format=json",
|
|
80
|
+
]);
|
|
81
|
+
if (!list.ok) {
|
|
82
|
+
return NO_MATCH.test(list.stderr)
|
|
83
|
+
? { ...base, status: "present" }
|
|
84
|
+
: failed(`list SVG objects: ${list.stderr.trim()}`);
|
|
85
|
+
}
|
|
86
|
+
let pending: string[];
|
|
87
|
+
try {
|
|
88
|
+
pending = svgObjectsMissingDisposition(list.stdout);
|
|
89
|
+
} catch (e) {
|
|
90
|
+
return failed(`list SVG objects: ${(e as Error).message}`);
|
|
91
|
+
}
|
|
92
|
+
if (pending.length === 0) return { ...base, status: "present" };
|
|
93
|
+
|
|
94
|
+
for (let i = 0; i < pending.length; i += UPDATE_BATCH) {
|
|
95
|
+
const update = await run([
|
|
96
|
+
"storage",
|
|
97
|
+
"objects",
|
|
98
|
+
"update",
|
|
99
|
+
...pending.slice(i, i + UPDATE_BATCH),
|
|
100
|
+
"--content-disposition=attachment",
|
|
101
|
+
project,
|
|
102
|
+
]);
|
|
103
|
+
if (!update.ok) return failed(`set Content-Disposition: ${update.stderr.trim()}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The edge still holds the old responses. One site-wide purge: SVGs sit
|
|
107
|
+
// under many paths, and this only runs when objects were just patched.
|
|
108
|
+
const maps = await run([
|
|
109
|
+
"compute",
|
|
110
|
+
"url-maps",
|
|
111
|
+
"list",
|
|
112
|
+
"--global",
|
|
113
|
+
project,
|
|
114
|
+
`--filter=name~^${gcpNamePrefix(install.env)}-url-map`,
|
|
115
|
+
"--format=value(name)",
|
|
116
|
+
]);
|
|
117
|
+
const urlMap = maps.ok ? maps.stdout.trim().split("\n")[0]?.trim() : "";
|
|
118
|
+
if (!urlMap) {
|
|
119
|
+
return failed(
|
|
120
|
+
`patched ${pending.length} SVG(s), but found no URL map to purge: ${maps.ok ? "none listed" : maps.stderr.trim()}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const purge = await run([
|
|
124
|
+
"compute",
|
|
125
|
+
"url-maps",
|
|
126
|
+
"invalidate-cdn-cache",
|
|
127
|
+
urlMap,
|
|
128
|
+
"--path=/*",
|
|
129
|
+
"--global",
|
|
130
|
+
"--async",
|
|
131
|
+
project,
|
|
132
|
+
"--quiet",
|
|
133
|
+
]);
|
|
134
|
+
if (!purge.ok) {
|
|
135
|
+
return failed(
|
|
136
|
+
`patched ${pending.length} SVG(s), but the CDN purge failed: ${purge.stderr.trim()}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return { ...base, status: "applied" };
|
|
140
|
+
}
|