feat: SQLite-backed installations, REST API, admin auth and web UI
Replaces the slice-2 hardcoded installation with a full install/deploy pipeline: - SQLite persistence (better-sqlite3) with a single `installations` table and a draft -> cloning -> building -> built -> starting -> running/error/stopped state machine; env vars and bearer tokens are encrypted at rest (AES-256-GCM, key derived from MASTER_KEY). - Build/run orchestration: git clone (+ .env.example key detection), `nixpacks build` as a logged subprocess, dockerode container run/stop with forced host/port env vars and a TCP-connect health check before flipping an installation to "running". - REST API (installations CRUD, build/deploy/redeploy, logs, token regeneration) behind single-operator session-cookie auth with a basic login lockout. - Vite+React admin SPA: login, installation list, install wizard, and a detail page to configure env vars, trigger build/deploy/redeploy, view the build log, and copy the authenticated /mcp/<slug> URL. - Dockerfile (multi-stage, pulls in nixpacks + the Docker CLI) and docker-compose.yml for production deployment. Validated end-to-end against a real cloned repo: draft creation with env-key detection, build, the error path on a failed health check, and delete, all through the live API. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import net from "node:net";
|
||||
import Docker from "dockerode";
|
||||
import { config } from "../config/env.js";
|
||||
import type { Installation } from "../installations.js";
|
||||
import type { Installation } from "../db/installations.js";
|
||||
|
||||
const docker = new Docker({ socketPath: config.dockerSocketPath });
|
||||
|
||||
@@ -11,15 +12,19 @@ export async function ensureNetwork(name: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runInstallationContainer(installation: Installation): Promise<void> {
|
||||
await ensureNetwork(config.mcpNetworkName);
|
||||
|
||||
const existing = docker.getContainer(installation.containerName);
|
||||
export async function removeContainerIfExists(containerName: string): Promise<void> {
|
||||
try {
|
||||
await existing.remove({ force: true });
|
||||
await docker.getContainer(containerName).remove({ force: true });
|
||||
} catch {
|
||||
// container didn't exist yet, nothing to clean up
|
||||
// didn't exist, nothing to clean up
|
||||
}
|
||||
}
|
||||
|
||||
export async function runInstallationContainer(installation: Installation): Promise<string> {
|
||||
if (!installation.imageTag) throw new Error("installation has no built image yet");
|
||||
|
||||
await ensureNetwork(config.mcpNetworkName);
|
||||
await removeContainerIfExists(installation.containerName);
|
||||
|
||||
const env = {
|
||||
...installation.env,
|
||||
@@ -38,6 +43,11 @@ export async function runInstallationContainer(installation: Installation): Prom
|
||||
});
|
||||
|
||||
await container.start();
|
||||
return container.id;
|
||||
}
|
||||
|
||||
export async function stopInstallationContainer(containerName: string): Promise<void> {
|
||||
await removeContainerIfExists(containerName);
|
||||
}
|
||||
|
||||
export async function getContainerIp(containerName: string): Promise<string> {
|
||||
@@ -49,3 +59,35 @@ export async function getContainerIp(containerName: string): Promise<string> {
|
||||
}
|
||||
return network.IPAddress;
|
||||
}
|
||||
|
||||
export async function waitForContainerHealth(
|
||||
installation: Installation,
|
||||
timeoutMs = 20_000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: Error | undefined;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const ip = await getContainerIp(installation.containerName);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.createConnection({ host: ip, port: installation.internalPort, timeout: 1000 });
|
||||
socket.once("connect", () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.once("error", reject);
|
||||
socket.once("timeout", () => reject(new Error("connect timeout")));
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err as Error;
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`container did not start listening on ${installation.portEnvVar}=${installation.internalPort} within ${timeoutMs}ms` +
|
||||
(lastError ? ` (last error: ${lastError.message})` : ""),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user