@caelo-cms/provisioning 0.10.48 → 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 (150) hide show
  1. package/caddy/Caddyfile.production +5 -1
  2. package/caddy/Caddyfile.staging +5 -1
  3. package/dist/agent-db.js +102 -58
  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 +2 -41
  45. package/dist/release-manifest.d.ts.map +1 -1
  46. package/dist/release-manifest.js +20 -122
  47. package/dist/release-manifest.js.map +1 -1
  48. package/dist/self-hosted-backup.d.ts +86 -0
  49. package/dist/self-hosted-backup.d.ts.map +1 -0
  50. package/dist/self-hosted-backup.js +350 -0
  51. package/dist/self-hosted-backup.js.map +1 -0
  52. package/dist/self-hosted-release.d.ts +12 -3
  53. package/dist/self-hosted-release.d.ts.map +1 -1
  54. package/dist/self-hosted-release.js +6 -4
  55. package/dist/self-hosted-release.js.map +1 -1
  56. package/dist/self-hosted-start.d.ts +50 -0
  57. package/dist/self-hosted-start.d.ts.map +1 -0
  58. package/dist/self-hosted-start.js +59 -0
  59. package/dist/self-hosted-start.js.map +1 -0
  60. package/dist/self-hosted-upgrade.d.ts +30 -0
  61. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  62. package/dist/self-hosted-upgrade.js +29 -7
  63. package/dist/self-hosted-upgrade.js.map +1 -1
  64. package/dist/tar-entries.d.ts +14 -0
  65. package/dist/tar-entries.d.ts.map +1 -0
  66. package/dist/tar-entries.js +104 -0
  67. package/dist/tar-entries.js.map +1 -0
  68. package/dist/upgrade/converge.d.ts +2 -2
  69. package/dist/upgrade/converge.d.ts.map +1 -1
  70. package/dist/upgrade/converge.js +44 -2
  71. package/dist/upgrade/converge.js.map +1 -1
  72. package/dist/upgrade/release-images.d.ts +14 -2
  73. package/dist/upgrade/release-images.d.ts.map +1 -1
  74. package/dist/upgrade/release-images.js +4 -2
  75. package/dist/upgrade/release-images.js.map +1 -1
  76. package/dist/upgrader-assess.d.ts +4 -1
  77. package/dist/upgrader-assess.d.ts.map +1 -1
  78. package/dist/upgrader-assess.js +8 -1
  79. package/dist/upgrader-assess.js.map +1 -1
  80. package/dist/upgrader-job.d.ts +3 -1
  81. package/dist/upgrader-job.d.ts.map +1 -1
  82. package/dist/upgrader-job.js.map +1 -1
  83. package/dist/url-map-redirects-access.d.ts +15 -0
  84. package/dist/url-map-redirects-access.d.ts.map +1 -0
  85. package/dist/url-map-redirects-access.js +81 -0
  86. package/dist/url-map-redirects-access.js.map +1 -0
  87. package/dist/wizard.d.ts.map +1 -1
  88. package/dist/wizard.js +3 -1
  89. package/dist/wizard.js.map +1 -1
  90. package/dist/wizards/gcp.d.ts.map +1 -1
  91. package/dist/wizards/gcp.js +45 -0
  92. package/dist/wizards/gcp.js.map +1 -1
  93. package/package.json +2 -2
  94. package/src/admin-memory.test.ts +2 -2
  95. package/src/agent/run.test.ts +55 -0
  96. package/src/caddy-redirects.docker.test.ts +255 -0
  97. package/src/caddy-redirects.test.ts +115 -0
  98. package/src/caddy-redirects.ts +68 -0
  99. package/src/caddy-validate.test.ts +119 -0
  100. package/src/caddy.test.ts +108 -0
  101. package/src/caddy.ts +89 -20
  102. package/src/cli-routing.test.ts +12 -0
  103. package/src/cli-routing.ts +7 -0
  104. package/src/cli.ts +299 -287
  105. package/src/compose.ts +106 -34
  106. package/src/gcp-names.ts +23 -0
  107. package/src/gcp-url-map.test.ts +150 -0
  108. package/src/gcp-url-map.ts +81 -0
  109. package/src/index.test.ts +18 -146
  110. package/src/index.ts +0 -9
  111. package/src/infra-contract.test.ts +39 -0
  112. package/src/infra-contract.ts +42 -1
  113. package/src/public-role-isolation.test.ts +89 -0
  114. package/src/public-role-isolation.ts +83 -0
  115. package/src/release-contract.ts +3 -0
  116. package/src/release-manifest.test.ts +20 -1
  117. package/src/release-manifest.ts +39 -158
  118. package/src/self-hosted-backup.test.ts +344 -0
  119. package/src/self-hosted-backup.ts +480 -0
  120. package/src/self-hosted-compose.test.ts +88 -4
  121. package/src/self-hosted-release.ts +22 -7
  122. package/src/self-hosted-start.test.ts +68 -0
  123. package/src/self-hosted-start.ts +123 -0
  124. package/src/self-hosted-upgrade.ts +35 -8
  125. package/src/stack-contract.test.ts +2 -2
  126. package/src/tar-entries.test.ts +52 -0
  127. package/src/tar-entries.ts +116 -0
  128. package/src/upgrade/converge.ts +51 -4
  129. package/src/upgrade/release-images.ts +4 -2
  130. package/src/upgrade/upgrade.test.ts +78 -1
  131. package/src/upgrader-assess.test.ts +64 -1
  132. package/src/upgrader-assess.ts +20 -2
  133. package/src/upgrader-job.test.ts +3 -0
  134. package/src/upgrader-job.ts +3 -0
  135. package/src/url-map-redirects-access.test.ts +88 -0
  136. package/src/url-map-redirects-access.ts +89 -0
  137. package/src/wizard.ts +3 -1
  138. package/src/wizards/gcp.ts +48 -0
  139. package/stacks/aws/index.ts +34 -2
  140. package/stacks/azure/index.ts +13 -0
  141. package/stacks/gcp/index.ts +24 -17
  142. package/stacks/gcp-firebase/index.ts +7 -0
  143. package/stacks/self-hosted/Pulumi.yaml +2 -2
  144. package/stacks/self-hosted/README.md +3 -3
  145. package/stacks/self-hosted/index.ts +45 -22
  146. package/dist/redirects-emit.d.ts +0 -52
  147. package/dist/redirects-emit.d.ts.map +0 -1
  148. package/dist/redirects-emit.js +0 -111
  149. package/dist/redirects-emit.js.map +0 -1
  150. package/src/redirects-emit.ts +0 -175
