Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 204 additions & 49 deletions apps/api/src/api/databases/index.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,218 @@
import { isIP } from "node:net";
import { Elysia } from "elysia";
import {
createDatabase as createDbRecord,
deleteDatabase,
getDatabaseById,
getProjectById,
listAllDatabases,
listDatabases,
updateDatabaseStatus,
} from "../../db/repo";
import { resolveServerIp } from "../../utils/dns";
import { validateDatabaseCreate } from "../../databases/validation";
import type { Database } from "../../types";

const sanitizeDatabase = <T extends { password: string; connectionString: string }>(database: T) => ({
...database,
password: "",
connectionString: "",
});

type DatabaseRecord = NonNullable<Awaited<ReturnType<typeof getDatabaseById>>>;

const dbOperationLock = new Map<string, Promise<void>>();

const withDatabaseLock = <T>(id: string, operation: () => Promise<T>): Promise<T> => {
const previous = dbOperationLock.get(id) ?? Promise.resolve();
let tail!: Promise<void>;
const result = previous.then(async () => {
try {
return await operation();
} finally {
if (dbOperationLock.get(id) === tail) dbOperationLock.delete(id);
}
});
tail = result.then(
() => undefined,
() => undefined,
);
dbOperationLock.set(id, tail);
return result;
};

const BUSY_STATUSES = new Set<Database["status"]>(["provisioning", "restarting", "deleting", "deletion_failed"]);

const busyError = (dbRecord: DatabaseRecord, set: any) => {
if (BUSY_STATUSES.has(dbRecord.status)) {
set.status = 409;
return { error: `Database is ${dbRecord.status}; wait for the current operation to finish` };
}
return null;
};

const isNonLoopbackIp = (value: string): boolean => {
if (isIP(value) !== 4) return false;
return !value.startsWith("127.");
};

const createManagedDatabase = async (body: any, projectId: string | null, set: any) => {
if (projectId && !(await getProjectById(projectId))) {
set.status = 404;
return { error: "Project not found" };
}
const validation = validateDatabaseCreate(body ?? {});
if (!validation.ok) {
set.status = 400;
return { error: validation.error };
}
const existing = await listAllDatabases();
if (existing.some((database) => database.name.toLowerCase() === validation.input.name.toLowerCase())) {
set.status = 409;
return { error: "A database with this name already exists" };
}
const dbRecord = await createDbRecord({ ...validation.input, projectId });
const { provisionDatabase } = await import("../../databases/manager");
provisionDatabase(dbRecord).catch((err: Error) =>
console.error("DB provision failed", err),
);
return sanitizeDatabase(dbRecord);
};

const findDatabase = async (id: string, set: any) => {
const dbRecord = await getDatabaseById(id);
if (!dbRecord) {
set.status = 404;
return null;
}
return dbRecord;
};

