@caelo-cms/provisioning 0.10.49 → 0.10.51

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 (191) hide show
  1. package/caddy/Caddyfile.production +4 -0
  2. package/caddy/Caddyfile.staging +4 -0
  3. package/dist/adapter.d.ts +4 -6
  4. package/dist/adapter.d.ts.map +1 -1
  5. package/dist/admin-internal.d.ts +41 -0
  6. package/dist/admin-internal.d.ts.map +1 -0
  7. package/dist/admin-internal.js +169 -0
  8. package/dist/admin-internal.js.map +1 -0
  9. package/dist/agent-db.js +93 -20
  10. package/dist/aws-task-roles.d.ts +27 -0
  11. package/dist/aws-task-roles.d.ts.map +1 -0
  12. package/dist/aws-task-roles.js +35 -0
  13. package/dist/aws-task-roles.js.map +1 -0
  14. package/dist/caddy-redirects.d.ts +15 -0
  15. package/dist/caddy-redirects.d.ts.map +1 -0
  16. package/dist/caddy-redirects.js +65 -0
  17. package/dist/caddy-redirects.js.map +1 -0
  18. package/dist/caddy.d.ts +22 -2
  19. package/dist/caddy.d.ts.map +1 -1
  20. package/dist/caddy.js +66 -17
  21. package/dist/caddy.js.map +1 -1
  22. package/dist/cli-routing.d.ts.map +1 -1
  23. package/dist/cli-routing.js +7 -0
  24. package/dist/cli-routing.js.map +1 -1
  25. package/dist/cli.js +282 -255
  26. package/dist/cli.js.map +1 -1
  27. package/dist/compose.d.ts +54 -2
  28. package/dist/compose.d.ts.map +1 -1
  29. package/dist/compose.js +88 -34
  30. package/dist/compose.js.map +1 -1
  31. package/dist/deployed-release.d.ts +3 -2
  32. package/dist/deployed-release.d.ts.map +1 -1
  33. package/dist/deployed-release.js +12 -5
  34. package/dist/deployed-release.js.map +1 -1
  35. package/dist/gateway-credentials.d.ts.map +1 -1
  36. package/dist/gateway-credentials.js +6 -3
  37. package/dist/gateway-credentials.js.map +1 -1
  38. package/dist/gcloud.d.ts +2 -2
  39. package/dist/gcloud.d.ts.map +1 -1
  40. package/dist/gcloud.js +3 -3
  41. package/dist/gcloud.js.map +1 -1
  42. package/dist/gcp-names.d.ts +12 -0
  43. package/dist/gcp-names.d.ts.map +1 -1
  44. package/dist/gcp-names.js +21 -0
  45. package/dist/gcp-names.js.map +1 -1
  46. package/dist/gcp-url-map.d.ts +27 -0
  47. package/dist/gcp-url-map.d.ts.map +1 -0
  48. package/dist/gcp-url-map.js +62 -0
  49. package/dist/gcp-url-map.js.map +1 -0
  50. package/dist/index.d.ts +0 -1
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +0 -1
  53. package/dist/index.js.map +1 -1
  54. package/dist/infra-contract.d.ts +20 -1
  55. package/dist/infra-contract.d.ts.map +1 -1
  56. package/dist/infra-contract.js +44 -1
  57. package/dist/infra-contract.js.map +1 -1
  58. package/dist/owner-reset.d.ts +37 -0
  59. package/dist/owner-reset.d.ts.map +1 -0
  60. package/dist/owner-reset.js +80 -0
  61. package/dist/owner-reset.js.map +1 -0
  62. package/dist/public-role-isolation.d.ts +19 -0
  63. package/dist/public-role-isolation.d.ts.map +1 -0
  64. package/dist/public-role-isolation.js +69 -0
  65. package/dist/public-role-isolation.js.map +1 -0
  66. package/dist/release-contract.d.ts.map +1 -1
  67. package/dist/release-contract.js +3 -0
  68. package/dist/release-contract.js.map +1 -1
  69. package/dist/release-manifest.d.ts.map +1 -1
  70. package/dist/release-manifest.js +2 -1
  71. package/dist/release-manifest.js.map +1 -1
  72. package/dist/self-hosted-backup.d.ts +86 -0
  73. package/dist/self-hosted-backup.d.ts.map +1 -0
  74. package/dist/self-hosted-backup.js +350 -0
  75. package/dist/self-hosted-backup.js.map +1 -0
  76. package/dist/self-hosted-release.d.ts +12 -3
  77. package/dist/self-hosted-release.d.ts.map +1 -1
  78. package/dist/self-hosted-release.js +6 -4
  79. package/dist/self-hosted-release.js.map +1 -1
  80. package/dist/self-hosted-start.d.ts +50 -0
  81. package/dist/self-hosted-start.d.ts.map +1 -0
  82. package/dist/self-hosted-start.js +59 -0
  83. package/dist/self-hosted-start.js.map +1 -0
  84. package/dist/self-hosted-upgrade.d.ts +30 -0
  85. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  86. package/dist/self-hosted-upgrade.js +29 -7
  87. package/dist/self-hosted-upgrade.js.map +1 -1
  88. package/dist/stack-contract.d.ts +9 -10
  89. package/dist/stack-contract.d.ts.map +1 -1
  90. package/dist/stack-contract.js +21 -10
  91. package/dist/stack-contract.js.map +1 -1
  92. package/dist/staging-admin.d.ts +12 -20
  93. package/dist/staging-admin.d.ts.map +1 -1
  94. package/dist/staging-admin.js +7 -159
  95. package/dist/staging-admin.js.map +1 -1
  96. package/dist/tar-entries.d.ts +14 -0
  97. package/dist/tar-entries.d.ts.map +1 -0
  98. package/dist/tar-entries.js +104 -0
  99. package/dist/tar-entries.js.map +1 -0
  100. package/dist/upgrade/converge.d.ts +2 -2
  101. package/dist/upgrade/converge.d.ts.map +1 -1
  102. package/dist/upgrade/converge.js +44 -2
  103. package/dist/upgrade/converge.js.map +1 -1
  104. package/dist/upgrader-assess.d.ts.map +1 -1
  105. package/dist/upgrader-assess.js +2 -1
  106. package/dist/upgrader-assess.js.map +1 -1
  107. package/dist/url-map-redirects-access.d.ts +15 -0
  108. package/dist/url-map-redirects-access.d.ts.map +1 -0
  109. package/dist/url-map-redirects-access.js +81 -0
  110. package/dist/url-map-redirects-access.js.map +1 -0
  111. package/dist/wizard.d.ts.map +1 -1
  112. package/dist/wizard.js +3 -1
  113. package/dist/wizard.js.map +1 -1
  114. package/dist/wizards/gcp-cost.js +1 -1
  115. package/dist/wizards/gcp.d.ts.map +1 -1
  116. package/dist/wizards/gcp.js +45 -0
  117. package/dist/wizards/gcp.js.map +1 -1
  118. package/package.json +2 -2
  119. package/src/adapter.ts +4 -6
  120. package/src/admin-internal.ts +212 -0
  121. package/src/admin-memory.test.ts +2 -2
  122. package/src/aws-task-roles.ts +58 -0
  123. package/src/caddy-redirects.docker.test.ts +255 -0
  124. package/src/caddy-redirects.test.ts +115 -0
  125. package/src/caddy-redirects.ts +68 -0
  126. package/src/caddy-validate.test.ts +119 -0
  127. package/src/caddy.test.ts +108 -0
  128. package/src/caddy.ts +89 -20
  129. package/src/checkpoint-secrets.test.ts +0 -1
  130. package/src/cli-routing.test.ts +12 -0
  131. package/src/cli-routing.ts +7 -0
  132. package/src/cli.ts +340 -288
  133. package/src/compose.ts +108 -36
  134. package/src/deployed-release.test.ts +22 -4
  135. package/src/deployed-release.ts +14 -7
  136. package/src/gateway-credentials.ts +6 -3
  137. package/src/gateway-env-isolation.test.ts +212 -0
  138. package/src/gcloud.ts +5 -3
  139. package/src/gcp-names.ts +23 -0
  140. package/src/gcp-url-map.test.ts +150 -0
  141. package/src/gcp-url-map.ts +81 -0
  142. package/src/index.test.ts +24 -152
  143. package/src/index.ts +0 -9
  144. package/src/infra-contract.test.ts +40 -0
  145. package/src/infra-contract.ts +47 -1
  146. package/src/owner-reset.test.ts +165 -0
  147. package/src/owner-reset.ts +106 -0
  148. package/src/public-role-isolation.test.ts +89 -0
  149. package/src/public-role-isolation.ts +83 -0
  150. package/src/release-contract.ts +3 -0
  151. package/src/release-manifest.test.ts +12 -0
  152. package/src/release-manifest.ts +2 -1
  153. package/src/runtime-secrets.test.ts +25 -8
  154. package/src/self-hosted-backup.test.ts +344 -0
  155. package/src/self-hosted-backup.ts +480 -0
  156. package/src/self-hosted-compose.test.ts +88 -4
  157. package/src/self-hosted-release.ts +22 -7
  158. package/src/self-hosted-start.test.ts +68 -0
  159. package/src/self-hosted-start.ts +123 -0
  160. package/src/self-hosted-upgrade.ts +35 -8
  161. package/src/stack-contract.test.ts +11 -20
  162. package/src/stack-contract.ts +25 -16
  163. package/src/stack-converge-secrets.test.ts +45 -10
  164. package/src/stack-converge.test.ts +19 -10
  165. package/src/staging-admin.ts +18 -181
  166. package/src/tar-entries.test.ts +52 -0
  167. package/src/tar-entries.ts +116 -0
  168. package/src/upgrade/converge.ts +51 -4
  169. package/src/upgrade/upgrade.test.ts +78 -1
  170. package/src/upgrader-assess.test.ts +22 -0
  171. package/src/upgrader-assess.ts +2 -1
  172. package/src/url-map-redirects-access.test.ts +88 -0
  173. package/src/url-map-redirects-access.ts +89 -0
  174. package/src/wizard.ts +3 -1
  175. package/src/wizards/gcp-cost.ts +1 -1
  176. package/src/wizards/gcp.ts +48 -0
  177. package/stacks/aws/README.md +1 -1
  178. package/stacks/aws/index.ts +89 -45
  179. package/stacks/azure/README.md +1 -1
  180. package/stacks/azure/index.ts +21 -14
  181. package/stacks/gcp/README.md +1 -1
  182. package/stacks/gcp/index.ts +26 -22
  183. package/stacks/gcp-firebase/index.ts +7 -3
  184. package/stacks/self-hosted/Pulumi.yaml +2 -2
  185. package/stacks/self-hosted/README.md +3 -3
  186. package/stacks/self-hosted/index.ts +45 -22
  187. package/dist/redirects-emit.d.ts +0 -52
  188. package/dist/redirects-emit.d.ts.map +0 -1
  189. package/dist/redirects-emit.js +0 -111
  190. package/dist/redirects-emit.js.map +0 -1
  191. package/src/redirects-emit.ts +0 -175
