| author | |
| committer | |
| log | 74796256868916da550ecbe9b5e37d359a8cb321 |
| tree | aefafcecf35e351cec5d77e468a057c52defe87d |
| parent | e3c85ead63c3a69f9be5dbb873b3d87c9b76b43f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>10 files changed, 369 insertions(+), 755 deletions(-)
.github/workflows/deploy-relay.yml+5-11| ... | @@ -54,9 +54,11 @@ jobs: | ... | @@ -54,9 +54,11 @@ jobs: |
| 54 | - --filter=t3code-relay... | 54 | - --filter=t3code-relay... |
| 55 | 55 | ||
| 56 | - name: Deploy production relay stage | 56 | - name: Deploy production relay stage |
| 57 | id: deploy | 57 | run: vp run --filter t3code-relay deploy --stage prod --yes --no-input |
| 58 | run: vp run --filter t3code-relay deploy --stage prod --yes --github-output | ||
| 59 | env: | 58 | env: |
| 59 | # The PublishClientConfig action writes the client env here instead | ||
| 60 | # of the repo-root .env; nothing on the runner reads it. | ||
| 61 | T3CODE_RELAY_CLIENT_CONFIG_ENV: ${{ runner.temp }}/relay-client-config.env | ||
| 60 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | 62 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} |
| 61 | PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }} | 63 | PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }} |
| 62 | PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} | 64 | PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }} |
| ... | @@ -69,20 +71,12 @@ jobs: | ... | @@ -69,20 +71,12 @@ jobs: |
| 69 | uses: actions/github-script@v8 | 71 | uses: actions/github-script@v8 |
| 70 | with: | 72 | with: |
| 71 | script: | | 73 | script: | |
| 72 | const result = "${{ steps.deploy.outputs.result }}"; | ||
| 73 | const changed = "${{ steps.deploy.outputs.changed }}" === "true"; | ||
| 74 | const description = changed | ||
| 75 | ? "Relay production deploy applied infrastructure changes." | ||
| 76 | : result === "noop" | ||
| 77 | ? "Relay production deploy was a no-op." | ||
| 78 | : `Relay production deploy completed with result: ${result}.`; | ||
| 79 | |||
| 80 | await github.rest.repos.createCommitStatus({ | 74 | await github.rest.repos.createCommitStatus({ |
| 81 | owner: context.repo.owner, | 75 | owner: context.repo.owner, |
| 82 | repo: context.repo.repo, | 76 | repo: context.repo.repo, |
| 83 | sha: context.sha, | 77 | sha: context.sha, |
| 84 | state: "success", | 78 | state: "success", |
| 85 | context: "Relay deploy / production", | 79 | context: "Relay deploy / production", |
| 86 | description, | 80 | description: "Relay production deploy completed.", |
| 87 | target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, | 81 | target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, |
| 88 | }); | 82 | }); |
.github/workflows/release.yml+20-7| ... | @@ -311,15 +311,28 @@ jobs: | ... | @@ -311,15 +311,28 @@ jobs: |
| 311 | args: | 311 | args: |
| 312 | - --filter=t3code-relay... | 312 | - --filter=t3code-relay... |
| 313 | 313 | ||
| 314 | - id: relay_state | 314 | # The deployed stack's outputs, read from Alchemy's state store without |
| 315 | name: Read production relay tracing config | 315 | # planning or applying. Redacted values are persisted as |
| 316 | # {"__redacted__": "<value>"}; the token is masked before it is written. | ||
| 317 | - name: Read production relay tracing config | ||
| 316 | shell: bash | 318 | shell: bash |
| 319 | working-directory: infra/relay | ||
| 317 | run: | | 320 | run: | |
| 318 | vp run --filter t3code-relay deploy \ | 321 | set -euo pipefail |
| 319 | --stage prod \ | 322 | output="$(npx alchemy state read T3CodeRelay/prod/output --no-input)" |
| 320 | --read-state \ | 323 | field() { |
| 321 | --github-output \ | 324 | jq -er --arg key "$1" '.[$key] | if type == "object" then .__redacted__ else . end | select(. != null and . != "")' <<<"$output" \ |
| 322 | --github-env-file "$RUNNER_TEMP/relay-client-tracing.env" | 325 | || { echo "Relay stack output is missing $1" >&2; exit 1; } |
| 326 | } | ||
| 327 | url="$(field clientTracingUrl)" | ||
| 328 | dataset="$(field clientTracingDataset)" | ||
| 329 | token="$(field clientTracingToken)" | ||
| 330 | echo "::add-mask::$token" | ||
| 331 | { | ||
| 332 | echo "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=$url" | ||
| 333 | echo "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=$dataset" | ||
| 334 | echo "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=$token" | ||
| 335 | } > "$RUNNER_TEMP/relay-client-tracing.env" | ||
| 323 | 336 | ||
| 324 | - name: Upload relay client tracing config | 337 | - name: Upload relay client tracing config |
| 325 | uses: actions/upload-artifact@v7 | 338 | uses: actions/upload-artifact@v7 |
docs/operations/connect-setup.md+1-1| ... | @@ -32,7 +32,7 @@ Bundled servers also accept runtime overrides for operator-managed deployments. | ... | @@ -32,7 +32,7 @@ Bundled servers also accept runtime overrides for operator-managed deployments. |
| 32 | 32 | ||
| 33 | Copy `infra/relay/.env.example` to `infra/relay/.env` for relay deployment settings. | 33 | Copy `infra/relay/.env.example` to `infra/relay/.env` for relay deployment settings. |
| 34 | Deploy `prod` before personal stages because it owns the retained database that their branches | 34 | Deploy `prod` before personal stages because it owns the retained database that their branches |
| 35 | depend on. The deploy wrapper writes the resulting relay URL back to the root `.env`. | 35 | depend on. The stack's `PublishClientConfig` action writes the resulting relay URL back to the root `.env`. |
| 36 | 36 | ||
| 37 | ## CLI OAuth application | 37 | ## CLI OAuth application |
| 38 | 38 |
infra/relay/README.md+6-4| ... | @@ -76,7 +76,8 @@ dependencies represented at their boundary rather than mocking internal behavior | ... | @@ -76,7 +76,8 @@ dependencies represented at their boundary rather than mocking internal behavior |
| 76 | 76 | ||
| 77 | ## Deployment | 77 | ## Deployment |
| 78 | 78 | ||
| 79 | The relay deploys through Alchemy: | 79 | The relay deploys with the Alchemy CLI (`vp run --filter t3code-relay deploy` is `alchemy deploy` |
| 80 | in this directory): | ||
| 80 | 81 | ||
| 81 | ```sh | 82 | ```sh |
| 82 | vp run --filter t3code-relay deploy | 83 | vp run --filter t3code-relay deploy |
| ... | @@ -109,9 +110,10 @@ DNS-safe sanitization as Alchemy physical resource names, so `prod` uses | ... | @@ -109,9 +110,10 @@ DNS-safe sanitization as Alchemy physical resource names, so `prod` uses |
| 109 | `<stage>-<digest>.<RELAY_TUNNEL_ZONE_NAME>`. `RELAY_DOMAIN` remains available as an explicit API | 110 | `<stage>-<digest>.<RELAY_TUNNEL_ZONE_NAME>`. `RELAY_DOMAIN` remains available as an explicit API |
| 110 | domain override. | 111 | domain override. |
| 111 | 112 | ||
| 112 | After a successful deploy, the wrapper updates the repository-root `.env` file with the derived relay | 113 | The stack's `PublishClientConfig` action ([`src/clientConfig.ts`](./src/clientConfig.ts)) writes the |
| 113 | URL. That makes subsequent source builds point at the relay that was just deployed without copying | 114 | deployed relay URL and tracing configuration into the repository-root `.env`, so subsequent source |
| 114 | the URL manually. | 115 | builds point at the relay that was just deployed without copying values manually. It runs only when |
| 116 | one of those outputs changed, and `T3CODE_RELAY_CLIENT_CONFIG_ENV` redirects it to another file. | ||
| 115 | 117 | ||
| 116 | ### Deployment CI | 118 | ### Deployment CI |
| 117 | 119 |
infra/relay/alchemy.run.ts+15| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | // @effect-diagnostics anyUnknownInErrorContext:off layerMergeAllWithDependencies:off - Alchemy provider helpers expose framework-owned any requirements. | 1 | // @effect-diagnostics anyUnknownInErrorContext:off layerMergeAllWithDependencies:off - Alchemy provider helpers expose framework-owned any requirements. |
| 2 | import * as Alchemy from "alchemy"; | 2 | import * as Alchemy from "alchemy"; |
| 3 | import * as Output from "alchemy/Output"; | ||
| 3 | import * as Axiom from "alchemy/Axiom"; | 4 | import * as Axiom from "alchemy/Axiom"; |
| 4 | import * as Cloudflare from "alchemy/Cloudflare"; | 5 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 5 | import * as Drizzle from "alchemy/Drizzle"; | 6 | import * as Drizzle from "alchemy/Drizzle"; |
| ... | @@ -7,6 +8,7 @@ import * as Effect from "effect/Effect"; | ... | @@ -7,6 +8,7 @@ import * as Effect from "effect/Effect"; |
| 7 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| 8 | import * as Planetscale from "alchemy/Planetscale"; | 9 | import * as Planetscale from "alchemy/Planetscale"; |
| 9 | 10 | ||
| 11 | import { PublishClientConfig, tokenDigest } from "./src/clientConfig.ts"; | ||
| 10 | import * as RelayDb from "./src/db.ts"; | 12 | import * as RelayDb from "./src/db.ts"; |
| 11 | import { RelayObservability } from "./src/observability.ts"; | 13 | import { RelayObservability } from "./src/observability.ts"; |
| 12 | import { ManagedEndpointZone, RelayApiZone } from "./src/zone.ts"; | 14 | import { ManagedEndpointZone, RelayApiZone } from "./src/zone.ts"; |
| ... | @@ -30,6 +32,19 @@ export default Alchemy.Stack( | ... | @@ -30,6 +32,19 @@ export default Alchemy.Stack( |
| 30 | const relayApiZone = yield* RelayApiZone.pipe(Effect.orDie); | 32 | const relayApiZone = yield* RelayApiZone.pipe(Effect.orDie); |
| 31 | const observability = yield* RelayObservability; | 33 | const observability = yield* RelayObservability; |
| 32 | const api = yield* Api; | 34 | const api = yield* Api; |
| 35 | yield* PublishClientConfig({ | ||
| 36 | url: api.url, | ||
| 37 | mobileTracingUrl: observability.traces.otelTracesEndpoint, | ||
| 38 | mobileTracingDataset: observability.traces.name, | ||
| 39 | mobileTracingToken: observability.mobileIngestToken.token, | ||
| 40 | clientTracingUrl: observability.traces.otelTracesEndpoint, | ||
| 41 | clientTracingDataset: observability.traces.name, | ||
| 42 | clientTracingToken: observability.clientIngestToken.token, | ||
| 43 | tokenDigest: Output.map( | ||
| 44 | Output.all(observability.mobileIngestToken.token, observability.clientIngestToken.token), | ||
| 45 | tokenDigest, | ||
| 46 | ), | ||
| 47 | }); | ||
| 33 | 48 | ||
| 34 | return { | 49 | return { |
| 35 | databaseName: db.database.name, | 50 | databaseName: db.database.name, |
infra/relay/package.json+1-1| ... | @@ -3,7 +3,7 @@ | ... | @@ -3,7 +3,7 @@ |
| 3 | "private": true, | 3 | "private": true, |
| 4 | "type": "module", | 4 | "type": "module", |
| 5 | "scripts": { | 5 | "scripts": { |
| 6 | "deploy": "node -- scripts/deploy.ts", | 6 | "deploy": "alchemy deploy", |
| 7 | "push:android:smoke": "node scripts/android-push-smoke.ts", | 7 | "push:android:smoke": "node scripts/android-push-smoke.ts", |
| 8 | "push:android:watch": "node scripts/android-push-watch.ts", | 8 | "push:android:watch": "node scripts/android-push-watch.ts", |
| 9 | "destroy": "alchemy destroy", | 9 | "destroy": "alchemy destroy", |
infra/relay/scripts/deploy.test.ts deleted-227| ... | @@ -1,227 +0,0 @@ | ||
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 2 | import { describe, expect, it } from "@effect/vitest"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as FileSystem from "effect/FileSystem"; | ||
| 5 | import * as Path from "effect/Path"; | ||
| 6 | |||
| 7 | import { | ||
| 8 | hasDeployChanges, | ||
| 9 | missingRelayPublicConfigFields, | ||
| 10 | publicConfigFromOutput, | ||
| 11 | reconcileRootEnvPublicConfig, | ||
| 12 | RelayDeployError, | ||
| 13 | RelayDeployPublicConfigUnavailableError, | ||
| 14 | serializeGithubOutput, | ||
| 15 | serializeRelayClientTracingEnvironment, | ||
| 16 | } from "./deploy.ts"; | ||
| 17 | |||
| 18 | describe("RelayDeployError", () => { | ||
| 19 | it("reports the incomplete state source, stage, and missing fields", () => { | ||
| 20 | const missingFields = missingRelayPublicConfigFields({ | ||
| 21 | url: "https://relay.example.test", | ||
| 22 | mobileTracingUrl: "https://api.axiom.co/v1/traces", | ||
| 23 | }); | ||
| 24 | const error = new RelayDeployError({ | ||
| 25 | source: "alchemy_state", | ||
| 26 | stage: "production", | ||
| 27 | missingFields, | ||
| 28 | }); | ||
| 29 | |||
| 30 | expect(error).toMatchObject({ | ||
| 31 | source: "alchemy_state", | ||
| 32 | stage: "production", | ||
| 33 | missingFields: [ | ||
| 34 | "mobileTracingDataset", | ||
| 35 | "mobileTracingToken", | ||
| 36 | "clientTracingUrl", | ||
| 37 | "clientTracingDataset", | ||
| 38 | "clientTracingToken", | ||
| 39 | ], | ||
| 40 | }); | ||
| 41 | expect(error.message).toBe( | ||
| 42 | "Relay deploy output from 'alchemy_state' for stage 'production' is missing required public config fields: mobileTracingDataset, mobileTracingToken, clientTracingUrl, clientTracingDataset, clientTracingToken", | ||
| 43 | ); | ||
| 44 | }); | ||
| 45 | |||
| 46 | it("distinguishes deploy results that do not produce public config", () => { | ||
| 47 | const error = new RelayDeployPublicConfigUnavailableError({ | ||
| 48 | result: "dry-run", | ||
| 49 | stage: "production", | ||
| 50 | outputPath: "/tmp/relay-client.env", | ||
| 51 | }); | ||
| 52 | |||
| 53 | expect(error.message).toBe( | ||
| 54 | "Relay deploy result 'dry-run' for stage 'production' did not produce public config required by GitHub environment output '/tmp/relay-client.env'.", | ||
| 55 | ); | ||
| 56 | }); | ||
| 57 | }); | ||
| 58 | |||
| 59 | describe("hasDeployChanges", () => { | ||
| 60 | it("detects resource, binding, and deletion changes", () => { | ||
| 61 | expect(hasDeployChanges({ resources: {}, deletions: {} } as never)).toBe(false); | ||
| 62 | expect( | ||
| 63 | hasDeployChanges({ | ||
| 64 | resources: { | ||
| 65 | api: { action: "create", bindings: [] }, | ||
| 66 | }, | ||
| 67 | deletions: {}, | ||
| 68 | } as never), | ||
| 69 | ).toBe(true); | ||
| 70 | expect( | ||
| 71 | hasDeployChanges({ | ||
| 72 | resources: { | ||
| 73 | api: { action: "noop", bindings: [{ action: "update" }] }, | ||
| 74 | }, | ||
| 75 | deletions: {}, | ||
| 76 | } as never), | ||
| 77 | ).toBe(true); | ||
| 78 | expect( | ||
| 79 | hasDeployChanges({ | ||
| 80 | resources: {}, | ||
| 81 | deletions: { | ||
| 82 | api: { action: "delete", bindings: [] }, | ||
| 83 | }, | ||
| 84 | } as never), | ||
| 85 | ).toBe(true); | ||
| 86 | }); | ||
| 87 | }); | ||
| 88 | |||
| 89 | describe("reconcileRootEnvPublicConfig", () => { | ||
| 90 | const config = { | ||
| 91 | relayUrl: "https://relay.example.test", | ||
| 92 | mobileTracingUrl: "https://api.axiom.co/v1/traces", | ||
| 93 | mobileTracingDataset: "t3-code-mobile-traces-dev", | ||
| 94 | mobileTracingToken: "xaat-public-ingest", | ||
| 95 | clientTracingUrl: "https://api.axiom.co/v1/traces", | ||
| 96 | clientTracingDataset: "t3-code-relay-client-traces-dev", | ||
| 97 | clientTracingToken: "xaat-relay-client-ingest", | ||
| 98 | } as const; | ||
| 99 | |||
| 100 | it("adds the complete local client config", () => { | ||
| 101 | expect(reconcileRootEnvPublicConfig("", config)).toBe( | ||
| 102 | [ | ||
| 103 | "T3CODE_RELAY_URL=https://relay.example.test", | ||
| 104 | "T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces", | ||
| 105 | "T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev", | ||
| 106 | "T3CODE_MOBILE_OTLP_TRACES_TOKEN=xaat-public-ingest", | ||
| 107 | "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://api.axiom.co/v1/traces", | ||
| 108 | "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=t3-code-relay-client-traces-dev", | ||
| 109 | "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=xaat-relay-client-ingest", | ||
| 110 | "", | ||
| 111 | ].join("\n"), | ||
| 112 | ); | ||
| 113 | }); | ||
| 114 | |||
| 115 | it("replaces stale values while preserving unrelated entries", () => { | ||
| 116 | expect( | ||
| 117 | reconcileRootEnvPublicConfig( | ||
| 118 | [ | ||
| 119 | "T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_example", | ||
| 120 | "T3CODE_RELAY_URL=https://old.example.test", | ||
| 121 | "T3CODE_MOBILE_OTLP_TRACES_URL=https://old.example.test/v1/traces", | ||
| 122 | "T3CODE_MOBILE_OTLP_TRACES_DATASET=old-dataset", | ||
| 123 | "T3CODE_MOBILE_OTLP_TRACES_TOKEN=old-token", | ||
| 124 | "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://old.example.test/v1/traces", | ||
| 125 | "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=old-client-dataset", | ||
| 126 | "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=old-client-token", | ||
| 127 | "", | ||
| 128 | ].join("\n"), | ||
| 129 | config, | ||
| 130 | ), | ||
| 131 | ).toBe( | ||
| 132 | [ | ||
| 133 | "T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_example", | ||
| 134 | "T3CODE_RELAY_URL=https://relay.example.test", | ||
| 135 | "T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces", | ||
| 136 | "T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev", | ||
| 137 | "T3CODE_MOBILE_OTLP_TRACES_TOKEN=xaat-public-ingest", | ||
| 138 | "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://api.axiom.co/v1/traces", | ||
| 139 | "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=t3-code-relay-client-traces-dev", | ||
| 140 | "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=xaat-relay-client-ingest", | ||
| 141 | "", | ||
| 142 | ].join("\n"), | ||
| 143 | ); | ||
| 144 | }); | ||
| 145 | }); | ||
| 146 | |||
| 147 | describe("serializeGithubOutput", () => { | ||
| 148 | it("serializes relay deploy metadata for GitHub Actions outputs", () => { | ||
| 149 | expect( | ||
| 150 | serializeGithubOutput({ | ||
| 151 | changed: false, | ||
| 152 | result: "noop", | ||
| 153 | relay_url: "https://relay.example.test", | ||
| 154 | }), | ||
| 155 | ).toBe("changed=false\nresult=noop\nrelay_url=https://relay.example.test\n"); | ||
| 156 | }); | ||
| 157 | }); | ||
| 158 | |||
| 159 | describe("serializeRelayClientTracingEnvironment", () => { | ||
| 160 | it("serializes tracing config for downstream GITHUB_ENV loading", () => { | ||
| 161 | expect( | ||
| 162 | serializeRelayClientTracingEnvironment({ | ||
| 163 | relayUrl: "https://relay.example.test", | ||
| 164 | mobileTracingUrl: "https://api.axiom.co/v1/traces", | ||
| 165 | mobileTracingDataset: "mobile", | ||
| 166 | mobileTracingToken: "mobile-token", | ||
| 167 | clientTracingUrl: "https://api.axiom.co/v1/traces", | ||
| 168 | clientTracingDataset: "relay", | ||
| 169 | clientTracingToken: "client-token", | ||
| 170 | }), | ||
| 171 | ).toBe( | ||
| 172 | [ | ||
| 173 | "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://api.axiom.co/v1/traces", | ||
| 174 | "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=relay", | ||
| 175 | "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=client-token", | ||
| 176 | "", | ||
| 177 | ].join("\n"), | ||
| 178 | ); | ||
| 179 | }); | ||
| 180 | }); | ||
| 181 | |||
| 182 | describe("release workflow tracing config propagation", () => { | ||
| 183 | it.effect("uses an artifact instead of a masked cross-job token output", () => | ||
| 184 | Effect.gen(function* () { | ||
| 185 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 186 | const path = yield* Path.Path; | ||
| 187 | const workflowPath = yield* path.fromFileUrl( | ||
| 188 | new URL("../../../.github/workflows/release.yml", import.meta.url), | ||
| 189 | ); | ||
| 190 | const workflow = yield* fileSystem.readFileString(workflowPath); | ||
| 191 | |||
| 192 | expect(workflow).not.toContain("client_tracing_token:"); | ||
| 193 | expect(workflow).not.toContain("needs.relay_public_config.outputs.client_tracing_token"); | ||
| 194 | expect(workflow).toContain('--github-env-file "$RUNNER_TEMP/relay-client-tracing.env"'); | ||
| 195 | expect(workflow).toContain("name: relay-client-tracing-config"); | ||
| 196 | expect(workflow).toContain('cat "$config_path" >> "$GITHUB_ENV"'); | ||
| 197 | }).pipe(Effect.provide(NodeServices.layer)), | ||
| 198 | ); | ||
| 199 | }); | ||
| 200 | |||
| 201 | describe("publicConfigFromOutput", () => { | ||
| 202 | it("reads the complete public tracing config from persisted Alchemy output", () => { | ||
| 203 | expect( | ||
| 204 | publicConfigFromOutput({ | ||
| 205 | url: "https://relay.example.test", | ||
| 206 | mobileTracingUrl: "https://api.axiom.co/v1/traces", | ||
| 207 | mobileTracingDataset: "mobile", | ||
| 208 | mobileTracingToken: "mobile-token", | ||
| 209 | clientTracingUrl: "https://api.axiom.co/v1/traces", | ||
| 210 | clientTracingDataset: "relay", | ||
| 211 | clientTracingToken: "client-token", | ||
| 212 | }), | ||
| 213 | ).toEqual({ | ||
| 214 | relayUrl: "https://relay.example.test", | ||
| 215 | mobileTracingUrl: "https://api.axiom.co/v1/traces", | ||
| 216 | mobileTracingDataset: "mobile", | ||
| 217 | mobileTracingToken: "mobile-token", | ||
| 218 | clientTracingUrl: "https://api.axiom.co/v1/traces", | ||
| 219 | clientTracingDataset: "relay", | ||
| 220 | clientTracingToken: "client-token", | ||
| 221 | }); | ||
| 222 | }); | ||
| 223 | |||
| 224 | it("rejects incomplete stack output", () => { | ||
| 225 | expect(publicConfigFromOutput({ url: "https://relay.example.test" })).toBeNull(); | ||
| 226 | }); | ||
| 227 | }); | ||
infra/relay/scripts/deploy.ts deleted-504| ... | @@ -1,504 +0,0 @@ | ||
| 1 | #!/usr/bin/env node | ||
| 2 | |||
| 3 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | ||
| 4 | import { AdoptPolicy } from "alchemy/AdoptPolicy"; | ||
| 5 | import { AlchemyContext, AlchemyContextLive } from "alchemy/AlchemyContext"; | ||
| 6 | import * as Apply from "alchemy/Apply"; | ||
| 7 | import { ArtifactStore, createArtifactStore, provideFreshArtifactStore } from "alchemy/Artifacts"; | ||
| 8 | import { AuthProviders } from "alchemy/Auth/AuthProvider"; | ||
| 9 | import { CredentialsStoreLive } from "alchemy/Auth/Credentials"; | ||
| 10 | import { ProfileStoreLive } from "alchemy/Auth/Profile"; | ||
| 11 | import * as Cloudflare from "alchemy/Cloudflare"; | ||
| 12 | import { layerNonInteractive } from "alchemy/Interaction"; | ||
| 13 | import { LoggingCli } from "alchemy/Cli/LoggingCli"; | ||
| 14 | import * as Plan from "alchemy/Plan"; | ||
| 15 | import { Cli } from "alchemy/Report"; | ||
| 16 | import * as Stage from "alchemy/Stage"; | ||
| 17 | import * as State from "alchemy/State/State"; | ||
| 18 | import { TelemetryLive } from "alchemy/Telemetry/Layer"; | ||
| 19 | import { PlatformServices } from "alchemy/Util/PlatformServices"; | ||
| 20 | import * as Config from "effect/Config"; | ||
| 21 | import * as ConfigProvider from "effect/ConfigProvider"; | ||
| 22 | import * as Console from "effect/Console"; | ||
| 23 | import * as Effect from "effect/Effect"; | ||
| 24 | import * as FileSystem from "effect/FileSystem"; | ||
| 25 | import * as Layer from "effect/Layer"; | ||
| 26 | import * as Option from "effect/Option"; | ||
| 27 | import * as Path from "effect/Path"; | ||
| 28 | import * as Redacted from "effect/Redacted"; | ||
| 29 | import * as Schema from "effect/Schema"; | ||
| 30 | import { Command, Flag, Prompt } from "effect/unstable/cli"; | ||
| 31 | import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; | ||
| 32 | |||
| 33 | import RelayStack from "../alchemy.run.ts"; | ||
| 34 | |||
| 35 | const relayDeployOutputFields = [ | ||
| 36 | "url", | ||
| 37 | "mobileTracingUrl", | ||
| 38 | "mobileTracingDataset", | ||
| 39 | "mobileTracingToken", | ||
| 40 | "clientTracingUrl", | ||
| 41 | "clientTracingDataset", | ||
| 42 | "clientTracingToken", | ||
| 43 | ] as const; | ||
| 44 | |||
| 45 | export const RelayDeployOutputField = Schema.Literals(relayDeployOutputFields); | ||
| 46 | export type RelayDeployOutputField = typeof RelayDeployOutputField.Type; | ||
| 47 | |||
| 48 | export const RelayDeployResult = Schema.Literals([ | ||
| 49 | "applied", | ||
| 50 | "noop", | ||
| 51 | "dry-run", | ||
| 52 | "cancelled", | ||
| 53 | "state", | ||
| 54 | ]); | ||
| 55 | export type RelayDeployResult = typeof RelayDeployResult.Type; | ||
| 56 | |||
| 57 | export class RelayDeployError extends Schema.TaggedError<RelayDeployError>()("RelayDeployError", { | ||
| 58 | source: Schema.Literals(["alchemy_state", "alchemy_apply"]), | ||
| 59 | stage: Schema.String, | ||
| 60 | missingFields: Schema.Array(RelayDeployOutputField), | ||
| 61 | }) { | ||
| 62 | override get message(): string { | ||
| 63 | return `Relay deploy output from '${this.source}' for stage '${this.stage}' is missing required public config fields: ${this.missingFields.join(", ")}`; | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | export class RelayDeployPublicConfigUnavailableError extends Schema.TaggedError<RelayDeployPublicConfigUnavailableError>()( | ||
| 68 | "RelayDeployPublicConfigUnavailableError", | ||
| 69 | { | ||
| 70 | result: RelayDeployResult, | ||
| 71 | stage: Schema.String, | ||
| 72 | outputPath: Schema.String, | ||
| 73 | }, | ||
| 74 | ) { | ||
| 75 | override get message(): string { | ||
| 76 | return `Relay deploy result '${this.result}' for stage '${this.stage}' did not produce public config required by GitHub environment output '${this.outputPath}'.`; | ||
| 77 | } | ||
| 78 | } | ||
| 79 | |||
| 80 | export interface RelayDeployOptions { | ||
| 81 | readonly dryRun: boolean; | ||
| 82 | readonly force: boolean; | ||
| 83 | readonly envFile: Option.Option<string>; | ||
| 84 | readonly stage: Option.Option<string>; | ||
| 85 | readonly yes: boolean; | ||
| 86 | readonly adopt: boolean; | ||
| 87 | readonly githubOutput: boolean; | ||
| 88 | readonly githubEnvFile: Option.Option<string>; | ||
| 89 | readonly readState: boolean; | ||
| 90 | } | ||
| 91 | |||
| 92 | export interface RelayPublicConfig { | ||
| 93 | readonly relayUrl: string; | ||
| 94 | readonly mobileTracingUrl: string; | ||
| 95 | readonly mobileTracingDataset: string; | ||
| 96 | readonly mobileTracingToken: string; | ||
| 97 | readonly clientTracingUrl: string; | ||
| 98 | readonly clientTracingDataset: string; | ||
| 99 | readonly clientTracingToken: string; | ||
| 100 | } | ||
| 101 | |||
| 102 | const publicConfigEnvEntries = (config: RelayPublicConfig) => | ||
| 103 | ({ | ||
| 104 | T3CODE_RELAY_URL: config.relayUrl, | ||
| 105 | T3CODE_MOBILE_OTLP_TRACES_URL: config.mobileTracingUrl, | ||
| 106 | T3CODE_MOBILE_OTLP_TRACES_DATASET: config.mobileTracingDataset, | ||
| 107 | T3CODE_MOBILE_OTLP_TRACES_TOKEN: config.mobileTracingToken, | ||
| 108 | T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: config.clientTracingUrl, | ||
| 109 | T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: config.clientTracingDataset, | ||
| 110 | T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: config.clientTracingToken, | ||
| 111 | }) as const; | ||
| 112 | |||
| 113 | export function reconcileRootEnvPublicConfig(contents: string, config: RelayPublicConfig): string { | ||
| 114 | let next = contents; | ||
| 115 | for (const [name, value] of Object.entries(publicConfigEnvEntries(config))) { | ||
| 116 | const entry = `${name}=${value}`; | ||
| 117 | const pattern = new RegExp(`^${name}=.*$`, "mu"); | ||
| 118 | if (pattern.test(next)) { | ||
| 119 | next = next.replace(pattern, entry); | ||
| 120 | continue; | ||
| 121 | } | ||
| 122 | if (!next) { | ||
| 123 | next = `${entry}\n`; | ||
| 124 | continue; | ||
| 125 | } | ||
| 126 | next = `${next}${next.endsWith("\n") ? "" : "\n"}${entry}\n`; | ||
| 127 | } | ||
| 128 | return next; | ||
| 129 | } | ||
| 130 | |||
| 131 | export function reconcileRootEnvRelayUrl(contents: string, relayUrl: string): string { | ||
| 132 | return reconcileRootEnvPublicConfig(contents, { | ||
| 133 | relayUrl, | ||
| 134 | mobileTracingUrl: "", | ||
| 135 | mobileTracingDataset: "", | ||
| 136 | mobileTracingToken: "", | ||
| 137 | clientTracingUrl: "", | ||
| 138 | clientTracingDataset: "", | ||
| 139 | clientTracingToken: "", | ||
| 140 | }) | ||
| 141 | .split("\n") | ||
| 142 | .filter((line) => !line.startsWith("T3CODE_MOBILE_OTLP_TRACES_")) | ||
| 143 | .filter((line) => !line.startsWith("T3CODE_RELAY_CLIENT_OTLP_TRACES_")) | ||
| 144 | .join("\n"); | ||
| 145 | } | ||
| 146 | |||
| 147 | export function hasDeployChanges(plan: Plan.Plan): boolean { | ||
| 148 | return ( | ||
| 149 | Object.keys(plan.deletions).length > 0 || | ||
| 150 | Object.values(plan.resources).some( | ||
| 151 | (node) => | ||
| 152 | node.action !== "noop" || node.bindings.some((binding) => binding.action !== "noop"), | ||
| 153 | ) | ||
| 154 | ); | ||
| 155 | } | ||
| 156 | |||
| 157 | export interface RelayDeployOutcome { | ||
| 158 | readonly result: RelayDeployResult; | ||
| 159 | readonly changed: boolean; | ||
| 160 | readonly publicConfig: Option.Option<RelayPublicConfig>; | ||
| 161 | } | ||
| 162 | |||
| 163 | export function serializeGithubOutput(entries: Readonly<Record<string, string | boolean>>): string { | ||
| 164 | return Object.entries(entries) | ||
| 165 | .map(([key, value]) => `${key}=${value}\n`) | ||
| 166 | .join(""); | ||
| 167 | } | ||
| 168 | |||
| 169 | export function serializeRelayClientTracingEnvironment(config: RelayPublicConfig): string { | ||
| 170 | return serializeGithubOutput({ | ||
| 171 | T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: config.clientTracingUrl, | ||
| 172 | T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: config.clientTracingDataset, | ||
| 173 | T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: config.clientTracingToken, | ||
| 174 | }); | ||
| 175 | } | ||
| 176 | |||
| 177 | const relayRoot = Effect.service(Path.Path).pipe( | ||
| 178 | Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), | ||
| 179 | ); | ||
| 180 | const repoRoot = Effect.service(Path.Path).pipe( | ||
| 181 | Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))), | ||
| 182 | ); | ||
| 183 | |||
| 184 | const loadDeployConfigProvider = Effect.fn("relay.deploy.loadConfigProvider")(function* ( | ||
| 185 | envFileOverride: Option.Option<string>, | ||
| 186 | ) { | ||
| 187 | const path = yield* Path.Path; | ||
| 188 | const root = yield* relayRoot; | ||
| 189 | |||
| 190 | if (Option.isSome(envFileOverride)) { | ||
| 191 | return yield* ConfigProvider.fromDotEnv({ path: path.resolve(root, envFileOverride.value) }); | ||
| 192 | } | ||
| 193 | |||
| 194 | return yield* ConfigProvider.fromDotEnv({ path: path.join(root, ".env") }).pipe( | ||
| 195 | Effect.orElseSucceed(() => ConfigProvider.fromEnv()), | ||
| 196 | ); | ||
| 197 | }); | ||
| 198 | |||
| 199 | const relayDeployStage = Config.NonEmptyString("stage").pipe( | ||
| 200 | Config.option, | ||
| 201 | Config.map( | ||
| 202 | Option.getOrElse(() => `dev_${process.env.USER ?? process.env.USERNAME ?? "unknown"}`), | ||
| 203 | ), | ||
| 204 | ); | ||
| 205 | |||
| 206 | const reconcileRootEnv = Effect.fn("relay.deploy.reconcileRootEnv")(function* ( | ||
| 207 | config: RelayPublicConfig, | ||
| 208 | ) { | ||
| 209 | const fs = yield* FileSystem.FileSystem; | ||
| 210 | const path = yield* Path.Path; | ||
| 211 | const root = yield* repoRoot; | ||
| 212 | const rootEnvPath = path.join(root, ".env"); | ||
| 213 | const contents = (yield* fs.exists(rootEnvPath)) ? yield* fs.readFileString(rootEnvPath) : ""; | ||
| 214 | |||
| 215 | yield* fs.writeFileString(rootEnvPath, reconcileRootEnvPublicConfig(contents, config)); | ||
| 216 | yield* Console.log(`Updated ${rootEnvPath} with relay public client configuration`); | ||
| 217 | }); | ||
| 218 | |||
| 219 | const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* ( | ||
| 220 | outcome: RelayDeployOutcome, | ||
| 221 | ) { | ||
| 222 | const fs = yield* FileSystem.FileSystem; | ||
| 223 | const githubOutputPath = yield* Config.NonEmptyString("GITHUB_OUTPUT"); | ||
| 224 | yield* fs.writeFileString( | ||
| 225 | githubOutputPath, | ||
| 226 | serializeGithubOutput({ | ||
| 227 | changed: outcome.changed, | ||
| 228 | result: outcome.result, | ||
| 229 | ...(Option.isSome(outcome.publicConfig) | ||
| 230 | ? { | ||
| 231 | relay_url: outcome.publicConfig.value.relayUrl, | ||
| 232 | } | ||
| 233 | : {}), | ||
| 234 | }), | ||
| 235 | { flag: "a" }, | ||
| 236 | ); | ||
| 237 | }); | ||
| 238 | |||
| 239 | const writeGithubEnvFile = Effect.fn("relay.deploy.writeGithubEnvFile")(function* ( | ||
| 240 | outcome: RelayDeployOutcome, | ||
| 241 | outputPath: string, | ||
| 242 | stage: string, | ||
| 243 | ) { | ||
| 244 | if (Option.isNone(outcome.publicConfig)) { | ||
| 245 | return yield* new RelayDeployPublicConfigUnavailableError({ | ||
| 246 | result: outcome.result, | ||
| 247 | stage, | ||
| 248 | outputPath, | ||
| 249 | }); | ||
| 250 | } | ||
| 251 | const fs = yield* FileSystem.FileSystem; | ||
| 252 | yield* Console.log(`::add-mask::${outcome.publicConfig.value.clientTracingToken}`); | ||
| 253 | yield* fs.writeFileString( | ||
| 254 | outputPath, | ||
| 255 | serializeRelayClientTracingEnvironment(outcome.publicConfig.value), | ||
| 256 | ); | ||
| 257 | }); | ||
| 258 | |||
| 259 | const deployBaseServices = Layer.mergeAll( | ||
| 260 | Layer.succeed(AuthProviders, {}), | ||
| 261 | Layer.succeed(ArtifactStore, createArtifactStore()), | ||
| 262 | Layer.provideMerge(AlchemyContextLive, PlatformServices), | ||
| 263 | Layer.provide(ProfileStoreLive, PlatformServices), | ||
| 264 | Layer.provide(CredentialsStoreLive, PlatformServices), | ||
| 265 | FetchHttpClient.layer, | ||
| 266 | TelemetryLive, | ||
| 267 | // LoggingCli captures the prompt environment when it is built. | ||
| 268 | Layer.provide(LoggingCli, PlatformServices), | ||
| 269 | // Alchemy's auth providers prompt through this service. None are configured | ||
| 270 | // here and the apply confirmation is this script's own prompt, so a prompt | ||
| 271 | // reaching it is a typed failure rather than a hang in CI. | ||
| 272 | layerNonInteractive(), | ||
| 273 | ); | ||
| 274 | const deployServices = deployBaseServices; | ||
| 275 | |||
| 276 | function relayPublicConfigValues( | ||
| 277 | output: unknown, | ||
| 278 | ): Readonly<Record<RelayDeployOutputField, string | undefined>> { | ||
| 279 | if (typeof output !== "object" || output === null) { | ||
| 280 | return { | ||
| 281 | url: undefined, | ||
| 282 | mobileTracingUrl: undefined, | ||
| 283 | mobileTracingDataset: undefined, | ||
| 284 | mobileTracingToken: undefined, | ||
| 285 | clientTracingUrl: undefined, | ||
| 286 | clientTracingDataset: undefined, | ||
| 287 | clientTracingToken: undefined, | ||
| 288 | }; | ||
| 289 | } | ||
| 290 | const value = output as Record<string, unknown>; | ||
| 291 | const text = (name: string) => { | ||
| 292 | const candidate = value[name]; | ||
| 293 | return typeof candidate === "string" && candidate.length > 0 ? candidate : undefined; | ||
| 294 | }; | ||
| 295 | const secret = (name: string): string | undefined => { | ||
| 296 | const candidate = value[name]; | ||
| 297 | if (!Redacted.isRedacted(candidate)) { | ||
| 298 | return text(name); | ||
| 299 | } | ||
| 300 | const redacted = Redacted.value(candidate); | ||
| 301 | return typeof redacted === "string" && redacted.length > 0 ? redacted : undefined; | ||
| 302 | }; | ||
| 303 | return { | ||
| 304 | url: text("url"), | ||
| 305 | mobileTracingUrl: text("mobileTracingUrl"), | ||
| 306 | mobileTracingDataset: text("mobileTracingDataset"), | ||
| 307 | mobileTracingToken: secret("mobileTracingToken"), | ||
| 308 | clientTracingUrl: text("clientTracingUrl"), | ||
| 309 | clientTracingDataset: text("clientTracingDataset"), | ||
| 310 | clientTracingToken: secret("clientTracingToken"), | ||
| 311 | }; | ||
| 312 | } | ||
| 313 | |||
| 314 | export function missingRelayPublicConfigFields( | ||
| 315 | output: unknown, | ||
| 316 | ): ReadonlyArray<RelayDeployOutputField> { | ||
| 317 | const values = relayPublicConfigValues(output); | ||
| 318 | return relayDeployOutputFields.filter((field) => values[field] === undefined); | ||
| 319 | } | ||
| 320 | |||
| 321 | function hasCompleteRelayPublicConfigValues( | ||
| 322 | values: Readonly<Record<RelayDeployOutputField, string | undefined>>, | ||
| 323 | ): values is Readonly<Record<RelayDeployOutputField, string>> { | ||
| 324 | return relayDeployOutputFields.every((field) => values[field] !== undefined); | ||
| 325 | } | ||
| 326 | |||
| 327 | export function publicConfigFromOutput(output: unknown): RelayPublicConfig | null { | ||
| 328 | const values = relayPublicConfigValues(output); | ||
| 329 | if (!hasCompleteRelayPublicConfigValues(values)) { | ||
| 330 | return null; | ||
| 331 | } | ||
| 332 | return { | ||
| 333 | relayUrl: values.url, | ||
| 334 | mobileTracingUrl: values.mobileTracingUrl, | ||
| 335 | mobileTracingDataset: values.mobileTracingDataset, | ||
| 336 | mobileTracingToken: values.mobileTracingToken, | ||
| 337 | clientTracingUrl: values.clientTracingUrl, | ||
| 338 | clientTracingDataset: values.clientTracingDataset, | ||
| 339 | clientTracingToken: values.clientTracingToken, | ||
| 340 | }; | ||
| 341 | } | ||
| 342 | |||
| 343 | const readRelayPublicConfig = Effect.fn("relay.deploy.readState")(function* (stage: string) { | ||
| 344 | const state = yield* State.State; | ||
| 345 | const service = yield* state; | ||
| 346 | const output = yield* service.getOutput({ stack: "T3CodeRelay", stage }); | ||
| 347 | const publicConfig = publicConfigFromOutput(output); | ||
| 348 | if (publicConfig === null) { | ||
| 349 | return yield* new RelayDeployError({ | ||
| 350 | source: "alchemy_state", | ||
| 351 | stage, | ||
| 352 | missingFields: missingRelayPublicConfigFields(output), | ||
| 353 | }); | ||
| 354 | } | ||
| 355 | return { | ||
| 356 | result: "state", | ||
| 357 | changed: false, | ||
| 358 | publicConfig: Option.some(publicConfig), | ||
| 359 | } satisfies RelayDeployOutcome; | ||
| 360 | }); | ||
| 361 | |||
| 362 | const runRelayDeploy = Effect.fn("relay.deploy.run")( | ||
| 363 | function* ( | ||
| 364 | options: RelayDeployOptions, | ||
| 365 | _configProvider: ConfigProvider.ConfigProvider, | ||
| 366 | stage: string, | ||
| 367 | ) { | ||
| 368 | const stack = yield* RelayStack; | ||
| 369 | const cli = yield* Cli; | ||
| 370 | const plan = yield* Plan.make(stack, { force: options.force }).pipe( | ||
| 371 | Effect.provide(stack.services), | ||
| 372 | ); | ||
| 373 | const changed = hasDeployChanges(plan); | ||
| 374 | if (options.dryRun) { | ||
| 375 | yield* cli.displayPlan(plan); | ||
| 376 | return { | ||
| 377 | result: "dry-run", | ||
| 378 | changed, | ||
| 379 | publicConfig: Option.none<RelayPublicConfig>(), | ||
| 380 | } satisfies RelayDeployOutcome; | ||
| 381 | } | ||
| 382 | if (!options.yes && changed) { | ||
| 383 | yield* cli.displayPlan(plan); | ||
| 384 | const approved = yield* Prompt.run( | ||
| 385 | Prompt.Confirm({ | ||
| 386 | message: "Apply this relay deployment?", | ||
| 387 | }), | ||
| 388 | ); | ||
| 389 | if (!approved) { | ||
| 390 | yield* Console.log("Deployment cancelled."); | ||
| 391 | return { | ||
| 392 | result: "cancelled", | ||
| 393 | changed, | ||
| 394 | publicConfig: Option.none<RelayPublicConfig>(), | ||
| 395 | } satisfies RelayDeployOutcome; | ||
| 396 | } | ||
| 397 | } | ||
| 398 | const output = yield* Apply.apply(plan).pipe(Effect.provide(stack.services)); | ||
| 399 | const publicConfig = publicConfigFromOutput(output); | ||
| 400 | if (publicConfig === null) { | ||
| 401 | return yield* new RelayDeployError({ | ||
| 402 | source: "alchemy_apply", | ||
| 403 | stage, | ||
| 404 | missingFields: missingRelayPublicConfigFields(output), | ||
| 405 | }); | ||
| 406 | } | ||
| 407 | return { | ||
| 408 | result: changed ? "applied" : "noop", | ||
| 409 | changed, | ||
| 410 | publicConfig: Option.some(publicConfig), | ||
| 411 | } satisfies RelayDeployOutcome; | ||
| 412 | }, | ||
| 413 | (effect, options, configProvider, stage) => | ||
| 414 | effect.pipe( | ||
| 415 | Effect.provide( | ||
| 416 | Layer.mergeAll( | ||
| 417 | Layer.effect( | ||
| 418 | AlchemyContext, | ||
| 419 | AlchemyContext.pipe(Effect.map((context) => ({ ...context, adopt: options.adopt }))), | ||
| 420 | ), | ||
| 421 | Layer.succeed(AdoptPolicy, options.adopt), | ||
| 422 | Layer.succeed(AuthProviders, {}), | ||
| 423 | ConfigProvider.layer(configProvider), | ||
| 424 | Layer.succeed(Stage.Stage, stage), | ||
| 425 | ), | ||
| 426 | ), | ||
| 427 | provideFreshArtifactStore, | ||
| 428 | ), | ||
| 429 | ); | ||
| 430 | |||
| 431 | export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployOptions) { | ||
| 432 | const configProvider = yield* loadDeployConfigProvider(options.envFile); | ||
| 433 | const configuredStage = yield* relayDeployStage.pipe( | ||
| 434 | Effect.provide(ConfigProvider.layer(configProvider)), | ||
| 435 | ); | ||
| 436 | const stage = Option.getOrElse(options.stage, () => configuredStage); | ||
| 437 | const outcome = options.readState | ||
| 438 | ? yield* readRelayPublicConfig(stage).pipe(Effect.provide(Cloudflare.state())) | ||
| 439 | : yield* runRelayDeploy(options, configProvider, stage); | ||
| 440 | if (Option.isSome(outcome.publicConfig)) { | ||
| 441 | yield* reconcileRootEnv(outcome.publicConfig.value); | ||
| 442 | } | ||
| 443 | if (options.githubOutput) { | ||
| 444 | yield* writeGithubOutput(outcome); | ||
| 445 | } | ||
| 446 | if (Option.isSome(options.githubEnvFile)) { | ||
| 447 | yield* writeGithubEnvFile(outcome, options.githubEnvFile.value, stage); | ||
| 448 | } | ||
| 449 | }); | ||
| 450 | |||
| 451 | export const relayDeployCommand = Command.make( | ||
| 452 | "relay-deploy", | ||
| 453 | { | ||
| 454 | dryRun: Flag.Boolean("dry-run").pipe( | ||
| 455 | Flag.withDescription("Dry run the deployment without applying changes."), | ||
| 456 | Flag.withDefault(false), | ||
| 457 | ), | ||
| 458 | force: Flag.Boolean("force").pipe( | ||
| 459 | Flag.withDescription("Force updates for resources that would otherwise no-op."), | ||
| 460 | Flag.withDefault(false), | ||
| 461 | ), | ||
| 462 | envFile: Flag.String("env-file").pipe( | ||
| 463 | Flag.withDescription( | ||
| 464 | "Environment file to load. Defaults to infra/relay/.env with process env fallback.", | ||
| 465 | ), | ||
| 466 | Flag.optional, | ||
| 467 | ), | ||
| 468 | stage: Flag.String("stage").pipe( | ||
| 469 | Flag.withDescription("Stage to deploy. Defaults to dev_${USER}."), | ||
| 470 | Flag.optional, | ||
| 471 | ), | ||
| 472 | yes: Flag.Boolean("yes").pipe( | ||
| 473 | Flag.withDescription("Skip the deployment confirmation prompt."), | ||
| 474 | Flag.withDefault(false), | ||
| 475 | ), | ||
| 476 | adopt: Flag.Boolean("adopt").pipe( | ||
| 477 | Flag.withDescription("Adopt pre-existing cloud resources that conflict with this stack."), | ||
| 478 | Flag.withDefault(false), | ||
| 479 | ), | ||
| 480 | githubOutput: Flag.Boolean("github-output").pipe( | ||
| 481 | Flag.withDescription("Append relay deployment metadata to GITHUB_OUTPUT."), | ||
| 482 | Flag.withDefault(false), | ||
| 483 | ), | ||
| 484 | githubEnvFile: Flag.String("github-env-file").pipe( | ||
| 485 | Flag.withDescription( | ||
| 486 | "Write relay client tracing variables to a file suitable for GITHUB_ENV.", | ||
| 487 | ), | ||
| 488 | Flag.optional, | ||
| 489 | ), | ||
| 490 | readState: Flag.Boolean("read-state").pipe( | ||
| 491 | Flag.withDescription("Read the deployed stack output without planning or applying changes."), | ||
| 492 | Flag.withDefault(false), | ||
| 493 | ), | ||
| 494 | }, | ||
| 495 | deploy, | ||
| 496 | ).pipe(Command.withDescription("Deploy the T3 Code relay through Alchemy.")); | ||
| 497 | |||
| 498 | if (import.meta.main) { | ||
| 499 | Command.run(relayDeployCommand, { version: "0.0.0" }).pipe( | ||
| 500 | Effect.provide(deployServices), | ||
| 501 | Effect.scoped, | ||
| 502 | NodeRuntime.runMain, | ||
| 503 | ); | ||
| 504 | } | ||
infra/relay/src/clientConfig.test.ts created+177| ... | @@ -0,0 +1,177 @@ | ||
| 1 | // @effect-diagnostics anyUnknownInErrorContext:off - ScratchStack.deploy is typed with `any` in its error channel by Alchemy. | ||
| 2 | import { inMemoryState } from "alchemy/State"; | ||
| 3 | import * as Test from "alchemy/Test/Vitest"; | ||
| 4 | import * as ConfigProvider from "effect/ConfigProvider"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as FileSystem from "effect/FileSystem"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | import * as Path from "effect/Path"; | ||
| 9 | import * as Redacted from "effect/Redacted"; | ||
| 10 | import { describe, expect, it } from "vite-plus/test"; | ||
| 11 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 12 | |||
| 13 | import { PublishClientConfig, reconcileEnvFile, tokenDigest } from "./clientConfig.ts"; | ||
| 14 | |||
| 15 | describe("reconcileEnvFile", () => { | ||
| 16 | it("adds every entry to an empty file", () => { | ||
| 17 | expect(reconcileEnvFile("", { A: "1", B: "2" })).toBe("A=1\nB=2\n"); | ||
| 18 | }); | ||
| 19 | |||
| 20 | it("replaces stale values and keeps unrelated lines", () => { | ||
| 21 | const existing = "KEEP=yes\nA=old\n# comment\n"; | ||
| 22 | expect(reconcileEnvFile(existing, { A: "new", B: "2" })).toBe( | ||
| 23 | "KEEP=yes\nA=new\n# comment\nB=2\n", | ||
| 24 | ); | ||
| 25 | }); | ||
| 26 | |||
| 27 | it("appends a newline before adding to a file without one", () => { | ||
| 28 | expect(reconcileEnvFile("A=1", { B: "2" })).toBe("A=1\nB=2\n"); | ||
| 29 | }); | ||
| 30 | |||
| 31 | it("collapses duplicate assignments to one, since parseEnv lets the last win", () => { | ||
| 32 | expect(reconcileEnvFile("A=old\nKEEP=1\nA=older\n", { A: "new" })).toBe("A=new\nKEEP=1\n"); | ||
| 33 | }); | ||
| 34 | |||
| 35 | it("recognises every assignment form parseEnv accepts", () => { | ||
| 36 | expect(reconcileEnvFile("A=old\n export A = stale\nexport A=older\n", { A: "new" })).toBe( | ||
| 37 | "A=new\n", | ||
| 38 | ); | ||
| 39 | }); | ||
| 40 | |||
| 41 | it("replaces a quoted multi-line value whole", () => { | ||
| 42 | expect(reconcileEnvFile('A="one\ntwo"\nKEEP=1\n', { A: "new" })).toBe("A=new\nKEEP=1\n"); | ||
| 43 | expect(reconcileEnvFile("A='x'\nKEEP=1\n", { A: "new" })).toBe("A=new\nKEEP=1\n"); | ||
| 44 | }); | ||
| 45 | |||
| 46 | it("keeps later lines when a quoted value never closes", () => { | ||
| 47 | expect(reconcileEnvFile('A="one\nKEEP=1\n', { A: "new" })).toBe("A=new\nKEEP=1\n"); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it("leaves a commented-out assignment alone", () => { | ||
| 51 | expect(reconcileEnvFile("#A=old\n", { A: "new" })).toBe("#A=old\nA=new\n"); | ||
| 52 | }); | ||
| 53 | }); | ||
| 54 | |||
| 55 | const { test } = Test.make({ | ||
| 56 | providers: Layer.empty, | ||
| 57 | state: inMemoryState(), | ||
| 58 | }); | ||
| 59 | |||
| 60 | const clientConfig = (token: string) => ({ | ||
| 61 | url: "https://relay.example.com", | ||
| 62 | mobileTracingUrl: "https://axiom.example.com/v1/traces", | ||
| 63 | mobileTracingDataset: "relay-traces", | ||
| 64 | mobileTracingToken: Redacted.make(`mobile-${token}`), | ||
| 65 | clientTracingUrl: "https://axiom.example.com/v1/traces", | ||
| 66 | clientTracingDataset: "relay-traces", | ||
| 67 | clientTracingToken: Redacted.make(`client-${token}`), | ||
| 68 | tokenDigest: tokenDigest([Redacted.make(`mobile-${token}`), Redacted.make(`client-${token}`)]), | ||
| 69 | }); | ||
| 70 | |||
| 71 | describe("PublishClientConfig", () => { | ||
| 72 | test.provider("writes the env file on deploy and skips an unchanged redeploy", (stack) => | ||
| 73 | Effect.gen(function* () { | ||
| 74 | const fs = yield* FileSystem.FileSystem; | ||
| 75 | const path = yield* Path.Path; | ||
| 76 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-relay-client-config-" }); | ||
| 77 | const target = path.join(dir, "client.env"); | ||
| 78 | yield* fs.writeFileString(target, "KEEP=yes\n"); | ||
| 79 | const configured = Effect.provide( | ||
| 80 | ConfigProvider.layer( | ||
| 81 | ConfigProvider.fromUnknown({ T3CODE_RELAY_CLIENT_CONFIG_ENV: target }), | ||
| 82 | ), | ||
| 83 | ); | ||
| 84 | |||
| 85 | yield* stack | ||
| 86 | .deploy( | ||
| 87 | Effect.gen(function* () { | ||
| 88 | return yield* PublishClientConfig(clientConfig("v1")); | ||
| 89 | }), | ||
| 90 | ) | ||
| 91 | .pipe(configured); | ||
| 92 | const first = yield* fs.readFileString(target); | ||
| 93 | expect(first).toContain("KEEP=yes\n"); | ||
| 94 | expect(first).toContain("T3CODE_RELAY_URL=https://relay.example.com\n"); | ||
| 95 | expect(first).toContain("T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=client-v1\n"); | ||
| 96 | expect(first).toContain("T3CODE_MOBILE_OTLP_TRACES_TOKEN=mobile-v1\n"); | ||
| 97 | |||
| 98 | // Same input: the action is skipped, so a change made by hand survives. | ||
| 99 | yield* fs.writeFileString(target, `${first}MANUAL=1\n`); | ||
| 100 | yield* stack | ||
| 101 | .deploy( | ||
| 102 | Effect.gen(function* () { | ||
| 103 | return yield* PublishClientConfig(clientConfig("v1")); | ||
| 104 | }), | ||
| 105 | ) | ||
| 106 | .pipe(configured); | ||
| 107 | expect(yield* fs.readFileString(target)).toContain("MANUAL=1\n"); | ||
| 108 | |||
| 109 | // A rotated token changes the input, so it runs again and replaces the line. | ||
| 110 | yield* stack | ||
| 111 | .deploy( | ||
| 112 | Effect.gen(function* () { | ||
| 113 | return yield* PublishClientConfig(clientConfig("v2")); | ||
| 114 | }), | ||
| 115 | ) | ||
| 116 | .pipe(configured); | ||
| 117 | const third = yield* fs.readFileString(target); | ||
| 118 | expect(third).toContain("T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=client-v2\n"); | ||
| 119 | expect(third).not.toContain("client-v1"); | ||
| 120 | expect(third).toContain("KEEP=yes\n"); | ||
| 121 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 122 | ); | ||
| 123 | |||
| 124 | test.provider("fails when the worker has no url rather than publishing an empty relay", (stack) => | ||
| 125 | Effect.gen(function* () { | ||
| 126 | const fs = yield* FileSystem.FileSystem; | ||
| 127 | const path = yield* Path.Path; | ||
| 128 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-relay-client-config-" }); | ||
| 129 | const target = path.join(dir, "client.env"); | ||
| 130 | const exit = yield* stack | ||
| 131 | .deploy( | ||
| 132 | Effect.gen(function* () { | ||
| 133 | return yield* PublishClientConfig({ ...clientConfig("v1"), url: undefined }); | ||
| 134 | }), | ||
| 135 | ) | ||
| 136 | .pipe( | ||
| 137 | Effect.provide( | ||
| 138 | ConfigProvider.layer( | ||
| 139 | ConfigProvider.fromUnknown({ T3CODE_RELAY_CLIENT_CONFIG_ENV: target }), | ||
| 140 | ), | ||
| 141 | ), | ||
| 142 | Effect.exit, | ||
| 143 | ); | ||
| 144 | expect(exit._tag).toBe("Failure"); | ||
| 145 | expect(String(exit)).toContain("RelayUrlUnavailableError"); | ||
| 146 | expect(yield* fs.exists(target)).toBe(false); | ||
| 147 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 148 | ); | ||
| 149 | |||
| 150 | test.provider("refuses a value with a line break rather than corrupting the file", (stack) => | ||
| 151 | Effect.gen(function* () { | ||
| 152 | const fs = yield* FileSystem.FileSystem; | ||
| 153 | const path = yield* Path.Path; | ||
| 154 | const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-relay-client-config-" }); | ||
| 155 | const target = path.join(dir, "client.env"); | ||
| 156 | const exit = yield* stack | ||
| 157 | .deploy( | ||
| 158 | Effect.gen(function* () { | ||
| 159 | return yield* PublishClientConfig({ | ||
| 160 | ...clientConfig("v1"), | ||
| 161 | clientTracingDataset: "relay-traces\nINJECTED=1", | ||
| 162 | }); | ||
| 163 | }), | ||
| 164 | ) | ||
| 165 | .pipe( | ||
| 166 | Effect.provide( | ||
| 167 | ConfigProvider.layer( | ||
| 168 | ConfigProvider.fromUnknown({ T3CODE_RELAY_CLIENT_CONFIG_ENV: target }), | ||
| 169 | ), | ||
| 170 | ), | ||
| 171 | Effect.exit, | ||
| 172 | ); | ||
| 173 | expect(exit._tag).toBe("Failure"); | ||
| 174 | expect(yield* fs.exists(target)).toBe(false); | ||
| 175 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 176 | ); | ||
| 177 | }); | ||
infra/relay/src/clientConfig.ts created+144| ... | @@ -0,0 +1,144 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - one sha256 over two strings; Effect.Crypto is async and the digest feeds a synchronous Output.map. | ||
| 2 | import * as NodeCrypto from "node:crypto"; | ||
| 3 | |||
| 4 | import * as Alchemy from "alchemy"; | ||
| 5 | import * as Config from "effect/Config"; | ||
| 6 | import * as Console from "effect/Console"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as FileSystem from "effect/FileSystem"; | ||
| 9 | import * as Option from "effect/Option"; | ||
| 10 | import * as Path from "effect/Path"; | ||
| 11 | import * as Redacted from "effect/Redacted"; | ||
| 12 | import * as Schema from "effect/Schema"; | ||
| 13 | |||
| 14 | /** The relay outputs a client (web, desktop, mobile) needs at build time. */ | ||
| 15 | export interface RelayClientConfig { | ||
| 16 | /** Alchemy types this as optional for workers reachable at no URL; ours always has one. */ | ||
| 17 | readonly url: string | undefined; | ||
| 18 | readonly mobileTracingUrl: string; | ||
| 19 | readonly mobileTracingDataset: string; | ||
| 20 | readonly mobileTracingToken: Redacted.Redacted<string>; | ||
| 21 | readonly clientTracingUrl: string; | ||
| 22 | readonly clientTracingDataset: string; | ||
| 23 | readonly clientTracingToken: Redacted.Redacted<string>; | ||
| 24 | /** | ||
| 25 | * Alchemy decides whether an Action runs by hashing `JSON.stringify` of its | ||
| 26 | * input, and a Redacted stringifies as `<redacted>`, so a rotated token | ||
| 27 | * alone would never re-run it. A digest of both tokens makes the input | ||
| 28 | * change with them without persisting the secrets in the hash. | ||
| 29 | */ | ||
| 30 | readonly tokenDigest: string; | ||
| 31 | } | ||
| 32 | |||
| 33 | export class RelayUrlUnavailableError extends Schema.TaggedError<RelayUrlUnavailableError>()( | ||
| 34 | "RelayUrlUnavailableError", | ||
| 35 | {}, | ||
| 36 | ) { | ||
| 37 | override get message(): string { | ||
| 38 | return "The relay worker has no URL yet; deploy again once the worker exists."; | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 42 | export const relayClientConfigEnv = (config: RelayClientConfig & { readonly url: string }) => | ||
| 43 | ({ | ||
| 44 | T3CODE_RELAY_URL: config.url, | ||
| 45 | T3CODE_MOBILE_OTLP_TRACES_URL: config.mobileTracingUrl, | ||
| 46 | T3CODE_MOBILE_OTLP_TRACES_DATASET: config.mobileTracingDataset, | ||
| 47 | T3CODE_MOBILE_OTLP_TRACES_TOKEN: Redacted.value(config.mobileTracingToken), | ||
| 48 | T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: config.clientTracingUrl, | ||
| 49 | T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: config.clientTracingDataset, | ||
| 50 | T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: Redacted.value(config.clientTracingToken), | ||
| 51 | }) as const; | ||
| 52 | |||
| 53 | export class EnvValueNotSingleLineError extends Schema.TaggedError<EnvValueNotSingleLineError>()( | ||
| 54 | "EnvValueNotSingleLineError", | ||
| 55 | { name: Schema.String }, | ||
| 56 | ) { | ||
| 57 | override get message(): string { | ||
| 58 | return `${this.name} contains a line break and cannot be written as one .env assignment.`; | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | /** | ||
| 63 | * Replaces or appends each `NAME=value` assignment, leaving unrelated lines | ||
| 64 | * alone. Every existing line for a name is dropped, not just the first: the | ||
| 65 | * file is read with `parseEnv`, where the last duplicate wins, so a stale | ||
| 66 | * second copy would override the value just written. | ||
| 67 | */ | ||
| 68 | export function reconcileEnvFile( | ||
| 69 | contents: string, | ||
| 70 | entries: Readonly<Record<string, string>>, | ||
| 71 | ): string { | ||
| 72 | const lines = contents === "" ? [] : contents.replace(/\n$/u, "").split("\n"); | ||
| 73 | // The forms `parseEnv` treats as an assignment: leading whitespace, an | ||
| 74 | // optional `export`, and whitespace around `=`. | ||
| 75 | const assignment = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/u; | ||
| 76 | const pending = new Map(Object.entries(entries)); | ||
| 77 | const out: string[] = []; | ||
| 78 | for (let index = 0; index < lines.length; index += 1) { | ||
| 79 | const line = lines[index]!; | ||
| 80 | const match = assignment.exec(line); | ||
| 81 | const name = match?.[1]; | ||
| 82 | if (match === null || name === undefined || !(name in entries)) { | ||
| 83 | out.push(line); | ||
| 84 | continue; | ||
| 85 | } | ||
| 86 | // A quoted value can span lines; skip to its closing quote so the | ||
| 87 | // continuation lines go with the assignment they belong to. With no | ||
| 88 | // closing quote in the file, parseEnv treats the opening line as the | ||
| 89 | // whole value, so nothing after it is consumed. | ||
| 90 | const rawValue = match[2] ?? ""; | ||
| 91 | const quote = /^(['"`])/u.exec(rawValue)?.[1]; | ||
| 92 | if (quote !== undefined && !closesQuote(rawValue, quote)) { | ||
| 93 | const closing = lines.findIndex((candidate, at) => at > index && candidate.includes(quote)); | ||
| 94 | if (closing !== -1) index = closing; | ||
| 95 | } | ||
| 96 | // The first occurrence keeps its position; later duplicates are dropped. | ||
| 97 | const value = pending.get(name); | ||
| 98 | if (value !== undefined) { | ||
| 99 | out.push(`${name}=${value}`); | ||
| 100 | pending.delete(name); | ||
| 101 | } | ||
| 102 | } | ||
| 103 | for (const [name, value] of pending) out.push(`${name}=${value}`); | ||
| 104 | return out.length === 0 ? "" : `${out.join("\n")}\n`; | ||
| 105 | } | ||
| 106 | |||
| 107 | /** Whether a value that opens with `quote` also closes on the same line. */ | ||
| 108 | const closesQuote = (value: string, quote: string): boolean => | ||
| 109 | value.length > 1 && value.slice(1).includes(quote); | ||
| 110 | |||
| 111 | /** | ||
| 112 | * Writes the relay's client configuration into the repo-root `.env` so the | ||
| 113 | * web, desktop, and mobile dev servers build against the stage just deployed. | ||
| 114 | * An Action rather than post-deploy scripting: it takes the stack outputs as | ||
| 115 | * input, so it runs only when one of them changed and is skipped on a no-op | ||
| 116 | * deploy. Set `T3CODE_RELAY_CLIENT_CONFIG_ENV` to write elsewhere (CI does). | ||
| 117 | */ | ||
| 118 | export const tokenDigest = (tokens: ReadonlyArray<Redacted.Redacted<string>>): string => | ||
| 119 | NodeCrypto.createHash("sha256").update(tokens.map(Redacted.value).join("\n")).digest("hex"); | ||
| 120 | |||
| 121 | export const PublishClientConfig = Alchemy.Action( | ||
| 122 | "PublishClientConfig", | ||
| 123 | Effect.gen(function* () { | ||
| 124 | const fs = yield* FileSystem.FileSystem; | ||
| 125 | const path = yield* Path.Path; | ||
| 126 | const override = yield* Config.String("T3CODE_RELAY_CLIENT_CONFIG_ENV").pipe(Config.option); | ||
| 127 | const repoRootEnv = path.fromFileUrl(new URL("../../../.env", import.meta.url)); | ||
| 128 | const target = Option.isSome(override) ? override.value : yield* repoRootEnv; | ||
| 129 | return Effect.fn(function* (input: RelayClientConfig) { | ||
| 130 | const url = input.url; | ||
| 131 | if (url === undefined) return yield* new RelayUrlUnavailableError(); | ||
| 132 | const entries = relayClientConfigEnv({ ...input, url }); | ||
| 133 | // Provider responses are copied in verbatim; a line break in one would | ||
| 134 | // become an extra assignment. | ||
| 135 | for (const [name, value] of Object.entries(entries)) { | ||
| 136 | if (/[\r\n]/u.test(value)) return yield* new EnvValueNotSingleLineError({ name }); | ||
| 137 | } | ||
| 138 | const existing = (yield* fs.exists(target)) ? yield* fs.readFileString(target) : ""; | ||
| 139 | yield* fs.writeFileString(target, reconcileEnvFile(existing, entries)); | ||
| 140 | yield* Console.log(`Wrote relay client configuration to ${target}`); | ||
| 141 | return { path: target }; | ||
| 142 | }); | ||
| 143 | }), | ||
| 144 | ); | ||