diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b6110f58..a9248cfd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 diff --git a/agent/cmd/agent/main.go b/agent/cmd/agent/main.go index 8275e2e6..da0c7961 100644 --- a/agent/cmd/agent/main.go +++ b/agent/cmd/agent/main.go @@ -4,6 +4,7 @@ import ( "context" "flag" "log" + "net/url" "os" "os/signal" "path/filepath" @@ -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 @@ -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 { @@ -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) } diff --git a/deployment/README.md b/deployment/README.md index 92f4e9dd..2d08b2f5 100644 --- a/deployment/README.md +++ b/deployment/README.md @@ -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. diff --git a/deployment/compose.postgres.yml b/deployment/compose.postgres.yml index db8b1bf8..c23e556e 100644 --- a/deployment/compose.postgres.yml +++ b/deployment/compose.postgres.yml @@ -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: diff --git a/deployment/compose.production.yml b/deployment/compose.production.yml index b32e0545..5ec51933 100644 --- a/deployment/compose.production.yml +++ b/deployment/compose.production.yml @@ -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: diff --git a/docs/services/scaling.mdx b/docs/services/scaling.mdx index 001797b3..9e647b9a 100644 --- a/docs/services/scaling.mdx +++ b/docs/services/scaling.mdx @@ -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 @@ -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. diff --git a/docs/services/volumes.mdx b/docs/services/volumes.mdx index 49079f76..ea13e4a5 100644 --- a/docs/services/volumes.mdx +++ b/docs/services/volumes.mdx @@ -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. diff --git a/web/actions/projects.ts b/web/actions/projects.ts index 3bf39383..e2ad7ad5 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -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 { @@ -465,7 +460,6 @@ export async function createService(input: CreateServiceInput) { githubRootDir, replicas: 1, stateful: false, - autoPlace: true, resourceCpuLimit: resourceLimits.cpuCores, resourceMemoryLimitMb: resourceLimits.memoryMb, }); @@ -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", + ); } } @@ -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( @@ -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)); } @@ -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, diff --git a/web/actions/settings.ts b/web/actions/settings.ts index 30c7c44e..4ff75306 100644 --- a/web/actions/settings.ts +++ b/web/actions/settings.ts @@ -1,16 +1,16 @@ "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); @@ -18,15 +18,6 @@ export async function updateBuildServers(serverIds: string[]) { 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); diff --git a/web/app/api/servers/route.ts b/web/app/api/servers/route.ts index abd7de04..417077c1 100644 --- a/web/app/api/servers/route.ts +++ b/web/app/api/servers/route.ts @@ -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(), }); @@ -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( - SETTING_KEYS.SERVERS_EXCLUDED_FROM_WORKLOAD_PLACEMENT, - )) || []; - } - - const query = db + const data = await db .select({ id: servers.id, name: servers.name, @@ -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); } diff --git a/web/app/api/v1/agent/builds/[id]/status/route.ts b/web/app/api/v1/agent/builds/[id]/status/route.ts index eafcbd86..bf35d19c 100644 --- a/web/app/api/v1/agent/builds/[id]/status/route.ts +++ b/web/app/api/v1/agent/builds/[id]/status/route.ts @@ -232,8 +232,7 @@ export async function POST( .from(serviceReplicas) .where(eq(serviceReplicas.serviceId, build.serviceId)); - const shouldDeploy = - replicas.length > 0 || (service.autoPlace && service.replicas > 0); + const shouldDeploy = replicas.some((replica) => replica.count > 0); console.log( `[build:complete] enqueueing create_manifest for single-target build ${baseImageUri} to server ${serverId.slice(0, 8)}`, @@ -295,8 +294,7 @@ export async function POST( .from(serviceReplicas) .where(eq(serviceReplicas.serviceId, build.serviceId)); - const shouldDeploy = - replicas.length > 0 || (service.autoPlace && service.replicas > 0); + const shouldDeploy = replicas.some((replica) => replica.count > 0); console.log( `[build:complete] enqueueing create_manifest for ${baseImageUri} to server ${serverId.slice(0, 8)}`, @@ -345,8 +343,7 @@ export async function POST( .from(serviceReplicas) .where(eq(serviceReplicas.serviceId, build.serviceId)); - const shouldDeploy = - replicas.length > 0 || (service.autoPlace && service.replicas > 0); + const shouldDeploy = replicas.some((replica) => replica.count > 0); if (shouldDeploy) { console.log( diff --git a/web/components/service/details/deployment-progress.tsx b/web/components/service/details/deployment-progress.tsx index 6e113867..e374c30c 100644 --- a/web/components/service/details/deployment-progress.tsx +++ b/web/components/service/details/deployment-progress.tsx @@ -140,9 +140,10 @@ export function getBarState( } } - const totalReplicas = service.autoPlace - ? service.replicas - : service.configuredReplicas.reduce((sum, r) => sum + r.count, 0); + const totalReplicas = service.configuredReplicas.reduce( + (sum, r) => sum + r.count, + 0, + ); const hasNoDeployments = service.deployments.length === 0; const hasChanges = changes.length > 0; diff --git a/web/components/service/details/pending-changes-banner.tsx b/web/components/service/details/pending-changes-banner.tsx index 08c908ac..e776a8e2 100644 --- a/web/components/service/details/pending-changes-banner.tsx +++ b/web/components/service/details/pending-changes-banner.tsx @@ -32,9 +32,10 @@ export const PendingChangesBanner = memo(function PendingChangesBanner({ const { mutate } = useSWRConfig(); const [isDeploying, setIsDeploying] = useState(false); - const totalReplicas = service.autoPlace - ? service.replicas - : service.configuredReplicas.reduce((sum, r) => sum + r.count, 0); + const totalReplicas = service.configuredReplicas.reduce( + (sum, r) => sum + r.count, + 0, + ); const hasNoDeployments = service.deployments.length === 0; const isGithubWithNoDeployments = service.sourceType === "github" && hasNoDeployments; diff --git a/web/components/service/details/replicas-section.tsx b/web/components/service/details/replicas-section.tsx index 5daa9927..acb024cf 100644 --- a/web/components/service/details/replicas-section.tsx +++ b/web/components/service/details/replicas-section.tsx @@ -1,7 +1,9 @@ "use client"; -import { useState, useMemo, useEffect, memo, useCallback } from "react"; +import { AlertTriangle, Lock, Server } from "lucide-react"; +import { memo, useCallback, useEffect, useMemo, useState } from "react"; import useSWR from "swr"; +import { updateServiceConfig } from "@/actions/projects"; import { Button } from "@/components/ui/button"; import { Empty, @@ -11,15 +13,7 @@ import { } from "@/components/ui/empty"; import { Input } from "@/components/ui/input"; import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item"; -import { Server, Lock, Zap, AlertTriangle } from "lucide-react"; -import { - updateServiceConfig, - updateServiceAutoPlace, - updateServiceReplicas, -} from "@/actions/projects"; import { Spinner } from "@/components/ui/spinner"; -import { Switch } from "@/components/ui/switch"; -import { Label } from "@/components/ui/label"; import type { Server as ServerType, ServiceWithDetails as Service, @@ -38,7 +32,7 @@ const fetcher = async (url: string): Promise => { })); }; -const SERVERS_URL = "/api/servers?forPlacement=true"; +const SERVERS_URL = "/api/servers"; export const ReplicasSection = memo(function ReplicasSection({ service, @@ -53,10 +47,6 @@ export const ReplicasSection = memo(function ReplicasSection({ ); const [selectedServerId, setSelectedServerId] = useState(null); const [isSaving, setIsSaving] = useState(false); - const [autoPlace, setAutoPlace] = useState(service.autoPlace ?? true); - const [totalReplicaCount, setTotalReplicaCount] = useState( - service.replicas ?? 1, - ); const configuredReplicas = useMemo( () => service.configuredReplicas || [], @@ -74,7 +64,7 @@ export const ReplicasSection = memo(function ReplicasSection({ } else { setSelectedServerId(null); } - } else if (!autoPlace) { + } else { const replicaMap: Record = {}; for (const r of configuredReplicas) { replicaMap[r.serverId] = r.count; @@ -86,13 +76,7 @@ export const ReplicasSection = memo(function ReplicasSection({ } setLocalReplicas(replicaMap); } - }, [ - servers, - configuredReplicas, - service.stateful, - service.lockedServerId, - autoPlace, - ]); + }, [servers, configuredReplicas, service.stateful, service.lockedServerId]); const hasChanges = useMemo(() => { if (service.stateful) { @@ -101,29 +85,15 @@ export const ReplicasSection = memo(function ReplicasSection({ return selectedServerId !== currentServerId; } - if (autoPlace !== (service.autoPlace ?? true)) return true; - if (autoPlace && totalReplicaCount !== (service.replicas ?? 1)) return true; - - if (!autoPlace) { - const configuredMap = new Map( - configuredReplicas.map((r) => [r.serverId, r.count]), - ); - for (const [serverId, count] of Object.entries(localReplicas)) { - const configured = configuredMap.get(serverId) ?? 0; - if (configured !== count) return true; - } + const configuredMap = new Map( + configuredReplicas.map((r) => [r.serverId, r.count]), + ); + for (const [serverId, count] of Object.entries(localReplicas)) { + const configured = configuredMap.get(serverId) ?? 0; + if (configured !== count) return true; } return false; - }, [ - configuredReplicas, - localReplicas, - service.stateful, - selectedServerId, - autoPlace, - service.autoPlace, - totalReplicaCount, - service.replicas, - ]); + }, [configuredReplicas, localReplicas, service.stateful, selectedServerId]); const isChangingServer = useMemo(() => { if (!service.stateful || !service.lockedServerId) return false; @@ -148,46 +118,31 @@ export const ReplicasSection = memo(function ReplicasSection({ })); }, []); - const handleAutoPlaceToggle = async (checked: boolean) => { - setAutoPlace(checked); - }; - const handleSave = async () => { setIsSaving(true); try { + let replicas: { serverId: string; count: number }[]; if (service.stateful) { - const replicas = selectedServerId + replicas = selectedServerId ? [{ serverId: selectedServerId, count: 1 }] : []; - await updateServiceConfig(service.id, { replicas }); } else { - if (autoPlace !== (service.autoPlace ?? true)) { - await updateServiceAutoPlace(service.id, autoPlace); - } - if (autoPlace) { - if (totalReplicaCount !== (service.replicas ?? 1)) { - await updateServiceReplicas(service.id, totalReplicaCount); - } - } else { - const replicas = Object.entries(localReplicas) - .filter(([, count]) => count > 0) - .map(([serverId, count]) => ({ serverId, count })); - await updateServiceConfig(service.id, { replicas }); - } + replicas = Object.entries(localReplicas) + .filter(([, count]) => count > 0) + .map(([serverId, count]) => ({ serverId, count })); } + await updateServiceConfig(service.id, { replicas }); onUpdate(); } finally { setIsSaving(false); } }; - const totalReplicas = autoPlace - ? totalReplicaCount - : service.stateful - ? selectedServerId - ? 1 - : 0 - : Object.values(localReplicas).reduce((sum, n) => sum + n, 0); + const totalReplicas = service.stateful + ? selectedServerId + ? 1 + : 0 + : Object.values(localReplicas).reduce((sum, n) => sum + n, 0); if (service.stateful) { return ( @@ -303,72 +258,7 @@ export const ReplicasSection = memo(function ReplicasSection({
-
-
- - -
- -
- - {autoPlace ? ( - <> -

- Replicas will be automatically distributed across healthy servers. - If a server goes offline, replicas are automatically recovered. -

-
- -
- - - setTotalReplicaCount( - Math.max(1, Math.min(10, parseInt(e.target.value) || 1)), - ) - } - min={1} - max={10} - className="w-16 h-8 text-center" - /> - -
-
- {hasChanges && ( -
- -
- )} - - ) : isLoading ? ( + {isLoading ? (
@@ -415,7 +305,10 @@ export const ReplicasSection = memo(function ReplicasSection({ type="number" value={localReplicas[server.id] || 0} onChange={(e) => - updateReplicas(server.id, parseInt(e.target.value) || 0) + updateReplicas( + server.id, + parseInt(e.target.value, 10) || 0, + ) } min={0} max={10} diff --git a/web/components/settings/email-settings.tsx b/web/components/settings/email-settings.tsx index d99b969c..04dc4d8b 100644 --- a/web/components/settings/email-settings.tsx +++ b/web/components/settings/email-settings.tsx @@ -2,7 +2,7 @@ import { Bell } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useMemo, useReducer } from "react"; +import { useReducer } from "react"; import { toast } from "sonner"; import { updateEmailAlertsConfig } from "@/actions/settings"; import { Button } from "@/components/ui/button"; @@ -45,8 +45,8 @@ const ALERT_SETTINGS: AlertSetting[] = [ }, { field: "deploymentMovedAlert", - label: "Deployment Moved Alert", - description: "Receive an email when a service is automatically redeployed", + label: "Manual Recovery Alert", + description: "Receive an email when offline replicas need manual recovery", }, ]; @@ -112,18 +112,14 @@ export function EmailSettings({ initialAlertsConfig }: Props) { } }; - const hasAlertsChanges = useMemo(() => { - return ALERT_SETTINGS.some( - (setting) => - state[setting.field] !== (initialAlertsConfig?.[setting.field] ?? true), - ); - }, [ - state.serverOfflineAlert, - state.buildFailure, - state.deploymentFailure, - state.deploymentMovedAlert, - initialAlertsConfig, - ]); + const hasAlertsChanges = + state.serverOfflineAlert !== + (initialAlertsConfig?.serverOfflineAlert ?? true) || + state.buildFailure !== (initialAlertsConfig?.buildFailure ?? true) || + state.deploymentFailure !== + (initialAlertsConfig?.deploymentFailure ?? true) || + state.deploymentMovedAlert !== + (initialAlertsConfig?.deploymentMovedAlert ?? true); return (
diff --git a/web/components/settings/global-settings.tsx b/web/components/settings/global-settings.tsx index 7ce54d1e..7648d0ca 100644 --- a/web/components/settings/global-settings.tsx +++ b/web/components/settings/global-settings.tsx @@ -1,46 +1,35 @@ "use client"; -import { useState } from "react"; +import { Clock, Hammer, Info, Network, Server, Shield } from "lucide-react"; import { useRouter } from "next/navigation"; import { useQueryState } from "nuqs"; +import { useState } from "react"; import { toast } from "sonner"; import { - Hammer, - Server, - Ban, - Clock, - Shield, - Network, - Info, -} from "lucide-react"; + updateAcmeEmail, + updateBuildServers, + updateBuildTimeout, + updateProxyDomain, +} from "@/actions/settings"; +import { EmailSettings } from "@/components/settings/email-settings"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item"; import { Empty, EmptyDescription, EmptyMedia, EmptyTitle, } from "@/components/ui/empty"; -import { - updateBuildServers, - updateExcludedServers, - updateBuildTimeout, - updateAcmeEmail, - updateProxyDomain, -} from "@/actions/settings"; +import { Input } from "@/components/ui/input"; +import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import type { Server as ServerType } from "@/db/types"; import type { EmailAlertsConfig } from "@/lib/settings-keys"; -import { EmailSettings } from "@/components/settings/email-settings"; - type Props = { servers: ServerType[]; initialSettings: { buildServerIds: string[]; - excludedServerIds: string[]; buildTimeoutMinutes: number; acmeEmail: string | null; proxyDomain: string | null; @@ -59,14 +48,10 @@ export function GlobalSettings({ const [buildServerIds, setBuildServerIds] = useState>( new Set(initialSettings.buildServerIds), ); - const [excludedServerIds, setExcludedServerIds] = useState>( - new Set(initialSettings.excludedServerIds), - ); const [buildTimeoutMinutes, setBuildTimeoutMinutes] = useState( String(initialSettings.buildTimeoutMinutes), ); const [isSavingBuild, setIsSavingBuild] = useState(false); - const [isSavingExcluded, setIsSavingExcluded] = useState(false); const [isSavingTimeout, setIsSavingTimeout] = useState(false); const [acmeEmail, setAcmeEmail] = useState(initialSettings.acmeEmail ?? ""); @@ -88,18 +73,6 @@ export function GlobalSettings({ }); }; - const toggleExcludedServer = (serverId: string) => { - setExcludedServerIds((prev) => { - const next = new Set(prev); - if (next.has(serverId)) { - next.delete(serverId); - } else { - next.add(serverId); - } - return next; - }); - }; - const handleSaveBuildServers = async () => { setIsSavingBuild(true); try { @@ -115,21 +88,6 @@ export function GlobalSettings({ } }; - const handleSaveExcludedServers = async () => { - setIsSavingExcluded(true); - try { - await updateExcludedServers(Array.from(excludedServerIds)); - toast.success("Excluded servers updated"); - router.refresh(); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Failed to update settings", - ); - } finally { - setIsSavingExcluded(false); - } - }; - const handleSaveBuildTimeout = async () => { setIsSavingTimeout(true); try { @@ -181,10 +139,6 @@ export function GlobalSettings({ buildServerIds.size !== initialSettings.buildServerIds.length || !initialSettings.buildServerIds.every((id) => buildServerIds.has(id)); - const excludedServersChanged = - excludedServerIds.size !== initialSettings.excludedServerIds.length || - !initialSettings.excludedServerIds.every((id) => excludedServerIds.has(id)); - const buildTimeoutChanged = buildTimeoutMinutes !== String(initialSettings.buildTimeoutMinutes); @@ -212,9 +166,6 @@ export function GlobalSettings({ Build - - Deployment - Infrastructure @@ -332,70 +283,6 @@ export function GlobalSettings({
- -
- - - - - - Excluded from Workloads - - -
-

- Select servers to exclude from workload placement. These servers - will not receive any new deployments. -

-
- {servers.map((server) => ( - - ))} -
-
- - {excludedServerIds.size === 0 - ? "No servers excluded" - : `${excludedServerIds.size} server${excludedServerIds.size !== 1 ? "s" : ""} excluded`} - -
- {excludedServersChanged && ( -
- -
- )} -
-
-
-
diff --git a/web/db/queries.ts b/web/db/queries.ts index dfe39e20..ebe35466 100644 --- a/web/db/queries.ts +++ b/web/db/queries.ts @@ -361,14 +361,12 @@ export async function getSetting(key: string): Promise { export async function getGlobalSettings() { const [ buildServers, - excludedServers, buildTimeout, acmeEmail, proxyDomain, emailAlertsConfig, ] = await Promise.all([ getSetting("servers_allowed_for_builds"), - getSetting("servers_excluded_from_workload_placement"), getSetting("build_timeout_minutes"), getSetting("acme_email"), getSetting("proxy_domain"), @@ -377,7 +375,6 @@ export async function getGlobalSettings() { return { buildServerIds: buildServers ?? [], - excludedServerIds: excludedServers ?? [], buildTimeoutMinutes: buildTimeout ?? 30, acmeEmail: acmeEmail ?? null, proxyDomain: proxyDomain ?? null, diff --git a/web/db/schema.ts b/web/db/schema.ts index f5d64604..8b1be38f 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -283,7 +283,6 @@ export const services = pgTable("services", { githubRootDir: text("github_root_dir"), replicas: integer("replicas").notNull().default(1), stateful: boolean("stateful").notNull().default(false), - autoPlace: boolean("auto_place").notNull().default(true), lockedServerId: text("locked_server_id").references(() => servers.id, { onDelete: "set null", }), diff --git a/web/lib/build-assignment.ts b/web/lib/build-assignment.ts index 599ba4c6..d31d7e11 100644 --- a/web/lib/build-assignment.ts +++ b/web/lib/build-assignment.ts @@ -1,7 +1,7 @@ +import { and, eq, inArray } from "drizzle-orm"; import { db } from "@/db"; -import { servers, services, serviceReplicas } from "@/db/schema"; -import { eq, and, inArray, isNotNull, notInArray } from "drizzle-orm"; import { getSetting } from "@/db/queries"; +import { servers, serviceReplicas, services } from "@/db/schema"; import { SETTING_KEYS } from "@/lib/settings-keys"; export async function selectBuildServerForPlatform( @@ -86,48 +86,20 @@ export async function getTargetPlatformsForService( let targetPlatforms: string[] = []; - if (service.autoPlace) { - const excludedFromWorkload = await getSetting( - SETTING_KEYS.SERVERS_EXCLUDED_FROM_WORKLOAD_PLACEMENT, - ); - - const eligibleServers = await db - .select({ meta: servers.meta }) - .from(servers) - .where( - excludedFromWorkload && excludedFromWorkload.length > 0 - ? and( - eq(servers.status, "online"), - isNotNull(servers.wireguardIp), - notInArray(servers.id, excludedFromWorkload), - ) - : and(eq(servers.status, "online"), isNotNull(servers.wireguardIp)), - ); - - targetPlatforms = [ - ...new Set( - eligibleServers - .map((s) => s.meta?.arch) - .filter((arch): arch is string => !!arch) - .map((arch) => `linux/${arch}`), - ), - ]; - } else { - const replicas = await db - .select({ meta: servers.meta }) - .from(serviceReplicas) - .innerJoin(servers, eq(serviceReplicas.serverId, servers.id)) - .where(eq(serviceReplicas.serviceId, service.id)); - - targetPlatforms = [ - ...new Set( - replicas - .map((r) => r.meta?.arch) - .filter((arch): arch is string => !!arch) - .map((arch) => `linux/${arch}`), - ), - ]; - } + const replicas = await db + .select({ meta: servers.meta }) + .from(serviceReplicas) + .innerJoin(servers, eq(serviceReplicas.serverId, servers.id)) + .where(eq(serviceReplicas.serviceId, service.id)); + + targetPlatforms = [ + ...new Set( + replicas + .map((r) => r.meta?.arch) + .filter((arch): arch is string => !!arch) + .map((arch) => `linux/${arch}`), + ), + ]; if (targetPlatforms.length === 0) { targetPlatforms.push("linux/amd64", "linux/arm64"); diff --git a/web/lib/cli-service.ts b/web/lib/cli-service.ts index 30d28f5f..07bf086e 100644 --- a/web/lib/cli-service.ts +++ b/web/lib/cli-service.ts @@ -1,4 +1,11 @@ import { and, desc, eq, ne } from "drizzle-orm"; +import { + deployService, + updateServiceConfig, + updateServiceResourceLimits, + updateServiceStartCommand, + validateDockerImage, +} from "@/actions/projects"; import { db } from "@/db"; import { deployments, @@ -6,32 +13,19 @@ import { projects, rollouts, servicePorts, - serviceVolumes, + serviceReplicas, services, + serviceVolumes, } from "@/db/schema"; -import { - techulusManifestSchema, - type TechulusManifest, -} from "@/lib/cli-manifest"; import { getManifestEnvironmentName, getManifestProjectSlug, getManifestServiceName, + type TechulusManifest, + techulusManifestSchema, } from "@/lib/cli-manifest"; -import { slugify } from "@/lib/utils"; import { DEFAULT_RESOURCE_LIMITS } from "@/lib/constants"; -import { - createEnvironment, - createProject, - createService, - deployService, - updateServiceAutoPlace, - updateServiceConfig, - updateServiceReplicas, - updateServiceResourceLimits, - updateServiceStartCommand, - validateDockerImage, -} from "@/actions/projects"; +import { slugify } from "@/lib/utils"; export type ManifestChange = { field: string; @@ -103,12 +97,7 @@ type ManifestIdentity = { type ServiceCompatibilityRecord = Pick< typeof services.$inferSelect, - | "sourceType" - | "stateful" - | "autoPlace" - | "replicas" - | "resourceCpuLimit" - | "resourceMemoryLimitMb" + "sourceType" | "stateful" | "resourceCpuLimit" | "resourceMemoryLimitMb" >; type PortCompatibilityRecord = Pick< @@ -125,9 +114,7 @@ type LinkValidationService = Pick< | "hostname" | "image" | "sourceType" - | "replicas" | "stateful" - | "autoPlace" | "healthCheckCmd" | "healthCheckInterval" | "healthCheckTimeout" @@ -146,14 +133,24 @@ type LinkValidationPort = Pick< type ServiceLinkValidation = { service: LinkValidationService; ports: LinkValidationPort[]; + placementReplicaCount: number; unsupportedReason: string | null; }; -function formatPort(port: { port: number; isPublic: boolean; domain: string | null }) { - return port.isPublic ? `${port.port} -> ${port.domain}` : `${port.port} (internal)`; +function formatPort(port: { + port: number; + isPublic: boolean; + domain: string | null; +}) { + return port.isPublic + ? `${port.port} -> ${port.domain}` + : `${port.port} (internal)`; } -function formatNullable(value: string | number | null | undefined, fallback = "(none)") { +function formatNullable( + value: string | number | null | undefined, + fallback = "(none)", +) { if (value === null || value === undefined || value === "") { return fallback; } @@ -201,7 +198,10 @@ async function findEnvironmentByManifest( return findEnvironmentByName(projectId, environmentName); } -async function findEnvironmentByName(projectId: string, environmentName: string) { +async function findEnvironmentByName( + projectId: string, + environmentName: string, +) { const [environment] = await db .select() .from(environments) @@ -249,6 +249,7 @@ function getUnsupportedReason( service: ServiceCompatibilityRecord, ports: PortCompatibilityRecord[], volumeCount: number, + placementReplicaCount: number, ) { if (service.sourceType !== "image") { return "CLI v1 only supports image-backed services. This service uses an unsupported source."; @@ -258,10 +259,6 @@ function getUnsupportedReason( return "CLI v1 does not support stateful services or volumes. Manage this service from the web UI."; } - if (!service.autoPlace) { - return "CLI v1 only supports auto-placement. Manage this service from the web UI."; - } - if (ports.some((port) => port.protocol !== "http")) { return "CLI v1 only supports HTTP ports. This service has TCP or UDP ports configured."; } @@ -270,8 +267,12 @@ function getUnsupportedReason( return "CLI v1 requires every public HTTP port to have a domain."; } - if (service.replicas < 1 || service.replicas > 10) { - return "CLI v1 only supports replica counts between 1 and 10."; + if (placementReplicaCount < 1) { + return "CLI v1 requires manual server placement to be configured in the web UI before deploy."; + } + + if (placementReplicaCount > 10) { + return "CLI v1 only supports manually placed replica counts between 1 and 10."; } const hasCpu = service.resourceCpuLimit !== null; @@ -296,9 +297,7 @@ async function getServiceLinkValidation( hostname: services.hostname, image: services.image, sourceType: services.sourceType, - replicas: services.replicas, stateful: services.stateful, - autoPlace: services.autoPlace, healthCheckCmd: services.healthCheckCmd, healthCheckInterval: services.healthCheckInterval, healthCheckTimeout: services.healthCheckTimeout, @@ -316,7 +315,7 @@ async function getServiceLinkValidation( return null; } - const [ports, volumes] = await Promise.all([ + const [ports, volumes, replicas] = await Promise.all([ db .select({ serviceId: servicePorts.serviceId, @@ -332,12 +331,26 @@ async function getServiceLinkValidation( .select({ id: serviceVolumes.id }) .from(serviceVolumes) .where(eq(serviceVolumes.serviceId, serviceId)), + db + .select({ count: serviceReplicas.count }) + .from(serviceReplicas) + .where(eq(serviceReplicas.serviceId, serviceId)), ]); + const placementReplicaCount = replicas.reduce( + (sum, replica) => sum + replica.count, + 0, + ); return { service, ports, - unsupportedReason: getUnsupportedReason(service, ports, volumes.length), + placementReplicaCount, + unsupportedReason: getUnsupportedReason( + service, + ports, + volumes.length, + placementReplicaCount, + ), }; } @@ -438,7 +451,7 @@ async function syncPorts( .map((port) => ({ port: port.port, isPublic: port.public, - domain: port.public ? port.domain ?? null : null, + domain: port.public ? (port.domain ?? null) : null, protocol: "http" as const, })); @@ -453,7 +466,9 @@ async function syncPorts( }, }); - for (const port of currentPorts.filter((item) => portsToRemove.includes(item.id))) { + for (const port of currentPorts.filter((item) => + portsToRemove.includes(item.id), + )) { changes.push({ field: `Port ${port.port}`, from: formatPort(port), @@ -465,7 +480,9 @@ async function syncPorts( changes.push({ field: `Port ${port.port}`, from: "(none)", - to: port.isPublic ? `${port.port} -> ${port.domain}` : `${port.port} (internal)`, + to: port.isPublic + ? `${port.port} -> ${port.domain}` + : `${port.port} (internal)`, }); } } @@ -570,27 +587,6 @@ async function syncResources( ); } -async function syncReplicas( - serviceId: string, - currentService: Pick, - desiredReplicas: number, - changes: ManifestChange[], -) { - if (!currentService.autoPlace) { - throw new Error( - "CLI v1 only supports auto-placement. This service uses manual placement.", - ); - } - - if (currentService.replicas === desiredReplicas) { - return; - } - - await updateServiceAutoPlace(serviceId, true); - await updateServiceReplicas(serviceId, desiredReplicas); - recordChange(changes, "Replicas", currentService.replicas, desiredReplicas); -} - async function assertSupportedExistingService(serviceId: string) { const validation = await getServiceLinkValidation(serviceId); if (!validation) { @@ -601,62 +597,56 @@ async function assertSupportedExistingService(serviceId: string) { throw new Error(validation.unsupportedReason); } - return validation.service; + return validation; +} + +function assertManifestReplicaCount( + placementReplicaCount: number, + desiredReplicaCount: number, +) { + if (placementReplicaCount !== desiredReplicaCount) { + throw new Error( + `CLI v1 cannot change server placement. Update placement in the web UI so it has ${desiredReplicaCount} replica${desiredReplicaCount === 1 ? "" : "s"}, or update replicas.count to match the current manual placement of ${placementReplicaCount}.`, + ); + } } export async function applyManifest( manifest: TechulusManifest, ): Promise { - let serviceCreated = false; - let project = await findProjectByManifest(manifest); - if (!project) { - await createProject(manifest.project.trim()); - project = await findProjectByManifest(manifest); - } + const project = await findProjectByManifest(manifest); if (!project) { - throw new Error("Failed to create project"); + throw new Error( + "Project not found. Create the service and select server placement in the web UI before using CLI v1.", + ); } - let environment = await findEnvironmentByManifest(project.id, manifest); - if (!environment) { - await createEnvironment(project.id, manifest.environment.trim()); - environment = await findEnvironmentByManifest(project.id, manifest); - } + const environment = await findEnvironmentByManifest(project.id, manifest); if (!environment) { - throw new Error("Failed to create environment"); + throw new Error( + "Environment not found. Create the service and select server placement in the web UI before using CLI v1.", + ); } - let service = await findServiceByManifest(project.id, environment.id, manifest); + const service = await findServiceByManifest( + project.id, + environment.id, + manifest, + ); const changes: ManifestChange[] = []; if (!service) { - serviceCreated = true; - const validation = await validateDockerImage(manifest.service.source.image); - if (!validation.valid) { - throw new Error(validation.error || "Invalid image"); - } - - await createService({ - projectId: project.id, - environmentId: environment.id, - name: getManifestServiceName(manifest), - image: manifest.service.source.image, - }); - service = await findServiceByManifest(project.id, environment.id, manifest); - if (!service) { - throw new Error("Failed to create service"); - } - - recordChange(changes, "Image", null, manifest.service.source.image); - recordChange( - changes, - "Replicas", - null, - manifest.service.replicas.count, + throw new Error( + "CLI v1 cannot create services because server placement must be selected in the web UI.", ); } - const currentService = await assertSupportedExistingService(service.id); + const validation = await assertSupportedExistingService(service.id); + const currentService = validation.service; + assertManifestReplicaCount( + validation.placementReplicaCount, + manifest.service.replicas.count, + ); await syncHostname( service.id, @@ -679,15 +669,11 @@ export async function applyManifest( changes, ); await syncResources(service.id, currentService, manifest, changes); - await syncReplicas( - service.id, - currentService, - manifest.service.replicas.count, - changes, - ); - const refreshedProject = await findProjectByManifest(manifest); - const refreshedEnvironment = await findEnvironmentByManifest(project.id, manifest); + const refreshedEnvironment = await findEnvironmentByManifest( + project.id, + manifest, + ); if (!refreshedProject || !refreshedEnvironment) { throw new Error("Failed to reload manifest resources after apply"); @@ -697,7 +683,7 @@ export async function applyManifest( project: refreshedProject, environment: refreshedEnvironment, serviceId: service.id, - action: serviceCreated ? "created" : changes.length === 0 ? "noop" : "updated", + action: changes.length === 0 ? "noop" : "updated", changes, }; } @@ -713,11 +699,21 @@ export async function deployManifest(manifest: TechulusManifest) { throw new Error("Environment not found"); } - const service = await findServiceByManifest(project.id, environment.id, manifest); + const service = await findServiceByManifest( + project.id, + environment.id, + manifest, + ); if (!service) { throw new Error("Service not found"); } + const validation = await assertSupportedExistingService(service.id); + assertManifestReplicaCount( + validation.placementReplicaCount, + manifest.service.replicas.count, + ); + const result = await deployService(service.id); return { @@ -783,6 +779,14 @@ export async function getManifestStatus(identity: ManifestIdentity) { }) .from(servicePorts) .where(eq(servicePorts.serviceId, service.id)); + const replicas = await db + .select({ count: serviceReplicas.count }) + .from(serviceReplicas) + .where(eq(serviceReplicas.serviceId, service.id)); + const placementReplicaCount = replicas.reduce( + (sum, replica) => sum + replica.count, + 0, + ); return { service: { @@ -790,7 +794,7 @@ export async function getManifestStatus(identity: ManifestIdentity) { name: service.name, image: service.image, hostname: service.hostname, - replicas: service.replicas, + replicas: placementReplicaCount, sourceType: service.sourceType, }, ports, @@ -800,51 +804,63 @@ export async function getManifestStatus(identity: ManifestIdentity) { } export async function listLinkTargets(): Promise { - const [projectRows, environmentRows, serviceRows, portRows, volumeRows] = - await Promise.all([ - db - .select({ - id: projects.id, - name: projects.name, - slug: projects.slug, - }) - .from(projects) - .orderBy(projects.createdAt), - db - .select({ - id: environments.id, - projectId: environments.projectId, - name: environments.name, - }) - .from(environments) - .orderBy(environments.createdAt), - db - .select({ - id: services.id, - name: services.name, - projectId: services.projectId, - environmentId: services.environmentId, - sourceType: services.sourceType, - stateful: services.stateful, - autoPlace: services.autoPlace, - replicas: services.replicas, - resourceCpuLimit: services.resourceCpuLimit, - resourceMemoryLimitMb: services.resourceMemoryLimitMb, - }) - .from(services) - .orderBy(services.createdAt), - db - .select({ - serviceId: servicePorts.serviceId, - protocol: servicePorts.protocol, - isPublic: servicePorts.isPublic, - domain: servicePorts.domain, - }) - .from(servicePorts), - db.select({ serviceId: serviceVolumes.serviceId }) - .from(serviceVolumes) - .orderBy(serviceVolumes.id), - ]); + const [ + projectRows, + environmentRows, + serviceRows, + portRows, + volumeRows, + replicaRows, + ] = await Promise.all([ + db + .select({ + id: projects.id, + name: projects.name, + slug: projects.slug, + }) + .from(projects) + .orderBy(projects.createdAt), + db + .select({ + id: environments.id, + projectId: environments.projectId, + name: environments.name, + }) + .from(environments) + .orderBy(environments.createdAt), + db + .select({ + id: services.id, + name: services.name, + projectId: services.projectId, + environmentId: services.environmentId, + sourceType: services.sourceType, + stateful: services.stateful, + resourceCpuLimit: services.resourceCpuLimit, + resourceMemoryLimitMb: services.resourceMemoryLimitMb, + }) + .from(services) + .orderBy(services.createdAt), + db + .select({ + serviceId: servicePorts.serviceId, + protocol: servicePorts.protocol, + isPublic: servicePorts.isPublic, + domain: servicePorts.domain, + }) + .from(servicePorts), + db + .select({ serviceId: serviceVolumes.serviceId }) + .from(serviceVolumes) + .orderBy(serviceVolumes.id), + db + .select({ + serviceId: serviceReplicas.serviceId, + count: serviceReplicas.count, + }) + .from(serviceReplicas) + .orderBy(serviceReplicas.id), + ]); const projectNameById = new Map( projectRows.map((project) => [project.id, project.name]), @@ -867,6 +883,13 @@ export async function listLinkTargets(): Promise { (volumeCountByServiceId.get(volume.serviceId) ?? 0) + 1, ); } + const replicaCountByServiceId = new Map(); + for (const replica of replicaRows) { + replicaCountByServiceId.set( + replica.serviceId, + (replicaCountByServiceId.get(replica.serviceId) ?? 0) + replica.count, + ); + } const servicesByEnvironmentId = new Map(); for (const service of serviceRows) { @@ -882,6 +905,7 @@ export async function listLinkTargets(): Promise { service, ports, volumeCountByServiceId.get(service.id) ?? 0, + replicaCountByServiceId.get(service.id) ?? 0, ); current.push({ @@ -967,7 +991,7 @@ export async function exportManifestForLinkedService( ...(port.isPublic && port.domain ? { domain: port.domain } : {}), })), replicas: { - count: validation.service.replicas, + count: validation.placementReplicaCount, }, ...(validation.service.healthCheckCmd ? { diff --git a/web/lib/email/index.ts b/web/lib/email/index.ts index a04cafa9..1f29288d 100644 --- a/web/lib/email/index.ts +++ b/web/lib/email/index.ts @@ -1,14 +1,14 @@ -import nodemailer from "nodemailer"; -import type { Transporter } from "nodemailer"; -import type { SmtpConfig } from "@/lib/settings-keys"; import { render } from "@react-email/render"; +import { eq } from "drizzle-orm"; +import type { Transporter } from "nodemailer"; +import nodemailer from "nodemailer"; import type { ReactElement } from "react"; -import { getSmtpConfig, getEmailAlertsConfig } from "@/db/queries"; -import { Alert } from "./templates/alert"; import { db } from "@/db"; -import { services, projects, servers, environments } from "@/db/schema"; -import { eq } from "drizzle-orm"; +import { getEmailAlertsConfig, getSmtpConfig } from "@/db/queries"; +import { environments, projects, servers, services } from "@/db/schema"; import { formatDateTime } from "@/lib/date"; +import type { SmtpConfig } from "@/lib/settings-keys"; +import { Alert } from "./templates/alert"; export function getAppBaseUrl(): string | undefined { return process.env.APP_URL; @@ -136,7 +136,7 @@ export async function sendServerOfflineAlert( heading: "Server Offline Alert", description: `The server "${options.serverName}" has gone offline and is no longer responding to health checks.`, details, - note: "Auto-placed stateless services running on this server will be automatically recovered and redeployed to healthy servers.", + note: "Services with replicas placed on this server require manual recovery or placement changes.", buttonText: dashboardUrl ? "View Dashboard" : undefined, buttonUrl: dashboardUrl, baseUrl, @@ -144,13 +144,16 @@ export async function sendServerOfflineAlert( }); } -type DeploymentMovedAlertOptions = { - serviceId: string; - reason: string; +type ManualRecoveryRequiredAlertOptions = { + serverId: string; + serverName: string; + serverIp?: string; + impactedReplicas: number; + serviceNames: string[]; }; -export async function sendDeploymentMovedAlert( - options: DeploymentMovedAlertOptions, +export async function sendManualRecoveryRequiredAlert( + options: ManualRecoveryRequiredAlertOptions, ): Promise { const alertsConfig = await getEmailAlertsConfig(); @@ -158,43 +161,34 @@ export async function sendDeploymentMovedAlert( return; } - const [result] = await db - .select({ - serviceName: services.name, - projectName: projects.name, - projectSlug: projects.slug, - envName: environments.name, - }) - .from(services) - .innerJoin(projects, eq(projects.id, services.projectId)) - .innerJoin(environments, eq(environments.id, services.environmentId)) - .where(eq(services.id, options.serviceId)); - - if (!result) { - return; - } - const baseUrl = getAppBaseUrl(); - const serviceUrl = baseUrl - ? `${baseUrl}/dashboard/projects/${result.projectSlug}/${result.envName}/services/${options.serviceId}` + const serverUrl = baseUrl + ? `${baseUrl}/dashboard/servers/${options.serverId}` : undefined; + const serviceSummary = + options.serviceNames.length > 0 + ? options.serviceNames.slice(0, 10).join(", ") + : "(unknown)"; const details = [ - { label: "Service", value: result.serviceName }, - { label: "Project", value: result.projectName }, - { label: "Reason", value: options.reason }, - { label: "Moved At", value: formatDateTime(new Date()) }, + { label: "Server", value: options.serverName }, + ...(options.serverIp + ? [{ label: "IP Address", value: options.serverIp }] + : []), + { label: "Impacted Replicas", value: String(options.impactedReplicas) }, + { label: "Services", value: serviceSummary }, + { label: "Detected At", value: formatDateTime(new Date()) }, ]; await sendAlert({ - subject: `Service "${result.serviceName}" automatically redeployed`, + subject: `Manual recovery required for "${options.serverName}"`, template: Alert({ - bannerText: "DEPLOYMENT MOVED", - heading: "Service Automatically Redeployed", - description: `The service "${result.serviceName}" in project "${result.projectName}" was automatically redeployed due to server failure.`, + bannerText: "MANUAL RECOVERY REQUIRED", + heading: "Manual Recovery Required", + description: `${options.impactedReplicas} active replica${options.impactedReplicas === 1 ? "" : "s"} were running on "${options.serverName}" when it went offline. Automatic recovery is disabled, so placement or recovery must be handled manually.`, details, - buttonText: serviceUrl ? "View Service" : undefined, - buttonUrl: serviceUrl, + buttonText: serverUrl ? "View Server" : undefined, + buttonUrl: serverUrl, baseUrl, }), }); diff --git a/web/lib/inngest/functions/build-workflow.ts b/web/lib/inngest/functions/build-workflow.ts index a1147f15..e9129d9f 100644 --- a/web/lib/inngest/functions/build-workflow.ts +++ b/web/lib/inngest/functions/build-workflow.ts @@ -64,10 +64,7 @@ export const buildWorkflow = inngest.createFunction( .where(and(eq(services.id, serviceId), isNull(services.deletedAt))) .then((r) => r[0]); - return ( - !!service && - (replicas.length > 0 || (service.autoPlace && service.replicas > 0)) - ); + return !!service && replicas.some((replica) => replica.count > 0); }); if (shouldDeploy) { @@ -143,10 +140,7 @@ export const buildWorkflow = inngest.createFunction( .where(and(eq(services.id, serviceId), isNull(services.deletedAt))) .then((r) => r[0]); - return ( - !!service && - (replicas.length > 0 || (service.autoPlace && service.replicas > 0)) - ); + return !!service && replicas.some((replica) => replica.count > 0); }); if (shouldDeploy) { diff --git a/web/lib/inngest/functions/rollout-helpers.ts b/web/lib/inngest/functions/rollout-helpers.ts index 8a88940a..8ca5f716 100644 --- a/web/lib/inngest/functions/rollout-helpers.ts +++ b/web/lib/inngest/functions/rollout-helpers.ts @@ -13,10 +13,6 @@ import { serviceVolumes, } from "@/db/schema"; import { getCertificate, issueCertificate } from "@/lib/acme-manager"; -import { - calculateResourceAwarePlacement, - replaceServiceReplicaPlacements, -} from "@/lib/placement"; import { buildCurrentConfig } from "@/lib/service-config"; import { assignContainerIp } from "@/lib/wireguard"; import { enqueueWork } from "@/lib/work-queue"; @@ -88,32 +84,6 @@ export async function calculateServicePlacements( totalReplicas: number; migrationNeeded?: { targetServerId: string }; }> { - let placements: Placement[]; - - if (service.autoPlace && !service.stateful) { - const totalReplicas = service.replicas; - if (totalReplicas < 1) { - throw new Error("At least one replica is required"); - } - if (totalReplicas > 10) { - throw new Error("Maximum 10 replicas allowed"); - } - - const calculatedPlacements = await calculateResourceAwarePlacement( - service, - totalReplicas, - ); - - await replaceServiceReplicaPlacements(service.id, calculatedPlacements); - - placements = calculatedPlacements.map((p) => ({ - serverId: p.serverId, - replicas: p.count, - })); - - return { placements, totalReplicas }; - } - const configuredReplicas = await db .select({ serverId: serviceReplicas.serverId, @@ -122,7 +92,7 @@ export async function calculateServicePlacements( .from(serviceReplicas) .where(eq(serviceReplicas.serviceId, service.id)); - placements = configuredReplicas.filter((p) => p.replicas > 0); + const placements = configuredReplicas.filter((p) => p.replicas > 0); const totalReplicas = placements.reduce((sum, p) => sum + p.replicas, 0); if (totalReplicas < 1) { diff --git a/web/lib/inngest/functions/rollout-workflow.ts b/web/lib/inngest/functions/rollout-workflow.ts index 3d301dbc..a3872e25 100644 --- a/web/lib/inngest/functions/rollout-workflow.ts +++ b/web/lib/inngest/functions/rollout-workflow.ts @@ -21,7 +21,6 @@ import { handleRolloutFailure } from "./rollout-utils"; const PREFLIGHT_FAILURE_MESSAGES = [ "At least one replica is required", "Maximum 10 replicas allowed", - "No healthy servers available for placement", "No servers selected for deployment", "Stateful services can only have exactly 1 replica", "Stateful services must be deployed to exactly one server", @@ -80,7 +79,7 @@ export const rolloutWorkflow = inngest.createFunction( ); }); - const placementResult = await step.run("calculate-placements", async () => { + const placementResult = await step.run("load-placements", async () => { const service = await getService(serviceId); if (!service) { throw new Error("Service not found"); @@ -91,7 +90,7 @@ export const rolloutWorkflow = inngest.createFunction( rolloutId, serviceId, "preparing", - `Calculated placements: ${result.totalReplicas} replica(s)`, + `Loaded placements: ${result.totalReplicas} replica(s)`, ); return { success: true as const, ...result }; } catch (error) { @@ -104,7 +103,7 @@ export const rolloutWorkflow = inngest.createFunction( rolloutId, serviceId, "preparing", - `Placement failed: ${reason}`, + `Placement validation failed: ${reason}`, ); await handleRolloutFailure(rolloutId, serviceId, reason, false); return { success: false as const, reason }; diff --git a/web/lib/placement-planner.ts b/web/lib/placement-planner.ts deleted file mode 100644 index 5d418010..00000000 --- a/web/lib/placement-planner.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { createHash } from "node:crypto"; -import type { HealthStats } from "@/db/types"; - -export type PlacementResult = { serverId: string; count: number }[]; - -export type PlacementServerSnapshot = { - id: string; - status: string; - wireguardIp: string | null; - healthStats?: HealthStats | null; - containerHealth?: { - runtimeResponsive: boolean; - runningContainers: number; - stoppedContainers: number; - storageUsedGb: number; - } | null; -}; - -export type ReplicaAllocationSnapshot = { - serverId: string; - serviceId: string; - count: number; -}; - -export type PlacementPlanInput = { - serviceId: string; - totalReplicas: number; - servers: PlacementServerSnapshot[]; - existingReplicas: ReplicaAllocationSnapshot[]; - excludeServerIds?: string[]; -}; - -type ProjectedServerLoad = { - existingReplicas: number; - assignedServiceReplicas: number; -}; - -type CandidateScore = { - server: PlacementServerSnapshot; - score: number; - hashScore: number; -}; - -const EPSILON = 0.000001; -const LIVE_CPU_PRESSURE_WEIGHT = 20; -const LIVE_MEMORY_PRESSURE_WEIGHT = 20; -const LIVE_DISK_PRESSURE_WEIGHT = 15; -const EXISTING_REPLICA_WEIGHT = 2; -const RUNNING_CONTAINER_WEIGHT = 0.5; -const UNRESPONSIVE_RUNTIME_PENALTY = 100; - -export function calculateResourceAwarePlacementFromSnapshot({ - serviceId, - totalReplicas, - servers, - existingReplicas, - excludeServerIds, -}: PlacementPlanInput): PlacementResult { - if (totalReplicas < 1) { - throw new Error("At least one replica is required"); - } - - const excludedIds = new Set(excludeServerIds ?? []); - const eligibleServers = servers.filter( - (server) => - server.status === "online" && - server.wireguardIp !== null && - !excludedIds.has(server.id), - ); - - if (eligibleServers.length === 0) { - throw new Error("No healthy servers available for placement"); - } - - const projectedLoad = buildInitialProjectedLoad( - serviceId, - eligibleServers, - existingReplicas, - ); - const assignments: string[] = []; - - for (let replicaIndex = 0; replicaIndex < totalReplicas; replicaIndex++) { - // Resource limits are runtime caps, not reserved capacity, so placement - // ranks eligible servers without treating those limits as guaranteed load. - const unassignedCandidates = eligibleServers.filter( - (server) => - getProjectedLoad(projectedLoad, server.id).assignedServiceReplicas === - 0, - ); - const candidates = - unassignedCandidates.length > 0 ? unassignedCandidates : eligibleServers; - - const rankedCandidates = candidates - .map((server) => ({ - server, - score: scoreServer(server, getProjectedLoad(projectedLoad, server.id)), - hashScore: rendezvousHashScore(serviceId, replicaIndex, server.id), - })) - .sort(compareCandidates); - - const selected = rankedCandidates[0].server; - assignments.push(selected.id); - - const selectedLoad = getProjectedLoad(projectedLoad, selected.id); - selectedLoad.existingReplicas += 1; - selectedLoad.assignedServiceReplicas += 1; - } - - return groupAssignments(assignments); -} - -function buildInitialProjectedLoad( - serviceId: string, - servers: PlacementServerSnapshot[], - existingReplicas: ReplicaAllocationSnapshot[], -) { - const projectedLoad = new Map(); - const eligibleServerIds = new Set(servers.map((server) => server.id)); - - for (const server of servers) { - projectedLoad.set(server.id, { - existingReplicas: 0, - assignedServiceReplicas: 0, - }); - } - - for (const replica of existingReplicas) { - if ( - replica.serviceId === serviceId || - !eligibleServerIds.has(replica.serverId) - ) { - continue; - } - - const count = Math.max(0, replica.count); - const load = getProjectedLoad(projectedLoad, replica.serverId); - load.existingReplicas += count; - } - - return projectedLoad; -} - -function getProjectedLoad( - projectedLoad: Map, - serverId: string, -) { - const load = projectedLoad.get(serverId); - if (!load) { - throw new Error(`Missing projected load for server ${serverId}`); - } - return load; -} - -function scoreServer( - server: PlacementServerSnapshot, - load: ProjectedServerLoad, -) { - const liveCpuPressure = percentToRatio(server.healthStats?.cpuUsagePercent); - const liveMemoryPressure = percentToRatio( - server.healthStats?.memoryUsagePercent, - ); - const liveDiskPressure = percentToRatio(server.healthStats?.diskUsagePercent); - const runtimePenalty = - server.containerHealth?.runtimeResponsive === false - ? UNRESPONSIVE_RUNTIME_PENALTY - : 0; - const containerCountPenalty = - (server.containerHealth?.runningContainers ?? 0) * RUNNING_CONTAINER_WEIGHT; - - return ( - liveCpuPressure * LIVE_CPU_PRESSURE_WEIGHT + - liveMemoryPressure * LIVE_MEMORY_PRESSURE_WEIGHT + - liveDiskPressure * LIVE_DISK_PRESSURE_WEIGHT + - load.existingReplicas * EXISTING_REPLICA_WEIGHT + - containerCountPenalty + - runtimePenalty - ); -} - -function percentToRatio(value: number | undefined) { - if (value === undefined || Number.isNaN(value)) return 0; - return Math.max(0, value) / 100; -} - -function rendezvousHashScore( - serviceId: string, - replicaIndex: number, - serverId: string, -) { - const digest = createHash("sha256") - .update(`${serviceId}:${replicaIndex}:${serverId}`) - .digest("hex") - .slice(0, 12); - return Number.parseInt(digest, 16); -} - -function compareCandidates(a: CandidateScore, b: CandidateScore) { - if (Math.abs(a.score - b.score) > EPSILON) { - return a.score - b.score; - } - - if (a.hashScore !== b.hashScore) { - return b.hashScore - a.hashScore; - } - - return a.server.id.localeCompare(b.server.id); -} - -function groupAssignments(assignments: string[]): PlacementResult { - const placements = new Map(); - - for (const serverId of assignments) { - placements.set(serverId, (placements.get(serverId) ?? 0) + 1); - } - - return [...placements].map(([serverId, count]) => ({ serverId, count })); -} diff --git a/web/lib/placement.ts b/web/lib/placement.ts deleted file mode 100644 index 89a6a281..00000000 --- a/web/lib/placement.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { and, eq, isNotNull } from "drizzle-orm"; -import { db } from "@/db"; -import { metricSnapshotToHealthStats } from "@/db/queries"; -import { servers, serviceReplicas, settings } from "@/db/schema"; -import type { Service } from "@/db/types"; -import { - calculateResourceAwarePlacementFromSnapshot, - type PlacementResult, - type PlacementServerSnapshot, -} from "@/lib/placement-planner"; -import { SETTING_KEYS } from "@/lib/settings-keys"; -import { - type NodeMetricsSnapshot, - queryNodeMetricsSnapshots, -} from "@/lib/victoria-metrics"; - -export type { PlacementResult }; - -export async function calculateResourceAwarePlacement( - service: Pick, - totalReplicas: number, - excludeServerIds?: string[], -): Promise { - const [candidateServers, allocatedReplicas, excludedFromWorkload] = - await Promise.all([ - db - .select({ - id: servers.id, - status: servers.status, - wireguardIp: servers.wireguardIp, - containerHealth: servers.containerHealth, - }) - .from(servers) - .where( - and(eq(servers.status, "online"), isNotNull(servers.wireguardIp)), - ), - db - .select({ - serverId: serviceReplicas.serverId, - serviceId: serviceReplicas.serviceId, - count: serviceReplicas.count, - }) - .from(serviceReplicas), - getExcludedFromWorkloadPlacement(), - ]); - - const metricsByServer = await queryNodeMetricsSnapshots( - candidateServers.map((server) => server.id), - ).catch((error) => { - console.error("[placement] failed to query metrics:", error); - return new Map(); - }); - const serversWithMetrics = candidateServers.map((server) => { - const metrics = metricsByServer.get(server.id); - return { - ...server, - healthStats: metricSnapshotToHealthStats(metrics), - }; - }); - - return calculateResourceAwarePlacementFromSnapshot({ - serviceId: service.id, - totalReplicas, - servers: serversWithMetrics satisfies PlacementServerSnapshot[], - existingReplicas: allocatedReplicas, - excludeServerIds: [...(excludeServerIds ?? []), ...excludedFromWorkload], - }); -} - -async function getExcludedFromWorkloadPlacement(): Promise { - const row = await db - .select({ value: settings.value }) - .from(settings) - .where( - eq(settings.key, SETTING_KEYS.SERVERS_EXCLUDED_FROM_WORKLOAD_PLACEMENT), - ) - .then((result) => result[0]); - - const value = row?.value; - return Array.isArray(value) - ? value.filter( - (serverId): serverId is string => typeof serverId === "string", - ) - : []; -} - -export async function replaceServiceReplicaPlacements( - serviceId: string, - placements: PlacementResult, -) { - await db.transaction(async (tx) => { - await tx - .delete(serviceReplicas) - .where(eq(serviceReplicas.serviceId, serviceId)); - - if (placements.length === 0) return; - - await tx.insert(serviceReplicas).values( - placements.map((placement) => ({ - id: randomUUID(), - serviceId, - serverId: placement.serverId, - count: placement.count, - })), - ); - }); -} diff --git a/web/lib/scheduler.ts b/web/lib/scheduler.ts index 263722bc..89527b66 100644 --- a/web/lib/scheduler.ts +++ b/web/lib/scheduler.ts @@ -10,11 +10,10 @@ import { services, workQueue, } from "@/db/schema"; -import { sendDeploymentMovedAlert, sendServerOfflineAlert } from "@/lib/email"; import { - calculateResourceAwarePlacement, - replaceServiceReplicaPlacements, -} from "@/lib/placement"; + sendManualRecoveryRequiredAlert, + sendServerOfflineAlert, +} from "@/lib/email"; import { WORK_QUEUE_LEASE_DURATION_MS, WORK_QUEUE_MAX_ATTEMPTS, @@ -27,77 +26,80 @@ export async function triggerRecoveryForOfflineServers( ): Promise { if (offlineServerIds.length === 0) return; - const activeStatuses = ["running", "healthy", "starting"] as const; + const activeStatuses = [ + "pending", + "pulling", + "starting", + "healthy", + "running", + ] as const; const affectedDeployments = await db .select({ deploymentId: deployments.id, - serviceId: deployments.serviceId, serverId: deployments.serverId, - autoPlace: services.autoPlace, - stateful: services.stateful, - replicas: services.replicas, + serverName: servers.name, + serverPublicIp: servers.publicIp, + serverWireguardIp: servers.wireguardIp, + serviceName: services.name, }) .from(deployments) - .innerJoin(services, eq(deployments.serviceId, services.id)) + .innerJoin(servers, eq(servers.id, deployments.serverId)) + .innerJoin(services, eq(services.id, deployments.serviceId)) .where( and( inArray(deployments.serverId, offlineServerIds), inArray(deployments.status, activeStatuses), - eq(services.autoPlace, true), - eq(services.stateful, false), isNull(services.deletedAt), ), ); if (affectedDeployments.length === 0) { console.log( - "[scheduler] no auto-placed services affected by server failure", + `[scheduler] ${offlineServerIds.length} server(s) went offline; no active replicas need manual recovery`, ); return; } - const serviceIds = [...new Set(affectedDeployments.map((d) => d.serviceId))]; - console.log( - `[scheduler] recovering ${serviceIds.length} services affected by server failure`, - ); - - for (const serviceId of serviceIds) { - try { - const service = affectedDeployments.find( - (d) => d.serviceId === serviceId, - ); - if (!service) continue; - - console.log(`[scheduler] recovering service ${serviceId}`); - - const newPlacements = await calculateResourceAwarePlacement( - { id: service.serviceId }, - service.replicas, - offlineServerIds, - ); - - await replaceServiceReplicaPlacements(serviceId, newPlacements); - - await deployService(serviceId); - - sendDeploymentMovedAlert({ - serviceId, - reason: "Server went offline", - }).catch((error) => { - console.error( - `[scheduler] failed to send deployment moved alert for ${serviceId}:`, - error, - ); - }); + const affectedByServer = new Map< + string, + { + serverName: string; + serverIp?: string; + impactedReplicas: number; + serviceNames: Set; + } + >(); + + for (const deployment of affectedDeployments) { + const current = affectedByServer.get(deployment.serverId) ?? { + serverName: deployment.serverName, + serverIp: + deployment.serverWireguardIp || deployment.serverPublicIp || undefined, + impactedReplicas: 0, + serviceNames: new Set(), + }; + current.impactedReplicas += 1; + current.serviceNames.add(deployment.serviceName); + affectedByServer.set(deployment.serverId, current); + } - console.log(`[scheduler] service ${serviceId} recovery triggered`); - } catch (error) { + for (const [serverId, impact] of affectedByServer) { + console.log( + `[scheduler] server ${impact.serverName} went offline with ${impact.impactedReplicas} active replica(s); manual recovery required`, + ); + sendManualRecoveryRequiredAlert({ + serverId, + serverName: impact.serverName, + serverIp: impact.serverIp, + impactedReplicas: impact.impactedReplicas, + serviceNames: [...impact.serviceNames], + }).catch((error) => { console.error( - `[scheduler] failed to recover service ${serviceId}:`, + `[scheduler] failed to send manual recovery alert for ${impact.serverName}:`, error, ); - } + }); } } diff --git a/web/lib/service-config.ts b/web/lib/service-config.ts index 8848a723..7e6aec83 100644 --- a/web/lib/service-config.ts +++ b/web/lib/service-config.ts @@ -41,7 +41,6 @@ export type ResourceLimitsConfig = { }; export type PlacementConfig = { - autoPlace: boolean; replicas: number; }; @@ -77,7 +76,6 @@ export function buildCurrentConfig( startCommand: string | null; resourceCpuLimit: number | null; resourceMemoryLimitMb: number | null; - autoPlace: boolean; replicas: number; }, replicas: { serverId: string; serverName: string; count: number }[], @@ -95,10 +93,7 @@ export function buildCurrentConfig( }, hostname: service.hostname ?? undefined, placement: { - autoPlace: service.autoPlace, - replicas: service.autoPlace - ? service.replicas - : replicas.reduce((sum, r) => sum + r.count, 0), + replicas: replicas.reduce((sum, r) => sum + r.count, 0), }, replicas: replicas.map((r) => ({ serverId: r.serverId, @@ -152,20 +147,12 @@ export function diffConfigs( to: current.source.image, }); } - if (current.placement?.autoPlace) { + for (const replica of current.replicas) { changes.push({ - field: "Replicas", + field: `${replica.serverName} replicas`, from: "0", - to: String(current.placement.replicas), + to: String(replica.count), }); - } else { - for (const replica of current.replicas) { - changes.push({ - field: `${replica.serverName} replicas`, - from: "0", - to: String(replica.count), - }); - } } if (current.healthCheck) { changes.push({ @@ -236,72 +223,38 @@ export function diffConfigs( }); } - const deployedTotalReplicas = (deployed.replicas || []).reduce( - (sum, r) => sum + r.count, - 0, + const deployedReplicasMap = new Map( + (deployed.replicas || []).map((r) => [r.serverId, r]), + ); + const currentReplicasMap = new Map( + (current.replicas || []).map((r) => [r.serverId, r]), ); - if (current.placement?.autoPlace) { - if (deployed.placement && !deployed.placement.autoPlace) { + for (const [serverId, currentReplica] of currentReplicasMap) { + const deployedReplica = deployedReplicasMap.get(serverId); + if (!deployedReplica) { changes.push({ - field: "Placement", - from: "Manual", - to: "Auto-placement", + field: `${currentReplica.serverName} replicas`, + from: "0", + to: String(currentReplica.count), }); - } - - const deployedReplicaCount = - deployed.placement?.replicas ?? deployedTotalReplicas; - - if (deployedReplicaCount !== current.placement.replicas) { + } else if (deployedReplica.count !== currentReplica.count) { changes.push({ - field: "Replicas", - from: String(deployedReplicaCount), - to: String(current.placement.replicas), + field: `${currentReplica.serverName} replicas`, + from: String(deployedReplica.count), + to: String(currentReplica.count), }); } - } else { - if (deployed.placement?.autoPlace) { + } + + for (const [serverId, deployedReplica] of deployedReplicasMap) { + if (!currentReplicasMap.has(serverId)) { changes.push({ - field: "Placement", - from: "Auto-placement", - to: "Manual", + field: `${deployedReplica.serverName} replicas`, + from: String(deployedReplica.count), + to: "0 (removed)", }); } - - const deployedReplicasMap = new Map( - (deployed.replicas || []).map((r) => [r.serverId, r]), - ); - const currentReplicasMap = new Map( - (current.replicas || []).map((r) => [r.serverId, r]), - ); - - for (const [serverId, currentReplica] of currentReplicasMap) { - const deployedReplica = deployedReplicasMap.get(serverId); - if (!deployedReplica) { - changes.push({ - field: `${currentReplica.serverName} replicas`, - from: "0", - to: String(currentReplica.count), - }); - } else if (deployedReplica.count !== currentReplica.count) { - changes.push({ - field: `${currentReplica.serverName} replicas`, - from: String(deployedReplica.count), - to: String(currentReplica.count), - }); - } - } - - for (const [serverId, deployedReplica] of deployedReplicasMap) { - if (!currentReplicasMap.has(serverId)) { - changes.push({ - field: `${deployedReplica.serverName} replicas`, - from: String(deployedReplica.count), - to: "0 (removed)", - }); - } - } } const deployedHc = deployed.healthCheck; diff --git a/web/lib/settings-keys.ts b/web/lib/settings-keys.ts index 7001d9a5..6fda3c36 100644 --- a/web/lib/settings-keys.ts +++ b/web/lib/settings-keys.ts @@ -2,8 +2,6 @@ import { z } from "zod"; export const SETTING_KEYS = { SERVERS_ALLOWED_FOR_BUILDS: "servers_allowed_for_builds", - SERVERS_EXCLUDED_FROM_WORKLOAD_PLACEMENT: - "servers_excluded_from_workload_placement", BUILD_TIMEOUT_MINUTES: "build_timeout_minutes", ACME_EMAIL: "acme_email", PROXY_DOMAIN: "proxy_domain",