diff --git a/apps/api/src/api/databases/index.ts b/apps/api/src/api/databases/index.ts index df9746e..10968c6 100644 --- a/apps/api/src/api/databases/index.ts +++ b/apps/api/src/api/databases/index.ts @@ -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 = (database: T) => ({ + ...database, + password: "", + connectionString: "", +}); + +type DatabaseRecord = NonNullable>>; + +const dbOperationLock = new Map>(); + +const withDatabaseLock = (id: string, operation: () => Promise): Promise => { + const previous = dbOperationLock.get(id) ?? Promise.resolve(); + let tail!: Promise; + 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(["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}`; +}; diff --git a/apps/api/src/api/projects/index.ts b/apps/api/src/api/projects/index.ts index a0cf7b9..e4d19a9 100644 --- a/apps/api/src/api/projects/index.ts +++ b/apps/api/src/api/projects/index.ts @@ -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; @@ -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) { diff --git a/apps/api/src/databases/__tests__/manager.test.ts b/apps/api/src/databases/__tests__/manager.test.ts new file mode 100644 index 0000000..b0537cc --- /dev/null +++ b/apps/api/src/databases/__tests__/manager.test.ts @@ -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 => ({ + 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"); + }); +}); diff --git a/apps/api/src/databases/__tests__/validation.test.ts b/apps/api/src/databases/__tests__/validation.test.ts new file mode 100644 index 0000000..01a992b --- /dev/null +++ b/apps/api/src/databases/__tests__/validation.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "bun:test"; +import { isValidCidr, validateDatabaseCreate } from "../validation"; +describe("database input validation", () => { + it("accepts public access with an IPv4 allowlist", () => { + const result = validateDatabaseCreate({ + name: "orders", + type: "postgresql", + allowedCidrs: ["203.0.113.4/32"], + }); + expect(result.ok).toBe(true); + }); + + it("rejects public access without an allowlist or explicit allow-anywhere", () => { + const result = validateDatabaseCreate({ name: "orders", type: "postgresql" }); + expect(result.ok).toBe(false); + }); + + it("allows private-only databases without an allowlist", () => { + const result = validateDatabaseCreate({ + name: "orders", + type: "postgresql", + publicAccess: false, + }); + expect(result.ok).toBe(true); + }); + + it("validates IPv4 CIDRs and rejects IPv6 ranges", () => { + expect(isValidCidr("10.0.0.0/8")).toBe(true); + expect(isValidCidr("203.0.113.4")).toBe(true); + expect(isValidCidr("2001:db8::/32")).toBe(false); + expect(isValidCidr("10.0.0.1/33")).toBe(false); + expect(isValidCidr("not-an-ip/24")).toBe(false); + }); + + it("rejects more than 20 allowed CIDRs", () => { + const result = validateDatabaseCreate({ + name: "orders", + type: "postgresql", + allowedCidrs: Array.from({ length: 21 }, (_, index) => `10.0.${index}.0/24`), + }); + expect(result.ok).toBe(false); + }); + + it("rejects unsafe image versions", () => { + const result = validateDatabaseCreate({ + name: "orders", + type: "postgresql", + version: "latest;evil", + allowPublicAccessFromAnywhere: true, + }); + expect(result.ok).toBe(false); + }); + + it("rejects non-string names without crashing", () => { + const result = validateDatabaseCreate({ + name: { evil: true }, + type: "postgresql", + allowPublicAccessFromAnywhere: true, + } as never); + expect(result.ok).toBe(false); + }); + + it("rejects numeric versions without crashing", () => { + const result = validateDatabaseCreate({ + name: "orders", + type: "postgresql", + version: 18, + allowPublicAccessFromAnywhere: true, + } as never); + expect(result.ok).toBe(false); + }); + + it("rejects numeric names without crashing", () => { + const result = validateDatabaseCreate({ + name: 42, + type: "postgresql", + allowPublicAccessFromAnywhere: true, + } as never); + expect(result.ok).toBe(false); + }); +}); diff --git a/apps/api/src/databases/manager.ts b/apps/api/src/databases/manager.ts index d05bc29..13a811e 100644 --- a/apps/api/src/databases/manager.ts +++ b/apps/api/src/databases/manager.ts @@ -1,9 +1,17 @@ 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 { Database } from '../types'; -import { updateDatabaseStatus } from '../db/repo'; +import { DEQUEL_DATABASE_LABEL } from '../utils/dequel-labels'; +import type { Database, DatabaseType } from '../types'; +import { + deleteDatabase, + getDatabaseById, + listAllDatabases, + updateDatabaseRuntime, + updateDatabaseStatus, +} from '../db/repo'; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const run = (cmd: string, args: string[]) => new Promise((resolve, reject) => { @@ -21,70 +29,348 @@ const run = (cmd: string, args: string[]) => const tryRun = (cmd: string, args: string[]) => run(cmd, args).catch(() => undefined); -export const provisionDatabase = async (dbRecord: Database): Promise => { - const containerName = dbRecord.internalHost; +const isMissingResource = (error: unknown) => + /No such (?:container|volume)|No such object/.test(error instanceof Error ? error.message : String(error)); + +export const ensureContainerRemoved = async (name: string): Promise => { + try { + await run(dockerBin, ['rm', '-f', name]); + } catch (error) { + if (!isMissingResource(error)) throw error; + } +}; +export const ensureVolumeRemoved = async (name: string): Promise => { try { - const version = dbRecord.version || (dbRecord.type === 'mysql' ? '8.0' : '16-alpine'); - const image = dbRecord.type === 'mysql' ? `mysql:${version}` : `postgres:${version}`; + await run(dockerBin, ['volume', 'rm', '-f', name]); + } catch (error) { + if (!isMissingResource(error)) throw error; + } +}; + +export const resolveDbImage = (type: string, versionInput?: string | null): string => { + if (type === 'postgresql') { + const ver = versionInput || '18'; + const tag = ver.includes('alpine') || ver.includes('debian') || ver === 'latest' ? ver : `${ver}-alpine`; + return `postgres:${tag}`; + } + if (type === 'mysql') { + const ver = versionInput || '8.4'; + return `mysql:${ver}`; + } + if (type === 'redis') { + const ver = versionInput || '8.0'; + const tag = ver.includes('alpine') || ver === 'latest' ? ver : `${ver}-alpine`; + return `redis:${tag}`; + } + if (type === 'mongodb') { + const ver = versionInput || '8.0'; + return `mongo:${ver}`; + } + throw new Error(`Unsupported database type: ${type}`); +}; - await tryRun(dockerBin, ['pull', image]); +type EngineRuntimeConfig = { volumeTarget: string; envVars: string[]; extraCmdArgs: string[] }; - const envVars = dbRecord.type === 'mysql' - ? [ - `MYSQL_ROOT_PASSWORD=${dbRecord.password}`, - `MYSQL_DATABASE=${dbRecord.databaseName}`, - `MYSQL_USER=${dbRecord.username}`, - `MYSQL_PASSWORD=${dbRecord.password}`, - ] - : [ - `POSTGRES_USER=${dbRecord.username}`, - `POSTGRES_PASSWORD=${dbRecord.password}`, - `POSTGRES_DB=${dbRecord.databaseName}`, - ]; +const ENGINE_RUNTIME_CONFIGS: Record EngineRuntimeConfig> = { + postgresql: (dbRecord) => { + const major = Number((dbRecord.version ?? '18').match(/^\d+/)?.[0]); + if (!Number.isInteger(major)) { + throw new Error(`PostgreSQL version ${dbRecord.version} must be a numeric major version`); + } + return { + volumeTarget: major >= 18 ? '/var/lib/postgresql' : '/var/lib/postgresql/data', + envVars: [ + `POSTGRES_USER=${dbRecord.username}`, + `POSTGRES_PASSWORD=${dbRecord.password}`, + `POSTGRES_DB=${dbRecord.databaseName}`, + ], + extraCmdArgs: [], + }; + }, + mysql: (dbRecord) => ({ + volumeTarget: '/var/lib/mysql', + envVars: [ + `MYSQL_ROOT_PASSWORD=${dbRecord.password}`, + `MYSQL_DATABASE=${dbRecord.databaseName}`, + `MYSQL_USER=${dbRecord.username}`, + `MYSQL_PASSWORD=${dbRecord.password}`, + ], + extraCmdArgs: [], + }), + redis: (dbRecord) => ({ + volumeTarget: '/data', + envVars: [], + extraCmdArgs: ['redis-server', '--requirepass', dbRecord.password], + }), + mongodb: (dbRecord) => ({ + volumeTarget: '/data/db', + envVars: [ + `MONGO_INITDB_ROOT_USERNAME=${dbRecord.username}`, + `MONGO_INITDB_ROOT_PASSWORD=${dbRecord.password}`, + `MONGO_INITDB_DATABASE=${dbRecord.databaseName}`, + ], + extraCmdArgs: [], + }), +}; + +const resolveEngineConfig = (dbRecord: Database): EngineRuntimeConfig => { + const resolver = ENGINE_RUNTIME_CONFIGS[dbRecord.type]; + if (!resolver) throw new Error(`Unsupported database type: ${dbRecord.type}`); + return resolver(dbRecord); +}; + +const provisionInFlight = new Map>(); + +export const provisionDatabase = async (dbRecord: Database): Promise => { + const task = runProvision(dbRecord).finally(() => provisionInFlight.delete(dbRecord.id)); + provisionInFlight.set(dbRecord.id, task); + await task; +}; + +export const waitForProvision = (id: string): Promise => + provisionInFlight.get(id) ?? Promise.resolve(); + +const runProvision = async (dbRecord: Database): Promise => { + const containerName = dbRecord.internalHost; + let createdVolume = false; + + const stillProvisioning = async () => { + const current = await getDatabaseById(dbRecord.id); + return current !== null && current.status === 'provisioning'; + }; + + const abortIfDeleted = async () => { + if (!(await stillProvisioning())) { + if (createdVolume) await ensureVolumeRemoved(dbRecord.volumeName).catch(() => {}); + await ensureContainerRemoved(containerName).catch(() => {}); + await ensureContainerRemoved(publicProxyName(dbRecord)).catch(() => {}); + return true; + } + return false; + }; - const volumeName = `db-${dbRecord.id.slice(0, 12)}`; + try { + const image = resolveDbImage(dbRecord.type, dbRecord.version); + const { volumeTarget, envVars, extraCmdArgs } = resolveEngineConfig(dbRecord); + await run(dockerBin, ['pull', image]); + if (await abortIfDeleted()) return; - await tryRun(dockerBin, ['volume', 'create', volumeName]); - await tryRun(dockerBin, ['rm', '-f', containerName]); + await run(dockerBin, ['volume', 'create', dbRecord.volumeName]); + createdVolume = true; + await ensureContainerRemoved(containerName); + if (await abortIfDeleted()) return; const args = [ 'run', '-d', '--name', containerName, '--network', config.dockerNetwork, '--network-alias', containerName, - '-l', DEQUEL_MANAGED_LABEL, + '--restart', 'unless-stopped', + '-l', DEQUEL_DATABASE_LABEL, ...(dbRecord.cpuLimit ? ['--cpus', String(dbRecord.cpuLimit)] : []), ...(dbRecord.memoryLimitMb ? ['--memory', `${Math.round(dbRecord.memoryLimitMb)}m`] : []), - '-v', `${volumeName}:/var/lib/${dbRecord.type === 'mysql' ? 'mysql' : 'postgresql/data'}`, - '-e', `TZ=UTC`, + '-v', `${dbRecord.volumeName}:${volumeTarget}`, + '-e', 'TZ=UTC', ...envVars.flatMap(e => ['-e', e]), image, + ...extraCmdArgs, ]; await run(dockerBin, args); + let externalPort: number | null = null; + let proxyContainerName: string | null = null; + if (dbRecord.publicAccess) { + const proxy = await provisionPublicProxy(dbRecord); + externalPort = proxy.externalPort; + proxyContainerName = proxy.containerName; + } + + if (await abortIfDeleted()) return; + for (let i = 0; i < 30; i++) { try { const status = await run(dockerBin, ['inspect', '-f', '{{.State.Status}}', containerName]); if (status.trim() === 'running') { + await updateDatabaseRuntime(dbRecord.id, { externalPort, proxyContainerName }); await updateDatabaseStatus(dbRecord.id, 'running', containerName); return; } } catch {} - await new Promise(r => setTimeout(r, 2000)); + await sleep(2000); } + if (proxyContainerName) await ensureContainerRemoved(proxyContainerName).catch(() => {}); await updateDatabaseStatus(dbRecord.id, 'failed', containerName); throw new Error(`Database ${containerName} failed to become ready within 60 seconds`); } catch (err) { console.error(`[DB Provisioner] Error provisioning database ${dbRecord.id}:`, err); + await deprovisionDatabase({ ...dbRecord, proxyContainerName: publicProxyName(dbRecord) }).catch(() => {}); await updateDatabaseStatus(dbRecord.id, 'failed', containerName).catch(() => {}); throw err; } }; export const deprovisionDatabase = async (dbRecord: Database): Promise => { - await tryRun(dockerBin, ['stop', '-t', '5', dbRecord.internalHost]); - await tryRun(dockerBin, ['rm', '-f', dbRecord.internalHost]); + await ensureContainerRemoved(dbRecord.proxyContainerName ?? publicProxyName(dbRecord)); + await ensureContainerRemoved(dbRecord.internalHost); + await ensureVolumeRemoved(dbRecord.volumeName); +}; + +export const publicProxyName = (dbRecord: Database): string => + `${dbRecord.internalHost}-public`; + +export const proxyConfig = (dbRecord: Database, port: number): string => { + const rules = dbRecord.allowPublicAccessFromAnywhere + ? '' + : ` acl allowed src ${dbRecord.allowedCidrs.join(' ')}\n tcp-request connection reject if !allowed\n`; + return [ + 'global', + 'defaults', + ' mode tcp', + ' timeout connect 5s', + ' timeout client 1h', + ' timeout server 1h', + 'frontend database', + ` bind 0.0.0.0:${port}`, + rules, + ' default_backend database', + 'backend database', + ` server database ${dbRecord.internalHost}:${dbRecord.internalPort} check`, + ].join('\n') + '\n'; +}; + +const PUBLIC_PORT_MIN = 20000; +const PUBLIC_PORT_MAX = 40000; + +const provisionPublicProxy = async (dbRecord: Database): Promise<{ containerName: string; externalPort: number }> => { + const containerName = publicProxyName(dbRecord); + await run(dockerBin, ['pull', 'haproxy:3.0-alpine']); + for (let attempt = 0; attempt < 20; attempt++) { + const externalPort = PUBLIC_PORT_MIN + Math.floor(Math.random() * (PUBLIC_PORT_MAX - PUBLIC_PORT_MIN)); + const configText = proxyConfig(dbRecord, externalPort); + await ensureContainerRemoved(containerName); + try { + await run(dockerBin, [ + 'run', '-d', + '--name', containerName, + '--network', config.dockerNetwork, + '-p', `${externalPort}:${externalPort}`, + '--restart', 'unless-stopped', + '-l', DEQUEL_DATABASE_LABEL, + '-e', `DEQUEL_HAPROXY_CONFIG=${configText}`, + 'haproxy:3.0-alpine', + 'sh', '-c', 'printf "%s" "$DEQUEL_HAPROXY_CONFIG" > /tmp/haproxy.cfg && exec haproxy -f /tmp/haproxy.cfg', + ]); + } catch { + await ensureContainerRemoved(containerName).catch(() => {}); + continue; + } + await sleep(1500); + try { + const status = await run(dockerBin, ['inspect', '-f', '{{.State.Status}}', containerName]); + if (status.trim() === 'running') return { containerName, externalPort }; + } catch {} + await ensureContainerRemoved(containerName); + } + throw new Error('Could not allocate a free port for public database access'); +}; + +export const stopDatabase = async (dbRecord: Database): Promise => { + if (dbRecord.proxyContainerName) await tryRun(dockerBin, ['stop', '-t', '5', dbRecord.proxyContainerName]); + await run(dockerBin, ['stop', '-t', '10', dbRecord.internalHost]); + await updateDatabaseStatus(dbRecord.id, 'stopped'); +}; + +export const startDatabase = async (dbRecord: Database): Promise => { + await run(dockerBin, ['start', dbRecord.internalHost]); + if (dbRecord.proxyContainerName) await run(dockerBin, ['start', dbRecord.proxyContainerName]); + await updateDatabaseStatus(dbRecord.id, 'running'); +}; + +export const restartDatabase = async (dbRecord: Database): Promise => { + await updateDatabaseStatus(dbRecord.id, 'restarting'); + await run(dockerBin, ['restart', dbRecord.internalHost]); + if (dbRecord.proxyContainerName) await run(dockerBin, ['restart', dbRecord.proxyContainerName]); + await updateDatabaseStatus(dbRecord.id, 'running'); +}; + +export const measureDatabaseStorage = async (dbRecord: Database): Promise => { + const output = await run(dockerBin, [ + 'run', '--rm', + '-v', `${dbRecord.volumeName}:/data:ro`, + 'alpine:3.20', + 'du', '-sm', '/data', + ]); + const usedMb = Number.parseInt(output, 10) || 0; + await updateDatabaseRuntime(dbRecord.id, { storageUsedMb: usedMb }); + return usedMb; +}; + +const STORAGE_MEASURE_INTERVAL_MS = 10 * 60_000; +const lastStorageMeasure = new Map(); + +const reconcileMissingContainer = async (dbRecord: Database) => { + if (dbRecord.status === 'running' || dbRecord.status === 'restarting') { + await updateDatabaseStatus(dbRecord.id, 'failed'); + } +}; + +export const startDatabaseMonitoring = () => { + let checkInFlight = false; + const check = async () => { + if (checkInFlight) return; + checkInFlight = true; + try { + for (const dbRecord of await listAllDatabases()) { + if (dbRecord.status === 'deleting' || dbRecord.status === 'deletion_failed') { + try { + await deprovisionDatabase(dbRecord); + await deleteDatabase(dbRecord.id); + } catch (error) { + console.warn(`[Database] Cleanup retry failed for ${dbRecord.id}:`, error); + } + continue; + } + try { + const inspect = await run(dockerBin, ['inspect', '-f', '{{.State.Status}}', dbRecord.internalHost]); + const running = inspect.trim() === 'running'; + if (dbRecord.status === 'running' && !running) { + await updateDatabaseStatus(dbRecord.id, 'stopped'); + } else if (dbRecord.status === 'stopped' && running) { + await updateDatabaseStatus(dbRecord.id, 'running'); + } else if (dbRecord.status === 'restarting') { + const inspectStatus = inspect.trim(); + if (inspectStatus === 'restarting') continue; + await updateDatabaseStatus(dbRecord.id, inspectStatus === 'running' ? 'running' : 'failed'); + } else if (dbRecord.status === 'provisioning' && !provisionInFlight.has(dbRecord.id)) { + await updateDatabaseStatus(dbRecord.id, running ? 'running' : 'failed'); + } + if (running) { + const lastMeasured = lastStorageMeasure.get(dbRecord.id) ?? 0; + if (Date.now() - lastMeasured >= STORAGE_MEASURE_INTERVAL_MS) { + lastStorageMeasure.set(dbRecord.id, Date.now()); + const usedMb = await measureDatabaseStorage(dbRecord); + if (dbRecord.storageLimitMb && usedMb >= dbRecord.storageLimitMb) { + console.warn(`[Database] ${dbRecord.name} storage limit reached (${usedMb}/${dbRecord.storageLimitMb} MB)`); + } else if (dbRecord.storageLimitMb && usedMb >= dbRecord.storageLimitMb * 0.8) { + console.warn(`[Database] ${dbRecord.name} storage usage above 80% (${usedMb}/${dbRecord.storageLimitMb} MB)`); + } + } + } + } catch (error) { + if (isMissingResource(error)) { + await reconcileMissingContainer(dbRecord); + } else { + console.warn(`[Database] Monitoring skipped for ${dbRecord.id}:`, error); + } + } + } + } finally { + checkInFlight = false; + } + }; + void check(); + return setInterval(() => void check(), 60_000); }; diff --git a/apps/api/src/databases/validation.ts b/apps/api/src/databases/validation.ts new file mode 100644 index 0000000..323a995 --- /dev/null +++ b/apps/api/src/databases/validation.ts @@ -0,0 +1,78 @@ +import { isIP } from "node:net"; +import type { CreateDatabaseInput, DatabaseType } from "../types"; + +const DATABASE_TYPES: DatabaseType[] = ["postgresql", "mysql", "redis", "mongodb"]; +const MAX_ALLOWED_CIDRS = 20; +const MAX_CIDR_LENGTH = 64; + +export type DatabaseCreateBody = Partial & { type?: string }; + +export const isSafeDatabaseVersion = (value: string): boolean => + /^(?:latest|\d+(?:\.\d+){0,2}(?:-(?:alpine|bookworm|bullseye))?)$/.test(value); + +export const isValidCidr = (value: string): boolean => { + const [address, prefixText, extra] = value.trim().split("/"); + if (extra !== undefined) return false; + if (isIP(address) !== 4) return false; + if (prefixText === undefined) return true; + if (!/^\d+$/.test(prefixText)) return false; + const prefix = Number(prefixText); + return prefix >= 0 && prefix <= 32; +}; + +export const validateDatabaseCreate = ( + body: DatabaseCreateBody, +): { ok: true; input: Omit } | { ok: false; error: string } => { + if (typeof body.name !== "string" || !body.name.trim() || body.name.trim().length > 80) { + return { ok: false, error: "name is required and must be at most 80 characters" }; + } + if (!body.type || !DATABASE_TYPES.includes(body.type as DatabaseType)) { + return { ok: false, error: "type must be postgresql, mysql, redis, or mongodb" }; + } + if (body.version !== undefined && (typeof body.version !== "string" || !isSafeDatabaseVersion(body.version.trim()))) { + return { ok: false, error: "version must be a numeric image version or latest" }; + } + + const cpuLimit = body.cpuLimit == null ? null : Number(body.cpuLimit); + const memoryLimitMb = body.memoryLimitMb == null ? null : Number(body.memoryLimitMb); + const storageLimitMb = body.storageLimitMb == null ? null : Number(body.storageLimitMb); + if (cpuLimit !== null && (!Number.isFinite(cpuLimit) || cpuLimit <= 0 || cpuLimit > 128)) { + return { ok: false, error: "cpuLimit must be between 0 and 128 cores" }; + } + if (memoryLimitMb !== null && (!Number.isInteger(memoryLimitMb) || memoryLimitMb < 64)) { + return { ok: false, error: "memoryLimitMb must be an integer of at least 64" }; + } + if (storageLimitMb !== null && (!Number.isInteger(storageLimitMb) || storageLimitMb < 64)) { + return { ok: false, error: "storageLimitMb must be an integer of at least 64" }; + } + + const publicAccess = body.publicAccess !== false; + const allowPublicAccessFromAnywhere = body.allowPublicAccessFromAnywhere === true; + const allowedCidrs = Array.isArray(body.allowedCidrs) + ? body.allowedCidrs.map(String).map((value) => value.trim()).filter(Boolean) + : []; + if (allowedCidrs.length > MAX_ALLOWED_CIDRS) { + return { ok: false, error: `allowedCidrs must contain at most ${MAX_ALLOWED_CIDRS} ranges` }; + } + if (allowedCidrs.some((cidr) => cidr.length > MAX_CIDR_LENGTH || !isValidCidr(cidr))) { + return { ok: false, error: "allowedCidrs must contain valid IPv4 CIDR ranges" }; + } + if (publicAccess && !allowPublicAccessFromAnywhere && allowedCidrs.length === 0) { + return { ok: false, error: "public databases require an allowed CIDR or allowPublicAccessFromAnywhere" }; + } + + return { + ok: true, + input: { + name: body.name.trim(), + type: body.type as DatabaseType, + version: body.version?.trim() || undefined, + cpuLimit, + memoryLimitMb, + storageLimitMb, + publicAccess, + allowPublicAccessFromAnywhere, + allowedCidrs, + }, + }; +}; diff --git a/apps/api/src/db/__tests__/databases-repo-runner.ts b/apps/api/src/db/__tests__/databases-repo-runner.ts new file mode 100644 index 0000000..f243d48 --- /dev/null +++ b/apps/api/src/db/__tests__/databases-repo-runner.ts @@ -0,0 +1,50 @@ +import { rmSync } from 'node:fs'; + +const dbPath = process.env.DATABASE_PATH ?? `/tmp/dequel-repo-test-${Date.now()}.db`; + +const { migrate } = await import('../migrate'); +const { createDatabase, listAllDatabases, listDatabases, getDatabaseById, updateDatabaseStatus, updateDatabaseRuntime, deleteDatabase } = await import('../repo'); + +try { + await migrate(); + const standalone = await createDatabase({ name: 'orders', type: 'postgresql', version: '16' }); + const attached = await createDatabase({ + name: 'internal', + type: 'mysql', + projectId: 'proj-1', + publicAccess: false, + allowedCidrs: ['10.0.0.0/8'], + }); + await updateDatabaseStatus(standalone.id, 'running', 'db-container-1'); + await updateDatabaseRuntime(standalone.id, { externalPort: 30123, proxyContainerName: 'db-container-1-public', storageUsedMb: 256 }); + const updated = await getDatabaseById(standalone.id); + console.log(JSON.stringify({ + standalone: { + projectId: standalone.projectId, + internalPort: standalone.internalPort, + status: standalone.status, + publicAccess: standalone.publicAccess, + allowAnywhere: standalone.allowPublicAccessFromAnywhere, + volumeName: standalone.volumeName, + connectionPrefix: standalone.connectionString.slice(0, 11), + hasPassword: standalone.password.length > 0, + }, + attached: { + projectId: attached.projectId, + publicAccess: attached.publicAccess, + allowedCidrs: attached.allowedCidrs, + projectListCount: (await listDatabases('proj-1')).length, + }, + updated: { + status: updated?.status, + containerName: updated?.containerName, + externalPort: updated?.externalPort, + proxyContainerName: updated?.proxyContainerName, + storageUsedMb: updated?.storageUsedMb, + }, + deleted: await deleteDatabase(attached.id), + total: (await listAllDatabases()).length, + })); +} finally { + rmSync(dbPath, { force: true }); +} diff --git a/apps/api/src/db/__tests__/databases.test.ts b/apps/api/src/db/__tests__/databases.test.ts new file mode 100644 index 0000000..0fc6ae1 --- /dev/null +++ b/apps/api/src/db/__tests__/databases.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; + +const runnerPath = join(import.meta.dir, 'databases-repo-runner.ts'); + +const runRepoScenarios = (): any => { + const dbPath = join(tmpdir(), `dequel-repo-test-${randomUUID()}.db`); + const result = spawnSync('bun', [runnerPath], { + env: { ...process.env, DATABASE_PATH: dbPath }, + encoding: 'utf8', + }); + if (result.status !== 0) { + throw new Error(`Repo test runner failed:\n${result.stdout}\n${result.stderr}`); + } + for (const line of result.stdout.split('\n').reverse()) { + try { + return JSON.parse(line); + } catch {} + } + throw new Error(`Repo test runner produced no JSON output:\n${result.stdout}`); +}; + +describe('Database Repository Tests', () => { + it('creates standalone and project-attached databases against the migrated schema', () => { + const result = runRepoScenarios(); + expect(result.standalone.projectId).toBeNull(); + expect(result.standalone.internalPort).toBe(5432); + expect(result.standalone.status).toBe('provisioning'); + expect(result.standalone.publicAccess).toBe(true); + expect(result.standalone.allowAnywhere).toBe(false); + expect(result.standalone.volumeName).toMatch(/^db-[a-f0-9-]{12}$/); + expect(result.standalone.connectionPrefix).toBe('postgresql:'); + expect(result.standalone.hasPassword).toBe(true); + }); + + it('persists project attachment, allowlist, and lifecycle updates', () => { + const result = runRepoScenarios(); + expect(result.attached.projectId).toBe('proj-1'); + expect(result.attached.publicAccess).toBe(false); + expect(result.attached.allowedCidrs).toEqual(['10.0.0.0/8']); + expect(result.attached.projectListCount).toBe(1); + expect(result.updated.status).toBe('running'); + expect(result.updated.containerName).toBe('db-container-1'); + expect(result.updated.externalPort).toBe(30123); + expect(result.updated.proxyContainerName).toBe('db-container-1-public'); + expect(result.updated.storageUsedMb).toBe(256); + expect(result.deleted).toBe(true); + expect(result.total).toBe(1); + }); +}); diff --git a/apps/api/src/db/__tests__/migration-backfill.test.ts b/apps/api/src/db/__tests__/migration-backfill.test.ts new file mode 100644 index 0000000..7d8e82d --- /dev/null +++ b/apps/api/src/db/__tests__/migration-backfill.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'bun:test'; +import { Database } from 'bun:sqlite'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const migrationSql = readFileSync(join(import.meta.dir, '..', 'migrations', '0007_standalone_databases.sql'), 'utf8'); + +const createLegacySchema = (db: Database) => { + db.exec(` + PRAGMA foreign_keys = OFF; + CREATE TABLE IF NOT EXISTS "projects" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "source_type" text NOT NULL DEFAULT 'git', + "created_at" text NOT NULL, + "updated_at" text NOT NULL + ); + CREATE TABLE IF NOT EXISTS "databases" ( + "id" text PRIMARY KEY NOT NULL, + "project_id" text NOT NULL, + "type" text NOT NULL, + "version" text, + "database_name" text NOT NULL, + "username" text NOT NULL, + "password" text NOT NULL, + "internal_host" text NOT NULL, + "internal_port" integer NOT NULL, + "cpu_limit" real, + "memory_limit_mb" integer, + "connection_string" text NOT NULL, + "status" text NOT NULL DEFAULT 'provisioning', + "container_name" text, + "created_at" text NOT NULL, + "updated_at" text NOT NULL, + FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE + ); + `); +}; + +const applyMigration = (db: Database) => { + for (const statement of migrationSql.split('--> statement-breakpoint')) { + const trimmed = statement.trim(); + if (trimmed) db.exec(trimmed); + } +}; + +describe('0007 standalone databases migration', () => { + it('backfills legacy databases as private standalone records', () => { + const db = new Database(':memory:'); + createLegacySchema(db); + db.run( + `INSERT INTO projects (id, name, source_type, created_at, updated_at) VALUES ('proj-1', 'legacy', 'git', '2026-01-01', '2026-01-01')`, + ); + db.run( + `INSERT INTO databases (id, project_id, type, version, database_name, username, password, internal_host, internal_port, connection_string, status, created_at, updated_at) + VALUES ('legacy-db-1', 'proj-1', 'postgresql', '16', 'legacydb', 'user_1', 'pw', 'legacy-db-1', 5432, 'postgresql://user_1:pw@legacy-db-1:5432/legacydb', 'running', '2026-01-01', '2026-01-01')`, + ); + applyMigration(db); + + const row = db.query(`SELECT * FROM databases WHERE id = 'legacy-db-1'`).get() as any; + expect(row.name).toBe('legacydb'); + expect(row.project_id).toBe('proj-1'); + expect(row.volume_name).toBe('db-legacy-db-1'); + expect(row.public_access).toBe(0); + expect(row.allow_public_access_from_anywhere).toBe(0); + expect(row.allowed_cidrs).toBe('[]'); + expect(row.external_port).toBeNull(); + expect(row.proxy_container_name).toBeNull(); + expect(row.storage_limit_mb).toBeNull(); + expect(row.storage_used_mb).toBe(0); + }); + + it('detaches databases when the attached project is deleted', () => { + const db = new Database(':memory:'); + createLegacySchema(db); + db.run( + `INSERT INTO projects (id, name, source_type, created_at, updated_at) VALUES ('proj-1', 'legacy', 'git', '2026-01-01', '2026-01-01')`, + ); + db.run( + `INSERT INTO databases (id, project_id, type, version, database_name, username, password, internal_host, internal_port, connection_string, status, created_at, updated_at) + VALUES ('legacy-db-1', 'proj-1', 'postgresql', '16', 'legacydb', 'user_1', 'pw', 'legacy-db-1', 5432, 'postgresql://user_1:pw@legacy-db-1:5432/legacydb', 'running', '2026-01-01', '2026-01-01')`, + ); + applyMigration(db); + + db.exec(`DELETE FROM projects WHERE id = 'proj-1'`); + const row = db.query(`SELECT * FROM databases WHERE id = 'legacy-db-1'`).get() as any; + expect(row.project_id).toBeNull(); + expect(row.name).toBe('legacydb'); + }); +}); diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts index f7e8cc4..dc6359d 100644 --- a/apps/api/src/db/migrate.ts +++ b/apps/api/src/db/migrate.ts @@ -31,9 +31,35 @@ export const migrate = async () => { await addClearCacheColumn(db); await addFinishedAtColumn(db); + await addInstallCommandColumn(db); + await addOutputDirColumn(db); await seedFromConfig(); }; +const addOutputDirColumn = async (db: ReturnType) => { + try { + db.run(sql`ALTER TABLE projects ADD COLUMN output_dir text`); + console.log("[Migrate] Added output_dir column to projects table"); + } catch (err) { + const cause = err instanceof Error && "cause" in err ? err.cause : err; + if (cause instanceof Error && cause.message.includes("duplicate column name")) return; + console.error("[Migrate] Failed to add output_dir column:", err); + throw err; + } +}; + +const addInstallCommandColumn = async (db: ReturnType) => { + try { + db.run(sql`ALTER TABLE projects ADD COLUMN install_command text`); + console.log("[Migrate] Added install_command column to projects table"); + } catch (err) { + const cause = err instanceof Error && "cause" in err ? err.cause : err; + if (cause instanceof Error && cause.message.includes("duplicate column name")) return; + console.error("[Migrate] Failed to add install_command column:", err); + throw err; + } +}; + const addClearCacheColumn = async (db: ReturnType) => { try { db.run(sql`ALTER TABLE deployments ADD COLUMN clear_cache integer NOT NULL DEFAULT 0`); diff --git a/apps/api/src/db/migrations/0007_standalone_databases.sql b/apps/api/src/db/migrations/0007_standalone_databases.sql new file mode 100644 index 0000000..63aa52f --- /dev/null +++ b/apps/api/src/db/migrations/0007_standalone_databases.sql @@ -0,0 +1,34 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_databases` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text, + `name` text NOT NULL, + `type` text NOT NULL, + `version` text, + `database_name` text NOT NULL, + `username` text NOT NULL, + `password` text NOT NULL, + `internal_host` text NOT NULL, + `internal_port` integer NOT NULL, + `cpu_limit` real, + `memory_limit_mb` integer, + `storage_limit_mb` integer, + `storage_used_mb` integer DEFAULT 0 NOT NULL, + `public_access` integer DEFAULT 1 NOT NULL, + `allow_public_access_from_anywhere` integer DEFAULT 0 NOT NULL, + `allowed_cidrs` text DEFAULT '[]' NOT NULL, + `external_port` integer, + `proxy_container_name` text, + `volume_name` text NOT NULL, + `connection_string` text NOT NULL, + `status` text DEFAULT 'provisioning' NOT NULL, + `container_name` text, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE SET NULL +);--> statement-breakpoint +INSERT INTO `__new_databases` (`id`, `project_id`, `name`, `type`, `version`, `database_name`, `username`, `password`, `internal_host`, `internal_port`, `cpu_limit`, `memory_limit_mb`, `storage_limit_mb`, `storage_used_mb`, `public_access`, `allow_public_access_from_anywhere`, `allowed_cidrs`, `external_port`, `proxy_container_name`, `volume_name`, `connection_string`, `status`, `container_name`, `created_at`, `updated_at`) +SELECT `id`, `project_id`, `database_name`, `type`, `version`, `database_name`, `username`, `password`, `internal_host`, `internal_port`, `cpu_limit`, `memory_limit_mb`, NULL, 0, 0, 0, '[]', NULL, NULL, 'db-' || substr(`id`, 1, 12), `connection_string`, `status`, `container_name`, `created_at`, `updated_at` FROM `databases`;--> statement-breakpoint +DROP TABLE `databases`;--> statement-breakpoint +ALTER TABLE `__new_databases` RENAME TO `databases`;--> statement-breakpoint +PRAGMA foreign_keys=ON; diff --git a/apps/api/src/db/migrations/0008_closed_whirlwind.sql b/apps/api/src/db/migrations/0008_closed_whirlwind.sql new file mode 100644 index 0000000..0ad8a82 --- /dev/null +++ b/apps/api/src/db/migrations/0008_closed_whirlwind.sql @@ -0,0 +1,34 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_databases` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text, + `name` text NOT NULL, + `type` text NOT NULL, + `version` text, + `database_name` text NOT NULL, + `username` text NOT NULL, + `password` text NOT NULL, + `internal_host` text NOT NULL, + `internal_port` integer NOT NULL, + `cpu_limit` real, + `memory_limit_mb` integer, + `storage_limit_mb` integer, + `storage_used_mb` integer DEFAULT 0 NOT NULL, + `public_access` integer DEFAULT 1 NOT NULL, + `allow_public_access_from_anywhere` integer DEFAULT 0 NOT NULL, + `allowed_cidrs` text DEFAULT '[]' NOT NULL, + `external_port` integer, + `proxy_container_name` text, + `volume_name` text NOT NULL, + `connection_string` text NOT NULL, + `status` text DEFAULT 'provisioning' NOT NULL, + `container_name` text, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE no action +); +--> statement-breakpoint +INSERT INTO `__new_databases`("id", "project_id", "name", "type", "version", "database_name", "username", "password", "internal_host", "internal_port", "cpu_limit", "memory_limit_mb", "storage_limit_mb", "storage_used_mb", "public_access", "allow_public_access_from_anywhere", "allowed_cidrs", "external_port", "proxy_container_name", "volume_name", "connection_string", "status", "container_name", "created_at", "updated_at") SELECT "id", "project_id", "name", "type", "version", "database_name", "username", "password", "internal_host", "internal_port", "cpu_limit", "memory_limit_mb", "storage_limit_mb", "storage_used_mb", "public_access", "allow_public_access_from_anywhere", "allowed_cidrs", "external_port", "proxy_container_name", "volume_name", "connection_string", "status", "container_name", "created_at", "updated_at" FROM `databases`;--> statement-breakpoint +DROP TABLE `databases`;--> statement-breakpoint +ALTER TABLE `__new_databases` RENAME TO `databases`;--> statement-breakpoint +PRAGMA foreign_keys=ON; \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/0008_snapshot.json b/apps/api/src/db/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..eb604d5 --- /dev/null +++ b/apps/api/src/db/migrations/meta/0008_snapshot.json @@ -0,0 +1,1442 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0d86f3f9-9d67-47f7-9491-80f939b8775f", + "prevId": "c4054dd7-dc6f-44e1-90e6-7e5ae9eaa8f9", + "tables": { + "alerts": { + "name": "alerts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "threshold": { + "name": "threshold", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'email'" + }, + "destination": { + "name": "destination", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "alerts_project_id_projects_id_fk": { + "name": "alerts_project_id_projects_id_fk", + "tableFrom": "alerts", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'deploy:read'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "databases": { + "name": "databases", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "database_name": { + "name": "database_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "internal_host": { + "name": "internal_host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "internal_port": { + "name": "internal_port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cpu_limit": { + "name": "cpu_limit", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "memory_limit_mb": { + "name": "memory_limit_mb", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_limit_mb": { + "name": "storage_limit_mb", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "storage_used_mb": { + "name": "storage_used_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "public_access": { + "name": "public_access", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "allow_public_access_from_anywhere": { + "name": "allow_public_access_from_anywhere", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "allowed_cidrs": { + "name": "allowed_cidrs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "external_port": { + "name": "external_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "proxy_container_name": { + "name": "proxy_container_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "volume_name": { + "name": "volume_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_string": { + "name": "connection_string", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'provisioning'" + }, + "container_name": { + "name": "container_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "databases_project_id_projects_id_fk": { + "name": "databases_project_id_projects_id_fk", + "tableFrom": "databases", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deployment_logs": { + "name": "deployment_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "deployment_id": { + "name": "deployment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stage": { + "name": "stage", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_logs_dep_seq": { + "name": "idx_logs_dep_seq", + "columns": [ + "deployment_id", + "sequence" + ], + "isUnique": true + } + }, + "foreignKeys": { + "deployment_logs_deployment_id_deployments_id_fk": { + "name": "deployment_logs_deployment_id_deployments_id_fk", + "tableFrom": "deployment_logs", + "tableTo": "deployments", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "deployments": { + "name": "deployments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "image_tag": { + "name": "image_tag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "container_name": { + "name": "container_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "route_path": { + "name": "route_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "live_url": { + "name": "live_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "replicas": { + "name": "replicas", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "clear_cache": { + "name": "clear_cache", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "finished_at": { + "name": "finished_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "domains": { + "name": "domains", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'custom'" + }, + "validation_status": { + "name": "validation_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "ssl_status": { + "name": "ssl_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "target_service": { + "name": "target_service", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_port": { + "name": "target_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "domains_project_id_projects_id_fk": { + "name": "domains_project_id_projects_id_fk", + "tableFrom": "domains", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environment_variables": { + "name": "environment_variables", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value_encrypted": { + "name": "value_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value_iv": { + "name": "value_iv", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value_tag": { + "name": "value_tag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'production'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_env_vars_project": { + "name": "idx_env_vars_project", + "columns": [ + "project_id", + "environment" + ], + "isUnique": false + } + }, + "foreignKeys": { + "environment_variables_project_id_projects_id_fk": { + "name": "environment_variables_project_id_projects_id_fk", + "tableFrom": "environment_variables", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "github_integrations": { + "name": "github_integrations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "app_name": { + "name": "app_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Dequel'" + }, + "webhook_secret": { + "name": "webhook_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_branch": { + "name": "repo_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_domain": { + "name": "base_domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_limit": { + "name": "cpu_limit", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "memory_limit_mb": { + "name": "memory_limit_mb", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_dir": { + "name": "source_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'git'" + }, + "project_type": { + "name": "project_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'web'" + }, + "build_type": { + "name": "build_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'railpack'" + }, + "compose_service": { + "name": "compose_service", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compose_port": { + "name": "compose_port", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compose_services": { + "name": "compose_services", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "build_command": { + "name": "build_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "install_command": { + "name": "install_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_command": { + "name": "start_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_token_encrypted": { + "name": "github_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_token_iv": { + "name": "github_token_iv", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_token_tag": { + "name": "github_token_tag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "refresh_tokens": { + "name": "refresh_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blacklisted_at": { + "name": "blacklisted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "refresh_tokens_token_hash_unique": { + "name": "refresh_tokens_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scaling_policies": { + "name": "scaling_policies", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "min_replicas": { + "name": "min_replicas", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "max_replicas": { + "name": "max_replicas", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "cpu_threshold_percent": { + "name": "cpu_threshold_percent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 70 + }, + "memory_threshold_percent": { + "name": "memory_threshold_percent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 85 + }, + "cooldown_seconds": { + "name": "cooldown_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 120 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "scaling_policies_project_id_unique": { + "name": "scaling_policies_project_id_unique", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "scaling_policies_project_id_projects_id_fk": { + "name": "scaling_policies_project_id_projects_id_fk", + "tableFrom": "scaling_policies", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "servers": { + "name": "servers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2375 + }, + "auth_token": { + "name": "auth_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "cpu_total": { + "name": "cpu_total", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "memory_total_mb": { + "name": "memory_total_mb", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disk_total_mb": { + "name": "disk_total_mb", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cpu_used_percent": { + "name": "cpu_used_percent", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "memory_used_mb": { + "name": "memory_used_mb", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_heartbeat": { + "name": "last_heartbeat", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "smtp_settings": { + "name": "smtp_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 587 + }, + "user": { + "name": "user", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "pass_encrypted": { + "name": "pass_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pass_iv": { + "name": "pass_iv", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pass_tag": { + "name": "pass_tag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "from_address": { + "name": "from_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dequel@localhost'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "volumes": { + "name": "volumes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mount_path": { + "name": "mount_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'/app/data'" + }, + "size_mb": { + "name": "size_mb", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "docker_volume_name": { + "name": "docker_volume_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "volumes_project_id_projects_id_fk": { + "name": "volumes_project_id_projects_id_fk", + "tableFrom": "volumes", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json index 038b6a3..3d6af4f 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -50,6 +50,20 @@ "when": 1785409331053, "tag": "0006_cheerful_vance_astro", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1785596400000, + "tag": "0007_standalone_databases", + "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1785816991600, + "tag": "0008_closed_whirlwind", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/repo/databases.ts b/apps/api/src/db/repo/databases.ts index 0e0df08..db01200 100644 --- a/apps/api/src/db/repo/databases.ts +++ b/apps/api/src/db/repo/databases.ts @@ -7,7 +7,8 @@ import { now } from "./helpers"; const mapDatabase = (row: typeof databases.$inferSelect): Database => ({ id: row.id, - projectId: row.projectId, + projectId: row.projectId ?? null, + name: row.name, type: row.type as Database["type"], version: row.version, databaseName: row.databaseName, @@ -17,6 +18,16 @@ const mapDatabase = (row: typeof databases.$inferSelect): Database => ({ internalPort: row.internalPort, cpuLimit: row.cpuLimit, memoryLimitMb: row.memoryLimitMb, + storageLimitMb: row.storageLimitMb, + storageUsedMb: row.storageUsedMb, + publicAccess: row.publicAccess === 1, + allowPublicAccessFromAnywhere: row.allowPublicAccessFromAnywhere === 1, + allowedCidrs: (() => { + try { return JSON.parse(row.allowedCidrs || "[]"); } catch { return []; } + })(), + externalPort: row.externalPort, + proxyContainerName: row.proxyContainerName, + volumeName: row.volumeName, connectionString: row.connectionString, status: row.status as DatabaseStatus, containerName: row.containerName, @@ -28,17 +39,33 @@ export const createDatabase = async (input: CreateDatabaseInput): Promise => { + const db = await getDrizzle(); + db.update(databases).set({ ...updates, updatedAt: now() }).where(eq(databases.id, id)).run(); +}; + export const deleteDatabase = async (id: string): Promise => { const db = await getDrizzle(); return db.delete(databases).where(eq(databases.id, id)).run().changes > 0; diff --git a/apps/api/src/db/repo/index.ts b/apps/api/src/db/repo/index.ts index 5e6a53f..46676b1 100644 --- a/apps/api/src/db/repo/index.ts +++ b/apps/api/src/db/repo/index.ts @@ -20,7 +20,7 @@ export { createVolume, listVolumes, getVolumeById, deleteVolume } from "./volume export { createDatabase, listAllDatabases, listDatabases, getDatabaseById, - updateDatabaseStatus, deleteDatabase, + updateDatabaseStatus, updateDatabaseRuntime, deleteDatabase, } from "./databases"; export { diff --git a/apps/api/src/db/repo/projects.ts b/apps/api/src/db/repo/projects.ts index 850c8a1..2fd8256 100644 --- a/apps/api/src/db/repo/projects.ts +++ b/apps/api/src/db/repo/projects.ts @@ -34,6 +34,8 @@ const mapProject = (row: typeof projects.$inferSelect): Project => ({ composePort: row.composePort ?? null, composeServices: row.composeServices ?? null, buildCommand: row.buildCommand ?? null, + installCommand: row.installCommand ?? null, + outputDir: row.outputDir ?? null, startCommand: row.startCommand ?? null, githubTokenEncrypted: row.githubTokenEncrypted ?? null, githubTokenIv: row.githubTokenIv ?? null, @@ -64,6 +66,8 @@ export const createProject = async (input: CreateProjectInput): Promise composePort: input.composePort ?? null, composeServices: input.composeServices ?? null, buildCommand: input.buildCommand ?? null, + installCommand: input.installCommand ?? null, + outputDir: input.outputDir ?? null, startCommand: input.startCommand ?? null, createdAt: timestamp, updatedAt: timestamp, @@ -108,6 +112,8 @@ export const updateProject = async (id: string, patch: Partial v.dockerVolumeName).map(v => v.dockerVolumeName!); db.delete(volumes).where(eq(volumes.projectId, id)).run(); - const dbRows = db.select({ containerName: databases.containerName, id: databases.id }) + const dbRows = db.select({ containerName: databases.containerName, volumeName: databases.volumeName }) .from(databases).where(eq(databases.projectId, id)).all(); - const databaseContainerNames = dbRows.filter(d => d.containerName).map(d => d.containerName!); - const databaseVolumeNames = dbRows.map(d => `db-${d.id.slice(0, 12)}`); - db.delete(databases).where(eq(databases.projectId, id)).run(); + const databaseContainerNames: string[] = []; + const databaseVolumeNames: string[] = []; + db.update(databases).set({ projectId: null, updatedAt: now() }).where(eq(databases.projectId, id)).run(); const domainRows = db.select({ domain: domains.domain }).from(domains).where(eq(domains.projectId, id)).all(); const domainInfo = domainRows.map(d => ({ domain: d.domain, projectName: project.name ?? id })); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 2c32330..60ab341 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -27,6 +27,8 @@ export const projects = sqliteTable("projects", { composePort: integer("compose_port"), composeServices: text("compose_services"), buildCommand: text("build_command"), + installCommand: text("install_command"), + outputDir: text("output_dir"), startCommand: text("start_command"), githubTokenEncrypted: text("github_token_encrypted"), githubTokenIv: text("github_token_iv"), @@ -97,7 +99,8 @@ export const volumes = sqliteTable("volumes", { export const databases = sqliteTable("databases", { id: text().primaryKey(), - projectId: text("project_id").notNull(), + projectId: text("project_id"), + name: text().notNull(), type: text().notNull(), version: text(), databaseName: text("database_name").notNull(), @@ -107,13 +110,21 @@ export const databases = sqliteTable("databases", { internalPort: integer("internal_port").notNull(), cpuLimit: real("cpu_limit"), memoryLimitMb: integer("memory_limit_mb"), + storageLimitMb: integer("storage_limit_mb"), + storageUsedMb: integer("storage_used_mb").notNull().default(0), + publicAccess: integer("public_access").notNull().default(1), + allowPublicAccessFromAnywhere: integer("allow_public_access_from_anywhere").notNull().default(0), + allowedCidrs: text("allowed_cidrs").notNull().default("[]"), + externalPort: integer("external_port"), + proxyContainerName: text("proxy_container_name"), + volumeName: text("volume_name").notNull(), connectionString: text("connection_string").notNull(), status: text().notNull().default("provisioning"), containerName: text("container_name"), createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }, (table) => [ - foreignKey({ columns: [table.projectId], foreignColumns: [projects.id], onDelete: "cascade" }), + foreignKey({ columns: [table.projectId], foreignColumns: [projects.id], onDelete: "set null" }), ]); export const domains = sqliteTable("domains", { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index e63a333..acf9ea4 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -14,6 +14,7 @@ import { alertEvaluator } from './monitoring/evaluator'; import { loadOrCreateJwtSecret } from './utils/secrets'; import { initAuth, cleanupExpiredTokens } from './utils/auth'; import { startBuildCleanup } from './orchestrator/cleanup'; +import { startDatabaseMonitoring } from './databases/manager'; const bootstrap = async () => { await mkdir(dirname(config.databasePath), { recursive: true }); await mkdir(config.workspaceRoot, { recursive: true }); @@ -30,6 +31,7 @@ const bootstrap = async () => { startDomainPolling(); alertEvaluator.start(); startBuildCleanup(); + startDatabaseMonitoring(); setInterval(() => { cleanupExpiredTokens().catch(() => {}); }, 60_000); const metrics = { diff --git a/apps/api/src/orchestrator/__tests__/deployment-errors.test.ts b/apps/api/src/orchestrator/__tests__/deployment-errors.test.ts new file mode 100644 index 0000000..98aa493 --- /dev/null +++ b/apps/api/src/orchestrator/__tests__/deployment-errors.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { summarizeDeploymentError } from "../deployment-errors"; + +describe("summarizeDeploymentError", () => { + test("extracts an actionable runtime requirement from noisy build output", () => { + const error = new Error(`railpack build failed: +DEBU Building /app/workspace/deployment-id +0.917 > next build +1.049 You are using Node.js 18.20.8. For Next.js, Node.js version ">=20.9.0" is required. +ERRO failed to solve: process "sh -c pnpm run build" did not complete successfully: exit code: 1 +unrecognized image format`); + + expect(summarizeDeploymentError(error)).toBe( + 'You are using Node.js 18.20.8. For Next.js, Node.js version ">=20.9.0" is required.', + ); + }); + + test("extracts compiler and package errors regardless of deployment stack", () => { + expect( + summarizeDeploymentError( + new Error("docker compose build failed: step 8\nError: Module @repo/ui not found\nstack trace line"), + ), + ).toBe("Error: Module @repo/ui not found"); + }); + + test("keeps an already concise error", () => { + expect(summarizeDeploymentError(new Error("Container exited with code 1"))).toBe( + "Container exited with code 1", + ); + }); + + test("uses a generic message when output has no useful error", () => { + expect(summarizeDeploymentError(new Error("DEBU build stopped\nDONE"))).toBe( + "Deployment failed. Check the deployment logs for details.", + ); + }); +}); diff --git a/apps/api/src/orchestrator/deployment-errors.ts b/apps/api/src/orchestrator/deployment-errors.ts new file mode 100644 index 0000000..990b0b8 --- /dev/null +++ b/apps/api/src/orchestrator/deployment-errors.ts @@ -0,0 +1,24 @@ +const ansiPattern = /\u001b\[[0-9;]*m/g; +const noisyLinePattern = /^(DEBU|DEBUG|DONE|CACHED|loading|resolving?|copy |transferring context|sha256:|[-─╭╰│▸↳⚠])/i; +const wrapperPattern = /^(railpack build failed|docker compose (build|up) failed|failed to solve|error: process .* did not complete successfully|unrecognized image format)/i; +const actionablePattern = /(error:|exception:|failed:|not found|cannot find|could not|requires?|required|unsupported|permission denied|out of memory|no space left|timed out|timeout|exited with code|address already in use|connection refused|ELIFECYCLE)/i; + +export const summarizeDeploymentError = (error: unknown): string => { + const raw = error instanceof Error ? error.message : String(error ?? ""); + const lines = raw + .replace(ansiPattern, "") + .split("\n") + .map((line) => line.trim().replace(/^#\d+\s+(?:\d+\.\d+\s+)?/, "").replace(/^\d+\.\d+\s+/, "")) + .filter(Boolean); + + const actionable = lines.find( + (line) => actionablePattern.test(line) && !wrapperPattern.test(line) && !noisyLinePattern.test(line), + ); + if (actionable) return actionable.slice(0, 500); + + if (lines.length === 1 && !noisyLinePattern.test(lines[0])) { + return lines[0].slice(0, 500); + } + + return "Deployment failed. Check the deployment logs for details."; +}; diff --git a/apps/api/src/orchestrator/pipeline.ts b/apps/api/src/orchestrator/pipeline.ts index de8f480..03e8296 100644 --- a/apps/api/src/orchestrator/pipeline.ts +++ b/apps/api/src/orchestrator/pipeline.ts @@ -32,6 +32,7 @@ import { import { ensureProjectDashboard } from "../utils/grafana"; import { buildWithCompose, destroyComposeStack } from "./compose"; import { deployComposeStack } from "./compose-deploy"; +import { summarizeDeploymentError } from "./deployment-errors"; const now = () => new Date() @@ -440,6 +441,8 @@ export class PipelineOrchestrator { sourceDir: project?.sourceDir, projectType: project?.projectType, buildCommand: project?.buildCommand, + installCommand: project?.installCommand, + outputDir: project?.outputDir, startCommand: project?.startCommand, environmentVariables: filterBuildEnvVars(envVars), signal: controller.signal, @@ -690,10 +693,7 @@ export class PipelineOrchestrator { if (error instanceof CancelledError) { return true; } - const message = - error instanceof Error - ? error.message - : "Unknown deployment failure"; + const message = summarizeDeploymentError(error); console.error( `[Orchestrator] Deployment ${deploymentId} failed:`, error, @@ -701,7 +701,7 @@ export class PipelineOrchestrator { await emitLog( deploymentId, "system", - `CRITICAL FAILURE: ${message}`, + `Deployment failed: ${message}`, ); await updateDeploymentStatus( deploymentId, @@ -936,10 +936,7 @@ export class PipelineOrchestrator { "Rollback complete — deployment is running", ); } catch (error) { - const message = - error instanceof Error - ? error.message - : "Unknown rollback failure"; + const message = summarizeDeploymentError(error); console.error( `[Orchestrator] Rollback of ${targetDeploymentId} failed:`, error, @@ -947,7 +944,7 @@ export class PipelineOrchestrator { await emitLog( targetDeploymentId, "system", - `CRITICAL FAILURE: ${message}`, + `Rollback failed: ${message}`, ); await updateDeploymentStatus( targetDeploymentId, diff --git a/apps/api/src/orchestrator/railpack-config-utils.ts b/apps/api/src/orchestrator/railpack-config-utils.ts index 5a4f64b..5e48932 100644 --- a/apps/api/src/orchestrator/railpack-config-utils.ts +++ b/apps/api/src/orchestrator/railpack-config-utils.ts @@ -85,6 +85,23 @@ export const rewriteLocalhostBinding = async ( } }; +const fixPnpmWorkspaceIfNeeded = async (workspacePath: string, onLog: (line: string) => Promise) => { + try { + const workspaceFilePath = join(workspacePath, "pnpm-workspace.yaml"); + const file = Bun.file(workspaceFilePath); + if (await file.exists()) { + const content = await file.text(); + if (!content.includes("packages:")) { + await onLog("Auto-repairing invalid pnpm-workspace.yaml (missing packages entry)..."); + const updated = `${content.trim()}\n\npackages:\n - '.'\n`; + await Bun.write(workspaceFilePath, updated); + } + } + } catch { + // Ignore workspace repair errors + } +}; + export const generateDynamicRailpackJson = async ( workspace: string, sourceDir: string | null, @@ -92,10 +109,15 @@ export const generateDynamicRailpackJson = async ( buildCommandOverride: string | null, startCommandOverride: string | null, onLog: (line: string) => Promise, + installCommandOverride?: string | null, + outputDirOverride?: string | null, ): Promise => { const cleanSourceDir = sourceDir ? sourceDir.replace(/^\//, "") : ""; + const cleanOutputDir = outputDirOverride + ? outputDirOverride.replace(/^\//, "") + : ""; const buildDir = cleanSourceDir ? join(workspace, cleanSourceDir) : workspace; @@ -103,6 +125,7 @@ export const generateDynamicRailpackJson = async ( buildDir, onLog, ); + await fixPnpmWorkspaceIfNeeded(workspace, onLog); const configPath = join( workspace, "railpack.json", @@ -117,9 +140,18 @@ export const generateDynamicRailpackJson = async ( let configured = false; - // 1. Check if user provided manual build / start command overrides - if (buildCommandOverride || startCommandOverride) { - await onLog("Applying custom build/start settings"); + // 1. Check if user provided manual build / start / install command overrides + if (buildCommandOverride || startCommandOverride || installCommandOverride) { + await onLog("Applying custom build/start/install settings"); + if (installCommandOverride) { + config.steps.install = { + commands: [ + cleanSourceDir + ? `cd ${cleanSourceDir} && ${installCommandOverride}` + : installCommandOverride, + ], + }; + } if (buildCommandOverride) { config.steps.build = { commands: [ @@ -237,15 +269,21 @@ const fs = require("fs"); const path = require("path"); const PORT = Number(process.env.PORT || 3000); const cleanSourceDir = "${cleanSourceDir}"; +const cleanOutputDir = "${cleanOutputDir}"; let staticDir = "."; const candidates = [ + ...(cleanOutputDir ? [path.join(cleanSourceDir, cleanOutputDir), cleanOutputDir] : []), path.join(cleanSourceDir, "dist"), path.join(cleanSourceDir, "build"), path.join(cleanSourceDir, "out"), + path.join(cleanSourceDir, ".next/server/app"), + path.join(cleanSourceDir, ".next/server/pages"), path.join(cleanSourceDir, "public"), "dist", "build", "out", + ".next/server/app", + ".next/server/pages", "public", "." ]; diff --git a/apps/api/src/orchestrator/railpack.ts b/apps/api/src/orchestrator/railpack.ts index cc6ac61..9617a14 100644 --- a/apps/api/src/orchestrator/railpack.ts +++ b/apps/api/src/orchestrator/railpack.ts @@ -280,6 +280,8 @@ export const buildWithRailpack = async ( sourceDir?: string | null; projectType?: string | null; buildCommand?: string | null; + installCommand?: string | null; + outputDir?: string | null; startCommand?: string | null; environmentVariables?: { key: string; value: string }[]; signal?: AbortSignal; @@ -318,6 +320,8 @@ export const buildWithRailpack = async ( opts?.buildCommand ?? null, opts?.startCommand ?? null, onLog, + opts?.installCommand ?? null, + opts?.outputDir ?? null, ); const args = [ diff --git a/apps/api/src/types.ts b/apps/api/src/types.ts index 330aa6c..e77f103 100644 --- a/apps/api/src/types.ts +++ b/apps/api/src/types.ts @@ -1,8 +1,8 @@ export type DeploymentStatus = 'pending' | 'building' | 'deploying' | 'running' | 'failed' | 'inactive'; export type SourceType = 'git' | 'upload' | 'image' | 'compose'; export type LogStage = 'build' | 'deploy' | 'system'; -export type DatabaseType = 'postgresql' | 'mysql'; -export type DatabaseStatus = 'provisioning' | 'running' | 'failed'; +export type DatabaseType = 'postgresql' | 'mysql' | 'redis' | 'mongodb'; +export type DatabaseStatus = 'provisioning' | 'running' | 'stopped' | 'restarting' | 'deleting' | 'deletion_failed' | 'failed'; export type DomainType = 'base' | 'custom'; export type DomainValidationStatus = 'pending' | 'verified' | 'failed'; export type SslStatus = 'pending' | 'provisioned' | 'failed'; @@ -28,6 +28,8 @@ export interface Project { composePort: number | null; composeServices: string | null; buildCommand: string | null; + installCommand: string | null; + outputDir: string | null; startCommand: string | null; githubTokenEncrypted: string | null; githubTokenIv: string | null; @@ -62,6 +64,8 @@ export interface CreateProjectInput { composePort?: number | null; composeServices?: string | null; buildCommand?: string | null; + installCommand?: string | null; + outputDir?: string | null; startCommand?: string | null; } @@ -98,7 +102,8 @@ export interface CreateVolumeInput { export interface Database { id: string; - projectId: string; + projectId: string | null; + name: string; type: DatabaseType; version: string | null; databaseName: string; @@ -108,6 +113,14 @@ export interface Database { internalPort: number; cpuLimit: number | null; memoryLimitMb: number | null; + storageLimitMb: number | null; + storageUsedMb: number; + publicAccess: boolean; + allowPublicAccessFromAnywhere: boolean; + allowedCidrs: string[]; + externalPort: number | null; + proxyContainerName: string | null; + volumeName: string; connectionString: string; status: DatabaseStatus; containerName: string | null; @@ -116,11 +129,16 @@ export interface Database { } export interface CreateDatabaseInput { - projectId: string; + projectId?: string | null; + name: string; type: DatabaseType; version?: string; cpuLimit?: number | null; memoryLimitMb?: number | null; + storageLimitMb?: number | null; + publicAccess?: boolean; + allowPublicAccessFromAnywhere?: boolean; + allowedCidrs?: string[]; } export interface Domain { diff --git a/apps/api/src/utils/__tests__/dns.test.ts b/apps/api/src/utils/__tests__/dns.test.ts index 52acb31..36bcfd9 100644 --- a/apps/api/src/utils/__tests__/dns.test.ts +++ b/apps/api/src/utils/__tests__/dns.test.ts @@ -104,7 +104,7 @@ describe('resolveServerIp', () => { it('returns IP from api.ipify.org', async () => { globalThis.fetch = mock(() => - Promise.resolve({ text: () => Promise.resolve('203.0.113.42') }) + Promise.resolve({ ok: true, text: () => Promise.resolve('203.0.113.42') }) ) as typeof fetch; const ip = await resolveServerIp(); expect(ip).toBe('203.0.113.42'); diff --git a/apps/api/src/utils/dequel-labels.ts b/apps/api/src/utils/dequel-labels.ts index cac7a70..3b37569 100644 --- a/apps/api/src/utils/dequel-labels.ts +++ b/apps/api/src/utils/dequel-labels.ts @@ -1 +1,2 @@ export const DEQUEL_MANAGED_LABEL = 'com.dequel.managed=true'; +export const DEQUEL_DATABASE_LABEL = 'com.dequel.database=true'; diff --git a/apps/api/src/utils/dns.ts b/apps/api/src/utils/dns.ts index 0097c91..d8a35e3 100644 --- a/apps/api/src/utils/dns.ts +++ b/apps/api/src/utils/dns.ts @@ -3,7 +3,8 @@ import { config } from './config'; export const resolveServerIp = async (): Promise => { try { - const resp = await fetch('https://api.ipify.org'); + const resp = await fetch('https://api.ipify.org', { signal: AbortSignal.timeout(5000) }); + if (!resp.ok) return '127.0.0.1'; return (await resp.text()).trim(); } catch { return '127.0.0.1'; diff --git a/apps/docs/src/components/Hero.astro b/apps/docs/src/components/Hero.astro index 1e2d827..7cd29bb 100644 --- a/apps/docs/src/components/Hero.astro +++ b/apps/docs/src/components/Hero.astro @@ -8,9 +8,9 @@ interface Props { const { tag = "Self-Hosted PaaS Platform • Open Source v1.0", - titleNormal = "Deploy apps from Git, ZIP, or Docker Compose", - titleItalic = "yours by default.", - subText = "The all-in-one self-hosted Platform as a Service (PaaS). Build container images via Railpack, route with dynamic Caddy auto-SSL, scale workloads, and monitor metrics — all on your own servers with zero vendor lock-in.", + titleNormal = "Deploy your way.", + titleItalic = "Own it all.", + subText = "Ship apps from Git, ZIP, or Docker Compose in seconds — with automatic SSL, scaling, and monitoring built in, running entirely on servers that are actually yours. Zero lock-in, ever", } = Astro.props; --- @@ -30,8 +30,8 @@ const {

- Deploy apps from Git, ZIP, or Docker Compose — - yours by default. + {titleNormal} + {titleItalic}

@@ -45,9 +45,6 @@ const { Get Started in 1 Command → - - Documentation - }); // Databases +export const listAllDatabases = () => + apiFetch("/databases"); export const listDatabases = ( projectId: string, ) => @@ -268,19 +270,24 @@ export const listDatabases = ( `/projects/${projectId}/databases`, ); export const createDatabase = ( - projectId: string, + projectId: string | null, type: string, options?: { + name?: string; version?: string; cpuLimit?: number | null; memoryLimitMb?: number | null; + storageLimitMb?: number | null; + publicAccess?: boolean; + allowPublicAccessFromAnywhere?: boolean; + allowedCidrs?: string[]; }, ) => apiFetch( - `/projects/${projectId}/databases`, + projectId ? `/projects/${projectId}/databases` : "/databases", { method: "POST", - body: JSON.stringify({ type, ...options }), + body: JSON.stringify({ type, projectId, ...options }), }, ); export const getDatabase = (id: string) => @@ -290,6 +297,23 @@ export const deleteDatabase = (id: string) => `/databases/${id}`, { method: "DELETE" }, ); +export const getDatabaseCredentials = (id: string) => + apiFetch<{ + username: string; + password: string; + internalConnectionString: string; + externalConnectionString: string | null; + externalHost: string | null; + externalPort: number | null; + }>(`/databases/${id}/credentials`); +export const startDatabase = (id: string) => + apiFetch(`/databases/${id}/start`, { method: "POST" }); +export const stopDatabase = (id: string) => + apiFetch(`/databases/${id}/stop`, { method: "POST" }); +export const restartDatabase = (id: string) => + apiFetch(`/databases/${id}/restart`, { method: "POST" }); +export const retryDatabase = (id: string) => + apiFetch(`/databases/${id}/retry`, { method: "POST" }); // Domains export const listDomains = (projectId: string) => diff --git a/apps/web/src/components/StatusBadge.tsx b/apps/web/src/components/StatusBadge.tsx index d6e2abe..0546f00 100644 --- a/apps/web/src/components/StatusBadge.tsx +++ b/apps/web/src/components/StatusBadge.tsx @@ -10,6 +10,10 @@ const statusMap: Record void; + projects: Project[]; + defaultProjectId?: string | null; + onCreated: () => void; +} + +export function CreateDatabaseDialog({ open, onOpenChange, projects, defaultProjectId = null, onCreated }: CreateDatabaseDialogProps) { + const [name, setName] = useState(""); + const [projectId, setProjectId] = useState(defaultProjectId ?? "standalone"); + const [type, setType] = useState("postgresql"); + const [version, setVersion] = useState("18"); + const [cpu, setCpu] = useState("1"); + const [memory, setMemory] = useState("512"); + const [storage, setStorage] = useState("10240"); + const [allowAnywhere, setAllowAnywhere] = useState(false); + const [cidrs, setCidrs] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setVersion(type === "postgresql" ? "18" : type === "mysql" ? "8.4" : "8.0"); + }, [type]); + + const create = async () => { + setIsCreating(true); + setError(null); + try { + await api.createDatabase(projectId === "standalone" ? null : projectId, type, { + name, + version, + cpuLimit: Number(cpu), + memoryLimitMb: Number(memory), + storageLimitMb: Number(storage), + publicAccess: true, + allowPublicAccessFromAnywhere: allowAnywhere, + allowedCidrs: cidrs.split(/[\n,]/).map((value) => value.trim()).filter(Boolean), + }); + onCreated(); + onOpenChange(false); + setName(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Database creation failed"); + } finally { + setIsCreating(false); + } + }; + + return ( + + + + Create managed database + Public access is enabled by default and protected by database credentials plus the network allowlist. + +
+
setName(event.target.value)} placeholder="Production PostgreSQL" />
+
+
+
+
+
setVersion(event.target.value)} />
+
+
setCpu(event.target.value)} />
+
setMemory(event.target.value)} />
+
setStorage(event.target.value)} />

Alerts at 80% and 100% usage; not a hard cap.

+
+
+

Allow access from anywhere

Disable this to require source IP/CIDR matching.

setAllowAnywhere(event.target.checked)} className="h-4 w-4 accent-amber-500" aria-label="Allow public database access from anywhere" />
+ {!allowAnywhere &&
setCidrs(event.target.value)} placeholder="203.0.113.4/32, 10.0.0.0/8" />

At least one address is required.

} +
+ {error &&

{error}

} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/databases/DatabaseCard.tsx b/apps/web/src/components/databases/DatabaseCard.tsx new file mode 100644 index 0000000..5be4db1 --- /dev/null +++ b/apps/web/src/components/databases/DatabaseCard.tsx @@ -0,0 +1,122 @@ +import { useState } from "react"; +import { Copy, Eye, EyeOff, Pause, Play, RefreshCw, Trash2 } from "lucide-react"; +import * as api from "../../api/client"; +import type { Database, Project } from "../../types"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Card, CardContent } from "../ui/card"; +import { StatusBadge } from "../StatusBadge"; + +interface DatabaseCardProps { + database: Database; + project?: Project; + onChanged: () => void; + onDelete: (database: Database) => void; +} + +export function DatabaseCard({ database, project, onChanged, onDelete }: DatabaseCardProps) { + const [credentials, setCredentials] = useState> | null>(null); + const [credentialsError, setCredentialsError] = useState(null); + const [actionError, setActionError] = useState(null); + const [isBusy, setIsBusy] = useState(false); + + const reveal = async () => { + if (credentials) return setCredentials(null); + setCredentialsError(null); + try { + setCredentials(await api.getDatabaseCredentials(database.id)); + } catch (err) { + setCredentialsError(err instanceof Error ? err.message : "Could not load credentials"); + } + }; + + const lifecycle = async (action: "start" | "stop" | "restart" | "retry") => { + setIsBusy(true); + setActionError(null); + try { + if (action === "start") await api.startDatabase(database.id); + else if (action === "stop") await api.stopDatabase(database.id); + else if (action === "retry") await api.retryDatabase(database.id); + else await api.restartDatabase(database.id); + onChanged(); + } catch (err) { + setActionError(err instanceof Error ? err.message : "Action failed"); + } finally { + setIsBusy(false); + } + }; + + const storagePercent = database.storageLimitMb + ? Math.min(100, Math.round((database.storageUsedMb / database.storageLimitMb) * 100)) + : 0; + + return ( + + +
+
+
+ {database.type} + +
+

{database.name}

+

{project?.name ?? "Standalone"} · {database.databaseName}

+
+ +
+ +
+
+

Internal endpoint

+

{database.internalHost}:{database.internalPort}

+
+
+

Public port

+

{database.publicAccess ? (database.externalPort ? `:${database.externalPort}` : "Provisioning") : "Disabled"}

+
+
+ +
+
+ Storage usage + = 100 ? "text-red-400" : storagePercent >= 80 ? "text-amber-400" : "text-zinc-300"}> + {database.storageUsedMb} MB{database.storageLimitMb ? ` / ${database.storageLimitMb} MB` : ""} + +
+
+
+
+
+ +
+
+

+ {credentials?.externalConnectionString ?? credentials?.internalConnectionString ?? "Credentials hidden"} +

+
+ + {credentials && } +
+
+ {credentialsError &&

{credentialsError}

} +
+ +
+ {database.status === "stopped" ? ( + + ) : database.status === "failed" ? ( + + ) : ( + + )} + +
+ {actionError &&

{actionError}

} + + + ); +} diff --git a/apps/web/src/components/layout/SidebarNav.tsx b/apps/web/src/components/layout/SidebarNav.tsx index 5e6682d..b01b86b 100644 --- a/apps/web/src/components/layout/SidebarNav.tsx +++ b/apps/web/src/components/layout/SidebarNav.tsx @@ -53,6 +53,18 @@ export function SidebarNav({ Projects + + + Databases + - - -
- - {provisionDb && ( -
-
- - - -
- -
-
- - setDbVersion(e.target.value)} - /> -
-
- - setDbCpu(e.target.value)} - /> -
-
- - setDbMemory(e.target.value)} - /> -
-
-
- )} - - - ); + return ( +
+
+

Application resources

+

+ Configure compute limits for this project. Managed databases are created separately from the Databases page. +

+
+
+
+ + setCpuLimit(event.target.value)} className="h-9 border-[#222227] bg-[#141418] font-mono text-zinc-200 focus:border-amber-500" /> +
+
+ + setMemoryLimitMb(event.target.value)} className="h-9 border-[#222227] bg-[#141418] font-mono text-zinc-200 focus:border-amber-500" /> +
+
+
+ ); } diff --git a/apps/web/src/components/project/databases/DatabasesTab.tsx b/apps/web/src/components/project/databases/DatabasesTab.tsx deleted file mode 100644 index b0fe66b..0000000 --- a/apps/web/src/components/project/databases/DatabasesTab.tsx +++ /dev/null @@ -1,557 +0,0 @@ -import React, { - useState, - useEffect, -} from "react"; -import { useQuery } from "@tanstack/react-query"; -import * as api from "../../../api/client"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "../../ui/card"; -import { Input } from "../../ui/input"; -import { Button } from "../../ui/button"; -import { Badge } from "../../ui/badge"; -import { StatusBadge } from "../../StatusBadge"; -import { - Database, - Trash2, - Plus, - Copy, - Check, - Eye, - EyeOff, -} from "lucide-react"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "../../ui/dialog"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../../ui/select"; - -interface DatabasesTabProps { - projectId: string; -} - -export function DatabasesTab({ - projectId, -}: DatabasesTabProps) { - const { data: databases = [], refetch } = - useQuery({ - queryKey: ["databases", projectId], - queryFn: () => - api.listDatabases(projectId), - refetchInterval: 5000, - }); - - const [isProvisionOpen, setIsProvisionOpen] = - useState(false); - const [deletingDbId, setDeletingDbId] = - useState(null); - - const [dbType, setDbType] = useState< - "postgresql" | "mysql" - >("postgresql"); - const [dbVersion, setDbVersion] = - useState("16-alpine"); - const [dbCpu, setDbCpu] = useState(""); - const [dbMemory, setDbMemory] = useState(""); - const [isProvisioning, setIsProvisioning] = - useState(false); - - useEffect(() => { - setDbVersion( - dbType === "mysql" - ? "8.0" - : "16-alpine", - ); - }, [dbType]); - - const [revealDbConn, setRevealDbConn] = - useState>({}); - const [copiedDbId, setCopiedDbId] = useState< - string | null - >(null); - - const createDb = async () => { - setIsProvisioning(true); - try { - await api.createDatabase( - projectId, - dbType, - { - version: - dbVersion.trim() || - undefined, - cpuLimit: dbCpu.trim() - ? Number(dbCpu) - : null, - memoryLimitMb: dbMemory.trim() - ? Number(dbMemory) - : null, - }, - ); - setDbVersion(""); - setDbCpu(""); - setDbMemory(""); - setIsProvisionOpen(false); - refetch(); - } finally { - setIsProvisioning(false); - } - }; - - const handleDeleteDb = async () => { - if (!deletingDbId) return; - await api.deleteDatabase(deletingDbId); - setDeletingDbId(null); - refetch(); - }; - - const copyConnectionString = ( - id: string, - connStr: string, - ) => { - navigator.clipboard.writeText(connStr); - setCopiedDbId(id); - setTimeout( - () => setCopiedDbId(null), - 1500, - ); - }; - - const toggleRevealDb = (id: string) => { - setRevealDbConn((prev) => ({ - ...prev, - [id]: !prev[id], - })); - }; - - const selectedDbToDelete = databases.find( - (d) => d.id === deletingDbId, - ); - - return ( -
- {databases.length === 0 ? ( -
-
-
-
- -
-

- No Databases Provisioned -

-

- Instantly spin up a - dedicated PostgreSQL or - MySQL instance configured - and secure within your - workspace cluster - networks. -

- -
- ) : ( -
-
-
-

- Managed Databases -

-

- Dedicated database - instances attached - to your service. -

-
- -
- -
- {databases.map((db) => ( - - -
-
-
- - { - db.type - } - - -
-

- { - db.databaseName - } -

-
- -
- - {db.connectionString && ( -
- -
- - {revealDbConn[ - db - .id - ] - ? db.connectionString - : "••••••••••••••••••••••••••••••••"} - -
- - -
-
-
- )} -
-
- ))} -
-
- )} - - {/* Provision Database Dialog */} - - - - - Provision Database - - - Choose database engine - type, version, and - server capacity - boundaries. - - - -
- {/* Database Type Select buttons */} -
- {( - [ - "postgresql", - "mysql", - ] as const - ).map((type) => { - const isSelected = - dbType === - type; - return ( - - ); - })} -
- -
- - -
- -
-
- - - setDbCpu( - e - .target - .value, - ) - } - className="h-10 bg-[#0d0d11] border-input focus:ring-2 focus:ring-primary text-sm font-semibold rounded-lg" - /> -
-
- - - setDbMemory( - e - .target - .value, - ) - } - className="h-10 bg-[#0d0d11] border-input focus:ring-2 focus:ring-primary text-sm font-semibold rounded-lg" - /> -
-
- -
- - -
-
-
-
- - {/* Delete Database Confirmation Dialog */} - - !open && setDeletingDbId(null) - } - > - - - - Delete Database - - - Are you sure you want - to delete database{" "} - - { - selectedDbToDelete?.databaseName - } - - ? This action cannot - be undone and will - permanently destroy - all data tables and - tablespace. - - - - - - - - -
- ); -} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 5b39672..4884211 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -4,6 +4,7 @@ @layer base { :root { + color-scheme: dark; --background: 0 0% 4%; --foreground: 0 0% 95%; --card: 0 0% 6%; @@ -25,11 +26,18 @@ --ring: 24 95% 53%; --radius: 0.5rem; } + + html { + color-scheme: dark; + } } @layer base { * { @apply border-border; + + scrollbar-width: thin; + scrollbar-color: #27272a transparent; } body { diff --git a/apps/web/src/routes/CreateProjectPage.tsx b/apps/web/src/routes/CreateProjectPage.tsx index 7208c34..cc57ddd 100644 --- a/apps/web/src/routes/CreateProjectPage.tsx +++ b/apps/web/src/routes/CreateProjectPage.tsx @@ -99,9 +99,10 @@ export function CreateProjectPage() { const [bulkText, setBulkText] = useState(''); const [fileError, setFileError] = useState(''); - // Provision Database State & Version Selection - const [provisionDb, setProvisionDb] = useState(false); - const [dbType, setDbType] = useState<'postgresql' | 'mysql'>('postgresql'); + // Legacy project-scoped database controls are hidden in favor of /databases. + const provisionDb = false; + const setProvisionDb = (_value: boolean) => {}; + const [dbType, setDbType] = useState('postgresql'); const [dbVersion, setDbVersion] = useState('16-alpine'); const [dbCpu, setDbCpu] = useState(''); const [dbMemory, setDbMemory] = useState(''); @@ -129,11 +130,11 @@ export function CreateProjectPage() { // Update default version when database engine changes useEffect(() => { - if (dbType === 'postgresql') { - setDbVersion('16-alpine'); - } else { - setDbVersion('8.0'); - } + if (dbType === 'postgresql') setDbVersion('16'); + else if (dbType === 'mysql') setDbVersion('8.0'); + else if (dbType === 'mariadb') setDbVersion('11.4'); + else if (dbType === 'redis') setDbVersion('7.4'); + else if (dbType === 'mongodb') setDbVersion('7.0'); }, [dbType]); // Env Parsing helper @@ -256,28 +257,21 @@ export function CreateProjectPage() { }; if (buildType === 'compose') { - payload.composeService = composeServicesList[0]?.serviceName.trim() || undefined; - payload.composePort = composeServicesList[0]?.port.trim() ? Number(composeServicesList[0].port) || null : undefined; - payload.composeServices = JSON.stringify(composeServicesList); + const first = composeServicesList[0]; + payload.composeService = first.serviceName.trim() || undefined; + payload.composePort = first.port.trim() ? Number(first.port) || null : undefined; + const mappings = composeServicesList + .filter((item) => item.serviceName.trim()) + .map((item) => ({ + serviceName: item.serviceName.trim(), + port: item.port.trim() ? Number(item.port) || 0 : undefined, + subdomain: item.subdomain.trim() || undefined, + })); + payload.composeServices = mappings.length ? JSON.stringify(mappings) : undefined; } project = await createProject.mutateAsync(payload); - if (zipFile && sourceType === 'upload') { - const form = new FormData(); - form.append('sourceType', 'upload'); - form.append('projectId', project.id); - form.append('archive', zipFile); - await api.createDeployment(form); - } else if (sourceType === 'git' && repoUrl.trim()) { - const form = new FormData(); - form.append('sourceType', 'git'); - form.append('projectId', project.id); - form.append('gitUrl', repoUrl.trim()); - if (repoBranch.trim()) form.append('branch', repoBranch.trim()); - await api.createDeployment(form); - } - if (stagedEnvs.length > 0) { setSubmittingStatus('creating_envs'); await Promise.all( @@ -291,13 +285,19 @@ export function CreateProjectPage() { ); } - if (provisionDb) { - setSubmittingStatus('creating_db'); - await api.createDatabase(project.id, dbType, { - version: dbVersion.trim() || undefined, - cpuLimit: dbCpu.trim() ? Number(dbCpu) : null, - memoryLimitMb: dbMemory.trim() ? Number(dbMemory) : null, - }); + if (zipFile && sourceType === 'upload') { + const form = new FormData(); + form.append('sourceType', 'upload'); + form.append('projectId', project.id); + form.append('archive', zipFile); + await api.createDeployment(form); + } else if (sourceType === 'git' && repoUrl.trim()) { + const form = new FormData(); + form.append('sourceType', 'git'); + form.append('projectId', project.id); + form.append('gitUrl', repoUrl.trim()); + if (repoBranch.trim()) form.append('branch', repoBranch.trim()); + await api.createDeployment(form); } setSubmittingStatus('done'); @@ -880,7 +880,7 @@ export function CreateProjectPage() {
{/* Managed Database Selection Cards */} -
+
@@ -908,45 +908,85 @@ export function CreateProjectPage() { {provisionDb && (
-
+
+ + + +
@@ -957,17 +997,32 @@ export function CreateProjectPage() { onChange={(e) => setDbVersion(e.target.value)} className="w-full bg-[#0c0c0e] border border-[#222227] rounded-lg px-3 py-2 text-xs text-zinc-200 font-mono focus:outline-none focus:border-orange-500" > - {dbType === 'postgresql' ? ( + {dbType === 'postgresql' && ( <> - - - - + + + - ) : ( + )} + {dbType === 'mysql' && ( + <> + + + + + )} + {dbType === 'redis' && ( + <> + + + + + )} + {dbType === 'mongodb' && ( <> - - + + + )} diff --git a/apps/web/src/routes/Databases.tsx b/apps/web/src/routes/Databases.tsx new file mode 100644 index 0000000..385c512 --- /dev/null +++ b/apps/web/src/routes/Databases.tsx @@ -0,0 +1,42 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Database, Plus } from "lucide-react"; +import * as api from "../api/client"; +import type { Database as DatabaseRecord } from "../types"; +import { CreateDatabaseDialog } from "../components/databases/CreateDatabaseDialog"; +import { DatabaseCard } from "../components/databases/DatabaseCard"; +import { Button } from "../components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../components/ui/dialog"; + +export function Databases() { + const [isCreating, setIsCreating] = useState(false); + const [deleting, setDeleting] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(null); + const databases = useQuery({ queryKey: ["databases"], queryFn: api.listAllDatabases, refetchInterval: 10_000 }); + const projects = useQuery({ queryKey: ["projects"], queryFn: api.listProjects }); + const refresh = () => void databases.refetch(); + + return ( +
+
+

Data services

Managed databases

Provision persistent databases independently, or attach one to a project at creation time. Every database receives an internal endpoint and credential-protected public endpoint.

+ +
+ + {databases.isError ? ( +
+

Failed to load managed databases.

+ +
+ ) : databases.isLoading ?
: databases.data?.length ? ( +
{databases.data.map((database) => project.id === database.projectId)} onChanged={refresh} onDelete={setDeleting} />)}
+ ) : ( +

No managed databases

Create a standalone database, or attach one to a project at creation time.

+ )} + + + !open && setDeleting(null)}>Delete databaseThis permanently removes {deleting?.name}, its container, public endpoint, and stored data.{deleteError &&

{deleteError}

}
+
+ ); +} diff --git a/apps/web/src/routes/ProjectDetail.tsx b/apps/web/src/routes/ProjectDetail.tsx index f4d5627..7e09327 100644 --- a/apps/web/src/routes/ProjectDetail.tsx +++ b/apps/web/src/routes/ProjectDetail.tsx @@ -14,7 +14,6 @@ import { ArrowLeft, FolderKanban } from "lucide-react"; import { DeploymentsTab } from "../components/project/deployments/DeploymentsTab"; import { EnvVarsTab } from "../components/project/envtab/EnvVarsTab"; import { VolumesTab } from "../components/project/volumes/VolumesTab"; -import { DatabasesTab } from "../components/project/databases/DatabasesTab"; import { DomainsTab } from "../components/project/domains/DomainsTab"; import { ScalingTab } from "../components/project/scaling/ScalingTab"; import { AlertsTab } from "../components/project/alerts/AlertsTab"; @@ -128,9 +127,6 @@ export function ProjectDetail({ Volumes - - Databases - Domains @@ -166,11 +162,6 @@ export function ProjectDetail({ projectId={projectId} /> - - - ( @@ -38,6 +39,12 @@ const createProjectRoute = createRoute({ component: CreateProjectPage, }); +const databasesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/databases', + component: Databases, +}); + const ProjectPage = () => { const { projectId } = projectRoute.useParams(); return ; @@ -52,7 +59,7 @@ const projectRoute = createRoute({ }), }); -const routeTree = rootRoute.addChildren([indexRoute, loginRoute, settingsRoute, projectRoute, createProjectRoute]); +const routeTree = rootRoute.addChildren([indexRoute, loginRoute, settingsRoute, databasesRoute, projectRoute, createProjectRoute]); export const router = createRouter({ routeTree }); diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts index ecbba14..5c88ea8 100644 --- a/apps/web/src/types/index.ts +++ b/apps/web/src/types/index.ts @@ -1,8 +1,8 @@ export type DeploymentStatus = 'pending' | 'building' | 'deploying' | 'running' | 'failed' | 'inactive'; export type SourceType = 'git' | 'upload' | 'image' | 'compose'; export type LogStage = 'build' | 'deploy' | 'system'; -export type DatabaseType = 'postgresql' | 'mysql'; -export type DatabaseStatus = 'provisioning' | 'running' | 'failed'; +export type DatabaseType = 'postgresql' | 'mysql' | 'redis' | 'mongodb'; +export type DatabaseStatus = 'provisioning' | 'running' | 'stopped' | 'restarting' | 'deleting' | 'deletion_failed' | 'failed'; export type DomainType = 'base' | 'custom'; export type DomainValidationStatus = 'pending' | 'verified' | 'failed'; export type SslStatus = 'pending' | 'provisioned' | 'failed'; @@ -73,7 +73,8 @@ export interface Volume { export interface Database { id: string; - projectId: string; + projectId: string | null; + name: string; type: DatabaseType; version: string | null; databaseName: string; @@ -83,6 +84,14 @@ export interface Database { internalPort: number; cpuLimit: number | null; memoryLimitMb: number | null; + storageLimitMb: number | null; + storageUsedMb: number; + publicAccess: boolean; + allowPublicAccessFromAnywhere: boolean; + allowedCidrs: string[]; + externalPort: number | null; + proxyContainerName: string | null; + volumeName: string; connectionString: string; status: DatabaseStatus; containerName: string | null;