From ee6bc0ec6dd989598a40844c017607e9238255ea Mon Sep 17 00:00:00 2001
From: Lftobs
Date: Thu, 8 Oct 2026 05:46:01 +0100
Subject: [PATCH 01/17] fix(failover): live-probe targets, rate-limit auto
moves, clean old containers
Abort failover when the chosen target does not answer its SSH port,
refuse automatic failovers within FAILOVER_MIN_INTERVAL_MS (default 10m),
allow disabling the monitor with FAILOVER_DISABLED, and remove the previous
deployment's containers from the old server behind a 30s cap so a dead
host cannot hang the failover.
---
.../orchestrator/__tests__/failover-runner.ts | 92 +++++++++++++++++-
.../orchestrator/__tests__/failover.test.ts | 18 ++++
apps/api/src/orchestrator/failover.ts | 94 ++++++++++++++++++-
apps/api/src/utils/config.ts | 2 +
4 files changed, 201 insertions(+), 5 deletions(-)
diff --git a/apps/api/src/orchestrator/__tests__/failover-runner.ts b/apps/api/src/orchestrator/__tests__/failover-runner.ts
index b500c01..ce1d535 100644
--- a/apps/api/src/orchestrator/__tests__/failover-runner.ts
+++ b/apps/api/src/orchestrator/__tests__/failover-runner.ts
@@ -1,4 +1,5 @@
import { mock } from "bun:test";
+import net from "node:net";
const fileUrl = (relPath: string) => new URL(relPath, import.meta.url).toString();
@@ -7,6 +8,22 @@ let servers: any[] = [];
let projects: any[] = [];
let deploymentsByProject: Record = {};
+const dockerCalls: any[][] = [];
+
+mock.module(fileUrl("../../utils/docker-run"), () => ({
+ getDockerTargetArgs: mock(() => []),
+ dockerRun: mock(() => Promise.resolve("")),
+ dockerRunTry: mock((...args: any[]) => {
+ dockerCalls.push(args);
+ return Promise.resolve("");
+ }),
+ dockerExecStream: mock(() => null),
+ dockerExec: mock(() => Promise.resolve({ code: 0, stdout: "", stderr: "" })),
+ dockerExecWithStdin: mock(() => Promise.resolve({ code: 0, stdout: "", stderr: "" })),
+ ensureContainerRemoved: mock(() => Promise.resolve()),
+ ensureVolumeRemoved: mock(() => Promise.resolve()),
+}));
+
mock.module(fileUrl("../../db/repo"), () => ({
getPlatformSettings: mock(() => Promise.resolve(platformSettings)),
getProjectById: mock((id: string) => Promise.resolve(projects.find((p) => p.id === id) ?? null)),
@@ -47,6 +64,10 @@ mock.module(fileUrl("../../executors/dispatch"), () => ({
})),
}));
+const probeListener = net.createServer();
+await new Promise((resolve) => probeListener.listen(0, "127.0.0.1", resolve));
+const probePort = (probeListener.address() as net.AddressInfo).port;
+
const { failoverProject } = await import("../failover");
const results: any = {};
@@ -115,12 +136,20 @@ try {
platformSettings = { ingressServerId: "ing" };
servers = [
{ id: "ing", name: "Ingress", mode: "ssh" },
- { id: "a", name: "Current", mode: "ssh", status: "connected" },
- { id: "b", name: "Target", mode: "ssh", status: "connected" },
+ { id: "a", name: "Current", mode: "ssh", status: "connected", host: "127.0.0.1", port: probePort },
+ { id: "b", name: "Target", mode: "ssh", status: "connected", host: "127.0.0.1", port: probePort },
];
projects = [{ id: "p1", name: "proj-1", serverId: "a" }];
deploymentsByProject = {
- p1: [{ id: "dep-1", sourceType: "git", sourceRef: "https://github.com/x/y.git", branch: "main" }],
+ p1: [
+ {
+ id: "dep-1",
+ sourceType: "git",
+ sourceRef: "https://github.com/x/y.git",
+ branch: "main",
+ containerName: "app-old",
+ },
+ ],
};
const deployment = await failoverProject("p1");
@@ -133,4 +162,61 @@ try {
results.test5 = { ok: false, error: e.message };
}
+// Test 6: aborts when the chosen target is not actually reachable
+let createDeploymentCallsBefore6 = 0;
+try {
+ platformSettings = { ingressServerId: "ing" };
+ servers = [
+ { id: "ing", name: "Ingress", mode: "ssh" },
+ { id: "a", name: "Current", mode: "ssh", status: "connected", host: "127.0.0.1", port: probePort },
+ { id: "b", name: "DeadTarget", mode: "ssh", status: "connected", host: "127.0.0.1", port: 1 },
+ ];
+ projects = [{ id: "p1", name: "proj-1", serverId: "a" }];
+ deploymentsByProject = {
+ p1: [
+ {
+ id: "dep-1",
+ sourceType: "git",
+ sourceRef: "https://github.com/x/y.git",
+ branch: "main",
+ containerName: "app-old",
+ },
+ ],
+ };
+ const repo6 = await import("../../db/repo");
+ createDeploymentCallsBefore6 = (repo6.createDeployment as any).mock.calls.length;
+ await failoverProject("p1");
+ results.test6 = { ok: false, error: "should have thrown" };
+} catch (e: any) {
+ const repo = await import("../../db/repo");
+ const created = (repo.createDeployment as any).mock.calls.length - createDeploymentCallsBefore6;
+ results.test6 = {
+ ok: e.message?.includes("not reachable") && created === 0,
+ error: e.message,
+ };
+}
+
+// Test 7: stops the previous deployment's containers on the old server
+try {
+ const primaryRm = dockerCalls.some(
+ (args) => args[1]?.[0] === "rm" && args[1]?.includes("app-old") && args[2]?.id === "a",
+ );
+ const replicaList = dockerCalls.some(
+ (args) =>
+ args[1]?.[0] === "ps" && args[1]?.some((a: string) => a.includes("deploy-dep-1-replica-")) && args[2]?.id === "a",
+ );
+ results.test7 = { ok: primaryRm && replicaList };
+} catch (e: any) {
+ results.test7 = { ok: false, error: e.message };
+}
+
+// Test 8: refuses a second automatic failover within the minimum interval
+try {
+ await failoverProject("p1", { trigger: "auto" });
+ results.test8 = { ok: false, error: "should have thrown" };
+} catch (e: any) {
+ results.test8 = { ok: e.message?.includes("before another failover"), error: e.message };
+}
+
+probeListener.close();
console.log(JSON.stringify(results));
diff --git a/apps/api/src/orchestrator/__tests__/failover.test.ts b/apps/api/src/orchestrator/__tests__/failover.test.ts
index 5e442b1..a74b356 100644
--- a/apps/api/src/orchestrator/__tests__/failover.test.ts
+++ b/apps/api/src/orchestrator/__tests__/failover.test.ts
@@ -45,4 +45,22 @@ describe("failoverProject", () => {
const r = parse(stdout);
expect(r.test5.ok).toBe(true);
});
+
+ it("aborts when the chosen target server is not reachable", async () => {
+ const { stdout } = await run();
+ const r = parse(stdout);
+ expect(r.test6.ok).toBe(true);
+ });
+
+ it("removes the previous deployment's containers on the old server", async () => {
+ const { stdout } = await run();
+ const r = parse(stdout);
+ expect(r.test7.ok).toBe(true);
+ });
+
+ it("refuses a second automatic failover within the minimum interval", async () => {
+ const { stdout } = await run();
+ const r = parse(stdout);
+ expect(r.test8.ok).toBe(true);
+ });
});
diff --git a/apps/api/src/orchestrator/failover.ts b/apps/api/src/orchestrator/failover.ts
index 5b37cbb..4bf8c1e 100644
--- a/apps/api/src/orchestrator/failover.ts
+++ b/apps/api/src/orchestrator/failover.ts
@@ -11,16 +11,24 @@ import {
updateProject,
updateRouteStatus,
} from "../db/repo";
+import type { Server } from "../types";
+import { config } from "../utils/config";
+import { dockerBin } from "../utils/docker-bin";
+import { dockerRunTry } from "../utils/docker-run";
import { getIngressServer, removeIngressRouteFile } from "../utils/ingress";
import { pickBestServer } from "../utils/server-default";
+import { getContainerName } from "./runtime";
const CHECK_INTERVAL_MS = 30_000;
const GRACE_MS = 180_000;
const CONNECT_TIMEOUT_MS = 5_000;
+const OLD_CONTAINER_CLEANUP_TIMEOUT_MS = 30_000;
const unreachableSince = new Map();
const failingOver = new Set();
const previouslyUnreachableServers = new Set();
+const lastFailoverAt = new Map();
+const rateLimitWarned = new Set();
export const isServerReachable = (host: string, port: number = 22): Promise =>
new Promise((resolve) => {
@@ -34,11 +42,69 @@ export const isServerReachable = (host: string, port: number = 22): Promise finish(false));
});
-export const failoverProject = async (projectId: string) => {
+const isFailoverDisabled = () => /^(1|true|yes)$/i.test(config.failoverDisabled);
+
+const bestEffort = (work: Promise, label: string): Promise =>
+ Promise.race([
+ work,
+ new Promise((_resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error("timed out")), OLD_CONTAINER_CLEANUP_TIMEOUT_MS);
+ timer.unref?.();
+ }),
+ ])
+ .then(() => undefined)
+ .catch((error) => {
+ console.error(`[Failover] ${label} failed:`, error instanceof Error ? error.message : error);
+ });
+
+const removeOldServerContainers = (
+ server: Server,
+ deployment: { id: string; containerName?: string | null },
+ project: { name?: string | null; id: string },
+): Promise => {
+ const primaryName =
+ deployment.containerName || getContainerName(deployment.id, project.name ?? undefined, project.id);
+ const remove = async () => {
+ const listed = await dockerRunTry(
+ dockerBin,
+ ["ps", "-a", "--format", "{{.Names}}", "--filter", `name=deploy-${deployment.id}-replica-`],
+ server,
+ );
+ const replicas = (listed ?? "")
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean);
+ const names = [primaryName, ...replicas];
+ let removed = 0;
+ for (const name of names) {
+ await dockerRunTry(dockerBin, ["stop", "-t", "10", name], server);
+ const result = await dockerRunTry(dockerBin, ["rm", "-f", name], server);
+ if (result !== undefined) removed++;
+ }
+ console.log(
+ `[Failover] old-container cleanup on ${server.name}: removed ${removed}/${names.length} (${names.join(", ")})`,
+ );
+ };
+ return bestEffort(remove(), `old-container cleanup on ${server.name}`);
+};
+
+export const failoverProject = async (
+ projectId: string,
+ opts: { trigger?: "manual" | "auto" } = {},
+): Promise>> => {
if (failingOver.has(projectId)) throw new Error("Failover already in progress for this project");
const project = await getProjectById(projectId);
if (!project) throw new Error("Project not found");
if (!project.serverId) throw new Error("Project has no server assigned");
+ if ((opts.trigger ?? "manual") === "auto") {
+ const last = lastFailoverAt.get(projectId);
+ const since = last ? Date.now() - last : Number.POSITIVE_INFINITY;
+ if (since < config.failoverMinIntervalMs) {
+ throw new Error(
+ `Project failed over ${Math.round(since / 1000)}s ago — waiting ${config.failoverMinIntervalMs / 1000}s before another failover`,
+ );
+ }
+ }
const ingressServer = await getIngressServer();
if (!ingressServer) throw new Error("No ingress server configured");
const currentServer = await getServerById(project.serverId);
@@ -57,6 +123,10 @@ export const failoverProject = async (projectId: string) => {
}
const targetServer = await getServerById(targetId);
if (!targetServer) throw new Error("Target server not found");
+ const targetReachable = await isServerReachable(targetServer.host, targetServer.port || 22);
+ if (!targetReachable) {
+ throw new Error(`Target server ${targetServer.name} is not reachable — failover aborted`);
+ }
const deployment = await createDeployment({
projectId,
@@ -82,11 +152,17 @@ export const failoverProject = async (projectId: string) => {
await queueRemoteDeployment(deployment, project);
}
+ await removeOldServerContainers(currentServer, latest, project);
await updateProject(projectId, { serverId: targetId });
+ lastFailoverAt.set(projectId, Date.now());
+ rateLimitWarned.delete(projectId);
unreachableSince.delete(projectId);
await updateDeploymentStatus(latest.id, "inactive", {
failureReason: `Superseded by failover deployment to ${targetServer.name}`,
}).catch(() => {});
+ console.log(
+ `[Failover] project ${project.name ?? projectId} moved from ${currentServer.name} to ${targetServer.name} (deployment ${deployment.id})`,
+ );
return deployment;
} finally {
failingOver.delete(projectId);
@@ -148,7 +224,15 @@ export const failoverMonitorTick = async () => {
console.log(
`[Failover] Server ${server.name} unreachable for ${Math.round((Date.now() - firstSeen) / 1000)}s — failing over project ${project.name}`,
);
- failoverProject(project.id).catch((error) => {
+ failoverProject(project.id, { trigger: "auto" }).catch((error) => {
+ const message = error instanceof Error ? error.message : String(error);
+ if (message.includes("before another failover")) {
+ if (!rateLimitWarned.has(project.id)) {
+ rateLimitWarned.add(project.id);
+ console.log(`[Failover] skipping project ${project.name}: ${message}`);
+ }
+ return;
+ }
console.error(`[Failover] Auto-failover for project ${project.id} failed:`, error);
unreachableSince.delete(project.id);
});
@@ -170,14 +254,20 @@ export const failoverMonitorTick = async () => {
};
export const startFailoverMonitor = () => {
+ if (isFailoverDisabled()) {
+ console.log("[Failover] monitor disabled via FAILOVER_DISABLED");
+ return null;
+ }
const handle = setInterval(failoverMonitorTick, CHECK_INTERVAL_MS);
failoverMonitorTick();
return handle;
};
export const failoverState = () => ({
+ disabled: isFailoverDisabled(),
unreachable: [...unreachableSince.entries()].map(([projectId, since]) => ({
projectId,
unreachableForMs: Date.now() - since,
})),
+ minIntervalMs: config.failoverMinIntervalMs,
});
diff --git a/apps/api/src/utils/config.ts b/apps/api/src/utils/config.ts
index b54ddc0..8588e89 100644
--- a/apps/api/src/utils/config.ts
+++ b/apps/api/src/utils/config.ts
@@ -50,4 +50,6 @@ export const config = {
wireguardServerIp: withFile("WIREGUARD_SERVER_IP", "10.200.0.1"),
wireguardPeerCidr: withFile("WIREGUARD_PEER_CIDR", "10.200.0.0/24"),
gatewayCertDir: withFile("GATEWAY_CERT_DIR", "/app/data"),
+ failoverDisabled: withFile("FAILOVER_DISABLED", ""),
+ failoverMinIntervalMs: withFile("FAILOVER_MIN_INTERVAL_MS", "600000", Number),
};
From 850bf5b66aacd7e267d9a4de52e8c3fd407d62d0 Mon Sep 17 00:00:00 2001
From: Lftobs
Date: Thu, 8 Oct 2026 08:32:44 +0100
Subject: [PATCH 02/17] fix(ssh): run the railpack config generator on remote
hosts
Remote builds cloned the repo and ran `railpack build .` without ever
consulting the project's sourceDir or projectType, so any project whose
code lives in a subfolder failed with "Railpack could not determine how to
build the app" while the same project deployed fine locally.
Send the local railpack-config-utils.ts source to the target host, write it
into the clone through a quoted heredoc, and run it there with bun, which is
installed unzip-free via python3 when the host has neither bun nor unzip.
Remote failures now read stdout before stderr, so failureReason no longer
reports git's "Cloning into '.'..." instead of the real error.
---
.../src/executors/__tests__/dispatch.test.ts | 42 +++++++++++
apps/api/src/executors/ssh-build-script.ts | 70 +++++++++++++++++++
apps/api/src/executors/ssh.ts | 17 ++++-
.../__tests__/deployment-errors.test.ts | 23 +++++-
.../api/src/orchestrator/deployment-errors.ts | 3 +
5 files changed, 151 insertions(+), 4 deletions(-)
diff --git a/apps/api/src/executors/__tests__/dispatch.test.ts b/apps/api/src/executors/__tests__/dispatch.test.ts
index ab8d3bb..3ed6ebb 100644
--- a/apps/api/src/executors/__tests__/dispatch.test.ts
+++ b/apps/api/src/executors/__tests__/dispatch.test.ts
@@ -2,6 +2,8 @@ import { describe, expect, it } from "bun:test";
import { executorFor } from "../dispatch";
import { buildRemoteDeployScript, parseRemoteBuildResult } from "../ssh-build-script";
+const generator = "export const generateDynamicRailpackJson = async () => {};\n";
+
const input = {
deploymentId: "deployment-1",
workspaceRoot: "/var/lib/dequel/workspace",
@@ -11,6 +13,7 @@ const input = {
imageTag: "example-api-deploym:latest",
clearCache: false,
environmentVariables: [{ key: "NODE_ENV", value: "production" }],
+ railpackGenerator: generator,
};
describe("remote SSH build script", () => {
@@ -47,6 +50,45 @@ describe("remote SSH build script", () => {
const script = buildRemoteDeployScript(input);
expect(script).toContain("curl -fsSL https://railpack.com/install.sh");
});
+
+ it("ships the railpack generator into the workspace after the clone", () => {
+ const script = buildRemoteDeployScript(input);
+ expect(script).toContain("cat > \"$PROJECT_DIR/.dequel-railpack-gen.ts\" <<'DEQUEL_RAILPACK_GEN_EOF'");
+ expect(script).toContain(generator);
+ expect(script).toContain("await generateDynamicRailpackJson(");
+ expect(script.indexOf("git clone")).toBeLessThan(script.indexOf("DEQUEL_RAILPACK_GEN_EOF"));
+ expect(script).toContain('bun "$PROJECT_DIR/.dequel-railpack-gen.ts" "$PROJECT_DIR"');
+ expect(script).toContain("DEQUEL_RAILPACK_GEN_EOF\nbun ");
+ });
+
+ it("installs bun before generating the config", () => {
+ const script = buildRemoteDeployScript(input);
+ expect(script).toContain("bun-linux-${BUN_ARCH}.zip");
+ expect(script).toContain("python3 -m zipfile -e");
+ expect(script).toContain("bun.sh/install");
+ expect(script).toContain('export PATH="$HOME/.bun/bin:$PATH"');
+ expect(script.indexOf("bun-linux-${BUN_ARCH}.zip")).toBeLessThan(script.indexOf(".dequel-railpack-gen.ts"));
+ });
+
+ it("passes source dir and project type to the generator", () => {
+ const script = buildRemoteDeployScript({
+ ...input,
+ sourceDir: "client",
+ projectType: "static",
+ buildCommand: "npm run build",
+ startCommand: "npm start",
+ installCommand: null,
+ outputDir: null,
+ });
+ expect(script).toContain(
+ `bun "$PROJECT_DIR/.dequel-railpack-gen.ts" "$PROJECT_DIR" 'client' 'static' 'npm run build' 'npm start' '' ''`,
+ );
+ });
+
+ it("escapes single quotes in the source dir", () => {
+ const script = buildRemoteDeployScript({ ...input, sourceDir: "it's" });
+ expect(script).toContain(`"$PROJECT_DIR" 'it'\\''s'`);
+ });
});
describe("remote build result parsing", () => {
diff --git a/apps/api/src/executors/ssh-build-script.ts b/apps/api/src/executors/ssh-build-script.ts
index 6b94e4c..8b138cd 100644
--- a/apps/api/src/executors/ssh-build-script.ts
+++ b/apps/api/src/executors/ssh-build-script.ts
@@ -7,10 +7,32 @@ export interface SshBuildScriptInput {
imageTag: string;
clearCache?: boolean;
environmentVariables: { key: string; value: string }[];
+ sourceDir?: string | null;
+ projectType?: string | null;
+ buildCommand?: string | null;
+ installCommand?: string | null;
+ outputDir?: string | null;
+ startCommand?: string | null;
+ railpackGenerator: string;
}
const sh = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`;
+const GENERATOR_EOF = "DEQUEL_RAILPACK_GEN_EOF";
+
+const GENERATOR_CLI = `const [workspace, sourceDir, projectType, buildCommand, startCommand, installCommand, outputDir] =
+ process.argv.slice(2);
+await generateDynamicRailpackJson(
+ workspace,
+ sourceDir || null,
+ projectType || null,
+ buildCommand || null,
+ startCommand || null,
+ async (line) => console.log(line),
+ installCommand || null,
+ outputDir || null,
+);`;
+
export const buildRemoteDeployScript = (input: SshBuildScriptInput): string => {
const cacheKey = input.imageTag
.split(":")[0]
@@ -28,6 +50,16 @@ export const buildRemoteDeployScript = (input: SshBuildScriptInput): string => {
const remoteWorkspace = input.workspaceRoot.startsWith("/app") ? "$HOME/.dequel/workspace" : input.workspaceRoot;
+ const generatorArgs = [
+ '"$PROJECT_DIR"',
+ sh(input.sourceDir ?? ""),
+ sh(input.projectType ?? ""),
+ sh(input.buildCommand ?? ""),
+ sh(input.startCommand ?? ""),
+ sh(input.installCommand ?? ""),
+ sh(input.outputDir ?? ""),
+ ].join(" ");
+
return [
"set -euo pipefail",
"",
@@ -55,6 +87,44 @@ export const buildRemoteDeployScript = (input: SshBuildScriptInput): string => {
`git clone --depth 1 ${sh(input.gitUrl)} .`,
checkoutSha,
"",
+ 'if command -v bun >/dev/null 2>&1 || [ -x "$HOME/.bun/bin/bun" ]; then',
+ ' echo "[build] bun already installed"',
+ "else",
+ ' echo "[build] Installing bun (required to generate railpack.json)"',
+ ' ARCH="$(uname -m)"',
+ ' case "$ARCH" in',
+ " x86_64) BUN_ARCH=x64 ;;",
+ " aarch64|arm64) BUN_ARCH=aarch64 ;;",
+ ' *) echo "[build] unsupported architecture for bun: $ARCH" >&2; exit 1 ;;',
+ " esac",
+ " if command -v python3 >/dev/null 2>&1; then",
+ ' mkdir -p "$HOME/.bun/bin" /tmp/dequel-bun',
+ ' curl -fsSL -o /tmp/dequel-bun.zip "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-${BUN_ARCH}.zip"',
+ " python3 -m zipfile -e /tmp/dequel-bun.zip /tmp/dequel-bun",
+ ' mv "/tmp/dequel-bun/bun-linux-${BUN_ARCH}/bun" "$HOME/.bun/bin/bun"',
+ ' chmod +x "$HOME/.bun/bin/bun"',
+ " rm -rf /tmp/dequel-bun.zip /tmp/dequel-bun",
+ " elif command -v unzip >/dev/null 2>&1; then",
+ ' curl -fsSL https://bun.sh/install | BUN_INSTALL="$HOME/.bun" bash',
+ " else",
+ ' echo "[build] bun install requires python3 or unzip on this machine" >&2',
+ " exit 1",
+ " fi",
+ "fi",
+ 'export PATH="$HOME/.bun/bin:$PATH"',
+ "if ! command -v bun >/dev/null 2>&1; then",
+ ' echo "[build] bun is required to generate railpack.json but is not installed" >&2',
+ " exit 1",
+ "fi",
+ "",
+ 'echo "[build] Generating dynamic railpack.json for caching and monorepo resolution"',
+ `cat > "$PROJECT_DIR/.dequel-railpack-gen.ts" <<'${GENERATOR_EOF}'`,
+ input.railpackGenerator,
+ GENERATOR_CLI,
+ GENERATOR_EOF,
+ `bun "$PROJECT_DIR/.dequel-railpack-gen.ts" ${generatorArgs}`,
+ 'rm -f "$PROJECT_DIR/.dequel-railpack-gen.ts"',
+ "",
'SHA="$(git rev-parse HEAD)"',
`echo "[build] Building image ${input.imageTag} with Railpack"`,
`railpack build --name ${sh(input.imageTag)} --progress plain --cache-key ${sh(effectiveCacheKey)} \\`,
diff --git a/apps/api/src/executors/ssh.ts b/apps/api/src/executors/ssh.ts
index 2c2eff5..f5911c3 100644
--- a/apps/api/src/executors/ssh.ts
+++ b/apps/api/src/executors/ssh.ts
@@ -1,4 +1,6 @@
-import { summarizeDeploymentError } from "../orchestrator/deployment-errors";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { remoteScriptFailure, summarizeDeploymentError } from "../orchestrator/deployment-errors";
import type { Deployment, Project, Server } from "../types";
import { config } from "../utils/config";
import { CANCELLED_FAILURE_REASON } from "../utils/failure-outcome";
@@ -15,6 +17,8 @@ import type { DeploymentExecutor, ExecutorCancelInput, ExecutorDeployInput, Exec
let repoModule: typeof import("../db/repo") | null = null;
let runtimeModule: typeof import("../orchestrator/runtime") | null = null;
+const railpackGenerator = readFileSync(join(import.meta.dir, "../orchestrator/railpack-config-utils.ts"), "utf8");
+
const getRepo = async () => (repoModule ??= await import("../db/repo"));
const getRuntime = async () => (runtimeModule ??= await import("../orchestrator/runtime"));
@@ -108,7 +112,7 @@ const deployComposeRemote = async (deployment: Deployment, project: Project, ser
await emitLog(deployment.id, "build", line);
},
});
- if (result.code !== 0) throw new Error(result.stderr || result.stdout || "Remote compose build failed");
+ if (result.code !== 0) throw new Error(remoteScriptFailure(result, "Remote compose build failed"));
const composeResult = parseRemoteComposeResult(result.stdout);
if (!composeResult) throw new Error("Remote compose completed without a result marker");
@@ -297,6 +301,13 @@ export const sshExecutor: DeploymentExecutor = {
imageTag,
clearCache: deployment.clearCache ?? false,
environmentVariables: envVars,
+ sourceDir: project.sourceDir,
+ projectType: project.projectType,
+ buildCommand: project.buildCommand,
+ installCommand: project.installCommand,
+ outputDir: project.outputDir,
+ startCommand: project.startCommand,
+ railpackGenerator,
});
try {
@@ -305,7 +316,7 @@ export const sshExecutor: DeploymentExecutor = {
await emitLog(deployment.id, "build", line);
},
});
- if (result.code !== 0) throw new Error(result.stderr || result.stdout || "Remote build failed");
+ if (result.code !== 0) throw new Error(remoteScriptFailure(result, "Remote build failed"));
const buildResult = parseRemoteBuildResult(result.stdout);
if (!buildResult) throw new Error("Remote build completed without a result marker");
diff --git a/apps/api/src/orchestrator/__tests__/deployment-errors.test.ts b/apps/api/src/orchestrator/__tests__/deployment-errors.test.ts
index fdbf786..4b0a5bd 100644
--- a/apps/api/src/orchestrator/__tests__/deployment-errors.test.ts
+++ b/apps/api/src/orchestrator/__tests__/deployment-errors.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
-import { summarizeDeploymentError } from "../deployment-errors";
+import { remoteScriptFailure, summarizeDeploymentError } from "../deployment-errors";
describe("summarizeDeploymentError", () => {
test("extracts an actionable runtime requirement from noisy build output", () => {
@@ -33,3 +33,24 @@ unrecognized image format`);
);
});
});
+
+describe("remoteScriptFailure", () => {
+ test("prefers stdout so git progress on stderr cannot become the failure reason", () => {
+ const message = remoteScriptFailure(
+ { stdout: "✖ Railpack could not determine how to build the app.", stderr: "Cloning into '.'..." },
+ "Remote build failed",
+ );
+ expect(message).toContain("Railpack could not determine");
+ expect(message).toContain("Cloning into");
+ });
+
+ test("keeps stderr when stdout is empty", () => {
+ expect(remoteScriptFailure({ stdout: "", stderr: "Permission denied (publickey)." }, "fallback")).toBe(
+ "Permission denied (publickey).",
+ );
+ });
+
+ test("falls back when both streams are empty", () => {
+ expect(remoteScriptFailure({ stdout: "", stderr: "" }, "Remote build failed")).toBe("Remote build failed");
+ });
+});
diff --git a/apps/api/src/orchestrator/deployment-errors.ts b/apps/api/src/orchestrator/deployment-errors.ts
index 8f9bbf1..770a3d2 100644
--- a/apps/api/src/orchestrator/deployment-errors.ts
+++ b/apps/api/src/orchestrator/deployment-errors.ts
@@ -29,3 +29,6 @@ export const summarizeDeploymentError = (error: unknown): string => {
return "Deployment failed. Check the deployment logs for details.";
};
+
+export const remoteScriptFailure = (result: { stdout: string; stderr: string }, fallback: string): string =>
+ [result.stdout, result.stderr].filter(Boolean).join("\n") || fallback;
From 34a863854e580fd48f58d77b778e29d38da2b425 Mon Sep 17 00:00:00 2001
From: Lftobs
Date: Thu, 8 Oct 2026 21:47:06 +0100
Subject: [PATCH 03/17] fix(logs): access-log ingress routes and target runtime
logs at the deployment's server
ingressSite (the route writer for cross-server projects behind the local
ingress) never emitted CADDY_ACCESS_LOG_BLOCK, so project requests were
never access-logged and request-logs/metrics stayed empty.
Runtime logs ran 'docker logs' against the local node for every
deployment (the SSE stream used a raw spawn, bypassing ssh targeting),
returning [] for anything on a remote server; both handlers now resolve
the deployment's server behind orchestrator/runtime-logs.ts.
---
apps/api/src/api/deployments/index.ts | 28 ++++----
.../__tests__/runtime-logs-runner.ts | 67 +++++++++++++++++++
.../__tests__/runtime-logs.test.ts | 46 +++++++++++++
apps/api/src/orchestrator/runtime-logs.ts | 41 ++++++++++++
apps/api/src/utils/__tests__/ingress.test.ts | 2 +
apps/api/src/utils/ingress.ts | 4 +-
6 files changed, 171 insertions(+), 17 deletions(-)
create mode 100644 apps/api/src/orchestrator/__tests__/runtime-logs-runner.ts
create mode 100644 apps/api/src/orchestrator/__tests__/runtime-logs.test.ts
create mode 100644 apps/api/src/orchestrator/runtime-logs.ts
diff --git a/apps/api/src/api/deployments/index.ts b/apps/api/src/api/deployments/index.ts
index 138e5c8..2da9526 100644
--- a/apps/api/src/api/deployments/index.ts
+++ b/apps/api/src/api/deployments/index.ts
@@ -16,6 +16,7 @@ import { executorFor } from "../../executors/dispatch";
import { orchestrator } from "../../orchestrator";
import { summarizeDeploymentError } from "../../orchestrator/deployment-errors";
import { logBus } from "../../orchestrator/log-bus";
+import { readRuntimeLogs, runtimeLogFollowerArgs } from "../../orchestrator/runtime-logs";
import { config } from "../../utils/config";
import { isPrivateGitUrl } from "../../utils/validate";
import { created, fail, ok } from "../response";
@@ -329,20 +330,8 @@ export const deploymentsRoutes = new Elysia()
set.status = 404;
return fail("Deployment not found");
}
- const { run } = await import("../../orchestrator/runtime");
- const containerName = deployment.containerName || `deploy-${id}`;
try {
- const output = await run("docker", ["logs", "--tail", "200", containerName]);
- const lines = output
- .split("\n")
- .filter(Boolean)
- .map((line, i) => ({
- sequence: i + 1,
- message: line,
- timestamp: new Date().toISOString(),
- stage: "runtime" as const,
- }));
- return ok(lines);
+ return ok(await readRuntimeLogs(deployment));
} catch {
return ok([]);
}
@@ -354,7 +343,6 @@ export const deploymentsRoutes = new Elysia()
return fail("Deployment not found");
}
const encoder = new TextEncoder();
- const containerName = deployment.containerName || `deploy-${id}`;
let closed = false;
const stop = () => {
closed = true;
@@ -369,9 +357,19 @@ export const deploymentsRoutes = new Elysia()
controller.enqueue(encoder.encode(`event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`));
};
const { spawn } = await import("node:child_process");
- const child = spawn("docker", ["logs", "--tail", "100", "--follow", containerName], {
+ const child = spawn("docker", await runtimeLogFollowerArgs(deployment), {
stdio: ["ignore", "pipe", "pipe"],
});
+ child.on("error", (err) => {
+ send("log", {
+ sequence: 0,
+ message: `docker logs failed: ${err.message}`,
+ timestamp: new Date().toISOString(),
+ stage: "runtime",
+ });
+ send("close", { reason: "spawn failed" });
+ stop();
+ });
let seq = 0;
child.stdout.on("data", (chunk: Buffer) => {
const lines = chunk.toString().split("\n").filter(Boolean);
diff --git a/apps/api/src/orchestrator/__tests__/runtime-logs-runner.ts b/apps/api/src/orchestrator/__tests__/runtime-logs-runner.ts
new file mode 100644
index 0000000..2159e72
--- /dev/null
+++ b/apps/api/src/orchestrator/__tests__/runtime-logs-runner.ts
@@ -0,0 +1,67 @@
+import { mock } from "bun:test";
+
+const fileUrl = (relPath: string) => new URL(relPath, import.meta.url).toString();
+
+const servers: Record = {
+ "srv-worker": { id: "srv-worker", mode: "ssh", host: "1.2.3.4", port: 22 },
+};
+
+const dockerCalls: any[][] = [];
+const targetArgsCalls: any[] = [];
+
+mock.module(fileUrl("../../db/repo"), () => ({
+ getServerById: mock((id: string) => Promise.resolve(servers[id] ?? null)),
+}));
+
+mock.module(fileUrl("../../utils/docker-run"), () => ({
+ dockerRun: mock((...args: any[]) => {
+ dockerCalls.push(args);
+ return Promise.resolve("line one\nline two");
+ }),
+ getDockerTargetArgs: mock((server: any) => {
+ targetArgsCalls.push(server);
+ return server?.mode === "ssh" ? ["-H", `ssh://root@${server.host}:${server.port}`] : [];
+ }),
+}));
+
+try {
+ const { readRuntimeLogs, runtimeLogFollowerArgs } = await import("../runtime-logs");
+
+ const results: any = {};
+
+ const lines = await readRuntimeLogs({ id: "dep-1", containerName: "app-old", serverId: "srv-worker" });
+ results.readOnRemote = {
+ count: lines.length,
+ first: { sequence: lines[0].sequence, message: lines[0].message, stage: lines[0].stage },
+ serverPassed: dockerCalls[0][2]?.id === "srv-worker",
+ command: dockerCalls[0].slice(0, 2),
+ };
+
+ dockerCalls.length = 0;
+ const localLines = await readRuntimeLogs({ id: "dep-2", containerName: null, serverId: null });
+ results.readLocal = {
+ count: localLines.length,
+ command: dockerCalls[0].slice(0, 2),
+ serverNull: dockerCalls[0][2] === null,
+ };
+
+ targetArgsCalls.length = 0;
+ const followerArgs = await runtimeLogFollowerArgs({ id: "dep-3", containerName: "app-1", serverId: "srv-worker" });
+ results.follower = {
+ args: followerArgs,
+ serverPassed: targetArgsCalls[0]?.id === "srv-worker",
+ };
+
+ targetArgsCalls.length = 0;
+ const localArgs = await runtimeLogFollowerArgs({ id: "dep-4", containerName: null, serverId: null });
+ results.followerLocal = {
+ args: localArgs,
+ serverNull: targetArgsCalls[0] === null,
+ };
+
+ console.log(JSON.stringify(results));
+} catch (err: any) {
+ console.error("Runner error:", err?.message ?? String(err));
+ console.error(err?.stack ?? err);
+ process.exit(1);
+}
diff --git a/apps/api/src/orchestrator/__tests__/runtime-logs.test.ts b/apps/api/src/orchestrator/__tests__/runtime-logs.test.ts
new file mode 100644
index 0000000..a2cdfa4
--- /dev/null
+++ b/apps/api/src/orchestrator/__tests__/runtime-logs.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "bun:test";
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+
+const exec = promisify(execFile);
+
+const run = () =>
+ exec("bun", ["run", "src/orchestrator/__tests__/runtime-logs-runner.ts"], {
+ cwd: `${import.meta.dir}/../../..`,
+ timeout: 30000,
+ });
+
+const parse = (stdout: string) => {
+ const lines = stdout.trim().split("\n");
+ return JSON.parse(lines[lines.length - 1]);
+};
+
+describe("runtime logs target the deployment's server", () => {
+ it("reads logs through the remote server and maps lines", async () => {
+ const { stdout } = await run();
+ const results = parse(stdout);
+
+ expect(results.readOnRemote.count).toBe(2);
+ expect(results.readOnRemote.first).toEqual({ sequence: 1, message: "line one", stage: "runtime" });
+ expect(results.readOnRemote.serverPassed).toBe(true);
+ expect(results.readOnRemote.command).toEqual(["docker", ["logs", "--tail", "200", "app-old"]]);
+
+ expect(results.readLocal.count).toBe(2);
+ expect(results.readLocal.command).toEqual(["docker", ["logs", "--tail", "200", "deploy-dep-2"]]);
+ expect(results.readLocal.serverNull).toBe(true);
+
+ expect(results.follower.serverPassed).toBe(true);
+ expect(results.follower.args).toEqual([
+ "-H",
+ "ssh://root@1.2.3.4:22",
+ "logs",
+ "--tail",
+ "100",
+ "--follow",
+ "app-1",
+ ]);
+
+ expect(results.followerLocal.serverNull).toBe(true);
+ expect(results.followerLocal.args).toEqual(["logs", "--tail", "100", "--follow", "deploy-dep-4"]);
+ }, 30_000);
+});
diff --git a/apps/api/src/orchestrator/runtime-logs.ts b/apps/api/src/orchestrator/runtime-logs.ts
new file mode 100644
index 0000000..76f31e9
--- /dev/null
+++ b/apps/api/src/orchestrator/runtime-logs.ts
@@ -0,0 +1,41 @@
+import { getServerById } from "../db/repo";
+import type { Server } from "../types";
+import { dockerRun, getDockerTargetArgs } from "../utils/docker-run";
+
+export interface RuntimeLogLine {
+ sequence: number;
+ message: string;
+ timestamp: string;
+ stage: "runtime";
+}
+
+interface DeploymentTarget {
+ id: string;
+ containerName?: string | null;
+ serverId?: string | null;
+}
+
+const resolveServer = async (deployment: DeploymentTarget): Promise =>
+ deployment.serverId ? await getServerById(deployment.serverId) : null;
+
+const containerNameFor = (deployment: DeploymentTarget): string =>
+ deployment.containerName || `deploy-${deployment.id}`;
+
+export const readRuntimeLogs = async (deployment: DeploymentTarget): Promise => {
+ const server = await resolveServer(deployment);
+ const output = await dockerRun("docker", ["logs", "--tail", "200", containerNameFor(deployment)], server);
+ return output
+ .split("\n")
+ .filter(Boolean)
+ .map((line, i) => ({
+ sequence: i + 1,
+ message: line,
+ timestamp: new Date().toISOString(),
+ stage: "runtime" as const,
+ }));
+};
+
+export const runtimeLogFollowerArgs = async (deployment: DeploymentTarget): Promise => {
+ const server = await resolveServer(deployment);
+ return [...getDockerTargetArgs(server), "logs", "--tail", "100", "--follow", containerNameFor(deployment)];
+};
diff --git a/apps/api/src/utils/__tests__/ingress.test.ts b/apps/api/src/utils/__tests__/ingress.test.ts
index 433f6e3..2e2c425 100644
--- a/apps/api/src/utils/__tests__/ingress.test.ts
+++ b/apps/api/src/utils/__tests__/ingress.test.ts
@@ -19,6 +19,8 @@ describe("ingress route shapes", () => {
const snippet = ingressSite("app.example.com", "203.0.113.10");
expect(snippet).toContain("reverse_proxy 203.0.113.10:80");
expect(snippet).not.toContain("header_up Host");
+ expect(snippet).toContain("log {");
+ expect(snippet).toContain("output stdout");
});
});
diff --git a/apps/api/src/utils/ingress.ts b/apps/api/src/utils/ingress.ts
index cdb3936..22e9b0a 100644
--- a/apps/api/src/utils/ingress.ts
+++ b/apps/api/src/utils/ingress.ts
@@ -1,7 +1,7 @@
import { rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createAgentJob, getPlatformSettings, getServerById, upsertRoute } from "../db/repo";
-import { caddyReverseProxy, caddySite } from "./caddy-site";
+import { CADDY_ACCESS_LOG_BLOCK, caddyReverseProxy, caddySite } from "./caddy-site";
import { config } from "./config";
import { removeRemoteCaddyRoute, syncRemoteCaddyRoute } from "./ssh";
@@ -47,7 +47,7 @@ export const projectServerSite = (
};
export const ingressSite = (hostname: string, upstreamHost: string): string =>
- `${hostname} {\n reverse_proxy ${upstreamHost}:80\n}\n`;
+ `${hostname} {\n${CADDY_ACCESS_LOG_BLOCK}\n reverse_proxy ${upstreamHost}:80\n}\n`;
export const syncIngressRoute = async (
ingressServer: { id: string; mode: string },
From 31cbea0fcf6091ea4b1a991e23a0a7cec34812d4 Mon Sep 17 00:00:00 2001
From: Lftobs
Date: Fri, 9 Oct 2026 02:06:38 +0100
Subject: [PATCH 04/17] fix: bug-sheet fixes, test-suite repair, and Let's
Encrypt certs for database hosts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Close out the open rows from artifacts/verify-dequel/BUGS.md (005-018,
027, 028, 030-033, 039, 040), live-verified on the primary host.
BUG-027: cancelling a deployment now kills the build's whole process
group (detached local spawns with pgid tracking, DEQUEL_PGID echoed by
the remote script and parsed on abort), and the cancel handler reports
the abort instead of a generic failure.
BUG-039: pam-auth mounts host /etc as a directory and copies
passwd/shadow/group into the container in place every 2s, so
useradd/chpasswd/usermod become visible to PAM immediately — file binds
froze the mount-time inode so logins kept failing until a restart.
BUG-040: Caddy now provisions real Let's Encrypt certificates for db-*
hostnames (site route files database-.caddy written on create,
patch, and delete trigger auto-HTTPS), and the gateway serves those
certs from the shared caddy-data volume (mtime-cached, SAN and expiry
checked, self-signed fallback kept). Live-verified: openssl
-verify_return_error returns 0 for all three public databases and the
node pg driver's verify-full semantics now connect out of the box.
BUG-016: the credentials endpoint probes external reachability and
returns a warning the dashboard surfaces when the endpoint is down.
Also extract authMiddleware into its own module, add lazy repo imports
and orchestrator mocks so bun test runs green again (440 pass, 1
pre-existing DiagRunMachine failure), and correct the database TLS docs
note to describe the new certificate path.
---
apps/api/src/api/auth-middleware.ts | 53 +++++++
apps/api/src/api/databases/index.ts | 58 +++++++-
apps/api/src/api/deployments/index.ts | 10 +-
apps/api/src/api/index.ts | 47 +------
apps/api/src/api/projects/index.ts | 23 ++-
apps/api/src/api/scaling/index.ts | 49 +++++++
apps/api/src/api/ssh-keys/index.ts | 65 +++++++--
apps/api/src/db/repo/projects.ts | 50 +++++--
apps/api/src/db/repo/ssh-keys.ts | 10 +-
apps/api/src/executors/ssh.ts | 64 +++++++--
apps/api/src/fixdiag/provider-models.ts | 21 +--
apps/api/src/index.ts | 11 +-
.../orchestrator/__tests__/failover-runner.ts | 24 +++-
.../orchestrator/__tests__/failover.test.ts | 6 +
apps/api/src/orchestrator/compose.ts | 17 ++-
apps/api/src/orchestrator/failover.ts | 21 +--
apps/api/src/orchestrator/project-cleanup.ts | 51 +++++++
apps/api/src/orchestrator/railpack.ts | 12 +-
apps/api/src/scaling/__tests__/engine.test.ts | 46 ++++++
apps/api/src/servers/prepare.ts | 131 ++++++++++++++++--
apps/api/src/tunnels/cert-store.ts | 56 ++++++++
apps/api/src/tunnels/gateway.ts | 12 +-
apps/api/src/utils/compose-ingress.ts | 10 +-
apps/api/src/utils/db-caddy-route.ts | 68 +++++++++
apps/api/src/utils/permissions.ts | 13 ++
apps/api/src/utils/proc-group.ts | 12 ++
apps/api/src/utils/server-default.ts | 6 +-
apps/api/src/utils/ssh.ts | 9 +-
apps/api/src/utils/tcp-probe.ts | 16 +++
apps/docs/src/content/docs/databases.md | 2 +-
apps/web/src/api/client.ts | 13 +-
apps/web/src/api/databases.ts | 2 +
.../src/components/databases/DatabaseCard.tsx | 6 +
.../components/databases/DatabaseOverview.tsx | 11 +-
.../components/settings/LlmKeysSection.tsx | 14 +-
.../components/settings/ServersSection.tsx | 22 +--
.../components/settings/SshKeyPoolSection.tsx | 56 ++++++--
apps/web/src/routes/CreateProjectPage.tsx | 52 +++++--
apps/web/src/routes/Login.tsx | 4 +-
docker-compose.yml | 18 ++-
40 files changed, 963 insertions(+), 208 deletions(-)
create mode 100644 apps/api/src/api/auth-middleware.ts
create mode 100644 apps/api/src/orchestrator/project-cleanup.ts
create mode 100644 apps/api/src/tunnels/cert-store.ts
create mode 100644 apps/api/src/utils/db-caddy-route.ts
create mode 100644 apps/api/src/utils/proc-group.ts
create mode 100644 apps/api/src/utils/tcp-probe.ts
diff --git a/apps/api/src/api/auth-middleware.ts b/apps/api/src/api/auth-middleware.ts
new file mode 100644
index 0000000..2ba3a07
--- /dev/null
+++ b/apps/api/src/api/auth-middleware.ts
@@ -0,0 +1,53 @@
+import type { Elysia } from "elysia";
+import { fail } from "./response";
+
+const BYPASS_PATHS = new Set([
+ "/api/auth/login",
+ "/api/auth/logout",
+ "/api/auth/refresh",
+ "/api/auth/me",
+ "/api/health",
+ "/api/github/callback",
+ "/api/github/webhook",
+ "/api/agents/register",
+ "/api/agents/p2p-sync",
+]);
+
+export const authMiddleware = (app: Elysia) =>
+ app.onBeforeHandle(async ({ request, set, path }) => {
+ if (BYPASS_PATHS.has(path)) return;
+
+ const cookie = request.headers.get("cookie") || "";
+ const match = cookie.match(/(?:^|;\s*)dequel_session=([^;]+)/);
+ if (match) {
+ const { verifyAccessToken } = await import("../utils/auth");
+ const payload = await verifyAccessToken(match[1]);
+ if (payload) return;
+ set.status = 401;
+ return fail("Invalid session");
+ }
+
+ const authHeader = request.headers.get("authorization");
+ if (authHeader?.startsWith("Bearer ")) {
+ const token = authHeader.slice(7);
+ if (token) {
+ const { validateApiKey } = await import("../db/repo");
+ const { canPerformMethod, parsePermissions, requiredPermissionForMethod } = await import(
+ "../utils/permissions"
+ );
+ const key = await validateApiKey(token);
+ if (!key) {
+ set.status = 401;
+ return fail("Invalid API key");
+ }
+ if (!canPerformMethod(parsePermissions(key.permissions), request.method)) {
+ set.status = 403;
+ return fail(`API key is missing the ${requiredPermissionForMethod(request.method)} scope`);
+ }
+ return;
+ }
+ }
+
+ set.status = 401;
+ return fail("Authentication required");
+ });
diff --git a/apps/api/src/api/databases/index.ts b/apps/api/src/api/databases/index.ts
index fdd2526..39acbc5 100644
--- a/apps/api/src/api/databases/index.ts
+++ b/apps/api/src/api/databases/index.ts
@@ -13,7 +13,9 @@ import {
} from "../../db/repo";
import type { Database } from "../../types";
import { config } from "../../utils/config";
+import { removeDbGatewayRoute, syncDbGatewayRoute } from "../../utils/db-caddy-route";
import { resolveServerIp } from "../../utils/dns";
+import { probeTcp } from "../../utils/tcp-probe";
import { created, fail, ok } from "../response";
import { executeDatabaseQuery, getDatabaseTables } from "./query";
@@ -76,6 +78,7 @@ const createManagedDatabase = async (body: any, projectId: string | null, set: a
return fail("A database with this name already exists");
}
const dbRecord = await createDbRecord({ ...validation.input, projectId });
+ void syncDbGatewayRoute(dbRecord);
const { provisionDatabase } = await import("../../databases/manager");
provisionDatabase(dbRecord).catch((err: Error) => console.error("DB provision failed", err));
return created(sanitizeDatabase(dbRecord));
@@ -118,27 +121,66 @@ export const databasesRoutes = new Elysia()
? `${dbRecord.internalHost}.${baseDomain}`
: null;
if (gatewayHost) {
+ const reachable = await probeTcp(gatewayHost, 443);
+ if (reachable) {
+ return ok({
+ username: dbRecord.username,
+ password: dbRecord.password,
+ internalConnectionString: dbRecord.connectionString,
+ externalConnectionString: buildGatewayConnectionString(dbRecord, gatewayHost),
+ externalHost: gatewayHost,
+ externalPort: 443,
+ externalReachable: true,
+ warning: null,
+ });
+ }
return ok({
username: dbRecord.username,
password: dbRecord.password,
internalConnectionString: dbRecord.connectionString,
- externalConnectionString: buildGatewayConnectionString(dbRecord, gatewayHost),
+ externalConnectionString: null,
externalHost: gatewayHost,
externalPort: 443,
+ externalReachable: false,
+ warning: `Gateway endpoint ${gatewayHost}:443 is not reachable from the network — check the gateway service and DNS.`,
});
}
const externalHost = dbRecord.publicAccess && dbRecord.externalPort ? await resolveServerIp() : null;
const usableHost = externalHost && isNonLoopbackIp(externalHost) ? externalHost : null;
- const externalConnectionString = usableHost
- ? buildConnectionString(dbRecord, usableHost, dbRecord.externalPort!)
- : null;
+ if (usableHost && dbRecord.externalPort) {
+ const reachable = await probeTcp(usableHost, dbRecord.externalPort);
+ if (reachable) {
+ return ok({
+ username: dbRecord.username,
+ password: dbRecord.password,
+ internalConnectionString: dbRecord.connectionString,
+ externalConnectionString: buildConnectionString(dbRecord, usableHost, dbRecord.externalPort),
+ externalHost: usableHost,
+ externalPort: dbRecord.externalPort,
+ externalReachable: true,
+ warning: null,
+ });
+ }
+ return ok({
+ username: dbRecord.username,
+ password: dbRecord.password,
+ internalConnectionString: dbRecord.connectionString,
+ externalConnectionString: null,
+ externalHost: usableHost,
+ externalPort: dbRecord.externalPort,
+ externalReachable: false,
+ warning: `Public endpoint ${usableHost}:${dbRecord.externalPort} is not reachable — open the port in your cloud security group or firewall to allow external clients.`,
+ });
+ }
return ok({
username: dbRecord.username,
password: dbRecord.password,
internalConnectionString: dbRecord.connectionString,
- externalConnectionString,
- externalHost: usableHost,
- externalPort: usableHost ? dbRecord.externalPort : null,
+ externalConnectionString: null,
+ externalHost: null,
+ externalPort: null,
+ externalReachable: null,
+ warning: null,
});
})
.post("/databases/:id/start", async ({ params: { id }, set }) => {
@@ -235,6 +277,7 @@ export const databasesRoutes = new Elysia()
backupSchedule: body?.backupSchedule,
backupRetention: body?.backupRetention,
});
+ void syncDbGatewayRoute(updated!);
return ok(sanitizeDatabase(updated!));
})
.delete("/databases/:id", async ({ params: { id }, set }) => {
@@ -259,6 +302,7 @@ export const databasesRoutes = new Elysia()
return fail("Database resources could not be deleted; cleanup will be retried automatically");
}
await deleteDatabase(id);
+ void removeDbGatewayRoute(current);
return ok(null, "Database deleted");
});
});
diff --git a/apps/api/src/api/deployments/index.ts b/apps/api/src/api/deployments/index.ts
index 2da9526..ee97b0f 100644
--- a/apps/api/src/api/deployments/index.ts
+++ b/apps/api/src/api/deployments/index.ts
@@ -142,9 +142,17 @@ export const deploymentsRoutes = new Elysia()
set.status = 400;
return fail("archive file is required for upload source");
}
+ const safeName = basename(file.name || "project.zip").replace(/[^a-zA-Z0-9._-]/g, "_");
+ if (![".zip", ".tar", ".tar.gz", ".tgz"].some((ext) => safeName.toLowerCase().endsWith(ext))) {
+ set.status = 400;
+ return fail("Unsupported archive format. Use .zip, .tar, .tar.gz, or .tgz");
+ }
+ if (file.size === 0) {
+ set.status = 400;
+ return fail("Archive file is empty");
+ }
const uploadsDir = join(config.workspaceRoot, "uploads");
await mkdir(uploadsDir, { recursive: true });
- const safeName = basename(file.name || "project.zip").replace(/[^a-zA-Z0-9._-]/g, "_");
const uploadPath = join(uploadsDir, `${Date.now()}-${safeName}`);
const bytes = new Uint8Array(await file.arrayBuffer());
await writeFile(uploadPath, bytes);
diff --git a/apps/api/src/api/index.ts b/apps/api/src/api/index.ts
index 571912f..42708fc 100644
--- a/apps/api/src/api/index.ts
+++ b/apps/api/src/api/index.ts
@@ -1,9 +1,10 @@
import { Elysia } from "elysia";
-import { fail } from "./response";
+import { fixdiagRoutes } from "../fixdiag/routes";
import { agentRoutes } from "./agents";
import { alertsRoutes } from "./alerts";
import { apiKeysRoutes } from "./api-keys";
import { authRoutes } from "./auth";
+import { authMiddleware } from "./auth-middleware";
import { backupRoutes } from "./backups";
import { databasesRoutes } from "./databases";
import { deploymentsRoutes } from "./deployments";
@@ -14,59 +15,17 @@ import { healthRoutes } from "./health";
import { projectsRoutes } from "./projects";
import { projectStatusRoutes } from "./projects/status";
import { prometheusRoutes } from "./prometheus";
+import { fail } from "./response";
import { routesRoutes } from "./routes";
import { scalingRoutes } from "./scaling";
import { serverInfoRoutes } from "./server-info";
import { serversRoutes } from "./servers";
import { settingsRoutes } from "./settings";
import { llmSettingsRoutes } from "./settings/llm";
-import { fixdiagRoutes } from "../fixdiag/routes";
import { sharedEnvLinksRoutes, sharedEnvVarsRoutes } from "./shared-env-vars";
import { sshKeysRoutes } from "./ssh-keys";
import { volumesRoutes } from "./volumes";
-const BYPASS_PATHS = new Set([
- "/api/auth/login",
- "/api/auth/logout",
- "/api/auth/refresh",
- "/api/auth/me",
- "/api/health",
- "/api/github/callback",
- "/api/github/webhook",
- "/api/agents/register",
- "/api/agents/p2p-sync",
-]);
-
-const authMiddleware = (app: Elysia) =>
- app.onBeforeHandle(async ({ request, set, path }) => {
- if (BYPASS_PATHS.has(path)) return;
-
- const cookie = request.headers.get("cookie") || "";
- const match = cookie.match(/(?:^|;\s*)dequel_session=([^;]+)/);
- if (match) {
- const { verifyAccessToken } = await import("../utils/auth");
- const payload = await verifyAccessToken(match[1]);
- if (payload) return;
- set.status = 401;
- return fail("Invalid session");
- }
-
- const authHeader = request.headers.get("authorization");
- if (authHeader?.startsWith("Bearer ")) {
- const token = authHeader.slice(7);
- if (token) {
- const { validateApiKey } = await import("../db/repo");
- const key = await validateApiKey(token);
- if (key) return;
- set.status = 401;
- return fail("Invalid API key");
- }
- }
-
- set.status = 401;
- return fail("Authentication required");
- });
-
const INTERNAL_ERROR =
/Failed query:|params:|getaddrinfo|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|EHOSTUNREACH|timeout exceeded|node:internal|Cannot read propert|is not a function|is not a constructor|Unexpected token/i;
diff --git a/apps/api/src/api/projects/index.ts b/apps/api/src/api/projects/index.ts
index fe698b2..06f377d 100644
--- a/apps/api/src/api/projects/index.ts
+++ b/apps/api/src/api/projects/index.ts
@@ -9,14 +9,17 @@ import {
listProjects,
updateProject,
} from "../../db/repo";
+import { destroyComposeStack } from "../../orchestrator/compose";
import { failoverProject } from "../../orchestrator/failover";
+import { cleanupProjectContainers } from "../../orchestrator/project-cleanup";
import { reloadCaddy, tryRun } from "../../orchestrator/runtime";
import { config } from "../../utils/config";
import { dockerBin } from "../../utils/docker-bin";
+import { dockerRunTry, ensureContainerRemoved } from "../../utils/docker-run";
import { removeFromCaddyRoute } from "../../utils/domain-verifier";
import { buildProjectRequestHostRegex, caddyRequestLogSelector } from "../../utils/loki";
-import { isPort, isPrivateGitUrl, SERVICE_NAME_RE, validateComposeServices } from "../../utils/validate";
import { captureTelemetry } from "../../utils/telemetry";
+import { isPort, isPrivateGitUrl, SERVICE_NAME_RE, validateComposeServices } from "../../utils/validate";
import { created, fail, ok } from "../response";
const validateComposeFields = (body: any): string | null => {
@@ -168,23 +171,17 @@ export const projectsRoutes = new Elysia()
return fail("Project not found");
}
- for (const name of info.deploymentContainerNames) {
- await tryRun(dockerBin, ["stop", "-t", "5", name]);
- await tryRun(dockerBin, ["rm", "-f", name]);
- }
- for (const name of info.databaseContainerNames) {
- await tryRun(dockerBin, ["stop", "-t", "5", name]);
- await tryRun(dockerBin, ["rm", "-f", name]);
- }
+ await cleanupProjectContainers(info, {
+ getServerById,
+ ensureContainerRemoved,
+ destroyComposeStack,
+ dockerRunTry,
+ });
for (const name of [...info.databaseVolumeNames, ...info.volumeDockerNames]) {
await tryRun(dockerBin, ["volume", "rm", "-f", name]);
}
- for (const tag of info.deploymentImageTags) {
- if (tag) await tryRun(dockerBin, ["rmi", "-f", tag]);
- }
-
for (const { domain, projectName } of info.domains) {
await removeFromCaddyRoute(domain, id, projectName);
}
diff --git a/apps/api/src/api/scaling/index.ts b/apps/api/src/api/scaling/index.ts
index ef1c7b6..419afcb 100644
--- a/apps/api/src/api/scaling/index.ts
+++ b/apps/api/src/api/scaling/index.ts
@@ -2,6 +2,45 @@ import { Elysia } from "elysia";
import { deleteScalingPolicy, getProjectById, getScalingPolicy, upsertScalingPolicy } from "../../db/repo";
import { fail, ok } from "../response";
+const isPercent = (value: unknown): value is number =>
+ typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 100;
+
+const isNonNegativeInt = (value: unknown): value is number =>
+ typeof value === "number" && Number.isInteger(value) && value >= 0;
+
+const isCooldown = (value: unknown): value is number =>
+ typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 86_400;
+
+const validatePolicyBody = (body: any): string | null => {
+ if (body.cpuThresholdPercent !== undefined && !isPercent(body.cpuThresholdPercent)) {
+ return "cpuThresholdPercent must be an integer between 1 and 100";
+ }
+ if (body.memoryThresholdPercent !== undefined && !isPercent(body.memoryThresholdPercent)) {
+ return "memoryThresholdPercent must be an integer between 1 and 100";
+ }
+ if (body.minReplicas !== undefined && !isNonNegativeInt(body.minReplicas)) {
+ return "minReplicas must be an integer of 0 or greater";
+ }
+ if (body.maxReplicas !== undefined && !isNonNegativeInt(body.maxReplicas)) {
+ return "maxReplicas must be an integer of 0 or greater";
+ }
+ if (body.cooldownSeconds !== undefined && !isCooldown(body.cooldownSeconds)) {
+ return "cooldownSeconds must be an integer between 1 and 86400";
+ }
+ if (body.enabled !== undefined && typeof body.enabled !== "boolean") {
+ return "enabled must be a boolean";
+ }
+ return null;
+};
+
+const validateReplicaRange = async (projectId: string, body: any): Promise => {
+ if (body.minReplicas === undefined && body.maxReplicas === undefined) return null;
+ const existing = await getScalingPolicy(projectId);
+ const min = body.minReplicas ?? existing?.minReplicas ?? 1;
+ const max = body.maxReplicas ?? existing?.maxReplicas ?? 5;
+ return max < min ? "maxReplicas must be greater than or equal to minReplicas" : null;
+};
+
export const scalingRoutes = new Elysia()
.get("/projects/:id/scaling", async ({ params, set }) => {
const policy = await getScalingPolicy(params.id);
@@ -16,11 +55,21 @@ export const scalingRoutes = new Elysia()
set.status = 400;
return fail("body is required");
}
+ const bodyError = validatePolicyBody(body);
+ if (bodyError) {
+ set.status = 400;
+ return fail(bodyError);
+ }
const project = await getProjectById(params.id);
if (!project) {
set.status = 404;
return fail("Project not found");
}
+ const rangeError = await validateReplicaRange(params.id, body);
+ if (rangeError) {
+ set.status = 400;
+ return fail(rangeError);
+ }
if (body.enabled !== false && (!project.cpuLimit || project.cpuLimit <= 0)) {
set.status = 400;
return fail("Cannot enable autoscaling on a project without CPU resource limits configured.");
diff --git a/apps/api/src/api/ssh-keys/index.ts b/apps/api/src/api/ssh-keys/index.ts
index bd0cec4..dd6f03f 100644
--- a/apps/api/src/api/ssh-keys/index.ts
+++ b/apps/api/src/api/ssh-keys/index.ts
@@ -1,7 +1,41 @@
import { Elysia } from "elysia";
-import { createSshKey, deleteSshKey, getSshKeyById, listSshKeys } from "../../db/repo/ssh-keys";
+import {
+ computeSshKeyFingerprint,
+ createSshKey,
+ deleteSshKey,
+ getSshKeyByFingerprint,
+ getSshKeyById,
+ listSshKeys,
+} from "../../db/repo/ssh-keys";
import { fail, ok } from "../response";
+const PRIVATE_KEY_PATTERN =
+ /^-----BEGIN ((?:[A-Z0-9]+ )*?)PRIVATE KEY-----\r?\n([\s\S]*?)-----END \1PRIVATE KEY-----\s*$/;
+const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
+
+const isValidPrivateKey = (value: string): boolean => {
+ const match = PRIVATE_KEY_PATTERN.exec(value.trim());
+ if (!match) return false;
+ const body = match[2]
+ .split(/\r?\n/)
+ .filter((line) => !line.includes(":"))
+ .join("")
+ .replace(/\s/g, "");
+ return body.length >= 16 && BASE64_PATTERN.test(body);
+};
+
+const isUniqueViolation = (err: unknown): boolean => {
+ const e = err as { code?: string; cause?: { code?: string } };
+ return e?.code === "23505" || e?.cause?.code === "23505";
+};
+
+const duplicateKey = async (fingerprint: string, set: any) => {
+ const existing = await getSshKeyByFingerprint(fingerprint);
+ if (!existing) return null;
+ set.status = 409;
+ return fail(`SSH key already exists in the pool as "${existing.name}"`);
+};
+
export const sshKeysRoutes = new Elysia()
.get("/ssh-keys", async () => {
return ok(await listSshKeys());
@@ -11,13 +45,28 @@ export const sshKeysRoutes = new Elysia()
set.status = 400;
return fail("name and privateKey are required");
}
- const created = await createSshKey({
- name: body.name,
- privateKey: body.privateKey,
- tags: body.tags,
- });
- set.status = 201;
- return ok(created);
+ if (!isValidPrivateKey(body.privateKey)) {
+ set.status = 400;
+ return fail("privateKey must be a valid PEM or OpenSSH private key");
+ }
+ const fingerprint = computeSshKeyFingerprint(body.privateKey);
+ const existing = await duplicateKey(fingerprint, set);
+ if (existing) return existing;
+ try {
+ const created = await createSshKey({
+ name: body.name,
+ privateKey: body.privateKey,
+ tags: body.tags,
+ });
+ set.status = 201;
+ return ok(created);
+ } catch (err) {
+ if (isUniqueViolation(err)) {
+ const raced = await duplicateKey(fingerprint, set);
+ if (raced) return raced;
+ }
+ throw err;
+ }
})
.get("/ssh-keys/:id", async ({ params, set }: any) => {
const key = await getSshKeyById(params.id);
diff --git a/apps/api/src/db/repo/projects.ts b/apps/api/src/db/repo/projects.ts
index 34d53c1..5504870 100644
--- a/apps/api/src/db/repo/projects.ts
+++ b/apps/api/src/db/repo/projects.ts
@@ -16,9 +16,18 @@ import {
import { formatTimestamp, getRowsAffected, now } from "./helpers";
export interface ProjectCleanupInfo {
- deploymentContainerNames: string[];
- deploymentImageTags: string[];
- databaseContainerNames: string[];
+ deploymentTargets: {
+ id: string;
+ sourceType: string;
+ containerName: string | null;
+ imageTag: string | null;
+ serverId: string | null;
+ }[];
+ databaseTargets: {
+ containerName: string | null;
+ proxyContainerName: string | null;
+ serverId: string | null;
+ }[];
databaseVolumeNames: string[];
volumeDockerNames: string[];
domains: { domain: string; projectName: string }[];
@@ -163,12 +172,23 @@ export const deleteProjectCascade = async (id: string): Promise d.containerName).map((d) => d.containerName!);
- const deploymentImageTags = depRows.filter((d) => d.imageTag).map((d) => d.imageTag!);
+ const deploymentTargets = depRows.map((d) => ({
+ id: d.id,
+ sourceType: d.sourceType,
+ containerName: d.containerName,
+ imageTag: d.imageTag,
+ serverId: d.serverId,
+ }));
for (const dep of depRows) {
await db.delete(deploymentLogs).where(eq(deploymentLogs.deploymentId, dep.id)).execute();
@@ -185,11 +205,20 @@ export const deleteProjectCascade = async (id: string): Promise d.containerName).map((d) => d.containerName!);
+ const databaseTargets = dbRows.map((d) => ({
+ containerName: d.containerName,
+ proxyContainerName: d.proxyContainerName,
+ serverId: d.serverId,
+ }));
const databaseVolumeNames = dbRows.filter((d) => d.volumeName).map((d) => d.volumeName!);
await db.update(databases).set({ projectId: null, updatedAt: now() }).where(eq(databases.projectId, id)).execute();
@@ -206,9 +235,8 @@ export const deleteProjectCascade = async (id: string): Promise {
+export const computeSshKeyFingerprint = (privateKey: string): string => {
return createHash("sha256").update(privateKey).digest("hex").slice(0, 32);
};
@@ -24,7 +24,7 @@ const mapSshKey = (row: typeof sshKeys.$inferSelect): SshKey => ({
export const createSshKey = async (input: CreateSshKeyInput): Promise => {
const id = randomUUID();
const timestamp = now();
- const fingerprint = computeFingerprint(input.privateKey);
+ const fingerprint = computeSshKeyFingerprint(input.privateKey);
const encrypted = encryptValue(input.privateKey, config.envEncryptionKey);
const db = await getDb();
await db
@@ -57,6 +57,12 @@ export const getSshKeyById = async (id: string): Promise => {
return row ? mapSshKey(row) : null;
};
+export const getSshKeyByFingerprint = async (fingerprint: string): Promise => {
+ const db = await getDb();
+ const [row] = await db.select().from(sshKeys).where(eq(sshKeys.fingerprint, fingerprint)).execute();
+ return row ? mapSshKey(row) : null;
+};
+
export const getSshKeyPrivateKey = async (id: string): Promise => {
const db = await getDb();
const [row] = await db
diff --git a/apps/api/src/executors/ssh.ts b/apps/api/src/executors/ssh.ts
index f5911c3..1546502 100644
--- a/apps/api/src/executors/ssh.ts
+++ b/apps/api/src/executors/ssh.ts
@@ -4,7 +4,7 @@ import { remoteScriptFailure, summarizeDeploymentError } from "../orchestrator/d
import type { Deployment, Project, Server } from "../types";
import { config } from "../utils/config";
import { CANCELLED_FAILURE_REASON } from "../utils/failure-outcome";
-import { removeRemoteCaddyRoute, runRemoteScript, syncRemoteCaddyRoute } from "../utils/ssh";
+import { RemoteScriptAbortedError, removeRemoteCaddyRoute, runRemoteScript, syncRemoteCaddyRoute } from "../utils/ssh";
import { emitLog } from "./logging";
import { buildRemoteDeployScript, parseRemoteBuildResult } from "./ssh-build-script";
import {
@@ -175,10 +175,10 @@ const deployComposeRemote = async (deployment: Deployment, project: Project, ser
const domain = rawBaseDomain === "localhost" ? `${fallbackDomain}:80` : fallbackDomain;
snippet = `${domain} {\n log {\n output stdout\n format json\n }\n reverse_proxy ${primary.container}:${primary.port} {\n header_up Host {upstream_hostport}\n }\n}\n`;
}
- const { DB_SERVICE_NAMES } = await import("../utils/compose-ingress");
+ const { isDbServiceName } = await import("../utils/compose-ingress");
for (const svc of webServices) {
if (svc.name === primaryServiceName) continue;
- if (DB_SERVICE_NAMES.has(svc.name)) continue;
+ if (isDbServiceName(svc.name)) continue;
const customMatch = customMappings.find((c) => c.serviceName === svc.name);
const domains: string[] = [];
if (customMatch?.subdomain?.trim()) {
@@ -266,6 +266,26 @@ const markFailed = async (deploymentId: string, error: unknown) => {
await recordDeploymentFailure({ deploymentId, reason: message, source: "ssh" });
};
+const remoteBuilds = new Map();
+
+export const parseRemoteBuildPgid = (line: string): string | undefined => {
+ const match = line.match(/DEQUEL_PGID:\s*(\d+)/);
+ return match?.[1];
+};
+
+export const killRemoteBuildGroup = async (
+ server: Parameters[0],
+ pgid: string,
+ run: typeof runRemoteScript = runRemoteScript,
+): Promise => {
+ await run(
+ server,
+ `kill -TERM -- -${pgid} 2>/dev/null || true
+sleep 3
+kill -KILL -- -${pgid} 2>/dev/null || true`,
+ ).catch(() => {});
+};
+
export const sshExecutor: DeploymentExecutor = {
mode: "ssh",
@@ -292,7 +312,8 @@ export const sshExecutor: DeploymentExecutor = {
const imageTag = `${slugify(project.name)}-${deployment.id.slice(0, 8)}:latest`;
const envVars = await listEnvironmentVariablesForDeploy(project.id, deployment.environment ?? undefined);
- const script = buildRemoteDeployScript({
+ const pgidEcho = `echo "DEQUEL_PGID:$(ps -o pgid= -p $$ | tr -d ' ')"`;
+ const script = `${pgidEcho}\n${buildRemoteDeployScript({
deploymentId: deployment.id,
workspaceRoot: config.workspaceRoot,
gitUrl: deployment.sourceRef,
@@ -308,11 +329,19 @@ export const sshExecutor: DeploymentExecutor = {
outputDir: project.outputDir,
startCommand: project.startCommand,
railpackGenerator,
- });
+ })}`;
+ const buildEntry = { controller: new AbortController() };
+ remoteBuilds.set(deployment.id, buildEntry);
try {
const result = await runRemoteScript(server, script, {
+ signal: buildEntry.controller.signal,
onLog: async (line) => {
+ const pgid = parseRemoteBuildPgid(line);
+ if (pgid) {
+ buildEntry.pgid = pgid;
+ return;
+ }
await emitLog(deployment.id, "build", line);
},
});
@@ -336,7 +365,11 @@ export const sshExecutor: DeploymentExecutor = {
await emitLog(current.id, "system", `Marked inactive (superseded by ${deployment.id.slice(0, 8)})`);
}
} catch (error) {
- await markFailed(deployment.id, error);
+ if (!(error instanceof RemoteScriptAbortedError)) {
+ await markFailed(deployment.id, error);
+ }
+ } finally {
+ remoteBuilds.delete(deployment.id);
}
},
@@ -412,7 +445,7 @@ export const sshExecutor: DeploymentExecutor = {
await deleteDeploymentAndLogs(deployment.id);
},
- async cancel({ deployment }: ExecutorCancelInput) {
+ async cancel({ deployment, server }: ExecutorCancelInput) {
const { recordDeploymentCancellation } = await getRepo();
if (deployment.status !== "pending" && deployment.status !== "building") return;
await recordDeploymentCancellation({
@@ -420,6 +453,21 @@ export const sshExecutor: DeploymentExecutor = {
reason: CANCELLED_FAILURE_REASON,
source: "ssh",
});
- await emitLog(deployment.id, "system", "Deployment cancelled by user (remote build may continue on the server)");
+ const entry = remoteBuilds.get(deployment.id);
+ let remoteStopped = false;
+ if (entry) {
+ entry.controller.abort();
+ if (entry.pgid) {
+ remoteStopped = true;
+ killRemoteBuildGroup(server, entry.pgid).catch(() => {});
+ }
+ }
+ await emitLog(
+ deployment.id,
+ "system",
+ remoteStopped
+ ? "Deployment cancelled by user — remote build processes stopped"
+ : "Deployment cancelled by user (remote build may continue on the server)",
+ );
},
};
diff --git a/apps/api/src/fixdiag/provider-models.ts b/apps/api/src/fixdiag/provider-models.ts
index 308efa0..b66b674 100644
--- a/apps/api/src/fixdiag/provider-models.ts
+++ b/apps/api/src/fixdiag/provider-models.ts
@@ -6,22 +6,9 @@ export interface FetchModelsOptions {
}
export const DEFAULT_PROVIDER_MODELS: Record = {
- openai: ["gpt-4o", "gpt-4o-mini", "o1", "o3-mini", "gpt-4-turbo", "gpt-6.1-sol", "gpt-6-astra", "gpt-5.6-sol"],
- anthropic: [
- "claude-3-5-sonnet-latest",
- "claude-3-5-haiku-latest",
- "claude-3-opus-latest",
- "claude-sonnet-5",
- "claude-opus-5",
- ],
- gemini: [
- "gemini-2.5-flash",
- "gemini-2.5-pro",
- "gemini-1.5-pro",
- "gemini-1.5-flash",
- "gemini-3.8-flash",
- "gemini-3.7-flash",
- ],
+ openai: ["gpt-4o", "gpt-4o-mini", "o1", "o3-mini", "gpt-4-turbo"],
+ anthropic: ["claude-3-5-sonnet-latest", "claude-3-5-haiku-latest", "claude-3-opus-latest"],
+ gemini: ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-1.5-pro", "gemini-1.5-flash"],
groq: [
"llama-3.3-70b-versatile",
"llama-3.1-8b-instant",
@@ -29,7 +16,7 @@ export const DEFAULT_PROVIDER_MODELS: Record = {
"moonshotai/kimi-k2-instruct",
"openai/gpt-oss-120b",
],
- ollama: ["llama3:latest", "qwen2.5-coder:latest", "mistral:latest", "deepseek-r1:latest", "qwen3.5:0.8b"],
+ ollama: ["llama3:latest", "qwen2.5-coder:latest", "mistral:latest", "deepseek-r1:latest"],
custom: [],
};
diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts
index 24f8a7d..1f42f18 100644
--- a/apps/api/src/index.ts
+++ b/apps/api/src/index.ts
@@ -9,7 +9,7 @@ import { getDb } from "./db/db-provider";
import { migrate } from "./db/migrate";
import { ensureLocalServer } from "./db/repo";
import { markInterruptedDiagRuns } from "./db/repo/diag-runs";
-import { deployments } from "./db/schema";
+import { deployments, projects } from "./db/schema";
import { alertEvaluator } from "./monitoring/evaluator";
import { startFailureNotifier } from "./monitoring/failure-notifier";
import { orchestrator } from "./orchestrator";
@@ -22,8 +22,6 @@ import { cleanupExpiredTokens, initAuth } from "./utils/auth";
import { config } from "./utils/config";
import { startDomainPolling } from "./utils/domain-verifier";
import { loadOrCreateJwtSecret } from "./utils/secrets";
-
-import { projects } from "./db/schema";
import { captureTelemetry } from "./utils/telemetry";
const bootstrap = async () => {
@@ -97,7 +95,12 @@ dequel_uptime_seconds ${uptimeSec}
};
const app = new Elysia()
- .use(cors())
+ .use(
+ cors({
+ origin: () => true,
+ credentials: false,
+ }),
+ )
.onBeforeHandle(() => {
metrics.requestsTotal++;
})
diff --git a/apps/api/src/orchestrator/__tests__/failover-runner.ts b/apps/api/src/orchestrator/__tests__/failover-runner.ts
index ce1d535..0e9728a 100644
--- a/apps/api/src/orchestrator/__tests__/failover-runner.ts
+++ b/apps/api/src/orchestrator/__tests__/failover-runner.ts
@@ -83,7 +83,10 @@ try {
await failoverProject("p1");
results.test1 = { ok: false, error: "should have thrown" };
} catch (e: any) {
- results.test1 = { ok: e.message?.includes("No other healthy server available") };
+ results.test1 = {
+ ok: e.message?.includes("No other healthy server available") && e.message?.includes("p1"),
+ error: e.message,
+ };
}
// Test 2: rejects non-ssh project servers
@@ -218,5 +221,24 @@ try {
results.test8 = { ok: e.message?.includes("before another failover"), error: e.message };
}
+// Test 9: no-deployment error carries project context on a single line
+try {
+ platformSettings = { ingressServerId: "ing" };
+ servers = [
+ { id: "ing", name: "Ingress", mode: "ssh" },
+ { id: "a", name: "Current", mode: "ssh", status: "connected", host: "127.0.0.1", port: probePort },
+ ];
+ projects = [{ id: "p1", serverId: "a" }];
+ deploymentsByProject = { p1: [] };
+ await failoverProject("p1");
+ results.test9 = { ok: false, error: "should have thrown" };
+} catch (e: any) {
+ const message = e.message ?? "";
+ results.test9 = {
+ ok: message.includes("no deployments to fail over") && message.includes("p1") && !message.includes("\n"),
+ error: message,
+ };
+}
+
probeListener.close();
console.log(JSON.stringify(results));
diff --git a/apps/api/src/orchestrator/__tests__/failover.test.ts b/apps/api/src/orchestrator/__tests__/failover.test.ts
index a74b356..890e78e 100644
--- a/apps/api/src/orchestrator/__tests__/failover.test.ts
+++ b/apps/api/src/orchestrator/__tests__/failover.test.ts
@@ -63,4 +63,10 @@ describe("failoverProject", () => {
const r = parse(stdout);
expect(r.test8.ok).toBe(true);
});
+
+ it("includes project context in the no-deployments error", async () => {
+ const { stdout } = await run();
+ const r = parse(stdout);
+ expect(r.test9.ok).toBe(true);
+ });
});
diff --git a/apps/api/src/orchestrator/compose.ts b/apps/api/src/orchestrator/compose.ts
index d25df56..0ec1efd 100644
--- a/apps/api/src/orchestrator/compose.ts
+++ b/apps/api/src/orchestrator/compose.ts
@@ -1,7 +1,10 @@
import { spawn } from "node:child_process";
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { dirname, join, resolve, sep } from "node:path";
+import type { Server } from "../types";
import { dockerBin } from "../utils/docker-bin";
+import { dockerRun } from "../utils/docker-run";
+import { killChildTree } from "../utils/proc-group";
import { CancelledError } from "./railpack";
export interface ComposeTarget {
@@ -203,6 +206,7 @@ const spawnComposeCommand = (
cwd,
env: envVars || {},
stdio: ["ignore", "pipe", "pipe"],
+ detached: true,
});
let stdout = "";
@@ -223,7 +227,8 @@ const spawnComposeCommand = (
};
const onAbort = () => {
- child.kill("SIGTERM");
+ killChildTree(child, "SIGTERM");
+ setTimeout(() => killChildTree(child, "SIGKILL"), 5000);
finish(undefined, new CancelledError());
};
@@ -322,14 +327,14 @@ export const deployWithCompose = async (
await onLog("Docker Compose stack started.");
};
-export const destroyComposeStack = async (projectName: string): Promise => {
+export const destroyComposeStack = async (projectName: string, server?: Server | null): Promise => {
const filter = `label=com.docker.compose.project=${projectName}`;
- const ps = await runDocker(["ps", "-aq", "--filter", filter]);
- const ids = ps.stdout.split(/\s+/).filter(Boolean);
+ const ps = await dockerRun(dockerBin, ["ps", "-aq", "--filter", filter], server).catch(() => "");
+ const ids = ps.split(/\s+/).filter((s) => /^[0-9a-f]{12,}$/.test(s));
for (const id of ids) {
- await runDocker(["rm", "-f", id]);
+ await dockerRun(dockerBin, ["rm", "-f", id], server).catch(() => {});
}
- await runDocker(["network", "rm", `${projectName}_default`]).catch(() => {});
+ await dockerRun(dockerBin, ["network", "rm", `${projectName}_default`], server).catch(() => {});
};
export const getComposeContainerNames = async (projectName: string): Promise
)}
+
+ {credentials?.warning && (
+
+ {credentials.warning}
+
+ )}
diff --git a/apps/web/src/components/databases/DatabaseOverview.tsx b/apps/web/src/components/databases/DatabaseOverview.tsx
index 41c6fed..a66bf3b 100644
--- a/apps/web/src/components/databases/DatabaseOverview.tsx
+++ b/apps/web/src/components/databases/DatabaseOverview.tsx
@@ -7,7 +7,11 @@ import { Input } from "../ui/input";
interface DatabaseOverviewProps {
database: Database;
- creds: { internalConnectionString?: string; externalConnectionString?: string } | null;
+ creds?: {
+ internalConnectionString?: string | null;
+ externalConnectionString?: string | null;
+ warning?: string | null;
+ } | null;
}
export function DatabaseOverview({ database, creds }: DatabaseOverviewProps) {
@@ -50,6 +54,11 @@ export function DatabaseOverview({ database, creds }: DatabaseOverviewProps) {
{copied ? "Copied" : "Copy"}
+ {creds?.warning && (
+
+ {creds.warning}
+
+ )}
diff --git a/apps/web/src/components/settings/LlmKeysSection.tsx b/apps/web/src/components/settings/LlmKeysSection.tsx
index 0ed86e4..54ea302 100644
--- a/apps/web/src/components/settings/LlmKeysSection.tsx
+++ b/apps/web/src/components/settings/LlmKeysSection.tsx
@@ -38,15 +38,20 @@ export function LlmKeysSection() {
const configured = data?.filter((k) => k.configured) ?? [];
const currentStatus = data?.find((k) => k.provider === provider);
const currentMeta = PROVIDERS.find((p) => p.id === provider) ?? PROVIDERS[0];
+ const missingKey = !currentStatus?.configured && provider !== "ollama" && !apiKey.trim();
useEffect(() => {
setBaseURL(currentStatus?.baseUrl ?? currentMeta.defaultBaseUrl ?? "");
setApiKey("");
- setSaveResult(null);
}, [provider, currentStatus?.baseUrl, currentMeta.defaultBaseUrl]);
+ useEffect(() => {
+ setSaveResult(null);
+ }, [provider]);
+
const save = async (e: React.FormEvent) => {
e.preventDefault();
+ if (missingKey) return;
setSaveResult(null);
setSaving(true);
try {
@@ -294,13 +299,18 @@ export function LlmKeysSection() {
)}
{saveResult}
+ ) : missingKey && !saving ? (
+
+
+ Enter an API key to save.
+
) : (
)}
@@ -140,9 +148,7 @@ export function ServersSection() {
-
- {s.mode === "agent" ? `WireGuard P2P ${s.agentVersion || ""}` : `SSH (${s.sshUser || "root"}@${s.host}:${s.port})`}
-
+ {connectionLabel(s)}
{s.mode !== "local" && (
)}
@@ -193,9 +201,7 @@ export function ServersSection() {
variant="outline"
className="font-mono text-[11px] bg-black/40 text-zinc-300 border-border/60"
>
- {s.mode === "agent"
- ? `WireGuard P2P ${s.agentVersion || ""}`
- : `SSH (${s.sshUser || "root"}@${s.host}:${s.port})`}
+ {connectionLabel(s)}
diff --git a/apps/web/src/components/settings/SshKeyPoolSection.tsx b/apps/web/src/components/settings/SshKeyPoolSection.tsx
index d54ebe5..c79a955 100644
--- a/apps/web/src/components/settings/SshKeyPoolSection.tsx
+++ b/apps/web/src/components/settings/SshKeyPoolSection.tsx
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
-import { KeyRound, Plus, Shield, Trash2, Upload } from "lucide-react";
+import { KeyRound, Plus, Shield, ShieldAlert, Trash2, Upload } from "lucide-react";
import { useRef, useState } from "react";
import * as api from "../../api/client";
import { Badge } from "../ui/badge";
@@ -18,9 +18,13 @@ export function SshKeyPoolSection() {
const [name, setName] = useState("");
const [privateKey, setPrivateKey] = useState("");
const [isAdding, setIsAdding] = useState(false);
+ const [addError, setAddError] = useState("");
+ const [isSubmitting, setIsSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState(null);
const fileInputRef = useRef(null);
+ const deletingKey = keys.find((k) => k.id === deletingId);
+
const detectedType = privateKey.includes("ED25519")
? "Ed25519"
: privateKey.includes("RSA")
@@ -48,11 +52,24 @@ export function SshKeyPoolSection() {
const add = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !privateKey.trim()) return;
- await api.createSshKey({ name: name.trim(), privateKey: privateKey.trim() });
- setName("");
- setPrivateKey("");
- setIsAdding(false);
- refetch();
+ setAddError("");
+ setIsSubmitting(true);
+ try {
+ await api.createSshKey({ name: name.trim(), privateKey: privateKey.trim() });
+ setName("");
+ setPrivateKey("");
+ setIsAdding(false);
+ refetch();
+ } catch (err) {
+ setAddError(err instanceof Error ? err.message : "Failed to add SSH key");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ const toggleForm = () => {
+ setAddError("");
+ setIsAdding((open) => !open);
};
const handleDelete = async () => {
@@ -78,7 +95,7 @@ export function SshKeyPoolSection() {