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:
32
apps/server/src/db/index.ts
Normal file
32
apps/server/src/db/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import { config } from "../config/env.js";
|
||||
|
||||
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
|
||||
|
||||
export const db = new Database(config.dbPath);
|
||||
db.pragma("journal_mode = WAL");
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS installations (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
repo_url TEXT NOT NULL,
|
||||
git_ref TEXT,
|
||||
status TEXT NOT NULL,
|
||||
status_detail TEXT,
|
||||
image_tag TEXT,
|
||||
container_id TEXT,
|
||||
container_name TEXT NOT NULL,
|
||||
internal_port INTEGER NOT NULL DEFAULT 8080,
|
||||
http_path TEXT NOT NULL DEFAULT '/mcp',
|
||||
port_env_var TEXT NOT NULL DEFAULT 'PORT',
|
||||
host_env_var TEXT NOT NULL DEFAULT 'HOST',
|
||||
bearer_token_encrypted TEXT,
|
||||
env_json_encrypted TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
192
apps/server/src/db/installations.ts
Normal file
192
apps/server/src/db/installations.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import crypto from "node:crypto";
|
||||
import { db } from "./index.js";
|
||||
import { encryptSecret, decryptSecret } from "../crypto/secrets.js";
|
||||
|
||||
export type InstallationStatus =
|
||||
| "draft"
|
||||
| "cloning"
|
||||
| "building"
|
||||
| "built"
|
||||
| "starting"
|
||||
| "running"
|
||||
| "stopped"
|
||||
| "error";
|
||||
|
||||
export interface Installation {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
repoUrl: string;
|
||||
gitRef: string | null;
|
||||
status: InstallationStatus;
|
||||
statusDetail: string | null;
|
||||
imageTag: string | null;
|
||||
containerId: string | null;
|
||||
containerName: string;
|
||||
internalPort: number;
|
||||
httpPath: string;
|
||||
portEnvVar: string;
|
||||
hostEnvVar: string;
|
||||
env: Record<string, string>;
|
||||
bearerToken: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
repo_url: string;
|
||||
git_ref: string | null;
|
||||
status: InstallationStatus;
|
||||
status_detail: string | null;
|
||||
image_tag: string | null;
|
||||
container_id: string | null;
|
||||
container_name: string;
|
||||
internal_port: number;
|
||||
http_path: string;
|
||||
port_env_var: string;
|
||||
host_env_var: string;
|
||||
bearer_token_encrypted: string | null;
|
||||
env_json_encrypted: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function rowToInstallation(row: Row): Installation {
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
repoUrl: row.repo_url,
|
||||
gitRef: row.git_ref,
|
||||
status: row.status,
|
||||
statusDetail: row.status_detail,
|
||||
imageTag: row.image_tag,
|
||||
containerId: row.container_id,
|
||||
containerName: row.container_name,
|
||||
internalPort: row.internal_port,
|
||||
httpPath: row.http_path,
|
||||
portEnvVar: row.port_env_var,
|
||||
hostEnvVar: row.host_env_var,
|
||||
env: row.env_json_encrypted ? JSON.parse(decryptSecret(row.env_json_encrypted)) : {},
|
||||
bearerToken: row.bearer_token_encrypted ? decryptSecret(row.bearer_token_encrypted) : null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48);
|
||||
}
|
||||
|
||||
export function createInstallation(input: { name: string; slug: string; repoUrl: string; gitRef?: string }): Installation {
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
`INSERT INTO installations
|
||||
(id, slug, name, repo_url, git_ref, status, container_name, created_at, updated_at)
|
||||
VALUES (@id, @slug, @name, @repoUrl, @gitRef, 'draft', @containerName, @now, @now)`,
|
||||
).run({
|
||||
id,
|
||||
slug: input.slug,
|
||||
name: input.name,
|
||||
repoUrl: input.repoUrl,
|
||||
gitRef: input.gitRef ?? null,
|
||||
containerName: `mcp-${input.slug}`,
|
||||
now,
|
||||
});
|
||||
return getInstallationById(id)!;
|
||||
}
|
||||
|
||||
export function getInstallationById(id: string): Installation | undefined {
|
||||
const row = db.prepare("SELECT * FROM installations WHERE id = ?").get(id) as Row | undefined;
|
||||
return row ? rowToInstallation(row) : undefined;
|
||||
}
|
||||
|
||||
export function getInstallationBySlug(slug: string): Installation | undefined {
|
||||
const row = db.prepare("SELECT * FROM installations WHERE slug = ?").get(slug) as Row | undefined;
|
||||
return row ? rowToInstallation(row) : undefined;
|
||||
}
|
||||
|
||||
export function listInstallations(): Installation[] {
|
||||
const rows = db.prepare("SELECT * FROM installations ORDER BY created_at DESC").all() as Row[];
|
||||
return rows.map(rowToInstallation);
|
||||
}
|
||||
|
||||
export interface InstallationUpdate {
|
||||
status?: InstallationStatus;
|
||||
statusDetail?: string | null;
|
||||
imageTag?: string | null;
|
||||
containerId?: string | null;
|
||||
internalPort?: number;
|
||||
httpPath?: string;
|
||||
portEnvVar?: string;
|
||||
hostEnvVar?: string;
|
||||
env?: Record<string, string>;
|
||||
bearerToken?: string | null;
|
||||
gitRef?: string | null;
|
||||
}
|
||||
|
||||
export function updateInstallation(id: string, patch: InstallationUpdate): Installation {
|
||||
const current = getInstallationById(id);
|
||||
if (!current) throw new Error(`installation ${id} not found`);
|
||||
|
||||
const next = {
|
||||
status: patch.status ?? current.status,
|
||||
statusDetail: patch.statusDetail !== undefined ? patch.statusDetail : current.statusDetail,
|
||||
imageTag: patch.imageTag !== undefined ? patch.imageTag : current.imageTag,
|
||||
containerId: patch.containerId !== undefined ? patch.containerId : current.containerId,
|
||||
internalPort: patch.internalPort ?? current.internalPort,
|
||||
httpPath: patch.httpPath ?? current.httpPath,
|
||||
portEnvVar: patch.portEnvVar ?? current.portEnvVar,
|
||||
hostEnvVar: patch.hostEnvVar ?? current.hostEnvVar,
|
||||
env: patch.env ?? current.env,
|
||||
bearerToken: patch.bearerToken !== undefined ? patch.bearerToken : current.bearerToken,
|
||||
gitRef: patch.gitRef !== undefined ? patch.gitRef : current.gitRef,
|
||||
};
|
||||
|
||||
db.prepare(
|
||||
`UPDATE installations SET
|
||||
status = @status,
|
||||
status_detail = @statusDetail,
|
||||
image_tag = @imageTag,
|
||||
container_id = @containerId,
|
||||
internal_port = @internalPort,
|
||||
http_path = @httpPath,
|
||||
port_env_var = @portEnvVar,
|
||||
host_env_var = @hostEnvVar,
|
||||
env_json_encrypted = @envJsonEncrypted,
|
||||
bearer_token_encrypted = @bearerTokenEncrypted,
|
||||
git_ref = @gitRef,
|
||||
updated_at = @updatedAt
|
||||
WHERE id = @id`,
|
||||
).run({
|
||||
id,
|
||||
status: next.status,
|
||||
statusDetail: next.statusDetail,
|
||||
imageTag: next.imageTag,
|
||||
containerId: next.containerId,
|
||||
internalPort: next.internalPort,
|
||||
httpPath: next.httpPath,
|
||||
portEnvVar: next.portEnvVar,
|
||||
hostEnvVar: next.hostEnvVar,
|
||||
envJsonEncrypted: encryptSecret(JSON.stringify(next.env)),
|
||||
bearerTokenEncrypted: next.bearerToken ? encryptSecret(next.bearerToken) : null,
|
||||
gitRef: next.gitRef,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return getInstallationById(id)!;
|
||||
}
|
||||
|
||||
export function deleteInstallation(id: string): void {
|
||||
db.prepare("DELETE FROM installations WHERE id = ?").run(id);
|
||||
}
|
||||
Reference in New Issue
Block a user