@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.
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
package/src/gcloud.ts CHANGED
@@ -86,6 +86,71 @@ export async function activeAccount(
86
86
  return value.length > 0 ? value : null;
87
87
  }
88
88
 
89
+ /**
90
+ * gcloud's stderr when the stored credentials can no longer mint a token:
91
+ * an expired or revoked refresh token, a reauth policy that wants the
92
+ * password again, or a reauth prompt it may not show because prompts are off
93
+ * ({@link gcloudEnv}). Every one is fixed by logging in again, not by
94
+ * retrying (#630).
95
+ */
96
+ const GCLOUD_AUTH_ERROR =
97
+ /Reauthentication|reauth|invalid_grant|cannot prompt|refreshing your current auth tokens|do not currently have an active account/i;
98
+
99
+ /** Whether a failed gcloud call failed because its credentials expired (#630). */
100
+ export function isGcloudAuthError(stderr: string): boolean {
101
+ return GCLOUD_AUTH_ERROR.test(stderr);
102
+ }
103
+
104
+ /** The fix for expired credentials on the Owner's machine or in Cloud Shell. */
105
+ export const GCLOUD_REAUTH_HINT =
106
+ "gcloud credentials expired. Run `gcloud auth login` (and `gcloud auth application-default login` where Pulumi runs), then run this command again.";
107
+
108
+ /** The first non-empty line of gcloud's stderr, for a one-line error. */
109
+ export function gcloudErrorLine(stderr: string): string {
110
+ return (
111
+ stderr
112
+ .split("\n")
113
+ .map((l) => l.replace(/^ERROR:\s*/, "").trim())
114
+ .find((l) => l.length > 0) ?? "no error output"
115
+ );
116
+ }
117
+
118
+ export type GcloudAuthCheck =
119
+ | { readonly ok: true; readonly account: string }
120
+ | { readonly ok: false; readonly error: string };
121
+
122
+ /**
123
+ * Pre-flight for every lifecycle command that talks to GCP: an active
124
+ * account AND a token gcloud can mint now. {@link activeAccount} alone
125
+ * passes with expired credentials (`auth list` reads local config only), so
126
+ * the first real lookup failed and read as "service not found" (#630).
127
+ * The token itself is discarded.
128
+ */
129
+ export async function checkGcloudAuth(
130
+ run: (args: string[]) => Promise<GcloudResult> = gcloud,
131
+ ): Promise<GcloudAuthCheck> {
132
+ const account = await activeAccount(run);
133
+ if (!account) {
134
+ return {
135
+ ok: false,
136
+ error:
137
+ "No gcloud account is logged in. Run `gcloud auth login` with the Owner's Google account, then run this command again.",
138
+ };
139
+ }
140
+ const token = await run(["auth", "print-access-token", "--quiet"]);
141
+ if (token.ok) return { ok: true, account };
142
+ if (isGcloudAuthError(token.stderr)) {
143
+ return {
144
+ ok: false,
145
+ error: `${GCLOUD_REAUTH_HINT} (${account}: ${gcloudErrorLine(token.stderr)})`,
146
+ };
147
+ }
148
+ return {
149
+ ok: false,
150
+ error: `gcloud could not get an access token for ${account}: ${gcloudErrorLine(token.stderr)}. Check \`gcloud auth list\` and the network, then run this command again.`,
151
+ };
152
+ }
153
+
89
154
  export interface BillingAccount {
90
155
  id: string;
91
156
  displayName: string;
@@ -116,6 +181,23 @@ export async function projectExists(projectId: string): Promise<boolean> {
116
181
  return r.ok && r.stdout.trim() === projectId;
117
182
  }
118
183
 
184
+ /**
185
+ * Human-readable GCP project name for an install, e.g. `Caelo searchviu-com`.
186
+ * Carries the domain so an operator with several installs can tell them apart
187
+ * in the Cloud Console project picker (every install used to be "Caelo CMS").
188
+ * GCP display names allow only letters, digits, spaces, hyphens, quotes and
189
+ * `!`, at 4–30 chars — so dots become hyphens and long domains are cut.
190
+ */
191
+ export function projectDisplayName(domain: string): string {
192
+ const prefix = "Caelo ";
193
+ const slug = domain
194
+ .toLowerCase()
195
+ .replace(/[^a-z0-9-]+/g, "-")
196
+ .slice(0, 30 - prefix.length)
197
+ .replace(/^-+|-+$/g, "");
198
+ return `${prefix}${slug}`;
199
+ }
200
+
119
201
  export async function createProject(projectId: string, displayName: string): Promise<GcloudResult> {
120
202
  return gcloud(["projects", "create", projectId, "--name", displayName]);
121
203
  }
package/src/index.test.ts CHANGED
@@ -3,7 +3,11 @@
3
3
  import { describe, expect, it } from "bun:test";
4
4
  import { readFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
- import { CONTENT_HASHED_PATH_PATTERN, IMMUTABLE_CACHE_CONTROL } from "@caelo-cms/shared";
6
+ import {
7
+ CONTENT_HASHED_PATH_PATTERN,
8
+ IMMUTABLE_CACHE_CONTROL,
9
+ SVG_DOCUMENT_CSP,
10
+ } from "@caelo-cms/shared";
7
11
  import { generateBootstrapToken } from "./bootstrap-token.js";
8
12
  import { generateCaddyfile } from "./caddy.js";
9
13
  import { loadCdnCopyAdapter, selfHostedCdnCopy } from "./cdn-copy.js";
@@ -92,7 +96,37 @@ describe("Caddy cache policy for content-hashed assets", () => {
92
96
  );
93
97
  expect(body).toContain(`header @content_hashed Cache-Control "${IMMUTABLE_CACHE_CONTROL}"`);
94
98
  expect(body).toContain('\t@ico path *.ico\n\theader @ico Content-Type "image/x-icon"\n');
99
+ // #891 — the same security headers as the generated vhosts.
100
+ expect(body).toContain('\theader X-Content-Type-Options "nosniff"\n');
101
+ expect(body).toContain(
102
+ `\t@svg path *.svg\n\theader @svg Content-Security-Policy "${SVG_DOCUMENT_CSP}"\n`,
103
+ );
104
+ }
105
+ });
106
+
107
+ // #891 regression: the public vhost served uploaded SVGs without a CSP, so
108
+ // a script that got past sanitizeSvg ran on the site's origin (which also
109
+ // serves /api/*) when the SVG's URL was opened.
110
+ it("public vhosts (live and staging) sandbox SVGs and nosniff everything; admin vhosts are untouched", () => {
111
+ const out = generateCaddyfile({
112
+ ...spec,
113
+ domains: [
114
+ ...spec.domains,
115
+ { hostname: "staging.example.com", kind: "public", env: "staging" },
116
+ ],
117
+ });
118
+ const blocks = [
119
+ out.match(/\nexample\.com \{[\s\S]*?\n\}/)?.[0] ?? "",
120
+ out.match(/\nstaging\.example\.com \{[\s\S]*?\n\}/)?.[0] ?? "",
121
+ ];
122
+ for (const block of blocks) {
123
+ expect(block).toContain(' header X-Content-Type-Options "nosniff"\n');
124
+ expect(block).toContain(
125
+ ` @svg path *.svg\n header @svg Content-Security-Policy "${SVG_DOCUMENT_CSP}"\n`,
126
+ );
95
127
  }
128
+ const adminBlock = out.match(/admin\.example\.com \{[\s\S]*?\n\}/)?.[0] ?? "";
129
+ expect(adminBlock).not.toContain("Content-Security-Policy");
96
130
  });
