@caelo-cms/provisioning 0.10.61 → 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.
Files changed (281) hide show
  1. package/caddy/Caddyfile.production +7 -0
  2. package/caddy/Caddyfile.staging +7 -0
  3. package/dist/admin-internal-answer.d.ts +12 -0
  4. package/dist/admin-internal-answer.d.ts.map +1 -0
  5. package/dist/admin-internal-answer.js +32 -0
  6. package/dist/admin-internal-answer.js.map +1 -0
  7. package/dist/admin-internal-google.d.ts +4 -0
  8. package/dist/admin-internal-google.d.ts.map +1 -0
  9. package/dist/admin-internal-google.js +161 -0
  10. package/dist/admin-internal-google.js.map +1 -0
  11. package/dist/admin-internal-self-hosted.d.ts +30 -0
  12. package/dist/admin-internal-self-hosted.d.ts.map +1 -0
  13. package/dist/admin-internal-self-hosted.js +92 -0
  14. package/dist/admin-internal-self-hosted.js.map +1 -0
  15. package/dist/admin-internal.d.ts +33 -12
  16. package/dist/admin-internal.d.ts.map +1 -1
  17. package/dist/admin-internal.js +27 -159
  18. package/dist/admin-internal.js.map +1 -1
  19. package/dist/agent/command.d.ts +7 -0
  20. package/dist/agent/command.d.ts.map +1 -1
  21. package/dist/agent/command.js +1 -0
  22. package/dist/agent/command.js.map +1 -1
  23. package/dist/agent/lock.d.ts +13 -1
  24. package/dist/agent/lock.d.ts.map +1 -1
  25. package/dist/agent/lock.js +62 -5
  26. package/dist/agent/lock.js.map +1 -1
  27. package/dist/agent/run.d.ts +5 -1
  28. package/dist/agent/run.d.ts.map +1 -1
  29. package/dist/agent/run.js +8 -1
  30. package/dist/agent/run.js.map +1 -1
  31. package/dist/agent-db.js +192 -90
  32. package/dist/caddy.d.ts.map +1 -1
  33. package/dist/caddy.js +11 -1
  34. package/dist/caddy.js.map +1 -1
  35. package/dist/cli-routing.js +1 -1
  36. package/dist/cli-routing.js.map +1 -1
  37. package/dist/cli.js +95 -38
  38. package/dist/cli.js.map +1 -1
  39. package/dist/compose.d.ts +7 -0
  40. package/dist/compose.d.ts.map +1 -1
  41. package/dist/compose.js +5 -0
  42. package/dist/compose.js.map +1 -1
  43. package/dist/converge-job-gcp.d.ts.map +1 -1
  44. package/dist/converge-job-gcp.js +10 -7
  45. package/dist/converge-job-gcp.js.map +1 -1
  46. package/dist/converge-job-preflight.d.ts +21 -0
  47. package/dist/converge-job-preflight.d.ts.map +1 -0
  48. package/dist/converge-job-preflight.js +111 -0
  49. package/dist/converge-job-preflight.js.map +1 -0
  50. package/dist/converge-job.d.ts +8 -2
  51. package/dist/converge-job.d.ts.map +1 -1
  52. package/dist/converge-job.js +14 -0
  53. package/dist/converge-job.js.map +1 -1
  54. package/dist/gateway-credentials.d.ts +5 -3
  55. package/dist/gateway-credentials.d.ts.map +1 -1
  56. package/dist/gateway-credentials.js +5 -8
  57. package/dist/gateway-credentials.js.map +1 -1
  58. package/dist/gcloud.d.ts +29 -0
  59. package/dist/gcloud.d.ts.map +1 -1
  60. package/dist/gcloud.js +66 -0
  61. package/dist/gcloud.js.map +1 -1
  62. package/dist/infra-contract.d.ts +1 -1
  63. package/dist/infra-contract.d.ts.map +1 -1
  64. package/dist/infra-contract.js +10 -1
  65. package/dist/infra-contract.js.map +1 -1
  66. package/dist/install-job.d.ts +3 -1
  67. package/dist/install-job.d.ts.map +1 -1
  68. package/dist/install-job.js.map +1 -1
  69. package/dist/install-state.d.ts +10 -0
  70. package/dist/install-state.d.ts.map +1 -1
  71. package/dist/install-state.js +12 -0
  72. package/dist/install-state.js.map +1 -1
  73. package/dist/lifecycle.d.ts +12 -0
  74. package/dist/lifecycle.d.ts.map +1 -1
  75. package/dist/lifecycle.js +113 -28
  76. package/dist/lifecycle.js.map +1 -1
  77. package/dist/mcp-iap.d.ts +2 -0
  78. package/dist/mcp-iap.d.ts.map +1 -1
  79. package/dist/mcp-iap.js +50 -17
  80. package/dist/mcp-iap.js.map +1 -1
  81. package/dist/owner-reset.d.ts +16 -19
  82. package/dist/owner-reset.d.ts.map +1 -1
  83. package/dist/owner-reset.js +92 -19
  84. package/dist/owner-reset.js.map +1 -1
  85. package/dist/preview-plan.d.ts +71 -0
  86. package/dist/preview-plan.d.ts.map +1 -0
  87. package/dist/preview-plan.js +168 -0
  88. package/dist/preview-plan.js.map +1 -0
  89. package/dist/public-role-isolation.d.ts +2 -0
  90. package/dist/public-role-isolation.d.ts.map +1 -1
  91. package/dist/public-role-isolation.js +9 -3
  92. package/dist/public-role-isolation.js.map +1 -1
  93. package/dist/release-contract.d.ts.map +1 -1
  94. package/dist/release-contract.js +4 -0
  95. package/dist/release-contract.js.map +1 -1
  96. package/dist/self-hosted-internal-secret.d.ts +27 -0
  97. package/dist/self-hosted-internal-secret.d.ts.map +1 -0
  98. package/dist/self-hosted-internal-secret.js +69 -0
  99. package/dist/self-hosted-internal-secret.js.map +1 -0
  100. package/dist/self-hosted-release.d.ts +3 -1
  101. package/dist/self-hosted-release.d.ts.map +1 -1
  102. package/dist/self-hosted-release.js +6 -2
  103. package/dist/self-hosted-release.js.map +1 -1
  104. package/dist/self-hosted-upgrade.d.ts +13 -1
  105. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  106. package/dist/self-hosted-upgrade.js +26 -2
  107. package/dist/self-hosted-upgrade.js.map +1 -1
  108. package/dist/stack-converge.d.ts +5 -2
  109. package/dist/stack-converge.d.ts.map +1 -1
  110. package/dist/stack-converge.js +73 -8
  111. package/dist/stack-converge.js.map +1 -1
  112. package/dist/staging-admin.d.ts +4 -1
  113. package/dist/staging-admin.d.ts.map +1 -1
  114. package/dist/staging-admin.js.map +1 -1
  115. package/dist/state-backend.d.ts +3 -2
  116. package/dist/state-backend.d.ts.map +1 -1
  117. package/dist/state-backend.js +3 -2
  118. package/dist/state-backend.js.map +1 -1
  119. package/dist/state-command.d.ts +17 -0
  120. package/dist/state-command.d.ts.map +1 -1
  121. package/dist/state-command.js +34 -5
  122. package/dist/state-command.js.map +1 -1
  123. package/dist/state-migrate.d.ts +14 -3
  124. package/dist/state-migrate.d.ts.map +1 -1
  125. package/dist/state-migrate.js +31 -7
  126. package/dist/state-migrate.js.map +1 -1
  127. package/dist/state-pulumi.d.ts +0 -2
  128. package/dist/state-pulumi.d.ts.map +1 -1
  129. package/dist/state-pulumi.js +19 -20
  130. package/dist/state-pulumi.js.map +1 -1
  131. package/dist/static-security-headers.d.ts +86 -0
  132. package/dist/static-security-headers.d.ts.map +1 -0
  133. package/dist/static-security-headers.js +126 -0
  134. package/dist/static-security-headers.js.map +1 -0
  135. package/dist/static-svg-gcp.d.ts +28 -0
  136. package/dist/static-svg-gcp.d.ts.map +1 -0
  137. package/dist/static-svg-gcp.js +106 -0
  138. package/dist/static-svg-gcp.js.map +1 -0
  139. package/dist/upgrade/app-roll.d.ts +3 -0
  140. package/dist/upgrade/app-roll.d.ts.map +1 -1
  141. package/dist/upgrade/app-roll.js +12 -5
  142. package/dist/upgrade/app-roll.js.map +1 -1
  143. package/dist/upgrade/backup.d.ts +2 -0
  144. package/dist/upgrade/backup.d.ts.map +1 -1
  145. package/dist/upgrade/backup.js +5 -4
  146. package/dist/upgrade/backup.js.map +1 -1
  147. package/dist/upgrade/converge.d.ts +7 -0
  148. package/dist/upgrade/converge.d.ts.map +1 -1
  149. package/dist/upgrade/converge.js +19 -7
  150. package/dist/upgrade/converge.js.map +1 -1
  151. package/dist/upgrade/gcp-lookup.d.ts +38 -3
  152. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  153. package/dist/upgrade/gcp-lookup.js +31 -9
  154. package/dist/upgrade/gcp-lookup.js.map +1 -1
  155. package/dist/upgrade/health.d.ts +31 -5
  156. package/dist/upgrade/health.d.ts.map +1 -1
  157. package/dist/upgrade/health.js +114 -25
  158. package/dist/upgrade/health.js.map +1 -1
  159. package/dist/upgrade/release-images.d.ts +17 -0
  160. package/dist/upgrade/release-images.d.ts.map +1 -1
  161. package/dist/upgrade/release-images.js +23 -12
  162. package/dist/upgrade/release-images.js.map +1 -1
  163. package/dist/upgrade/reporter.d.ts +2 -0
  164. package/dist/upgrade/reporter.d.ts.map +1 -1
  165. package/dist/upgrade/reporter.js +1 -0
  166. package/dist/upgrade/reporter.js.map +1 -1
  167. package/dist/upgrade/upgrade.d.ts +11 -1
  168. package/dist/upgrade/upgrade.d.ts.map +1 -1
  169. package/dist/upgrade/upgrade.js +5 -2
  170. package/dist/upgrade/upgrade.js.map +1 -1
  171. package/dist/upgrader-gcp.d.ts +12 -2
  172. package/dist/upgrader-gcp.d.ts.map +1 -1
  173. package/dist/upgrader-gcp.js +22 -13
  174. package/dist/upgrader-gcp.js.map +1 -1
  175. package/dist/upgrader-iam-preflight.d.ts +16 -3
  176. package/dist/upgrader-iam-preflight.d.ts.map +1 -1
  177. package/dist/upgrader-iam-preflight.js +9 -4
  178. package/dist/upgrader-iam-preflight.js.map +1 -1
  179. package/dist/upgrader-job.d.ts.map +1 -1
  180. package/dist/upgrader-job.js +10 -8
  181. package/dist/upgrader-job.js.map +1 -1
  182. package/dist/upgrader-reporter.d.ts.map +1 -1
  183. package/dist/upgrader-reporter.js +1 -0
  184. package/dist/upgrader-reporter.js.map +1 -1
  185. package/dist/wizards/gcp-pulumi.d.ts +70 -23
  186. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  187. package/dist/wizards/gcp-pulumi.js +118 -39
  188. package/dist/wizards/gcp-pulumi.js.map +1 -1
  189. package/dist/wizards/gcp.d.ts.map +1 -1
  190. package/dist/wizards/gcp.js +67 -11
  191. package/dist/wizards/gcp.js.map +1 -1
  192. package/package.json +2 -2
  193. package/src/admin-internal-answer.ts +43 -0
  194. package/src/admin-internal-google.ts +187 -0
  195. package/src/admin-internal-self-hosted.test.ts +70 -0
  196. package/src/admin-internal-self-hosted.ts +128 -0
  197. package/src/admin-internal.ts +59 -174
  198. package/src/admin-memory.test.ts +1 -0
  199. package/src/agent/command.ts +3 -0
  200. package/src/agent/lock-config.test.ts +57 -2
  201. package/src/agent/lock.ts +68 -5
  202. package/src/agent/run.test.ts +18 -1
  203. package/src/agent/run.ts +17 -6
  204. package/src/caddy-redirects.test.ts +1 -0
  205. package/src/caddy.ts +12 -0
  206. package/src/cli-routing.ts +1 -1
  207. package/src/cli.ts +102 -44
  208. package/src/compose.ts +12 -0
  209. package/src/converge-job-gcp.ts +16 -11
  210. package/src/converge-job-preflight.test.ts +108 -0
  211. package/src/converge-job-preflight.ts +124 -0
  212. package/src/converge-job.test.ts +50 -0
  213. package/src/converge-job.ts +38 -2
  214. package/src/gateway-credentials.test.ts +4 -3
  215. package/src/gateway-credentials.ts +6 -10
  216. package/src/gateway-env-isolation.test.ts +1 -0
  217. package/src/gcloud.test.ts +93 -1
  218. package/src/gcloud.ts +82 -0
  219. package/src/index.test.ts +38 -1
  220. package/src/infra-contract.test.ts +4 -3
  221. package/src/infra-contract.ts +10 -1
  222. package/src/install-job.ts +5 -2
  223. package/src/install-state.ts +13 -0
  224. package/src/lifecycle.ts +134 -28
  225. package/src/mcp-iap.test.ts +66 -5
  226. package/src/mcp-iap.ts +55 -17
  227. package/src/owner-reset-providers.test.ts +370 -0
  228. package/src/owner-reset.test.ts +2 -2
  229. package/src/owner-reset.ts +108 -19
  230. package/src/preview-plan.test.ts +233 -0
  231. package/src/preview-plan.ts +226 -0
  232. package/src/public-role-isolation.test.ts +13 -2
  233. package/src/public-role-isolation.ts +14 -4
  234. package/src/release-contract.ts +4 -0
  235. package/src/self-hosted-compose.test.ts +25 -0
  236. package/src/self-hosted-internal-secret.test.ts +61 -0
  237. package/src/self-hosted-internal-secret.ts +80 -0
  238. package/src/self-hosted-release.ts +10 -3
  239. package/src/self-hosted-upgrade.test.ts +35 -1
  240. package/src/self-hosted-upgrade.ts +40 -3
  241. package/src/stack-contract.test.ts +1 -0
  242. package/src/stack-converge.test.ts +89 -5
  243. package/src/stack-converge.ts +81 -8
  244. package/src/staging-admin.test.ts +1 -1
  245. package/src/staging-admin.ts +2 -1
  246. package/src/state-backend.ts +3 -2
  247. package/src/state-command.test.ts +31 -0
  248. package/src/state-command.ts +35 -8
  249. package/src/state-migrate.test.ts +58 -7
  250. package/src/state-migrate.ts +48 -10
  251. package/src/state-pulumi.ts +20 -21
  252. package/src/static-security-headers.test.ts +124 -0
  253. package/src/static-security-headers.ts +154 -0
  254. package/src/static-svg-gcp.test.ts +133 -0
  255. package/src/static-svg-gcp.ts +140 -0
  256. package/src/upgrade/app-roll.ts +18 -7
  257. package/src/upgrade/backup.ts +14 -5
  258. package/src/upgrade/converge.ts +25 -7
  259. package/src/upgrade/gcp-lookup.test.ts +80 -1
  260. package/src/upgrade/gcp-lookup.ts +61 -11
  261. package/src/upgrade/health.test.ts +132 -8
  262. package/src/upgrade/health.ts +169 -31
  263. package/src/upgrade/release-images.test.ts +17 -0
  264. package/src/upgrade/release-images.ts +26 -14
  265. package/src/upgrade/reporter.ts +3 -0
  266. package/src/upgrade/upgrade.test.ts +137 -8
  267. package/src/upgrade/upgrade.ts +14 -2
  268. package/src/upgrader-gcp.ts +28 -13
  269. package/src/upgrader-iam-preflight.test.ts +6 -3
  270. package/src/upgrader-iam-preflight.ts +26 -5
  271. package/src/upgrader-job.ts +14 -6
  272. package/src/upgrader-reporter.ts +1 -0
  273. package/src/wizards/gcp-pulumi.test.ts +1 -1
  274. package/src/wizards/gcp-pulumi.ts +147 -38
  275. package/src/wizards/gcp.ts +89 -9
  276. package/src/wizards/stack-work-dir.test.ts +175 -0
  277. package/stacks/aws/index.ts +40 -0
  278. package/stacks/azure/index.ts +22 -1
  279. package/stacks/gcp/README.md +4 -0
  280. package/stacks/gcp/index.ts +6 -2
  281. package/stacks/gcp/Pulumi.production.yaml +0 -16
