| 1 | import { Query } from "@distilled.cloud/core/query"; |
| 2 | import { Railway as RailwayApi } from "@distilled.cloud/railway"; |
| 3 | import * as Provider from "@/Provider"; |
| 4 | import * as Railway from "@/Railway"; |
| 5 | import { noopSession } from "@/Report"; |
| 6 | import * as Test from "@/Test/Alchemy"; |
| 7 | import { expect } from "alchemy-test"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import { MinimumLogLevel } from "effect/References"; |
| 10 | import * as Schedule from "effect/Schedule"; |
| 11 | import * as Schema from "effect/Schema"; |
| 12 | import { suitePartition } from "./suiteProject.ts"; |
| 13 | |
| 14 | const { test } = Test.make({ providers: Railway.providers() }); |
| 15 | |
| 16 | const logLevel = Effect.provideService( |
| 17 | MinimumLogLevel, |
| 18 | process.env.DEBUG ? "Debug" : "Info", |
| 19 | ); |
| 20 | |
| 21 | const NetworkConfig = Schema.Struct({ |
| 22 | privateNetworkDisabled: Schema.optional(Schema.NullOr(Schema.Boolean)), |
| 23 | services: Schema.optional( |
| 24 | Schema.NullOr( |
| 25 | Schema.Record( |
| 26 | Schema.String, |
| 27 | Schema.NullOr( |
| 28 | Schema.Struct({ |
| 29 | networking: Schema.optional( |
| 30 | Schema.NullOr( |
| 31 | Schema.Struct({ |
| 32 | privateNetworkEndpoint: Schema.optional( |
| 33 | Schema.NullOr(Schema.String), |
| 34 | ), |
| 35 | serviceDomains: Schema.optional( |
| 36 | Schema.NullOr(Schema.Record(Schema.String, Schema.Unknown)), |
| 37 | ), |
| 38 | }), |
| 39 | ), |
| 40 | ), |
| 41 | }), |
| 42 | ), |
| 43 | ), |
| 44 | ), |
| 45 | ), |
| 46 | }); |
| 47 | |
| 48 | const readEnvironmentConfig = Query.fn((id: string) => { |
| 49 | const environment = RailwayApi.environment({ id }); |
| 50 | return { config: environment.config, configEtag: environment.configEtag }; |
| 51 | }); |
| 52 | |
| 53 | const createService = Query.fn( |
| 54 | (input: { |
| 55 | projectId: string; |
| 56 | environmentId: string; |
| 57 | name: string; |
| 58 | image: string; |
| 59 | }) => { |
| 60 | const service = RailwayApi.serviceCreate({ |
| 61 | input: { |
| 62 | projectId: input.projectId, |
| 63 | environmentId: input.environmentId, |
| 64 | name: input.name, |
| 65 | source: { image: input.image }, |
| 66 | }, |
| 67 | }); |
| 68 | return { id: service.id, name: service.name }; |
| 69 | }, |
| 70 | ); |
| 71 | |
| 72 | const deleteService = Query.fn((id: string) => |
| 73 | RailwayApi.serviceDelete({ id }), |
| 74 | ); |
| 75 | |
| 76 | const commitEnvironmentPatch = Query.fn( |
| 77 | (environmentId: string, patch: unknown) => |
| 78 | RailwayApi.environmentPatchCommit({ environmentId, patch }), |
| 79 | ); |
| 80 | |
| 81 | const deployServiceInstance = Query.fn( |
| 82 | (environmentId: string, serviceId: string) => |
| 83 | RailwayApi.serviceInstanceDeploy({ environmentId, serviceId }), |
| 84 | ); |
| 85 | |
| 86 | const readPrivateNetworks = Query.fn((environmentId: string) => |
| 87 | RailwayApi.privateNetworks({ environmentId }).pipe( |
| 88 | Query.map((network) => ({ |
| 89 | publicId: network.publicId, |
| 90 | name: network.name, |
| 91 | dnsName: network.dnsName, |
| 92 | deletedAt: network.deletedAt, |
| 93 | })), |
| 94 | ), |
| 95 | ); |
| 96 | |
| 97 | const readEnvironmentPatches = Query.fn((environmentId: string) => |
| 98 | RailwayApi.environmentPatches({ environmentId, first: 5 }).pipe( |
| 99 | Query.map((patch) => ({ |
| 100 | status: patch.status, |
| 101 | lastAppliedError: patch.lastAppliedError, |
| 102 | })), |
| 103 | ), |
| 104 | ); |
| 105 | |
| 106 | const readPrivateNetworkEndpoint = Query.fn( |
| 107 | (input: { |
| 108 | environmentId: string; |
| 109 | privateNetworkId: string; |
| 110 | serviceId: string; |
| 111 | }) => |
| 112 | RailwayApi.privateNetworkEndpoint(input).pipe( |
| 113 | Query.map((endpoint) => ({ |
| 114 | publicId: endpoint.publicId, |
| 115 | dnsName: endpoint.dnsName, |
| 116 | deletedAt: endpoint.deletedAt, |
| 117 | syncStatus: endpoint.syncStatus, |
| 118 | })), |
| 119 | ), |
| 120 | ); |
| 121 | |
| 122 | const readConfig = Effect.fn(function* (environmentId: string) { |
| 123 | const environment = yield* readEnvironmentConfig(environmentId); |
| 124 | return { |
| 125 | config: yield* Schema.decodeUnknownEffect(NetworkConfig)( |
| 126 | environment.config, |
| 127 | ), |
| 128 | etag: environment.configEtag, |
| 129 | }; |
| 130 | }); |
| 131 | |
| 132 | const lifecycle = (id: string) => ({ |
| 133 | id, |
| 134 | fqn: id, |
| 135 | instanceId: id, |
| 136 | bindings: [], |
| 137 | session: { ...noopSession, note: () => Effect.void }, |
| 138 | }); |
| 139 | |
| 140 | const networkStack = Effect.gen(function* () { |
| 141 | const { project, environment } = yield* suitePartition; |
| 142 | const network = yield* Railway.PrivateNetwork("Mesh", { environment }); |
| 143 | return { project, environment, network }; |
| 144 | }); |
| 145 | |
| 146 | const readEndpoint = (input: { |
| 147 | environmentId: string; |
| 148 | privateNetworkId: string; |
| 149 | serviceId: string; |
| 150 | }) => |
| 151 | readPrivateNetworkEndpoint(input).pipe( |
| 152 | Effect.catchTag("RailwayNotFound", () => Effect.succeed(null)), |
| 153 | Effect.map((endpoint) => |
| 154 | endpoint?.deletedAt == null && |
| 155 | endpoint?.syncStatus !== "DELETED" && |
| 156 | endpoint?.syncStatus !== "DELETING" |
| 157 | ? endpoint |
| 158 | : null, |
| 159 | ), |
| 160 | ); |
| 161 | |
| 162 | const waitForEndpoint = ( |
| 163 | input: Parameters<typeof readEndpoint>[0], |
| 164 | dnsName?: string, |
| 165 | ) => |
| 166 | readEndpoint(input).pipe( |
| 167 | Effect.repeat({ |
| 168 | schedule: Schedule.spaced("2 seconds"), |
| 169 | times: 8, |
| 170 | until: (endpoint) => |
| 171 | endpoint != null && |
| 172 | (dnsName === undefined || endpoint.dnsName === dnsName), |
| 173 | }), |
| 174 | Effect.tap((endpoint) => |
| 175 | Effect.sync(() => { |
| 176 | expect(endpoint).not.toBeNull(); |
| 177 | if (dnsName !== undefined) expect(endpoint?.dnsName).toBe(dnsName); |
| 178 | }), |
| 179 | ), |
| 180 | ); |
| 181 | |
| 182 | const setPrefix = ( |
| 183 | environmentId: string, |
| 184 | serviceId: string, |
| 185 | prefix: string | null, |
| 186 | ) => |
| 187 | commitEnvironmentPatch(environmentId, { |
| 188 | services: { |
| 189 | [serviceId]: { networking: { privateNetworkEndpoint: prefix } }, |
| 190 | }, |
| 191 | }); |
| 192 | |
| 193 | test.provider( |
| 194 | "private network enables the environment and restores its previous disabled flag", |
| 195 | (stack) => |
| 196 | Effect.gen(function* () { |
| 197 | yield* stack.destroy(); |
| 198 | const partition = yield* stack.deploy(suitePartition); |
| 199 | const environmentId = partition.environment.environmentId; |
| 200 | const service = yield* Effect.acquireRelease( |
| 201 | createService({ |
| 202 | projectId: partition.project.projectId, |
| 203 | environmentId, |
| 204 | name: "private-network-disable-fixture", |
| 205 | image: "nginx:alpine", |
| 206 | }), |
| 207 | (service) => |
| 208 | deleteService(service.id).pipe( |
| 209 | Effect.catchTag("RailwayNotFound", () => Effect.void), |
| 210 | Effect.orDie, |
| 211 | ), |
| 212 | ); |
| 213 | const [platformNetwork] = yield* readPrivateNetworks(environmentId); |
| 214 | yield* waitForEndpoint({ |
| 215 | environmentId, |
| 216 | privateNetworkId: platformNetwork!.publicId, |
| 217 | serviceId: service.id, |
| 218 | }); |
| 219 | const disablePatch = yield* commitEnvironmentPatch(environmentId, { |
| 220 | privateNetworkDisabled: true, |
| 221 | }); |
| 222 | yield* Effect.logInfo("Private-network disable commit", { disablePatch }); |
| 223 | const disabled = yield* readConfig(environmentId).pipe( |
| 224 | Effect.repeat({ |
| 225 | schedule: Schedule.spaced("1 second"), |
| 226 | times: 8, |
| 227 | until: ({ config }) => config.privateNetworkDisabled === true, |
| 228 | }), |
| 229 | ); |
| 230 | yield* Effect.logInfo("Private-network disabled state", { |
| 231 | privateNetworkDisabled: disabled.config.privateNetworkDisabled, |
| 232 | patches: yield* readEnvironmentPatches(environmentId), |
| 233 | networks: yield* readPrivateNetworks(environmentId), |
| 234 | }); |
| 235 | expect(disabled.config.privateNetworkDisabled).toBe(true); |
| 236 | |
| 237 | const provider = yield* Provider.findProvider(Railway.PrivateNetwork); |
| 238 | for (const name of ["backend", "legacy-network"]) { |
| 239 | const rejected = yield* provider |
| 240 | .reconcile({ |
| 241 | ...lifecycle("UnsupportedMesh"), |
| 242 | news: { environment: partition.environment, name }, |
| 243 | olds: undefined, |
| 244 | output: undefined, |
| 245 | }) |
| 246 | .pipe(Effect.result); |
| 247 | expect(rejected).toMatchObject({ |
| 248 | _tag: "Failure", |
| 249 | failure: { _tag: "Railway.PrivateNetworkNameUnsupported", name }, |
| 250 | }); |
| 251 | } |
| 252 | expect((yield* readConfig(environmentId)).etag).toBe(disabled.etag); |
| 253 | |
| 254 | const created = yield* stack.deploy(networkStack); |
| 255 | expect(created.network.name).toBe("railway"); |
| 256 | expect(created.network.environmentId).toBe(environmentId); |
| 257 | expect(created.network.projectId).toBe(partition.project.projectId); |
| 258 | expect(created.network.previousPrivateNetworkDisabled).toBe(true); |
| 259 | const enabled = yield* readConfig(environmentId); |
| 260 | expect(enabled.config.privateNetworkDisabled).toBe(false); |
| 261 | const networks = yield* readPrivateNetworks(environmentId); |
| 262 | expect( |
| 263 | networks.find( |
| 264 | (network) => |
| 265 | network.publicId === created.network.publicId && |
| 266 | network.deletedAt == null, |
| 267 | ), |
| 268 | ).toMatchObject({ name: "railway", dnsName: created.network.dnsName }); |
| 269 | |
| 270 | const props = { environment: partition.environment }; |
| 271 | const refreshed = yield* provider.read!({ |
| 272 | ...lifecycle("Mesh"), |
| 273 | olds: props, |
| 274 | output: created.network, |
| 275 | }); |
| 276 | expect(refreshed?.previousPrivateNetworkDisabled).toBe(true); |
| 277 | const repeated = yield* stack.deploy(networkStack); |
| 278 | expect(repeated.network.publicId).toBe(created.network.publicId); |
| 279 | expect(repeated.network.previousPrivateNetworkDisabled).toBe(true); |
| 280 | expect((yield* readConfig(environmentId)).etag).toBe(enabled.etag); |
| 281 | |
| 282 | // Legacy state cannot establish what the environment used to contain. |
| 283 | const { previousPrivateNetworkDisabled: _, ...legacy } = repeated.network; |
| 284 | yield* provider.delete({ |
| 285 | ...lifecycle("Mesh"), |
| 286 | olds: props, |
| 287 | output: legacy, |
| 288 | }); |
| 289 | expect((yield* readConfig(environmentId)).etag).toBe(enabled.etag); |
| 290 | expect( |
| 291 | (yield* readConfig(environmentId)).config.privateNetworkDisabled, |
| 292 | ).toBe(false); |
| 293 | |
| 294 | const retained = yield* stack.deploy(suitePartition); |
| 295 | expect(retained.environment.environmentId).toBe(environmentId); |
| 296 | const restored = yield* readConfig(environmentId); |
| 297 | expect(restored.config.privateNetworkDisabled).toBe(true); |
| 298 | for (let attempt = 0; attempt < 2; attempt++) { |
| 299 | yield* provider.delete({ |
| 300 | ...lifecycle("Mesh"), |
| 301 | olds: props, |
| 302 | output: repeated.network, |
| 303 | }); |
| 304 | } |
| 305 | expect((yield* readConfig(environmentId)).etag).toBe(restored.etag); |
| 306 | }).pipe( |
| 307 | Effect.scoped, |
| 308 | Effect.ensuring(stack.destroy().pipe(Effect.orDie)), |
| 309 | logLevel, |
| 310 | ), |
| 311 | { |
| 312 | tags: [ |
| 313 | "provider:railway", |
| 314 | "provider:railway:privatenetwork", |
| 315 | "provider:railway:project", |
| 316 | "provider:railway:projectenvironment", |
| 317 | "live", |
| 318 | ], |
| 319 | timeout: 120_000, |
| 320 | }, |
| 321 | ); |
| 322 | |
| 323 | test.provider( |
| 324 | "private endpoint updates platform DNS and restores only its own changes", |
| 325 | (stack) => |
| 326 | Effect.gen(function* () { |
| 327 | yield* stack.destroy(); |
| 328 | const base = yield* stack.deploy(networkStack); |
| 329 | const environmentId = base.environment.environmentId; |
| 330 | |
| 331 | yield* Effect.acquireUseRelease( |
| 332 | createService({ |
| 333 | projectId: base.project.projectId, |
| 334 | environmentId, |
| 335 | name: "private-network-fixture", |
| 336 | image: "nginx:alpine", |
| 337 | }), |
| 338 | (service) => |
| 339 | Effect.gen(function* () { |
| 340 | const serviceRef = { serviceId: service.id, name: service.name }; |
| 341 | const input = { |
| 342 | environmentId, |
| 343 | privateNetworkId: base.network.publicId, |
| 344 | serviceId: service.id, |
| 345 | }; |
| 346 | if ((yield* readEndpoint(input)) === null) { |
| 347 | yield* deployServiceInstance(environmentId, service.id); |
| 348 | } |
| 349 | const platformEndpoint = yield* waitForEndpoint(input); |
| 350 | const domainKey = yield* Effect.sync(() => crypto.randomUUID()); |
| 351 | yield* commitEnvironmentPatch(environmentId, { |
| 352 | services: { |
| 353 | [service.id]: { |
| 354 | networking: { |
| 355 | privateNetworkEndpoint: "original", |
| 356 | serviceDomains: { [domainKey]: {} }, |
| 357 | }, |
| 358 | }, |
| 359 | }, |
| 360 | }); |
| 361 | yield* waitForEndpoint(input, "original"); |
| 362 | const baseline = yield* readConfig(environmentId); |
| 363 | const baselineDomains = |
| 364 | baseline.config.services?.[service.id]?.networking |
| 365 | ?.serviceDomains; |
| 366 | expect(baselineDomains).toBeDefined(); |
| 367 | expect(Object.keys(baselineDomains ?? {}).length).toBeGreaterThan( |
| 368 | 0, |
| 369 | ); |
| 370 | |
| 371 | const deployEndpoint = (name?: string) => |
| 372 | stack |
| 373 | .deploy( |
| 374 | Effect.gen(function* () { |
| 375 | const partition = yield* networkStack; |
| 376 | const endpoint = yield* Railway.PrivateNetworkEndpoint( |
| 377 | "ApiDns", |
| 378 | { |
| 379 | network: partition.network, |
| 380 | service: serviceRef, |
| 381 | ...(name === undefined ? {} : { name }), |
| 382 | }, |
| 383 | ); |
| 384 | return { ...partition, endpoint }; |
| 385 | }), |
| 386 | ) |
| 387 | .pipe( |
| 388 | Effect.tapError(() => |
| 389 | readConfig(environmentId).pipe( |
| 390 | Effect.tap(({ config }) => |
| 391 | Effect.logInfo("Endpoint configuration after failure", { |
| 392 | requested: name ?? null, |
| 393 | observed: |
| 394 | config.services?.[service.id]?.networking |
| 395 | ?.privateNetworkEndpoint ?? null, |
| 396 | }), |
| 397 | ), |
| 398 | ), |
| 399 | ), |
| 400 | ); |
| 401 | const assertConfig = (prefix: string | null) => |
| 402 | Effect.gen(function* () { |
| 403 | const { config } = yield* readConfig(environmentId); |
| 404 | const networking = config.services?.[service.id]?.networking; |
| 405 | expect(networking?.privateNetworkEndpoint ?? null).toBe(prefix); |
| 406 | expect(networking?.serviceDomains).toEqual(baselineDomains); |
| 407 | expect(config.privateNetworkDisabled).toBe( |
| 408 | baseline.config.privateNetworkDisabled, |
| 409 | ); |
| 410 | }); |
| 411 | const provider = yield* Provider.findProvider( |
| 412 | Railway.PrivateNetworkEndpoint, |
| 413 | ); |
| 414 | const created = yield* deployEndpoint("api"); |
| 415 | expect(created.endpoint.publicId).toBe(platformEndpoint?.publicId); |
| 416 | expect(created.endpoint.serviceId).toBe(service.id); |
| 417 | expect(created.endpoint.environmentId).toBe(environmentId); |
| 418 | expect(created.endpoint.privateNetworkId).toBe( |
| 419 | base.network.publicId, |
| 420 | ); |
| 421 | expect(created.endpoint.previousDnsPrefix).toBe("original"); |
| 422 | expect(created.endpoint.dnsName).toBe("api"); |
| 423 | yield* waitForEndpoint(input, created.endpoint.dnsName); |
| 424 | yield* assertConfig("api"); |
| 425 | |
| 426 | const props = { |
| 427 | network: created.network, |
| 428 | service: serviceRef, |
| 429 | name: "api", |
| 430 | }; |
| 431 | const refreshed = yield* provider.read!({ |
| 432 | ...lifecycle("ApiDns"), |
| 433 | olds: props, |
| 434 | output: created.endpoint, |
| 435 | }); |
| 436 | expect(refreshed?.previousDnsPrefix).toBe("original"); |
| 437 | const repeated = yield* deployEndpoint("api"); |
| 438 | expect(repeated.endpoint.publicId).toBe(created.endpoint.publicId); |
| 439 | expect(repeated.endpoint.previousDnsPrefix).toBe("original"); |
| 440 | yield* assertConfig("api"); |
| 441 | |
| 442 | const beforeLegacyDelete = yield* readConfig(environmentId); |
| 443 | const { previousDnsPrefix: _, ...legacy } = repeated.endpoint; |
| 444 | const legacyDeletion = yield* provider |
| 445 | .delete({ |
| 446 | ...lifecycle("ApiDns"), |
| 447 | olds: props, |
| 448 | output: legacy, |
| 449 | }) |
| 450 | .pipe(Effect.result); |
| 451 | expect(legacyDeletion).toMatchObject({ |
| 452 | _tag: "Failure", |
| 453 | failure: { |
| 454 | _tag: "Railway.PrivateNetworkEndpointRestoreUnavailable", |
| 455 | }, |
| 456 | }); |
| 457 | expect((yield* readConfig(environmentId)).etag).toBe( |
| 458 | beforeLegacyDelete.etag, |
| 459 | ); |
| 460 | yield* assertConfig("api"); |
| 461 | |
| 462 | const renamed = yield* deployEndpoint("gateway"); |
| 463 | expect(renamed.endpoint.publicId).toBe(created.endpoint.publicId); |
| 464 | expect(renamed.endpoint.previousDnsPrefix).toBe("original"); |
| 465 | expect(renamed.endpoint.dnsName).toBe("gateway"); |
| 466 | yield* waitForEndpoint(input, renamed.endpoint.dnsName); |
| 467 | yield* assertConfig("gateway"); |
| 468 | |
| 469 | const reset = yield* deployEndpoint(); |
| 470 | expect(reset.endpoint.publicId).toBe(created.endpoint.publicId); |
| 471 | expect(reset.endpoint.previousDnsPrefix).toBe("original"); |
| 472 | expect(reset.endpoint.dnsName).toBe(service.name); |
| 473 | yield* waitForEndpoint(input, reset.endpoint.dnsName); |
| 474 | yield* assertConfig(null); |
| 475 | |
| 476 | const retained = yield* stack.deploy(networkStack); |
| 477 | expect(retained.environment.environmentId).toBe(environmentId); |
| 478 | expect(retained.network.publicId).toBe(base.network.publicId); |
| 479 | yield* assertConfig("original"); |
| 480 | const restored = yield* waitForEndpoint(input, "original"); |
| 481 | expect(restored?.publicId).toBe(created.endpoint.publicId); |
| 482 | |
| 483 | const afterRestore = yield* readConfig(environmentId); |
| 484 | for (let attempt = 0; attempt < 2; attempt++) { |
| 485 | yield* provider.delete({ |
| 486 | ...lifecycle("ApiDns"), |
| 487 | olds: { network: reset.network, service: serviceRef }, |
| 488 | output: reset.endpoint, |
| 489 | }); |
| 490 | } |
| 491 | expect((yield* readConfig(environmentId)).etag).toBe( |
| 492 | afterRestore.etag, |
| 493 | ); |
| 494 | yield* assertConfig("original"); |
| 495 | |
| 496 | const managedAgain = yield* deployEndpoint("api"); |
| 497 | expect(managedAgain.endpoint.previousDnsPrefix).toBe("original"); |
| 498 | yield* setPrefix(environmentId, service.id, "external"); |
| 499 | yield* waitForEndpoint(input, "external"); |
| 500 | yield* stack.deploy(networkStack); |
| 501 | yield* assertConfig("external"); |
| 502 | const external = yield* waitForEndpoint(input, "external"); |
| 503 | expect(external?.publicId).toBe(created.endpoint.publicId); |
| 504 | }), |
| 505 | (service) => |
| 506 | deleteService(service.id).pipe( |
| 507 | Effect.catchTag("RailwayNotFound", () => Effect.void), |
| 508 | Effect.orDie, |
| 509 | ), |
| 510 | ); |
| 511 | }).pipe(Effect.ensuring(stack.destroy().pipe(Effect.orDie)), logLevel), |
| 512 | { |
| 513 | tags: [ |
| 514 | "provider:railway", |
| 515 | "provider:railway:privatenetwork", |
| 516 | "provider:railway:project", |
| 517 | "provider:railway:projectenvironment", |
| 518 | "live", |
| 519 | ], |
| 520 | timeout: 120_000, |
| 521 | }, |
| 522 | ); |