@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/admin-domain.ts
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* `cms-provision admin-domain enable` — serve a gcp-firebase admin at
|
|
5
|
-
* `admin.<domain>` instead of its `*.run.app` URL
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* `admin.<domain>` instead of its `*.run.app` URL. (The staging host has no
|
|
6
|
+
* Cloud Run domain mapping any more since #1330: it is a custom domain of
|
|
7
|
+
* the staging Hosting site, staging-gateway-setup.ts.)
|
|
8
8
|
*
|
|
9
9
|
* The gcp-firebase stack only creates that Cloud Run domain mapping behind
|
|
10
10
|
* `provisionAdminDomain=true`, off by default: Google refuses a mapping
|
|
@@ -48,9 +48,6 @@ export type AdminDomainResult =
|
|
|
48
48
|
| { status: "needs-verification"; hostname: string; verifyDomain: string }
|
|
49
49
|
| { status: "failed"; error: string };
|
|
50
50
|
|
|
51
|
-
/** What `staging-domain enable` reports; same shape as the admin's. */
|
|
52
|
-
export type StagingDomainResult = AdminDomainResult;
|
|
53
|
-
|
|
54
51
|
interface DomainMappingJson {
|
|
55
52
|
status?: { resourceRecords?: { name?: string; type?: string; rrdata?: string }[] };
|
|
56
53
|
}
|
|
@@ -80,7 +77,7 @@ function logOf(opts: DomainOpts): (message: string) => void {
|
|
|
80
77
|
/**
|
|
81
78
|
* The one deployed Cloud Run service playing `slug` on a gcp-firebase
|
|
82
79
|
* install (stack-contract.ts cloudRunServicePrefix: since #1328 the visitor
|
|
83
|
-
* gateway is `caelo-production-data-gateway`)
|
|
80
|
+
* gateway is `caelo-production-data-gateway`).
|
|
84
81
|
*/
|
|
85
82
|
export async function findService(
|
|
86
83
|
run: Run,
|
|
@@ -126,116 +123,6 @@ export async function enableAdminDomain(opts: DomainOpts): Promise<AdminDomainRe
|
|
|
126
123
|
return mapDomain(run, logOf(opts), scope, opts.domain, `admin.${opts.domain}`, found.service);
|
|
127
124
|
}
|
|
128
125
|
|
|
129
|
-
/**
|
|
130
|
-
* #650 — map the install's staging host (its setting, default
|
|
131
|
-
* `staging.<domain>`; any FQDN) to the gateway, which serves the protected
|
|
132
|
-
* staging site. Cloud Run refuses domain-mapped requests while a service's
|
|
133
|
-
* ingress is internal-only, so the gateway's ingress is opened first (the
|
|
134
|
-
* stack declares the same since #650; installs from before keep the old
|
|
135
|
-
* value until this runs). Idempotent like the admin mapping.
|
|
136
|
-
*
|
|
137
|
-
* #1328 — a new mapping goes to the data gateway, which runs the gate. A
|
|
138
|
-
* mapping made before stays on the service that was the gateway (now the
|
|
139
|
-
* MCP-Auth-Proxy, which forwards it there) until the staging cutover moves
|
|
140
|
-
* it (#1330, #1331); an existing mapping is only reported here.
|
|
141
|
-
*/
|
|
142
|
-
export async function enableStagingDomain(
|
|
143
|
-
opts: DomainOpts & { stagingHost: string },
|
|
144
|
-
): Promise<StagingDomainResult> {
|
|
145
|
-
const run = opts.run ?? defaultGcloud;
|
|
146
|
-
const scope = [`--region=${opts.region}`, `--project=${opts.projectId}`];
|
|
147
|
-
const found = await findService(run, scope, "gateway");
|
|
148
|
-
if (!found.ok) return { status: "failed", error: found.error };
|
|
149
|
-
const ingress = await run([
|
|
150
|
-
"run",
|
|
151
|
-
"services",
|
|
152
|
-
"describe",
|
|
153
|
-
found.service,
|
|
154
|
-
...scope,
|
|
155
|
-
'--format=value(metadata.annotations["run.googleapis.com/ingress"])',
|
|
156
|
-
]);
|
|
157
|
-
if (!ingress.ok) {
|
|
158
|
-
return { status: "failed", error: `read the gateway's ingress: ${ingress.stderr.trim()}` };
|
|
159
|
-
}
|
|
160
|
-
if (ingress.stdout.trim() !== "all") {
|
|
161
|
-
const update = await run([
|
|
162
|
-
"run",
|
|
163
|
-
"services",
|
|
164
|
-
"update",
|
|
165
|
-
found.service,
|
|
166
|
-
...scope,
|
|
167
|
-
"--ingress=all",
|
|
168
|
-
"--quiet",
|
|
169
|
-
]);
|
|
170
|
-
if (!update.ok) {
|
|
171
|
-
return {
|
|
172
|
-
status: "failed",
|
|
173
|
-
error: `open the gateway's ingress for the domain mapping: ${update.stderr.trim()}`,
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
// Search Console verification: the install domain covers its own
|
|
178
|
-
// subdomains; a host on another domain is verified itself.
|
|
179
|
-
const verifyDomain =
|
|
180
|
-
opts.stagingHost === opts.domain || opts.stagingHost.endsWith(`.${opts.domain}`)
|
|
181
|
-
? opts.domain
|
|
182
|
-
: opts.stagingHost;
|
|
183
|
-
return mapDomain(run, logOf(opts), scope, verifyDomain, opts.stagingHost, found.service);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** What {@link stagingDomainState} found. */
|
|
187
|
-
export type StagingDomainState =
|
|
188
|
-
| { readonly status: "mapped" }
|
|
189
|
-
| { readonly status: "unmapped" }
|
|
190
|
-
| { readonly status: "unknown"; readonly reason: string };
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Whether the install's staging host already has its Cloud Run domain
|
|
194
|
-
* mapping, read-only. The upgrade asks before it tells the operator to run
|
|
195
|
-
* `staging-domain enable`, so an install that serves its staging host is
|
|
196
|
-
* not sent to set it up again. It never installs the gcloud `beta`
|
|
197
|
-
* component (an upgrade should not pause for a download to print a hint):
|
|
198
|
-
* without it, or on any error but gcloud's NOT_FOUND, the state is unknown
|
|
199
|
-
* and says why.
|
|
200
|
-
*/
|
|
201
|
-
export async function stagingDomainState(opts: {
|
|
202
|
-
projectId: string;
|
|
203
|
-
region: string;
|
|
204
|
-
stagingHost: string;
|
|
205
|
-
run?: Run;
|
|
206
|
-
}): Promise<StagingDomainState> {
|
|
207
|
-
const run = opts.run ?? defaultGcloud;
|
|
208
|
-
const listed = await run([
|
|
209
|
-
"components",
|
|
210
|
-
"list",
|
|
211
|
-
"--only-local-state",
|
|
212
|
-
"--filter=id=beta",
|
|
213
|
-
"--format=value(id)",
|
|
214
|
-
]);
|
|
215
|
-
if (!listed.ok) {
|
|
216
|
-
return {
|
|
217
|
-
status: "unknown",
|
|
218
|
-
reason: `check the gcloud "beta" component: ${listed.stderr.trim() || `exit ${listed.exitCode}`}`,
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
if (!listed.stdout.split("\n").some((s) => s.trim() === "beta")) {
|
|
222
|
-
return { status: "unknown", reason: 'the gcloud "beta" component is not installed' };
|
|
223
|
-
}
|
|
224
|
-
const described = await run([
|
|
225
|
-
"beta",
|
|
226
|
-
"run",
|
|
227
|
-
"domain-mappings",
|
|
228
|
-
"describe",
|
|
229
|
-
`--domain=${opts.stagingHost}`,
|
|
230
|
-
`--region=${opts.region}`,
|
|
231
|
-
`--project=${opts.projectId}`,
|
|
232
|
-
"--format=value(metadata.name)",
|
|
233
|
-
]);
|
|
234
|
-
if (described.ok) return { status: "mapped" };
|
|
235
|
-
if (/\bNOT_FOUND\b/.test(described.stderr)) return { status: "unmapped" };
|
|
236
|
-
return { status: "unknown", reason: described.stderr.trim() };
|
|
237
|
-
}
|
|
238
|
-
|
|
239
126
|
/** What {@link setAdminStagingHostEnv} did. */
|
|
240
127
|
export type AdminStagingEnvResult =
|
|
241
128
|
| { readonly status: "set"; readonly service: string; readonly previous: string | null }
|
package/src/admin-memory.test.ts
CHANGED
|
@@ -78,6 +78,8 @@ describe("self-hosted compose", () => {
|
|
|
78
78
|
caeloSecretKek: "0".repeat(64),
|
|
79
79
|
dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
|
|
80
80
|
internalSecret: "i".repeat(48),
|
|
81
|
+
stagingHost: "staging.example.com",
|
|
82
|
+
stagingGateSecret: "g".repeat(64),
|
|
81
83
|
diskSize: "20Gi",
|
|
82
84
|
ports: { http: 80, https: 443 },
|
|
83
85
|
project: "caelo-example-com",
|
|
@@ -91,7 +91,7 @@ describe("ensureAuditJob", () => {
|
|
|
91
91
|
for (const c of bucketGrants) expect(flag(c, "condition")).toBe("None");
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
-
it("grants the job only results-create,
|
|
94
|
+
it("grants the job only results-create, read of the staging bucket and logs", async () => {
|
|
95
95
|
const { run, calls } = fakeGcloud();
|
|
96
96
|
const r = await ensureAuditJob(TARGET, { run, sleep: async () => {} });
|
|
97
97
|
expect(r.ok).toBe(true);
|
|
@@ -102,10 +102,6 @@ describe("ensureAuditJob", () => {
|
|
|
102
102
|
"storage buckets add-iam-policy-binding gs://acme-caelo-production-staging",
|
|
103
103
|
"roles/storage.objectViewer",
|
|
104
104
|
],
|
|
105
|
-
[
|
|
106
|
-
"storage buckets add-iam-policy-binding gs://acme-caelo-production-static",
|
|
107
|
-
"roles/storage.objectViewer",
|
|
108
|
-
],
|
|
109
105
|
["projects add-iam-policy-binding acme", "roles/logging.logWriter"],
|
|
110
106
|
]);
|
|
111
107
|
for (const g of job) {
|
|
@@ -151,7 +147,7 @@ describe("ensureAuditJob", () => {
|
|
|
151
147
|
expect(flag(deploy, "max-retries")).toBe("0");
|
|
152
148
|
expect(flag(deploy, "parallelism")).toBe("15");
|
|
153
149
|
expect(flag(deploy, "set-env-vars")).toBe(
|
|
154
|
-
`CAELO_PROVIDER=gcp,CAELO_AUDIT_RESULTS_BUCKET=${RESULTS},CAELO_STAGING_BUCKET=acme-caelo-production-staging
|
|
150
|
+
`CAELO_PROVIDER=gcp,CAELO_AUDIT_RESULTS_BUCKET=${RESULTS},CAELO_STAGING_BUCKET=acme-caelo-production-staging`,
|
|
155
151
|
);
|
|
156
152
|
const line = deploy.join(" ");
|
|
157
153
|
for (const banned of ["--network", "--subnet", "--vpc", "--set-secrets", "--update-secrets"]) {
|
|
@@ -206,16 +202,20 @@ describe("ensureAuditJob", () => {
|
|
|
206
202
|
expect(deployOf(calls)).toBeUndefined();
|
|
207
203
|
});
|
|
208
204
|
|
|
209
|
-
it("on gcp-firebase reads
|
|
205
|
+
it("#1330 — on gcp-firebase reads the staging bucket too: no preview channel any more", async () => {
|
|
210
206
|
const { run, calls } = fakeGcloud();
|
|
211
207
|
await ensureAuditJob({ ...TARGET, provider: "gcp-firebase" }, { run, sleep: async () => {} });
|
|
212
208
|
const job = grants(calls).filter((g) => g.member === AUDIT);
|
|
213
|
-
expect(job.map((g) => g.role)).toEqual([
|
|
214
|
-
"roles/storage.objectCreator",
|
|
215
|
-
|
|
209
|
+
expect(job.map((g) => [g.where, g.role])).toEqual([
|
|
210
|
+
[`storage buckets add-iam-policy-binding gs://${RESULTS}`, "roles/storage.objectCreator"],
|
|
211
|
+
[
|
|
212
|
+
"storage buckets add-iam-policy-binding gs://acme-caelo-production-staging",
|
|
213
|
+
"roles/storage.objectViewer",
|
|
214
|
+
],
|
|
215
|
+
["projects add-iam-policy-binding acme", "roles/logging.logWriter"],
|
|
216
216
|
]);
|
|
217
217
|
expect(flag(deployOf(calls), "set-env-vars")).toBe(
|
|
218
|
-
`CAELO_PROVIDER=gcp-firebase,CAELO_AUDIT_RESULTS_BUCKET=${RESULTS}`,
|
|
218
|
+
`CAELO_PROVIDER=gcp-firebase,CAELO_AUDIT_RESULTS_BUCKET=${RESULTS},CAELO_STAGING_BUCKET=acme-caelo-production-staging`,
|
|
219
219
|
);
|
|
220
220
|
});
|
|
221
221
|
|
package/src/audit-job-setup.ts
CHANGED
|
@@ -22,10 +22,12 @@
|
|
|
22
22
|
*
|
|
23
23
|
* - `roles/storage.objectCreator` on the audit results bucket — create
|
|
24
24
|
* only: it can neither read results back nor overwrite one;
|
|
25
|
-
* -
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
25
|
+
* - `roles/storage.objectViewer` on the staging bucket: it serves the
|
|
26
|
+
* staged build to its own browser from the served build slot, which
|
|
27
|
+
* holds the complete build (#1330, admin-core quality/staged-origin.ts),
|
|
28
|
+
* so Lighthouse never goes through the staging gateway (#875). Both
|
|
29
|
+
* providers since #1330 (gcp-firebase had no bucket and audited the
|
|
30
|
+
* preview channel before); no read on the static bucket any more;
|
|
29
31
|
* - `roles/logging.logWriter`.
|
|
30
32
|
*
|
|
31
33
|
* Nothing else: no Secret Manager, no Cloud SQL, and no VPC egress, so the
|
|
@@ -231,18 +233,16 @@ export async function ensureAuditJob(
|
|
|
231
233
|
}
|
|
232
234
|
done.push(`bucket ${resultsUrl} (results deleted after 1 day)`);
|
|
233
235
|
|
|
234
|
-
// 3. Bucket grants: the job creates results and
|
|
236
|
+
// 3. Bucket grants: the job creates results and reads the staged build;
|
|
235
237
|
// the admin reads results.
|
|
236
238
|
const bucketGrants: { bucket: string; member: string; role: string; why: string }[] = [
|
|
237
239
|
{ bucket: results, member, role: "roles/storage.objectCreator", why: "tasks write results" },
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}))
|
|
245
|
-
: []),
|
|
240
|
+
{
|
|
241
|
+
bucket: gcpBucketName(projectId, env, "staging"),
|
|
242
|
+
member,
|
|
243
|
+
role: "roles/storage.objectViewer",
|
|
244
|
+
why: "tasks serve the staged build to their browser",
|
|
245
|
+
},
|
|
246
246
|
{
|
|
247
247
|
bucket: results,
|
|
248
248
|
member: runSaMember,
|
|
@@ -295,12 +295,7 @@ export async function ensureAuditJob(
|
|
|
295
295
|
const envVars = [
|
|
296
296
|
`CAELO_PROVIDER=${provider}`,
|
|
297
297
|
`CAELO_AUDIT_RESULTS_BUCKET=${results}`,
|
|
298
|
-
|
|
299
|
-
? [
|
|
300
|
-
`CAELO_STAGING_BUCKET=${gcpBucketName(projectId, env, "staging")}`,
|
|
301
|
-
`CAELO_STATIC_BUCKET=${gcpBucketName(projectId, env, "static")}`,
|
|
302
|
-
]
|
|
303
|
-
: []),
|
|
298
|
+
`CAELO_STAGING_BUCKET=${gcpBucketName(projectId, env, "staging")}`,
|
|
304
299
|
];
|
|
305
300
|
const deploy = await run([
|
|
306
301
|
"run",
|
|
@@ -9,9 +9,10 @@
|
|
|
9
9
|
* imported;
|
|
10
10
|
* - a container started like the Compose service (same Caddyfile shape,
|
|
11
11
|
* same `/srv/caelo` mount, same reload-loop command and snippet glob)
|
|
12
|
-
* answers redirected paths on the live
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* answers redirected paths on the live host with the planned status +
|
|
13
|
+
* Location — query kept, case-sensitive, the live page wins, 410 for
|
|
14
|
+
* gone — and serves nothing of output/staging on the staging host (#1330:
|
|
15
|
+
* that is the staging gateway's);
|
|
15
16
|
* - the reload loop picks up a deploy's new snippet, a snippet Caddy
|
|
16
17
|
* rejects leaves the previous redirects live, and the next good snippet
|
|
17
18
|
* is picked up again.
|
|
@@ -147,7 +148,7 @@ describe.skipIf(!dockerUp)(`self-hosted redirects through ${CADDY_IMAGE}`, () =>
|
|
|
147
148
|
ownerEmail: "owner@example.test",
|
|
148
149
|
tls: "internal",
|
|
149
150
|
publicSiteRoot: `${SITE_OUTPUT_ROOT}/output/production/current`,
|
|
150
|
-
|
|
151
|
+
stagingGatewayUpstream: "caelo-staging-gateway:8091",
|
|
151
152
|
adminUpstream: COMPOSE_ADMIN_UPSTREAM,
|
|
152
153
|
gatewayUpstream: COMPOSE_GATEWAY_UPSTREAM,
|
|
153
154
|
mcpAuthProxyUpstream: COMPOSE_MCP_AUTH_PROXY_UPSTREAM,
|
|
@@ -246,7 +247,11 @@ describe.skipIf(!dockerUp)(`self-hosted redirects through ${CADDY_IMAGE}`, () =>
|
|
|
246
247
|
expectRedirect("/hop1", 301, "/blog/");
|
|
247
248
|
expectRedirect("/Case", 301, "/about/");
|
|
248
249
|
expectRedirect("/q?dropped=1", 301, "/about?ref=old");
|
|
249
|
-
|
|
250
|
+
// #1330 — Caddy serves no staged file and answers no staging redirect:
|
|
251
|
+
// the staging host goes to the staging gateway (not running here, so
|
|
252
|
+
// Caddy answers 502), whatever lies in output/staging.
|
|
253
|
+
expect(probe(STAGING, "/staged-old").status).toBe(502);
|
|
254
|
+
expect(probe(STAGING, "/index.html").status).toBe(502);
|
|
250
255
|
expect(probe(LIVE, "/gone").status).toBe(410);
|
|
251
256
|
// Exact, case-sensitive: /case is not /Case.
|
|
252
257
|
expect(probe(LIVE, "/case").status).toBe(404);
|
|
@@ -24,7 +24,7 @@ const caddyfile = generateCaddyfile({
|
|
|
24
24
|
ownerEmail: "owner@example.com",
|
|
25
25
|
tls: "acme",
|
|
26
26
|
publicSiteRoot: "/srv/caelo/output/production/current",
|
|
27
|
-
|
|
27
|
+
stagingGatewayUpstream: "caelo-staging-gateway:8091",
|
|
28
28
|
adminUpstream: COMPOSE_ADMIN_UPSTREAM,
|
|
29
29
|
gatewayUpstream: COMPOSE_GATEWAY_UPSTREAM,
|
|
30
30
|
mcpAuthProxyUpstream: COMPOSE_MCP_AUTH_PROXY_UPSTREAM,
|
|
@@ -41,13 +41,13 @@ function block(host: string): string {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
describe("Caddyfile — redirects (#587)", () => {
|
|
44
|
-
it("
|
|
44
|
+
it("the live vhost imports the redirect snippet of the build it serves", () => {
|
|
45
45
|
expect(block("example.com")).toContain(
|
|
46
46
|
" root * /srv/caelo/output/production/current\n import /srv/caelo/output/production/current/_redirects.caddy*\n",
|
|
47
47
|
);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
);
|
|
48
|
+
// #1330 — staging's redirects are the staging gateway's (from the
|
|
49
|
+
// staging projection), not Caddy's.
|
|
50
|
+
expect(block("staging.example.com")).not.toContain("_redirects.caddy");
|
|
51
51
|
expect(block("admin.example.com")).not.toContain("_redirects.caddy");
|
|
52
52
|
});
|
|
53
53
|
|
|
@@ -67,6 +67,8 @@ describe("Compose — Caddy picks up new redirects (#587)", () => {
|
|
|
67
67
|
caeloSecretKek: "0".repeat(64),
|
|
68
68
|
dbTlsServerCertSha256: "abababababababababababababababababababababababababababababababab",
|
|
69
69
|
internalSecret: "i".repeat(48),
|
|
70
|
+
stagingHost: "staging.example.com",
|
|
71
|
+
stagingGateSecret: "g".repeat(64),
|
|
70
72
|
diskSize: "20Gi",
|
|
71
73
|
ports: { http: 80, https: 443 },
|
|
72
74
|
project: "caelo-example-com",
|
|
@@ -64,7 +64,7 @@ const base: CaddyfileSpec = {
|
|
|
64
64
|
ownerEmail: "owner@example.com",
|
|
65
65
|
tls: "acme",
|
|
66
66
|
publicSiteRoot: "/srv/caelo/output/production/current",
|
|
67
|
-
|
|
67
|
+
stagingGatewayUpstream: "caelo-staging-gateway:8091",
|
|
68
68
|
adminUpstream: COMPOSE_ADMIN_UPSTREAM,
|
|
69
69
|
gatewayUpstream: COMPOSE_GATEWAY_UPSTREAM,
|
|
70
70
|
mcpAuthProxyUpstream: COMPOSE_MCP_AUTH_PROXY_UPSTREAM,
|
package/src/caddy.test.ts
CHANGED
|
@@ -20,7 +20,7 @@ const spec: CaddyfileSpec = {
|
|
|
20
20
|
ownerEmail: "owner@example.com",
|
|
21
21
|
tls: "acme",
|
|
22
22
|
publicSiteRoot: "/srv/caelo/output/production/current",
|
|
23
|
-
|
|
23
|
+
stagingGatewayUpstream: "caelo-staging-gateway:8091",
|
|
24
24
|
adminUpstream: COMPOSE_ADMIN_UPSTREAM,
|
|
25
25
|
gatewayUpstream: COMPOSE_GATEWAY_UPSTREAM,
|
|
26
26
|
mcpAuthProxyUpstream: COMPOSE_MCP_AUTH_PROXY_UPSTREAM,
|
|
@@ -96,9 +96,12 @@ describe("Caddyfile upstreams (#707)", () => {
|
|
|
96
96
|
expect(out).not.toContain("localhost");
|
|
97
97
|
expect(out).not.toContain("127.0.0.1");
|
|
98
98
|
expect(block(out, "admin.example.com")).toContain("reverse_proxy caelo-admin:5173");
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
expect(block(out, "example.com")).toContain(
|
|
100
|
+
"handle /api/* {\n reverse_proxy caelo-gateway:8090\n",
|
|
101
|
+
);
|
|
102
|
+
// #1330 — the staging host goes to the staging gateway, every path.
|
|
103
|
+
expect(block(out, "staging.example.com")).toContain("reverse_proxy caelo-staging-gateway:8091");
|
|
104
|
+
expect(block(out, "staging.example.com")).not.toContain("caelo-gateway:8090");
|
|
102
105
|
});
|
|
103
106
|
|
|
104
107
|
it("does not route /admin/* on the public site (the admin routes from / on its own host)", () => {
|
|
@@ -123,12 +126,15 @@ describe("Caddyfile static site (#566)", () => {
|
|
|
123
126
|
);
|
|
124
127
|
});
|
|
125
128
|
|
|
126
|
-
it("serves production
|
|
129
|
+
it("serves production from its root; staging only through the staging gateway, noindex", () => {
|
|
127
130
|
const out = generateCaddyfile(spec);
|
|
128
131
|
expect(block(out, "example.com")).toContain("root * /srv/caelo/output/production/current");
|
|
129
132
|
expect(block(out, "example.com")).not.toContain("X-Robots-Tag");
|
|
130
133
|
const staging = block(out, "staging.example.com");
|
|
131
|
-
|
|
134
|
+
// #1330 — no second path to the staged files: Caddy serves none of them.
|
|
135
|
+
expect(staging).not.toContain("root *");
|
|
136
|
+
expect(staging).not.toContain("file_server");
|
|
137
|
+
expect(staging).not.toContain("output/staging");
|
|
132
138
|
expect(staging).toContain('header X-Robots-Tag "noindex"');
|
|
133
139
|
});
|
|
134
140
|
});
|
package/src/caddy.ts
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
* # MCP-Auth-Proxy on its own host, never the site's origin)
|
|
16
16
|
* # public → /api/* → <gatewayUpstream>; everything else is the
|
|
17
17
|
* # static site (try_files + file_server, 404 → /404.html)
|
|
18
|
+
* # staging → reverse_proxy <stagingGatewayUpstream> for every path
|
|
19
|
+
* # (#1330: the staging gateway serves the staged build
|
|
20
|
+
* # behind the staging gate, and staging's own /api)
|
|
18
21
|
* }
|
|
19
22
|
*
|
|
20
23
|
* Upstreams are `host:port` as seen FROM CADDY. In the Compose install
|
|
@@ -27,7 +30,10 @@
|
|
|
27
30
|
* run in CI) — issue #566 shipped a `try_files` nested inside
|
|
28
31
|
* `file_server { }`, which Caddy 2.11 refuses, so no vhost came up.
|
|
29
32
|
*
|
|
30
|
-
* Staging vhosts force `X-Robots-Tag: noindex`.
|
|
33
|
+
* Staging vhosts force `X-Robots-Tag: noindex`. Caddy serves no staging
|
|
34
|
+
* file itself (#1330): the staging gateway mounts the staged builds
|
|
35
|
+
* read-only, and Caddy has no other path to them, so nothing reaches a
|
|
36
|
+
* staged page without passing the gate.
|
|
31
37
|
*
|
|
32
38
|
* Public vhosts import the redirect snippet of the build they serve
|
|
33
39
|
* (`_redirects.caddy`, #587); the Caddy container reloads itself when a
|
|
@@ -91,7 +97,8 @@ export interface CaddyfileSpec {
|
|
|
91
97
|
readonly ownerEmail: string;
|
|
92
98
|
readonly tls: CaddyTlsMode;
|
|
93
99
|
readonly publicSiteRoot: string; // absolute path inside the Caddy container
|
|
94
|
-
|
|
100
|
+
/** #1330 — `host:port` of the staging gateway as Caddy reaches it (Compose: `caelo-staging-gateway:8091`). */
|
|
101
|
+
readonly stagingGatewayUpstream: string;
|
|
95
102
|
/** `host:port` of the admin as Caddy reaches it (Compose: `caelo-admin:5173`). */
|
|
96
103
|
readonly adminUpstream: string;
|
|
97
104
|
/** `host:port` of the API gateway as Caddy reaches it (Compose: `caelo-gateway:8090`). */
|
|
@@ -173,7 +180,7 @@ function assertSafeCaddyToken(label: string, value: string, kind: "address" | "t
|
|
|
173
180
|
export function generateCaddyfile(spec: CaddyfileSpec): string {
|
|
174
181
|
assertSafeCaddyToken("ownerEmail", spec.ownerEmail, "token");
|
|
175
182
|
assertSafeCaddyToken("publicSiteRoot", spec.publicSiteRoot, "token");
|
|
176
|
-
assertSafeCaddyToken("
|
|
183
|
+
assertSafeCaddyToken("stagingGatewayUpstream", spec.stagingGatewayUpstream, "token");
|
|
177
184
|
assertSafeCaddyToken("adminUpstream", spec.adminUpstream, "token");
|
|
178
185
|
assertSafeCaddyToken("gatewayUpstream", spec.gatewayUpstream, "token");
|
|
179
186
|
assertSafeCaddyToken("mcpAuthProxyUpstream", spec.mcpAuthProxyUpstream, "token");
|
|
@@ -232,12 +239,24 @@ function vhost(d: CaddyDomainSpec, spec: CaddyfileSpec): string {
|
|
|
232
239
|
header X-Content-Type-Options "${NOSNIFF}"
|
|
233
240
|
reverse_proxy ${spec.mcpAuthProxyUpstream}
|
|
234
241
|
}
|
|
242
|
+
`;
|
|
243
|
+
}
|
|
244
|
+
if (d.env === "staging") {
|
|
245
|
+
// #1330 — every path to the staging gateway: it runs the gate first and
|
|
246
|
+
// serves the staged build and staging's /api itself. The #891 security
|
|
247
|
+
// headers stay on as a second layer over the gateway's own.
|
|
248
|
+
return `${d.hostname} {${noindex}
|
|
249
|
+
header X-Content-Type-Options "${NOSNIFF}"
|
|
250
|
+
@svg path *.svg
|
|
251
|
+
header @svg Content-Security-Policy "${SVG_DOCUMENT_CSP}"
|
|
252
|
+
reverse_proxy ${spec.stagingGatewayUpstream}
|
|
253
|
+
}
|
|
235
254
|
`;
|
|
236
255
|
}
|
|
237
256
|
// public — API routes go to the gateway, the rest is the static site.
|
|
238
257
|
// `try_files` is its own directive (a rewrite), never a file_server
|
|
239
258
|
// subdirective (#566).
|
|
240
|
-
const root =
|
|
259
|
+
const root = spec.publicSiteRoot;
|
|
241
260
|
// #587 — the redirects table of the build this vhost serves.
|
|
242
261
|
return `${d.hostname} {${noindex}
|
|
243
262
|
root * ${root}
|