@caelo-cms/provisioning 0.10.48 → 0.10.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/caddy/Caddyfile.production +5 -1
- package/caddy/Caddyfile.staging +5 -1
- package/dist/agent-db.js +102 -58
- 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 +248 -254
- 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 +86 -32
- package/dist/compose.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 +39 -1
- package/dist/infra-contract.js.map +1 -1
- 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 +2 -41
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +20 -122
- 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/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/upgrade/release-images.d.ts +14 -2
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +4 -2
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrader-assess.d.ts +4 -1
- package/dist/upgrader-assess.d.ts.map +1 -1
- package/dist/upgrader-assess.js +8 -1
- package/dist/upgrader-assess.js.map +1 -1
- package/dist/upgrader-job.d.ts +3 -1
- package/dist/upgrader-job.d.ts.map +1 -1
- package/dist/upgrader-job.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.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/admin-memory.test.ts +2 -2
- package/src/agent/run.test.ts +55 -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/cli-routing.test.ts +12 -0
- package/src/cli-routing.ts +7 -0
- package/src/cli.ts +299 -287
- package/src/compose.ts +106 -34
- 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 +18 -146
- package/src/index.ts +0 -9
- package/src/infra-contract.test.ts +39 -0
- package/src/infra-contract.ts +42 -1
- 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 +20 -1
- package/src/release-manifest.ts +39 -158
- 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 +2 -2
- 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/release-images.ts +4 -2
- package/src/upgrade/upgrade.test.ts +78 -1
- package/src/upgrader-assess.test.ts +64 -1
- package/src/upgrader-assess.ts +20 -2
- package/src/upgrader-job.test.ts +3 -0
- package/src/upgrader-job.ts +3 -0
- 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.ts +48 -0
- package/stacks/aws/index.ts +34 -2
- package/stacks/azure/index.ts +13 -0
- package/stacks/gcp/index.ts +24 -17
- package/stacks/gcp-firebase/index.ts +7 -0
- 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
|
@@ -179,6 +179,9 @@ const TARGET: GcpUpgradeTarget = {
|
|
|
179
179
|
skipHealthCheck: false,
|
|
180
180
|
};
|
|
181
181
|
|
|
182
|
+
/** The same install on the `gcp` provider (load balancer + URL map). */
|
|
183
|
+
const GCP_TARGET: GcpUpgradeTarget = { ...TARGET, provider: "gcp" };
|
|
184
|
+
|
|
182
185
|
function upgradeDeps(
|
|
183
186
|
events: string[],
|
|
184
187
|
opts: { overrides?: [RegExp, Answer][]; deep?: HealthOutcome } = {},
|
|
@@ -217,7 +220,7 @@ function upgradeDeps(
|
|
|
217
220
|
|
|
218
221
|
/** Calls that change IAM, service accounts, secrets or APIs — converge-only. */
|
|
219
222
|
const CONVERGE_WRITE =
|
|
220
|
-
/add-iam-policy-binding|remove-iam-policy-binding|set-iam-policy|service-accounts create|secrets create|secrets versions add|services enable|roles create|firewall-rules delete/;
|
|
223
|
+
/add-iam-policy-binding|remove-iam-policy-binding|set-iam-policy|service-accounts create|secrets create|secrets versions add|services enable|roles create|roles update|roles undelete|firewall-rules delete|users assign-roles/;
|
|
221
224
|
/** Calls that move a service or run migrations — app-roll-only. */
|
|
222
225
|
const APP_ROLL_WRITE =
|
|
223
226
|
/^run services update |^run services update-traffic|jobs execute caelo-migrate/;
|
|
@@ -383,6 +386,80 @@ describe("upgradeGcpInstall — converge, then app-roll", () => {
|
|
|
383
386
|
expect(calls.some((c) => c.includes("remove-iam-policy-binding"))).toBe(false);
|
|
384
387
|
});
|
|
385
388
|
|
|
389
|
+
it("revokes public_role's Cloud SQL role memberships before the migrations audit them (#633)", async () => {
|
|
390
|
+
const events: string[] = [];
|
|
391
|
+
const { deps, lines } = upgradeDeps(events);
|
|
392
|
+
expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
|
|
393
|
+
const revoke = indexOf(
|
|
394
|
+
events,
|
|
395
|
+
/^gcloud sql users assign-roles public_role --instance=caelo-production-pg-abc --type=BUILT_IN --revoke-existing-roles --project=acme/,
|
|
396
|
+
);
|
|
397
|
+
expect(indexOf(events, /^gcloud sql backups create/)).toBeLessThan(revoke);
|
|
398
|
+
expect(revoke).toBeLessThan(indexOf(events, /^migrate /));
|
|
399
|
+
expect(lines).toContain(
|
|
400
|
+
"done: public_role holds no database roles (no database roles left on caelo-production-pg-abc)",
|
|
401
|
+
);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("a refused public_role revoke stops the upgrade before migrations and any roll (#633)", async () => {
|
|
405
|
+
const events: string[] = [];
|
|
406
|
+
const { deps, calls, lines } = upgradeDeps(events, {
|
|
407
|
+
overrides: [[/^sql users assign-roles public_role/, fail("PERMISSION_DENIED: no")]],
|
|
408
|
+
});
|
|
409
|
+
expect(await upgradeGcpInstall(TARGET, deps)).toBe(false);
|
|
410
|
+
expect(events.some((e) => e.startsWith("migrate"))).toBe(false);
|
|
411
|
+
expect(calls.filter((c) => APP_ROLL_WRITE.test(c))).toEqual([]);
|
|
412
|
+
expect(lines).toContain(
|
|
413
|
+
"failed: public_role's role memberships could not be revoked. Aborting upgrade.",
|
|
414
|
+
);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("on gcp, runs both contract steps: the public_role revoke (3) and the URL-map redirects role (4)", async () => {
|
|
418
|
+
const events: string[] = [];
|
|
419
|
+
const { deps, lines } = upgradeDeps(events, {
|
|
420
|
+
overrides: [[/^iam roles describe/, fail("NOT_FOUND: role")]],
|
|
421
|
+
});
|
|
422
|
+
expect(await upgradeGcpInstall({ ...GCP_TARGET, infraContractVersion: 4 }, deps)).toBe(true);
|
|
423
|
+
const revoke = indexOf(events, /^gcloud sql users assign-roles public_role /);
|
|
424
|
+
const role = indexOf(
|
|
425
|
+
events,
|
|
426
|
+
/^gcloud iam roles create caelo_production_urlMapRedirects --project=acme .*--permissions=compute\.urlMaps\.list,/,
|
|
427
|
+
);
|
|
428
|
+
const grant = indexOf(
|
|
429
|
+
events,
|
|
430
|
+
/^gcloud projects add-iam-policy-binding acme --member=serviceAccount:caelo-production-run-sa@acme\.iam\.gserviceaccount\.com --role=projects\/acme\/roles\/caelo_production_urlMapRedirects /,
|
|
431
|
+
);
|
|
432
|
+
expect(revoke).toBeLessThan(indexOf(events, /^migrate /));
|
|
433
|
+
expect(role).toBeLessThan(grant);
|
|
434
|
+
expect(grant).toBeLessThan(indexOf(events, /^migrate /));
|
|
435
|
+
expect(lines).toContain(
|
|
436
|
+
"done: URL-map redirects role ready (projects/acme/roles/caelo_production_urlMapRedirects)",
|
|
437
|
+
);
|
|
438
|
+
// (This fake models gcp-firebase's listings, so gcp-only steps such as
|
|
439
|
+
// the default-firewall cleanup stay incomplete; the role step is not.)
|
|
440
|
+
expect(lines.some((l) => l.includes("URL-map redirects role not set up"))).toBe(false);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("on gcp, a refused redirects role leaves the converge incomplete: contract 4 is not recorded", async () => {
|
|
444
|
+
const events: string[] = [];
|
|
445
|
+
const { deps, lines, recorded } = upgradeDeps(events, {
|
|
446
|
+
overrides: [
|
|
447
|
+
[/^iam roles describe/, fail("NOT_FOUND: role")],
|
|
448
|
+
[/^iam roles create/, fail("PERMISSION_DENIED: iam.roles.create")],
|
|
449
|
+
],
|
|
450
|
+
});
|
|
451
|
+
expect(await upgradeGcpInstall({ ...GCP_TARGET, infraContractVersion: 4 }, deps)).toBe(true);
|
|
452
|
+
expect(lines.some((l) => l.startsWith("warned: URL-map redirects role not set up"))).toBe(true);
|
|
453
|
+
expect(recorded.contract).toEqual([]);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("on gcp-firebase there is no URL map: no redirects role", async () => {
|
|
457
|
+
const events: string[] = [];
|
|
458
|
+
const { deps, calls } = upgradeDeps(events);
|
|
459
|
+
expect(await upgradeGcpInstall(TARGET, deps)).toBe(true);
|
|
460
|
+
expect(calls.some((c) => c.includes("urlMapRedirects"))).toBe(false);
|
|
461
|
+
});
|
|
462
|
+
|
|
386
463
|
it("runs the keyless-provisioner step as the first converge step; its failure stops before migrations", async () => {
|
|
387
464
|
const events: string[] = [];
|
|
388
465
|
const { deps, calls } = upgradeDeps(events);
|
|
@@ -35,9 +35,12 @@ const POLICY: StoredUpdatePolicy = {
|
|
|
35
35
|
windowEnd: null,
|
|
36
36
|
windowTimezone: "UTC",
|
|
37
37
|
soakHours: 24,
|
|
38
|
+
securitySoakHours: 2,
|
|
38
39
|
paused: false,
|
|
39
40
|
pausedReason: null,
|
|
40
41
|
pausedAt: null,
|
|
42
|
+
pausedUntil: null,
|
|
43
|
+
pausedByOwner: false,
|
|
41
44
|
updatedAt: "2026-10-13T00:00:00Z",
|
|
42
45
|
};
|
|
43
46
|
|
|
@@ -57,6 +60,7 @@ interface Row {
|
|
|
57
60
|
readonly manifest?: ReleaseManifestResult;
|
|
58
61
|
readonly installed?: InstalledState;
|
|
59
62
|
readonly trigger?: "manual" | "auto";
|
|
63
|
+
readonly autoBasis?: "security" | "patch" | "minor";
|
|
60
64
|
readonly policy?: StoredUpdatePolicy;
|
|
61
65
|
readonly target?: string;
|
|
62
66
|
/** "proceed" or `<status>/<code>`. */
|
|
@@ -109,6 +113,28 @@ const ROWS: readonly Row[] = [
|
|
|
109
113
|
expect: "needs_cloud_shell/infra-contract",
|
|
110
114
|
reasonContains: "Cloud Shell",
|
|
111
115
|
},
|
|
116
|
+
{
|
|
117
|
+
name: "contract 2 → 4 names both pending steps (#633, #587)",
|
|
118
|
+
manifest: verified({ infraContractVersion: 4 }),
|
|
119
|
+
installed: { version: "0.10.45", infraContractVersion: 2 },
|
|
120
|
+
expect: "needs_cloud_shell/infra-contract",
|
|
121
|
+
reasonContains:
|
|
122
|
+
"infrastructure contract 2 → 4: public_role out of the Cloud SQL superuser group (#633); the admin's permission to serve redirects from the load balancer (#587)",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: "contract 3 → 4 names only the redirects role (#587)",
|
|
126
|
+
manifest: verified({ infraContractVersion: 4 }),
|
|
127
|
+
installed: { version: "0.10.45", infraContractVersion: 3 },
|
|
128
|
+
expect: "needs_cloud_shell/infra-contract",
|
|
129
|
+
reasonContains:
|
|
130
|
+
"infrastructure contract 3 → 4: the admin's permission to serve redirects from the load balancer (#587))",
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: "contract 4 installed, release at 4",
|
|
134
|
+
manifest: verified({ infraContractVersion: 4 }),
|
|
135
|
+
installed: { version: "0.10.45", infraContractVersion: 4 },
|
|
136
|
+
expect: "proceed",
|
|
137
|
+
},
|
|
112
138
|
{
|
|
113
139
|
name: "never converged at all",
|
|
114
140
|
installed: { version: "0.10.45", infraContractVersion: 0 },
|
|
@@ -157,6 +183,39 @@ const ROWS: readonly Row[] = [
|
|
|
157
183
|
manifest: verified({ security: true, advisories: ["GHSA-abcd-efgh-ijkm"] }),
|
|
158
184
|
expect: "proceed",
|
|
159
185
|
},
|
|
186
|
+
{
|
|
187
|
+
name: "automatic 'security' flag without a published advisory",
|
|
188
|
+
trigger: "auto",
|
|
189
|
+
policy: { ...POLICY, autoMinor: false, autoPatch: false },
|
|
190
|
+
manifest: verified({ security: true, advisories: [] }),
|
|
191
|
+
expect: "refused/policy",
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: "queued as a security fix, signed manifest says it is not one",
|
|
195
|
+
trigger: "auto",
|
|
196
|
+
autoBasis: "security",
|
|
197
|
+
expect: "refused/policy",
|
|
198
|
+
reasonContains: "names no published security advisory",
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: "queued as a security fix, signed manifest agrees",
|
|
202
|
+
trigger: "auto",
|
|
203
|
+
autoBasis: "security",
|
|
204
|
+
manifest: verified({ security: true, advisories: ["GHSA-abcd-efgh-ijkm"] }),
|
|
205
|
+
expect: "proceed",
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
name: "automatic, Owner pause already over",
|
|
209
|
+
trigger: "auto",
|
|
210
|
+
policy: {
|
|
211
|
+
...POLICY,
|
|
212
|
+
paused: true,
|
|
213
|
+
pausedByOwner: true,
|
|
214
|
+
pausedAt: "2026-10-12T00:00:00Z",
|
|
215
|
+
pausedUntil: "2026-10-13T00:00:00Z",
|
|
216
|
+
},
|
|
217
|
+
expect: "proceed",
|
|
218
|
+
},
|
|
160
219
|
{
|
|
161
220
|
name: "automatic, outside the window",
|
|
162
221
|
trigger: "auto",
|
|
@@ -175,7 +234,11 @@ const ROWS: readonly Row[] = [
|
|
|
175
234
|
describe("assessUpgradeRequest — refusal matrix", () => {
|
|
176
235
|
it.each(ROWS.map((r) => [r.name, r] as const))("%s", (_name, row) => {
|
|
177
236
|
const d = assessUpgradeRequest({
|
|
178
|
-
request: {
|
|
237
|
+
request: {
|
|
238
|
+
targetVersion: row.target ?? "0.11.0",
|
|
239
|
+
trigger: row.trigger ?? "manual",
|
|
240
|
+
autoBasis: row.autoBasis ?? null,
|
|
241
|
+
},
|
|
179
242
|
manifest: row.manifest ?? verified(),
|
|
180
243
|
installed: row.installed ?? INSTALLED,
|
|
181
244
|
policy: row.policy ?? POLICY,
|
package/src/upgrader-assess.ts
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
* | contract (destructive) migrations, automatic run | refused |
|
|
31
31
|
* | contract migrations, Owner's click | proceed + warning |
|
|
32
32
|
* | automatic run the update policy does not allow | refused |
|
|
33
|
+
* | automatic "security" run, signed manifest disagrees | refused |
|
|
33
34
|
*
|
|
34
35
|
* Self-hosted installs (the host agent, #696 Phase 4c) have no separately
|
|
35
36
|
* converged infrastructure: their Compose file, roles and bootstrap script
|
|
@@ -40,11 +41,13 @@
|
|
|
40
41
|
*/
|
|
41
42
|
|
|
42
43
|
import {
|
|
44
|
+
type AutoUpdateBasis,
|
|
43
45
|
autoInstallRefusal,
|
|
44
46
|
type PlatformUpdateTrigger,
|
|
45
47
|
type StoredUpdatePolicy,
|
|
46
48
|
updateKind,
|
|
47
49
|
} from "@caelo-cms/shared";
|
|
50
|
+
import { infraStepsText } from "./infra-contract.js";
|
|
48
51
|
import {
|
|
49
52
|
assessAutoInstall,
|
|
50
53
|
channelForVersion,
|
|
@@ -119,7 +122,12 @@ const stop = (
|
|
|
119
122
|
|
|
120
123
|
/** Decide what the job does with a request. See the file comment for the matrix. */
|
|
121
124
|
export function assessUpgradeRequest(input: {
|
|
122
|
-
readonly request: {
|
|
125
|
+
readonly request: {
|
|
126
|
+
readonly targetVersion: string;
|
|
127
|
+
readonly trigger: PlatformUpdateTrigger;
|
|
128
|
+
/** Why the admin's scheduler queued an automatic request (Phase 5). */
|
|
129
|
+
readonly autoBasis?: AutoUpdateBasis | null;
|
|
130
|
+
};
|
|
123
131
|
readonly manifest: ReleaseManifestResult;
|
|
124
132
|
readonly installed: InstalledState;
|
|
125
133
|
readonly policy: StoredUpdatePolicy;
|
|
@@ -187,7 +195,7 @@ export function assessUpgradeRequest(input: {
|
|
|
187
195
|
return stop(
|
|
188
196
|
"needs_cloud_shell",
|
|
189
197
|
"infra-contract",
|
|
190
|
-
`v${target} changes this install's cloud setup (infrastructure contract ${infraContractVersion} → ${manifest.infraContractVersion}). The admin may not change cloud permissions, so this release is installed once with your own Google account in Cloud Shell.`,
|
|
198
|
+
`v${target} changes this install's cloud setup (infrastructure contract ${infraContractVersion} → ${manifest.infraContractVersion}: ${infraStepsText(infraContractVersion, manifest.infraContractVersion)}). The admin may not change cloud permissions, so this release is installed once with your own Google account in Cloud Shell.`,
|
|
191
199
|
manifest,
|
|
192
200
|
);
|
|
193
201
|
}
|
|
@@ -228,6 +236,16 @@ export function assessUpgradeRequest(input: {
|
|
|
228
236
|
if (refusal) {
|
|
229
237
|
return stop("refused", "policy", `Not installed automatically: ${refusal}.`, manifest);
|
|
230
238
|
}
|
|
239
|
+
// The scheduler read the manifest unverified; it may only have used the
|
|
240
|
+
// security fix's shorter soak period if the SIGNED manifest backs it.
|
|
241
|
+
if (request.autoBasis === "security" && assessment.kind !== "security") {
|
|
242
|
+
return stop(
|
|
243
|
+
"refused",
|
|
244
|
+
"policy",
|
|
245
|
+
`Not installed automatically: v${target} was queued as a security fix, but its signed release information names no published security advisory. Install it from Settings → Updates.`,
|
|
246
|
+
manifest,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
231
249
|
}
|
|
232
250
|
return { kind: "proceed", manifest, warnings };
|
|
233
251
|
}
|
package/src/upgrader-job.test.ts
CHANGED
|
@@ -53,9 +53,12 @@ const POLICY: StoredUpdatePolicy = {
|
|
|
53
53
|
windowEnd: null,
|
|
54
54
|
windowTimezone: "UTC",
|
|
55
55
|
soakHours: 24,
|
|
56
|
+
securitySoakHours: 2,
|
|
56
57
|
paused: false,
|
|
57
58
|
pausedReason: null,
|
|
58
59
|
pausedAt: null,
|
|
60
|
+
pausedUntil: null,
|
|
61
|
+
pausedByOwner: false,
|
|
59
62
|
updatedAt: "2026-10-13T00:00:00Z",
|
|
60
63
|
};
|
|
61
64
|
|
package/src/upgrader-job.ts
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import type {
|
|
36
|
+
AutoUpdateBasis,
|
|
36
37
|
PlatformUpdateEventLevel,
|
|
37
38
|
PlatformUpdatePhase,
|
|
38
39
|
PlatformUpdateTrigger,
|
|
@@ -61,6 +62,8 @@ export interface ClaimedUpdate {
|
|
|
61
62
|
readonly id: string;
|
|
62
63
|
readonly targetVersion: string;
|
|
63
64
|
readonly trigger: PlatformUpdateTrigger;
|
|
65
|
+
/** Why the admin's scheduler queued an automatic request; null for the Owner's. */
|
|
66
|
+
readonly autoBasis?: AutoUpdateBasis | null;
|
|
64
67
|
readonly policy: StoredUpdatePolicy;
|
|
65
68
|
}
|
|
66
69
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #587 — `cms-provision upgrade` gives older `gcp` installs the admin's
|
|
5
|
+
* URL-map redirects role: create or update the custom role, undelete a
|
|
6
|
+
* soft-deleted one, bind it to the admin's run SA.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import type { GcloudResult } from "./gcloud.js";
|
|
11
|
+
import { ensureUrlMapRedirectsAccess } from "./url-map-redirects-access.js";
|
|
12
|
+
|
|
13
|
+
const ok = (stdout = ""): GcloudResult => ({ ok: true, stdout, stderr: "", exitCode: 0 });
|
|
14
|
+
const fail = (stderr: string): GcloudResult => ({ ok: false, stdout: "", stderr, exitCode: 1 });
|
|
15
|
+
|
|
16
|
+
/** The gcloud verb of a call (`projects add-iam-policy-binding` has it second). */
|
|
17
|
+
const verb = (c: string[]) => (c[0] === "projects" ? c[1] : c[2]);
|
|
18
|
+
|
|
19
|
+
function runner(answer: (args: string[]) => GcloudResult) {
|
|
20
|
+
const calls: string[][] = [];
|
|
21
|
+
return {
|
|
22
|
+
calls,
|
|
23
|
+
run: async (args: string[]) => {
|
|
24
|
+
calls.push(args);
|
|
25
|
+
return answer(args);
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const PERMS =
|
|
31
|
+
"--permissions=compute.urlMaps.list,compute.urlMaps.get,compute.urlMaps.update,compute.backendBuckets.use,compute.backendServices.use,compute.globalOperations.get";
|
|
32
|
+
const BIND = [
|
|
33
|
+
"projects",
|
|
34
|
+
"add-iam-policy-binding",
|
|
35
|
+
"p1",
|
|
36
|
+
"--member=serviceAccount:caelo-production-run-sa@p1.iam.gserviceaccount.com",
|
|
37
|
+
"--role=projects/p1/roles/caelo_production_urlMapRedirects",
|
|
38
|
+
"--condition=None",
|
|
39
|
+
"--quiet",
|
|
40
|
+
"--format=none",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
describe("ensureUrlMapRedirectsAccess", () => {
|
|
44
|
+
it("creates the role when missing, then binds it", async () => {
|
|
45
|
+
const r = runner((a) => (a[2] === "describe" ? fail("NOT_FOUND") : ok()));
|
|
46
|
+
const out = await ensureUrlMapRedirectsAccess({
|
|
47
|
+
projectId: "p1",
|
|
48
|
+
env: "production",
|
|
49
|
+
run: r.run,
|
|
50
|
+
});
|
|
51
|
+
expect(out).toEqual({ ok: true, role: "projects/p1/roles/caelo_production_urlMapRedirects" });
|
|
52
|
+
expect(r.calls[1]?.slice(0, 5)).toEqual([
|
|
53
|
+
"iam",
|
|
54
|
+
"roles",
|
|
55
|
+
"create",
|
|
56
|
+
"caelo_production_urlMapRedirects",
|
|
57
|
+
"--project=p1",
|
|
58
|
+
]);
|
|
59
|
+
expect(r.calls[1]).toContain(PERMS);
|
|
60
|
+
expect(r.calls[2]).toEqual(BIND);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("updates an existing role to the current permissions (no drift)", async () => {
|
|
64
|
+
const r = runner((a) => (a[2] === "describe" ? ok('{"name":"x"}') : ok()));
|
|
65
|
+
await ensureUrlMapRedirectsAccess({ projectId: "p1", env: "production", run: r.run });
|
|
66
|
+
expect(r.calls.map(verb)).toEqual(["describe", "update", "add-iam-policy-binding"]);
|
|
67
|
+
expect(r.calls[1]).toContain(PERMS);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("undeletes a soft-deleted role first", async () => {
|
|
71
|
+
const r = runner((a) => (a[2] === "describe" ? ok('{"deleted":true}') : ok()));
|
|
72
|
+
await ensureUrlMapRedirectsAccess({ projectId: "p1", env: "production", run: r.run });
|
|
73
|
+
expect(r.calls.map(verb)).toEqual(["describe", "undelete", "update", "add-iam-policy-binding"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("reports a failure instead of throwing", async () => {
|
|
77
|
+
const r = runner((a) =>
|
|
78
|
+
a[2] === "describe" ? fail("NOT_FOUND") : fail("PERMISSION_DENIED: iam.roles.create"),
|
|
79
|
+
);
|
|
80
|
+
expect(
|
|
81
|
+
await ensureUrlMapRedirectsAccess({ projectId: "p1", env: "production", run: r.run }),
|
|
82
|
+
).toEqual({
|
|
83
|
+
ok: false,
|
|
84
|
+
error:
|
|
85
|
+
"create projects/p1/roles/caelo_production_urlMapRedirects: PERMISSION_DENIED: iam.roles.create",
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The admin's URL-map redirects role (#587) on `gcp` installs. The installer
|
|
5
|
+
* (wizards/gcp.ts) and `cms-provision upgrade` both call
|
|
6
|
+
* {@link ensureUrlMapRedirectsAccess}, so new and older installs get the same
|
|
7
|
+
* custom role + binding. The stack does not declare it: a role id exists once
|
|
8
|
+
* per project, so a stack-created role would collide with one upgrade made.
|
|
9
|
+
*
|
|
10
|
+
* 1. project custom role {@link urlMapRedirectsRoleId} with exactly
|
|
11
|
+
* {@link URL_MAP_REDIRECTS_PERMISSIONS} (created, or updated to that
|
|
12
|
+
* permission set; undeleted first if it was soft-deleted)
|
|
13
|
+
* 2. that role for the admin's run SA on the project
|
|
14
|
+
*
|
|
15
|
+
* Both steps are idempotent. Without them Confirm-publish fails with a 403
|
|
16
|
+
* when it writes the redirects into the URL map.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { gcloud as defaultGcloud } from "./gcloud.js";
|
|
20
|
+
import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
|
|
21
|
+
import {
|
|
22
|
+
runServiceAccountEmail,
|
|
23
|
+
URL_MAP_REDIRECTS_PERMISSIONS,
|
|
24
|
+
urlMapRedirectsRoleId,
|
|
25
|
+
} from "./gcp-names.js";
|
|
26
|
+
|
|
27
|
+
/** Ensure the custom role and the admin's binding; never throws for gcloud. */
|
|
28
|
+
export async function ensureUrlMapRedirectsAccess(opts: {
|
|
29
|
+
readonly projectId: string;
|
|
30
|
+
readonly env: string;
|
|
31
|
+
readonly run?: GcloudRunner;
|
|
32
|
+
readonly sleep?: Sleep;
|
|
33
|
+
}): Promise<{ ok: true; role: string } | { ok: false; error: string }> {
|
|
34
|
+
const run = opts.run ?? defaultGcloud;
|
|
35
|
+
const sleep = opts.sleep ?? realSleep;
|
|
36
|
+
const project = `--project=${opts.projectId}`;
|
|
37
|
+
const roleId = urlMapRedirectsRoleId(opts.env);
|
|
38
|
+
const role = `projects/${opts.projectId}/roles/${roleId}`;
|
|
39
|
+
const permissions = `--permissions=${URL_MAP_REDIRECTS_PERMISSIONS.join(",")}`;
|
|
40
|
+
|
|
41
|
+
const describe = await run(["iam", "roles", "describe", roleId, project, "--format=json"]);
|
|
42
|
+
if (describe.ok) {
|
|
43
|
+
const live = JSON.parse(describe.stdout) as { deleted?: boolean };
|
|
44
|
+
if (live.deleted) {
|
|
45
|
+
const undelete = await run(["iam", "roles", "undelete", roleId, project, "--quiet"]);
|
|
46
|
+
if (!undelete.ok) return { ok: false, error: `undelete ${role}: ${undelete.stderr.trim()}` };
|
|
47
|
+
}
|
|
48
|
+
const update = await run([
|
|
49
|
+
"iam",
|
|
50
|
+
"roles",
|
|
51
|
+
"update",
|
|
52
|
+
roleId,
|
|
53
|
+
project,
|
|
54
|
+
permissions,
|
|
55
|
+
"--quiet",
|
|
56
|
+
"--format=none",
|
|
57
|
+
]);
|
|
58
|
+
if (!update.ok) return { ok: false, error: `update ${role}: ${update.stderr.trim()}` };
|
|
59
|
+
} else {
|
|
60
|
+
const create = await run([
|
|
61
|
+
"iam",
|
|
62
|
+
"roles",
|
|
63
|
+
"create",
|
|
64
|
+
roleId,
|
|
65
|
+
project,
|
|
66
|
+
"--title=Caelo admin: URL-map redirects",
|
|
67
|
+
"--description=Lets the Caelo admin write the redirects table into the site's URL map (#587).",
|
|
68
|
+
permissions,
|
|
69
|
+
"--stage=GA",
|
|
70
|
+
"--quiet",
|
|
71
|
+
"--format=none",
|
|
72
|
+
]);
|
|
73
|
+
if (!create.ok) return { ok: false, error: `create ${role}: ${create.stderr.trim()}` };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A just-created role takes a few seconds to become bindable.
|
|
77
|
+
const bind = await runWithRetry(run, sleep, [
|
|
78
|
+
"projects",
|
|
79
|
+
"add-iam-policy-binding",
|
|
80
|
+
opts.projectId,
|
|
81
|
+
`--member=serviceAccount:${runServiceAccountEmail(opts.projectId, opts.env)}`,
|
|
82
|
+
`--role=${role}`,
|
|
83
|
+
"--condition=None",
|
|
84
|
+
"--quiet",
|
|
85
|
+
"--format=none",
|
|
86
|
+
]);
|
|
87
|
+
if (!bind.ok) return { ok: false, error: `grant ${role}: ${bind.stderr.trim()}` };
|
|
88
|
+
return { ok: true, role };
|
|
89
|
+
}
|
package/src/wizard.ts
CHANGED
|
@@ -222,8 +222,10 @@ export async function runWizard(opts: WizardOptions = {}): Promise<void> {
|
|
|
222
222
|
});
|
|
223
223
|
break;
|
|
224
224
|
case "self-hosted":
|
|
225
|
+
// The self-hosted install runs on the Docker host itself, from the
|
|
226
|
+
// directory that should hold `.caelo/` — not from this wizard.
|
|
225
227
|
cancel(
|
|
226
|
-
`
|
|
228
|
+
`Run this on the Docker host, in the directory that should hold the install:\n ${selfCommand("--provider", "self-hosted", "--domain", domain, "--owner-email", ownerEmail)}`,
|
|
227
229
|
);
|
|
228
230
|
process.exit(0);
|
|
229
231
|
break;
|
package/src/wizards/gcp.ts
CHANGED
|
@@ -99,6 +99,7 @@ import {
|
|
|
99
99
|
PROVISIONER_ACCOUNT_ID,
|
|
100
100
|
provisionerServiceAccountEmail,
|
|
101
101
|
} from "../provisioner-identity.js";
|
|
102
|
+
import { revokePublicRoleMemberships } from "../public-role-isolation.js";
|
|
102
103
|
import { decideRegion, detectCliRegion, installableRegions, suggestRegion } from "../regions.js";
|
|
103
104
|
import type { RunMode } from "../run-mode.js";
|
|
104
105
|
import { selfCommand } from "../self-command.js";
|
|
@@ -120,6 +121,7 @@ import {
|
|
|
120
121
|
verifyCosignAll,
|
|
121
122
|
} from "../upgrade/release-images.js";
|
|
122
123
|
import { ensureUpgraderJob, resolveUpgraderTarget } from "../upgrader-setup.js";
|
|
124
|
+
import { ensureUrlMapRedirectsAccess } from "../url-map-redirects-access.js";
|
|
123
125
|
import { estimateGcpCost } from "./gcp-cost.js";
|
|
124
126
|
import { promptProjectId } from "./gcp-prompts.js";
|
|
125
127
|
import { GCP_STACK_KNOB_DEFAULTS, pulumiUpGcp, readStackOutput } from "./gcp-pulumi.js";
|
|
@@ -430,6 +432,17 @@ export async function runGcpConverge(opts: GcpConvergeOpts): Promise<void> {
|
|
|
430
432
|
// P21 ship 3 — shared with `cms-provision upgrade`. Aborts the
|
|
431
433
|
// wizard with a clear error if migrations fail (vs. silently
|
|
432
434
|
// continuing with a half-migrated DB).
|
|
435
|
+
// === 10.9. public_role out of cloudsqlsuperuser (#633) ===
|
|
436
|
+
// The stack creates public_role through the Cloud SQL Admin API, which
|
|
437
|
+
// makes it a member of cloudsqlsuperuser. Revoked before migrations: the
|
|
438
|
+
// migration job's role audit verifies it and fails the install otherwise.
|
|
439
|
+
await stepPublicRoleIsolation(projectId);
|
|
440
|
+
|
|
441
|
+
// === 10.95. The admin may write redirects into the URL map (#587) ===
|
|
442
|
+
// Confirm-publish writes the redirects table into the load balancer's URL
|
|
443
|
+
// map; the custom role is the CLI's, not the stack's (url-map-redirects-access.ts).
|
|
444
|
+
if (provider === "gcp") await stepUrlMapRedirectsAccess(projectId);
|
|
445
|
+
|
|
433
446
|
phase("Applying database migrations");
|
|
434
447
|
{
|
|
435
448
|
const { runMigrationsViaCloudRunJob } = await import("../migration-runner.js");
|
|
@@ -886,6 +899,41 @@ async function stepGrantBaseRoles(
|
|
|
886
899
|
markStepDone(installId, stepName, { granted });
|
|
887
900
|
}
|
|
888
901
|
|
|
902
|
+
/**
|
|
903
|
+
* Revoke the database roles the Cloud SQL Admin API gave `public_role`
|
|
904
|
+
* (cloudsqlsuperuser, #633). Idempotent, so it runs on every wizard pass;
|
|
905
|
+
* the migration job's role audit verifies the result.
|
|
906
|
+
*/
|
|
907
|
+
async function stepPublicRoleIsolation(projectId: string): Promise<void> {
|
|
908
|
+
const s = spinner();
|
|
909
|
+
s.start("Removing public_role from the Cloud SQL superuser group...");
|
|
910
|
+
const r = await revokePublicRoleMemberships({ projectId, env: GCP_STACK_ENV });
|
|
911
|
+
if (r.status === "failed") {
|
|
912
|
+
s.stop(red(`Failed: ${r.error ?? ""}`));
|
|
913
|
+
log.error("Re-run the wizard once the cause is fixed (this step is idempotent).");
|
|
914
|
+
cancel("Aborted.");
|
|
915
|
+
process.exit(1);
|
|
916
|
+
}
|
|
917
|
+
s.stop(green("public_role holds no database roles"));
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* The admin's custom role for writing the redirects table into the URL map
|
|
922
|
+
* (#587). Idempotent, so it runs on every wizard pass.
|
|
923
|
+
*/
|
|
924
|
+
async function stepUrlMapRedirectsAccess(projectId: string): Promise<void> {
|
|
925
|
+
const s = spinner();
|
|
926
|
+
s.start("Letting the admin serve redirects from the load balancer...");
|
|
927
|
+
const r = await ensureUrlMapRedirectsAccess({ projectId, env: GCP_STACK_ENV });
|
|
928
|
+
if (!r.ok) {
|
|
929
|
+
s.stop(red(`Failed: ${r.error}`));
|
|
930
|
+
log.error("Re-run the wizard once the cause is fixed (this step is idempotent).");
|
|
931
|
+
cancel("Aborted.");
|
|
932
|
+
process.exit(1);
|
|
933
|
+
}
|
|
934
|
+
s.stop(green(`Redirects role ready (${r.role})`));
|
|
935
|
+
}
|
|
936
|
+
|
|
889
937
|
/**
|
|
890
938
|
* Give `gateway_role` (created by migration 0248) its password from the
|
|
891
939
|
* `gateway-role-password` secret (#613). public_role already has its own:
|
package/stacks/aws/index.ts
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* - Bootstrap script runs as a one-shot ECS task post-create.
|
|
12
12
|
* 3. S3 buckets: caelo-media + caelo-static (per env).
|
|
13
13
|
* 4. CloudFront distribution with two origins (S3 static + ALB-fronted ECS).
|
|
14
|
-
* Static files are served as files
|
|
15
|
-
*
|
|
14
|
+
* Static files are served as files (CLAUDE.md §11.B Tier 1); the only
|
|
15
|
+
* edge code is the redirects table's CloudFront Function, a KeyValueStore
|
|
16
|
+
* lookup that runs before the origin (#587).
|
|
16
17
|
* 5. ECS Fargate cluster + four services (admin, gateway, orchestrator, runner).
|
|
17
18
|
* 6. Secrets Manager entries (postgres-password, anthropic-api-key, ...).
|
|
18
19
|
* 7. Route 53 hosted zone + ACM certs per (domain, locale).
|
|
@@ -26,6 +27,7 @@
|
|
|
26
27
|
* is to wire those env vars to AWS-native resources.
|
|
27
28
|
*/
|
|
28
29
|
|
|
30
|
+
import { cloudFrontRedirectFunctionCode } from "@caelo-cms/shared";
|
|
29
31
|
import * as aws from "@pulumi/aws";
|
|
30
32
|
import * as awsx from "@pulumi/awsx";
|
|
31
33
|
import * as pulumi from "@pulumi/pulumi";
|
|
@@ -225,6 +227,27 @@ const cert = new aws.acm.Certificate(
|
|
|
225
227
|
{ provider: usEast1Provider },
|
|
226
228
|
);
|
|
227
229
|
|
|
230
|
+
// === 6b. Redirects at the edge (#587) ===
|
|
231
|
+
// The redirects table: a CloudFront Function on viewer-request answers each
|
|
232
|
+
// redirected path from a KeyValueStore before the S3 origin is asked
|
|
233
|
+
// (redirect-emit-cloudfront.ts in @caelo-cms/shared). The deploy writes
|
|
234
|
+
// the store, so redirects change without republishing the function.
|
|
235
|
+
// FIXME(issue #749): the S3 static publisher is still a stub (#5); until
|
|
236
|
+
// it writes the store, the function finds no keys and every request passes
|
|
237
|
+
// through to the origin.
|
|
238
|
+
const redirectKvs = new aws.cloudfront.KeyValueStore(`${namePrefix}-redirects`, {
|
|
239
|
+
name: `${namePrefix}-redirects`,
|
|
240
|
+
comment: `Caelo ${env} redirects table (#587)`,
|
|
241
|
+
});
|
|
242
|
+
const redirectFunction = new aws.cloudfront.Function(`${namePrefix}-redirects`, {
|
|
243
|
+
name: `${namePrefix}-redirects`,
|
|
244
|
+
runtime: "cloudfront-js-2.0",
|
|
245
|
+
comment: `Caelo ${env}: answer the redirects table at the edge (#587)`,
|
|
246
|
+
publish: true,
|
|
247
|
+
code: redirectKvs.arn.apply((arn) => cloudFrontRedirectFunctionCode(kvsIdFromArn(arn))),
|
|
248
|
+
keyValueStoreAssociations: [redirectKvs.arn],
|
|
249
|
+
});
|
|
250
|
+
|
|
228
251
|
// === 7. CloudFront distribution ===
|
|
229
252
|
const distribution = new aws.cloudfront.Distribution(`${namePrefix}-cdn`, {
|
|
230
253
|
enabled: true,
|
|
@@ -252,6 +275,7 @@ const distribution = new aws.cloudfront.Distribution(`${namePrefix}-cdn`, {
|
|
|
252
275
|
minTtl: 0,
|
|
253
276
|
defaultTtl: 60,
|
|
254
277
|
maxTtl: 86400,
|
|
278
|
+
functionAssociations: [{ eventType: "viewer-request", functionArn: redirectFunction.arn }],
|
|
255
279
|
},
|
|
256
280
|
orderedCacheBehaviors: [
|
|
257
281
|
{
|
|
@@ -421,6 +445,7 @@ export const edgeLogSinkUrlOut = out.edgeLogSinkUrl;
|
|
|
421
445
|
export const providerOut = out.provider;
|
|
422
446
|
export const environmentOut = out.environment;
|
|
423
447
|
export const cloudfrontDomainOut = distribution.domainName;
|
|
448
|
+
export const redirectKvsArnOut = redirectKvs.arn;
|
|
424
449
|
export const ecsClusterArnOut = cluster.arn;
|
|
425
450
|
export const rdsAddressOut = db.address;
|
|
426
451
|
|
|
@@ -429,3 +454,10 @@ export const rdsAddressOut = db.address;
|
|
|
429
454
|
// state via `pulumi.secret(...)`. Operators retrieve via
|
|
430
455
|
// `pulumi stack output --show-secrets`.
|
|
431
456
|
export const bootstrapTokenExpiresAtOut = tokenInfo.expiresAt;
|
|
457
|
+
|
|
458
|
+
/** A KeyValueStore's id: the last segment of its ARN. */
|
|
459
|
+
function kvsIdFromArn(arn: string): string {
|
|
460
|
+
const id = arn.slice(arn.lastIndexOf("/") + 1);
|
|
461
|
+
if (!id || id === arn) throw new Error(`not a CloudFront KeyValueStore ARN: ${arn}`);
|
|
462
|
+
return id;
|
|
463
|
+
}
|
package/stacks/azure/index.ts
CHANGED
|
@@ -269,6 +269,18 @@ const siteDomain = new azure.cdn.AFDCustomDomain(`${namePrefix}-fd-domain`, {
|
|
|
269
269
|
tlsSettings: { certificateType: "ManagedCertificate", minimumTlsVersion: "TLS12" },
|
|
270
270
|
});
|
|
271
271
|
|
|
272
|
+
// #587 — the redirects table as rules-engine rules on the static route
|
|
273
|
+
// (redirect-emit-front-door.ts in @caelo-cms/shared): Front Door answers
|
|
274
|
+
// them at the edge before the Blob origin. The deploy owns the rules; the
|
|
275
|
+
// stack only creates the rule set and attaches it.
|
|
276
|
+
// FIXME(issue #750): the Blob static publisher is still a stub (#6); until
|
|
277
|
+
// it writes the rules, the set stays empty and no redirect is served.
|
|
278
|
+
const redirectRuleSet = new azure.cdn.RuleSet(`${namePrefix}-fd-redirects`, {
|
|
279
|
+
resourceGroupName: rg.name,
|
|
280
|
+
profileName: frontDoor.name,
|
|
281
|
+
ruleSetName: "caeloRedirects",
|
|
282
|
+
});
|
|
283
|
+
|
|
272
284
|
new azure.cdn.Route(
|
|
273
285
|
`${namePrefix}-fd-route`,
|
|
274
286
|
{
|
|
@@ -278,6 +290,7 @@ new azure.cdn.Route(
|
|
|
278
290
|
routeName: "static",
|
|
279
291
|
originGroup: { id: staticOriginGroup.id },
|
|
280
292
|
customDomains: [{ id: siteDomain.id }],
|
|
293
|
+
ruleSets: [{ id: redirectRuleSet.id }],
|
|
281
294
|
patternsToMatch: ["/*"],
|
|
282
295
|
// Without a cache configuration Front Door does not cache the route at
|
|
283
296
|
// all; static files are edge-cached per their Cache-Control (§11.B
|