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);
+ });
+});