diff --git a/web/app/(dashboard)/dashboard/servers/[id]/loading.tsx b/web/app/(dashboard)/dashboard/servers/[id]/loading.tsx index f42210a..b59bad0 100644 --- a/web/app/(dashboard)/dashboard/servers/[id]/loading.tsx +++ b/web/app/(dashboard)/dashboard/servers/[id]/loading.tsx @@ -1,161 +1,10 @@ import { SetBreadcrumbs } from "@/components/core/breadcrumb-data"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; -function ServerDetailsSkeleton() { - return ( - - - Server Details - - -
- {Array.from({ length: 9 }).map((_, index) => ( -
- - -
- ))} -
-
-
- ); -} - -function HealthMetricSkeleton() { - return ( -
-
- - -
- - -
- ); -} - -function HealthStatusSkeleton() { - return ( -
- -
- - -
-
- ); -} - -function SystemHealthSkeleton() { - return ( - - - System Health - - -
- - - -
-
-
- - - -
-
-
-
- ); -} - -function RunningServicesSkeleton() { - return ( - - - - - - - - - - - -
- {Array.from({ length: 4 }).map((_, index) => ( -
- -
- - -
-
- ))} -
-
-
- ); -} - -function AgentLogsSkeleton() { - return ( -
-

Agent Logs

-
-
- - -
-
- {Array.from({ length: 12 }).map((_, index) => ( - - ))} -
-
-
- ); -} - -function DangerZoneSkeleton() { - return ( - - - - - - - - - - - - - - ); -} - export default function Loading() { return ( <> - + - - - - - + + + +
Loading server details diff --git a/web/app/api/projects/[id]/services/[serviceId]/position/route.ts b/web/app/api/projects/[id]/services/[serviceId]/position/route.ts new file mode 100644 index 0000000..771441d --- /dev/null +++ b/web/app/api/projects/[id]/services/[serviceId]/position/route.ts @@ -0,0 +1,53 @@ +import { and, eq, isNull } from "drizzle-orm"; +import { headers } from "next/headers"; +import { z } from "zod"; +import { db } from "@/db"; +import { services } from "@/db/schema"; +import { auth } from "@/lib/auth"; + +const positionSchema = z.object({ + canvasX: z.number().int().min(0).max(10000), + canvasY: z.number().int().min(0).max(10000), +}); + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string; serviceId: string }> }, +) { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { id: projectId, serviceId } = await params; + const parsed = positionSchema.safeParse(await request.json()); + + if (!parsed.success) { + return Response.json({ error: "Invalid position" }, { status: 400 }); + } + + const [service] = await db + .update(services) + .set(parsed.data) + .where( + and( + eq(services.id, serviceId), + eq(services.projectId, projectId), + isNull(services.deletedAt), + ), + ) + .returning({ + id: services.id, + canvasX: services.canvasX, + canvasY: services.canvasY, + }); + + if (!service) { + return Response.json({ error: "Service not found" }, { status: 404 }); + } + + return Response.json(service); +} diff --git a/web/components/dashboard/dashboard-page-skeleton.tsx b/web/components/dashboard/dashboard-page-skeleton.tsx index 9d4a521..1b43e4b 100644 --- a/web/components/dashboard/dashboard-page-skeleton.tsx +++ b/web/components/dashboard/dashboard-page-skeleton.tsx @@ -1,92 +1,21 @@ import { Skeleton } from "@/components/ui/skeleton"; -function DashboardHeaderSkeleton() { - return ( -
-
-
- - -
- -
-
- ); -} - -function ProjectCardSkeleton() { - return ( -
-
- -
- - -
-
-
- ); -} - -function ServerRowSkeleton() { - return ( -
-
-
- -
- - -
-
- -
-
- ); -} - export function DashboardPageSkeleton() { return (
- +
+
+ + +
+
-
- - - -
- -
-
-
- - -
- -
-
- - - -
-
- -
-
-
- - -
- -
-
- - -
-
+ + +
Loading dashboard diff --git a/web/components/service/service-canvas.tsx b/web/components/service/service-canvas.tsx index 429b906..bb8fffd 100644 --- a/web/components/service/service-canvas.tsx +++ b/web/components/service/service-canvas.tsx @@ -14,7 +14,8 @@ import { } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useMemo, useState } from "react"; +import type { AnchorHTMLAttributes, MouseEvent, PointerEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import useSWR from "swr"; import { buttonVariants } from "@/components/ui/button"; import { getStatusColorFromDeployments } from "@/components/ui/canvas-wrapper"; @@ -48,10 +49,93 @@ import { CreateGitHubServiceDialog, } from "./create-service-dialog"; +type CanvasPosition = { + canvasX: number; + canvasY: number; +}; + +const SERVICE_CARD_WIDTH = 320; +const SERVICE_CARD_HEIGHT = 150; +const SERVICE_CARD_GAP_X = 56; +const SERVICE_CARD_GAP_Y = 48; +const DEFAULT_GRID_COLUMNS = 3; +const DEFAULT_GRID_ROWS = 3; +const CANVAS_WIDTH = 1320; +const CANVAS_HEIGHT = 900; +const MIN_CANVAS_SCALE = 0.5; +const SNAP_GRID_SIZE = 24; + +function getCanvasScale() { + if (typeof window === "undefined") { + return 1; + } + + const availableWidth = window.innerWidth - 96; + const availableHeight = window.innerHeight - 112; + + return Math.max( + MIN_CANVAS_SCALE, + Math.min(1, availableWidth / CANVAS_WIDTH, availableHeight / CANVAS_HEIGHT), + ); +} + +function getDefaultServicePosition(index: number): CanvasPosition { + const gridWidth = + DEFAULT_GRID_COLUMNS * SERVICE_CARD_WIDTH + + (DEFAULT_GRID_COLUMNS - 1) * SERVICE_CARD_GAP_X; + const gridHeight = + DEFAULT_GRID_ROWS * SERVICE_CARD_HEIGHT + + (DEFAULT_GRID_ROWS - 1) * SERVICE_CARD_GAP_Y; + const gridStartX = (CANVAS_WIDTH - gridWidth) / 2; + const gridStartY = (CANVAS_HEIGHT - gridHeight) / 2; + const column = index % DEFAULT_GRID_COLUMNS; + const row = Math.floor(index / DEFAULT_GRID_COLUMNS); + + return { + canvasX: gridStartX + column * (SERVICE_CARD_WIDTH + SERVICE_CARD_GAP_X), + canvasY: gridStartY + row * (SERVICE_CARD_HEIGHT + SERVICE_CARD_GAP_Y), + }; +} + +function clampPosition(position: CanvasPosition): CanvasPosition { + return { + canvasX: Math.max( + 0, + Math.min(CANVAS_WIDTH - SERVICE_CARD_WIDTH, Math.round(position.canvasX)), + ), + canvasY: Math.max( + 0, + Math.min( + CANVAS_HEIGHT - SERVICE_CARD_HEIGHT, + Math.round(position.canvasY), + ), + ), + }; +} + +function snapPosition(position: CanvasPosition): CanvasPosition { + return clampPosition({ + canvasX: Math.round(position.canvasX / SNAP_GRID_SIZE) * SNAP_GRID_SIZE, + canvasY: Math.round(position.canvasY / SNAP_GRID_SIZE) * SNAP_GRID_SIZE, + }); +} + +function getServicePosition( + service: ServiceWithDetails, + index: number, +): CanvasPosition { + const fallback = getDefaultServicePosition(index); + + return { + canvasX: service.canvasX ?? fallback.canvasX, + canvasY: service.canvasY ?? fallback.canvasY, + }; +} + function ServiceCardSkeleton() { return ( -
-
+
+
@@ -202,13 +286,17 @@ function ServiceCard({ projectSlug, envName, proxyDomain, + dragHandleProps, }: { service: ServiceWithDetails; projectSlug: string; envName: string; proxyDomain: string | null; + dragHandleProps?: AnchorHTMLAttributes; }) { const colors = getStatusColorFromDeployments(service.deployments); + const { className: dragHandleClassName, ...linkProps } = + dragHandleProps ?? {}; const publicPorts = service.ports.filter((p) => p.isPublic && p.domain); const tcpUdpPorts = service.ports.filter( (p) => @@ -229,99 +317,249 @@ function ServiceCard({ hasInternalDns; return ( -
+
-
-
-
-

+
+
+
+

{service.name}

- - - - +
+ + {runningCount > 0 && ( + + )} + + + + {service.deployments.length > 0 + ? `${runningCount}/${service.deployments.length}` + : "Not deployed"} + +
-
- {hasEndpoints && ( -
- {publicPorts.map((port) => ( -
- - - {port.domain} - -
- ))} - {tcpUdpPorts.length > 0 && - proxyDomain && - tcpUdpPorts.map((port) => ( + {hasEndpoints && ( +
+ {publicPorts.map((port) => (
- - - {port.protocol}://{proxyDomain}:{port.externalPort} - +
+ + {port.domain} +
))} - {hasInternalDns && ( -
- - - {service.hostname || service.name}.internal - -
- )} -
- )} + {tcpUdpPorts.length > 0 && + proxyDomain && + tcpUdpPorts.map((port) => ( +
+
+ + + {port.protocol}://{proxyDomain}:{port.externalPort} + +
+
+ ))} + {hasInternalDns && ( +
+
+ + + {service.hostname || service.name}.internal + +
+
+ )} +
+ )} +

{service.volumes && service.volumes.length > 0 && ( -
+
{service.volumes.map((volume) => (
- - {volume.name} + + + {volume.name} +
))}
)} + +
+ ); +} - {service.deployments.length > 0 && ( -
-
- Replicas - - {runningCount}/{service.deployments.length} - -
-
- )} +function DraggableServiceCard({ + service, + index, + projectSlug, + envName, + proxyDomain, + canvasScale, + onPositionChange, +}: { + service: ServiceWithDetails; + index: number; + projectSlug: string; + envName: string; + proxyDomain: string | null; + canvasScale: number; + onPositionChange: (serviceId: string, position: CanvasPosition) => void; +}) { + const [dragPosition, setDragPosition] = useState(null); + const dragRef = useRef<{ + pointerId: number; + startX: number; + startY: number; + origin: CanvasPosition; + moved: boolean; + } | null>(null); + const suppressClickRef = useRef(false); + const position = dragPosition ?? getServicePosition(service, index); - {service.deployments.length === 0 && ( -
- Not deployed -
- )} - + const handlePointerDown = useCallback( + (event: PointerEvent) => { + if (event.button !== 0) { + return; + } + + event.currentTarget.setPointerCapture(event.pointerId); + dragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + origin: position, + moved: false, + }; + }, + [position], + ); + + const handlePointerMove = useCallback( + (event: PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + + const deltaX = (event.clientX - drag.startX) / canvasScale; + const deltaY = (event.clientY - drag.startY) / canvasScale; + const nextPosition = clampPosition({ + canvasX: drag.origin.canvasX + deltaX, + canvasY: drag.origin.canvasY + deltaY, + }); + + if (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3) { + drag.moved = true; + event.preventDefault(); + } + + setDragPosition(nextPosition); + }, + [canvasScale], + ); + + const handlePointerUp = useCallback( + (event: PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + dragRef.current = null; + setDragPosition(null); + + if (drag.moved) { + suppressClickRef.current = true; + onPositionChange(service.id, snapPosition(position)); + } + }, + [onPositionChange, position, service.id], + ); + + const handlePointerCancel = useCallback( + (event: PointerEvent) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + dragRef.current = null; + setDragPosition(null); + suppressClickRef.current = false; + }, + [], + ); + + const handleClickCapture = useCallback( + (event: MouseEvent) => { + if (!suppressClickRef.current) { + return; + } + + suppressClickRef.current = false; + event.preventDefault(); + event.stopPropagation(); + }, + [], + ); + + return ( +
+ event.preventDefault(), + }} + />
); } @@ -346,6 +584,7 @@ export function ServiceCanvas({ const [dockerDialogOpen, setDockerDialogOpen] = useState(false); const [githubDialogOpen, setGithubDialogOpen] = useState(false); + const [canvasScale, setCanvasScale] = useState(1); const { data: services, @@ -360,6 +599,15 @@ export function ServiceCanvas({ }, ); + useEffect(() => { + const updateCanvasScale = () => setCanvasScale(getCanvasScale()); + + updateCanvasScale(); + window.addEventListener("resize", updateCanvasScale); + + return () => window.removeEventListener("resize", updateCanvasScale); + }, []); + const composeHref = `/dashboard/projects/${projectSlug}/${envName}/import-compose`; const menuCallbacks = useMemo( @@ -390,6 +638,57 @@ export function ServiceCanvas({ [projectId, envId, projectSlug, envName, mutate], ); + const handlePositionChange = useCallback( + (serviceId: string, position: CanvasPosition) => { + const nextPosition = clampPosition(position); + + void mutate( + (current) => + current?.map((service) => + service.id === serviceId + ? { + ...service, + ...nextPosition, + } + : service, + ), + false, + ); + + void fetch(`/api/projects/${projectId}/services/${serviceId}/position`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(nextPosition), + }) + .then(async (response) => { + if (!response.ok) { + void mutate(); + return; + } + + const savedPosition = (await response.json()) as CanvasPosition; + + void mutate( + (current) => + current?.map((service) => + service.id === serviceId + ? { + ...service, + canvasX: savedPosition.canvasX, + canvasY: savedPosition.canvasY, + } + : service, + ), + false, + ); + }) + .catch(() => { + void mutate(); + }); + }, + [mutate, projectId], + ); + if (!environments || isLoading) { return ( <> @@ -535,11 +834,11 @@ export function ServiceCanvas({
-
- {services.map((service) => ( - - ))} +
+
+ {services.map((service, index) => ( + + ))} +
r[0]); + + return server?.name || serverId; +} + async function completeTargetMigration(serviceId: string) { await db .update(services) @@ -110,6 +120,12 @@ export async function applyStatusReport( await db.update(servers).set(updateData).where(eq(servers.id, serverId)); + let serverLogName: string | undefined; + const getCurrentServerLogName = async () => { + serverLogName ??= await getServerLogName(serverId); + return serverLogName; + }; + const reportedDeploymentIds = report.containers .map((c) => c.deploymentId) .filter((id) => id !== ""); @@ -275,11 +291,12 @@ export async function applyStatusReport( ); if (deployment.rolloutId) { + const currentServerName = await getCurrentServerLogName(); await ingestRolloutLog( deployment.rolloutId, deployment.serviceId, "deploying", - `Deployment ${deployment.id} starting on server ${serverId}`, + `Starting container on server ${currentServerName}`, ); } @@ -392,11 +409,12 @@ export async function applyStatusReport( } if (autohealFailed && deployment.rolloutId) { + const currentServerName = await getCurrentServerLogName(); await ingestRolloutLog( deployment.rolloutId, deployment.serviceId, "autoheal", - `Deployment ${deployment.id} exceeded autoheal restart limit`, + `Container exceeded autoheal restart limit on server ${currentServerName}`, ); await inngest.send( inngestEvents.resourceStatusChanged.create({ @@ -427,11 +445,12 @@ export async function applyStatusReport( .where(eq(deployments.id, deployment.id)); if (deployment.rolloutId) { + const currentServerName = await getCurrentServerLogName(); await ingestRolloutLog( deployment.rolloutId, deployment.serviceId, "health_check", - `Deployment ${deployment.id} is healthy`, + `Container is healthy on server ${currentServerName}`, ); await inngest.send( inngestEvents.resourceStatusChanged.create({ @@ -480,11 +499,12 @@ export async function applyStatusReport( .where(eq(deployments.id, deployment.id)); if (isRolloutDeployment && deployment.rolloutId) { + const currentServerName = await getCurrentServerLogName(); await ingestRolloutLog( deployment.rolloutId, deployment.serviceId, "health_check", - `Deployment ${deployment.id} failed health check`, + `Container failed health check on server ${currentServerName}`, ); await inngest.send( inngestEvents.resourceStatusChanged.create({ @@ -523,11 +543,12 @@ export async function applyStatusReport( ); for (const rollout of rolloutsInDnsSync) { + const currentServerName = await getCurrentServerLogName(); await ingestRolloutLog( rollout.id, "", "dns_sync", - `DNS synced on server ${serverId}`, + `DNS synced on server ${currentServerName}`, ); await inngest.send( inngestEvents.serverDnsSynced.create({ diff --git a/web/lib/build-assignment.ts b/web/lib/build-assignment.ts index d31d7e1..afbfe02 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/lib/inngest/functions/rollout-workflow.ts b/web/lib/inngest/functions/rollout-workflow.ts index 3747e1b..39c24e8 100644 --- a/web/lib/inngest/functions/rollout-workflow.ts +++ b/web/lib/inngest/functions/rollout-workflow.ts @@ -1,7 +1,7 @@ import { and, eq, inArray, isNull, ne, or } from "drizzle-orm"; import { db } from "@/db"; import { getService } from "@/db/queries"; -import { deployments, rollouts } from "@/db/schema"; +import { deployments, rollouts, servers } from "@/db/schema"; import { ingestRolloutLog } from "@/lib/victoria-logs"; import { inngest } from "../client"; import { inngestEvents } from "../events"; @@ -335,8 +335,13 @@ export const rolloutWorkflow = inngest.createFunction( } const deploymentStates = await db - .select({ id: deployments.id, status: deployments.status }) + .select({ + id: deployments.id, + status: deployments.status, + serverName: servers.name, + }) .from(deployments) + .innerJoin(servers, eq(deployments.serverId, servers.id)) .where(inArray(deployments.id, pendingHealthDeploymentIds)); return deploymentStates.filter( @@ -347,7 +352,7 @@ export const rolloutWorkflow = inngest.createFunction( ); if (unhealthyDeployments.length > 0) { - const failedDeploymentId = unhealthyDeployments[0].id; + const failedDeployment = unhealthyDeployments[0]; const failedReason = healthResults.includes(null) ? "health_check_timeout" : "health_check_failed"; @@ -357,8 +362,8 @@ export const rolloutWorkflow = inngest.createFunction( serviceId, "health_check", failedReason === "health_check_timeout" - ? `Health check timed out for deployment ${failedDeploymentId}` - : `Health check failed for deployment ${failedDeploymentId}`, + ? `Health check timed out on server ${failedDeployment.serverName}` + : `Health check failed on server ${failedDeployment.serverName}`, ); }); await step.run("handle-health-timeout", async () => { @@ -372,7 +377,7 @@ export const rolloutWorkflow = inngest.createFunction( return { status: "failed", reason: failedReason, - deploymentId: failedDeploymentId, + deploymentId: failedDeployment.id, }; } @@ -425,18 +430,32 @@ export const rolloutWorkflow = inngest.createFunction( ); const dnsTimedOut = dnsResults.some((r) => r === null); + const dnsServerNames = await step.run("load-dns-server-names", async () => { + if (serverIds.length === 0) { + return []; + } + + return db + .select({ id: servers.id, name: servers.name }) + .from(servers) + .where(inArray(servers.id, serverIds)); + }); + const dnsServerNameById = new Map( + dnsServerNames.map((server) => [server.id, server.name]), + ); for (let i = 0; i < dnsResults.length; i++) { if (dnsResults[i] === null) { + const serverName = dnsServerNameById.get(serverIds[i]) || serverIds[i]; console.warn( - `[rollout:${rolloutId}] DNS sync timeout for server ${serverIds[i]}`, + `[rollout:${rolloutId}] DNS sync timeout for server ${serverName}`, ); await step.run(`log-dns-timeout-${serverIds[i]}`, async () => { await ingestRolloutLog( rolloutId, serviceId, "dns_sync", - `DNS sync timed out for server ${serverIds[i]}`, + `DNS sync timed out for server ${serverName}`, ); }); } diff --git a/web/tests/build-assignment.test.ts b/web/tests/build-assignment.test.ts new file mode 100644 index 0000000..da69c7c --- /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); + }); +});