mirror of
https://github.com/VibedByKaKi/t3-code-android-nightly.git
synced 2026-10-11 21:01:16 +02:00
474 lines
15 KiB
TypeScript
474 lines
15 KiB
TypeScript
import { RailwayAuth } from "@/Railway/AuthProvider.ts";
|
|
import { fromAuthProvider } from "@/Railway/Credentials.ts";
|
|
import { Query } from "@distilled.cloud/core/query";
|
|
import { GraphQLLive, Railway as RailwayApi } from "@distilled.cloud/railway";
|
|
import * as Alchemy from "@/index.ts";
|
|
import * as Provider from "@/Provider";
|
|
import * as Railway from "@/Railway";
|
|
import { suitePartition } from "./suiteProject.ts";
|
|
import { waitUntilVolumeGone } from "./waitUntilVolumeGone.ts";
|
|
import * as Test from "@/Test/Alchemy";
|
|
import { describe, expect } from "alchemy-test";
|
|
import * as Data from "effect/Data";
|
|
import * as Effect from "effect/Effect";
|
|
import * as Layer from "effect/Layer";
|
|
import { MinimumLogLevel } from "effect/References";
|
|
import * as Schedule from "effect/Schedule";
|
|
import * as HttpClient from "effect/http/HttpClient";
|
|
import MySQLApi, { Db, Site } from "./fixtures/mysql-api.ts";
|
|
|
|
const { test } = Test.make({
|
|
providers: Railway.providers(),
|
|
});
|
|
|
|
const logLevel = Effect.provideService(
|
|
MinimumLogLevel,
|
|
process.env.DEBUG ? "Debug" : "Info",
|
|
);
|
|
|
|
const distilled = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
effect.pipe(
|
|
Effect.provide(
|
|
Layer.merge(
|
|
GraphQLLive,
|
|
fromAuthProvider().pipe(Layer.provide(RailwayAuth)),
|
|
),
|
|
),
|
|
);
|
|
|
|
const firstOk = (rows: unknown): unknown => {
|
|
if (!Array.isArray(rows) || rows[0] == null || typeof rows[0] !== "object") {
|
|
return undefined;
|
|
}
|
|
return (rows[0] as { ok?: unknown }).ok;
|
|
};
|
|
|
|
const selectOne = (url: string) =>
|
|
Effect.tryPromise({
|
|
try: async () => {
|
|
const mysql = await import("mysql2/promise");
|
|
const connection = await mysql.createConnection({
|
|
uri: url,
|
|
connectTimeout: 5_000,
|
|
});
|
|
try {
|
|
const [rows] = await connection.query({
|
|
sql: "select 1 as ok",
|
|
timeout: 5_000,
|
|
});
|
|
return rows;
|
|
} finally {
|
|
connection.destroy();
|
|
}
|
|
},
|
|
catch: (cause) => new Error(String(cause)),
|
|
// Four attempts permit at most 40 seconds of connection/query work
|
|
// and six seconds of backoff; cleanup never waits on a stuck server.
|
|
}).pipe(Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 3 }));
|
|
|
|
const readVariables = Query.fn(
|
|
(projectId: string, environmentId: string, serviceId: string) =>
|
|
RailwayApi.variables({
|
|
projectId,
|
|
environmentId,
|
|
serviceId,
|
|
unrendered: true,
|
|
}),
|
|
);
|
|
|
|
const readServiceDeletedAt = Query.fn((id: string) => ({
|
|
deletedAt: RailwayApi.service({ id }).deletedAt,
|
|
}));
|
|
|
|
const readService = Query.fn((id: string) => {
|
|
const service = RailwayApi.service({ id });
|
|
return {
|
|
id: service.id,
|
|
name: service.name,
|
|
projectId: service.projectId,
|
|
deletedAt: service.deletedAt,
|
|
};
|
|
});
|
|
|
|
const readServiceInstance = Query.fn(
|
|
(environmentId: string, serviceId: string) => {
|
|
const instance = RailwayApi.serviceInstance({ environmentId, serviceId });
|
|
return {
|
|
serviceId: instance.serviceId,
|
|
image: instance.source.image,
|
|
startCommand: instance.startCommand,
|
|
};
|
|
},
|
|
);
|
|
|
|
const readVolumeInstance = Query.fn((id: string) => {
|
|
const volume = RailwayApi.volumeInstance({ id });
|
|
return {
|
|
id: volume.id,
|
|
volumeId: volume.volumeId,
|
|
mountPath: volume.mountPath,
|
|
serviceId: volume.serviceId,
|
|
};
|
|
});
|
|
|
|
const readTcpProxies = Query.fn((environmentId: string, serviceId: string) =>
|
|
RailwayApi.tcpProxies({ environmentId, serviceId }).pipe(
|
|
Query.map((proxy) => ({
|
|
id: proxy.id,
|
|
domain: proxy.domain,
|
|
proxyPort: proxy.proxyPort,
|
|
applicationPort: proxy.applicationPort,
|
|
deletedAt: proxy.deletedAt,
|
|
syncStatus: proxy.syncStatus,
|
|
})),
|
|
),
|
|
);
|
|
|
|
const asVariableMap = (value: unknown): Record<string, string> => {
|
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
return {};
|
|
}
|
|
const out: Record<string, string> = {};
|
|
for (const [key, item] of Object.entries(value)) {
|
|
if (typeof item === "string") {
|
|
out[key] = item;
|
|
}
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const readServiceVariables = (
|
|
projectId: string,
|
|
environmentId: string,
|
|
serviceId: string,
|
|
) =>
|
|
readVariables(projectId, environmentId, serviceId).pipe(
|
|
Effect.map(asVariableMap),
|
|
Effect.catchTag("RailwayNotFound", () =>
|
|
Effect.succeed({} as Record<string, string>),
|
|
),
|
|
);
|
|
|
|
const waitUntilServiceGone = (serviceId: string) =>
|
|
readServiceDeletedAt(serviceId).pipe(
|
|
Effect.map((service) =>
|
|
service.deletedAt != null ? ("gone" as const) : ("found" as const),
|
|
),
|
|
Effect.catchTag("RailwayNotFound", () => Effect.succeed("gone" as const)),
|
|
Effect.repeat({
|
|
schedule: Schedule.spaced("1 second"),
|
|
until: (status) => status === "gone",
|
|
times: 10,
|
|
}),
|
|
);
|
|
|
|
class NotReady extends Data.TaggedError("NotReady")<{
|
|
status: number;
|
|
body?: unknown;
|
|
}> {
|
|
override get message() {
|
|
return this.body === undefined
|
|
? `status ${this.status}`
|
|
: `status ${this.status}: ${JSON.stringify(this.body)}`;
|
|
}
|
|
}
|
|
|
|
const FixtureStack = Alchemy.Stack(
|
|
"RailwayMySQLFixture",
|
|
{
|
|
providers: Railway.providers(),
|
|
state: Alchemy.localState(),
|
|
},
|
|
Effect.gen(function* () {
|
|
const project = yield* Site;
|
|
const db = yield* Db;
|
|
const api = yield* MySQLApi;
|
|
return {
|
|
projectId: project.projectId,
|
|
environmentId: db.environmentId,
|
|
serviceId: api.serviceId,
|
|
url: api.url,
|
|
publicConnectionUri: db.publicConnectionUri,
|
|
mode: "effect" as const,
|
|
};
|
|
}),
|
|
);
|
|
|
|
test.provider(
|
|
"create, select 1, update, list, and delete mysql",
|
|
(stack) =>
|
|
Effect.gen(function* () {
|
|
yield* stack.destroy();
|
|
|
|
const created = yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
const { project, environment } = yield* suitePartition;
|
|
const db = yield* Railway.MySQL("Db", { project, environment });
|
|
return { project, environment, db };
|
|
}),
|
|
);
|
|
|
|
expect(created.db.serviceId).toEqual(expect.any(String));
|
|
expect(created.db.serviceId.length).toBeGreaterThan(0);
|
|
expect(created.db.projectId).toEqual(created.project.projectId);
|
|
expect(created.db.environmentId).toEqual(
|
|
created.environment.environmentId,
|
|
);
|
|
expect(created.db.name).toEqual(expect.any(String));
|
|
expect(created.db.name.length).toBeGreaterThan(0);
|
|
expect(created.db.name.length).toBeLessThanOrEqual(32);
|
|
expect(created.db.name).toMatch(/^[a-z][a-z0-9-]*$/);
|
|
expect(created.db.user).toEqual("root");
|
|
expect(created.db.database).toEqual("railway");
|
|
expect(created.db.volumeId.length).toBeGreaterThan(0);
|
|
expect(created.db.volumeInstanceId.length).toBeGreaterThan(0);
|
|
expect(created.db.tcpProxyId).toEqual(expect.any(String));
|
|
expect(created.db.tcpProxyDomain).toEqual(expect.any(String));
|
|
expect(created.db.tcpProxyDomain).toContain("proxy.rlwy.net");
|
|
expect(created.db.tcpProxyPort).toEqual(expect.any(Number));
|
|
expect(created.db.tcpProxyPort).toBeGreaterThan(0);
|
|
expect(
|
|
created.db.connectionUri.includes(
|
|
`${created.db.name}.railway.internal`,
|
|
),
|
|
).toEqual(true);
|
|
expect(
|
|
created.db.publicConnectionUri.includes(created.db.tcpProxyDomain!),
|
|
).toEqual(true);
|
|
|
|
const fetched = yield* readService(created.db.serviceId);
|
|
expect(fetched.id).toEqual(created.db.serviceId);
|
|
expect(fetched.name).toEqual(created.db.name);
|
|
expect(fetched.projectId).toEqual(created.db.projectId);
|
|
expect(fetched.deletedAt).toBeNull();
|
|
|
|
const instance = yield* readServiceInstance(
|
|
created.db.environmentId,
|
|
created.db.serviceId,
|
|
);
|
|
expect(instance.serviceId).toEqual(created.db.serviceId);
|
|
expect(instance.image).toEqual(expect.stringContaining("mysql"));
|
|
expect(instance.startCommand).toEqual(
|
|
Railway.DEFAULT_MYSQL_START_COMMAND,
|
|
);
|
|
|
|
const volume = yield* readVolumeInstance(created.db.volumeInstanceId);
|
|
expect(volume.id).toEqual(created.db.volumeInstanceId);
|
|
expect(volume.volumeId).toEqual(created.db.volumeId);
|
|
expect(volume.mountPath).toEqual("/var/lib/mysql");
|
|
expect(volume.serviceId).toEqual(created.db.serviceId);
|
|
|
|
const proxies = yield* readTcpProxies(
|
|
created.db.environmentId,
|
|
created.db.serviceId,
|
|
);
|
|
const liveProxy = proxies.find(
|
|
(proxy) => proxy.deletedAt == null && proxy.syncStatus !== "DELETED",
|
|
);
|
|
expect(liveProxy).toBeDefined();
|
|
expect(liveProxy?.id).toEqual(created.db.tcpProxyId);
|
|
expect(liveProxy?.applicationPort).toEqual(3306);
|
|
|
|
const vars = yield* readServiceVariables(
|
|
created.db.projectId,
|
|
created.db.environmentId,
|
|
created.db.serviceId,
|
|
);
|
|
expect(vars.MYSQLHOST).toEqual("${{RAILWAY_PRIVATE_DOMAIN}}");
|
|
expect(vars.MYSQLPORT).toEqual("3306");
|
|
expect(vars.MYSQLUSER).toEqual("root");
|
|
expect((vars.MYSQLPASSWORD ?? "").length).toBeGreaterThan(0);
|
|
expect(vars.MYSQLDATABASE).toEqual("railway");
|
|
expect((vars[Railway.MYSQL_URL_SECRET] ?? "").length).toBeGreaterThan(0);
|
|
expect(
|
|
(vars[Railway.MYSQL_PUBLIC_URL_SECRET] ?? "").length,
|
|
).toBeGreaterThan(0);
|
|
|
|
const provider = yield* Provider.findProvider(Railway.MySQL);
|
|
yield* Effect.logDebug("MySQL test: provider.list started");
|
|
const listed = yield* provider.list();
|
|
yield* Effect.logDebug("MySQL test: provider.list completed");
|
|
const found = listed.find(
|
|
(row) => row.serviceId === created.db.serviceId,
|
|
);
|
|
expect(found).toBeDefined();
|
|
expect(found?.name).toEqual(created.db.name);
|
|
expect(found?.projectId).toEqual(created.db.projectId);
|
|
|
|
yield* Effect.logDebug("MySQL test: SELECT 1 started");
|
|
const rows = yield* selectOne(created.db.publicConnectionUri);
|
|
yield* Effect.logDebug("MySQL test: SELECT 1 completed");
|
|
expect(firstOk(rows)).toEqual(1);
|
|
|
|
const nextName =
|
|
created.db.name.slice(0, -1) +
|
|
(created.db.name.endsWith("z") ? "y" : "z");
|
|
|
|
yield* Effect.logDebug("MySQL test: update started");
|
|
const updated = yield* stack.deploy(
|
|
Effect.gen(function* () {
|
|
const { project, environment } = yield* suitePartition;
|
|
const db = yield* Railway.MySQL("Db", {
|
|
project,
|
|
environment,
|
|
name: nextName,
|
|
});
|
|
return { project, environment, db };
|
|
}),
|
|
);
|
|
|
|
yield* Effect.logDebug("MySQL test: update completed");
|
|
expect(updated.db.serviceId).toEqual(created.db.serviceId);
|
|
expect(updated.db.name).toEqual(nextName);
|
|
expect(updated.db.projectId).toEqual(created.db.projectId);
|
|
expect(updated.db.volumeId).toEqual(created.db.volumeId);
|
|
expect(
|
|
updated.db.connectionUri.includes(`${nextName}.railway.internal`),
|
|
).toEqual(true);
|
|
|
|
const fetchedUpdate = yield* readService(updated.db.serviceId);
|
|
expect(fetchedUpdate.id).toEqual(updated.db.serviceId);
|
|
expect(fetchedUpdate.name).toEqual(nextName);
|
|
|
|
yield* stack.destroy();
|
|
|
|
const gone = yield* waitUntilServiceGone(created.db.serviceId);
|
|
expect(gone).toEqual("gone");
|
|
const volumeGone = yield* waitUntilVolumeGone(
|
|
created.db.volumeInstanceId,
|
|
);
|
|
expect(volumeGone).toEqual("gone");
|
|
}).pipe(logLevel),
|
|
{
|
|
tags: [
|
|
"provider:railway",
|
|
"provider:railway:mysql",
|
|
"provider:railway:project",
|
|
"provider:railway:projectenvironment",
|
|
"provider:railway:service",
|
|
"provider:railway:variable",
|
|
"live",
|
|
],
|
|
timeout: 120_000,
|
|
},
|
|
);
|
|
|
|
// Runtime fixture failures must not prevent the independent resource lifecycle test.
|
|
describe(
|
|
"ConnectMySQL runtime integrations",
|
|
{
|
|
tags: [
|
|
"provider:railway",
|
|
"provider:railway:mysql",
|
|
"provider:railway:project",
|
|
"provider:railway:projectenvironment",
|
|
"provider:railway:service",
|
|
"provider:railway:variable",
|
|
"live",
|
|
],
|
|
},
|
|
() => {
|
|
const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
|
|
providers: Railway.providers(),
|
|
});
|
|
|
|
const fixture = beforeAll(deploy(FixtureStack), {
|
|
timeout: 120_000,
|
|
});
|
|
afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(FixtureStack), {
|
|
timeout: 120_000,
|
|
});
|
|
|
|
test(
|
|
"a Service connects and SELECTs through ConnectMySQL",
|
|
Effect.gen(function* () {
|
|
const out = yield* fixture;
|
|
expect(out.serviceId).toEqual(expect.any(String));
|
|
expect(out.serviceId.length).toBeGreaterThan(0);
|
|
expect(out.url).toEqual(expect.any(String));
|
|
expect(out.url).toContain("up.railway.app");
|
|
|
|
const fetched = yield* distilled(readService(out.serviceId));
|
|
expect(fetched.id).toEqual(out.serviceId);
|
|
expect(fetched.deletedAt).toBeNull();
|
|
|
|
const vars = yield* distilled(
|
|
readServiceVariables(out.projectId, out.environmentId, out.serviceId),
|
|
);
|
|
expect((vars[Railway.MYSQL_URL_SECRET] ?? "").length).toBeGreaterThan(
|
|
0,
|
|
);
|
|
|
|
const client = yield* HttpClient.HttpClient;
|
|
const get = (path: string) =>
|
|
client.get(`${out.url}${path}`).pipe(
|
|
Effect.timeoutOrElse({
|
|
duration: "8 seconds",
|
|
orElse: () => Effect.fail(new NotReady({ status: 0 })),
|
|
}),
|
|
Effect.flatMap((res) =>
|
|
res.status === 200
|
|
? res.json.pipe(
|
|
Effect.mapError(() => new NotReady({ status: res.status })),
|
|
)
|
|
: Effect.fail(new NotReady({ status: res.status })),
|
|
),
|
|
Effect.retry({
|
|
while: (e) =>
|
|
e._tag === "NotReady" &&
|
|
(e.status === 0 ||
|
|
e.status === 404 ||
|
|
e.status === 502 ||
|
|
e.status === 503),
|
|
schedule: Schedule.exponential("500 millis").pipe(
|
|
Schedule.upTo({ duration: "45 seconds" }),
|
|
),
|
|
times: 10,
|
|
}),
|
|
);
|
|
|
|
const getText = client.get(out.url!).pipe(
|
|
Effect.timeoutOrElse({
|
|
duration: "8 seconds",
|
|
orElse: () => Effect.fail(new NotReady({ status: 0 })),
|
|
}),
|
|
Effect.flatMap((res) =>
|
|
res.status === 200
|
|
? res.text.pipe(
|
|
Effect.mapError(() => new NotReady({ status: res.status })),
|
|
)
|
|
: Effect.fail(new NotReady({ status: res.status })),
|
|
),
|
|
Effect.retry({
|
|
while: (e) =>
|
|
e._tag === "NotReady" &&
|
|
(e.status === 0 ||
|
|
e.status === 404 ||
|
|
e.status === 502 ||
|
|
e.status === 503),
|
|
schedule: Schedule.exponential("500 millis").pipe(
|
|
Schedule.upTo({ duration: "45 seconds" }),
|
|
),
|
|
times: 10,
|
|
}),
|
|
);
|
|
|
|
if (out.mode === "effect") {
|
|
const ping = (yield* get("/ping")) as { ok?: boolean };
|
|
expect(ping.ok).toEqual(true);
|
|
|
|
const health = (yield* get("/health")) as { rows?: unknown };
|
|
expect(firstOk(health.rows)).toEqual(1);
|
|
} else {
|
|
const body = yield* getText;
|
|
expect(typeof body).toEqual("string");
|
|
expect(body.length).toBeGreaterThan(0);
|
|
}
|
|
|
|
const rows = yield* selectOne(out.publicConnectionUri);
|
|
expect(firstOk(rows)).toEqual(1);
|
|
}).pipe(logLevel),
|
|
{ timeout: 120_000 },
|
|
);
|
|
},
|
|
);
|