97
131
 
98
132
  it("public vhosts serve .ico favicons as image/x-icon; admin vhosts are untouched", () => {
@@ -117,6 +151,7 @@ describe("docker-compose generator", () => {
117
151
  postgresPassword: "supersecret",
118
152
  rolePasswords: { admin: "adminpw", public: "publicpw" },
119
153
  caeloSecretKek: "0".repeat(64),
154
+ internalSecret: "i".repeat(48),
120
155
  anthropicApiKey: "sk-ant-…",
121
156
  diskSize: "20Gi",
122
157
  images: RELEASE_IMAGES,
@@ -138,6 +173,7 @@ describe("docker-compose generator", () => {
138
173
  postgresPassword: "supersecret",
139
174
  rolePasswords: { admin: "adminpw", public: "publicpw" },
140
175
  caeloSecretKek: "0".repeat(64),
176
+ internalSecret: "i".repeat(48),
141
177
  diskSize: "20Gi",
142
178
  images: RELEASE_IMAGES,
143
179
  ports: { http: 80, https: 443 },
@@ -162,6 +198,7 @@ describe("docker-compose generator", () => {
162
198
  postgresPassword: 'pa"ss',
163
199
  rolePasswords: { admin: "a", public: "b" },
164
200
  caeloSecretKek: "0".repeat(64),
201
+ internalSecret: "i".repeat(48),
165
202
  diskSize: "1Gi",
166
203
  images: RELEASE_IMAGES,
167
204
  ports: { http: 80, https: 443 },
@@ -20,8 +20,8 @@ describe("infrastructure contract steps", () => {
20
20
  );
21
21
  });
22
22
 
23
- it("is at 12: public_role revoke (3), URL-map redirects role (4), no gateway cms_admin login (5), ops-bucket list (6), gateway proxy hops (7), quality-audit job (8), converge job (9), remote MCP (10), Google sign-in (11), project-level sign-in client (12)", () => {
24
- expect(INFRA_CONTRACT_VERSION).toBe(12);
23
+ it("is at 13: public_role revoke (3), URL-map redirects role (4), no gateway cms_admin login (5), ops-bucket list (6), gateway proxy hops (7), quality-audit job (8), converge job (9), remote MCP (10), Google sign-in (11), project-level sign-in client (12), edge security headers (13)", () => {
24
+ expect(INFRA_CONTRACT_VERSION).toBe(13);
25
25
  expect(INFRA_CONTRACT_STEPS[3]).toContain("#633");
26
26
  expect(INFRA_CONTRACT_STEPS[4]).toContain("#587");
27
27
  expect(INFRA_CONTRACT_STEPS[5]).toContain("#635");
@@ -32,6 +32,7 @@ describe("infrastructure contract steps", () => {
32
32
  expect(INFRA_CONTRACT_STEPS[10]).toContain("#806");
33
33
  expect(INFRA_CONTRACT_STEPS[11]).toContain("#914");
34
34
  expect(INFRA_CONTRACT_STEPS[12]).toContain("project's IAP settings");
35
+ expect(INFRA_CONTRACT_STEPS[13]).toContain("#891");
35
36
  });
36
37
 
37
38
  it("lists the pending steps oldest first", () => {
@@ -42,6 +43,6 @@ describe("infrastructure contract steps", () => {
42
43
  });
43
44
 
44
45
  it("names a version a newer release added by number", () => {
45
- expect(infraStepsText(12, 13)).toBe("infrastructure step 13 (see that release's notes)");
46
+ expect(infraStepsText(13, 14)).toBe("infrastructure step 14 (see that release's notes)");
46
47
  });
47
48
  });
@@ -22,7 +22,7 @@
22
22
  * whose app-roll references infrastructure an install does not have yet fails
23
23
  * at roll time, after migrations ran.
24
24
  */
25
- export const INFRA_CONTRACT_VERSION = 12;
25
+ export const INFRA_CONTRACT_VERSION = 13;
26
26
 
27
27
  // History (why each bump happened — an install below a number needs the
28
28
  // converge that introduced it):
@@ -93,6 +93,14 @@ export const INFRA_CONTRACT_VERSION = 12;
93
93
  // (`iap.projects.getSettings` / `updateSettings`) on the project. New
94
94
  // IAM the in-cloud upgrader cannot grant; without it the new job image
95
95
  // fails every Google sign-in sync with 403.
96
+ // 13 — #891: the public site's security headers at the edge. On `gcp` the
97
+ // static backend bucket gets the `X-Content-Type-Options: nosniff`
98
+ // custom response header, the live SVG objects get
99
+ // `Content-Disposition: attachment` and Cloud CDN is purged once
100
+ // (static-svg-gcp.ts); aws / azure gain response-header policies /
101
+ // a Front Door rule set (static-security-headers.ts). Backend-bucket,
102
+ // object-metadata and CDN writes the in-cloud upgrader holds no rights
103
+ // for, so the converge applies them.
96
104
 
97
105
  /**
98
106
  * What each contract version adds, in operator terms — the upgrader's
@@ -112,6 +120,7 @@ export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
112
120
  10: "remote MCP: the OAuth client and seal-key secrets, their IAM, the gateway's secret file and timeout, IAP programmatic-client sync and the load-balancer rules (#806)",
113
121
  11: "Google sign-in: the renamed sign-in client secret, its IAM and mounts, the operator-access job's read access to it, and the admin's URL and organisation domain (#914)",
114
122
  12: "Google sign-in at project level: the operator-access job's permission to set the sign-in client in the project's IAP settings (#914)",
123
+ 13: "the public site's security headers at the edge: nosniff on every file, uploaded SVGs no longer run as documents on the site's origin, and a one-time CDN purge (#891)",
115
124
  };
116
125
 
117
126
  /**
@@ -69,11 +69,14 @@ export const OPS_IMAGE_REPOSITORY =
69
69
  * tail it. Granted only to installs that use the job. Not an escalation: the
70
70
  * provisioner already holds `resourcemanager.projectIamAdmin`.
71
71
  */
72
- export const INSTALL_JOB_ROLES: readonly string[] = [
72
+ export const INSTALL_JOB_ROLES = [
73
73
  "roles/iam.roleAdmin",
74
74
  "roles/cloudscheduler.admin",
75
75
  "roles/logging.logWriter",
76
- ];
76
+ ] as const;
77
+
78
+ /** One of {@link INSTALL_JOB_ROLES}. */
79
+ export type InstallJobRole = (typeof INSTALL_JOB_ROLES)[number];
77
80
 
78
81
  /** Resources the job runs with: Pulumi + the Node language host want memory. */
79
82
  const JOB_CPU = "2";
@@ -230,6 +230,19 @@ export function installRoot(installId: string): string {
230
230
  return join(homedir(), `${ROOT_PREFIX}${installId}`);
231
231
  }
232
232
 
233
+ /**
234
+ * Where Pulumi runs for one install (#736): `<installRoot>/stack/<provider>/`.
235
+ * It holds the install's own stack settings file (`Pulumi.production.yaml`)
236
+ * and a project file pointing at the package's stack program
237
+ * (wizards/gcp-pulumi.ts `prepareStackWorkDir`). The settings file used to
238
+ * live in the package's stack dir, shared by every install of the provider
239
+ * on the machine: two CLI runs for different installs at once could preview
240
+ * or `up` one install's state with the other's config.
241
+ */
242
+ export function stackWorkDir(installRoot: string, provider: "gcp" | "gcp-firebase"): string {
243
+ return join(installRoot, "stack", provider);
244
+ }
245
+
233
246
  /**
234
247
  * Stable install id from the (provider, projectId-or-domain) pair so a re-run
235
248
  * with the same inputs always lands on the same `~/.caelo-<id>/` directory.
package/src/lifecycle.ts CHANGED
@@ -16,11 +16,11 @@
16
16
  * - emits human-readable progress + a final summary
17
17
  */
18
18
 
19
- import { join, resolve as resolvePath } from "node:path";
19
+ import { join } from "node:path";
20
20
  import { provisioningUpgradeCommand } from "@caelo-cms/shared";
21
21
  import { cancel, confirm, isCancel, log, note, select, spinner } from "@clack/prompts";
22
22
  import { bold, cyan, dim, green, red, yellow } from "kleur/colors";
23
- import { activeAccount, gcloud } from "./gcloud.js";
23
+ import { activeAccount, checkGcloudAuth, gcloud } from "./gcloud.js";
24
24
  import { type GcloudRunner, realSleep } from "./gcloud-retry.js";
25
25
  import { GCP_STACK_ENV } from "./gcp-names.js";
26
26
  import { INFRA_CONTRACT_VERSION } from "./infra-contract.js";
@@ -39,6 +39,7 @@ import {
39
39
  recordInfraContractVersion,
40
40
  scrubInstallProgress,
41
41
  selectInstall,
42
+ stackWorkDir,
42
43
  writeMetadata,
43
44
  } from "./install-state.js";
44
45
  import { runMigrationsViaCloudRunJob } from "./migration-runner.js";
@@ -73,11 +74,12 @@ import {
73
74
  type RollSlug,
74
75
  } from "./upgrade/app-roll.js";
75
76
  import { restoreCommand, takeSqlBackup } from "./upgrade/backup.js";
76
- import { findServiceUrl, resolveGcpResourceName } from "./upgrade/gcp-lookup.js";
77
+ import { findServiceUrl, lookupFailure, resolveGcpResourceName } from "./upgrade/gcp-lookup.js";
77
78
  import { createIapHealthProbe, type HealthProbe } from "./upgrade/health.js";
78
79
  import { recordRolledDigests } from "./upgrade/record-rolled.js";
79
80
  import {
80
81
  canonicalReleaseTag,
82
+ ensureCosignAvailable,
81
83
  fetchLatestReleaseTag,
82
84
  RELEASE_REGISTRY,
83
85
  releaseImageRef,
@@ -148,14 +150,22 @@ async function chooseInstalls(opts: {
148
150
 
149
151
  function exitNoInstall(message: string): never {
150
152
  log.error(red(message));
151
- if (listInstalls().length === 0) {
152
- log.warn(
153
- `Run ${bold(selfCommand())} first to provision an install, OR copy ${dim("~/.caelo-<install-id>/")} from the provisioning machine.`,
154
- );
155
- }
153
+ log.warn(noInstallHint());
156
154
  process.exit(1);
157
155
  }
158
156
 
157
+ /**
158
+ * What to do when this machine has no copy of the install. A Google Cloud
159
+ * install keeps its records in its own project, so the fix is `attach`, not
160
+ * the installer: the installer would provision a new site (#816).
161
+ */
162
+ export function noInstallHint(): string {
163
+ return [
164
+ `A Google Cloud install keeps its records in its own project: run ${bold(selfCommand("attach", "--project", "<gcp-project-id>"))} once on this machine (or pass ${bold("--project <gcp-project-id>")} to upgrade, which attaches it first).`,
165
+ `A self-hosted install is managed on its own server. To create a new install instead, run ${bold(selfCommand())}.`,
166
+ ].join("\n");
167
+ }
168
+
159
169
  /** The one install a single-install command (rotate-secret, truncate, destroy) acts on. */
160
170
  async function requireInstall(verb: string): Promise<{ installId: string; meta: InstallMetadata }> {
161
171
  const [meta] = await chooseInstalls({ verb, allowAll: false });
@@ -212,6 +222,27 @@ function installRegion(meta: InstallMetadata, requested?: string): string {
212
222
  }
213
223
  }
214
224
 
225
+ /**
226
+ * Pre-flight for a lifecycle command on a GCP install, before any lock,
227
+ * lookup or change: gcloud can mint a token now (#630: expired credentials
228
+ * otherwise surface as "service not found" half-way through) and, for an
229
+ * upgrade that verifies signatures, cosign is on PATH (#816).
230
+ *
231
+ * @returns false when the command must stop (the reason is printed)
232
+ */
233
+ async function gcpPreflight(opts: { readonly cosign: boolean }): Promise<boolean> {
234
+ const auth = await checkGcloudAuth();
235
+ if (!auth.ok) {
236
+ log.error(red(`Nothing was changed: ${auth.error}`));
237
+ return false;
238
+ }
239
+ if (opts.cosign && !(await ensureCosignAvailable())) {
240
+ log.error(red("Nothing was changed: the release images cannot be verified without cosign."));
241
+ return false;
242
+ }
243
+ return true;
244
+ }
245
+
215
246
  // =========================================================================
216
247
  // status — health check + monthly cost
217
248
  // =========================================================================
@@ -264,27 +295,34 @@ async function getLatestReleaseTag(): Promise<string | null> {
264
295
 
265
296
  async function gcpStatus(meta: InstallMetadata): Promise<void> {
266
297
  if (!meta.projectId) return;
298
+ if (!(await gcpPreflight({ cosign: false }))) return;
267
299
  const s = spinner();
268
300
  s.start("Resolving deployed resources + checking health...");
269
301
 
270
302
  const region = installRegion(meta);
271
- const adminName = await resolveGcpResourceName(
303
+ const admin = await resolveGcpResourceName(
272
304
  gcloud,
273
305
  "run-service",
274
306
  "caelo-production-admin",
275
307
  meta.projectId,
276
308
  region,
277
309
  );
278
- const sqlName = await resolveGcpResourceName(
310
+ const sql = await resolveGcpResourceName(
279
311
  gcloud,
280
312
  "sql-instance",
281
313
  "caelo-production-pg",
282
314
  meta.projectId,
283
315
  );
284
- if (!adminName || !sqlName) {
285
- s.stop(red("Could not resolve deployed resource names — is the install live?"));
316
+ if (admin.status !== "found") {
317
+ s.stop(red(lookupFailure(admin, "caelo-production-admin* Cloud Run service", "operator")));
318
+ return;
319
+ }
320
+ if (sql.status !== "found") {
321
+ s.stop(red(lookupFailure(sql, "caelo-production-pg* Cloud SQL instance", "operator")));
286
322
  return;
287
323
  }
324
+ const adminName = admin.name;
325
+ const sqlName = sql.name;
288
326
 
289
327
  const adminUri = await gcloud([
290
328
  "run",
@@ -366,9 +404,43 @@ interface UpgradeOpts {
366
404
  * `--install` or the picker (chooseInstalls).
367
405
  */
368
406
  readonly installId?: string;
407
+ /**
408
+ * #816 — the GCP project the install lives in. The upgrade attaches it
409
+ * from the project's ops bucket first (state-command.ts attachProject),
410
+ * so it works on a machine (or a fresh Cloud Shell) with no local copy.
411
+ */
412
+ readonly project?: string;
369
413
  }
370
414
 
371
415
  export async function upgradeCommand(opts: UpgradeOpts = {}): Promise<void> {
416
+ if (opts.project) {
417
+ if (!(await gcpPreflight({ cosign: false }))) {
418
+ process.exitCode = 1;
419
+ return;
420
+ }
421
+ const { attachProject } = await import("./state-command.js");
422
+ const attached = attachProject(opts.project);
423
+ let wanted: string | undefined;
424
+ try {
425
+ wanted = installFlag(process.argv);
426
+ } catch (e) {
427
+ log.error(red(e instanceof Error ? e.message : String(e)));
428
+ process.exit(2);
429
+ }
430
+ const selected = selectInstall(listInstalls(), wanted ?? attached);
431
+ if (!selected.ok) return exitNoInstall(selected.message);
432
+ if (selected.meta.installId !== attached) {
433
+ log.error(
434
+ red(
435
+ `--install ${wanted} is not the install in project ${opts.project} (${attached}). Drop one of the two flags.`,
436
+ ),
437
+ );
438
+ process.exit(2);
439
+ }
440
+ const meta = currentMetadata(selected.meta);
441
+ if (!(await upgradeInstall(meta.installId, meta, opts))) process.exitCode = 1;
442
+ return;
443
+ }
372
444
  if (opts.installId) {
373
445
  const selected = selectInstall(listInstalls(), opts.installId);
374
446
  if (!selected.ok) return exitNoInstall(selected.message);
@@ -493,6 +565,7 @@ async function upgradeInstall(
493
565
  if (!meta.projectId) return false;
494
566
  const provider = meta.provider;
495
567
  const projectId = meta.projectId;
568
+ if (!(await gcpPreflight({ cosign: opts.skipVerify !== true }))) return false;
496
569
 
497
570
  // #815 — the install's state lock, the one the in-app upgrader job,
498
571
  // `pulumi up` and `state migrate` take: a Cloud Shell upgrade must not
@@ -569,6 +642,7 @@ async function upgradeLockedInstall(
569
642
  infraContractVersion: INFRA_CONTRACT_VERSION,
570
643
  skipVerify: opts.skipVerify === true,
571
644
  skipHealthCheck: opts.skipHealthCheck === true,
645
+ credentials: "operator",
572
646
  },
573
647
  {
574
648
  run,
@@ -578,7 +652,7 @@ async function upgradeLockedInstall(
578
652
  verifyImages: verifyCosignAll,
579
653
  migrate: (imageRef) =>
580
654
  runMigrationsViaCloudRunJob({ projectId, region, imageOverride: imageRef }, { run }),
581
- healthProbe: (plan) => gcpHealthProbe(run, http, plan),
655
+ healthProbe: (plan, context) => gcpHealthProbe(run, http, plan, context),
582
656
  recordDigests: async (digests) => {
583
657
  recorded = await recordRolledRelease(
584
658
  installId,
@@ -621,6 +695,7 @@ async function gcpHealthProbe(
621
695
  run: GcloudRunner,
622
696
  http: HttpFetch,
623
697
  plan: AppRollPlan,
698
+ context: { readonly accessJustChanged: boolean },
624
699
  ): Promise<{ ok: true; probe: HealthProbe } | { ok: false; error: string }> {
625
700
  const { install } = plan;
626
701
  const urls: Partial<Record<RollSlug, string>> = {};
@@ -644,7 +719,7 @@ async function gcpHealthProbe(
644
719
  adminServiceUrl: urls.admin as string,
645
720
  gatewayServiceUrl: urls.gateway as string,
646
721
  },
647
- { run, http, sleep: realSleep },
722
+ { run, http, sleep: realSleep, accessJustChanged: context.accessJustChanged },
648
723
  ),
649
724
  };
650
725
  }
@@ -676,7 +751,12 @@ export function recordRolledRelease(
676
751
  readPassphrase: () => readSecret(installId, "pulumi-passphrase"),
677
752
  });
678
753
  const { writeImageDigestsToStack } = await import("./wizards/gcp-pulumi.js");
679
- return writeImageDigestsToStack({ backend, provider, digests: d });
754
+ return writeImageDigestsToStack({
755
+ installRoot: installRoot(installId),
756
+ backend,
757
+ provider,
758
+ digests: d,
759
+ });
680
760
  },
681
761
  stackRecovery: (d) => stackConfigRecovery(installId, local ?? {}, provider, d),
682
762
  installJsonLocation: bucket
@@ -702,7 +782,7 @@ function stackConfigRecovery(
702
782
  digests: ImageDigests,
703
783
  ): string {
704
784
  const env = backendShellEnv(meta, installRoot(installId));
705
- const cwd = `--cwd ${resolvePath(import.meta.dir, "../stacks", provider)} --stack ${GCP_STACK_ENV}`;
785
+ const cwd = `--cwd ${stackWorkDir(installRoot(installId), provider)} --stack ${GCP_STACK_ENV}`;
706
786
  const lines = Object.entries(digests).map(
707
787
  ([svc, d]) => ` ${env} pulumi config set ${cwd} caelo-${provider}:image-digest-${svc} ${d}`,
708
788
  );
@@ -730,12 +810,13 @@ async function backupOf(meta: InstallMetadata): Promise<boolean> {
730
810
  return false;
731
811
  }
732
812
  if (!meta.projectId) return false;
813
+ if (!(await gcpPreflight({ cosign: false }))) return false;
733
814
 
734
815
  const s = spinner();
735
816
  s.start("Resolving Cloud SQL instance + triggering on-demand backup...");
736
817
  const r = await takeSqlBackup(
737
818
  gcloud,
738
- { projectId: meta.projectId, env: GCP_STACK_ENV },
819
+ { projectId: meta.projectId, env: GCP_STACK_ENV, credentials: "operator" },
739
820
  "backup",
740
821
  );
741
822
  if (!r.ok) {
@@ -776,21 +857,28 @@ export async function rotateSecretCommand(
776
857
  }
777
858
  if (!meta.projectId) return;
778
859
  const region = installRegion(meta);
860
+ if (!(await gcpPreflight({ cosign: false }))) {
861
+ process.exitCode = 1;
862
+ return;
863
+ }
779
864
 
780
865
  if (name === SEAL_KEY_SECRET) {
781
- const gatewayService = await resolveGcpResourceName(
866
+ const gateway = await resolveGcpResourceName(
782
867
  gcloud,
783
868
  "run-service",
784
869
  "caelo-production-gateway",
785
870
  meta.projectId,
786
871
  region,
787
872
  );
788
- if (!gatewayService) {
873
+ if (gateway.status !== "found") {
789
874
  log.error(
790
- red("Could not find the caelo-production-gateway* Cloud Run service — nothing rotated"),
875
+ red(
876
+ `${lookupFailure(gateway, "caelo-production-gateway* Cloud Run service", "operator")} — nothing rotated`,
877
+ ),
791
878
  );
792
879
  return;
793
880
  }
881
+ const gatewayService = gateway.name;
794
882
  const target = { projectId: meta.projectId, region, env: GCP_STACK_ENV, gatewayService };
795
883
  const report = opts.finish
796
884
  ? await finishSealKeyRotation(target, {}, { now: opts.now === true })
@@ -807,23 +895,41 @@ export async function rotateSecretCommand(
807
895
  s.start(`Rotating ${secret}...`);
808
896
  const services: Partial<Record<"admin" | "gateway", string>> = {};
809
897
  for (const slug of ["admin", "gateway"] as const) {
810
- const serviceName = await resolveGcpResourceName(
898
+ const service = await resolveGcpResourceName(
811
899
  gcloud,
812
900
  "run-service",
813
901
  `caelo-production-${slug}`,
814
902
  meta.projectId,
815
903
  region,
816
904
  );
817
- if (!serviceName) {
818
- s.stop(red(`Could not find caelo-production-${slug}* Cloud Run service — nothing rotated`));
905
+ if (service.status !== "found") {
906
+ s.stop(
907
+ red(
908
+ `${lookupFailure(service, `caelo-production-${slug}* Cloud Run service`, "operator")} — nothing rotated`,
909
+ ),
910
+ );
911
+ return;
912
+ }
913
+ services[slug] = service.name;
914
+ }
915
+ let sqlInstance: string | null = null;
916
+ if (rolesOfSecret(secret).length > 0) {
917
+ const sql = await resolveGcpResourceName(
918
+ gcloud,
919
+ "sql-instance",
920
+ "caelo-production-pg",
921
+ meta.projectId,
922
+ );
923
+ if (sql.status !== "found") {
924
+ s.stop(
925
+ red(
926
+ `${lookupFailure(sql, "caelo-production-pg* Cloud SQL instance", "operator")} — nothing rotated`,
927
+ ),
928
+ );
819
929
  return;
820
930
  }
821
- services[slug] = serviceName;
931
+ sqlInstance = sql.name;
822
932
  }
823
- const sqlInstance =
824
- rolesOfSecret(secret).length > 0
825
- ? await resolveGcpResourceName(gcloud, "sql-instance", "caelo-production-pg", meta.projectId)
826
- : null;
827
933
  const report = await rotateRuntimeSecret(
828
934
  {
829
935
  projectId: meta.projectId,