diff --git a/web/lib/build-assignment.ts b/web/lib/build-assignment.ts index d31d7e11..afbfe027 100644 --- a/web/lib/build-assignment.ts +++ b/web/lib/build-assignment.ts @@ -1,9 +1,51 @@ -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq, gt, inArray } from "drizzle-orm"; import { db } from "@/db"; import { getSetting } from "@/db/queries"; import { servers, serviceReplicas, services } from "@/db/schema"; import { SETTING_KEYS } from "@/lib/settings-keys"; +type BuildTargetServer = { + id: string; + status: string; + meta: { arch: string }; +}; + +async function getStatefulBuildTargetServer( + serviceId: string, +): Promise { + const targetServers = await db + .select({ id: servers.id, status: servers.status, meta: servers.meta }) + .from(serviceReplicas) + .innerJoin(servers, eq(serviceReplicas.serverId, servers.id)) + .where( + and( + eq(serviceReplicas.serviceId, serviceId), + gt(serviceReplicas.count, 0), + ), + ); + + if (targetServers.length !== 1) { + throw new Error( + "Stateful services must have exactly one active replica server", + ); + } + + const [targetServer] = targetServers; + + if (targetServer.status !== "online") { + throw new Error("Stateful service target server is offline"); + } + + if (!targetServer.meta?.arch) { + throw new Error("Stateful service target server architecture is unknown"); + } + + return { + ...targetServer, + meta: { arch: targetServer.meta.arch }, + }; +} + export async function selectBuildServerForPlatform( serviceId: string, platform: string, @@ -18,27 +60,19 @@ export async function selectBuildServerForPlatform( throw new Error("Service not found"); } - if (service.stateful && service.lockedServerId) { - const server = await db - .select() - .from(servers) - .where( - and( - eq(servers.id, service.lockedServerId), - eq(servers.status, "online"), - ), - ) - .then((r) => r[0]); + const arch = platform.split("/")[1]; - if (!server) { - throw new Error("Locked server is offline"); + if (service.stateful) { + const targetServer = await getStatefulBuildTargetServer(serviceId); + if (targetServer.meta.arch !== arch) { + throw new Error( + `Stateful service target server architecture ${targetServer.meta.arch} does not match platform ${platform}`, + ); } - return server.id; + return targetServer.id; } - const arch = platform.split("/")[1]; - const allowedBuildServerIds = await getSetting( SETTING_KEYS.SERVERS_ALLOWED_FOR_BUILDS, ); @@ -84,13 +118,23 @@ export async function getTargetPlatformsForService( throw new Error("Service not found"); } + if (service.stateful) { + const targetServer = await getStatefulBuildTargetServer(serviceId); + return [`linux/${targetServer.meta.arch}`]; + } + let targetPlatforms: string[] = []; const replicas = await db .select({ meta: servers.meta }) .from(serviceReplicas) .innerJoin(servers, eq(serviceReplicas.serverId, servers.id)) - .where(eq(serviceReplicas.serviceId, service.id)); + .where( + and( + eq(serviceReplicas.serviceId, service.id), + gt(serviceReplicas.count, 0), + ), + ); targetPlatforms = [ ...new Set( diff --git a/web/tests/build-assignment.test.ts b/web/tests/build-assignment.test.ts new file mode 100644 index 00000000..da69c7c8 --- /dev/null +++ b/web/tests/build-assignment.test.ts @@ -0,0 +1,233 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + // Results are consumed in the same order as db.select() calls in each test. + const queryResults: unknown[][] = []; + const queries: Array<{ + from: ReturnType; + innerJoin: ReturnType; + where: ReturnType; + }> = []; + + function createQuery(result: unknown[]) { + const query = { + from: vi.fn(() => query), + innerJoin: vi.fn(() => query), + where: vi.fn(() => query), + then: ( + resolve: (value: unknown[]) => unknown, + reject?: (reason: unknown) => unknown, + ) => + Promise.resolve(result).then(resolve, reject), + }; + + queries.push(query); + return query; + } + + return { + queryResults, + queries, + db: { + select: vi.fn(() => createQuery(queryResults.shift() ?? [])), + }, + getSetting: vi.fn(), + }; +}); + +vi.mock("@/db", () => ({ db: mocks.db })); +vi.mock("@/db/queries", () => ({ getSetting: mocks.getSetting })); + +import { + getTargetPlatformsForService, + selectBuildServerForPlatform, +} from "@/lib/build-assignment"; + +describe("build assignment", () => { + beforeEach(() => { + mocks.queryResults.length = 0; + mocks.queries.length = 0; + mocks.db.select.mockClear(); + mocks.getSetting.mockReset(); + }); + + function sqlTokens(value: unknown): unknown[] { + if (!value || typeof value !== "object") { + return [value]; + } + + const record = value as { + name?: string; + queryChunks?: unknown[]; + value?: unknown; + }; + + if (Array.isArray(record.queryChunks)) { + return record.queryChunks.flatMap(sqlTokens); + } + + if (Array.isArray(record.value)) { + return record.value.flatMap(sqlTokens); + } + + if ("value" in record) { + return [record.value]; + } + + if (record.name) { + return [record.name]; + } + + return []; + } + + function expectActiveReplicaPredicate(queryIndex: number) { + const condition = mocks.queries[queryIndex]?.where.mock.calls[0]?.[0]; + const tokens = sqlTokens(condition); + + expect(tokens).toEqual(expect.arrayContaining(["service_id", "count", 0])); + expect(tokens).toContain(" > "); + } + + it("assigns stateful builds to the active replica server", async () => { + mocks.queryResults.push( + [{ id: "service_1", stateful: true }], + [ + { + id: "server_target", + status: "online", + meta: { arch: "arm64" }, + }, + ], + ); + + await expect( + selectBuildServerForPlatform("service_1", "linux/arm64"), + ).resolves.toBe("server_target"); + expectActiveReplicaPredicate(1); + }); + + it("rejects stateful builds without exactly one active replica server", async () => { + mocks.queryResults.push([{ id: "service_1", stateful: true }], []); + + await expect( + selectBuildServerForPlatform("service_1", "linux/arm64"), + ).rejects.toThrow( + "Stateful services must have exactly one active replica server", + ); + }); + + it("rejects stateful builds with multiple active replica rows", async () => { + mocks.queryResults.push( + [{ id: "service_1", stateful: true }], + [ + { id: "server_1", status: "online", meta: { arch: "arm64" } }, + { id: "server_2", status: "online", meta: { arch: "arm64" } }, + ], + ); + + await expect( + selectBuildServerForPlatform("service_1", "linux/arm64"), + ).rejects.toThrow( + "Stateful services must have exactly one active replica server", + ); + }); + + it("rejects stateful builds when the active replica server is offline", async () => { + mocks.queryResults.push( + [{ id: "service_1", stateful: true }], + [{ id: "server_target", status: "offline", meta: { arch: "arm64" } }], + ); + + await expect( + selectBuildServerForPlatform("service_1", "linux/arm64"), + ).rejects.toThrow("Stateful service target server is offline"); + }); + + it("rejects stateful builds when the active replica arch does not match the requested platform", async () => { + mocks.queryResults.push( + [{ id: "service_1", stateful: true }], + [ + { + id: "server_target", + status: "online", + meta: { arch: "arm64" }, + }, + ], + ); + + await expect( + selectBuildServerForPlatform("service_1", "linux/amd64"), + ).rejects.toThrow( + "Stateful service target server architecture arm64 does not match platform linux/amd64", + ); + expectActiveReplicaPredicate(1); + }); + + it("builds a stateful target platform from the active replica server architecture", async () => { + mocks.queryResults.push( + [{ id: "service_1", stateful: true }], + [{ id: "server_target", status: "online", meta: { arch: "arm64" } }], + ); + + await expect(getTargetPlatformsForService("service_1")).resolves.toEqual([ + "linux/arm64", + ]); + expectActiveReplicaPredicate(1); + }); + + it("rejects stateful target platform resolution when the active replica arch is unknown", async () => { + mocks.queryResults.push( + [{ id: "service_1", stateful: true }], + [{ id: "server_target", status: "online", meta: null }], + ); + + await expect(getTargetPlatformsForService("service_1")).rejects.toThrow( + "Stateful service target server architecture is unknown", + ); + }); + + it("keeps stateless builds assigned by matching architecture", async () => { + mocks.getSetting.mockResolvedValue(null); + mocks.queryResults.push( + [{ id: "service_1", stateful: false }], + [ + { id: "server_amd", meta: { arch: "amd64" } }, + { id: "server_arm", meta: { arch: "arm64" } }, + ], + ); + + await expect( + selectBuildServerForPlatform("service_1", "linux/arm64"), + ).resolves.toBe("server_arm"); + }); + + it("limits stateless build assignment to allowed build servers", async () => { + mocks.getSetting.mockResolvedValue(["server_arm"]); + mocks.queryResults.push( + [{ id: "service_1", stateful: false }], + [{ id: "server_arm", meta: { arch: "arm64" } }], + ); + + await expect( + selectBuildServerForPlatform("service_1", "linux/arm64"), + ).resolves.toBe("server_arm"); + + const condition = mocks.queries[1]?.where.mock.calls[0]?.[0]; + expect(sqlTokens(condition)).toEqual( + expect.arrayContaining(["status", "online", "id", " in "]), + ); + }); + + it("builds target platforms from active replica server architectures", async () => { + mocks.queryResults.push( + [{ id: "service_1" }], + [{ meta: { arch: "arm64" } }, { meta: { arch: "arm64" } }], + ); + + await expect(getTargetPlatformsForService("service_1")).resolves.toEqual([ + "linux/arm64", + ]); + expectActiveReplicaPredicate(1); + }); +});