@@ -0,0 +1,115 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * #587 — self-hosted wiring for the redirects table: each public vhost
5
+ * imports its build's `_redirects.caddy`, and the Caddy container reloads
6
+ * itself when a deploy changes it (caddy-redirects.ts). The snippet and the
7
+ * loop run against a real Caddy in caddy-redirects.docker.test.ts.
8
+ */
9
+
10
+ import { describe, expect, it } from "bun:test";
11
+ import { readFileSync } from "node:fs";
12
+ import { resolve } from "node:path";
13
+ import { generateCaddyfile, selfHostedSeedDomains } from "./caddy.js";
14
+ import { caddyRedirectReloadCommand, caddyRedirectReloadScript } from "./caddy-redirects.js";
15
+ import {
16
+ COMPOSE_ADMIN_UPSTREAM,
17
+ COMPOSE_GATEWAY_UPSTREAM,
18
+ COMPOSE_REDIRECT_SNIPPETS,
19
+ generateDockerCompose,
20
+ } from "./compose.js";
21
+
22
+ const caddyfile = generateCaddyfile({
23
+ ownerEmail: "owner@example.com",
24
+ tls: "acme",
25
+ publicSiteRoot: "/srv/caelo/output/production/current",
26
+ stagingSiteRoot: "/srv/caelo/output/staging/current",
27
+ adminUpstream: COMPOSE_ADMIN_UPSTREAM,
28
+ gatewayUpstream: COMPOSE_GATEWAY_UPSTREAM,
29
+ domains: selfHostedSeedDomains("example.com", "staging.example.com"),
30
+ });
31
+
32
+ /** The site block of `host`: from its `<host> {` line to the next `}` line. */
33
+ function block(host: string): string {
34
+ const lines = caddyfile.split("\n");
35
+ const start = lines.indexOf(`${host} {`);
36
+ if (start === -1) return "";
37
+ const end = lines.indexOf("}", start);
38
+ return lines.slice(start, end + 1).join("\n");
39
+ }
40
+
41
+ describe("Caddyfile — redirects (#587)", () => {
42
+ it("each public vhost imports the redirect snippet of the build it serves", () => {
43
+ expect(block("example.com")).toContain(
44
+ " root * /srv/caelo/output/production/current\n import /srv/caelo/output/production/current/_redirects.caddy*\n",
45
+ );
46
+ expect(block("staging.example.com")).toContain(
47
+ "import /srv/caelo/output/staging/current/_redirects.caddy*",
48
+ );
49
+ expect(block("admin.example.com")).not.toContain("_redirects.caddy");
50
+ });
51
+
52
+ it("the dev Caddyfiles import the snippet of /srv/current", () => {
53
+ for (const env of ["production", "staging"]) {
54
+ const src = readFileSync(resolve(import.meta.dir, `../caddy/Caddyfile.${env}`), "utf8");
55
+ expect(src).toContain("\timport /srv/current/_redirects.caddy*\n");
56
+ }
57
+ });
58
+ });
59
+
60
+ describe("Compose — Caddy picks up new redirects (#587)", () => {
61
+ const yaml = generateDockerCompose({
62
+ domain: "example.com",
63
+ postgresPassword: "pgpw",
64
+ rolePasswords: { admin: "a", public: "p", gateway: "g" },
65
+ caeloSecretKek: "0".repeat(64),
66
+ diskSize: "20Gi",
67
+ ports: { http: 80, https: 443 },
68
+ project: "caelo-example-com",
69
+ images: {
70
+ mode: "release",
71
+ admin: `ghcr.io/caelo-cms/admin@sha256:${"a".repeat(64)}`,
72
+ gateway: `ghcr.io/caelo-cms/gateway@sha256:${"b".repeat(64)}`,
73
+ },
74
+ });
75
+ const service = (name: string) => {
76
+ const start = yaml.indexOf(` ${name}:\n`);
77
+ const end = yaml.indexOf("\n\n", start);
78
+ return yaml.slice(start, end === -1 ? undefined : end);
79
+ };
80
+
81
+ it("runs Caddy with the loop that reloads it on a changed snippet", () => {
82
+ expect(service("caddy")).toContain(
83
+ ` ${caddyRedirectReloadCommand(COMPOSE_REDIRECT_SNIPPETS)}\n`,
84
+ );
85
+ expect(COMPOSE_REDIRECT_SNIPPETS).toBe("/srv/caelo/output/*/current/_redirects.caddy");
86
+ // The loop names no container or project: it runs inside the Caddy
87
+ // container of whichever Compose project this is.
88
+ expect(caddyRedirectReloadCommand(COMPOSE_REDIRECT_SNIPPETS)).not.toMatch(
89
+ /caelo-caddy|COMPOSE_PROJECT|docker/,
90
+ );
91
+ expect(yaml).not.toContain("caddy-admin");
92
+ });
93
+ });
94
+
95
+ describe("dev docker-compose.yml (#587)", () => {
96
+ it("runs both dev Caddies with the same reload loop", () => {
97
+ const src = readFileSync(resolve(import.meta.dir, "../../../docker-compose.yml"), "utf8");
98
+ const line = ` ${caddyRedirectReloadCommand("/srv/current/_redirects.caddy")}\n`;
99
+ expect(src.split(line)).toHaveLength(3);
100
+ });
101
+ });
102
+
103
+ describe("caddyRedirectReloadScript", () => {
104
+ it("refuses a glob that could break out of the shell script", () => {
105
+ expect(() => caddyRedirectReloadScript("/srv/x; rm -rf /")).toThrow(/unsafe snippet glob/);
106
+ });
107
+
108
+ it("doubles every $ for Compose, nothing else", () => {
109
+ const script = caddyRedirectReloadScript("/srv/current/_redirects.caddy");
110
+ const cmd = caddyRedirectReloadCommand("/srv/current/_redirects.caddy");
111
+ const parsed = JSON.parse(cmd.slice("command: ".length)) as string[];
112
+ expect(parsed.slice(0, 2)).toEqual(["/bin/sh", "-c"]);
113
+ expect(parsed[2]?.replaceAll("$$", "$")).toBe(script);
114
+ });
115
+ });
@@ -0,0 +1,68 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * How the self-hosted Caddy picks up a deploy's redirect snippet (#587).
5
+ *
6
+ * Each public site block imports `<site root>/_redirects.caddy`, which the
7
+ * static generator rewrites on every build. Caddy reads imports only when it
8
+ * loads a config, so something must reload it after a deploy. The container
9
+ * does that itself: its command runs Caddy plus a small loop that watches a
10
+ * checksum of the snippets and runs `caddy reload` (against Caddy's own
11
+ * localhost admin endpoint, inside the container) when they change.
12
+ *
13
+ * Why not the alternatives:
14
+ * - `caddy run --watch` stops watching for good after the first config that
15
+ * fails to load — a snippet read while a deploy is still copying it would
16
+ * freeze the redirects until Caddy restarts.
17
+ * - The admin reloading Caddy through the admin API would need a channel
18
+ * from the admin into Caddy and would post a file's content over HTTP.
19
+ *
20
+ * The loop waits until the checksum is stable across two reads before
21
+ * reloading (a deploy may be mid-copy), and a reload Caddy rejects only
22
+ * logs: Caddy keeps the previous config, and the next change retries.
23
+ */
24
+
25
+ /** Seconds between checksum polls. */
26
+ const POLL_SECONDS = 2;
27
+
28
+ /**
29
+ * The container's shell script: Caddy in the foreground of the container's
30
+ * lifetime, reloaded when any file matching `snippetGlob` changes.
31
+ *
32
+ * @param snippetGlob Shell glob of the imported snippets, e.g.
33
+ * `/srv/caelo/output/*\/current/_redirects.caddy`.
34
+ */
35
+ export function caddyRedirectReloadScript(snippetGlob: string): string {
36
+ if (/[\s'"`$\\;&|<>]/.test(snippetGlob)) {
37
+ throw new Error(`caddy-redirects: unsafe snippet glob ${JSON.stringify(snippetGlob)}`);
38
+ }
39
+ const config = "--config /etc/caddy/Caddyfile --adapter caddyfile";
40
+ return [
41
+ `sum() { cat ${snippetGlob} 2>/dev/null | md5sum; }`,
42
+ "last=$(sum)",
43
+ `caddy run ${config} &`,
44
+ "pid=$!",
45
+ `trap 'kill -TERM "$pid" 2>/dev/null; wait "$pid"; exit $?' TERM INT`,
46
+ `while kill -0 "$pid" 2>/dev/null; do`,
47
+ ` sleep ${POLL_SECONDS}`,
48
+ " now=$(sum)",
49
+ ` [ "$now" = "$last" ] && continue`,
50
+ // A deploy may still be copying the file: reload only a stable one.
51
+ " sleep 1",
52
+ ` [ "$(sum)" = "$now" ] || continue`,
53
+ " last=$now",
54
+ ` caddy reload ${config} || echo "caelo: redirects changed but Caddy rejected the reload; still serving the previous redirects" >&2`,
55
+ "done",
56
+ `wait "$pid"`,
57
+ ].join("\n");
58
+ }
59
+
60
+ /**
61
+ * A Compose `command:` line running {@link caddyRedirectReloadScript}. `$` is
62
+ * doubled because Compose interpolates `$VAR` in the file; the JSON string is
63
+ * a valid YAML double-quoted scalar.
64
+ */
65
+ export function caddyRedirectReloadCommand(snippetGlob: string): string {
66
+ const script = caddyRedirectReloadScript(snippetGlob).replaceAll("$", () => "$$");
67
+ return `command: ["/bin/sh", "-c", ${JSON.stringify(script)}]`;
68
+ }
@@ -0,0 +1,119 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The generated Caddyfile through the real Caddy (issue #566): `caddy
5
+ * validate` on the exact image the Compose file runs ({@link CADDY_IMAGE}).
6
+ * String assertions cannot catch a directive Caddy refuses — #566 shipped a
7
+ * `try_files` nested in `file_server { }` that every unit test accepted and
8
+ * Caddy 2.11 rejected, so no self-hosted vhost came up.
9
+ *
10
+ * Needs Docker. CI sets `CAELO_CADDY_VALIDATE=required`, which turns a
11
+ * missing Docker into a failure instead of a skip, so this check can never
12
+ * silently stop running there.
13
+ */
14
+
15
+ import { describe, expect, it } from "bun:test";
16
+ import { readFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { type CaddyfileSpec, generateCaddyfile, selfHostedSeedDomains } from "./caddy.js";
19
+ import { CADDY_IMAGE, COMPOSE_ADMIN_UPSTREAM, COMPOSE_GATEWAY_UPSTREAM } from "./compose.js";
20
+
21
+ const REQUIRED = process.env.CAELO_CADDY_VALIDATE === "required";
22
+
23
+ function dockerAvailable(): boolean {
24
+ try {
25
+ return Bun.spawnSync(["docker", "info"], { stdout: "ignore", stderr: "ignore" }).exitCode === 0;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ const HAVE_DOCKER = dockerAvailable();
32
+
33
+ /** `caddy validate` with the Caddyfile on stdin (no bind mount, so it works on any Docker host). */
34
+ async function caddyValidate(caddyfile: string): Promise<{ exitCode: number; output: string }> {
35
+ const proc = Bun.spawn(
36
+ [
37
+ "docker",
38
+ "run",
39
+ "--rm",
40
+ "-i",
41
+ CADDY_IMAGE,
42
+ "caddy",
43
+ "validate",
44
+ "--config",
45
+ "/dev/stdin",
46
+ "--adapter",
47
+ "caddyfile",
48
+ ],
49
+ { stdin: new TextEncoder().encode(caddyfile), stdout: "pipe", stderr: "pipe" },
50
+ );
51
+ const [stdout, stderr] = await Promise.all([
52
+ new Response(proc.stdout).text(),
53
+ new Response(proc.stderr).text(),
54
+ ]);
55
+ return { exitCode: await proc.exited, output: `${stdout}\n${stderr}` };
56
+ }
57
+
58
+ const base: CaddyfileSpec = {
59
+ ownerEmail: "owner@example.com",
60
+ tls: "acme",
61
+ publicSiteRoot: "/srv/caelo/output/production/current",
62
+ stagingSiteRoot: "/srv/caelo/output/staging/current",
63
+ adminUpstream: COMPOSE_ADMIN_UPSTREAM,
64
+ gatewayUpstream: COMPOSE_GATEWAY_UPSTREAM,
65
+ domains: selfHostedSeedDomains("example.com", "staging.example.com"),
66
+ };
67
+
68
+ const CASES: Record<string, string> = {
69
+ "the seed vhosts `init` writes (ACME)": generateCaddyfile(base),
70
+ "the seed vhosts with --tls internal": generateCaddyfile({ ...base, tls: "internal" }),
71
+ "seed + operator-added admin and public domains": generateCaddyfile({
72
+ ...base,
73
+ domains: [
74
+ ...base.domains,
75
+ { hostname: "www.example.org", kind: "public", env: "production" },
76
+ { hostname: "cms.example.org", kind: "admin", env: "production" },
77
+ ],
78
+ }),
79
+ "no domains yet (the :8081 block)": generateCaddyfile({ ...base, domains: [] }),
80
+ "caddy/Caddyfile.production": readFileSync(
81
+ join(import.meta.dir, "..", "caddy", "Caddyfile.production"),
82
+ "utf8",
83
+ ),
84
+ "caddy/Caddyfile.staging": readFileSync(
85
+ join(import.meta.dir, "..", "caddy", "Caddyfile.staging"),
86
+ "utf8",
87
+ ),
88
+ };
89
+
90
+ describe(`generated Caddyfiles pass \`caddy validate\` on ${CADDY_IMAGE} (#566)`, () => {
91
+ if (REQUIRED) {
92
+ it("Docker is available (CAELO_CADDY_VALIDATE=required)", () => {
93
+ expect(HAVE_DOCKER).toBe(true);
94
+ });
95
+ }
96
+ it.skipIf(!HAVE_DOCKER)(
97
+ "rejects the #566 shape (proves the check is not a no-op)",
98
+ async () => {
99
+ const r = await caddyValidate(
100
+ ":80 {\n file_server {\n try_files {path} {path}/index.html\n }\n}\n",
101
+ );
102
+ expect(r.exitCode).not.toBe(0);
103
+ expect(r.output).toContain("unknown subdirective 'try_files'");
104
+ },
105
+ 180_000,
106
+ );
107
+ for (const [name, caddyfile] of Object.entries(CASES)) {
108
+ it.skipIf(!HAVE_DOCKER)(
109
+ name,
110
+ async () => {
111
+ const r = await caddyValidate(caddyfile);
112
+ if (r.exitCode !== 0) console.error(r.output);
113
+ expect(r.exitCode).toBe(0);
114
+ expect(r.output).toContain("Valid configuration");
115
+ },
116
+ 180_000,
117
+ );
118
+ }
119
+ });
@@ -0,0 +1,108 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import {
5
+ type CaddyfileSpec,
6
+ generateCaddyfile,
7
+ selfHostedAdminHost,
8
+ selfHostedSeedDomains,
9
+ } from "./caddy.js";
10
+ import { COMPOSE_ADMIN_UPSTREAM, COMPOSE_GATEWAY_UPSTREAM } from "./compose.js";
11
+
12
+ /** The inputs `cms-provision init` passes for example.com. */
13
+ const spec: CaddyfileSpec = {
14
+ ownerEmail: "owner@example.com",
15
+ tls: "acme",
16
+ publicSiteRoot: "/srv/caelo/output/production/current",
17
+ stagingSiteRoot: "/srv/caelo/output/staging/current",
18
+ adminUpstream: COMPOSE_ADMIN_UPSTREAM,
19
+ gatewayUpstream: COMPOSE_GATEWAY_UPSTREAM,
20
+ domains: selfHostedSeedDomains("example.com", "staging.example.com"),
21
+ };
22
+
23
+ /** One site block, from its address line to its closing brace. */
24
+ function block(caddyfile: string, host: string): string {
25
+ const start = caddyfile.indexOf(`\n${host} {\n`);
26
+ if (start === -1) throw new Error(`no site block for ${host}`);
27
+ return caddyfile.slice(start + 1, caddyfile.indexOf("\n}\n", start) + 2);
28
+ }
29
+
30
+ describe("self-hosted seed vhosts", () => {
31
+ it("puts the admin on admin.<domain>, the live and the staging site on their own hosts", () => {
32
+ expect(selfHostedSeedDomains("example.com", "staging.example.com")).toEqual([
33
+ { hostname: "admin.example.com", kind: "admin", env: "production" },
34
+ { hostname: "example.com", kind: "public", env: "production" },
35
+ { hostname: "staging.example.com", kind: "public", env: "staging" },
36
+ ]);
37
+ expect(selfHostedAdminHost("example.com")).toBe("admin.example.com");
38
+ });
39
+
40
+ it("refuses two vhosts for one host — Caddy would reject the whole file", () => {
41
+ expect(() =>
42
+ generateCaddyfile({
43
+ ...spec,
44
+ domains: [
45
+ { hostname: "example.com", kind: "admin", env: "production" },
46
+ { hostname: "example.com", kind: "public", env: "production" },
47
+ ],
48
+ }),
49
+ ).toThrow(/example\.com is listed twice/);
50
+ });
51
+ });
52
+
53
+ describe("Caddyfile upstreams (#707)", () => {
54
+ const out = generateCaddyfile(spec);
55
+
56
+ it("proxies to the Compose service names, never to localhost (Caddy's own container)", () => {
57
+ expect(out).not.toContain("localhost");
58
+ expect(out).not.toContain("127.0.0.1");
59
+ expect(block(out, "admin.example.com")).toContain("reverse_proxy caelo-admin:5173");
60
+ for (const host of ["example.com", "staging.example.com"]) {
61
+ expect(block(out, host)).toContain("handle /api/* {\n reverse_proxy caelo-gateway:8090\n");
62
+ }
63
+ });
64
+
65
+ it("does not route /admin/* on the public site (the admin routes from / on its own host)", () => {
66
+ expect(block(out, "example.com")).not.toContain("/admin");
67
+ });
68
+ });
69
+
70
+ describe("Caddyfile static site (#566)", () => {
71
+ const publicBlock = block(generateCaddyfile(spec), "example.com");
72
+
73
+ it("emits try_files as its own directive before file_server, never inside it", () => {
74
+ expect(publicBlock).toContain(
75
+ " handle {\n try_files {path} {path}/index.html\n file_server\n }\n",
76
+ );
77
+ expect(publicBlock).not.toMatch(/file_server \{[^}]*try_files/);
78
+ });
79
+
80
+ it("answers a missing path with a 404 and the generator's 404 page, not the home page", () => {
81
+ expect(publicBlock).not.toContain("/index.html /index.html");
82
+ expect(publicBlock).toContain(
83
+ " handle_errors 404 {\n rewrite * /404.html\n file_server\n }\n",
84
+ );
85
+ });
86
+
87
+ it("serves production and staging from their own roots; only staging is noindex", () => {
88
+ const out = generateCaddyfile(spec);
89
+ expect(block(out, "example.com")).toContain("root * /srv/caelo/output/production/current");
90
+ expect(block(out, "example.com")).not.toContain("X-Robots-Tag");
91
+ const staging = block(out, "staging.example.com");
92
+ expect(staging).toContain("root * /srv/caelo/output/staging/current");
93
+ expect(staging).toContain('header X-Robots-Tag "noindex"');
94
+ });
95
+ });
96
+
97
+ describe("Caddyfile TLS", () => {
98
+ it("uses ACME with the Owner's email by default", () => {
99
+ const out = generateCaddyfile(spec);
100
+ expect(out.startsWith("{\n email owner@example.com\n}\n")).toBe(true);
101
+ expect(out).not.toContain("local_certs");
102
+ });
103
+
104
+ it("issues every certificate from Caddy's local CA with tls internal", () => {
105
+ const out = generateCaddyfile({ ...spec, tls: "internal" });
106
+ expect(out.startsWith("{\n email owner@example.com\n local_certs\n}\n")).toBe(true);
107
+ });
108
+ });
package/src/caddy.ts CHANGED
@@ -5,18 +5,32 @@
5
5
  *
6
6
  * Reads the `domains` table + the deploy targets and emits a
7
7
  * deterministic Caddyfile string. The CLI's `regenerate-caddy`
8
- * sub-command writes this to `/etc/caddy/Caddyfile` and runs
8
+ * sub-command writes this to `.caelo/Caddyfile` and runs
9
9
  * `caddy reload`.
10
10
  *
11
11
  * Per-vhost shape:
12
12
  * <hostname> {
13
- * # admin → reverse_proxy localhost:5173
14
- * # public → root + try_files (static), with /api/* → gateway
15
- * tls <ownerEmail>
13
+ * # admin → reverse_proxy <adminUpstream>
14
+ * # public → /api/* → <gatewayUpstream>; everything else is the
15
+ * # static site (try_files + file_server, 404 → /404.html)
16
16
  * }
17
17
  *
18
+ * Upstreams are `host:port` as seen FROM CADDY. In the Compose install
19
+ * Caddy runs in its own container, where `localhost` is Caddy itself, so
20
+ * the CLI passes the Compose service names (`caelo-admin:5173`,
21
+ * `caelo-gateway:8090`) — issue #707.
22
+ *
23
+ * Every directive here must be one Caddy accepts: the output is checked
24
+ * with `caddy validate` on the pinned Caddy image (caddy-validate.test.ts,
25
+ * run in CI) — issue #566 shipped a `try_files` nested inside
26
+ * `file_server { }`, which Caddy 2.11 refuses, so no vhost came up.
27
+ *
18
28
  * Staging vhosts force `X-Robots-Tag: noindex`.
19
29
  *
30
+ * Public vhosts import the redirect snippet of the build they serve
31
+ * (`_redirects.caddy`, #587); the Caddy container reloads itself when a
32
+ * deploy changes it (caddy-redirects.ts).
33
+ *
20
34
  * Public vhosts mark content-hashed build outputs (fonts, plugin
21
35
  * bundles — `CONTENT_HASHED_PATH_PATTERN` in @caelo-cms/shared)
22
36
  * `immutable` for a year. Everything else keeps Caddy's file_server
@@ -26,13 +40,22 @@
26
40
  * hashed URL (an old page requesting an asset the new build dropped)
27
41
  * is never cached for a year.
28
42
  *
43
+ * A path that matches no file is a real 404 serving the generator's
44
+ * `/404.html` — never the home page with a 200 (an SPA-style
45
+ * `/index.html` fallback would turn every dead URL into a soft 404).
46
+ * Same behaviour as the GCS / S3 / Blob adapters (CLAUDE.md §11.B).
47
+ *
29
48
  * `.ico` files (theme favicons) are served as `image/x-icon`, the one
30
49
  * icon MIME the media library stores and `<link rel="icon" type>`
31
50
  * declares. file_server only fills Content-Type when no earlier handler
32
51
  * set it, so the `header` line wins over the host's mime.types name.
33
52
  */
34
53
 
35
- import { CONTENT_HASHED_PATH_PATTERN, IMMUTABLE_CACHE_CONTROL } from "@caelo-cms/shared";
54
+ import {
55
+ CONTENT_HASHED_PATH_PATTERN,
56
+ caddyRedirectsImport,
57
+ IMMUTABLE_CACHE_CONTROL,
58
+ } from "@caelo-cms/shared";
36
59
 
37
60
  export interface CaddyDomainSpec {
38
61
  readonly hostname: string;
@@ -40,21 +63,64 @@ export interface CaddyDomainSpec {
40
63
  readonly env: "production" | "staging";
41
64
  }
42
65
 
66
+ /**
67
+ * How Caddy gets certificates: `acme` — publicly trusted ones from Let's
68
+ * Encrypt / ZeroSSL (needs public DNS + ports 80/443 reachable); `internal`
69
+ * — Caddy's own local CA, for a LAN or evaluation install without public
70
+ * DNS (browsers warn until the CA root is trusted).
71
+ */
72
+ export type CaddyTlsMode = "acme" | "internal";
73
+
43
74
  export interface CaddyfileSpec {
44
75
  readonly ownerEmail: string;
45
- readonly publicSiteRoot: string; // absolute path on disk
76
+ readonly tls: CaddyTlsMode;
77
+ readonly publicSiteRoot: string; // absolute path inside the Caddy container
46
78
  readonly stagingSiteRoot: string;
47
- readonly adminPort: number;
48
- readonly gatewayPort: number;
79
+ /** `host:port` of the admin as Caddy reaches it (Compose: `caelo-admin:5173`). */
80
+ readonly adminUpstream: string;
81
+ /** `host:port` of the API gateway as Caddy reaches it (Compose: `caelo-gateway:8090`). */
82
+ readonly gatewayUpstream: string;
49
83
  readonly domains: ReadonlyArray<CaddyDomainSpec>;
50
84
  }
51
85
 
86
+ /** The host the admin of a self-hosted install answers on (same convention as GCP). */
87
+ export function selfHostedAdminHost(domain: string): string {
88
+ return `admin.${domain}`;
89
+ }
90
+
91
+ /**
92
+ * The vhosts every self-hosted install serves before an Owner adds domains
93
+ * in the admin: the admin on `admin.<domain>`, the live site on `<domain>`,
94
+ * the staging site on its own host. The admin needs its own host — it
95
+ * routes from `/` (`/setup`, `/login`, …), so it cannot share one with the
96
+ * public site, and Caddy refuses two site blocks for the same address.
97
+ */
98
+ export function selfHostedSeedDomains(domain: string, stagingDomain: string): CaddyDomainSpec[] {
99
+ return [
100
+ { hostname: selfHostedAdminHost(domain), kind: "admin", env: "production" },
101
+ { hostname: domain, kind: "public", env: "production" },
102
+ { hostname: stagingDomain, kind: "public", env: "staging" },
103
+ ];
104
+ }
105
+
52
106
  export function generateCaddyfile(spec: CaddyfileSpec): string {
107
+ const hostnames = new Set<string>();
108
+ for (const d of spec.domains) {
109
+ if (hostnames.has(d.hostname)) {
110
+ // Caddy refuses a config with two site blocks for one address, so a
111
+ // duplicate would take every vhost down, not just this one.
112
+ throw new Error(
113
+ `Caddyfile: ${d.hostname} is listed twice — one host serves either the admin or a public site, not both.`,
114
+ );
115
+ }
116
+ hostnames.add(d.hostname);
117
+ }
118
+
53
119
  const blocks: string[] = [];
54
120
 
55
121
  // Global options.
56
122
  blocks.push(`{
57
- email ${spec.ownerEmail}
123
+ email ${spec.ownerEmail}${spec.tls === "internal" ? "\n local_certs" : ""}
58
124
  }
59
125
  `);
60
126
 
@@ -68,7 +134,7 @@ export function generateCaddyfile(spec: CaddyfileSpec): string {
68
134
  blocks.push(`# No domains configured yet — cms-provision regenerate-caddy
69
135
  # will overwrite this file when you add one at /settings/domains.
70
136
  :8081 {
71
- reverse_proxy localhost:${spec.adminPort}
137
+ reverse_proxy ${spec.adminUpstream}
72
138
  }
73
139
  `);
74
140
  }
@@ -80,15 +146,18 @@ function vhost(d: CaddyDomainSpec, spec: CaddyfileSpec): string {
80
146
  const noindex = d.env === "staging" ? `\n header X-Robots-Tag "noindex"` : "";
81
147
  if (d.kind === "admin") {
82
148
  return `${d.hostname} {${noindex}
83
- reverse_proxy localhost:${spec.adminPort}
149
+ reverse_proxy ${spec.adminUpstream}
84
150
  }
85
151
  `;
86
152
  }
87
- // public — API routes go to the gateway,
88
- // the rest serves static files. Locale variants serve from a
153
+ // public — API routes go to the gateway, the rest is the static site.
154
+ // `try_files` is its own directive (a rewrite), never a file_server
155
+ // subdirective (#566).
89
156
  const root = d.env === "staging" ? spec.stagingSiteRoot : spec.publicSiteRoot;
157
+ // #587 — the redirects table of the build this vhost serves.
90
158
  return `${d.hostname} {${noindex}
91
159
  root * ${root}
160
+ ${caddyRedirectsImport(root)}
92
161
  @content_hashed {
93
162
  path_regexp content_hashed ${CONTENT_HASHED_PATH_PATTERN}
94
163
  file
@@ -97,15 +166,15 @@ function vhost(d: CaddyDomainSpec, spec: CaddyfileSpec): string {
97
166
  @ico path *.ico
98
167
  header @ico Content-Type "image/x-icon"
99
168
  handle /api/* {
100
- reverse_proxy localhost:${spec.gatewayPort}
101
- }
102
- handle /admin/* {
103
- reverse_proxy localhost:${spec.adminPort}
169
+ reverse_proxy ${spec.gatewayUpstream}
104
170
  }
105
171
  handle {
106
- file_server {
107
- try_files {path} {path}/index.html /index.html
108
- }
172
+ try_files {path} {path}/index.html
173
+ file_server
174
+ }
175
+ handle_errors 404 {
176
+ rewrite * /404.html
177
+ file_server
109
178
  }
110
179
  }
111
180
  `;
@@ -13,6 +13,18 @@ describe("resolveCliRoute", () => {
13
13
  ).toEqual({ kind: "wizard" });
14
14
  });
15
15
 
16
+ it("routes the self-hosted one-command install to init (#707)", () => {
17
+ expect(
18
+ resolveCliRoute(
19
+ argv("--provider", "self-hosted", "--domain", "example.com", "--owner-email", "o@x.y"),
20
+ HANDLERS,
21
+ ),
22
+ ).toEqual({ kind: "handler", name: "init" });
23
+ expect(resolveCliRoute(argv("--provider", "self-hosted", "--help"), HANDLERS)).toEqual({
24
+ kind: "usage",
25
+ });
26
+ });
27
+
16
28
  it("routes a bare invocation to the wizard", () => {
17
29
  expect(resolveCliRoute(argv(), HANDLERS)).toEqual({ kind: "wizard" });
18
30
  });
@@ -30,6 +30,13 @@ export function resolveCliRoute(argv: readonly string[], handlerNames: Iterable<
30
30
  if (argv.includes("--no-wizard") || argv.includes("--help") || argv.includes("-h")) {
31
31
  return { kind: "usage" };
32
32
  }
33
+ // #707 — `--provider self-hosted --domain … --owner-email …` is the
34
+ // self-hosted one-command install: `init` writes `.caelo/` and starts the
35
+ // stack. (The cloud wizard has nothing to provision for it.)
36
+ const provider = argv.indexOf("--provider");
37
+ if (provider !== -1 && argv[provider + 1] === "self-hosted") {
38
+ return { kind: "handler", name: "init" };
39
+ }
33
40
  return { kind: "wizard" };
34
41
  }
35
42