@@ -59,6 +59,7 @@ import {
59
59
  linkBilling,
60
60
  listBillingAccounts,
61
61
  PROVISIONER_ROLE_LIST,
62
+ projectDisplayName,
62
63
  projectExists,
63
64
  REQUIRED_API_LIST,
64
65
  serviceAccountExists,
@@ -95,6 +96,7 @@ import {
95
96
  resolveOperatorAccessTarget,
96
97
  } from "../operator-access.js";
97
98
  import { ensureOpsBackend } from "../ops-backend.js";
99
+ import { formatPlan, type PlannedStep, planGate, writePlanFile } from "../preview-plan.js";
98
100
  import {
99
101
  convergeKeylessProvisioner,
100
102
  PROVISIONER_ACCOUNT_ID,
@@ -117,7 +119,7 @@ import {
117
119
  resolveBackend,
118
120
  } from "../state-backend.js";
119
121
  import { acquireStateLock, GcsStateStore } from "../state-store.js";
120
- import { resolveGcpResourceName } from "../upgrade/gcp-lookup.js";
122
+ import { lookupFailure, resolveGcpResourceName } from "../upgrade/gcp-lookup.js";
121
123
  import {
122
124
  releaseVersionForDigest,
123
125
  resolveOpsImageRef,
@@ -127,7 +129,12 @@ import { ensureUpgraderJob, resolveUpgraderTarget } from "../upgrader-setup.js";
127
129
  import { ensureUrlMapRedirectsAccess } from "../url-map-redirects-access.js";
128
130
  import { estimateGcpCost } from "./gcp-cost.js";
129
131
  import { promptProjectId } from "./gcp-prompts.js";
130
- import { GCP_STACK_KNOB_DEFAULTS, pulumiUpGcp, readStackOutput } from "./gcp-pulumi.js";
132
+ import {
133
+ GCP_STACK_KNOB_DEFAULTS,
134
+ PulumiUpDeclinedError,
135
+ pulumiUpGcp,
136
+ readStackOutput,
137
+ } from "./gcp-pulumi.js";
131
138
 
132
139
  export interface GcpWizardOpts {
133
140
  installId: string;
@@ -177,7 +184,7 @@ export async function runGcpWizard(opts: GcpWizardOpts): Promise<void> {
177
184
  if (meta) writeMetadata(installId, { ...meta, projectId, region });
178
185
 
179
186
  // === 3. Project create ===
180
- await stepProjectCreate(installId, projectId);
187
+ await stepProjectCreate(installId, projectId, opts.domain);
181
188
 
182
189
  // === 4. Billing link ===
183
190
  await stepBillingLink(installId, projectId, opts.nonInteractive);
@@ -396,6 +403,7 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
396
403
  // v0.3.1 — route pulumi up at the right stack folder + use the
397
404
  // matching config namespace.
398
405
  provider,
406
+ nonInteractive: opts.nonInteractive,
399
407
  });
400
408
  recordImageDigests(installId, imageDigests);
401
409
 
@@ -692,7 +700,11 @@ async function stepRegion(
692
700
  return choice as string;
693
701
  }
694
702
 
695
- async function stepProjectCreate(installId: string, projectId: string): Promise<void> {
703
+ async function stepProjectCreate(
704
+ installId: string,
705
+ projectId: string,
706
+ domain: string,
707
+ ): Promise<void> {
696
708
  const stepName = `project-create-${projectId}`;
697
709
  if (isStepDone(installId, stepName)) {
698
710
  log.success(`Project ${bold(projectId)} ${dim("(already created)")}`);
@@ -705,7 +717,7 @@ async function stepProjectCreate(installId: string, projectId: string): Promise<
705
717
  }
706
718
  const s = spinner();
707
719
  s.start(`Creating GCP project ${projectId}...`);
708
- const r = await createProject(projectId, "Caelo CMS");
720
+ const r = await createProject(projectId, projectDisplayName(domain));
709
721
  if (!r.ok) {
710
722
  s.stop(red(`Failed: ${r.stderr.trim()}`));
711
723
  log.error(
@@ -917,7 +929,11 @@ async function stepGrantBaseRoles(
917
929
  async function stepPublicRoleIsolation(projectId: string): Promise<void> {
918
930
  const s = spinner();
919
931
  s.start("Removing public_role from the Cloud SQL superuser group...");
920
- const r = await revokePublicRoleMemberships({ projectId, env: GCP_STACK_ENV });
932
+ const r = await revokePublicRoleMemberships({
933
+ projectId,
934
+ env: GCP_STACK_ENV,
935
+ credentials: "operator",
936
+ });
921
937
  if (r.status === "failed") {
922
938
  s.stop(red(`Failed: ${r.error ?? ""}`));
923
939
  log.error("Re-run the wizard once the cause is fixed (this step is idempotent).");
@@ -1161,7 +1177,7 @@ async function stepRemoteMcp(
1161
1177
  projectId,
1162
1178
  region,
1163
1179
  );
1164
- const [admin, gateway] = [await name("admin"), await name("gateway")];
1180
+ const [adminLookup, gatewayLookup] = [await name("admin"), await name("gateway")];
1165
1181
  const failed = (error: string) => {
1166
1182
  s.stop(yellow(`Remote MCP not prepared: ${error}`));
1167
1183
  log.warn(
@@ -1169,7 +1185,17 @@ async function stepRemoteMcp(
1169
1185
  );
1170
1186
  return false;
1171
1187
  };
1172
- if (!admin || !gateway) return failed("the admin or gateway Cloud Run service was not found");
1188
+ if (adminLookup.status !== "found") {
1189
+ return failed(
1190
+ lookupFailure(adminLookup, `caelo-${GCP_STACK_ENV}-admin* Cloud Run service`, "operator"),
1191
+ );
1192
+ }
1193
+ if (gatewayLookup.status !== "found") {
1194
+ return failed(
1195
+ lookupFailure(gatewayLookup, `caelo-${GCP_STACK_ENV}-gateway* Cloud Run service`, "operator"),
1196
+ );
1197
+ }
1198
+ const [admin, gateway] = [adminLookup.name, gatewayLookup.name];
1173
1199
  const edge = await ensureRemoteMcpEdge({ provider, projectId, env: GCP_STACK_ENV, domain });
1174
1200
  if (!edge.ok) return failed(edge.error);
1175
1201
  const env = await ensureRemoteMcpIdentityEnv({
@@ -1436,6 +1462,50 @@ interface PulumiUpOpts {
1436
1462
  imageDigests: ImageDigests;
1437
1463
  /** v0.3.1 — provider variant; routes pulumi up at the right stack. */
1438
1464
  provider?: "gcp" | "gcp-firebase";
1465
+ /** No terminal to ask (the install job, `--non-interactive`): see planGate. */
1466
+ nonInteractive: boolean;
1467
+ }
1468
+
1469
+ /**
1470
+ * The installer's review of a deployed stack's plan before `pulumi up`
1471
+ * (#737): the grouped list, the full diff in the install dir, and a y/N
1472
+ * for any replace or delete. A data-bearing one is asked about by name and
1473
+ * defaults to No; without a terminal it is refused (planGate).
1474
+ *
1475
+ * @returns whether `pulumi up` may apply the plan
1476
+ */
1477
+ async function reviewPulumiPlan(
1478
+ installId: string,
1479
+ steps: readonly PlannedStep[],
1480
+ nonInteractive: boolean,
1481
+ ): Promise<boolean> {
1482
+ const gate = planGate(steps, { interactive: !nonInteractive && process.stdin.isTTY === true });
1483
+ if (steps.length === 0) {
1484
+ log.info(dim("Preview: no changes planned."));
1485
+ return true;
1486
+ }
1487
+ const file = writePlanFile(installRoot(installId), "installer", steps);
1488
+ note([...formatPlan(steps), "", `Full diff: ${file}`].join("\n"), "pulumi up will change");
1489
+ if (gate.kind === "proceed") return true;
1490
+ if (gate.kind === "refuse") {
1491
+ log.error(
1492
+ red(
1493
+ `Nothing was applied: this plan replaces or deletes resources that hold the site's data, and an unattended run cannot confirm that. Read ${file}; if the change is intended, re-run the installer in a terminal and confirm it there.`,
1494
+ ),
1495
+ );
1496
+ return false;
1497
+ }
1498
+ const answer = await confirm({
1499
+ message: gate.losesData
1500
+ ? red("This plan DESTROYS the data named above. Apply it anyway?")
1501
+ : "This plan replaces or deletes the resources listed above. Apply it?",
1502
+ initialValue: false,
1503
+ });
1504
+ if (isCancel(answer) || answer !== true) {
1505
+ log.warn(`Nothing was applied. The plan is in ${file}.`);
1506
+ return false;
1507
+ }
1508
+ return true;
1439
1509
  }
1440
1510
 
1441
1511
  async function stepPulumiUp(installId: string, opts: PulumiUpOpts): Promise<void> {
@@ -1488,7 +1558,8 @@ async function stepPulumiUp(installId: string, opts: PulumiUpOpts): Promise<void
1488
1558
  log.info(`Pulumi up — wall-clock 8–15 min (Cloud SQL is the long pole). Streaming progress...`);
1489
1559
 
1490
1560
  let resourceCount = 0;
1491
- let result: Awaited<ReturnType<typeof pulumiUpGcp>>;
1561
+ let result: Awaited<ReturnType<typeof pulumiUpGcp>> | undefined;
1562
+ let declined = "";
1492
1563
  try {
1493
1564
  result = await pulumiUpGcp(
1494
1565
  {
@@ -1514,10 +1585,18 @@ async function stepPulumiUp(installId: string, opts: PulumiUpOpts): Promise<void
1514
1585
  // logs: silent — too noisy for a live progress UI; pulumi
1515
1586
  // diagnostics will print on failure via the SDK's onOutput.
1516
1587
  },
1588
+ (steps) => reviewPulumiPlan(installId, steps, opts.nonInteractive),
1517
1589
  );
1590
+ } catch (e) {
1591
+ if (!(e instanceof PulumiUpDeclinedError)) throw e;
1592
+ declined = e.message;
1518
1593
  } finally {
1519
1594
  releaseLock();
1520
1595
  }
1596
+ if (result === undefined) {
1597
+ cancel(declined);
1598
+ process.exit(1);
1599
+ }
1521
1600
  process.stdout.write("\n");
1522
1601
  log.success(
1523
1602
  green(
@@ -1915,6 +1994,7 @@ async function readBootstrapUrl(installId: string, provider: string): Promise<st
1915
1994
  const meta = readMetadata(installId);
1916
1995
  if (!meta) throw new Error(`No install.json for install '${installId}'.`);
1917
1996
  const value = await readStackOutput({
1997
+ installRoot: installRoot(installId),
1918
1998
  backend: resolveBackend(meta, {
1919
1999
  installRoot: installRoot(installId),
1920
2000
  readPassphrase: () => readSecret(installId, "pulumi-passphrase"),
@@ -0,0 +1,175 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #736 — every install runs Pulumi in its own work dir, so two CLI runs for
5
+ * different installs of one provider never read each other's stack config.
6
+ * The last test drives the real `pulumi` CLI (skipped where it is not
7
+ * installed, as in CI): two installs set their config and run `up` at the
8
+ * same time, and each sees only its own.
9
+ */
10
+
11
+ import { afterAll, describe, expect, it } from "bun:test";
12
+ import {
13
+ mkdirSync,
14
+ mkdtempSync,
15
+ readdirSync,
16
+ readFileSync,
17
+ realpathSync,
18
+ rmSync,
19
+ statSync,
20
+ writeFileSync,
21
+ } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join, resolve } from "node:path";
24
+ import * as pulumi from "@pulumi/pulumi/automation";
25
+ import { stackWorkDir } from "../install-state.js";
26
+ import {
27
+ assertStackProject,
28
+ packageStackDir,
29
+ prepareStackWorkDir,
30
+ workDirProjectFile,
31
+ } from "./gcp-pulumi.js";
32
+
33
+ const STACKS = resolve(import.meta.dir, "../../stacks");
34
+ const scratch = mkdtempSync(join(tmpdir(), "caelo-stack-work-dir-"));
35
+ afterAll(() => rmSync(scratch, { recursive: true, force: true }));
36
+
37
+ /** Every file under the package's stacks/ dir with its mtime. */
38
+ function packageStacksSnapshot(): Record<string, number> {
39
+ return Object.fromEntries(
40
+ readdirSync(STACKS, { recursive: true, withFileTypes: true })
41
+ .filter((e) => e.isFile())
42
+ .map((e) => {
43
+ const path = join(e.parentPath, e.name);
44
+ return [path, statSync(path).mtimeMs];
45
+ }),
46
+ );
47
+ }
48
+
49
+ describe("workDirProjectFile", () => {
50
+ it("points main from the work dir at the package's program, keeping the rest", () => {
51
+ const yaml = "name: caelo-gcp\nruntime:\n name: nodejs\nmain: ./index.ts\ndescription: x\n";
52
+ expect(workDirProjectFile(yaml, "/pkg/stacks/gcp", "/home/o/.caelo-a/stack/gcp")).toBe(
53
+ "name: caelo-gcp\nruntime:\n name: nodejs\nmain: ../../../../../pkg/stacks/gcp/index.ts\ndescription: x\n",
54
+ );
55
+ });
56
+
57
+ it("refuses a project file without exactly one main line", () => {
58
+ expect(() => workDirProjectFile("name: x\n", "/pkg", "/w")).toThrow(/exactly one/);
59
+ expect(() => workDirProjectFile("main: a.ts\nmain: b.ts\n", "/pkg", "/w")).toThrow(/found 2/);
60
+ });
61
+ });
62
+
63
+ describe("prepareStackWorkDir", () => {
64
+ for (const provider of ["gcp", "gcp-firebase"] as const) {
65
+ it(`gives two ${provider} installs separate work dirs and never writes into the package`, () => {
66
+ const before = packageStacksSnapshot();
67
+ const a = prepareStackWorkDir(join(scratch, "a"), provider);
68
+ const b = prepareStackWorkDir(join(scratch, "b"), provider);
69
+ expect(a).toBe(stackWorkDir(join(scratch, "a"), provider));
70
+ expect(a).not.toBe(b);
71
+
72
+ // Each install keeps its own settings file; preparing again (a new
73
+ // CLI version) leaves it alone.
74
+ writeFileSync(join(a, "Pulumi.production.yaml"), "config:\n x:project: a\n");
75
+ writeFileSync(join(b, "Pulumi.production.yaml"), "config:\n x:project: b\n");
76
+ prepareStackWorkDir(join(scratch, "a"), provider);
77
+ expect(readFileSync(join(a, "Pulumi.production.yaml"), "utf8")).toContain("project: a");
78
+ expect(readFileSync(join(b, "Pulumi.production.yaml"), "utf8")).toContain("project: b");
79
+
80
+ const project = readFileSync(join(a, "Pulumi.yaml"), "utf8");
81
+ const main = /^main: (\S+)$/m.exec(project)?.[1] ?? "";
82
+ expect(resolve(realpathSync(a), main)).toBe(
83
+ join(realpathSync(packageStackDir(provider)), "index.ts"),
84
+ );
85
+ expect(packageStacksSnapshot()).toEqual(before);
86
+ });
87
+ }
88
+
89
+ it("the package ships no stack settings file (nothing for installs to share)", () => {
90
+ const settings = Object.keys(packageStacksSnapshot()).filter((p) =>
91
+ /Pulumi\.(?!yaml$)[^/]+\.ya?ml$/.test(p),
92
+ );
93
+ expect(settings).toEqual([]);
94
+ });
95
+ });
96
+
97
+ /** A stack whose config holds `project` (or nothing), for the guard. */
98
+ function stackWithProject(project: string | undefined): pulumi.Stack {
99
+ const fake: Pick<pulumi.Stack, "getConfig"> & { workspace: Pick<pulumi.Workspace, "workDir"> } = {
100
+ workspace: { workDir: "/home/o/.caelo-a/stack/gcp-firebase" },
101
+ getConfig: async (key) => {
102
+ if (project === undefined) throw new Error(`configuration key '${key}' not found`);
103
+ return { value: project, secret: false };
104
+ },
105
+ };
106
+ return fake as unknown as pulumi.Stack;
107
+ }
108
+
109
+ describe("assertStackProject", () => {
110
+ it("passes when the stack config names the install's project", async () => {
111
+ await assertStackProject(stackWithProject("acme"), "gcp-firebase", "acme");
112
+ });
113
+
114
+ it("refuses another install's project, naming both", async () => {
115
+ await expect(
116
+ assertStackProject(stackWithProject("searchviu-website"), "gcp-firebase", "caelo-site"),
117
+ ).rejects.toThrow(/names project searchviu-website, but this install is caelo-site/);
118
+ });
119
+
120
+ it("refuses a stack config without a project", async () => {
121
+ await expect(assertStackProject(stackWithProject(undefined), "gcp", "acme")).rejects.toThrow(
122
+ /names project \(none\)/,
123
+ );
124
+ });
125
+ });
126
+
127
+ describe.skipIf(!Bun.which("pulumi"))("two installs at once, real pulumi CLI", () => {
128
+ it("each install's up reads only its own config", async () => {
129
+ // A stand-in for the package's stack program; under the package so node
130
+ // resolves @pulumi/pulumi from it, as the real stacks do.
131
+ const pkg = mkdtempSync(join(import.meta.dir, "../../.stack-work-dir-test-"));
132
+ try {
133
+ const programDir = join(pkg, "stacks", "gcp-firebase");
134
+ mkdirSync(programDir, { recursive: true });
135
+ writeFileSync(
136
+ join(programDir, "Pulumi.yaml"),
137
+ "name: caelo-gcp-firebase\nruntime:\n name: nodejs\n options:\n typescript: true\nmain: ./index.ts\n",
138
+ );
139
+ writeFileSync(
140
+ join(programDir, "index.ts"),
141
+ 'import * as pulumi from "@pulumi/pulumi";\nexport const project = new pulumi.Config("caelo-gcp-firebase").require("project");\n',
142
+ );
143
+ writeFileSync(
144
+ join(programDir, "tsconfig.json"),
145
+ readFileSync(join(STACKS, "gcp-firebase", "tsconfig.json")),
146
+ );
147
+
148
+ const run = async (install: string, project: string) => {
149
+ const workDir = prepareStackWorkDir(join(scratch, install), "gcp-firebase", programDir);
150
+ const state = join(scratch, install, "state");
151
+ mkdirSync(state, { recursive: true });
152
+ const stack = await pulumi.LocalWorkspace.createOrSelectStack(
153
+ { stackName: "production", workDir },
154
+ {
155
+ envVars: {
156
+ PULUMI_BACKEND_URL: `file://${state}`,
157
+ PULUMI_CONFIG_PASSPHRASE: "test",
158
+ PULUMI_SKIP_UPDATE_CHECK: "true",
159
+ },
160
+ },
161
+ );
162
+ await stack.setConfig("caelo-gcp-firebase:project", { value: project });
163
+ await assertStackProject(stack, "gcp-firebase", project);
164
+ const up = await stack.up();
165
+ return up.outputs.project?.value;
166
+ };
167
+
168
+ const [a, b] = await Promise.all([run("real-a", "project-a"), run("real-b", "project-b")]);
169
+ expect([a, b]).toEqual(["project-a", "project-b"]);
170
+ expect(readdirSync(programDir).sort()).toEqual(["Pulumi.yaml", "index.ts", "tsconfig.json"]);
171
+ } finally {
172
+ rmSync(pkg, { recursive: true, force: true });
173
+ }
174
+ }, 180_000);
175
+ });
@@ -40,6 +40,10 @@ import {
40
40
  } from "../../dist/aws-task-roles.js";
41
41
  import { generateBootstrapToken } from "../../dist/bootstrap-token.js";
42
42
  import { ADMIN_MEMORY_DEFAULT, memoryQuantityMiB } from "../../dist/stack-contract.js";
43
+ import {
44
+ CLOUDFRONT_SVG_BEHAVIORS,
45
+ cloudFrontHeadersPolicy,
46
+ } from "../../dist/static-security-headers.js";
43
47
 
44
48
  const cfg = new pulumi.Config();
45
49
  const domain = cfg.require("domain");
@@ -245,6 +249,19 @@ const redirectFunction = new aws.cloudfront.Function(`${namePrefix}-redirects`,
245
249
  keyValueStoreAssociations: [redirectKvs.arn],
246
250
  });
247
251
 
252
+ // === 6c. Security response headers (#891) ===
253
+ // nosniff on every response; the sandboxing CSP on every SVG, so an
254
+ // uploaded SVG opened directly runs no script on the site's origin
255
+ // (src/static-security-headers.ts).
256
+ const siteHeadersPolicy = new aws.cloudfront.ResponseHeadersPolicy(`${namePrefix}-site-headers`, {
257
+ name: `${namePrefix}-site-headers`,
258
+ ...cloudFrontHeadersPolicy("site"),
259
+ });
260
+ const svgHeadersPolicy = new aws.cloudfront.ResponseHeadersPolicy(`${namePrefix}-svg-headers`, {
261
+ name: `${namePrefix}-svg-headers`,
262
+ ...cloudFrontHeadersPolicy("svg"),
263
+ });
264
+
248
265
  // === 7. CloudFront distribution ===
249
266
  const distribution = new aws.cloudfront.Distribution(`${namePrefix}-cdn`, {
250
267
  enabled: true,
@@ -273,8 +290,30 @@ const distribution = new aws.cloudfront.Distribution(`${namePrefix}-cdn`, {
273
290
  defaultTtl: 60,
274
291
  maxTtl: 86400,
275
292
  functionAssociations: [{ eventType: "viewer-request", functionArn: redirectFunction.arn }],
293
+ responseHeadersPolicyId: siteHeadersPolicy.id,
276
294
  },
277
295
  orderedCacheBehaviors: [
296
+ // #891 — SVGs first (first match wins), each like the behavior it
297
+ // shadows except for the CSP policy.
298
+ ...CLOUDFRONT_SVG_BEHAVIORS.map(({ pathPattern, targetOriginId }) => ({
299
+ pathPattern,
300
+ targetOriginId,
301
+ viewerProtocolPolicy: "redirect-to-https",
302
+ allowedMethods: ["GET", "HEAD"],
303
+ cachedMethods: ["GET", "HEAD"],
304
+ forwardedValues: { queryString: false, cookies: { forward: "none" } },
305
+ minTtl: 0,
306
+ ...(targetOriginId === "media"
307
+ ? { defaultTtl: 31536000, maxTtl: 31536000 }
308
+ : {
309
+ defaultTtl: 60,
310
+ maxTtl: 86400,
311
+ functionAssociations: [
312
+ { eventType: "viewer-request", functionArn: redirectFunction.arn },
313
+ ],
314
+ }),
315
+ responseHeadersPolicyId: svgHeadersPolicy.id,
316
+ })),
278
317
  {
279
318
  pathPattern: "/media/*",
280
319
  targetOriginId: "media",
@@ -285,6 +324,7 @@ const distribution = new aws.cloudfront.Distribution(`${namePrefix}-cdn`, {
285
324
  minTtl: 0,
286
325
  defaultTtl: 31536000,
287
326
  maxTtl: 31536000,
327
+ responseHeadersPolicyId: siteHeadersPolicy.id,
288
328
  },
289
329
  ],
290
330
  viewerCertificate: {
@@ -45,6 +45,10 @@ import {
45
45
  GATEWAY_TRUSTED_PROXY_HOPS,
46
46
  TRUSTED_PROXY_HOPS_ENV,
47
47
  } from "../../dist/stack-contract.js";
48
+ import {
49
+ FRONT_DOOR_SECURITY_RULE_SET,
50
+ frontDoorSecurityRules,
51
+ } from "../../dist/static-security-headers.js";
48
52
 
49
53
  const cfg = new pulumi.Config();
50
54
  const domain = cfg.require("domain");
@@ -281,6 +285,23 @@ const redirectRuleSet = new azure.cdn.RuleSet(`${namePrefix}-fd-redirects`, {
281
285
  ruleSetName: "caeloRedirects",
282
286
  });
283
287
 
288
+ // #891 — the security response headers: nosniff on every response, the
289
+ // sandboxing CSP on every SVG (src/static-security-headers.ts). A set of
290
+ // its own: the deploy replaces the redirects set's rules wholesale.
291
+ const securityRuleSet = new azure.cdn.RuleSet(`${namePrefix}-fd-security`, {
292
+ resourceGroupName: rg.name,
293
+ profileName: frontDoor.name,
294
+ ruleSetName: FRONT_DOOR_SECURITY_RULE_SET,
295
+ });
296
+ for (const rule of frontDoorSecurityRules()) {
297
+ new azure.cdn.Rule(`${namePrefix}-fd-security-${rule.ruleName}`, {
298
+ resourceGroupName: rg.name,
299
+ profileName: frontDoor.name,
300
+ ruleSetName: securityRuleSet.name,
301
+ ...rule,
302
+ });
303
+ }
304
+
284
305
  new azure.cdn.Route(
285
306
  `${namePrefix}-fd-route`,
286
307
  {
@@ -290,7 +311,7 @@ new azure.cdn.Route(
290
311
  routeName: "static",
291
312
  originGroup: { id: staticOriginGroup.id },
292
313
  customDomains: [{ id: siteDomain.id }],
293
- ruleSets: [{ id: redirectRuleSet.id }],
314
+ ruleSets: [{ id: redirectRuleSet.id }, { id: securityRuleSet.id }],
294
315
  patternsToMatch: ["/*"],
295
316
  // Without a cache configuration Front Door does not cache the route at
296
317
  // all; static files are edge-cached per their Cache-Control (§11.B
@@ -66,6 +66,10 @@ pulumi stack output bootstrapUrlOut
66
66
 
67
67
  Open after the domain's and the admin's DNS A records point at the load balancer IP and the managed certificate is active.
68
68
 
69
+ ## Uploaded SVGs download instead of opening
70
+
71
+ Opening an SVG's URL on the live site (for example `https://<domain>/_assets/logo.<hash>.svg`) downloads the file. It still renders normally in pages. The other providers send a sandboxing CSP on SVG responses. This stack cannot: GCS serves only a fixed set of metadata headers, the backend bucket's custom headers apply to every file including pages, and the URL map's site path matcher holds the redirect rules, which cannot coexist with per-extension route rules. So the publisher stores SVGs with `Content-Disposition: attachment`, and the backend bucket adds `X-Content-Type-Options: nosniff` to every response (#891). `cms-provision upgrade` patches the SVGs already live and purges Cloud CDN once. If the upgrade warns that this step failed, the next Publish live rewrites every SVG. After that, purge the CDN with `gcloud compute url-maps invalidate-cdn-cache <url-map> --path="/*" --global`.
72
+
69
73
  ## v1 limitations (P15 review-pass items)
70
74
 
71
75
  - **Container image build pipeline** — operators push via `gcloud builds submit` for v1. A unified `cms-provision build-images --provider gcp` lands later.
@@ -48,6 +48,7 @@ import {
48
48
  STATIC_CDN_POLICY,
49
49
  secretFileVolumesTemplate,
50
50
  } from "../../dist/stack-contract.js";
51
+ import { GCP_STATIC_RESPONSE_HEADERS } from "../../dist/static-security-headers.js";
51
52
 
52
53
  const cfg = new pulumi.Config();
53
54
  const domain = cfg.require("domain");
@@ -870,14 +871,17 @@ const wafPolicy = new gcp.compute.SecurityPolicy(
870
871
  );
871
872
 
872
873
  // Tier 1 backend — static GCS bucket via Cloud CDN. The CDN policy (one-year
873
- // client/edge TTL so immutable assets stay immutable, #555) is shared with
874
- // `cms-provision upgrade`, which applies it to existing installs.
874
+ // client/edge TTL so immutable assets stay immutable, #555) and the response
875
+ // headers (#891: nosniff on every file; SVGs are stored as downloads by the
876
+ // publisher, see static-security-headers.ts) are shared with
877
+ // `cms-provision upgrade`, which applies them to existing installs.
875
878
  const staticBackendBucket = new gcp.compute.BackendBucket(
876
879
  `${namePrefix}-static-backend`,
877
880
  {
878
881
  bucketName: staticBucket.name,
879
882
  enableCdn: true,
880
883
  cdnPolicy: { ...STATIC_CDN_POLICY },
884
+ customResponseHeaders: [...GCP_STATIC_RESPONSE_HEADERS],
881
885
  },
882
886
  opts,
883
887
  );
@@ -1,16 +0,0 @@
1
- encryptionsalt: v1:mEv+SGjVDdU=:v1:1Tmd1g2Yh0KXxhp5:fGsA3LiAPntjAGUMORKO+B1gxQgIEg==
2
- config:
3
- caelo-gcp:adminMinInstances: "0"
4
- caelo-gcp:anthropicApiKey:
5
- secure: v1:/RCE2zS+Y8ZrTdcj:gIXucRKkT1vX+qg0yE8Uq3oIL+lHa23wLOheAKeR762d97ydAo332XwfteqJOgDg/scAtD2dict1NoNxNgxXpJnw1jltUi8fsF9vMt9elh2aV71eFB2oRGBlxIWbAgYdXqs/gfMxRtZ8r0dhYADuSCrtTQcI7GAFWg5EhA==
6
- caelo-gcp:cloudSqlHa: "false"
7
- caelo-gcp:cloudSqlTier: db-f1-micro
8
- caelo-gcp:domain: caelo-cms.com
9
- caelo-gcp:gatewayMinInstances: "0"
10
- caelo-gcp:iapAllowlist: user:michael@searchviu.com
11
- caelo-gcp:ownerEmail: michael@searchviu.com
12
- caelo-gcp:project: caelo-website
13
- caelo-gcp:region: europe-west1
14
- caelo-gcp:wafAdaptiveProtection: "false"
15
- caelo-gcp:image-digest-admin: sha256:92fb8ee1267de781f37c664e34d299b2f29c6051a113fa1bdbd6fddb753ef81e
16
- caelo-gcp:image-digest-gateway: sha256:b4ceac2fd371be2850f7fc9549700fcd9e595eae6144f4a87b27192adf0cd9eb