@caelo-cms/provisioning 0.10.47 → 0.10.49
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 +1 -1
- package/caddy/Caddyfile.staging +1 -1
- package/dist/agent/apply.d.ts +45 -0
- package/dist/agent/apply.d.ts.map +1 -0
- package/dist/agent/apply.js +83 -0
- package/dist/agent/apply.js.map +1 -0
- package/dist/agent/cli-provenance.d.ts +42 -0
- package/dist/agent/cli-provenance.d.ts.map +1 -0
- package/dist/agent/cli-provenance.js +138 -0
- package/dist/agent/cli-provenance.js.map +1 -0
- package/dist/agent/command.d.ts +79 -0
- package/dist/agent/command.d.ts.map +1 -0
- package/dist/agent/command.js +454 -0
- package/dist/agent/command.js.map +1 -0
- package/dist/agent/config.d.ts +30 -0
- package/dist/agent/config.d.ts.map +1 -0
- package/dist/agent/config.js +60 -0
- package/dist/agent/config.js.map +1 -0
- package/dist/agent/lock.d.ts +13 -0
- package/dist/agent/lock.d.ts.map +1 -0
- package/dist/agent/lock.js +100 -0
- package/dist/agent/lock.js.map +1 -0
- package/dist/agent/queue.d.ts +35 -0
- package/dist/agent/queue.d.ts.map +1 -0
- package/dist/agent/queue.js +50 -0
- package/dist/agent/queue.js.map +1 -0
- package/dist/agent/run.d.ts +97 -0
- package/dist/agent/run.d.ts.map +1 -0
- package/dist/agent/run.js +198 -0
- package/dist/agent/run.js.map +1 -0
- package/dist/agent/systemd.d.ts +55 -0
- package/dist/agent/systemd.d.ts.map +1 -0
- package/dist/agent/systemd.js +171 -0
- package/dist/agent/systemd.js.map +1 -0
- package/dist/agent/units.d.ts +44 -0
- package/dist/agent/units.d.ts.map +1 -0
- package/dist/agent/units.js +127 -0
- package/dist/agent/units.js.map +1 -0
- package/dist/agent-db.js +18402 -0
- package/dist/cli.js +119 -16
- package/dist/cli.js.map +1 -1
- package/dist/cloudsql-iam-user.d.ts +39 -0
- package/dist/cloudsql-iam-user.d.ts.map +1 -0
- package/dist/cloudsql-iam-user.js +119 -0
- package/dist/cloudsql-iam-user.js.map +1 -0
- package/dist/compose.d.ts +16 -0
- package/dist/compose.d.ts.map +1 -1
- package/dist/compose.js +26 -1
- package/dist/compose.js.map +1 -1
- package/dist/dns/index.d.ts +2 -0
- package/dist/dns/index.d.ts.map +1 -1
- package/dist/dns/index.js +3 -2
- package/dist/dns/index.js.map +1 -1
- package/dist/dns/manual.d.ts +5 -0
- package/dist/dns/manual.d.ts.map +1 -1
- package/dist/dns/manual.js +28 -12
- package/dist/dns/manual.js.map +1 -1
- package/dist/gcloud.d.ts +2 -0
- package/dist/gcloud.d.ts.map +1 -1
- package/dist/gcloud.js +1 -1
- package/dist/gcloud.js.map +1 -1
- package/dist/gcp-names.d.ts +15 -0
- package/dist/gcp-names.d.ts.map +1 -1
- package/dist/gcp-names.js +23 -0
- package/dist/gcp-names.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/infra-contract.d.ts +1 -1
- package/dist/infra-contract.js +7 -1
- package/dist/infra-contract.js.map +1 -1
- package/dist/install-job-runner.d.ts +13 -0
- package/dist/install-job-runner.d.ts.map +1 -0
- package/dist/install-job-runner.js +155 -0
- package/dist/install-job-runner.js.map +1 -0
- package/dist/install-job.d.ts +210 -0
- package/dist/install-job.d.ts.map +1 -0
- package/dist/install-job.js +449 -0
- package/dist/install-job.js.map +1 -0
- package/dist/install-state.d.ts +10 -1
- package/dist/install-state.d.ts.map +1 -1
- package/dist/install-state.js +10 -2
- package/dist/install-state.js.map +1 -1
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +19 -4
- package/dist/lifecycle.js.map +1 -1
- package/dist/migration-runner.d.ts +5 -0
- package/dist/migration-runner.d.ts.map +1 -1
- package/dist/migration-runner.js +1 -1
- package/dist/migration-runner.js.map +1 -1
- package/dist/operator-access.d.ts +14 -8
- package/dist/operator-access.d.ts.map +1 -1
- package/dist/operator-access.js +16 -102
- package/dist/operator-access.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 +18 -121
- package/dist/release-manifest.js.map +1 -1
- package/dist/run-mode.d.ts +27 -0
- package/dist/run-mode.d.ts.map +1 -0
- package/dist/run-mode.js +22 -0
- package/dist/run-mode.js.map +1 -0
- package/dist/runtime-secrets.d.ts +19 -0
- package/dist/runtime-secrets.d.ts.map +1 -1
- package/dist/runtime-secrets.js +44 -0
- package/dist/runtime-secrets.js.map +1 -1
- package/dist/self-hosted-roles.d.ts +22 -1
- package/dist/self-hosted-roles.d.ts.map +1 -1
- package/dist/self-hosted-roles.js +81 -12
- package/dist/self-hosted-roles.js.map +1 -1
- package/dist/self-hosted-upgrade.d.ts +17 -0
- package/dist/self-hosted-upgrade.d.ts.map +1 -1
- package/dist/self-hosted-upgrade.js +16 -1
- package/dist/self-hosted-upgrade.js.map +1 -1
- package/dist/stack-contract.d.ts +6 -0
- package/dist/stack-contract.d.ts.map +1 -1
- package/dist/stack-contract.js +10 -1
- package/dist/stack-contract.js.map +1 -1
- package/dist/upgrade/converge.d.ts +6 -0
- package/dist/upgrade/converge.d.ts.map +1 -1
- package/dist/upgrade/converge.js +59 -2
- package/dist/upgrade/converge.js.map +1 -1
- package/dist/upgrade/gcp-lookup.d.ts +6 -0
- package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
- package/dist/upgrade/gcp-lookup.js +25 -0
- package/dist/upgrade/gcp-lookup.js.map +1 -1
- package/dist/upgrade/release-images.d.ts +26 -2
- package/dist/upgrade/release-images.d.ts.map +1 -1
- package/dist/upgrade/release-images.js +35 -2
- package/dist/upgrade/release-images.js.map +1 -1
- package/dist/upgrade/upgrade.d.ts +5 -0
- package/dist/upgrade/upgrade.d.ts.map +1 -1
- package/dist/upgrade/upgrade.js +28 -1
- package/dist/upgrade/upgrade.js.map +1 -1
- package/dist/upgrader-assess.d.ts +85 -0
- package/dist/upgrader-assess.d.ts.map +1 -0
- package/dist/upgrader-assess.js +122 -0
- package/dist/upgrader-assess.js.map +1 -0
- package/dist/upgrader-gcp.d.ts +17 -0
- package/dist/upgrader-gcp.d.ts.map +1 -0
- package/dist/upgrader-gcp.js +87 -0
- package/dist/upgrader-gcp.js.map +1 -0
- package/dist/upgrader-job.d.ts +133 -0
- package/dist/upgrader-job.d.ts.map +1 -0
- package/dist/upgrader-job.js +259 -0
- package/dist/upgrader-job.js.map +1 -0
- package/dist/upgrader-reporter.d.ts +35 -0
- package/dist/upgrader-reporter.d.ts.map +1 -0
- package/dist/upgrader-reporter.js +79 -0
- package/dist/upgrader-reporter.js.map +1 -0
- package/dist/upgrader-setup.d.ts +59 -0
- package/dist/upgrader-setup.d.ts.map +1 -0
- package/dist/upgrader-setup.js +308 -0
- package/dist/upgrader-setup.js.map +1 -0
- package/dist/wizard.d.ts +6 -0
- package/dist/wizard.d.ts.map +1 -1
- package/dist/wizard.js +32 -4
- package/dist/wizard.js.map +1 -1
- package/dist/wizards/gcp-firebase.d.ts +3 -0
- package/dist/wizards/gcp-firebase.d.ts.map +1 -1
- package/dist/wizards/gcp-firebase.js +1 -0
- package/dist/wizards/gcp-firebase.js.map +1 -1
- package/dist/wizards/gcp-install-job.d.ts +74 -0
- package/dist/wizards/gcp-install-job.d.ts.map +1 -0
- package/dist/wizards/gcp-install-job.js +398 -0
- package/dist/wizards/gcp-install-job.js.map +1 -0
- package/dist/wizards/gcp-prompts.d.ts +3 -0
- package/dist/wizards/gcp-prompts.d.ts.map +1 -0
- package/dist/wizards/gcp-prompts.js +25 -0
- package/dist/wizards/gcp-prompts.js.map +1 -0
- package/dist/wizards/gcp-pulumi.d.ts +4 -2
- package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
- package/dist/wizards/gcp-pulumi.js +1 -1
- package/dist/wizards/gcp-pulumi.js.map +1 -1
- package/dist/wizards/gcp.d.ts +40 -0
- package/dist/wizards/gcp.d.ts.map +1 -1
- package/dist/wizards/gcp.js +223 -59
- package/dist/wizards/gcp.js.map +1 -1
- package/package.json +6 -4
- package/src/adapter.ts +97 -0
- package/src/adc-impersonation.ts +176 -0
- package/src/admin-domain.test.ts +254 -0
- package/src/admin-domain.ts +245 -0
- package/src/admin-memory.test.ts +151 -0
- package/src/agent/apply.test.ts +185 -0
- package/src/agent/apply.ts +124 -0
- package/src/agent/cli-provenance.test.ts +159 -0
- package/src/agent/cli-provenance.ts +191 -0
- package/src/agent/command.test.ts +77 -0
- package/src/agent/command.ts +564 -0
- package/src/agent/config.ts +86 -0
- package/src/agent/lock-config.test.ts +97 -0
- package/src/agent/lock.ts +110 -0
- package/src/agent/queue.test.ts +10 -0
- package/src/agent/queue.ts +91 -0
- package/src/agent/run.test.ts +437 -0
- package/src/agent/run.ts +295 -0
- package/src/agent/systemd.ts +209 -0
- package/src/agent/units.test.ts +223 -0
- package/src/agent/units.ts +163 -0
- package/src/azure-front-door.test.ts +119 -0
- package/src/azure-front-door.ts +56 -0
- package/src/bootstrap-token.ts +20 -0
- package/src/caddy.ts +112 -0
- package/src/cdn-copy.ts +91 -0
- package/src/cert-wait.test.ts +194 -0
- package/src/cert-wait.ts +163 -0
- package/src/checkpoint-secrets.test.ts +380 -0
- package/src/checkpoint-secrets.ts +283 -0
- package/src/cli-routing.test.ts +45 -0
- package/src/cli-routing.ts +43 -0
- package/src/cli.ts +1491 -0
- package/src/cloudsql-iam-user.ts +147 -0
- package/src/compose.ts +334 -0
- package/src/default-firewall.test.ts +411 -0
- package/src/default-firewall.ts +342 -0
- package/src/deployed-release.test.ts +231 -0
- package/src/deployed-release.ts +220 -0
- package/src/dns/cloudflare.ts +182 -0
- package/src/dns/groups.test.ts +76 -0
- package/src/dns/groups.ts +86 -0
- package/src/dns/index.ts +56 -0
- package/src/dns/manual.ts +130 -0
- package/src/dns/types.ts +27 -0
- package/src/gateway-credentials.test.ts +180 -0
- package/src/gateway-credentials.ts +144 -0
- package/src/gcloud-components.test.ts +76 -0
- package/src/gcloud-components.ts +85 -0
- package/src/gcloud-retry.ts +41 -0
- package/src/gcloud-runner.test.ts +61 -0
- package/src/gcloud.test.ts +99 -0
- package/src/gcloud.ts +322 -0
- package/src/gcp-names.ts +135 -0
- package/src/index.test.ts +332 -0
- package/src/index.ts +114 -0
- package/src/infra-contract.ts +32 -0
- package/src/install-job-runner.ts +166 -0
- package/src/install-job.test.ts +566 -0
- package/src/install-job.ts +542 -0
- package/src/install-state.test.ts +239 -0
- package/src/install-state.ts +583 -0
- package/src/internal-token.ts +35 -0
- package/src/lifecycle.ts +900 -0
- package/src/mcp-iap-stacks.test.ts +58 -0
- package/src/mcp-iap.test.ts +149 -0
- package/src/mcp-iap.ts +131 -0
- package/src/media-volume.test.ts +296 -0
- package/src/migration-runner.ts +365 -0
- package/src/operator-access-stacks.test.ts +55 -0
- package/src/operator-access.test.ts +332 -0
- package/src/operator-access.ts +554 -0
- package/src/ops-backend.test.ts +320 -0
- package/src/ops-backend.ts +414 -0
- package/src/provisioner-identity.test.ts +482 -0
- package/src/provisioner-identity.ts +407 -0
- package/src/redirects-emit.ts +175 -0
- package/src/region-data.ts +428 -0
- package/src/regions.test.ts +277 -0
- package/src/regions.ts +370 -0
- package/src/release-contract.ts +57 -0
- package/src/release-manifest.test.ts +300 -0
- package/src/release-manifest.ts +310 -0
- package/src/run-mode.ts +40 -0
- package/src/runtime-secrets.test.ts +627 -0
- package/src/runtime-secrets.ts +754 -0
- package/src/self-command.test.ts +14 -0
- package/src/self-command.ts +21 -0
- package/src/self-hosted-compose.test.ts +177 -0
- package/src/self-hosted-release.ts +240 -0
- package/src/self-hosted-roles.integration.test.ts +161 -0
- package/src/self-hosted-roles.test.ts +124 -0
- package/src/self-hosted-roles.ts +203 -0
- package/src/self-hosted-upgrade.test.ts +394 -0
- package/src/self-hosted-upgrade.ts +548 -0
- package/src/stack-contract-parity.test.ts +199 -0
- package/src/stack-contract.test.ts +391 -0
- package/src/stack-contract.ts +888 -0
- package/src/stack-converge-secrets.test.ts +421 -0
- package/src/stack-converge.test.ts +833 -0
- package/src/stack-converge.ts +909 -0
- package/src/staging-admin.test.ts +246 -0
- package/src/staging-admin.ts +324 -0
- package/src/state-attach.test.ts +211 -0
- package/src/state-attach.ts +132 -0
- package/src/state-backend.test.ts +145 -0
- package/src/state-backend.ts +262 -0
- package/src/state-command.ts +293 -0
- package/src/state-migrate.test.ts +479 -0
- package/src/state-migrate.ts +434 -0
- package/src/state-pulumi.ts +289 -0
- package/src/state-store.test.ts +212 -0
- package/src/state-store.ts +243 -0
- package/src/upgrade/app-roll.ts +486 -0
- package/src/upgrade/backup.ts +109 -0
- package/src/upgrade/converge.ts +437 -0
- package/src/upgrade/gcp-lookup.ts +167 -0
- package/src/upgrade/health.test.ts +279 -0
- package/src/upgrade/health.ts +305 -0
- package/src/upgrade/release-images.test.ts +119 -0
- package/src/upgrade/release-images.ts +393 -0
- package/src/upgrade/reporter.ts +46 -0
- package/src/upgrade/upgrade.test.ts +626 -0
- package/src/upgrade/upgrade.ts +249 -0
- package/src/upgrader-assess.test.ts +306 -0
- package/src/upgrader-assess.ts +250 -0
- package/src/upgrader-gcp.ts +123 -0
- package/src/upgrader-job.test.ts +411 -0
- package/src/upgrader-job.ts +502 -0
- package/src/upgrader-reporter.test.ts +46 -0
- package/src/upgrader-reporter.ts +105 -0
- package/src/upgrader-setup.test.ts +169 -0
- package/src/upgrader-setup.ts +355 -0
- package/src/wizard.ts +425 -0
- package/src/wizards/gcp-cost.ts +188 -0
- package/src/wizards/gcp-firebase.ts +72 -0
- package/src/wizards/gcp-install-job.ts +506 -0
- package/src/wizards/gcp-prompts.ts +26 -0
- package/src/wizards/gcp-pulumi.test.ts +246 -0
- package/src/wizards/gcp-pulumi.ts +516 -0
- package/src/wizards/gcp.ts +1935 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The keyless provisioner identity (#696, CLAUDE.md §11.C: "No long-lived
|
|
5
|
+
* service-account keys downloaded to disk on user machines").
|
|
6
|
+
*
|
|
7
|
+
* Every GCP install has a `caelo-provisioner` service account holding the
|
|
8
|
+
* roles Pulumi needs to converge the stack (gcloud.ts PROVISIONER_ROLES).
|
|
9
|
+
* Installers before #696 minted a JSON key for it into
|
|
10
|
+
* `~/.caelo-<id>/secrets/sa-key.json` and handed Pulumi that file. Now nobody
|
|
11
|
+
* holds a key: the operator's own Google login impersonates the account.
|
|
12
|
+
*
|
|
13
|
+
* - The operator's gcloud account gets `roles/iam.serviceAccountTokenCreator`
|
|
14
|
+
* on `caelo-provisioner` only (never project-wide), so it can mint
|
|
15
|
+
* short-lived tokens for that one account and nothing else
|
|
16
|
+
* ({@link ensureProvisionerImpersonation}).
|
|
17
|
+
* - Pulumi's GCP provider runs with `GOOGLE_IMPERSONATE_SERVICE_ACCOUNT`
|
|
18
|
+
* (wizards/gcp-pulumi.ts `pulumiGcpEnv`). Its source credential is the
|
|
19
|
+
* operator's Application Default Credentials, which refresh themselves,
|
|
20
|
+
* and the provider re-mints the impersonated token as it expires — so a
|
|
21
|
+
* `pulumi up` longer than an hour does not fail on an expired token, as a
|
|
22
|
+
* static `GOOGLE_OAUTH_ACCESS_TOKEN` would.
|
|
23
|
+
* adc-impersonation.ts verifies that chain before Pulumi starts.
|
|
24
|
+
* - An existing install's key is deleted, locally and in IAM, on its next
|
|
25
|
+
* `upgrade` or installer run ({@link removeLegacyProvisionerKey}). Only the
|
|
26
|
+
* IAM key whose id matches the local file is deleted; other user-managed
|
|
27
|
+
* keys of the account are reported, never deleted blindly.
|
|
28
|
+
*
|
|
29
|
+
* Nothing here handles a token or private key material: the key file is
|
|
30
|
+
* read only for its id and account, and the file is then deleted.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
import { gcloud as defaultGcloud } from "./gcloud.js";
|
|
36
|
+
import { type GcloudRunner, realSleep, runWithRetry, type Sleep } from "./gcloud-retry.js";
|
|
37
|
+
|
|
38
|
+
/** Account id of the service account Pulumi converges the stack as. */
|
|
39
|
+
export const PROVISIONER_ACCOUNT_ID = "caelo-provisioner";
|
|
40
|
+
|
|
41
|
+
/** The role that lets the operator impersonate the provisioner. */
|
|
42
|
+
export const TOKEN_CREATOR_ROLE = "roles/iam.serviceAccountTokenCreator";
|
|
43
|
+
|
|
44
|
+
/** File name of the JSON key installers before #696 kept in `secrets/`. */
|
|
45
|
+
export const LEGACY_SA_KEY_FILE = "sa-key.json";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Environment variables that would make Google's client libraries use a
|
|
49
|
+
* credential other than the operator's Application Default Credentials —
|
|
50
|
+
* a key file (perhaps the deleted `sa-key.json`), a static token that expires
|
|
51
|
+
* after an hour, or another impersonation target. Removed from every
|
|
52
|
+
* infrastructure run so the chain is always ADC → impersonated provisioner.
|
|
53
|
+
*/
|
|
54
|
+
export const AMBIENT_GOOGLE_CREDENTIAL_ENV: readonly string[] = [
|
|
55
|
+
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
56
|
+
"GOOGLE_CREDENTIALS",
|
|
57
|
+
"GOOGLE_CLOUD_KEYFILE_JSON",
|
|
58
|
+
"GCLOUD_KEYFILE_JSON",
|
|
59
|
+
"GOOGLE_OAUTH_ACCESS_TOKEN",
|
|
60
|
+
"GOOGLE_IMPERSONATE_SERVICE_ACCOUNT",
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/** `caelo-provisioner@<project>.iam.gserviceaccount.com`. */
|
|
64
|
+
export function provisionerServiceAccountEmail(projectId: string): string {
|
|
65
|
+
return `${PROVISIONER_ACCOUNT_ID}@${projectId}.iam.gserviceaccount.com`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `base` without the credential variables {@link AMBIENT_GOOGLE_CREDENTIAL_ENV} lists. */
|
|
69
|
+
export function withoutAmbientGoogleCredentials(base: NodeJS.ProcessEnv): Record<string, string> {
|
|
70
|
+
const out: Record<string, string> = {};
|
|
71
|
+
for (const [k, v] of Object.entries(base)) {
|
|
72
|
+
if (v !== undefined && !AMBIENT_GOOGLE_CREDENTIAL_ENV.includes(k)) out[k] = v;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** IAM member for a gcloud account: a service account when it is one, else a user. */
|
|
78
|
+
export function operatorMember(account: string): string {
|
|
79
|
+
const email = account.trim().toLowerCase();
|
|
80
|
+
return email.endsWith(".gserviceaccount.com") ? `serviceAccount:${email}` : `user:${email}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface IamPolicy {
|
|
84
|
+
bindings?: { role?: string; members?: string[]; condition?: unknown }[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type ImpersonationGrant =
|
|
88
|
+
| { readonly ok: true; readonly status: "already" | "granted" }
|
|
89
|
+
| { readonly ok: false; readonly error: string };
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Give the operator's gcloud account `roles/iam.serviceAccountTokenCreator`
|
|
93
|
+
* on the provisioner service account (and on nothing else). Reads the
|
|
94
|
+
* account's IAM policy first, so an operator who was granted the role by
|
|
95
|
+
* someone else needs no IAM-write permission. Never throws for a gcloud
|
|
96
|
+
* failure.
|
|
97
|
+
*/
|
|
98
|
+
export async function ensureProvisionerImpersonation(
|
|
99
|
+
target: { readonly projectId: string; readonly operatorAccount: string },
|
|
100
|
+
deps: { run?: GcloudRunner; sleep?: Sleep } = {},
|
|
101
|
+
): Promise<ImpersonationGrant> {
|
|
102
|
+
const run = deps.run ?? defaultGcloud;
|
|
103
|
+
const sleep = deps.sleep ?? realSleep;
|
|
104
|
+
const sa = provisionerServiceAccountEmail(target.projectId);
|
|
105
|
+
const member = operatorMember(target.operatorAccount);
|
|
106
|
+
const project = `--project=${target.projectId}`;
|
|
107
|
+
|
|
108
|
+
const policy = await runWithRetry(run, sleep, [
|
|
109
|
+
"iam",
|
|
110
|
+
"service-accounts",
|
|
111
|
+
"get-iam-policy",
|
|
112
|
+
sa,
|
|
113
|
+
project,
|
|
114
|
+
"--format=json",
|
|
115
|
+
]);
|
|
116
|
+
if (policy.ok) {
|
|
117
|
+
let parsed: IamPolicy = {};
|
|
118
|
+
try {
|
|
119
|
+
parsed = JSON.parse(policy.stdout || "{}") as IamPolicy;
|
|
120
|
+
} catch {
|
|
121
|
+
parsed = {};
|
|
122
|
+
}
|
|
123
|
+
// Only an unconditional binding counts: a conditional one (e.g. an
|
|
124
|
+
// expired time-limited grant) may not allow impersonation at all.
|
|
125
|
+
const has = (parsed.bindings ?? []).some(
|
|
126
|
+
(b) =>
|
|
127
|
+
b.role === TOKEN_CREATOR_ROLE &&
|
|
128
|
+
b.condition == null &&
|
|
129
|
+
(b.members ?? []).some((m) => m.toLowerCase() === member),
|
|
130
|
+
);
|
|
131
|
+
if (has) return { ok: true, status: "already" };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const grant = await runWithRetry(run, sleep, [
|
|
135
|
+
"iam",
|
|
136
|
+
"service-accounts",
|
|
137
|
+
"add-iam-policy-binding",
|
|
138
|
+
sa,
|
|
139
|
+
project,
|
|
140
|
+
`--member=${member}`,
|
|
141
|
+
`--role=${TOKEN_CREATOR_ROLE}`,
|
|
142
|
+
"--condition=None",
|
|
143
|
+
"--quiet",
|
|
144
|
+
]);
|
|
145
|
+
if (grant.ok) return { ok: true, status: "granted" };
|
|
146
|
+
return { ok: false, error: grantError(target.projectId, sa, member, grant.stderr) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function grantError(projectId: string, sa: string, member: string, stderr: string): string {
|
|
150
|
+
const denied = /PERMISSION_DENIED|permission|403|not authorized/i.test(stderr);
|
|
151
|
+
return [
|
|
152
|
+
`Could not let ${member.replace(/^\w+:/, "")} impersonate ${sa}: ${stderr.trim() || "(no output)"}`,
|
|
153
|
+
denied
|
|
154
|
+
? "Your gcloud account may not change that service account's IAM policy (it needs iam.serviceAccounts.setIamPolicy, e.g. roles/iam.serviceAccountAdmin or Owner on the project)."
|
|
155
|
+
: "",
|
|
156
|
+
"Ask a project Owner to run this once, then re-run:",
|
|
157
|
+
` gcloud iam service-accounts add-iam-policy-binding ${sa} --project=${projectId} --member=${member} --role=${TOKEN_CREATOR_ROLE} --condition=None`,
|
|
158
|
+
]
|
|
159
|
+
.filter(Boolean)
|
|
160
|
+
.join("\n");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export type LegacyKeyReport =
|
|
164
|
+
| {
|
|
165
|
+
readonly ok: true;
|
|
166
|
+
/** `none`: no local key file. `removed`: file (and its IAM key, if live) deleted. */
|
|
167
|
+
readonly status: "none" | "removed";
|
|
168
|
+
/** The IAM key id deleted, when the key was still live in IAM. */
|
|
169
|
+
readonly deletedKeyId?: string;
|
|
170
|
+
/** Lines for the operator: what was removed, other keys found. */
|
|
171
|
+
readonly lines: string[];
|
|
172
|
+
}
|
|
173
|
+
| { readonly ok: false; readonly error: string };
|
|
174
|
+
|
|
175
|
+
interface ServiceAccountKeyRow {
|
|
176
|
+
name?: string;
|
|
177
|
+
keyType?: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Delete the provisioner's JSON key an installer before #696 minted: first
|
|
182
|
+
* the IAM key whose id matches the local file's `private_key_id`, then the
|
|
183
|
+
* file. The file is only removed once its key is gone from IAM, so a failed
|
|
184
|
+
* IAM delete can be retried on the next run. Other user-managed keys of the
|
|
185
|
+
* account are listed in `lines` with the command to delete them — never
|
|
186
|
+
* deleted here. Idempotent: no local file means nothing to do. Never throws
|
|
187
|
+
* for a gcloud failure.
|
|
188
|
+
*/
|
|
189
|
+
export async function removeLegacyProvisionerKey(
|
|
190
|
+
target: { readonly projectId: string; readonly secretsDir: string },
|
|
191
|
+
deps: { run?: GcloudRunner } = {},
|
|
192
|
+
): Promise<LegacyKeyReport> {
|
|
193
|
+
const run = deps.run ?? defaultGcloud;
|
|
194
|
+
const sa = provisionerServiceAccountEmail(target.projectId);
|
|
195
|
+
const project = `--project=${target.projectId}`;
|
|
196
|
+
const keyPath = join(target.secretsDir, LEGACY_SA_KEY_FILE);
|
|
197
|
+
if (!existsSync(keyPath)) return { ok: true, status: "none", lines: [] };
|
|
198
|
+
|
|
199
|
+
const key = readKeyIdentity(keyPath);
|
|
200
|
+
if (!key.ok) return { ok: false, error: key.error };
|
|
201
|
+
if (key.clientEmail !== sa) {
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
error: `${keyPath} is a key for ${key.clientEmail}, not for ${sa}. It was left in place: check where it came from, delete it yourself, then re-run.`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const list = await run([
|
|
209
|
+
"iam",
|
|
210
|
+
"service-accounts",
|
|
211
|
+
"keys",
|
|
212
|
+
"list",
|
|
213
|
+
`--iam-account=${sa}`,
|
|
214
|
+
project,
|
|
215
|
+
"--managed-by=user",
|
|
216
|
+
"--format=json",
|
|
217
|
+
]);
|
|
218
|
+
if (!list.ok) {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
error: `Could not list the keys of ${sa}: ${list.stderr.trim()}\nYour gcloud account needs iam.serviceAccountKeys.list (e.g. roles/iam.serviceAccountKeyAdmin). ${keyPath} was left in place; re-run once fixed.`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
let ids: string[];
|
|
225
|
+
try {
|
|
226
|
+
ids = (JSON.parse(list.stdout || "[]") as ServiceAccountKeyRow[])
|
|
227
|
+
.filter((k) => k.keyType === undefined || k.keyType === "USER_MANAGED")
|
|
228
|
+
.map((k) => (k.name ?? "").split("/").pop() ?? "")
|
|
229
|
+
.filter((id) => id.length > 0);
|
|
230
|
+
} catch {
|
|
231
|
+
return { ok: false, error: `Could not read the key list of ${sa} (not JSON).` };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const lines: string[] = [];
|
|
235
|
+
let deletedKeyId: string | undefined;
|
|
236
|
+
if (ids.includes(key.keyId)) {
|
|
237
|
+
const del = await run([
|
|
238
|
+
"iam",
|
|
239
|
+
"service-accounts",
|
|
240
|
+
"keys",
|
|
241
|
+
"delete",
|
|
242
|
+
key.keyId,
|
|
243
|
+
`--iam-account=${sa}`,
|
|
244
|
+
project,
|
|
245
|
+
"--quiet",
|
|
246
|
+
]);
|
|
247
|
+
if (!del.ok) {
|
|
248
|
+
return {
|
|
249
|
+
ok: false,
|
|
250
|
+
error: `Could not delete key ${key.keyId} of ${sa}: ${del.stderr.trim()}\nYour gcloud account needs iam.serviceAccountKeys.delete (e.g. roles/iam.serviceAccountKeyAdmin). ${keyPath} was left in place so the next run can match it; re-run once fixed.`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
deletedKeyId = key.keyId;
|
|
254
|
+
lines.push(`Deleted key ${key.keyId} of ${sa} in IAM.`);
|
|
255
|
+
} else {
|
|
256
|
+
lines.push(`Key ${key.keyId} of ${sa} was already deleted in IAM.`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
rmSync(keyPath, { force: true });
|
|
260
|
+
lines.push(`Deleted ${keyPath}.`);
|
|
261
|
+
|
|
262
|
+
const others = ids.filter((id) => id !== key.keyId);
|
|
263
|
+
if (others.length > 0) {
|
|
264
|
+
lines.push(
|
|
265
|
+
`${sa} has ${others.length} other user-managed key(s) Caelo did not record, left in place: ${others.join(", ")}.`,
|
|
266
|
+
"Caelo no longer uses keys for this account. If nothing else does, delete them with:",
|
|
267
|
+
...others.map(
|
|
268
|
+
(id) => ` gcloud iam service-accounts keys delete ${id} --iam-account=${sa} ${project}`,
|
|
269
|
+
),
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
return { ok: true, status: "removed", ...(deletedKeyId ? { deletedKeyId } : {}), lines };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The key id + account of a JSON key file, without keeping its private key. */
|
|
276
|
+
function readKeyIdentity(
|
|
277
|
+
keyPath: string,
|
|
278
|
+
): { ok: true; keyId: string; clientEmail: string } | { ok: false; error: string } {
|
|
279
|
+
let parsed: { private_key_id?: unknown; client_email?: unknown };
|
|
280
|
+
try {
|
|
281
|
+
parsed = JSON.parse(readFileSync(keyPath, "utf8")) as typeof parsed;
|
|
282
|
+
} catch {
|
|
283
|
+
return {
|
|
284
|
+
ok: false,
|
|
285
|
+
error: `${keyPath} is not a readable JSON key, so its IAM key can't be matched. Check the keys with \`gcloud iam service-accounts keys list --iam-account=<caelo-provisioner email> --managed-by=user\`, delete the one that file held, remove the file, then re-run.`,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const keyId = typeof parsed.private_key_id === "string" ? parsed.private_key_id : "";
|
|
289
|
+
const clientEmail = typeof parsed.client_email === "string" ? parsed.client_email : "";
|
|
290
|
+
if (!/^[0-9a-f]{40}$/i.test(keyId) || clientEmail === "") {
|
|
291
|
+
return {
|
|
292
|
+
ok: false,
|
|
293
|
+
error: `${keyPath} has no service-account key id or account, so its IAM key can't be matched. Check the keys with \`gcloud iam service-accounts keys list --iam-account=<caelo-provisioner email> --managed-by=user\`, delete the one that file held, remove the file, then re-run.`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
return { ok: true, keyId: keyId.toLowerCase(), clientEmail: clientEmail.toLowerCase() };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Delays between impersonation probes while a fresh TokenCreator grant propagates. */
|
|
300
|
+
export const PROPAGATION_DELAYS_MS: readonly number[] = [
|
|
301
|
+
5_000, 10_000, 15_000, 30_000, 60_000, 60_000,
|
|
302
|
+
];
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Prove the operator's gcloud account can impersonate `sa` by having gcloud
|
|
306
|
+
* mint a short-lived token for it. The token on stdout is dropped unread.
|
|
307
|
+
* After a fresh grant, waits out IAM propagation.
|
|
308
|
+
*/
|
|
309
|
+
async function verifyGcloudImpersonation(
|
|
310
|
+
run: GcloudRunner,
|
|
311
|
+
sleep: Sleep,
|
|
312
|
+
sa: string,
|
|
313
|
+
freshGrant: boolean,
|
|
314
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
315
|
+
const delays = freshGrant ? PROPAGATION_DELAYS_MS : [];
|
|
316
|
+
const args = ["auth", "print-access-token", `--impersonate-service-account=${sa}`, "--quiet"];
|
|
317
|
+
let r = await run(args);
|
|
318
|
+
for (const delay of delays) {
|
|
319
|
+
if (r.ok) break;
|
|
320
|
+
await sleep(delay);
|
|
321
|
+
r = await run(args);
|
|
322
|
+
}
|
|
323
|
+
if (r.ok) return { ok: true };
|
|
324
|
+
return {
|
|
325
|
+
ok: false,
|
|
326
|
+
error: `Your gcloud account can't impersonate ${sa} yet, so its key was kept: ${r.stderr.trim() || "(no output)"}\nA grant made moments ago can take a few minutes to apply: wait, then re-run. If it persists, check that your account holds ${TOKEN_CREATOR_ROLE} on ${sa} without a condition.`,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export type KeylessConvergence =
|
|
331
|
+
| {
|
|
332
|
+
readonly ok: true;
|
|
333
|
+
/** `no-provisioner`: the project has no caelo-provisioner account; nothing to do. */
|
|
334
|
+
readonly grant: "already" | "granted" | "no-provisioner";
|
|
335
|
+
readonly legacyKey: "none" | "removed";
|
|
336
|
+
readonly lines: string[];
|
|
337
|
+
}
|
|
338
|
+
| { readonly ok: false; readonly error: string };
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Bring an install to the keyless provisioner (#696): the operator's gcloud
|
|
342
|
+
* account may impersonate `caelo-provisioner`, and a key an older installer
|
|
343
|
+
* minted is gone, locally and in IAM. The grant comes first, so a run that
|
|
344
|
+
* cannot set it up stops before the key — today's only working credential
|
|
345
|
+
* for older tooling — is deleted. Shared by the installer and `upgrade`;
|
|
346
|
+
* idempotent; never throws for a gcloud failure.
|
|
347
|
+
*/
|
|
348
|
+
export async function convergeKeylessProvisioner(
|
|
349
|
+
target: {
|
|
350
|
+
readonly projectId: string;
|
|
351
|
+
readonly secretsDir: string;
|
|
352
|
+
readonly operatorAccount: string;
|
|
353
|
+
/**
|
|
354
|
+
* The installer just created the account: wait out IAM's propagation
|
|
355
|
+
* delay instead of reading a NOT_FOUND as "no provisioner".
|
|
356
|
+
*/
|
|
357
|
+
readonly justCreated?: boolean;
|
|
358
|
+
},
|
|
359
|
+
deps: { run?: GcloudRunner; sleep?: Sleep } = {},
|
|
360
|
+
): Promise<KeylessConvergence> {
|
|
361
|
+
const run = deps.run ?? defaultGcloud;
|
|
362
|
+
const sleep = deps.sleep ?? realSleep;
|
|
363
|
+
const sa = provisionerServiceAccountEmail(target.projectId);
|
|
364
|
+
const keyOnDisk = existsSync(join(target.secretsDir, LEGACY_SA_KEY_FILE));
|
|
365
|
+
const describeArgs = [
|
|
366
|
+
"iam",
|
|
367
|
+
"service-accounts",
|
|
368
|
+
"describe",
|
|
369
|
+
sa,
|
|
370
|
+
`--project=${target.projectId}`,
|
|
371
|
+
"--format=value(email)",
|
|
372
|
+
];
|
|
373
|
+
const describe = target.justCreated
|
|
374
|
+
? await runWithRetry(run, sleep, describeArgs)
|
|
375
|
+
: await run(describeArgs);
|
|
376
|
+
if (!describe.ok && !/NOT_FOUND|does not exist|not found/i.test(describe.stderr)) {
|
|
377
|
+
return { ok: false, error: `Could not read ${sa}: ${describe.stderr.trim()}` };
|
|
378
|
+
}
|
|
379
|
+
const exists = describe.ok && describe.stdout.trim().toLowerCase() === sa;
|
|
380
|
+
if (!exists && !keyOnDisk) {
|
|
381
|
+
return { ok: true, grant: "no-provisioner", legacyKey: "none", lines: [] };
|
|
382
|
+
}
|
|
383
|
+
if (!exists) {
|
|
384
|
+
return {
|
|
385
|
+
ok: false,
|
|
386
|
+
error: `${join(target.secretsDir, LEGACY_SA_KEY_FILE)} holds a key for ${sa}, but that service account does not exist in ${target.projectId}. The key is unusable: delete the file, then re-run.`,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const grant = await ensureProvisionerImpersonation(
|
|
391
|
+
{ projectId: target.projectId, operatorAccount: target.operatorAccount },
|
|
392
|
+
deps,
|
|
393
|
+
);
|
|
394
|
+
if (!grant.ok) return grant;
|
|
395
|
+
// The key is still the install's working credential: delete it only once
|
|
396
|
+
// impersonation demonstrably works, not merely because a binding exists.
|
|
397
|
+
if (keyOnDisk) {
|
|
398
|
+
const works = await verifyGcloudImpersonation(run, sleep, sa, grant.status === "granted");
|
|
399
|
+
if (!works.ok) return works;
|
|
400
|
+
}
|
|
401
|
+
const legacy = await removeLegacyProvisionerKey(
|
|
402
|
+
{ projectId: target.projectId, secretsDir: target.secretsDir },
|
|
403
|
+
{ run },
|
|
404
|
+
);
|
|
405
|
+
if (!legacy.ok) return legacy;
|
|
406
|
+
return { ok: true, grant: grant.status, legacyKey: legacy.status, lines: legacy.lines };
|
|
407
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* P15 — per-provider redirect file generators.
|
|
5
|
+
*
|
|
6
|
+
* P14's self-hosted Caddy consumes a `_redirects.caddy` file. Cloud
|
|
7
|
+
* providers want different formats: Cloudflare Pages reads `_redirects`
|
|
8
|
+
* (also a fine fit for any platform that consumes that format),
|
|
9
|
+
* CloudFront wants a JSON config a Lambda@Edge function reads at
|
|
10
|
+
* startup, and Azure Front Door wants a rules-engine RuleEngineRule[].
|
|
11
|
+
*
|
|
12
|
+
* The deploy step calls the right emitter based on `process.env.CAELO_PROVIDER`
|
|
13
|
+
* and uploads the result to the right place. Each emitter is pure +
|
|
14
|
+
* idempotent — same input → same byte output.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { redirectTargetIssue } from "@caelo-cms/shared";
|
|
18
|
+
|
|
19
|
+
export type RedirectStatusCode = 301 | 302 | 307 | 308;
|
|
20
|
+
|
|
21
|
+
export interface RedirectRow {
|
|
22
|
+
readonly fromPath: string;
|
|
23
|
+
readonly toPath: string;
|
|
24
|
+
readonly statusCode: RedirectStatusCode;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Reject whitespace + control chars in either path before any emitter
|
|
29
|
+
* touches them. The Cloudflare `_redirects` format is space-delimited;
|
|
30
|
+
* a `fromPath` containing a space ambiguates the line (CF parses
|
|
31
|
+
* `/old foo /new 301` as from=`/old`, to=`foo`, status=`/new`, then
|
|
32
|
+
* fails on `301`). Caelo's redirects table normally rejects these
|
|
33
|
+
* upstream but the emitter shouldn't trust that — pure functions
|
|
34
|
+
* defend their own contract.
|
|
35
|
+
*/
|
|
36
|
+
function assertPathClean(label: "fromPath" | "toPath", path: string): void {
|
|
37
|
+
// \p{Cc} matches the Unicode control category (U+0000–U+001F + U+007F–U+009F);
|
|
38
|
+
// the linter rejects literal \x00-\x1f ranges in regex, but the intent — defending
|
|
39
|
+
// emitter contracts against control chars — is the explicit reason for this check.
|
|
40
|
+
if (/[\s\p{Cc}]/u.test(path)) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`redirects-emit: ${label}=${JSON.stringify(path)} contains whitespace or control chars; reject upstream`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function assertRowsClean(rows: ReadonlyArray<RedirectRow>): void {
|
|
48
|
+
for (const r of rows) {
|
|
49
|
+
assertPathClean("fromPath", r.fromPath);
|
|
50
|
+
assertPathClean("toPath", r.toPath);
|
|
51
|
+
// #657 — a `//host` or `/https://…` target would ship as an open
|
|
52
|
+
// redirect or a broken 301 in every CDN format below.
|
|
53
|
+
const targetIssue = redirectTargetIssue(r.toPath);
|
|
54
|
+
if (targetIssue) throw new Error(`redirects-emit: ${targetIssue}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Cloudflare Pages `_redirects` format (also consumed by Netlify, Vercel,
|
|
60
|
+
* Render, etc.). One redirect per line: `<from> <to> <status>`. Wildcards
|
|
61
|
+
* use `:splat`; we only support exact-path redirects in v1 (matches what
|
|
62
|
+
* Caelo's redirects table allows).
|
|
63
|
+
*/
|
|
64
|
+
export function emitRedirectsCloudflare(rows: ReadonlyArray<RedirectRow>): string {
|
|
65
|
+
assertRowsClean(rows);
|
|
66
|
+
const header = "# Generated by @caelo-cms/provisioning — do not edit by hand.\n";
|
|
67
|
+
const lines = rows.map((r) => `${r.fromPath} ${r.toPath} ${r.statusCode}`);
|
|
68
|
+
return header + lines.join("\n") + (rows.length > 0 ? "\n" : "");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* CloudFront via Lambda@Edge. Returns a JSON config the L@E function
|
|
73
|
+
* reads at startup + a tiny Lambda source that consumes it. The L@E
|
|
74
|
+
* function is deployed to `us-east-1` (CloudFront constraint); the
|
|
75
|
+
* config blob is bundled into the Lambda deployment artifact.
|
|
76
|
+
*
|
|
77
|
+
* Returning both halves lets the AWS stack ship a single asset.
|
|
78
|
+
*/
|
|
79
|
+
export interface CloudFrontRedirectArtifact {
|
|
80
|
+
readonly jsonConfig: string;
|
|
81
|
+
readonly lambdaSource: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function emitRedirectsCloudFront(
|
|
85
|
+
rows: ReadonlyArray<RedirectRow>,
|
|
86
|
+
): CloudFrontRedirectArtifact {
|
|
87
|
+
assertRowsClean(rows);
|
|
88
|
+
const jsonConfig = JSON.stringify(
|
|
89
|
+
{ redirects: rows.map((r) => ({ from: r.fromPath, to: r.toPath, status: r.statusCode })) },
|
|
90
|
+
null,
|
|
91
|
+
2,
|
|
92
|
+
);
|
|
93
|
+
// Tiny Lambda@Edge handler. v1 is exact-path match (matches Caelo's
|
|
94
|
+
// redirects table). Wildcard support lands when the table grows it.
|
|
95
|
+
const lambdaSource = `// SPDX-License-Identifier: MPL-2.0
|
|
96
|
+
// Generated by @caelo-cms/provisioning. Bundled with redirects.json.
|
|
97
|
+
const REDIRECTS = require("./redirects.json").redirects;
|
|
98
|
+
const TABLE = new Map(REDIRECTS.map((r) => [r.from, r]));
|
|
99
|
+
exports.handler = (event, _ctx, callback) => {
|
|
100
|
+
const req = event.Records[0].cf.request;
|
|
101
|
+
const m = TABLE.get(req.uri);
|
|
102
|
+
if (!m) return callback(null, req);
|
|
103
|
+
callback(null, {
|
|
104
|
+
status: String(m.status),
|
|
105
|
+
statusDescription: "Redirect",
|
|
106
|
+
headers: { location: [{ key: "Location", value: m.to }] },
|
|
107
|
+
});
|
|
108
|
+
};`;
|
|
109
|
+
return { jsonConfig, lambdaSource };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Azure Front Door rules engine. Returns a Pulumi-compatible
|
|
114
|
+
* RuleEngineRule[]-shaped array (untyped here so callers don't need to
|
|
115
|
+
* import @pulumi/azure-native). Each rule fires on a path-equals match
|
|
116
|
+
* + executes a 301/302/etc. response.
|
|
117
|
+
*/
|
|
118
|
+
export interface FrontDoorRule {
|
|
119
|
+
readonly name: string;
|
|
120
|
+
readonly order: number;
|
|
121
|
+
readonly conditions: ReadonlyArray<{
|
|
122
|
+
readonly name: "RequestUri";
|
|
123
|
+
readonly parameters: {
|
|
124
|
+
readonly operator: "Equal";
|
|
125
|
+
readonly matchValues: ReadonlyArray<string>;
|
|
126
|
+
};
|
|
127
|
+
}>;
|
|
128
|
+
readonly actions: ReadonlyArray<{
|
|
129
|
+
readonly name: "UrlRedirect";
|
|
130
|
+
readonly parameters: {
|
|
131
|
+
readonly redirectType: "Moved" | "Found" | "TemporaryRedirect" | "PermanentRedirect";
|
|
132
|
+
readonly destinationPath: string;
|
|
133
|
+
};
|
|
134
|
+
}>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function emitRedirectsAzureFrontDoor(
|
|
138
|
+
rows: ReadonlyArray<RedirectRow>,
|
|
139
|
+
): ReadonlyArray<FrontDoorRule> {
|
|
140
|
+
assertRowsClean(rows);
|
|
141
|
+
return rows.map((r, i) => ({
|
|
142
|
+
name: `redirect-${i + 1}`,
|
|
143
|
+
order: i + 1,
|
|
144
|
+
conditions: [
|
|
145
|
+
{
|
|
146
|
+
name: "RequestUri" as const,
|
|
147
|
+
parameters: { operator: "Equal" as const, matchValues: [r.fromPath] },
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
actions: [
|
|
151
|
+
{
|
|
152
|
+
name: "UrlRedirect" as const,
|
|
153
|
+
parameters: {
|
|
154
|
+
redirectType: statusToFrontDoor(r.statusCode),
|
|
155
|
+
destinationPath: r.toPath,
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function statusToFrontDoor(
|
|
163
|
+
status: RedirectStatusCode,
|
|
164
|
+
): FrontDoorRule["actions"][0]["parameters"]["redirectType"] {
|
|
165
|
+
switch (status) {
|
|
166
|
+
case 301:
|
|
167
|
+
return "Moved";
|
|
168
|
+
case 302:
|
|
169
|
+
return "Found";
|
|
170
|
+
case 307:
|
|
171
|
+
return "TemporaryRedirect";
|
|
172
|
+
case 308:
|
|
173
|
+
return "PermanentRedirect";
|
|
174
|
+
}
|
|
175
|
+
}
|