-
+
{children}
diff --git a/web/app/api/projects/[id]/services/route.ts b/web/app/api/projects/[id]/services/route.ts
index 7f69ca9d..35628784 100644
--- a/web/app/api/projects/[id]/services/route.ts
+++ b/web/app/api/projects/[id]/services/route.ts
@@ -1,6 +1,6 @@
export const dynamic = "force-dynamic";
-import { and, desc, eq, isNull } from "drizzle-orm";
+import { and, desc, eq, inArray, isNull } from "drizzle-orm";
import { headers } from "next/headers";
import { db } from "@/db";
import {
@@ -12,12 +12,14 @@ import {
servers,
servicePorts,
serviceReplicas,
+ serviceRevisions,
services,
serviceVolumes,
volumeBackups,
} from "@/db/schema";
import { auth } from "@/lib/auth";
import { getTimestamp } from "@/lib/date";
+import { revisionSpecToDeployedConfig } from "@/lib/service-config";
export async function GET(
request: Request,
@@ -114,6 +116,40 @@ export async function GET(
: Promise.resolve(null),
]);
+ const activeDeployment = serviceDeployments.find(
+ (deployment) =>
+ deployment.trafficState === "active" &&
+ deployment.runtimeDesiredState !== "removed",
+ );
+ const activeRevision = activeDeployment
+ ? await db
+ .select({ specification: serviceRevisions.specification })
+ .from(serviceRevisions)
+ .where(eq(serviceRevisions.id, activeDeployment.serviceRevisionId))
+ .then((rows) => rows[0])
+ : null;
+ const revisionServers = activeRevision
+ ? await db
+ .select({ id: servers.id, name: servers.name })
+ .from(servers)
+ .where(
+ inArray(
+ servers.id,
+ activeRevision.specification.placements.map(
+ (placement) => placement.serverId,
+ ),
+ ),
+ )
+ : [];
+ const activeConfig = activeRevision
+ ? revisionSpecToDeployedConfig(
+ activeRevision.specification,
+ Object.fromEntries(
+ revisionServers.map((server) => [server.id, server.name]),
+ ),
+ )
+ : null;
+
const deploymentsWithDetails = await Promise.all(
serviceDeployments.map(async (deployment) => {
const [depPorts, server] = await Promise.all([
@@ -121,13 +157,9 @@ export async function GET(
.select({
id: deploymentPorts.id,
hostPort: deploymentPorts.hostPort,
- containerPort: servicePorts.port,
+ containerPort: deploymentPorts.containerPort,
})
.from(deploymentPorts)
- .innerJoin(
- servicePorts,
- eq(deploymentPorts.servicePortId, servicePorts.id),
- )
.where(eq(deploymentPorts.deploymentId, deployment.id)),
db
.select({ name: servers.name, wireguardIp: servers.wireguardIp })
@@ -205,6 +237,7 @@ export async function GET(
volumes,
lockedServer,
latestBuild,
+ activeConfig,
deletionBackupFallback,
};
}),
diff --git a/web/app/api/services/[id]/revisions/route.ts b/web/app/api/services/[id]/revisions/route.ts
new file mode 100644
index 00000000..4a2cfe9c
--- /dev/null
+++ b/web/app/api/services/[id]/revisions/route.ts
@@ -0,0 +1,216 @@
+export const dynamic = "force-dynamic";
+
+import { and, desc, eq, inArray, isNull, lt, or, sql } from "drizzle-orm";
+import { db } from "@/db";
+import { rollouts, servers, serviceRevisions, services } from "@/db/schema";
+import { requireRequestSession } from "@/lib/api-auth";
+import {
+ diffServiceRevisionSpecs,
+ parseServiceRevisionSpec,
+ type ServiceRevisionChangelogItem,
+ type ServiceRevisionChangelogResponse,
+} from "@/lib/service-revision-changes";
+import type { ServiceRevisionSpec } from "@/lib/service-revision-spec";
+
+const PAGE_SIZE = 25;
+const REVISION_CURSOR_TIMESTAMP =
+ /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}(?::?\d{2})?)$/;
+
+type RevisionCursor = {
+ createdAt: string;
+ id: string;
+};
+
+function encodeCursor(cursor: RevisionCursor): string {
+ return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
+}
+
+function decodeCursor(value: string): RevisionCursor | null {
+ try {
+ const decoded = JSON.parse(
+ Buffer.from(value, "base64url").toString("utf8"),
+ ) as unknown;
+ if (!decoded || typeof decoded !== "object") return null;
+
+ const cursor = decoded as Partial
;
+ if (
+ typeof cursor.createdAt !== "string" ||
+ !REVISION_CURSOR_TIMESTAMP.test(cursor.createdAt) ||
+ Number.isNaN(Date.parse(cursor.createdAt)) ||
+ typeof cursor.id !== "string" ||
+ cursor.id.length === 0 ||
+ cursor.id.length > 200
+ ) {
+ return null;
+ }
+
+ return { createdAt: cursor.createdAt, id: cursor.id };
+ } catch {
+ return null;
+ }
+}
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const sessionResult = await requireRequestSession(request);
+ if (!sessionResult.ok) return sessionResult.response;
+
+ const { id: serviceId } = await params;
+ const cursorValue = new URL(request.url).searchParams.get("cursor");
+ const cursor = cursorValue ? decodeCursor(cursorValue) : null;
+ if (cursorValue && !cursor) {
+ return Response.json(
+ { message: "Invalid revision cursor" },
+ { status: 400 },
+ );
+ }
+
+ const service = await db
+ .select({ id: services.id })
+ .from(services)
+ .where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
+ .then((rows) => rows[0]);
+ if (!service) {
+ return Response.json({ message: "Service not found" }, { status: 404 });
+ }
+
+ const revisions = await db
+ .select({
+ id: serviceRevisions.id,
+ createdAt: serviceRevisions.createdAt,
+ cursorCreatedAt: sql`${serviceRevisions.createdAt}::text`,
+ specification: serviceRevisions.specification,
+ })
+ .from(serviceRevisions)
+ .where(
+ and(
+ eq(serviceRevisions.serviceId, serviceId),
+ cursor
+ ? or(
+ lt(
+ serviceRevisions.createdAt,
+ sql`${cursor.createdAt}::timestamptz`,
+ ),
+ and(
+ eq(
+ serviceRevisions.createdAt,
+ sql`${cursor.createdAt}::timestamptz`,
+ ),
+ lt(serviceRevisions.id, cursor.id),
+ ),
+ )
+ : undefined,
+ ),
+ )
+ .orderBy(desc(serviceRevisions.createdAt), desc(serviceRevisions.id))
+ .limit(PAGE_SIZE + 1);
+
+ const pageRevisions = revisions.slice(0, PAGE_SIZE);
+ const revisionIds = pageRevisions.map((revision) => revision.id);
+ const parsedSpecifications = new Map();
+ for (const revision of revisions) {
+ try {
+ parsedSpecifications.set(
+ revision.id,
+ parseServiceRevisionSpec(revision.specification),
+ );
+ } catch {
+ // Unsupported or malformed historical revisions remain visible.
+ }
+ }
+ const placementServerIds = [
+ ...new Set(
+ [...parsedSpecifications.values()].flatMap((specification) =>
+ specification.placements.map((placement) => placement.serverId),
+ ),
+ ),
+ ];
+ const [serverRows, revisionRollouts] = await Promise.all([
+ placementServerIds.length > 0
+ ? db
+ .select({ id: servers.id, name: servers.name })
+ .from(servers)
+ .where(inArray(servers.id, placementServerIds))
+ : Promise.resolve([]),
+ revisionIds.length > 0
+ ? db
+ .select({
+ id: rollouts.id,
+ serviceRevisionId: rollouts.serviceRevisionId,
+ status: rollouts.status,
+ createdAt: rollouts.createdAt,
+ })
+ .from(rollouts)
+ .where(
+ and(
+ eq(rollouts.serviceId, serviceId),
+ inArray(rollouts.serviceRevisionId, revisionIds),
+ ),
+ )
+ .orderBy(desc(rollouts.createdAt), desc(rollouts.id))
+ : Promise.resolve([]),
+ ]);
+ const serverNames = new Map(
+ serverRows.map((server) => [server.id, server.name] as const),
+ );
+ const rolloutByRevisionId = new Map<
+ string,
+ (typeof revisionRollouts)[number]
+ >();
+ for (const rollout of revisionRollouts) {
+ if (
+ rollout.serviceRevisionId &&
+ !rolloutByRevisionId.has(rollout.serviceRevisionId)
+ ) {
+ rolloutByRevisionId.set(rollout.serviceRevisionId, rollout);
+ }
+ }
+
+ const items: ServiceRevisionChangelogItem[] = pageRevisions.map(
+ (revision, index) => {
+ const previous = revisions[index + 1];
+ let comparison: ServiceRevisionChangelogItem["comparison"];
+ if (!previous) {
+ comparison = { kind: "initial" };
+ } else {
+ const currentSpecification = parsedSpecifications.get(revision.id);
+ const previousSpecification = parsedSpecifications.get(previous.id);
+ if (currentSpecification && previousSpecification) {
+ comparison = {
+ kind: "changes",
+ changes: diffServiceRevisionSpecs(
+ previousSpecification,
+ currentSpecification,
+ serverNames,
+ ),
+ };
+ } else {
+ comparison = { kind: "unavailable" };
+ }
+ }
+
+ const rollout = rolloutByRevisionId.get(revision.id);
+ return {
+ id: revision.id,
+ createdAt: revision.createdAt.toISOString(),
+ comparison,
+ rollout: rollout ? { id: rollout.id, status: rollout.status } : null,
+ };
+ },
+ );
+
+ const response: ServiceRevisionChangelogResponse = {
+ revisions: items,
+ nextCursor:
+ revisions.length > PAGE_SIZE && pageRevisions.length > 0
+ ? encodeCursor({
+ createdAt: pageRevisions[pageRevisions.length - 1].cursorCreatedAt,
+ id: pageRevisions[pageRevisions.length - 1].id,
+ })
+ : null,
+ };
+
+ return Response.json(response);
+}
diff --git a/web/components/builds/build-details.tsx b/web/components/builds/build-details.tsx
index dfeef859..6b7aa5b4 100644
--- a/web/components/builds/build-details.tsx
+++ b/web/components/builds/build-details.tsx
@@ -265,7 +265,6 @@ export function BuildDetails({
-
- Timing
Created
diff --git a/web/components/service/details/changelog-history.tsx b/web/components/service/details/changelog-history.tsx
new file mode 100644
index 00000000..364db408
--- /dev/null
+++ b/web/components/service/details/changelog-history.tsx
@@ -0,0 +1,275 @@
+"use client";
+
+import {
+ ArrowRight,
+ CheckCircle2,
+ Clock,
+ GitCommitHorizontal,
+ Loader2,
+ RotateCcw,
+ XCircle,
+} from "lucide-react";
+import Link from "next/link";
+import { useMemo } from "react";
+import useSWRInfinite from "swr/infinite";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Empty,
+ EmptyDescription,
+ EmptyMedia,
+ EmptyTitle,
+} from "@/components/ui/empty";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { RolloutStatus } from "@/db/types";
+import { formatDateTime, formatRelativeTime } from "@/lib/date";
+import { fetcher } from "@/lib/fetcher";
+import type {
+ ServiceRevisionChangelogItem,
+ ServiceRevisionChangelogResponse,
+} from "@/lib/service-revision-changes";
+
+const STATUS_CONFIG: Record<
+ RolloutStatus,
+ { label: string; icon: typeof Clock; className: string }
+> = {
+ queued: { label: "Queued", icon: Clock, className: "text-slate-600" },
+ in_progress: {
+ label: "In progress",
+ icon: Loader2,
+ className: "text-blue-600",
+ },
+ completed: {
+ label: "Completed",
+ icon: CheckCircle2,
+ className: "text-emerald-600",
+ },
+ failed: { label: "Failed", icon: XCircle, className: "text-red-600" },
+ rolled_back: {
+ label: "Rolled back",
+ icon: RotateCcw,
+ className: "text-orange-600",
+ },
+};
+
+function RolloutBadge({
+ rollout,
+ serviceId,
+ projectSlug,
+ envName,
+}: {
+ rollout: NonNullable
;
+ serviceId: string;
+ projectSlug: string;
+ envName: string;
+}) {
+ const config = STATUS_CONFIG[rollout.status] ?? {
+ label: rollout.status,
+ icon: Clock,
+ className: "text-muted-foreground",
+ };
+ const Icon = config.icon;
+
+ return (
+
+ }
+ >
+
+ {config.label}
+
+ );
+}
+
+function ChangelogSkeleton() {
+ return (
+
+ {[1, 2, 3].map((item) => (
+
+
+
+
+ ))}
+
+ );
+}
+
+function RevisionChanges({ item }: { item: ServiceRevisionChangelogItem }) {
+ if (item.comparison.kind === "initial") {
+ return (
+
+ Initial service configuration captured.
+
+ );
+ }
+
+ if (item.comparison.kind === "unavailable") {
+ return (
+
+ Changes are unavailable for this revision format.
+
+ );
+ }
+
+ if (item.comparison.changes.length === 0) {
+ return (
+
+ No configuration changes — redeploy.
+
+ );
+ }
+
+ return (
+
+ {item.comparison.changes.map((change) => (
+
+
{change.field}
+
+
{change.from}
+
+
{change.to}
+
+
+ ))}
+
+ );
+}
+
+export function ChangelogHistory({
+ serviceId,
+ projectSlug,
+ envName,
+}: {
+ serviceId: string;
+ projectSlug: string;
+ envName: string;
+}) {
+ const { data, error, isLoading, isValidating, mutate, size, setSize } =
+ useSWRInfinite(
+ (pageIndex, previousPage) => {
+ if (previousPage && !previousPage.nextCursor) return null;
+ const cursor = pageIndex === 0 ? null : previousPage?.nextCursor;
+ return `/api/services/${serviceId}/revisions${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`;
+ },
+ fetcher,
+ {
+ refreshInterval: (pages) =>
+ pages?.some((page) =>
+ page.revisions.some(
+ (revision) =>
+ revision.rollout?.status === "queued" ||
+ revision.rollout?.status === "in_progress",
+ ),
+ )
+ ? 3000
+ : 0,
+ revalidateOnFocus: true,
+ },
+ );
+
+ const revisions = useMemo(() => {
+ const byId = new Map();
+ for (const page of data ?? []) {
+ for (const revision of page.revisions) byId.set(revision.id, revision);
+ }
+ return [...byId.values()];
+ }, [data]);
+ const hasMore = data?.[data.length - 1]?.nextCursor != null;
+ const isLoadingMore = isValidating && Boolean(data?.[size - 1] === undefined);
+
+ if (isLoading) return ;
+
+ if (error) {
+ return (
+
+
+
+
+ Unable to load changelog
+
+ Revision history could not be loaded. Try again.
+
+
+
+ );
+ }
+
+ return (
+
+ {revisions.length === 0 ? (
+
+
+
+
+ No revisions yet
+
+ Deploy this service to create its first revision.
+
+
+ ) : (
+
+ {revisions.map((revision) => (
+
+
+
+
+ {revision.comparison.kind === "initial"
+ ? "Initial revision"
+ : revision.comparison.kind === "changes" &&
+ revision.comparison.changes.length === 0
+ ? "Redeployed"
+ : "Configuration updated"}
+
+
+ {formatRelativeTime(revision.createdAt)} ·{" "}
+ {revision.id.slice(0, 8)}
+
+
+ {revision.rollout ? (
+
+ ) : null}
+
+
+
+ ))}
+
+ )}
+
+ {hasMore ? (
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx
index 6eeb3449..a5d584ba 100644
--- a/web/components/service/details/service-details-overview.tsx
+++ b/web/components/service/details/service-details-overview.tsx
@@ -40,6 +40,8 @@ export type ServiceMetricsResponse = {
windowEnd: string;
stepSeconds: number;
totalRequests: number;
+ totalIngressBytes: number | null;
+ totalEgressBytes: number | null;
statusCodes: string[];
buckets: Array<{
timestamp: string;
@@ -313,8 +315,8 @@ export function ServiceMetricsPanel({
data={chartRows}
margin={{
top: 8,
- right: 4,
- left: getYAxisMargin(chartMode),
+ right: fixedMode ? 48 : 4,
+ left: 0,
bottom: 0,
}}
>
@@ -336,6 +338,7 @@ export function ServiceMetricsPanel({
className="text-xs"
/>
@@ -1013,10 +1016,12 @@ function buildServiceMetricSummaryItems(
hasMetricData: boolean,
rangeLabel = "24h",
): ServiceMetricSummaryItem[] {
+ const summaryRangeLabel = stats?.range ?? rangeLabel;
+
if (mode === "requests") {
return [
{
- label: `requests in ${rangeLabel}`,
+ label: `requests in ${summaryRangeLabel}`,
value:
hasMetricData && stats
? formatCompactNumber(stats.totalRequests)
@@ -1061,18 +1066,16 @@ function buildServiceMetricSummaryItems(
if (mode === "traffic") {
return [
{
- label: "ingress",
- value: formatNullableMetric(
- getLatestValue(rows, "ingressBytesPerSecond"),
- (value) => `${formatBytes(value)}/s`,
- ),
+ label: `ingress in ${summaryRangeLabel}`,
+ value: hasMetricData
+ ? formatNullableMetric(stats?.totalIngressBytes ?? null, formatBytes)
+ : "-",
},
{
- label: "egress",
- value: formatNullableMetric(
- getLatestValue(rows, "egressBytesPerSecond"),
- (value) => `${formatBytes(value)}/s`,
- ),
+ label: `egress in ${summaryRangeLabel}`,
+ value: hasMetricData
+ ? formatNullableMetric(stats?.totalEgressBytes ?? null, formatBytes)
+ : "-",
},
];
}
@@ -1255,13 +1258,6 @@ function formatRateTick(value: number): string {
return value.toFixed(1);
}
-function getYAxisMargin(mode: ServiceChartMode): number {
- if (mode === "traffic") return -4;
- if (mode === "latency") return -12;
- if (mode === "resources") return -24;
- return -20;
-}
-
function formatRequestCount(value: number): string {
if (!Number.isFinite(value)) return "-";
diff --git a/web/components/service/details/service-metrics-page.tsx b/web/components/service/details/service-metrics-page.tsx
index 98b33739..538f4be7 100644
--- a/web/components/service/details/service-metrics-page.tsx
+++ b/web/components/service/details/service-metrics-page.tsx
@@ -53,10 +53,17 @@ export function ServiceMetricsPage() {
- }>
+
+ }
+ >
{LABELS[range]}
-
+
setRange(value as typeof range)}
diff --git a/web/components/service/service-canvas.tsx b/web/components/service/service-canvas.tsx
index 1e825499..c5aa5367 100644
--- a/web/components/service/service-canvas.tsx
+++ b/web/components/service/service-canvas.tsx
@@ -856,7 +856,7 @@ export function ServiceCanvas({
-
+
{
if (!service) return [];
- const deployed = parseDeployedConfig(service.deployedConfig);
+ const deployed = service.activeConfig ?? null;
const replicas = (service.configuredReplicas || []).map((r) => ({
serverId: r.serverId,
serverName: r.serverName,
@@ -132,6 +128,7 @@ export function ServiceLayoutClient({
const isConstrainedTab =
pathname.includes("/metrics") ||
pathname.includes("/configuration") ||
+ pathname.includes("/changelog") ||
pathname.includes("/builds") ||
pathname.includes("/backups");
@@ -151,6 +148,7 @@ export function ServiceLayoutClient({
...(service?.stateful
? [{ name: "Backups", href: `${basePath}/backups` }]
: []),
+ { name: "Changes", href: `${basePath}/changelog` },
];
const isActiveTab = (href: string) => {
diff --git a/web/db/schema.ts b/web/db/schema.ts
index a3bc22ca..7f3ed25e 100644
--- a/web/db/schema.ts
+++ b/web/db/schema.ts
@@ -2,6 +2,7 @@ import { relations, sql } from "drizzle-orm";
import {
bigint,
boolean,
+ foreignKey,
index,
integer,
jsonb,
@@ -9,8 +10,10 @@ import {
real,
text,
timestamp,
+ unique,
uniqueIndex,
} from "drizzle-orm/pg-core";
+import type { ServiceRevisionSpec } from "@/lib/service-revision-spec";
export const user = pgTable("user", {
id: text("id").primaryKey(),
@@ -93,7 +96,7 @@ export const verification = pgTable(
);
export const twoFactor = pgTable(
- "twoFactor",
+ "two_factor",
{
id: text("id").primaryKey(),
secret: text("secret").notNull(),
@@ -114,7 +117,7 @@ export const twoFactor = pgTable(
);
export const deviceCode = pgTable(
- "deviceCode",
+ "device_code",
{
id: text("id").primaryKey(),
deviceCode: text("device_code").notNull(),
@@ -417,7 +420,6 @@ export const services = pgTable("services", {
serverlessWakeTimeoutSeconds: integer("serverless_wake_timeout_seconds")
.notNull()
.default(300),
- deployedConfig: text("deployed_config"),
deploymentSchedule: text("deployment_schedule"),
lastScheduledDeploymentRunAt: timestamp("last_scheduled_deployment_run_at", {
withTimezone: true,
@@ -547,6 +549,34 @@ export const secrets = pgTable("secrets", {
.notNull(),
});
+export const serviceRevisions = pgTable(
+ "service_revisions",
+ {
+ id: text("id").primaryKey(),
+ serviceId: text("service_id")
+ .notNull()
+ .references(() => services.id, { onDelete: "cascade" }),
+ specification: jsonb("specification")
+ .$type
()
+ .notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .defaultNow()
+ .notNull(),
+ },
+ (table) => [
+ unique("service_revisions_id_service_id_unique").on(
+ table.id,
+ table.serviceId,
+ ),
+ index("service_revisions_service_id_idx").on(table.serviceId),
+ index("service_revisions_service_created_id_idx").on(
+ table.serviceId,
+ table.createdAt,
+ table.id,
+ ),
+ ],
+);
+
export const deployments = pgTable(
"deployments",
{
@@ -554,6 +584,7 @@ export const deployments = pgTable(
serviceId: text("service_id")
.notNull()
.references(() => services.id, { onDelete: "cascade" }),
+ serviceRevisionId: text("service_revision_id").notNull(),
serverId: text("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
@@ -611,12 +642,18 @@ export const deployments = pgTable(
index("deployments_container_id_idx").on(table.containerId),
index("deployments_rollout_id_idx").on(table.rolloutId),
index("deployments_service_id_idx").on(table.serviceId),
+ index("deployments_service_revision_id_idx").on(table.serviceRevisionId),
index("deployments_server_id_idx").on(table.serverId),
index("deployments_runtime_desired_state_idx").on(
table.runtimeDesiredState,
),
index("deployments_traffic_state_idx").on(table.trafficState),
index("deployments_observed_phase_idx").on(table.observedPhase),
+ foreignKey({
+ name: "deployments_service_revision_service_fk",
+ columns: [table.serviceRevisionId, table.serviceId],
+ foreignColumns: [serviceRevisions.id, serviceRevisions.serviceId],
+ }).onDelete("no action"),
],
);
@@ -627,6 +664,7 @@ export const rollouts = pgTable(
serviceId: text("service_id")
.notNull()
.references(() => services.id, { onDelete: "cascade" }),
+ serviceRevisionId: text("service_revision_id"),
status: text("status", {
enum: ["queued", "in_progress", "completed", "failed", "rolled_back"],
})
@@ -638,7 +676,15 @@ export const rollouts = pgTable(
.notNull(),
completedAt: timestamp("completed_at", { withTimezone: true }),
},
- (table) => [index("rollouts_service_id_idx").on(table.serviceId)],
+ (table) => [
+ index("rollouts_service_id_idx").on(table.serviceId),
+ index("rollouts_service_revision_id_idx").on(table.serviceRevisionId),
+ foreignKey({
+ name: "rollouts_service_revision_service_fk",
+ columns: [table.serviceRevisionId, table.serviceId],
+ foreignColumns: [serviceRevisions.id, serviceRevisions.serviceId],
+ }).onDelete("no action"),
+ ],
);
export const deploymentPorts = pgTable("deployment_ports", {
@@ -646,9 +692,7 @@ export const deploymentPorts = pgTable("deployment_ports", {
deploymentId: text("deployment_id")
.notNull()
.references(() => deployments.id, { onDelete: "cascade" }),
- servicePortId: text("service_port_id")
- .notNull()
- .references(() => servicePorts.id, { onDelete: "cascade" }),
+ containerPort: integer("container_port").notNull(),
hostPort: integer("host_port").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
diff --git a/web/db/types.ts b/web/db/types.ts
index 32c4d446..6a8d6f44 100644
--- a/web/db/types.ts
+++ b/web/db/types.ts
@@ -1,3 +1,4 @@
+import type { DeployedConfig } from "@/lib/service-config";
import type {
builds,
deploymentPorts,
@@ -54,6 +55,7 @@ export type HealthStats = {
};
export type ServiceWithDetails = Service & {
+ activeConfig?: DeployedConfig | null;
ports: ServicePort[];
configuredReplicas: Array<
ServiceReplica & { serverName: string; serverIsProxy: boolean }
diff --git a/web/lib/agent-status.ts b/web/lib/agent-status.ts
index dc4a362b..51243b29 100644
--- a/web/lib/agent-status.ts
+++ b/web/lib/agent-status.ts
@@ -3,11 +3,11 @@ import { db } from "@/db";
import {
type AgentHealth,
type ContainerHealth,
- deploymentPorts,
deployments,
type NetworkHealth,
rollouts,
servers,
+ serviceRevisions,
services,
workQueue,
} from "@/db/schema";
@@ -29,7 +29,7 @@ import {
import { inngest } from "@/lib/inngest/client";
import { inngestEvents } from "@/lib/inngest/events";
import { getServerlessWakeFailureUpdate } from "@/lib/serverless-wake-failures";
-import { getDeployedServerlessConfig } from "@/lib/service-config";
+import type { ServiceRevisionSpec } from "@/lib/service-revision-spec";
import { ingestRolloutLog } from "@/lib/victoria-logs";
import { enqueueWork } from "@/lib/work-queue";
@@ -153,17 +153,16 @@ async function applyDeploymentErrors(
rolloutId: deployments.rolloutId,
observedPhase: deployments.observedPhase,
serverlessWakeFailureCount: deployments.serverlessWakeFailureCount,
- serverlessEnabled: services.serverlessEnabled,
- serverlessSleepAfterSeconds: services.serverlessSleepAfterSeconds,
- serverlessWakeTimeoutSeconds: services.serverlessWakeTimeoutSeconds,
- stateful: services.stateful,
- deployedConfig: services.deployedConfig,
+ revisionSpec: serviceRevisions.specification,
rolloutStatus: rollouts.status,
serverName: servers.name,
})
.from(deployments)
.innerJoin(servers, eq(deployments.serverId, servers.id))
- .innerJoin(services, eq(deployments.serviceId, services.id))
+ .innerJoin(
+ serviceRevisions,
+ eq(deployments.serviceRevisionId, serviceRevisions.id),
+ )
.leftJoin(rollouts, eq(deployments.rolloutId, rollouts.id))
.where(
and(
@@ -192,8 +191,7 @@ async function applyDeploymentErrors(
.set(
isServerlessWakeDeployment
? getServerlessWakeFailureUpdate({
- serverlessEnabled:
- getDeployedServerlessConfig(deployment).enabled,
+ serverlessEnabled: deployment.revisionSpec.serverless.enabled,
currentFailureCount: deployment.serverlessWakeFailureCount,
failedStage: "serverless_wake",
})
@@ -284,16 +282,15 @@ async function applyServerlessTransitions(
trafficState: deployments.trafficState,
observedPhase: deployments.observedPhase,
serverlessWakeFailureCount: deployments.serverlessWakeFailureCount,
- serverlessEnabled: services.serverlessEnabled,
- serverlessSleepAfterSeconds: services.serverlessSleepAfterSeconds,
- serverlessWakeTimeoutSeconds: services.serverlessWakeTimeoutSeconds,
- stateful: services.stateful,
- deployedConfig: services.deployedConfig,
+ revisionSpec: serviceRevisions.specification,
serverIsProxy: servers.isProxy,
serverName: servers.name,
})
.from(deployments)
- .innerJoin(services, eq(deployments.serviceId, services.id))
+ .innerJoin(
+ serviceRevisions,
+ eq(deployments.serviceRevisionId, serviceRevisions.id),
+ )
.innerJoin(servers, eq(deployments.serverId, servers.id))
.where(eq(deployments.id, transition.deploymentId))
.then((rows) => rows[0]);
@@ -418,7 +415,7 @@ async function applyServerlessTransitions(
.update(deployments)
.set(
getServerlessWakeFailureUpdate({
- serverlessEnabled: getDeployedServerlessConfig(deployment).enabled,
+ serverlessEnabled: deployment.revisionSpec.serverless.enabled,
currentFailureCount: deployment.serverlessWakeFailureCount,
failedStage: "serverless_wake",
}),
@@ -565,10 +562,7 @@ function getInvalidServerlessTransitionReason({
runtimeDesiredState: string;
trafficState: string;
observedPhase: string;
- serverlessEnabled: boolean;
- serverlessSleepAfterSeconds: number;
- serverlessWakeTimeoutSeconds: number;
- deployedConfig: string | null;
+ revisionSpec: ServiceRevisionSpec;
serverIsProxy: boolean;
}
| undefined;
@@ -577,7 +571,7 @@ function getInvalidServerlessTransitionReason({
if (deployment.serverId !== serverId)
return "deployment belongs to another server";
if (!deployment.serverIsProxy) return "server is not a proxy";
- if (!getDeployedServerlessConfig(deployment).enabled) {
+ if (!deployment.revisionSpec.serverless.enabled) {
return "service is not serverless";
}
if (deployment.runtimeDesiredState === "removed") {
@@ -764,9 +758,6 @@ export async function applyStatusReport(
console.log(
`[status:${serverId.slice(0, 8)}] deployment ${dep.id.slice(0, 8)} was removed and container gone, deleting`,
);
- await db
- .delete(deploymentPorts)
- .where(eq(deploymentPorts.deploymentId, dep.id));
await db.delete(deployments).where(eq(deployments.id, dep.id));
} else if (
isObservedActiveContainer(dep.observedPhase) &&
@@ -817,13 +808,20 @@ export async function applyStatusReport(
`[health:recover] found stuck deployment ${stuckDeployment.id}, attaching container ${container.containerId}`,
);
- const service = await db
- .select()
- .from(services)
- .where(eq(services.id, stuckDeployment.serviceId))
- .then((r) => r[0]);
-
- const hasHealthCheck = service?.healthCheckCmd != null;
+ const [service, revision] = await Promise.all([
+ db
+ .select()
+ .from(services)
+ .where(eq(services.id, stuckDeployment.serviceId))
+ .then((r) => r[0]),
+ db
+ .select({ specification: serviceRevisions.specification })
+ .from(serviceRevisions)
+ .where(eq(serviceRevisions.id, stuckDeployment.serviceRevisionId))
+ .then((r) => r[0]),
+ ]);
+
+ const hasHealthCheck = revision?.specification.healthCheck != null;
const newStatus = hasHealthCheck ? "starting" : "healthy";
await db
@@ -913,17 +911,17 @@ export async function applyStatusReport(
deployment.runtimeDesiredState === "running" &&
["sleeping", "stopped"].includes(deployment.observedPhase)
) {
- const service = await db
- .select()
- .from(services)
- .where(eq(services.id, deployment.serviceId))
+ const revision = await db
+ .select({ specification: serviceRevisions.specification })
+ .from(serviceRevisions)
+ .where(eq(serviceRevisions.id, deployment.serviceRevisionId))
.then((r) => r[0]);
- if (service && getDeployedServerlessConfig(service).enabled) {
+ if (revision?.specification.serverless.enabled) {
Object.assign(
updateFields,
getStaleStoppedServerlessReportUpdate({
- hasHealthCheck: service.healthCheckCmd != null,
+ hasHealthCheck: revision.specification.healthCheck != null,
healthStatus,
}),
);
@@ -938,13 +936,20 @@ export async function applyStatusReport(
continue;
}
- const service = await db
- .select()
- .from(services)
- .where(eq(services.id, deployment.serviceId))
- .then((r) => r[0]);
+ const [revision, service] = await Promise.all([
+ db
+ .select({ specification: serviceRevisions.specification })
+ .from(serviceRevisions)
+ .where(eq(serviceRevisions.id, deployment.serviceRevisionId))
+ .then((r) => r[0]),
+ db
+ .select({ migrationStatus: services.migrationStatus })
+ .from(services)
+ .where(eq(services.id, deployment.serviceId))
+ .then((r) => r[0]),
+ ]);
- const hasHealthCheck = service?.healthCheckCmd != null;
+ const hasHealthCheck = revision?.specification.healthCheck != null;
const newStatus = hasHealthCheck ? "starting" : "healthy";
updateFields.observedPhase = newStatus;
if (deployment.observedPhase === "waking") {
diff --git a/web/lib/agent/expected-state.ts b/web/lib/agent/expected-state.ts
index cb13d987..db5c4b36 100644
--- a/web/lib/agent/expected-state.ts
+++ b/web/lib/agent/expected-state.ts
@@ -3,11 +3,9 @@ import { db } from "@/db";
import {
deploymentPorts,
deployments,
- secrets,
servers,
- servicePorts,
+ serviceRevisions,
services,
- serviceVolumes,
} from "@/db/schema";
import { getAllCertificatesForDomains } from "@/lib/acme-manager";
import {
@@ -17,30 +15,35 @@ import {
runtimeExpectedStates,
} from "@/lib/deployment-status";
import {
- getDeployedServerlessConfig,
- getDeployedServicePorts,
- isDeployedServerlessService,
-} from "@/lib/service-config";
-import { slugify } from "@/lib/utils";
+ SERVICE_REVISION_SCHEMA_VERSION,
+ type ServiceRevisionSecret,
+ type ServiceRevisionSpec,
+} from "@/lib/service-revision-spec";
import { getWireGuardPeers } from "@/lib/wireguard";
type Server = typeof servers.$inferSelect;
type Service = typeof services.$inferSelect;
type Deployment = typeof deployments.$inferSelect;
-type ServicePort = typeof servicePorts.$inferSelect;
+type ServiceRevision = typeof serviceRevisions.$inferSelect;
const SERVERLESS_GATEWAY_PORT = 18080;
-type RouteServicePort = Pick<
- ServicePort,
- | "id"
- | "serviceId"
- | "port"
- | "isPublic"
- | "domain"
- | "protocol"
- | "externalPort"
- | "tlsPassthrough"
->;
+type RouteServicePort = {
+ id: string;
+ serviceId: string;
+ port: number;
+ isPublic: boolean;
+ domain: string | null;
+ protocol: "http" | "tcp" | "udp";
+ externalPort: number | null;
+ tlsPassthrough: boolean;
+};
+
+export type RuntimeServiceRevision = {
+ id: string;
+ name: string;
+ revisionId: string;
+ specification: ServiceRevisionSpec;
+};
export type ExpectedContainer = {
deploymentId: string;
@@ -127,18 +130,6 @@ type DeploymentPortRow = {
containerPort: number;
};
-type SecretRow = {
- serviceId: string;
- key: string;
- encryptedValue: string;
-};
-
-type VolumeRow = {
- serviceId: string;
- name: string;
- containerPath: string;
-};
-
type RoutableDeploymentRow = {
serviceId: string;
ipAddress: string | null;
@@ -172,16 +163,19 @@ export async function getServer(serverId: string) {
export async function buildAgentExpectedState(
server: Server,
): Promise {
- const allServices = await getActiveServices();
- const containers = await buildExpectedContainers(server.id);
- const dnsRecords = await buildDnsRecords(allServices);
- const traefikConfig = await buildTraefikConfig(server, allServices);
+ const runtimeServices = await getRuntimeServiceRevisions();
+ const [containers, dnsRecords, traefikConfig, wireguardPeers] =
+ await Promise.all([
+ buildExpectedContainers(server.id),
+ buildDnsRecords(runtimeServices),
+ buildTraefikConfig(server, runtimeServices),
+ getWireGuardPeers(server.id, server.privateIp),
+ ]);
const serverless = await buildServerlessExpectedState(
server,
- allServices,
+ runtimeServices,
containers,
);
- const wireguardPeers = await getWireGuardPeers(server.id, server.privateIp);
return {
serverName: server.name,
@@ -193,8 +187,45 @@ export async function buildAgentExpectedState(
};
}
-async function getActiveServices() {
- return db.select().from(services).where(isNull(services.deletedAt));
+async function getRuntimeServiceRevisions(): Promise {
+ const rows = await db
+ .select({
+ serviceId: services.id,
+ serviceName: services.name,
+ revisionId: serviceRevisions.id,
+ specification: serviceRevisions.specification,
+ })
+ .from(deployments)
+ .innerJoin(services, eq(deployments.serviceId, services.id))
+ .innerJoin(
+ serviceRevisions,
+ eq(deployments.serviceRevisionId, serviceRevisions.id),
+ )
+ .where(
+ and(
+ isNull(services.deletedAt),
+ inArray(deployments.runtimeDesiredState, runtimeExpectedStates),
+ inArray(deployments.trafficState, activeTrafficStates),
+ ),
+ );
+
+ const runtimeServices = new Map();
+ for (const row of rows) {
+ if (row.specification.schemaVersion !== SERVICE_REVISION_SCHEMA_VERSION) {
+ throw new Error(`Service ${row.serviceId} uses an unsupported revision`);
+ }
+ const existing = runtimeServices.get(row.serviceId);
+ if (existing && existing.revisionId !== row.revisionId) {
+ throw new Error(`Service ${row.serviceId} has multiple active revisions`);
+ }
+ runtimeServices.set(row.serviceId, {
+ id: row.serviceId,
+ name: row.serviceName,
+ revisionId: row.revisionId,
+ specification: row.specification,
+ });
+ }
+ return [...runtimeServices.values()].sort((a, b) => a.id.localeCompare(b.id));
}
async function buildExpectedContainers(
@@ -211,42 +242,28 @@ async function buildExpectedContainers(
);
const serviceIds = unique(serverDeployments.map((dep) => dep.serviceId));
+ const revisionIds = unique(
+ serverDeployments.map((dep) => dep.serviceRevisionId),
+ );
if (serviceIds.length === 0) return [];
- const [activeServices, depPorts, serviceSecrets, volumes] = await Promise.all(
- [
- db
- .select()
- .from(services)
- .where(
- and(inArray(services.id, serviceIds), isNull(services.deletedAt)),
- ),
- fetchDeploymentPorts(serverDeployments.map((dep) => dep.id)),
- db
- .select({
- serviceId: secrets.serviceId,
- key: secrets.key,
- encryptedValue: secrets.encryptedValue,
- })
- .from(secrets)
- .where(inArray(secrets.serviceId, serviceIds)),
- db
- .select({
- serviceId: serviceVolumes.serviceId,
- name: serviceVolumes.name,
- containerPath: serviceVolumes.containerPath,
- })
- .from(serviceVolumes)
- .where(inArray(serviceVolumes.serviceId, serviceIds)),
- ],
- );
+ const [activeServices, revisions, depPorts] = await Promise.all([
+ db
+ .select()
+ .from(services)
+ .where(and(inArray(services.id, serviceIds), isNull(services.deletedAt))),
+ db
+ .select()
+ .from(serviceRevisions)
+ .where(inArray(serviceRevisions.id, revisionIds)),
+ fetchDeploymentPorts(serverDeployments.map((dep) => dep.id)),
+ ]);
return buildExpectedContainersFromRows({
deployments: serverDeployments,
services: activeServices,
+ revisions,
deploymentPorts: depPorts,
- secrets: serviceSecrets,
- volumes,
});
}
@@ -257,25 +274,27 @@ async function fetchDeploymentPorts(deploymentIds: string[]) {
.select({
deploymentId: deploymentPorts.deploymentId,
hostPort: deploymentPorts.hostPort,
- containerPort: servicePorts.port,
+ containerPort: deploymentPorts.containerPort,
})
.from(deploymentPorts)
- .innerJoin(servicePorts, eq(deploymentPorts.servicePortId, servicePorts.id))
- .where(inArray(deploymentPorts.deploymentId, deploymentIds));
+ .where(inArray(deploymentPorts.deploymentId, deploymentIds))
+ .orderBy(
+ deploymentPorts.deploymentId,
+ deploymentPorts.containerPort,
+ deploymentPorts.hostPort,
+ );
}
export function buildExpectedContainersFromRows({
deployments: deploymentRows,
services: serviceRows,
+ revisions: revisionRows,
deploymentPorts: deploymentPortRows,
- secrets: secretRows,
- volumes: volumeRows,
}: {
deployments: Deployment[];
services: Service[];
+ revisions: ServiceRevision[];
deploymentPorts: DeploymentPortRow[];
- secrets: SecretRow[];
- volumes: VolumeRow[];
}): ExpectedContainer[] {
const servicesById = new Map(
serviceRows.map((service) => [service.id, service]),
@@ -284,16 +303,61 @@ export function buildExpectedContainersFromRows({
deploymentPortRows,
(port) => port.deploymentId,
);
- const secretsByServiceId = groupBy(secretRows, (secret) => secret.serviceId);
- const volumesByServiceId = groupBy(volumeRows, (volume) => volume.serviceId);
+ const revisionsById = new Map(
+ revisionRows.map((revision) => [revision.id, revision]),
+ );
return deploymentRows
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
.flatMap((dep) => {
const service = servicesById.get(dep.serviceId);
- if (!service) return [];
-
+ const revision = revisionsById.get(dep.serviceRevisionId);
+ if (!service) {
+ console.error(
+ `[expected-state] deployment ${dep.id} omitted because its service is deleted`,
+ );
+ return [];
+ }
+ if (!revision) {
+ throw new Error(`Deployment ${dep.id} has no service revision`);
+ }
+ if (
+ revision.specification.schemaVersion !== SERVICE_REVISION_SCHEMA_VERSION
+ ) {
+ throw new Error(
+ `Deployment ${dep.id} uses an unsupported service revision`,
+ );
+ }
+ const specification = revision.specification;
+ const ports = (portsByDeploymentId.get(dep.id) ?? [])
+ .slice()
+ .sort(
+ (a, b) =>
+ a.containerPort - b.containerPort || a.hostPort - b.hostPort,
+ )
+ .map((port) => ({
+ containerPort: port.containerPort,
+ hostPort: port.hostPort,
+ }));
+ const expectedContainerPorts = specification.ports
+ .map((port) => port.containerPort)
+ .sort((a, b) => a - b);
+ const allocatedContainerPorts = ports.map((port) => port.containerPort);
+ if (
+ JSON.stringify(expectedContainerPorts) !==
+ JSON.stringify(allocatedContainerPorts)
+ ) {
+ throw new Error(`Deployment ${dep.id} has incomplete port allocation`);
+ }
+ const env = buildEnv(specification.secrets);
+ const volumes = specification.volumes
+ .slice()
+ .sort((a, b) => a.containerPath.localeCompare(b.containerPath))
+ .map((volume) => ({
+ name: volume.name,
+ containerPath: volume.containerPath,
+ }));
return [
{
deploymentId: dep.id,
@@ -302,28 +366,16 @@ export function buildExpectedContainersFromRows({
name: `${dep.serviceId}-${dep.id.slice(0, 8)}`,
desiredState:
dep.runtimeDesiredState === "stopped" ? "stopped" : "running",
- image: normalizeImage(service.image),
+ image: normalizeImage(specification.image),
ipAddress: dep.ipAddress,
- ports: (portsByDeploymentId.get(dep.id) ?? [])
- .slice()
- .sort((a, b) => a.containerPort - b.containerPort)
- .map((p) => ({
- containerPort: p.containerPort,
- hostPort: p.hostPort,
- })),
- publishLocalPorts: isDeployedServerlessService(service),
- env: buildEnv(secretsByServiceId.get(dep.serviceId) ?? []),
- startCommand: service.startCommand || null,
- healthCheck: buildHealthCheck(service),
- volumes: (volumesByServiceId.get(dep.serviceId) ?? [])
- .slice()
- .sort((a, b) => a.containerPath.localeCompare(b.containerPath))
- .map((v) => ({
- name: v.name,
- containerPath: v.containerPath,
- })),
- resourceCpuLimit: service.resourceCpuLimit,
- resourceMemoryLimitMb: service.resourceMemoryLimitMb,
+ ports,
+ publishLocalPorts: specification.serverless.enabled,
+ env,
+ startCommand: specification.startCommand,
+ healthCheck: specification.healthCheck,
+ volumes,
+ resourceCpuLimit: specification.resourceLimits.cpuCores,
+ resourceMemoryLimitMb: specification.resourceLimits.memoryMb,
},
];
});
@@ -331,42 +383,39 @@ export function buildExpectedContainersFromRows({
async function buildServerlessExpectedState(
server: Server,
- allServices: Service[],
+ allServices: RuntimeServiceRevision[],
containers: ExpectedContainer[],
): Promise<{ routes: ServerlessRoute[] }> {
if (!server.isProxy) {
return { routes: [] };
}
- const serverlessServices = allServices.filter(isDeployedServerlessService);
+ const serverlessServices = allServices.filter(
+ (service) => service.specification.serverless.enabled,
+ );
if (serverlessServices.length === 0) return { routes: [] };
const serviceIds = serverlessServices.map((service) => service.id);
- const [ports, deploymentRows] = await Promise.all([
- db
- .select()
- .from(servicePorts)
- .where(inArray(servicePorts.serviceId, serviceIds)),
- db
- .select({
- id: deployments.id,
- serviceId: deployments.serviceId,
- serverId: deployments.serverId,
- ipAddress: deployments.ipAddress,
- runtimeDesiredState: deployments.runtimeDesiredState,
- trafficState: deployments.trafficState,
- observedPhase: deployments.observedPhase,
- serverIsProxy: servers.isProxy,
- })
- .from(deployments)
- .innerJoin(servers, eq(deployments.serverId, servers.id))
- .where(
- and(
- inArray(deployments.serviceId, serviceIds),
- inArray(deployments.runtimeDesiredState, runtimeExpectedStates),
- ),
+ const deploymentRows = await db
+ .select({
+ id: deployments.id,
+ serviceId: deployments.serviceId,
+ serverId: deployments.serverId,
+ ipAddress: deployments.ipAddress,
+ runtimeDesiredState: deployments.runtimeDesiredState,
+ trafficState: deployments.trafficState,
+ observedPhase: deployments.observedPhase,
+ serverIsProxy: servers.isProxy,
+ })
+ .from(deployments)
+ .innerJoin(servers, eq(deployments.serverId, servers.id))
+ .where(
+ and(
+ inArray(deployments.serviceId, serviceIds),
+ inArray(deployments.runtimeDesiredState, runtimeExpectedStates),
),
- ]);
+ );
+ const ports = buildRuntimeRoutePorts(serverlessServices);
return {
routes: buildServerlessRoutesFromRows({
@@ -387,7 +436,7 @@ export function buildServerlessRoutesFromRows({
containers,
}: {
serverId: string;
- services: Service[];
+ services: RuntimeServiceRevision[];
ports: RouteServicePort[];
deployments: ServerlessDeploymentRow[];
containers: ExpectedContainer[];
@@ -403,10 +452,10 @@ export function buildServerlessRoutesFromRows({
return serviceRows
.flatMap((service) =>
- getRuntimeServicePortsForRoutes(
+ (portsByServiceId.get(service.id) ?? []).map((port) => ({
service,
- portsByServiceId.get(service.id) ?? [],
- ).map((port) => ({ service, port })),
+ port,
+ })),
)
.sort((a, b) => compareServicePorts(a.port, b.port))
.flatMap(({ service, port }) => {
@@ -451,10 +500,9 @@ export function buildServerlessRoutesFromRows({
serviceId: service.id,
domain: port.domain,
port: port.port,
- sleepAfterSeconds:
- getDeployedServerlessConfig(service).sleepAfterSeconds,
+ sleepAfterSeconds: service.specification.serverless.sleepAfterSeconds,
wakeTimeoutSeconds:
- getDeployedServerlessConfig(service).wakeTimeoutSeconds,
+ service.specification.serverless.wakeTimeoutSeconds,
localDeploymentIds,
upstreams,
},
@@ -462,7 +510,7 @@ export function buildServerlessRoutesFromRows({
});
}
-async function buildDnsRecords(allServices: Service[]) {
+async function buildDnsRecords(allServices: RuntimeServiceRevision[]) {
const serviceIds = allServices.map((service) => service.id);
if (serviceIds.length === 0) return [];
@@ -495,25 +543,21 @@ async function buildDnsRecords(allServices: Service[]) {
if (ips.length === 0) return [];
- const hostname = service.hostname || slugify(service.name);
- return [{ name: `${hostname}.internal`, ips }];
+ return [{ name: `${service.specification.hostname}.internal`, ips }];
})
.sort((a, b) => a.name.localeCompare(b.name));
}
-async function buildTraefikConfig(server: Server, allServices: Service[]) {
+async function buildTraefikConfig(
+ server: Server,
+ allServices: RuntimeServiceRevision[],
+) {
const emptyConfig = { httpRoutes: [], tcpRoutes: [], udpRoutes: [] };
if (!server.isProxy) return emptyConfig;
const serviceIds = allServices.map((service) => service.id);
- const [ports, routableDeployments, proxyHostedServerlessDeployments] =
+ const [routableDeployments, proxyHostedServerlessDeployments] =
await Promise.all([
- serviceIds.length > 0
- ? db
- .select()
- .from(servicePorts)
- .where(inArray(servicePorts.serviceId, serviceIds))
- : Promise.resolve([]),
serviceIds.length > 0
? db
.select({
@@ -556,7 +600,7 @@ async function buildTraefikConfig(server: Server, allServices: Service[]) {
proxyHostedServerlessDeployments,
});
- const routePorts = buildRuntimeRoutePorts(allServices, ports);
+ const routePorts = buildRuntimeRoutePorts(allServices);
const routes = buildTraefikRoutes({
serverId: server.id,
ports: routePorts,
@@ -687,7 +731,7 @@ export function buildServerlessTraefikRouteSets({
proxyHostedServerlessDeployments,
}: {
serverId: string;
- services: Service[];
+ services: RuntimeServiceRevision[];
proxyHostedServerlessDeployments: ProxyHostedServerlessDeploymentRow[];
}) {
const proxyHostedServerlessServiceIds = new Set(
@@ -702,7 +746,7 @@ export function buildServerlessTraefikRouteSets({
serviceRows
.filter(
(service) =>
- isDeployedServerlessService(service) &&
+ service.specification.serverless.enabled &&
proxyHostedServerlessServiceIds.has(service.id),
)
.map((service) => service.id),
@@ -732,19 +776,7 @@ export function buildTraefikCertificateDomains(ports: RouteServicePort[]) {
).sort();
}
-function buildHealthCheck(service: Service): ExpectedContainer["healthCheck"] {
- if (!service.healthCheckCmd) return null;
-
- return {
- cmd: service.healthCheckCmd,
- interval: service.healthCheckInterval ?? 10,
- timeout: service.healthCheckTimeout ?? 5,
- retries: service.healthCheckRetries ?? 3,
- startPeriod: service.healthCheckStartPeriod ?? 30,
- };
-}
-
-function buildEnv(secretRows: SecretRow[]) {
+function buildEnv(secretRows: ServiceRevisionSecret[]) {
const env: Record = {};
for (const secret of secretRows
.slice()
@@ -796,44 +828,20 @@ function groupBy(items: T[], keyFn: (item: T) => K) {
}
export function buildRuntimeRoutePorts(
- serviceRows: Service[],
- portRows: ServicePort[],
+ serviceRows: RuntimeServiceRevision[],
): RouteServicePort[] {
- const portsByServiceId = groupBy(portRows, (port) => port.serviceId);
return serviceRows.flatMap((service) =>
- getRuntimeServicePortsForRoutes(
- service,
- portsByServiceId.get(service.id) ?? [],
- ),
- );
-}
-
-function getRuntimeServicePortsForRoutes(
- service: Service,
- livePorts: RouteServicePort[],
-): RouteServicePort[] {
- const ports = isDeployedServerlessService(service)
- ? getDeployedServicePorts(service, livePorts)
- : livePorts;
- return ports.map((port, index) => {
- const routePort = port as Partial;
- return {
- id:
- typeof routePort.id === "string"
- ? routePort.id
- : `${service.id}:deployed:${index}`,
+ service.specification.ports.map((port, index) => ({
+ id: `${service.revisionId}:${index}`,
serviceId: service.id,
- port: port.port,
+ port: port.containerPort,
isPublic: port.isPublic,
domain: port.domain,
- protocol: port.protocol ?? "http",
- externalPort:
- typeof routePort.externalPort === "number"
- ? routePort.externalPort
- : null,
- tlsPassthrough: routePort.tlsPassthrough ?? false,
- };
- });
+ protocol: port.protocol,
+ externalPort: port.externalPort,
+ tlsPassthrough: port.tlsPassthrough,
+ })),
+ );
}
function compareServicePorts(a: RouteServicePort, b: RouteServicePort) {
diff --git a/web/lib/deploy-service.ts b/web/lib/deploy-service.ts
index 4f78e9d2..aa420c20 100644
--- a/web/lib/deploy-service.ts
+++ b/web/lib/deploy-service.ts
@@ -1,4 +1,3 @@
-import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
@@ -7,6 +6,7 @@ import { rollouts, serviceReplicas } from "@/db/schema";
import { inngest } from "@/lib/inngest/client";
import { inngestEvents } from "@/lib/inngest/events";
import { startMigrationInternal } from "@/lib/migrations";
+import { createRolloutWithServiceRevision } from "@/lib/service-revisions";
export async function deployServiceInternal(serviceId: string) {
const service = await getService(serviceId);
@@ -48,14 +48,7 @@ export async function deployServiceInternal(serviceId: string) {
}
}
- const rolloutId = randomUUID();
-
- await db.insert(rollouts).values({
- id: rolloutId,
- serviceId,
- status: "queued",
- currentStage: "queued",
- });
+ const { rolloutId } = await createRolloutWithServiceRevision(serviceId);
try {
await inngest.send(
diff --git a/web/lib/inngest/functions/rollout-helpers.ts b/web/lib/inngest/functions/rollout-helpers.ts
index 20273c2d..3943b9f6 100644
--- a/web/lib/inngest/functions/rollout-helpers.ts
+++ b/web/lib/inngest/functions/rollout-helpers.ts
@@ -1,19 +1,15 @@
import { randomUUID } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import { db } from "@/db";
-import { getService } from "@/db/queries";
import {
deploymentPorts,
deployments,
- secrets,
+ rollouts,
servers,
- servicePorts,
- serviceReplicas,
services,
- serviceVolumes,
} from "@/db/schema";
import { getCertificate, issueCertificate } from "@/lib/acme-manager";
-import { buildCurrentConfig, getDeployedStateful } from "@/lib/service-config";
+import type { ServiceRevisionSpec } from "@/lib/service-revision-spec";
import { assignContainerIp } from "@/lib/wireguard";
import { enqueueWork } from "@/lib/work-queue";
@@ -23,7 +19,8 @@ const PORT_RANGE_END = 32767;
export type Placement = { serverId: string; replicas: number };
export type DeploymentContext = {
- service: NonNullable>>;
+ revisionId: string;
+ specification: ServiceRevisionSpec;
placements: Placement[];
serverMap: Map<
string,
@@ -33,20 +30,6 @@ export type DeploymentContext = {
isRollingUpdate: boolean;
};
-export function isActiveDeploymentForRollout(
- deployment: { trafficState: string },
- service: {
- deployedConfig?: string | null;
- stateful?: boolean | null;
- serverlessEnabled?: boolean | null;
- serverlessSleepAfterSeconds?: number | null;
- serverlessWakeTimeoutSeconds?: number | null;
- },
-) {
- void service;
- return deployment.trafficState === "active";
-}
-
export function normalizeImage(image: string): string {
if (!image.includes("/")) {
return `docker.io/library/${image}`;
@@ -64,14 +47,14 @@ async function getUsedPorts(serverId: string): Promise> {
.innerJoin(deployments, eq(deploymentPorts.deploymentId, deployments.id))
.where(eq(deployments.serverId, serverId));
- return new Set(existingPorts.map((p) => p.hostPort));
+ return new Set(existingPorts.map((port) => port.hostPort));
}
export async function allocateHostPorts(
serverId: string,
count: number,
): Promise {
- const usedPorts = await getUsedPorts(serverId);
+ const unavailablePorts = await getUsedPorts(serverId);
const allocated: number[] = [];
for (
@@ -79,7 +62,7 @@ export async function allocateHostPorts(
port <= PORT_RANGE_END && allocated.length < count;
port++
) {
- if (!usedPorts.has(port)) {
+ if (!unavailablePorts.has(port)) {
allocated.push(port);
}
}
@@ -91,22 +74,16 @@ export async function allocateHostPorts(
return allocated;
}
-export async function calculateServicePlacements(
- service: NonNullable>>,
-): Promise<{
+export function calculateRevisionPlacements(
+ specification: ServiceRevisionSpec,
+): {
placements: Placement[];
totalReplicas: number;
- migrationNeeded?: { targetServerId: string };
-}> {
- const configuredReplicas = await db
- .select({
- serverId: serviceReplicas.serverId,
- replicas: serviceReplicas.count,
- })
- .from(serviceReplicas)
- .where(eq(serviceReplicas.serviceId, service.id));
-
- const placements = configuredReplicas.filter((p) => p.replicas > 0);
+} {
+ const placements = specification.placements.map((placement) => ({
+ serverId: placement.serverId,
+ replicas: placement.count,
+ }));
const totalReplicas = placements.reduce((sum, p) => sum + p.replicas, 0);
if (totalReplicas < 1) {
@@ -116,7 +93,7 @@ export async function calculateServicePlacements(
throw new Error("Maximum 10 replicas allowed");
}
- if (service.stateful) {
+ if (specification.stateful) {
if (totalReplicas !== 1) {
throw new Error("Stateful services can only have exactly 1 replica");
}
@@ -127,14 +104,6 @@ export async function calculateServicePlacements(
"Stateful services must be deployed to exactly one server",
);
}
-
- const targetServerId = serverIds[0];
- if (service.lockedServerId && service.lockedServerId !== targetServerId) {
- if (service.migrationStatus) {
- throw new Error("Migration already in progress");
- }
- return { placements, totalReplicas, migrationNeeded: { targetServerId } };
- }
}
return { placements, totalReplicas };
@@ -178,63 +147,28 @@ export async function validateServers(
return serverMap;
}
-export async function prepareRollingUpdate(
- serviceId: string,
-): Promise<{ deploymentIds: string[] }> {
- const service = await getService(serviceId);
- if (!service) {
- return { deploymentIds: [] };
- }
-
- const existingDeployments = await db
- .select()
- .from(deployments)
- .where(eq(deployments.serviceId, serviceId));
-
- const runningDeployments = existingDeployments.filter((d) =>
- isActiveDeploymentForRollout(d, service),
- );
-
- return { deploymentIds: runningDeployments.map((d) => d.id) };
-}
-
export async function cleanupTerminalDeployments(
serviceId: string,
): Promise {
- const terminalDeployments = await db
- .select()
- .from(deployments)
+ await db
+ .delete(deployments)
.where(
and(
eq(deployments.serviceId, serviceId),
eq(deployments.runtimeDesiredState, "removed"),
),
);
-
- for (const dep of terminalDeployments) {
- await db
- .delete(deploymentPorts)
- .where(eq(deploymentPorts.deploymentId, dep.id));
- await db.delete(deployments).where(eq(deployments.id, dep.id));
- }
}
export async function cleanupExistingDeployments(
serviceId: string,
): Promise<{ deletedCount: number }> {
- const existingDeployments = await db
- .select()
- .from(deployments)
- .where(eq(deployments.serviceId, serviceId));
+ const deletedDeployments = await db
+ .delete(deployments)
+ .where(eq(deployments.serviceId, serviceId))
+ .returning({ id: deployments.id });
- for (const dep of existingDeployments) {
- await db
- .delete(deploymentPorts)
- .where(eq(deploymentPorts.deploymentId, dep.id));
- await db.delete(deployments).where(eq(deployments.id, dep.id));
- }
-
- return { deletedCount: existingDeployments.length };
+ return { deletedCount: deletedDeployments.length };
}
export type CertificateProvisioningResult = {
@@ -244,17 +178,12 @@ export type CertificateProvisioningResult = {
failedDomains: string[];
};
-export async function issueCertificatesForService(
- serviceId: string,
+export async function issueCertificatesForRevision(
+ specification: ServiceRevisionSpec,
): Promise {
- const servicePortsList = await db
- .select()
- .from(servicePorts)
- .where(eq(servicePorts.serviceId, serviceId));
-
const domainsNeedingCerts = Array.from(
new Set(
- servicePortsList
+ specification.ports
.filter((p) => p.isPublic && p.domain)
.map((p) => (p.domain as string).trim())
.filter(Boolean),
@@ -304,40 +233,9 @@ export async function createDeploymentRecords(
serviceId: string,
context: DeploymentContext,
): Promise<{ deploymentIds: string[] }> {
- const { service, placements, serverMap, totalReplicas } = context;
-
- const servicePortsList = await db
- .select()
- .from(servicePorts)
- .where(eq(servicePorts.serviceId, serviceId));
-
- const serviceSecrets = await db
- .select()
- .from(secrets)
- .where(eq(secrets.serviceId, serviceId));
-
- const volumes = await db
- .select()
- .from(serviceVolumes)
- .where(eq(serviceVolumes.serviceId, serviceId));
-
- const env: Record = {};
- for (const secret of serviceSecrets) {
- env[secret.key] = secret.encryptedValue;
- }
-
- const updateData: { replicas: number; lockedServerId?: string } = {
- replicas: totalReplicas,
- };
-
- if (service.stateful && !service.lockedServerId) {
- updateData.lockedServerId = placements.map((p) => p.serverId)[0];
- }
-
- await db.update(services).set(updateData).where(eq(services.id, serviceId));
+ const { revisionId, specification, placements, serverMap } = context;
const deploymentIds: string[] = [];
- let replicaIndex = 0;
for (const placement of placements) {
if (placement.replicas <= 0) continue;
@@ -348,70 +246,43 @@ export async function createDeploymentRecords(
}
for (let i = 0; i < placement.replicas; i++) {
- replicaIndex++;
+ const deploymentId = randomUUID();
const hostPorts = await allocateHostPorts(
server.id,
- servicePortsList.length,
+ specification.ports.length,
);
const ipAddress = await assignContainerIp(server.id);
- const deploymentId = randomUUID();
- deploymentIds.push(deploymentId);
-
- await db.insert(deployments).values({
- id: deploymentId,
- serviceId,
- serverId: server.id,
- ipAddress,
- runtimeDesiredState: "running",
- trafficState: "candidate",
- observedPhase: "pending",
- rolloutId,
- });
-
- const portMappings: { containerPort: number; hostPort: number }[] = [];
- for (let j = 0; j < servicePortsList.length; j++) {
- const sp = servicePortsList[j];
- const hostPort = hostPorts[j];
-
- await db.insert(deploymentPorts).values({
- id: randomUUID(),
- deploymentId,
- servicePortId: sp.id,
- hostPort,
+ await db.transaction(async (tx) => {
+ await tx.insert(deployments).values({
+ id: deploymentId,
+ serviceId,
+ serviceRevisionId: revisionId,
+ serverId: server.id,
+ ipAddress,
+ runtimeDesiredState: "running",
+ trafficState: "candidate",
+ observedPhase: "pending",
+ rolloutId,
});
- portMappings.push({ containerPort: sp.port, hostPort });
- }
+ if (specification.ports.length > 0) {
+ await tx.insert(deploymentPorts).values(
+ specification.ports.map((port, index) => ({
+ id: randomUUID(),
+ deploymentId,
+ containerPort: port.containerPort,
+ hostPort: hostPorts[index],
+ })),
+ );
+ }
+ });
- const healthCheck = service.healthCheckCmd
- ? {
- cmd: service.healthCheckCmd,
- interval: service.healthCheckInterval ?? 10,
- timeout: service.healthCheckTimeout ?? 5,
- retries: service.healthCheckRetries ?? 3,
- startPeriod: service.healthCheckStartPeriod ?? 30,
- }
- : null;
-
- const volumeMounts = volumes.map((v) => ({
- name: v.name,
- containerPath: v.containerPath,
- }));
+ deploymentIds.push(deploymentId);
await enqueueWork(server.id, "reconcile", {
reason: "rollout_deployment_created",
deploymentId,
- serviceId,
- serviceName: service.name,
- image: normalizeImage(service.image),
- portMappings,
- wireguardIp: server.wireguardIp,
- ipAddress,
- name: `${serviceId}-${replicaIndex}`,
- healthCheck,
- env,
- volumeMounts,
});
}
}
@@ -419,76 +290,82 @@ export async function createDeploymentRecords(
return { deploymentIds };
}
-export async function saveDeployedConfig(
+export async function completeRollout(
+ rolloutId: string,
serviceId: string,
- context: DeploymentContext,
-): Promise {
- const { placements, serverMap } = context;
-
- const servicePortsList = await db
- .select()
- .from(servicePorts)
- .where(eq(servicePorts.serviceId, serviceId));
-
- const serviceSecrets = await db
- .select()
- .from(secrets)
- .where(eq(secrets.serviceId, serviceId));
-
- const volumes = await db
- .select()
- .from(serviceVolumes)
- .where(eq(serviceVolumes.serviceId, serviceId));
-
- const replicaConfigs = placements
- .filter((p) => p.replicas > 0)
- .map((p) => ({
- serverId: p.serverId,
- serverName: serverMap.get(p.serverId)?.name ?? "Unknown",
- count: p.replicas,
- }));
-
- const portConfigs = servicePortsList.map((p) => ({
- port: p.port,
- isPublic: p.isPublic,
- domain: p.domain,
- protocol: p.protocol,
- tlsPassthrough: p.tlsPassthrough,
- }));
-
- const updatedService = await getService(serviceId);
- if (updatedService) {
- const deployedConfig = buildCurrentConfig(
- updatedService,
- replicaConfigs,
- portConfigs,
- serviceSecrets,
- volumes,
- );
+ context: Omit,
+): Promise<{ completed: boolean; stoppedCount: number }> {
+ const { placements, specification, totalReplicas, isRollingUpdate } = context;
+ const lockedServerId = specification.stateful
+ ? placements[0]?.serverId
+ : undefined;
+
+ return db.transaction(async (tx) => {
+ const rollout = await tx
+ .select({ status: rollouts.status })
+ .from(rollouts)
+ .where(eq(rollouts.id, rolloutId))
+ .for("update")
+ .then((rows) => rows[0]);
+ if (rollout?.status !== "in_progress") {
+ return { completed: false, stoppedCount: 0 };
+ }
- await db
+ const stoppedDeployments = isRollingUpdate
+ ? await tx
+ .update(deployments)
+ .set({
+ runtimeDesiredState: "removed",
+ trafficState: "inactive",
+ })
+ .where(
+ and(
+ eq(deployments.serviceId, serviceId),
+ eq(deployments.trafficState, "draining"),
+ ),
+ )
+ .returning({ id: deployments.id })
+ : [];
+
+ await tx
.update(services)
- .set({ deployedConfig: JSON.stringify(deployedConfig) })
+ .set({
+ replicas: totalReplicas,
+ ...(lockedServerId ? { lockedServerId } : {}),
+ })
.where(eq(services.id, serviceId));
- }
+
+ await tx
+ .update(rollouts)
+ .set({
+ status: "completed",
+ currentStage: "completed",
+ completedAt: new Date(),
+ })
+ .where(eq(rollouts.id, rolloutId));
+ return { completed: true, stoppedCount: stoppedDeployments.length };
+ });
}
export async function checkForRollingUpdate(
serviceId: string,
+ specification: ServiceRevisionSpec,
): Promise {
- const service = await getService(serviceId);
- if (!service || getDeployedStateful(service)) {
+ if (specification.stateful) {
return false;
}
- const existingDeployments = await db
- .select()
+ const existingDeployment = await db
+ .select({ id: deployments.id })
.from(deployments)
- .where(eq(deployments.serviceId, serviceId));
-
- const runningDeployments = existingDeployments.filter((d) =>
- isActiveDeploymentForRollout(d, service),
- );
+ .where(
+ and(
+ eq(deployments.serviceId, serviceId),
+ eq(deployments.trafficState, "active"),
+ ),
+ )
+ .limit(1)
+ .then((rows) => rows[0]);
- return runningDeployments.length > 0;
+ return existingDeployment != null;
}
diff --git a/web/lib/inngest/functions/rollout-workflow.ts b/web/lib/inngest/functions/rollout-workflow.ts
index d2f6d5ee..ccafa198 100644
--- a/web/lib/inngest/functions/rollout-workflow.ts
+++ b/web/lib/inngest/functions/rollout-workflow.ts
@@ -2,18 +2,19 @@ import { and, eq, inArray, isNull, lt, ne, or, sql } from "drizzle-orm";
import { db } from "@/db";
import { getService } from "@/db/queries";
import { deployments, rollouts, servers } from "@/db/schema";
+import type { ServiceRevisionSpec } from "@/lib/service-revision-spec";
+import { getRolloutServiceRevision } from "@/lib/service-revisions";
import { ingestRolloutLog } from "@/lib/victoria-logs";
import { inngest } from "../client";
import { inngestEvents } from "../events";
import {
- calculateServicePlacements,
+ calculateRevisionPlacements,
checkForRollingUpdate,
cleanupExistingDeployments,
cleanupTerminalDeployments,
+ completeRollout,
createDeploymentRecords,
- issueCertificatesForService,
- prepareRollingUpdate,
- saveDeployedConfig,
+ issueCertificatesForRevision,
validateServers,
} from "./rollout-helpers";
import { handleRolloutFailure } from "./rollout-utils";
@@ -193,6 +194,19 @@ export const rolloutWorkflow = inngest.createFunction(
return { status: "failed", rolloutId, reason: "queue_timeout" };
}
+ const revision = await step.run("load-service-revision", async () => {
+ const rolloutRevision = await getRolloutServiceRevision(rolloutId);
+ const executionSpecification: ServiceRevisionSpec = {
+ ...rolloutRevision.specification,
+ secrets: [],
+ };
+ return {
+ id: rolloutRevision.id,
+ specification: executionSpecification,
+ };
+ });
+ const specification = revision.specification;
+
await step.run("log-rollout-started", async () => {
await ingestRolloutLog(
rolloutId,
@@ -203,12 +217,8 @@ export const rolloutWorkflow = inngest.createFunction(
});
const placementResult = await step.run("load-placements", async () => {
- const service = await getService(serviceId);
- if (!service) {
- throw new Error("Service not found");
- }
try {
- const result = await calculateServicePlacements(service);
+ const result = calculateRevisionPlacements(specification);
await ingestRolloutLog(
rolloutId,
serviceId,
@@ -286,20 +296,10 @@ export const rolloutWorkflow = inngest.createFunction(
});
const isRollingUpdate = await step.run("check-rolling-update", async () => {
- return checkForRollingUpdate(serviceId);
+ return checkForRollingUpdate(serviceId, specification);
});
- if (isRollingUpdate) {
- await step.run("prepare-rolling-update", async () => {
- await prepareRollingUpdate(serviceId);
- await ingestRolloutLog(
- rolloutId,
- serviceId,
- "preparing",
- "Prepared rolling update",
- );
- });
- } else {
+ if (!isRollingUpdate) {
await step.run("cleanup-existing", async () => {
const { deletedCount } = await cleanupExistingDeployments(serviceId);
if (deletedCount > 0) {
@@ -319,7 +319,7 @@ export const rolloutWorkflow = inngest.createFunction(
.set({ currentStage: "certificates" })
.where(eq(rollouts.id, rolloutId));
try {
- const result = await issueCertificatesForService(serviceId);
+ const result = await issueCertificatesForRevision(specification);
if (result.issuedDomains.length > 0) {
await ingestRolloutLog(
rolloutId,
@@ -360,15 +360,11 @@ export const rolloutWorkflow = inngest.createFunction(
.set({ currentStage: "deploying" })
.where(eq(rollouts.id, rolloutId));
- const service = await getService(serviceId);
- if (!service) {
- throw new Error("Service not found");
- }
-
const serverMap = await validateServers(placements);
const result = await createDeploymentRecords(rolloutId, serviceId, {
- service,
+ revisionId: revision.id,
+ specification,
placements,
serverMap,
totalReplicas,
@@ -386,8 +382,7 @@ export const rolloutWorkflow = inngest.createFunction(
});
await step.run("start-health-check", async () => {
- const service = await getService(serviceId);
- const hasHealthCheck = service?.healthCheckCmd != null;
+ const hasHealthCheck = specification.healthCheck != null;
await db
.update(rollouts)
@@ -583,84 +578,33 @@ export const rolloutWorkflow = inngest.createFunction(
return { status: "rolled_back", rolloutId, reason: "dns_sync_timeout" };
}
- if (isRollingUpdate) {
- await step.run("stop-old-deployments", async () => {
- const stoppedDeploymentsWithoutContainers = await db
- .update(deployments)
- .set({
- runtimeDesiredState: "removed",
- trafficState: "inactive",
- })
- .where(
- and(
- eq(deployments.serviceId, serviceId),
- eq(deployments.trafficState, "draining"),
- isNull(deployments.containerId),
- ),
- )
- .returning({ id: deployments.id });
-
- const stoppingDeployments = await db
- .update(deployments)
- .set({
- runtimeDesiredState: "removed",
- trafficState: "inactive",
- })
- .where(
- and(
- eq(deployments.serviceId, serviceId),
- eq(deployments.trafficState, "draining"),
- ),
- )
- .returning({ id: deployments.id });
-
- const stoppedCount =
- stoppedDeploymentsWithoutContainers.length +
- stoppingDeployments.length;
- if (stoppedCount > 0) {
- await ingestRolloutLog(
- rolloutId,
- serviceId,
- "dns_sync",
- `Stopping ${stoppedCount} old deployment(s) after DNS sync`,
- );
- }
- });
- }
-
- await step.run("save-deployed-config", async () => {
- const service = await getService(serviceId);
- if (!service) {
- throw new Error("Service not found");
- }
-
- const serverMap = await validateServers(placements);
-
- await saveDeployedConfig(serviceId, {
- service,
+ const rolloutCompleted = await step.run("complete-rollout", async () => {
+ const result = await completeRollout(rolloutId, serviceId, {
+ specification,
placements,
- serverMap,
totalReplicas,
isRollingUpdate,
});
- });
-
- await step.run("complete-rollout", async () => {
- await db
- .update(rollouts)
- .set({
- status: "completed",
- currentStage: "completed",
- completedAt: new Date(),
- })
- .where(eq(rollouts.id, rolloutId));
+ if (!result.completed) return false;
+ if (result.stoppedCount > 0) {
+ await ingestRolloutLog(
+ rolloutId,
+ serviceId,
+ "dns_sync",
+ `Stopping ${result.stoppedCount} old deployment(s) after DNS sync`,
+ );
+ }
await ingestRolloutLog(
rolloutId,
serviceId,
"completed",
"Rollout completed successfully",
);
+ return true;
});
+ if (!rolloutCompleted) {
+ return { status: "cancelled", rolloutId };
+ }
return { status: "completed", rolloutId };
},
diff --git a/web/lib/service-config.ts b/web/lib/service-config.ts
index 742aa2aa..22d56181 100644
--- a/web/lib/service-config.ts
+++ b/web/lib/service-config.ts
@@ -1,3 +1,5 @@
+import type { ServiceRevisionSpec } from "@/lib/service-revision-spec";
+
export type ReplicaConfig = {
serverId: string;
serverName: string;
@@ -399,8 +401,8 @@ export function diffConfigs(
});
}
- const deployedCpu = deployed.resourceLimits?.cpuCores;
- const currentCpu = current.resourceLimits?.cpuCores;
+ const deployedCpu = deployed.resourceLimits?.cpuCores ?? null;
+ const currentCpu = current.resourceLimits?.cpuCores ?? null;
if (deployedCpu !== currentCpu) {
changes.push({
field: "CPU limit",
@@ -409,8 +411,8 @@ export function diffConfigs(
});
}
- const deployedMemory = deployed.resourceLimits?.memoryMb;
- const currentMemory = current.resourceLimits?.memoryMb;
+ const deployedMemory = deployed.resourceLimits?.memoryMb ?? null;
+ const currentMemory = current.resourceLimits?.memoryMb ?? null;
if (deployedMemory !== currentMemory) {
changes.push({
field: "Memory limit",
@@ -537,17 +539,6 @@ export function diffConfigs(
return changes;
}
-export function parseDeployedConfig(
- json: string | null,
-): DeployedConfig | null {
- if (!json) return null;
- try {
- return JSON.parse(json) as DeployedConfig;
- } catch {
- return null;
- }
-}
-
export function normalizeServerlessConfig(
config: ServerlessConfig | undefined,
): ServerlessConfig {
@@ -580,49 +571,43 @@ export function getCurrentServerlessConfig(service: {
};
}
-export function getDeployedServerlessConfig(service: {
- deployedConfig?: string | null;
- serverlessEnabled?: boolean | null;
- serverlessSleepAfterSeconds?: number | null;
- serverlessWakeTimeoutSeconds?: number | null;
-}): ServerlessConfig {
- const deployed = parseDeployedConfig(service.deployedConfig ?? null);
- return deployed?.serverless
- ? normalizeServerlessConfig(deployed.serverless)
- : getCurrentServerlessConfig(service);
-}
-
-export function getDeployedStateful(service: {
- deployedConfig?: string | null;
- stateful?: boolean | null;
-}) {
- const deployed = parseDeployedConfig(service.deployedConfig ?? null);
- return deployed?.stateful ?? service.stateful ?? false;
-}
-
-export function isDeployedServerlessService(service: {
- deployedConfig?: string | null;
- stateful?: boolean | null;
- serverlessEnabled?: boolean | null;
- serverlessSleepAfterSeconds?: number | null;
- serverlessWakeTimeoutSeconds?: number | null;
-}) {
- return getDeployedServerlessConfig(service).enabled;
-}
-
-export function getDeployedServicePorts(
- service: { id: string; deployedConfig?: string | null },
- livePorts: PortConfig[],
-): PortConfig[] {
- const deployed = parseDeployedConfig(service.deployedConfig ?? null);
- if (!deployed?.ports) {
- return livePorts;
- }
- return deployed.ports.map((port) => ({
- port: port.port,
- isPublic: port.isPublic,
- domain: port.domain,
- protocol: port.protocol ?? "http",
- tlsPassthrough: port.tlsPassthrough,
- }));
+export function revisionSpecToDeployedConfig(
+ specification: ServiceRevisionSpec,
+ serverNames: Record,
+): DeployedConfig {
+ return {
+ source: { type: "image", image: specification.image },
+ hostname: specification.hostname,
+ stateful: specification.stateful,
+ placement: {
+ replicas: specification.placements.reduce(
+ (sum, placement) => sum + placement.count,
+ 0,
+ ),
+ },
+ replicas: specification.placements.map((placement) => ({
+ serverId: placement.serverId,
+ serverName: serverNames[placement.serverId] ?? "Unknown",
+ count: placement.count,
+ })),
+ healthCheck: specification.healthCheck,
+ startCommand: specification.startCommand,
+ resourceLimits: {
+ cpuCores: specification.resourceLimits.cpuCores,
+ memoryMb: specification.resourceLimits.memoryMb,
+ },
+ ports: specification.ports.map((port) => ({
+ port: port.containerPort,
+ isPublic: port.isPublic,
+ domain: port.domain,
+ protocol: port.protocol,
+ tlsPassthrough: port.tlsPassthrough,
+ })),
+ serverless: specification.serverless,
+ secrets: specification.secrets.map((secret) => ({
+ key: secret.key,
+ updatedAt: secret.updatedAt,
+ })),
+ volumes: specification.volumes,
+ };
}
diff --git a/web/lib/service-revision-changes.ts b/web/lib/service-revision-changes.ts
new file mode 100644
index 00000000..45516edb
--- /dev/null
+++ b/web/lib/service-revision-changes.ts
@@ -0,0 +1,301 @@
+import { z } from "zod";
+import type { RolloutStatus } from "@/db/types";
+import type {
+ ServiceRevisionHealthCheck,
+ ServiceRevisionPort,
+ ServiceRevisionSpec,
+} from "@/lib/service-revision-spec";
+
+const serviceRevisionSpecSchema = z.strictObject({
+ schemaVersion: z.literal(1),
+ image: z.string(),
+ hostname: z.string(),
+ stateful: z.boolean(),
+ serverless: z.strictObject({
+ enabled: z.boolean(),
+ sleepAfterSeconds: z.number(),
+ wakeTimeoutSeconds: z.number(),
+ }),
+ healthCheck: z
+ .strictObject({
+ cmd: z.string(),
+ interval: z.number(),
+ timeout: z.number(),
+ retries: z.number(),
+ startPeriod: z.number(),
+ })
+ .nullable(),
+ startCommand: z.string().nullable(),
+ resourceLimits: z.strictObject({
+ cpuCores: z.number().nullable(),
+ memoryMb: z.number().nullable(),
+ }),
+ placements: z.array(
+ z.strictObject({ serverId: z.string(), count: z.number() }),
+ ),
+ ports: z.array(
+ z.strictObject({
+ containerPort: z.number(),
+ isPublic: z.boolean(),
+ domain: z.string().nullable(),
+ protocol: z.enum(["http", "tcp", "udp"]),
+ externalPort: z.number().nullable(),
+ tlsPassthrough: z.boolean(),
+ }),
+ ),
+ secrets: z.array(
+ z.strictObject({
+ key: z.string(),
+ encryptedValue: z.string(),
+ updatedAt: z.string(),
+ }),
+ ),
+ volumes: z.array(
+ z.strictObject({ name: z.string(), containerPath: z.string() }),
+ ),
+});
+
+export type ServiceRevisionChange = {
+ field: string;
+ from: string;
+ to: string;
+};
+
+export type ServiceRevisionComparison =
+ | { kind: "initial" }
+ | { kind: "changes"; changes: ServiceRevisionChange[] }
+ | { kind: "unavailable" };
+
+export type ServiceRevisionChangelogItem = {
+ id: string;
+ createdAt: string;
+ comparison: ServiceRevisionComparison;
+ rollout: {
+ id: string;
+ status: RolloutStatus;
+ } | null;
+};
+
+export type ServiceRevisionChangelogResponse = {
+ revisions: ServiceRevisionChangelogItem[];
+ nextCursor: string | null;
+};
+
+export function parseServiceRevisionSpec(value: unknown): ServiceRevisionSpec {
+ return serviceRevisionSpecSchema.parse(value);
+}
+
+function compareStrings(a: string, b: string): number {
+ return a.localeCompare(b, "en");
+}
+
+function enabled(value: boolean): string {
+ return value ? "Enabled" : "Disabled";
+}
+
+function healthCheckDescription(
+ healthCheck: ServiceRevisionHealthCheck,
+): string {
+ return `${healthCheck.cmd} (interval ${healthCheck.interval}s, timeout ${healthCheck.timeout}s, retries ${healthCheck.retries}, start period ${healthCheck.startPeriod}s)`;
+}
+
+function portIdentity(port: ServiceRevisionPort): string {
+ return `${port.containerPort}/${port.protocol}/${port.domain ?? "(none)"}`;
+}
+
+function portLabel(port: ServiceRevisionPort): string {
+ return `${port.containerPort}/${port.protocol}`;
+}
+
+function portDescription(port: ServiceRevisionPort): string {
+ return [
+ `container ${port.containerPort}`,
+ `protocol ${port.protocol}`,
+ port.isPublic ? "public" : "internal",
+ `domain ${port.domain ?? "(none)"}`,
+ `external ${port.externalPort ?? "(default)"}`,
+ `TLS passthrough ${enabled(port.tlsPassthrough).toLowerCase()}`,
+ ].join(", ");
+}
+
+/** Compare two immutable v1 specifications without requiring browser APIs. */
+export function diffServiceRevisionSpecs(
+ previous: ServiceRevisionSpec,
+ current: ServiceRevisionSpec,
+ serverNames: ReadonlyMap = new Map(),
+): ServiceRevisionChange[] {
+ const changes: ServiceRevisionChange[] = [];
+ const add = (field: string, from: string, to: string) => {
+ if (from !== to) changes.push({ field, from, to });
+ };
+
+ add("Image", previous.image, current.image);
+ add("Hostname", previous.hostname, current.hostname);
+ add(
+ "Service type",
+ previous.stateful ? "Stateful" : "Stateless",
+ current.stateful ? "Stateful" : "Stateless",
+ );
+ add(
+ "Serverless",
+ enabled(previous.serverless.enabled),
+ enabled(current.serverless.enabled),
+ );
+ add(
+ "Serverless sleep timeout",
+ `${previous.serverless.sleepAfterSeconds}s`,
+ `${current.serverless.sleepAfterSeconds}s`,
+ );
+ add(
+ "Serverless wake timeout",
+ `${previous.serverless.wakeTimeoutSeconds}s`,
+ `${current.serverless.wakeTimeoutSeconds}s`,
+ );
+
+ if (previous.healthCheck === null || current.healthCheck === null) {
+ add(
+ "Health check",
+ previous.healthCheck
+ ? healthCheckDescription(previous.healthCheck)
+ : "(none)",
+ current.healthCheck
+ ? healthCheckDescription(current.healthCheck)
+ : "(none)",
+ );
+ } else {
+ add(
+ "Health check command",
+ previous.healthCheck.cmd,
+ current.healthCheck.cmd,
+ );
+ add(
+ "Health check interval",
+ `${previous.healthCheck.interval}s`,
+ `${current.healthCheck.interval}s`,
+ );
+ add(
+ "Health check timeout",
+ `${previous.healthCheck.timeout}s`,
+ `${current.healthCheck.timeout}s`,
+ );
+ add(
+ "Health check retries",
+ String(previous.healthCheck.retries),
+ String(current.healthCheck.retries),
+ );
+ add(
+ "Health check start period",
+ `${previous.healthCheck.startPeriod}s`,
+ `${current.healthCheck.startPeriod}s`,
+ );
+ }
+
+ add(
+ "Start command",
+ previous.startCommand ?? "(default)",
+ current.startCommand ?? "(default)",
+ );
+ add(
+ "CPU limit",
+ previous.resourceLimits.cpuCores === null
+ ? "(no limit)"
+ : `${previous.resourceLimits.cpuCores} cores`,
+ current.resourceLimits.cpuCores === null
+ ? "(no limit)"
+ : `${current.resourceLimits.cpuCores} cores`,
+ );
+ add(
+ "Memory limit",
+ previous.resourceLimits.memoryMb === null
+ ? "(no limit)"
+ : `${previous.resourceLimits.memoryMb} MB`,
+ current.resourceLimits.memoryMb === null
+ ? "(no limit)"
+ : `${current.resourceLimits.memoryMb} MB`,
+ );
+
+ const previousPlacements = new Map(
+ previous.placements.map((placement) => [placement.serverId, placement]),
+ );
+ const currentPlacements = new Map(
+ current.placements.map((placement) => [placement.serverId, placement]),
+ );
+ for (const serverId of [
+ ...new Set([...previousPlacements.keys(), ...currentPlacements.keys()]),
+ ].sort(compareStrings)) {
+ const before = previousPlacements.get(serverId);
+ const after = currentPlacements.get(serverId);
+ const serverName = serverNames.get(serverId)?.trim();
+ add(
+ serverName
+ ? `${serverName} replicas`
+ : `Deleted server (${serverId.slice(0, 8)}) replicas`,
+ before ? `${before.count} replicas` : "(none)",
+ after ? `${after.count} replicas` : "(removed)",
+ );
+ }
+
+ const previousPorts = new Map(
+ previous.ports.map((port) => [portIdentity(port), port]),
+ );
+ const currentPorts = new Map(
+ current.ports.map((port) => [portIdentity(port), port]),
+ );
+ for (const identity of [
+ ...new Set([...previousPorts.keys(), ...currentPorts.keys()]),
+ ].sort(compareStrings)) {
+ const before = previousPorts.get(identity);
+ const after = currentPorts.get(identity);
+ const port = after ?? before;
+ add(
+ port ? `Port ${portLabel(port)}` : "Port",
+ before ? portDescription(before) : "(none)",
+ after ? portDescription(after) : "(removed)",
+ );
+ }
+
+ const previousSecrets = new Map(
+ previous.secrets.map((secret) => [secret.key, secret]),
+ );
+ const currentSecrets = new Map(
+ current.secrets.map((secret) => [secret.key, secret]),
+ );
+ for (const key of [
+ ...new Set([...previousSecrets.keys(), ...currentSecrets.keys()]),
+ ].sort(compareStrings)) {
+ const before = previousSecrets.get(key);
+ const after = currentSecrets.get(key);
+ if (!before && after)
+ changes.push({ field: "Secret", from: "(none)", to: `${key} (added)` });
+ else if (before && !after)
+ changes.push({ field: "Secret", from: key, to: "(removed)" });
+ else if (
+ before &&
+ after &&
+ (before.encryptedValue !== after.encryptedValue ||
+ before.updatedAt !== after.updatedAt)
+ ) {
+ changes.push({ field: "Secret", from: key, to: `${key} (updated)` });
+ }
+ }
+
+ const previousVolumes = new Map(
+ previous.volumes.map((volume) => [volume.name, volume]),
+ );
+ const currentVolumes = new Map(
+ current.volumes.map((volume) => [volume.name, volume]),
+ );
+ for (const name of [
+ ...new Set([...previousVolumes.keys(), ...currentVolumes.keys()]),
+ ].sort(compareStrings)) {
+ const before = previousVolumes.get(name);
+ const after = currentVolumes.get(name);
+ add(
+ `Volume ${name}`,
+ before?.containerPath ?? "(none)",
+ after?.containerPath ?? "(removed)",
+ );
+ }
+
+ return changes;
+}
diff --git a/web/lib/service-revision-spec.ts b/web/lib/service-revision-spec.ts
new file mode 100644
index 00000000..a8f7aeed
--- /dev/null
+++ b/web/lib/service-revision-spec.ts
@@ -0,0 +1,203 @@
+export const SERVICE_REVISION_SCHEMA_VERSION = 1 as const;
+
+export function getDefaultServiceHostname(name: string): string {
+ return name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "");
+}
+
+export type ServiceRevisionHealthCheck = {
+ cmd: string;
+ interval: number;
+ timeout: number;
+ retries: number;
+ startPeriod: number;
+};
+
+export type ServiceRevisionPlacement = {
+ serverId: string;
+ count: number;
+};
+
+export type ServiceRevisionPort = {
+ containerPort: number;
+ isPublic: boolean;
+ domain: string | null;
+ protocol: "http" | "tcp" | "udp";
+ externalPort: number | null;
+ tlsPassthrough: boolean;
+};
+
+export type ServiceRevisionSecret = {
+ key: string;
+ encryptedValue: string;
+ updatedAt: string;
+};
+
+export type ServiceRevisionVolume = {
+ name: string;
+ containerPath: string;
+};
+
+export type ServiceRevisionSpec = {
+ schemaVersion: typeof SERVICE_REVISION_SCHEMA_VERSION;
+ image: string;
+ hostname: string;
+ stateful: boolean;
+ serverless: {
+ enabled: boolean;
+ sleepAfterSeconds: number;
+ wakeTimeoutSeconds: number;
+ };
+ healthCheck: ServiceRevisionHealthCheck | null;
+ startCommand: string | null;
+ resourceLimits: {
+ cpuCores: number | null;
+ memoryMb: number | null;
+ };
+ placements: ServiceRevisionPlacement[];
+ ports: ServiceRevisionPort[];
+ secrets: ServiceRevisionSecret[];
+ volumes: ServiceRevisionVolume[];
+};
+
+export type ServiceRevisionDraft = {
+ service: {
+ name: string;
+ image: string;
+ hostname: string | null;
+ stateful: boolean | null;
+ serverlessEnabled: boolean | null;
+ serverlessSleepAfterSeconds: number | null;
+ serverlessWakeTimeoutSeconds: number | null;
+ healthCheckCmd: string | null;
+ healthCheckInterval: number | null;
+ healthCheckTimeout: number | null;
+ healthCheckRetries: number | null;
+ healthCheckStartPeriod: number | null;
+ startCommand: string | null;
+ resourceCpuLimit: number | null;
+ resourceMemoryLimitMb: number | null;
+ };
+ placements: Array<{ serverId: string; count: number }>;
+ ports: Array<{
+ port: number;
+ isPublic: boolean;
+ domain: string | null;
+ protocol: "http" | "tcp" | "udp" | null;
+ externalPort: number | null;
+ tlsPassthrough: boolean | null;
+ }>;
+ secrets: Array<{
+ key: string;
+ encryptedValue: string;
+ updatedAt: Date | string;
+ }>;
+ volumes: Array<{ name: string; containerPath: string }>;
+};
+
+function validateServiceRevisionSpec(specification: ServiceRevisionSpec) {
+ const totalReplicas = specification.placements.reduce(
+ (sum, placement) => sum + placement.count,
+ 0,
+ );
+
+ if (totalReplicas < 1) {
+ throw new Error("At least one replica is required");
+ }
+ if (totalReplicas > 10) {
+ throw new Error("Maximum 10 replicas allowed");
+ }
+ if (specification.stateful && totalReplicas !== 1) {
+ throw new Error("Stateful services can only have exactly 1 replica");
+ }
+ if (specification.stateful && specification.placements.length !== 1) {
+ throw new Error("Stateful services must be deployed to exactly one server");
+ }
+}
+
+function compareStrings(a: string, b: string) {
+ return a.localeCompare(b, "en");
+}
+
+export function buildServiceRevisionSpec(
+ draft: ServiceRevisionDraft,
+): ServiceRevisionSpec {
+ const { service } = draft;
+
+ const specification: ServiceRevisionSpec = {
+ schemaVersion: SERVICE_REVISION_SCHEMA_VERSION,
+ image: service.image.trim(),
+ hostname:
+ service.hostname?.trim() || getDefaultServiceHostname(service.name),
+ stateful: service.stateful ?? false,
+ serverless: {
+ enabled: service.serverlessEnabled ?? false,
+ sleepAfterSeconds: Math.max(
+ service.serverlessSleepAfterSeconds ?? 300,
+ 120,
+ ),
+ wakeTimeoutSeconds: service.serverlessWakeTimeoutSeconds ?? 300,
+ },
+ healthCheck: service.healthCheckCmd
+ ? {
+ cmd: service.healthCheckCmd,
+ interval: service.healthCheckInterval ?? 10,
+ timeout: service.healthCheckTimeout ?? 5,
+ retries: service.healthCheckRetries ?? 3,
+ startPeriod: service.healthCheckStartPeriod ?? 30,
+ }
+ : null,
+ startCommand: service.startCommand?.trim() || null,
+ resourceLimits: {
+ cpuCores: service.resourceCpuLimit,
+ memoryMb: service.resourceMemoryLimitMb,
+ },
+ placements: draft.placements
+ .filter((placement) => placement.count > 0)
+ .map((placement) => ({
+ serverId: placement.serverId,
+ count: placement.count,
+ }))
+ .sort((a, b) => compareStrings(a.serverId, b.serverId)),
+ ports: draft.ports
+ .map((port) => ({
+ containerPort: port.port,
+ isPublic: port.isPublic,
+ domain: port.domain?.trim() || null,
+ protocol: port.protocol ?? "http",
+ externalPort: port.externalPort,
+ tlsPassthrough: port.tlsPassthrough ?? false,
+ }))
+ .sort(
+ (a, b) =>
+ a.containerPort - b.containerPort ||
+ compareStrings(a.protocol, b.protocol) ||
+ compareStrings(a.domain ?? "", b.domain ?? "") ||
+ (a.externalPort ?? 0) - (b.externalPort ?? 0),
+ ),
+ secrets: draft.secrets
+ .map((secret) => ({
+ key: secret.key,
+ encryptedValue: secret.encryptedValue,
+ updatedAt:
+ secret.updatedAt instanceof Date
+ ? secret.updatedAt.toISOString()
+ : secret.updatedAt,
+ }))
+ .sort((a, b) => compareStrings(a.key, b.key)),
+ volumes: draft.volumes
+ .map((volume) => ({
+ name: volume.name,
+ containerPath: volume.containerPath,
+ }))
+ .sort(
+ (a, b) =>
+ compareStrings(a.name, b.name) ||
+ compareStrings(a.containerPath, b.containerPath),
+ ),
+ };
+ validateServiceRevisionSpec(specification);
+ return specification;
+}
diff --git a/web/lib/service-revisions.ts b/web/lib/service-revisions.ts
new file mode 100644
index 00000000..eb43fafa
--- /dev/null
+++ b/web/lib/service-revisions.ts
@@ -0,0 +1,113 @@
+import { randomUUID } from "node:crypto";
+import { and, eq, isNull } from "drizzle-orm";
+import { db } from "@/db";
+import {
+ rollouts,
+ secrets,
+ servicePorts,
+ serviceReplicas,
+ serviceRevisions,
+ services,
+ serviceVolumes,
+} from "@/db/schema";
+import {
+ buildServiceRevisionSpec,
+ SERVICE_REVISION_SCHEMA_VERSION,
+} from "@/lib/service-revision-spec";
+
+export async function createRolloutWithServiceRevision(serviceId: string) {
+ return db.transaction(
+ async (tx) => {
+ const service = await tx
+ .select()
+ .from(services)
+ .where(and(eq(services.id, serviceId), isNull(services.deletedAt)))
+ .then((rows) => rows[0]);
+
+ if (!service) {
+ throw new Error("Service not found");
+ }
+
+ const placements = await tx
+ .select({
+ serverId: serviceReplicas.serverId,
+ count: serviceReplicas.count,
+ })
+ .from(serviceReplicas)
+ .where(eq(serviceReplicas.serviceId, serviceId));
+ const ports = await tx
+ .select()
+ .from(servicePorts)
+ .where(eq(servicePorts.serviceId, serviceId));
+ const revisionSecrets = await tx
+ .select({
+ key: secrets.key,
+ encryptedValue: secrets.encryptedValue,
+ updatedAt: secrets.updatedAt,
+ })
+ .from(secrets)
+ .where(eq(secrets.serviceId, serviceId));
+ const volumes = await tx
+ .select({
+ name: serviceVolumes.name,
+ containerPath: serviceVolumes.containerPath,
+ })
+ .from(serviceVolumes)
+ .where(eq(serviceVolumes.serviceId, serviceId));
+
+ const specification = buildServiceRevisionSpec({
+ service,
+ placements,
+ ports,
+ secrets: revisionSecrets,
+ volumes,
+ });
+ const revision = await tx
+ .insert(serviceRevisions)
+ .values({ id: randomUUID(), serviceId, specification })
+ .returning()
+ .then((rows) => rows[0]);
+ if (!revision) {
+ throw new Error("Failed to create service revision");
+ }
+
+ const rolloutId = randomUUID();
+ await tx.insert(rollouts).values({
+ id: rolloutId,
+ serviceId,
+ serviceRevisionId: revision.id,
+ status: "queued",
+ currentStage: "queued",
+ });
+
+ return { rolloutId, revision };
+ },
+ { isolationLevel: "repeatable read" },
+ );
+}
+
+export async function getRolloutServiceRevision(rolloutId: string) {
+ const result = await db
+ .select({
+ revision: serviceRevisions,
+ })
+ .from(rollouts)
+ .innerJoin(
+ serviceRevisions,
+ eq(rollouts.serviceRevisionId, serviceRevisions.id),
+ )
+ .where(eq(rollouts.id, rolloutId))
+ .then((rows) => rows[0]);
+
+ if (!result) {
+ throw new Error("Rollout revision not found");
+ }
+ if (
+ result.revision.specification.schemaVersion !==
+ SERVICE_REVISION_SCHEMA_VERSION
+ ) {
+ throw new Error("Unsupported service revision schema version");
+ }
+
+ return result.revision;
+}
diff --git a/web/lib/victoria-metrics.ts b/web/lib/victoria-metrics.ts
index 2bc4b7d1..a164c836 100644
--- a/web/lib/victoria-metrics.ts
+++ b/web/lib/victoria-metrics.ts
@@ -79,6 +79,8 @@ export type ServiceMetrics = {
windowEnd: string;
stepSeconds: number;
totalRequests: number;
+ totalIngressBytes: number | null;
+ totalEgressBytes: number | null;
statusCodes: string[];
buckets: ServiceMetricsBucket[];
};
@@ -310,6 +312,8 @@ export function createEmptyServiceMetrics(
windowEnd: window.end.toISOString(),
stepSeconds: window.stepSeconds,
totalRequests: 0,
+ totalIngressBytes: null,
+ totalEgressBytes: null,
statusCodes: [],
buckets: [],
};
@@ -328,6 +332,7 @@ export async function queryServiceMetrics(options: {
const serviceMatcher = buildTraefikServiceMatcher(options.serviceId);
const serviceId = escapePromQL(options.serviceId);
const rangeWindow = formatPromDuration(window.stepSeconds);
+ const totalWindow = formatPromDuration(window.durationMs / 1000);
const traefikFilter = `service=~"${serviceMatcher}"`;
const queryStart = addMilliseconds(
window.start,
@@ -345,6 +350,8 @@ export async function queryServiceMetrics(options: {
cpuResults,
memoryPercentResults,
memoryBytesResults,
+ totalIngressBytes,
+ totalEgressBytes,
] = await Promise.all([
queryRangePromQL(endpoint, {
query: `sum by (code) (increase(traefik_service_requests_total{${traefikFilter}}[${rangeWindow}]))`,
@@ -406,6 +413,16 @@ export async function queryServiceMetrics(options: {
end: window.end,
stepSeconds: window.stepSeconds,
}).catch(() => []),
+ queryInstantPromQL(
+ endpoint,
+ `sum(increase(traefik_service_requests_bytes_total{${traefikFilter}}[${totalWindow}]))`,
+ window.end,
+ ).catch(() => null),
+ queryInstantPromQL(
+ endpoint,
+ `sum(increase(traefik_service_responses_bytes_total{${traefikFilter}}[${totalWindow}]))`,
+ window.end,
+ ).catch(() => null),
]);
const buckets = createServiceMetricBuckets(window);
@@ -468,11 +485,40 @@ export async function queryServiceMetrics(options: {
(total, bucket) => total + bucket.totalRequests,
0,
),
+ totalIngressBytes,
+ totalEgressBytes,
statusCodes: sortStatusCodes([...statusCodes]),
buckets,
};
}
+async function queryInstantPromQL(
+ endpoint: EndpointConfig,
+ query: string,
+ time: Date,
+): Promise {
+ const url = new URL(`${endpoint.url}/api/v1/query`);
+ url.searchParams.set("query", query);
+ url.searchParams.set("time", String(Math.floor(time.getTime() / 1000)));
+
+ const response = await fetch(url.toString(), buildFetchOptions(endpoint));
+ if (!response.ok) {
+ throw new Error(
+ `Failed to query metrics: ${response.status} ${response.statusText}`,
+ );
+ }
+
+ const data = (await response.json()) as VictoriaInstantResponse;
+ if (data.status !== "success") {
+ throw new Error(data.error || "Failed to query metrics");
+ }
+
+ const rawValue = data.data?.result?.[0]?.value?.[1];
+ if (rawValue === undefined) return null;
+ const value = Number.parseFloat(rawValue);
+ return Number.isFinite(value) ? value : null;
+}
+
async function queryInstantMetric(
endpoint: EndpointConfig,
metricName: string,
diff --git a/web/tests/expected-state.test.ts b/web/tests/expected-state.test.ts
index 4848957d..fcc15421 100644
--- a/web/tests/expected-state.test.ts
+++ b/web/tests/expected-state.test.ts
@@ -14,27 +14,52 @@ import {
buildTraefikCertificateDomains,
buildTraefikRoutes,
} from "@/lib/agent/expected-state";
+import type { ServiceRevisionSpec } from "@/lib/service-revision-spec";
describe("expected-state pure builders", () => {
- const deployedServerlessConfig = JSON.stringify({
- source: { type: "image", image: "nginx" },
- stateful: false,
- replicas: [],
- healthCheck: null,
- ports: [
- {
- port: 3000,
- isPublic: true,
- domain: "sleepy.example.com",
- protocol: "http",
+ function revision(
+ serviceId: string,
+ overrides: Partial = {},
+ ) {
+ return {
+ id: `rev_${serviceId}`,
+ name: serviceId,
+ serviceId,
+ revisionId: `rev_${serviceId}`,
+ specification: {
+ schemaVersion: 1,
+ image: "nginx",
+ hostname: serviceId,
+ stateful: false,
+ serverless: {
+ enabled: false,
+ sleepAfterSeconds: 300,
+ wakeTimeoutSeconds: 300,
+ },
+ healthCheck: null,
+ startCommand: null,
+ resourceLimits: { cpuCores: null, memoryMb: null },
+ placements: [],
+ ports: [],
+ secrets: [],
+ volumes: [],
+ ...overrides,
},
- ],
- serverless: {
- enabled: true,
- sleepAfterSeconds: 300,
- wakeTimeoutSeconds: 120,
- },
- });
+ } as any;
+ }
+
+ function runtimeRevision(
+ serviceId: string,
+ overrides: Partial = {},
+ ) {
+ const revisionRow = revision(serviceId, overrides);
+ return {
+ id: serviceId,
+ name: serviceId,
+ revisionId: revisionRow.id,
+ specification: revisionRow.specification,
+ };
+ }
it("groups container inputs by deployment and service deterministically", () => {
const containers = buildExpectedContainersFromRows({
@@ -42,6 +67,7 @@ describe("expected-state pure builders", () => {
{
id: "dep_bbbbbbbb",
serviceId: "svc_1",
+ serviceRevisionId: "rev_svc_1",
ipAddress: "10.0.0.2",
runtimeDesiredState: "stopped",
trafficState: "active",
@@ -50,12 +76,65 @@ describe("expected-state pure builders", () => {
{
id: "dep_aaaaaaaa",
serviceId: "svc_1",
+ serviceRevisionId: "rev_svc_1",
ipAddress: "10.0.0.1",
runtimeDesiredState: "running",
trafficState: "active",
observedPhase: "running",
},
] as any,
+ revisions: [
+ revision("svc_1", {
+ startCommand: "npm start",
+ healthCheck: {
+ cmd: "curl -f /health",
+ interval: 10,
+ timeout: 5,
+ retries: 3,
+ startPeriod: 30,
+ },
+ resourceLimits: { cpuCores: 1, memoryMb: 512 },
+ serverless: {
+ enabled: true,
+ sleepAfterSeconds: 300,
+ wakeTimeoutSeconds: 300,
+ },
+ ports: [
+ {
+ containerPort: 80,
+ isPublic: true,
+ domain: "api.example.com",
+ protocol: "http",
+ externalPort: null,
+ tlsPassthrough: false,
+ },
+ {
+ containerPort: 3000,
+ isPublic: false,
+ domain: null,
+ protocol: "http",
+ externalPort: null,
+ tlsPassthrough: false,
+ },
+ ],
+ secrets: [
+ {
+ key: "ZED",
+ encryptedValue: "last",
+ updatedAt: "2026-07-01T00:00:00.000Z",
+ },
+ {
+ key: "ALPHA",
+ encryptedValue: "first",
+ updatedAt: "2026-07-01T00:00:00.000Z",
+ },
+ ],
+ volumes: [
+ { name: "cache", containerPath: "/var/cache" },
+ { name: "data", containerPath: "/data" },
+ ],
+ }),
+ ],
services: [
{
id: "svc_1",
@@ -75,14 +154,8 @@ describe("expected-state pure builders", () => {
deploymentPorts: [
{ deploymentId: "dep_aaaaaaaa", hostPort: 30001, containerPort: 3000 },
{ deploymentId: "dep_aaaaaaaa", hostPort: 80, containerPort: 80 },
- ] as any,
- secrets: [
- { serviceId: "svc_1", key: "ZED", encryptedValue: "last" },
- { serviceId: "svc_1", key: "ALPHA", encryptedValue: "first" },
- ] as any,
- volumes: [
- { serviceId: "svc_1", name: "cache", containerPath: "/var/cache" },
- { serviceId: "svc_1", name: "data", containerPath: "/data" },
+ { deploymentId: "dep_bbbbbbbb", hostPort: 30002, containerPort: 3000 },
+ { deploymentId: "dep_bbbbbbbb", hostPort: 81, containerPort: 80 },
] as any,
});
@@ -118,6 +191,73 @@ describe("expected-state pure builders", () => {
});
});
+ it("rejects partial expected state when a deployment revision is missing", () => {
+ expect(() =>
+ buildExpectedContainersFromRows({
+ deployments: [
+ {
+ id: "dep_missing_revision",
+ serviceId: "svc_1",
+ serviceRevisionId: "rev_missing",
+ runtimeDesiredState: "running",
+ },
+ ] as any,
+ services: [{ id: "svc_1", name: "api" }] as any,
+ revisions: [],
+ deploymentPorts: [],
+ }),
+ ).toThrow("Deployment dep_missing_revision has no service revision");
+ });
+
+ it("omits deployments whose service was soft-deleted", () => {
+ expect(
+ buildExpectedContainersFromRows({
+ deployments: [
+ {
+ id: "dep_deleted_service",
+ serviceId: "svc_1",
+ serviceRevisionId: "rev_svc_1",
+ runtimeDesiredState: "running",
+ },
+ ] as any,
+ services: [],
+ revisions: [revision("svc_1")],
+ deploymentPorts: [],
+ }),
+ ).toEqual([]);
+ });
+
+ it("rejects partial expected state when deployment ports are incomplete", () => {
+ expect(() =>
+ buildExpectedContainersFromRows({
+ deployments: [
+ {
+ id: "dep_incomplete_ports",
+ serviceId: "svc_1",
+ serviceRevisionId: "rev_svc_1",
+ runtimeDesiredState: "running",
+ },
+ ] as any,
+ services: [{ id: "svc_1", name: "api" }] as any,
+ revisions: [
+ revision("svc_1", {
+ ports: [
+ {
+ containerPort: 3000,
+ isPublic: false,
+ domain: null,
+ protocol: "http",
+ externalPort: null,
+ tlsPassthrough: false,
+ },
+ ],
+ }),
+ ],
+ deploymentPorts: [],
+ }),
+ ).toThrow("Deployment dep_incomplete_ports has incomplete port allocation");
+ });
+
it("keeps HTTP local upstreams before remote upstreams", () => {
const routes = buildTraefikRoutes({
serverId: "server_local",
@@ -162,12 +302,22 @@ describe("expected-state pure builders", () => {
{
id: "dep_sleeping",
serviceId: "svc_private",
+ serviceRevisionId: "rev_svc_private",
ipAddress: "10.0.0.10",
runtimeDesiredState: "running",
trafficState: "active",
observedPhase: "running",
},
] as any,
+ revisions: [
+ revision("svc_private", {
+ serverless: {
+ enabled: true,
+ sleepAfterSeconds: 300,
+ wakeTimeoutSeconds: 300,
+ },
+ }),
+ ],
services: [
{
id: "svc_private",
@@ -177,8 +327,6 @@ describe("expected-state pure builders", () => {
},
] as any,
deploymentPorts: [],
- secrets: [],
- volumes: [],
});
expect(containers[0]).toMatchObject({
@@ -193,12 +341,14 @@ describe("expected-state pure builders", () => {
{
id: "dep_stopped",
serviceId: "svc_private",
+ serviceRevisionId: "rev_svc_private",
ipAddress: "10.0.0.10",
runtimeDesiredState: "stopped",
trafficState: "active",
observedPhase: "sleeping",
},
] as any,
+ revisions: [revision("svc_private")],
services: [
{
id: "svc_private",
@@ -208,8 +358,6 @@ describe("expected-state pure builders", () => {
},
] as any,
deploymentPorts: [],
- secrets: [],
- volumes: [],
});
expect(containers[0]).toMatchObject({
@@ -225,12 +373,22 @@ describe("expected-state pure builders", () => {
{
id: "dep_sleeping",
serviceId: "svc_public",
+ serviceRevisionId: "rev_svc_public",
ipAddress: "10.0.0.10",
runtimeDesiredState: "stopped",
trafficState: "active",
observedPhase: "sleeping",
},
] as any,
+ revisions: [
+ revision("svc_public", {
+ serverless: {
+ enabled: true,
+ sleepAfterSeconds: 300,
+ wakeTimeoutSeconds: 300,
+ },
+ }),
+ ],
services: [
{
id: "svc_public",
@@ -240,8 +398,6 @@ describe("expected-state pure builders", () => {
},
] as any,
deploymentPorts: [],
- secrets: [],
- volumes: [],
});
expect(containers[0]).toMatchObject({
@@ -251,30 +407,37 @@ describe("expected-state pure builders", () => {
});
});
- it("keeps deployed serverless sleeping while live serverless settings are disabled", () => {
+ it("keeps revision serverless behavior while draft settings are disabled", () => {
const containers = buildExpectedContainersFromRows({
deployments: [
{
id: "dep_sleeping",
serviceId: "svc_public",
+ serviceRevisionId: "rev_svc_public",
ipAddress: "10.0.0.10",
runtimeDesiredState: "stopped",
trafficState: "active",
observedPhase: "sleeping",
},
] as any,
+ revisions: [
+ revision("svc_public", {
+ serverless: {
+ enabled: true,
+ sleepAfterSeconds: 300,
+ wakeTimeoutSeconds: 120,
+ },
+ }),
+ ],
services: [
{
id: "svc_public",
name: "public-api",
image: "nginx",
serverlessEnabled: false,
- deployedConfig: deployedServerlessConfig,
},
] as any,
deploymentPorts: [],
- secrets: [],
- volumes: [],
});
expect(containers[0]).toMatchObject({
@@ -290,6 +453,7 @@ describe("expected-state pure builders", () => {
{
id: "dep_draining",
serviceId: "svc_public",
+ serviceRevisionId: "rev_svc_public",
ipAddress: "10.0.0.10",
runtimeDesiredState: "stopped",
trafficState: "draining",
@@ -297,6 +461,15 @@ describe("expected-state pure builders", () => {
containerId: null,
},
] as any,
+ revisions: [
+ revision("svc_public", {
+ serverless: {
+ enabled: true,
+ sleepAfterSeconds: 300,
+ wakeTimeoutSeconds: 300,
+ },
+ }),
+ ],
services: [
{
id: "svc_public",
@@ -306,8 +479,6 @@ describe("expected-state pure builders", () => {
},
] as any,
deploymentPorts: [],
- secrets: [],
- volumes: [],
});
expect(containers[0]).toMatchObject({
@@ -347,12 +518,14 @@ describe("expected-state pure builders", () => {
const services: Parameters<
typeof buildServerlessTraefikRouteSets
>[0]["services"] = [
- {
- id: "svc_serverless",
- serverlessEnabled: true,
- stateful: false,
- },
- ] as Parameters[0]["services"];
+ runtimeRevision("svc_serverless", {
+ serverless: {
+ enabled: true,
+ sleepAfterSeconds: 300,
+ wakeTimeoutSeconds: 300,
+ },
+ }),
+ ];
const ports: Parameters[0]["ports"] = [
{
id: "port_1",
@@ -536,14 +709,14 @@ describe("expected-state pure builders", () => {
const routes = buildServerlessRoutesFromRows({
serverId: "proxy_1",
services: [
- {
- id: "svc_1",
- serverlessEnabled: true,
- stateful: false,
- serverlessSleepAfterSeconds: 300,
- serverlessWakeTimeoutSeconds: 120,
- },
- ] as any,
+ runtimeRevision("svc_1", {
+ serverless: {
+ enabled: true,
+ sleepAfterSeconds: 300,
+ wakeTimeoutSeconds: 120,
+ },
+ }),
+ ],
ports: [
{
id: "port_1",
@@ -619,14 +792,15 @@ describe("expected-state pure builders", () => {
const routes = buildServerlessRoutesFromRows({
serverId: "proxy_1",
services: [
- {
- id: "svc_stateful",
- serverlessEnabled: true,
+ runtimeRevision("svc_stateful", {
stateful: true,
- serverlessSleepAfterSeconds: 300,
- serverlessWakeTimeoutSeconds: 120,
- },
- ] as any,
+ serverless: {
+ enabled: true,
+ sleepAfterSeconds: 300,
+ wakeTimeoutSeconds: 120,
+ },
+ }),
+ ],
ports: [
{
id: "port_1",
@@ -674,14 +848,14 @@ describe("expected-state pure builders", () => {
const routes = buildServerlessRoutesFromRows({
serverId: "proxy_1",
services: [
- {
- id: "svc_1",
- serverlessEnabled: true,
- stateful: false,
- serverlessSleepAfterSeconds: 300,
- serverlessWakeTimeoutSeconds: 120,
- },
- ] as any,
+ runtimeRevision("svc_1", {
+ serverless: {
+ enabled: true,
+ sleepAfterSeconds: 300,
+ wakeTimeoutSeconds: 120,
+ },
+ }),
+ ],
ports: [
{
id: "port_1",
@@ -726,20 +900,28 @@ describe("expected-state pure builders", () => {
).toEqual(["dep_new"]);
});
- it("keeps wake metadata from deployed serverless config while live settings are disabled", () => {
- const routes = buildServerlessRoutesFromRows({
- serverId: "proxy_1",
- services: [
+ it("keeps wake metadata and ports pinned to the active revision", () => {
+ const service = runtimeRevision("svc_1", {
+ serverless: {
+ enabled: true,
+ sleepAfterSeconds: 300,
+ wakeTimeoutSeconds: 120,
+ },
+ ports: [
{
- id: "svc_1",
- serverlessEnabled: false,
- stateful: false,
- serverlessSleepAfterSeconds: 60,
- serverlessWakeTimeoutSeconds: 60,
- deployedConfig: deployedServerlessConfig,
+ containerPort: 3000,
+ isPublic: true,
+ domain: "sleepy.example.com",
+ protocol: "http",
+ externalPort: null,
+ tlsPassthrough: false,
},
- ] as any,
- ports: [],
+ ],
+ });
+ const routes = buildServerlessRoutesFromRows({
+ serverId: "proxy_1",
+ services: [service],
+ ports: buildRuntimeRoutePorts([service]),
deployments: [
{
id: "dep_sleeping",
@@ -770,18 +952,26 @@ describe("expected-state pure builders", () => {
]);
});
- it("keeps Traefik gateway route from deployed serverless ports while live ports are removed", () => {
- const ports = buildRuntimeRoutePorts(
- [
- {
- id: "svc_public",
- serverlessEnabled: false,
- stateful: false,
- deployedConfig: deployedServerlessConfig,
+ it("keeps Traefik gateway routes pinned to revision ports", () => {
+ const ports = buildRuntimeRoutePorts([
+ runtimeRevision("svc_public", {
+ serverless: {
+ enabled: true,
+ sleepAfterSeconds: 300,
+ wakeTimeoutSeconds: 120,
},
- ] as any,
- [],
- );
+ ports: [
+ {
+ containerPort: 3000,
+ isPublic: true,
+ domain: "sleepy.example.com",
+ protocol: "http",
+ externalPort: null,
+ tlsPassthrough: false,
+ },
+ ],
+ }),
+ ]);
const routes = buildTraefikRoutes({
serverId: "proxy_1",
diff --git a/web/tests/rollout-helpers.test.ts b/web/tests/rollout-helpers.test.ts
deleted file mode 100644
index 65f96954..00000000
--- a/web/tests/rollout-helpers.test.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-
-vi.mock("@/db", () => ({ db: {} }));
-vi.mock("@/db/queries", () => ({ getService: vi.fn() }));
-vi.mock("@/lib/acme-manager", () => ({
- getCertificate: vi.fn(),
- issueCertificate: vi.fn(),
-}));
-vi.mock("@/lib/wireguard", () => ({
- assignContainerIp: vi.fn(),
-}));
-vi.mock("@/lib/work-queue", () => ({
- enqueueWork: vi.fn(),
-}));
-
-import { isActiveDeploymentForRollout } from "@/lib/inngest/functions/rollout-helpers";
-
-describe("rollout helpers", () => {
- it("treats active traffic deployments as the live rollout version", () => {
- expect(
- isActiveDeploymentForRollout(
- { trafficState: "active" },
- { serverlessEnabled: true },
- ),
- ).toBe(true);
- expect(
- isActiveDeploymentForRollout(
- { trafficState: "candidate" },
- { serverlessEnabled: true },
- ),
- ).toBe(false);
- expect(
- isActiveDeploymentForRollout(
- { trafficState: "draining" },
- { serverlessEnabled: false },
- ),
- ).toBe(false);
- });
-});
diff --git a/web/tests/service-config.test.ts b/web/tests/service-config.test.ts
index 95444145..10a54340 100644
--- a/web/tests/service-config.test.ts
+++ b/web/tests/service-config.test.ts
@@ -2,10 +2,9 @@ import { describe, expect, it } from "vitest";
import {
type DeployedConfig,
diffConfigs,
- getDeployedServerlessConfig,
getCurrentServerlessConfig,
- isDeployedServerlessService,
MIN_SERVERLESS_SLEEP_AFTER_SECONDS,
+ revisionSpecToDeployedConfig,
} from "@/lib/service-config";
function deployedConfig(
@@ -27,24 +26,7 @@ function deployedConfig(
}
describe("service config", () => {
- it("uses deployed serverless settings as the runtime mode", () => {
- const service = {
- serverlessEnabled: false,
- serverlessSleepAfterSeconds: 60,
- serverlessWakeTimeoutSeconds: 60,
- stateful: true,
- deployedConfig: JSON.stringify(deployedConfig()),
- };
-
- expect(getDeployedServerlessConfig(service)).toMatchObject({
- enabled: true,
- sleepAfterSeconds: 300,
- wakeTimeoutSeconds: 120,
- });
- expect(isDeployedServerlessService(service)).toBe(true);
- });
-
- it("enforces the minimum serverless sleep timeout for legacy config", () => {
+ it("enforces the minimum serverless sleep timeout", () => {
expect(
getCurrentServerlessConfig({
serverlessEnabled: true,
@@ -54,33 +36,43 @@ describe("service config", () => {
).toMatchObject({
sleepAfterSeconds: MIN_SERVERLESS_SLEEP_AFTER_SECONDS,
});
- expect(
- getDeployedServerlessConfig({
- deployedConfig: JSON.stringify(
- deployedConfig({
- serverless: {
- enabled: true,
- sleepAfterSeconds: 60,
- wakeTimeoutSeconds: 120,
- },
- }),
- ),
- }),
- ).toMatchObject({
- sleepAfterSeconds: MIN_SERVERLESS_SLEEP_AFTER_SECONDS,
- });
});
- it("allows deployed stateful services to be serverless", () => {
- const service = {
- serverlessEnabled: true,
- stateful: true,
- serverlessSleepAfterSeconds: 300,
- serverlessWakeTimeoutSeconds: 120,
- deployedConfig: JSON.stringify(deployedConfig({ stateful: true })),
- };
+ it("converts an immutable revision for pending-change comparisons", () => {
+ const config = revisionSpecToDeployedConfig(
+ {
+ schemaVersion: 1,
+ image: "nginx",
+ hostname: "api",
+ stateful: false,
+ serverless: {
+ enabled: false,
+ sleepAfterSeconds: 300,
+ wakeTimeoutSeconds: 300,
+ },
+ healthCheck: null,
+ startCommand: null,
+ resourceLimits: { cpuCores: null, memoryMb: null },
+ placements: [{ serverId: "server-1", count: 1 }],
+ ports: [],
+ secrets: [],
+ volumes: [],
+ },
+ { "server-1": "Sydney" },
+ );
+
+ expect(config.replicas).toEqual([
+ { serverId: "server-1", serverName: "Sydney", count: 1 },
+ ]);
+ });
+
+ it("does not report null and omitted resource limits as pending", () => {
+ const deployed = deployedConfig({
+ resourceLimits: { cpuCores: null, memoryMb: null },
+ });
+ const current = deployedConfig();
- expect(isDeployedServerlessService(service)).toBe(true);
+ expect(diffConfigs(deployed, current)).toEqual([]);
});
it("reports serverless changes as pending config", () => {
diff --git a/web/tests/service-revision-changes.test.ts b/web/tests/service-revision-changes.test.ts
new file mode 100644
index 00000000..ba956c5c
--- /dev/null
+++ b/web/tests/service-revision-changes.test.ts
@@ -0,0 +1,192 @@
+import { describe, expect, it } from "vitest";
+import { diffServiceRevisionSpecs } from "@/lib/service-revision-changes";
+import type { ServiceRevisionSpec } from "@/lib/service-revision-spec";
+
+function spec(): ServiceRevisionSpec {
+ return {
+ schemaVersion: 1,
+ image: "app:v1",
+ hostname: "app",
+ stateful: false,
+ serverless: {
+ enabled: false,
+ sleepAfterSeconds: 300,
+ wakeTimeoutSeconds: 60,
+ },
+ healthCheck: {
+ cmd: "curl /health",
+ interval: 10,
+ timeout: 5,
+ retries: 3,
+ startPeriod: 30,
+ },
+ startCommand: null,
+ resourceLimits: { cpuCores: null, memoryMb: null },
+ placements: [{ serverId: "server-a", count: 1 }],
+ ports: [
+ {
+ containerPort: 80,
+ protocol: "http",
+ isPublic: false,
+ domain: null,
+ externalPort: null,
+ tlsPassthrough: false,
+ },
+ ],
+ secrets: [
+ { key: "TOKEN", encryptedValue: "cipher-one", updatedAt: "2026-01-01" },
+ ],
+ volumes: [{ name: "data", containerPath: "/data" }],
+ };
+}
+
+describe("diffServiceRevisionSpecs", () => {
+ it("reports representative scalar and health check changes", () => {
+ const previous = spec();
+ const current = structuredClone(previous);
+ current.image = "app:v2";
+ current.hostname = "new-app";
+ current.stateful = true;
+ current.serverless = {
+ enabled: true,
+ sleepAfterSeconds: 600,
+ wakeTimeoutSeconds: 90,
+ };
+ current.healthCheck = {
+ cmd: "wget /ready",
+ interval: 20,
+ timeout: 8,
+ retries: 5,
+ startPeriod: 40,
+ };
+ current.startCommand = "npm start";
+ current.resourceLimits = { cpuCores: 2, memoryMb: 512 };
+
+ expect(diffServiceRevisionSpecs(previous, current)).toEqual(
+ expect.arrayContaining([
+ { field: "Image", from: "app:v1", to: "app:v2" },
+ { field: "Health check start period", from: "30s", to: "40s" },
+ { field: "Start command", from: "(default)", to: "npm start" },
+ { field: "CPU limit", from: "(no limit)", to: "2 cores" },
+ { field: "Memory limit", from: "(no limit)", to: "512 MB" },
+ ]),
+ );
+ });
+
+ it("compares collections and includes every port property", () => {
+ const previous = spec();
+ const current = structuredClone(previous);
+ current.placements[0].count = 2;
+ current.volumes[0].containerPath = "/mnt/data";
+ current.ports[0] = {
+ containerPort: 80,
+ protocol: "http",
+ isPublic: true,
+ domain: "app.test",
+ externalPort: 443,
+ tlsPassthrough: true,
+ };
+ current.ports.push({
+ containerPort: 80,
+ protocol: "tcp",
+ isPublic: false,
+ domain: null,
+ externalPort: 8080,
+ tlsPassthrough: false,
+ });
+
+ const changes = diffServiceRevisionSpecs(
+ previous,
+ current,
+ new Map([["server-a", "Sydney"]]),
+ );
+ expect(changes).toContainEqual({
+ field: "Sydney replicas",
+ from: "1 replicas",
+ to: "2 replicas",
+ });
+ expect(changes).toContainEqual({
+ field: "Volume data",
+ from: "/data",
+ to: "/mnt/data",
+ });
+ expect(changes).toContainEqual({
+ field: "Port 80/http",
+ from: "container 80, protocol http, internal, domain (none), external (default), TLS passthrough disabled",
+ to: "(removed)",
+ });
+ expect(changes).toContainEqual({
+ field: "Port 80/http",
+ from: "(none)",
+ to: "container 80, protocol http, public, domain app.test, external 443, TLS passthrough enabled",
+ });
+ expect(changes).toContainEqual(
+ expect.objectContaining({ field: "Port 80/tcp" }),
+ );
+ });
+
+ it("identifies deleted placement servers without exposing full IDs", () => {
+ const previous = spec();
+ const current = structuredClone(previous);
+ current.placements[0].count = 2;
+
+ expect(diffServiceRevisionSpecs(previous, current)).toContainEqual({
+ field: "Deleted server (server-a) replicas",
+ from: "1 replicas",
+ to: "2 replicas",
+ });
+ });
+
+ it("never exposes secret ciphertext while detecting additions, updates, and removals", () => {
+ const previous = spec();
+ previous.secrets.push({
+ key: "OLD",
+ encryptedValue: "do-not-leak-old",
+ updatedAt: "1",
+ });
+ const current = structuredClone(previous);
+ current.secrets = [
+ {
+ key: "TOKEN",
+ encryptedValue: "do-not-leak-new",
+ updatedAt: "2026-01-01",
+ },
+ { key: "NEW", encryptedValue: "do-not-leak-added", updatedAt: "1" },
+ ];
+
+ const changes = diffServiceRevisionSpecs(previous, current);
+ expect(changes).toEqual([
+ { field: "Secret", from: "(none)", to: "NEW (added)" },
+ { field: "Secret", from: "OLD", to: "(removed)" },
+ { field: "Secret", from: "TOKEN", to: "TOKEN (updated)" },
+ ]);
+ expect(JSON.stringify(changes)).not.toContain("do-not-leak");
+ });
+
+ it("ignores canonical-equivalent array ordering and identical specs", () => {
+ const previous = spec();
+ previous.placements.push({ serverId: "server-b", count: 2 });
+ previous.ports.push({
+ containerPort: 53,
+ protocol: "udp",
+ isPublic: false,
+ domain: null,
+ externalPort: null,
+ tlsPassthrough: false,
+ });
+ previous.secrets.push({
+ key: "OTHER",
+ encryptedValue: "cipher-two",
+ updatedAt: "2",
+ });
+ previous.volumes.push({ name: "logs", containerPath: "/logs" });
+ const reordered = structuredClone(previous);
+ reordered.placements.reverse();
+ reordered.ports.reverse();
+ reordered.secrets.reverse();
+ reordered.volumes.reverse();
+
+ expect(diffServiceRevisionSpecs(previous, reordered)).toEqual([]);
+ expect(diffServiceRevisionSpecs(previous, previous)).toEqual([]);
+ });
+});
diff --git a/web/tests/service-revision-spec.test.ts b/web/tests/service-revision-spec.test.ts
new file mode 100644
index 00000000..24bc3624
--- /dev/null
+++ b/web/tests/service-revision-spec.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildServiceRevisionSpec,
+ type ServiceRevisionDraft,
+} from "@/lib/service-revision-spec";
+
+function draft(
+ overrides: Partial = {},
+): ServiceRevisionDraft {
+ return {
+ service: {
+ name: "API Service",
+ image: "nginx:latest",
+ hostname: "api.internal",
+ stateful: false,
+ serverlessEnabled: false,
+ serverlessSleepAfterSeconds: 300,
+ serverlessWakeTimeoutSeconds: 300,
+ healthCheckCmd: "curl -f http://localhost/health",
+ healthCheckInterval: 10,
+ healthCheckTimeout: 5,
+ healthCheckRetries: 3,
+ healthCheckStartPeriod: 30,
+ startCommand: null,
+ resourceCpuLimit: null,
+ resourceMemoryLimitMb: null,
+ },
+ placements: [
+ { serverId: "server-b", count: 1 },
+ { serverId: "server-a", count: 2 },
+ ],
+ ports: [
+ {
+ port: 443,
+ isPublic: true,
+ domain: "api.example.com",
+ protocol: "tcp",
+ externalPort: 443,
+ tlsPassthrough: true,
+ },
+ {
+ port: 80,
+ isPublic: false,
+ domain: null,
+ protocol: "http",
+ externalPort: null,
+ tlsPassthrough: false,
+ },
+ ],
+ secrets: [
+ {
+ key: "TOKEN",
+ encryptedValue: "ciphertext-2",
+ updatedAt: "2026-07-01T00:00:00.000Z",
+ },
+ {
+ key: "API_KEY",
+ encryptedValue: "ciphertext-1",
+ updatedAt: "2026-07-01T00:00:00.000Z",
+ },
+ ],
+ volumes: [
+ { name: "logs", containerPath: "/logs" },
+ { name: "data", containerPath: "/data" },
+ ],
+ ...overrides,
+ };
+}
+
+describe("service revision specification", () => {
+ it("normalizes draft row ordering", () => {
+ const first = buildServiceRevisionSpec(draft());
+ const reorderedDraft = draft();
+ reorderedDraft.placements.reverse();
+ reorderedDraft.ports.reverse();
+ reorderedDraft.secrets.reverse();
+ reorderedDraft.volumes.reverse();
+ const second = buildServiceRevisionSpec(reorderedDraft);
+
+ expect(second).toEqual(first);
+ });
+
+ it("normalizes defaults once", () => {
+ const input = draft();
+ input.service.serverlessSleepAfterSeconds = 30;
+ input.service.healthCheckInterval = null;
+ input.ports[0].protocol = null;
+ input.ports[0].tlsPassthrough = null;
+
+ const spec = buildServiceRevisionSpec(input);
+
+ expect(spec.serverless.sleepAfterSeconds).toBe(120);
+ expect(spec.healthCheck?.interval).toBe(10);
+ expect(spec.ports[1]).toMatchObject({
+ protocol: "http",
+ tlsPassthrough: false,
+ });
+ });
+
+ it("rejects an invalid replica layout before it can be persisted", () => {
+ const input = draft({ placements: [] });
+
+ expect(() => buildServiceRevisionSpec(input)).toThrow(
+ "At least one replica is required",
+ );
+ });
+});
diff --git a/web/tests/service-revisions-route.test.ts b/web/tests/service-revisions-route.test.ts
new file mode 100644
index 00000000..7286125c
--- /dev/null
+++ b/web/tests/service-revisions-route.test.ts
@@ -0,0 +1,219 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { ServiceRevisionSpec } from "@/lib/service-revision-spec";
+
+const mocks = vi.hoisted(() => {
+ const queryResults: unknown[][] = [];
+
+ function createQuery(result: unknown[]) {
+ const query = {
+ from: vi.fn(() => query),
+ where: vi.fn(() => query),
+ orderBy: vi.fn(() => query),
+ limit: vi.fn(() => query),
+ // biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are awaitable.
+ then: (
+ resolve: (value: unknown[]) => unknown,
+ reject?: (reason: unknown) => unknown,
+ ) => Promise.resolve(result).then(resolve, reject),
+ };
+ return query;
+ }
+
+ return {
+ queryResults,
+ db: {
+ select: vi.fn(() => createQuery(queryResults.shift() ?? [])),
+ },
+ requireRequestSession: vi.fn(),
+ };
+});
+
+vi.mock("@/db", () => ({ db: mocks.db }));
+vi.mock("@/lib/api-auth", () => ({
+ requireRequestSession: mocks.requireRequestSession,
+}));
+
+import { GET } from "@/app/api/services/[id]/revisions/route";
+
+function revisionSpec(
+ image = "app:v1",
+ encryptedValue = "cipher",
+): ServiceRevisionSpec {
+ return {
+ schemaVersion: 1,
+ image,
+ hostname: "app",
+ stateful: false,
+ serverless: {
+ enabled: false,
+ sleepAfterSeconds: 300,
+ wakeTimeoutSeconds: 300,
+ },
+ healthCheck: null,
+ startCommand: null,
+ resourceLimits: { cpuCores: null, memoryMb: null },
+ placements: [{ serverId: "server-1", count: 1 }],
+ ports: [],
+ secrets: [{ key: "TOKEN", encryptedValue, updatedAt: "2026-01-01" }],
+ volumes: [],
+ };
+}
+
+function request(cursor?: string) {
+ const url = new URL("http://localhost/api/services/service-1/revisions");
+ if (cursor) url.searchParams.set("cursor", cursor);
+ return GET(new Request(url), {
+ params: Promise.resolve({ id: "service-1" }),
+ });
+}
+
+describe("service revisions route", () => {
+ beforeEach(() => {
+ mocks.queryResults.length = 0;
+ mocks.db.select.mockClear();
+ mocks.requireRequestSession.mockReset();
+ mocks.requireRequestSession.mockResolvedValue({
+ ok: true,
+ session: { user: { id: "user-1" } },
+ });
+ });
+
+ it("requires authentication", async () => {
+ mocks.requireRequestSession.mockResolvedValue({
+ ok: false,
+ response: Response.json({ message: "Unauthorized" }, { status: 401 }),
+ });
+
+ const response = await request();
+
+ expect(response.status).toBe(401);
+ expect(mocks.db.select).not.toHaveBeenCalled();
+ });
+
+ it("rejects malformed cursors before querying the service", async () => {
+ const response = await request("not-a-cursor");
+
+ expect(response.status).toBe(400);
+ expect(await response.json()).toEqual({
+ message: "Invalid revision cursor",
+ });
+ expect(mocks.db.select).not.toHaveBeenCalled();
+ });
+
+ it("rejects parseable timestamps that PostgreSQL may not accept", async () => {
+ const cursor = Buffer.from(
+ JSON.stringify({
+ createdAt: "Mon Jul 13 2026 02:00:00 GMT+0000",
+ id: "revision-1",
+ }),
+ ).toString("base64url");
+
+ const response = await request(cursor);
+
+ expect(response.status).toBe(400);
+ expect(mocks.db.select).not.toHaveBeenCalled();
+ });
+
+ it("returns safe changes and rollout metadata", async () => {
+ mocks.queryResults.push(
+ [{ id: "service-1" }],
+ [
+ {
+ id: "revision-2",
+ createdAt: new Date("2026-07-13T02:00:00Z"),
+ cursorCreatedAt: "2026-07-13 02:00:00+00",
+ specification: revisionSpec("app:v2", "secret-new"),
+ },
+ {
+ id: "revision-1",
+ createdAt: new Date("2026-07-13T01:00:00Z"),
+ cursorCreatedAt: "2026-07-13 01:00:00+00",
+ specification: revisionSpec("app:v1", "secret-old"),
+ },
+ ],
+ [{ id: "server-1", name: "Sydney" }],
+ [
+ {
+ id: "rollout-2",
+ serviceRevisionId: "revision-2",
+ status: "completed",
+ createdAt: new Date("2026-07-13T02:00:00Z"),
+ },
+ ],
+ );
+
+ const response = await request();
+ const body = await response.json();
+
+ expect(response.status).toBe(200);
+ expect(body.revisions[0]).toMatchObject({
+ id: "revision-2",
+ rollout: { id: "rollout-2", status: "completed" },
+ comparison: {
+ kind: "changes",
+ changes: expect.arrayContaining([
+ { field: "Image", from: "app:v1", to: "app:v2" },
+ { field: "Secret", from: "TOKEN", to: "TOKEN (updated)" },
+ ]),
+ },
+ });
+ expect(body.revisions[1].comparison).toEqual({ kind: "initial" });
+ expect(JSON.stringify(body)).not.toContain("secret-new");
+ expect(JSON.stringify(body)).not.toContain("secret-old");
+ expect(JSON.stringify(body)).not.toContain("specification");
+ });
+
+ it("uses the extra revision as the page boundary comparison", async () => {
+ const revisions = Array.from({ length: 26 }, (_, index) => ({
+ id: `revision-${String(26 - index).padStart(2, "0")}`,
+ createdAt: new Date(Date.UTC(2026, 6, 13, 2, 0, 26 - index)),
+ cursorCreatedAt: `2026-07-13 02:00:${String(26 - index).padStart(2, "0")}.123456+00`,
+ specification: revisionSpec(`app:v${26 - index}`),
+ }));
+ mocks.queryResults.push([{ id: "service-1" }], revisions, [], []);
+
+ const response = await request();
+ const body = await response.json();
+
+ expect(body.revisions).toHaveLength(25);
+ expect(body.nextCursor).toEqual(expect.any(String));
+ expect(
+ JSON.parse(Buffer.from(body.nextCursor, "base64url").toString("utf8")),
+ ).toMatchObject({ createdAt: expect.stringContaining(".123456") });
+ expect(body.revisions[24].comparison).toMatchObject({
+ kind: "changes",
+ changes: [{ field: "Image", from: "app:v1", to: "app:v2" }],
+ });
+ });
+
+ it("rejects malformed v1 specifications without exposing their values", async () => {
+ mocks.queryResults.push(
+ [{ id: "service-1" }],
+ [
+ {
+ id: "revision-2",
+ createdAt: new Date("2026-07-13T02:00:00Z"),
+ cursorCreatedAt: "2026-07-13 02:00:00+00",
+ specification: {
+ ...revisionSpec(),
+ image: { encryptedValue: "must-not-leak" },
+ },
+ },
+ {
+ id: "revision-1",
+ createdAt: new Date("2026-07-13T01:00:00Z"),
+ cursorCreatedAt: "2026-07-13 01:00:00+00",
+ specification: revisionSpec(),
+ },
+ ],
+ [],
+ [],
+ );
+
+ const response = await request();
+ const body = await response.json();
+
+ expect(body.revisions[0].comparison).toEqual({ kind: "unavailable" });
+ expect(JSON.stringify(body)).not.toContain("must-not-leak");
+ });
+});
diff --git a/web/tests/victoria-metrics-service-metrics.test.ts b/web/tests/victoria-metrics-service-metrics.test.ts
index 40010cc2..ef489e0f 100644
--- a/web/tests/victoria-metrics-service-metrics.test.ts
+++ b/web/tests/victoria-metrics-service-metrics.test.ts
@@ -41,6 +41,8 @@ describe("VictoriaMetrics service metrics", () => {
windowEnd: "2026-07-02T01:00:00.000Z",
stepSeconds: 60,
totalRequests: 0,
+ totalIngressBytes: null,
+ totalEgressBytes: null,
statusCodes: [],
buckets: [],
});
@@ -52,13 +54,26 @@ describe("VictoriaMetrics service metrics", () => {
const queries: string[] = [];
const starts: string[] = [];
+ const instantTimes: string[] = [];
const fetchMock = vi.fn(async (input: string | URL | Request) => {
const url = new URL(String(input));
const query = url.searchParams.get("query") || "";
queries.push(query);
- starts.push(url.searchParams.get("start") || "");
+ if (url.pathname === "/api/v1/query_range") {
+ starts.push(url.searchParams.get("start") || "");
+ }
+
+ expect(["/api/v1/query", "/api/v1/query_range"]).toContain(url.pathname);
- expect(url.pathname).toBe("/api/v1/query_range");
+ if (url.pathname === "/api/v1/query") {
+ instantTimes.push(url.searchParams.get("time") || "");
+ if (query.includes("traefik_service_requests_bytes_total")) {
+ return instantJsonResponse("460");
+ }
+ if (query.includes("traefik_service_responses_bytes_total")) {
+ return instantJsonResponse("683051");
+ }
+ }
if (query.includes("traefik_service_requests_total")) {
return jsonResponse([
@@ -91,6 +106,9 @@ describe("VictoriaMetrics service metrics", () => {
if (query.includes("histogram_quantile(0.95")) {
return jsonResponse([{ metric: {}, values: [[END_TS, "0.123"]] }]);
}
+ if (query.includes("traefik_service_requests_bytes_total")) {
+ return jsonResponse([{ metric: {}, values: [[END_TS, "512"]] }]);
+ }
if (query.includes("traefik_service_responses_bytes_total")) {
return jsonResponse([{ metric: {}, values: [[END_TS, "2048"]] }]);
}
@@ -108,7 +126,8 @@ describe("VictoriaMetrics service metrics", () => {
now: new Date("2026-07-02T12:34:00Z"),
});
- expect(fetchMock).toHaveBeenCalledTimes(10);
+ expect(fetchMock).toHaveBeenCalledTimes(12);
+ expect(instantTimes).toEqual([String(END_TS), String(END_TS)]);
expect(queries.some((query) => query.includes("LogSQL"))).toBe(false);
expect(queries).toContain(
`sum by (code) (increase(traefik_service_requests_total{service=~"^${SERVICE_ID}(@file)?$"}[5m]))`,
@@ -119,10 +138,18 @@ describe("VictoriaMetrics service metrics", () => {
expect(queries).toContain(
`sum(avg_over_time(techulus_service_memory_usage_percent{service_id="${SERVICE_ID}"}[5m]))`,
);
+ expect(queries).toContain(
+ `sum(increase(traefik_service_requests_bytes_total{service=~"^${SERVICE_ID}(@file)?$"}[1d]))`,
+ );
+ expect(queries).toContain(
+ `sum(increase(traefik_service_responses_bytes_total{service=~"^${SERVICE_ID}(@file)?$"}[1d]))`,
+ );
expect(
starts.every((start) => start === String(END_TS - 24 * 60 * 60 + 5 * 60)),
).toBe(true);
expect(stats.totalRequests).toBe(21);
+ expect(stats.totalIngressBytes).toBe(460);
+ expect(stats.totalEgressBytes).toBe(683051);
expect(stats.statusCodes).toEqual(["2xx", "3xx", "4xx", "5xx"]);
expect(stats.buckets).toHaveLength(288);
expect(stats.windowEnd).toBe("2026-07-02T12:30:00.000Z");
@@ -133,10 +160,38 @@ describe("VictoriaMetrics service metrics", () => {
totalRequests: 21,
statuses: { "2xx": 5, "3xx": 1, "4xx": 4, "5xx": 11 },
p95ResponseTimeMs: 123,
+ ingressBytesPerSecond: 512,
egressBytesPerSecond: 2048,
cpuUsagePercent: 42,
});
});
+
+ it("keeps service metrics available when traffic total queries fail", async () => {
+ vi.stubEnv("VICTORIA_METRICS_URL", "http://victoria.test");
+ vi.stubEnv("VICTORIA_METRICS_PRIVATE_URL", "");
+
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: string | URL | Request) => {
+ const url = new URL(String(input));
+ if (url.pathname === "/api/v1/query") {
+ return new Response("Unavailable", { status: 503 });
+ }
+ return jsonResponse([]);
+ }),
+ );
+
+ const stats = await queryServiceMetrics({
+ serviceId: SERVICE_ID,
+ range: "1h",
+ now: new Date("2026-07-02T12:34:00Z"),
+ });
+
+ expect(stats.metricsEnabled).toBe(true);
+ expect(stats.totalIngressBytes).toBeNull();
+ expect(stats.totalEgressBytes).toBeNull();
+ expect(stats.buckets).toHaveLength(60);
+ });
});
function jsonResponse(result: unknown[]) {
@@ -147,3 +202,12 @@ function jsonResponse(result: unknown[]) {
}),
);
}
+
+function instantJsonResponse(value: string) {
+ return new Response(
+ JSON.stringify({
+ status: "success",
+ data: { result: [{ metric: {}, value: [END_TS, value] }] },
+ }),
+ );
+}