| ... | ... | @@ -19,21 +19,65 @@ import { PlatformServices } from "alchemy/Util/PlatformServices"; |
| 19 | 19 | import * as Config from "effect/Config"; |
| 20 | 20 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 21 | 21 | import * as Console from "effect/Console"; |
| 22 | | import * as Data from "effect/Data"; |
| 23 | 22 | import * as Effect from "effect/Effect"; |
| 24 | 23 | import * as FileSystem from "effect/FileSystem"; |
| 25 | 24 | import * as Layer from "effect/Layer"; |
| 26 | 25 | import * as Option from "effect/Option"; |
| 27 | 26 | import * as Path from "effect/Path"; |
| 28 | 27 | import * as Redacted from "effect/Redacted"; |
| 28 | import * as Schema from "effect/Schema"; |
| 29 | 29 | import { Command, Flag, Prompt } from "effect/unstable/cli"; |
| 30 | 30 | import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; |
| 31 | 31 | |
| 32 | 32 | import RelayStack from "../alchemy.run.ts"; |
| 33 | 33 | |
| 34 | | export class RelayDeployError extends Data.TaggedError("RelayDeployError")<{ |
| 35 | | readonly message: string; |
| 36 | | }> {} |
| 34 | const relayDeployOutputFields = [ |
| 35 | "url", |
| 36 | "mobileTracingUrl", |
| 37 | "mobileTracingDataset", |
| 38 | "mobileTracingToken", |
| 39 | "clientTracingUrl", |
| 40 | "clientTracingDataset", |
| 41 | "clientTracingToken", |
| 42 | ] as const; |
| 43 | |
| 44 | export const RelayDeployOutputField = Schema.Literals(relayDeployOutputFields); |
| 45 | export type RelayDeployOutputField = typeof RelayDeployOutputField.Type; |
| 46 | |
| 47 | export const RelayDeployResult = Schema.Literals([ |
| 48 | "applied", |
| 49 | "noop", |
| 50 | "dry-run", |
| 51 | "cancelled", |
| 52 | "state", |
| 53 | ]); |
| 54 | export type RelayDeployResult = typeof RelayDeployResult.Type; |
| 55 | |
| 56 | export class RelayDeployError extends Schema.TaggedErrorClass<RelayDeployError>()( |
| 57 | "RelayDeployError", |
| 58 | { |
| 59 | source: Schema.Literals(["alchemy_state", "alchemy_apply"]), |
| 60 | stage: Schema.String, |
| 61 | missingFields: Schema.Array(RelayDeployOutputField), |
| 62 | }, |
| 63 | ) { |
| 64 | override get message(): string { |
| 65 | return `Relay deploy output from '${this.source}' for stage '${this.stage}' is missing required public config fields: ${this.missingFields.join(", ")}`; |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | export class RelayDeployPublicConfigUnavailableError extends Schema.TaggedErrorClass<RelayDeployPublicConfigUnavailableError>()( |
| 70 | "RelayDeployPublicConfigUnavailableError", |
| 71 | { |
| 72 | result: RelayDeployResult, |
| 73 | stage: Schema.String, |
| 74 | outputPath: Schema.String, |
| 75 | }, |
| 76 | ) { |
| 77 | override get message(): string { |
| 78 | return `Relay deploy result '${this.result}' for stage '${this.stage}' did not produce public config required by GitHub environment output '${this.outputPath}'.`; |
| 79 | } |
| 80 | } |
| 37 | 81 | |
| 38 | 82 | export interface RelayDeployOptions { |
| 39 | 83 | readonly dryRun: boolean; |
| ... | ... | @@ -112,8 +156,6 @@ export function hasDeployChanges(plan: Plan.Plan): boolean { |
| 112 | 156 | ); |
| 113 | 157 | } |
| 114 | 158 | |
| 115 | | export type RelayDeployResult = "applied" | "noop" | "dry-run" | "cancelled" | "state"; |
| 116 | | |
| 117 | 159 | export interface RelayDeployOutcome { |
| 118 | 160 | readonly result: RelayDeployResult; |
| 119 | 161 | readonly changed: boolean; |
| ... | ... | @@ -199,10 +241,13 @@ const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* |
| 199 | 241 | const writeGithubEnvFile = Effect.fn("relay.deploy.writeGithubEnvFile")(function* ( |
| 200 | 242 | outcome: RelayDeployOutcome, |
| 201 | 243 | outputPath: string, |
| 244 | stage: string, |
| 202 | 245 | ) { |
| 203 | 246 | if (Option.isNone(outcome.publicConfig)) { |
| 204 | | return yield* new RelayDeployError({ |
| 205 | | message: "Relay public client config is unavailable for the GitHub environment file", |
| 247 | return yield* new RelayDeployPublicConfigUnavailableError({ |
| 248 | result: outcome.result, |
| 249 | stage, |
| 250 | outputPath, |
| 206 | 251 | }); |
| 207 | 252 | } |
| 208 | 253 | const fs = yield* FileSystem.FileSystem; |
| ... | ... | @@ -224,44 +269,71 @@ const deployBaseServices = Layer.mergeAll( |
| 224 | 269 | ); |
| 225 | 270 | const deployServices = deployBaseServices; |
| 226 | 271 | |
| 227 | | export function publicConfigFromOutput(output: unknown): RelayPublicConfig | null { |
| 272 | function relayPublicConfigValues( |
| 273 | output: unknown, |
| 274 | ): Readonly<Record<RelayDeployOutputField, string | undefined>> { |
| 228 | 275 | if (typeof output !== "object" || output === null) { |
| 229 | | return null; |
| 276 | return { |
| 277 | url: undefined, |
| 278 | mobileTracingUrl: undefined, |
| 279 | mobileTracingDataset: undefined, |
| 280 | mobileTracingToken: undefined, |
| 281 | clientTracingUrl: undefined, |
| 282 | clientTracingDataset: undefined, |
| 283 | clientTracingToken: undefined, |
| 284 | }; |
| 230 | 285 | } |
| 231 | 286 | const value = output as Record<string, unknown>; |
| 232 | | const text = (name: string) => (typeof value[name] === "string" ? value[name] : undefined); |
| 287 | const text = (name: string) => { |
| 288 | const candidate = value[name]; |
| 289 | return typeof candidate === "string" && candidate.length > 0 ? candidate : undefined; |
| 290 | }; |
| 233 | 291 | const secret = (name: string): string | undefined => { |
| 234 | 292 | const candidate = value[name]; |
| 235 | 293 | if (!Redacted.isRedacted(candidate)) { |
| 236 | 294 | return text(name); |
| 237 | 295 | } |
| 238 | 296 | const redacted = Redacted.value(candidate); |
| 239 | | return typeof redacted === "string" ? redacted : undefined; |
| 297 | return typeof redacted === "string" && redacted.length > 0 ? redacted : undefined; |
| 298 | }; |
| 299 | return { |
| 300 | url: text("url"), |
| 301 | mobileTracingUrl: text("mobileTracingUrl"), |
| 302 | mobileTracingDataset: text("mobileTracingDataset"), |
| 303 | mobileTracingToken: secret("mobileTracingToken"), |
| 304 | clientTracingUrl: text("clientTracingUrl"), |
| 305 | clientTracingDataset: text("clientTracingDataset"), |
| 306 | clientTracingToken: secret("clientTracingToken"), |
| 307 | }; |
| 308 | } |
| 309 | |
| 310 | export function missingRelayPublicConfigFields( |
| 311 | output: unknown, |
| 312 | ): ReadonlyArray<RelayDeployOutputField> { |
| 313 | const values = relayPublicConfigValues(output); |
| 314 | return relayDeployOutputFields.filter((field) => values[field] === undefined); |
| 315 | } |
| 316 | |
| 317 | function hasCompleteRelayPublicConfigValues( |
| 318 | values: Readonly<Record<RelayDeployOutputField, string | undefined>>, |
| 319 | ): values is Readonly<Record<RelayDeployOutputField, string>> { |
| 320 | return relayDeployOutputFields.every((field) => values[field] !== undefined); |
| 321 | } |
| 322 | |
| 323 | export function publicConfigFromOutput(output: unknown): RelayPublicConfig | null { |
| 324 | const values = relayPublicConfigValues(output); |
| 325 | if (!hasCompleteRelayPublicConfigValues(values)) { |
| 326 | return null; |
| 327 | } |
| 328 | return { |
| 329 | relayUrl: values.url, |
| 330 | mobileTracingUrl: values.mobileTracingUrl, |
| 331 | mobileTracingDataset: values.mobileTracingDataset, |
| 332 | mobileTracingToken: values.mobileTracingToken, |
| 333 | clientTracingUrl: values.clientTracingUrl, |
| 334 | clientTracingDataset: values.clientTracingDataset, |
| 335 | clientTracingToken: values.clientTracingToken, |
| 240 | 336 | }; |
| 241 | | const relayUrl = text("url"); |
| 242 | | const mobileTracingUrl = text("mobileTracingUrl"); |
| 243 | | const mobileTracingDataset = text("mobileTracingDataset"); |
| 244 | | const mobileTracingToken = secret("mobileTracingToken"); |
| 245 | | const clientTracingUrl = text("clientTracingUrl"); |
| 246 | | const clientTracingDataset = text("clientTracingDataset"); |
| 247 | | const clientTracingToken = secret("clientTracingToken"); |
| 248 | | return relayUrl && |
| 249 | | mobileTracingUrl && |
| 250 | | mobileTracingDataset && |
| 251 | | mobileTracingToken && |
| 252 | | clientTracingUrl && |
| 253 | | clientTracingDataset && |
| 254 | | clientTracingToken |
| 255 | | ? { |
| 256 | | relayUrl, |
| 257 | | mobileTracingUrl, |
| 258 | | mobileTracingDataset, |
| 259 | | mobileTracingToken, |
| 260 | | clientTracingUrl, |
| 261 | | clientTracingDataset, |
| 262 | | clientTracingToken, |
| 263 | | } |
| 264 | | : null; |
| 265 | 337 | } |
| 266 | 338 | |
| 267 | 339 | const readRelayPublicConfig = Effect.fn("relay.deploy.readState")(function* (stage: string) { |
| ... | ... | @@ -271,7 +343,9 @@ const readRelayPublicConfig = Effect.fn("relay.deploy.readState")(function* (sta |
| 271 | 343 | const publicConfig = publicConfigFromOutput(output); |
| 272 | 344 | if (publicConfig === null) { |
| 273 | 345 | return yield* new RelayDeployError({ |
| 274 | | message: `Alchemy relay state for stage ${stage} did not include complete public client config`, |
| 346 | source: "alchemy_state", |
| 347 | stage, |
| 348 | missingFields: missingRelayPublicConfigFields(output), |
| 275 | 349 | }); |
| 276 | 350 | } |
| 277 | 351 | return { |
| ... | ... | @@ -285,7 +359,7 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")( |
| 285 | 359 | function* ( |
| 286 | 360 | options: RelayDeployOptions, |
| 287 | 361 | _configProvider: ConfigProvider.ConfigProvider, |
| 288 | | _stage: string, |
| 362 | stage: string, |
| 289 | 363 | ) { |
| 290 | 364 | const stack = yield* RelayStack; |
| 291 | 365 | const cli = yield* Cli; |
| ... | ... | @@ -318,31 +392,18 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")( |
| 318 | 392 | } |
| 319 | 393 | } |
| 320 | 394 | const output = yield* Apply.apply(plan).pipe(Effect.provide(stack.services)); |
| 321 | | if ( |
| 322 | | output.url === undefined || |
| 323 | | output.mobileTracingUrl === undefined || |
| 324 | | output.mobileTracingDataset === undefined || |
| 325 | | output.mobileTracingToken === undefined || |
| 326 | | output.clientTracingUrl === undefined || |
| 327 | | output.clientTracingDataset === undefined || |
| 328 | | output.clientTracingToken === undefined |
| 329 | | ) { |
| 395 | const publicConfig = publicConfigFromOutput(output); |
| 396 | if (publicConfig === null) { |
| 330 | 397 | return yield* new RelayDeployError({ |
| 331 | | message: "Alchemy relay deploy output did not include complete public client config", |
| 398 | source: "alchemy_apply", |
| 399 | stage, |
| 400 | missingFields: missingRelayPublicConfigFields(output), |
| 332 | 401 | }); |
| 333 | 402 | } |
| 334 | 403 | return { |
| 335 | 404 | result: changed ? "applied" : "noop", |
| 336 | 405 | changed, |
| 337 | | publicConfig: Option.some({ |
| 338 | | relayUrl: output.url, |
| 339 | | mobileTracingUrl: output.mobileTracingUrl, |
| 340 | | mobileTracingDataset: output.mobileTracingDataset, |
| 341 | | mobileTracingToken: Redacted.value(output.mobileTracingToken), |
| 342 | | clientTracingUrl: output.clientTracingUrl, |
| 343 | | clientTracingDataset: output.clientTracingDataset, |
| 344 | | clientTracingToken: Redacted.value(output.clientTracingToken), |
| 345 | | }), |
| 406 | publicConfig: Option.some(publicConfig), |
| 346 | 407 | } satisfies RelayDeployOutcome; |
| 347 | 408 | }, |
| 348 | 409 | (effect, options, configProvider, stage) => |
| ... | ... | @@ -379,7 +440,7 @@ export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployO |
| 379 | 440 | yield* writeGithubOutput(outcome); |
| 380 | 441 | } |
| 381 | 442 | if (Option.isSome(options.githubEnvFile)) { |
| 382 | | yield* writeGithubEnvFile(outcome, options.githubEnvFile.value); |
| 443 | yield* writeGithubEnvFile(outcome, options.githubEnvFile.value, stage); |
| 383 | 444 | } |
| 384 | 445 | }); |
| 385 | 446 | |