@caelo-cms/provisioning 0.10.49 → 0.10.50

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 (137) hide show
  1. package/caddy/Caddyfile.production +4 -0
  2. package/caddy/Caddyfile.staging +4 -0
  3. package/dist/agent-db.js +4 -0
  4. package/dist/caddy-redirects.d.ts +15 -0
  5. package/dist/caddy-redirects.d.ts.map +1 -0
  6. package/dist/caddy-redirects.js +65 -0
  7. package/dist/caddy-redirects.js.map +1 -0
  8. package/dist/caddy.d.ts +22 -2
  9. package/dist/caddy.d.ts.map +1 -1
  10. package/dist/caddy.js +66 -17
  11. package/dist/caddy.js.map +1 -1
  12. package/dist/cli-routing.d.ts.map +1 -1
  13. package/dist/cli-routing.js +7 -0
  14. package/dist/cli-routing.js.map +1 -1
  15. package/dist/cli.js +248 -254
  16. package/dist/cli.js.map +1 -1
  17. package/dist/compose.d.ts +54 -2
  18. package/dist/compose.d.ts.map +1 -1
  19. package/dist/compose.js +86 -32
  20. package/dist/compose.js.map +1 -1
  21. package/dist/gcp-names.d.ts +12 -0
  22. package/dist/gcp-names.d.ts.map +1 -1
  23. package/dist/gcp-names.js +21 -0
  24. package/dist/gcp-names.js.map +1 -1
  25. package/dist/gcp-url-map.d.ts +27 -0
  26. package/dist/gcp-url-map.d.ts.map +1 -0
  27. package/dist/gcp-url-map.js +62 -0
  28. package/dist/gcp-url-map.js.map +1 -0
  29. package/dist/index.d.ts +0 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +0 -1
  32. package/dist/index.js.map +1 -1
  33. package/dist/infra-contract.d.ts +20 -1
  34. package/dist/infra-contract.d.ts.map +1 -1
  35. package/dist/infra-contract.js +39 -1
  36. package/dist/infra-contract.js.map +1 -1
  37. package/dist/public-role-isolation.d.ts +19 -0
  38. package/dist/public-role-isolation.d.ts.map +1 -0
  39. package/dist/public-role-isolation.js +69 -0
  40. package/dist/public-role-isolation.js.map +1 -0
  41. package/dist/release-contract.d.ts.map +1 -1
  42. package/dist/release-contract.js +3 -0
  43. package/dist/release-contract.js.map +1 -1
  44. package/dist/release-manifest.d.ts.map +1 -1
  45. package/dist/release-manifest.js +2 -1
  46. package/dist/release-manifest.js.map +1 -1
  47. package/dist/self-hosted-backup.d.ts +86 -0
  48. package/dist/self-hosted-backup.d.ts.map +1 -0
  49. package/dist/self-hosted-backup.js +350 -0
  50. package/dist/self-hosted-backup.js.map +1 -0
  51. package/dist/self-hosted-release.d.ts +12 -3
  52. package/dist/self-hosted-release.d.ts.map +1 -1
  53. package/dist/self-hosted-release.js +6 -4
  54. package/dist/self-hosted-release.js.map +1 -1
  55. package/dist/self-hosted-start.d.ts +50 -0
  56. package/dist/self-hosted-start.d.ts.map +1 -0
  57. package/dist/self-hosted-start.js +59 -0
  58. package/dist/self-hosted-start.js.map +1 -0
  59. package/dist/self-hosted-upgrade.d.ts +30 -0
  60. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  61. package/dist/self-hosted-upgrade.js +29 -7
  62. package/dist/self-hosted-upgrade.js.map +1 -1
  63. package/dist/tar-entries.d.ts +14 -0
  64. package/dist/tar-entries.d.ts.map +1 -0
  65. package/dist/tar-entries.js +104 -0
  66. package/dist/tar-entries.js.map +1 -0
  67. package/dist/upgrade/converge.d.ts +2 -2
  68. package/dist/upgrade/converge.d.ts.map +1 -1
  69. package/dist/upgrade/converge.js +44 -2
  70. package/dist/upgrade/converge.js.map +1 -1
  71. package/dist/upgrader-assess.d.ts.map +1 -1
  72. package/dist/upgrader-assess.js +2 -1
  73. package/dist/upgrader-assess.js.map +1 -1
  74. package/dist/url-map-redirects-access.d.ts +15 -0
  75. package/dist/url-map-redirects-access.d.ts.map +1 -0
  76. package/dist/url-map-redirects-access.js +81 -0
  77. package/dist/url-map-redirects-access.js.map +1 -0
  78. package/dist/wizard.d.ts.map +1 -1
  79. package/dist/wizard.js +3 -1
  80. package/dist/wizard.js.map +1 -1
  81. package/dist/wizards/gcp.d.ts.map +1 -1
  82. package/dist/wizards/gcp.js +45 -0
  83. package/dist/wizards/gcp.js.map +1 -1
  84. package/package.json +2 -2
  85. package/src/admin-memory.test.ts +2 -2
  86. package/src/caddy-redirects.docker.test.ts +255 -0
  87. package/src/caddy-redirects.test.ts +115 -0
  88. package/src/caddy-redirects.ts +68 -0
  89. package/src/caddy-validate.test.ts +119 -0
  90. package/src/caddy.test.ts +108 -0
  91. package/src/caddy.ts +89 -20
  92. package/src/cli-routing.test.ts +12 -0
  93. package/src/cli-routing.ts +7 -0
  94. package/src/cli.ts +299 -287
  95. package/src/compose.ts +106 -34
  96. package/src/gcp-names.ts +23 -0
  97. package/src/gcp-url-map.test.ts +150 -0
  98. package/src/gcp-url-map.ts +81 -0
  99. package/src/index.test.ts +18 -146
  100. package/src/index.ts +0 -9
  101. package/src/infra-contract.test.ts +39 -0
  102. package/src/infra-contract.ts +42 -1
  103. package/src/public-role-isolation.test.ts +89 -0
  104. package/src/public-role-isolation.ts +83 -0
  105. package/src/release-contract.ts +3 -0
  106. package/src/release-manifest.test.ts +12 -0
  107. package/src/release-manifest.ts +2 -1
  108. package/src/self-hosted-backup.test.ts +344 -0
  109. package/src/self-hosted-backup.ts +480 -0
  110. package/src/self-hosted-compose.test.ts +88 -4
  111. package/src/self-hosted-release.ts +22 -7
  112. package/src/self-hosted-start.test.ts +68 -0
  113. package/src/self-hosted-start.ts +123 -0
  114. package/src/self-hosted-upgrade.ts +35 -8
  115. package/src/stack-contract.test.ts +2 -2
  116. package/src/tar-entries.test.ts +52 -0
  117. package/src/tar-entries.ts +116 -0
  118. package/src/upgrade/converge.ts +51 -4
  119. package/src/upgrade/upgrade.test.ts +78 -1
  120. package/src/upgrader-assess.test.ts +22 -0
  121. package/src/upgrader-assess.ts +2 -1
  122. package/src/url-map-redirects-access.test.ts +88 -0
  123. package/src/url-map-redirects-access.ts +89 -0
  124. package/src/wizard.ts +3 -1
  125. package/src/wizards/gcp.ts +48 -0
  126. package/stacks/aws/index.ts +34 -2
  127. package/stacks/azure/index.ts +13 -0
  128. package/stacks/gcp/index.ts +24 -17
  129. package/stacks/gcp-firebase/index.ts +7 -0
  130. package/stacks/self-hosted/Pulumi.yaml +2 -2
  131. package/stacks/self-hosted/README.md +3 -3
  132. package/stacks/self-hosted/index.ts +45 -22
  133. package/dist/redirects-emit.d.ts +0 -52
  134. package/dist/redirects-emit.d.ts.map +0 -1
  135. package/dist/redirects-emit.js +0 -111
  136. package/dist/redirects-emit.js.map +0 -1
  137. package/src/redirects-emit.ts +0 -175
