@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,150 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #587 — the `gcp` stack's URL map under Pulumi mocks: host routing, the
|
|
5
|
+
* public path matcher the admin writes redirect rules into, the field the
|
|
6
|
+
* stack ignores after creation; the admin's custom role is the CLI's.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { beforeAll, describe, expect, it } from "bun:test";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { GCP_PUBLIC_BASE_PATHS, isGcpRedirectRule, withGcpRedirects } from "@caelo-cms/shared";
|
|
13
|
+
import * as pulumi from "@pulumi/pulumi";
|
|
14
|
+
import { URL_MAP_REDIRECTS_PERMISSIONS, urlMapRedirectsRoleId } from "./gcp-names.js";
|
|
15
|
+
|
|
16
|
+
interface Registered {
|
|
17
|
+
readonly type: string;
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly inputs: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
const registered: Registered[] = [];
|
|
22
|
+
|
|
23
|
+
beforeAll(() => {
|
|
24
|
+
pulumi.runtime.setMocks(
|
|
25
|
+
{
|
|
26
|
+
newResource: (args) => {
|
|
27
|
+
registered.push({ type: args.type, name: args.name, inputs: args.inputs });
|
|
28
|
+
return { id: `${args.name}-id`, state: args.inputs };
|
|
29
|
+
},
|
|
30
|
+
call: (args) => args.inputs,
|
|
31
|
+
},
|
|
32
|
+
"caelo",
|
|
33
|
+
"production",
|
|
34
|
+
false,
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const resolved = <T>(o: pulumi.Output<T>): Promise<T> => new Promise((r) => o.apply(r));
|
|
39
|
+
|
|
40
|
+
describe("gcp stack URL map (Pulumi mocks)", () => {
|
|
41
|
+
it("routes the site host to the bucket with /api/* to the gateway, the admin host to the admin", async () => {
|
|
42
|
+
// Imported after setMocks so the resource registers against the mock monitor.
|
|
43
|
+
const { createSiteUrlMap, PUBLIC_PATH_MATCHER } = await import("./gcp-url-map.js");
|
|
44
|
+
const map = createSiteUrlMap(
|
|
45
|
+
"caelo-production-url-map",
|
|
46
|
+
{
|
|
47
|
+
domain: "example.com",
|
|
48
|
+
adminDomain: "admin.example.com",
|
|
49
|
+
staticBackend: "bucket-id",
|
|
50
|
+
gatewayBackend: "gateway-id",
|
|
51
|
+
adminBackend: "admin-id",
|
|
52
|
+
},
|
|
53
|
+
{},
|
|
54
|
+
);
|
|
55
|
+
const [hostRules, pathMatchers, defaultService] = await Promise.all([
|
|
56
|
+
resolved(map.hostRules),
|
|
57
|
+
resolved(map.pathMatchers),
|
|
58
|
+
resolved(map.defaultService),
|
|
59
|
+
]);
|
|
60
|
+
expect(defaultService).toBe("bucket-id");
|
|
61
|
+
expect(hostRules).toEqual([
|
|
62
|
+
{ hosts: ["example.com"], pathMatcher: PUBLIC_PATH_MATCHER },
|
|
63
|
+
{ hosts: ["admin.example.com"], pathMatcher: "admin" },
|
|
64
|
+
]);
|
|
65
|
+
expect(pathMatchers).toEqual([
|
|
66
|
+
{
|
|
67
|
+
name: PUBLIC_PATH_MATCHER,
|
|
68
|
+
defaultService: "bucket-id",
|
|
69
|
+
pathRules: [{ paths: [...GCP_PUBLIC_BASE_PATHS], service: "gateway-id" }],
|
|
70
|
+
},
|
|
71
|
+
{ name: "admin", defaultService: "admin-id" },
|
|
72
|
+
]);
|
|
73
|
+
expect(registered.find((r) => r.name === "caelo-production-url-map")?.type).toBe(
|
|
74
|
+
"gcp:compute/uRLMap:URLMap",
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// The rules the admin writes on Confirm-publish fit this matcher and keep
|
|
78
|
+
// its routing — the base the Stage-time budget check assumes.
|
|
79
|
+
const first = pathMatchers?.[0];
|
|
80
|
+
if (!first) throw new Error("no public path matcher");
|
|
81
|
+
const withRedirects = withGcpRedirects(
|
|
82
|
+
{
|
|
83
|
+
name: first.name,
|
|
84
|
+
pathRules: (first.pathRules ?? []).map((r) => ({
|
|
85
|
+
paths: r.paths ?? [],
|
|
86
|
+
service: r.service ?? "",
|
|
87
|
+
})),
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
redirects: [
|
|
91
|
+
{
|
|
92
|
+
fromPath: "/old",
|
|
93
|
+
matchPaths: ["/old", "/old/"],
|
|
94
|
+
location: "/new/",
|
|
95
|
+
statusCode: 301,
|
|
96
|
+
preserveQuery: true,
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
gone: [],
|
|
100
|
+
shadowedByBuild: [],
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
expect(withRedirects.pathRules?.filter((r) => !isGcpRedirectRule(r))).toEqual([
|
|
104
|
+
{ paths: ["/api/*"], service: "gateway-id" },
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("ignores the public path matcher's path rules after creation (the admin owns the redirects)", async () => {
|
|
109
|
+
const { SITE_URL_MAP_IGNORE_CHANGES, siteUrlMapArgs, PUBLIC_PATH_MATCHER } = await import(
|
|
110
|
+
"./gcp-url-map.js"
|
|
111
|
+
);
|
|
112
|
+
expect(SITE_URL_MAP_IGNORE_CHANGES).toEqual(["pathMatchers[0].pathRules"]);
|
|
113
|
+
const args = siteUrlMapArgs({
|
|
114
|
+
domain: "d",
|
|
115
|
+
adminDomain: "a.d",
|
|
116
|
+
staticBackend: "s",
|
|
117
|
+
gatewayBackend: "g",
|
|
118
|
+
adminBackend: "a",
|
|
119
|
+
});
|
|
120
|
+
// Index 0 must stay the public matcher, or the ignore would hit the admin's.
|
|
121
|
+
expect((args.pathMatchers as { name: string }[])[0]?.name).toBe(PUBLIC_PATH_MATCHER);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("gcp stack wiring (source)", () => {
|
|
126
|
+
const src = readFileSync(resolve(import.meta.dir, "../stacks/gcp/index.ts"), "utf8");
|
|
127
|
+
|
|
128
|
+
it("builds the URL map through createSiteUrlMap", () => {
|
|
129
|
+
expect(src).toMatch(/const urlMap = createSiteUrlMap\(\s*`\$\{namePrefix\}-url-map`/);
|
|
130
|
+
expect(src).not.toMatch(/new gcp\.compute\.URLMap\(\s*`\$\{namePrefix\}-url-map`/);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("leaves the admin's URL-map redirects role to the CLI (installer + upgrade)", () => {
|
|
134
|
+
expect(src).not.toContain("IAMCustomRole");
|
|
135
|
+
expect(src).not.toContain("urlMapRedirects");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("names a valid custom role id with only the permissions the writer needs", () => {
|
|
139
|
+
expect(urlMapRedirectsRoleId("production")).toBe("caelo_production_urlMapRedirects");
|
|
140
|
+
expect(urlMapRedirectsRoleId("production")).toMatch(/^[A-Za-z0-9_.]{3,64}$/);
|
|
141
|
+
expect([...URL_MAP_REDIRECTS_PERMISSIONS].sort()).toEqual([
|
|
142
|
+
"compute.backendBuckets.use",
|
|
143
|
+
"compute.backendServices.use",
|
|
144
|
+
"compute.globalOperations.get",
|
|
145
|
+
"compute.urlMaps.get",
|
|
146
|
+
"compute.urlMaps.list",
|
|
147
|
+
"compute.urlMaps.update",
|
|
148
|
+
]);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `gcp` stack's URL map, and who may write its redirect rules (#587).
|
|
5
|
+
*
|
|
6
|
+
* The admin writes the redirects table into the site host's path matcher
|
|
7
|
+
* as `urlRedirect` path rules on every Confirm-publish (admin-core
|
|
8
|
+
* deploy/gcp-url-map-redirects.ts). Those rules change at runtime, not with
|
|
9
|
+
* `pulumi up`, so the stack must not own them: the URL map ignores changes
|
|
10
|
+
* to the public path matcher's `pathRules` after creation. Otherwise a
|
|
11
|
+
* `pulumi refresh` + `up` would wipe every redirect until the next publish.
|
|
12
|
+
* The admin's writer keeps every non-redirect rule (`/api/*`) as it finds
|
|
13
|
+
* it.
|
|
14
|
+
*
|
|
15
|
+
* The admin's run SA gets a project custom role with only the permissions
|
|
16
|
+
* that read-modify-write needs (`URL_MAP_REDIRECTS_PERMISSIONS`, gcp-names.ts);
|
|
17
|
+
* the CLI creates it (url-map-redirects-access.ts), not the stack.
|
|
18
|
+
*
|
|
19
|
+
* Imported by the stack from `dist/`; keep it free of CLI dependencies.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { GCP_PUBLIC_BASE_PATHS } from "@caelo-cms/shared";
|
|
23
|
+
import * as gcp from "@pulumi/gcp";
|
|
24
|
+
import type * as pulumi from "@pulumi/pulumi";
|
|
25
|
+
|
|
26
|
+
/** Name of the site host's path matcher. */
|
|
27
|
+
export const PUBLIC_PATH_MATCHER = "public";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The URL map's `ignoreChanges`: the public path matcher (index 0, see
|
|
31
|
+
* {@link siteUrlMapArgs}) holds the admin-managed redirect rules.
|
|
32
|
+
*/
|
|
33
|
+
export const SITE_URL_MAP_IGNORE_CHANGES: readonly string[] = ["pathMatchers[0].pathRules"];
|
|
34
|
+
|
|
35
|
+
/** Inputs of {@link siteUrlMapArgs}. */
|
|
36
|
+
export interface SiteUrlMapInputs {
|
|
37
|
+
readonly domain: string;
|
|
38
|
+
readonly adminDomain: string;
|
|
39
|
+
readonly staticBackend: pulumi.Input<string>;
|
|
40
|
+
readonly gatewayBackend: pulumi.Input<string>;
|
|
41
|
+
readonly adminBackend: pulumi.Input<string>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The URL map's arguments: the site host to the static bucket with `/api/*`
|
|
46
|
+
* to the gateway; the admin host to the IAP-gated admin. The public path
|
|
47
|
+
* matcher comes first — {@link SITE_URL_MAP_IGNORE_CHANGES} addresses it by
|
|
48
|
+
* index.
|
|
49
|
+
*/
|
|
50
|
+
export function siteUrlMapArgs(i: SiteUrlMapInputs): gcp.compute.URLMapArgs {
|
|
51
|
+
return {
|
|
52
|
+
defaultService: i.staticBackend,
|
|
53
|
+
hostRules: [
|
|
54
|
+
{ hosts: [i.domain], pathMatcher: PUBLIC_PATH_MATCHER },
|
|
55
|
+
{ hosts: [i.adminDomain], pathMatcher: "admin" },
|
|
56
|
+
],
|
|
57
|
+
pathMatchers: [
|
|
58
|
+
{
|
|
59
|
+
name: PUBLIC_PATH_MATCHER,
|
|
60
|
+
defaultService: i.staticBackend,
|
|
61
|
+
pathRules: [{ paths: [...GCP_PUBLIC_BASE_PATHS], service: i.gatewayBackend }],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "admin",
|
|
65
|
+
defaultService: i.adminBackend,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The stack's URL map (see the module comment for the ignored field). */
|
|
72
|
+
export function createSiteUrlMap(
|
|
73
|
+
name: string,
|
|
74
|
+
inputs: SiteUrlMapInputs,
|
|
75
|
+
opts: pulumi.CustomResourceOptions,
|
|
76
|
+
): gcp.compute.URLMap {
|
|
77
|
+
return new gcp.compute.URLMap(name, siteUrlMapArgs(inputs), {
|
|
78
|
+
...opts,
|
|
79
|
+
ignoreChanges: [...(opts.ignoreChanges ?? []), ...SITE_URL_MAP_IGNORE_CHANGES],
|
|
80
|
+
});
|
|
81
|
+
}
|
package/src/index.test.ts
CHANGED
|
@@ -8,12 +8,6 @@ import { generateBootstrapToken } from "./bootstrap-token.js";
|
|
|
8
8
|
import { generateCaddyfile } from "./caddy.js";
|
|
9
9
|
import { loadCdnCopyAdapter, selfHostedCdnCopy } from "./cdn-copy.js";
|
|
10
10
|
import { generateDockerCompose } from "./compose.js";
|
|
11
|
-
import {
|
|
12
|
-
emitRedirectsAzureFrontDoor,
|
|
13
|
-
emitRedirectsCloudFront,
|
|
14
|
-
emitRedirectsCloudflare,
|
|
15
|
-
type RedirectRow,
|
|
16
|
-
} from "./redirects-emit.js";
|
|
17
11
|
|
|
18
12
|
describe("Caddyfile generator", () => {
|
|
19
13
|
it("emits a vhost per domain + email block", () => {
|
|
@@ -21,8 +15,9 @@ describe("Caddyfile generator", () => {
|
|
|
21
15
|
ownerEmail: "owner@example.com",
|
|
22
16
|
publicSiteRoot: "/srv/caelo/output/production/current",
|
|
23
17
|
stagingSiteRoot: "/srv/caelo/output/staging/current",
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
tls: "acme",
|
|
19
|
+
adminUpstream: "caelo-admin:5173",
|
|
20
|
+
gatewayUpstream: "caelo-gateway:8090",
|
|
26
21
|
domains: [
|
|
27
22
|
{ hostname: "example.com", kind: "public", env: "production" },
|
|
28
23
|
{ hostname: "staging.example.com", kind: "public", env: "staging" },
|
|
@@ -47,12 +42,13 @@ describe("Caddyfile generator", () => {
|
|
|
47
42
|
ownerEmail: "x@y.z",
|
|
48
43
|
publicSiteRoot: "/srv/caelo/output/production/current",
|
|
49
44
|
stagingSiteRoot: "/srv/caelo/output/staging/current",
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
tls: "acme",
|
|
46
|
+
adminUpstream: "caelo-admin:5173",
|
|
47
|
+
gatewayUpstream: "caelo-gateway:8090",
|
|
52
48
|
domains: [],
|
|
53
49
|
});
|
|
54
50
|
expect(out).toContain(":8081 {");
|
|
55
|
-
expect(out).toContain("reverse_proxy
|
|
51
|
+
expect(out).toContain("reverse_proxy caelo-admin:5173");
|
|
56
52
|
});
|
|
57
53
|
});
|
|
58
54
|
|
|
@@ -61,8 +57,9 @@ describe("Caddy cache policy for content-hashed assets", () => {
|
|
|
61
57
|
ownerEmail: "owner@example.com",
|
|
62
58
|
publicSiteRoot: "/srv/caelo/output/production/current",
|
|
63
59
|
stagingSiteRoot: "/srv/caelo/output/staging/current",
|
|
64
|
-
|
|
65
|
-
|
|
60
|
+
tls: "acme",
|
|
61
|
+
adminUpstream: "caelo-admin:5173",
|
|
62
|
+
gatewayUpstream: "caelo-gateway:8090",
|
|
66
63
|
domains: [
|
|
67
64
|
{ hostname: "example.com", kind: "public", env: "production" },
|
|
68
65
|
{ hostname: "admin.example.com", kind: "admin", env: "production" },
|
|
@@ -119,43 +116,42 @@ describe("docker-compose generator", () => {
|
|
|
119
116
|
domain: "example.com",
|
|
120
117
|
postgresPassword: "supersecret",
|
|
121
118
|
rolePasswords: { admin: "adminpw", public: "publicpw", gateway: "gatewaypw" },
|
|
122
|
-
minioRootUser: "caelo",
|
|
123
|
-
minioRootPassword: "miniopass",
|
|
124
119
|
caeloSecretKek: "0".repeat(64),
|
|
125
120
|
anthropicApiKey: "sk-ant-…",
|
|
126
121
|
diskSize: "20Gi",
|
|
127
122
|
images: RELEASE_IMAGES,
|
|
123
|
+
ports: { http: 80, https: 443 },
|
|
124
|
+
project: "caelo-example-com",
|
|
128
125
|
});
|
|
129
|
-
expect(out).toContain("
|
|
130
|
-
expect(out).toContain("
|
|
126
|
+
expect(out).toContain(" postgres:\n");
|
|
127
|
+
expect(out).toContain(" caddy:\n");
|
|
131
128
|
expect(out).toContain("caelo-admin");
|
|
132
129
|
expect(out).toContain("caelo-gateway");
|
|
133
130
|
expect(out).toContain("supersecret");
|
|
134
|
-
expect(out).toContain("miniopass");
|
|
135
131
|
expect(out).toContain("ANTHROPIC_API_KEY");
|
|
136
132
|
expect(out).toContain("CAELO_SECRET_KEK");
|
|
137
133
|
});
|
|
138
134
|
|
|
139
|
-
it("gives the gateway
|
|
135
|
+
it("gives the gateway public_role only and no cms_admin credential (#613, #635)", () => {
|
|
140
136
|
const out = generateDockerCompose({
|
|
141
137
|
domain: "example.com",
|
|
142
138
|
postgresPassword: "supersecret",
|
|
143
139
|
rolePasswords: { admin: "adminpw", public: "publicpw", gateway: "gatewaypw" },
|
|
144
|
-
minioRootUser: "caelo",
|
|
145
|
-
minioRootPassword: "miniopass",
|
|
146
140
|
caeloSecretKek: "0".repeat(64),
|
|
147
141
|
diskSize: "20Gi",
|
|
148
142
|
images: RELEASE_IMAGES,
|
|
143
|
+
ports: { http: 80, https: 443 },
|
|
144
|
+
project: "caelo-example-com",
|
|
149
145
|
});
|
|
150
146
|
const gateway = out.slice(out.indexOf("caelo-gateway:"), out.indexOf("\nvolumes:"));
|
|
151
|
-
expect(gateway).toContain(
|
|
152
|
-
'GATEWAY_DATABASE_URL: "postgres://gateway_role:gatewaypw@postgres:5432/cms_admin"',
|
|
153
|
-
);
|
|
154
147
|
expect(gateway).toContain(
|
|
155
148
|
'PUBLIC_DATABASE_URL: "postgres://public_role:publicpw@postgres:5432/cms_public"',
|
|
156
149
|
);
|
|
157
|
-
expect(gateway).not.toMatch(
|
|
158
|
-
|
|
150
|
+
expect(gateway).not.toMatch(
|
|
151
|
+
/ADMIN_DATABASE|GATEWAY_DATABASE|cms_admin|admin_role|gateway_role|adminpw|gatewaypw|supersecret|CAELO_SECRET_KEK/,
|
|
152
|
+
);
|
|
153
|
+
// bootstrap.sh still creates the three roles from these (gateway_role
|
|
154
|
+
// until the contract release drops it).
|
|
159
155
|
expect(out).toContain('GATEWAY_ROLE_PASSWORD: "gatewaypw"');
|
|
160
156
|
});
|
|
161
157
|
|
|
@@ -164,11 +160,11 @@ describe("docker-compose generator", () => {
|
|
|
164
160
|
domain: "example.com",
|
|
165
161
|
postgresPassword: 'pa"ss',
|
|
166
162
|
rolePasswords: { admin: "a", public: "b", gateway: "g" },
|
|
167
|
-
minioRootUser: "caelo",
|
|
168
|
-
minioRootPassword: "x",
|
|
169
163
|
caeloSecretKek: "0".repeat(64),
|
|
170
164
|
diskSize: "1Gi",
|
|
171
165
|
images: RELEASE_IMAGES,
|
|
166
|
+
ports: { http: 80, https: 443 },
|
|
167
|
+
project: "caelo-example-com",
|
|
172
168
|
});
|
|
173
169
|
expect(out).toContain('pa\\"ss');
|
|
174
170
|
});
|
|
@@ -190,130 +186,6 @@ describe("bootstrap token generator", () => {
|
|
|
190
186
|
});
|
|
191
187
|
});
|
|
192
188
|
|
|
193
|
-
describe("redirects-emit", () => {
|
|
194
|
-
const rows: RedirectRow[] = [
|
|
195
|
-
{ fromPath: "/old", toPath: "/new", statusCode: 301 },
|
|
196
|
-
{ fromPath: "/blog/2024", toPath: "/blog", statusCode: 302 },
|
|
197
|
-
{ fromPath: "/legacy", toPath: "/modern", statusCode: 308 },
|
|
198
|
-
];
|
|
199
|
-
|
|
200
|
-
describe("emitRedirectsCloudflare", () => {
|
|
201
|
-
it("emits one line per row in `<from> <to> <status>` format", () => {
|
|
202
|
-
const out = emitRedirectsCloudflare(rows);
|
|
203
|
-
expect(out).toContain("/old /new 301\n");
|
|
204
|
-
expect(out).toContain("/blog/2024 /blog 302\n");
|
|
205
|
-
expect(out).toContain("/legacy /modern 308\n");
|
|
206
|
-
expect(out.startsWith("# Generated by @caelo-cms/provisioning")).toBe(true);
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it("empty rows → header only, no trailing newline noise", () => {
|
|
210
|
-
const out = emitRedirectsCloudflare([]);
|
|
211
|
-
expect(out).toBe("# Generated by @caelo-cms/provisioning — do not edit by hand.\n");
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
it("is byte-identical for the same input (deterministic)", () => {
|
|
215
|
-
expect(emitRedirectsCloudflare(rows)).toBe(emitRedirectsCloudflare([...rows]));
|
|
216
|
-
});
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
describe("emitRedirectsCloudFront", () => {
|
|
220
|
-
it("returns parseable JSON config + a Lambda source that consumes it", () => {
|
|
221
|
-
const out = emitRedirectsCloudFront(rows);
|
|
222
|
-
const parsed = JSON.parse(out.jsonConfig) as {
|
|
223
|
-
redirects: Array<{ from: string; to: string; status: number }>;
|
|
224
|
-
};
|
|
225
|
-
expect(parsed.redirects).toHaveLength(3);
|
|
226
|
-
expect(parsed.redirects[0]).toEqual({ from: "/old", to: "/new", status: 301 });
|
|
227
|
-
expect(out.lambdaSource).toContain("exports.handler");
|
|
228
|
-
expect(out.lambdaSource).toContain('require("./redirects.json")');
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it("preserves status codes verbatim (301/302/307/308)", () => {
|
|
232
|
-
const allStatuses: RedirectRow[] = [
|
|
233
|
-
{ fromPath: "/a", toPath: "/x", statusCode: 301 },
|
|
234
|
-
{ fromPath: "/b", toPath: "/x", statusCode: 302 },
|
|
235
|
-
{ fromPath: "/c", toPath: "/x", statusCode: 307 },
|
|
236
|
-
{ fromPath: "/d", toPath: "/x", statusCode: 308 },
|
|
237
|
-
];
|
|
238
|
-
const out = emitRedirectsCloudFront(allStatuses);
|
|
239
|
-
const parsed = JSON.parse(out.jsonConfig) as {
|
|
240
|
-
redirects: Array<{ status: number }>;
|
|
241
|
-
};
|
|
242
|
-
expect(parsed.redirects.map((r) => r.status)).toEqual([301, 302, 307, 308]);
|
|
243
|
-
});
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
describe("emitRedirectsAzureFrontDoor", () => {
|
|
247
|
-
it("emits one rule per row with monotonically-increasing order", () => {
|
|
248
|
-
const out = emitRedirectsAzureFrontDoor(rows);
|
|
249
|
-
expect(out).toHaveLength(3);
|
|
250
|
-
expect(out[0]?.name).toBe("redirect-1");
|
|
251
|
-
expect(out[0]?.order).toBe(1);
|
|
252
|
-
expect(out[2]?.order).toBe(3);
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
it("maps status codes to Front Door redirectType vocabulary", () => {
|
|
256
|
-
const out = emitRedirectsAzureFrontDoor([
|
|
257
|
-
{ fromPath: "/a", toPath: "/x", statusCode: 301 },
|
|
258
|
-
{ fromPath: "/b", toPath: "/x", statusCode: 302 },
|
|
259
|
-
{ fromPath: "/c", toPath: "/x", statusCode: 307 },
|
|
260
|
-
{ fromPath: "/d", toPath: "/x", statusCode: 308 },
|
|
261
|
-
]);
|
|
262
|
-
expect(out.map((r) => r.actions[0]?.parameters.redirectType)).toEqual([
|
|
263
|
-
"Moved",
|
|
264
|
-
"Found",
|
|
265
|
-
"TemporaryRedirect",
|
|
266
|
-
"PermanentRedirect",
|
|
267
|
-
]);
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it("RequestUri condition matches the from path exactly", () => {
|
|
271
|
-
const out = emitRedirectsAzureFrontDoor(rows);
|
|
272
|
-
expect(out[1]?.conditions[0]?.parameters.matchValues).toEqual(["/blog/2024"]);
|
|
273
|
-
expect(out[1]?.actions[0]?.parameters.destinationPath).toBe("/blog");
|
|
274
|
-
});
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
describe("path-validation guard (review-pass #9)", () => {
|
|
278
|
-
it("Cloudflare emitter rejects fromPath with whitespace", () => {
|
|
279
|
-
expect(() =>
|
|
280
|
-
emitRedirectsCloudflare([{ fromPath: "/old path", toPath: "/new", statusCode: 301 }]),
|
|
281
|
-
).toThrow(/contains whitespace/);
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
it("CloudFront emitter rejects toPath with newline", () => {
|
|
285
|
-
expect(() =>
|
|
286
|
-
emitRedirectsCloudFront([{ fromPath: "/old", toPath: "/new\nlies", statusCode: 301 }]),
|
|
287
|
-
).toThrow(/contains whitespace/);
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
it("every emitter rejects an off-site or URL-embedding toPath (#657)", () => {
|
|
291
|
-
for (const toPath of ["//evil.example/x", "/https://example.com/x"]) {
|
|
292
|
-
const rows = [{ fromPath: "/old", toPath, statusCode: 301 as const }];
|
|
293
|
-
expect(() => emitRedirectsCloudflare(rows)).toThrow(/redirects-emit: toPath/);
|
|
294
|
-
expect(() => emitRedirectsCloudFront(rows)).toThrow(/redirects-emit: toPath/);
|
|
295
|
-
expect(() => emitRedirectsAzureFrontDoor(rows)).toThrow(/redirects-emit: toPath/);
|
|
296
|
-
}
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
it("Azure emitter rejects tab in path", () => {
|
|
300
|
-
expect(() =>
|
|
301
|
-
emitRedirectsAzureFrontDoor([{ fromPath: "/old\there", toPath: "/x", statusCode: 301 }]),
|
|
302
|
-
).toThrow(/contains whitespace/);
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
it("hyphens, dots, slashes, unicode in path segments are accepted", () => {
|
|
306
|
-
// Regression guard for the original buggy regex `[\s -]` which
|
|
307
|
-
// rejected hyphens.
|
|
308
|
-
expect(() =>
|
|
309
|
-
emitRedirectsCloudflare([
|
|
310
|
-
{ fromPath: "/blog/spring-launch", toPath: "/blog/sommer-2026", statusCode: 301 },
|
|
311
|
-
]),
|
|
312
|
-
).not.toThrow();
|
|
313
|
-
});
|
|
314
|
-
});
|
|
315
|
-
});
|
|
316
|
-
|
|
317
189
|
describe("cdn-copy adapter loader", () => {
|
|
318
190
|
it("returns the no-op self-hosted adapter when provider is unset/empty/self-hosted", async () => {
|
|
319
191
|
expect(await loadCdnCopyAdapter(undefined)).toBe(selfHostedCdnCopy);
|
package/src/index.ts
CHANGED
|
@@ -47,15 +47,6 @@ export {
|
|
|
47
47
|
} from "./cdn-copy.js";
|
|
48
48
|
export { type ComposeSpec, generateDockerCompose, type RolePasswords } from "./compose.js";
|
|
49
49
|
export { INFRA_CONTRACT_VERSION } from "./infra-contract.js";
|
|
50
|
-
export {
|
|
51
|
-
type CloudFrontRedirectArtifact,
|
|
52
|
-
emitRedirectsAzureFrontDoor,
|
|
53
|
-
emitRedirectsCloudFront,
|
|
54
|
-
emitRedirectsCloudflare,
|
|
55
|
-
type FrontDoorRule,
|
|
56
|
-
type RedirectRow,
|
|
57
|
-
type RedirectStatusCode,
|
|
58
|
-
} from "./redirects-emit.js";
|
|
59
50
|
export {
|
|
60
51
|
INFRA_CONTRACT_PATHS,
|
|
61
52
|
isInfraContractPath,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Every infrastructure contract version names the step it adds, so the
|
|
5
|
+
* upgrader's refusal tells the Owner what the Cloud Shell run will change.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from "bun:test";
|
|
9
|
+
import {
|
|
10
|
+
INFRA_CONTRACT_STEPS,
|
|
11
|
+
INFRA_CONTRACT_VERSION,
|
|
12
|
+
infraStepsText,
|
|
13
|
+
pendingInfraSteps,
|
|
14
|
+
} from "./infra-contract.js";
|
|
15
|
+
|
|
16
|
+
describe("infrastructure contract steps", () => {
|
|
17
|
+
it("describes every version up to the current one, and no other", () => {
|
|
18
|
+
expect(Object.keys(INFRA_CONTRACT_STEPS).map(Number)).toEqual(
|
|
19
|
+
Array.from({ length: INFRA_CONTRACT_VERSION }, (_, i) => i + 1),
|
|
20
|
+
);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("is at 5: public_role revoke (3), URL-map redirects role (4), no gateway cms_admin login (5)", () => {
|
|
24
|
+
expect(INFRA_CONTRACT_VERSION).toBe(5);
|
|
25
|
+
expect(INFRA_CONTRACT_STEPS[3]).toContain("#633");
|
|
26
|
+
expect(INFRA_CONTRACT_STEPS[4]).toContain("#587");
|
|
27
|
+
expect(INFRA_CONTRACT_STEPS[5]).toContain("#635");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("lists the pending steps oldest first", () => {
|
|
31
|
+
expect(pendingInfraSteps(2, 4)).toEqual([INFRA_CONTRACT_STEPS[3], INFRA_CONTRACT_STEPS[4]]);
|
|
32
|
+
expect(pendingInfraSteps(3, 4)).toEqual([INFRA_CONTRACT_STEPS[4]]);
|
|
33
|
+
expect(pendingInfraSteps(4, 4)).toEqual([]);
|
|
34
|
+
expect(pendingInfraSteps(0, 1)).toEqual([INFRA_CONTRACT_STEPS[1]]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("names a version a newer release added by number", () => {
|
|
38
|
+
expect(infraStepsText(5, 6)).toBe("infrastructure step 6 (see that release's notes)");
|
|
39
|
+
});
|
|
40
|
+
});
|
package/src/infra-contract.ts
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* whose app-roll references infrastructure an install does not have yet fails
|
|
23
23
|
* at roll time, after migrations ran.
|
|
24
24
|
*/
|
|
25
|
-
export const INFRA_CONTRACT_VERSION =
|
|
25
|
+
export const INFRA_CONTRACT_VERSION = 5;
|
|
26
26
|
|
|
27
27
|
// History (why each bump happened — an install below a number needs the
|
|
28
28
|
// converge that introduced it):
|
|
@@ -30,3 +30,49 @@ export const INFRA_CONTRACT_VERSION = 2;
|
|
|
30
30
|
// 2 — the upgrader job (#696 Phase 2): its service account + scoped
|
|
31
31
|
// grants, the Cloud SQL backup role, its IAM database user, the job,
|
|
32
32
|
// the admin's run-only grant and CAELO_UPGRADER_JOB on the admin.
|
|
33
|
+
// 3 — public_role out of cloudsqlsuperuser (#633): the Admin API revoke
|
|
34
|
+
// runs as the operator; migration 0278's role audit refuses an install
|
|
35
|
+
// that has not had it, so the in-cloud upgrader must not get there
|
|
36
|
+
// first.
|
|
37
|
+
// 4 — #587: on `gcp`, the admin's custom role for writing the redirects
|
|
38
|
+
// table into the URL map (url-map-redirects-access.ts).
|
|
39
|
+
// 5 — #635: the gateway has no cms_admin login. Its GATEWAY_DATABASE_URL /
|
|
40
|
+
// GATEWAY_DATABASE_PASSWORD go, and its SA loses access to the
|
|
41
|
+
// gateway-role-password secret (a retired IAM binding, removed after
|
|
42
|
+
// the roll — the in-cloud upgrader cannot change IAM).
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What each contract version adds, in operator terms — the upgrader's
|
|
46
|
+
* refusal names the steps an install is missing. Keep one entry per version
|
|
47
|
+
* (the history above says why each bump happened).
|
|
48
|
+
*/
|
|
49
|
+
export const INFRA_CONTRACT_STEPS: Readonly<Record<number, string>> = {
|
|
50
|
+
1: "the converge/app-roll split",
|
|
51
|
+
2: "the in-cloud upgrader job and its scoped permissions",
|
|
52
|
+
3: "public_role out of the Cloud SQL superuser group (#633)",
|
|
53
|
+
4: "the admin's permission to serve redirects from the load balancer (#587)",
|
|
54
|
+
5: "the gateway's cms_admin login removed from its env and IAM (#635)",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The steps an install at contract `installed` lacks for `target`, oldest
|
|
59
|
+
* first. A release newer than this code can carry versions it does not know
|
|
60
|
+
* (the upgrader reads the NEW release's manifest); those are named by number.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* pendingInfraSteps(2, 4)
|
|
64
|
+
* // → ["public_role out of the Cloud SQL superuser group (#633)",
|
|
65
|
+
* // "the admin's permission to serve redirects from the load balancer (#587)"]
|
|
66
|
+
*/
|
|
67
|
+
export function pendingInfraSteps(installed: number, target: number): string[] {
|
|
68
|
+
const steps: string[] = [];
|
|
69
|
+
for (let v = Math.max(installed, 0) + 1; v <= target; v += 1) {
|
|
70
|
+
steps.push(INFRA_CONTRACT_STEPS[v] ?? `infrastructure step ${v} (see that release's notes)`);
|
|
71
|
+
}
|
|
72
|
+
return steps;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** {@link pendingInfraSteps} as one sentence fragment for messages. */
|
|
76
|
+
export function infraStepsText(installed: number, target: number): string {
|
|
77
|
+
return pendingInfraSteps(installed, target).join("; ");
|
|
78
|
+
}
|