From 41b63c1318c5ef7b537c8c2f815bf389439e5e6c Mon Sep 17 00:00:00 2001 From: Lftobs Date: Thu, 13 Aug 2026 03:10:09 +0100 Subject: [PATCH 01/50] feat(agent): introduce remote deployment agent - Add `apps/agent` for remote deployment orchestration - Implement agent-server P2P protocol with heartbeat, job leasing, and task execution - Add WireGuard tunneling support for agent-to-server connectivity - Extend API to support agent registration, job queuing, and status sync - Update UI to allow selecting deployment targets for projects - Add database migrations for agent foundations, credentials, and job state management --- .github/workflows/release.yml | 12 + .gitignore | 1 + apps/agent/Dockerfile | 20 ++ apps/agent/package.json | 11 + apps/agent/src/capabilities.ts | 34 +++ apps/agent/src/config.ts | 26 ++ apps/agent/src/credentials.ts | 38 +++ apps/agent/src/executor.test.ts | 97 ++++++ apps/agent/src/executor.ts | 284 ++++++++++++++++++ apps/agent/src/index.ts | 92 ++++++ apps/agent/src/protocol.test.ts | 26 ++ apps/agent/src/protocol.ts | 56 ++++ apps/agent/src/stats.ts | 50 +++ apps/agent/src/wireguard.ts | 44 +++ .../src/agents/__tests__/deployments.test.ts | 47 +++ .../api/src/agents/__tests__/protocol.test.ts | 58 ++++ apps/api/src/agents/deployment-contract.ts | 19 ++ apps/api/src/agents/deployments.ts | 42 +++ apps/api/src/agents/job-channel.ts | 101 +++++++ apps/api/src/agents/protocol.ts | 112 +++++++ apps/api/src/agents/stats-cache.ts | 50 +++ apps/api/src/api/agents/index.ts | 73 +++++ apps/api/src/api/deployments/index.ts | 90 +++++- apps/api/src/api/github/index.ts | 1 + apps/api/src/api/index.ts | 4 +- apps/api/src/api/projects/index.ts | 12 + apps/api/src/api/servers/index.ts | 26 +- .../migrations/0009_remote_control_plane.sql | 25 ++ .../db/migrations/0010_agent_foundation.sql | 40 +++ .../db/migrations/0011_agent_wireguard.sql | 1 + apps/api/src/db/migrations/meta/_journal.json | 21 ++ apps/api/src/db/repo/agent-jobs.ts | 111 +++++++ apps/api/src/db/repo/agents.ts | 127 ++++++++ apps/api/src/db/repo/deployments.ts | 2 + apps/api/src/db/repo/index.ts | 15 +- apps/api/src/db/repo/projects.ts | 3 + apps/api/src/db/repo/servers.ts | 67 ++++- apps/api/src/db/schema.ts | 52 ++++ .../src/executors/__tests__/dispatch.test.ts | 74 +++++ apps/api/src/executors/agent.ts | 86 ++++++ apps/api/src/executors/dispatch.ts | 18 ++ apps/api/src/executors/local.ts | 29 ++ apps/api/src/executors/logging.ts | 20 ++ apps/api/src/executors/ssh-build-script.ts | 78 +++++ apps/api/src/executors/ssh.ts | 172 +++++++++++ apps/api/src/executors/types.ts | 33 ++ apps/api/src/index.ts | 2 + apps/api/src/orchestrator/pipeline.ts | 2 + apps/api/src/orchestrator/runtime.ts | 56 ++-- apps/api/src/servers/manager.ts | 19 +- apps/api/src/types.ts | 21 +- apps/api/src/utils/config.ts | 20 ++ apps/api/src/utils/ssh.ts | 208 +++++++++++++ apps/api/src/utils/wireguard.ts | 68 +++++ apps/web/src/api/client.ts | 14 +- .../project/create/CreateProjectDialog.tsx | 24 +- .../create/DeploymentTargetSection.tsx | 85 ++++++ .../components/project/create/StepBasics.tsx | 11 +- .../create/StepBasicsGeneralSettings.tsx | 16 + apps/web/src/routes/CreateProjectPage.tsx | 20 +- apps/web/src/routes/Settings.tsx | 98 ++++-- apps/web/src/types/index.ts | 12 +- package.json | 4 +- 63 files changed, 3004 insertions(+), 76 deletions(-) create mode 100644 apps/agent/Dockerfile create mode 100644 apps/agent/package.json create mode 100644 apps/agent/src/capabilities.ts create mode 100644 apps/agent/src/config.ts create mode 100644 apps/agent/src/credentials.ts create mode 100644 apps/agent/src/executor.test.ts create mode 100644 apps/agent/src/executor.ts create mode 100644 apps/agent/src/index.ts create mode 100644 apps/agent/src/protocol.test.ts create mode 100644 apps/agent/src/protocol.ts create mode 100644 apps/agent/src/stats.ts create mode 100644 apps/agent/src/wireguard.ts create mode 100644 apps/api/src/agents/__tests__/deployments.test.ts create mode 100644 apps/api/src/agents/__tests__/protocol.test.ts create mode 100644 apps/api/src/agents/deployment-contract.ts create mode 100644 apps/api/src/agents/deployments.ts create mode 100644 apps/api/src/agents/job-channel.ts create mode 100644 apps/api/src/agents/protocol.ts create mode 100644 apps/api/src/agents/stats-cache.ts create mode 100644 apps/api/src/api/agents/index.ts create mode 100644 apps/api/src/db/migrations/0009_remote_control_plane.sql create mode 100644 apps/api/src/db/migrations/0010_agent_foundation.sql create mode 100644 apps/api/src/db/migrations/0011_agent_wireguard.sql create mode 100644 apps/api/src/db/repo/agent-jobs.ts create mode 100644 apps/api/src/db/repo/agents.ts create mode 100644 apps/api/src/executors/__tests__/dispatch.test.ts create mode 100644 apps/api/src/executors/agent.ts create mode 100644 apps/api/src/executors/dispatch.ts create mode 100644 apps/api/src/executors/local.ts create mode 100644 apps/api/src/executors/logging.ts create mode 100644 apps/api/src/executors/ssh-build-script.ts create mode 100644 apps/api/src/executors/ssh.ts create mode 100644 apps/api/src/executors/types.ts create mode 100644 apps/api/src/utils/ssh.ts create mode 100644 apps/api/src/utils/wireguard.ts create mode 100644 apps/web/src/components/project/create/DeploymentTargetSection.tsx diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ee0a56..639e6ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,7 @@ jobs: REPO_LC="${REPO,,}" echo "API_IMAGE=ghcr.io/${REPO_LC}/api" >> $GITHUB_ENV echo "WEB_IMAGE=ghcr.io/${REPO_LC}/web" >> $GITHUB_ENV + echo "AGENT_IMAGE=ghcr.io/${REPO_LC}/agent" >> $GITHUB_ENV env: REPO: ${{ github.repository }} @@ -65,6 +66,17 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max + - name: Build and push Agent image + uses: docker/build-push-action@v6 + with: + context: apps/agent + push: true + tags: | + ${{ env.AGENT_IMAGE }}:${{ steps.version.outputs.VERSION }} + ${{ env.AGENT_IMAGE }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Build config tarball run: | VERSION="${{ steps.version.outputs.VERSION }}" diff --git a/.gitignore b/.gitignore index 9a00a7c..ebcab81 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ __pycache__ .tegami/changes-* SETUP_GUIDE.md bun.lock +apps/docs/.blume/ diff --git a/apps/agent/Dockerfile b/apps/agent/Dockerfile new file mode 100644 index 0000000..7be03ac --- /dev/null +++ b/apps/agent/Dockerfile @@ -0,0 +1,20 @@ +FROM oven/bun:1 + +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git caddy gnupg iproute2 wireguard-tools \ + && install -m 0755 -d /etc/apt/keyrings \ + && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ + && chmod a+r /etc/apt/keyrings/docker.asc \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends docker-ce-cli docker-buildx-plugin docker-compose-plugin \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -sSL https://railpack.com/install.sh | sh -s -- --bin-dir /usr/local/bin + +WORKDIR /app +COPY package.json ./ +COPY src ./src + +RUN mkdir -p /var/lib/dequel/workspace + +CMD ["bun", "src/index.ts"] diff --git a/apps/agent/package.json b/apps/agent/package.json new file mode 100644 index 0000000..46d86b2 --- /dev/null +++ b/apps/agent/package.json @@ -0,0 +1,11 @@ +{ + "name": "dequel-agent", + "version": "0.2.1", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --watch src/index.ts", + "start": "bun src/index.ts", + "test": "bun test" + } +} diff --git a/apps/agent/src/capabilities.ts b/apps/agent/src/capabilities.ts new file mode 100644 index 0000000..c961ecd --- /dev/null +++ b/apps/agent/src/capabilities.ts @@ -0,0 +1,34 @@ +import { availableParallelism, freemem, totalmem, arch } from "node:os"; +import { statfs } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import type { AgentCapabilities } from "./protocol"; + +const succeeds = (command: string, args: string[]) => new Promise((resolve) => { + const child = spawn(command, args, { stdio: "ignore" }); + child.on("error", () => resolve(false)); + child.on("close", (code) => resolve(code === 0)); +}); + +export const collectCapabilities = async (): Promise => { + const [docker, buildkit, caddy, compose, disk] = await Promise.all([ + succeeds("docker", ["info"]), + succeeds("docker", ["buildx", "version"]), + succeeds("caddy", ["version"]), + succeeds("docker", ["compose", "version"]), + statfs("/").catch(() => null), + ]); + return { + docker, + buildkit, + caddy, + compose, + architectures: [arch()], + cpuCount: availableParallelism(), + memoryBytes: totalmem(), + diskBytes: disk ? disk.blocks * disk.bsize : 0, + }; +}; + +export const collectResourceUsage = () => ({ + memoryUsedMb: Math.round((totalmem() - freemem()) / 1024 / 1024), +}); diff --git a/apps/agent/src/config.ts b/apps/agent/src/config.ts new file mode 100644 index 0000000..a472a14 --- /dev/null +++ b/apps/agent/src/config.ts @@ -0,0 +1,26 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; + +export interface WireGuardPeerConfig { + peerIp: string; + privateKey: string; + serverPublicKey: string; + serverEndpoint: string; + allowedIps: string; +} + +export const config = { + controlPlaneUrl: process.env.DEQUEL_CONTROL_PLANE?.replace(/\/+$/, "") || "", + tunnelUrl: process.env.DEQUEL_AGENT_TUNNEL_URL?.replace(/\/+$/, "") || "", + registrationToken: process.env.DEQUEL_REGISTRATION_TOKEN, + credentialPath: process.env.DEQUEL_AGENT_CREDENTIAL_PATH || join(homedir(), ".dequel", "agent.json"), + agentVersion: process.env.DEQUEL_AGENT_VERSION || "0.2.1", + publicHost: process.env.DEQUEL_AGENT_PUBLIC_HOST, + workspaceRoot: process.env.DEQUEL_AGENT_WORKSPACE || "/var/lib/dequel/workspace", + dockerNetwork: process.env.DEQUEL_DOCKER_NETWORK || "dequel_net", +}; + +export const requireControlPlaneUrl = () => { + if (!config.controlPlaneUrl) throw new Error("DEQUEL_CONTROL_PLANE is required"); + return config.controlPlaneUrl; +}; \ No newline at end of file diff --git a/apps/agent/src/credentials.ts b/apps/agent/src/credentials.ts new file mode 100644 index 0000000..46796e2 --- /dev/null +++ b/apps/agent/src/credentials.ts @@ -0,0 +1,38 @@ +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { config, requireControlPlaneUrl, type WireGuardPeerConfig } from "./config"; +import type { AgentCapabilities } from "./protocol"; + +type StoredCredential = { serverId: string; agentId: string; credential: string; wireguard: WireGuardPeerConfig | null }; + +export const loadCredential = async (): Promise => { + try { + return JSON.parse(await readFile(config.credentialPath, "utf8")); + } catch { + return null; + } +}; + +export const registerAgent = async (capabilities: AgentCapabilities): Promise => { + if (!config.registrationToken) throw new Error("No stored credential and DEQUEL_REGISTRATION_TOKEN is not set"); + const response = await fetch(`${requireControlPlaneUrl()}/api/agents/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: config.registrationToken, agentVersion: config.agentVersion, capabilities, publicHost: config.publicHost }), + }); + if (!response.ok) { + const body = await response.json().catch(() => ({ error: response.statusText })) as { error?: string }; + throw new Error(body.error || "Agent registration failed"); + } + const registration = await response.json() as StoredCredential; + const stored: StoredCredential = { + serverId: registration.serverId, + agentId: registration.agentId, + credential: registration.credential, + wireguard: registration.wireguard ?? null, + }; + await mkdir(dirname(config.credentialPath), { recursive: true, mode: 0o700 }); + await writeFile(config.credentialPath, JSON.stringify(stored), { mode: 0o600 }); + await chmod(config.credentialPath, 0o600); + return stored; +}; \ No newline at end of file diff --git a/apps/agent/src/executor.test.ts b/apps/agent/src/executor.test.ts new file mode 100644 index 0000000..d83394e --- /dev/null +++ b/apps/agent/src/executor.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "bun:test"; +import { validateDeploymentPayload, validateDestroyPayload, validateRollbackPayload } from "./executor"; + +const payload = { + deploymentId: "deployment-1", + projectId: "project-1", + projectName: "Example API", + gitUrl: "https://github.com/example/api.git", + branch: "main", + appPort: 3000, + environmentVariables: [{ key: "NODE_ENV", value: "production" }], +}; + +const rollbackPayload = { + deploymentId: "deployment-1", + projectId: "project-1", + projectName: "Example API", + imageTag: "dequel-example-api:deployment-1234", + appPort: 3000, + environmentVariables: [{ key: "NODE_ENV", value: "production" }], + volumes: [{ volumeName: "vol-project-1", mountPath: "/app/data" }], +}; + +describe("remote deployment payload", () => { + it("accepts a constrained public Git deployment", () => { + expect(validateDeploymentPayload(payload)).toEqual(payload); + }); + + it("rejects repository credentials embedded in URLs", () => { + expect(() => validateDeploymentPayload({ + ...payload, + gitUrl: "https://token@github.com/example/api.git", + })).toThrow("Only public HTTPS Git URLs are supported"); + }); + + it("rejects unsafe environment names", () => { + expect(() => validateDeploymentPayload({ + ...payload, + environmentVariables: [{ key: "BAD-KEY", value: "value" }], + })).toThrow("Invalid environment variables"); + }); + + it("rejects branches that can be interpreted as command options", () => { + expect(() => validateDeploymentPayload({ ...payload, branch: "--upload-pack=evil" })).toThrow("Invalid Git branch"); + }); +}); + +describe("remote rollback payload", () => { + it("accepts a valid rollback payload", () => { + expect(validateRollbackPayload(rollbackPayload)).toEqual(rollbackPayload); + }); + + it("accepts null project and volumes", () => { + expect(validateRollbackPayload({ + deploymentId: "deployment-1", + projectId: null, + projectName: null, + imageTag: "dequel-app:deployment-1234", + appPort: 3000, + environmentVariables: [], + volumes: undefined, + }).projectId).toBeNull(); + }); + + it("rejects image tags without a tag suffix", () => { + expect(() => validateRollbackPayload({ ...rollbackPayload, imageTag: "dequel-app" })).toThrow("Invalid image tag"); + }); + + it("rejects volumes with unsafe paths", () => { + expect(() => validateRollbackPayload({ + ...rollbackPayload, + volumes: [{ volumeName: "vol-1", mountPath: "../../etc" }], + })).toThrow("Invalid volumes"); + }); +}); + +describe("remote destroy payload", () => { + it("accepts a valid destroy payload", () => { + expect(validateDestroyPayload({ + deploymentId: "deployment-1", + containerName: "example-api-deploym", + imageTag: "dequel-example-api:deployment-1234", + })).toEqual({ + deploymentId: "deployment-1", + containerName: "example-api-deploym", + imageTag: "dequel-example-api:deployment-1234", + }); + }); + + it("rejects container names with unsafe characters", () => { + expect(() => validateDestroyPayload({ + deploymentId: "deployment-1", + containerName: "rm -rf /", + imageTag: null, + })).toThrow("Invalid container name"); + }); +}); diff --git a/apps/agent/src/executor.ts b/apps/agent/src/executor.ts new file mode 100644 index 0000000..4642fe8 --- /dev/null +++ b/apps/agent/src/executor.ts @@ -0,0 +1,284 @@ +import { mkdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { spawn } from "node:child_process"; +import { config } from "./config"; +import type { AgentJobEnvelope } from "./protocol"; + +export type RemoteGitDeployPayload = { + deploymentId: string; + projectId: string; + projectName: string; + gitUrl: string; + branch?: string; + commitSha?: string; + appPort: number; + cpuLimit?: number; + memoryLimitMb?: number; + environmentVariables: { key: string; value: string }[]; +}; + +export type RemoteRollbackPayload = { + deploymentId: string; + projectId: string | null; + projectName: string | null; + imageTag: string; + appPort: number; + cpuLimit?: number | null; + memoryLimitMb?: number | null; + environmentVariables: { key: string; value: string }[]; + volumes?: { volumeName: string; mountPath: string }[]; +}; + +export type RemoteDestroyPayload = { + deploymentId: string; + containerName: string | null; + imageTag: string | null; +}; + +export type RemoteDeployResult = { + imageTag: string; + containerName: string; + hostPort: number; + liveUrl: string | null; + commitSha: string | null; +}; + +type Progress = (stage: string, message: string) => void; +type ContainerSpec = { + deploymentId: string; + projectId?: string | null; + appPort: number; + cpuLimit?: number | null; + memoryLimitMb?: number | null; + environmentVariables: { key: string; value: string }[]; + volumes?: { volumeName: string; mountPath: string }[]; +}; + +const ID_RE = /^[a-zA-Z0-9-]{1,100}$/; +const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +const SHA_RE = /^[0-9a-f]{7,40}$/i; +const IMAGE_TAG_RE = /^[a-zA-Z0-9][a-zA-Z0-9._/-]*:[a-zA-Z0-9._-]+$/; +const VOLUME_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/; +const MOUNT_PATH_RE = /^\/(?:[a-zA-Z0-9._-]+\/?)+$/; + +const run = ( + command: string, + args: string[], + options: { cwd?: string; signal?: AbortSignal; onLine?: (line: string) => void; timeoutMs?: number } = {}, +) => new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd: options.cwd, signal: options.signal, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + let buffer = ""; + const emit = (chunk: unknown) => { + const text = String(chunk); + buffer += text; + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) if (line.trim()) options.onLine?.(line.trim()); + }; + child.stdout.on("data", (chunk) => { stdout += String(chunk); emit(chunk); }); + child.stderr.on("data", (chunk) => { stderr += String(chunk); emit(chunk); }); + const timeout = options.timeoutMs ? setTimeout(() => child.kill("SIGTERM"), options.timeoutMs) : null; + child.on("error", (error) => { + if (timeout) clearTimeout(timeout); + reject(error); + }); + child.on("close", (code) => { + if (timeout) clearTimeout(timeout); + if (buffer.trim()) options.onLine?.(buffer.trim()); + if (code === 0) resolve(stdout.trim()); + else reject(new Error(`${command} failed (${code}): ${(stderr || stdout).trim()}`)); + }); +}); + +const validatePayload = (value: unknown): RemoteGitDeployPayload => { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Deployment payload must be an object"); + const input = value as Record; + if (typeof input.deploymentId !== "string" || !ID_RE.test(input.deploymentId)) throw new Error("Invalid deployment ID"); + if (typeof input.projectId !== "string" || !ID_RE.test(input.projectId)) throw new Error("Invalid project ID"); + if (typeof input.projectName !== "string" || !input.projectName.trim()) throw new Error("Invalid project name"); + if (typeof input.gitUrl !== "string") throw new Error("Invalid Git URL"); + const gitUrl = new URL(input.gitUrl); + if (gitUrl.protocol !== "https:" || gitUrl.username || gitUrl.password) throw new Error("Only public HTTPS Git URLs are supported"); + if (input.branch !== undefined && (typeof input.branch !== "string" || input.branch.startsWith("-"))) throw new Error("Invalid Git branch"); + if (input.commitSha !== undefined && (typeof input.commitSha !== "string" || !SHA_RE.test(input.commitSha))) throw new Error("Invalid commit SHA"); + if (!Number.isInteger(input.appPort) || Number(input.appPort) < 1 || Number(input.appPort) > 65535) throw new Error("Invalid application port"); + if (input.cpuLimit !== undefined && (typeof input.cpuLimit !== "number" || input.cpuLimit <= 0)) throw new Error("Invalid CPU limit"); + if (input.memoryLimitMb !== undefined && (typeof input.memoryLimitMb !== "number" || input.memoryLimitMb <= 0)) throw new Error("Invalid memory limit"); + if (!Array.isArray(input.environmentVariables) || input.environmentVariables.some((item) => + !item || typeof item !== "object" || typeof item.key !== "string" || !ENV_KEY_RE.test(item.key) || typeof item.value !== "string" + )) throw new Error("Invalid environment variables"); + return input as RemoteGitDeployPayload; +}; + +const validateRollbackPayloadImpl = (value: unknown): RemoteRollbackPayload => { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Rollback payload must be an object"); + const input = value as Record; + if (typeof input.deploymentId !== "string" || !ID_RE.test(input.deploymentId)) throw new Error("Invalid deployment ID"); + if (input.projectId !== null && (typeof input.projectId !== "string" || !ID_RE.test(input.projectId))) throw new Error("Invalid project ID"); + if (input.projectName !== null && (typeof input.projectName !== "string" || !input.projectName.trim())) throw new Error("Invalid project name"); + if (typeof input.imageTag !== "string" || !IMAGE_TAG_RE.test(input.imageTag)) throw new Error("Invalid image tag"); + if (!Number.isInteger(input.appPort) || Number(input.appPort) < 1 || Number(input.appPort) > 65535) throw new Error("Invalid application port"); + if (input.cpuLimit !== undefined && input.cpuLimit !== null && (typeof input.cpuLimit !== "number" || input.cpuLimit <= 0)) throw new Error("Invalid CPU limit"); + if (input.memoryLimitMb !== undefined && input.memoryLimitMb !== null && (typeof input.memoryLimitMb !== "number" || input.memoryLimitMb <= 0)) throw new Error("Invalid memory limit"); + if (!Array.isArray(input.environmentVariables) || input.environmentVariables.some((item) => + !item || typeof item !== "object" || typeof item.key !== "string" || !ENV_KEY_RE.test(item.key) || typeof item.value !== "string" + )) throw new Error("Invalid environment variables"); + if (input.volumes !== undefined && (!Array.isArray(input.volumes) || input.volumes.some((v) => + !v || typeof v !== "object" || typeof v.volumeName !== "string" || !VOLUME_NAME_RE.test(v.volumeName) || + typeof v.mountPath !== "string" || !MOUNT_PATH_RE.test(v.mountPath) + ))) throw new Error("Invalid volumes"); + return input as RemoteRollbackPayload; +}; + +const validateDestroyPayloadImpl = (value: unknown): RemoteDestroyPayload => { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Destroy payload must be an object"); + const input = value as Record; + if (typeof input.deploymentId !== "string" || !ID_RE.test(input.deploymentId)) throw new Error("Invalid deployment ID"); + if (input.containerName !== null && (typeof input.containerName !== "string" || !ID_RE.test(input.containerName))) throw new Error("Invalid container name"); + if (input.imageTag !== null && (typeof input.imageTag !== "string" || !IMAGE_TAG_RE.test(input.imageTag))) throw new Error("Invalid image tag"); + return input as RemoteDestroyPayload; +}; + +const slugify = (value: string) => value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "app"; + +const ensureNetwork = async () => { + await run("docker", ["network", "inspect", config.dockerNetwork]).catch(() => run("docker", ["network", "create", config.dockerNetwork])); +}; + +const prepareSource = async (payload: RemoteGitDeployPayload, workspace: string, signal: AbortSignal, progress: Progress) => { + await rm(workspace, { recursive: true, force: true }); + await mkdir(workspace, { recursive: true }); + progress("source", `Cloning ${payload.gitUrl}`); + if (payload.commitSha) { + await run("git", ["init"], { cwd: workspace, signal }); + await run("git", ["remote", "add", "origin", payload.gitUrl], { cwd: workspace, signal }); + await run("git", ["fetch", "--depth", "1", "origin", payload.commitSha], { cwd: workspace, signal }); + await run("git", ["checkout", "--detach", "FETCH_HEAD"], { cwd: workspace, signal }); + } else { + const args = ["clone", "--depth", "1"]; + if (payload.branch) args.push("--branch", payload.branch); + args.push("--", payload.gitUrl, workspace); + await run("git", args, { signal }); + } + return run("git", ["rev-parse", "HEAD"], { cwd: workspace, signal }); +}; + +const buildImage = async (payload: RemoteGitDeployPayload, workspace: string, imageTag: string, signal: AbortSignal, progress: Progress) => { + progress("build", `Building image ${imageTag}`); + const args = ["build", "--name", imageTag, "--progress", "plain", "--cache-key", `project-${payload.projectId}`]; + for (const env of payload.environmentVariables) args.push("--env", `${env.key}=${env.value}`); + args.push(workspace); + await run("railpack", args, { + signal, + timeoutMs: 20 * 60_000, + onLine: (line) => progress("build", line), + }); +}; + +const startContainer = async (spec: ContainerSpec, imageTag: string, containerName: string, signal: AbortSignal, progress: Progress) => { + await ensureNetwork(); + await run("docker", ["rm", "-f", containerName]).catch(() => ""); + const args = [ + "run", "-d", "--name", containerName, + "--network", config.dockerNetwork, + "--label", "com.dequel.managed=true", + "--publish", String(spec.appPort), + "--env", `PORT=${spec.appPort}`, + ]; + if (spec.projectId) { + args.push("--label", `com.dequel.project=${spec.projectId}`); + args.push("--label", `com.dequel.deployment=${spec.deploymentId}`); + } + if (spec.cpuLimit) args.push("--cpus", String(spec.cpuLimit)); + if (spec.memoryLimitMb) args.push("--memory", `${Math.round(spec.memoryLimitMb)}m`); + for (const env of spec.environmentVariables) args.push("--env", `${env.key}=${env.value}`); + if (spec.volumes && spec.volumes.length > 0) { + for (const vol of spec.volumes) { + await run("docker", ["volume", "create", vol.volumeName]).catch(() => ""); + args.push("--volume", `${vol.volumeName}:${vol.mountPath}`); + } + } else if (spec.projectId) { + const defaultVolume = `vol-${spec.projectId.slice(0, 12)}`; + await run("docker", ["volume", "create", defaultVolume]).catch(() => ""); + args.push("--volume", `${defaultVolume}:/app/data`); + } + args.push(imageTag); + progress("deploy", `Starting container ${containerName}`); + await run("docker", args, { signal }); + await Bun.sleep(2_000); + const status = await run("docker", ["inspect", "--format", "{{.State.Status}}", containerName], { signal }); + if (status.trim() !== "running") { + const logs = await run("docker", ["logs", "--tail", "100", containerName]).catch(() => "No container logs available"); + throw new Error(`Container failed to remain running: ${logs}`); + } + const portOutput = await run("docker", ["port", containerName, `${spec.appPort}/tcp`], { signal }); + const match = portOutput.match(/:(\d+)\s*$/m); + if (!match) throw new Error("Docker did not publish an application port"); + const hostPort = Number(match[1]); + if (spec.projectId) { + const previous = (await run("docker", ["ps", "-aq", "--filter", `label=com.dequel.project=${spec.projectId}`], { signal })) + .split("\n").map((id) => id.trim()).filter(Boolean); + const currentId = await run("docker", ["inspect", "--format", "{{.Id}}", containerName], { signal }); + for (const id of previous) if (id !== currentId) await run("docker", ["rm", "-f", id]).catch(() => ""); + } + return hostPort; +}; + +const deployFromGit = async (payload: RemoteGitDeployPayload, signal: AbortSignal, progress: Progress): Promise => { + const workspace = join(config.workspaceRoot, payload.deploymentId); + const slug = slugify(payload.projectName); + const imageTag = `dequel-${slug}:${payload.deploymentId.slice(0, 12)}`; + const containerName = `${slug}-${payload.deploymentId.slice(0, 8)}`; + try { + const commitSha = await prepareSource(payload, workspace, signal, progress); + await buildImage(payload, workspace, imageTag, signal, progress); + const hostPort = await startContainer(payload, imageTag, containerName, signal, progress); + const liveUrl = config.publicHost ? `http://${config.publicHost}:${hostPort}` : null; + progress("deploy", liveUrl ? `Deployment reachable at ${liveUrl}` : `Container published on host port ${hostPort}`); + return { imageTag, containerName, hostPort, liveUrl, commitSha }; + } catch (error) { + await run("docker", ["rm", "-f", containerName]).catch(() => ""); + throw error; + } finally { + await rm(workspace, { recursive: true, force: true }).catch(() => {}); + } +}; + +const rollbackToImage = async (payload: RemoteRollbackPayload, signal: AbortSignal, progress: Progress): Promise => { + const slug = slugify(payload.projectName || payload.deploymentId); + const containerName = `${slug}-${payload.deploymentId.slice(0, 8)}`; + const hostPort = await startContainer(payload, payload.imageTag, containerName, signal, progress); + const liveUrl = config.publicHost ? `http://${config.publicHost}:${hostPort}` : null; + progress("deploy", liveUrl ? `Rollback reachable at ${liveUrl}` : `Rollback container published on host port ${hostPort}`); + return { imageTag: payload.imageTag, containerName, hostPort, liveUrl, commitSha: null }; +}; + +const destroyDeployment = async (payload: RemoteDestroyPayload, progress: Progress) => { + if (payload.containerName) { + await run("docker", ["rm", "-f", payload.containerName]).catch(() => ""); + } + if (payload.imageTag) { + await run("docker", ["rmi", "-f", payload.imageTag]).catch(() => ""); + } + progress("deploy", "Container and image removed"); + return { ok: true as const }; +}; + +export const executeJob = async (job: AgentJobEnvelope, signal: AbortSignal, progress: Progress): Promise => { + switch (job.type) { + case "deploy": + return deployFromGit(validatePayload(job.payload), signal, progress); + case "rollback": + return rollbackToImage(validateRollbackPayload(job.payload), signal, progress); + case "destroy": + return destroyDeployment(validateDestroyPayload(job.payload), progress); + default: + throw new Error(`Agent executor does not support ${job.type} jobs yet`); + } +}; + +export const validateDeploymentPayload = validatePayload; +export const validateRollbackPayload = validateRollbackPayloadImpl; +export const validateDestroyPayload = validateDestroyPayloadImpl; \ No newline at end of file diff --git a/apps/agent/src/index.ts b/apps/agent/src/index.ts new file mode 100644 index 0000000..853c80a --- /dev/null +++ b/apps/agent/src/index.ts @@ -0,0 +1,92 @@ +import { config, requireControlPlaneUrl } from "./config"; +import { collectCapabilities, collectResourceUsage } from "./capabilities"; +import { loadCredential, registerAgent } from "./credentials"; +import { collectContainerStats } from "./stats"; +import { bringUpTunnel } from "./wireguard"; +import { AGENT_PROTOCOL_VERSION, parseP2PResponse, serializeAgentMessage, type AgentJobEnvelope, type AgentMessage } from "./protocol"; +import { executeJob } from "./executor"; + +const capabilities = await collectCapabilities(); +const stored = await loadCredential() ?? await registerAgent(capabilities); + +if (stored.wireguard) { + const tunnelUp = await bringUpTunnel(stored.wireguard); + if (tunnelUp && config.tunnelUrl) console.log(`[Agent] Polling control plane over WireGuard tunnel at ${config.tunnelUrl}`); +} + +const endpoint = (config.tunnelUrl || requireControlPlaneUrl()).replace(/\/+$/, "") + "/api/agents/p2p-sync"; + +let retryMs = 1_000; +const activeJobs = new Map(); +let busy = false; + +const sync = async (message: AgentMessage): Promise => { + const res = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: serializeAgentMessage(message), + }); + if (!res.ok) throw new Error(`p2p-sync failed (${res.status})`); + const parsed = parseP2PResponse(await res.json()); + if (!parsed) throw new Error("Invalid p2p-sync response"); + for (const jobId of parsed.cancelJobIds) { + const controller = activeJobs.get(jobId); + if (controller) controller.abort(); + } + if (parsed.jobs.length > 0 && !busy) { + busy = true; + try { + await runJob(parsed.jobs[0]); + } finally { + busy = false; + } + } +}; + +const runJob = async (job: AgentJobEnvelope) => { + const controller = new AbortController(); + activeJobs.set(job.id, controller); + try { + await sync({ type: "job_ack", protocolVersion: AGENT_PROTOCOL_VERSION, credential: stored.credential, jobId: job.id, leaseId: job.leaseId }); + const result = await executeJob(job, controller.signal, async (stage, message) => { + await sync({ type: "job_progress", protocolVersion: AGENT_PROTOCOL_VERSION, credential: stored.credential, jobId: job.id, leaseId: job.leaseId, stage, message }).catch(() => {}); + }); + await sync({ type: "job_result", protocolVersion: AGENT_PROTOCOL_VERSION, credential: stored.credential, jobId: job.id, leaseId: job.leaseId, success: true, result }); + } catch (error) { + await sync({ + type: "job_result", + protocolVersion: AGENT_PROTOCOL_VERSION, + credential: stored.credential, + jobId: job.id, + leaseId: job.leaseId, + success: false, + error: error instanceof Error ? error.message : String(error), + }).catch(() => {}); + } finally { + activeJobs.delete(job.id); + } +}; + +const poll = async () => { + try { + const containers = await collectContainerStats(); + await sync({ + type: "p2p_heartbeat", + protocolVersion: AGENT_PROTOCOL_VERSION, + credential: stored.credential, + agentVersion: config.agentVersion, + capabilities, + resources: collectResourceUsage(), + containers, + }); + retryMs = 1_000; + } catch (err) { + console.error("[Agent] Poll failed:", err instanceof Error ? err.message : String(err)); + retryMs = Math.min(retryMs * 2, 60_000); + } +}; + +setInterval(() => void poll(), 5_000); +void poll(); + +console.log(`[Agent] Connected as server ${stored.serverId.slice(0, 8)}${stored.wireguard ? " (WireGuard P2P)" : ""}`); \ No newline at end of file diff --git a/apps/agent/src/protocol.test.ts b/apps/agent/src/protocol.test.ts new file mode 100644 index 0000000..2e22c33 --- /dev/null +++ b/apps/agent/src/protocol.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "bun:test"; +import { parseP2PResponse, serializeAgentMessage } from "./protocol"; + +describe("agent P2P protocol", () => { + it("parses a valid p2p response with jobs", () => { + const response = { + ok: true, + serverId: "server-1", + heartbeatIntervalMs: 15_000, + jobs: [{ id: "job-1", deploymentId: "dep-1", type: "deploy", payload: {}, leaseId: "lease-1", leaseExpiresAt: "2026-01-01T00:00:00Z", idempotencyKey: "deployment:dep-1" }], + cancelJobIds: [], + }; + expect(parseP2PResponse(JSON.stringify(response))).toEqual(response); + }); + + it("rejects malformed responses", () => { + expect(parseP2PResponse({ ok: false })).toBeNull(); + expect(parseP2PResponse({ ok: true, serverId: "s", heartbeatIntervalMs: 1000, jobs: {}, cancelJobIds: [] })).toBeNull(); + expect(parseP2PResponse("not json")).toBeNull(); + }); + + it("serializes heartbeat messages", () => { + const raw = serializeAgentMessage({ type: "p2p_heartbeat", protocolVersion: 1, credential: "dqa_test", agentVersion: "0.2.1", capabilities: { docker: true, buildkit: true, caddy: true, compose: true, architectures: [], cpuCount: 2, memoryBytes: 1024, diskBytes: 1024 } }); + expect(JSON.parse(raw).type).toBe("p2p_heartbeat"); + }); +}); \ No newline at end of file diff --git a/apps/agent/src/protocol.ts b/apps/agent/src/protocol.ts new file mode 100644 index 0000000..509e3ba --- /dev/null +++ b/apps/agent/src/protocol.ts @@ -0,0 +1,56 @@ +export const AGENT_PROTOCOL_VERSION = 1 as const; + +export type AgentCapabilities = { + docker: boolean; + buildkit: boolean; + caddy: boolean; + compose: boolean; + architectures: string[]; + cpuCount: number; + memoryBytes: number; + diskBytes: number; +}; + +export type AgentContainerStat = { + containerName: string; + cpuPercent: number; + memoryMb: number; +}; + +export type AgentMessage = + | { type: "p2p_heartbeat"; protocolVersion: 1; credential: string; agentVersion: string; capabilities: AgentCapabilities; resources?: { cpuUsedPercent?: number; memoryUsedMb?: number }; containers?: AgentContainerStat[] } + | { type: "job_ack"; protocolVersion: 1; credential: string; jobId: string; leaseId: string } + | { type: "job_progress"; protocolVersion: 1; credential: string; jobId: string; leaseId: string; stage: string; message: string } + | { type: "job_result"; protocolVersion: 1; credential: string; jobId: string; leaseId: string; success: boolean; result?: unknown; error?: string }; + +export type P2PResponse = { + ok: true; + serverId: string; + heartbeatIntervalMs: number; + jobs: AgentJobEnvelope[]; + cancelJobIds: string[]; +}; + +export type AgentJobEnvelope = { + id: string; + deploymentId: string | null; + type: "deploy" | "rollback" | "scale" | "destroy" | "reload_routes"; + payload: unknown; + leaseId: string; + leaseExpiresAt: string; + idempotencyKey: string; +}; + +export const serializeAgentMessage = (message: AgentMessage): string => JSON.stringify(message); + +export const parseP2PResponse = (raw: unknown): P2PResponse | null => { + try { + const value = typeof raw === "string" ? JSON.parse(raw) : raw; + if (!value || typeof value !== "object" || value.ok !== true) return null; + if (typeof value.serverId !== "string" || typeof value.heartbeatIntervalMs !== "number") return null; + if (!Array.isArray(value.jobs) || !Array.isArray(value.cancelJobIds)) return null; + return value as P2PResponse; + } catch { + return null; + } +}; \ No newline at end of file diff --git a/apps/agent/src/stats.ts b/apps/agent/src/stats.ts new file mode 100644 index 0000000..2cfc5a5 --- /dev/null +++ b/apps/agent/src/stats.ts @@ -0,0 +1,50 @@ +import { spawn } from "node:child_process"; +import type { AgentContainerStat } from "./protocol"; + +const run = (command: string, args: string[]) => new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { stdout += String(chunk); }); + child.stderr.on("data", (chunk) => { stderr += String(chunk); }); + child.on("close", (code) => { + if (code === 0) resolve(stdout.trim()); + else reject(new Error(`${command} failed (${code}): ${stderr.trim()}`)); + }); + child.on("error", () => reject(new Error(`${command} not available`))); +}); + +let cached: { at: number; stats: AgentContainerStat[] } | null = null; + +export const collectContainerStats = async (): Promise => { + if (cached && Date.now() - cached.at < 30_000) return cached.stats; + const stats: AgentContainerStat[] = []; + try { + const ids = (await run("docker", ["ps", "-q", "--filter", "label=com.dequel.managed=true"])) + .split("\n").map((id) => id.trim()).filter(Boolean); + for (const id of ids) { + const name = (await run("docker", ["inspect", "--format", "{{.Name}}", id])).replace(/^\//, ""); + const raw = await run("docker", ["stats", "--no-stream", "--format", "{{json .}}", id]); + const parsed = JSON.parse(raw); + const cpuPercent = parseFloat(String(parsed.CPUPerc ?? "0").replace("%", "")); + const memStr = String(parsed.MemUsage ?? "0B").split("/")[0]?.trim() ?? "0B"; + stats.push({ containerName: name, cpuPercent, memoryMb: parseMemToMb(memStr) }); + } + } catch { + return cached?.stats ?? []; + } + cached = { at: Date.now(), stats }; + return stats; +}; + +const parseMemToMb = (mem: string): number => { + const match = mem.match(/^([\d.]+)(\w+)$/); + if (!match) return 0; + const val = parseFloat(match[1]); + switch (match[2]) { + case "GiB": case "GB": return val * 1024; + case "MiB": case "MB": return val; + case "KiB": case "KB": return val / 1024; + default: return val; + } +}; \ No newline at end of file diff --git a/apps/agent/src/wireguard.ts b/apps/agent/src/wireguard.ts new file mode 100644 index 0000000..c8a594b --- /dev/null +++ b/apps/agent/src/wireguard.ts @@ -0,0 +1,44 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import type { WireGuardPeerConfig } from "./config"; + +const run = (command: string, args: string[]) => new Promise<{ code: number; output: string }>((resolve) => { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + let output = ""; + child.stdout.on("data", (chunk) => { output += String(chunk); }); + child.stderr.on("data", (chunk) => { output += String(chunk); }); + child.on("close", (code) => resolve({ code: code ?? 1, output: output.trim() })); + child.on("error", () => resolve({ code: 127, output: `${command} not found` })); +}); + +export const bringUpTunnel = async (config: WireGuardPeerConfig): Promise => { + const confPath = "/etc/wireguard/dequel0.conf"; + const contents = [ + "[Interface]", + `PrivateKey = ${config.privateKey}`, + `Address = ${config.peerIp}/24`, + "", + "[Peer]", + `PublicKey = ${config.serverPublicKey}`, + `Endpoint = ${config.serverEndpoint}`, + `AllowedIPs = ${config.allowedIps}`, + "PersistentKeepalive = 25", + "", + ].join("\n"); + try { + await mkdir("/etc/wireguard", { recursive: true }); + await writeFile(confPath, contents, { mode: 0o600 }); + const up = await run("wg-quick", ["up", "dequel0"]); + if (up.code !== 0) { + const status = await run("wg-quick", ["down", "dequel0"]); + if (status.code === 0) await run("wg-quick", ["up", "dequel0"]); + else console.warn(`[WireGuard] Could not bring up tunnel: ${up.output}`); + return false; + } + console.log(`[WireGuard] Tunnel up at ${config.peerIp}`); + return true; + } catch (error) { + console.warn("[WireGuard] Tunnel setup skipped:", error instanceof Error ? error.message : String(error)); + return false; + } +}; \ No newline at end of file diff --git a/apps/api/src/agents/__tests__/deployments.test.ts b/apps/api/src/agents/__tests__/deployments.test.ts new file mode 100644 index 0000000..ed69ce0 --- /dev/null +++ b/apps/api/src/agents/__tests__/deployments.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test"; +import { validateRemoteDeployment } from "../deployment-contract"; +import type { Deployment, Project } from "../../types"; + +const project = { + id: "project-1", + name: "Example", + serverId: "server-1", + buildType: "railpack", + projectType: "web", + sourceDir: null, + buildCommand: null, + installCommand: null, + outputDir: null, + startCommand: null, +} as Project; + +const deployment = { + id: "deployment-1", + projectId: project.id, + serverId: project.serverId, + sourceType: "git", + sourceRef: "https://github.com/example/app.git", +} as Deployment; + +describe("remote deployment validation", () => { + it("accepts public Git Railpack web services", () => { + expect(validateRemoteDeployment(deployment, project)).toBeNull(); + }); + + it("rejects private credential URLs", () => { + expect(validateRemoteDeployment({ ...deployment, sourceRef: "https://token@github.com/example/app.git" }, project)) + .toBe("Remote Git deployments require a public HTTPS repository URL"); + }); + + it("rejects unsupported project overrides", () => { + expect(validateRemoteDeployment(deployment, { ...project, sourceDir: "apps/api" })) + .toBe("Remote agents do not support source-directory or command overrides yet"); + }); + + it("rejects Compose and static projects", () => { + expect(validateRemoteDeployment(deployment, { ...project, buildType: "compose" })) + .toBe("Remote agents currently support Railpack web services only"); + expect(validateRemoteDeployment(deployment, { ...project, projectType: "static" })) + .toBe("Remote agents currently support Railpack web services only"); + }); +}); diff --git a/apps/api/src/agents/__tests__/protocol.test.ts b/apps/api/src/agents/__tests__/protocol.test.ts new file mode 100644 index 0000000..9adf523 --- /dev/null +++ b/apps/api/src/agents/__tests__/protocol.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test"; +import { parseP2PAgentRequest } from "../protocol"; + +const capabilities = { + docker: true, + buildkit: true, + caddy: true, + compose: true, + architectures: ["x64"], + cpuCount: 4, + memoryBytes: 1024, + diskBytes: 2048, +}; + +describe("agent P2P protocol", () => { + it("accepts a valid heartbeat request", () => { + expect(parseP2PAgentRequest(JSON.stringify({ + type: "p2p_heartbeat", + protocolVersion: 1, + credential: "dqa_test", + agentVersion: "0.2.1", + capabilities, + resources: { cpuUsedPercent: 12, memoryUsedMb: 512 }, + containers: [{ containerName: "app-abc", cpuPercent: 4, memoryMb: 64 }], + }))).toEqual({ + type: "p2p_heartbeat", + protocolVersion: 1, + credential: "dqa_test", + agentVersion: "0.2.1", + capabilities, + resources: { cpuUsedPercent: 12, memoryUsedMb: 512 }, + containers: [{ containerName: "app-abc", cpuPercent: 4, memoryMb: 64 }], + }); + }); + + it("rejects unsupported protocol versions", () => { + expect(parseP2PAgentRequest({ type: "p2p_heartbeat", protocolVersion: 2, credential: "dqa_test", agentVersion: "0.2.1", capabilities })).toBeNull(); + }); + + it("rejects requests without a credential", () => { + expect(parseP2PAgentRequest({ type: "job_ack", protocolVersion: 1, jobId: "job-1", leaseId: "lease-1" })).toBeNull(); + }); + + it("requires lease IDs on job lifecycle messages", () => { + expect(parseP2PAgentRequest({ type: "job_ack", protocolVersion: 1, credential: "dqa_test", jobId: "job-1" })).toBeNull(); + expect(parseP2PAgentRequest({ type: "job_ack", protocolVersion: 1, credential: "dqa_test", jobId: "job-1", leaseId: "lease-1" })).not.toBeNull(); + }); + + it("rejects malformed capabilities", () => { + expect(parseP2PAgentRequest({ + type: "p2p_heartbeat", + protocolVersion: 1, + credential: "dqa_test", + agentVersion: "0.2.1", + capabilities: { ...capabilities, cpuCount: "four" }, + })).toBeNull(); + }); +}); \ No newline at end of file diff --git a/apps/api/src/agents/deployment-contract.ts b/apps/api/src/agents/deployment-contract.ts new file mode 100644 index 0000000..86376ad --- /dev/null +++ b/apps/api/src/agents/deployment-contract.ts @@ -0,0 +1,19 @@ +import type { Deployment, Project } from "../types"; + +const GIT_URL = /^https:\/\/[^\s]+$/; + +export const validateRemoteDeployment = (deployment: Deployment, project: Project): string | null => { + if (deployment.sourceType !== "git") return "Remote agents currently support Git deployments only"; + if (!GIT_URL.test(deployment.sourceRef)) return "Remote Git deployments require a public HTTPS repository URL"; + try { + const url = new URL(deployment.sourceRef); + if (url.username || url.password) return "Remote Git deployments require a public HTTPS repository URL"; + } catch { + return "Remote Git deployments require a public HTTPS repository URL"; + } + if (project.buildType !== "railpack" || project.projectType !== "web") return "Remote agents currently support Railpack web services only"; + if (project.sourceDir || project.buildCommand || project.installCommand || project.outputDir || project.startCommand) { + return "Remote agents do not support source-directory or command overrides yet"; + } + return null; +}; diff --git a/apps/api/src/agents/deployments.ts b/apps/api/src/agents/deployments.ts new file mode 100644 index 0000000..739108a --- /dev/null +++ b/apps/api/src/agents/deployments.ts @@ -0,0 +1,42 @@ +import type { Deployment, Project } from "../types"; +import { + appendLog, + createAgentJob, + listEnvironmentVariablesForDeploy, + updateDeploymentStatus, +} from "../db/repo"; +import type { RemoteGitDeployPayload } from "./protocol"; +import { validateRemoteDeployment } from "./deployment-contract"; + +export { validateRemoteDeployment } from "./deployment-contract"; + +export const queueRemoteDeployment = async (deployment: Deployment, project: Project) => { + const validationError = validateRemoteDeployment(deployment, project); + if (validationError) throw new Error(validationError); + if (!deployment.serverId || deployment.serverId === "local") throw new Error("Remote deployment requires an agent server"); + const environmentVariables = await listEnvironmentVariablesForDeploy( + project.id, + deployment.environment ?? "production", + ); + const payload: RemoteGitDeployPayload = { + deploymentId: deployment.id, + projectId: project.id, + projectName: project.name, + gitUrl: deployment.sourceRef, + branch: deployment.branch ?? undefined, + commitSha: deployment.commitSha ?? undefined, + appPort: project.port || 3000, + cpuLimit: project.cpuLimit ?? undefined, + memoryLimitMb: project.memoryLimitMb ?? undefined, + environmentVariables, + }; + await createAgentJob({ + deploymentId: deployment.id, + serverId: deployment.serverId, + type: "deploy", + payload, + idempotencyKey: `deployment:${deployment.id}`, + }); + await updateDeploymentStatus(deployment.id, "pending", { failureReason: null }); + await appendLog(deployment.id, "system", `Deployment queued for server ${deployment.serverId}`); +}; diff --git a/apps/api/src/agents/job-channel.ts b/apps/api/src/agents/job-channel.ts new file mode 100644 index 0000000..ff86f1e --- /dev/null +++ b/apps/api/src/agents/job-channel.ts @@ -0,0 +1,101 @@ +import { + acknowledgeAgentJob, + appendLog, + deleteDeploymentAndLogs, + finishAgentJob, + getAgentJobDeploymentId, + getAgentJobInfo, + leaseNextAgentJob, + listCancelledJobIds, + listDeployments, + updateAgentHeartbeat, + updateDeploymentCommitSha, + updateDeploymentStatus, +} from "../db/repo"; +import { agentStatsCache } from "./stats-cache"; +import { isRemoteDeployResult, type AgentCapabilities, type P2PAgentRequest } from "./protocol"; + +export const HEARTBEAT_INTERVAL_MS = 15_000; + +export const handleP2PHeartbeat = async ( + serverId: string, + patch: { + agentVersion: string; + capabilities: AgentCapabilities; + cpuUsedPercent?: number; + memoryUsedMb?: number; + containers?: { containerName: string; cpuPercent: number; memoryMb: number }[]; + }, +) => { + await updateAgentHeartbeat(serverId, { + agentVersion: patch.agentVersion, + capabilities: patch.capabilities, + cpuUsedPercent: patch.cpuUsedPercent, + memoryUsedMb: patch.memoryUsedMb, + }); + if (patch.containers && patch.containers.length > 0) { + await agentStatsCache.set(serverId, patch.containers); + } +}; + +export const processAgentJobUpdate = async (serverId: string, update: Exclude): Promise => { + if (update.type === "job_ack") { + if (!(await acknowledgeAgentJob(update.jobId, serverId, update.leaseId))) return; + const deploymentId = await getAgentJobDeploymentId(update.jobId, serverId, update.leaseId); + if (deploymentId) await updateDeploymentStatus(deploymentId, "building", { failureReason: null }); + return; + } + if (update.type === "job_progress") { + const deploymentId = await getAgentJobDeploymentId(update.jobId, serverId, update.leaseId); + if (deploymentId) { + const stage = update.stage === "deploy" ? "deploy" : update.stage === "build" ? "build" : "system"; + if (stage === "deploy") await updateDeploymentStatus(deploymentId, "deploying"); + await appendLog(deploymentId, stage, update.message).catch(() => {}); + } + return; + } + const jobInfo = await getAgentJobInfo(update.jobId, serverId, update.leaseId); + const deploymentId = jobInfo?.deploymentId ?? null; + + if (jobInfo?.type === "destroy") { + if (update.success && deploymentId) { + await deleteDeploymentAndLogs(deploymentId); + } + await finishAgentJob(update.jobId, serverId, update.leaseId, update.success, update.error); + return; + } + + if (update.success && !isRemoteDeployResult(update.result)) return; + if (deploymentId) { + if (update.success && isRemoteDeployResult(update.result)) { + if (update.result.commitSha) await updateDeploymentCommitSha(deploymentId, update.result.commitSha); + await updateDeploymentStatus(deploymentId, "running", { + imageTag: update.result.imageTag, + containerName: update.result.containerName, + liveUrl: update.result.liveUrl, + failureReason: null, + }); + await appendLog(deploymentId, "system", `Remote deployment is running on server ${serverId}`); + if (jobInfo?.type === "rollback") { + const payload = (jobInfo.payload ?? {}) as { projectId?: string | null }; + if (payload.projectId) { + const all = await listDeployments(payload.projectId); + const current = all.find((d) => d.status === "running" && d.id !== deploymentId); + if (current) { + await updateDeploymentStatus(current.id, "inactive", { failureReason: `Superseded by rollback to ${deploymentId.slice(0, 8)}` }); + await appendLog(current.id, "system", `Marked inactive (rolled back to ${deploymentId.slice(0, 8)})`); + } + } + } + } else { + await updateDeploymentStatus(deploymentId, "failed", { failureReason: update.error || "Remote agent deployment failed" }); + await appendLog(deploymentId, "system", `Remote deployment failed: ${update.error || "Unknown agent error"}`); + } + } + await finishAgentJob(update.jobId, serverId, update.leaseId, update.success, update.error); +}; + +export const nextJobBatch = async (serverId: string) => { + const job = await leaseNextAgentJob(serverId); + return { jobs: job ? [job] : [], cancelJobIds: await listCancelledJobIds(serverId) }; +}; \ No newline at end of file diff --git a/apps/api/src/agents/protocol.ts b/apps/api/src/agents/protocol.ts new file mode 100644 index 0000000..1f83d3f --- /dev/null +++ b/apps/api/src/agents/protocol.ts @@ -0,0 +1,112 @@ +export const AGENT_PROTOCOL_VERSION = 1 as const; + +export type AgentCapabilities = { + docker: boolean; + buildkit: boolean; + caddy: boolean; + compose: boolean; + architectures: string[]; + cpuCount: number; + memoryBytes: number; + diskBytes: number; +}; + +export type AgentContainerStat = { + containerName: string; + cpuPercent: number; + memoryMb: number; +}; + +export type P2PAgentRequest = + | { type: "p2p_heartbeat"; protocolVersion: 1; credential: string; agentVersion: string; capabilities: AgentCapabilities; resources?: { cpuUsedPercent?: number; memoryUsedMb?: number }; containers?: AgentContainerStat[] } + | { type: "job_ack"; protocolVersion: 1; credential: string; jobId: string; leaseId: string } + | { type: "job_progress"; protocolVersion: 1; credential: string; jobId: string; leaseId: string; stage: string; message: string } + | { type: "job_result"; protocolVersion: 1; credential: string; jobId: string; leaseId: string; success: boolean; result?: unknown; error?: string }; + +export type P2PResponse = { + ok: true; + serverId: string; + heartbeatIntervalMs: number; + jobs: AgentJobEnvelope[]; + cancelJobIds: string[]; +}; + +export type AgentJobEnvelope = { + id: string; + deploymentId: string | null; + type: "deploy" | "rollback" | "scale" | "destroy" | "reload_routes"; + payload: unknown; + leaseId: string; + leaseExpiresAt: string; + idempotencyKey: string; +}; + +export type RemoteGitDeployPayload = { + deploymentId: string; + projectId: string; + projectName: string; + gitUrl: string; + branch?: string; + commitSha?: string; + appPort: number; + cpuLimit?: number; + memoryLimitMb?: number; + environmentVariables: { key: string; value: string }[]; +}; + +export type RemoteDeployResult = { + imageTag: string; + containerName: string; + hostPort: number; + liveUrl: string | null; + commitSha: string | null; +}; + +export const isRemoteDeployResult = (value: unknown): value is RemoteDeployResult => { + if (!isRecord(value)) return false; + return typeof value.imageTag === "string" + && typeof value.containerName === "string" + && typeof value.hostPort === "number" + && (value.liveUrl === null || typeof value.liveUrl === "string") + && (value.commitSha === null || typeof value.commitSha === "string"); +}; + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === "object" && !Array.isArray(value); + +const isCapabilities = (value: unknown): value is AgentCapabilities => { + if (!isRecord(value)) return false; + return ["docker", "buildkit", "caddy", "compose"].every((key) => typeof value[key] === "boolean") + && Array.isArray(value.architectures) + && value.architectures.every((item) => typeof item === "string") + && ["cpuCount", "memoryBytes", "diskBytes"].every((key) => typeof value[key] === "number"); +}; + +export const parseP2PAgentRequest = (raw: unknown): P2PAgentRequest | null => { + let value: unknown = raw; + if (typeof raw === "string") { + try { value = JSON.parse(raw); } catch { return null; } + } + if (!isRecord(value) || value.protocolVersion !== AGENT_PROTOCOL_VERSION || typeof value.type !== "string") return null; + if (typeof value.credential !== "string") return null; + if (value.type === "p2p_heartbeat") { + if (typeof value.agentVersion !== "string" || !isCapabilities(value.capabilities)) return null; + if (value.resources !== undefined && !isRecord(value.resources)) return null; + if (value.containers !== undefined && !Array.isArray(value.containers)) return null; + return value as P2PAgentRequest; + } + if (value.type === "job_ack") return typeof value.jobId === "string" && typeof value.leaseId === "string" ? value as P2PAgentRequest : null; + if (value.type === "job_progress") { + return typeof value.jobId === "string" && typeof value.leaseId === "string" && typeof value.stage === "string" && typeof value.message === "string" + ? value as P2PAgentRequest + : null; + } + if (value.type === "job_result") { + return typeof value.jobId === "string" && typeof value.leaseId === "string" && typeof value.success === "boolean" && (value.error === undefined || typeof value.error === "string") + ? value as P2PAgentRequest + : null; + } + return null; +}; + +export const serializeP2PResponse = (response: P2PResponse): string => JSON.stringify(response); diff --git a/apps/api/src/agents/stats-cache.ts b/apps/api/src/agents/stats-cache.ts new file mode 100644 index 0000000..8782969 --- /dev/null +++ b/apps/api/src/agents/stats-cache.ts @@ -0,0 +1,50 @@ +import Redis from "ioredis"; +import { config } from "../utils/config"; +import type { AgentContainerStat } from "./protocol"; + +const STATS_TTL_SECONDS = 120; + +class AgentStatsCache { + private redis: Redis; + + constructor() { + this.redis = new Redis(config.redisUrl, { maxRetriesPerRequest: null, enableOfflineQueue: false }); + } + + private key(serverId: string) { + return `dequel:agent-stats:${serverId}`; + } + + async set(serverId: string, containers: AgentContainerStat[]) { + if (!containers || containers.length === 0) return; + const payload = JSON.stringify({ updatedAt: new Date().toISOString(), containers }); + await this.redis.set(this.key(serverId), payload, "EX", STATS_TTL_SECONDS).catch(() => {}); + } + + async get(serverId: string): Promise> { + const raw = await this.redis.get(this.key(serverId)).catch(() => null); + const result = new Map(); + if (!raw) return result; + try { + const parsed = JSON.parse(raw) as { containers?: AgentContainerStat[] }; + for (const container of parsed.containers ?? []) { + result.set(container.containerName, { cpuPercent: container.cpuPercent, memoryMb: container.memoryMb }); + } + } catch { + return result; + } + return result; + } + + async getAll(): Promise>> { + const keys = await this.redis.keys("dequel:agent-stats:*").catch(() => [] as string[]); + const result = new Map>(); + for (const key of keys) { + const serverId = key.slice("dequel:agent-stats:".length); + result.set(serverId, await this.get(serverId)); + } + return result; + } +} + +export const agentStatsCache = new AgentStatsCache(); \ No newline at end of file diff --git a/apps/api/src/api/agents/index.ts b/apps/api/src/api/agents/index.ts new file mode 100644 index 0000000..818b242 --- /dev/null +++ b/apps/api/src/api/agents/index.ts @@ -0,0 +1,73 @@ +import { Elysia } from "elysia"; +import { createAgentRegistrationToken, exchangeAgentRegistrationToken, validateAgentCredential } from "../../db/repo"; +import { HEARTBEAT_INTERVAL_MS, handleP2PHeartbeat, nextJobBatch, processAgentJobUpdate } from "../../agents/job-channel"; +import { parseP2PAgentRequest } from "../../agents/protocol"; +import type { AgentCapabilities } from "../../agents/protocol"; + +const isLabels = (value: unknown): value is Record => + value === undefined || (typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((item) => typeof item === "string")); + +const isCapabilities = (value: unknown): value is AgentCapabilities => { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const item = value as Record; + return ["docker", "buildkit", "caddy", "compose"].every((key) => typeof item[key] === "boolean") + && Array.isArray(item.architectures) + && item.architectures.every((arch) => typeof arch === "string") + && ["cpuCount", "memoryBytes", "diskBytes"].every((key) => typeof item[key] === "number"); +}; + +export const agentRoutes = new Elysia() + .post("/agents/registration-tokens", async ({ body, set }: any) => { + if (!body?.name || typeof body.name !== "string" || !body.name.trim() || body.name.trim().length > 100 || !isLabels(body.labels)) { + set.status = 400; + return { error: "name and string labels are required" }; + } + return createAgentRegistrationToken(body.name.trim(), body.labels || {}); + }) + .post("/agents/register", async ({ body, set }: any) => { + if (!body?.token || typeof body.token !== "string" || typeof body.agentVersion !== "string" || !isCapabilities(body.capabilities)) { + set.status = 400; + return { error: "token, agentVersion, and valid capabilities are required" }; + } + if (body.publicHost !== undefined && (typeof body.publicHost !== "string" || !/^[a-zA-Z0-9.-]+$/.test(body.publicHost))) { + set.status = 400; + return { error: "publicHost must be a hostname or IP address" }; + } + const result = await exchangeAgentRegistrationToken(body.token, body.agentVersion, body.capabilities, body.publicHost); + if (!result) { + set.status = 401; + return { error: "Registration token is invalid, expired, or already used" }; + } + return result; + }) + .post("/agents/p2p-sync", async ({ body, set }: any) => { + const request = parseP2PAgentRequest(body); + if (!request) { + set.status = 400; + return { error: "Invalid or unsupported P2P request" }; + } + const serverId = await validateAgentCredential(request.credential); + if (!serverId) { + set.status = 401; + return { error: "Agent credential is invalid or revoked" }; + } + if (request.type === "p2p_heartbeat") { + await handleP2PHeartbeat(serverId, { + agentVersion: request.agentVersion, + capabilities: request.capabilities, + cpuUsedPercent: request.resources?.cpuUsedPercent, + memoryUsedMb: request.resources?.memoryUsedMb, + containers: request.containers, + }); + } else { + await processAgentJobUpdate(serverId, request); + } + const batch = await nextJobBatch(serverId); + return { + ok: true, + serverId, + heartbeatIntervalMs: HEARTBEAT_INTERVAL_MS, + jobs: batch.jobs, + cancelJobIds: batch.cancelJobIds, + }; + }); \ No newline at end of file diff --git a/apps/api/src/api/deployments/index.ts b/apps/api/src/api/deployments/index.ts index a84b8f9..bbe7a4a 100644 --- a/apps/api/src/api/deployments/index.ts +++ b/apps/api/src/api/deployments/index.ts @@ -6,12 +6,32 @@ import { countDeployments, getDeploymentById, getProjectById, + getServerById, getLogs, listDeployments, } from "../../db/repo"; import { orchestrator } from "../../orchestrator"; import { logBus } from "../../orchestrator/log-bus"; import { config } from "../../utils/config"; +import { executorFor } from "../../executors/dispatch"; +import { queueRemoteDeployment, validateRemoteDeployment } from "../../agents/deployments"; + +const dispatchDeployment = async (deployment: Awaited>, project: Awaited>, server: Awaited>) => { + if (server.mode === "local") { + orchestrator.enqueue(deployment.id); + return; + } + if (!project) throw new Error("Remote deployment requires a project"); + if (deployment.sourceType !== "git") throw new Error("Remote servers currently support Git deployments only"); + const executor = executorFor(server.mode); + if (server.mode === "ssh") { + void executor.deploy({ deployment, project, server }).catch((error) => { + console.error(`[SSH Executor] Deployment ${deployment.id} failed:`, error); + }); + return; + } + await executor.deploy({ deployment, project, server }); +}; export const deploymentsRoutes = new Elysia() .get( @@ -57,6 +77,13 @@ export const deploymentsRoutes = new Elysia() const projectId = String(form.get("projectId") ?? "").trim() || undefined; + const project = projectId ? await getProjectById(projectId) : null; + const serverId = project?.serverId ?? "local"; + const server = await getServerById(serverId); + if (!server) { + set.status = 400; + return { error: "Selected deployment server does not exist" }; + } const branch = String(form.get("branch") ?? "").trim() || undefined; @@ -86,8 +113,29 @@ export const deploymentsRoutes = new Elysia() error: "gitUrl is required for git source", }; } + if (server.mode === "agent") { + if (!project) { + set.status = 400; + return { error: "Remote deployment requires a project" }; + } + const preview = { + id: "validation", + projectId: project.id, + serverId, + sourceType: "git" as const, + sourceRef: gitUrl, + branch: resolvedBranch ?? null, + commitSha: commitSha ?? null, + } as any; + const error = validateRemoteDeployment(preview, project); + if (error) { + set.status = 400; + return { error }; + } + } const deployment = await createDeployment({ projectId, + serverId, sourceType: "git", sourceRef: gitUrl, branch: resolvedBranch, @@ -95,10 +143,14 @@ export const deploymentsRoutes = new Elysia() commitSha, clearCache, }); - orchestrator.enqueue(deployment.id); + await dispatchDeployment(deployment, project, server); return deployment; } const file = form.get("archive"); + if (server.mode === "agent" || server.mode === "ssh") { + set.status = 400; + return { error: "Remote servers currently support Git deployments only" }; + } if (!(file instanceof File)) { set.status = 400; return { @@ -122,13 +174,14 @@ export const deploymentsRoutes = new Elysia() await writeFile(uploadPath, bytes); const deployment = await createDeployment({ projectId, + serverId, sourceType: "upload", sourceRef: uploadPath, branch: resolvedBranch, environment, clearCache, }); - orchestrator.enqueue(deployment.id); + await dispatchDeployment(deployment, project, server); return deployment; }, ) @@ -166,7 +219,13 @@ export const deploymentsRoutes = new Elysia() }; } try { - await orchestrator.rollbackTo(id); + const server = await getServerById(target.serverId ?? "local"); + if (!server) { + set.status = 400; + return { error: "Deployment server does not exist" }; + } + const executor = executorFor(server.mode); + await executor.rollback({ deployment: target, project, server }); const updated = await getDeploymentById(id); return updated; } catch (err: any) { @@ -196,14 +255,20 @@ export const deploymentsRoutes = new Elysia() }; } const project = original.projectId ? await getProjectById(original.projectId) : null; + const server = await getServerById(original.serverId ?? "local"); + if (!server) { + set.status = 400; + return { error: "Selected deployment server does not exist" }; + } const deployment = await createDeployment({ projectId: original.projectId || undefined, + serverId: original.serverId, sourceType: original.sourceType, sourceRef: original.sourceRef, branch: original.branch || project?.repoBranch || undefined, environment: original.environment || undefined, }); - orchestrator.enqueue(deployment.id); + await dispatchDeployment(deployment, project, server); return deployment; }, ) @@ -221,7 +286,13 @@ export const deploymentsRoutes = new Elysia() error: "Only pending or building deployments can be cancelled", }; } - await orchestrator.cancelDeployment(id); + const server = await getServerById(deployment.serverId ?? "local"); + if (!server) { + set.status = 400; + return { error: "Deployment server does not exist" }; + } + const executor = executorFor(server.mode); + await executor.cancel({ deployment, server }); return { ok: true }; }, ) @@ -239,7 +310,14 @@ export const deploymentsRoutes = new Elysia() error: "Cannot delete a running deployment — stop it first", }; } - await orchestrator.deleteDeployment(id); + const project = deployment.projectId ? await getProjectById(deployment.projectId) : null; + const server = await getServerById(deployment.serverId ?? "local"); + if (!server) { + set.status = 400; + return { error: "Deployment server does not exist" }; + } + const executor = executorFor(server.mode); + await executor.destroy({ deployment, project, server }); return { ok: true }; }, ) diff --git a/apps/api/src/api/github/index.ts b/apps/api/src/api/github/index.ts index b0cfef5..8d6f629 100644 --- a/apps/api/src/api/github/index.ts +++ b/apps/api/src/api/github/index.ts @@ -415,6 +415,7 @@ export const githubRoutes = new Elysia({ prefix: "/github" }) for (const project of targets) { const dep = await createDeployment({ projectId: project.id, + serverId: project.serverId, sourceType: "git", sourceRef: repoUrl, branch, diff --git a/apps/api/src/api/index.ts b/apps/api/src/api/index.ts index d8c56b1..b891c0f 100644 --- a/apps/api/src/api/index.ts +++ b/apps/api/src/api/index.ts @@ -1,5 +1,6 @@ import { Elysia } from "elysia"; import { alertsRoutes } from "./alerts"; +import { agentRoutes } from "./agents"; import { apiKeysRoutes } from "./api-keys"; import { authRoutes } from "./auth"; import { databasesRoutes } from "./databases"; @@ -16,7 +17,7 @@ import { serversRoutes } from "./servers"; import { volumesRoutes } from "./volumes"; import { settingsRoutes } from "./settings"; -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"]); +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 }) => { @@ -53,6 +54,7 @@ export const apiRoutes = new Elysia({ }) .use(authRoutes) .use(authMiddleware) + .use(agentRoutes) .use(healthRoutes) .use(projectsRoutes) .use(deploymentsRoutes) diff --git a/apps/api/src/api/projects/index.ts b/apps/api/src/api/projects/index.ts index e4d19a9..74f6992 100644 --- a/apps/api/src/api/projects/index.ts +++ b/apps/api/src/api/projects/index.ts @@ -7,6 +7,7 @@ import { getProjectById, updateProject, deleteProjectCascade, + getServerById, listDomains, } from "../../db/repo"; import { tryRun, reloadCaddy } from "../../orchestrator/runtime"; @@ -57,8 +58,14 @@ export const projectsRoutes = new Elysia() set.status = 400; return { error: validationError }; } + const serverId = body.serverId || "local"; + if (!(await getServerById(serverId))) { + set.status = 400; + return { error: "Selected server does not exist" }; + } const project = await createProject({ name: body.name, + serverId, description: body.description, baseDomain: body.baseDomain, repoUrl: body.repoUrl, @@ -89,8 +96,13 @@ export const projectsRoutes = new Elysia() set.status = 400; return { error: validationError }; } + if (body?.serverId !== undefined && !(await getServerById(body.serverId))) { + set.status = 400; + return { error: "Selected server does not exist" }; + } const project = await updateProject(id, { name: body?.name, + serverId: body?.serverId, description: body?.description, baseDomain: body?.baseDomain, repoUrl: body?.repoUrl, diff --git a/apps/api/src/api/servers/index.ts b/apps/api/src/api/servers/index.ts index fc508e0..750edda 100644 --- a/apps/api/src/api/servers/index.ts +++ b/apps/api/src/api/servers/index.ts @@ -5,22 +5,27 @@ import { getServerById, listServers, } from "../../db/repo"; +import { testSshConnection } from "../../utils/ssh"; export const serversRoutes = new Elysia() .get("/servers", async () => listServers()) .post( "/servers", async ({ body, set }: any) => { - if (!body?.name || !body?.host || !body?.authToken) { + if (!body?.name || !body?.host) { set.status = 400; return { - error: "name, host, and authToken are required", + error: "name and host are required", }; } return createServer({ name: body.name, host: body.host, - port: body.port, + port: body.port ?? (body.mode === "ssh" ? 22 : 2375), + mode: body.mode ?? "ssh", + sshUser: body.sshUser ?? "root", + sshKey: body.sshKey, + sshPassword: body.sshPassword, authToken: body.authToken, }); }, @@ -36,6 +41,21 @@ export const serversRoutes = new Elysia() return server; }, ) + .post( + "/servers/:id/test", + async ({ params: { id }, set }) => { + const server = await getServerById(id); + if (!server) { + set.status = 404; + return { error: "Server not found" }; + } + if (server.mode === "ssh") { + const ok = await testSshConnection(server); + return { ok, mode: "ssh" }; + } + return { ok: server.status === "connected", mode: server.mode }; + }, + ) .delete( "/servers/:id", async ({ params: { id }, set }) => { diff --git a/apps/api/src/db/migrations/0009_remote_control_plane.sql b/apps/api/src/db/migrations/0009_remote_control_plane.sql new file mode 100644 index 0000000..2984edc --- /dev/null +++ b/apps/api/src/db/migrations/0009_remote_control_plane.sql @@ -0,0 +1,25 @@ +ALTER TABLE projects ADD COLUMN server_id text; +--> statement-breakpoint +ALTER TABLE deployments ADD COLUMN server_id text; +--> statement-breakpoint +ALTER TABLE servers ADD COLUMN mode text NOT NULL DEFAULT 'docker_tcp'; +--> statement-breakpoint +ALTER TABLE servers ADD COLUMN agent_id text; +--> statement-breakpoint +ALTER TABLE servers ADD COLUMN agent_version text; +--> statement-breakpoint +ALTER TABLE servers ADD COLUMN capabilities text NOT NULL DEFAULT '{}'; +--> statement-breakpoint +ALTER TABLE servers ADD COLUMN labels text NOT NULL DEFAULT '{}'; +--> statement-breakpoint +ALTER TABLE servers ADD COLUMN registered_at text; +--> statement-breakpoint +ALTER TABLE servers ADD COLUMN revoked_at text; +--> statement-breakpoint +INSERT INTO servers (id, name, host, port, auth_token, mode, status, capabilities, labels, registered_at, created_at, updated_at) +SELECT 'local', 'Local server', '127.0.0.1', 0, '', 'local', 'connected', '{"docker":true,"buildkit":true,"caddy":true,"compose":true}', '{}', datetime('now'), datetime('now'), datetime('now') +WHERE NOT EXISTS (SELECT 1 FROM servers WHERE id = 'local'); +--> statement-breakpoint +UPDATE projects SET server_id = 'local' WHERE server_id IS NULL; +--> statement-breakpoint +UPDATE deployments SET server_id = COALESCE((SELECT server_id FROM projects WHERE projects.id = deployments.project_id), 'local') WHERE server_id IS NULL; diff --git a/apps/api/src/db/migrations/0010_agent_foundation.sql b/apps/api/src/db/migrations/0010_agent_foundation.sql new file mode 100644 index 0000000..5280a15 --- /dev/null +++ b/apps/api/src/db/migrations/0010_agent_foundation.sql @@ -0,0 +1,40 @@ +CREATE TABLE agent_registration_tokens ( + id text PRIMARY KEY NOT NULL, + token_hash text NOT NULL UNIQUE, + server_name text NOT NULL, + labels text NOT NULL DEFAULT '{}', + expires_at text NOT NULL, + used_at text, + created_at text NOT NULL +); +--> statement-breakpoint +CREATE TABLE agent_credentials ( + id text PRIMARY KEY NOT NULL, + server_id text NOT NULL, + credential_hash text NOT NULL UNIQUE, + created_at text NOT NULL, + last_used_at text, + revoked_at text, + FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE TABLE agent_jobs ( + id text PRIMARY KEY NOT NULL, + deployment_id text, + server_id text NOT NULL, + type text NOT NULL, + payload text NOT NULL, + status text NOT NULL DEFAULT 'queued', + attempts integer NOT NULL DEFAULT 0, + lease_id text, + lease_expires_at text, + idempotency_key text NOT NULL UNIQUE, + failure_reason text, + created_at text NOT NULL, + started_at text, + finished_at text, + FOREIGN KEY (deployment_id) REFERENCES deployments(id) ON DELETE CASCADE, + FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE INDEX idx_agent_jobs_server_status ON agent_jobs(server_id, status); diff --git a/apps/api/src/db/migrations/0011_agent_wireguard.sql b/apps/api/src/db/migrations/0011_agent_wireguard.sql new file mode 100644 index 0000000..e8d777f --- /dev/null +++ b/apps/api/src/db/migrations/0011_agent_wireguard.sql @@ -0,0 +1 @@ +ALTER TABLE servers ADD COLUMN peer_ip TEXT; diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json index 3d6af4f..81212c2 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -64,6 +64,27 @@ "when": 1785816991600, "tag": "0008_closed_whirlwind", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1785900000000, + "tag": "0009_remote_control_plane", + "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1785900100000, + "tag": "0010_agent_foundation", + "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1785900200000, + "tag": "0011_agent_wireguard", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/repo/agent-jobs.ts b/apps/api/src/db/repo/agent-jobs.ts new file mode 100644 index 0000000..4f8278b --- /dev/null +++ b/apps/api/src/db/repo/agent-jobs.ts @@ -0,0 +1,111 @@ +import { randomUUID } from "node:crypto"; +import { and, eq, or, lt, isNull } from "drizzle-orm"; +import { agentJobs } from "../schema"; +import { getDrizzle } from "../drizzle"; +import { now } from "./helpers"; +import type { AgentJobEnvelope } from "../../agents/protocol"; + +type AgentJobType = AgentJobEnvelope["type"]; + +export const createAgentJob = async (input: { + deploymentId?: string | null; + serverId: string; + type: AgentJobType; + payload: unknown; + idempotencyKey: string; +}) => { + const db = await getDrizzle(); + const id = randomUUID(); + db.insert(agentJobs).values({ + id, + deploymentId: input.deploymentId ?? null, + serverId: input.serverId, + type: input.type, + payload: JSON.stringify(input.payload), + idempotencyKey: input.idempotencyKey, + createdAt: now(), + }).run(); + return id; +}; + +export const leaseNextAgentJob = async (serverId: string, leaseMs = 30_000): Promise => { + const db = await getDrizzle(); + const timestamp = now(); + const row = db.select().from(agentJobs).where(and( + eq(agentJobs.serverId, serverId), + or(eq(agentJobs.status, "queued"), and(eq(agentJobs.status, "leased"), or(isNull(agentJobs.leaseExpiresAt), lt(agentJobs.leaseExpiresAt, timestamp)))), + )).orderBy(agentJobs.createdAt).get(); + if (!row) return null; + const leaseExpiresAt = new Date(Date.now() + leaseMs).toISOString(); + const leaseId = randomUUID(); + const sameLease = row.leaseId ? eq(agentJobs.leaseId, row.leaseId) : isNull(agentJobs.leaseId); + const leased = db.update(agentJobs).set({ + status: "leased", + attempts: row.attempts + 1, + leaseId, + leaseExpiresAt, + }).where(and(eq(agentJobs.id, row.id), eq(agentJobs.status, row.status), sameLease)).run(); + if (leased.changes !== 1) return null; + return { + id: row.id, + deploymentId: row.deploymentId, + type: row.type as AgentJobType, + payload: JSON.parse(row.payload), + leaseId, + leaseExpiresAt, + idempotencyKey: row.idempotencyKey, + }; +}; + +export const acknowledgeAgentJob = async (jobId: string, serverId: string, leaseId: string) => { + const db = await getDrizzle(); + return db.update(agentJobs).set({ status: "running", startedAt: now() }) + .where(and(eq(agentJobs.id, jobId), eq(agentJobs.serverId, serverId), eq(agentJobs.leaseId, leaseId), eq(agentJobs.status, "leased"))).run().changes === 1; +}; + +export const getAgentJobDeploymentId = async (jobId: string, serverId: string, leaseId: string) => { + const db = await getDrizzle(); + return db.select({ deploymentId: agentJobs.deploymentId }).from(agentJobs) + .where(and(eq(agentJobs.id, jobId), eq(agentJobs.serverId, serverId), eq(agentJobs.leaseId, leaseId), eq(agentJobs.status, "running"))).get()?.deploymentId ?? null; +}; + +export const getAgentJobInfo = async (jobId: string, serverId: string, leaseId: string) => { + const db = await getDrizzle(); + const row = db.select({ deploymentId: agentJobs.deploymentId, type: agentJobs.type, payload: agentJobs.payload }).from(agentJobs) + .where(and(eq(agentJobs.id, jobId), eq(agentJobs.serverId, serverId), eq(agentJobs.leaseId, leaseId), eq(agentJobs.status, "running"))).get(); + if (!row) return null; + return { deploymentId: row.deploymentId, type: row.type, payload: JSON.parse(row.payload) }; +}; + +export const finishAgentJob = async (jobId: string, serverId: string, leaseId: string, success: boolean, error?: string) => { + const db = await getDrizzle(); + return db.update(agentJobs).set({ + status: success ? "succeeded" : "failed", + failureReason: success ? null : error || "Agent job failed", + finishedAt: now(), + leaseId: null, + leaseExpiresAt: null, + }).where(and(eq(agentJobs.id, jobId), eq(agentJobs.serverId, serverId), eq(agentJobs.leaseId, leaseId), or(eq(agentJobs.status, "running"), eq(agentJobs.status, "cancelled")))).run().changes === 1; +}; + +export const cancelAgentJobsByDeploymentId = async (deploymentId: string) => { + const db = await getDrizzle(); + return db.update(agentJobs).set({ status: "cancelled" }) + .where(and(eq(agentJobs.deploymentId, deploymentId), or(eq(agentJobs.status, "queued"), eq(agentJobs.status, "leased"), eq(agentJobs.status, "running")))).run().changes; +}; + +export const listCancelledJobIds = async (serverId: string) => { + const db = await getDrizzle(); + return db.select({ id: agentJobs.id }).from(agentJobs) + .where(and(eq(agentJobs.serverId, serverId), eq(agentJobs.status, "cancelled"))).all().map((row) => row.id); +}; + +export const requeueRunningAgentJobs = async (serverId: string) => { + const db = await getDrizzle(); + return db.update(agentJobs).set({ + status: "queued", + leaseId: null, + leaseExpiresAt: null, + startedAt: null, + }).where(and(eq(agentJobs.serverId, serverId), eq(agentJobs.status, "running"))).run().changes; +}; diff --git a/apps/api/src/db/repo/agents.ts b/apps/api/src/db/repo/agents.ts new file mode 100644 index 0000000..a9fe2aa --- /dev/null +++ b/apps/api/src/db/repo/agents.ts @@ -0,0 +1,127 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { and, eq, gt, isNull } from "drizzle-orm"; +import { agentCredentials, agentRegistrationTokens, servers } from "../schema"; +import { getDrizzle } from "../drizzle"; +import { hashToken } from "../../utils/auth"; +import { now } from "./helpers"; +import { config } from "../../utils/config"; +import { buildWireGuardPeerConfig, generateWireGuardKeyPair } from "../../utils/wireguard"; +import type { AgentCapabilities } from "../../agents/protocol"; + +export const createAgentRegistrationToken = async (serverName: string, labels: Record = {}) => { + const rawToken = `dqr_${randomBytes(32).toString("hex")}`; + const createdAt = now(); + const expiresAt = new Date(Date.now() + 5 * 60_000).toISOString(); + const db = await getDrizzle(); + db.insert(agentRegistrationTokens).values({ + id: randomUUID(), + tokenHash: hashToken(rawToken), + serverName, + labels: JSON.stringify(labels), + expiresAt, + createdAt, + }).run(); + return { token: rawToken, expiresAt }; +}; + +const allocatePeerIp = async (): Promise => { + const db = await getDrizzle(); + const rows = db.select({ peerIp: servers.peerIp }).from(servers) + .where(and(eq(servers.mode, "agent"), isNull(servers.revokedAt))).all(); + const used = new Set(rows.map((row) => Number(row.peerIp?.split(".").pop() || 0))); + for (let i = 2; i <= 254; i++) { + if (!used.has(i)) return `10.200.0.${i}`; + } + throw new Error("No WireGuard peer IPs available"); +}; + +export const exchangeAgentRegistrationToken = async ( + rawToken: string, + agentVersion: string, + capabilities: AgentCapabilities, + publicHost?: string, +) => { + const db = await getDrizzle(); + const timestamp = now(); + const registration = db.select().from(agentRegistrationTokens).where(and( + eq(agentRegistrationTokens.tokenHash, hashToken(rawToken)), + isNull(agentRegistrationTokens.usedAt), + gt(agentRegistrationTokens.expiresAt, timestamp), + )).get(); + if (!registration) return null; + const consumed = db.update(agentRegistrationTokens).set({ usedAt: timestamp }).where(and( + eq(agentRegistrationTokens.id, registration.id), + isNull(agentRegistrationTokens.usedAt), + )).run(); + if (consumed.changes !== 1) return null; + + const serverId = randomUUID(); + const agentId = randomUUID(); + const rawCredential = `dqa_${randomBytes(32).toString("hex")}`; + const keyPair = generateWireGuardKeyPair(); + const peerIp = await allocatePeerIp(); + const wireguard = buildWireGuardPeerConfig( + peerIp, + keyPair.privateKey, + config.wireguardServerPublicKey, + config.wireguardServerEndpoint, + ); + db.insert(servers).values({ + id: serverId, + name: registration.serverName, + host: publicHost || "agent", + port: 0, + authToken: "", + mode: "agent", + agentId, + agentVersion, + peerIp, + capabilities: JSON.stringify(capabilities), + labels: JSON.stringify({ ...parseLabels(registration.labels), wgPublicKey: keyPair.publicKey }), + status: "pending", + registeredAt: timestamp, + createdAt: timestamp, + updatedAt: timestamp, + }).run(); + db.insert(agentCredentials).values({ + id: randomUUID(), + serverId, + credentialHash: hashToken(rawCredential), + createdAt: timestamp, + }).run(); + return { serverId, agentId, credential: rawCredential, wireguard, peerIp }; +}; + +const parseLabels = (value: string): Record => { + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +}; + +export const validateAgentCredential = async (rawCredential: string) => { + const db = await getDrizzle(); + const row = db.select().from(agentCredentials).where(and( + eq(agentCredentials.credentialHash, hashToken(rawCredential)), + isNull(agentCredentials.revokedAt), + )).get(); + if (!row) return null; + db.update(agentCredentials).set({ lastUsedAt: now() }).where(eq(agentCredentials.id, row.id)).run(); + return row.serverId; +}; + +export const updateAgentHeartbeat = async ( + serverId: string, + patch: { agentVersion?: string; capabilities?: AgentCapabilities; cpuUsedPercent?: number; memoryUsedMb?: number }, +) => { + const timestamp = now(); + const updates: Record = { status: "connected", lastHeartbeat: timestamp, updatedAt: timestamp }; + if (patch.agentVersion !== undefined) updates.agentVersion = patch.agentVersion; + if (patch.capabilities !== undefined) updates.capabilities = JSON.stringify(patch.capabilities); + if (patch.cpuUsedPercent !== undefined) updates.cpuUsedPercent = patch.cpuUsedPercent; + if (patch.memoryUsedMb !== undefined) updates.memoryUsedMb = patch.memoryUsedMb; + const db = await getDrizzle(); + db.update(servers).set(updates).where(eq(servers.id, serverId)).run(); +}; diff --git a/apps/api/src/db/repo/deployments.ts b/apps/api/src/db/repo/deployments.ts index 843ac82..186e5e9 100644 --- a/apps/api/src/db/repo/deployments.ts +++ b/apps/api/src/db/repo/deployments.ts @@ -8,6 +8,7 @@ import { now } from "./helpers"; const mapDeployment = (row: typeof deployments.$inferSelect): Deployment => ({ id: row.id, projectId: row.projectId, + serverId: row.serverId ?? null, sourceType: row.sourceType as Deployment["sourceType"], sourceRef: row.sourceRef, status: row.status as DeploymentStatus, @@ -33,6 +34,7 @@ export const createDeployment = async (input: CreateDeploymentInput): Promise ({ id: row.id, + serverId: row.serverId ?? null, name: row.name, description: row.description, repoUrl: row.repoUrl, @@ -50,6 +51,7 @@ export const createProject = async (input: CreateProjectInput): Promise const db = await getDrizzle(); db.insert(projects).values({ id, + serverId: input.serverId ?? "local", name: input.name, description: input.description ?? null, repoUrl: input.repoUrl ?? null, @@ -98,6 +100,7 @@ export const updateProject = async (id: string, patch: Partial = { updatedAt: now() }; if (patch.name !== undefined) updates.name = patch.name; + if (patch.serverId !== undefined) updates.serverId = patch.serverId; if (patch.description !== undefined) updates.description = patch.description; if (patch.repoUrl !== undefined) updates.repoUrl = patch.repoUrl; if (patch.repoBranch !== undefined) updates.repoBranch = patch.repoBranch; diff --git a/apps/api/src/db/repo/servers.ts b/apps/api/src/db/repo/servers.ts index 7fbafc2..2657477 100644 --- a/apps/api/src/db/repo/servers.ts +++ b/apps/api/src/db/repo/servers.ts @@ -1,7 +1,7 @@ import { eq, desc } from "drizzle-orm"; import { getDrizzle } from "../drizzle"; import { servers } from "../schema"; -import type { Server, CreateServerInput, ServerStatus } from "../../types"; +import type { Server, CreateServerInput, ServerMode, ServerStatus } from "../../types"; import { randomUUID } from "node:crypto"; import { now } from "./helpers"; @@ -10,7 +10,11 @@ const mapServer = (row: typeof servers.$inferSelect): Server => ({ name: row.name, host: row.host, port: row.port, - authToken: row.authToken, + mode: row.mode as ServerMode, + agentId: row.agentId, + agentVersion: row.agentVersion, + capabilities: parseJsonObject(row.capabilities), + labels: parseJsonObject(row.labels) as Record, status: row.status as ServerStatus, cpuTotal: row.cpuTotal, memoryTotalMb: row.memoryTotalMb, @@ -18,10 +22,21 @@ const mapServer = (row: typeof servers.$inferSelect): Server => ({ cpuUsedPercent: row.cpuUsedPercent, memoryUsedMb: row.memoryUsedMb, lastHeartbeat: row.lastHeartbeat, + registeredAt: row.registeredAt, + revokedAt: row.revokedAt, createdAt: row.createdAt, updatedAt: row.updatedAt, }); +const parseJsonObject = (value: string): Record => { + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +}; + export const createServer = async (input: CreateServerInput): Promise => { const id = randomUUID(); const timestamp = now(); @@ -31,7 +46,8 @@ export const createServer = async (input: CreateServerInput): Promise => name: input.name, host: input.host, port: input.port ?? 2375, - authToken: input.authToken, + authToken: input.authToken ?? "", + mode: input.mode ?? "ssh", status: "pending", createdAt: timestamp, updatedAt: timestamp, @@ -40,6 +56,51 @@ export const createServer = async (input: CreateServerInput): Promise => return mapServer(row); }; +export interface ServerConnection { + id: string; + host: string; + port: number; + authToken: string; + mode: ServerMode; +} + +export const listServerConnections = async (): Promise => { + const db = await getDrizzle(); + return db.select({ + id: servers.id, + host: servers.host, + port: servers.port, + authToken: servers.authToken, + mode: servers.mode, + }).from(servers).all().map((row) => ({ + ...row, + mode: row.mode as ServerMode, + })); +}; + +export const ensureLocalServer = async (): Promise => { + const existing = await getServerById("local"); + if (existing) return existing; + const timestamp = now(); + const db = await getDrizzle(); + db.insert(servers).values({ + id: "local", + name: "Local server", + host: "127.0.0.1", + port: 22, + authToken: "", + mode: "local", + status: "connected", + capabilities: JSON.stringify({ docker: true, buildkit: true, caddy: true, compose: true }), + labels: "{}", + registeredAt: timestamp, + lastHeartbeat: timestamp, + createdAt: timestamp, + updatedAt: timestamp, + }).run(); + return getServerById("local") as Promise; +}; + export const listServers = async (): Promise => { const db = await getDrizzle(); return db.select().from(servers).orderBy(servers.name).all().map(mapServer); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 60ab341..85d029c 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -11,6 +11,7 @@ export const githubIntegrations = sqliteTable("github_integrations", { export const projects = sqliteTable("projects", { id: text().primaryKey(), + serverId: text("server_id"), name: text().notNull(), description: text(), repoUrl: text("repo_url"), @@ -40,6 +41,7 @@ export const projects = sqliteTable("projects", { export const deployments = sqliteTable("deployments", { id: text().primaryKey(), projectId: text("project_id"), + serverId: text("server_id"), sourceType: text("source_type").notNull(), sourceRef: text("source_ref").notNull(), status: text().notNull().default("pending"), @@ -163,6 +165,12 @@ export const servers = sqliteTable("servers", { host: text().notNull(), port: integer().notNull().default(2375), authToken: text("auth_token").notNull().default(""), + mode: text().notNull().default("docker_tcp"), + agentId: text("agent_id").unique(), + agentVersion: text("agent_version"), + peerIp: text("peer_ip"), + capabilities: text().notNull().default("{}"), + labels: text().notNull().default("{}"), status: text().notNull().default("pending"), cpuTotal: integer("cpu_total"), memoryTotalMb: integer("memory_total_mb"), @@ -170,10 +178,54 @@ export const servers = sqliteTable("servers", { cpuUsedPercent: real("cpu_used_percent"), memoryUsedMb: integer("memory_used_mb"), lastHeartbeat: text("last_heartbeat"), + registeredAt: text("registered_at"), + revokedAt: text("revoked_at"), createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }); +export const agentRegistrationTokens = sqliteTable("agent_registration_tokens", { + id: text().primaryKey(), + tokenHash: text("token_hash").notNull().unique(), + serverName: text("server_name").notNull(), + labels: text().notNull().default("{}"), + expiresAt: text("expires_at").notNull(), + usedAt: text("used_at"), + createdAt: text("created_at").notNull(), +}); + +export const agentCredentials = sqliteTable("agent_credentials", { + id: text().primaryKey(), + serverId: text("server_id").notNull(), + credentialHash: text("credential_hash").notNull().unique(), + createdAt: text("created_at").notNull(), + lastUsedAt: text("last_used_at"), + revokedAt: text("revoked_at"), +}, (table) => [ + foreignKey({ columns: [table.serverId], foreignColumns: [servers.id], onDelete: "cascade" }), +]); + +export const agentJobs = sqliteTable("agent_jobs", { + id: text().primaryKey(), + deploymentId: text("deployment_id"), + serverId: text("server_id").notNull(), + type: text().notNull(), + payload: text().notNull(), + status: text().notNull().default("queued"), + attempts: integer().notNull().default(0), + leaseId: text("lease_id"), + leaseExpiresAt: text("lease_expires_at"), + idempotencyKey: text("idempotency_key").notNull().unique(), + failureReason: text("failure_reason"), + createdAt: text("created_at").notNull(), + startedAt: text("started_at"), + finishedAt: text("finished_at"), +}, (table) => [ + foreignKey({ columns: [table.serverId], foreignColumns: [servers.id], onDelete: "cascade" }), + foreignKey({ columns: [table.deploymentId], foreignColumns: [deployments.id], onDelete: "cascade" }), + index("idx_agent_jobs_server_status").on(table.serverId, table.status), +]); + export const refreshTokens = sqliteTable("refresh_tokens", { id: text().primaryKey(), username: text().notNull(), diff --git a/apps/api/src/executors/__tests__/dispatch.test.ts b/apps/api/src/executors/__tests__/dispatch.test.ts new file mode 100644 index 0000000..de8ed5a --- /dev/null +++ b/apps/api/src/executors/__tests__/dispatch.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "bun:test"; +import { buildRemoteDeployScript, parseRemoteBuildResult } from "../ssh-build-script"; +import { executorFor } from "../dispatch"; + +const input = { + deploymentId: "deployment-1", + workspaceRoot: "/var/lib/dequel/workspace", + gitUrl: "https://github.com/example/api.git", + branch: "main", + commitSha: null, + imageTag: "example-api-deploym:latest", + clearCache: false, + environmentVariables: [{ key: "NODE_ENV", value: "production" }], +}; + +describe("remote SSH build script", () => { + it("embeds inputs with single-quote escaping", () => { + const script = buildRemoteDeployScript(input); + expect(script).toContain("set -euo pipefail"); + expect(script).toContain("git clone --depth 1 'https://github.com/example/api.git' ."); + expect(script).toContain("--env 'NODE_ENV=production'"); + expect(script).toContain("--name 'example-api-deploym:latest'"); + expect(script).toContain('echo "RESULT:{\\"imageTag\\":\\"example-api-deploym:latest\\",\\"commitSha\\":\\"$SHA\\"}"'); + }); + + it("escapes single quotes in values", () => { + const script = buildRemoteDeployScript({ + ...input, + environmentVariables: [{ key: "GREETING", value: "it's fine" }], + }); + expect(script).toContain("--env 'GREETING=it'\\''s fine'"); + }); + + it("checks out a specific commit when provided", () => { + const script = buildRemoteDeployScript({ ...input, commitSha: "abc1234", branch: null }); + expect(script).toContain("git fetch --depth 1 origin 'abc1234' && git checkout 'abc1234'"); + }); + + it("uses a fresh cache key for clear builds", () => { + const script = buildRemoteDeployScript({ ...input, clearCache: true }); + expect(script).toContain("--cache-key 'example-api-deploym-clear-"); + }); + + it("installs railpack when missing", () => { + const script = buildRemoteDeployScript(input); + expect(script).toContain("curl -fsSL https://railpack.com/install.sh"); + }); +}); + +describe("remote build result parsing", () => { + it("parses the RESULT marker", () => { + expect(parseRemoteBuildResult("line1\nRESULT:{\"imageTag\":\"x:latest\",\"commitSha\":\"deadbeef\"}")).toEqual({ + imageTag: "x:latest", + commitSha: "deadbeef", + }); + }); + + it("returns null when the marker is missing", () => { + expect(parseRemoteBuildResult("no marker here")).toBeNull(); + expect(parseRemoteBuildResult("RESULT:not-json")).toBeNull(); + }); +}); + +describe("executor dispatch", () => { + it("maps each mode to its executor", () => { + expect(executorFor("local").mode).toBe("local"); + expect(executorFor("ssh").mode).toBe("ssh"); + expect(executorFor("agent").mode).toBe("agent"); + }); + + it("defaults unknown modes to local", () => { + expect(executorFor("docker_tcp").mode).toBe("local"); + }); +}); \ No newline at end of file diff --git a/apps/api/src/executors/agent.ts b/apps/api/src/executors/agent.ts new file mode 100644 index 0000000..321c1d7 --- /dev/null +++ b/apps/api/src/executors/agent.ts @@ -0,0 +1,86 @@ +import type { Deployment, Project, Server } from "../types"; +import type { DeploymentExecutor, ExecutorCancelInput, ExecutorDeployInput, ExecutorDestroyInput, ExecutorRollbackInput } from "./types"; + +let repoModule: typeof import("../db/repo") | null = null; +let deploymentsModule: typeof import("../agents/deployments") | null = null; + +const getRepo = async () => (repoModule ??= await import("../db/repo")); +const getDeployments = async () => (deploymentsModule ??= await import("../agents/deployments")); + +export const queueRemoteRollback = async (deployment: Deployment, project: Project | null, server: Server) => { + if (!deployment.imageTag) throw new Error("Deployment has no built image to rollback to"); + if (!deployment.serverId || deployment.serverId === "local") throw new Error("Remote rollback requires an agent server"); + if (project?.buildType === "compose") throw new Error("Rollback is not supported for Docker Compose deployments"); + + const { createAgentJob, listEnvironmentVariablesForDeploy, listVolumes, updateDeploymentStatus, appendLog } = await getRepo(); + const environmentVariables = await listEnvironmentVariablesForDeploy(deployment.projectId ?? "", deployment.environment ?? undefined); + const volumes = await listVolumes(deployment.projectId ?? ""); + + const payload = { + deploymentId: deployment.id, + projectId: deployment.projectId ?? null, + projectName: project?.name ?? null, + imageTag: deployment.imageTag, + appPort: project?.port || 3000, + cpuLimit: project?.cpuLimit ?? null, + memoryLimitMb: project?.memoryLimitMb ?? null, + environmentVariables, + volumes: volumes.map((v) => ({ + volumeName: v.dockerVolumeName ?? `vol-${v.id.slice(0, 12)}`, + mountPath: v.mountPath, + })), + }; + + await createAgentJob({ + deploymentId: deployment.id, + serverId: deployment.serverId, + type: "rollback", + payload, + idempotencyKey: `rollback:${deployment.id}`, + }); + await updateDeploymentStatus(deployment.id, "pending", { failureReason: null }); + await appendLog(deployment.id, "system", `Rollback queued for server ${server.name}`); +}; + +export const queueRemoteDestroy = async (deployment: Deployment, server: Server) => { + if (!deployment.serverId || deployment.serverId === "local") throw new Error("Remote destroy requires an agent server"); + const { createAgentJob, appendLog } = await getRepo(); + await createAgentJob({ + deploymentId: deployment.id, + serverId: deployment.serverId, + type: "destroy", + payload: { + deploymentId: deployment.id, + containerName: deployment.containerName ?? null, + imageTag: deployment.imageTag ?? null, + }, + idempotencyKey: `destroy:${deployment.id}`, + }); + await appendLog(deployment.id, "system", `Destroy queued for server ${server.name}`); +}; + +export const agentExecutor: DeploymentExecutor = { + mode: "agent", + + async deploy({ deployment, project, server }: ExecutorDeployInput) { + if (!project) throw new Error("Remote deployment requires a project"); + const { queueRemoteDeployment } = await getDeployments(); + await queueRemoteDeployment(deployment, project); + }, + + async rollback({ deployment, project, server }: ExecutorRollbackInput) { + await queueRemoteRollback(deployment, project, server); + }, + + async destroy({ deployment, server }: ExecutorDestroyInput) { + await queueRemoteDestroy(deployment, server); + }, + + async cancel({ deployment }: ExecutorCancelInput) { + const { cancelAgentJobsByDeploymentId, updateDeploymentStatus, appendLog } = await getRepo(); + if (deployment.status !== "pending" && deployment.status !== "building") return; + await cancelAgentJobsByDeploymentId(deployment.id); + await updateDeploymentStatus(deployment.id, "failed", { failureReason: "Cancelled" }); + await appendLog(deployment.id, "system", "Deployment cancelled by user"); + }, +}; \ No newline at end of file diff --git a/apps/api/src/executors/dispatch.ts b/apps/api/src/executors/dispatch.ts new file mode 100644 index 0000000..858001f --- /dev/null +++ b/apps/api/src/executors/dispatch.ts @@ -0,0 +1,18 @@ +import { agentExecutor } from "./agent"; +import { localExecutor } from "./local"; +import { sshExecutor } from "./ssh"; +import type { DeploymentExecutor } from "./types"; + +export const executorFor = (mode: string): DeploymentExecutor => { + switch (mode) { + case "agent": + return agentExecutor; + case "ssh": + return sshExecutor; + default: + return localExecutor; + } +}; + +export { agentExecutor, localExecutor, sshExecutor }; +export type { DeploymentExecutor, ExecutorDeployInput, ExecutorRollbackInput, ExecutorDestroyInput, ExecutorCancelInput } from "./types"; \ No newline at end of file diff --git a/apps/api/src/executors/local.ts b/apps/api/src/executors/local.ts new file mode 100644 index 0000000..607cade --- /dev/null +++ b/apps/api/src/executors/local.ts @@ -0,0 +1,29 @@ +import type { DeploymentExecutor, ExecutorCancelInput, ExecutorDeployInput, ExecutorDestroyInput, ExecutorRollbackInput } from "./types"; + +let orchestratorModule: typeof import("../orchestrator") | null = null; + +const getOrchestrator = async () => (orchestratorModule ??= await import("../orchestrator")); + +export const localExecutor: DeploymentExecutor = { + mode: "local", + + async deploy({ deployment }: ExecutorDeployInput) { + const { orchestrator } = await getOrchestrator(); + orchestrator.enqueue(deployment.id); + }, + + async rollback({ deployment }: ExecutorRollbackInput) { + const { orchestrator } = await getOrchestrator(); + await orchestrator.rollbackTo(deployment.id); + }, + + async destroy({ deployment }: ExecutorDestroyInput) { + const { orchestrator } = await getOrchestrator(); + await orchestrator.deleteDeployment(deployment.id); + }, + + async cancel({ deployment }: ExecutorCancelInput) { + const { orchestrator } = await getOrchestrator(); + await orchestrator.cancelDeployment(deployment.id); + }, +}; \ No newline at end of file diff --git a/apps/api/src/executors/logging.ts b/apps/api/src/executors/logging.ts new file mode 100644 index 0000000..86fd067 --- /dev/null +++ b/apps/api/src/executors/logging.ts @@ -0,0 +1,20 @@ +import { logBus } from "../orchestrator/log-bus"; + +let appendLogModule: typeof import("../db/repo") | null = null; + +export const emitLog = async ( + deploymentId: string, + stage: "build" | "deploy" | "system", + message: string, +) => { + const timestamp = new Date().toISOString().replace("T", " ").replace(/\.\d{3}Z$/, ""); + appendLogModule ??= await import("../db/repo"); + const saved = await appendLogModule.appendLog(deploymentId, stage, message); + logBus.publish({ + deploymentId, + sequence: saved.sequence, + stage, + message, + timestamp, + }); +}; \ No newline at end of file diff --git a/apps/api/src/executors/ssh-build-script.ts b/apps/api/src/executors/ssh-build-script.ts new file mode 100644 index 0000000..71ac494 --- /dev/null +++ b/apps/api/src/executors/ssh-build-script.ts @@ -0,0 +1,78 @@ +export interface SshBuildScriptInput { + deploymentId: string; + workspaceRoot: string; + gitUrl: string; + branch?: string | null; + commitSha?: string | null; + imageTag: string; + clearCache?: boolean; + environmentVariables: { key: string; value: string }[]; +} + +const sh = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`; + +export const buildRemoteDeployScript = (input: SshBuildScriptInput): string => { + const cacheKey = input.imageTag.split(":")[0].replace(/-[0-9a-f]{8}$/i, "").replace(/[^a-zA-Z0-9_-]/g, "-"); + const effectiveCacheKey = input.clearCache ? `${cacheKey}-clear-${Date.now()}` : cacheKey; + + const envFlags = input.environmentVariables + .map((env) => `--env ${sh(`${env.key}=${env.value}`)}`) + .join(" "); + + const checkoutSha = input.commitSha + ? `git fetch --depth 1 origin ${sh(input.commitSha)} && git checkout ${sh(input.commitSha)}` + : input.branch + ? `git checkout ${sh(input.branch)}` + : "true"; + + return [ + "set -euo pipefail", + "", + `WORKSPACE=${sh(input.workspaceRoot)}`, + `PROJECT_DIR="$WORKSPACE/${input.deploymentId}"`, + 'echo "[build] Ensuring workspace"', + 'rm -rf "$PROJECT_DIR"', + 'mkdir -p "$PROJECT_DIR"', + 'cd "$PROJECT_DIR"', + "", + 'if command -v railpack >/dev/null 2>&1; then', + ' echo "[build] Railpack already installed"', + "else", + ' echo "[build] Installing railpack"', + " curl -fsSL https://railpack.com/install.sh | sh -s -- --bin-dir /usr/local/bin", + "fi", + "", + `echo "[build] Cloning repository ${input.gitUrl}"`, + `git clone --depth 1 ${sh(input.gitUrl)} .`, + checkoutSha, + "", + '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)} \\`, + " --env CARGO_HTTP_MULTIPLEXING=false --env CARGO_HTTP_TIMEOUT=120 \\", + " --env CARGO_NET_GIT_FETCH_WITH_CLI=true --env RUSTUP_AUTO_SELF_UPDATE=off \\", + " --env NPM_CONFIG_TIMEOUT=600000 --env NPM_CONFIG_AUDIT=false --env NPM_CONFIG_FUND=false \\", + " --env PNPM_CONFIG_TRUST_LOCKFILE=true --env NPM_CONFIG_MAXSOCKETS=4 \\", + " --env PNPM_CONFIG_NETWORK_CONCURRENCY=4 --env PNPM_CONFIG_CHILD_CONCURRENCY=4 \\", + " --env PNPM_CONFIG_FETCH_RETRIES=10", + envFlags ? ` ${envFlags} \\` : "", + " .", + "", + `echo "RESULT:{\\"imageTag\\":\\"${input.imageTag}\\",\\"commitSha\\":\\"$SHA\\"}"`, + ].filter((line) => line !== "").join("\n"); +}; + +export interface RemoteBuildResult { + imageTag: string; + commitSha?: string; +} + +export const parseRemoteBuildResult = (stdout: string): RemoteBuildResult | null => { + const line = stdout.split("\n").reverse().find((l) => l.startsWith("RESULT:")); + if (!line) return null; + try { + return JSON.parse(line.slice("RESULT:".length)) as RemoteBuildResult; + } catch { + return null; + } +}; \ No newline at end of file diff --git a/apps/api/src/executors/ssh.ts b/apps/api/src/executors/ssh.ts new file mode 100644 index 0000000..1d707d0 --- /dev/null +++ b/apps/api/src/executors/ssh.ts @@ -0,0 +1,172 @@ +import { config } from "../utils/config"; +import { removeRemoteCaddyRoute, runRemoteScript } from "../utils/ssh"; +import { buildRemoteDeployScript, parseRemoteBuildResult } from "./ssh-build-script"; +import { emitLog } from "./logging"; +import { summarizeDeploymentError } from "../orchestrator/deployment-errors"; +import type { Deployment, Project, Server } from "../types"; +import type { DeploymentExecutor, ExecutorCancelInput, ExecutorDeployInput, ExecutorRollbackInput } from "./types"; + +let repoModule: typeof import("../db/repo") | null = null; +let runtimeModule: typeof import("../orchestrator/runtime") | null = null; + +const getRepo = async () => (repoModule ??= await import("../db/repo")); +const getRuntime = async () => (runtimeModule ??= await import("../orchestrator/runtime")); + +const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63); + +const buildEnvVars = async (deployment: Deployment) => { + const { listEnvironmentVariablesForDeploy } = await getRepo(); + const vars = await listEnvironmentVariablesForDeploy(deployment.projectId ?? "", deployment.environment ?? undefined); + if (vars.length === 0) return undefined; + const envVars: Record = {}; + for (const v of vars) envVars[v.key] = v.value; + return envVars; +}; + +const buildVolumes = async (deployment: Deployment) => { + const { listVolumes } = await getRepo(); + const vols = await listVolumes(deployment.projectId ?? ""); + if (vols.length === 0) return undefined; + return vols.map((v) => ({ + volumeName: v.dockerVolumeName ?? `vol-${v.id.slice(0, 12)}`, + mountPath: v.mountPath, + })); +}; + +const deployFromImage = async ( + deployment: Deployment, + project: Project | null, + server: Server, + imageTag: string, + oldContainerName?: string, +) => { + const { updateDeploymentStatus } = await getRepo(); + const { deployContainer } = await getRuntime(); + const envVars = await buildEnvVars(deployment); + const volumes = await buildVolumes(deployment); + const runtime = await deployContainer( + deployment.id, + imageTag, + async (line) => { await emitLog(deployment.id, "deploy", line); }, + { + projectId: deployment.projectId ?? undefined, + projectName: project?.name, + oldContainerName, + envVars, + volumes, + cpuLimit: project?.cpuLimit, + memoryLimitMb: project?.memoryLimitMb, + appPort: project?.port, + targetServer: server, + }, + ); + await updateDeploymentStatus(deployment.id, "running", { + containerName: runtime.containerName, + liveUrl: runtime.liveUrl, + imageTag, + }); + await emitLog(deployment.id, "system", "Deployment is running"); + return runtime; +}; + +const markFailed = async (deploymentId: string, error: unknown) => { + const { updateDeploymentStatus } = await getRepo(); + const message = summarizeDeploymentError(error); + await emitLog(deploymentId, "system", `Deployment failed: ${message}`); + await updateDeploymentStatus(deploymentId, "failed", { failureReason: message }); +}; + +export const sshExecutor: DeploymentExecutor = { + mode: "ssh", + + async deploy({ deployment, project, server }: ExecutorDeployInput) { + if (deployment.sourceType !== "git") throw new Error("SSH mode currently supports Git deployments only"); + if (!project) throw new Error("Deployment requires a project"); + + const { listEnvironmentVariablesForDeploy, listDeployments, updateDeploymentStatus } = await getRepo(); + await updateDeploymentStatus(deployment.id, "building", { failureReason: null }); + await emitLog(deployment.id, "system", `Deploying on server ${server.name} over SSH (build runs on the target machine)`); + + const imageTag = `${slugify(project.name)}-${deployment.id.slice(0, 8)}:latest`; + const envVars = await listEnvironmentVariablesForDeploy(project.id, deployment.environment ?? undefined); + const script = buildRemoteDeployScript({ + deploymentId: deployment.id, + workspaceRoot: config.workspaceRoot, + gitUrl: deployment.sourceRef, + branch: deployment.branch, + commitSha: deployment.commitSha, + imageTag, + clearCache: deployment.clearCache ?? false, + environmentVariables: envVars, + }); + + try { + const result = await runRemoteScript(server, script, { + onLog: async (line) => { await emitLog(deployment.id, "build", line); }, + }); + if (result.code !== 0) throw new Error(result.stderr || result.stdout || "Remote build failed"); + + const buildResult = parseRemoteBuildResult(result.stdout); + if (!buildResult) throw new Error("Remote build completed without a result marker"); + + await updateDeploymentStatus(deployment.id, "deploying"); + await emitLog(deployment.id, "system", "Build complete — starting container on server"); + + const all = await listDeployments(project.id); + const current = all.find((d) => d.status === "running" && d.id !== deployment.id); + + await deployFromImage(deployment, project, server, buildResult.imageTag, current?.containerName ?? undefined); + + if (current) { + await updateDeploymentStatus(current.id, "inactive", { failureReason: `Superseded by deployment ${deployment.id.slice(0, 8)}` }); + await emitLog(current.id, "system", `Marked inactive (superseded by ${deployment.id.slice(0, 8)})`); + } + } catch (error) { + await markFailed(deployment.id, error); + } + }, + + async rollback({ deployment, project, server }: ExecutorRollbackInput) { + const { getProjectById, listDeployments, updateDeploymentStatus } = await getRepo(); + await updateDeploymentStatus(deployment.id, "deploying"); + await emitLog(deployment.id, "system", `Rolling back to this version (image: ${deployment.imageTag})`); + try { + const all = await listDeployments(deployment.projectId ?? ""); + const current = all.find((d) => d.status === "running" && d.id !== deployment.id); + const resolvedProject = project ?? (deployment.projectId ? await getProjectById(deployment.projectId) : null); + const runtime = await deployFromImage(deployment, resolvedProject, server, deployment.imageTag!, current?.containerName ?? undefined); + if (current) { + await updateDeploymentStatus(current.id, "inactive", { failureReason: `Superseded by rollback to ${deployment.id.slice(0, 8)}` }); + await emitLog(current.id, "system", `Marked inactive (rolled back to ${deployment.id.slice(0, 8)})`); + } + return runtime; + } catch (error) { + const message = summarizeDeploymentError(error); + await emitLog(deployment.id, "system", `Rollback failed: ${message}`); + await updateDeploymentStatus(deployment.id, "failed", { failureReason: message }); + throw error; + } + }, + + async destroy({ deployment, project, server }) { + const { deleteDeploymentAndLogs } = await getRepo(); + const { tryRun } = await getRuntime(); + if (deployment.containerName) { + await tryRun("docker", ["stop", "-t", "5", deployment.containerName], server); + await tryRun("docker", ["rm", "-f", deployment.containerName], server); + } + if (deployment.imageTag && deployment.sourceType !== "image") { + await tryRun("docker", ["rmi", "-f", deployment.imageTag], server); + } + const slug = slugify(project?.name || deployment.projectId || deployment.id); + await removeRemoteCaddyRoute(server, `${slug}.caddy`); + await deleteDeploymentAndLogs(deployment.id); + }, + + async cancel({ deployment }: ExecutorCancelInput) { + const { updateDeploymentStatus } = await getRepo(); + if (deployment.status !== "pending" && deployment.status !== "building") return; + await updateDeploymentStatus(deployment.id, "failed", { failureReason: "Cancelled" }); + await emitLog(deployment.id, "system", "Deployment cancelled by user (remote build may continue on the server)"); + }, +}; \ No newline at end of file diff --git a/apps/api/src/executors/types.ts b/apps/api/src/executors/types.ts new file mode 100644 index 0000000..99a2ca3 --- /dev/null +++ b/apps/api/src/executors/types.ts @@ -0,0 +1,33 @@ +import type { Deployment, Project, Server } from "../../types"; + +export interface ExecutorDeployInput { + deployment: Deployment; + project: Project | null; + server: Server; +} + +export interface ExecutorRollbackInput { + deployment: Deployment; + project: Project | null; + server: Server; + imageTag: string; +} + +export interface ExecutorDestroyInput { + deployment: Deployment; + project: Project | null; + server: Server; +} + +export interface ExecutorCancelInput { + deployment: Deployment; + server: Server; +} + +export interface DeploymentExecutor { + readonly mode: string; + deploy(input: ExecutorDeployInput): Promise; + rollback(input: ExecutorRollbackInput): Promise; + destroy(input: ExecutorDestroyInput): Promise; + cancel(input: ExecutorCancelInput): Promise; +} \ No newline at end of file diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index acf9ea4..a6357ae 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -15,6 +15,7 @@ import { loadOrCreateJwtSecret } from './utils/secrets'; import { initAuth, cleanupExpiredTokens } from './utils/auth'; import { startBuildCleanup } from './orchestrator/cleanup'; import { startDatabaseMonitoring } from './databases/manager'; +import { ensureLocalServer } from './db/repo'; const bootstrap = async () => { await mkdir(dirname(config.databasePath), { recursive: true }); await mkdir(config.workspaceRoot, { recursive: true }); @@ -24,6 +25,7 @@ const bootstrap = async () => { initAuth(jwtSecret); await migrate(); + await ensureLocalServer(); await orchestrator.reconcileState(); orchestrator.startWorker(); scalingEngine.start(); diff --git a/apps/api/src/orchestrator/pipeline.ts b/apps/api/src/orchestrator/pipeline.ts index 6b02003..a271939 100644 --- a/apps/api/src/orchestrator/pipeline.ts +++ b/apps/api/src/orchestrator/pipeline.ts @@ -570,6 +570,7 @@ export class PipelineOrchestrator { appPort = project.port; } + const targetServer = project?.serverId ? await getServerById(project.serverId) : null; let runtimeContainerName = ""; let runtimeLiveUrl = ""; @@ -614,6 +615,7 @@ export class PipelineOrchestrator { cpuLimit, memoryLimitMb, appPort: appPort ?? undefined, + targetServer, }, ); runtimeContainerName = runtime.containerName; diff --git a/apps/api/src/orchestrator/runtime.ts b/apps/api/src/orchestrator/runtime.ts index 10ca24f..11f4d4a 100644 --- a/apps/api/src/orchestrator/runtime.ts +++ b/apps/api/src/orchestrator/runtime.ts @@ -4,6 +4,8 @@ import { spawn } from 'node:child_process'; import { config } from '../utils/config'; import { dockerBin } from '../utils/docker-bin'; import { DEQUEL_MANAGED_LABEL } from '../utils/dequel-labels'; +import type { Server } from '../types'; +import { getDockerSshTarget, syncRemoteCaddyRoute } from '../utils/ssh'; const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 63); @@ -17,34 +19,33 @@ export interface RuntimeOpts { cpuLimit?: number | null; memoryLimitMb?: number | null; appPort?: number; + targetServer?: Server | null; } -export const run = (cmd: string, args: string[]) => +const getDockerTargetArgs = (server?: Server | null): string[] => { + if (server?.mode === 'ssh') { + return ['-H', getDockerSshTarget(server)]; + } + return []; +}; + +export const run = (cmd: string, args: string[], server?: Server | null) => new Promise((resolve, reject) => { - const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + const targetArgs = getDockerTargetArgs(server); + const fullArgs = cmd === dockerBin && targetArgs.length > 0 ? [...targetArgs, ...args] : args; + const child = spawn(cmd, fullArgs, { stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { stdout += String(chunk); }); child.stderr.on('data', (chunk) => { stderr += String(chunk); }); child.on('close', (code) => { if (code === 0) resolve((stdout + '\n' + stderr).trim()); - else reject(new Error(`${cmd} ${args.join(' ')} failed (${code}): ${stderr}`)); + else reject(new Error(`${cmd} ${fullArgs.join(' ')} failed (${code}): ${stderr}`)); }); }); -const getCaddyContainer = async (): Promise => { - const output = await run(dockerBin, [ - 'ps', '-q', - '--filter', 'label=com.docker.compose.service=caddy', - '--filter', `network=${config.dockerNetwork}`, - ]); - const containerId = output.split('\n').map(l => l.trim()).find(Boolean); - if (!containerId) throw new Error('Could not find running Caddy container'); - return containerId; -}; - -export const tryRun = async (cmd: string, args: string[]) => { - try { await run(cmd, args); } catch { return; } +export const tryRun = async (cmd: string, args: string[], server?: Server | null) => { + try { await run(cmd, args, server); } catch { return; } }; const waitForRunningContainer = async ( @@ -189,21 +190,26 @@ export const deployContainer = async ( dockerArgs.push(imageTag); - await run(dockerBin, dockerArgs); + await run(dockerBin, dockerArgs, opts.targetServer); await onLog(`Waiting for container ${containerName} to report running`); await waitForRunningContainer(containerName, 40, onLog); - await tryRun(dockerBin, ['network', 'connect', config.dockerNetwork, containerName]); + await tryRun(dockerBin, ['network', 'connect', config.dockerNetwork, containerName], opts.targetServer); - const caddyRouteFile = join(config.caddyRoutesDir, `${slug}.caddy`); const { buildCaddySnippet } = await import('../utils/domain-verifier'); const caddySnippet = await buildCaddySnippet(slug, containerName, opts.projectId, undefined, opts.appPort); - await onLog(`Writing Caddy route file: ${caddyRouteFile}`); - await writeFile(caddyRouteFile, caddySnippet, 'utf8'); - - await onLog('Reloading Caddy to apply dynamic route'); - try { await reloadCaddy(); } catch (error) { - await onLog(`Caddy reload failed (might not be ready): ${error instanceof Error ? error.message : String(error)}`); + if (opts.targetServer?.mode === 'ssh' || opts.targetServer?.mode === 'docker_tcp') { + await onLog(`Syncing Caddy route to remote server (${opts.targetServer.name}): ${slug}.caddy`); + await syncRemoteCaddyRoute(opts.targetServer, `${slug}.caddy`, caddySnippet); + } else { + const caddyRouteFile = join(config.caddyRoutesDir, `${slug}.caddy`); + await onLog(`Writing Caddy route file: ${caddyRouteFile}`); + await writeFile(caddyRouteFile, caddySnippet, 'utf8'); + + await onLog('Reloading Caddy to apply dynamic route'); + try { await reloadCaddy(); } catch (error) { + await onLog(`Caddy reload failed (might not be ready): ${error instanceof Error ? error.message : String(error)}`); + } } await onLog('Caddy route reload completed'); diff --git a/apps/api/src/servers/manager.ts b/apps/api/src/servers/manager.ts index fc1c415..ea084ad 100644 --- a/apps/api/src/servers/manager.ts +++ b/apps/api/src/servers/manager.ts @@ -1,5 +1,5 @@ import { spawn } from 'node:child_process'; -import { listServers, updateServerStatus, getServerById } from '../db/repo'; +import { listServerConnections, updateServerStatus } from '../db/repo'; const run = (cmd: string, args: string[]) => new Promise((resolve, reject) => { @@ -32,8 +32,9 @@ class ServerManager { private async heartbeat() { try { - const servers = await listServers(); + const servers = await listServerConnections(); for (const server of servers) { + if (server.mode === 'agent') continue; // Handled via WebSocket heartbeats await this.checkServer(server); } } catch (err) { @@ -41,11 +42,13 @@ class ServerManager { } } - private async checkServer(server: { id: string; host: string; port: number; authToken: string }) { + private async checkServer(server: { id: string; host: string; port: number; mode: string; sshUser?: string | null }) { try { - // Use Docker API via CLI with -H flag to check remote server + let dockerTarget = `unix:///var/run/docker.sock`; + if (server.mode === 'ssh') dockerTarget = `ssh://${server.sshUser || 'root'}@${server.host}:${server.port || 22}`; + const info = await tryRun('docker', [ - '-H', `tcp://${server.host}:${server.port}`, + '-H', dockerTarget, 'info', '--format', '{{json .}}', ]); @@ -62,12 +65,6 @@ class ServerManager { memoryUsedMb: null, }; - // Try to get container stats for resource usage - const stats = await tryRun('docker', [ - '-H', `tcp://${server.host}:${server.port}`, - 'stats', '--no-stream', '--format', '{{json .}}', - ]); - await updateServerStatus(server.id, 'connected', resources); } catch { await updateServerStatus(server.id, 'disconnected'); diff --git a/apps/api/src/types.ts b/apps/api/src/types.ts index e77f103..147661b 100644 --- a/apps/api/src/types.ts +++ b/apps/api/src/types.ts @@ -7,11 +7,13 @@ export type DomainType = 'base' | 'custom'; export type DomainValidationStatus = 'pending' | 'verified' | 'failed'; export type SslStatus = 'pending' | 'provisioned' | 'failed'; export type ServerStatus = 'pending' | 'connected' | 'disconnected' | 'failed'; +export type ServerMode = 'local' | 'ssh' | 'agent'; export type AlertChannel = 'email' | 'slack' | 'webhook'; export type AlertType = 'cpu' | 'memory' | 'error_rate' | 'downtime' | 'cert_expiry'; export interface Project { id: string; + serverId: string | null; name: string; description: string | null; repoUrl: string | null; @@ -49,6 +51,7 @@ export interface GithubIntegration { export interface CreateProjectInput { name: string; + serverId?: string | null; description?: string; repoUrl?: string; repoBranch?: string; @@ -190,7 +193,13 @@ export interface Server { name: string; host: string; port: number; - authToken: string; + mode: ServerMode; + sshUser?: string | null; + sshKeyEncrypted?: string | null; + agentId: string | null; + agentVersion: string | null; + capabilities: Record; + labels: Record; status: ServerStatus; cpuTotal: number | null; memoryTotalMb: number | null; @@ -198,6 +207,8 @@ export interface Server { cpuUsedPercent: number | null; memoryUsedMb: number | null; lastHeartbeat: string | null; + registeredAt: string | null; + revokedAt: string | null; createdAt: string; updatedAt: string; } @@ -206,7 +217,11 @@ export interface CreateServerInput { name: string; host: string; port?: number; - authToken: string; + authToken?: string; + mode?: ServerMode; + sshUser?: string; + sshKey?: string; + sshPassword?: string; } export interface ApiKey { @@ -247,6 +262,7 @@ export interface CreateAlertInput { export interface Deployment { id: string; projectId: string | null; + serverId: string | null; sourceType: SourceType; sourceRef: string; status: DeploymentStatus; @@ -274,6 +290,7 @@ export interface PaginatedResult { export interface CreateDeploymentInput { projectId?: string; + serverId?: string | null; sourceType: SourceType; sourceRef: string; branch?: string; diff --git a/apps/api/src/utils/config.ts b/apps/api/src/utils/config.ts index d311de9..12a4a04 100644 --- a/apps/api/src/utils/config.ts +++ b/apps/api/src/utils/config.ts @@ -121,4 +121,24 @@ export const config = { "GRAFANA_PASS", "admin", ), + wireguardServerContainer: withFile( + "WIREGUARD_SERVER_CONTAINER", + "", + ), + wireguardServerPublicKey: withFile( + "WIREGUARD_SERVER_PUBLIC_KEY", + "", + ), + wireguardServerEndpoint: withFile( + "WIREGUARD_SERVER_ENDPOINT", + "", + ), + wireguardServerIp: withFile( + "WIREGUARD_SERVER_IP", + "10.200.0.1", + ), + wireguardPeerCidr: withFile( + "WIREGUARD_PEER_CIDR", + "10.200.0.0/24", + ), }; diff --git a/apps/api/src/utils/ssh.ts b/apps/api/src/utils/ssh.ts new file mode 100644 index 0000000..1b34adb --- /dev/null +++ b/apps/api/src/utils/ssh.ts @@ -0,0 +1,208 @@ +import { spawn } from "node:child_process"; +import type { Server } from "../types"; + +export interface SshExecutionOptions { + env?: Record; + onLog?: (line: string) => Promise | void; + signal?: AbortSignal; +} + +export const getDockerSshTarget = (server: Server | { host: string; port?: number; sshUser?: string | null }): string => { + const user = server.sshUser || "root"; + const port = server.port || 22; + return `ssh://${user}@${server.host}:${port}`; +}; + +export const testSshConnection = (server: { host: string; port?: number; sshUser?: string | null }): Promise => { + return new Promise((resolve) => { + const target = getDockerSshTarget(server); + const child = spawn("docker", ["-H", target, "info", "--format", "{{.ServerVersion}}"], { + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout?.on("data", (chunk) => { output += String(chunk); }); + child.on("close", (code) => { + resolve(code === 0 && output.trim().length > 0); + }); + child.on("error", () => resolve(false)); + }); +}; + +export const execDockerSshCommand = ( + server: Server | { host: string; port?: number; sshUser?: string | null }, + args: string[], + options: SshExecutionOptions = {} +): Promise<{ code: number; stdout: string; stderr: string }> => { + return new Promise((resolve, reject) => { + const target = getDockerSshTarget(server); + const fullArgs = ["-H", target, ...args]; + const child = spawn("docker", fullArgs, { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, ...options.env }, + }); + + let stdout = ""; + let stderr = ""; + + child.stdout?.on("data", (chunk) => { + const text = String(chunk); + stdout += text; + if (options.onLog) { + text.split("\n").filter(Boolean).forEach((line) => options.onLog!(line)); + } + }); + + child.stderr?.on("data", (chunk) => { + const text = String(chunk); + stderr += text; + if (options.onLog) { + text.split("\n").filter(Boolean).forEach((line) => options.onLog!(line)); + } + }); + + child.on("close", (code) => { + resolve({ code: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim() }); + }); + + child.on("error", (err) => reject(err)); + + if (options.signal) { + options.signal.addEventListener("abort", () => { + child.kill("SIGTERM"); + reject(new Error("SSH docker command aborted")); + }); + } + }); +}; + +export const syncRemoteCaddyRoute = ( + server: Server | { host: string; port?: number; sshUser?: string | null }, + filename: string, + content: string +): Promise => { + return new Promise((resolve) => { + const user = server.sshUser || "root"; + const port = server.port || 22; + // Writes route file via SSH tee command to /etc/caddy/routes/ or caddy reload + const sshCmd = spawn("ssh", [ + "-p", String(port), + "-o", "StrictHostKeyChecking=no", + `${user}@${server.host}`, + `mkdir -p /etc/caddy/routes && cat > /etc/caddy/routes/${filename} && (caddy reload --config /etc/caddy/Caddyfile || docker exec dequel-caddy caddy reload || true)` + ], { stdio: ["pipe", "pipe", "pipe"] }); + + sshCmd.stdin?.write(content); + sshCmd.stdin?.end(); + + sshCmd.on("close", (code) => { + resolve(code === 0); + }); + sshCmd.on("error", () => resolve(false)); + }); +}; + +export const removeRemoteCaddyRoute = ( + server: Server | { host: string; port?: number; sshUser?: string | null }, + filename: string +): Promise => { + return new Promise((resolve) => { + const user = server.sshUser || "root"; + const port = server.port || 22; + const sshCmd = spawn("ssh", [ + "-p", String(port), + "-o", "StrictHostKeyChecking=no", + `${user}@${server.host}`, + `rm -f /etc/caddy/routes/${filename} && (caddy reload --config /etc/caddy/Caddyfile || docker exec dequel-caddy caddy reload || true)` + ], { stdio: ["ignore", "pipe", "pipe"] }); + sshCmd.on("close", (code) => resolve(code === 0)); + sshCmd.on("error", () => resolve(false)); + }); +}; + +export const execRemoteCommand = ( + server: Server | { host: string; port?: number; sshUser?: string | null }, + command: string, + options: { env?: Record; onLog?: (line: string) => Promise | void; signal?: AbortSignal } = {} +): Promise<{ code: number; stdout: string; stderr: string }> => { + return new Promise((resolve, reject) => { + const user = server.sshUser || "root"; + const port = server.port || 22; + const child = spawn("ssh", [ + "-p", String(port), + "-o", "StrictHostKeyChecking=no", + "-o", "ConnectTimeout=30", + `${user}@${server.host}`, + command, + ], { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, ...options.env } }); + + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => { + const text = String(chunk); + stdout += text; + if (options.onLog) { + text.split("\n").filter(Boolean).forEach((line) => options.onLog!(line)); + } + }); + child.stderr?.on("data", (chunk) => { + const text = String(chunk); + stderr += text; + if (options.onLog) { + text.split("\n").filter(Boolean).forEach((line) => options.onLog!(line)); + } + }); + child.on("close", (code) => resolve({ code: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim() })); + child.on("error", (err) => reject(err)); + if (options.signal) { + options.signal.addEventListener("abort", () => { + child.kill("SIGTERM"); + reject(new Error("Remote SSH command aborted")); + }); + } + }); +}; + +export const runRemoteScript = ( + server: Server | { host: string; port?: number; sshUser?: string | null }, + script: string, + options: { onLog?: (line: string) => Promise | void; signal?: AbortSignal } = {} +): Promise<{ code: number; stdout: string; stderr: string }> => { + return new Promise((resolve, reject) => { + const user = server.sshUser || "root"; + const port = server.port || 22; + const child = spawn("ssh", [ + "-p", String(port), + "-o", "StrictHostKeyChecking=no", + "-o", "ConnectTimeout=30", + `${user}@${server.host}`, + "bash -s", + ], { stdio: ["pipe", "pipe", "pipe"] }); + + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => { + const text = String(chunk); + stdout += text; + if (options.onLog) { + text.split("\n").filter(Boolean).forEach((line) => options.onLog!(line)); + } + }); + child.stderr?.on("data", (chunk) => { + const text = String(chunk); + stderr += text; + if (options.onLog) { + text.split("\n").filter(Boolean).forEach((line) => options.onLog!(line)); + } + }); + child.on("close", (code) => resolve({ code: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim() })); + child.on("error", (err) => reject(err)); + if (options.signal) { + options.signal.addEventListener("abort", () => { + child.kill("SIGTERM"); + reject(new Error("Remote build script aborted")); + }); + } + child.stdin.write(script); + child.stdin.end(); + }); +}; diff --git a/apps/api/src/utils/wireguard.ts b/apps/api/src/utils/wireguard.ts new file mode 100644 index 0000000..2c5d1a2 --- /dev/null +++ b/apps/api/src/utils/wireguard.ts @@ -0,0 +1,68 @@ +import { spawn } from "node:child_process"; +import { generateKeyPairSync } from "node:crypto"; +import { config } from "./config"; + +export interface WireGuardPeerConfig { + peerIp: string; + privateKey: string; + serverPublicKey: string; + serverEndpoint: string; + allowedIps: string; +} + +export interface WireGuardKeyPair { + privateKey: string; + publicKey: string; +} + +const base64urlToBase64 = (value: string) => Buffer.from(value, "base64url").toString("base64"); + +export const generateWireGuardKeyPair = (): WireGuardKeyPair => { + const { privateKey, publicKey } = generateKeyPairSync("x25519"); + const privJwk = privateKey.export({ format: "jwk" }) as { d: string }; + const pubJwk = publicKey.export({ format: "jwk" }) as { x: string }; + return { + privateKey: base64urlToBase64(privJwk.d), + publicKey: base64urlToBase64(pubJwk.x), + }; +}; + +export const buildWireGuardPeerConfig = ( + peerIp: string, + privateKey: string, + serverPublicKey: string, + serverEndpoint: string, +): WireGuardPeerConfig | null => { + if (!serverPublicKey || !serverEndpoint) return null; + return { + peerIp, + privateKey, + serverPublicKey, + serverEndpoint, + allowedIps: `${config.wireguardPeerCidr}`, + }; +}; + +const execWgCommand = (args: string[]): Promise => { + if (!config.wireguardServerContainer) return Promise.resolve(false); + return new Promise((resolve) => { + const child = spawn("docker", ["exec", config.wireguardServerContainer!, "wg", ...args], { stdio: ["ignore", "pipe", "pipe"] }); + let stderr = ""; + child.stderr?.on("data", (chunk) => { stderr += String(chunk); }); + child.on("close", (code) => { + if (code !== 0) console.warn(`[WireGuard] wg ${args[0]} failed: ${stderr.trim()}`); + resolve(code === 0); + }); + child.on("error", () => resolve(false)); + }); +}; + +export const provisionWireGuardPeer = async (peerIp: string, publicKey: string): Promise => { + if (!config.wireguardServerPublicKey || !publicKey || !peerIp) return false; + return execWgCommand(["set", "wg0", "peer", publicKey, "allowed-ips", `${peerIp}/32`]); +}; + +export const removeWireGuardPeer = async (publicKey: string): Promise => { + if (!publicKey) return false; + return execWgCommand(["set", "wg0", "peer", publicKey, "remove"]); +}; \ No newline at end of file diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index dae8f98..aa8a8f9 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -438,7 +438,11 @@ export const createServer = (data: { name: string; host: string; port?: number; - authToken: string; + mode?: string; + sshUser?: string; + sshKey?: string; + sshPassword?: string; + authToken?: string; }) => apiFetch("/servers", { method: "POST", @@ -450,6 +454,14 @@ export const deleteServer = (id: string) => apiFetch<{ ok: boolean }>(`/servers/${id}`, { method: "DELETE", }); +export const createAgentRegistrationToken = (data: { + name: string; + labels?: Record; +}) => + apiFetch<{ token: string; expiresAt: string }>("/agents/registration-tokens", { + method: "POST", + body: JSON.stringify(data), + }); // API Keys export const listApiKeys = () => diff --git a/apps/web/src/components/project/create/CreateProjectDialog.tsx b/apps/web/src/components/project/create/CreateProjectDialog.tsx index c283c6b..43ca68c 100644 --- a/apps/web/src/components/project/create/CreateProjectDialog.tsx +++ b/apps/web/src/components/project/create/CreateProjectDialog.tsx @@ -1,4 +1,5 @@ import { useState, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useCreateProject } from "../../../hooks/useProjects"; import { Button } from "../../ui/button"; import { @@ -14,8 +15,9 @@ import { cn } from "../../../lib/utils"; import { Plus } from "lucide-react"; import * as api from "../../../api/client"; import { getGithubIntegration } from "../../../api/client"; -import type { GithubRepo, DatabaseType } from "../../../types"; +import type { GithubRepo, DatabaseType, Server as DequelServer } from "../../../types"; import type { FrameworkPreset } from "../../../utils/presets"; +import { getDeploymentTargets } from "./DeploymentTargetSection"; import { StepBasics } from "./StepBasics"; import { StepEnvironment } from "./StepEnvironment"; @@ -34,9 +36,20 @@ export function CreateProjectDialog({ const createProject = useCreateProject(); const [step, setStep] = useState(1); + const { data: servers = [] } = useQuery({ + queryKey: ["servers"], + queryFn: () => + api + .listServers() + .catch(() => [] as DequelServer[]), + staleTime: 30_000, + }); + const [name, setName] = useState(""); const [description, setDescription] = useState(""); + const [serverId, setServerId] = + useState("local"); const [baseDomain, setBaseDomain] = useState(""); const [repoUrl, setRepoUrl] = useState(""); @@ -160,6 +173,7 @@ export function CreateProjectDialog({ setOutputDir(""); setPort(""); setZipFile(null); + setServerId("local"); } }; @@ -186,6 +200,11 @@ export function CreateProjectDialog({ description: description.trim() || undefined, + serverId: getDeploymentTargets( + servers, + ).some((s) => s.id === serverId) + ? serverId + : "local", baseDomain: baseDomain.trim() || undefined, @@ -455,6 +474,9 @@ export function CreateProjectDialog({ setZipFile={setZipFile} selectedPresetId={selectedPresetId} onSelectPreset={handleSelectPreset} + serverId={serverId} + setServerId={setServerId} + servers={servers} /> )} diff --git a/apps/web/src/components/project/create/DeploymentTargetSection.tsx b/apps/web/src/components/project/create/DeploymentTargetSection.tsx new file mode 100644 index 0000000..3d196ca --- /dev/null +++ b/apps/web/src/components/project/create/DeploymentTargetSection.tsx @@ -0,0 +1,85 @@ +import { Laptop, Server } from 'lucide-react'; +import type { Server as DequelServer } from '../../../types'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select'; +import { cn } from '../../../lib/utils'; + +export function getDeploymentTargets(servers: DequelServer[]): DequelServer[] { + return servers.filter( + (s) => s.status === 'connected' && (s.mode === 'local' || s.mode === 'agent'), + ); +} + +interface DeploymentTargetSelectProps { + serverId: string; + setServerId: (v: string) => void; + servers: DequelServer[]; +} + +export function DeploymentTargetSelect({ serverId, setServerId, servers }: DeploymentTargetSelectProps) { + const targets = getDeploymentTargets(servers); + + return ( +
+ + {targets.length > 0 ? ( + + ) : ( +
+ No deployment servers available - project will be created on the local server. +
+ )} + + Remote agents currently support Git sources with Railpack web builds. + +
+ ); +} + +interface DeploymentTargetSectionProps extends DeploymentTargetSelectProps { + className?: string; +} + +export function DeploymentTargetSection({ serverId, setServerId, servers, className }: DeploymentTargetSectionProps) { + return ( +
+
+
+

+ + Deployment Target +

+

+ Choose which server runs this project's deployments. +

+
+
+ +
+ ); +} diff --git a/apps/web/src/components/project/create/StepBasics.tsx b/apps/web/src/components/project/create/StepBasics.tsx index 4491fd5..bd5b83f 100644 --- a/apps/web/src/components/project/create/StepBasics.tsx +++ b/apps/web/src/components/project/create/StepBasics.tsx @@ -1,6 +1,6 @@ import { Input } from "../../ui/input"; import { Box } from "lucide-react"; -import type { GithubRepo } from "../../../types"; +import type { GithubRepo, Server as DequelServer } from "../../../types"; import { StepBasicsGeneralSettings } from "./StepBasicsGeneralSettings"; import { StepBasicsSourceSection } from "./StepBasicsSourceSection"; import type { FrameworkPreset } from "../../../utils/presets"; @@ -32,6 +32,9 @@ interface StepBasicsProps { setZipFile: (v: File | null) => void; selectedPresetId: string; onSelectPreset: (preset: FrameworkPreset) => void; + serverId: string; + setServerId: (v: string) => void; + servers: DequelServer[]; } export function StepBasics({ @@ -61,6 +64,9 @@ export function StepBasics({ setZipFile, selectedPresetId, onSelectPreset, + serverId, + setServerId, + servers, }: StepBasicsProps) { return (
@@ -75,6 +81,9 @@ export function StepBasics({ setProjectType={setProjectType} selectedPresetId={selectedPresetId} onSelectPreset={onSelectPreset} + serverId={serverId} + setServerId={setServerId} + servers={servers} /> void; selectedPresetId: string; onSelectPreset: (preset: FrameworkPreset) => void; + serverId: string; + setServerId: (v: string) => void; + servers: DequelServer[]; } export function StepBasicsGeneralSettings({ @@ -29,6 +34,9 @@ export function StepBasicsGeneralSettings({ setProjectType, selectedPresetId, onSelectPreset, + serverId, + setServerId, + servers, }: StepBasicsGeneralSettingsProps) { const handleTypeChange = (type: string) => { const preset = FRAMEWORK_PRESETS.find((p) => p.id === selectedPresetId); @@ -92,6 +100,14 @@ export function StepBasicsGeneralSettings({ />
+
+ +
+