@caelo-cms/provisioning 0.11.13 → 0.11.14
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/dist/admin-domain.d.ts +4 -46
- package/dist/admin-domain.d.ts.map +1 -1
- package/dist/admin-domain.js +4 -101
- package/dist/admin-domain.js.map +1 -1
- package/dist/agent-db.js +29 -5
- package/dist/audit-job-setup.d.ts.map +1 -1
- package/dist/audit-job-setup.js +14 -19
- package/dist/audit-job-setup.js.map +1 -1
- package/dist/caddy.d.ts +2 -1
- package/dist/caddy.d.ts.map +1 -1
- package/dist/caddy.js +21 -3
- package/dist/caddy.js.map +1 -1
- package/dist/cli.js +111 -62
- package/dist/cli.js.map +1 -1
- package/dist/compose.d.ts +22 -2
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +53 -3
- package/dist/compose.js.map +1 -1
- package/dist/converge-checkpoint.d.ts +1 -0
- package/dist/converge-checkpoint.d.ts.map +1 -1
- package/dist/converge-checkpoint.js +1 -0
- package/dist/converge-checkpoint.js.map +1 -1
- package/dist/deployed-release.d.ts.map +1 -1
- package/dist/deployed-release.js +40 -8
- package/dist/deployed-release.js.map +1 -1
- package/dist/gcp-names.d.ts +33 -14
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +43 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/gcp-url-map.d.ts +19 -4
- package/dist/gcp-url-map.d.ts.map +1 -1
- package/dist/gcp-url-map.js +33 -5
- package/dist/gcp-url-map.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.d.ts.map +1 -1
- package/dist/infra-contract.js +18 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +5 -39
- package/dist/lifecycle.js.map +1 -1
- package/dist/mcp-auth-proxy-setup.d.ts +8 -2
- package/dist/mcp-auth-proxy-setup.d.ts.map +1 -1
- package/dist/mcp-auth-proxy-setup.js +4 -0
- package/dist/mcp-auth-proxy-setup.js.map +1 -1
- package/dist/release-manifest.d.ts +1 -1
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +2 -0
- package/dist/release-manifest.js.map +1 -1
- package/dist/remote-mcp.d.ts.map +1 -1
- package/dist/remote-mcp.js +11 -1
- package/dist/remote-mcp.js.map +1 -1
- package/dist/runtime-secrets.d.ts +10 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +10 -1
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/stack-contract.d.ts +61 -20
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +173 -35
- package/dist/stack-contract.js.map +1 -1
- package/dist/stack-converge.d.ts.map +1 -1
- package/dist/stack-converge.js +4 -2
- package/dist/stack-converge.js.map +1 -1
- package/dist/staging-admin.d.ts +2 -2
- package/dist/staging-admin.d.ts.map +1 -1
- package/dist/staging-gateway-setup.d.ts +140 -0
- package/dist/staging-gateway-setup.d.ts.map +1 -0
- package/dist/staging-gateway-setup.js +848 -0
- package/dist/staging-gateway-setup.js.map +1 -0
- package/dist/upgrade/app-roll.d.ts +4 -4
- package/dist/upgrade/app-roll.d.ts.map +1 -1
- package/dist/upgrade/app-roll.js +7 -5
- package/dist/upgrade/app-roll.js.map +1 -1
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +36 -27
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/image-forms.d.ts +3 -3
- package/dist/upgrade/image-forms.d.ts.map +1 -1
- package/dist/upgrade/image-forms.js +2 -2
- package/dist/upgrade/image-forms.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +20 -0
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +28 -0
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +3 -1
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +22 -1
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.js +30 -5
- package/dist/upgrader-job.js.map +1 -1
- package/dist/upgrader-records.d.ts +2 -2
- package/dist/upgrader-records.d.ts.map +1 -1
- package/dist/upgrader-records.js +2 -2
- package/dist/upgrader-records.js.map +1 -1
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +3 -3
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +48 -9
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +2 -2
- package/src/admin-domain.test.ts +0 -156
- package/src/admin-domain.ts +4 -117
- package/src/admin-memory.test.ts +2 -0
- package/src/audit-job-setup.test.ts +11 -11
- package/src/audit-job-setup.ts +14 -19
- package/src/caddy-redirects.docker.test.ts +10 -5
- package/src/caddy-redirects.test.ts +7 -5
- package/src/caddy-validate.test.ts +1 -1
- package/src/caddy.test.ts +12 -6
- package/src/caddy.ts +23 -4
- package/src/cli.ts +138 -77
- package/src/compose.ts +67 -2
- package/src/converge-checkpoint.ts +1 -0
- package/src/database-tls-builders.test.ts +3 -1
- package/src/deployed-release.test.ts +21 -0
- package/src/deployed-release.ts +42 -7
- package/src/gateway-env-isolation.test.ts +2 -0
- package/src/gcp-names.ts +53 -0
- package/src/gcp-url-map.test.ts +26 -2
- package/src/gcp-url-map.ts +36 -5
- package/src/index.test.ts +9 -3
- package/src/infra-contract.test.ts +4 -3
- package/src/infra-contract.ts +18 -1
- package/src/lifecycle.ts +8 -52
- package/src/mcp-auth-proxy-setup.test.ts +15 -6
- package/src/mcp-auth-proxy-setup.ts +14 -2
- package/src/release-manifest.ts +3 -1
- package/src/remote-mcp-stack-config.test.ts +1 -0
- package/src/remote-mcp.test.ts +5 -1
- package/src/remote-mcp.ts +10 -1
- package/src/runtime-secrets.test.ts +10 -1
- package/src/runtime-secrets.ts +21 -0
- package/src/self-hosted-backup.test.ts +6 -4
- package/src/self-hosted-compose.test.ts +2 -0
- package/src/stack-contract-parity.test.ts +7 -3
- package/src/stack-contract.test.ts +73 -13
- package/src/stack-contract.ts +195 -42
- package/src/stack-converge-secrets.test.ts +7 -2
- package/src/stack-converge.test.ts +37 -9
- package/src/stack-converge.ts +4 -1
- package/src/staging-admin.ts +2 -2
- package/src/staging-gateway-setup.test.ts +361 -0
- package/src/staging-gateway-setup.ts +1083 -0
- package/src/upgrade/app-roll.ts +7 -4
- package/src/upgrade/converge.ts +40 -26
- package/src/upgrade/image-forms.ts +7 -7
- package/src/upgrade/release-images.test.ts +28 -0
- package/src/upgrade/release-images.ts +44 -0
- package/src/upgrade/upgrade.test.ts +63 -11
- package/src/upgrade/upgrade.ts +29 -2
- package/src/upgrader-job.test.ts +23 -0
- package/src/upgrader-job.ts +45 -11
- package/src/upgrader-records.ts +7 -7
- package/src/wizard.ts +2 -3
- package/src/wizards/gcp.ts +73 -9
- package/stacks/gcp/index.ts +47 -6
- package/stacks/gcp-firebase/index.ts +44 -12
- package/stacks/self-hosted/index.ts +9 -2
package/src/stack-converge.ts
CHANGED
|
@@ -33,7 +33,7 @@ import { isIpLiteral } from "@caelo-cms/shared";
|
|
|
33
33
|
import { removeDefaultFirewallRules } from "./default-firewall.js";
|
|
34
34
|
import { gcloud as defaultGcloud, type GcloudResult } from "./gcloud.js";
|
|
35
35
|
import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
|
|
36
|
-
import { gcpBucketName, gcpSecretId } from "./gcp-names.js";
|
|
36
|
+
import { firebaseStagingSiteId, gcpBucketName, gcpSecretId } from "./gcp-names.js";
|
|
37
37
|
import { selfCommand } from "./self-command.js";
|
|
38
38
|
import {
|
|
39
39
|
ADMIN_MEMORY_DEFAULT,
|
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
STATIC_CDN_POLICY,
|
|
64
64
|
secretFilePath,
|
|
65
65
|
stackIamInvariants,
|
|
66
|
+
stagingGatewayEnvContract,
|
|
66
67
|
staticBackendBucketPrefix,
|
|
67
68
|
} from "./stack-contract.js";
|
|
68
69
|
import { GCP_STATIC_RESPONSE_HEADERS } from "./static-security-headers.js";
|
|
@@ -360,6 +361,7 @@ export function planContractEnv(
|
|
|
360
361
|
...runtime,
|
|
361
362
|
provider: "gcp-firebase",
|
|
362
363
|
firebaseSiteId: site.value,
|
|
364
|
+
firebaseStagingSiteId: firebaseStagingSiteId(install.env, site.value),
|
|
363
365
|
gatewayService: services.gateway.serviceName,
|
|
364
366
|
};
|
|
365
367
|
} else {
|
|
@@ -380,6 +382,7 @@ export function planContractEnv(
|
|
|
380
382
|
contracts = {
|
|
381
383
|
admin: desired("admin", adminEnvContract(inputs)),
|
|
382
384
|
gateway: desired("gateway", gatewayEnvContract(runtime)),
|
|
385
|
+
"staging-gateway": desired("staging-gateway", stagingGatewayEnvContract(runtime)),
|
|
383
386
|
"mcp-auth-proxy": desired("mcp-auth-proxy", mcpAuthProxyEnvContract(install)),
|
|
384
387
|
};
|
|
385
388
|
} catch (e) {
|
package/src/staging-admin.ts
CHANGED
|
@@ -20,8 +20,8 @@ import {
|
|
|
20
20
|
export const STAGING_PROVISION_SCOPE = "staging.provision";
|
|
21
21
|
const ENDPOINT = "/api/internal/staging/provision";
|
|
22
22
|
|
|
23
|
-
/** `staging-domain enable`
|
|
24
|
-
export type StagingAdminTarget = Extract<AdminInternalTarget, { provider: "gcp-firebase" }>;
|
|
23
|
+
/** `staging-domain enable` runs on the Google Cloud installs (#1330: both serve staging through their staging gateway). */
|
|
24
|
+
export type StagingAdminTarget = Extract<AdminInternalTarget, { provider: "gcp-firebase" | "gcp" }>;
|
|
25
25
|
export type StagingAdminDeps = AdminInternalDeps;
|
|
26
26
|
|
|
27
27
|
/** Main's draft pages, as the admin reports them. */
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #1330 — the staging gateway's provisioning (staging-gateway-setup.ts): the
|
|
5
|
+
* service with exactly its rights, and the edge that routes the staging host
|
|
6
|
+
* to it and nowhere else, per provider (E11 of #763).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import { generateDockerCompose } from "./compose.js";
|
|
11
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
12
|
+
import type { GcloudRunner } from "./gcloud-retry.js";
|
|
13
|
+
import { firebaseStagingSiteId } from "./gcp-names.js";
|
|
14
|
+
import type { HttpFetch } from "./runtime-secrets.js";
|
|
15
|
+
import {
|
|
16
|
+
ensureStagingGatewayEdge,
|
|
17
|
+
ensureStagingGatewayService,
|
|
18
|
+
firebaseDesiredRecords,
|
|
19
|
+
firebaseStagingSiteConfig,
|
|
20
|
+
isFirebaseStagingConfig,
|
|
21
|
+
planStagingHostRule,
|
|
22
|
+
type StagingGatewayServiceInputs,
|
|
23
|
+
stagingCertName,
|
|
24
|
+
stagingGatewayDeployArgs,
|
|
25
|
+
} from "./staging-gateway-setup.js";
|
|
26
|
+
|
|
27
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
28
|
+
const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
|
|
29
|
+
|
|
30
|
+
/** Answers by the longest matching command prefix; anything else succeeds empty. */
|
|
31
|
+
function fakeGcloud(answers: Record<string, GcloudResult> = {}) {
|
|
32
|
+
const calls: string[] = [];
|
|
33
|
+
const run: GcloudRunner = async (args) => {
|
|
34
|
+
const line = args.join(" ");
|
|
35
|
+
calls.push(line);
|
|
36
|
+
const key = Object.keys(answers)
|
|
37
|
+
.filter((k) => line.startsWith(k))
|
|
38
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
39
|
+
return key ? (answers[key] as GcloudResult) : ok();
|
|
40
|
+
};
|
|
41
|
+
return { run, calls };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const INPUTS: StagingGatewayServiceInputs = {
|
|
45
|
+
provider: "gcp-firebase",
|
|
46
|
+
projectId: "acme",
|
|
47
|
+
region: "europe-west1",
|
|
48
|
+
env: "production",
|
|
49
|
+
domain: "acme.com",
|
|
50
|
+
stagingHost: "staging.acme.com",
|
|
51
|
+
gatewayImageRef: `registry/gateway@sha256:${"b".repeat(64)}`,
|
|
52
|
+
databaseHost: "db1.p-label.europe-west1.sql-psa.goog",
|
|
53
|
+
vpc: { network: "caelo-production-vpc", subnetwork: "caelo-production-subnet" },
|
|
54
|
+
};
|
|
55
|
+
const STG_SA = "caelo-production-stg-gw-sa@acme.iam.gserviceaccount.com";
|
|
56
|
+
|
|
57
|
+
describe("the staging gateway service", () => {
|
|
58
|
+
it("deploys the gateway image as staging: its own account, its one login, no gate key", () => {
|
|
59
|
+
const args = stagingGatewayDeployArgs(INPUTS).join(" ");
|
|
60
|
+
expect(args).toContain("run deploy caelo-production-staging-gateway ");
|
|
61
|
+
expect(args).toContain(`--image=${INPUTS.gatewayImageRef}`);
|
|
62
|
+
expect(args).toContain(`--service-account=${STG_SA}`);
|
|
63
|
+
expect(args).toContain("CAELO_GATEWAY_ENVIRONMENT=staging");
|
|
64
|
+
expect(args).toContain("CAELO_STAGING_STORAGE=gs://acme-caelo-production-staging");
|
|
65
|
+
expect(args).toContain("public_staging_role@db1.p-label.europe-west1.sql-psa.goog");
|
|
66
|
+
expect(args).toContain(
|
|
67
|
+
"PUBLIC_DATABASE_PASSWORD=caelo-production-public-staging-role-password:latest",
|
|
68
|
+
);
|
|
69
|
+
expect(args).not.toContain("STAGING_GATE_SECRET");
|
|
70
|
+
expect(args).not.toContain("public_role@");
|
|
71
|
+
expect(args).not.toContain("MEDIA_STORAGE_URL");
|
|
72
|
+
expect(args).toContain("--network=caelo-production-vpc");
|
|
73
|
+
// Firebase rewrites stop at 60 s; Hosting arrives from Google's edge.
|
|
74
|
+
expect(args).toContain("--timeout=60");
|
|
75
|
+
expect(args).toContain("--ingress=all");
|
|
76
|
+
expect(stagingGatewayDeployArgs({ ...INPUTS, provider: "gcp" })).toContain(
|
|
77
|
+
"--ingress=internal-and-cloud-load-balancing",
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("gcp-firebase: creates a private staging bucket before the service; the account only reads it", async () => {
|
|
82
|
+
const { run, calls } = fakeGcloud({
|
|
83
|
+
[`iam service-accounts describe ${STG_SA}`]: fail("NOT_FOUND"),
|
|
84
|
+
"storage buckets describe gs://acme-caelo-production-staging": fail("404 NOT_FOUND"),
|
|
85
|
+
"run services describe caelo-production-staging-gateway": fail(
|
|
86
|
+
"ERROR: Cannot find service [caelo-production-staging-gateway]",
|
|
87
|
+
),
|
|
88
|
+
});
|
|
89
|
+
const outcomes = await ensureStagingGatewayService(INPUTS, { run, sleep: async () => {} });
|
|
90
|
+
expect(outcomes.every((o) => o.status !== "failed")).toBe(true);
|
|
91
|
+
const at = (re: RegExp) => calls.findIndex((c) => re.test(c));
|
|
92
|
+
const bucket = at(/^storage buckets create gs:\/\/acme-caelo-production-staging /);
|
|
93
|
+
expect(calls[bucket]).toContain("--public-access-prevention");
|
|
94
|
+
expect(calls[bucket]).toContain("--uniform-bucket-level-access");
|
|
95
|
+
expect(bucket).toBeLessThan(at(/^run deploy caelo-production-staging-gateway /));
|
|
96
|
+
// The staging gateway's grants: its login password, read on the staging bucket.
|
|
97
|
+
const grants = calls.filter((c) => c.includes(`serviceAccount:${STG_SA}`));
|
|
98
|
+
expect(grants).toEqual([
|
|
99
|
+
expect.stringMatching(
|
|
100
|
+
/^secrets add-iam-policy-binding caelo-production-public-staging-role-password .*secretAccessor/,
|
|
101
|
+
),
|
|
102
|
+
expect.stringMatching(
|
|
103
|
+
/^storage buckets add-iam-policy-binding gs:\/\/acme-caelo-production-staging .*--role=roles\/storage\.objectViewer/,
|
|
104
|
+
),
|
|
105
|
+
]);
|
|
106
|
+
// Invokable without a Google identity: the gate is its own, on every host.
|
|
107
|
+
expect(calls).toContainEqual(
|
|
108
|
+
expect.stringMatching(
|
|
109
|
+
/^run services add-iam-policy-binding caelo-production-staging-gateway .*--member=allUsers --role=roles\/run\.invoker/,
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("gcp: the bucket exists already (the stack's); an existing service is not deployed again", async () => {
|
|
115
|
+
const { run, calls } = fakeGcloud();
|
|
116
|
+
const outcomes = await ensureStagingGatewayService(
|
|
117
|
+
{ ...INPUTS, provider: "gcp" },
|
|
118
|
+
{ run, sleep: async () => {} },
|
|
119
|
+
);
|
|
120
|
+
expect(outcomes.every((o) => o.status !== "failed")).toBe(true);
|
|
121
|
+
expect(calls.some((c) => c.startsWith("storage buckets create"))).toBe(false);
|
|
122
|
+
expect(calls.some((c) => c.startsWith("run deploy"))).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("the gcp-firebase edge: a second Hosting site", () => {
|
|
127
|
+
const LIVE_SITE = "caelo-production-site-abc123";
|
|
128
|
+
const SITE = firebaseStagingSiteId("production", LIVE_SITE);
|
|
129
|
+
|
|
130
|
+
function adminWith(site: string) {
|
|
131
|
+
return {
|
|
132
|
+
"run services list --region europe-west1 --project acme --filter metadata.name~^caelo-production-admin":
|
|
133
|
+
ok("caelo-production-admin-aaa\n"),
|
|
134
|
+
"run services describe caelo-production-admin-aaa": ok(
|
|
135
|
+
JSON.stringify({
|
|
136
|
+
spec: {
|
|
137
|
+
template: {
|
|
138
|
+
spec: { containers: [{ env: [{ name: "CAELO_FIREBASE_SITE", value: site }] }] },
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
}),
|
|
142
|
+
),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
it("names the staging site after the live one, deterministically and within Hosting's limits", () => {
|
|
147
|
+
expect(SITE).toMatch(/^caelo-production-stg-[0-9a-f]{6}$/);
|
|
148
|
+
expect(firebaseStagingSiteId("production", LIVE_SITE)).toBe(SITE);
|
|
149
|
+
expect(firebaseStagingSiteId("production", "other-site")).not.toBe(SITE);
|
|
150
|
+
expect(SITE.length).toBeLessThanOrEqual(30);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("creates the site, one `**` rewrite to the staging gateway, the custom domain; deletes the old mapping", async () => {
|
|
154
|
+
const { run, calls } = fakeGcloud({
|
|
155
|
+
...adminWith(LIVE_SITE),
|
|
156
|
+
"auth print-access-token": ok("tok\n"),
|
|
157
|
+
// An existing Cloud Run domain mapping (#650) to the old gateway service.
|
|
158
|
+
"beta run domain-mappings describe": ok("staging.acme.com\n"),
|
|
159
|
+
});
|
|
160
|
+
const requests: { method: string; url: string; body?: string }[] = [];
|
|
161
|
+
const http: HttpFetch = async (url, init) => {
|
|
162
|
+
requests.push({ method: init.method, url, ...(init.body ? { body: init.body } : {}) });
|
|
163
|
+
const json = (status: number, body: unknown) => ({
|
|
164
|
+
ok: status < 400,
|
|
165
|
+
status,
|
|
166
|
+
text: async () => JSON.stringify(body),
|
|
167
|
+
});
|
|
168
|
+
if (init.method === "GET" && url.endsWith(`/sites/${SITE}`)) return json(404, {});
|
|
169
|
+
if (url.includes("/channels/live/releases")) return json(404, {});
|
|
170
|
+
if (init.method === "POST" && url.includes(`/sites/${SITE}/versions`)) {
|
|
171
|
+
return json(200, { name: `sites/${SITE}/versions/v1` });
|
|
172
|
+
}
|
|
173
|
+
if (init.method === "GET" && url.includes("/customDomains/staging.acme.com")) {
|
|
174
|
+
const seen = requests.filter((r) => r.url.includes("/customDomains")).length;
|
|
175
|
+
return seen === 1
|
|
176
|
+
? json(404, {})
|
|
177
|
+
: json(200, {
|
|
178
|
+
requiredDnsUpdates: {
|
|
179
|
+
desired: [
|
|
180
|
+
{
|
|
181
|
+
records: [
|
|
182
|
+
{ domainName: "staging.acme.com.", type: "A", rdata: "199.36.158.100" },
|
|
183
|
+
],
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
return json(200, {});
|
|
190
|
+
};
|
|
191
|
+
const r = await ensureStagingGatewayEdge(
|
|
192
|
+
{
|
|
193
|
+
provider: "gcp-firebase",
|
|
194
|
+
projectId: "acme",
|
|
195
|
+
region: "europe-west1",
|
|
196
|
+
env: "production",
|
|
197
|
+
domain: "acme.com",
|
|
198
|
+
stagingHost: "staging.acme.com",
|
|
199
|
+
},
|
|
200
|
+
{ run, http },
|
|
201
|
+
);
|
|
202
|
+
expect(r.ok).toBe(true);
|
|
203
|
+
if (!r.ok) return;
|
|
204
|
+
expect(r.dnsRecords).toEqual([
|
|
205
|
+
{ name: "staging.acme.com", type: "A", value: "199.36.158.100" },
|
|
206
|
+
]);
|
|
207
|
+
const version = requests.find((q) => q.method === "POST" && q.url.endsWith("/versions"));
|
|
208
|
+
expect(JSON.parse(version?.body ?? "{}")).toEqual({
|
|
209
|
+
config: {
|
|
210
|
+
rewrites: [
|
|
211
|
+
{
|
|
212
|
+
glob: "**",
|
|
213
|
+
run: { serviceId: "caelo-production-staging-gateway", region: "europe-west1" },
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
expect(
|
|
219
|
+
requests.some((q) => q.method === "POST" && q.url.includes("/releases?versionName=")),
|
|
220
|
+
).toBe(true);
|
|
221
|
+
expect(
|
|
222
|
+
requests.some(
|
|
223
|
+
(q) =>
|
|
224
|
+
q.method === "POST" && q.url.includes("customDomains?customDomainId=staging.acme.com"),
|
|
225
|
+
),
|
|
226
|
+
).toBe(true);
|
|
227
|
+
// No second path to the staged files: the old mapping goes.
|
|
228
|
+
expect(calls).toContainEqual(expect.stringMatching(/^beta run domain-mappings delete /));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("a site already serving the rewrite is not released again", () => {
|
|
232
|
+
const t = { env: "production", region: "europe-west1" };
|
|
233
|
+
expect(isFirebaseStagingConfig(firebaseStagingSiteConfig(t), t)).toBe(true);
|
|
234
|
+
expect(
|
|
235
|
+
isFirebaseStagingConfig({ ...firebaseStagingSiteConfig(t), redirects: [{ glob: "/x" }] }, t),
|
|
236
|
+
).toBe(false);
|
|
237
|
+
expect(isFirebaseStagingConfig({ rewrites: [{ glob: "/api/**" }] }, t)).toBe(false);
|
|
238
|
+
expect(isFirebaseStagingConfig(null, t)).toBe(false);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("reads the DNS records Hosting asks for", () => {
|
|
242
|
+
expect(firebaseDesiredRecords(null)).toEqual([]);
|
|
243
|
+
expect(
|
|
244
|
+
firebaseDesiredRecords({
|
|
245
|
+
requiredDnsUpdates: {
|
|
246
|
+
desired: [
|
|
247
|
+
{ records: [{ domainName: "s.example.com.", type: "TXT", rdata: "hosting=1" }] },
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
}),
|
|
251
|
+
).toEqual([{ name: "s.example.com", type: "TXT", value: "hosting=1" }]);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("the gcp edge: the staging host's own rule on the load balancer", () => {
|
|
256
|
+
const BACKEND =
|
|
257
|
+
"https://compute/projects/acme/global/backendServices/caelo-production-staging-gateway-backend";
|
|
258
|
+
const map = {
|
|
259
|
+
name: "caelo-production-url-map-1",
|
|
260
|
+
hostRules: [
|
|
261
|
+
{ hosts: ["acme.com"], pathMatcher: "public" },
|
|
262
|
+
{ hosts: ["admin.acme.com"], pathMatcher: "admin" },
|
|
263
|
+
],
|
|
264
|
+
pathMatchers: [
|
|
265
|
+
{ name: "public", defaultService: "static" },
|
|
266
|
+
{ name: "admin", defaultService: "admin" },
|
|
267
|
+
],
|
|
268
|
+
};
|
|
269
|
+
const t = { env: "production", domain: "acme.com", stagingHost: "staging.acme.com" };
|
|
270
|
+
|
|
271
|
+
it("routes the staging host, every path, to the staging gateway's backend; keeps every other rule", () => {
|
|
272
|
+
const next = planStagingHostRule(map, t, BACKEND);
|
|
273
|
+
expect(next?.hostRules).toEqual([
|
|
274
|
+
...map.hostRules,
|
|
275
|
+
{ hosts: ["staging.acme.com"], pathMatcher: "staging" },
|
|
276
|
+
]);
|
|
277
|
+
expect(next?.pathMatchers).toEqual([
|
|
278
|
+
...map.pathMatchers,
|
|
279
|
+
{ name: "staging", defaultService: BACKEND },
|
|
280
|
+
]);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("is a no-op when the map already routes it, and moves a changed staging host", () => {
|
|
284
|
+
const routed = planStagingHostRule(map, t, BACKEND);
|
|
285
|
+
if (!routed) throw new Error("expected a plan");
|
|
286
|
+
expect(planStagingHostRule(routed, t, BACKEND)).toBeNull();
|
|
287
|
+
// The stack's fail-closed placeholder (a redirect, no backend) is replaced.
|
|
288
|
+
const placeholder = {
|
|
289
|
+
...routed,
|
|
290
|
+
pathMatchers: [
|
|
291
|
+
...map.pathMatchers,
|
|
292
|
+
{ name: "staging", defaultUrlRedirect: { hostRedirect: "acme.com" } },
|
|
293
|
+
],
|
|
294
|
+
};
|
|
295
|
+
expect(planStagingHostRule(placeholder, t, BACKEND)?.pathMatchers?.at(-1)).toEqual({
|
|
296
|
+
name: "staging",
|
|
297
|
+
defaultService: BACKEND,
|
|
298
|
+
});
|
|
299
|
+
const moved = planStagingHostRule(routed, { ...t, stagingHost: "preview.acme.com" }, BACKEND);
|
|
300
|
+
expect(moved?.hostRules?.at(-1)).toEqual({
|
|
301
|
+
hosts: ["preview.acme.com"],
|
|
302
|
+
pathMatcher: "staging",
|
|
303
|
+
});
|
|
304
|
+
expect(moved?.hostRules).toHaveLength(3);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("names one certificate per staging host", () => {
|
|
308
|
+
expect(stagingCertName("production", "staging.acme.com")).toMatch(
|
|
309
|
+
/^caelo-production-staging-cert-[0-9a-f]{6}$/,
|
|
310
|
+
);
|
|
311
|
+
expect(stagingCertName("production", "staging.acme.com")).not.toBe(
|
|
312
|
+
stagingCertName("production", "preview.acme.com"),
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe("self-hosted: the staging gateway container", () => {
|
|
318
|
+
const compose = generateDockerCompose({
|
|
319
|
+
domain: "example.com",
|
|
320
|
+
stagingHost: "staging.example.com",
|
|
321
|
+
stagingGateSecret: "g".repeat(64),
|
|
322
|
+
postgresPassword: "pg",
|
|
323
|
+
rolePasswords: { admin: "a", public: "p", publicStaging: "ps" },
|
|
324
|
+
caeloSecretKek: "k".repeat(64),
|
|
325
|
+
internalSecret: "i".repeat(48),
|
|
326
|
+
dbTlsServerCertSha256: "c".repeat(64),
|
|
327
|
+
diskSize: "20Gi",
|
|
328
|
+
images: {
|
|
329
|
+
mode: "release",
|
|
330
|
+
admin: `ghcr.io/caelo-cms/admin@sha256:${"a".repeat(64)}`,
|
|
331
|
+
gateway: `ghcr.io/caelo-cms/gateway@sha256:${"b".repeat(64)}`,
|
|
332
|
+
},
|
|
333
|
+
ports: { http: 80, https: 443 },
|
|
334
|
+
project: "caelo",
|
|
335
|
+
});
|
|
336
|
+
const service = (name: string) => {
|
|
337
|
+
const start = compose.indexOf(`\n ${name}:\n`);
|
|
338
|
+
const end = compose.indexOf("\n\n", start + 1);
|
|
339
|
+
return compose.slice(start, end === -1 ? undefined : end);
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
it("runs the gateway image as staging, reading only output/staging, read-only", () => {
|
|
343
|
+
const stg = service("caelo-staging-gateway");
|
|
344
|
+
expect(stg).toContain(`image: ghcr.io/caelo-cms/gateway@sha256:${"b".repeat(64)}`);
|
|
345
|
+
expect(stg).toContain('CAELO_GATEWAY_ENVIRONMENT: "staging"');
|
|
346
|
+
expect(stg).toContain("./output/output/staging:/srv/staging:ro");
|
|
347
|
+
expect(stg).not.toContain("./output:");
|
|
348
|
+
expect(stg).toContain('CAELO_STAGING_STORAGE: "file:///srv/staging"');
|
|
349
|
+
expect(stg).toContain("public_staging_role:ps@");
|
|
350
|
+
expect(stg).not.toContain("public_role:");
|
|
351
|
+
expect(stg).not.toContain("CAELO_STAGING_GATE_SECRET");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("gives the admin the gate key and the staging gateway's internal URL; the live gateway neither", () => {
|
|
355
|
+
const admin = service("caelo-admin");
|
|
356
|
+
expect(admin).toContain(`CAELO_STAGING_GATE_SECRET: "${"g".repeat(64)}"`);
|
|
357
|
+
expect(admin).toContain('CAELO_STAGING_GATEWAY_URL: "http://caelo-staging-gateway:8091"');
|
|
358
|
+
expect(admin).toContain('CAELO_STAGING_HOST: "staging.example.com"');
|
|
359
|
+
expect(service("caelo-gateway")).not.toContain("STAGING_GATE");
|
|
360
|
+
});
|
|
361
|
+
});
|