diff --git a/.gitignore b/.gitignore index 4994d5c..9a00a7c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ bump.sh scripts/workflow/bump.sh __pycache__ .tegami/changes-* +SETUP_GUIDE.md +bun.lock diff --git a/apps/api/package.json b/apps/api/package.json index 8b5b81f..4985557 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,7 +14,8 @@ "drizzle-orm": "^0.45.2", "elysia": "^1.1.26", "ioredis": "^5.4.1", - "nodemailer": "^8.0.10" + "nodemailer": "^8.0.10", + "yaml": "^2.9.0" }, "devDependencies": { "@types/nodemailer": "^8.0.0", diff --git a/apps/api/src/api/deployments/index.ts b/apps/api/src/api/deployments/index.ts index 7cf7d66..a84b8f9 100644 --- a/apps/api/src/api/deployments/index.ts +++ b/apps/api/src/api/deployments/index.ts @@ -158,6 +158,13 @@ export const deploymentsRoutes = new Elysia() error: "Cannot rollback to a deployment that is still in progress", }; } + const project = target.projectId ? await getProjectById(target.projectId) : null; + if (project?.buildType === "compose") { + set.status = 400; + return { + error: "Rollback is not supported for Docker Compose deployments", + }; + } try { await orchestrator.rollbackTo(id); const updated = await getDeploymentById(id); diff --git a/apps/api/src/api/domains/index.ts b/apps/api/src/api/domains/index.ts index bc899ca..10fd9ed 100644 --- a/apps/api/src/api/domains/index.ts +++ b/apps/api/src/api/domains/index.ts @@ -7,6 +7,7 @@ import { listDomains, updateDomainValidation, } from "../../db/repo"; +import { SERVICE_NAME_RE, isPort } from "../../utils/validate"; export const domainsRoutes = new Elysia() .get( @@ -20,10 +21,21 @@ export const domainsRoutes = new Elysia() set.status = 400; return { error: "domain is required" }; } + if (body.targetService && !SERVICE_NAME_RE.test(String(body.targetService))) { + set.status = 400; + return { error: "targetService may only contain letters, numbers, underscores and hyphens" }; + } + const targetPort = body.targetPort ? Number(body.targetPort) : null; + if (targetPort !== null && !isPort(targetPort)) { + set.status = 400; + return { error: "targetPort must be an integer between 1 and 65535" }; + } const domain = await createDomain({ projectId: params.id, domain: body.domain, type: body.type ?? "custom", + targetService: body.targetService || null, + targetPort, }); const { validateDomain, resolveServerIp } = await import( "../../utils/dns", diff --git a/apps/api/src/api/projects/index.ts b/apps/api/src/api/projects/index.ts index 92fad65..a0cf7b9 100644 --- a/apps/api/src/api/projects/index.ts +++ b/apps/api/src/api/projects/index.ts @@ -13,6 +13,24 @@ import { tryRun, reloadCaddy } from "../../orchestrator/runtime"; import { removeFromCaddyRoute } from "../../utils/domain-verifier"; import { config } from "../../utils/config"; import { dockerBin } from "../../utils/docker-bin"; +import { SERVICE_NAME_RE, isPort, validateComposeServices } from "../../utils/validate"; + +const validateComposeFields = (body: any): string | null => { + if (body?.composeService && !SERVICE_NAME_RE.test(String(body.composeService))) { + return "composeService may only contain letters, numbers, underscores and hyphens"; + } + if (body?.composePort) { + const port = Number(body.composePort); + if (!isPort(port)) { + return "composePort must be an integer between 1 and 65535"; + } + } + if (body?.composeServices) { + const result = validateComposeServices(body.composeServices); + if (!result.ok) return result.error; + } + return null; +}; export const projectsRoutes = new Elysia() .get("/projects", async () => listProjects()) @@ -34,6 +52,11 @@ export const projectsRoutes = new Elysia() set.status = 400; return { error: "name is required" }; } + const validationError = validateComposeFields(body); + if (validationError) { + set.status = 400; + return { error: validationError }; + } const project = await createProject({ name: body.name, description: body.description, @@ -46,6 +69,10 @@ export const projectsRoutes = new Elysia() sourceDir: body.sourceDir || null, sourceType: body.sourceType || "git", projectType: body.projectType || "web", + buildType: body.buildType || "railpack", + composeService: body.composeService || null, + 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, startCommand: body.startCommand || undefined, }); @@ -55,6 +82,11 @@ export const projectsRoutes = new Elysia() .patch( "/projects/:id", async ({ params: { id }, body, set }: any) => { + const validationError = validateComposeFields(body); + if (validationError) { + set.status = 400; + return { error: validationError }; + } const project = await updateProject(id, { name: body?.name, description: body?.description, @@ -66,6 +98,10 @@ export const projectsRoutes = new Elysia() port: body?.port ? Number(body.port) : body?.port === null ? null : undefined, sourceDir: body?.sourceDir ?? 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, startCommand: "startCommand" in (body ?? {}) ? (body.startCommand ?? "") || null : undefined, }); diff --git a/apps/api/src/databases/manager.ts b/apps/api/src/databases/manager.ts index 8c02b04..d05bc29 100644 --- a/apps/api/src/databases/manager.ts +++ b/apps/api/src/databases/manager.ts @@ -24,59 +24,64 @@ const tryRun = (cmd: string, args: string[]) => export const provisionDatabase = async (dbRecord: Database): Promise => { const containerName = dbRecord.internalHost; - const version = dbRecord.version || (dbRecord.type === 'mysql' ? '8.0' : '16-alpine'); - const image = dbRecord.type === 'mysql' ? `mysql:${version}` : `postgres:${version}`; + try { + const version = dbRecord.version || (dbRecord.type === 'mysql' ? '8.0' : '16-alpine'); + const image = dbRecord.type === 'mysql' ? `mysql:${version}` : `postgres:${version}`; - // Pull image first (non-blocking) - await tryRun(dockerBin, ['pull', image]); + await tryRun(dockerBin, ['pull', image]); - 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 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 volumeName = `db-${dbRecord.id.slice(0, 12)}`; + const volumeName = `db-${dbRecord.id.slice(0, 12)}`; - // Create volume - await tryRun(dockerBin, ['volume', 'create', volumeName]); + await tryRun(dockerBin, ['volume', 'create', volumeName]); + await tryRun(dockerBin, ['rm', '-f', containerName]); - const args = [ - 'run', '-d', - '--name', containerName, - '--network', config.dockerNetwork, - '--network-alias', containerName, - '-l', DEQUEL_MANAGED_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`, - ...envVars.flatMap(e => ['-e', e]), - image, - ]; + const args = [ + 'run', '-d', + '--name', containerName, + '--network', config.dockerNetwork, + '--network-alias', containerName, + '-l', DEQUEL_MANAGED_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`, + ...envVars.flatMap(e => ['-e', e]), + image, + ]; - await run(dockerBin, args); + await run(dockerBin, args); - // Wait for healthy - for (let i = 0; i < 30; i++) { - try { - const status = await run(dockerBin, ['inspect', '-f', '{{.State.Status}}', containerName]); - if (status.trim() === 'running') { - await updateDatabaseStatus(dbRecord.id, 'running', containerName); - return; - } - } catch {} - await new Promise(r => setTimeout(r, 2000)); - } + for (let i = 0; i < 30; i++) { + try { + const status = await run(dockerBin, ['inspect', '-f', '{{.State.Status}}', containerName]); + if (status.trim() === 'running') { + await updateDatabaseStatus(dbRecord.id, 'running', containerName); + return; + } + } catch {} + await new Promise(r => setTimeout(r, 2000)); + } - await updateDatabaseStatus(dbRecord.id, 'failed', containerName); + 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 updateDatabaseStatus(dbRecord.id, 'failed', containerName).catch(() => {}); + throw err; + } }; export const deprovisionDatabase = async (dbRecord: Database): Promise => { diff --git a/apps/api/src/db/migrations/0004_early_sunfire.sql b/apps/api/src/db/migrations/0004_early_sunfire.sql new file mode 100644 index 0000000..3d35187 --- /dev/null +++ b/apps/api/src/db/migrations/0004_early_sunfire.sql @@ -0,0 +1,3 @@ +ALTER TABLE `projects` ADD `build_type` text DEFAULT 'railpack' NOT NULL;--> statement-breakpoint +ALTER TABLE `projects` ADD `compose_service` text;--> statement-breakpoint +ALTER TABLE `projects` ADD `compose_port` integer; \ No newline at end of file diff --git a/apps/api/src/db/migrations/0005_famous_grandmaster.sql b/apps/api/src/db/migrations/0005_famous_grandmaster.sql new file mode 100644 index 0000000..663e8e1 --- /dev/null +++ b/apps/api/src/db/migrations/0005_famous_grandmaster.sql @@ -0,0 +1,2 @@ +ALTER TABLE `domains` ADD `target_service` text;--> statement-breakpoint +ALTER TABLE `domains` ADD `target_port` integer; \ No newline at end of file diff --git a/apps/api/src/db/migrations/0006_cheerful_vance_astro.sql b/apps/api/src/db/migrations/0006_cheerful_vance_astro.sql new file mode 100644 index 0000000..6470972 --- /dev/null +++ b/apps/api/src/db/migrations/0006_cheerful_vance_astro.sql @@ -0,0 +1 @@ +ALTER TABLE `projects` ADD `compose_services` text; \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/0004_snapshot.json b/apps/api/src/db/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..91db366 --- /dev/null +++ b/apps/api/src/db/migrations/meta/0004_snapshot.json @@ -0,0 +1,1347 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9f22f4b0-41ab-482d-b095-81d9e6735b7c", + "prevId": "b951d02c-f63a-48e5-925c-aac34aa7a759", + "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": 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 + }, + "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'" + }, + "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 + }, + "build_command": { + "name": "build_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/0005_snapshot.json b/apps/api/src/db/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..f9fbea8 --- /dev/null +++ b/apps/api/src/db/migrations/meta/0005_snapshot.json @@ -0,0 +1,1361 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c3c49a94-32f1-4700-a21d-1a2b1ab78644", + "prevId": "9f22f4b0-41ab-482d-b095-81d9e6735b7c", + "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": 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 + }, + "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 + }, + "build_command": { + "name": "build_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/0006_snapshot.json b/apps/api/src/db/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..c7af04c --- /dev/null +++ b/apps/api/src/db/migrations/meta/0006_snapshot.json @@ -0,0 +1,1368 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c4054dd7-dc6f-44e1-90e6-7e5ae9eaa8f9", + "prevId": "c3c49a94-32f1-4700-a21d-1a2b1ab78644", + "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": 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 + }, + "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 + }, + "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 f2fdd56..038b6a3 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -29,6 +29,27 @@ "when": 1784681960309, "tag": "0003_aberrant_viper", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1785247979558, + "tag": "0004_early_sunfire", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1785254008342, + "tag": "0005_famous_grandmaster", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1785409331053, + "tag": "0006_cheerful_vance_astro", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/repo/domains.ts b/apps/api/src/db/repo/domains.ts index 3885059..a0ab10a 100644 --- a/apps/api/src/db/repo/domains.ts +++ b/apps/api/src/db/repo/domains.ts @@ -12,6 +12,8 @@ const mapDomain = (row: typeof domains.$inferSelect): Domain => ({ type: row.type as Domain["type"], validationStatus: row.validationStatus as DomainValidationStatus, sslStatus: row.sslStatus as SslStatus, + targetService: row.targetService ?? null, + targetPort: row.targetPort ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, }); @@ -27,6 +29,8 @@ export const createDomain = async (input: CreateDomainInput): Promise => type: input.type, validationStatus: "pending", sslStatus: "pending", + targetService: input.targetService ?? null, + targetPort: input.targetPort ?? null, createdAt: timestamp, updatedAt: timestamp, }).run(); diff --git a/apps/api/src/db/repo/projects.ts b/apps/api/src/db/repo/projects.ts index a7086a2..850c8a1 100644 --- a/apps/api/src/db/repo/projects.ts +++ b/apps/api/src/db/repo/projects.ts @@ -29,6 +29,10 @@ const mapProject = (row: typeof projects.$inferSelect): Project => ({ sourceDir: row.sourceDir ?? null, sourceType: row.sourceType, projectType: row.projectType, + buildType: row.buildType, + composeService: row.composeService ?? null, + composePort: row.composePort ?? null, + composeServices: row.composeServices ?? null, buildCommand: row.buildCommand ?? null, startCommand: row.startCommand ?? null, githubTokenEncrypted: row.githubTokenEncrypted ?? null, @@ -55,6 +59,10 @@ export const createProject = async (input: CreateProjectInput): Promise sourceDir: input.sourceDir ?? null, sourceType: input.sourceType ?? "git", projectType: input.projectType ?? "web", + buildType: input.buildType ?? "railpack", + composeService: input.composeService ?? null, + composePort: input.composePort ?? null, + composeServices: input.composeServices ?? null, buildCommand: input.buildCommand ?? null, startCommand: input.startCommand ?? null, createdAt: timestamp, @@ -95,6 +103,10 @@ export const updateProject = async (id: string, patch: Partial [ diff --git a/apps/api/src/orchestrator/__tests__/compose.test.ts b/apps/api/src/orchestrator/__tests__/compose.test.ts new file mode 100644 index 0000000..fcde5b3 --- /dev/null +++ b/apps/api/src/orchestrator/__tests__/compose.test.ts @@ -0,0 +1,123 @@ +import { describe, test, expect } from "bun:test"; +import { parseComposeTarget, parseContainerTargetPort, extractComposeServices, parseAllComposeServices } from "../compose"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +describe("parseContainerTargetPort", () => { + test("parses number ports", () => { + expect(parseContainerTargetPort(8080)).toBe(8080); + }); + + test("parses host-bound IP mapping 127.0.0.1:8080:80", () => { + expect(parseContainerTargetPort("127.0.0.1:8080:80")).toBe(80); + }); + + test("parses mapping 8080:80", () => { + expect(parseContainerTargetPort("8080:80")).toBe(80); + }); + + test("parses string with protocol suffix 0.0.0.0:9000:9000/tcp", () => { + expect(parseContainerTargetPort("0.0.0.0:9000:9000/tcp")).toBe(9000); + }); + + test("parses long-form port object", () => { + expect(parseContainerTargetPort({ target: 8080, published: 80 })).toBe(8080); + }); +}); + +describe("parseComposeTarget", () => { + test("parses single service with ports correctly", () => { + const dir = mkdtempSync(join(tmpdir(), "dequel-compose-test-")); + try { + const yaml = ` +version: '3.8' +services: + server: + build: . + ports: + - "3001" +`; + writeFileSync(join(dir, "docker-compose.yml"), yaml); + const target = parseComposeTarget(dir); + expect(target.serviceName).toBe("server"); + expect(target.port).toBe(3001); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("parses mapping with host:container ports", () => { + const dir = mkdtempSync(join(tmpdir(), "dequel-compose-test-")); + try { + const yaml = ` +version: '3.8' +services: + web: + image: nginx + ports: + - "127.0.0.1:8080:80" + db: + image: postgres +`; + writeFileSync(join(dir, "docker-compose.yml"), yaml); + const target = parseComposeTarget(dir); + expect(target.serviceName).toBe("web"); + expect(target.port).toBe(80); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("respects preferred service and port overrides", () => { + const dir = mkdtempSync(join(tmpdir(), "dequel-compose-test-")); + try { + const yaml = ` +version: '3.8' +services: + api: + build: ./api + web: + build: ./web +`; + writeFileSync(join(dir, "docker-compose.yml"), yaml); + const target = parseComposeTarget(dir, null, "api", 4000); + expect(target.serviceName).toBe("api"); + expect(target.port).toBe(4000); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + +describe("parseAllComposeServices", () => { + test("extracts all services with target ports and long-form syntax", () => { + const dir = mkdtempSync(join(tmpdir(), "dequel-compose-test-")); + try { + const yaml = ` +version: '3.8' +services: + web: + build: ./web + ports: + - "127.0.0.1:8080:80" + api: + build: ./api + ports: + - target: 5000 + published: 8080 +`; + writeFileSync(join(dir, "docker-compose.yml"), yaml); + const services = parseAllComposeServices(dir); + expect(services.length).toBe(2); + const web = services.find((s) => s.serviceName === "web"); + const api = services.find((s) => s.serviceName === "api"); + expect(web?.port).toBe(80); + expect(web?.isPrimary).toBe(true); + expect(api?.port).toBe(5000); + expect(api?.isPrimary).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/api/src/orchestrator/compose-deploy.ts b/apps/api/src/orchestrator/compose-deploy.ts new file mode 100644 index 0000000..631cdf6 --- /dev/null +++ b/apps/api/src/orchestrator/compose-deploy.ts @@ -0,0 +1,129 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { config } from "../utils/config"; +import { buildCaddySnippet } from "../utils/domain-verifier"; +import { dockerBin } from "../utils/docker-bin"; +import { deployWithCompose, parseAllComposeServices, destroyComposeStack, getComposeContainerNames } from "./compose"; +import { reloadCaddy, tryRun } from "./runtime"; + +export interface ComposeDeployResult { + containerName: string; + liveUrl: string; +} + +export interface ComposeDeployOptions { + workspacePath: string; + deploymentId: string; + projectId: string; + projectName: string; + sourceDir?: string | null; + composeService?: string | null; + composePort?: number | null; + composeServicesJson?: string | null; + oldDeploymentId?: string | null; + envVars?: Record; + signal?: AbortSignal; + onLog: (line: string) => Promise; +} + +export const deployComposeStack = async ( + options: ComposeDeployOptions, +): Promise => { + const { + workspacePath, + deploymentId, + projectId, + projectName, + sourceDir, + composeService, + composePort, + composeServicesJson, + oldDeploymentId, + envVars, + signal, + onLog, + } = options; + + if (oldDeploymentId) { + await onLog(`Stopping previous compose stack deploy-${oldDeploymentId}`); + await destroyComposeStack(`deploy-${oldDeploymentId}`); + } + + await deployWithCompose( + workspacePath, + `deploy-${deploymentId}`, + onLog, + sourceDir, + envVars, + signal, + ); + + const allServices = parseAllComposeServices( + workspacePath, + sourceDir, + composeService, + composePort, + ); + + const composeContainers = await getComposeContainerNames(`deploy-${deploymentId}`); + const containerFor = (serviceName: string) => + composeContainers.get(serviceName) || `deploy-${deploymentId}-${serviceName}-1`; + + for (const s of allServices) { + const cName = containerFor(s.serviceName); + await tryRun(dockerBin, ["network", "connect", config.dockerNetwork, cName]); + } + + const target = allServices.find((s) => s.isPrimary) || allServices[0]; + const containerName = containerFor(target.serviceName); + + const slug = projectName + ? projectName.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 63) + : projectId; + + let snippet = await buildCaddySnippet( + slug, + containerName, + projectId, + undefined, + target.port, + ); + + const rawBaseDomain = config.caddyBaseDomain || 'localhost'; + const baseDomainForCaddy = rawBaseDomain === 'localhost' ? `${rawBaseDomain}:80` : rawBaseDomain; + let customMappings: { serviceName: string; port: number | string; subdomain?: string }[] = []; + if (composeServicesJson) { + try { + customMappings = JSON.parse(composeServicesJson); + } catch (e) {} + } + + for (const s of allServices) { + if (s.isPrimary) continue; + const sContainer = containerFor(s.serviceName); + const customMatch = customMappings.find((c) => c.serviceName === s.serviceName); + const domains: string[] = []; + if (customMatch?.subdomain?.trim()) { + domains.push(`${customMatch.subdomain.trim()}.${slug}.${baseDomainForCaddy}`); + } else { + domains.push(`${s.serviceName}.${slug}.${baseDomainForCaddy}`); + if (s.serviceName === "server" && !domains.includes(`api.${slug}.${baseDomainForCaddy}`)) { + domains.push(`api.${slug}.${baseDomainForCaddy}`); + } + } + const targetPort = customMatch?.port ? Number(customMatch.port) : s.port; + snippet += `\n${domains.join(", ")} {\n log {\n output stdout\n format json\n }\n reverse_proxy ${sContainer}:${targetPort} {\n header_up Host {upstream_hostport}\n }\n}\n`; + } + + const caddyRouteFile = join(config.caddyRoutesDir, `${slug}.caddy`); + await writeFile(caddyRouteFile, snippet, "utf8"); + try { + await reloadCaddy(); + } catch { + console.warn("[Pipeline] Caddy not ready for reload after compose deploy"); + } + + const liveUrl = rawBaseDomain === 'localhost' ? `http://${slug}.localhost` : `https://${slug}.${rawBaseDomain}`; + + return { containerName, liveUrl }; +}; diff --git a/apps/api/src/orchestrator/compose.ts b/apps/api/src/orchestrator/compose.ts new file mode 100644 index 0000000..e80c9b9 --- /dev/null +++ b/apps/api/src/orchestrator/compose.ts @@ -0,0 +1,369 @@ +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { dirname, join, resolve, sep } from "node:path"; +import { spawn } from "node:child_process"; +import { dockerBin } from "../utils/docker-bin"; +import { CancelledError } from "./railpack"; + +export interface ComposeTarget { + serviceName: string; + port: number; +} + +export const resolveComposeDir = (workspacePath: string, sourceDir?: string | null): string | null => { + let resolvedWorkspace: string; + try { + resolvedWorkspace = realpathSync(workspacePath); + } catch { + return null; + } + const candidate = sourceDir ? resolve(resolvedWorkspace, sourceDir.replace(/^\/+/, "")) : resolvedWorkspace; + if (candidate !== resolvedWorkspace && !candidate.startsWith(resolvedWorkspace + sep)) { + return null; + } + let realCandidate: string; + try { + realCandidate = realpathSync(candidate); + } catch { + return null; + } + if (realCandidate !== resolvedWorkspace && !realCandidate.startsWith(resolvedWorkspace + sep)) { + return null; + } + return realCandidate; +}; + +export const findComposeFilePath = (workspacePath: string, sourceDir?: string | null): string | null => { + const base = resolveComposeDir(workspacePath, sourceDir); + if (!base) return null; + const ymlPath = join(base, "docker-compose.yml"); + if (existsSync(ymlPath)) return ymlPath; + const yamlPath = join(base, "docker-compose.yaml"); + if (existsSync(yamlPath)) return yamlPath; + return null; +}; + +import { parse as parseYaml } from "yaml"; + +export interface ExtractedComposeService { + serviceName: string; + port: number | null; +} + +export const parseContainerTargetPort = (entry: any): number | null => { + if (typeof entry === "number") { + return isNaN(entry) || entry <= 0 ? null : entry; + } + if (typeof entry === "object" && entry !== null) { + if ("target" in entry) { + const num = Number(entry.target); + return !isNaN(num) && num > 0 ? num : null; + } + } + if (typeof entry === "string") { + let str = entry.trim().replace(/^["']|["']$/g, ""); + str = str.split("/")[0].trim(); + if (!str) return null; + const parts = str.split(":"); + const lastPart = parts[parts.length - 1]?.trim(); + const num = Number(lastPart); + return !isNaN(num) && num > 0 ? num : null; + } + return null; +}; + +export const extractComposeServices = ( + workspacePath: string, + sourceDir?: string | null, +): ExtractedComposeService[] => { + const composeFile = findComposeFilePath(workspacePath, sourceDir); + if (!composeFile) return []; + + const content = readFileSync(composeFile, "utf-8"); + let parsed: any; + try { + parsed = parseYaml(content); + } catch { + return []; + } + + if (!parsed || typeof parsed !== "object" || !parsed.services || typeof parsed.services !== "object") { + return []; + } + + const result: ExtractedComposeService[] = []; + const serviceEntries = Object.entries(parsed.services); + + for (const [serviceName, serviceConfig] of serviceEntries) { + if (!serviceName) continue; + let detectedPort: number | null = null; + const configObj = (serviceConfig && typeof serviceConfig === "object") ? serviceConfig : {}; + + if (Array.isArray((configObj as any).ports)) { + for (const portEntry of (configObj as any).ports) { + const portNum = parseContainerTargetPort(portEntry); + if (portNum !== null) { + detectedPort = portNum; + break; + } + } + } + + result.push({ + serviceName, + port: detectedPort, + }); + } + + return result; +}; + +export const parseComposeTarget = ( + workspacePath: string, + sourceDir?: string | null, + preferredService?: string | null, + preferredPort?: number | null, +): ComposeTarget => { + const composeFile = findComposeFilePath(workspacePath, sourceDir); + if (!composeFile) { + throw new Error("No docker-compose.yml or docker-compose.yaml found in workspace."); + } + + const services = extractComposeServices(workspacePath, sourceDir); + if (services.length === 0) { + throw new Error("Could not detect any services in docker-compose.yml."); + } + + const serviceNames = services.map((s) => s.serviceName); + const servicePortMap: Record = {}; + for (const s of services) { + servicePortMap[s.serviceName] = s.port; + } + + let targetService = preferredService?.trim() || ""; + let targetPort = preferredPort || 0; + + if (!targetService) { + const commonNames = ["web", "frontend", "client", "app", "server", "api"]; + for (const name of commonNames) { + if (serviceNames.includes(name)) { + targetService = name; + break; + } + } + if (!targetService && serviceNames.length > 0) { + targetService = serviceNames[0]; + } + } + + if (!targetService) { + throw new Error("Could not detect any services in docker-compose.yml."); + } + + if (!targetPort) { + targetPort = servicePortMap[targetService] || 3000; + } + + return { + serviceName: targetService, + port: targetPort, + }; +}; + +export interface ComposeService { + serviceName: string; + port: number; + isPrimary: boolean; +} + +export const parseAllComposeServices = ( + workspacePath: string, + sourceDir?: string | null, + preferredService?: string | null, + preferredPort?: number | null, +): ComposeService[] => { + const primaryTarget = parseComposeTarget(workspacePath, sourceDir, preferredService, preferredPort); + const services = extractComposeServices(workspacePath, sourceDir); + if (services.length === 0) { + return [{ ...primaryTarget, isPrimary: true }]; + } + + return services.map((s) => ({ + serviceName: s.serviceName, + port: s.serviceName === primaryTarget.serviceName ? primaryTarget.port : (s.port || 3000), + isPrimary: s.serviceName === primaryTarget.serviceName, + })); +}; + +const spawnComposeCommand = ( + args: string[], + cwd: string, + envVars?: Record, + onLog?: (line: string) => Promise, + signal?: AbortSignal, +): Promise<{ code: number; stdout: string; stderr: string }> => { + return new Promise((resolve, reject) => { + const child = spawn(dockerBin, ["compose", ...args], { + cwd, + env: { ...process.env, ...(envVars || {}) }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let settled = false; + + const finish = (result?: { code: number; stdout: string; stderr: string }, error?: Error) => { + if (settled) return; + settled = true; + if (signal) { + signal.removeEventListener("abort", onAbort); + } + if (error) { + reject(error); + return; + } + resolve(result!); + }; + + const onAbort = () => { + child.kill("SIGTERM"); + finish(undefined, new CancelledError()); + }; + + if (signal) { + if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + } + + child.stdout.on("data", (chunk) => { + const str = String(chunk); + stdout += str; + if (onLog) { + for (const line of str.split("\n").map((l) => l.trim()).filter(Boolean)) { + void onLog(line); + } + } + }); + + child.stderr.on("data", (chunk) => { + const str = String(chunk); + stderr += str; + if (onLog) { + for (const line of str.split("\n").map((l) => l.trim()).filter(Boolean)) { + void onLog(line); + } + } + }); + + child.on("error", (err) => finish(undefined, err)); + child.on("close", (code) => { + finish({ code: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim() }); + }); + }); +}; + +export const buildWithCompose = async ( + workspacePath: string, + projectName: string, + onLog: (line: string) => Promise, + sourceDir?: string | null, + envVars?: Record, + signal?: AbortSignal, +): Promise => { + const composeFile = findComposeFilePath(workspacePath, sourceDir); + if (!composeFile) { + throw new Error("No docker-compose.yml found in workspace."); + } + + const cwd = dirname(composeFile); + await onLog(`Starting Docker Compose build (project: ${projectName})...`); + + const res = await spawnComposeCommand(["-f", composeFile, "-p", projectName, "build"], cwd, envVars, onLog, signal); + if (res.code !== 0) { + throw new Error(`docker compose build failed: ${res.stderr || res.stdout}`); + } + + await onLog("Docker Compose build completed successfully."); +}; + +export const deployWithCompose = async ( + workspacePath: string, + projectName: string, + onLog: (line: string) => Promise, + sourceDir?: string | null, + envVars?: Record, + signal?: AbortSignal, +): Promise => { + const composeFile = findComposeFilePath(workspacePath, sourceDir); + if (!composeFile) { + throw new Error("No docker-compose.yml found in workspace."); + } + + const cwd = dirname(composeFile); + await onLog(`Starting Docker Compose services (project: ${projectName})...`); + + const res = await spawnComposeCommand(["-f", composeFile, "-p", projectName, "up", "-d"], cwd, envVars, onLog, signal); + if (res.code !== 0) { + throw new Error(`docker compose up failed: ${res.stderr || res.stdout}`); + } + + await onLog("Docker Compose stack started."); +}; + +export const destroyComposeStack = async (projectName: string): Promise => { + const filter = `label=com.docker.compose.project=${projectName}`; + const ps = await runDocker(["ps", "-aq", "--filter", filter]); + const ids = ps.stdout.split(/\s+/).filter(Boolean); + for (const id of ids) { + await runDocker(["rm", "-f", id]); + } + await runDocker(["network", "rm", `${projectName}_default`]).catch(() => {}); +}; + +export const getComposeContainerNames = async ( + projectName: string, +): Promise> => { + const res = await runDocker([ + "ps", + "-a", + "--filter", + `label=com.docker.compose.project=${projectName}`, + "--format", + '{{.Label "com.docker.compose.service"}}|{{.Names}}', + ]); + const map = new Map(); + for (const line of res.stdout.split("\n")) { + const [service, name] = line.split("|"); + if (service && name) map.set(service, name); + } + return map; +}; + +const runDocker = ( + args: string[], +): Promise<{ code: number; stdout: string; stderr: string }> => { + return new Promise((resolve, reject) => { + const child = spawn(dockerBin, args, { + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + + child.on("error", reject); + child.on("close", (code) => { + resolve({ code: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim() }); + }); + }); +}; diff --git a/apps/api/src/orchestrator/pipeline.ts b/apps/api/src/orchestrator/pipeline.ts index a757519..de8f480 100644 --- a/apps/api/src/orchestrator/pipeline.ts +++ b/apps/api/src/orchestrator/pipeline.ts @@ -1,4 +1,5 @@ -import { rm } from "node:fs/promises"; +import { rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; import { appendLog, deleteDeploymentAndLogs, @@ -29,6 +30,8 @@ import { tryRun, } from "./runtime"; import { ensureProjectDashboard } from "../utils/grafana"; +import { buildWithCompose, destroyComposeStack } from "./compose"; +import { deployComposeStack } from "./compose-deploy"; const now = () => new Date() @@ -146,22 +149,37 @@ export class PipelineOrchestrator { await this.queue.remove(deploymentId); - if (deployment.containerName) { - await tryRun("docker", [ - "stop", - "-t", - "5", - deployment.containerName, - ]); - await tryRun("docker", [ - "rm", - "-f", - deployment.containerName, - ]); - } + const project = deployment.projectId + ? await getProjectById(deployment.projectId) + : null; + + if (project?.buildType === "compose") { + await destroyComposeStack( + `deploy-${deploymentId}`, + ).catch((e) => + console.warn( + `[Pipeline] Compose teardown failed for ${deploymentId}:`, + e, + ), + ); + } else { + if (deployment.containerName) { + await tryRun("docker", [ + "stop", + "-t", + "5", + deployment.containerName, + ]); + await tryRun("docker", [ + "rm", + "-f", + deployment.containerName, + ]); + } - if (deployment.imageTag && deployment.sourceType !== "image") { - await tryRun("docker", ["rmi", "-f", deployment.imageTag]); + if (deployment.imageTag && deployment.sourceType !== "image") { + await tryRun("docker", ["rmi", "-f", deployment.imageTag]); + } } await deleteDeploymentAndLogs( @@ -389,27 +407,46 @@ export class PipelineOrchestrator { deployment.projectId || deploymentId; const envVars = deployment.projectId ? await listEnvironmentVariablesForDeploy(deployment.projectId, "production") : []; - await buildWithRailpack( - workspacePath, - imageTag, - async (line) => { - await emitLog( - deploymentId, - "build", - line, - ); - }, - { - cacheKey, - sourceDir: project?.sourceDir, - projectType: project?.projectType, - buildCommand: project?.buildCommand, - startCommand: project?.startCommand, - environmentVariables: filterBuildEnvVars(envVars), - signal: controller.signal, - clearCache: deployment.clearCache - }, - ); + if (project?.buildType === "compose") { + const envMap: Record = {}; + for (const v of envVars) envMap[v.key] = v.value; + await buildWithCompose( + workspacePath, + `deploy-${deploymentId}`, + async (line) => { + await emitLog( + deploymentId, + "build", + line, + ); + }, + project?.sourceDir, + envMap, + controller.signal, + ); + } else { + await buildWithRailpack( + workspacePath, + imageTag, + async (line) => { + await emitLog( + deploymentId, + "build", + line, + ); + }, + { + cacheKey, + sourceDir: project?.sourceDir, + projectType: project?.projectType, + buildCommand: project?.buildCommand, + startCommand: project?.startCommand, + environmentVariables: filterBuildEnvVars(envVars), + signal: controller.signal, + clearCache: deployment.clearCache + }, + ); + } } else { await emitLog( deploymentId, @@ -483,6 +520,9 @@ export class PipelineOrchestrator { let oldContainerName: | string | undefined; + let oldDeploymentId: + | string + | undefined; if (deployment.projectId) { const all = await listDeployments( deployment.projectId, @@ -497,6 +537,7 @@ export class PipelineOrchestrator { if (prev?.containerName) { oldContainerName = prev.containerName; + oldDeploymentId = prev.id; await emitLog( deploymentId, "deploy", @@ -527,29 +568,55 @@ export class PipelineOrchestrator { appPort = project.port; } - const runtime = await deployContainer( - deploymentId, - imageTag, - async (line) => { - await emitLog( - deploymentId, - "deploy", - line, - ); - }, - { - projectId: - deployment.projectId || - undefined, - projectName, - oldContainerName, + let runtimeContainerName = ""; + let runtimeLiveUrl = ""; + + if (project?.buildType === "compose") { + const composeRuntime = await deployComposeStack({ + workspacePath, + deploymentId, + projectId: project.id, + projectName: project.name, + sourceDir: project.sourceDir, + composeService: project.composeService, + composePort: project.composePort, + composeServicesJson: (project as any).composeServices, + oldDeploymentId, envVars, - volumes, - cpuLimit, - memoryLimitMb, - appPort: appPort ?? undefined, - }, - ); + signal: controller.signal, + onLog: async (line) => { + await emitLog(deploymentId, "deploy", line); + }, + }); + runtimeContainerName = composeRuntime.containerName; + runtimeLiveUrl = composeRuntime.liveUrl; + } else { + const runtime = await deployContainer( + deploymentId, + imageTag, + async (line) => { + await emitLog( + deploymentId, + "deploy", + line, + ); + }, + { + projectId: + deployment.projectId || + undefined, + projectName, + oldContainerName, + envVars, + volumes, + cpuLimit, + memoryLimitMb, + appPort: appPort ?? undefined, + }, + ); + runtimeContainerName = runtime.containerName; + runtimeLiveUrl = runtime.liveUrl; + } deployed = true; @@ -558,9 +625,8 @@ export class PipelineOrchestrator { "running", { imageTag, - containerName: - runtime.containerName, - liveUrl: runtime.liveUrl, + containerName: runtimeContainerName, + liveUrl: runtimeLiveUrl, failureReason: null, }, ); @@ -649,14 +715,22 @@ export class PipelineOrchestrator { "system", "Cleaning up Docker resources from failed deployment", ); - await cleanupFailedDeployment( - deploymentId, - deployment.sourceType === "image" ? undefined : imageTag, - projectName, - deployment.projectId, - ).catch(e => - console.warn(`[Cleanup] Failed to clean deployment ${deploymentId}:`, e), - ); + if (project?.buildType === "compose") { + await destroyComposeStack( + `deploy-${deploymentId}`, + ).catch((e) => + console.warn(`[Cleanup] Failed to tear down compose stack for ${deploymentId}:`, e), + ); + } else { + await cleanupFailedDeployment( + deploymentId, + deployment.sourceType === "image" ? undefined : imageTag, + projectName, + deployment.projectId, + ).catch(e => + console.warn(`[Cleanup] Failed to clean deployment ${deploymentId}:`, e), + ); + } } if (deployment.projectId) { @@ -723,6 +797,11 @@ export class PipelineOrchestrator { const proj = await getProjectById( target.projectId, ); + if (proj?.buildType === "compose") { + throw new Error( + "Rollback is not supported for Docker Compose deployments", + ); + } const slug = proj ? this.slugify(proj.name) : target.id; diff --git a/apps/api/src/types.ts b/apps/api/src/types.ts index 46e65e2..330aa6c 100644 --- a/apps/api/src/types.ts +++ b/apps/api/src/types.ts @@ -23,6 +23,10 @@ export interface Project { sourceDir: string | null; sourceType: string; projectType: string; + buildType: string; + composeService: string | null; + composePort: number | null; + composeServices: string | null; buildCommand: string | null; startCommand: string | null; githubTokenEncrypted: string | null; @@ -53,6 +57,10 @@ export interface CreateProjectInput { sourceDir?: string | null; sourceType?: string; projectType?: string; + buildType?: string; + composeService?: string | null; + composePort?: number | null; + composeServices?: string | null; buildCommand?: string | null; startCommand?: string | null; } @@ -122,6 +130,8 @@ export interface Domain { type: DomainType; validationStatus: DomainValidationStatus; sslStatus: SslStatus; + targetService: string | null; + targetPort: number | null; createdAt: string; updatedAt: string; } @@ -130,6 +140,8 @@ export interface CreateDomainInput { projectId: string; domain: string; type: DomainType; + targetService?: string | null; + targetPort?: number | null; } export interface ScalingPolicy { diff --git a/apps/api/src/utils/__tests__/domain-verifier.test.ts b/apps/api/src/utils/__tests__/domain-verifier.test.ts index c86fad6..24dbc45 100644 --- a/apps/api/src/utils/__tests__/domain-verifier.test.ts +++ b/apps/api/src/utils/__tests__/domain-verifier.test.ts @@ -8,8 +8,8 @@ let routesDir: string; const testConfig = { caddyRoutesDir: '', appInternalPort: 3000, caddyBaseDomain: 'localhost' }; const fileUrl = (path: string) => new URL(path, import.meta.url).toString(); -mock.module(fileUrl('../config'), () => ({ config: testConfig })); -mock.module(fileUrl('../../orchestrator/runtime'), () => ({ +mock.module(fileUrl('../config.ts'), () => ({ config: testConfig })); +mock.module(fileUrl('../../orchestrator/runtime.ts'), () => ({ reloadCaddy: mock(() => Promise.resolve()), })); diff --git a/apps/api/src/utils/__tests__/validate.test.ts b/apps/api/src/utils/__tests__/validate.test.ts new file mode 100644 index 0000000..0692ce2 --- /dev/null +++ b/apps/api/src/utils/__tests__/validate.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "bun:test"; +import { validateComposeServices, validateComposeServiceMapping } from "../validate"; + +describe("validateComposeServiceMapping", () => { + it("accepts a valid mapping", () => { + expect(validateComposeServiceMapping({ serviceName: "web", subdomain: "app", port: 3000 })).toBeNull(); + }); + + it("rejects serviceName with spaces or braces", () => { + expect(validateComposeServiceMapping({ serviceName: "x} { respond" })).not.toBeNull(); + expect(validateComposeServiceMapping({ serviceName: "a b" })).not.toBeNull(); + expect(validateComposeServiceMapping({ serviceName: "" })).not.toBeNull(); + }); + + it("rejects subdomain injection attempts", () => { + expect(validateComposeServiceMapping({ serviceName: "web", subdomain: "x} { respond \"owned\" } {.x" })).not.toBeNull(); + expect(validateComposeServiceMapping({ serviceName: "web", subdomain: "UPPER" })).not.toBeNull(); + expect(validateComposeServiceMapping({ serviceName: "web", subdomain: "-bad" })).not.toBeNull(); + expect(validateComposeServiceMapping({ serviceName: "web", subdomain: "ok-label" })).toBeNull(); + }); + + it("rejects invalid ports", () => { + expect(validateComposeServiceMapping({ serviceName: "web", port: 0 })).not.toBeNull(); + expect(validateComposeServiceMapping({ serviceName: "web", port: 65536 })).not.toBeNull(); + expect(validateComposeServiceMapping({ serviceName: "web", port: "80} {" })).not.toBeNull(); + expect(validateComposeServiceMapping({ serviceName: "web", port: "8080" })).toBeNull(); + }); + + it("rejects non-object mappings", () => { + expect(validateComposeServiceMapping("web")).not.toBeNull(); + expect(validateComposeServiceMapping(null)).not.toBeNull(); + }); +}); + +describe("validateComposeServices", () => { + it("accepts a valid array", () => { + const result = validateComposeServices([{ serviceName: "web", subdomain: "app", port: 3000 }]); + expect(result.ok).toBe(true); + }); + + it("accepts a valid JSON string", () => { + const result = validateComposeServices('[{"serviceName":"web","subdomain":"app"}]'); + expect(result.ok).toBe(true); + }); + + it("rejects invalid JSON", () => { + const result = validateComposeServices('{not json'); + expect(result.ok).toBe(false); + }); + + it("rejects non-array values", () => { + expect(validateComposeServices({}).ok).toBe(false); + expect(validateComposeServices("web").ok).toBe(false); + }); + + it("rejects an array with an injection payload", () => { + const result = validateComposeServices([{ serviceName: "web", subdomain: "x} { respond \"owned\" } {.x" }]); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("subdomain"); + } + }); +}); diff --git a/apps/api/src/utils/domain-verifier.ts b/apps/api/src/utils/domain-verifier.ts index 62bb1cd..cffaeeb 100644 --- a/apps/api/src/utils/domain-verifier.ts +++ b/apps/api/src/utils/domain-verifier.ts @@ -119,21 +119,35 @@ export const buildCaddySnippet = async ( appPort?: number, ): Promise => { const baseDomain = config.caddyBaseDomain === 'localhost' ? `${config.caddyBaseDomain}:80` : config.caddyBaseDomain; - let domains = [`${slug}.${baseDomain}`]; + let defaultDomains = [`${slug}.${baseDomain}`]; let port = appPort ?? config.appInternalPort; + const customBlocks: string[] = []; if (projectId) { const projectDomains = await listDomainsFn(projectId); const verified = projectDomains.filter(d => d.validationStatus === 'verified'); for (const d of verified) { const withPort = `${d.domain}:80`; - if (!domains.includes(withPort)) domains.push(withPort); + if (d.targetService || d.targetPort) { + let targetContainer = containerName; + if (d.targetService) { + const parts = containerName.split('-'); + if (parts.length >= 3) { + parts[parts.length - 2] = d.targetService; + targetContainer = parts.join('-'); + } + } + const tPort = d.targetPort || port; + customBlocks.push(`${withPort} {\n log {\n output stdout\n format json\n }\n reverse_proxy ${targetContainer}:${tPort} {\n header_up Host {upstream_hostport}\n }\n}\n`); + } else { + if (!defaultDomains.includes(withPort)) defaultDomains.push(withPort); + } } try { const envVars = await listEnvironmentVariablesForDeploy(projectId); const portVar = envVars.find(v => v.key === 'PORT'); - if (portVar && portVar.value) { + if (portVar && portVar.value && !appPort) { const parsedPort = Number(portVar.value); if (!isNaN(parsedPort) && parsedPort > 0) { port = parsedPort; @@ -144,5 +158,7 @@ export const buildCaddySnippet = async ( } } - return `${domains.join(', ')} {\n log {\n output stdout\n format json\n }\n reverse_proxy ${containerName}:${port} {\n header_up Host {upstream_hostport}\n }\n}\n`; + const primaryBlock = `${defaultDomains.join(', ')} {\n log {\n output stdout\n format json\n }\n reverse_proxy ${containerName}:${port} {\n header_up Host {upstream_hostport}\n }\n}\n`; + + return [primaryBlock, ...customBlocks].join('\n'); }; diff --git a/apps/api/src/utils/validate.ts b/apps/api/src/utils/validate.ts new file mode 100644 index 0000000..cb4be3b --- /dev/null +++ b/apps/api/src/utils/validate.ts @@ -0,0 +1,53 @@ +export const SERVICE_NAME_RE = /^[a-zA-Z0-9_-]+$/; + +export const SUBDOMAIN_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; + +export const isPort = (value: unknown): value is number => + typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535; + +export interface ComposeServiceMapping { + serviceName: string; + subdomain?: string; + port?: number | string; +} + +export const validateComposeServiceMapping = (mapping: unknown): string | null => { + if (!mapping || typeof mapping !== "object") { + return "compose service mapping must be an object"; + } + const m = mapping as ComposeServiceMapping; + if (typeof m.serviceName !== "string" || !SERVICE_NAME_RE.test(m.serviceName)) { + return "serviceName may only contain letters, numbers, underscores and hyphens"; + } + if (m.subdomain !== undefined && m.subdomain !== null) { + if (typeof m.subdomain !== "string" || !SUBDOMAIN_RE.test(m.subdomain)) { + return "subdomain must be a valid hostname label (lowercase letters, numbers, hyphens)"; + } + } + if (m.port !== undefined && m.port !== null) { + const port = Number(m.port); + if (!isPort(port)) { + return "port must be an integer between 1 and 65535"; + } + } + return null; +}; + +export const validateComposeServices = (value: unknown): { ok: true; services: ComposeServiceMapping[] } | { ok: false; error: string } => { + let parsed: unknown = value; + if (typeof value === "string") { + try { + parsed = JSON.parse(value); + } catch { + return { ok: false, error: "composeServices must be valid JSON" }; + } + } + if (!Array.isArray(parsed)) { + return { ok: false, error: "composeServices must be an array" }; + } + for (const mapping of parsed) { + const err = validateComposeServiceMapping(mapping); + if (err) return { ok: false, error: err }; + } + return { ok: true, services: parsed as ComposeServiceMapping[] }; +}; diff --git a/apps/docs/.astro/astro/content.d.ts b/apps/docs/.astro/astro/content.d.ts index e2e9502..2c97712 100644 --- a/apps/docs/.astro/astro/content.d.ts +++ b/apps/docs/.astro/astro/content.d.ts @@ -162,6 +162,13 @@ declare module 'astro:content' { collection: "changelogs"; data: any } & { render(): Render[".md"] }; +"v0.2.1.md": { + id: "v0.2.1.md"; + slug: "v021"; + body: string; + collection: "changelogs"; + data: any +} & { render(): Render[".md"] }; }; "docs": { "auth.md": { diff --git a/apps/docs/.astro/settings.json b/apps/docs/.astro/settings.json index be2c74f..b280c44 100644 --- a/apps/docs/.astro/settings.json +++ b/apps/docs/.astro/settings.json @@ -1,5 +1,5 @@ { "_variables": { - "lastUpdateCheck": 1784334507362 + "lastUpdateCheck": 1785406062618 } } \ No newline at end of file diff --git a/apps/docs/src/components/CTA.astro b/apps/docs/src/components/CTA.astro index 826c9dc..1d1fcc6 100644 --- a/apps/docs/src/components/CTA.astro +++ b/apps/docs/src/components/CTA.astro @@ -1,29 +1,44 @@ --- --- -
- - - - - - - - - - - - -

Ready to deploy?

-

- Your infrastructure, your rules. Start deploying in minutes. -

-
- - Read the docs → - - - Install Dequel → - +
+
+
+
+ + + + + + + + + + + +
+ +

+ Ready to own your deployment platform? +

+

+ Spin up Dequel on your server in seconds with zero third-party dependencies. +

+ + +
+ curl -fsSL https://dequel.intrep.xyz/install.sh | bash + 1-Line Install +
+ +
diff --git a/apps/docs/src/components/Comparison.astro b/apps/docs/src/components/Comparison.astro new file mode 100644 index 0000000..d8a4a73 --- /dev/null +++ b/apps/docs/src/components/Comparison.astro @@ -0,0 +1,83 @@ +--- +--- +
+
+
PaaS Paradigm Shift
+

+ Stop fighting infrastructure.
+ Start shipping instantly. +

+

+ Dequel replaces fragmented DevOps tools with a unified self-hosted platform. +

+
+ +
+ +
+
+
+ TRADITIONAL DEVOPS +
+

+ Fighting infra with DIY scripts & expensive PaaS +

+
    +
  • + ✕ + Wrestling with complex Kubernetes manifests, ingress controllers, and Helm charts just to host an API. +
  • +
  • + ✕ + Hand-rolling certbot renewal scripts, nginx proxy configurations, and port forward rules on raw VPS boxes. +
  • +
  • + ✕ + Paying bloated monthly per-seat fees or unpredictable bandwidth egress markups on public cloud PaaS platforms. +
  • +
  • + ✕ + Scattered logs, missing metrics dashboards, and zero visibility into build errors or worker queues. +
  • +
+
+
+ Status: High overhead • Proprietary lock-in +
+
+ + +
+
+
+ DEQUEL PAAS +
+

+ Shipping apps with Dequel in seconds +

+
    +
  • + ✓ + One-command install: Spin up the entire PaaS control plane on any clean Linux VPS in under 60 seconds. +
  • +
  • + ✓ + Automated Railpack builds: Connect Git or upload a ZIP file — Dequel autodetects your runtime and builds via local Buildkit. +
  • +
  • + ✓ + Zero-config SSL & Domains: Built-in Caddy engine automatically handles Let's Encrypt certificates and domain routing. +
  • +
  • + ✓ + Complete Observability: Full Prometheus, cAdvisor, Loki log aggregation, and Grafana monitoring out-of-the-box. +
  • +
+
+
+ Status: 100% Owned by You + 0% Markups +
+
+
+
diff --git a/apps/docs/src/components/Features.astro b/apps/docs/src/components/Features.astro index bce1715..1b50145 100644 --- a/apps/docs/src/components/Features.astro +++ b/apps/docs/src/components/Features.astro @@ -1,60 +1,175 @@ --- -const features = [ +const tabs = [ { - num: "01", - name: "Instant Deployments", - desc: "Push code and watch it go live in seconds. Zero-downtime deployments with automatic container rollbacks.", - iconHtml: `` + id: "build", + label: "Git & Build Engine", + icon: ``, + title: "Railpack Compilation & BuildKit Daemon", + description: "Push code directly from GitHub or upload a project ZIP file. Dequel's internal Railpack CLI autodetects Node.js, Python, Go, Rust, and Dockerfiles, compiling zero-config production containers via BuildKit.", + highlights: [ + "Automatic framework detection (Next.js, Express, Fastify, FastAPI, Go, etc.)", + "Layer-cached builds with isolated workspace staging", + "Instant rollback to previous container SHA hashes", + "Custom environment variable injection & secret encryption" + ], + codePreview: `// dequel.config.json (Optional) +{ + "name": "my-api-service", + "build": { + "builder": "railpack", + "env": { + "NODE_ENV": "production" + } }, - { - num: "02", - name: "Custom Domains & SSL", - desc: "Automatic HTTPS with Let's Encrypt, custom domain routing, and wildcard certificates via Caddy reverse proxy.", - iconHtml: `` - }, - { - num: "03", - name: "Observability", - desc: "Real-time metrics, request monitoring, and log aggregation powered by Prometheus, Loki, and Grafana.", - iconHtml: `` + "routes": [ + { "domain": "api.mydomain.com", "port": 3001 } + ] +}` }, { - num: "04", - name: "Managed Databases", - desc: "One-click PostgreSQL instances with automatic backups, connection pooling, and private network attachment.", - iconHtml: `` + id: "ingress", + label: "Dynamic Ingress & Auto SSL", + icon: ``, + title: "Caddy Ingress & Free Let's Encrypt Certificates", + description: "Every deployed container is instantly routed through an internal high-performance Caddy reverse proxy. Dynamic route files are updated in real-time with zero server reboots.", + highlights: [ + "Automatic SSL certificate issuance & renewal via Let's Encrypt", + "Wildcard subdomains & custom apex domain attachments", + "HTTP/2 and HTTP/3 QUIC protocol support out-of-the-box", + "Zero-downtime dynamic routing reloading" + ], + codePreview: `# Dynamic Caddyfile (Auto-generated by Dequel) +api.mydomain.com { + tls admin@mydomain.com + reverse_proxy 10.0.0.12:3001 { + header_up Host {host} + header_up X-Real-IP {remote} + } +}` }, { - num: "05", - name: "Auto Scaling", - desc: "Scale horizontally based on CPU, memory, or request load. Define min/max replicas with zero-config policies.", - iconHtml: `` + id: "observability", + label: "Observability Stack", + icon: ``, + title: "Full Observability: Prometheus, cAdvisor & Loki Logs", + description: "Get production-grade monitoring without setting up third-party SaaS agents. Dequel collects container stats, memory usage, CPU load, network I/O, and stdout/stderr log streams in real-time.", + highlights: [ + "cAdvisor real-time per-container CPU & RAM metrics", + "Prometheus metric scrape target integration", + "Grafana dashboard & Loki log aggregation", + "Live terminal stdout stream viewer in dashboard" + ], + codePreview: `# Querying logs via Dequel CLI +$ dequel logs --app my-api-service --follow + +[INFO] 10:45:02 [express] Server listening on port 3001 +[INFO] 10:45:15 GET /api/v1/health 200 4.2ms +[METRIC] CPU: 1.2% | RAM: 42.8MB / 512MB` }, { - num: "06", - name: "Secure by Default", - desc: "Container isolation, encrypted environment variables, network policies, and audit logging built into every layer.", - iconHtml: `` + id: "scaling", + label: "Auto-Scaling & Resiliency", + icon: ``, + title: "CPU & Memory Scaling Engine", + description: "Define auto-scaling parameters for high-traffic endpoints. Dequel's background scaling engine evaluates resource thresholds and automatically scales container replicas up or down.", + highlights: [ + "Horizontal pod-like replica scaling based on CPU/RAM thresholds", + "Automated health checks & container auto-healing restart policies", + "Redis job queue integration for background worker scaling", + "Zero-downtime rolling updates" + ], + codePreview: `# Scaling policy rule evaluated by Dequel API +scaling_policy: + target_cpu_percent: 75 + min_replicas: 2 + max_replicas: 10 + cooldown_period_seconds: 60` } ]; --- -
-
-
Platform Capabilities
-

- Everything you need to ship.
- Nothing you don't. +
+
+
Platform Capabilities
+

+ Everything you need to ship.
+ Powered by your infrastructure.

+

+ Explore Dequel's core PaaS engines built into a single lightweight control plane. +

+
+ + +
+
+ {tabs.map((tab, idx) => ( + + ))} +
-
- {features.map((feature) => ( -
-
{feature.num}
-
-

{feature.name}

-

{feature.desc}

+ +
+ {tabs.map((tab, idx) => ( +
+
+ +
+
+
+ PaaS Module: + {tab.id} +
+

+ {tab.title} +

+

+ {tab.description} +

+
    + {tab.highlights.map((item) => ( +
  • + ✓ + {item} +
  • + ))} +
+
+ +
+ + +
+
+
+ + {tab.label} Preview +
+ Dequel v1.0 +
+
{tab.codePreview}
+
+ Status: Active + Zero Config Required +
+
+
))}
diff --git a/apps/docs/src/components/Hero.astro b/apps/docs/src/components/Hero.astro index 359b666..1e2d827 100644 --- a/apps/docs/src/components/Hero.astro +++ b/apps/docs/src/components/Hero.astro @@ -7,59 +7,65 @@ interface Props { } const { - tag = "Private Beta", - titleNormal = "Deploy anything.", - titleItalic = "Scale everything.", - subText = "The self-hosted cloud platform for developers who want Railway-grade deployment workflows on their own infrastructure. Container orchestration, observability, and scaling — all from one dashboard.", + 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.", } = Astro.props; --- -
-
- - {tag} -
+
+
+ +
-

- {titleNormal}
- {titleItalic} -

+ + -

- {subText} -

+ +

+ Deploy apps from Git, ZIP, or Docker Compose — + yours by default. +

- + +

+ {subText} +

- -
diff --git a/apps/docs/src/components/HowItWorks.astro b/apps/docs/src/components/HowItWorks.astro index 5dcc486..9387d92 100644 --- a/apps/docs/src/components/HowItWorks.astro +++ b/apps/docs/src/components/HowItWorks.astro @@ -1,148 +1,147 @@ --- --- -
-
-
Self-Hosting Engine
-

- Your own PaaS, running on your own servers. +
+
+
Engine Architecture
+

+ Your own PaaS, running on your own servers.

-

- Dequel wraps raw Docker containers, Buildkit compilation, and Caddy reverse proxy routing in a single cohesive platform. No third-party APIs, no hidden fees, and zero vendor lock-in. +

+ Dequel orchestrates raw Docker containers, Buildkit compilation, Redis job queues, SQLite data, and dynamic Caddy routing into one unified platform.

-
+
-
-
-
STEP 01
-

Connect Your Server

+
+
+
+ STEP 01 + Debian / Ubuntu VPS +
+

Connect Your Server

- Run our installer script on any clean Debian/Ubuntu machine. Dequel boots its control plane, Redis task queue, and SQLite database inside a dedicated Docker network. + Run our 1-command installer. Dequel bootstraps its API control plane, Redis job queue, SQLite persistence, and cAdvisor monitoring inside a dedicated Docker network.

-
-
STEP 02
-

Push Code, Detect Stack

+
+
+ STEP 02 + Git / ZIP / Dockerfile +
+

Push Code & Build

- Dequel uses Railpack to analyze your source code (Git repo or ZIP upload), automatically detects the language, and compiles a containerized image via local Buildkit. + Dequel uses Railpack to analyze your repository, autodetects your language runtime, and compiles isolated container images locally via Buildkit.

-
-
STEP 03
-

Auto Routing & SSL

+
+
+ STEP 03 + Caddy Proxy + Auto-SSL +
+

Instant Auto Routing

- The internal Caddy engine dynamically reloads routes and automatically provisions SSL certificates via Let's Encrypt, mapping your domain directly to the app container. + The internal Caddy engine reloads dynamic routes without zero-downtime, automatically provisions Let's Encrypt certificates, and connects public traffic directly to your app.

-
+
- - HOST: vps-node-01 (ONLINE) + + NODE: vps-dequel-01 (ONLINE)
dequel-network (10.0.0.0/16)
-
+
-
-
- Client Request -
my-app.com
- -
- - +
+
+ HTTP Traffic +
app.domain.com
+
+ +
-
- Git Push -
Repo Webhook
-
- +
+ Git Webhook +
Push to main
+
+
-
+
-
+
- Caddy Ingress - Auto SSL / Proxy + Caddy Ingress Engine + Auto SSL / Reverse Proxy
- PORT 80/443 -
- - + PORT 80/443 +
+
-
- Dequel API - Docker Orchestrator -
- -
- -
- +
+ Dequel API Orchestrator + ElysiaJS + Bun Runtime +
+
-
- -
+
+
- deploy-my-app - Running (Replica 1) + web-app-prod + Replica #1 (Healthy)
- +
- -
+
buildkit-daemon Railpack compiling...
- +
- -
+
- db-postgres-1 - Private volume active + postgres-db-1 + Mounted Data Volume
- +
-
- SQLite: Local Persistence - Redis: Job Queue + SQLite: ./data/dequel.db + Redis: Task Queue
- 100% Owned by You + Zero Infrastructure Markup
diff --git a/apps/docs/src/components/Stats.astro b/apps/docs/src/components/Stats.astro index dbbb14c..ada148a 100644 --- a/apps/docs/src/components/Stats.astro +++ b/apps/docs/src/components/Stats.astro @@ -1,27 +1,28 @@ --- const stats = [ - { value: "15.2s", label: "Avg Deploy Time" }, - { value: "99.9%", label: "Uptime SLA" }, - { value: "0", label: "Vendor Lock-in" }, - { value: "∞", label: "Your Infrastructure" }, + { value: "< 10s", label: "Average Deploy Time", sub: "Railpack + Buildkit" }, + { value: "100%", label: "Self-Hosted Control", sub: "On your own VPS" }, + { value: "$0", label: "Third-Party SaaS Fees", sub: "Zero vendor lock-in" }, + { value: "0%", label: "Infrastructure Markup", sub: "Pay only for raw VPS" }, ]; --- -
-
-
- { - stats.map((stat) => ( -
-
- {stat.value} -
-
- {stat.label} -
-
- )) - } -
+
+
+ {stats.map((stat) => ( +
+
+
+ {stat.value} +
+
+ {stat.label} +
+
+
+ {stat.sub} +
+
+ ))} +
-
diff --git a/apps/docs/src/components/TerminalDemo.astro b/apps/docs/src/components/TerminalDemo.astro index 4c78aec..14085d3 100644 --- a/apps/docs/src/components/TerminalDemo.astro +++ b/apps/docs/src/components/TerminalDemo.astro @@ -1,28 +1,46 @@ --- --- -
-
-
-
Developer Experience
-

From git push to production
in under 4 seconds.

-

- Connect your repository, push to main, and Dequel handles the rest — - building your container image, routing traffic, provisioning SSL, - and serving your application at a custom domain. +

+
+
+
Developer Experience
+

+ From git push to production
+ in under 4 seconds. +

+

+ Connect your project, run dequel deploy, and watch Dequel handle the rest — building your container via Buildkit, reloaded Caddy dynamic proxy, automated SSL issuance, and live status reporting.

+
+
+ + Zero Downtime +
+
+ + Auto Certs +
+
-
-
-
- - - + + +
+
+
+ + +
- dequel — deploy + dequel cli v1.0.0 + bash
-
-
$
+
+
+ $ + + +
diff --git a/apps/docs/src/pages/index.astro b/apps/docs/src/pages/index.astro index 8f89f0d..5863a8e 100644 --- a/apps/docs/src/pages/index.astro +++ b/apps/docs/src/pages/index.astro @@ -2,10 +2,10 @@ import "../styles/global.css"; import Hero from "../components/Hero.astro"; import Stats from "../components/Stats.astro"; +import Comparison from "../components/Comparison.astro"; import Features from "../components/Features.astro"; import HowItWorks from "../components/HowItWorks.astro"; import TerminalDemo from "../components/TerminalDemo.astro"; -import TechStack from "../components/TechStack.astro"; import CTA from "../components/CTA.astro"; --- @@ -15,11 +15,11 @@ import CTA from "../components/CTA.astro"; - Dequel — Self-Hosted Container - Platform + Dequel — Self-Hosted PaaS Engine & Container Platform - + + - - - - - - - - -
- - +
+ + + + + + + +
- - -