export const databasesRoutes = new Elysia()
.get(
"/projects/:id/databases",
async ({ params }) => listDatabases(params.id),
.get("/databases", async () => (await listAllDatabases()).map(sanitizeDatabase))
.post("/databases", async ({ body, set }: any) =>
createManagedDatabase(body, body?.projectId || null, set),
)
.get("/projects/:id/databases", async ({ params }) => (await listDatabases(params.id)).map(sanitizeDatabase))
.post("/projects/:id/databases", async ({ params, body, set }: any) =>
createManagedDatabase(body, params.id, set),
)
.post(
"/projects/:id/databases",
async ({ params, body, set }: any) => {
if (!body?.type || !["postgresql", "mysql"].includes(body.type)) {
set.status = 400;
return { error: "type must be postgresql or mysql" };
.get("/databases/:id", async ({ params: { id }, set }) => {
const dbRecord = await findDatabase(id, set);
if (!dbRecord) return { error: "Database not found" };
const { measureDatabaseStorage } = await import("../../databases/manager");
const staleStorage = Date.now() - new Date(dbRecord.updatedAt).getTime() > 5 * 60_000;
if (dbRecord.status !== "provisioning" && staleStorage) {
await measureDatabaseStorage(dbRecord).catch(() => dbRecord.storageUsedMb);
}
return sanitizeDatabase((await getDatabaseById(id))!);
})
.get("/databases/:id/credentials", async ({ params: { id }, set }) => {
const dbRecord = await findDatabase(id, set);
if (!dbRecord) return { error: "Database not found" };
const externalHost = dbRecord.publicAccess && dbRecord.externalPort
? await resolveServerIp()
: null;
const usableHost = externalHost && isNonLoopbackIp(externalHost) ? externalHost : null;
const externalConnectionString = usableHost
? buildConnectionString(dbRecord, usableHost, dbRecord.externalPort!)
: null;
return {
username: dbRecord.username,
password: dbRecord.password,
internalConnectionString: dbRecord.connectionString,
externalConnectionString,
externalHost: usableHost,
externalPort: usableHost ? dbRecord.externalPort : null,
};
})
.post("/databases/:id/start", async ({ params: { id }, set }) => {
const dbRecord = await findDatabase(id, set);
if (!dbRecord) return { error: "Database not found" };
return withDatabaseLock(id, async () => {
const latest = (await getDatabaseById(id))!;
const blocked = busyError(latest, set);
if (blocked) return blocked;
const { startDatabase } = await import("../../databases/manager");
await startDatabase(latest);
return sanitizeDatabase((await getDatabaseById(id))!);
});
})
.post("/databases/:id/stop", async ({ params: { id }, set }) => {
const dbRecord = await findDatabase(id, set);
if (!dbRecord) return { error: "Database not found" };
return withDatabaseLock(id, async () => {
const latest = (await getDatabaseById(id))!;
const blocked = busyError(latest, set);
if (blocked) return blocked;
const { stopDatabase } = await import("../../databases/manager");
await stopDatabase(latest);
return sanitizeDatabase((await getDatabaseById(id))!);
});
})
.post("/databases/:id/restart", async ({ params: { id }, set }) => {
const dbRecord = await findDatabase(id, set);
if (!dbRecord) return { error: "Database not found" };
return withDatabaseLock(id, async () => {
const latest = (await getDatabaseById(id))!;
const blocked = busyError(latest, set);
if (blocked) return blocked;
const { restartDatabase } = await import("../../databases/manager");
await restartDatabase(latest);
return sanitizeDatabase((await getDatabaseById(id))!);
});
})
.post("/databases/:id/retry", async ({ params: { id }, set }) => {
const dbRecord = await findDatabase(id, set);
if (!dbRecord) return { error: "Database not found" };
return withDatabaseLock(id, async () => {
const latest = (await getDatabaseById(id))!;
if (latest.status !== "failed") {
set.status = 409;
return { error: "Only failed databases can be retried" };
}
const dbRecord = await createDbRecord({
projectId: params.id,
type: body.type,
version: body.version,
cpuLimit: body.cpuLimit,
memoryLimitMb: body.memoryLimitMb,
});
const { provisionDatabase } = await import(
"../../databases/manager",
await updateDatabaseStatus(id, "provisioning");
const { provisionDatabase } = await import("../../databases/manager");
provisionDatabase((await getDatabaseById(id))!).catch((err: Error) =>
console.error("DB reprovision failed", err),
);
provisionDatabase(dbRecord).catch((err: Error) =>
console.error("DB provision failed", err),
);
return dbRecord;
},
)
.get(
"/databases/:id",
async ({ params: { id }, set }) => {
const dbRecord = await getDatabaseById(id);
if (!dbRecord) {
set.status = 404;
return { error: "Database not found" };
return sanitizeDatabase((await getDatabaseById(id))!);
});
})
.delete("/databases/:id", async ({ params: { id }, set }) => {
const dbRecord = await findDatabase(id, set);
if (!dbRecord) return { error: "Database not found" };
return withDatabaseLock(id, async () => {
const latest = (await getDatabaseById(id))!;
if (latest.status === "deleting") {
set.status = 409;
return { error: "Database is already being deleted" };
}
return dbRecord;
},
)
.delete(
"/databases/:id",
async ({ params: { id }, set }) => {
const dbRecord = await getDatabaseById(id);
if (!dbRecord) {
set.status = 404;
return { error: "Database not found" };
const { deprovisionDatabase, waitForProvision } = await import("../../databases/manager");
await waitForProvision(id);
await updateDatabaseStatus(id, "deleting");
const current = (await getDatabaseById(id)) ?? latest;
try {
await deprovisionDatabase(current);
} catch (error) {
console.error(`Failed to deprovision database ${id}:`, error);
await updateDatabaseStatus(id, "deletion_failed");
set.status = 502;
return { error: "Database resources could not be deleted; cleanup will be retried automatically" };
}
const { deprovisionDatabase } = await import(
"../../databases/manager",
);
await deprovisionDatabase(dbRecord).catch(() => {});
const ok = await deleteDatabase(id);
return { ok };
},
);
return { ok: await deleteDatabase(id) };
});
});

const buildConnectionString = (
dbRecord: DatabaseRecord,
host: string,
port: number,
) => {
if (dbRecord.type === "redis") return `redis://:${dbRecord.password}@${host}:${port}`;
if (dbRecord.type === "mongodb") {
return `mongodb://${dbRecord.username}:${dbRecord.password}@${host}:${port}/${dbRecord.databaseName}?authSource=admin`;
}
const protocol = dbRecord.type === "mysql" ? "mysql" : "postgresql";
return `${protocol}://${dbRecord.username}:${dbRecord.password}@${host}:${port}/${dbRecord.databaseName}`;
};
6 changes: 5 additions & 1 deletion apps/api/src/api/projects/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export const projectsRoutes = new Elysia()
composePort: body.composePort ? Number(body.composePort) : null,
composeServices: body.composeServices ? (typeof body.composeServices === 'string' ? body.composeServices : JSON.stringify(body.composeServices)) : null,
buildCommand: body.buildCommand || undefined,
installCommand: body.installCommand || undefined,
outputDir: body.outputDir || undefined,
startCommand: body.startCommand || undefined,
});
return project;
Expand All @@ -96,13 +98,15 @@ export const projectsRoutes = new Elysia()
cpuLimit: body?.cpuLimit,
memoryLimitMb: body?.memoryLimitMb,
port: body?.port ? Number(body.port) : body?.port === null ? null : undefined,
sourceDir: body?.sourceDir ?? undefined,
sourceDir: "sourceDir" in (body ?? {}) ? (body.sourceDir || null) : undefined,
projectType: body?.projectType ?? undefined,
buildType: body?.buildType ?? undefined,
composeService: "composeService" in (body ?? {}) ? (body.composeService || null) : undefined,
composePort: "composePort" in (body ?? {}) ? (body.composePort ? Number(body.composePort) : null) : undefined,
composeServices: "composeServices" in (body ?? {}) ? (body.composeServices ? (typeof body.composeServices === 'string' ? body.composeServices : JSON.stringify(body.composeServices)) : null) : undefined,
buildCommand: "buildCommand" in (body ?? {}) ? (body.buildCommand ?? "") || null : undefined,
installCommand: "installCommand" in (body ?? {}) ? (body.installCommand ?? "") || null : undefined,
outputDir: "outputDir" in (body ?? {}) ? (body.outputDir ?? "") || null : undefined,
startCommand: "startCommand" in (body ?? {}) ? (body.startCommand ?? "") || null : undefined,
});
if (!project) {
Expand Down
81 changes: 81 additions & 0 deletions apps/api/src/databases/__tests__/manager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, it } from "bun:test";
import { proxyConfig, publicProxyName, resolveDbImage } from "../manager";
import type { Database } from "../../types";

const baseRecord = (overrides: Partial<Database> = {}): Database => ({
id: "db-1234",
projectId: null,
name: "orders",
type: "postgresql",
version: "18",
databaseName: "db_1234",
username: "user_1234",
password: "secret",
internalHost: "db-1234",
internalPort: 5432,
cpuLimit: null,
memoryLimitMb: null,
storageLimitMb: 1024,
storageUsedMb: 0,
publicAccess: true,
allowPublicAccessFromAnywhere: false,
allowedCidrs: ["203.0.113.4/32"],
externalPort: null,
proxyContainerName: null,
volumeName: "db-1234",
connectionString: "postgresql://user_1234:secret@db-1234:5432/db_1234",
status: "provisioning",
containerName: null,
createdAt: "",
updatedAt: "",
...overrides,
});

describe("database manager helpers", () => {
it("resolves postgres images with alpine suffix", () => {
expect(resolveDbImage("postgresql", "16")).toBe("postgres:16-alpine");
expect(resolveDbImage("postgresql", "16-alpine")).toBe("postgres:16-alpine");
expect(resolveDbImage("postgresql", "latest")).toBe("postgres:latest");
});

it("applies default versions when none are provided", () => {
expect(resolveDbImage("postgresql")).toBe("postgres:18-alpine");
expect(resolveDbImage("mysql")).toBe("mysql:8.4");
expect(resolveDbImage("redis")).toBe("redis:8.0-alpine");
expect(resolveDbImage("mongodb")).toBe("mongo:8.0");
});

it("rejects unsupported database types", () => {
expect(() => resolveDbImage("sqlite")).toThrow("Unsupported database type: sqlite");
});

it("resolves mysql and redis images", () => {
expect(resolveDbImage("mysql", "8.4")).toBe("mysql:8.4");
expect(resolveDbImage("redis", "8.0")).toBe("redis:8.0-alpine");
expect(resolveDbImage("mongodb")).toBe("mongo:8.0");
});

it("derives a deterministic proxy container name from the database host", () => {
expect(publicProxyName(baseRecord())).toBe("db-1234-public");
});

it("embeds an allowlist ACL when not allowing access from anywhere", () => {
const config = proxyConfig(baseRecord(), 30123);
expect(config).toContain("bind 0.0.0.0:30123");
expect(config).toContain("acl allowed src 203.0.113.4/32");
expect(config).toContain("tcp-request connection reject if !allowed");
expect(config).toContain("server database db-1234:5432 check");
});

it("omits the ACL when allowing access from anywhere", () => {
const config = proxyConfig(baseRecord({ allowPublicAccessFromAnywhere: true }), 30123);
expect(config).not.toContain("acl allowed");
expect(config).not.toContain("tcp-request connection reject");
});

it("emits a reject ACL when the allowlist is empty", () => {
const config = proxyConfig(baseRecord({ allowedCidrs: [] }), 30123);
expect(config).toContain("acl allowed src ");
expect(config).toContain("tcp-request connection reject if !allowed");
});
});
Loading
Loading