@@ -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 },
@@ -654,7 +662,6 @@ const envContractInputs = {
654
662
  admin: dbUrls.admin,
655
663
  publicAdmin: dbUrls.publicAdmin,
656
664
  public: dbUrls.public,
657
- gateway: dbUrls.gateway,
658
665
  },
659
666
  } as const;
660
667
 
@@ -745,8 +752,8 @@ const gatewaySvc = cloudRunService({
745
752
  minInstances: gatewayMinInstances,
746
753
  maxInstances: 100,
747
754
  memory: "512Mi",
748
- // public_role on cms_public + gateway_role on cms_admin, each with its
749
- // own password; never admin_role (#613, CLAUDE.md §2).
755
+ // public_role on cms_public only: no cms_admin login of any role
756
+ // (#613, #635, CLAUDE.md §2).
750
757
  contractEnv: gatewayEnvContract(envContractInputs),
751
758
  });
752
759
 
@@ -997,29 +1004,28 @@ new gcp.iap.WebBackendServiceIamMember(
997
1004
  // URL map: routes by host header.
998
1005
  // admin.<domain> → admin backend (IAP-gated)
999
1006
  // <domain> → default static bucket; /api/* → gateway
1000
- const urlMap = new gcp.compute.URLMap(
1007
+ // #587 — the public path matcher also carries the redirects table as
1008
+ // `urlRedirect` rules, written by the admin on every Confirm-publish; the
1009
+ // stack ignores that field after creation (src/gcp-url-map.ts).
1010
+ const urlMap = createSiteUrlMap(
1001
1011
  `${namePrefix}-url-map`,
1002
1012
  {
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
- ],
1013
+ domain,
1014
+ adminDomain,
1015
+ staticBackend: staticBackendBucket.id,
1016
+ gatewayBackend: gatewayBackendService.id,
1017
+ adminBackend: adminBackendService.id,
1019
1018
  },
1020
1019
  opts,
1021
1020
  );
1022
1021
 
1022
+ // #587 — the admin's run SA may rewrite the URL map's redirect rules through
1023
+ // a project custom role. The CLI owns that role, not this stack: the
1024
+ // installer and `cms-provision upgrade` both run ensureUrlMapRedirectsAccess
1025
+ // (url-map-redirects-access.ts). A role declared here would collide with the
1026
+ // one upgrade creates on installs older than it (a role id exists once per
1027
+ // project, and a create of an existing one fails).
1028
+
1023
1029
  // Managed TLS cert covering both hostnames.
1024
1030
  const managedCert = new gcp.compute.ManagedSslCertificate(
1025
1031
  `${namePrefix}-cert`,
@@ -1151,7 +1157,6 @@ const out: CloudAdapterOutputs = {
1151
1157
  // Password-less — the password lives only in Secret Manager.
1152
1158
  adminDatabaseUrl: dbUrls.admin as unknown as string,
1153
1159
  publicDatabaseUrl: dbUrls.public as unknown as string,
1154
- gatewayDatabaseUrl: dbUrls.gateway as unknown as string,
1155
1160
  mediaStorageUrl: pulumi.interpolate`gs://${mediaBucket.name}` as unknown as string,
1156
1161
  mediaCdnBaseUrl: pulumi.interpolate`https://${domain}/media` as unknown as string,
1157
1162
  bootstrapUrl:
@@ -1165,7 +1170,6 @@ const out: CloudAdapterOutputs = {
1165
1170
 
1166
1171
  export const adminDatabaseUrlOut = out.adminDatabaseUrl;
1167
1172
  export const publicDatabaseUrlOut = out.publicDatabaseUrl;
1168
- export const gatewayDatabaseUrlOut = out.gatewayDatabaseUrl;
1169
1173
  export const mediaStorageUrlOut = out.mediaStorageUrl;
1170
1174
  export const mediaCdnBaseUrlOut = out.mediaCdnBaseUrl;
1171
1175
  export const bootstrapUrlOut = out.bootstrapUrl;
@@ -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 },
@@ -599,7 +606,6 @@ const envContractInputs = {
599
606
  admin: dbUrls.admin,
600
607
  publicAdmin: dbUrls.publicAdmin,
601
608
  public: dbUrls.public,
602
- gateway: dbUrls.gateway,
603
609
  },
604
610
  } as const;
605
611
 
@@ -981,7 +987,6 @@ const out: CloudAdapterOutputs = {
981
987
  // Password-less — the password lives only in Secret Manager.
982
988
  adminDatabaseUrl: dbUrls.admin as unknown as string,
983
989
  publicDatabaseUrl: dbUrls.public as unknown as string,
984
- gatewayDatabaseUrl: dbUrls.gateway as unknown as string,
985
990
  mediaStorageUrl: pulumi.interpolate`gs://${mediaBucket.name}` as unknown as string,
986
991
  // Firebase Hosting serves media via its own CDN; we proxy through
987
992
  // the Firebase site's apex domain for canonical URLs.
@@ -998,7 +1003,6 @@ const out: CloudAdapterOutputs = {
998
1003
 
999
1004
  export const adminDatabaseUrlOut = out.adminDatabaseUrl;
1000
1005
  export const publicDatabaseUrlOut = out.publicDatabaseUrl;
1001
- export const gatewayDatabaseUrlOut = out.gatewayDatabaseUrl;
1002
1006
  export const mediaStorageUrlOut = out.mediaStorageUrl;
1003
1007
  export const mediaCdnBaseUrlOut = out.mediaCdnBaseUrl;
1004
1008
  export const bootstrapUrlOut = out.bootstrapUrl;
@@ -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
 
@@ -9,7 +9,8 @@
9
9
  * written to `<caeloDir>/pending-token.json`. Captured as a Pulumi
10
10
  * output so operators can read `pulumi stack output bootstrapUrl`
11
11
  * after `pulumi up`.
12
- * 3. `caeloDockerCompose` — runs `docker compose up -d` and tears down
12
+ * 3. `caeloDockerCompose` — starts Postgres, runs the migrations, runs
13
+ * `docker compose up -d` and tears down
13
14
  * with `docker compose down -v` on destroy.
14
15
  *
15
16
  * Why thin: Caelo's self-hosted stack is fundamentally a single Compose
@@ -24,8 +25,15 @@ import { resolve } from "node:path";
24
25
  import { local } from "@pulumi/command";
25
26
  import * as pulumi from "@pulumi/pulumi";
26
27
  import { generateBootstrapToken } from "../../dist/bootstrap-token.js";
27
- import { generateCaddyfile } from "../../dist/caddy.js";
28
- import { generateDockerCompose } from "../../dist/compose.js";
28
+ import { generateCaddyfile, selfHostedAdminHost, selfHostedSeedDomains } from "../../dist/caddy.js";
29
+ import {
30
+ COMPOSE_ADMIN_UPSTREAM,
31
+ COMPOSE_GATEWAY_UPSTREAM,
32
+ composeProjectName,
33
+ generateDockerCompose,
34
+ SITE_OUTPUT_ROOT,
35
+ } from "../../dist/compose.js";
36
+ import { selfHostedBootstrapScript } from "../../dist/self-hosted-roles.js";
29
37
 
30
38
  const cfg = new pulumi.Config();
31
39
  const domain = cfg.require("domain");
@@ -44,6 +52,8 @@ const gatewayImage = cfg.require("gatewayImage");
44
52
 
45
53
  const composePath = resolve(caeloDir, "docker-compose.yml");
46
54
  const caddyPath = resolve(caeloDir, "Caddyfile");
55
+ // What Postgres runs on its first start: the roles + databases (compose.ts mounts it).
56
+ const bootstrapPath = resolve(caeloDir, "bootstrap.sh");
47
57
  // The admin container mounts only `<caeloDir>/bootstrap/` (compose.ts).
48
58
  const tokenPath = resolve(caeloDir, "bootstrap", "pending-token.json");
49
59
 
@@ -56,44 +66,45 @@ const postgresPassword = pulumi.secret(randomHex(32));
56
66
  const adminRolePassword = pulumi.secret(randomHex(32));
57
67
  const publicRolePassword = pulumi.secret(randomHex(32));
58
68
  const gatewayRolePassword = pulumi.secret(randomHex(32));
59
- const minioRootUser = "caelo";
60
- const minioRootPassword = pulumi.secret(randomHex(32));
69
+ // P18 — the KEK that encrypts stored provider keys.
70
+ const caeloSecretKek = pulumi.secret(randomHex(32));
61
71
 
62
72
  const composeYaml = pulumi
63
73
  .all([
64
74
  postgresPassword,
65
- minioRootPassword,
66
75
  adminRolePassword,
67
76
  publicRolePassword,
68
77
  gatewayRolePassword,
78
+ caeloSecretKek,
69
79
  ])
70
- .apply(([pgPw, minioPw, adminPw, publicPw, gatewayPw]) =>
80
+ .apply(([pgPw, adminPw, publicPw, gatewayPw, kek]) =>
71
81
  generateDockerCompose({
72
82
  domain,
73
83
  postgresPassword: pgPw,
74
84
  rolePasswords: { admin: adminPw, public: publicPw, gateway: gatewayPw },
75
- minioRootUser,
76
- minioRootPassword: minioPw,
85
+ caeloSecretKek: kek,
77
86
  anthropicApiKey: process.env.ANTHROPIC_API_KEY,
78
87
  resendApiKey: process.env.RESEND_API_KEY,
79
88
  diskSize: "20Gi",
80
89
  ...(adminMemory ? { adminMemory } : {}),
81
90
  images: { mode: "release", admin: adminImage, gateway: gatewayImage },
91
+ ports: { http: 80, https: 443 },
92
+ project: composeProjectName(domain),
82
93
  }),
83
94
  );
84
95
 
96
+ // Same generator inputs as `cms-provision init` (#707): the admin on
97
+ // admin.<domain>, Caddy reaching the services by their Compose names.
85
98
  const caddyConf = generateCaddyfile({
86
99
  ownerEmail,
87
- publicSiteRoot: "/srv/caelo/output/production/current",
88
- stagingSiteRoot: "/srv/caelo/output/staging/current",
89
- adminPort: 5173,
90
- gatewayPort: 8090,
91
- domains: [
92
- { hostname: domain, kind: "admin", env: "production" as const },
93
- { hostname: domain, kind: "public", env: "production" as const },
94
- { hostname: stagingHost, kind: "public", env: "staging" as const },
95
- ],
100
+ tls: "acme",
101
+ publicSiteRoot: `${SITE_OUTPUT_ROOT}/output/production/current`,
102
+ stagingSiteRoot: `${SITE_OUTPUT_ROOT}/output/staging/current`,
103
+ adminUpstream: COMPOSE_ADMIN_UPSTREAM,
104
+ gatewayUpstream: COMPOSE_GATEWAY_UPSTREAM,
105
+ domains: selfHostedSeedDomains(domain, stagingHost),
96
106
  });
107
+ const bootstrapScript = selfHostedBootstrapScript({ agent: false });
97
108
 
98
109
  // Mint the owner bootstrap token at preview time. Captured below as a
99
110
  // Pulumi output so the URL surfaces on `pulumi up`.
@@ -111,8 +122,12 @@ EOF
111
122
  cat > ${tokenPath} <<'EOF'
112
123
  ${JSON.stringify(tokenInfo, null, 2)}
113
124
  EOF
125
+ cat > ${bootstrapPath} <<'EOF'
126
+ ${bootstrapScript}
127
+ EOF
128
+ chmod 755 ${bootstrapPath}
114
129
  `,
115
- delete: pulumi.interpolate`rm -f ${composePath} ${caddyPath} ${tokenPath}`,
130
+ delete: pulumi.interpolate`rm -f ${composePath} ${caddyPath} ${tokenPath} ${bootstrapPath}`,
116
131
  triggers: [composeYaml, caddyConf],
117
132
  });
118
133
 
@@ -121,7 +136,15 @@ EOF
121
136
  const composeUp = new local.Command(
122
137
  "caeloDockerCompose",
123
138
  {
124
- create: pulumi.interpolate`docker compose -f ${composePath} up -d`,
139
+ // Same order as `cms-provision init` (self-hosted-start.ts): Postgres
140
+ // (its first start runs bootstrap.sh), the migrations with the
141
+ // install's admin image, then everything else.
142
+ create: pulumi.interpolate`set -e
143
+ docker compose -f ${composePath} up -d --wait postgres
144
+ for target in admin public; do
145
+ docker compose -f ${composePath} run --rm --no-deps -T caelo-admin bun --bun /app/packages/migrations/src/migrate.ts "$target"
146
+ done
147
+ docker compose -f ${composePath} up -d --remove-orphans`,
125
148
  delete: pulumi.interpolate`docker compose -f ${composePath} down -v`,
126
149
  triggers: [composeYaml],
127
150
  },
@@ -130,9 +153,9 @@ const composeUp = new local.Command(
130
153
 
131
154
  export const composeFilePath = composePath;
132
155
  export const caddyFilePath = caddyPath;
133
- export const bootstrapUrl = pulumi.interpolate`https://${domain}/setup?token=${tokenInfo.token}`;
156
+ export const bootstrapUrl = pulumi.interpolate`https://${selfHostedAdminHost(domain)}/setup?token=${tokenInfo.token}`;
134
157
  export const bootstrapTokenExpiresAt = tokenInfo.expiresAt;
135
- export { minioRootPassword, postgresPassword };
158
+ export { postgresPassword };
136
159
 
137
160
  // Re-export so the destroy summary shows operators what they'll lose.
138
161
  export const composeRunOutput = composeUp.stdout;
@@ -1,52 +0,0 @@
1
- export type RedirectStatusCode = 301 | 302 | 307 | 308;
2
- export interface RedirectRow {
3
- readonly fromPath: string;
4
- readonly toPath: string;
5
- readonly statusCode: RedirectStatusCode;
6
- }
7
- /**
8
- * Cloudflare Pages `_redirects` format (also consumed by Netlify, Vercel,
9
- * Render, etc.). One redirect per line: `<from> <to> <status>`. Wildcards
10
- * use `:splat`; we only support exact-path redirects in v1 (matches what
11
- * Caelo's redirects table allows).
12
- */
13
- export declare function emitRedirectsCloudflare(rows: ReadonlyArray<RedirectRow>): string;
14
- /**
15
- * CloudFront via Lambda@Edge. Returns a JSON config the L@E function
16
- * reads at startup + a tiny Lambda source that consumes it. The L@E
17
- * function is deployed to `us-east-1` (CloudFront constraint); the
18
- * config blob is bundled into the Lambda deployment artifact.
19
- *
20
- * Returning both halves lets the AWS stack ship a single asset.
21
- */
22
- export interface CloudFrontRedirectArtifact {
23
- readonly jsonConfig: string;
24
- readonly lambdaSource: string;
25
- }
26
- export declare function emitRedirectsCloudFront(rows: ReadonlyArray<RedirectRow>): CloudFrontRedirectArtifact;
27
- /**
28
- * Azure Front Door rules engine. Returns a Pulumi-compatible
29
- * RuleEngineRule[]-shaped array (untyped here so callers don't need to
30
- * import @pulumi/azure-native). Each rule fires on a path-equals match
31
- * + executes a 301/302/etc. response.
32
- */
33
- export interface FrontDoorRule {
34
- readonly name: string;
35
- readonly order: number;
36
- readonly conditions: ReadonlyArray<{
37
- readonly name: "RequestUri";
38
- readonly parameters: {
39
- readonly operator: "Equal";
40
- readonly matchValues: ReadonlyArray<string>;
41
- };
42
- }>;
43
- readonly actions: ReadonlyArray<{
44
- readonly name: "UrlRedirect";
45
- readonly parameters: {
46
- readonly redirectType: "Moved" | "Found" | "TemporaryRedirect" | "PermanentRedirect";
47
- readonly destinationPath: string;
48
- };
49
- }>;
50
- }
51
- export declare function emitRedirectsAzureFrontDoor(rows: ReadonlyArray<RedirectRow>): ReadonlyArray<FrontDoorRule>;
52
- //# sourceMappingURL=redirects-emit.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"redirects-emit.d.ts","sourceRoot":"","sources":["../src/redirects-emit.ts"],"names":[],"mappings":"AAkBA,MAAM,MAAM,kBAAkB,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;AAEvD,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,UAAU,EAAE,kBAAkB,CAAC;CACzC;AAiCD;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,aAAa,CAAC,WAAW,CAAC,GAAG,MAAM,CAKhF;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B;AAED,wBAAgB,uBAAuB,CACrC,IAAI,EAAE,aAAa,CAAC,WAAW,CAAC,GAC/B,0BAA0B,CAwB5B;AAED;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,UAAU,EAAE,aAAa,CAAC;QACjC,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;QAC5B,QAAQ,CAAC,UAAU,EAAE;YACnB,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;YAC3B,QAAQ,CAAC,WAAW,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;SAC7C,CAAC;KACH,CAAC,CAAC;IACH,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC;QAC9B,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;QAC7B,QAAQ,CAAC,UAAU,EAAE;YACnB,QAAQ,CAAC,YAAY,EAAE,OAAO,GAAG,OAAO,GAAG,mBAAmB,GAAG,mBAAmB,CAAC;YACrF,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;SAClC,CAAC;KACH,CAAC,CAAC;CACJ;AAED,wBAAgB,2BAA2B,CACzC,IAAI,EAAE,aAAa,CAAC,WAAW,CAAC,GAC/B,aAAa,CAAC,aAAa,CAAC,CAqB9B"}
@@ -1,111 +0,0 @@
1
- // SPDX-License-Identifier: MPL-2.0
2
- /**
3
- * P15 — per-provider redirect file generators.
4
- *
5
- * P14's self-hosted Caddy consumes a `_redirects.caddy` file. Cloud
6
- * providers want different formats: Cloudflare Pages reads `_redirects`
7
- * (also a fine fit for any platform that consumes that format),
8
- * CloudFront wants a JSON config a Lambda@Edge function reads at
9
- * startup, and Azure Front Door wants a rules-engine RuleEngineRule[].
10
- *
11
- * The deploy step calls the right emitter based on `process.env.CAELO_PROVIDER`
12
- * and uploads the result to the right place. Each emitter is pure +
13
- * idempotent — same input → same byte output.
14
- */
15
- import { redirectTargetIssue } from "@caelo-cms/shared";
16
- /**
17
- * Reject whitespace + control chars in either path before any emitter
18
- * touches them. The Cloudflare `_redirects` format is space-delimited;
19
- * a `fromPath` containing a space ambiguates the line (CF parses
20
- * `/old foo /new 301` as from=`/old`, to=`foo`, status=`/new`, then
21
- * fails on `301`). Caelo's redirects table normally rejects these
22
- * upstream but the emitter shouldn't trust that — pure functions
23
- * defend their own contract.
24
- */
25
- function assertPathClean(label, path) {
26
- // \p{Cc} matches the Unicode control category (U+0000–U+001F + U+007F–U+009F);
27
- // the linter rejects literal \x00-\x1f ranges in regex, but the intent — defending
28
- // emitter contracts against control chars — is the explicit reason for this check.
29
- if (/[\s\p{Cc}]/u.test(path)) {
30
- throw new Error(`redirects-emit: ${label}=${JSON.stringify(path)} contains whitespace or control chars; reject upstream`);
31
- }
32
- }
33
- function assertRowsClean(rows) {
34
- for (const r of rows) {
35
- assertPathClean("fromPath", r.fromPath);
36
- assertPathClean("toPath", r.toPath);
37
- // #657 — a `//host` or `/https://…` target would ship as an open
38
- // redirect or a broken 301 in every CDN format below.
39
- const targetIssue = redirectTargetIssue(r.toPath);
40
- if (targetIssue)
41
- throw new Error(`redirects-emit: ${targetIssue}`);
42
- }
43
- }
44
- /**
45
- * Cloudflare Pages `_redirects` format (also consumed by Netlify, Vercel,
46
- * Render, etc.). One redirect per line: `<from> <to> <status>`. Wildcards
47
- * use `:splat`; we only support exact-path redirects in v1 (matches what
48
- * Caelo's redirects table allows).
49
- */
50
- export function emitRedirectsCloudflare(rows) {
51
- assertRowsClean(rows);
52
- const header = "# Generated by @caelo-cms/provisioning — do not edit by hand.\n";
53
- const lines = rows.map((r) => `${r.fromPath} ${r.toPath} ${r.statusCode}`);
54
- return header + lines.join("\n") + (rows.length > 0 ? "\n" : "");
55
- }
56
- export function emitRedirectsCloudFront(rows) {
57
- assertRowsClean(rows);
58
- const jsonConfig = JSON.stringify({ redirects: rows.map((r) => ({ from: r.fromPath, to: r.toPath, status: r.statusCode })) }, null, 2);
59
- // Tiny Lambda@Edge handler. v1 is exact-path match (matches Caelo's
60
- // redirects table). Wildcard support lands when the table grows it.
61
- const lambdaSource = `// SPDX-License-Identifier: MPL-2.0
62
- // Generated by @caelo-cms/provisioning. Bundled with redirects.json.
63
- const REDIRECTS = require("./redirects.json").redirects;
64
- const TABLE = new Map(REDIRECTS.map((r) => [r.from, r]));
65
- exports.handler = (event, _ctx, callback) => {
66
- const req = event.Records[0].cf.request;
67
- const m = TABLE.get(req.uri);
68
- if (!m) return callback(null, req);
69
- callback(null, {
70
- status: String(m.status),
71
- statusDescription: "Redirect",
72
- headers: { location: [{ key: "Location", value: m.to }] },
73
- });
74
- };`;
75
- return { jsonConfig, lambdaSource };
76
- }
77
- export function emitRedirectsAzureFrontDoor(rows) {
78
- assertRowsClean(rows);
79
- return rows.map((r, i) => ({
80
- name: `redirect-${i + 1}`,
81
- order: i + 1,
82
- conditions: [
83
- {
84
- name: "RequestUri",
85
- parameters: { operator: "Equal", matchValues: [r.fromPath] },
86
- },
87
- ],
88
- actions: [
89
- {
90
- name: "UrlRedirect",
91
- parameters: {
92
- redirectType: statusToFrontDoor(r.statusCode),
93
- destinationPath: r.toPath,
94
- },
95
- },
96
- ],
97
- }));
98
- }
99
- function statusToFrontDoor(status) {
100
- switch (status) {
101
- case 301:
102
- return "Moved";
103
- case 302:
104
- return "Found";
105
- case 307:
106
- return "TemporaryRedirect";
107
- case 308:
108
- return "PermanentRedirect";
109
- }
110
- }
111
- //# sourceMappingURL=redirects-emit.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"redirects-emit.js","sourceRoot":"","sources":["../src/redirects-emit.ts"],"names":[],"mappings":"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"}
@@ -1,175 +0,0 @@
1
- // SPDX-License-Identifier: MPL-2.0
2
-
3
- /**
4
- * P15 — per-provider redirect file generators.
5
- *
6
- * P14's self-hosted Caddy consumes a `_redirects.caddy` file. Cloud
7
- * providers want different formats: Cloudflare Pages reads `_redirects`
8
- * (also a fine fit for any platform that consumes that format),
9
- * CloudFront wants a JSON config a Lambda@Edge function reads at
10
- * startup, and Azure Front Door wants a rules-engine RuleEngineRule[].
11
- *
12
- * The deploy step calls the right emitter based on `process.env.CAELO_PROVIDER`
13
- * and uploads the result to the right place. Each emitter is pure +
14
- * idempotent — same input → same byte output.
15
- */
16
-
17
- import { redirectTargetIssue } from "@caelo-cms/shared";
18
-
19
- export type RedirectStatusCode = 301 | 302 | 307 | 308;
20
-
21
- export interface RedirectRow {
22
- readonly fromPath: string;
23
- readonly toPath: string;
24
- readonly statusCode: RedirectStatusCode;
25
- }
26
-
27
- /**
28
- * Reject whitespace + control chars in either path before any emitter
29
- * touches them. The Cloudflare `_redirects` format is space-delimited;
30
- * a `fromPath` containing a space ambiguates the line (CF parses
31
- * `/old foo /new 301` as from=`/old`, to=`foo`, status=`/new`, then
32
- * fails on `301`). Caelo's redirects table normally rejects these
33
- * upstream but the emitter shouldn't trust that — pure functions
34
- * defend their own contract.
35
- */
36
- function assertPathClean(label: "fromPath" | "toPath", path: string): void {
37
- // \p{Cc} matches the Unicode control category (U+0000–U+001F + U+007F–U+009F);
38
- // the linter rejects literal \x00-\x1f ranges in regex, but the intent — defending
39
- // emitter contracts against control chars — is the explicit reason for this check.
40
- if (/[\s\p{Cc}]/u.test(path)) {
41
- throw new Error(
42
- `redirects-emit: ${label}=${JSON.stringify(path)} contains whitespace or control chars; reject upstream`,
43
- );
44
- }
45
- }
46
-
47
- function assertRowsClean(rows: ReadonlyArray<RedirectRow>): void {
48
- for (const r of rows) {
49
- assertPathClean("fromPath", r.fromPath);
50
- assertPathClean("toPath", r.toPath);
51
- // #657 — a `//host` or `/https://…` target would ship as an open
52
- // redirect or a broken 301 in every CDN format below.
53
- const targetIssue = redirectTargetIssue(r.toPath);
54
- if (targetIssue) throw new Error(`redirects-emit: ${targetIssue}`);
55
- }
56
- }
57
-
58
- /**
59
- * Cloudflare Pages `_redirects` format (also consumed by Netlify, Vercel,
60
- * Render, etc.). One redirect per line: `<from> <to> <status>`. Wildcards
61
- * use `:splat`; we only support exact-path redirects in v1 (matches what
62
- * Caelo's redirects table allows).
63
- */
64
- export function emitRedirectsCloudflare(rows: ReadonlyArray<RedirectRow>): string {
65
- assertRowsClean(rows);
66
- const header = "# Generated by @caelo-cms/provisioning — do not edit by hand.\n";
67
- const lines = rows.map((r) => `${r.fromPath} ${r.toPath} ${r.statusCode}`);
68
- return header + lines.join("\n") + (rows.length > 0 ? "\n" : "");
69
- }
70
-
71
- /**
72
- * CloudFront via Lambda@Edge. Returns a JSON config the L@E function
73
- * reads at startup + a tiny Lambda source that consumes it. The L@E
74
- * function is deployed to `us-east-1` (CloudFront constraint); the
75
- * config blob is bundled into the Lambda deployment artifact.
76
- *
77
- * Returning both halves lets the AWS stack ship a single asset.
78
- */
79
- export interface CloudFrontRedirectArtifact {
80
- readonly jsonConfig: string;
81
- readonly lambdaSource: string;
82
- }
83
-
84
- export function emitRedirectsCloudFront(
85
- rows: ReadonlyArray<RedirectRow>,
86
- ): CloudFrontRedirectArtifact {
87
- assertRowsClean(rows);
88
- const jsonConfig = JSON.stringify(
89
- { redirects: rows.map((r) => ({ from: r.fromPath, to: r.toPath, status: r.statusCode })) },
90
- null,
91
- 2,
92
- );
93
- // Tiny Lambda@Edge handler. v1 is exact-path match (matches Caelo's
94
- // redirects table). Wildcard support lands when the table grows it.
95
- const lambdaSource = `// SPDX-License-Identifier: MPL-2.0
96
- // Generated by @caelo-cms/provisioning. Bundled with redirects.json.
97
- const REDIRECTS = require("./redirects.json").redirects;
98
- const TABLE = new Map(REDIRECTS.map((r) => [r.from, r]));
99
- exports.handler = (event, _ctx, callback) => {
100
- const req = event.Records[0].cf.request;
101
- const m = TABLE.get(req.uri);
102
- if (!m) return callback(null, req);
103
- callback(null, {
104
- status: String(m.status),
105
- statusDescription: "Redirect",
106
- headers: { location: [{ key: "Location", value: m.to }] },
107
- });
108
- };`;
109
- return { jsonConfig, lambdaSource };
110
- }
111
-
112
- /**
113
- * Azure Front Door rules engine. Returns a Pulumi-compatible
114
- * RuleEngineRule[]-shaped array (untyped here so callers don't need to
115
- * import @pulumi/azure-native). Each rule fires on a path-equals match
116
- * + executes a 301/302/etc. response.
117
- */
118
- export interface FrontDoorRule {
119
- readonly name: string;
120
- readonly order: number;
121
- readonly conditions: ReadonlyArray<{
122
- readonly name: "RequestUri";
123
- readonly parameters: {
124
- readonly operator: "Equal";
125
- readonly matchValues: ReadonlyArray<string>;
126
- };
127
- }>;
128
- readonly actions: ReadonlyArray<{
129
- readonly name: "UrlRedirect";
130
- readonly parameters: {
131
- readonly redirectType: "Moved" | "Found" | "TemporaryRedirect" | "PermanentRedirect";
132
- readonly destinationPath: string;
133
- };
134
- }>;
135
- }
136
-
137
- export function emitRedirectsAzureFrontDoor(
138
- rows: ReadonlyArray<RedirectRow>,
139
- ): ReadonlyArray<FrontDoorRule> {
140
- assertRowsClean(rows);
141
- return rows.map((r, i) => ({
142
- name: `redirect-${i + 1}`,
143
- order: i + 1,
144
- conditions: [
145
- {
146
- name: "RequestUri" as const,
147
- parameters: { operator: "Equal" as const, matchValues: [r.fromPath] },
148
- },
149
- ],
150
- actions: [
151
- {
152
- name: "UrlRedirect" as const,
153
- parameters: {
154
- redirectType: statusToFrontDoor(r.statusCode),
155
- destinationPath: r.toPath,
156
- },
157
- },
158
- ],
159
- }));
160
- }
161
-
162
- function statusToFrontDoor(
163
- status: RedirectStatusCode,
164
- ): FrontDoorRule["actions"][0]["parameters"]["redirectType"] {
165
- switch (status) {
166
- case 301:
167
- return "Moved";
168
- case 302:
169
- return "Found";
170
- case 307:
171
- return "TemporaryRedirect";
172
- case 308:
173
- return "PermanentRedirect";
174
- }
175
- }