@@ -113,6 +113,28 @@ const ROWS: readonly Row[] = [
113
113
  expect: "needs_cloud_shell/infra-contract",
114
114
  reasonContains: "Cloud Shell",
115
115
  },
116
+ {
117
+ name: "contract 2 → 4 names both pending steps (#633, #587)",
118
+ manifest: verified({ infraContractVersion: 4 }),
119
+ installed: { version: "0.10.45", infraContractVersion: 2 },
120
+ expect: "needs_cloud_shell/infra-contract",
121
+ reasonContains:
122
+ "infrastructure contract 2 → 4: public_role out of the Cloud SQL superuser group (#633); the admin's permission to serve redirects from the load balancer (#587)",
123
+ },
124
+ {
125
+ name: "contract 3 → 4 names only the redirects role (#587)",
126
+ manifest: verified({ infraContractVersion: 4 }),
127
+ installed: { version: "0.10.45", infraContractVersion: 3 },
128
+ expect: "needs_cloud_shell/infra-contract",
129
+ reasonContains:
130
+ "infrastructure contract 3 → 4: the admin's permission to serve redirects from the load balancer (#587))",
131
+ },
132
+ {
133
+ name: "contract 4 installed, release at 4",
134
+ manifest: verified({ infraContractVersion: 4 }),
135
+ installed: { version: "0.10.45", infraContractVersion: 4 },
136
+ expect: "proceed",
137
+ },
116
138
  {
117
139
  name: "never converged at all",
118
140
  installed: { version: "0.10.45", infraContractVersion: 0 },
@@ -47,6 +47,7 @@ import {
47
47
  type StoredUpdatePolicy,
48
48
  updateKind,
49
49
  } from "@caelo-cms/shared";
50
+ import { infraStepsText } from "./infra-contract.js";
50
51
  import {
51
52
  assessAutoInstall,
52
53
  channelForVersion,
@@ -194,7 +195,7 @@ export function assessUpgradeRequest(input: {
194
195
  return stop(
195
196
  "needs_cloud_shell",
196
197
  "infra-contract",
197
- `v${target} changes this install's cloud setup (infrastructure contract ${infraContractVersion} → ${manifest.infraContractVersion}). The admin may not change cloud permissions, so this release is installed once with your own Google account in Cloud Shell.`,
198
+ `v${target} changes this install's cloud setup (infrastructure contract ${infraContractVersion} → ${manifest.infraContractVersion}: ${infraStepsText(infraContractVersion, manifest.infraContractVersion)}). The admin may not change cloud permissions, so this release is installed once with your own Google account in Cloud Shell.`,
198
199
  manifest,
199
200
  );
200
201
  }
@@ -0,0 +1,88 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #587 — `cms-provision upgrade` gives older `gcp` installs the admin's
5
+ * URL-map redirects role: create or update the custom role, undelete a
6
+ * soft-deleted one, bind it to the admin's run SA.
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import type { GcloudResult } from "./gcloud.js";
11
+ import { ensureUrlMapRedirectsAccess } from "./url-map-redirects-access.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
+ /** The gcloud verb of a call (`projects add-iam-policy-binding` has it second). */
17
+ const verb = (c: string[]) => (c[0] === "projects" ? c[1] : c[2]);
18
+
19
+ function runner(answer: (args: string[]) => GcloudResult) {
20
+ const calls: string[][] = [];
21
+ return {
22
+ calls,
23
+ run: async (args: string[]) => {
24
+ calls.push(args);
25
+ return answer(args);
26
+ },
27
+ };
28
+ }
29
+
30
+ const PERMS =
31
+ "--permissions=compute.urlMaps.list,compute.urlMaps.get,compute.urlMaps.update,compute.backendBuckets.use,compute.backendServices.use,compute.globalOperations.get";
32
+ const BIND = [
33
+ "projects",
34
+ "add-iam-policy-binding",
35
+ "p1",
36
+ "--member=serviceAccount:caelo-production-run-sa@p1.iam.gserviceaccount.com",
37
+ "--role=projects/p1/roles/caelo_production_urlMapRedirects",
38
+ "--condition=None",
39
+ "--quiet",
40
+ "--format=none",
41
+ ];
42
+
43
+ describe("ensureUrlMapRedirectsAccess", () => {
44
+ it("creates the role when missing, then binds it", async () => {
45
+ const r = runner((a) => (a[2] === "describe" ? fail("NOT_FOUND") : ok()));
46
+ const out = await ensureUrlMapRedirectsAccess({
47
+ projectId: "p1",
48
+ env: "production",
49
+ run: r.run,
50
+ });
51
+ expect(out).toEqual({ ok: true, role: "projects/p1/roles/caelo_production_urlMapRedirects" });
52
+ expect(r.calls[1]?.slice(0, 5)).toEqual([
53
+ "iam",
54
+ "roles",
55
+ "create",
56
+ "caelo_production_urlMapRedirects",
57
+ "--project=p1",
58
+ ]);
59
+ expect(r.calls[1]).toContain(PERMS);
60
+ expect(r.calls[2]).toEqual(BIND);
61
+ });
62
+
63
+ it("updates an existing role to the current permissions (no drift)", async () => {
64
+ const r = runner((a) => (a[2] === "describe" ? ok('{"name":"x"}') : ok()));
65
+ await ensureUrlMapRedirectsAccess({ projectId: "p1", env: "production", run: r.run });
66
+ expect(r.calls.map(verb)).toEqual(["describe", "update", "add-iam-policy-binding"]);
67
+ expect(r.calls[1]).toContain(PERMS);
68
+ });
69
+
70
+ it("undeletes a soft-deleted role first", async () => {
71
+ const r = runner((a) => (a[2] === "describe" ? ok('{"deleted":true}') : ok()));
72
+ await ensureUrlMapRedirectsAccess({ projectId: "p1", env: "production", run: r.run });
73
+ expect(r.calls.map(verb)).toEqual(["describe", "undelete", "update", "add-iam-policy-binding"]);
74
+ });
75
+
76
+ it("reports a failure instead of throwing", async () => {
77
+ const r = runner((a) =>
78
+ a[2] === "describe" ? fail("NOT_FOUND") : fail("PERMISSION_DENIED: iam.roles.create"),
79
+ );
80
+ expect(
81
+ await ensureUrlMapRedirectsAccess({ projectId: "p1", env: "production", run: r.run }),
82
+ ).toEqual({
83
+ ok: false,
84
+ error:
85
+ "create projects/p1/roles/caelo_production_urlMapRedirects: PERMISSION_DENIED: iam.roles.create",
86
+ });
87
+ });
88
+ });
@@ -0,0 +1,89 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The admin's URL-map redirects role (#587) on `gcp` installs. The installer
5
+ * (wizards/gcp.ts) and `cms-provision upgrade` both call
6
+ * {@link ensureUrlMapRedirectsAccess}, so new and older installs get the same
7
+ * custom role + binding. The stack does not declare it: a role id exists once
8
+ * per project, so a stack-created role would collide with one upgrade made.
9
+ *
10
+ * 1. project custom role {@link urlMapRedirectsRoleId} with exactly
11
+ * {@link URL_MAP_REDIRECTS_PERMISSIONS} (created, or updated to that
12
+ * permission set; undeleted first if it was soft-deleted)
13
+ * 2. that role for the admin's run SA on the project
14
+ *
15
+ * Both steps are idempotent. Without them Confirm-publish fails with a 403
16
+ * when it writes the redirects into the URL map.
17
+ */
18
+
19
+ import { gcloud as defaultGcloud } from "./gcloud.js";
20
+ import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
21
+ import {
22
+ runServiceAccountEmail,
23
+ URL_MAP_REDIRECTS_PERMISSIONS,
24
+ urlMapRedirectsRoleId,
25
+ } from "./gcp-names.js";
26
+
27
+ /** Ensure the custom role and the admin's binding; never throws for gcloud. */
28
+ export async function ensureUrlMapRedirectsAccess(opts: {
29
+ readonly projectId: string;
30
+ readonly env: string;
31
+ readonly run?: GcloudRunner;
32
+ readonly sleep?: Sleep;
33
+ }): Promise<{ ok: true; role: string } | { ok: false; error: string }> {
34
+ const run = opts.run ?? defaultGcloud;
35
+ const sleep = opts.sleep ?? realSleep;
36
+ const project = `--project=${opts.projectId}`;
37
+ const roleId = urlMapRedirectsRoleId(opts.env);
38
+ const role = `projects/${opts.projectId}/roles/${roleId}`;
39
+ const permissions = `--permissions=${URL_MAP_REDIRECTS_PERMISSIONS.join(",")}`;
40
+
41
+ const describe = await run(["iam", "roles", "describe", roleId, project, "--format=json"]);
42
+ if (describe.ok) {
43
+ const live = JSON.parse(describe.stdout) as { deleted?: boolean };
44
+ if (live.deleted) {
45
+ const undelete = await run(["iam", "roles", "undelete", roleId, project, "--quiet"]);
46
+ if (!undelete.ok) return { ok: false, error: `undelete ${role}: ${undelete.stderr.trim()}` };
47
+ }
48
+ const update = await run([
49
+ "iam",
50
+ "roles",
51
+ "update",
52
+ roleId,
53
+ project,
54
+ permissions,
55
+ "--quiet",
56
+ "--format=none",
57
+ ]);
58
+ if (!update.ok) return { ok: false, error: `update ${role}: ${update.stderr.trim()}` };
59
+ } else {
60
+ const create = await run([
61
+ "iam",
62
+ "roles",
63
+ "create",
64
+ roleId,
65
+ project,
66
+ "--title=Caelo admin: URL-map redirects",
67
+ "--description=Lets the Caelo admin write the redirects table into the site's URL map (#587).",
68
+ permissions,
69
+ "--stage=GA",
70
+ "--quiet",
71
+ "--format=none",
72
+ ]);
73
+ if (!create.ok) return { ok: false, error: `create ${role}: ${create.stderr.trim()}` };
74
+ }
75
+
76
+ // A just-created role takes a few seconds to become bindable.
77
+ const bind = await runWithRetry(run, sleep, [
78
+ "projects",
79
+ "add-iam-policy-binding",
80
+ opts.projectId,
81
+ `--member=serviceAccount:${runServiceAccountEmail(opts.projectId, opts.env)}`,
82
+ `--role=${role}`,
83
+ "--condition=None",
84
+ "--quiet",
85
+ "--format=none",
86
+ ]);
87
+ if (!bind.ok) return { ok: false, error: `grant ${role}: ${bind.stderr.trim()}` };
88
+ return { ok: true, role };
89
+ }
package/src/wizard.ts CHANGED
@@ -222,8 +222,10 @@ export async function runWizard(opts: WizardOptions = {}): Promise<void> {
222
222
  });
223
223
  break;
224
224
  case "self-hosted":
225
+ // The self-hosted install runs on the Docker host itself, from the
226
+ // directory that should hold `.caelo/` — not from this wizard.
225
227
  cancel(
226
- `Self-hosted wizard is the existing \`cms-provision init\` flow. Run \`${selfCommand("init", "--domain", "...", "--owner-email", "...")}\` for now; wizard polish lands in a follow-up commit.`,
228
+ `Run this on the Docker host, in the directory that should hold the install:\n ${selfCommand("--provider", "self-hosted", "--domain", domain, "--owner-email", ownerEmail)}`,
227
229
  );
228
230
  process.exit(0);
229
231
  break;
@@ -99,6 +99,7 @@ import {
99
99
  PROVISIONER_ACCOUNT_ID,
100
100
  provisionerServiceAccountEmail,
101
101
  } from "../provisioner-identity.js";
102
+ import { revokePublicRoleMemberships } from "../public-role-isolation.js";
102
103
  import { decideRegion, detectCliRegion, installableRegions, suggestRegion } from "../regions.js";
103
104
  import type { RunMode } from "../run-mode.js";
104
105
  import { selfCommand } from "../self-command.js";
@@ -120,6 +121,7 @@ import {
120
121
  verifyCosignAll,
121
122
  } from "../upgrade/release-images.js";
122
123
  import { ensureUpgraderJob, resolveUpgraderTarget } from "../upgrader-setup.js";
124
+ import { ensureUrlMapRedirectsAccess } from "../url-map-redirects-access.js";
123
125
  import { estimateGcpCost } from "./gcp-cost.js";
124
126
  import { promptProjectId } from "./gcp-prompts.js";
125
127
  import { GCP_STACK_KNOB_DEFAULTS, pulumiUpGcp, readStackOutput } from "./gcp-pulumi.js";
@@ -430,6 +432,17 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
430
432
  // P21 ship 3 — shared with `cms-provision upgrade`. Aborts the
431
433
  // wizard with a clear error if migrations fail (vs. silently
432
434
  // continuing with a half-migrated DB).
435
+ // === 10.9. public_role out of cloudsqlsuperuser (#633) ===
436
+ // The stack creates public_role through the Cloud SQL Admin API, which
437
+ // makes it a member of cloudsqlsuperuser. Revoked before migrations: the
438
+ // migration job's role audit verifies it and fails the install otherwise.
439
+ await stepPublicRoleIsolation(projectId);
440
+
441
+ // === 10.95. The admin may write redirects into the URL map (#587) ===
442
+ // Confirm-publish writes the redirects table into the load balancer's URL
443
+ // map; the custom role is the CLI's, not the stack's (url-map-redirects-access.ts).
444
+ if (provider === "gcp") await stepUrlMapRedirectsAccess(projectId);
445
+
433
446
  phase("Applying database migrations");
434
447
  {
435
448
  const { runMigrationsViaCloudRunJob } = await import("../migration-runner.js");
@@ -886,6 +899,41 @@ async function stepGrantBaseRoles(
886
899
  markStepDone(installId, stepName, { granted });
887
900
  }
888
901
 
902
+ /**
903
+ * Revoke the database roles the Cloud SQL Admin API gave `public_role`
904
+ * (cloudsqlsuperuser, #633). Idempotent, so it runs on every wizard pass;
905
+ * the migration job's role audit verifies the result.
906
+ */
907
+ async function stepPublicRoleIsolation(projectId: string): Promise<void> {
908
+ const s = spinner();
909
+ s.start("Removing public_role from the Cloud SQL superuser group...");
910
+ const r = await revokePublicRoleMemberships({ projectId, env: GCP_STACK_ENV });
911
+ if (r.status === "failed") {
912
+ s.stop(red(`Failed: ${r.error ?? ""}`));
913
+ log.error("Re-run the wizard once the cause is fixed (this step is idempotent).");
914
+ cancel("Aborted.");
915
+ process.exit(1);
916
+ }
917
+ s.stop(green("public_role holds no database roles"));
918
+ }
919
+
920
+ /**
921
+ * The admin's custom role for writing the redirects table into the URL map
922
+ * (#587). Idempotent, so it runs on every wizard pass.
923
+ */
924
+ async function stepUrlMapRedirectsAccess(projectId: string): Promise<void> {
925
+ const s = spinner();
926
+ s.start("Letting the admin serve redirects from the load balancer...");
927
+ const r = await ensureUrlMapRedirectsAccess({ projectId, env: GCP_STACK_ENV });
928
+ if (!r.ok) {
929
+ s.stop(red(`Failed: ${r.error}`));
930
+ log.error("Re-run the wizard once the cause is fixed (this step is idempotent).");
931
+ cancel("Aborted.");
932
+ process.exit(1);
933
+ }
934
+ s.stop(green(`Redirects role ready (${r.role})`));
935
+ }
936
+
889
937
  /**
890
938
  * Give `gateway_role` (created by migration 0248) its password from the
891
939
  * `gateway-role-password` secret (#613). public_role already has its own:
@@ -11,8 +11,9 @@
11
11
  * - Bootstrap script runs as a one-shot ECS task post-create.
12
12
  * 3. S3 buckets: caelo-media + caelo-static (per env).
13
13
  * 4. CloudFront distribution with two origins (S3 static + ALB-fronted ECS).
14
- * Static files are served as files — no edge compute on the request
15
- * path (CLAUDE.md §11.B Tier 1).
14
+ * Static files are served as files (CLAUDE.md §11.B Tier 1); the only
15
+ * edge code is the redirects table's CloudFront Function, a KeyValueStore
16
+ * lookup that runs before the origin (#587).
16
17
  * 5. ECS Fargate cluster + four services (admin, gateway, orchestrator, runner).
17
18
  * 6. Secrets Manager entries (postgres-password, anthropic-api-key, ...).
18
19
  * 7. Route 53 hosted zone + ACM certs per (domain, locale).
@@ -26,6 +27,7 @@
26
27
  * is to wire those env vars to AWS-native resources.
27
28
  */
28
29
 
30
+ import { cloudFrontRedirectFunctionCode } from "@caelo-cms/shared";
29
31
  import * as aws from "@pulumi/aws";
30
32
  import * as awsx from "@pulumi/awsx";
31
33
  import * as pulumi from "@pulumi/pulumi";
@@ -225,6 +227,27 @@ const cert = new aws.acm.Certificate(
225
227
  { provider: usEast1Provider },
226
228
  );
227
229
 
230
+ // === 6b. Redirects at the edge (#587) ===
231
+ // The redirects table: a CloudFront Function on viewer-request answers each
232
+ // redirected path from a KeyValueStore before the S3 origin is asked
233
+ // (redirect-emit-cloudfront.ts in @caelo-cms/shared). The deploy writes
234
+ // the store, so redirects change without republishing the function.
235
+ // FIXME(issue #749): the S3 static publisher is still a stub (#5); until
236
+ // it writes the store, the function finds no keys and every request passes
237
+ // through to the origin.
238
+ const redirectKvs = new aws.cloudfront.KeyValueStore(`${namePrefix}-redirects`, {
239
+ name: `${namePrefix}-redirects`,
240
+ comment: `Caelo ${env} redirects table (#587)`,
241
+ });
242
+ const redirectFunction = new aws.cloudfront.Function(`${namePrefix}-redirects`, {
243
+ name: `${namePrefix}-redirects`,
244
+ runtime: "cloudfront-js-2.0",
245
+ comment: `Caelo ${env}: answer the redirects table at the edge (#587)`,
246
+ publish: true,
247
+ code: redirectKvs.arn.apply((arn) => cloudFrontRedirectFunctionCode(kvsIdFromArn(arn))),
248
+ keyValueStoreAssociations: [redirectKvs.arn],
249
+ });
250
+
228
251
  // === 7. CloudFront distribution ===
229
252
  const distribution = new aws.cloudfront.Distribution(`${namePrefix}-cdn`, {
230
253
  enabled: true,
@@ -252,6 +275,7 @@ const distribution = new aws.cloudfront.Distribution(`${namePrefix}-cdn`, {
252
275
  minTtl: 0,
253
276
  defaultTtl: 60,
254
277
  maxTtl: 86400,
278
+ functionAssociations: [{ eventType: "viewer-request", functionArn: redirectFunction.arn }],
255
279
  },
256
280
  orderedCacheBehaviors: [
257
281
  {
@@ -421,6 +445,7 @@ export const edgeLogSinkUrlOut = out.edgeLogSinkUrl;
421
445
  export const providerOut = out.provider;
422
446
  export const environmentOut = out.environment;
423
447
  export const cloudfrontDomainOut = distribution.domainName;
448
+ export const redirectKvsArnOut = redirectKvs.arn;
424
449
  export const ecsClusterArnOut = cluster.arn;
425
450
  export const rdsAddressOut = db.address;
426
451
 
@@ -429,3 +454,10 @@ export const rdsAddressOut = db.address;
429
454
  // state via `pulumi.secret(...)`. Operators retrieve via
430
455
  // `pulumi stack output --show-secrets`.
431
456
  export const bootstrapTokenExpiresAtOut = tokenInfo.expiresAt;
457
+
458
+ /** A KeyValueStore's id: the last segment of its ARN. */
459
+ function kvsIdFromArn(arn: string): string {
460
+ const id = arn.slice(arn.lastIndexOf("/") + 1);
461
+ if (!id || id === arn) throw new Error(`not a CloudFront KeyValueStore ARN: ${arn}`);
462
+ return id;
463
+ }
@@ -269,6 +269,18 @@ const siteDomain = new azure.cdn.AFDCustomDomain(`${namePrefix}-fd-domain`, {
269
269
  tlsSettings: { certificateType: "ManagedCertificate", minimumTlsVersion: "TLS12" },
270
270
  });
271
271
 
272
+ // #587 — the redirects table as rules-engine rules on the static route
273
+ // (redirect-emit-front-door.ts in @caelo-cms/shared): Front Door answers
274
+ // them at the edge before the Blob origin. The deploy owns the rules; the
275
+ // stack only creates the rule set and attaches it.
276
+ // FIXME(issue #750): the Blob static publisher is still a stub (#6); until
277
+ // it writes the rules, the set stays empty and no redirect is served.
278
+ const redirectRuleSet = new azure.cdn.RuleSet(`${namePrefix}-fd-redirects`, {
279
+ resourceGroupName: rg.name,
280
+ profileName: frontDoor.name,
281
+ ruleSetName: "caeloRedirects",
282
+ });
283
+
272
284
  new azure.cdn.Route(
273
285
  `${namePrefix}-fd-route`,
274
286
  {
@@ -278,6 +290,7 @@ new azure.cdn.Route(
278
290
  routeName: "static",
279
291
  originGroup: { id: staticOriginGroup.id },
280
292
  customDomains: [{ id: siteDomain.id }],
293
+ ruleSets: [{ id: redirectRuleSet.id }],
281
294
  patternsToMatch: ["/*"],
282
295
  // Without a cache configuration Front Door does not cache the route at
283
296
  // all; static files are edge-cached per their Cache-Control (§11.B
@@ -30,6 +30,7 @@ import {
30
30
  runServiceAccountId,
31
31
  staticPublisherServiceAccountId,
32
32
  } from "../../dist/gcp-names.js";
33
+ import { createSiteUrlMap } from "../../dist/gcp-url-map.js";
33
34
  import {
34
35
  ADMIN_MEMORY_DEFAULT,
35
36
  adminEnvContract,
@@ -323,6 +324,13 @@ const pgAdminUser = new gcp.sql.User(
323
324
  opts,
324
325
  );
325
326
 
327
+ // #633 — the Admin API makes this user a member of cloudsqlsuperuser, and
328
+ // the stack cannot declare "no roles" (an empty `databaseRoles` reads as
329
+ // unset, and the field is write-only, so drift would never show).
330
+ // `cms-provision` revokes the membership on every install pass and every
331
+ // `upgrade` (public-role-isolation.ts); the migration job's role audit
332
+ // fails the install or upgrade while it remains
333
+ // (packages/migrations/src/role-audit.ts).
326
334
  const pgPublicUser = new gcp.sql.User(
327
335
  `${namePrefix}-pg-public`,
328
336
  { instance: sqlInstance.name, name: "public_role", password: publicRolePassword },
@@ -997,29 +1005,28 @@ new gcp.iap.WebBackendServiceIamMember(
997
1005
  // URL map: routes by host header.
998
1006
  // admin.<domain> → admin backend (IAP-gated)
999
1007
  // <domain> → default static bucket; /api/* → gateway
1000
- const urlMap = new gcp.compute.URLMap(
1008
+ // #587 — the public path matcher also carries the redirects table as
1009
+ // `urlRedirect` rules, written by the admin on every Confirm-publish; the
1010
+ // stack ignores that field after creation (src/gcp-url-map.ts).
1011
+ const urlMap = createSiteUrlMap(
1001
1012
  `${namePrefix}-url-map`,
1002
1013
  {
1003
- defaultService: staticBackendBucket.id,
1004
- hostRules: [
1005
- { hosts: [domain], pathMatcher: "public" },
1006
- { hosts: [adminDomain], pathMatcher: "admin" },
1007
- ],
1008
- pathMatchers: [
1009
- {
1010
- name: "public",
1011
- defaultService: staticBackendBucket.id,
1012
- pathRules: [{ paths: ["/api/*"], service: gatewayBackendService.id }],
1013
- },
1014
- {
1015
- name: "admin",
1016
- defaultService: adminBackendService.id,
1017
- },
1018
- ],
1014
+ domain,
1015
+ adminDomain,
1016
+ staticBackend: staticBackendBucket.id,
1017
+ gatewayBackend: gatewayBackendService.id,
1018
+ adminBackend: adminBackendService.id,
1019
1019
  },
1020
1020
  opts,
1021
1021
  );
1022
1022
 
1023
+ // #587 — the admin's run SA may rewrite the URL map's redirect rules through
1024
+ // a project custom role. The CLI owns that role, not this stack: the
1025
+ // installer and `cms-provision upgrade` both run ensureUrlMapRedirectsAccess
1026
+ // (url-map-redirects-access.ts). A role declared here would collide with the
1027
+ // one upgrade creates on installs older than it (a role id exists once per
1028
+ // project, and a create of an existing one fails).
1029
+
1023
1030
  // Managed TLS cert covering both hostnames.
1024
1031
  const managedCert = new gcp.compute.ManagedSslCertificate(
1025
1032
  `${namePrefix}-cert`,
@@ -295,6 +295,13 @@ const pgAdminUser = new gcp.sql.User(
295
295
  { instance: sqlInstance.name, name: "admin_role", password: postgresPassword },
296
296
  opts,
297
297
  );
298
+ // #633 — the Admin API makes this user a member of cloudsqlsuperuser, and
299
+ // the stack cannot declare "no roles" (an empty `databaseRoles` reads as
300
+ // unset, and the field is write-only, so drift would never show).
301
+ // `cms-provision` revokes the membership on every install pass and every
302
+ // `upgrade` (public-role-isolation.ts); the migration job's role audit
303
+ // fails the install or upgrade while it remains
304
+ // (packages/migrations/src/role-audit.ts).
298
305
  new gcp.sql.User(
299
306
  `${namePrefix}-public-user`,
300
307
  { instance: sqlInstance.name, name: "public_role", password: publicRolePassword },
@@ -13,11 +13,11 @@ runtime:
13
13
  options:
14
14
  typescript: true
15
15
  packagemanager: bun
16
- description: Caelo self-hosted Docker Compose stack (Postgres + Caddy + MinIO + admin/gateway).
16
+ description: Caelo self-hosted Docker Compose stack (Postgres + Caddy + admin/gateway).
17
17
  config:
18
18
  caelo-self-hosted:domain:
19
19
  type: string
20
- description: Primary domain (e.g. example.com). Admin + production public both bind here; staging gets staging.<domain>.
20
+ description: Primary domain (e.g. example.com). The live site binds here, the admin to admin.<domain>, staging to staging.<domain>.
21
21
  caelo-self-hosted:ownerEmail:
22
22
  type: string
23
23
  description: Operator email used for ACME registration with Let's Encrypt.
@@ -23,11 +23,11 @@ pulumi config set caelo-self-hosted:gatewayImage ghcr.io/caelo-cms/gateway@sha25
23
23
  pulumi up
24
24
  ```
25
25
 
26
- Pulumi mints postgres + minio passwords, generates `docker-compose.yml` + `Caddyfile` + `pending-token.json` under `.caelo/`, and runs `docker compose up -d`. After the run:
26
+ Pulumi mints the postgres and role passwords and the KEK, generates `docker-compose.yml` + `Caddyfile` + `bootstrap.sh` + `pending-token.json` under `.caelo/`, starts Postgres, runs the migrations and runs `docker compose up -d`. Point DNS for `<domain>`, `admin.<domain>` and `staging.<domain>` at the host. After the run:
27
27
 
28
28
  ```bash
29
29
  pulumi stack output bootstrapUrl
30
- # → https://example.com/setup?token=<64-hex>
30
+ # → https://admin.example.com/setup?token=<64-hex>
31
31
  ```
32
32
 
33
33
  Open that URL once Caddy has issued the cert (~30 s).
@@ -38,7 +38,7 @@ Open that URL once Caddy has issued the cert (~30 s).
38
38
  pulumi destroy
39
39
  ```
40
40
 
41
- Tears down the compose stack with `-v`, deleting all volumes (Postgres + MinIO + Caddy data). The generated files are removed too.
41
+ Tears down the compose stack with `-v`, deleting all volumes (Postgres + Caddy data). The generated files are removed too.
42
42
 
43
43
  ## Why this exists alongside `cms-provision`
44
44