| ... | @@ -19,21 +19,65 @@ import { PlatformServices } from "alchemy/Util/PlatformServices"; | ... | @@ -19,21 +19,65 @@ import { PlatformServices } from "alchemy/Util/PlatformServices"; |
| 19 | import * as Config from "effect/Config"; | 19 | import * as Config from "effect/Config"; |
| 20 | import * as ConfigProvider from "effect/ConfigProvider"; | 20 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 21 | import * as Console from "effect/Console"; | 21 | import * as Console from "effect/Console"; |
| 22 | import * as Data from "effect/Data"; | | |
| 23 | import * as Effect from "effect/Effect"; | 22 | import * as Effect from "effect/Effect"; |
| 24 | import * as FileSystem from "effect/FileSystem"; | 23 | import * as FileSystem from "effect/FileSystem"; |
| 25 | import * as Layer from "effect/Layer"; | 24 | import * as Layer from "effect/Layer"; |
| 26 | import * as Option from "effect/Option"; | 25 | import * as Option from "effect/Option"; |
| 27 | import * as Path from "effect/Path"; | 26 | import * as Path from "effect/Path"; |
| 28 | import * as Redacted from "effect/Redacted"; | 27 | import * as Redacted from "effect/Redacted"; |
| | 28 | import * as Schema from "effect/Schema"; |
| 29 | import { Command, Flag, Prompt } from "effect/unstable/cli"; | 29 | import { Command, Flag, Prompt } from "effect/unstable/cli"; |
| 30 | import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; | 30 | import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"; |
| 31 | | 31 | |
| 32 | import RelayStack from "../alchemy.run.ts"; | 32 | import RelayStack from "../alchemy.run.ts"; |
| 33 | | 33 | |
| 34 | export class RelayDeployError extends Data.TaggedError("RelayDeployError")<{ | 34 | const relayDeployOutputFields = [ |
| 35 | readonly message: string; | 35 | "url", |
| 36 | }> {} | 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 | export interface RelayDeployOptions { | 82 | export interface RelayDeployOptions { |
| 39 | readonly dryRun: boolean; | 83 | readonly dryRun: boolean; |
| ... | @@ -112,8 +156,6 @@ export function hasDeployChanges(plan: Plan.Plan): 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 | export interface RelayDeployOutcome { | 159 | export interface RelayDeployOutcome { |
| 118 | readonly result: RelayDeployResult; | 160 | readonly result: RelayDeployResult; |
| 119 | readonly changed: boolean; | 161 | readonly changed: boolean; |
| ... | @@ -199,10 +241,13 @@ const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* | ... | @@ -199,10 +241,13 @@ const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* |
| 199 | const writeGithubEnvFile = Effect.fn("relay.deploy.writeGithubEnvFile")(function* ( | 241 | const writeGithubEnvFile = Effect.fn("relay.deploy.writeGithubEnvFile")(function* ( |
| 200 | outcome: RelayDeployOutcome, | 242 | outcome: RelayDeployOutcome, |
| 201 | outputPath: string, | 243 | outputPath: string, |
| | 244 | stage: string, |
| 202 | ) { | 245 | ) { |
| 203 | if (Option.isNone(outcome.publicConfig)) { | 246 | if (Option.isNone(outcome.publicConfig)) { |
| 204 | return yield* new RelayDeployError({ | 247 | return yield* new RelayDeployPublicConfigUnavailableError({ |
| 205 | message: "Relay public client config is unavailable for the GitHub environment file", | 248 | result: outcome.result, |
| | 249 | stage, |
| | 250 | outputPath, |
| 206 | }); | 251 | }); |
| 207 | } | 252 | } |
| 208 | const fs = yield* FileSystem.FileSystem; | 253 | const fs = yield* FileSystem.FileSystem; |
| ... | @@ -224,44 +269,71 @@ const deployBaseServices = Layer.mergeAll( | ... | @@ -224,44 +269,71 @@ const deployBaseServices = Layer.mergeAll( |
| 224 | ); | 269 | ); |
| 225 | const deployServices = deployBaseServices; | 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 | if (typeof output !== "object" || output === null) { | 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 | const value = output as Record<string, unknown>; | 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 | const secret = (name: string): string | undefined => { | 291 | const secret = (name: string): string | undefined => { |
| 234 | const candidate = value[name]; | 292 | const candidate = value[name]; |
| 235 | if (!Redacted.isRedacted(candidate)) { | 293 | if (!Redacted.isRedacted(candidate)) { |
| 236 | return text(name); | 294 | return text(name); |
| 237 | } | 295 | } |
| 238 | const redacted = Redacted.value(candidate); | 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 | const readRelayPublicConfig = Effect.fn("relay.deploy.readState")(function* (stage: string) { | 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,7 +343,9 @@ const readRelayPublicConfig = Effect.fn("relay.deploy.readState")(function* (sta |
| 271 | const publicConfig = publicConfigFromOutput(output); | 343 | const publicConfig = publicConfigFromOutput(output); |
| 272 | if (publicConfig === null) { | 344 | if (publicConfig === null) { |
| 273 | return yield* new RelayDeployError({ | 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 | return { | 351 | return { |
| ... | @@ -285,7 +359,7 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")( | ... | @@ -285,7 +359,7 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")( |
| 285 | function* ( | 359 | function* ( |
| 286 | options: RelayDeployOptions, | 360 | options: RelayDeployOptions, |
| 287 | _configProvider: ConfigProvider.ConfigProvider, | 361 | _configProvider: ConfigProvider.ConfigProvider, |
| 288 | _stage: string, | 362 | stage: string, |
| 289 | ) { | 363 | ) { |
| 290 | const stack = yield* RelayStack; | 364 | const stack = yield* RelayStack; |
| 291 | const cli = yield* Cli; | 365 | const cli = yield* Cli; |
| ... | @@ -318,31 +392,18 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")( | ... | @@ -318,31 +392,18 @@ const runRelayDeploy = Effect.fn("relay.deploy.run")( |
| 318 | } | 392 | } |
| 319 | } | 393 | } |
| 320 | const output = yield* Apply.apply(plan).pipe(Effect.provide(stack.services)); | 394 | const output = yield* Apply.apply(plan).pipe(Effect.provide(stack.services)); |
| 321 | if ( | 395 | const publicConfig = publicConfigFromOutput(output); |
| 322 | output.url === undefined || | 396 | if (publicConfig === null) { |
| 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 | ) { | | |
| 330 | return yield* new RelayDeployError({ | 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 | return { | 403 | return { |
| 335 | result: changed ? "applied" : "noop", | 404 | result: changed ? "applied" : "noop", |
| 336 | changed, | 405 | changed, |
| 337 | publicConfig: Option.some({ | 406 | publicConfig: Option.some(publicConfig), |
| 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 | }), | | |
| 346 | } satisfies RelayDeployOutcome; | 407 | } satisfies RelayDeployOutcome; |
| 347 | }, | 408 | }, |
| 348 | (effect, options, configProvider, stage) => | 409 | (effect, options, configProvider, stage) => |
| ... | @@ -379,7 +440,7 @@ export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployO | ... | @@ -379,7 +440,7 @@ export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployO |
| 379 | yield* writeGithubOutput(outcome); | 440 | yield* writeGithubOutput(outcome); |
| 380 | } | 441 | } |
| 381 | if (Option.isSome(options.githubEnvFile)) { | 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 | |