Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: web
push: true
Expand Down Expand Up @@ -123,7 +123,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: registry
push: true
Expand Down
20 changes: 18 additions & 2 deletions agent/cmd/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"flag"
"log"
"net/url"
"os"
"os/signal"
"path/filepath"
Expand Down Expand Up @@ -33,6 +34,21 @@ var (
dataDir string = paths.DataDir
)

func sanitizedEndpoint(endpoint string) string {
parsedURL, err := url.Parse(endpoint)
if err != nil || parsedURL.User == nil {
return endpoint
}

if _, hasPassword := parsedURL.User.Password(); hasPassword {
parsedURL.User = url.UserPassword("xxxxx", "xxxxx")
} else {
parsedURL.User = url.User("xxxxx")
}

return parsedURL.String()
}

func main() {
var (
controlPlaneURL string
Expand Down Expand Up @@ -274,7 +290,7 @@ func main() {
var metricsSender agent.MetricsSender

if logsEndpoint != "" {
log.Println("[logs] log collection enabled, endpoint:", logsEndpoint)
log.Println("[logs] log collection enabled, endpoint:", sanitizedEndpoint(logsEndpoint))
logsSender = logs.NewVictoriaLogsSender(logsEndpoint, config.ServerID)
logCollector = logs.NewCollector(logsSender, dataDir)
if isProxy {
Expand All @@ -287,7 +303,7 @@ func main() {
}

if metricsEndpoint != "" {
log.Println("[metrics] metrics collection enabled, endpoint:", metricsEndpoint)
log.Println("[metrics] metrics collection enabled, endpoint:", sanitizedEndpoint(metricsEndpoint))
metricsSender = metrics.NewVictoriaMetricsSender(metricsEndpoint, config.ServerID)
}

Expand Down
2 changes: 1 addition & 1 deletion deployment/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ start, so scaling `WEB_REPLICAS` does not run migrations from every replica.

## Database Migrations

Schema is synced automatically by the one-shot `migrate` service via `drizzle-kit push`. This approach auto-confirms non-destructive changes (adding tables, columns, indexes) but will **not** auto-apply destructive changes like dropping columns or tables — those require manual intervention. If schema sync fails, `web` startup is blocked; inspect the failure with `docker compose -f compose.production.yml logs migrate`.
Schema is synced automatically by the one-shot `migrate` service via `drizzle-kit push --force`. This keeps deployment non-interactive, including schema changes Drizzle classifies as data-loss operations such as dropping columns. If schema sync fails, `web` startup is blocked; inspect the failure with `docker compose -f compose.production.yml logs migrate`.

**Future plan:** Once the schema stabilizes, switch to `drizzle-kit generate` + `drizzle-orm migrate()` with pre-generated SQL migration files. This will eliminate the esbuild/drizzle-kit dependency from the production image.

Expand Down
2 changes: 1 addition & 1 deletion deployment/compose.postgres.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ services:
depends_on:
postgres:
condition: service_healthy
command: ["sh", "-c", "echo y | npx drizzle-kit push"]
command: ["npx", "drizzle-kit", "push", "--force"]
restart: on-failure

web:
Expand Down
2 changes: 1 addition & 1 deletion deployment/compose.production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ services:
- INNGEST_SIGNING_KEY=${INNGEST_SIGNING_KEY}
- INNGEST_EVENT_KEY=${INNGEST_EVENT_KEY}
- ALLOW_SIGNUP=${ALLOW_SIGNUP:-false}
command: ["sh", "-c", "echo y | npx drizzle-kit push"]
command: ["npx", "drizzle-kit", "push", "--force"]
restart: on-failure

web:
Expand Down
10 changes: 4 additions & 6 deletions docs/services/scaling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,13 @@ description: "Replicas, placement, and server pinning."

## Replicas

Each service can run multiple replicas across your cluster. Set the replica count from the service settings — the platform distributes containers across available servers.
Each service can run multiple replicas across your cluster. Configure how many replicas run on each server from the service settings.

Replica count ranges from 1 to 10 per service.

## Auto-Placement
## Placement

By default, **auto-placement** is enabled. The control plane decides which servers run each replica based on available capacity.

When auto-placement is disabled, you manually configure how many replicas run on each server using per-server replica assignments.
Services use manual placement. Select the target servers and replica counts explicitly before deploying.

## Server Pinning

Expand All @@ -26,6 +24,6 @@ You can also manually lock any service to a specific server by setting the locke
## Limitations

- Stateful services are limited to 1 replica.
- Stateful services cannot use auto-placement — they are always pinned to their locked server.
- Stateful services are always pinned to their locked server.
- Stateful services do not automatically fail over to another server.
- Maximum 10 replicas per service.
2 changes: 1 addition & 1 deletion docs/services/volumes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ You can restore a volume from any completed backup. The restore process download

## Limitations

- Services with volumes are locked to a single server — they cannot be auto-placed across multiple nodes.
- Services with volumes are locked to a single server.
- Replica count is fixed at 1 for stateful services.
- Volume data lives on the host filesystem and is not replicated to other servers.
- If the server is lost, data is only recoverable from completed backups.
Expand Down
98 changes: 13 additions & 85 deletions web/actions/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,11 @@ import {
import { DEFAULT_RESOURCE_LIMITS } from "@/lib/constants";
import { inngest } from "@/lib/inngest/client";
import { inngestEvents } from "@/lib/inngest/events";
import {
calculateResourceAwarePlacement,
replaceServiceReplicaPlacements,
} from "@/lib/placement";
import { allocatePort } from "@/lib/port-allocation";
import {
containerPathSchema,
githubRepoUrlSchema,
nameSchema,
replicaCountSchema,
volumeNameSchema,
} from "@/lib/schemas";
import type {
Expand Down Expand Up @@ -465,7 +460,6 @@ export async function createService(input: CreateServiceInput) {
githubRootDir,
replicas: 1,
stateful: false,
autoPlace: true,
resourceCpuLimit: resourceLimits.cpuCores,
resourceMemoryLimitMb: resourceLimits.memoryMb,
});
Expand Down Expand Up @@ -732,14 +726,9 @@ export async function restoreDeletedService(serviceId: string) {
if (activeReplica?.serverStatus === "online") {
targetServerId = activeReplica.serverId;
} else {
const placements = await calculateResourceAwarePlacement(service, 1);
const targetPlacement = placements[0];
if (!targetPlacement) {
throw new Error("Cannot restore because no online server is available");
}

targetServerId = targetPlacement.serverId;
await replaceServiceReplicaPlacements(serviceId, placements);
throw new Error(
"Cannot restore because the selected server is unavailable",
);
}
}

Expand Down Expand Up @@ -1352,14 +1341,14 @@ export async function addServiceVolume(
throw new Error("Service not found");
}

let totalReplicas = service.replicas;
if (!service.autoPlace) {
const configuredReplicas = await db
.select({ count: serviceReplicas.count })
.from(serviceReplicas)
.where(eq(serviceReplicas.serviceId, serviceId));
totalReplicas = configuredReplicas.reduce((sum, r) => sum + r.count, 0);
}
const configuredReplicas = await db
.select({ count: serviceReplicas.count })
.from(serviceReplicas)
.where(eq(serviceReplicas.serviceId, serviceId));
const totalReplicas = configuredReplicas.reduce(
(sum, r) => sum + r.count,
0,
);

if (totalReplicas > 1) {
throw new Error(
Expand Down Expand Up @@ -1391,7 +1380,7 @@ export async function addServiceVolume(
if (!service.stateful) {
await db
.update(services)
.set({ stateful: true, autoPlace: false })
.set({ stateful: true })
.where(eq(services.id, serviceId));
}

Expand Down Expand Up @@ -1450,74 +1439,13 @@ export async function removeServiceVolume(volumeId: string) {
if (remainingVolumes.length === 0 && service.stateful) {
await db
.update(services)
.set({ stateful: false, autoPlace: true })
.set({ stateful: false })
.where(eq(services.id, service.id));
}

return { success: true };
}

export async function updateServiceAutoPlace(
serviceId: string,
autoPlace: boolean,
) {
const service = await getService(serviceId);
if (!service) {
throw new Error("Service not found");
}

if (service.stateful && autoPlace) {
throw new Error(
"Services with volumes cannot use auto-placement. Remove volumes first.",
);
}

await db
.update(services)
.set({ autoPlace })
.where(eq(services.id, serviceId));

if (autoPlace) {
await db
.delete(serviceReplicas)
.where(eq(serviceReplicas.serviceId, serviceId));
}

return { success: true };
}

export async function updateServiceReplicas(
serviceId: string,
replicas: number,
) {
try {
const validatedReplicas = replicaCountSchema.parse(replicas);

const service = await getService(serviceId);
if (!service) {
throw new Error("Service not found");
}

if (service.stateful && validatedReplicas > 1) {
throw new Error(
"Services with volumes can only have 1 replica. Remove volumes first to scale up.",
);
}

await db
.update(services)
.set({ replicas: validatedReplicas })
.where(eq(services.id, serviceId));

return { success: true };
} catch (error) {
if (error instanceof ZodError) {
throw new Error(getZodErrorMessage(error, "Invalid replica count"));
}
throw error;
}
}

export async function updateServiceBackupSettings(
serviceId: string,
backupEnabled: boolean,
Expand Down
17 changes: 4 additions & 13 deletions web/actions/settings.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,23 @@
"use server";

import { setSetting } from "@/db/queries";
import { revalidatePath } from "next/cache";
import isEmail from "validator/es/lib/isEmail";
import { ZodError } from "zod";
import { setSetting } from "@/db/queries";
import { buildTimeoutSchema } from "@/lib/schemas";
import {
SETTING_KEYS,
type EmailAlertsConfig,
emailAlertsConfigSchema,
SETTING_KEYS,
} from "@/lib/settings-keys";
import { ZodError } from "zod";
import { getZodErrorMessage } from "@/lib/utils";
import { buildTimeoutSchema } from "@/lib/schemas";

export async function updateBuildServers(serverIds: string[]) {
await setSetting(SETTING_KEYS.SERVERS_ALLOWED_FOR_BUILDS, serverIds);
revalidatePath("/dashboard/settings");
return { success: true };
}

export async function updateExcludedServers(serverIds: string[]) {
await setSetting(
SETTING_KEYS.SERVERS_EXCLUDED_FROM_WORKLOAD_PLACEMENT,
serverIds,
);
revalidatePath("/dashboard/settings");
return { success: true };
}

export async function updateBuildTimeout(minutes: number) {
try {
const validatedMinutes = buildTimeoutSchema.parse(minutes);
Expand Down
30 changes: 5 additions & 25 deletions web/app/api/servers/route.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
export const dynamic = "force-dynamic";

import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { db } from "@/db";
import { servers } from "@/db/schema";
import { notInArray } from "drizzle-orm";
import { getSetting } from "@/db/queries";
import { SETTING_KEYS } from "@/lib/settings-keys";
import { auth } from "@/lib/auth";

export async function GET(request: Request) {
export async function GET() {
const session = await auth.api.getSession({
headers: await headers(),
});
Expand All @@ -17,18 +14,7 @@ export async function GET(request: Request) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const url = new URL(request.url);
const forPlacement = url.searchParams.get("forPlacement") === "true";

let excludedIds: string[] = [];
if (forPlacement) {
excludedIds =
(await getSetting<string[]>(
SETTING_KEYS.SERVERS_EXCLUDED_FROM_WORKLOAD_PLACEMENT,
)) || [];
}

const query = db
const data = await db
.select({
id: servers.id,
name: servers.name,
Expand All @@ -41,14 +27,8 @@ export async function GET(request: Request) {
resourcesDisk: servers.resourcesDisk,
meta: servers.meta,
})
.from(servers);

const data =
excludedIds.length > 0
? await query
.where(notInArray(servers.id, excludedIds))
.orderBy(servers.createdAt)
: await query.orderBy(servers.createdAt);
.from(servers)
.orderBy(servers.createdAt);

return Response.json(data);
}
Loading
Loading