@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.
- package/caddy/Caddyfile.production +4 -0
- package/caddy/Caddyfile.staging +4 -0
- package/dist/adapter.d.ts +4 -6
- package/dist/adapter.d.ts.map +1 -1
- package/dist/admin-internal.d.ts +41 -0
- package/dist/admin-internal.d.ts.map +1 -0
- package/dist/admin-internal.js +169 -0
- package/dist/admin-internal.js.map +1 -0
- package/dist/agent-db.js +93 -20
- package/dist/aws-task-roles.d.ts +27 -0
- package/dist/aws-task-roles.d.ts.map +1 -0
- package/dist/aws-task-roles.js +35 -0
- package/dist/aws-task-roles.js.map +1 -0
- package/dist/caddy-redirects.d.ts +15 -0
- package/dist/caddy-redirects.d.ts.map +1 -0
- package/dist/caddy-redirects.js +65 -0
- package/dist/caddy-redirects.js.map +1 -0
- package/dist/caddy.d.ts +22 -2
- package/dist/caddy.d.ts.map +1 -1
- package/dist/caddy.js +66 -17
- package/dist/caddy.js.map +1 -1
- package/dist/cli-routing.d.ts.map +1 -1
- package/dist/cli-routing.js +7 -0
- package/dist/cli-routing.js.map +1 -1
- package/dist/cli.js +282 -255
- package/dist/cli.js.map +1 -1
- package/dist/compose.d.ts +54 -2
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +88 -34
- package/dist/compose.js.map +1 -1
- package/dist/deployed-release.d.ts +3 -2
- package/dist/deployed-release.d.ts.map +1 -1
- package/dist/deployed-release.js +12 -5
- package/dist/deployed-release.js.map +1 -1
- package/dist/gateway-credentials.d.ts.map +1 -1
- package/dist/gateway-credentials.js +6 -3
- package/dist/gateway-credentials.js.map +1 -1
- package/dist/gcloud.d.ts +2 -2
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +3 -3
- package/dist/gcloud.js.map +1 -1
- package/dist/gcp-names.d.ts +12 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +21 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/gcp-url-map.d.ts +27 -0
- package/dist/gcp-url-map.d.ts.map +1 -0
- package/dist/gcp-url-map.js +62 -0
- package/dist/gcp-url-map.js.map +1 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +20 -1
- package/dist/infra-contract.d.ts.map +1 -1
- package/dist/infra-contract.js +44 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/owner-reset.d.ts +37 -0
- package/dist/owner-reset.d.ts.map +1 -0
- package/dist/owner-reset.js +80 -0
- package/dist/owner-reset.js.map +1 -0
- package/dist/public-role-isolation.d.ts +19 -0
- package/dist/public-role-isolation.d.ts.map +1 -0
- package/dist/public-role-isolation.js +69 -0
- package/dist/public-role-isolation.js.map +1 -0
- package/dist/release-contract.d.ts.map +1 -1
- package/dist/release-contract.js +3 -0
- package/dist/release-contract.js.map +1 -1
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +2 -1
- package/dist/release-manifest.js.map +1 -1
- package/dist/self-hosted-backup.d.ts +86 -0
- package/dist/self-hosted-backup.d.ts.map +1 -0
- package/dist/self-hosted-backup.js +350 -0
- package/dist/self-hosted-backup.js.map +1 -0
- package/dist/self-hosted-release.d.ts +12 -3
- package/dist/self-hosted-release.d.ts.map +1 -1
- package/dist/self-hosted-release.js +6 -4
- package/dist/self-hosted-release.js.map +1 -1
- package/dist/self-hosted-start.d.ts +50 -0
- package/dist/self-hosted-start.d.ts.map +1 -0
- package/dist/self-hosted-start.js +59 -0
- package/dist/self-hosted-start.js.map +1 -0
- package/dist/self-hosted-upgrade.d.ts +30 -0
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +29 -7
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +9 -10
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +21 -10
- package/dist/stack-contract.js.map +1 -1
- package/dist/staging-admin.d.ts +12 -20
- package/dist/staging-admin.d.ts.map +1 -1
- package/dist/staging-admin.js +7 -159
- package/dist/staging-admin.js.map +1 -1
- package/dist/tar-entries.d.ts +14 -0
- package/dist/tar-entries.d.ts.map +1 -0
- package/dist/tar-entries.js +104 -0
- package/dist/tar-entries.js.map +1 -0
- package/dist/upgrade/converge.d.ts +2 -2
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +44 -2
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrader-assess.d.ts.map +1 -1
- package/dist/upgrader-assess.js +2 -1
- package/dist/upgrader-assess.js.map +1 -1
- package/dist/url-map-redirects-access.d.ts +15 -0
- package/dist/url-map-redirects-access.d.ts.map +1 -0
- package/dist/url-map-redirects-access.js +81 -0
- package/dist/url-map-redirects-access.js.map +1 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +3 -1
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp-cost.js +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +45 -0
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/adapter.ts +4 -6
- package/src/admin-internal.ts +212 -0
- package/src/admin-memory.test.ts +2 -2
- package/src/aws-task-roles.ts +58 -0
- package/src/caddy-redirects.docker.test.ts +255 -0
- package/src/caddy-redirects.test.ts +115 -0
- package/src/caddy-redirects.ts +68 -0
- package/src/caddy-validate.test.ts +119 -0
- package/src/caddy.test.ts +108 -0
- package/src/caddy.ts +89 -20
- package/src/checkpoint-secrets.test.ts +0 -1
- package/src/cli-routing.test.ts +12 -0
- package/src/cli-routing.ts +7 -0
- package/src/cli.ts +340 -288
- package/src/compose.ts +108 -36
- package/src/deployed-release.test.ts +22 -4
- package/src/deployed-release.ts +14 -7
- package/src/gateway-credentials.ts +6 -3
- package/src/gateway-env-isolation.test.ts +212 -0
- package/src/gcloud.ts +5 -3
- package/src/gcp-names.ts +23 -0
- package/src/gcp-url-map.test.ts +150 -0
- package/src/gcp-url-map.ts +81 -0
- package/src/index.test.ts +24 -152
- package/src/index.ts +0 -9
- package/src/infra-contract.test.ts +40 -0
- package/src/infra-contract.ts +47 -1
- package/src/owner-reset.test.ts +165 -0
- package/src/owner-reset.ts +106 -0
- package/src/public-role-isolation.test.ts +89 -0
- package/src/public-role-isolation.ts +83 -0
- package/src/release-contract.ts +3 -0
- package/src/release-manifest.test.ts +12 -0
- package/src/release-manifest.ts +2 -1
- package/src/runtime-secrets.test.ts +25 -8
- package/src/self-hosted-backup.test.ts +344 -0
- package/src/self-hosted-backup.ts +480 -0
- package/src/self-hosted-compose.test.ts +88 -4
- package/src/self-hosted-release.ts +22 -7
- package/src/self-hosted-start.test.ts +68 -0
- package/src/self-hosted-start.ts +123 -0
- package/src/self-hosted-upgrade.ts +35 -8
- package/src/stack-contract.test.ts +11 -20
- package/src/stack-contract.ts +25 -16
- package/src/stack-converge-secrets.test.ts +45 -10
- package/src/stack-converge.test.ts +19 -10
- package/src/staging-admin.ts +18 -181
- package/src/tar-entries.test.ts +52 -0
- package/src/tar-entries.ts +116 -0
- package/src/upgrade/converge.ts +51 -4
- package/src/upgrade/upgrade.test.ts +78 -1
- package/src/upgrader-assess.test.ts +22 -0
- package/src/upgrader-assess.ts +2 -1
- package/src/url-map-redirects-access.test.ts +88 -0
- package/src/url-map-redirects-access.ts +89 -0
- package/src/wizard.ts +3 -1
- package/src/wizards/gcp-cost.ts +1 -1
- package/src/wizards/gcp.ts +48 -0
- package/stacks/aws/README.md +1 -1
- package/stacks/aws/index.ts +89 -45
- package/stacks/azure/README.md +1 -1
- package/stacks/azure/index.ts +21 -14
- package/stacks/gcp/README.md +1 -1
- package/stacks/gcp/index.ts +26 -22
- package/stacks/gcp-firebase/index.ts +7 -3
- package/stacks/self-hosted/Pulumi.yaml +2 -2
- package/stacks/self-hosted/README.md +3 -3
- package/stacks/self-hosted/index.ts +45 -22
- package/dist/redirects-emit.d.ts +0 -52
- package/dist/redirects-emit.d.ts.map +0 -1
- package/dist/redirects-emit.js +0 -111
- package/dist/redirects-emit.js.map +0 -1
- package/src/redirects-emit.ts +0 -175
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #587 — the self-hosted redirect path end to end against the Caddy image
|
|
5
|
+
* the Compose file runs ({@link CADDY_IMAGE}):
|
|
6
|
+
*
|
|
7
|
+
* - the GENERATED Caddyfile (the seed vhosts `init` writes) and the repo's
|
|
8
|
+
* dev Caddyfiles pass `caddy validate` with a real redirect snippet
|
|
9
|
+
* imported;
|
|
10
|
+
* - a container started like the Compose service (same Caddyfile shape,
|
|
11
|
+
* same `/srv/caelo` mount, same reload-loop command and snippet glob)
|
|
12
|
+
* answers redirected paths on the live and the staging host with the
|
|
13
|
+
* planned status + Location — query kept, case-sensitive, the live page
|
|
14
|
+
* wins, 410 for gone;
|
|
15
|
+
* - the reload loop picks up a deploy's new snippet, a snippet Caddy
|
|
16
|
+
* rejects leaves the previous redirects live, and the next good snippet
|
|
17
|
+
* is picked up again.
|
|
18
|
+
*
|
|
19
|
+
* Needs Docker and curl. CI sets `CAELO_CADDY_VALIDATE=required` (as for
|
|
20
|
+
* caddy-validate.test.ts), which turns a missing Docker into a failure.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
24
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { tmpdir } from "node:os";
|
|
26
|
+
import { join, resolve } from "node:path";
|
|
27
|
+
import { buildRedirectPlan, emitCaddyRedirects, type RedirectTableRow } from "@caelo-cms/shared";
|
|
28
|
+
import { generateCaddyfile, selfHostedSeedDomains } from "./caddy.js";
|
|
29
|
+
import { caddyRedirectReloadScript } from "./caddy-redirects.js";
|
|
30
|
+
import {
|
|
31
|
+
CADDY_IMAGE,
|
|
32
|
+
COMPOSE_ADMIN_UPSTREAM,
|
|
33
|
+
COMPOSE_GATEWAY_UPSTREAM,
|
|
34
|
+
COMPOSE_REDIRECT_SNIPPETS,
|
|
35
|
+
SITE_OUTPUT_ROOT,
|
|
36
|
+
} from "./compose.js";
|
|
37
|
+
|
|
38
|
+
const REQUIRED = process.env.CAELO_CADDY_VALIDATE === "required";
|
|
39
|
+
const dockerUp = Bun.spawnSync(["docker", "info"], { stdout: "ignore", stderr: "ignore" }).success;
|
|
40
|
+
if (!dockerUp && REQUIRED) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
"caddy-redirects.docker.test: CAELO_CADDY_VALIDATE=required but `docker info` failed",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const LIVE = "example.test";
|
|
47
|
+
const STAGING = "staging.example.test";
|
|
48
|
+
const CADDY_DIR = resolve(import.meta.dir, "../caddy");
|
|
49
|
+
const SERVED = ["index.html", "about/index.html", "blog/index.html"];
|
|
50
|
+
|
|
51
|
+
function docker(args: string[]): { ok: boolean; out: string } {
|
|
52
|
+
const r = Bun.spawnSync(["docker", ...args], { stdout: "pipe", stderr: "pipe" });
|
|
53
|
+
return { ok: r.success, out: `${r.stdout.toString()}${r.stderr.toString()}` };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function snippet(rows: [string, string, number][]): string {
|
|
57
|
+
const tableRows: RedirectTableRow[] = rows.map(([fromPath, toPath, statusCode]) => ({
|
|
58
|
+
fromPath,
|
|
59
|
+
toPath,
|
|
60
|
+
statusCode,
|
|
61
|
+
}));
|
|
62
|
+
return emitCaddyRedirects(
|
|
63
|
+
buildRedirectPlan({
|
|
64
|
+
rows: tableRows,
|
|
65
|
+
pageUrlStyle: "directory",
|
|
66
|
+
servedFiles: new Set(SERVED),
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe.skipIf(!dockerUp)(`self-hosted redirects through ${CADDY_IMAGE}`, () => {
|
|
72
|
+
const name = `caelo-test-587-${process.pid}-${Date.now()}`;
|
|
73
|
+
let dir = "";
|
|
74
|
+
let httpsPort = "";
|
|
75
|
+
|
|
76
|
+
/** The host dir mounted at SITE_OUTPUT_ROOT, as `./output` is in Compose. */
|
|
77
|
+
const outputDir = () => join(dir, "output");
|
|
78
|
+
const current = (env: string) => join(outputDir(), "output", env, "current");
|
|
79
|
+
const write = (env: string, body: string) =>
|
|
80
|
+
writeFileSync(join(current(env), "_redirects.caddy"), body);
|
|
81
|
+
|
|
82
|
+
/** Status + Location of `https://<host><path>` through the container. */
|
|
83
|
+
const probe = (host: string, path: string): { status: number; location: string | null } => {
|
|
84
|
+
const r = Bun.spawnSync(
|
|
85
|
+
[
|
|
86
|
+
"curl",
|
|
87
|
+
"-sk",
|
|
88
|
+
"-o",
|
|
89
|
+
"/dev/null",
|
|
90
|
+
"-D",
|
|
91
|
+
"-",
|
|
92
|
+
"--resolve",
|
|
93
|
+
`${host}:${httpsPort}:127.0.0.1`,
|
|
94
|
+
`https://${host}:${httpsPort}${path}`,
|
|
95
|
+
],
|
|
96
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
97
|
+
);
|
|
98
|
+
const head = r.stdout.toString();
|
|
99
|
+
const status = Number(head.match(/^HTTP\/\S+ (\d{3})/)?.[1] ?? 0);
|
|
100
|
+
const location = head.match(/^location: ([^\r\n]*)/im)?.[1]?.trim() ?? null;
|
|
101
|
+
return { status, location };
|
|
102
|
+
};
|
|
103
|
+
const eventually = async (host: string, path: string, location: string) => {
|
|
104
|
+
const deadline = Date.now() + 30_000;
|
|
105
|
+
for (;;) {
|
|
106
|
+
const got = probe(host, path).location;
|
|
107
|
+
if (got === location) return;
|
|
108
|
+
if (Date.now() > deadline) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`${host}${path} → ${got}, expected ${location}: ${docker(["logs", name]).out}`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
await Bun.sleep(250);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
beforeAll(() => {
|
|
118
|
+
dir = mkdtempSync(join(tmpdir(), "caelo-587-caddy-"));
|
|
119
|
+
for (const env of ["production", "staging"]) {
|
|
120
|
+
for (const f of SERVED) {
|
|
121
|
+
mkdirSync(join(current(env), f, ".."), { recursive: true });
|
|
122
|
+
writeFileSync(join(current(env), f), `<p>${env} ${f}</p>`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// The Caddyfile `init` writes, with Caddy's local CA so the test needs
|
|
126
|
+
// no public DNS.
|
|
127
|
+
writeFileSync(
|
|
128
|
+
join(dir, "Caddyfile"),
|
|
129
|
+
generateCaddyfile({
|
|
130
|
+
ownerEmail: "owner@example.test",
|
|
131
|
+
tls: "internal",
|
|
132
|
+
publicSiteRoot: `${SITE_OUTPUT_ROOT}/output/production/current`,
|
|
133
|
+
stagingSiteRoot: `${SITE_OUTPUT_ROOT}/output/staging/current`,
|
|
134
|
+
adminUpstream: COMPOSE_ADMIN_UPSTREAM,
|
|
135
|
+
gatewayUpstream: COMPOSE_GATEWAY_UPSTREAM,
|
|
136
|
+
domains: selfHostedSeedDomains(LIVE, STAGING),
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
write(
|
|
140
|
+
"production",
|
|
141
|
+
snippet([
|
|
142
|
+
["/old", "/about", 301],
|
|
143
|
+
["/tmp", "/blog", 302],
|
|
144
|
+
["/perm", "/about", 308],
|
|
145
|
+
["/hop1", "/hop2", 301],
|
|
146
|
+
["/hop2", "/blog", 301],
|
|
147
|
+
["/Case", "/about", 301],
|
|
148
|
+
["/q", "/about?ref=old", 301],
|
|
149
|
+
["/gone", "/410", 410],
|
|
150
|
+
["/about", "/elsewhere", 301],
|
|
151
|
+
]),
|
|
152
|
+
);
|
|
153
|
+
write("staging", snippet([["/staged-old", "/blog", 301]]));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
afterAll(() => {
|
|
157
|
+
docker(["rm", "-f", name]);
|
|
158
|
+
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("the generated Caddyfile and the repo's dev Caddyfiles validate with a snippet imported", () => {
|
|
162
|
+
const validate = (caddyfile: string, siteMount: string) =>
|
|
163
|
+
docker([
|
|
164
|
+
"run",
|
|
165
|
+
"--rm",
|
|
166
|
+
"-v",
|
|
167
|
+
`${caddyfile}:/etc/caddy/Caddyfile:ro`,
|
|
168
|
+
"-v",
|
|
169
|
+
siteMount,
|
|
170
|
+
CADDY_IMAGE,
|
|
171
|
+
"caddy",
|
|
172
|
+
"validate",
|
|
173
|
+
"--config",
|
|
174
|
+
"/etc/caddy/Caddyfile",
|
|
175
|
+
"--adapter",
|
|
176
|
+
"caddyfile",
|
|
177
|
+
]);
|
|
178
|
+
const generated = validate(join(dir, "Caddyfile"), `${outputDir()}:${SITE_OUTPUT_ROOT}:ro`);
|
|
179
|
+
expect(generated.out).toContain("Valid configuration");
|
|
180
|
+
for (const env of ["production", "staging"]) {
|
|
181
|
+
const devFile = join(dir, `Caddyfile.${env}`);
|
|
182
|
+
writeFileSync(devFile, readFileSync(join(CADDY_DIR, `Caddyfile.${env}`), "utf8"));
|
|
183
|
+
// docker-compose.yml mounts output/<env> at /srv; current/ holds the snippet.
|
|
184
|
+
const r = validate(devFile, `${join(outputDir(), "output", env)}:/srv:ro`);
|
|
185
|
+
expect(`${env}: ${r.out.includes("Valid configuration")}`).toBe(`${env}: true`);
|
|
186
|
+
}
|
|
187
|
+
}, 180_000);
|
|
188
|
+
|
|
189
|
+
it("a container started like the Compose service answers the planned redirects", async () => {
|
|
190
|
+
const run = docker([
|
|
191
|
+
"run",
|
|
192
|
+
"-d",
|
|
193
|
+
"--name",
|
|
194
|
+
name,
|
|
195
|
+
"-p",
|
|
196
|
+
"127.0.0.1::443",
|
|
197
|
+
"-v",
|
|
198
|
+
`${join(dir, "Caddyfile")}:/etc/caddy/Caddyfile:ro`,
|
|
199
|
+
"-v",
|
|
200
|
+
`${outputDir()}:${SITE_OUTPUT_ROOT}:ro`,
|
|
201
|
+
CADDY_IMAGE,
|
|
202
|
+
// The Compose service's command, with the Compose snippet glob.
|
|
203
|
+
"/bin/sh",
|
|
204
|
+
"-c",
|
|
205
|
+
caddyRedirectReloadScript(COMPOSE_REDIRECT_SNIPPETS),
|
|
206
|
+
]);
|
|
207
|
+
expect(run.ok).toBe(true);
|
|
208
|
+
httpsPort = docker(["port", name, "443/tcp"]).out.trim().split("\n")[0]?.split(":").pop() ?? "";
|
|
209
|
+
const deadline = Date.now() + 90_000;
|
|
210
|
+
while (probe(LIVE, "/").status !== 200) {
|
|
211
|
+
if (Date.now() > deadline) {
|
|
212
|
+
throw new Error(`Caddy did not start: ${docker(["logs", name]).out}`);
|
|
213
|
+
}
|
|
214
|
+
await Bun.sleep(500);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const expectRedirect = (path: string, status: number, location: string, host = LIVE) => {
|
|
218
|
+
const r = probe(host, path);
|
|
219
|
+
expect(`${host}${path} ${r.status} ${r.location}`).toBe(
|
|
220
|
+
`${host}${path} ${status} ${location}`,
|
|
221
|
+
);
|
|
222
|
+
};
|
|
223
|
+
expectRedirect("/old", 301, "/about/");
|
|
224
|
+
expectRedirect("/old/", 301, "/about/");
|
|
225
|
+
expectRedirect("/old?utm_source=x&a=1", 301, "/about/?utm_source=x&a=1");
|
|
226
|
+
expectRedirect("/tmp", 302, "/blog/");
|
|
227
|
+
expectRedirect("/perm/", 308, "/about/");
|
|
228
|
+
expectRedirect("/hop1", 301, "/blog/");
|
|
229
|
+
expectRedirect("/Case", 301, "/about/");
|
|
230
|
+
expectRedirect("/q?dropped=1", 301, "/about?ref=old");
|
|
231
|
+
expectRedirect("/staged-old", 301, "/blog/", STAGING);
|
|
232
|
+
expect(probe(LIVE, "/gone").status).toBe(410);
|
|
233
|
+
// Exact, case-sensitive: /case is not /Case.
|
|
234
|
+
expect(probe(LIVE, "/case").status).toBe(404);
|
|
235
|
+
// Each host serves its own build's redirects.
|
|
236
|
+
expect(probe(LIVE, "/staged-old").status).toBe(404);
|
|
237
|
+
// The live page wins over the recorded /about → /elsewhere.
|
|
238
|
+
expect(probe(LIVE, "/about/")).toEqual({ status: 200, location: null });
|
|
239
|
+
}, 180_000);
|
|
240
|
+
|
|
241
|
+
it("the reload loop picks up a deploy's new snippet", async () => {
|
|
242
|
+
write("production", snippet([["/brand-new", "/blog", 301]]));
|
|
243
|
+
await eventually(LIVE, "/brand-new", "/blog/");
|
|
244
|
+
expect(probe(LIVE, "/old").status).toBe(404);
|
|
245
|
+
}, 60_000);
|
|
246
|
+
|
|
247
|
+
it("a snippet Caddy rejects leaves the previous redirects live; the next good one is picked up", async () => {
|
|
248
|
+
write("production", "map {path} {\n");
|
|
249
|
+
await Bun.sleep(5_000);
|
|
250
|
+
expect(probe(LIVE, "/brand-new").location).toBe("/blog/");
|
|
251
|
+
expect(docker(["logs", name]).out).toContain("Caddy rejected the reload");
|
|
252
|
+
write("production", snippet([["/after-fix", "/about", 301]]));
|
|
253
|
+
await eventually(LIVE, "/after-fix", "/about/");
|
|
254
|
+
}, 60_000);
|
|
255
|
+
});
|
|
@@ -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
|
+
});
|